cyberkyne commited on
Commit
de90e29
Β·
verified Β·
1 Parent(s): 30e3992

Upload 13 files

Browse files
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: πŸ“Š
4
  colorFrom: green
5
  colorTo: gray
6
  sdk: gradio
7
- sdk_version: 4.44.0
8
  app_file: app.py
9
  pinned: true
10
  license: mit
 
4
  colorFrom: green
5
  colorTo: gray
6
  sdk: gradio
7
+ sdk_version: 5.9.1
8
  app_file: app.py
9
  pinned: true
10
  license: mit
app.py CHANGED
@@ -59,7 +59,7 @@ def refresh_kb():
59
  # TAB 1 β€” UPLOAD & EXTRACT
60
  # ═══════════════════════════════════════════════════
61
 
62
- def run_extraction(pdf_files, progress=gr.Progress(track_tqdm=True)):
63
  """Full extraction pipeline for uploaded PDFs."""
64
  if not pdf_files:
65
  return "⚠️ No PDFs uploaded.", ""
@@ -242,7 +242,7 @@ def run_backtests(
242
  strategy_filter,
243
  max_strategies,
244
  viable_only_push,
245
- progress=gr.Progress(track_tqdm=True),
246
  ):
247
  if not cfg.HF_TICK_REPO:
248
  return "❌ HF_TICK_REPO secret not set.", ""
 
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.", ""
 
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.", ""
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$ = cash * self.risk
290
+ sz = min(risk$ / 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 CHANGED
@@ -1,6 +1,7 @@
1
- gradio==4.44.0
2
- pyaudioop; python_version>="3.13"
3
- huggingface_hub>=0.24.0
 
4
  datasets>=2.20.0
5
  anthropic>=0.30.0
6
 
 
1
+ # Gradio 5.x + huggingface_hub 0.26.x are compatible
2
+ # gradio 4.x broke because HfFolder was removed from huggingface_hub>=0.24
3
+ gradio==5.9.1
4
+ huggingface_hub==0.26.5
5
  datasets>=2.20.0
6
  anthropic>=0.30.0
7
 
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