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