cyberkyne commited on
Commit
7046e69
Β·
verified Β·
1 Parent(s): 5dd55f8

Upload 13 files

Browse files
README.md ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Quant Knowledge Extractor
3
+ emoji: πŸ“Š
4
+ colorFrom: green
5
+ colorTo: gray
6
+ sdk: gradio
7
+ sdk_version: 5.25.0
8
+ app_file: app.py
9
+ pinned: true
10
+ license: mit
11
+ python_version: "3.11"
12
+ ---
13
+
14
+ # πŸ“Š Quant Knowledge Extractor + Backtesting Engine
15
+
16
+ Upload PDF ebooks β†’ extract every strategy β†’ backtest on your tick data β†’ download MT5 `.set` files.
17
+
18
+ ## Setup (one-time, 2 minutes)
19
+
20
+ ### 1. Add Secrets in Space Settings β†’ Variables and Secrets
21
+
22
+ | Secret | Value |
23
+ |--------|-------|
24
+ | `ANTHROPIC_API_KEY` | Your Claude API key |
25
+ | `HF_TOKEN` | HuggingFace write token (from hf.co/settings/tokens) |
26
+ | `HF_DATASET_REPO` | `your-username/quant-knowledge-base` |
27
+ | `HF_TICK_REPO` | `your-username/tick-data` |
28
+
29
+ ### 2. Create your dataset repos on HuggingFace
30
+ - `huggingface.co/new-dataset` β†’ name it `quant-knowledge-base`
31
+ - `huggingface.co/new-dataset` β†’ name it `tick-data`
32
+
33
+ ### 3. Upload your tick data to `tick-data` dataset
34
+ ```
35
+ your-username/tick-data/
36
+ EURUSD/ticks.parquet
37
+ BTCUSDT/ticks.parquet
38
+ GBPUSD/1h.parquet
39
+ ```
40
+
41
+ ### 4. Use the Space
42
+ 1. Go to **Upload & Extract** tab β†’ drag and drop PDFs
43
+ 2. Click **Run Extraction Pipeline**
44
+ 3. Go to **Backtest** tab β†’ select symbols β†’ click **Run Backtests**
45
+ 4. Go to **Results** tab β†’ download `.set` files for MT5
46
+
47
+ ## Everything is saved to your HuggingFace dataset automatically.
app.py ADDED
@@ -0,0 +1,703 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py ← HuggingFace Spaces entry point
3
+ ─────────────────────────────────────────────────────
4
+ Quant Knowledge Extractor + Backtesting Engine.
5
+
6
+ You upload PDFs here. Everything else is automatic:
7
+ 1. PDF text extraction (+ OCR for scanned books)
8
+ 2. Claude AI extracts strategies, formulas, systems
9
+ 3. 3-layer deduplication + layered variant merging
10
+ 4. Markdown files pushed to your HF dataset
11
+ 5. Strategies compiled to Python signal functions
12
+ 6. Walk-forward backtested on your tick data
13
+ 7. Optimal MT5 .set files + Julia configs pushed to HF
14
+
15
+ Zero local dependencies. Everything runs in this Space.
16
+ """
17
+
18
+ import io
19
+ import os
20
+ import json
21
+ import zipfile
22
+ import tempfile
23
+ import shutil
24
+ from pathlib import Path
25
+ from datetime import datetime
26
+
27
+ import gradio as gr
28
+ from loguru import logger
29
+
30
+ import utils.config as cfg
31
+ import utils.hf_io as hf
32
+ from pipeline.pdf_processor import PDFProcessor
33
+ from pipeline.extractor import AIExtractor, Deduplicator
34
+ from pipeline.backtester import (
35
+ StrategyCompiler, WalkForwardOptimizer,
36
+ build_markdown_report, build_optimal_json,
37
+ build_mt5_set, build_julia_config, slugify,
38
+ )
39
+ from pipeline.exporter import (
40
+ strategy_markdown, formula_markdown,
41
+ system_markdown, backtest_index_markdown,
42
+ )
43
+
44
+ # ── Lazy-loaded singletons ────────────────────────────
45
+ _kb: dict | None = None
46
+
47
+ def get_kb() -> dict:
48
+ global _kb
49
+ if _kb is None:
50
+ _kb = hf.kb_load()
51
+ return _kb
52
+
53
+ def refresh_kb():
54
+ global _kb
55
+ _kb = hf.kb_load()
56
+
57
+
58
+ # ═══════════════════════════════════════════════════
59
+ # TAB 1 β€” UPLOAD & EXTRACT
60
+ # ═══════════════════════════════════════════════════
61
+
62
+ def run_extraction(pdf_files, progress=gr.Progress()):
63
+ """Full extraction pipeline for uploaded PDFs."""
64
+ if not pdf_files:
65
+ return "⚠️ No PDFs uploaded.", ""
66
+ if not cfg.ANTHROPIC_API_KEY:
67
+ return "❌ ANTHROPIC_API_KEY secret not set in Space settings.", ""
68
+ if not cfg.HF_DATASET_REPO:
69
+ return "❌ HF_DATASET_REPO secret not set in Space settings.", ""
70
+
71
+ pdf_processor = PDFProcessor()
72
+ ai_extractor = AIExtractor()
73
+ dedup = Deduplicator()
74
+ kb = get_kb()
75
+
76
+ log, total_stats = [], {
77
+ "strategies": {"added":0,"merged":0,"skipped":0},
78
+ "formulas": {"added":0,"merged":0,"skipped":0},
79
+ "systems": {"added":0,"merged":0,"skipped":0},
80
+ }
81
+ hf_files = [] # Collect (remote_path, bytes) for batch push
82
+
83
+ for i, pdf_file in enumerate(pdf_files):
84
+ pdf_path = Path(pdf_file.name)
85
+ progress(i / len(pdf_files), desc=f"Processing {pdf_path.name}")
86
+ log.append(f"\nπŸ“– [{i+1}/{len(pdf_files)}] {pdf_path.name}")
87
+
88
+ # 1. Extract chunks
89
+ try:
90
+ chunks = list(pdf_processor.process(pdf_path))
91
+ log.append(f" β†’ {len(chunks)} chunks extracted")
92
+ except Exception as e:
93
+ log.append(f" ❌ Extraction failed: {e}")
94
+ continue
95
+
96
+ # 2. AI extraction + dedup
97
+ book_stats = {k: {"added":0,"merged":0,"skipped":0} for k in ("strategies","formulas","systems")}
98
+ for chunk in chunks:
99
+ extracted = ai_extractor.extract(chunk)
100
+ stats = dedup.process(extracted, kb)
101
+ for kind in ("strategies","formulas","systems"):
102
+ for action in ("added","merged","skipped"):
103
+ book_stats[kind][action] += stats[kind][action]
104
+ total_stats[kind][action] += stats[kind][action]
105
+
106
+ s_add = book_stats["strategies"]["added"]
107
+ f_add = book_stats["formulas"]["added"]
108
+ log.append(f" β†’ New: {s_add} strategies, {f_add} formulas")
109
+
110
+ # 3. Upload PDF to HF
111
+ if cfg.HF_TOKEN:
112
+ hf.pdf_upload(pdf_path)
113
+
114
+ # 4. Generate markdown for every item in KB + queue for HF push
115
+ for cid, rec in kb["strategies"].items():
116
+ md = strategy_markdown(rec)
117
+ sl = slugify(rec.get("name",""))
118
+ hf_files.append((f"extracted/strategies/{sl}.md", md.encode()))
119
+
120
+ for cid, rec in kb["formulas"].items():
121
+ md = formula_markdown(rec)
122
+ sl = slugify(rec.get("name",""))
123
+ hf_files.append((f"extracted/formulas/{sl}.md", md.encode()))
124
+
125
+ for cid, rec in kb["systems"].items():
126
+ md = system_markdown(rec)
127
+ sl = slugify(rec.get("name",""))
128
+ hf_files.append((f"extracted/systems/{sl}.md", md.encode()))
129
+
130
+ # 5. Save KB + push everything
131
+ progress(0.9, desc="Saving to HuggingFace...")
132
+ hf.kb_save(kb)
133
+ if hf_files and cfg.HF_TOKEN:
134
+ pushed = hf.push_batch(hf_files, "Update extracted knowledge")
135
+ log.append(f"\n☁️ Pushed {pushed} files to HuggingFace")
136
+
137
+ refresh_kb()
138
+
139
+ counts = {k: len(kb[k]) for k in kb}
140
+ summary = f"""
141
+ βœ… Extraction Complete
142
+
143
+ PDFs processed: {len(pdf_files)}
144
+
145
+ Strategies β€” added: {total_stats['strategies']['added']} merged: {total_stats['strategies']['merged']} skipped: {total_stats['strategies']['skipped']}
146
+ Formulas β€” added: {total_stats['formulas']['added']} merged: {total_stats['formulas']['merged']} skipped: {total_stats['formulas']['skipped']}
147
+ Systems β€” added: {total_stats['systems']['added']} merged: {total_stats['systems']['merged']} skipped: {total_stats['systems']['skipped']}
148
+
149
+ KNOWLEDGE BASE TOTALS
150
+ Unique strategies : {counts['strategies']}
151
+ Unique formulas : {counts['formulas']}
152
+ Complete systems : {counts['systems']}
153
+
154
+ Tokens used this run: {ai_extractor.tokens_used:,}
155
+ """.strip()
156
+ return summary, "\n".join(log[-40:])
157
+
158
+
159
+ # ═══════════════════════════════════════════════════
160
+ # TAB 2 β€” BROWSE KNOWLEDGE BASE
161
+ # ═══════════════════════════════════════════════════
162
+
163
+ def search_strategies(query, category):
164
+ kb = get_kb()
165
+ items = list(kb["strategies"].values())
166
+ if category and category != "All":
167
+ items = [x for x in items if x.get("category") == category]
168
+ if query:
169
+ q = query.lower()
170
+ items = [x for x in items if q in x.get("name","").lower()
171
+ or q in x.get("description","").lower()]
172
+ rows = [[
173
+ x.get("name","")[:50], x.get("category",""),
174
+ x.get("description","")[:100],
175
+ ", ".join(x.get("sources",[]))[:40],
176
+ len(x.get("layers",[])),
177
+ ] for x in items[:100]]
178
+ return rows, f"{len(items)} strategies"
179
+
180
+
181
+ def search_formulas(query):
182
+ kb = get_kb()
183
+ items = list(kb["formulas"].values())
184
+ if query:
185
+ q = query.lower()
186
+ items = [x for x in items if q in x.get("name","").lower()
187
+ or q in x.get("purpose","").lower()]
188
+ return [[
189
+ x.get("name","")[:50], x.get("category",""),
190
+ x.get("purpose","")[:80],
191
+ "βœ…" if x.get("latex") else "β€”",
192
+ ", ".join(x.get("sources",[]))[:40],
193
+ ] for x in items[:100]]
194
+
195
+
196
+ def download_strategy_md(name):
197
+ kb = get_kb()
198
+ for rec in kb["strategies"].values():
199
+ if rec.get("name","").lower() == name.strip().lower():
200
+ md = strategy_markdown(rec)
201
+ tmp = Path(tempfile.mktemp(suffix=".md"))
202
+ tmp.write_text(md, encoding="utf-8")
203
+ return str(tmp)
204
+ return None
205
+
206
+
207
+ def download_all_strategies_zip(category):
208
+ kb = get_kb()
209
+ items = list(kb["strategies"].values())
210
+ if category and category != "All":
211
+ items = [x for x in items if x.get("category") == category]
212
+ tmp = tempfile.mktemp(suffix=".zip")
213
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf:
214
+ for rec in items:
215
+ md = strategy_markdown(rec)
216
+ zf.writestr(f"{slugify(rec.get('name','unknown'))}.md", md)
217
+ return tmp
218
+
219
+
220
+ def download_all_formulas_zip():
221
+ kb = get_kb()
222
+ tmp = tempfile.mktemp(suffix=".zip")
223
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf:
224
+ for rec in kb["formulas"].values():
225
+ md = formula_markdown(rec)
226
+ zf.writestr(f"{slugify(rec.get('name','unknown'))}.md", md)
227
+ return tmp
228
+
229
+
230
+ # ═══════════════════════════════════════════════════
231
+ # TAB 3 β€” BACKTEST
232
+ # ═══════════════════════════════════════════════════
233
+
234
+ def load_symbols():
235
+ syms = hf.tick_list_symbols()
236
+ return gr.update(choices=syms, value=syms[:3] if len(syms) >= 3 else syms)
237
+
238
+
239
+ def run_backtests(
240
+ selected_symbols,
241
+ selected_timeframes,
242
+ strategy_filter,
243
+ max_strategies,
244
+ viable_only_push,
245
+ progress=gr.Progress(),
246
+ ):
247
+ if not cfg.HF_TICK_REPO:
248
+ return "❌ HF_TICK_REPO secret not set.", ""
249
+ if not cfg.ANTHROPIC_API_KEY:
250
+ return "❌ ANTHROPIC_API_KEY not set.", ""
251
+
252
+ kb = get_kb()
253
+ strategies = list(kb["strategies"].values())
254
+
255
+ if strategy_filter:
256
+ strategies = [s for s in strategies if strategy_filter.lower() in s.get("name","").lower()]
257
+ if max_strategies > 0:
258
+ strategies = strategies[:int(max_strategies)]
259
+ if not strategies:
260
+ return "⚠️ No strategies in KB. Run extraction first.", ""
261
+
262
+ symbols = selected_symbols or hf.tick_list_symbols()[:2]
263
+ timeframes = selected_timeframes or ["1h"]
264
+
265
+ compiler = StrategyCompiler()
266
+ optimizer = WalkForwardOptimizer()
267
+ log, all_results, viable_count = [], [], 0
268
+ total = len(strategies) * len(symbols) * len(timeframes)
269
+ done = 0
270
+
271
+ for si, rec in enumerate(strategies):
272
+ name = rec.get("name","?")
273
+ progress(si / len(strategies), desc=f"[{si+1}/{len(strategies)}] {name[:35]}")
274
+
275
+ exe = compiler.compile(rec)
276
+ if not exe.is_valid:
277
+ log.append(f"❌ Compile: {name[:40]} β€” {exe.error[:50]}")
278
+ continue
279
+ log.append(f"βœ… Compiled: {name[:40]}")
280
+
281
+ for sym in symbols:
282
+ df_cache = {}
283
+ for tf in timeframes:
284
+ progress((done/max(total,1)), desc=f"{name[:25]} | {sym} {tf}")
285
+ df = hf.tick_load(sym, tf)
286
+ if df is None or len(df) < 200:
287
+ log.append(f" ⚠️ {sym} {tf}: no data or too short")
288
+ done += 1; continue
289
+
290
+ opt = optimizer.optimize(exe, sym, tf, df)
291
+ all_results.append(opt)
292
+
293
+ # Build output files
294
+ report_md = build_markdown_report(opt, rec)
295
+ optimal_js = build_optimal_json(opt, rec)
296
+ mt5_set = build_mt5_set(opt, rec)
297
+ julia_cfg = build_julia_config(opt)
298
+
299
+ if cfg.HF_TOKEN and cfg.HF_DATASET_REPO:
300
+ if not viable_only_push or opt.is_viable:
301
+ hf.push_backtest_result(
302
+ opt.strategy, opt.symbol, opt.timeframe,
303
+ report_md, optimal_js, mt5_set, julia_cfg,
304
+ )
305
+
306
+ status = "βœ…" if opt.is_viable else "❌"
307
+ log.append(
308
+ f" {status} {sym} {tf}: Sharpe={opt.oos_sharpe_mean:.2f} "
309
+ f"DD={opt.oos_max_dd_mean:.1f}% Score={opt.robustness_score:.0f}"
310
+ )
311
+ if opt.is_viable:
312
+ viable_count += 1
313
+ done += 1
314
+
315
+ # Build + push master index
316
+ if all_results and cfg.HF_TOKEN:
317
+ idx_md = backtest_index_markdown(all_results)
318
+ idx_json = {
319
+ "generated": datetime.now().isoformat(),
320
+ "total_strategies": len(all_results),
321
+ "viable_count": viable_count,
322
+ "strategies": [r.to_dict() for r in all_results],
323
+ }
324
+ hf.push_backtest_index(idx_md, idx_json)
325
+ log.append(f"\n☁️ Index pushed to HuggingFace")
326
+
327
+ summary = f"""
328
+ 🏁 Backtesting Complete
329
+
330
+ Strategies compiled: {len([r for r in all_results])+len([l for l in log if '❌ Compile' in l])}
331
+ Combinations tested: {done}
332
+ Viable strategies: {viable_count}
333
+ Pass rate: {viable_count/max(done,1)*100:.1f}%
334
+
335
+ Results on HuggingFace:
336
+ {cfg.HF_DATASET_REPO}/optimal_sets/BACKTEST_INDEX.md
337
+ """.strip()
338
+
339
+ return summary, "\n".join(log[-60:])
340
+
341
+
342
+ # ═══════════════════════════════════════════════════
343
+ # TAB 4 β€” RESULTS
344
+ # ═══════════════════════════════════════════════════
345
+
346
+ def load_results():
347
+ data = hf.fetch_backtest_index()
348
+ if not data:
349
+ return [], "No results yet β€” run backtests first."
350
+ strategies = data.get("strategies", [])
351
+ viable = sorted(
352
+ [s for s in strategies if s.get("is_viable")],
353
+ key=lambda x: x.get("oos_sharpe_mean", 0), reverse=True
354
+ )
355
+ rows = [[
356
+ s.get("strategy","")[:45], s.get("symbol",""), s.get("timeframe",""),
357
+ f'{s.get("oos_sharpe_mean",0):.2f}',
358
+ f'{s.get("oos_max_dd_mean",0):.1f}%',
359
+ f'{s.get("oos_win_rate_mean",0):.1f}%',
360
+ f'{s.get("oos_pf_mean",0):.2f}',
361
+ f'{s.get("robustness_score",0):.0f}',
362
+ ] for s in viable]
363
+ count = (f"βœ… {len(viable)} viable / {len(strategies)} total tested | "
364
+ f"Generated: {data.get('generated','')[:16]}")
365
+ return rows, count
366
+
367
+
368
+ def download_result_file(strategy_name: str, symbol: str, timeframe: str, file_type: str):
369
+ sl = slugify(strategy_name)
370
+ sym = symbol.upper().strip()
371
+ tf = timeframe.strip()
372
+ prefix = f"{sl}_{sym}_{tf}"
373
+ ext_map = {
374
+ "MT5 .set file": f"optimal_sets/{prefix}.set",
375
+ "Optimal JSON": f"optimal_sets/{prefix}_optimal.json",
376
+ "Julia config": f"optimal_sets/{prefix}_config.jl",
377
+ "Full report": f"backtests/{sl}/{prefix}_report.md",
378
+ }
379
+ remote = ext_map.get(file_type, "")
380
+ if not remote:
381
+ return None
382
+ data = hf.fetch_file(remote)
383
+ if not data:
384
+ return None
385
+ suffix = Path(remote).suffix
386
+ tmp = tempfile.mktemp(suffix=suffix)
387
+ Path(tmp).write_bytes(data)
388
+ return tmp
389
+
390
+
391
+ def download_all_sets_zip():
392
+ """Download all MT5 .set files as a ZIP."""
393
+ data = hf.fetch_backtest_index()
394
+ if not data:
395
+ return None
396
+ tmp = tempfile.mktemp(suffix=".zip")
397
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf:
398
+ for s in data.get("strategies", []):
399
+ if not s.get("is_viable"):
400
+ continue
401
+ sl = slugify(s["strategy"])
402
+ sym = s["symbol"]
403
+ tf = s["timeframe"]
404
+ remote = f"optimal_sets/{sl}_{sym}_{tf}.set"
405
+ content = hf.fetch_file(remote)
406
+ if content:
407
+ zf.writestr(f"{sl}_{sym}_{tf}.set", content)
408
+ return tmp
409
+
410
+
411
+ def download_all_json_zip():
412
+ data = hf.fetch_backtest_index()
413
+ if not data:
414
+ return None
415
+ tmp = tempfile.mktemp(suffix=".zip")
416
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf:
417
+ for s in data.get("strategies", []):
418
+ if not s.get("is_viable"):
419
+ continue
420
+ sl = slugify(s["strategy"])
421
+ sym = s["symbol"]
422
+ tf = s["timeframe"]
423
+ remote = f"optimal_sets/{sl}_{sym}_{tf}_optimal.json"
424
+ content = hf.fetch_file(remote)
425
+ if content:
426
+ zf.writestr(f"{sl}_{sym}_{tf}_optimal.json", content)
427
+ return tmp
428
+
429
+
430
+ # ═══════════════════════════════════════════════════
431
+ # TAB 5 β€” SETUP GUIDE
432
+ # ═══════════════════════════════════════════════════
433
+
434
+ def check_config() -> str:
435
+ issues, ok = [], []
436
+ checks = [
437
+ ("ANTHROPIC_API_KEY", cfg.ANTHROPIC_API_KEY, "Claude AI extraction + compilation"),
438
+ ("HF_TOKEN", cfg.HF_TOKEN, "HuggingFace read/write access"),
439
+ ("HF_DATASET_REPO", cfg.HF_DATASET_REPO, "Where results are saved"),
440
+ ("HF_TICK_REPO", cfg.HF_TICK_REPO, "Your tick data source"),
441
+ ]
442
+ for name, val, desc in checks:
443
+ if val:
444
+ ok.append(f"βœ… `{name}` β€” {desc}")
445
+ else:
446
+ issues.append(f"❌ `{name}` missing β€” {desc}")
447
+
448
+ symbols = hf.tick_list_symbols() if cfg.HF_TICK_REPO else []
449
+ kb = get_kb()
450
+
451
+ lines = ["## Configuration Status", ""]
452
+ lines += ok + issues
453
+ lines += [
454
+ "", "## Data Status", "",
455
+ f"- Tick symbols available: **{len(symbols)}** β€” {', '.join(symbols[:8])}{'...' if len(symbols)>8 else ''}",
456
+ f"- Strategies in KB: **{len(kb['strategies'])}**",
457
+ f"- Formulas in KB: **{len(kb['formulas'])}**",
458
+ f"- Systems in KB: **{len(kb['systems'])}**",
459
+ "", "## Backtest Settings", "",
460
+ f"- WF Windows: `{cfg.WF_WINDOWS}`",
461
+ f"- IS Ratio: `{cfg.WF_IS_RATIO}`",
462
+ f"- Min Trades (OOS): `{cfg.MIN_TRADES}`",
463
+ f"- Min Sharpe: `{cfg.MIN_SHARPE}`",
464
+ f"- Commission: `{cfg.COMMISSION_PCT*100:.3f}%`",
465
+ f"- Risk per trade: `{cfg.RISK_PER_TRADE*100:.1f}%`",
466
+ f"- Timeframes: `{', '.join(cfg.BACKTEST_TIMEFRAMES)}`",
467
+ ]
468
+ if issues:
469
+ lines += ["", "## ⚠️ Missing Secrets",
470
+ "Go to **Settings β†’ Variables and Secrets** in your Space."]
471
+ return "\n".join(lines)
472
+
473
+
474
+ # ═══════════════════════════════════════════════════
475
+ # BUILD GRADIO APP
476
+ # ═══════════════════════════════════════════════════
477
+
478
+ CATS = ["All"] + cfg.CATEGORIES
479
+
480
+ with gr.Blocks(
481
+ title="Quant Knowledge Extractor",
482
+ theme=gr.themes.Base(primary_hue="emerald", neutral_hue="slate"),
483
+ css="""
484
+ .gr-button-primary { background: #10b981 !important; }
485
+ h1 { color: #10b981; }
486
+ .status-box { font-family: monospace; font-size: 0.85em; }
487
+ """,
488
+ ) as demo:
489
+
490
+ gr.HTML("""
491
+ <div style="text-align:center;padding:1.2em 0 0.3em">
492
+ <h1 style="font-size:2em;color:#10b981;margin:0">πŸ“Š Quant Knowledge Extractor</h1>
493
+ <p style="color:#94a3b8;margin:.4em 0 0">
494
+ Upload PDFs β†’ Extract Strategies β†’ Backtest on Tick Data β†’ Download MT5 .set Files
495
+ </p>
496
+ </div>
497
+ """)
498
+
499
+ with gr.Tabs():
500
+
501
+ # ── Tab 1: Upload & Extract ───────────────────
502
+ with gr.Tab("πŸ“€ Upload & Extract"):
503
+ gr.Markdown(
504
+ "### Step 1 β€” Upload your algorithmic trading ebooks\n"
505
+ "Supports text PDFs and scanned books (OCR applied automatically)."
506
+ )
507
+ with gr.Row():
508
+ with gr.Column(scale=2):
509
+ pdf_input = gr.File(
510
+ label="Drop PDFs here",
511
+ file_count="multiple",
512
+ file_types=[".pdf"],
513
+ )
514
+ extract_btn = gr.Button(
515
+ "πŸš€ Extract All Knowledge",
516
+ variant="primary", size="lg",
517
+ )
518
+ with gr.Column(scale=1):
519
+ extract_summary = gr.Textbox(
520
+ label="Result", lines=14, interactive=False,
521
+ elem_classes=["status-box"],
522
+ )
523
+ extract_log = gr.Textbox(
524
+ label="Live Log", lines=8, interactive=False,
525
+ elem_classes=["status-box"],
526
+ )
527
+ extract_btn.click(
528
+ fn=run_extraction,
529
+ inputs=[pdf_input],
530
+ outputs=[extract_summary, extract_log],
531
+ )
532
+
533
+ # ── Tab 2: Browse KB ──────────────────────────
534
+ with gr.Tab("πŸ“š Knowledge Base"):
535
+ with gr.Tabs():
536
+
537
+ with gr.Tab("πŸ“ˆ Strategies"):
538
+ with gr.Row():
539
+ s_query = gr.Textbox(label="Search", placeholder="RSI, breakout, Kelly…")
540
+ s_cat = gr.Dropdown(choices=CATS, value="All", label="Category")
541
+ s_btn = gr.Button("πŸ” Search", variant="primary")
542
+ s_table = gr.Dataframe(
543
+ headers=["Name","Category","Description","Sources","Variants"],
544
+ datatype=["str","str","str","str","number"],
545
+ interactive=False, label="Strategies",
546
+ )
547
+ s_count = gr.Markdown("")
548
+ with gr.Row():
549
+ s_name_in = gr.Textbox(label="Strategy name (exact) to download")
550
+ s_dl_btn = gr.Button("⬇️ Download Markdown")
551
+ s_dl_file = gr.File(label="")
552
+ with gr.Row():
553
+ s_zip_btn = gr.Button("πŸ“¦ Download Category ZIP")
554
+ s_zip_file = gr.File(label="")
555
+ s_btn.click(fn=search_strategies, inputs=[s_query,s_cat], outputs=[s_table,s_count])
556
+ s_dl_btn.click(fn=download_strategy_md, inputs=[s_name_in], outputs=[s_dl_file])
557
+ s_zip_btn.click(fn=download_all_strategies_zip, inputs=[s_cat], outputs=[s_zip_file])
558
+
559
+ with gr.Tab("βˆ‘ Formulas"):
560
+ with gr.Row():
561
+ f_query = gr.Textbox(label="Search formulas", placeholder="Sharpe, Kelly, ATR…")
562
+ f_btn = gr.Button("πŸ” Search", variant="primary")
563
+ f_table = gr.Dataframe(
564
+ headers=["Name","Category","Purpose","LaTeX","Sources"],
565
+ datatype=["str","str","str","str","str"],
566
+ interactive=False,
567
+ )
568
+ f_dl_btn = gr.Button("πŸ“¦ Download All Formulas ZIP")
569
+ f_dl_file = gr.File(label="")
570
+ f_btn.click(fn=search_formulas, inputs=[f_query], outputs=[f_table])
571
+ f_dl_btn.click(fn=download_all_formulas_zip, outputs=[f_dl_file])
572
+
573
+ # ── Tab 3: Backtest ───────────────────────────
574
+ with gr.Tab("πŸ”¬ Backtest"):
575
+ gr.Markdown(
576
+ "### Step 2 β€” Walk-Forward Backtest All Extracted Strategies\n"
577
+ "Compiles each strategy β†’ grid-searches parameters β†’ validates out-of-sample "
578
+ "β†’ pushes MT5 `.set` files to your HuggingFace dataset."
579
+ )
580
+ with gr.Row():
581
+ with gr.Column(scale=2):
582
+ with gr.Row():
583
+ bt_load_syms = gr.Button("πŸ”„ Load Symbols from HF Tick Repo")
584
+ bt_symbols = gr.CheckboxGroup(
585
+ label="Symbols to test",
586
+ choices=[], value=[],
587
+ info="Load symbols first, then select which to test",
588
+ )
589
+ bt_timeframes = gr.CheckboxGroup(
590
+ label="Timeframes",
591
+ choices=["1m","5m","15m","30m","1h","4h","1d"],
592
+ value=["1h","4h"],
593
+ )
594
+ bt_filter = gr.Textbox(
595
+ label="Strategy name filter (optional)",
596
+ placeholder="Leave blank to test all strategies",
597
+ )
598
+ bt_max = gr.Slider(
599
+ 0, 500, value=0, step=10,
600
+ label="Max strategies to test (0 = all)",
601
+ )
602
+ bt_viable_only = gr.Checkbox(
603
+ label="Push only VIABLE strategies to HuggingFace",
604
+ value=True,
605
+ )
606
+ bt_run = gr.Button(
607
+ "πŸš€ Run Walk-Forward Backtests",
608
+ variant="primary", size="lg",
609
+ )
610
+ with gr.Column(scale=1):
611
+ bt_summary = gr.Textbox(
612
+ label="Summary", lines=12, interactive=False,
613
+ elem_classes=["status-box"],
614
+ )
615
+ bt_log = gr.Textbox(
616
+ label="Backtest Log", lines=12, interactive=False,
617
+ elem_classes=["status-box"],
618
+ )
619
+ bt_load_syms.click(fn=load_symbols, outputs=[bt_symbols])
620
+ bt_run.click(
621
+ fn=run_backtests,
622
+ inputs=[bt_symbols, bt_timeframes, bt_filter, bt_max, bt_viable_only],
623
+ outputs=[bt_summary, bt_log],
624
+ )
625
+
626
+ # ── Tab 4: Results ────────────────────────────
627
+ with gr.Tab("πŸ† Results"):
628
+ gr.Markdown(
629
+ "### Viable Strategies β€” Download MT5 `.set` Files & Configs\n"
630
+ "Only strategies that passed walk-forward validation appear here."
631
+ )
632
+ res_refresh = gr.Button("πŸ”„ Refresh from HuggingFace", variant="primary")
633
+ res_table = gr.Dataframe(
634
+ headers=["Strategy","Symbol","TF","Sharpe","Max DD","Win%","PF","Score"],
635
+ datatype=["str","str","str","str","str","str","str","str"],
636
+ interactive=False, label="Viable Strategies (sorted by OOS Sharpe)",
637
+ )
638
+ res_count = gr.Markdown("")
639
+
640
+ gr.Markdown("#### Download a Specific Strategy File")
641
+ with gr.Row():
642
+ r_strategy = gr.Textbox(label="Strategy name", placeholder="Copy from table above")
643
+ r_symbol = gr.Textbox(label="Symbol", placeholder="EURUSD")
644
+ r_timeframe = gr.Textbox(label="Timeframe", placeholder="1h")
645
+ r_filetype = gr.Dropdown(
646
+ choices=["MT5 .set file","Optimal JSON","Julia config","Full report"],
647
+ value="MT5 .set file", label="File type",
648
+ )
649
+ r_dl_btn = gr.Button("⬇️ Download", variant="primary")
650
+ r_dl_file = gr.File(label="Download")
651
+
652
+ gr.Markdown("#### Batch Downloads (all viable strategies)")
653
+ with gr.Row():
654
+ all_sets_btn = gr.Button("🎯 All MT5 .set Files (ZIP)")
655
+ all_sets_file = gr.File(label="MT5 Sets")
656
+ all_json_btn = gr.Button("πŸ“Š All Optimal JSON (ZIP)")
657
+ all_json_file = gr.File(label="Optimal JSON")
658
+
659
+ res_refresh.click(fn=load_results, outputs=[res_table, res_count])
660
+ r_dl_btn.click(
661
+ fn=download_result_file,
662
+ inputs=[r_strategy, r_symbol, r_timeframe, r_filetype],
663
+ outputs=[r_dl_file],
664
+ )
665
+ all_sets_btn.click(fn=download_all_sets_zip, outputs=[all_sets_file])
666
+ all_json_btn.click(fn=download_all_json_zip, outputs=[all_json_file])
667
+ demo.load(fn=load_results, outputs=[res_table, res_count])
668
+
669
+ # ── Tab 5: Setup ──────────────────────────────
670
+ with gr.Tab("βš™οΈ Setup & Status"):
671
+ gr.Markdown(
672
+ "### Configuration & Health Check\n\n"
673
+ "**First time?** Set these Secrets in your Space:\n\n"
674
+ "> Space Settings β†’ Variables and Secrets β†’ New Secret\n\n"
675
+ "| Secret | Description |\n"
676
+ "|--------|-------------|\n"
677
+ "| `ANTHROPIC_API_KEY` | Claude API key (claude.ai/settings) |\n"
678
+ "| `HF_TOKEN` | HuggingFace write token (hf.co/settings/tokens) |\n"
679
+ "| `HF_DATASET_REPO` | `your-username/quant-knowledge-base` |\n"
680
+ "| `HF_TICK_REPO` | `your-username/tick-data` |\n\n"
681
+ "**Optional tuning:**\n\n"
682
+ "| Variable | Default | Effect |\n"
683
+ "|----------|---------|--------|\n"
684
+ "| `WF_WINDOWS` | `5` | Walk-forward windows |\n"
685
+ "| `MIN_SHARPE` | `0.5` | Min OOS Sharpe to mark viable |\n"
686
+ "| `MIN_TRADES` | `30` | Min OOS trades required |\n"
687
+ "| `COMMISSION_PCT` | `0.0002` | 0.02% per side |\n"
688
+ "| `BACKTEST_TIMEFRAMES` | `1h,4h,1d` | Timeframes to test |\n"
689
+ "| `SIMILARITY_THRESHOLD` | `0.85` | Dedup cosine threshold |\n"
690
+ )
691
+ cfg_refresh = gr.Button("πŸ”„ Check Configuration")
692
+ cfg_status = gr.Markdown(check_config())
693
+ cfg_refresh.click(fn=check_config, outputs=[cfg_status])
694
+
695
+ gr.HTML("""
696
+ <div style="text-align:center;padding:1em;color:#475569;font-size:.8em">
697
+ Quant Knowledge Extractor Β· Built on HuggingFace Spaces Β· Claude AI
698
+ </div>
699
+ """)
700
+
701
+ # ── Launch ────────────────────────────────────────────
702
+ if __name__ == "__main__":
703
+ demo.launch()
packages.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ tesseract-ocr
2
+ tesseract-ocr-eng
3
+ poppler-utils
4
+ libgl1
5
+ libglib2.0-0
pipeline/__init__.py ADDED
File without changes
pipeline/backtester.py ADDED
@@ -0,0 +1,700 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/backtester.py
3
+ ─────────────────────────────────────────────────────
4
+ Strategy compiler, vectorized backtest engine,
5
+ walk-forward optimizer, and output file generator.
6
+
7
+ Everything that runs on HF Spaces after extraction.
8
+ """
9
+
10
+ import json
11
+ import time
12
+ import hashlib
13
+ import itertools
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from typing import Optional, Callable
17
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FTimeout
18
+ from datetime import datetime
19
+ import numpy as np
20
+ import pandas as pd
21
+ import anthropic
22
+ from loguru import logger
23
+
24
+ import utils.config as cfg
25
+ import pipeline.indicators as ind
26
+
27
+
28
+ # ═══════════════════════════════════════════════════
29
+ # STRATEGY COMPILER
30
+ # ═══════════════════════════════════════════════════
31
+
32
+ @dataclass
33
+ class Executable:
34
+ canonical_id: str
35
+ name: str
36
+ code: str
37
+ generate_signals: Optional[Callable] = field(default=None, repr=False)
38
+ get_param_grid: Optional[Callable] = field(default=None, repr=False)
39
+ is_valid: bool = False
40
+ error: str = ""
41
+
42
+
43
+ class StrategyCompiler:
44
+ MODEL = "claude-sonnet-4-20250514"
45
+ TIMEOUT = 10 # seconds for sandboxed execution
46
+
47
+ def __init__(self):
48
+ self.client = anthropic.Anthropic(api_key=cfg.ANTHROPIC_API_KEY)
49
+
50
+ def compile(self, record: dict) -> Executable:
51
+ cid = record.get("canonical_id", "")
52
+ name = record.get("name", "Unknown")
53
+
54
+ # Try cache
55
+ cache = cfg.TMP_COMPILED / f"{cid}.py"
56
+ if cache.exists():
57
+ code = cache.read_text(encoding="utf-8")
58
+ exe, _ = self._load(code, cid, name)
59
+ if exe.is_valid:
60
+ return exe
61
+
62
+ # Generate via Claude
63
+ code = self._generate(record)
64
+ if not code:
65
+ return Executable(cid, name, "", error="API returned empty")
66
+
67
+ exe, err = self._load(code, cid, name)
68
+
69
+ # One retry with error feedback
70
+ if not exe.is_valid:
71
+ code2 = self._generate(record, prev_error=err)
72
+ if code2:
73
+ exe, _ = self._load(code2, cid, name)
74
+
75
+ if exe.is_valid:
76
+ cache.write_text(code, encoding="utf-8")
77
+
78
+ return exe
79
+
80
+ def _generate(self, record: dict, prev_error: str = "") -> str:
81
+ compact = {k: record.get(k) for k in
82
+ ("name","category","description","entry_rules","exit_rules",
83
+ "filters","parameters","mathematical_basis")}
84
+ prompt = cfg.COMPILER_PROMPT.format(strategy_json=json.dumps(compact, indent=2))
85
+ if prev_error:
86
+ prompt += f"\n\nPREVIOUS ERROR: {prev_error}\nFix the code."
87
+ try:
88
+ resp = self.client.messages.create(
89
+ model=self.MODEL,
90
+ max_tokens=2000,
91
+ messages=[{"role": "user", "content": prompt}],
92
+ )
93
+ code = resp.content[0].text.strip() if resp.content else ""
94
+ if code.startswith("```"):
95
+ code = "\n".join(code.split("\n")[1:]).rstrip("`").strip()
96
+ return code
97
+ except Exception as e:
98
+ logger.error(f"Compile API error: {e}")
99
+ return ""
100
+
101
+ def _load(self, code: str, cid: str, name: str) -> tuple[Executable, str]:
102
+ exe = Executable(cid, name, code)
103
+ ns = self._safe_ns()
104
+
105
+ def _exec():
106
+ exec(compile(code, "<strategy>", "exec"), ns)
107
+
108
+ try:
109
+ with ThreadPoolExecutor(1) as pool:
110
+ pool.submit(_exec).result(timeout=self.TIMEOUT)
111
+ except FTimeout:
112
+ exe.error = "Execution timed out"
113
+ return exe, exe.error
114
+ except SyntaxError as e:
115
+ exe.error = f"SyntaxError: {e}"
116
+ return exe, exe.error
117
+ except Exception as e:
118
+ exe.error = f"{type(e).__name__}: {e}"
119
+ return exe, exe.error
120
+
121
+ if "generate_signals" not in ns:
122
+ exe.error = "Missing: generate_signals(df, params)"
123
+ return exe, exe.error
124
+ if "get_parameter_grid" not in ns:
125
+ exe.error = "Missing: get_parameter_grid()"
126
+ return exe, exe.error
127
+
128
+ # Smoke test
129
+ err = self._smoke(ns)
130
+ if err:
131
+ exe.error = err
132
+ return exe, err
133
+
134
+ exe.generate_signals = ns["generate_signals"]
135
+ exe.get_param_grid = ns["get_parameter_grid"]
136
+ exe.is_valid = True
137
+ return exe, ""
138
+
139
+ @staticmethod
140
+ def _smoke(ns: dict) -> str:
141
+ try:
142
+ grid = ns["get_parameter_grid"]()
143
+ if not isinstance(grid, dict):
144
+ return "get_parameter_grid() must return dict"
145
+ params = {k: v[0] for k, v in grid.items() if v}
146
+ n = 200
147
+ rng = np.random.default_rng(42)
148
+ price = 100 + np.cumsum(rng.normal(0, 0.5, n))
149
+ df = pd.DataFrame({
150
+ "open": price + rng.uniform(-0.1, 0.1, n),
151
+ "high": price + rng.uniform(0, 0.5, n),
152
+ "low": price - rng.uniform(0, 0.5, n),
153
+ "close": price,
154
+ "volume": rng.uniform(1000, 10000, n),
155
+ }, index=pd.date_range("2020-01-01", periods=n, freq="1h", tz="UTC"))
156
+ sigs = ns["generate_signals"](df, params)
157
+ if not isinstance(sigs, pd.Series):
158
+ return f"generate_signals must return pd.Series, got {type(sigs).__name__}"
159
+ if len(sigs) != n:
160
+ return f"Signal length {len(sigs)} != df length {n}"
161
+ vals = set(int(round(v)) for v in sigs.dropna().unique())
162
+ if not vals.issubset({-1, 0, 1}):
163
+ return f"Signal values must be in {{-1,0,1}}, got {vals}"
164
+ except Exception as e:
165
+ return f"Smoke test: {type(e).__name__}: {e}"
166
+ return ""
167
+
168
+ @staticmethod
169
+ def _safe_ns() -> dict:
170
+ return {
171
+ "__builtins__": {
172
+ "abs":abs,"all":all,"any":any,"bool":bool,"dict":dict,
173
+ "enumerate":enumerate,"float":float,"int":int,
174
+ "isinstance":isinstance,"len":len,"list":list,
175
+ "max":max,"min":min,"print":lambda*a,**k:None,
176
+ "range":range,"round":round,"sorted":sorted,
177
+ "str":str,"sum":sum,"tuple":tuple,"zip":zip,
178
+ "None":None,"True":True,"False":False,
179
+ },
180
+ "np": np, "pd": pd, "indicators": ind,
181
+ }
182
+
183
+
184
+ # ═══════════════════════════════════════════════════
185
+ # BACKTEST ENGINE
186
+ # ═══════════════════════════════════════════════════
187
+
188
+ @dataclass
189
+ class BacktestResult:
190
+ strategy: str
191
+ symbol: str
192
+ timeframe: str
193
+ params: dict
194
+
195
+ total_return: float = 0.0
196
+ cagr: float = 0.0
197
+ sharpe: float = 0.0
198
+ sortino: float = 0.0
199
+ calmar: float = 0.0
200
+ max_dd: float = 0.0
201
+ n_trades: int = 0
202
+ win_rate: float = 0.0
203
+ profit_factor: float = 0.0
204
+ avg_win: float = 0.0
205
+ avg_loss: float = 0.0
206
+ expectancy: float = 0.0
207
+ final_equity: float = 0.0
208
+ total_comm: float = 0.0
209
+ data_start: str = ""
210
+ data_end: str = ""
211
+ n_bars: int = 0
212
+ is_valid: bool = False
213
+ error: str = ""
214
+ equity_curve: list = field(default_factory=list)
215
+
216
+ BARS_PER_YEAR = {"1m":525600,"5m":105120,"15m":35040,"30m":17520,
217
+ "1h":8760,"4h":2190,"1d":252,"1w":52}
218
+
219
+ def to_dict(self) -> dict:
220
+ return {k: v for k, v in self.__dict__.items() if k != "equity_curve"}
221
+
222
+
223
+ class BacktestEngine:
224
+ def __init__(self):
225
+ self.equity = cfg.INITIAL_EQUITY
226
+ self.comm = cfg.COMMISSION_PCT
227
+ self.slip = cfg.SLIPPAGE_PCT
228
+ self.risk = cfg.RISK_PER_TRADE
229
+ self.atr_mult = 2.0
230
+ self.max_pos = 0.20
231
+
232
+ def run(self, name: str, symbol: str, tf: str,
233
+ df: pd.DataFrame, signals: pd.Series, params: dict) -> BacktestResult:
234
+ res = BacktestResult(name, symbol, tf, params,
235
+ n_bars=len(df),
236
+ data_start=str(df.index[0]) if len(df) else "",
237
+ data_end=str(df.index[-1]) if len(df) else "")
238
+ if len(df) < 50:
239
+ res.error = "Insufficient bars (<50)"
240
+ return res
241
+ if signals.isna().all():
242
+ res.error = "All signals NaN"
243
+ return res
244
+ try:
245
+ trades, eq = self._simulate(df, signals)
246
+ res = self._metrics(res, trades, eq, tf)
247
+ res.is_valid = True
248
+ except Exception as e:
249
+ res.error = f"{type(e).__name__}: {e}"
250
+ return res
251
+
252
+ def _simulate(self, df, signals):
253
+ n = len(df)
254
+ closes = df["close"].values.astype(float)
255
+ highs = df["high"].values.astype(float)
256
+ lows = df["low"].values.astype(float)
257
+ sigs = signals.fillna(0).round().clip(-1,1).astype(int).values
258
+
259
+ atr_s = ind.atr(df["high"], df["low"], df["close"], 14).values.astype(float)
260
+ eq = np.full(n, self.equity, dtype=float)
261
+ trades = []
262
+ pos, entry, sz, stop = 0, 0.0, 0.0, 0.0
263
+ cash = self.equity
264
+ last_trade = -2
265
+
266
+ for i in range(1, n):
267
+ price = closes[i]
268
+ sig = sigs[i]
269
+
270
+ # Stop loss
271
+ if pos != 0:
272
+ hit = (pos == 1 and lows[i] <= stop) or (pos == -1 and highs[i] >= stop)
273
+ if hit:
274
+ ep = stop * (1 + self.slip * pos)
275
+ pnl, comm = self._pnl(pos, entry, ep, sz)
276
+ trades.append((pos, entry, ep, pnl - comm)); cash += pnl - comm; pos = 0; last_trade = i
277
+
278
+ # Exit on signal flip or flat
279
+ if pos != 0 and (sig == 0 or sig == -pos):
280
+ ep = price * (1 + self.slip * pos)
281
+ pnl, comm = self._pnl(pos, entry, ep, sz)
282
+ trades.append((pos, entry, ep, pnl - comm)); cash += pnl - comm; pos = 0; last_trade = i
283
+
284
+ # Entry
285
+ if pos == 0 and sig != 0 and (i - last_trade) >= 1:
286
+ entry = price * (1 + self.slip * sig)
287
+ a = atr_s[i] if not np.isnan(atr_s[i]) else price * 0.01
288
+ dist = self.atr_mult * a
289
+ risk_amt = cash * self.risk
290
+ sz = min(risk_amt / max(dist, 1e-8), cash * self.max_pos / entry)
291
+ sz = max(sz, 1e-8)
292
+ pos = sig
293
+ stop = entry - sig * dist
294
+
295
+ mtm = pos * (closes[i] - entry) * sz if pos != 0 else 0
296
+ eq[i] = cash + mtm
297
+
298
+ if pos != 0:
299
+ ep = closes[-1]
300
+ pnl, comm = self._pnl(pos, entry, ep, sz)
301
+ trades.append((pos, entry, ep, pnl - comm)); cash += pnl - comm
302
+ eq[-1] = cash
303
+
304
+ return trades, eq
305
+
306
+ @staticmethod
307
+ def _pnl(direction, entry, exit_, size):
308
+ gross = direction * (exit_ - entry) * size
309
+ comm = (entry + exit_) * size * cfg.COMMISSION_PCT
310
+ return gross, comm
311
+
312
+ def _metrics(self, res, trades, eq, tf):
313
+ init = self.equity; final = eq[-1]; n = len(eq)
314
+ bpy = BacktestResult.BARS_PER_YEAR.get(tf, 252)
315
+
316
+ res.final_equity = float(final)
317
+ res.total_return = float((final-init)/init*100)
318
+ res.equity_curve = eq.tolist()
319
+
320
+ years = n / bpy
321
+ res.cagr = float(((final/init)**(1/years)-1)*100) if years > 0 and final > 0 else 0.0
322
+
323
+ # Drawdown
324
+ peak = np.maximum.accumulate(eq)
325
+ dd = (eq - peak) / peak
326
+ res.max_dd = float(-dd.min() * 100)
327
+
328
+ # Sharpe / Sortino
329
+ rets = np.diff(eq) / eq[:-1]
330
+ rets = rets[~np.isnan(rets)]
331
+ if len(rets) > 1:
332
+ mu = rets.mean(); sig = rets.std()
333
+ ds = rets[rets < 0].std() if (rets < 0).any() else sig
334
+ af = np.sqrt(bpy)
335
+ res.sharpe = float(mu/sig*af) if sig > 0 else 0.0
336
+ res.sortino = float(mu/ds*af) if ds > 0 else 0.0
337
+ res.calmar = float(res.cagr/res.max_dd) if res.max_dd > 0 else 0.0
338
+
339
+ # Trade stats
340
+ res.n_trades = len(trades)
341
+ if not trades:
342
+ return res
343
+
344
+ pnls = np.array([t[3] for t in trades])
345
+ wins = pnls > 0; losses = pnls < 0
346
+ res.win_rate = float(wins.sum()/len(pnls)*100)
347
+ gross_w = pnls[wins].sum() if wins.any() else 0
348
+ gross_l = abs(pnls[losses].sum()) if losses.any() else 0
349
+ res.profit_factor = float(gross_w/gross_l) if gross_l > 0 else float("inf")
350
+ entry_vals = np.array([t[1] for t in trades])
351
+ pct = pnls / (entry_vals + 1e-10)
352
+ res.avg_win = float(pct[wins].mean()*100) if wins.any() else 0.0
353
+ res.avg_loss = float(pct[losses].mean()*100) if losses.any() else 0.0
354
+ res.expectancy = float(res.win_rate/100*res.avg_win + (1-res.win_rate/100)*res.avg_loss)
355
+ res.total_comm = float(sum(
356
+ (t[1]+t[2])*1e-4*cfg.COMMISSION_PCT for t in trades
357
+ ))
358
+ return res
359
+
360
+
361
+ # ═══════════════════════════════════════════════════
362
+ # WALK-FORWARD OPTIMIZER
363
+ # ═══════════════════════════════════════════════════
364
+
365
+ @dataclass
366
+ class OptResult:
367
+ strategy: str; symbol: str; timeframe: str
368
+ optimal_params: dict = field(default_factory=dict)
369
+ oos_sharpe_mean: float = 0.0
370
+ oos_sharpe_std: float = 0.0
371
+ oos_win_rate_mean: float = 0.0
372
+ oos_max_dd_mean: float = 0.0
373
+ oos_pf_mean: float = 0.0
374
+ oos_total_trades: int = 0
375
+ wf_efficiency: float = 0.0
376
+ robustness_score: float = 0.0
377
+ is_viable: bool = False
378
+ reasons: list = field(default_factory=list)
379
+ oos_results: list = field(default_factory=list)
380
+
381
+ def to_dict(self) -> dict:
382
+ return {k:v for k,v in self.__dict__.items() if k != "oos_results"}
383
+
384
+
385
+ class WalkForwardOptimizer:
386
+ def __init__(self):
387
+ self.n_win = cfg.WF_WINDOWS
388
+ self.is_r = cfg.WF_IS_RATIO
389
+ self.max_c = cfg.MAX_PARAM_COMBOS
390
+ self.engine = BacktestEngine()
391
+
392
+ def optimize(self, exe: Executable, symbol: str, tf: str, df: pd.DataFrame) -> OptResult:
393
+ res = OptResult(exe.name, symbol, tf)
394
+ if not exe.is_valid:
395
+ res.reasons.append("Strategy did not compile"); return res
396
+ if len(df) < 200:
397
+ res.reasons.append(f"Only {len(df)} bars, need β‰₯200"); return res
398
+
399
+ try:
400
+ grid = exe.get_param_grid()
401
+ except Exception as e:
402
+ res.reasons.append(f"param grid error: {e}"); return res
403
+
404
+ combos = self._combos(grid)
405
+ windows = self._windows(len(df))
406
+ if not windows:
407
+ res.reasons.append("Cannot build walk-forward windows"); return res
408
+
409
+ oos_results, is_sharpes, win_params = [], [], []
410
+
411
+ for is_s, is_e, oos_s, oos_e in windows:
412
+ is_df = df.iloc[is_s:is_e]
413
+ oos_df = df.iloc[oos_s:oos_e]
414
+
415
+ best_p, best_sh = None, -np.inf
416
+ for p in combos:
417
+ r = self._run(exe, symbol, tf, is_df, p)
418
+ if r.is_valid and r.n_trades >= cfg.MIN_TRADES and r.sharpe > best_sh:
419
+ best_sh = r.sharpe; best_p = p
420
+
421
+ if best_p is None:
422
+ continue
423
+
424
+ win_params.append(best_p)
425
+ is_sharpes.append(best_sh)
426
+ oos_r = self._run(exe, symbol, tf, oos_df, best_p)
427
+ oos_results.append(oos_r)
428
+
429
+ res.oos_results = oos_results
430
+ if not oos_results:
431
+ res.reasons.append("No valid WF windows produced results"); return res
432
+
433
+ # Aggregate OOS
434
+ valid_oos = [r for r in oos_results if r.is_valid and r.n_trades >= cfg.MIN_TRADES]
435
+ if valid_oos:
436
+ sharpes = np.array([r.sharpe for r in valid_oos])
437
+ res.oos_sharpe_mean = float(sharpes.mean())
438
+ res.oos_sharpe_std = float(sharpes.std())
439
+ res.oos_win_rate_mean = float(np.mean([r.win_rate for r in valid_oos]))
440
+ res.oos_max_dd_mean = float(np.mean([r.max_dd for r in valid_oos]))
441
+ res.oos_pf_mean = float(np.mean([r.profit_factor for r in valid_oos if r.profit_factor < 100]))
442
+ res.oos_total_trades = sum(r.n_trades for r in valid_oos)
443
+
444
+ # WF efficiency
445
+ if is_sharpes and oos_results:
446
+ oos_sh = [r.sharpe for r in oos_results if r.is_valid]
447
+ if is_sharpes and oos_sh:
448
+ res.wf_efficiency = float(np.mean(oos_sh) / np.mean(is_sharpes)) if np.mean(is_sharpes) > 0 else 0.0
449
+
450
+ # Optimal params
451
+ res.optimal_params = self._vote(win_params, oos_results)
452
+
453
+ # Robustness & viability
454
+ res.robustness_score = self._robustness(res)
455
+ res.is_viable, res.reasons = self._viability(res)
456
+ return res
457
+
458
+ def _run(self, exe, symbol, tf, df, params):
459
+ try:
460
+ sigs = exe.generate_signals(df.copy(), params)
461
+ return self.engine.run(exe.name, symbol, tf, df, sigs, params)
462
+ except Exception as e:
463
+ r = BacktestResult(exe.name, symbol, tf, params, error=str(e))
464
+ return r
465
+
466
+ def _combos(self, grid: dict) -> list[dict]:
467
+ if not grid:
468
+ return [{}]
469
+ keys = list(grid.keys())
470
+ values = [grid[k] for k in keys]
471
+ all_c = list(itertools.product(*values))
472
+ if len(all_c) > self.max_c:
473
+ rng = np.random.default_rng(42)
474
+ idx = rng.choice(len(all_c), self.max_c, replace=False)
475
+ all_c = [all_c[i] for i in idx]
476
+ return [dict(zip(keys, c)) for c in all_c]
477
+
478
+ def _windows(self, n: int) -> list[tuple]:
479
+ oos_sz = max(50, n // (self.n_win * 2))
480
+ wins = []
481
+ for i in range(self.n_win):
482
+ oos_e = n - i * oos_sz
483
+ oos_s = oos_e - oos_sz
484
+ is_e = oos_s
485
+ if is_e < 100 or oos_e - oos_s < 50:
486
+ continue
487
+ wins.append((0, is_e, oos_s, oos_e))
488
+ return list(reversed(wins))
489
+
490
+ @staticmethod
491
+ def _vote(params_list: list[dict], oos_results: list) -> dict:
492
+ if not params_list:
493
+ return {}
494
+ if len(params_list) == 1:
495
+ return params_list[0]
496
+ weights = np.array([max(0, r.sharpe) for r in oos_results[:len(params_list)]])
497
+ if weights.sum() == 0:
498
+ weights = np.ones(len(params_list))
499
+ weights /= weights.sum()
500
+ keys = set()
501
+ for p in params_list: keys.update(p.keys())
502
+ optimal = {}
503
+ for k in keys:
504
+ vals = [p[k] for p in params_list if k in p]
505
+ try:
506
+ arr = np.array(vals, dtype=float)
507
+ w = weights[:len(arr)]
508
+ si = np.argsort(arr)
509
+ cw = np.cumsum(w[si])
510
+ mi = np.searchsorted(cw, 0.5)
511
+ optimal[k] = float(arr[si[min(mi, len(arr)-1)]])
512
+ except Exception:
513
+ from collections import Counter
514
+ optimal[k] = Counter(str(v) for v in vals).most_common(1)[0][0]
515
+ return optimal
516
+
517
+ @staticmethod
518
+ def _robustness(r: OptResult) -> float:
519
+ s = 0.0
520
+ s += min(1.0, max(0.0, r.wf_efficiency)) * 40
521
+ if r.oos_sharpe_mean > 0:
522
+ cv = r.oos_sharpe_std / (r.oos_sharpe_mean + 1e-9)
523
+ s += max(0.0, 1.0 - cv) * 30
524
+ target = cfg.MIN_TRADES * cfg.WF_WINDOWS
525
+ s += min(1.0, r.oos_total_trades / max(1, target * 2)) * 20
526
+ if r.oos_pf_mean > 1:
527
+ s += min(1.0, (r.oos_pf_mean - 1) / 2) * 10
528
+ return round(s, 1)
529
+
530
+ @staticmethod
531
+ def _viability(r: OptResult) -> tuple[bool, list[str]]:
532
+ reasons = []
533
+ if r.oos_sharpe_mean < cfg.MIN_SHARPE:
534
+ reasons.append(f"OOS Sharpe {r.oos_sharpe_mean:.2f} < {cfg.MIN_SHARPE}")
535
+ if r.oos_total_trades < cfg.MIN_TRADES:
536
+ reasons.append(f"Too few OOS trades: {r.oos_total_trades}")
537
+ if r.oos_max_dd_mean > 30:
538
+ reasons.append(f"High avg DD: {r.oos_max_dd_mean:.1f}%")
539
+ if r.wf_efficiency < 0.3:
540
+ reasons.append(f"Low WF efficiency: {r.wf_efficiency:.2f}")
541
+ if r.oos_pf_mean < 1.1:
542
+ reasons.append(f"Profit factor {r.oos_pf_mean:.2f} < 1.1")
543
+ viable = len(reasons) == 0
544
+ if viable:
545
+ reasons = [f"βœ… Sharpe={r.oos_sharpe_mean:.2f} DD={r.oos_max_dd_mean:.1f}% "
546
+ f"WFE={r.wf_efficiency:.2f} Score={r.robustness_score:.0f}/100"]
547
+ return viable, reasons
548
+
549
+
550
+ # ═══════════════════════════════════════════════════
551
+ # OUTPUT FILE GENERATORS
552
+ # ═══════════════════════════════════════════════════
553
+
554
+ def slugify(text: str) -> str:
555
+ text = text.lower().strip()
556
+ text = re.sub(r"[^\w\s-]", "", text)
557
+ text = re.sub(r"[\s_-]+", "-", text)
558
+ return text[:50]
559
+
560
+
561
+ def build_markdown_report(opt: OptResult, record: dict) -> str:
562
+ now = datetime.now().strftime("%Y-%m-%d %H:%M UTC")
563
+ status = "βœ… VIABLE" if opt.is_viable else "❌ NOT VIABLE"
564
+ lines = [
565
+ f"# {opt.strategy}",
566
+ f"## Backtest Report β€” {opt.symbol} {opt.timeframe}",
567
+ f"",
568
+ f"> **Status:** {status} ",
569
+ f"> **Robustness Score:** {opt.robustness_score:.0f}/100 ",
570
+ f"> **Generated:** {now} ",
571
+ f"> **Sources:** {', '.join(record.get('sources', []))[:80]}",
572
+ f"",
573
+ "---", "",
574
+ "## Viability", "",
575
+ ]
576
+ for r in opt.reasons:
577
+ lines.append(f"- {r}")
578
+ lines += [""]
579
+
580
+ if opt.optimal_params:
581
+ lines += ["## Optimal Parameters", "", "| Parameter | Value |", "|-----------|-------|"]
582
+ for k, v in opt.optimal_params.items():
583
+ lines.append(f"| `{k}` | `{v}` |")
584
+ lines += [""]
585
+
586
+ lines += [
587
+ "## OOS Performance (Walk-Forward Aggregate)", "",
588
+ "| Metric | Value |", "|--------|-------|",
589
+ f"| Sharpe (mean Β± std) | `{opt.oos_sharpe_mean:.3f} Β± {opt.oos_sharpe_std:.3f}` |",
590
+ f"| Win Rate | `{opt.oos_win_rate_mean:.1f}%` |",
591
+ f"| Max Drawdown (mean) | `{opt.oos_max_dd_mean:.2f}%` |",
592
+ f"| Profit Factor | `{opt.oos_pf_mean:.3f}` |",
593
+ f"| Total OOS Trades | `{opt.oos_total_trades}` |",
594
+ f"| WF Efficiency Ratio | `{opt.wf_efficiency:.3f}` |",
595
+ "", "## Window Results", "",
596
+ "| Window | Trades | Sharpe | Max DD | Win% | PF |",
597
+ "|--------|--------|--------|--------|------|----|",
598
+ ]
599
+ for i, r in enumerate(opt.oos_results, 1):
600
+ if r.is_valid:
601
+ lines.append(f"| {i} | {r.n_trades} | {r.sharpe:.2f} | "
602
+ f"{r.max_dd:.1f}% | {r.win_rate:.1f}% | {r.profit_factor:.2f} |")
603
+ else:
604
+ lines.append(f"| {i} | β€” | β€” | β€” | β€” | β€” | *{r.error[:40]}* |")
605
+
606
+ if record.get("description"):
607
+ lines += ["", "## Strategy Description", "", record["description"]]
608
+ if record.get("entry_rules"):
609
+ lines += ["", "## Entry Rules", ""]
610
+ for i, r in enumerate(record["entry_rules"], 1):
611
+ lines.append(f"{i}. {r}")
612
+ if record.get("exit_rules"):
613
+ lines += ["", "## Exit Rules", ""]
614
+ for i, r in enumerate(record["exit_rules"], 1):
615
+ lines.append(f"{i}. {r}")
616
+
617
+ lines += ["", "---", "*Generated by Quant Knowledge Extractor on HuggingFace*"]
618
+ return "\n".join(lines)
619
+
620
+
621
+ def build_optimal_json(opt: OptResult, record: dict) -> dict:
622
+ return {
623
+ "metadata": {
624
+ "strategy_name": opt.strategy,
625
+ "symbol": opt.symbol,
626
+ "timeframe": opt.timeframe,
627
+ "generated": datetime.now().isoformat(),
628
+ "is_viable": opt.is_viable,
629
+ "robustness": opt.robustness_score,
630
+ },
631
+ "optimal_parameters": opt.optimal_params,
632
+ "performance": {
633
+ "oos_sharpe_mean": round(opt.oos_sharpe_mean, 4),
634
+ "oos_sharpe_std": round(opt.oos_sharpe_std, 4),
635
+ "oos_win_rate": round(opt.oos_win_rate_mean, 2),
636
+ "oos_max_dd_pct": round(opt.oos_max_dd_mean, 2),
637
+ "oos_pf_mean": round(opt.oos_pf_mean, 4),
638
+ "oos_total_trades": opt.oos_total_trades,
639
+ "wf_efficiency": round(opt.wf_efficiency, 4),
640
+ },
641
+ "viability": {"is_viable": opt.is_viable, "reasons": opt.reasons},
642
+ }
643
+
644
+
645
+ def build_mt5_set(opt: OptResult, record: dict) -> str:
646
+ lines = [
647
+ f"; MT5 Strategy Tester Set File",
648
+ f"; Strategy: {opt.strategy}",
649
+ f"; Symbol: {opt.symbol} Timeframe: {opt.timeframe}",
650
+ f"; OOS Sharpe: {opt.oos_sharpe_mean:.3f} Score: {opt.robustness_score:.0f}/100",
651
+ f"; Generated: {datetime.now().strftime('%Y-%m-%d')}",
652
+ f"; Viable: {opt.is_viable}",
653
+ f";",
654
+ f"; HOW TO USE:",
655
+ f"; 1. Open MetaTrader 5 β†’ View β†’ Strategy Tester",
656
+ f"; 2. Select Expert Advisor",
657
+ f"; 3. Inputs tab β†’ Open β†’ select this .set file",
658
+ f"; 4. Run backtest or deploy live",
659
+ "",
660
+ ]
661
+ for k, v in opt.optimal_params.items():
662
+ mt5_name = "".join(w.capitalize() for w in k.replace("-","_").split("_"))
663
+ try:
664
+ fv = float(v)
665
+ step = max(1.0, abs(fv) * 0.1)
666
+ lines.append(f"{mt5_name}={fv}||{max(0,fv-step*3):.4f}||{step:.4f}||{fv+step*3:.4f}||1")
667
+ except Exception:
668
+ lines.append(f"{mt5_name}={v}")
669
+ lines += ["", "RiskPercent=1.0||0.5||0.5||3.0||1", "LotSize=0.1||0.01||0.01||1.0||1"]
670
+ return "\n".join(lines)
671
+
672
+
673
+ def build_julia_config(opt: OptResult) -> str:
674
+ struct = "".join(w.capitalize() for w in opt.strategy.split()[:4])
675
+ params = "\n ".join(
676
+ f'{k.lower().replace("-","_")} = {float(v):.6g}' if isinstance(v,(int,float)) else f'{k} = "{v}"'
677
+ for k, v in opt.optimal_params.items()
678
+ ) or 'stop_loss = 0.002'
679
+ return f'''# Optimal Parameters β€” {opt.strategy}
680
+ # Symbol: {opt.symbol} Timeframe: {opt.timeframe}
681
+ # OOS Sharpe: {opt.oos_sharpe_mean:.3f} Score: {opt.robustness_score:.0f}/100
682
+ # Viable: {opt.is_viable}
683
+
684
+ function optimal_params()::{struct}Params
685
+ return {struct}Params(
686
+ {params}
687
+ )
688
+ end
689
+
690
+ const BACKTEST_METADATA = (
691
+ strategy = "{opt.strategy}",
692
+ symbol = "{opt.symbol}",
693
+ timeframe = "{opt.timeframe}",
694
+ oos_sharpe = {opt.oos_sharpe_mean:.4f},
695
+ max_dd_pct = {opt.oos_max_dd_mean:.2f},
696
+ win_rate = {opt.oos_win_rate_mean:.1f},
697
+ wf_eff = {opt.wf_efficiency:.4f},
698
+ viable = {str(opt.is_viable).lower()},
699
+ )
700
+ '''
pipeline/exporter.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/exporter.py
3
+ Generates markdown content (as strings) for extracted items.
4
+ No file system writes β€” returns strings for HF upload.
5
+ """
6
+ import re
7
+ from datetime import datetime
8
+
9
+
10
+ def slugify(text: str) -> str:
11
+ text = text.lower().strip()
12
+ text = re.sub(r"[^\w\s-]", "", text)
13
+ text = re.sub(r"[\s_-]+", "-", text)
14
+ return text[:50]
15
+
16
+
17
+ def strategy_markdown(record: dict) -> str:
18
+ name = record.get("name", "Unknown")
19
+ sources = record.get("sources", [])
20
+ category = record.get("category", "")
21
+ desc = record.get("description", "")
22
+ cid = record.get("canonical_id", "")
23
+ layers = record.get("layers", [])
24
+
25
+ lines = [
26
+ f"# {name}", "",
27
+ f"> **Category:** {category} ",
28
+ f"> **Sources:** {', '.join(sources)[:80]} ",
29
+ f"> **Extracted:** {datetime.now().strftime('%Y-%m-%d')} ",
30
+ f"> **ID:** `{cid}`", "",
31
+ "---", "", "## Description", "", desc, "",
32
+ ]
33
+ for section, key in [("## Entry Rules", "entry_rules"), ("## Exit Rules", "exit_rules"),
34
+ ("## Filters", "filters")]:
35
+ items = record.get(key, [])
36
+ if items:
37
+ lines += [section, ""]
38
+ for i, r in enumerate(items, 1):
39
+ lines.append(f"{i}. {r}")
40
+ lines.append("")
41
+
42
+ if record.get("timeframes") or record.get("instruments"):
43
+ lines += ["## Applicability", ""]
44
+ if record.get("timeframes"):
45
+ lines.append(f"**Timeframes:** {', '.join(record['timeframes'])}")
46
+ if record.get("instruments"):
47
+ lines.append(f"**Instruments:** {', '.join(record['instruments'])}")
48
+ lines.append("")
49
+
50
+ params = record.get("parameters", {})
51
+ if params:
52
+ lines += ["## Parameters", "", "| Parameter | Description |", "|-----------|-------------|"]
53
+ for k, v in params.items():
54
+ lines.append(f"| `{k}` | {v} |")
55
+ lines.append("")
56
+
57
+ if record.get("mathematical_basis"):
58
+ lines += ["## Mathematical Basis", "", record["mathematical_basis"], ""]
59
+
60
+ if layers:
61
+ lines += [f"## Variants Found in {len(layers)} Additional Sources", ""]
62
+ for i, layer in enumerate(layers, 1):
63
+ src = layer.get("source_file", "")
64
+ data = layer.get("data", {})
65
+ lines.append(f"### Variant {i} β€” {src}")
66
+ if data.get("description"):
67
+ lines.append(data["description"])
68
+ lines.append("")
69
+
70
+ lines += ["---", "", "*Generated by Quant Knowledge Extractor β€” HuggingFace Spaces*"]
71
+ return "\n".join(lines)
72
+
73
+
74
+ def formula_markdown(record: dict) -> str:
75
+ name = record.get("name", "Unknown")
76
+ sources = record.get("sources", [])
77
+ latex = record.get("latex", "")
78
+ plain = record.get("plain_text", "")
79
+ layers = record.get("layers", [])
80
+
81
+ lines = [
82
+ f"# {name}", "",
83
+ f"> **Category:** {record.get('category', '')} ",
84
+ f"> **Sources:** {', '.join(sources)[:80]} ",
85
+ f"> **ID:** `{record.get('canonical_id', '')}`",
86
+ "", "---", "",
87
+ ]
88
+ if latex:
89
+ lines += ["## Formula (LaTeX)", "", f"$$", latex, f"$$", ""]
90
+ if plain:
91
+ lines += ["## Plain Text", "", f"```", plain, f"```", ""]
92
+ if record.get("purpose"):
93
+ lines += ["## Purpose", "", record["purpose"], ""]
94
+ if record.get("usage_context"):
95
+ lines += ["## Usage in Trading", "", record["usage_context"], ""]
96
+ if record.get("variables"):
97
+ lines += ["## Variables", "", "| Symbol | Description |", "|--------|-------------|"]
98
+ for sym, desc in record["variables"].items():
99
+ lines.append(f"| `{sym}` | {desc} |")
100
+ lines.append("")
101
+ if layers:
102
+ lines += [f"## Variant Forms ({len(layers)})", ""]
103
+ for i, l in enumerate(layers, 1):
104
+ d = l.get("data", {})
105
+ lines.append(f"### Variant {i} β€” {l.get('source_file','')}")
106
+ if d.get("latex"):
107
+ lines.append(f"$$\n{d['latex']}\n$$")
108
+ elif d.get("plain_text"):
109
+ lines.append(f"```\n{d['plain_text']}\n```")
110
+ lines.append("")
111
+ lines += ["---", "", "*Generated by Quant Knowledge Extractor β€” HuggingFace Spaces*"]
112
+ return "\n".join(lines)
113
+
114
+
115
+ def system_markdown(record: dict) -> str:
116
+ name = record.get("name", "Unknown")
117
+ lines = [
118
+ f"# {name}", "",
119
+ f"> **Sources:** {', '.join(record.get('sources', []))[:80]} ",
120
+ f"> **ID:** `{record.get('canonical_id', '')}`",
121
+ "", "---", "",
122
+ ]
123
+ for label, key in [
124
+ ("System Components", "components"), ("Entry System", "entry_system"),
125
+ ("Exit System", "exit_system"), ("Risk Management", "risk_management"),
126
+ ("Position Sizing", "position_sizing"), ("Backtesting Notes", "backtesting_notes"),
127
+ ]:
128
+ val = record.get(key)
129
+ if not val:
130
+ continue
131
+ lines += [f"## {label}", ""]
132
+ if isinstance(val, list):
133
+ for v in val: lines.append(f"- {v}")
134
+ else:
135
+ lines.append(str(val))
136
+ lines.append("")
137
+ lines += ["---", "", "*Generated by Quant Knowledge Extractor β€” HuggingFace Spaces*"]
138
+ return "\n".join(lines)
139
+
140
+
141
+ def backtest_index_markdown(all_results: list) -> str:
142
+ viable = [r for r in all_results if r.is_viable]
143
+ viable.sort(key=lambda r: r.oos_sharpe_mean, reverse=True)
144
+ not_viable = [r for r in all_results if not r.is_viable]
145
+ now = datetime.now().strftime("%Y-%m-%d %H:%M UTC")
146
+ lines = [
147
+ "# Backtest Results Index", "",
148
+ f"> Generated: {now} ",
149
+ f"> Total Tested: {len(all_results)} ",
150
+ f"> Viable: {len(viable)}",
151
+ "", "---", "",
152
+ "## βœ… Viable Strategies (by OOS Sharpe)", "",
153
+ "| # | Strategy | Symbol | TF | Sharpe | DD% | Win% | PF | Score |",
154
+ "|---|----------|--------|----|--------|-----|------|----|-------|",
155
+ ]
156
+ for i, r in enumerate(viable, 1):
157
+ lines.append(
158
+ f"| {i} | {r.strategy[:35]} | {r.symbol} | {r.timeframe} | "
159
+ f"{r.oos_sharpe_mean:.2f} | {r.oos_max_dd_mean:.1f} | "
160
+ f"{r.oos_win_rate_mean:.1f} | {r.oos_pf_mean:.2f} | {r.robustness_score:.0f} |"
161
+ )
162
+ lines += ["", "## ❌ Not Viable", "",
163
+ "| Strategy | Symbol | TF | Reason |",
164
+ "|----------|--------|----|--------|"]
165
+ for r in not_viable[:30]:
166
+ reason = r.reasons[0][:60] if r.reasons else ""
167
+ lines.append(f"| {r.strategy[:35]} | {r.symbol} | {r.timeframe} | {reason} |")
168
+ lines += ["", "---", "", "## File Locations (HuggingFace Dataset)", "",
169
+ "```", "optimal_sets/",
170
+ "β”œβ”€β”€ BACKTEST_INDEX.md ← this file",
171
+ "β”œβ”€β”€ backtest_index.json ← machine-readable",
172
+ "β”œβ”€β”€ {strategy}_{sym}_{tf}.set ← MT5 drag-and-drop",
173
+ "β”œβ”€β”€ {strategy}_{sym}_{tf}_optimal.json",
174
+ "└── {strategy}_{sym}_{tf}_config.jl",
175
+ "```"]
176
+ return "\n".join(lines)
pipeline/extractor.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/extractor.py
3
+ ─────────────────────────────────────────────────────
4
+ AI extraction + 3-layer deduplication.
5
+
6
+ Extraction: Chunk β†’ Claude API β†’ structured JSON
7
+ Dedup: SHA256 exact match β†’ TF-IDF cosine similarity β†’ layered merge
8
+ """
9
+
10
+ import json
11
+ import time
12
+ import hashlib
13
+ from typing import Optional
14
+ import numpy as np
15
+ from sklearn.feature_extraction.text import TfidfVectorizer
16
+ from sklearn.metrics.pairwise import cosine_similarity
17
+ import anthropic
18
+ from loguru import logger
19
+ import utils.config as cfg
20
+
21
+
22
+ # ── Extraction ────────────────────────────────────────
23
+
24
+ class AIExtractor:
25
+ MODEL = "claude-sonnet-4-20250514"
26
+
27
+ def __init__(self):
28
+ self.client = anthropic.Anthropic(api_key=cfg.ANTHROPIC_API_KEY)
29
+ self.tokens_used = 0
30
+
31
+ def extract(self, chunk) -> dict:
32
+ """Extract from one Chunk. Returns {strategies, formulas, systems}."""
33
+ if chunk.word_count < 20:
34
+ return {"strategies": [], "formulas": [], "systems": []}
35
+
36
+ prompt = cfg.EXTRACTION_PROMPT.format(
37
+ source_file=chunk.source_file,
38
+ page_start=chunk.page_start,
39
+ page_end=chunk.page_end,
40
+ text=chunk.text,
41
+ )
42
+
43
+ raw = self._call(prompt)
44
+ if not raw:
45
+ return {"strategies": [], "formulas": [], "systems": []}
46
+
47
+ return self._parse(raw, chunk)
48
+
49
+ def _call(self, prompt: str, retries: int = 3) -> str:
50
+ delay = 2.0
51
+ for attempt in range(retries):
52
+ try:
53
+ resp = self.client.messages.create(
54
+ model=self.MODEL,
55
+ max_tokens=4096,
56
+ messages=[{"role": "user", "content": prompt}],
57
+ )
58
+ self.tokens_used += resp.usage.input_tokens + resp.usage.output_tokens
59
+ return resp.content[0].text if resp.content else ""
60
+ except anthropic.RateLimitError:
61
+ logger.warning(f"Rate limit β€” waiting {delay}s")
62
+ time.sleep(delay); delay *= 2
63
+ except Exception as e:
64
+ logger.error(f"API error: {e}")
65
+ if attempt == retries - 1:
66
+ return ""
67
+ time.sleep(delay); delay *= 2
68
+ return ""
69
+
70
+ def _parse(self, raw: str, chunk) -> dict:
71
+ raw = raw.strip()
72
+ if raw.startswith("```"):
73
+ raw = "\n".join(raw.split("\n")[1:])
74
+ raw = raw.rstrip("`").strip()
75
+ try:
76
+ data = json.loads(raw)
77
+ except json.JSONDecodeError:
78
+ try:
79
+ s = raw.find("{"); e = raw.rfind("}")
80
+ data = json.loads(raw[s:e+1]) if s != -1 else {}
81
+ except Exception:
82
+ return {"strategies": [], "formulas": [], "systems": []}
83
+
84
+ source_info = {
85
+ "source_file": chunk.source_file,
86
+ "source_pages": f"{chunk.page_start}-{chunk.page_end}",
87
+ }
88
+ result = {}
89
+ for kind in ("strategies", "formulas", "systems"):
90
+ result[kind] = []
91
+ for item in data.get(kind, []):
92
+ if isinstance(item, dict) and item.get("name"):
93
+ item.update(source_info)
94
+ item["content_hash"] = _hash(
95
+ item.get("description", "") +
96
+ item.get("plain_text", "") +
97
+ item.get("entry_system", "") +
98
+ item.get("name", "")
99
+ )
100
+ result[kind].append(item)
101
+ return result
102
+
103
+
104
+ # ── Deduplication ─────────────────────────────────────
105
+
106
+ class Deduplicator:
107
+ """
108
+ 3-layer dedup against a live knowledge base dict.
109
+ Layer 1: exact SHA256 hash β†’ skip
110
+ Layer 2: TF-IDF cosine > threshold β†’ merge as variant layer
111
+ Layer 3: genuinely new β†’ add with canonical_id
112
+ """
113
+
114
+ def __init__(self, threshold: float = cfg.SIMILARITY_THRESHOLD):
115
+ self.threshold = threshold
116
+ self._vec = TfidfVectorizer(ngram_range=(1, 2), max_features=5000, stop_words="english")
117
+
118
+ def process(self, extracted: dict, kb: dict) -> dict:
119
+ """
120
+ Merge extracted items into kb in-place.
121
+ Returns stats dict.
122
+ """
123
+ stats = {k: {"added": 0, "merged": 0, "skipped": 0}
124
+ for k in ("strategies", "formulas", "systems")}
125
+
126
+ for kind in ("strategies", "formulas", "systems"):
127
+ for item in extracted.get(kind, []):
128
+ action = self._process_one(item, kb[kind], kind)
129
+ stats[kind][action] += 1
130
+
131
+ return stats
132
+
133
+ def _process_one(self, item: dict, store: dict, kind: str) -> str:
134
+ h = item.get("content_hash", "")
135
+
136
+ # Layer 1: exact hash
137
+ for existing in store.values():
138
+ if existing.get("content_hash") == h:
139
+ self._add_source(item, existing)
140
+ return "skipped"
141
+
142
+ # Layer 2: semantic similarity
143
+ similar_id = self._find_similar(item, store, kind)
144
+ if similar_id:
145
+ self._merge_layer(item, store[similar_id])
146
+ return "merged"
147
+
148
+ # Layer 3: new
149
+ cid = _canonical_id(item["name"], h, kind)
150
+ item["canonical_id"] = cid
151
+ item["sources"] = [item.get("source_file", "")]
152
+ item["layers"] = []
153
+ store[cid] = item
154
+ return "added"
155
+
156
+ def _find_similar(self, item: dict, store: dict, kind: str) -> Optional[str]:
157
+ if not store:
158
+ return None
159
+ item_text = _item_text(item, kind)
160
+ store_items = list(store.items())
161
+ texts = [_item_text(v, kind) for _, v in store_items] + [item_text]
162
+ try:
163
+ mat = self._vec.fit_transform(texts)
164
+ sims = cosine_similarity(mat[-1], mat[:-1])[0]
165
+ idx = int(np.argmax(sims))
166
+ if sims[idx] >= self.threshold:
167
+ return store_items[idx][0]
168
+ except Exception:
169
+ pass
170
+ return None
171
+
172
+ @staticmethod
173
+ def _add_source(item: dict, existing: dict):
174
+ src = item.get("source_file", "")
175
+ if src and src not in existing.get("sources", []):
176
+ existing.setdefault("sources", []).append(src)
177
+
178
+ @staticmethod
179
+ def _merge_layer(item: dict, existing: dict):
180
+ Deduplicator._add_source(item, existing)
181
+ layers = existing.setdefault("layers", [])
182
+ hashes = {l.get("content_hash") for l in layers}
183
+ if item.get("content_hash") not in hashes:
184
+ layers.append({
185
+ "source_file": item.get("source_file"),
186
+ "source_pages": item.get("source_pages"),
187
+ "content_hash": item.get("content_hash"),
188
+ "data": {k: v for k, v in item.items()
189
+ if k not in ("sources","layers","canonical_id")},
190
+ })
191
+
192
+
193
+ # ── Helpers ───────────────────────────────────────────
194
+
195
+ def _hash(text: str) -> str:
196
+ return hashlib.sha256(" ".join(text.lower().split()).encode()).hexdigest()[:16]
197
+
198
+ def _canonical_id(name: str, h: str, kind: str) -> str:
199
+ return hashlib.md5(f"{kind}_{name}_{h}".encode()).hexdigest()[:12]
200
+
201
+ def _item_text(item: dict, kind: str) -> str:
202
+ if kind == "strategies":
203
+ return f"{item.get('name','')} {item.get('description','')} {' '.join(item.get('entry_rules',[]))}"
204
+ if kind == "formulas":
205
+ return f"{item.get('name','')} {item.get('plain_text','')} {item.get('purpose','')}"
206
+ return f"{item.get('name','')} {item.get('entry_system','')} {item.get('exit_system','')}"
pipeline/indicators.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/indicators.py
3
+ Vectorized technical indicator library used by compiled strategies.
4
+ All functions return pd.Series aligned to input index.
5
+ """
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+
10
+ def sma(s, n): return s.rolling(n, min_periods=n).mean()
11
+ def ema(s, n): return s.ewm(span=n, min_periods=n, adjust=False).mean()
12
+ def std_dev(s, n): return s.rolling(n, min_periods=n).std()
13
+ def highest(s, n): return s.rolling(n, min_periods=n).max()
14
+ def lowest(s, n): return s.rolling(n, min_periods=n).min()
15
+ def momentum(s, n): return s - s.shift(n)
16
+ def roc(s, n): return 100 * (s - s.shift(n)) / s.shift(n).replace(0, np.nan)
17
+
18
+ def wma(s, n):
19
+ w = np.arange(1, n + 1, dtype=float)
20
+ return s.rolling(n, min_periods=n).apply(lambda x: np.dot(x, w) / w.sum(), raw=True)
21
+
22
+ def tema(s, n):
23
+ e1 = ema(s, n); e2 = ema(e1, n); e3 = ema(e2, n)
24
+ return 3*e1 - 3*e2 + e3
25
+
26
+ def rsi(s, n=14):
27
+ d = s.diff()
28
+ g = d.clip(lower=0); l = (-d).clip(lower=0)
29
+ ag = g.ewm(com=n-1, min_periods=n, adjust=False).mean()
30
+ al = l.ewm(com=n-1, min_periods=n, adjust=False).mean()
31
+ return 100 - 100/(1 + ag/al.replace(0, np.nan))
32
+
33
+ def macd(s, fast=12, slow=26, sig=9):
34
+ ml = ema(s, fast) - ema(s, slow)
35
+ sl = ema(ml, sig)
36
+ return ml, sl, ml - sl
37
+
38
+ def stoch(h, l, c, k=14, d=3):
39
+ ll = l.rolling(k, min_periods=k).min()
40
+ hh = h.rolling(k, min_periods=k).max()
41
+ K = 100 * (c - ll) / (hh - ll).replace(0, np.nan)
42
+ return K, K.rolling(d, min_periods=d).mean()
43
+
44
+ def atr(h, l, c, n=14):
45
+ tr = pd.concat([h-l, (h-c.shift()).abs(), (l-c.shift()).abs()], axis=1).max(axis=1)
46
+ return tr.ewm(com=n-1, min_periods=n, adjust=False).mean()
47
+
48
+ def bbands(s, n=20, k=2.0):
49
+ mid = sma(s, n); sd = std_dev(s, n)
50
+ return mid + k*sd, mid, mid - k*sd
51
+
52
+ def keltner(h, l, c, n=20, k=2.0):
53
+ mid = ema(c, n); a = atr(h, l, c, n)
54
+ return mid + k*a, mid, mid - k*a
55
+
56
+ def donchian(h, l, n=20):
57
+ u = h.rolling(n, min_periods=n).max()
58
+ lo = l.rolling(n, min_periods=n).min()
59
+ return u, (u+lo)/2, lo
60
+
61
+ def adx(h, l, c, n=14):
62
+ tr = atr(h, l, c, 1)
63
+ up = h - h.shift(); dn = l.shift() - l
64
+ pdm = up.where((up>dn)&(up>0), 0.0)
65
+ ndm = dn.where((dn>up)&(dn>0), 0.0)
66
+ sm = tr.ewm(com=n-1, min_periods=n, adjust=False).mean()
67
+ pdi = 100*pdm.ewm(com=n-1, min_periods=n, adjust=False).mean()/sm.replace(0,np.nan)
68
+ ndi = 100*ndm.ewm(com=n-1, min_periods=n, adjust=False).mean()/sm.replace(0,np.nan)
69
+ dx = 100*(pdi-ndi).abs()/(pdi+ndi).replace(0,np.nan)
70
+ return dx.ewm(com=n-1, min_periods=n, adjust=False).mean(), pdi, ndi
71
+
72
+ def cci(h, l, c, n=20):
73
+ tp = (h+l+c)/3; s = sma(tp, n)
74
+ md = tp.rolling(n).apply(lambda x: np.abs(x-x.mean()).mean(), raw=True)
75
+ return (tp-s)/(0.015*md.replace(0,np.nan))
76
+
77
+ def williams_r(h, l, c, n=14):
78
+ hh = h.rolling(n, min_periods=n).max()
79
+ ll = l.rolling(n, min_periods=n).min()
80
+ return -100*(hh-c)/(hh-ll).replace(0,np.nan)
81
+
82
+ def vwap(h, l, c, v):
83
+ tp = (h+l+c)/3
84
+ return (tp*v).cumsum()/v.cumsum().replace(0,np.nan)
85
+
86
+ def obv(c, v):
87
+ return (np.sign(c.diff())*v).cumsum()
88
+
89
+ def zscore(s, n=20):
90
+ return (s - sma(s, n))/std_dev(s, n).replace(0,np.nan)
91
+
92
+ def crossover(a, b):
93
+ return (a > b) & (a.shift() <= b.shift())
94
+
95
+ def crossunder(a, b):
96
+ return (a < b) & (a.shift() >= b.shift())
pipeline/pdf_processor.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/pdf_processor.py
3
+ Extract text from PDFs. OCR fallback for scanned books.
4
+ Runs entirely in HF Spaces /tmp β€” no local disk needed.
5
+ """
6
+
7
+ import re
8
+ import io
9
+ from pathlib import Path
10
+ from typing import Generator
11
+ from dataclasses import dataclass
12
+ import fitz # PyMuPDF
13
+ import pdfplumber
14
+ import pytesseract
15
+ from pdf2image import convert_from_path
16
+ from loguru import logger
17
+ import utils.config as cfg
18
+
19
+
20
+ @dataclass
21
+ class Chunk:
22
+ source_file: str
23
+ page_start: int
24
+ page_end: int
25
+ text: str
26
+ was_ocr: bool = False
27
+
28
+ @property
29
+ def word_count(self) -> int:
30
+ return len(self.text.split())
31
+
32
+
33
+ class PDFProcessor:
34
+ MIN_CHARS = 80 # chars per page to skip OCR
35
+ CHUNK_WORDS = cfg.MAX_TOKENS_PER_CHUNK // 2
36
+
37
+ def process(self, pdf_path: Path) -> Generator[Chunk, None, None]:
38
+ """Yield text chunks from one PDF."""
39
+ pages = self._extract_pages(pdf_path)
40
+ yield from self._chunk(pages, pdf_path.name)
41
+
42
+ # ── Extraction ────────────────────────────────────
43
+
44
+ def _extract_pages(self, path: Path) -> list[tuple[int, str, bool]]:
45
+ """Returns [(page_num, text, was_ocr), ...]"""
46
+ results = []
47
+
48
+ # Pass 1: PyMuPDF (fastest)
49
+ mupdf_text = {}
50
+ try:
51
+ doc = fitz.open(str(path))
52
+ for i, page in enumerate(doc):
53
+ mupdf_text[i + 1] = self._clean(page.get_text("text"))
54
+ doc.close()
55
+ except Exception as e:
56
+ logger.warning(f"PyMuPDF failed {path.name}: {e}")
57
+
58
+ # Pass 2: pdfplumber (better table/layout handling)
59
+ plumber_text = {}
60
+ try:
61
+ with pdfplumber.open(str(path)) as pdf:
62
+ for i, page in enumerate(pdf.pages):
63
+ try:
64
+ plumber_text[i + 1] = self._clean(page.extract_text() or "")
65
+ except Exception:
66
+ plumber_text[i + 1] = ""
67
+ except Exception as e:
68
+ logger.warning(f"pdfplumber failed {path.name}: {e}")
69
+
70
+ total = max(len(mupdf_text), len(plumber_text), 1)
71
+ ocr_needed = []
72
+
73
+ for pnum in range(1, total + 1):
74
+ a = mupdf_text.get(pnum, "")
75
+ b = plumber_text.get(pnum, "")
76
+ best = a if len(a) > len(b) else b
77
+ if len(best) >= self.MIN_CHARS:
78
+ results.append((pnum, best, False))
79
+ else:
80
+ results.append((pnum, best, False)) # placeholder
81
+ ocr_needed.append(pnum)
82
+
83
+ # Pass 3: OCR for scanned pages
84
+ if ocr_needed:
85
+ logger.info(f" OCR: {len(ocr_needed)} pages in {path.name}")
86
+ ocr = self._ocr_pages(path, ocr_needed)
87
+ for i, (pnum, text, _) in enumerate(results):
88
+ if pnum in ocr:
89
+ results[i] = (pnum, ocr[pnum], True)
90
+
91
+ return results
92
+
93
+ def _ocr_pages(self, path: Path, pages: list[int]) -> dict[int, str]:
94
+ out = {}
95
+ try:
96
+ images = convert_from_path(
97
+ str(path), dpi=cfg.OCR_DPI,
98
+ first_page=min(pages), last_page=max(pages),
99
+ )
100
+ for i, pnum in enumerate(range(min(pages), max(pages) + 1)):
101
+ if pnum in pages and i < len(images):
102
+ text = pytesseract.image_to_string(images[i], lang="eng", config="--psm 6")
103
+ out[pnum] = self._clean(text)
104
+ except Exception as e:
105
+ logger.warning(f"OCR error: {e}")
106
+ return out
107
+
108
+ def _chunk(self, pages: list[tuple[int, str, bool]], source: str) -> Generator[Chunk, None, None]:
109
+ buf, words, p_start, any_ocr = [], 0, 1, False
110
+ for pnum, text, ocr in pages:
111
+ if not text:
112
+ continue
113
+ buf.append(text)
114
+ words += len(text.split())
115
+ if ocr:
116
+ any_ocr = True
117
+ if words >= self.CHUNK_WORDS:
118
+ yield Chunk(source, p_start, pnum, "\n\n".join(buf), any_ocr)
119
+ buf, words, p_start, any_ocr = [text], len(text.split()), pnum, ocr
120
+ if buf:
121
+ last = pages[-1][0] if pages else p_start
122
+ yield Chunk(source, p_start, last, "\n\n".join(buf), any_ocr)
123
+
124
+ @staticmethod
125
+ def _clean(text: str) -> str:
126
+ if not text:
127
+ return ""
128
+ text = re.sub(r"(\w)-\n(\w)", r"\1\2", text)
129
+ text = re.sub(r"\n{3,}", "\n\n", text)
130
+ text = re.sub(r"[ \t]+", " ", text)
131
+ text = re.sub(r"\x00", "", text)
132
+ return text.strip()
requirements.txt ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HF Spaces base image forces huggingface-hub>=0.30, so Gradio 4.x is impossible
2
+ # (HfFolder removed in hub 0.24). Must use Gradio 5.x.
3
+ # The dict-schema bug (TypeError: 'bool' not iterable) was fixed in gradio 5.13.0.
4
+ # Do NOT pin huggingface_hub or gradio_client - let HF Spaces resolve them.
5
+ gradio>=5.13.0
6
+
7
+ anthropic>=0.30.0
8
+
9
+ # PDF processing
10
+ pypdf>=4.2.0
11
+ pdfplumber>=0.11.0
12
+ pymupdf>=1.24.0
13
+ pdf2image>=1.17.0
14
+ pytesseract>=0.3.10
15
+ Pillow>=10.3.0
16
+
17
+ # Data
18
+ numpy>=1.26.0
19
+ pandas>=2.2.0
20
+ pyarrow>=16.0.0
21
+ scikit-learn>=1.5.0
22
+
23
+ # Utilities
24
+ python-dotenv>=1.0.0
25
+ loguru>=0.7.2
26
+ pydantic>=2.7.0
27
+ tqdm>=4.66.0
utils/__init__.py ADDED
File without changes
utils/config.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ utils/config.py
3
+ All config is read from HuggingFace Spaces Secrets (environment variables).
4
+ Set these in: Space Settings β†’ Variables and Secrets
5
+ No .env file needed β€” Spaces injects secrets automatically.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ # ── HF Spaces uses /tmp for ephemeral working storage ─
12
+ # All persistent data is written to HF Dataset via API.
13
+ TMP = Path("/tmp/quant")
14
+ TMP_PDFS = TMP / "pdfs"
15
+ TMP_CHUNKS = TMP / "chunks"
16
+ TMP_COMPILED = TMP / "compiled"
17
+ TMP_BACKTESTS = TMP / "backtests"
18
+ TMP_EXPORTS = TMP / "exports"
19
+ TMP_TICK_CACHE = TMP / "tick_cache"
20
+ TMP_KB = TMP / "knowledge_base.jsonl"
21
+
22
+ for d in [TMP_PDFS, TMP_CHUNKS, TMP_COMPILED,
23
+ TMP_BACKTESTS, TMP_EXPORTS, TMP_TICK_CACHE]:
24
+ d.mkdir(parents=True, exist_ok=True)
25
+
26
+
27
+ def get(key: str, default: str = "") -> str:
28
+ return os.environ.get(key, default)
29
+
30
+
31
+ # ── API Keys (set in HF Space Secrets) ────────────────
32
+ ANTHROPIC_API_KEY = get("ANTHROPIC_API_KEY")
33
+ HF_TOKEN = get("HF_TOKEN")
34
+
35
+ # ── HuggingFace Dataset repos ─────────────────────────
36
+ HF_DATASET_REPO = get("HF_DATASET_REPO") # knowledge base + results
37
+ HF_TICK_REPO = get("HF_TICK_REPO") # your tick data
38
+
39
+ # ── Extraction settings ───────────────────────────────
40
+ SIMILARITY_THRESHOLD = float(get("SIMILARITY_THRESHOLD", "0.85"))
41
+ MAX_TOKENS_PER_CHUNK = int(get("MAX_TOKENS_PER_CHUNK", "3000"))
42
+ OCR_DPI = int(get("OCR_DPI", "300"))
43
+
44
+ # ── Backtest settings ─────────────────────────────────
45
+ INITIAL_EQUITY = float(get("INITIAL_EQUITY", "10000"))
46
+ COMMISSION_PCT = float(get("COMMISSION_PCT", "0.0002"))
47
+ SLIPPAGE_PCT = float(get("SLIPPAGE_PCT", "0.0001"))
48
+ RISK_PER_TRADE = float(get("RISK_PER_TRADE", "0.01"))
49
+ WF_WINDOWS = int(get("WF_WINDOWS", "5"))
50
+ WF_IS_RATIO = float(get("WF_IS_RATIO", "0.70"))
51
+ MAX_PARAM_COMBOS = int(get("MAX_PARAM_COMBOS", "300"))
52
+ MIN_TRADES = int(get("MIN_TRADES", "30"))
53
+ MIN_SHARPE = float(get("MIN_SHARPE", "0.5"))
54
+ BACKTEST_TIMEFRAMES = get("BACKTEST_TIMEFRAMES", "1h,4h,1d").split(",")
55
+
56
+ # ── Strategy categories ───────────────────────────────
57
+ CATEGORIES = [
58
+ "Trend Following", "Mean Reversion", "Statistical Arbitrage",
59
+ "Momentum", "Breakout", "Volatility Trading", "Market Making",
60
+ "Pattern Recognition", "Machine Learning", "Options Strategy",
61
+ "High Frequency", "Pairs Trading", "Carry Trade",
62
+ "Seasonal / Calendar", "Risk Management", "Position Sizing",
63
+ "Portfolio Construction", "Market Microstructure", "Other",
64
+ ]
65
+
66
+ # ── Extraction prompt ─────────────────────────────────
67
+ EXTRACTION_PROMPT = """
68
+ You are a quantitative finance knowledge extraction engine.
69
+
70
+ Extract ALL trading strategies, mathematical formulas, and complete trading systems
71
+ from the text chunk below, taken from an algorithmic trading book.
72
+
73
+ Output ONLY valid JSON β€” no markdown fences, no preamble:
74
+ {
75
+ "strategies": [{
76
+ "name": "string",
77
+ "category": "string",
78
+ "description": "string",
79
+ "entry_rules": ["string"],
80
+ "exit_rules": ["string"],
81
+ "filters": ["string"],
82
+ "timeframes": ["string"],
83
+ "instruments": ["string"],
84
+ "parameters": {"name": "description with typical value"},
85
+ "mathematical_basis": "string",
86
+ "source_context": "brief quote from text"
87
+ }],
88
+ "formulas": [{
89
+ "name": "string",
90
+ "category": "string",
91
+ "latex": "LaTeX string",
92
+ "plain_text": "string",
93
+ "variables": {"symbol": "description"},
94
+ "purpose": "string",
95
+ "usage_context": "string",
96
+ "source_context": "string"
97
+ }],
98
+ "systems": [{
99
+ "name": "string",
100
+ "components": ["string"],
101
+ "entry_system": "string",
102
+ "exit_system": "string",
103
+ "risk_management": "string",
104
+ "position_sizing": "string",
105
+ "backtesting_notes": "string",
106
+ "source_context": "string"
107
+ }]
108
+ }
109
+
110
+ Rules:
111
+ - Return empty arrays [] if nothing found for a category
112
+ - Never paraphrase formulas β€” preserve exact mathematical form
113
+ - Include LaTeX for every formula that has a standard form
114
+ - Be exhaustive β€” extract even briefly mentioned concepts
115
+
116
+ Source: {source_file} | Pages: {page_start}–{page_end}
117
+ --- TEXT ---
118
+ {text}
119
+ --- END ---
120
+ """.strip()
121
+
122
+ # ── Strategy compiler prompt ──────────────────────────
123
+ COMPILER_PROMPT = """
124
+ You are a Python algorithmic trading code generator.
125
+
126
+ Convert the strategy JSON below into a Python module.
127
+ Output ONLY valid Python β€” no markdown, no explanation.
128
+
129
+ Required functions:
130
+
131
+ def get_parameter_grid() -> dict:
132
+ # Returns {param_name: [list_of_values_to_try]}
133
+ # Provide 3-5 values per parameter covering a reasonable range
134
+ ...
135
+
136
+ def generate_signals(df: pd.DataFrame, params: dict) -> pd.Series:
137
+ # df columns: open, high, low, close, volume (DatetimeIndex, UTC)
138
+ # Returns pd.Series of int: 1=long, -1=short, 0=flat, same index as df
139
+ # Use ONLY: numpy as np, pandas as pd, indicators module
140
+ # Available in indicators: sma, ema, rsi, atr, bbands, macd,
141
+ # stoch, adx, donchian, keltner, vwap, obv, cci, williams_r,
142
+ # momentum, roc, std_dev, zscore, crossover, crossunder
143
+ ...
144
+
145
+ Allowed imports: numpy as np, pandas as pd, indicators (pre-imported)
146
+ No file I/O. No network. No print statements.
147
+
148
+ Strategy:
149
+ {strategy_json}
150
+ """.strip()
utils/hf_io.py ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ utils/hf_io.py
3
+ ─────────────────────────────────────────────────────
4
+ Single module for all HuggingFace Hub I/O.
5
+
6
+ The HF dataset repo is the persistent store for everything:
7
+ β”œβ”€β”€ pdfs/ uploaded source ebooks
8
+ β”œβ”€β”€ knowledge_base.jsonl deduplicated strategies/formulas
9
+ β”œβ”€β”€ extracted/ individual markdown files
10
+ β”œβ”€β”€ backtests/ per-strategy backtest reports
11
+ └── optimal_sets/ .set, .json, .jl files
12
+
13
+ The HF tick repo is read-only from this Space:
14
+ └── {SYMBOL}/ticks.parquet or {SYMBOL}/{tf}.parquet
15
+ """
16
+
17
+ import json
18
+ import io
19
+ from pathlib import Path
20
+ from typing import Optional
21
+ import pandas as pd
22
+ from huggingface_hub import (
23
+ HfApi, hf_hub_download, list_repo_files, CommitOperationAdd
24
+ )
25
+ from loguru import logger
26
+ import utils.config as cfg
27
+
28
+
29
+ def _api() -> HfApi:
30
+ return HfApi(token=cfg.HF_TOKEN)
31
+
32
+
33
+ # ═══════════════════════════════════════════════════
34
+ # KNOWLEDGE BASE β€” read / write
35
+ # ═══════════════════════════════════════════════════
36
+
37
+ def kb_load() -> dict:
38
+ """
39
+ Download knowledge_base.jsonl from HF dataset.
40
+ Returns {"strategies": [...], "formulas": [...], "systems": [...]}.
41
+ """
42
+ empty = {"strategies": {}, "formulas": {}, "systems": {}}
43
+ if not cfg.HF_DATASET_REPO:
44
+ return empty
45
+ try:
46
+ path = hf_hub_download(
47
+ repo_id=cfg.HF_DATASET_REPO,
48
+ filename="knowledge_base.jsonl",
49
+ repo_type="dataset",
50
+ token=cfg.HF_TOKEN,
51
+ local_dir=str(cfg.TMP),
52
+ force_download=True,
53
+ )
54
+ result = {"strategies": {}, "formulas": {}, "systems": {}}
55
+ with open(path, encoding="utf-8") as f:
56
+ for line in f:
57
+ line = line.strip()
58
+ if not line:
59
+ continue
60
+ rec = json.loads(line)
61
+ kind = rec.get("_type", "")
62
+ cid = rec.get("canonical_id", "")
63
+ if kind in result and cid:
64
+ result[kind][cid] = rec
65
+ logger.info(
66
+ f"KB loaded: {len(result['strategies'])} strategies, "
67
+ f"{len(result['formulas'])} formulas, {len(result['systems'])} systems"
68
+ )
69
+ return result
70
+ except Exception as e:
71
+ logger.warning(f"KB load (may not exist yet): {e}")
72
+ return empty
73
+
74
+
75
+ def kb_save(kb: dict) -> bool:
76
+ """Push knowledge base JSONL to HF dataset."""
77
+ if not cfg.HF_DATASET_REPO:
78
+ return False
79
+ try:
80
+ lines = []
81
+ for kind in ("strategies", "formulas", "systems"):
82
+ for rec in kb[kind].values():
83
+ lines.append(json.dumps({**rec, "_type": kind}))
84
+ content = "\n".join(lines).encode("utf-8")
85
+ _api().upload_file(
86
+ path_or_fileobj=io.BytesIO(content),
87
+ path_in_repo="knowledge_base.jsonl",
88
+ repo_id=cfg.HF_DATASET_REPO,
89
+ repo_type="dataset",
90
+ commit_message="Update knowledge base",
91
+ )
92
+ logger.info(f"KB saved: {len(lines)} records")
93
+ return True
94
+ except Exception as e:
95
+ logger.error(f"KB save failed: {e}")
96
+ return False
97
+
98
+
99
+ # ═══════════════════════════════════════════════════
100
+ # PDF β€” upload
101
+ # ═══════════════════════════════════════════════════
102
+
103
+ def pdf_upload(local_path: Path) -> str:
104
+ """Upload a PDF to the HF dataset. Returns URL or ''."""
105
+ if not cfg.HF_DATASET_REPO:
106
+ return ""
107
+ try:
108
+ url = _api().upload_file(
109
+ path_or_fileobj=str(local_path),
110
+ path_in_repo=f"pdfs/{local_path.name}",
111
+ repo_id=cfg.HF_DATASET_REPO,
112
+ repo_type="dataset",
113
+ commit_message=f"Add PDF: {local_path.name}",
114
+ )
115
+ return str(url)
116
+ except Exception as e:
117
+ logger.warning(f"PDF upload failed {local_path.name}: {e}")
118
+ return ""
119
+
120
+
121
+ def pdf_list() -> list[str]:
122
+ """List all PDFs stored in HF dataset."""
123
+ if not cfg.HF_DATASET_REPO:
124
+ return []
125
+ try:
126
+ return [
127
+ f for f in list_repo_files(
128
+ repo_id=cfg.HF_DATASET_REPO,
129
+ repo_type="dataset",
130
+ token=cfg.HF_TOKEN,
131
+ )
132
+ if f.startswith("pdfs/") and f.endswith(".pdf")
133
+ ]
134
+ except Exception as e:
135
+ logger.warning(f"PDF list failed: {e}")
136
+ return []
137
+
138
+
139
+ # ═════════════════════════���═════════════════════════
140
+ # EXTRACTED MARKDOWN β€” push
141
+ # ═══════════════════════════════════════════════════
142
+
143
+ def push_markdown(content: str, remote_path: str) -> bool:
144
+ """Push a single markdown file to HF dataset."""
145
+ if not cfg.HF_DATASET_REPO:
146
+ return False
147
+ try:
148
+ _api().upload_file(
149
+ path_or_fileobj=io.BytesIO(content.encode("utf-8")),
150
+ path_in_repo=remote_path,
151
+ repo_id=cfg.HF_DATASET_REPO,
152
+ repo_type="dataset",
153
+ commit_message=f"Add: {remote_path}",
154
+ )
155
+ return True
156
+ except Exception as e:
157
+ logger.debug(f"MD push failed {remote_path}: {e}")
158
+ return False
159
+
160
+
161
+ def push_batch(files: list[tuple[str, bytes]], commit_msg: str = "Batch update") -> int:
162
+ """
163
+ Push multiple files in a single commit.
164
+ files: list of (remote_path, bytes_content)
165
+ Returns number of files successfully pushed.
166
+ """
167
+ if not cfg.HF_DATASET_REPO or not files:
168
+ return 0
169
+ ops = [
170
+ CommitOperationAdd(
171
+ path_in_repo=remote_path,
172
+ path_or_fileobj=io.BytesIO(content),
173
+ )
174
+ for remote_path, content in files
175
+ ]
176
+ # HF allows max 100 ops per commit
177
+ pushed = 0
178
+ for i in range(0, len(ops), 100):
179
+ batch = ops[i:i+100]
180
+ try:
181
+ _api().create_commit(
182
+ repo_id=cfg.HF_DATASET_REPO,
183
+ repo_type="dataset",
184
+ operations=batch,
185
+ commit_message=f"{commit_msg} [{i+1}–{i+len(batch)}]",
186
+ )
187
+ pushed += len(batch)
188
+ except Exception as e:
189
+ logger.error(f"Batch push failed: {e}")
190
+ return pushed
191
+
192
+
193
+ # ═══════════════════════════════════════════════════
194
+ # TICK DATA β€” read from HF tick repo
195
+ # ═══════════════════════════════════════════════════
196
+
197
+ def tick_list_symbols() -> list[str]:
198
+ """List all symbols in the tick dataset."""
199
+ if not cfg.HF_TICK_REPO:
200
+ return []
201
+ try:
202
+ files = list(list_repo_files(
203
+ repo_id=cfg.HF_TICK_REPO,
204
+ repo_type="dataset",
205
+ token=cfg.HF_TOKEN,
206
+ ))
207
+ seen = set()
208
+ symbols = []
209
+ for f in files:
210
+ parts = f.split("/")
211
+ if len(parts) >= 2 and parts[0] not in seen:
212
+ seen.add(parts[0])
213
+ symbols.append(parts[0])
214
+ return sorted(symbols)
215
+ except Exception as e:
216
+ logger.warning(f"Tick symbol list failed: {e}")
217
+ return []
218
+
219
+
220
+ def tick_load(symbol: str, timeframe: str = "1h") -> Optional[pd.DataFrame]:
221
+ """
222
+ Load OHLCV data for a symbol at given timeframe.
223
+ Downloads from HF tick repo, caches locally in /tmp.
224
+
225
+ Looks for (in order):
226
+ {symbol}/{timeframe}.parquet
227
+ {symbol}/ticks.parquet β†’ then resamples to timeframe
228
+ {symbol}/data.parquet
229
+ """
230
+ cache_path = cfg.TMP_TICK_CACHE / f"{symbol}_{timeframe}.parquet"
231
+ if cache_path.exists():
232
+ return pd.read_parquet(cache_path)
233
+
234
+ if not cfg.HF_TICK_REPO:
235
+ return None
236
+
237
+ # Try pre-built timeframe file
238
+ for fname in [f"{timeframe}.parquet", f"{timeframe}.csv"]:
239
+ df = _try_download_tick(symbol, fname)
240
+ if df is not None:
241
+ df = _normalize_ohlcv(df)
242
+ if df is not None:
243
+ df.to_parquet(cache_path)
244
+ return df
245
+
246
+ # Fall back to ticks + resample
247
+ for fname in ["ticks.parquet", "data.parquet", "ticks.csv"]:
248
+ ticks = _try_download_tick(symbol, fname)
249
+ if ticks is not None:
250
+ df = _resample(ticks, timeframe)
251
+ if df is not None and not df.empty:
252
+ df.to_parquet(cache_path)
253
+ return df
254
+
255
+ logger.warning(f"No data found for {symbol} in tick repo")
256
+ return None
257
+
258
+
259
+ def _try_download_tick(symbol: str, filename: str) -> Optional[pd.DataFrame]:
260
+ try:
261
+ local = cfg.TMP_TICK_CACHE / symbol
262
+ local.mkdir(parents=True, exist_ok=True)
263
+ path = hf_hub_download(
264
+ repo_id=cfg.HF_TICK_REPO,
265
+ filename=f"{symbol}/{filename}",
266
+ repo_type="dataset",
267
+ token=cfg.HF_TOKEN,
268
+ local_dir=str(local),
269
+ force_download=False,
270
+ )
271
+ if filename.endswith(".parquet"):
272
+ return pd.read_parquet(path)
273
+ return pd.read_csv(path)
274
+ except Exception:
275
+ return None
276
+
277
+
278
+ _RESAMPLE_MAP = {
279
+ "1m":"1min","3m":"3min","5m":"5min","15m":"15min","30m":"30min",
280
+ "1h":"1h","2h":"2h","4h":"4h","6h":"6h","12h":"12h",
281
+ "1d":"1D","1w":"1W",
282
+ }
283
+
284
+
285
+ def _resample(ticks: pd.DataFrame, timeframe: str) -> Optional[pd.DataFrame]:
286
+ """Resample raw tick data to OHLCV."""
287
+ import numpy as np
288
+ ticks = ticks.copy()
289
+
290
+ # Parse timestamp
291
+ ts_col = next((c for c in ticks.columns if "time" in c.lower() or "date" in c.lower()), None)
292
+ if ts_col:
293
+ ticks.index = pd.to_datetime(ticks[ts_col], utc=True)
294
+ ticks = ticks.drop(columns=[ts_col])
295
+ else:
296
+ try:
297
+ ticks.index = pd.to_datetime(ticks.index, utc=True)
298
+ except Exception:
299
+ return None
300
+
301
+ ticks.index = ticks.index.tz_convert("UTC") if ticks.index.tz else ticks.index.tz_localize("UTC")
302
+
303
+ # Derive price
304
+ if "bid" in ticks.columns and "ask" in ticks.columns:
305
+ ticks["_price"] = (ticks["bid"] + ticks["ask"]) / 2
306
+ elif "price" in ticks.columns:
307
+ ticks["_price"] = ticks["price"]
308
+ elif "close" in ticks.columns:
309
+ ticks["_price"] = ticks["close"]
310
+ else:
311
+ return None
312
+
313
+ rule = _RESAMPLE_MAP.get(timeframe, "1h")
314
+ ohlcv = ticks["_price"].resample(rule).ohlc()
315
+ ohlcv.columns = ["open", "high", "low", "close"]
316
+
317
+ vol_col = next((c for c in ticks.columns if "vol" in c.lower() or "size" in c.lower()), None)
318
+ ohlcv["volume"] = ticks[vol_col].resample(rule).sum() if vol_col else ticks["_price"].resample(rule).count()
319
+ return ohlcv.dropna()
320
+
321
+
322
+ def _normalize_ohlcv(df: pd.DataFrame) -> Optional[pd.DataFrame]:
323
+ """Normalize column names and index for any OHLCV DataFrame."""
324
+ df = df.copy()
325
+
326
+ # Parse index
327
+ ts_col = next((c for c in df.columns if "time" in c.lower() or "date" in c.lower()), None)
328
+ if ts_col:
329
+ df.index = pd.to_datetime(df[ts_col], utc=True)
330
+ df = df.drop(columns=[ts_col])
331
+ else:
332
+ try:
333
+ df.index = pd.to_datetime(df.index, utc=True)
334
+ except Exception:
335
+ return None
336
+
337
+ df.index = df.index.tz_convert("UTC") if df.index.tz else df.index.tz_localize("UTC")
338
+ df.index.name = "timestamp"
339
+ df = df.sort_index()
340
+
341
+ # Normalize column names
342
+ renames = {}
343
+ for c in df.columns:
344
+ lc = c.lower().strip()
345
+ if lc in ("o", "open"): renames[c] = "open"
346
+ elif lc in ("h", "high"): renames[c] = "high"
347
+ elif lc in ("l", "low"): renames[c] = "low"
348
+ elif lc in ("c", "close"): renames[c] = "close"
349
+ elif lc in ("v","vol","volume","tick_volume"): renames[c] = "volume"
350
+ df = df.rename(columns=renames)
351
+
352
+ for col in ["open","high","low","close"]:
353
+ if col not in df.columns:
354
+ return None
355
+ if "volume" not in df.columns:
356
+ df["volume"] = 0.0
357
+
358
+ df = df[["open","high","low","close","volume"]].astype(float)
359
+ df = df.dropna(subset=["open","high","low","close"])
360
+
361
+ # Fix inverted high/low
362
+ bad = df["high"] < df["low"]
363
+ if bad.any():
364
+ df.loc[bad, ["high","low"]] = df.loc[bad, ["low","high"]].values
365
+
366
+ return df
367
+
368
+
369
+ # ═══════════════════════════════════════════════════
370
+ # BACKTEST RESULTS β€” push
371
+ # ═══════════════════════════════════════════════════
372
+
373
+ def push_backtest_result(
374
+ strategy_name: str,
375
+ symbol: str,
376
+ timeframe: str,
377
+ report_md: str,
378
+ optimal_json: dict,
379
+ mt5_set: str,
380
+ julia_cfg: str,
381
+ ) -> bool:
382
+ """Push all output files for one backtest result to HF dataset."""
383
+ from pipeline.exporter import slugify
384
+ slug = slugify(strategy_name)
385
+ prefix = f"{slug}_{symbol}_{timeframe}"
386
+
387
+ files = [
388
+ (f"backtests/{slug}/{prefix}_report.md", report_md.encode()),
389
+ (f"optimal_sets/{prefix}_optimal.json", json.dumps(optimal_json, indent=2).encode()),
390
+ (f"optimal_sets/{prefix}.set", mt5_set.encode()),
391
+ (f"optimal_sets/{prefix}_config.jl", julia_cfg.encode()),
392
+ ]
393
+ pushed = push_batch(files, commit_msg=f"Backtest: {strategy_name} {symbol} {timeframe}")
394
+ return pushed == len(files)
395
+
396
+
397
+ def push_backtest_index(index_md: str, index_json: dict) -> bool:
398
+ """Push master index files."""
399
+ files = [
400
+ ("optimal_sets/BACKTEST_INDEX.md", index_md.encode()),
401
+ ("optimal_sets/backtest_index.json", json.dumps(index_json, indent=2).encode()),
402
+ ]
403
+ return push_batch(files, "Update backtest index") == 2
404
+
405
+
406
+ def fetch_backtest_index() -> dict:
407
+ """Download backtest index JSON from HF dataset."""
408
+ if not cfg.HF_DATASET_REPO:
409
+ return {}
410
+ try:
411
+ path = hf_hub_download(
412
+ repo_id=cfg.HF_DATASET_REPO,
413
+ filename="optimal_sets/backtest_index.json",
414
+ repo_type="dataset",
415
+ token=cfg.HF_TOKEN,
416
+ local_dir=str(cfg.TMP),
417
+ force_download=True,
418
+ )
419
+ return json.loads(Path(path).read_text())
420
+ except Exception:
421
+ return {}
422
+
423
+
424
+ def fetch_file(remote_path: str) -> Optional[bytes]:
425
+ """Download any file from HF dataset as bytes."""
426
+ if not cfg.HF_DATASET_REPO:
427
+ return None
428
+ try:
429
+ path = hf_hub_download(
430
+ repo_id=cfg.HF_DATASET_REPO,
431
+ filename=remote_path,
432
+ repo_type="dataset",
433
+ token=cfg.HF_TOKEN,
434
+ local_dir=str(cfg.TMP / "downloads"),
435
+ force_download=True,
436
+ )
437
+ return Path(path).read_bytes()
438
+ except Exception as e:
439
+ logger.debug(f"Fetch {remote_path}: {e}")
440
+ return None