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