Spaces:
Sleeping
Sleeping
Delete pipeline
Browse files- pipeline/__init__.py +0 -0
- pipeline/backtester.py +0 -700
- pipeline/exporter.py +0 -176
- pipeline/extractor.py +0 -206
- pipeline/indicators.py +0 -96
- pipeline/pdf_processor.py +0 -132
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|