cyberkyne commited on
Commit
a7fd640
·
verified ·
1 Parent(s): 7046e69

Delete pipeline

Browse files
pipeline/__init__.py DELETED
File without changes
pipeline/backtester.py DELETED
@@ -1,700 +0,0 @@
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 DELETED
@@ -1,176 +0,0 @@
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 DELETED
@@ -1,206 +0,0 @@
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 DELETED
@@ -1,96 +0,0 @@
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 DELETED
@@ -1,132 +0,0 @@
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()