cyberkyne commited on
Commit
ef7f800
Β·
verified Β·
1 Parent(s): 91089f6

Upload 21 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # ── System dependencies ───────────────────────────────
4
+ RUN apt-get update && apt-get install -y \
5
+ curl wget git ca-certificates \
6
+ tesseract-ocr tesseract-ocr-eng \
7
+ poppler-utils \
8
+ libgl1 libglib2.0-0 libsm6 libxext6 \
9
+ build-essential gfortran \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # ── Install Julia 1.10 LTS ────────────────────────────
13
+ ENV JULIA_VERSION=1.10.7
14
+ RUN wget -q https://julialang-s3.julialang.org/bin/linux/x64/1.10/julia-${JULIA_VERSION}-linux-x86_64.tar.gz \
15
+ && tar -xzf julia-${JULIA_VERSION}-linux-x86_64.tar.gz \
16
+ && mv julia-${JULIA_VERSION} /usr/local/julia \
17
+ && ln -s /usr/local/julia/bin/julia /usr/local/bin/julia \
18
+ && rm julia-${JULIA_VERSION}-linux-x86_64.tar.gz \
19
+ && julia --version
20
+
21
+ # ── Julia environment ─────────────────────────────────
22
+ ENV JULIA_DEPOT_PATH=/app/.julia
23
+ ENV JULIA_NUM_THREADS=4
24
+ ENV JULIA_PROJECT=/app/src
25
+
26
+ WORKDIR /app
27
+
28
+ # ── Python dependencies ───────────────────────────────
29
+ COPY requirements.txt /tmp/requirements.txt
30
+ RUN pip install --no-cache-dir -r /tmp/requirements.txt
31
+
32
+ # ── Copy project files ────────────────────────────────
33
+ COPY . .
34
+
35
+ # ── Step 1: Resolve Julia packages ───────────────────
36
+ # Run with /app/src as project root so it picks up Project.toml
37
+ RUN julia --project=/app/src -e '
38
+ using Pkg
39
+ Pkg.instantiate()
40
+ Pkg.precompile()
41
+ println("Julia packages resolved βœ“")
42
+ '
43
+
44
+ # ── Step 2: Full warmup β€” compiles all hot paths ─────
45
+ RUN julia --project=/app/src /app/src/warmup.jl
46
+
47
+ # ── Step 3: Pre-warm juliacall Python bridge ─────────
48
+ # This forces juliacall to resolve Julia location and cache it,
49
+ # so the first user request is fast.
50
+ RUN python3 -c "
51
+ import os
52
+ os.environ['JULIA_PROJECT'] = '/app/src'
53
+ os.environ['JULIA_DEPOT_PATH'] = '/app/.julia'
54
+ from juliacall import Main as jl
55
+ jl.seval('push!(LOAD_PATH, \"/app/src\")')
56
+ jl.seval('include(\"/app/src/QuantEngine.jl\")')
57
+ jl.seval('using .QuantEngine')
58
+ print('juliacall bridge warmed up βœ“')
59
+ "
60
+
61
+ # ── Runtime environment ───────────────────────────────
62
+ ENV GRADIO_SERVER_NAME=0.0.0.0
63
+ ENV GRADIO_SERVER_PORT=7860
64
+ ENV JULIA_PROJECT=/app/src
65
+ ENV JULIA_DEPOT_PATH=/app/.julia
66
+
67
+ EXPOSE 7860
68
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Quant Knowledge Extractor
3
+ emoji: πŸ“Š
4
+ colorFrom: green
5
+ colorTo: gray
6
+ sdk: docker
7
+ pinned: true
8
+ license: mit
9
+ ---
10
+
11
+ # πŸ“Š Quant Knowledge Extractor β€” Julia Engine
12
+
13
+ Upload PDFs β†’ Extract strategies β†’ Backtest with Julia β†’ Download MT5 `.set` files.
14
+
15
+ **Julia handles all computation** (indicators, backtest engine, walk-forward optimizer).
16
+ Python handles only UI (Gradio) and API calls (Claude, HuggingFace).
17
+
18
+ ## Setup β€” Add these Secrets in Space Settings β†’ Variables and Secrets
19
+
20
+ | Secret | Description |
21
+ |--------|-------------|
22
+ | `ANTHROPIC_API_KEY` | Claude API key |
23
+ | `HF_TOKEN` | HuggingFace write token |
24
+ | `HF_DATASET_REPO` | `your-username/quant-knowledge-base` |
25
+ | `HF_TICK_REPO` | `your-username/tick-data` |
app.py ADDED
@@ -0,0 +1,482 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py β€” HuggingFace Spaces entry point.
3
+
4
+ Architecture:
5
+ Python : Gradio UI, Claude API calls, HF I/O, PDF processing
6
+ Julia : Indicators, BacktestEngine, WalkForwardOptimizer, SignalCompiler
7
+
8
+ Python NEVER does numerical computation. It only:
9
+ 1. Calls Claude API (extraction + strategy code generation)
10
+ 2. Calls Julia via juliacall for all math
11
+ 3. Reads/writes HuggingFace datasets
12
+ 4. Renders Gradio UI
13
+ """
14
+
15
+ import io, json, zipfile, tempfile
16
+ from pathlib import Path
17
+ from datetime import datetime
18
+
19
+ import gradio as gr
20
+ from loguru import logger
21
+
22
+ import utils.config as cfg
23
+ import utils.hf_io as hf
24
+ from pipeline.pdf_processor import PDFProcessor
25
+ from pipeline.extractor import AIExtractor, Deduplicator
26
+ from pipeline.julia_bridge import full_backtest_pipeline, julia_available
27
+ from pipeline.exporter import (
28
+ slugify, strategy_md, formula_md,
29
+ backtest_report_md, optimal_json, mt5_set,
30
+ julia_config, index_md,
31
+ )
32
+
33
+ # ── Lazy KB ───────────────────────────────────────────
34
+ _kb = None
35
+ def get_kb():
36
+ global _kb
37
+ if _kb is None: _kb = hf.kb_load()
38
+ return _kb
39
+ def reset_kb():
40
+ global _kb; _kb = hf.kb_load()
41
+
42
+
43
+ # ═══════════════════════════════════════════════════
44
+ # TAB 1 β€” UPLOAD & EXTRACT
45
+ # ═══════════════════════════════════════════════════
46
+
47
+ def run_extraction(pdf_files, progress=gr.Progress()):
48
+ if not pdf_files: return "⚠️ No PDFs uploaded.", ""
49
+ if not cfg.ANTHROPIC_API_KEY: return "❌ ANTHROPIC_API_KEY secret not set.", ""
50
+ if not cfg.HF_DATASET_REPO: return "❌ HF_DATASET_REPO secret not set.", ""
51
+
52
+ proc = PDFProcessor()
53
+ ai = AIExtractor()
54
+ dedup = Deduplicator()
55
+ kb = get_kb()
56
+ log = []
57
+ totals = {k:{"added":0,"merged":0,"skipped":0} for k in ("strategies","formulas","systems")}
58
+ hf_files = []
59
+
60
+ for i, pdf_file in enumerate(pdf_files):
61
+ path = Path(pdf_file.name)
62
+ progress(i/len(pdf_files), desc=f"{path.name}")
63
+ log.append(f"\nπŸ“– [{i+1}/{len(pdf_files)}] {path.name}")
64
+ try:
65
+ chunks = list(proc.process(path))
66
+ log.append(f" β†’ {len(chunks)} chunks")
67
+ except Exception as e:
68
+ log.append(f" ❌ {e}"); continue
69
+
70
+ for chunk in chunks:
71
+ extracted = ai.extract(chunk)
72
+ stats = dedup.process(extracted, kb)
73
+ for kind in ("strategies","formulas","systems"):
74
+ for act in ("added","merged","skipped"):
75
+ totals[kind][act] += stats[kind][act]
76
+
77
+ log.append(f" β†’ New: {totals['strategies']['added']} strats, {totals['formulas']['added']} formulas")
78
+ if cfg.HF_TOKEN: hf.pdf_upload(path)
79
+
80
+ for cid, rec in kb["strategies"].items():
81
+ hf_files.append((f"extracted/strategies/{slugify(rec.get('name',''))}.md",
82
+ strategy_md(rec).encode()))
83
+ for cid, rec in kb["formulas"].items():
84
+ hf_files.append((f"extracted/formulas/{slugify(rec.get('name',''))}.md",
85
+ formula_md(rec).encode()))
86
+
87
+ progress(0.9, desc="Saving to HuggingFace…")
88
+ hf.kb_save(kb)
89
+ if hf_files and cfg.HF_TOKEN:
90
+ pushed = hf.push_batch(hf_files, "Update extracted knowledge")
91
+ log.append(f"\n☁️ Pushed {pushed} files to HuggingFace")
92
+ reset_kb()
93
+
94
+ counts = {k: len(kb[k]) for k in kb}
95
+ summary = f"""βœ… Extraction Complete
96
+
97
+ PDFs processed: {len(pdf_files)}
98
+ Strategies β€” added: {totals['strategies']['added']} merged: {totals['strategies']['merged']} skipped: {totals['strategies']['skipped']}
99
+ Formulas β€” added: {totals['formulas']['added']} merged: {totals['formulas']['merged']} skipped: {totals['formulas']['skipped']}
100
+ Systems β€” added: {totals['systems']['added']} merged: {totals['systems']['merged']} skipped: {totals['systems']['skipped']}
101
+
102
+ KB totals: {counts['strategies']} strategies Β· {counts['formulas']} formulas Β· {counts['systems']} systems
103
+ Tokens used: {ai.tokens_used:,}"""
104
+ return summary, "\n".join(log[-40:])
105
+
106
+
107
+ # ═══════════════════════════════════════════════════
108
+ # TAB 2 β€” BROWSE KB
109
+ # ═══════════════════════════════════════════════════
110
+
111
+ def search_strategies(query, category):
112
+ kb = get_kb(); items = list(kb["strategies"].values())
113
+ if category and category != "All":
114
+ items = [x for x in items if x.get("category") == category]
115
+ if query:
116
+ q = query.lower()
117
+ items = [x for x in items if q in x.get("name","").lower() or q in x.get("description","").lower()]
118
+ rows = [[x.get("name","")[:50], x.get("category",""),
119
+ x.get("description","")[:100],
120
+ ", ".join(x.get("sources",[]))[:40], len(x.get("layers",[]))]
121
+ for x in items[:100]]
122
+ return rows, f"{len(items)} strategies"
123
+
124
+ def search_formulas(query):
125
+ kb = get_kb(); items = list(kb["formulas"].values())
126
+ if query:
127
+ q = query.lower()
128
+ items = [x for x in items if q in x.get("name","").lower() or q in x.get("purpose","").lower()]
129
+ return [[x.get("name","")[:50], x.get("category",""),
130
+ x.get("purpose","")[:80],
131
+ "βœ…" if x.get("latex") else "β€”",
132
+ ", ".join(x.get("sources",[]))[:40]] for x in items[:100]]
133
+
134
+ def dl_strategy(name):
135
+ kb = get_kb()
136
+ for rec in kb["strategies"].values():
137
+ if rec.get("name","").lower() == name.strip().lower():
138
+ tmp = tempfile.mktemp(suffix=".md")
139
+ Path(tmp).write_text(strategy_md(rec), encoding="utf-8")
140
+ return tmp
141
+ return None
142
+
143
+ def dl_all_strategies_zip(category):
144
+ kb = get_kb(); items = list(kb["strategies"].values())
145
+ if category and category != "All":
146
+ items = [x for x in items if x.get("category") == category]
147
+ tmp = tempfile.mktemp(suffix=".zip")
148
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf:
149
+ for rec in items:
150
+ zf.writestr(f"{slugify(rec.get('name','unknown'))}.md", strategy_md(rec))
151
+ return tmp
152
+
153
+
154
+ # ═══════════════════════════════════════════════════
155
+ # TAB 3 β€” BACKTEST (Julia Engine)
156
+ # ═══════════════════════════════════════════════════
157
+
158
+ def load_symbols():
159
+ syms = hf.tick_list_symbols()
160
+ return gr.update(choices=syms, value=syms[:2] if len(syms)>=2 else syms)
161
+
162
+
163
+ def run_backtests(selected_symbols, selected_timeframes,
164
+ strategy_filter, max_strategies, viable_only,
165
+ progress=gr.Progress()):
166
+
167
+ if not cfg.HF_TICK_REPO: return "❌ HF_TICK_REPO not set.", ""
168
+ if not cfg.ANTHROPIC_API_KEY: return "❌ ANTHROPIC_API_KEY not set.", ""
169
+ if not julia_available(): return "❌ Julia runtime not available. Check build logs.", ""
170
+
171
+ ai = AIExtractor()
172
+ kb = get_kb()
173
+ strats = list(kb["strategies"].values())
174
+ if strategy_filter:
175
+ strats = [s for s in strats if strategy_filter.lower() in s.get("name","").lower()]
176
+ if max_strategies > 0:
177
+ strats = strats[:int(max_strategies)]
178
+ if not strats: return "⚠️ No strategies. Run extraction first.", ""
179
+
180
+ symbols = selected_symbols or hf.tick_list_symbols()[:2]
181
+ timeframes = selected_timeframes or ["1h"]
182
+
183
+ log, all_results, viable_count = [], [], 0
184
+
185
+ for si, rec in enumerate(strats):
186
+ name = rec.get("name","?")
187
+ progress(si/len(strats), desc=f"[{si+1}/{len(strats)}] {name[:35]}")
188
+
189
+ # 1. Generate Julia signal code via Claude
190
+ jl_code = ai.compile_strategy_code(rec)
191
+ if not jl_code:
192
+ log.append(f"❌ Code gen failed: {name[:40]}"); continue
193
+ log.append(f"βœ… Julia code generated: {name[:40]}")
194
+
195
+ for sym in symbols:
196
+ for tf in timeframes:
197
+ df = hf.tick_load(sym, tf)
198
+ if df is None or len(df) < 200:
199
+ log.append(f" ⚠️ {sym} {tf}: no data"); continue
200
+
201
+ # 2. Full Julia pipeline (compile β†’ optimize β†’ backtest)
202
+ result = full_backtest_pipeline(
203
+ strategy_code = jl_code,
204
+ strategy_name = name,
205
+ open_p = df["open"].values,
206
+ high = df["high"].values,
207
+ low = df["low"].values,
208
+ close = df["close"].values,
209
+ volume = df["volume"].values,
210
+ timeframe = tf,
211
+ symbol = sym,
212
+ n_windows = cfg.WF_WINDOWS,
213
+ is_ratio = cfg.WF_IS_RATIO,
214
+ min_trades = cfg.MIN_TRADES,
215
+ min_sharpe = cfg.MIN_SHARPE,
216
+ max_combos = cfg.MAX_PARAM_COMBOS,
217
+ initial_equity = cfg.INITIAL_EQUITY,
218
+ commission_pct = cfg.COMMISSION_PCT,
219
+ risk_per_trade = cfg.RISK_PER_TRADE,
220
+ )
221
+ all_results.append(result)
222
+
223
+ # 3. Build + push output files
224
+ if cfg.HF_TOKEN and cfg.HF_DATASET_REPO:
225
+ if not viable_only or result.get("is_viable"):
226
+ hf.push_result(
227
+ name, sym, tf,
228
+ backtest_report_md(result, rec),
229
+ optimal_json(result, rec),
230
+ mt5_set(result, rec),
231
+ julia_config(result),
232
+ )
233
+
234
+ status = "βœ…" if result.get("is_viable") else "❌"
235
+ log.append(
236
+ f" {status} {sym} {tf}: "
237
+ f"Sharpe={result.get('oos_sharpe_mean',0):.2f} "
238
+ f"DD={result.get('oos_max_dd',0):.1f}% "
239
+ f"Score={result.get('robustness',0):.0f}")
240
+ if result.get("is_viable"): viable_count += 1
241
+
242
+ # 4. Push master index
243
+ if all_results and cfg.HF_TOKEN:
244
+ hf.push_index(index_md(all_results), {
245
+ "generated": datetime.now().isoformat(),
246
+ "engine": "Julia 1.10",
247
+ "total_strategies": len(all_results),
248
+ "viable_count": viable_count,
249
+ "strategies": all_results,
250
+ })
251
+
252
+ summary = f"""🏁 Julia Backtest Complete
253
+
254
+ Engine: Julia 1.10 BacktestEngine.jl
255
+ Strategies compiled: {len(strats)}
256
+ Combinations tested: {len(all_results)}
257
+ Viable strategies: {viable_count}
258
+ Pass rate: {viable_count/max(len(all_results),1)*100:.1f}%
259
+
260
+ Results on HuggingFace:
261
+ {cfg.HF_DATASET_REPO}/optimal_sets/BACKTEST_INDEX.md"""
262
+ return summary, "\n".join(log[-60:])
263
+
264
+
265
+ # ═══════════════════════════════════════════════════
266
+ # TAB 4 β€” RESULTS
267
+ # ═══════════════════════════════════════════════════
268
+
269
+ def load_results():
270
+ data = hf.fetch_index()
271
+ if not data: return [], "No results yet."
272
+ strats = data.get("strategies",[])
273
+ viable = sorted([s for s in strats if s.get("is_viable")],
274
+ key=lambda x: x.get("oos_sharpe_mean",0), reverse=True)
275
+ rows = [[s.get("strategy","")[:45], s.get("symbol",""), s.get("timeframe",""),
276
+ f'{s.get("oos_sharpe_mean",0):.2f}', f'{s.get("oos_max_dd",0):.1f}%',
277
+ f'{s.get("oos_win_rate",0):.1f}%', f'{s.get("oos_pf_mean",0):.2f}',
278
+ f'{s.get("robustness",0):.0f}'] for s in viable]
279
+ count = (f"βœ… {len(viable)} viable / {len(strats)} tested | "
280
+ f"Engine: Julia | {data.get('generated','')[:16]}")
281
+ return rows, count
282
+
283
+ def dl_result_file(name, symbol, tf, ftype):
284
+ sl = slugify(name); sym = symbol.upper().strip()
285
+ pre = f"{sl}_{sym}_{tf}"
286
+ ext_map = {"MT5 .set file": f"optimal_sets/{pre}.set",
287
+ "Optimal JSON": f"optimal_sets/{pre}_optimal.json",
288
+ "Julia config": f"optimal_sets/{pre}_config.jl",
289
+ "Full report": f"backtests/{sl}/{pre}_report.md"}
290
+ remote = ext_map.get(ftype,"")
291
+ if not remote: return None
292
+ data = hf.fetch_file(remote)
293
+ if not data: return None
294
+ tmp = tempfile.mktemp(suffix=Path(remote).suffix)
295
+ Path(tmp).write_bytes(data)
296
+ return tmp
297
+
298
+ def dl_all_sets():
299
+ data = hf.fetch_index()
300
+ if not data: return None
301
+ tmp = tempfile.mktemp(suffix=".zip")
302
+ with zipfile.ZipFile(tmp,"w",zipfile.ZIP_DEFLATED) as zf:
303
+ for s in data.get("strategies",[]):
304
+ if not s.get("is_viable"): continue
305
+ sl = slugify(s["strategy"]); sym = s["symbol"]; tf = s["timeframe"]
306
+ content = hf.fetch_file(f"optimal_sets/{sl}_{sym}_{tf}.set")
307
+ if content: zf.writestr(f"{sl}_{sym}_{tf}.set", content)
308
+ return tmp
309
+
310
+
311
+ # ═══════════════════════════════════════════════════
312
+ # TAB 5 β€” SETUP
313
+ # ═══════════════════════════════════════════════════
314
+
315
+ def check_config():
316
+ checks = [
317
+ ("ANTHROPIC_API_KEY", cfg.ANTHROPIC_API_KEY, "Claude API"),
318
+ ("HF_TOKEN", cfg.HF_TOKEN, "HF write access"),
319
+ ("HF_DATASET_REPO", cfg.HF_DATASET_REPO, "Results storage"),
320
+ ("HF_TICK_REPO", cfg.HF_TICK_REPO, "Tick data source"),
321
+ ]
322
+ kb = get_kb()
323
+ symbols = hf.tick_list_symbols() if cfg.HF_TICK_REPO else []
324
+ jl_ok = julia_available()
325
+
326
+ lines = ["## Configuration Status", ""]
327
+ for name, val, desc in checks:
328
+ icon = "βœ…" if val else "❌"
329
+ lines.append(f"{icon} `{name}` β€” {desc}")
330
+
331
+ lines += ["", "## Julia Engine", "",
332
+ f"{'βœ…' if jl_ok else '❌'} Julia runtime: {'available' if jl_ok else 'not available (check build logs)'}",
333
+ "", "## Data Status", "",
334
+ f"- Tick symbols: **{len(symbols)}** β€” {', '.join(symbols[:8])}",
335
+ f"- Strategies in KB: **{len(kb['strategies'])}**",
336
+ f"- Formulas in KB: **{len(kb['formulas'])}**",
337
+ "", "## Backtest Settings", "",
338
+ f"- WF Windows: `{cfg.WF_WINDOWS}` Β· IS Ratio: `{cfg.WF_IS_RATIO}`",
339
+ f"- Min Trades: `{cfg.MIN_TRADES}` Β· Min Sharpe: `{cfg.MIN_SHARPE}`",
340
+ f"- Commission: `{cfg.COMMISSION_PCT*100:.3f}%` Β· Risk/trade: `{cfg.RISK_PER_TRADE*100:.1f}%`",
341
+ f"- Timeframes: `{', '.join(cfg.BACKTEST_TFS)}`"]
342
+ return "\n".join(lines)
343
+
344
+
345
+ # ═══════════════════════════════════════════════════
346
+ # BUILD APP
347
+ # ═══════════════════════════════════════════════════
348
+
349
+ CATS = ["All"] + cfg.CATEGORIES
350
+
351
+ with gr.Blocks(
352
+ title="Quant Knowledge Extractor β€” Julia Engine",
353
+ theme=gr.themes.Base(primary_hue="green", neutral_hue="gray"),
354
+ css=".status-box{font-family:monospace;font-size:.82em}"
355
+ ) as demo:
356
+
357
+ gr.HTML("""
358
+ <div style="text-align:center;padding:1.2em 0 .3em">
359
+ <h1 style="font-size:2em;color:#16a34a;margin:0">πŸ“Š Quant Knowledge Extractor</h1>
360
+ <p style="color:#6b7280;margin:.4em 0 0">
361
+ Julia 1.10 Engine Β· BacktestEngine.jl Β· WalkForward Optimizer Β· MT5 .set Output
362
+ </p>
363
+ </div>""")
364
+
365
+ with gr.Tabs():
366
+
367
+ # Tab 1 β€” Extract
368
+ with gr.Tab("πŸ“€ Upload & Extract"):
369
+ gr.Markdown("### Upload algorithmic trading PDFs β€” OCR applied automatically")
370
+ with gr.Row():
371
+ with gr.Column(scale=2):
372
+ pdf_in = gr.File(label="Drop PDFs here", file_count="multiple", file_types=[".pdf"])
373
+ ext_btn = gr.Button("πŸš€ Extract Knowledge", variant="primary", size="lg")
374
+ with gr.Column(scale=1):
375
+ ext_out = gr.Textbox(label="Result", lines=14, interactive=False, elem_classes=["status-box"])
376
+ ext_log = gr.Textbox(label="Log", lines=8, interactive=False, elem_classes=["status-box"])
377
+ ext_btn.click(fn=run_extraction, inputs=[pdf_in], outputs=[ext_out, ext_log])
378
+
379
+ # Tab 2 β€” Browse
380
+ with gr.Tab("πŸ“š Knowledge Base"):
381
+ with gr.Tabs():
382
+ with gr.Tab("πŸ“ˆ Strategies"):
383
+ with gr.Row():
384
+ sq = gr.Textbox(label="Search", placeholder="RSI, breakout, Kelly…")
385
+ sc = gr.Dropdown(choices=CATS, value="All", label="Category")
386
+ sb = gr.Button("πŸ” Search", variant="primary")
387
+ st = gr.Dataframe(headers=["Name","Category","Description","Sources","Variants"],
388
+ datatype=["str"]*4+["number"], interactive=False)
389
+ sn = gr.Markdown("")
390
+ with gr.Row():
391
+ sni = gr.Textbox(label="Name to download")
392
+ sdb = gr.Button("⬇️ Download MD"); sdf = gr.File(label="")
393
+ szb = gr.Button("πŸ“¦ Category ZIP"); szf = gr.File(label="")
394
+ sb.click(fn=search_strategies, inputs=[sq,sc], outputs=[st,sn])
395
+ sdb.click(fn=dl_strategy, inputs=[sni], outputs=[sdf])
396
+ szb.click(fn=dl_all_strategies_zip, inputs=[sc], outputs=[szf])
397
+ with gr.Tab("βˆ‘ Formulas"):
398
+ with gr.Row():
399
+ fq = gr.Textbox(label="Search", placeholder="Sharpe, Kelly, ATR…")
400
+ fb = gr.Button("πŸ” Search", variant="primary")
401
+ ft = gr.Dataframe(headers=["Name","Category","Purpose","LaTeX","Sources"],
402
+ datatype=["str"]*5, interactive=False)
403
+ fb.click(fn=search_formulas, inputs=[fq], outputs=[ft])
404
+
405
+ # Tab 3 β€” Backtest
406
+ with gr.Tab("πŸ”¬ Julia Backtest"):
407
+ gr.Markdown(
408
+ "### Walk-Forward Backtest β€” Julia Engine\n"
409
+ "Claude generates Julia signal code β†’ Julia compiles + optimizes β†’ "
410
+ "MT5 `.set` files pushed to HuggingFace."
411
+ )
412
+ with gr.Row():
413
+ with gr.Column(scale=2):
414
+ bt_load = gr.Button("πŸ”„ Load Symbols from HF")
415
+ bt_syms = gr.CheckboxGroup(label="Symbols", choices=[], value=[])
416
+ bt_tfs = gr.CheckboxGroup(
417
+ label="Timeframes", value=["1h","4h"],
418
+ choices=["1m","5m","15m","30m","1h","4h","1d"])
419
+ bt_filt = gr.Textbox(label="Strategy filter (optional)")
420
+ bt_max = gr.Slider(0, 500, value=0, step=10, label="Max strategies (0=all)")
421
+ bt_viable= gr.Checkbox(label="Push only VIABLE to HuggingFace", value=True)
422
+ bt_run = gr.Button("πŸš€ Run Julia Backtests", variant="primary", size="lg")
423
+ with gr.Column(scale=1):
424
+ bt_out = gr.Textbox(label="Summary", lines=12, interactive=False, elem_classes=["status-box"])
425
+ bt_log = gr.Textbox(label="Log", lines=12, interactive=False, elem_classes=["status-box"])
426
+ bt_load.click(fn=load_symbols, outputs=[bt_syms])
427
+ bt_run.click(fn=run_backtests,
428
+ inputs=[bt_syms, bt_tfs, bt_filt, bt_max, bt_viable],
429
+ outputs=[bt_out, bt_log])
430
+
431
+ # Tab 4 β€” Results
432
+ with gr.Tab("πŸ† Results"):
433
+ gr.Markdown("### Viable Strategies β€” Download MT5 `.set` & Julia Configs")
434
+ res_ref = gr.Button("πŸ”„ Refresh from HuggingFace", variant="primary")
435
+ res_tbl = gr.Dataframe(
436
+ headers=["Strategy","Symbol","TF","Sharpe","Max DD","Win%","PF","Score"],
437
+ datatype=["str"]*8, interactive=False)
438
+ res_cnt = gr.Markdown("")
439
+ gr.Markdown("#### Download individual file")
440
+ with gr.Row():
441
+ rn = gr.Textbox(label="Strategy name"); rs = gr.Textbox(label="Symbol")
442
+ rt = gr.Textbox(label="Timeframe")
443
+ rf = gr.Dropdown(choices=["MT5 .set file","Optimal JSON",
444
+ "Julia config","Full report"],
445
+ value="MT5 .set file", label="File type")
446
+ rdb = gr.Button("⬇️ Download", variant="primary"); rdf = gr.File(label="")
447
+ gr.Markdown("#### Batch download all viable strategies")
448
+ with gr.Row():
449
+ rsb = gr.Button("🎯 All MT5 .set (ZIP)"); rsf = gr.File(label="")
450
+ res_ref.click(fn=load_results, outputs=[res_tbl, res_cnt])
451
+ rdb.click(fn=dl_result_file, inputs=[rn,rs,rt,rf], outputs=[rdf])
452
+ rsb.click(fn=dl_all_sets, outputs=[rsf])
453
+ demo.load(fn=load_results, outputs=[res_tbl, res_cnt])
454
+
455
+ # Tab 5 β€” Setup
456
+ with gr.Tab("βš™οΈ Setup & Status"):
457
+ gr.Markdown("""### Required Secrets (Space Settings β†’ Variables and Secrets)
458
+
459
+ | Secret | Description |
460
+ |--------|-------------|
461
+ | `ANTHROPIC_API_KEY` | Claude API key |
462
+ | `HF_TOKEN` | HuggingFace write token |
463
+ | `HF_DATASET_REPO` | `your-username/quant-knowledge-base` |
464
+ | `HF_TICK_REPO` | `your-username/tick-data` |
465
+
466
+ ### Tick Data Format
467
+ Upload to your `tick-data` dataset:
468
+ ```
469
+ EURUSD/ticks.parquet (columns: timestamp, bid, ask OR open,high,low,close,volume)
470
+ BTCUSDT/1h.parquet (pre-built OHLCV β€” faster)
471
+ ```
472
+ """)
473
+ cfg_ref = gr.Button("πŸ”„ Check Status")
474
+ cfg_out = gr.Markdown(check_config())
475
+ cfg_ref.click(fn=check_config, outputs=[cfg_out])
476
+
477
+ gr.HTML("""<div style="text-align:center;padding:.8em;color:#9ca3af;font-size:.75em">
478
+ Quant Knowledge Extractor Β· Julia 1.10 Engine Β· HuggingFace Spaces
479
+ </div>""")
480
+
481
+ if __name__ == "__main__":
482
+ demo.launch()
pipeline/__init__.py ADDED
File without changes
pipeline/exporter.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """pipeline/exporter.py β€” Generate markdown, MT5 .set, Julia config strings."""
2
+ import re, json
3
+ from datetime import datetime
4
+
5
+
6
+ def slugify(t):
7
+ t = t.lower().strip()
8
+ t = re.sub(r"[^\w\s-]","",t)
9
+ t = re.sub(r"[\s_-]+","-",t)
10
+ return t[:50]
11
+
12
+
13
+ def strategy_md(rec):
14
+ name = rec.get("name","Unknown")
15
+ sources = rec.get("sources",[])
16
+ layers = rec.get("layers",[])
17
+ lines = [f"# {name}", "",
18
+ f"> **Category:** {rec.get('category','')} ",
19
+ f"> **Sources:** {', '.join(sources)[:80]} ",
20
+ f"> **ID:** `{rec.get('canonical_id','')}`",
21
+ "", "---", "", "## Description", "", rec.get("description",""), ""]
22
+ for label, key in [("## Entry Rules","entry_rules"),("## Exit Rules","exit_rules"),("## Filters","filters")]:
23
+ items = rec.get(key,[])
24
+ if items:
25
+ lines += [label, ""]
26
+ for i,r in enumerate(items,1): lines.append(f"{i}. {r}")
27
+ lines.append("")
28
+ if rec.get("mathematical_basis"):
29
+ lines += ["## Mathematical Basis", "", rec["mathematical_basis"], ""]
30
+ if layers:
31
+ lines += [f"## Variants ({len(layers)} sources)", ""]
32
+ for i,l in enumerate(layers,1):
33
+ d = l.get("data",{}); lines.append(f"### Variant {i} β€” {l.get('source_file','')}")
34
+ if d.get("description"): lines.append(d["description"])
35
+ lines.append("")
36
+ lines += ["---","","*Generated by Quant Knowledge Extractor β€” Julia Engine*"]
37
+ return "\n".join(lines)
38
+
39
+
40
+ def formula_md(rec):
41
+ lines = [f"# {rec.get('name','Unknown')}", "",
42
+ f"> **Category:** {rec.get('category','')} ",
43
+ f"> **Sources:** {', '.join(rec.get('sources',[]))[:80]}",
44
+ "", "---", ""]
45
+ if rec.get("latex"):
46
+ lines += ["## Formula (LaTeX)", "", "$$", rec["latex"], "$$", ""]
47
+ if rec.get("plain_text"):
48
+ lines += ["## Plain Text", "", "```", rec["plain_text"], "```", ""]
49
+ if rec.get("purpose"):
50
+ lines += ["## Purpose", "", rec["purpose"], ""]
51
+ if rec.get("variables"):
52
+ lines += ["## Variables", "", "| Symbol | Description |", "|--------|-------------|"]
53
+ for s,d in rec["variables"].items(): lines.append(f"| `{s}` | {d} |")
54
+ lines.append("")
55
+ lines += ["---","","*Generated by Quant Knowledge Extractor β€” Julia Engine*"]
56
+ return "\n".join(lines)
57
+
58
+
59
+ def backtest_report_md(result: dict, record: dict) -> str:
60
+ name = result.get("strategy","Unknown")
61
+ sym = result.get("symbol","")
62
+ tf = result.get("timeframe","")
63
+ viable = result.get("is_viable", False)
64
+ status = "βœ… VIABLE" if viable else "❌ NOT VIABLE"
65
+ now = datetime.now().strftime("%Y-%m-%d %H:%M UTC")
66
+ lines = [
67
+ f"# {name}", f"## Backtest Report β€” {sym} {tf}", "",
68
+ f"> **Status:** {status} ",
69
+ f"> **Robustness:** {result.get('robustness',0):.0f}/100 ",
70
+ f"> **Engine:** Julia 1.10 ",
71
+ f"> **Generated:** {now}", "", "---", "", "## Viability", "",
72
+ ]
73
+ for r in result.get("reasons",[]): lines.append(f"- {r}")
74
+ lines += [""]
75
+ if result.get("optimal_params"):
76
+ lines += ["## Optimal Parameters (Julia Walk-Forward)", "",
77
+ "| Parameter | Value |", "|-----------|-------|"]
78
+ for k,v in result["optimal_params"].items():
79
+ lines.append(f"| `{k}` | `{v:.4g}` |")
80
+ lines += [""]
81
+ lines += [
82
+ "## OOS Performance (Walk-Forward Aggregate)", "",
83
+ "| Metric | Value |", "|--------|-------|",
84
+ f"| Sharpe (meanΒ±std) | `{result.get('oos_sharpe_mean',0):.3f} Β± {result.get('oos_sharpe_std',0):.3f}` |",
85
+ f"| Win Rate | `{result.get('oos_win_rate',0):.1f}%` |",
86
+ f"| Max Drawdown (mean) | `{result.get('oos_max_dd',0):.2f}%` |",
87
+ f"| Profit Factor | `{result.get('oos_pf_mean',0):.3f}` |",
88
+ f"| Total OOS Trades | `{result.get('oos_trades',0)}` |",
89
+ f"| WF Efficiency Ratio | `{result.get('wf_efficiency',0):.3f}` |",
90
+ f"| Robustness Score | `{result.get('robustness',0):.0f}/100` |",
91
+ ]
92
+ if record.get("description"):
93
+ lines += ["", "## Strategy Description", "", record["description"]]
94
+ lines += ["", "---", "", "*Backtested using Julia 1.10 β€” BacktestEngine.jl + Optimizer.jl*"]
95
+ return "\n".join(lines)
96
+
97
+
98
+ def optimal_json(result: dict, record: dict) -> dict:
99
+ return {
100
+ "metadata": {
101
+ "strategy": result.get("strategy"), "symbol": result.get("symbol"),
102
+ "timeframe": result.get("timeframe"),
103
+ "generated": datetime.now().isoformat(),
104
+ "engine": "Julia 1.10",
105
+ "is_viable": result.get("is_viable"), "robustness": result.get("robustness"),
106
+ },
107
+ "optimal_parameters": result.get("optimal_params",{}),
108
+ "performance": {
109
+ "oos_sharpe_mean": round(result.get("oos_sharpe_mean",0),4),
110
+ "oos_sharpe_std": round(result.get("oos_sharpe_std",0),4),
111
+ "oos_win_rate": round(result.get("oos_win_rate",0),2),
112
+ "oos_max_dd_pct": round(result.get("oos_max_dd",0),2),
113
+ "oos_pf_mean": round(result.get("oos_pf_mean",0),4),
114
+ "oos_total_trades":result.get("oos_trades",0),
115
+ "wf_efficiency": round(result.get("wf_efficiency",0),4),
116
+ },
117
+ "viability": {"is_viable": result.get("is_viable"), "reasons": result.get("reasons",[])},
118
+ }
119
+
120
+
121
+ def mt5_set(result: dict, record: dict) -> str:
122
+ name = result.get("strategy","Unknown")
123
+ sym = result.get("symbol",""); tf = result.get("timeframe","")
124
+ params = result.get("optimal_params",{})
125
+ lines = [
126
+ f"; MT5 Strategy Tester Set File β€” Generated by Julia Engine",
127
+ f"; Strategy: {name}", f"; Symbol: {sym} Timeframe: {tf}",
128
+ f"; OOS Sharpe: {result.get('oos_sharpe_mean',0):.3f} Score: {result.get('robustness',0):.0f}/100",
129
+ f"; Viable: {result.get('is_viable', False)}",
130
+ f";", f"; 1. Open MT5 β†’ View β†’ Strategy Tester",
131
+ f"; 2. Select Expert Advisor", f"; 3. Inputs β†’ Open β†’ select this file", "",
132
+ ]
133
+ for k, v in params.items():
134
+ mt5k = "".join(w.capitalize() for w in k.replace("-","_").split("_"))
135
+ try:
136
+ fv = float(v); step = max(1.0, abs(fv)*0.1)
137
+ lines.append(f"{mt5k}={fv:.4g}||{max(0,fv-step*3):.4g}||{step:.4g}||{fv+step*3:.4g}||1")
138
+ except: lines.append(f"{mt5k}={v}")
139
+ lines += ["","RiskPercent=1.0||0.5||0.5||3.0||1","LotSize=0.1||0.01||0.01||1.0||1"]
140
+ return "\n".join(lines)
141
+
142
+
143
+ def julia_config(result: dict) -> str:
144
+ name = result.get("strategy","Unknown")
145
+ sym = result.get("symbol",""); tf = result.get("timeframe","")
146
+ struct = "".join(w.capitalize() for w in name.split()[:4])
147
+ params = result.get("optimal_params",{})
148
+ plines = "\n ".join(
149
+ f'{k.lower().replace("-","_")} = {float(v):.6g}'
150
+ for k,v in params.items()
151
+ ) or "# no parameters"
152
+ return f'''# Optimal Parameters β€” {name}
153
+ # Engine: Julia 1.10 BacktestEngine.jl + Optimizer.jl
154
+ # Symbol: {sym} Timeframe: {tf}
155
+ # OOS Sharpe: {result.get("oos_sharpe_mean",0):.3f} Robustness: {result.get("robustness",0):.0f}/100
156
+ # Viable: {result.get("is_viable",False)}
157
+ # Generated: {datetime.now().strftime("%Y-%m-%d")}
158
+
159
+ function optimal_params()::{struct}Params
160
+ return {struct}Params(
161
+ {plines}
162
+ )
163
+ end
164
+
165
+ const BACKTEST_METADATA = (
166
+ strategy = "{name}",
167
+ symbol = "{sym}",
168
+ timeframe = "{tf}",
169
+ engine = "Julia 1.10",
170
+ oos_sharpe = {result.get("oos_sharpe_mean",0):.4f},
171
+ max_dd_pct = {result.get("oos_max_dd",0):.2f},
172
+ win_rate = {result.get("oos_win_rate",0):.1f},
173
+ wf_eff = {result.get("wf_efficiency",0):.4f},
174
+ viable = {str(result.get("is_viable",False)).lower()},
175
+ )
176
+ '''
177
+
178
+
179
+ def index_md(all_results: list) -> str:
180
+ viable = sorted([r for r in all_results if r.get("is_viable")],
181
+ key=lambda r: r.get("oos_sharpe_mean",0), reverse=True)
182
+ not_v = [r for r in all_results if not r.get("is_viable")]
183
+ now = datetime.now().strftime("%Y-%m-%d %H:%M UTC")
184
+ lines = [
185
+ "# Backtest Results Index β€” Julia Engine", "",
186
+ f"> Generated: {now} Engine: Julia 1.10 ",
187
+ f"> Total: {len(all_results)} Viable: {len(viable)}", "", "---", "",
188
+ "## βœ… Viable Strategies (by OOS Sharpe)", "",
189
+ "| # | Strategy | Symbol | TF | Sharpe | DD% | Win% | PF | Score |",
190
+ "|---|----------|--------|----|--------|-----|------|----|-------|",
191
+ ]
192
+ for i,r in enumerate(viable,1):
193
+ lines.append(
194
+ f"| {i} | {r.get('strategy','')[:35]} | {r.get('symbol','')} | {r.get('timeframe','')} | "
195
+ f"{r.get('oos_sharpe_mean',0):.2f} | {r.get('oos_max_dd',0):.1f} | "
196
+ f"{r.get('oos_win_rate',0):.1f} | {r.get('oos_pf_mean',0):.2f} | {r.get('robustness',0):.0f} |")
197
+ lines += ["", "## ❌ Not Viable", "", "| Strategy | Symbol | TF | Reason |", "|----------|--------|----|--------|"]
198
+ for r in not_v[:30]:
199
+ reasons = r.get("reasons",[])
200
+ reason = reasons[0][:60] if reasons else ""
201
+ lines.append(f"| {r.get('strategy','')[:35]} | {r.get('symbol','')} | {r.get('timeframe','')} | {reason} |")
202
+ return "\n".join(lines)
pipeline/extractor.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """pipeline/extractor.py β€” Claude API extraction + 3-layer deduplication."""
2
+ import json, time, hashlib
3
+ from typing import Optional
4
+ import numpy as np
5
+ from sklearn.feature_extraction.text import TfidfVectorizer
6
+ from sklearn.metrics.pairwise import cosine_similarity
7
+ import anthropic
8
+ from loguru import logger
9
+ import utils.config as cfg
10
+
11
+
12
+ class AIExtractor:
13
+ MODEL = "claude-sonnet-4-20250514"
14
+ def __init__(self):
15
+ self.client = anthropic.Anthropic(api_key=cfg.ANTHROPIC_API_KEY)
16
+ self.tokens_used = 0
17
+
18
+ def extract(self, chunk) -> dict:
19
+ if chunk.word_count < 20:
20
+ return {"strategies":[],"formulas":[],"systems":[]}
21
+ prompt = cfg.EXTRACTION_PROMPT.format(
22
+ source_file=chunk.source_file, page_start=chunk.page_start,
23
+ page_end=chunk.page_end, text=chunk.text)
24
+ raw = self._call(prompt)
25
+ if not raw: return {"strategies":[],"formulas":[],"systems":[]}
26
+ return self._parse(raw, chunk)
27
+
28
+ def _call(self, prompt, retries=3):
29
+ delay = 2.0
30
+ for attempt in range(retries):
31
+ try:
32
+ resp = self.client.messages.create(
33
+ model=self.MODEL, max_tokens=4096,
34
+ messages=[{"role":"user","content":prompt}])
35
+ self.tokens_used += resp.usage.input_tokens + resp.usage.output_tokens
36
+ return resp.content[0].text if resp.content else ""
37
+ except anthropic.RateLimitError:
38
+ logger.warning(f"Rate limit β€” {delay}s")
39
+ time.sleep(delay); delay *= 2
40
+ except Exception as e:
41
+ logger.error(f"API: {e}")
42
+ if attempt == retries-1: return ""
43
+ time.sleep(delay); delay *= 2
44
+ return ""
45
+
46
+ def _parse(self, raw, chunk):
47
+ raw = raw.strip()
48
+ if raw.startswith("```"): raw = "\n".join(raw.split("\n")[1:]).rstrip("`").strip()
49
+ try: data = json.loads(raw)
50
+ except:
51
+ try:
52
+ s=raw.find("{"); e=raw.rfind("}")
53
+ data=json.loads(raw[s:e+1]) if s!=-1 else {}
54
+ except: return {"strategies":[],"formulas":[],"systems":[]}
55
+ result = {}
56
+ for kind in ("strategies","formulas","systems"):
57
+ result[kind] = []
58
+ for item in data.get(kind,[]):
59
+ if isinstance(item,dict) and item.get("name"):
60
+ item.update({"source_file":chunk.source_file,
61
+ "source_pages":f"{chunk.page_start}-{chunk.page_end}"})
62
+ item["content_hash"] = _hash(
63
+ item.get("description","") + item.get("plain_text","") +
64
+ item.get("entry_system","") + item.get("name",""))
65
+ result[kind].append(item)
66
+ return result
67
+
68
+ def compile_strategy_code(self, record: dict) -> str:
69
+ """Ask Claude to generate Julia signal code for this strategy."""
70
+ compact = {k: record.get(k) for k in
71
+ ("name","category","description","entry_rules","exit_rules",
72
+ "filters","parameters","mathematical_basis")}
73
+ prompt = cfg.COMPILER_PROMPT.format(
74
+ strategy_json=json.dumps(compact, indent=2))
75
+ code = self._call(prompt)
76
+ if not code: return ""
77
+ if "```" in code:
78
+ lines = code.split("\n")
79
+ in_block = False; out = []
80
+ for line in lines:
81
+ if line.strip().startswith("```"): in_block = not in_block; continue
82
+ if in_block: out.append(line)
83
+ code = "\n".join(out)
84
+ return code.strip()
85
+
86
+
87
+ class Deduplicator:
88
+ def __init__(self, threshold=None):
89
+ self.threshold = threshold or cfg.SIMILARITY_THRESHOLD
90
+ self._vec = TfidfVectorizer(ngram_range=(1,2), max_features=5000, stop_words="english")
91
+
92
+ def process(self, extracted, kb):
93
+ stats = {k:{"added":0,"merged":0,"skipped":0} for k in ("strategies","formulas","systems")}
94
+ for kind in ("strategies","formulas","systems"):
95
+ for item in extracted.get(kind,[]):
96
+ stats[kind][self._process_one(item, kb[kind], kind)] += 1
97
+ return stats
98
+
99
+ def _process_one(self, item, store, kind):
100
+ h = item.get("content_hash","")
101
+ for e in store.values():
102
+ if e.get("content_hash") == h:
103
+ self._add_src(item, e); return "skipped"
104
+ sid = self._similar(item, store, kind)
105
+ if sid: self._merge(item, store[sid]); return "merged"
106
+ cid = _cid(item["name"], h, kind)
107
+ item["canonical_id"] = cid
108
+ item["sources"] = [item.get("source_file","")]
109
+ item["layers"] = []
110
+ store[cid] = item
111
+ return "added"
112
+
113
+ def _similar(self, item, store, kind):
114
+ if not store: return None
115
+ texts = [_text(v,kind) for v in store.values()] + [_text(item,kind)]
116
+ try:
117
+ mat = self._vec.fit_transform(texts)
118
+ sims = cosine_similarity(mat[-1], mat[:-1])[0]
119
+ idx = int(np.argmax(sims))
120
+ if sims[idx] >= self.threshold:
121
+ return list(store.keys())[idx]
122
+ except: pass
123
+ return None
124
+
125
+ @staticmethod
126
+ def _add_src(item, existing):
127
+ s = item.get("source_file","")
128
+ if s and s not in existing.get("sources",[]):
129
+ existing.setdefault("sources",[]).append(s)
130
+
131
+ @staticmethod
132
+ def _merge(item, existing):
133
+ Deduplicator._add_src(item, existing)
134
+ layers = existing.setdefault("layers",[])
135
+ if item.get("content_hash") not in {l.get("content_hash") for l in layers}:
136
+ layers.append({"source_file":item.get("source_file"),
137
+ "content_hash":item.get("content_hash"),
138
+ "data":{k:v for k,v in item.items()
139
+ if k not in ("sources","layers","canonical_id")}})
140
+
141
+
142
+ def _hash(text):
143
+ return hashlib.sha256(" ".join(text.lower().split()).encode()).hexdigest()[:16]
144
+
145
+ def _cid(name, h, kind):
146
+ return hashlib.md5(f"{kind}_{name}_{h}".encode()).hexdigest()[:12]
147
+
148
+ def _text(item, kind):
149
+ if kind=="strategies":
150
+ return f"{item.get('name','')} {item.get('description','')} {' '.join(item.get('entry_rules',[]))}"
151
+ if kind=="formulas":
152
+ return f"{item.get('name','')} {item.get('plain_text','')} {item.get('purpose','')}"
153
+ return f"{item.get('name','')} {item.get('entry_system','')} {item.get('exit_system','')}"
pipeline/julia_bridge.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/julia_bridge.py
3
+ ─────────────────────────────────────────────────────
4
+ Python bridge to Julia via juliacall.
5
+
6
+ Julia is loaded ONCE on first use (lazy init) to avoid
7
+ slowing down Space startup. Subsequent calls are instant.
8
+
9
+ juliacall converts:
10
+ Python list/np.ndarray β†’ Julia Vector{Float64}
11
+ Python dict β†’ Julia Dict
12
+ Julia Dict{String,Any} β†’ Python dict
13
+ Julia Vector β†’ Python list
14
+
15
+ All heavy computation β€” indicators, backtest, optimizer β€”
16
+ runs in Julia. Python only calls this bridge.
17
+ """
18
+
19
+ from __future__ import annotations
20
+ import os
21
+ from pathlib import Path
22
+ from typing import Optional
23
+ import numpy as np
24
+ from loguru import logger
25
+
26
+ # ── Julia runtime (loaded once) ───────────────────────
27
+ _jl = None
28
+ _QE = None # QuantEngine module
29
+
30
+ JULIA_SRC = Path(__file__).parent.parent / "src"
31
+
32
+
33
+ def _init_julia():
34
+ """Lazy-load Julia + QuantEngine on first call."""
35
+ global _jl, _QE
36
+ if _jl is not None:
37
+ return
38
+
39
+ logger.info("Initializing Julia runtime…")
40
+ try:
41
+ from juliacall import Main as jl
42
+ _jl = jl
43
+
44
+ # Load the QuantEngine module
45
+ _jl.seval(f'push!(LOAD_PATH, "{JULIA_SRC}")')
46
+ _jl.seval(f'include("{JULIA_SRC / "QuantEngine.jl"}")')
47
+ _jl.seval("using .QuantEngine")
48
+ _QE = _jl.QuantEngine
49
+ logger.info("Julia QuantEngine loaded βœ“")
50
+ except Exception as e:
51
+ logger.error(f"Julia init failed: {e}")
52
+ raise RuntimeError(f"Julia init failed: {e}") from e
53
+
54
+
55
+ def _arr(x) -> "jl.Vector":
56
+ """Convert Python list/ndarray to Julia Vector{Float64}."""
57
+ _init_julia()
58
+ import numpy as np
59
+ arr = np.asarray(x, dtype=np.float64)
60
+ return _jl.convert(_jl.Vector[_jl.Float64], arr.tolist())
61
+
62
+
63
+ def _signals(x) -> "jl.Vector":
64
+ """Convert signal array to Julia Vector{Int}."""
65
+ _init_julia()
66
+ arr = [int(v) for v in x]
67
+ return _jl.convert(_jl.Vector[_jl.Int], arr)
68
+
69
+
70
+ def _pydict(jl_dict) -> dict:
71
+ """Recursively convert Julia Dict to Python dict."""
72
+ out = {}
73
+ for k in jl_dict.keys():
74
+ v = jl_dict[k]
75
+ k_py = str(k)
76
+ if hasattr(v, "keys"):
77
+ out[k_py] = _pydict(v)
78
+ elif hasattr(v, "__iter__") and not isinstance(v, str):
79
+ out[k_py] = list(v)
80
+ elif isinstance(v, bool):
81
+ out[k_py] = bool(v)
82
+ elif hasattr(v, "__float__"):
83
+ out[k_py] = float(v)
84
+ elif hasattr(v, "__int__"):
85
+ out[k_py] = int(v)
86
+ else:
87
+ out[k_py] = v
88
+ return out
89
+
90
+
91
+ # ── Public API ────────────────────────────────────────
92
+
93
+ def full_backtest_pipeline(
94
+ strategy_code: str,
95
+ strategy_name: str,
96
+ open_p: list | np.ndarray,
97
+ high: list | np.ndarray,
98
+ low: list | np.ndarray,
99
+ close: list | np.ndarray,
100
+ volume: list | np.ndarray,
101
+ timeframe: str,
102
+ symbol: str,
103
+ n_windows: int = 5,
104
+ is_ratio: float = 0.70,
105
+ min_trades: int = 30,
106
+ min_sharpe: float = 0.5,
107
+ max_combos: int = 300,
108
+ initial_equity: float = 10_000.0,
109
+ commission_pct: float = 0.0002,
110
+ risk_per_trade: float = 0.01,
111
+ ) -> dict:
112
+ """
113
+ Run full Julia backtest pipeline.
114
+ Compile strategy code β†’ walk-forward optimize β†’ return results dict.
115
+ """
116
+ _init_julia()
117
+ try:
118
+ result = _QE.full_backtest_pipeline(
119
+ strategy_code, strategy_name,
120
+ _arr(open_p), _arr(high), _arr(low),
121
+ _arr(close), _arr(volume),
122
+ timeframe, symbol,
123
+ n_windows = n_windows,
124
+ is_ratio = is_ratio,
125
+ min_trades = min_trades,
126
+ min_sharpe = min_sharpe,
127
+ max_combos = max_combos,
128
+ initial_equity = initial_equity,
129
+ commission_pct = commission_pct,
130
+ risk_per_trade = risk_per_trade,
131
+ )
132
+ return _pydict(result)
133
+ except Exception as e:
134
+ logger.error(f"Julia pipeline error [{strategy_name}]: {e}")
135
+ return {
136
+ "is_valid": False,
137
+ "error": str(e),
138
+ "strategy": strategy_name,
139
+ "symbol": symbol,
140
+ "timeframe": timeframe,
141
+ }
142
+
143
+
144
+ def run_backtest_only(
145
+ open_p: np.ndarray, high: np.ndarray, low: np.ndarray,
146
+ close: np.ndarray, volume: np.ndarray,
147
+ signals: list | np.ndarray,
148
+ timeframe: str = "1h",
149
+ initial_equity: float = 10_000.0,
150
+ commission_pct: float = 0.0002,
151
+ risk_per_trade: float = 0.01,
152
+ ) -> dict:
153
+ """Run a single backtest with pre-computed signals."""
154
+ _init_julia()
155
+ try:
156
+ cfg = _QE.BacktestConfig(
157
+ initial_equity=initial_equity,
158
+ commission_pct=commission_pct,
159
+ risk_per_trade=risk_per_trade,
160
+ )
161
+ r = _QE.run_backtest(
162
+ _arr(open_p), _arr(high), _arr(low),
163
+ _arr(close), _arr(volume),
164
+ _signals(signals), timeframe, cfg,
165
+ )
166
+ return {
167
+ "total_return": float(r.total_return),
168
+ "cagr": float(r.cagr),
169
+ "sharpe": float(r.sharpe),
170
+ "sortino": float(r.sortino),
171
+ "calmar": float(r.calmar),
172
+ "max_dd": float(r.max_dd),
173
+ "n_trades": int(r.n_trades),
174
+ "win_rate": float(r.win_rate),
175
+ "profit_factor": float(r.profit_factor),
176
+ "final_equity": float(r.final_equity),
177
+ "equity_curve": list(r.equity_curve),
178
+ "is_valid": bool(r.is_valid),
179
+ "error": str(r.error_msg),
180
+ }
181
+ except Exception as e:
182
+ return {"is_valid": False, "error": str(e)}
183
+
184
+
185
+ def indicator(name: str, *args, **kwargs):
186
+ """
187
+ Call any indicator by name from Python.
188
+ Returns numpy array.
189
+
190
+ Example:
191
+ rsi_values = indicator("rsi", close_array, 14)
192
+ upper, mid, lower = indicator("bbands", close_array, 20, 2.0)
193
+ """
194
+ _init_julia()
195
+ fn = getattr(_QE, name, None)
196
+ if fn is None:
197
+ raise ValueError(f"Unknown indicator: {name}")
198
+ jl_args = [_arr(a) if isinstance(a, (list, np.ndarray)) else a
199
+ for a in args]
200
+ result = fn(*jl_args, **kwargs)
201
+ if isinstance(result, tuple):
202
+ return tuple(np.array(list(r)) for r in result)
203
+ return np.array(list(result))
204
+
205
+
206
+ def julia_available() -> bool:
207
+ """Check if Julia runtime is available."""
208
+ try:
209
+ _init_julia()
210
+ return _QE is not None
211
+ except Exception:
212
+ return False
pipeline/pdf_processor.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """pipeline/pdf_processor.py β€” PDF text extraction + OCR fallback."""
2
+ import re
3
+ from pathlib import Path
4
+ from typing import Generator
5
+ from dataclasses import dataclass
6
+ import fitz, pdfplumber, pytesseract
7
+ from pdf2image import convert_from_path
8
+ from loguru import logger
9
+ import utils.config as cfg
10
+
11
+
12
+ @dataclass
13
+ class Chunk:
14
+ source_file: str
15
+ page_start: int
16
+ page_end: int
17
+ text: str
18
+ was_ocr: bool = False
19
+
20
+ @property
21
+ def word_count(self): return len(self.text.split())
22
+
23
+
24
+ class PDFProcessor:
25
+ MIN_CHARS = 80
26
+ CHUNK_WORDS = cfg.MAX_TOKENS_PER_CHUNK // 2
27
+
28
+ def process(self, pdf_path: Path) -> Generator[Chunk, None, None]:
29
+ pages = self._extract_pages(pdf_path)
30
+ yield from self._chunk(pages, pdf_path.name)
31
+
32
+ def _extract_pages(self, path):
33
+ mu = {}
34
+ try:
35
+ doc = fitz.open(str(path))
36
+ for i, pg in enumerate(doc): mu[i+1] = self._clean(pg.get_text("text"))
37
+ doc.close()
38
+ except: pass
39
+ pl = {}
40
+ try:
41
+ with pdfplumber.open(str(path)) as pdf:
42
+ for i, pg in enumerate(pdf.pages):
43
+ try: pl[i+1] = self._clean(pg.extract_text() or "")
44
+ except: pl[i+1] = ""
45
+ except: pass
46
+ total = max(len(mu), len(pl), 1)
47
+ results = []; ocr_needed = []
48
+ for pnum in range(1, total+1):
49
+ best = mu.get(pnum,"") if len(mu.get(pnum,"")) > len(pl.get(pnum,"")) else pl.get(pnum,"")
50
+ if len(best) >= self.MIN_CHARS: results.append((pnum, best, False))
51
+ else: results.append((pnum, best, False)); ocr_needed.append(pnum)
52
+ if ocr_needed:
53
+ ocr = self._ocr(path, ocr_needed)
54
+ for i,(pnum,_,_) in enumerate(results):
55
+ if pnum in ocr: results[i] = (pnum, ocr[pnum], True)
56
+ return results
57
+
58
+ def _ocr(self, path, pages):
59
+ out = {}
60
+ try:
61
+ imgs = convert_from_path(str(path), dpi=cfg.OCR_DPI,
62
+ first_page=min(pages), last_page=max(pages))
63
+ for i, pnum in enumerate(range(min(pages), max(pages)+1)):
64
+ if pnum in pages and i < len(imgs):
65
+ out[pnum] = self._clean(pytesseract.image_to_string(imgs[i],lang="eng",config="--psm 6"))
66
+ except Exception as e: logger.warning(f"OCR: {e}")
67
+ return out
68
+
69
+ def _chunk(self, pages, source):
70
+ buf, words, p_start, any_ocr = [], 0, 1, False
71
+ for pnum, text, ocr in pages:
72
+ if not text: continue
73
+ buf.append(text); words += len(text.split())
74
+ if ocr: any_ocr = True
75
+ if words >= self.CHUNK_WORDS:
76
+ yield Chunk(source, p_start, pnum, "\n\n".join(buf), any_ocr)
77
+ buf, words, p_start, any_ocr = [text], len(text.split()), pnum, ocr
78
+ if buf:
79
+ last = pages[-1][0] if pages else p_start
80
+ yield Chunk(source, p_start, last, "\n\n".join(buf), any_ocr)
81
+
82
+ @staticmethod
83
+ def _clean(text):
84
+ if not text: return ""
85
+ text = re.sub(r"(\w)-\n(\w)", r"\1\2", text)
86
+ text = re.sub(r"\n{3,}", "\n\n", text)
87
+ text = re.sub(r"[ \t]+", " ", text)
88
+ return text.strip()
requirements.txt ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # juliacall embeds Julia in Python β€” the actual computation engine
2
+ juliacall>=0.9.23
3
+
4
+ # Gradio UI
5
+ gradio>=5.13.0
6
+
7
+ # Claude API for extraction + strategy compilation
8
+ anthropic>=0.30.0
9
+
10
+ # HuggingFace β€” no version pin, Spaces base image controls this
11
+ huggingface_hub
12
+
13
+ # PDF processing (Python side only)
14
+ pypdf>=4.2.0
15
+ pdfplumber>=0.11.0
16
+ pymupdf>=1.24.0
17
+ pdf2image>=1.17.0
18
+ pytesseract>=0.3.10
19
+ Pillow>=10.3.0
20
+
21
+ # Data (Python side for loading/passing to Julia)
22
+ numpy>=1.26.0
23
+ pandas>=2.2.0
24
+ pyarrow>=16.0.0
25
+
26
+ # Utilities
27
+ loguru>=0.7.2
28
+ pydantic>=2.7.0
29
+ tqdm>=4.66.0
src/BacktestEngine.jl ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BacktestEngine.jl
3
+ ─────────────────────────────────────────────────────
4
+ Vectorized event-driven backtest engine.
5
+
6
+ Inputs:
7
+ close, high, low, volume :: Vector{Float64}
8
+ signals :: Vector{Int8} (1=long, -1=short, 0=flat)
9
+ params :: NamedTuple
10
+
11
+ Returns BacktestResult with all performance metrics.
12
+
13
+ Complexity: O(n) simulation, O(n) metrics.
14
+ All arithmetic is Float64. No allocations inside the hot loop.
15
+ """
16
+ module BacktestEngine
17
+
18
+ using Statistics
19
+
20
+ export run_backtest, BacktestResult, BacktestConfig
21
+
22
+ include("Indicators.jl")
23
+ using .Indicators: atr
24
+
25
+ # ── Config ────────────────────────────────────────────
26
+
27
+ Base.@kwdef struct BacktestConfig
28
+ initial_equity :: Float64 = 10_000.0
29
+ commission_pct :: Float64 = 0.0002 # 0.02% per side
30
+ slippage_pct :: Float64 = 0.0001
31
+ risk_per_trade :: Float64 = 0.01 # 1% per trade
32
+ atr_mult :: Float64 = 2.0 # stop at 2Γ—ATR
33
+ max_pos_pct :: Float64 = 0.20
34
+ atr_period :: Int = 14
35
+ end
36
+
37
+ # ── Result struct ─────────────────────────────────────
38
+
39
+ mutable struct BacktestResult
40
+ # Returns
41
+ total_return :: Float64
42
+ cagr :: Float64
43
+ sharpe :: Float64
44
+ sortino :: Float64
45
+ calmar :: Float64
46
+ max_dd :: Float64
47
+ max_dd_bars :: Int
48
+ # Trades
49
+ n_trades :: Int
50
+ n_wins :: Int
51
+ win_rate :: Float64
52
+ profit_factor :: Float64
53
+ avg_win_pct :: Float64
54
+ avg_loss_pct :: Float64
55
+ expectancy :: Float64
56
+ avg_bars_held :: Float64
57
+ max_consec_wins :: Int
58
+ max_consec_loss :: Int
59
+ # Capital
60
+ final_equity :: Float64
61
+ total_comm :: Float64
62
+ # Data
63
+ equity_curve :: Vector{Float64}
64
+ n_bars :: Int
65
+ is_valid :: Bool
66
+ error_msg :: String
67
+ end
68
+
69
+ function BacktestResult(; n_bars=0, is_valid=false, error_msg="")
70
+ BacktestResult(
71
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
72
+ 0, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, 0,
73
+ 10_000.0, 0.0,
74
+ Float64[],
75
+ n_bars, is_valid, error_msg
76
+ )
77
+ end
78
+
79
+ # ── Bars per year lookup ───────────────────────────────
80
+
81
+ const BARS_PER_YEAR = Dict(
82
+ "1m" => 525_600, "3m" => 175_200, "5m" => 105_120,
83
+ "15m" => 35_040, "30m" => 17_520,
84
+ "1h" => 8_760, "2h" => 4_380, "4h" => 2_190,
85
+ "6h" => 1_460, "12h" => 730,
86
+ "1d" => 252, "1w" => 52,
87
+ )
88
+
89
+ # ── Main engine ───────────────────────────────────────
90
+
91
+ """
92
+ run_backtest(open, high, low, close, volume, signals, timeframe, cfg)
93
+
94
+ Core vectorized backtest. Hot loop is pure Julia β€” no allocations per bar.
95
+ """
96
+ function run_backtest(
97
+ open_p :: Vector{Float64},
98
+ high :: Vector{Float64},
99
+ low :: Vector{Float64},
100
+ close :: Vector{Float64},
101
+ volume :: Vector{Float64},
102
+ signals :: Vector{Int},
103
+ timeframe :: String = "1h",
104
+ cfg :: BacktestConfig = BacktestConfig(),
105
+ ) :: BacktestResult
106
+
107
+ n = length(close)
108
+ n < 50 && return BacktestResult(; n_bars=n, error_msg="Need β‰₯50 bars, got $n")
109
+
110
+ # Pre-compute ATR for position sizing
111
+ atr_v = atr(high, low, close, cfg.atr_period)
112
+
113
+ # ── Simulation ────────────────────────────────────
114
+ equity = cfg.initial_equity
115
+ eq_curve = fill(cfg.initial_equity, n)
116
+
117
+ # Trade tracking (pre-allocate max possible)
118
+ trade_pnls = Vector{Float64}(undef, n Γ· 2 + 1)
119
+ trade_sizes = Vector{Float64}(undef, n Γ· 2 + 1)
120
+ trade_entries = Vector{Float64}(undef, n Γ· 2 + 1)
121
+ trade_bars = Vector{Int}(undef, n Γ· 2 + 1)
122
+ trade_wins = Vector{Bool}(undef, n Γ· 2 + 1)
123
+ n_trades = 0
124
+ total_comm = 0.0
125
+
126
+ # State
127
+ pos = 0 # 1=long, -1=short, 0=flat
128
+ entry_px = 0.0
129
+ pos_sz = 0.0
130
+ stop_px = 0.0
131
+ entry_bar = 1
132
+ last_trade = 0
133
+
134
+ @inbounds for i in 2:n
135
+ px = close[i]
136
+ sig = signals[i]
137
+
138
+ # ── Stop loss check ────────────────────────
139
+ if pos != 0
140
+ hit = (pos == 1 && low[i] <= stop_px) ||
141
+ (pos == -1 && high[i] >= stop_px)
142
+ if hit
143
+ ep = stop_px * (1.0 + cfg.slippage_pct * pos)
144
+ pnl, comm = _close(pos, entry_px, ep, pos_sz, cfg)
145
+ n_trades += 1
146
+ trade_pnls[n_trades] = pnl - comm
147
+ trade_sizes[n_trades] = pos_sz
148
+ trade_entries[n_trades] = entry_px
149
+ trade_bars[n_trades] = i - entry_bar
150
+ trade_wins[n_trades] = pnl > comm
151
+ total_comm += comm
152
+ equity += pnl - comm
153
+ pos = 0
154
+ last_trade = i
155
+ end
156
+ end
157
+
158
+ # ── Exit on signal change ──────────────────
159
+ if pos != 0 && (sig == 0 || sig == -pos)
160
+ ep = px * (1.0 + cfg.slippage_pct * pos)
161
+ pnl, comm = _close(pos, entry_px, ep, pos_sz, cfg)
162
+ n_trades += 1
163
+ trade_pnls[n_trades] = pnl - comm
164
+ trade_sizes[n_trades] = pos_sz
165
+ trade_entries[n_trades] = entry_px
166
+ trade_bars[n_trades] = i - entry_bar
167
+ trade_wins[n_trades] = pnl > comm
168
+ total_comm += comm
169
+ equity += pnl - comm
170
+ pos = 0
171
+ last_trade = i
172
+ end
173
+
174
+ # ── Entry ──────────────────────────────────
175
+ if pos == 0 && sig != 0 && (i - last_trade) >= 1
176
+ ep = px * (1.0 + cfg.slippage_pct * sig)
177
+ a_val = isnan(atr_v[i]) ? px * 0.01 : atr_v[i]
178
+ dist = cfg.atr_mult * a_val
179
+ risk_a = equity * cfg.risk_per_trade
180
+ sz = min(
181
+ risk_a / max(dist, 1e-8),
182
+ equity * cfg.max_pos_pct / ep,
183
+ )
184
+ sz = max(sz, 1e-8)
185
+ pos = sig
186
+ entry_px = ep
187
+ pos_sz = sz
188
+ stop_px = ep - sig * dist
189
+ entry_bar = i
190
+ end
191
+
192
+ # ── Mark-to-market equity ──────────────────
193
+ mtm = pos != 0 ? pos * (px - entry_px) * pos_sz : 0.0
194
+ eq_curve[i] = equity + mtm
195
+ end
196
+
197
+ # Close any open position at end
198
+ if pos != 0
199
+ ep = close[n]
200
+ pnl, comm = _close(pos, entry_px, ep, pos_sz, cfg)
201
+ n_trades += 1
202
+ trade_pnls[n_trades] = pnl - comm
203
+ trade_sizes[n_trades] = pos_sz
204
+ trade_entries[n_trades] = entry_px
205
+ trade_bars[n_trades] = n - entry_bar
206
+ trade_wins[n_trades] = pnl > comm
207
+ total_comm += comm
208
+ equity += pnl - comm
209
+ eq_curve[n] = equity
210
+ end
211
+
212
+ # Trim to actual trade count
213
+ pnls = trade_pnls[1:n_trades]
214
+ wins = trade_wins[1:n_trades]
215
+ bars = trade_bars[1:n_trades]
216
+ ents = trade_entries[1:n_trades]
217
+ szs = trade_sizes[1:n_trades]
218
+
219
+ # ── Compute metrics ────────────────────────────
220
+ return _metrics(
221
+ eq_curve, pnls, wins, bars, ents, szs,
222
+ total_comm, n, timeframe, cfg
223
+ )
224
+ end
225
+
226
+ # ── Helper: close trade ───────────────────────────────
227
+
228
+ @inline function _close(pos, entry, exit_px, sz, cfg)
229
+ gross = pos * (exit_px - entry) * sz
230
+ comm = (entry + exit_px) * sz * cfg.commission_pct
231
+ return gross, comm
232
+ end
233
+
234
+ # ── Metrics computation ───────────────────────────────
235
+
236
+ function _metrics(
237
+ eq :: Vector{Float64},
238
+ pnls :: Vector{Float64},
239
+ wins :: Vector{Bool},
240
+ bars :: Vector{Int},
241
+ entries :: Vector{Float64},
242
+ sizes :: Vector{Float64},
243
+ total_comm :: Float64,
244
+ n_bars :: Int,
245
+ tf :: String,
246
+ cfg :: BacktestConfig,
247
+ ) :: BacktestResult
248
+
249
+ init = cfg.initial_equity
250
+ final = eq[end]
251
+ bpy = get(BARS_PER_YEAR, tf, 252)
252
+
253
+ r = BacktestResult(; n_bars=n_bars, is_valid=true)
254
+ r.equity_curve = eq
255
+ r.final_equity = final
256
+ r.total_comm = total_comm
257
+ r.total_return = (final - init) / init * 100.0
258
+
259
+ # CAGR
260
+ years = n_bars / bpy
261
+ r.cagr = years > 0 && final > 0 ?
262
+ ((final / init)^(1.0/years) - 1.0) * 100.0 : 0.0
263
+
264
+ # Drawdown
265
+ peak = eq[1]
266
+ max_dd = 0.0
267
+ dd_run = 0; max_dd_bars = 0
268
+ for v in eq
269
+ peak = max(peak, v)
270
+ dd = (peak - v) / peak
271
+ max_dd = max(max_dd, dd)
272
+ if v < peak; dd_run += 1; max_dd_bars = max(max_dd_bars, dd_run)
273
+ else; dd_run = 0; end
274
+ end
275
+ r.max_dd = max_dd * 100.0
276
+ r.max_dd_bars = max_dd_bars
277
+
278
+ # Sharpe / Sortino
279
+ rets = diff(eq) ./ eq[1:end-1]
280
+ filter!(!isnan, rets)
281
+ if length(rets) > 1
282
+ mu = mean(rets)
283
+ sig = std(rets)
284
+ ds = std(filter(x -> x < 0, rets))
285
+ af = sqrt(Float64(bpy))
286
+ r.sharpe = sig > 0 ? mu/sig * af : 0.0
287
+ r.sortino = ds > 0 ? mu/ds * af : 0.0
288
+ r.calmar = r.max_dd > 0 ? r.cagr / r.max_dd : 0.0
289
+ end
290
+
291
+ # Trade stats
292
+ n_tr = length(pnls)
293
+ r.n_trades = n_tr
294
+ n_tr == 0 && return r
295
+
296
+ n_win = count(wins)
297
+ r.n_wins = n_win
298
+ r.win_rate = n_win / n_tr * 100.0
299
+
300
+ gw = sum(pnls[wins]); gl = abs(sum(pnls[.!wins]))
301
+ r.profit_factor = gl > 0 ? gw / gl : (gw > 0 ? Inf : 0.0)
302
+
303
+ # Pct returns per trade
304
+ pct = pnls ./ (entries .* sizes .+ 1e-10) .* 100.0
305
+ r.avg_win_pct = n_win > 0 ? mean(pct[wins]) : 0.0
306
+ r.avg_loss_pct = (n_tr-n_win) > 0 ? mean(pct[.!wins]) : 0.0
307
+ r.expectancy = r.win_rate/100.0 * r.avg_win_pct +
308
+ (1 - r.win_rate/100.0) * r.avg_loss_pct
309
+ r.avg_bars_held = mean(Float64.(bars))
310
+
311
+ # Consecutive wins/losses
312
+ r.max_consec_wins = _max_run(wins)
313
+ r.max_consec_loss = _max_run(.!wins)
314
+
315
+ return r
316
+ end
317
+
318
+ function _max_run(bools::Vector{Bool})::Int
319
+ mx = run = 0
320
+ for b in bools
321
+ b ? (run += 1; mx = max(mx, run)) : (run = 0)
322
+ end
323
+ return mx
324
+ end
325
+
326
+ end # module BacktestEngine
src/Indicators.jl ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Indicators.jl
3
+ ─────────────────────────────────────────────────────
4
+ Vectorized technical indicator library.
5
+ All functions operate on Float64 vectors.
6
+ Returns Vector{Float64} of same length, NaN for warmup bars.
7
+
8
+ Performance: all O(n), fully vectorized, allocation-minimal.
9
+ """
10
+ module Indicators
11
+
12
+ using Statistics
13
+
14
+ export sma, ema, wma, tema, dema,
15
+ rsi, macd, stoch, cci, williams_r,
16
+ atr, bbands, keltner, donchian, adx,
17
+ vwap, obv, cmf, zscore, std_dev,
18
+ momentum, roc, highest, lowest,
19
+ crossover, crossunder
20
+
21
+ # ── Utilities ─────────────────────────────────────────
22
+
23
+ """Fill NaN for first `n` elements."""
24
+ function _warmup(v::Vector{Float64}, n::Int)
25
+ result = copy(v)
26
+ result[1:min(n, end)] .= NaN
27
+ return result
28
+ end
29
+
30
+ function _nanfill(n::Int)::Vector{Float64}
31
+ fill(NaN, n)
32
+ end
33
+
34
+ # ── Trend ─────────────────────────────────────────────
35
+
36
+ """Simple Moving Average β€” O(n)"""
37
+ function sma(x::Vector{Float64}, n::Int)::Vector{Float64}
38
+ len = length(x)
39
+ out = fill(NaN, len)
40
+ running = 0.0
41
+ for i in 1:len
42
+ running += x[i]
43
+ if i >= n
44
+ if i > n
45
+ running -= x[i - n]
46
+ end
47
+ out[i] = running / n
48
+ end
49
+ end
50
+ return out
51
+ end
52
+
53
+ """Exponential Moving Average β€” Wilder-style adjusting=false"""
54
+ function ema(x::Vector{Float64}, n::Int)::Vector{Float64}
55
+ len = length(x)
56
+ out = fill(NaN, len)
57
+ k = 2.0 / (n + 1.0)
58
+ # seed with first non-NaN
59
+ seed_i = findfirst(!isnan, x)
60
+ seed_i === nothing && return out
61
+ val = x[seed_i]
62
+ for i in seed_i:len
63
+ if isnan(x[i]); continue; end
64
+ if i == seed_i
65
+ # Build SMA seed over first n valid values
66
+ count = 0; s = 0.0
67
+ for j in seed_i:len
68
+ if !isnan(x[j])
69
+ s += x[j]; count += 1
70
+ if count == n
71
+ val = s / n
72
+ out[j] = val
73
+ for m in (j+1):len
74
+ if !isnan(x[m])
75
+ val = x[m] * k + val * (1.0 - k)
76
+ out[m] = val
77
+ end
78
+ end
79
+ return out
80
+ end
81
+ end
82
+ end
83
+ break
84
+ end
85
+ end
86
+ return out
87
+ end
88
+
89
+ """Weighted Moving Average"""
90
+ function wma(x::Vector{Float64}, n::Int)::Vector{Float64}
91
+ len = length(x)
92
+ out = fill(NaN, len)
93
+ w_sum = n * (n + 1) / 2.0
94
+ for i in n:len
95
+ s = 0.0
96
+ for j in 1:n
97
+ s += x[i - n + j] * j
98
+ end
99
+ out[i] = s / w_sum
100
+ end
101
+ return out
102
+ end
103
+
104
+ """Triple EMA: 3e1 - 3e2 + e3"""
105
+ function tema(x::Vector{Float64}, n::Int)::Vector{Float64}
106
+ e1 = ema(x, n); e2 = ema(e1, n); e3 = ema(e2, n)
107
+ return 3.0 .* e1 .- 3.0 .* e2 .+ e3
108
+ end
109
+
110
+ """Double EMA: 2e1 - e2"""
111
+ function dema(x::Vector{Float64}, n::Int)::Vector{Float64}
112
+ e1 = ema(x, n); e2 = ema(e1, n)
113
+ return 2.0 .* e1 .- e2
114
+ end
115
+
116
+ # ── Oscillators ───────────────────────────────────────
117
+
118
+ """RSI using Wilder's smoothing"""
119
+ function rsi(close::Vector{Float64}, n::Int=14)::Vector{Float64}
120
+ len = length(close)
121
+ out = fill(NaN, len)
122
+ n < 1 && return out
123
+ avg_gain = 0.0; avg_loss = 0.0
124
+ # Seed with first n differences
125
+ for i in 2:(n+1)
126
+ i > len && break
127
+ d = close[i] - close[i-1]
128
+ if d > 0; avg_gain += d; else avg_loss += abs(d); end
129
+ end
130
+ avg_gain /= n; avg_loss /= n
131
+ if n + 1 <= len
132
+ rs = avg_loss == 0 ? 100.0 : avg_gain / avg_loss
133
+ out[n+1] = 100.0 - 100.0 / (1.0 + rs)
134
+ end
135
+ for i in (n+2):len
136
+ d = close[i] - close[i-1]
137
+ g = d > 0 ? d : 0.0; l = d < 0 ? abs(d) : 0.0
138
+ avg_gain = (avg_gain * (n-1) + g) / n
139
+ avg_loss = (avg_loss * (n-1) + l) / n
140
+ rs = avg_loss == 0 ? 100.0 : avg_gain / avg_loss
141
+ out[i] = 100.0 - 100.0 / (1.0 + rs)
142
+ end
143
+ return out
144
+ end
145
+
146
+ """MACD β€” returns (macd_line, signal_line, histogram)"""
147
+ function macd(close::Vector{Float64};
148
+ fast::Int=12, slow::Int=26, sig::Int=9
149
+ )::Tuple{Vector{Float64},Vector{Float64},Vector{Float64}}
150
+ ml = ema(close, fast) .- ema(close, slow)
151
+ sl = ema(ml, sig)
152
+ hist = ml .- sl
153
+ return ml, sl, hist
154
+ end
155
+
156
+ """Stochastic %K and %D"""
157
+ function stoch(high::Vector{Float64}, low::Vector{Float64},
158
+ close::Vector{Float64};
159
+ k::Int=14, d::Int=3
160
+ )::Tuple{Vector{Float64},Vector{Float64}}
161
+ len = length(close)
162
+ K = fill(NaN, len)
163
+ for i in k:len
164
+ hh = maximum(high[i-k+1:i])
165
+ ll = minimum(low[i-k+1:i])
166
+ denom = hh - ll
167
+ K[i] = denom == 0 ? 50.0 : 100.0 * (close[i] - ll) / denom
168
+ end
169
+ D = sma(K, d)
170
+ return K, D
171
+ end
172
+
173
+ """Commodity Channel Index"""
174
+ function cci(high::Vector{Float64}, low::Vector{Float64},
175
+ close::Vector{Float64}, n::Int=20)::Vector{Float64}
176
+ len = length(close)
177
+ tp = (high .+ low .+ close) ./ 3.0
178
+ out = fill(NaN, len)
179
+ for i in n:len
180
+ window = tp[i-n+1:i]
181
+ m = mean(window)
182
+ md = mean(abs.(window .- m))
183
+ out[i] = md == 0 ? 0.0 : (tp[i] - m) / (0.015 * md)
184
+ end
185
+ return out
186
+ end
187
+
188
+ """Williams %R"""
189
+ function williams_r(high::Vector{Float64}, low::Vector{Float64},
190
+ close::Vector{Float64}, n::Int=14)::Vector{Float64}
191
+ len = length(close)
192
+ out = fill(NaN, len)
193
+ for i in n:len
194
+ hh = maximum(high[i-n+1:i])
195
+ ll = minimum(low[i-n+1:i])
196
+ denom = hh - ll
197
+ out[i] = denom == 0 ? -50.0 : -100.0 * (hh - close[i]) / denom
198
+ end
199
+ return out
200
+ end
201
+
202
+ """Rate of Change (%)"""
203
+ function roc(x::Vector{Float64}, n::Int=10)::Vector{Float64}
204
+ len = length(x)
205
+ out = fill(NaN, len)
206
+ for i in (n+1):len
207
+ out[i] = x[i-n] == 0 ? 0.0 : 100.0 * (x[i] - x[i-n]) / x[i-n]
208
+ end
209
+ return out
210
+ end
211
+
212
+ """Momentum"""
213
+ function momentum(x::Vector{Float64}, n::Int=10)::Vector{Float64}
214
+ len = length(x)
215
+ out = fill(NaN, len)
216
+ for i in (n+1):len
217
+ out[i] = x[i] - x[i-n]
218
+ end
219
+ return out
220
+ end
221
+
222
+ # ── Volatility ────────────────────────────────────────
223
+
224
+ """True Range"""
225
+ function _tr(high::Vector{Float64}, low::Vector{Float64},
226
+ close::Vector{Float64})::Vector{Float64}
227
+ len = length(close)
228
+ tr = fill(NaN, len)
229
+ tr[1] = high[1] - low[1]
230
+ for i in 2:len
231
+ tr[i] = max(high[i] - low[i],
232
+ abs(high[i] - close[i-1]),
233
+ abs(low[i] - close[i-1]))
234
+ end
235
+ return tr
236
+ end
237
+
238
+ """Average True Range β€” Wilder's smoothing"""
239
+ function atr(high::Vector{Float64}, low::Vector{Float64},
240
+ close::Vector{Float64}, n::Int=14)::Vector{Float64}
241
+ tr = _tr(high, low, close)
242
+ return ema(tr, n)
243
+ end
244
+
245
+ """Bollinger Bands β€” returns (upper, middle, lower)"""
246
+ function bbands(close::Vector{Float64}, n::Int=20,
247
+ k::Float64=2.0
248
+ )::Tuple{Vector{Float64},Vector{Float64},Vector{Float64}}
249
+ len = length(close)
250
+ mid = sma(close, n)
251
+ sd = fill(NaN, len)
252
+ for i in n:len
253
+ sd[i] = std(close[i-n+1:i]; corrected=false)
254
+ end
255
+ upper = mid .+ k .* sd
256
+ lower = mid .- k .* sd
257
+ return upper, mid, lower
258
+ end
259
+
260
+ """Keltner Channels β€” returns (upper, middle, lower)"""
261
+ function keltner(high::Vector{Float64}, low::Vector{Float64},
262
+ close::Vector{Float64}, n::Int=20,
263
+ k::Float64=2.0
264
+ )::Tuple{Vector{Float64},Vector{Float64},Vector{Float64}}
265
+ mid = ema(close, n)
266
+ a = atr(high, low, close, n)
267
+ upper = mid .+ k .* a
268
+ lower = mid .- k .* a
269
+ return upper, mid, lower
270
+ end
271
+
272
+ """Donchian Channels β€” returns (upper, middle, lower)"""
273
+ function donchian(high::Vector{Float64}, low::Vector{Float64},
274
+ n::Int=20
275
+ )::Tuple{Vector{Float64},Vector{Float64},Vector{Float64}}
276
+ len = length(high)
277
+ upper = fill(NaN, len)
278
+ lower = fill(NaN, len)
279
+ for i in n:len
280
+ upper[i] = maximum(high[i-n+1:i])
281
+ lower[i] = minimum(low[i-n+1:i])
282
+ end
283
+ mid = (upper .+ lower) ./ 2.0
284
+ return upper, mid, lower
285
+ end
286
+
287
+ """Rolling standard deviation"""
288
+ function std_dev(x::Vector{Float64}, n::Int=20)::Vector{Float64}
289
+ len = length(x)
290
+ out = fill(NaN, len)
291
+ for i in n:len
292
+ out[i] = std(x[i-n+1:i]; corrected=false)
293
+ end
294
+ return out
295
+ end
296
+
297
+ """Rolling Z-score"""
298
+ function zscore(x::Vector{Float64}, n::Int=20)::Vector{Float64}
299
+ mu = sma(x, n)
300
+ sd = std_dev(x, n)
301
+ out = fill(NaN, length(x))
302
+ for i in eachindex(x)
303
+ if !isnan(mu[i]) && !isnan(sd[i]) && sd[i] > 0
304
+ out[i] = (x[i] - mu[i]) / sd[i]
305
+ end
306
+ end
307
+ return out
308
+ end
309
+
310
+ # ── Trend Strength ────────────────────────────────────
311
+
312
+ """ADX, +DI, -DI"""
313
+ function adx(high::Vector{Float64}, low::Vector{Float64},
314
+ close::Vector{Float64}, n::Int=14
315
+ )::Tuple{Vector{Float64},Vector{Float64},Vector{Float64}}
316
+ len = length(close)
317
+ tr = _tr(high, low, close)
318
+ up_mv = diff(vcat(high[1], high))
319
+ dn_mv = diff(vcat(low[1], low))
320
+
321
+ pdm = map((u,d) -> (u > d && u > 0) ? u : 0.0, up_mv, dn_mv)
322
+ ndm = map((u,d) -> (d > u && d > 0) ? d : 0.0, up_mv, dn_mv)
323
+
324
+ sm_tr = ema(tr, n)
325
+ sm_pdm = ema(pdm, n)
326
+ sm_ndm = ema(ndm, n)
327
+
328
+ pdi = 100.0 .* sm_pdm ./ (sm_tr .+ 1e-10)
329
+ ndi = 100.0 .* sm_ndm ./ (sm_tr .+ 1e-10)
330
+ dx = 100.0 .* abs.(pdi .- ndi) ./ (pdi .+ ndi .+ 1e-10)
331
+ adx_v = ema(dx, n)
332
+
333
+ return adx_v, pdi, ndi
334
+ end
335
+
336
+ # ── Volume ────────────────────────────────────────────
337
+
338
+ """VWAP (session-based, cumulative)"""
339
+ function vwap(high::Vector{Float64}, low::Vector{Float64},
340
+ close::Vector{Float64}, volume::Vector{Float64})::Vector{Float64}
341
+ tp = (high .+ low .+ close) ./ 3.0
342
+ cum_pv = cumsum(tp .* volume)
343
+ cum_vol = cumsum(volume)
344
+ return cum_pv ./ (cum_vol .+ 1e-10)
345
+ end
346
+
347
+ """On-Balance Volume"""
348
+ function obv(close::Vector{Float64}, volume::Vector{Float64})::Vector{Float64}
349
+ len = length(close)
350
+ out = zeros(Float64, len)
351
+ out[1] = volume[1]
352
+ for i in 2:len
353
+ d = close[i] - close[i-1]
354
+ out[i] = out[i-1] + (d > 0 ? volume[i] : d < 0 ? -volume[i] : 0.0)
355
+ end
356
+ return out
357
+ end
358
+
359
+ """Chaikin Money Flow"""
360
+ function cmf(high::Vector{Float64}, low::Vector{Float64},
361
+ close::Vector{Float64}, volume::Vector{Float64},
362
+ n::Int=20)::Vector{Float64}
363
+ len = length(close)
364
+ out = fill(NaN, len)
365
+ hl = high .- low
366
+ mf_mult = map((c,l,h,hl) -> hl == 0 ? 0.0 : (2c - l - h) / hl,
367
+ close, low, high, hl)
368
+ mf_vol = mf_mult .* volume
369
+ for i in n:len
370
+ sv = sum(volume[i-n+1:i])
371
+ out[i] = sv == 0 ? 0.0 : sum(mf_vol[i-n+1:i]) / sv
372
+ end
373
+ return out
374
+ end
375
+
376
+ # ── Rolling utilities ─────────────────────────────────
377
+
378
+ """Rolling maximum"""
379
+ function highest(x::Vector{Float64}, n::Int)::Vector{Float64}
380
+ len = length(x)
381
+ out = fill(NaN, len)
382
+ for i in n:len
383
+ out[i] = maximum(x[i-n+1:i])
384
+ end
385
+ return out
386
+ end
387
+
388
+ """Rolling minimum"""
389
+ function lowest(x::Vector{Float64}, n::Int)::Vector{Float64}
390
+ len = length(x)
391
+ out = fill(NaN, len)
392
+ for i in n:len
393
+ out[i] = minimum(x[i-n+1:i])
394
+ end
395
+ return out
396
+ end
397
+
398
+ """Crossover: a crosses above b"""
399
+ function crossover(a::Vector{Float64}, b::Vector{Float64})::Vector{Bool}
400
+ len = length(a)
401
+ out = fill(false, len)
402
+ for i in 2:len
403
+ out[i] = a[i] > b[i] && a[i-1] <= b[i-1]
404
+ end
405
+ return out
406
+ end
407
+
408
+ """Crossunder: a crosses below b"""
409
+ function crossunder(a::Vector{Float64}, b::Vector{Float64})::Vector{Bool}
410
+ len = length(a)
411
+ out = fill(false, len)
412
+ for i in 2:len
413
+ out[i] = a[i] < b[i] && a[i-1] >= b[i-1]
414
+ end
415
+ return out
416
+ end
417
+
418
+ end # module Indicators
src/Manifest.toml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file is machine-generated - editing it directly is not recommended
2
+ julia_version = "1.10.7"
3
+ manifest_format = "2.0"
4
+ project_hash = "abc123"
5
+
6
+ [[deps.JSON3]]
7
+ deps = ["Dates", "Mmap", "Parsers", "PrecompileTools", "StructTypes", "UUIDs"]
8
+ git-tree-sha1 = "eb3edce0ed4fa32f75a0a11217433c31d56bd48b"
9
+ uuid = "0f8b85d8-7e73-4b43-9b43-f8e4f07d6bcd"
10
+ version = "1.14.0"
11
+
12
+ [[deps.Random]]
13
+ deps = ["SHA"]
14
+ uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
15
+
16
+ [[deps.Statistics]]
17
+ deps = ["LinearAlgebra", "SparseArrays"]
18
+ uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
19
+ version = "1.10.0"
src/Optimizer.jl ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Optimizer.jl
3
+ ─────────────────────────────────────────────────────
4
+ Walk-forward optimization engine.
5
+
6
+ Algorithm:
7
+ 1. Split data into N anchored windows
8
+ 2. Per window: grid search on IS β†’ pick best Sharpe params
9
+ 3. Test best params OOS β†’ record result
10
+ 4. Final params: Sharpe-weighted median vote across windows
11
+ 5. Compute WF efficiency, robustness score, viability
12
+
13
+ All heavy computation runs in Julia.
14
+ Python receives a Dict with the final results.
15
+ """
16
+ module Optimizer
17
+
18
+ using Statistics
19
+ using Random
20
+
21
+ export walk_forward_optimize, OptimResult
22
+
23
+ include("BacktestEngine.jl")
24
+ using .BacktestEngine: run_backtest, BacktestConfig, BacktestResult
25
+
26
+ # ── Result struct ─────────────────────────────────────
27
+
28
+ mutable struct OptimResult
29
+ strategy_name :: String
30
+ symbol :: String
31
+ timeframe :: String
32
+ optimal_params :: Dict{String, Float64}
33
+ oos_sharpe_mean:: Float64
34
+ oos_sharpe_std :: Float64
35
+ oos_win_rate :: Float64
36
+ oos_max_dd :: Float64
37
+ oos_pf_mean :: Float64
38
+ oos_trades :: Int
39
+ wf_efficiency :: Float64
40
+ robustness :: Float64
41
+ is_viable :: Bool
42
+ reasons :: Vector{String}
43
+ oos_sharpes :: Vector{Float64} # per-window OOS Sharpes
44
+ end
45
+
46
+ function OptimResult(name, symbol, tf)
47
+ OptimResult(
48
+ name, symbol, tf,
49
+ Dict{String,Float64}(),
50
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0, 0.0, 0.0, false,
51
+ String[], Float64[]
52
+ )
53
+ end
54
+
55
+ # ── Main optimizer ────────────────────────────────────
56
+
57
+ """
58
+ walk_forward_optimize(signal_fn, param_grid, open_p, high, low, close, volume,
59
+ timeframe, strategy_name, symbol;
60
+ n_windows, is_ratio, min_trades, min_sharpe,
61
+ max_combinations, cfg)
62
+
63
+ signal_fn :: (open, high, low, close, volume, params) -> Vector{Int}
64
+ param_grid :: Dict{String, Vector{Float64}}
65
+ """
66
+ function walk_forward_optimize(
67
+ signal_fn :: Function,
68
+ param_grid :: Dict{String, Vector{Float64}},
69
+ open_p :: Vector{Float64},
70
+ high :: Vector{Float64},
71
+ low :: Vector{Float64},
72
+ close :: Vector{Float64},
73
+ volume :: Vector{Float64},
74
+ timeframe :: String,
75
+ strategy_name :: String,
76
+ symbol :: String;
77
+ n_windows :: Int = 5,
78
+ is_ratio :: Float64 = 0.70,
79
+ min_trades :: Int = 30,
80
+ min_sharpe :: Float64 = 0.5,
81
+ max_combos :: Int = 300,
82
+ cfg :: BacktestConfig = BacktestConfig(),
83
+ ) :: OptimResult
84
+
85
+ result = OptimResult(strategy_name, symbol, timeframe)
86
+ n = length(close)
87
+
88
+ n < 200 && (push!(result.reasons, "Need β‰₯200 bars, got $n"); return result)
89
+ isempty(param_grid) && (param_grid = Dict{String,Float64}())
90
+
91
+ combos = _build_combos(param_grid, max_combos)
92
+ windows = _build_windows(n, n_windows)
93
+ isempty(windows) && (push!(result.reasons, "Could not build WF windows"); return result)
94
+
95
+ win_params = Vector{Dict{String,Float64}}()
96
+ is_sharpes = Float64[]
97
+ oos_sharpes = Float64[]
98
+ oos_results = Vector{BacktestResult}()
99
+
100
+ for (is_s, is_e, oos_s, oos_e) in windows
101
+ is_o = open_p[is_s:is_e]; is_h = high[is_s:is_e]
102
+ is_l = low[is_s:is_e]; is_c = close[is_s:is_e]
103
+ is_v = volume[is_s:is_e]
104
+
105
+ best_p = nothing; best_sh = -Inf
106
+
107
+ for params in combos
108
+ r = _run_one(signal_fn, is_o, is_h, is_l, is_c, is_v,
109
+ params, timeframe, strategy_name, symbol, cfg)
110
+ if r.is_valid && r.n_trades >= min_trades && r.sharpe > best_sh
111
+ best_sh = r.sharpe; best_p = params
112
+ end
113
+ end
114
+
115
+ best_p === nothing && continue
116
+
117
+ push!(win_params, best_p)
118
+ push!(is_sharpes, best_sh)
119
+
120
+ oos_o = open_p[oos_s:oos_e]; oos_h = high[oos_s:oos_e]
121
+ oos_l = low[oos_s:oos_e]; oos_c = close[oos_s:oos_e]
122
+ oos_v = volume[oos_s:oos_e]
123
+
124
+ oos_r = _run_one(signal_fn, oos_o, oos_h, oos_l, oos_c, oos_v,
125
+ best_p, timeframe, strategy_name, symbol, cfg)
126
+ push!(oos_results, oos_r)
127
+ push!(oos_sharpes, oos_r.sharpe)
128
+ end
129
+
130
+ isempty(oos_results) && (push!(result.reasons, "No valid WF windows"); return result)
131
+
132
+ # ── Aggregate OOS metrics ─────────────────────────
133
+ valid_oos = filter(r -> r.is_valid && r.n_trades >= min_trades, oos_results)
134
+ result.oos_sharpes = oos_sharpes
135
+
136
+ if !isempty(valid_oos)
137
+ sh = [r.sharpe for r in valid_oos]
138
+ result.oos_sharpe_mean = mean(sh)
139
+ result.oos_sharpe_std = std(sh)
140
+ result.oos_win_rate = mean([r.win_rate for r in valid_oos])
141
+ result.oos_max_dd = mean([r.max_dd for r in valid_oos])
142
+ result.oos_pf_mean = mean(filter(x->x<100, [r.profit_factor for r in valid_oos]))
143
+ result.oos_trades = sum(r.n_trades for r in valid_oos)
144
+ end
145
+
146
+ # WF efficiency
147
+ if !isempty(is_sharpes) && !isempty(oos_sharpes)
148
+ mean_is = mean(is_sharpes)
149
+ mean_oos = mean(oos_sharpes)
150
+ result.wf_efficiency = mean_is > 0 ? mean_oos / mean_is : 0.0
151
+ end
152
+
153
+ # Optimal params via Sharpe-weighted median vote
154
+ result.optimal_params = _vote_params(win_params, oos_sharpes)
155
+
156
+ # Robustness & viability
157
+ result.robustness = _robustness(result, min_trades)
158
+ result.is_viable, result.reasons =
159
+ _viability(result, min_trades, min_sharpe)
160
+
161
+ return result
162
+ end
163
+
164
+ # ── Single backtest run ───────────────────────────────
165
+
166
+ function _run_one(signal_fn, o, h, l, c, v, params, tf, name, sym, cfg)
167
+ try
168
+ sigs = signal_fn(o, h, l, c, v, params)
169
+ return run_backtest(o, h, l, c, v, sigs, tf, cfg)
170
+ catch e
171
+ return BacktestResult(; error_msg=string(e))
172
+ end
173
+ end
174
+
175
+ # ── Build parameter combinations ─────────────────────
176
+
177
+ function _build_combos(
178
+ grid :: Dict{String, Vector{Float64}},
179
+ max_c :: Int,
180
+ ) :: Vector{Dict{String, Float64}}
181
+
182
+ isempty(grid) && return [Dict{String,Float64}()]
183
+
184
+ keys_ = collect(keys(grid))
185
+ vals_ = [grid[k] for k in keys_]
186
+ n_all = prod(length(v) for v in vals_)
187
+
188
+ # Cartesian product
189
+ all_c = Vector{Dict{String,Float64}}()
190
+ sizehint!(all_c, min(n_all, max_c))
191
+
192
+ indices = ones(Int, length(keys_))
193
+ for _ in 1:n_all
194
+ d = Dict(keys_[j] => vals_[j][indices[j]] for j in eachindex(keys_))
195
+ push!(all_c, d)
196
+ # Increment indices
197
+ for j in length(indices):-1:1
198
+ indices[j] += 1
199
+ if indices[j] <= length(vals_[j]); break
200
+ else; indices[j] = 1; end
201
+ end
202
+ end
203
+
204
+ # Random sample if too many
205
+ if length(all_c) > max_c
206
+ idx = randperm(length(all_c))[1:max_c]
207
+ all_c = all_c[idx]
208
+ end
209
+
210
+ return all_c
211
+ end
212
+
213
+ # ── Build anchored walk-forward windows ───────────────
214
+
215
+ function _build_windows(n::Int, n_win::Int
216
+ )::Vector{Tuple{Int,Int,Int,Int}}
217
+ oos_sz = max(50, n Γ· (n_win * 2))
218
+ windows = Tuple{Int,Int,Int,Int}[]
219
+ for i in 0:(n_win-1)
220
+ oos_e = n - i * oos_sz
221
+ oos_s = oos_e - oos_sz + 1
222
+ is_e = oos_s - 1
223
+ is_s = 1
224
+ is_e - is_s < 100 && continue
225
+ oos_e - oos_s < 50 && continue
226
+ push!(windows, (is_s, is_e, oos_s, oos_e))
227
+ end
228
+ return reverse(windows) # chronological order
229
+ end
230
+
231
+ # ── Sharpe-weighted median vote ───────────────────────
232
+
233
+ function _vote_params(
234
+ param_list :: Vector{Dict{String,Float64}},
235
+ oos_sharpes :: Vector{Float64},
236
+ ) :: Dict{String, Float64}
237
+
238
+ isempty(param_list) && return Dict{String,Float64}()
239
+ length(param_list) == 1 && return param_list[1]
240
+
241
+ weights = max.(0.0, oos_sharpes[1:length(param_list)])
242
+ total_w = sum(weights)
243
+ weights = total_w > 0 ? weights ./ total_w :
244
+ fill(1.0/length(param_list), length(param_list))
245
+
246
+ keys_ = collect(keys(param_list[1]))
247
+ result = Dict{String,Float64}()
248
+
249
+ for k in keys_
250
+ vals = [p[k] for p in param_list if haskey(p, k)]
251
+ w = weights[1:length(vals)]
252
+ # Weighted median
253
+ si = sortperm(vals)
254
+ cv = cumsum(w[si])
255
+ mi = findfirst(x -> x >= 0.5, cv)
256
+ result[k] = vals[si[mi !== nothing ? mi : end]]
257
+ end
258
+
259
+ return result
260
+ end
261
+
262
+ # ── Robustness score (0-100) ──────────────────────────
263
+
264
+ function _robustness(r::OptimResult, min_trades::Int) :: Float64
265
+ s = 0.0
266
+ s += clamp(r.wf_efficiency, 0.0, 1.0) * 40.0
267
+ if r.oos_sharpe_mean > 0
268
+ cv = r.oos_sharpe_std / (r.oos_sharpe_mean + 1e-9)
269
+ s += clamp(1.0 - cv, 0.0, 1.0) * 30.0
270
+ end
271
+ s += clamp(r.oos_trades / max(1, min_trades * 10), 0.0, 1.0) * 20.0
272
+ r.oos_pf_mean > 1 && (s += clamp((r.oos_pf_mean-1)/2, 0.0, 1.0) * 10.0)
273
+ return round(s; digits=1)
274
+ end
275
+
276
+ # ── Viability assessment ──────────────────────────────
277
+
278
+ function _viability(
279
+ r :: OptimResult,
280
+ min_trades :: Int,
281
+ min_sharpe :: Float64,
282
+ ) :: Tuple{Bool, Vector{String}}
283
+
284
+ reasons = String[]
285
+ r.oos_sharpe_mean < min_sharpe &&
286
+ push!(reasons, "OOS Sharpe $(round(r.oos_sharpe_mean;digits=2)) < $min_sharpe")
287
+ r.oos_trades < min_trades &&
288
+ push!(reasons, "Too few OOS trades: $(r.oos_trades) < $min_trades")
289
+ r.oos_max_dd > 30.0 &&
290
+ push!(reasons, "High avg drawdown: $(round(r.oos_max_dd;digits=1))%")
291
+ r.wf_efficiency < 0.3 &&
292
+ push!(reasons, "Low WF efficiency: $(round(r.wf_efficiency;digits=2))")
293
+ r.oos_pf_mean < 1.1 &&
294
+ push!(reasons, "Profit factor $(round(r.oos_pf_mean;digits=2)) < 1.1")
295
+
296
+ viable = isempty(reasons)
297
+ if viable
298
+ push!(reasons,
299
+ "βœ… Sharpe=$(round(r.oos_sharpe_mean;digits=2)) " *
300
+ "DD=$(round(r.oos_max_dd;digits=1))% " *
301
+ "WFE=$(round(r.wf_efficiency;digits=2)) " *
302
+ "Score=$(r.robustness)/100")
303
+ end
304
+ return viable, reasons
305
+ end
306
+
307
+ end # module Optimizer
src/Project.toml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [deps]
2
+ JSON3 = "0f8b85d8-7e73-4b43-9b43-f8e4f07d6bcd"
3
+ Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
4
+ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
5
+
6
+ [compat]
7
+ julia = "1.10"
src/QuantEngine.jl ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ QuantEngine.jl
3
+ ─────────────────────────────────────────────────────
4
+ Top-level module. This is what Python imports via juliacall.
5
+
6
+ Exposes a clean, flat API that python can call with simple types
7
+ (arrays of Float64, Dicts, Strings) β€” no Julia-specific types
8
+ crossing the boundary.
9
+ """
10
+ module QuantEngine
11
+
12
+ using Statistics
13
+
14
+ include("Indicators.jl")
15
+ include("BacktestEngine.jl")
16
+ include("Optimizer.jl")
17
+ include("SignalCompiler.jl")
18
+
19
+ using .Indicators
20
+ using .BacktestEngine
21
+ using .Optimizer
22
+ using .SignalCompiler
23
+
24
+ export
25
+ # Indicators (all return Vector{Float64})
26
+ sma, ema, wma, tema, dema,
27
+ rsi, macd, stoch, cci, williams_r,
28
+ atr, bbands, keltner, donchian, adx,
29
+ vwap, obv, cmf, zscore, std_dev,
30
+ momentum, roc, highest, lowest,
31
+ crossover, crossunder,
32
+ # Engine
33
+ run_backtest, BacktestConfig,
34
+ # Optimizer
35
+ walk_forward_optimize,
36
+ # Compiler
37
+ compile_strategy,
38
+ # High-level pipeline
39
+ full_backtest_pipeline
40
+
41
+ # ── High-level pipeline function ─────────────────────
42
+ """
43
+ full_backtest_pipeline(strategy_code, strategy_name,
44
+ open_p, high, low, close, volume,
45
+ timeframe, symbol;
46
+ kwargs...) -> Dict
47
+
48
+ End-to-end: compile β†’ optimize β†’ return result as a plain Dict
49
+ that crosses cleanly to Python via juliacall.
50
+ """
51
+ function full_backtest_pipeline(
52
+ strategy_code :: String,
53
+ strategy_name :: String,
54
+ open_p :: Vector{Float64},
55
+ high :: Vector{Float64},
56
+ low :: Vector{Float64},
57
+ close :: Vector{Float64},
58
+ volume :: Vector{Float64},
59
+ timeframe :: String,
60
+ symbol :: String;
61
+ n_windows :: Int = 5,
62
+ is_ratio :: Float64 = 0.70,
63
+ min_trades :: Int = 30,
64
+ min_sharpe :: Float64 = 0.5,
65
+ max_combos :: Int = 300,
66
+ initial_equity :: Float64 = 10_000.0,
67
+ commission_pct :: Float64 = 0.0002,
68
+ risk_per_trade :: Float64 = 0.01,
69
+ ) :: Dict{String, Any}
70
+
71
+ # 1. Compile strategy
72
+ compiled = compile_strategy(strategy_name, strategy_code)
73
+ if !compiled.is_valid
74
+ return Dict{String,Any}(
75
+ "is_valid" => false,
76
+ "error" => compiled.error,
77
+ "strategy" => strategy_name,
78
+ "symbol" => symbol,
79
+ "timeframe" => timeframe,
80
+ )
81
+ end
82
+
83
+ # 2. Walk-forward optimize
84
+ cfg = BacktestConfig(
85
+ initial_equity = initial_equity,
86
+ commission_pct = commission_pct,
87
+ risk_per_trade = risk_per_trade,
88
+ )
89
+
90
+ opt = walk_forward_optimize(
91
+ compiled.generate_fn,
92
+ compiled.param_grid_fn(),
93
+ open_p, high, low, close, volume,
94
+ timeframe, strategy_name, symbol;
95
+ n_windows = n_windows,
96
+ is_ratio = is_ratio,
97
+ min_trades = min_trades,
98
+ min_sharpe = min_sharpe,
99
+ max_combos = max_combos,
100
+ cfg = cfg,
101
+ )
102
+
103
+ # 3. Return as plain Dict (crosses Python boundary cleanly)
104
+ return Dict{String,Any}(
105
+ "is_valid" => true,
106
+ "strategy" => opt.strategy_name,
107
+ "symbol" => opt.symbol,
108
+ "timeframe" => opt.timeframe,
109
+ "optimal_params" => opt.optimal_params,
110
+ "oos_sharpe_mean" => opt.oos_sharpe_mean,
111
+ "oos_sharpe_std" => opt.oos_sharpe_std,
112
+ "oos_win_rate" => opt.oos_win_rate,
113
+ "oos_max_dd" => opt.oos_max_dd,
114
+ "oos_pf_mean" => opt.oos_pf_mean,
115
+ "oos_trades" => opt.oos_trades,
116
+ "wf_efficiency" => opt.wf_efficiency,
117
+ "robustness" => opt.robustness,
118
+ "is_viable" => opt.is_viable,
119
+ "reasons" => opt.reasons,
120
+ "oos_sharpes" => opt.oos_sharpes,
121
+ )
122
+ end
123
+
124
+ end # module QuantEngine
src/SignalCompiler.jl ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SignalCompiler.jl
3
+ ─────────────────────────────────────────────────────
4
+ Compiles AI-generated Julia strategy code into callable functions.
5
+
6
+ Sandbox design:
7
+ - Creates a fresh anonymous Module per strategy
8
+ - Injects all Indicators functions as module-level constants
9
+ - Injects safe Base/Statistics functions
10
+ - No `using` statements inside sandbox (avoids relative-path issues)
11
+ - Validates both required functions exist
12
+ - Smoke-tests with synthetic 200-bar OHLCV data
13
+ """
14
+ module SignalCompiler
15
+
16
+ using Statistics, Random
17
+
18
+ export compile_strategy, CompiledStrategy
19
+
20
+ include("Indicators.jl")
21
+ using .Indicators
22
+
23
+ # ── Strategy handle ───────────────────────────────────
24
+
25
+ struct CompiledStrategy
26
+ name :: String
27
+ generate_fn :: Function
28
+ param_grid_fn :: Function
29
+ is_valid :: Bool
30
+ error :: String
31
+ end
32
+
33
+ CompiledStrategy(name::String; error::String="") =
34
+ CompiledStrategy(name,
35
+ (o,h,l,c,v,p) -> zeros(Int, length(c)),
36
+ () -> Dict{String,Vector{Float64}}(),
37
+ false, error)
38
+
39
+ # ── Compiler ──────────────────────────────────────────
40
+
41
+ """
42
+ compile_strategy(name, code) -> CompiledStrategy
43
+
44
+ Evaluate AI-generated Julia code in a sandboxed Module.
45
+ All Indicators functions are pre-injected so the strategy code
46
+ can call sma(), rsi() etc. directly without any import statement.
47
+ """
48
+ function compile_strategy(name::String, code::String) :: CompiledStrategy
49
+ safe_name = replace(replace(name, " " => "_"), r"[^\w]" => "x")
50
+ sandbox = Module(Symbol("Strat_" * safe_name * "_" * string(rand(UInt16), base=16)))
51
+
52
+ # ── Pre-inject Indicators ─────────────────────────
53
+ for fn_name in names(Indicators; all=false)
54
+ fn_name == :Indicators && continue
55
+ if isdefined(Indicators, fn_name)
56
+ try
57
+ Core.eval(sandbox,
58
+ Expr(:const, Expr(:(=), fn_name, getfield(Indicators, fn_name))))
59
+ catch; end
60
+ end
61
+ end
62
+
63
+ # ── Pre-inject Statistics ─────────────────────────
64
+ for sym in (:mean, :std, :var, :median, :cor, :cov)
65
+ isdefined(Statistics, sym) &&
66
+ Core.eval(sandbox,
67
+ Expr(:const, Expr(:(=), sym, getfield(Statistics, sym))))
68
+ end
69
+
70
+ # ── Pre-inject safe Base functions ───────────────
71
+ for sym in (
72
+ :length, :size, :zeros, :ones, :fill, :similar,
73
+ :sum, :prod, :diff, :cumsum, :cumprod,
74
+ :max, :min, :abs, :sqrt, :log, :exp, :floor, :ceil, :round, :clamp,
75
+ :isnan, :isinf, :isfinite, :sign,
76
+ :sort, :sortperm, :reverse, :unique, :findall, :findfirst,
77
+ :push!, :append!, :pop!, :first, :last, :eachindex,
78
+ :map, :filter, :any, :all, :count,
79
+ :Int, :Float64, :Bool,
80
+ :Dict, :Vector, :Tuple, :Set,
81
+ :NaN, :Inf, :pi, :true, :false,
82
+ :println, :string, :@inbounds,
83
+ )
84
+ try
85
+ val = sym in (:NaN, :Inf, :pi, :true, :false) ?
86
+ getfield(Base, sym) : getfield(Base, sym)
87
+ Core.eval(sandbox, Expr(:const, Expr(:(=), sym, val)))
88
+ catch; end
89
+ end
90
+
91
+ # ── Parse and evaluate generated code ────────────
92
+ parsed = try
93
+ Meta.parseall(code)
94
+ catch e
95
+ return CompiledStrategy(name; error="Parse error: $(sprint(showerror, e))")
96
+ end
97
+
98
+ try
99
+ Core.eval(sandbox, parsed)
100
+ catch e
101
+ return CompiledStrategy(name; error="Eval error: $(sprint(showerror, e))")
102
+ end
103
+
104
+ # ── Validate required functions ───────────────────
105
+ isdefined(sandbox, :get_param_grid) ||
106
+ return CompiledStrategy(name;
107
+ error="Missing: get_param_grid() -> Dict{String,Vector{Float64}}")
108
+ isdefined(sandbox, :generate_signals) ||
109
+ return CompiledStrategy(name;
110
+ error="Missing: generate_signals(o,h,l,c,v,params) -> Vector{Int}")
111
+
112
+ gen_fn = getfield(sandbox, :generate_signals)
113
+ grid_fn = getfield(sandbox, :get_param_grid)
114
+
115
+ # ── Smoke test ────────────────────────────────────
116
+ err = _smoke_test(gen_fn, grid_fn)
117
+ err != "" && return CompiledStrategy(name; error=err)
118
+
119
+ return CompiledStrategy(name, gen_fn, grid_fn, true, "")
120
+ end
121
+
122
+ # ── Smoke test ────────────────────────────────────────
123
+
124
+ function _smoke_test(gen_fn, grid_fn) :: String
125
+ try
126
+ grid = grid_fn()
127
+ grid isa Dict || return "get_param_grid() must return Dict"
128
+ params = Dict{String,Float64}(
129
+ k => (v isa Vector && !isempty(v) ? Float64(v[1]) : 0.0)
130
+ for (k,v) in grid)
131
+
132
+ n = 200
133
+ c = 100.0 .* exp.(cumsum(randn(n) .* 0.005))
134
+ h = c .* (1.0 .+ abs.(randn(n)) .* 0.003)
135
+ l = c .* (1.0 .- abs.(randn(n)) .* 0.003)
136
+ o = c .* (1.0 .+ randn(n) .* 0.001)
137
+ v = abs.(randn(n)) .* 1000.0 .+ 500.0
138
+
139
+ sigs = gen_fn(o, h, l, c, v, params)
140
+
141
+ sigs isa Vector || return "generate_signals must return Vector, got $(typeof(sigs))"
142
+ length(sigs) != n && return "Signal length $(length(sigs)) β‰  $n"
143
+ any(s -> !(s in (-1, 0, 1)), sigs) &&
144
+ return "Signal values must be in {-1,0,1}, got $(unique(sigs))"
145
+ catch e
146
+ return "Runtime: $(sprint(showerror, e))"
147
+ end
148
+ return ""
149
+ end
150
+
151
+ end # module SignalCompiler
src/strategy_template.jl ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ═══════════════════════════════════════════════════════════════════
2
+ # JULIA STRATEGY TEMPLATE
3
+ # This is the exact format Claude generates for each strategy.
4
+ # Two functions required. No module/using declarations needed β€”
5
+ # all Indicators functions are pre-injected by SignalCompiler.jl.
6
+ # ═══════════════════════════════════════════════════════════════════
7
+
8
+ # ── Example: EMA Crossover Strategy ─────────────────────────────────
9
+
10
+ """
11
+ Return parameter ranges for walk-forward grid search.
12
+ Keys must be valid Julia identifiers. Values are Float64 ranges.
13
+ """
14
+ function get_param_grid() :: Dict{String, Vector{Float64}}
15
+ return Dict(
16
+ "fast_period" => [10.0, 15.0, 20.0, 25.0],
17
+ "slow_period" => [40.0, 50.0, 60.0, 80.0],
18
+ "atr_filter" => [14.0], # single value = no optimization
19
+ )
20
+ end
21
+
22
+ """
23
+ Generate trading signals from OHLCV arrays.
24
+
25
+ Arguments (all same length n):
26
+ open_p, high, low, close, volume :: Vector{Float64}
27
+ params :: Dict{String,Float64} β€” one value per key from get_param_grid()
28
+
29
+ Returns Vector{Int} of length n:
30
+ 1 = enter/hold long
31
+ -1 = enter/hold short
32
+ 0 = flat / no position
33
+
34
+ Rules:
35
+ - Return 0 for the first ~slow_period bars (warmup / NaN period)
36
+ - Always use isnan() checks before comparisons
37
+ - Signals are position signals, not entry triggers
38
+ (engine manages entries/exits from signal transitions)
39
+ """
40
+ function generate_signals(
41
+ open_p :: Vector{Float64},
42
+ high :: Vector{Float64},
43
+ low :: Vector{Float64},
44
+ close :: Vector{Float64},
45
+ volume :: Vector{Float64},
46
+ params :: Dict{String, Float64},
47
+ ) :: Vector{Int}
48
+
49
+ n = length(close)
50
+ fast_p = Int(round(get(params, "fast_period", 20.0)))
51
+ slow_p = Int(round(get(params, "slow_period", 50.0)))
52
+ atr_p = Int(round(get(params, "atr_filter", 14.0)))
53
+
54
+ fast_ema = ema(close, fast_p)
55
+ slow_ema = ema(close, slow_p)
56
+ atr_vals = atr(high, low, close, atr_p)
57
+
58
+ signals = zeros(Int, n)
59
+
60
+ for i in (slow_p + 1):n
61
+ # Skip if any indicator is NaN (still in warmup)
62
+ isnan(fast_ema[i]) && continue
63
+ isnan(slow_ema[i]) && continue
64
+ isnan(atr_vals[i]) && continue
65
+
66
+ # Optional: ATR volatility filter β€” only trade when market is moving
67
+ atr_threshold = close[i] * 0.001 # 0.1% of price
68
+ atr_vals[i] < atr_threshold && continue
69
+
70
+ if fast_ema[i] > slow_ema[i]
71
+ signals[i] = 1 # bullish: long
72
+ elseif fast_ema[i] < slow_ema[i]
73
+ signals[i] = -1 # bearish: short
74
+ else
75
+ signals[i] = 0 # neutral
76
+ end
77
+ end
78
+
79
+ return signals
80
+ end
81
+
82
+ # ═══════════════════════════════════════════════════════════════════
83
+ # Example 2: RSI Mean Reversion
84
+ # ═══════════════════════════════════════════════════════════════════
85
+
86
+ # function get_param_grid()
87
+ # return Dict(
88
+ # "rsi_period" => [7.0, 10.0, 14.0, 21.0],
89
+ # "oversold" => [25.0, 30.0, 35.0],
90
+ # "overbought" => [65.0, 70.0, 75.0],
91
+ # "ma_period" => [20.0, 50.0],
92
+ # )
93
+ # end
94
+ #
95
+ # function generate_signals(open_p, high, low, close, volume, params)
96
+ # n = length(close)
97
+ # rsi_p = Int(round(get(params, "rsi_period", 14.0)))
98
+ # oversold = get(params, "oversold", 30.0)
99
+ # overbought = get(params, "overbought", 70.0)
100
+ # ma_p = Int(round(get(params, "ma_period", 50.0)))
101
+ #
102
+ # rsi_vals = rsi(close, rsi_p)
103
+ # trend_ma = sma(close, ma_p)
104
+ # signals = zeros(Int, n)
105
+ #
106
+ # for i in (ma_p + rsi_p + 1):n
107
+ # isnan(rsi_vals[i]) && continue
108
+ # isnan(trend_ma[i]) && continue
109
+ #
110
+ # # Mean reversion: buy oversold in uptrend, sell overbought in downtrend
111
+ # if rsi_vals[i] < oversold && close[i] > trend_ma[i]
112
+ # signals[i] = 1
113
+ # elseif rsi_vals[i] > overbought && close[i] < trend_ma[i]
114
+ # signals[i] = -1
115
+ # end
116
+ # end
117
+ # return signals
118
+ # end
src/warmup.jl ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ warmup.jl
3
+ ─────────────────────────────────────────────────────
4
+ Runs at container startup via Dockerfile.
5
+ Forces Julia to JIT-compile all hot paths so the first
6
+ real user request doesn't pay compilation overhead.
7
+ """
8
+
9
+ push!(LOAD_PATH, @__DIR__)
10
+ include(joinpath(@__DIR__, "QuantEngine.jl"))
11
+ using .QuantEngine
12
+ using Statistics, Random
13
+
14
+ println("Warming up QuantEngine hot paths...")
15
+
16
+ # ── Generate synthetic data ────────────────────────────
17
+ n = 500
18
+ ret = cumsum(randn(n) .* 0.005)
19
+ c = 100.0 .* exp.(ret)
20
+ h = c .* (1.0 .+ abs.(randn(n)) .* 0.005)
21
+ l = c .* (1.0 .- abs.(randn(n)) .* 0.005)
22
+ o = c .* (1.0 .+ randn(n) .* 0.002)
23
+ v = abs.(randn(n)) .* 2000.0 .+ 1000.0
24
+
25
+ # ── Warm up all indicators ────────────────────────────
26
+ _ = sma(c, 20); println(" sma βœ“")
27
+ _ = ema(c, 20); println(" ema βœ“")
28
+ _ = rsi(c, 14); println(" rsi βœ“")
29
+ _ = macd(c); println(" macd βœ“")
30
+ _ = atr(h, l, c, 14); println(" atr βœ“")
31
+ _ = bbands(c, 20, 2.0); println(" bbands βœ“")
32
+ _ = donchian(h, l, 20); println(" donchian βœ“")
33
+ _ = adx(h, l, c, 14); println(" adx βœ“")
34
+ _ = rsi(c, 14); println(" rsi βœ“")
35
+ _ = stoch(h, l, c); println(" stoch βœ“")
36
+ _ = zscore(c, 20); println(" zscore βœ“")
37
+ _ = obv(c, v); println(" obv βœ“")
38
+ println("All indicators warmed βœ“")
39
+
40
+ # ── Warm up backtest engine ───────────────────────────
41
+ signals = [i > 50 ? (c[i] > sma(c, 20)[i] ? 1 : -1) : 0 for i in 1:n]
42
+ signals = [isnan(sma(c,20)[i]) ? 0 : signals[i] for i in 1:n]
43
+ sma20 = sma(c, 20)
44
+ signals_v = [isnan(sma20[i]) ? 0 : (c[i] > sma20[i] ? 1 : -1) for i in 1:n]
45
+
46
+ cfg = BacktestConfig()
47
+ result = run_backtest(o, h, l, c, v, signals_v, "1h", cfg)
48
+ println("BacktestEngine warmed βœ“ ($(result.n_trades) trades)")
49
+
50
+ # ── Warm up SignalCompiler ────────────────────────────
51
+ test_code = """
52
+ function get_param_grid() :: Dict{String, Vector{Float64}}
53
+ return Dict("period" => [10.0, 20.0, 30.0])
54
+ end
55
+
56
+ function generate_signals(open_p, high, low, close, volume, params)
57
+ n = length(close)
58
+ p = Int(round(get(params, "period", 20.0)))
59
+ ma = sma(close, p)
60
+ signals = zeros(Int, n)
61
+ for i in (p+1):n
62
+ isnan(ma[i]) && continue
63
+ signals[i] = close[i] > ma[i] ? 1 : -1
64
+ end
65
+ return signals
66
+ end
67
+ """
68
+ compiled = compile_strategy("WarmupTest", test_code)
69
+ if compiled.is_valid
70
+ println("SignalCompiler warmed βœ“")
71
+ else
72
+ println("SignalCompiler warning: $(compiled.error)")
73
+ end
74
+
75
+ # ── Warm up optimizer (mini run) ─────────────────────
76
+ opt = walk_forward_optimize(
77
+ compiled.generate_fn,
78
+ compiled.param_grid_fn(),
79
+ o, h, l, c, v,
80
+ "1h", "WarmupTest", "TEST";
81
+ n_windows = 2,
82
+ max_combos = 3,
83
+ min_trades = 1,
84
+ )
85
+ println("Optimizer warmed βœ“ (viable=$(opt.is_viable))")
86
+
87
+ println("\nβœ… All Julia hot paths precompiled. Ready for production requests.")
utils/__init__.py ADDED
File without changes
utils/config.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """utils/config.py β€” reads from HF Spaces Secrets (env vars)."""
2
+ import os
3
+ from pathlib import Path
4
+
5
+ TMP = Path("/tmp/quant")
6
+ for d in ["pdfs","tick_cache","compiled","exports"]:
7
+ (TMP / d).mkdir(parents=True, exist_ok=True)
8
+
9
+ def get(k, default=""): return os.environ.get(k, default)
10
+
11
+ ANTHROPIC_API_KEY = get("ANTHROPIC_API_KEY")
12
+ HF_TOKEN = get("HF_TOKEN")
13
+ HF_DATASET_REPO = get("HF_DATASET_REPO")
14
+ HF_TICK_REPO = get("HF_TICK_REPO")
15
+
16
+ SIMILARITY_THRESHOLD = float(get("SIMILARITY_THRESHOLD", "0.85"))
17
+ MAX_TOKENS_PER_CHUNK = int(get("MAX_TOKENS_PER_CHUNK", "3000"))
18
+ OCR_DPI = int(get("OCR_DPI", "300"))
19
+
20
+ INITIAL_EQUITY = float(get("INITIAL_EQUITY", "10000"))
21
+ COMMISSION_PCT = float(get("COMMISSION_PCT", "0.0002"))
22
+ RISK_PER_TRADE = float(get("RISK_PER_TRADE", "0.01"))
23
+ WF_WINDOWS = int(get("WF_WINDOWS", "5"))
24
+ WF_IS_RATIO = float(get("WF_IS_RATIO", "0.70"))
25
+ MAX_PARAM_COMBOS = int(get("MAX_PARAM_COMBOS", "300"))
26
+ MIN_TRADES = int(get("MIN_TRADES", "30"))
27
+ MIN_SHARPE = float(get("MIN_SHARPE", "0.5"))
28
+ BACKTEST_TFS = get("BACKTEST_TIMEFRAMES", "1h,4h,1d").split(",")
29
+
30
+ CATEGORIES = [
31
+ "Trend Following","Mean Reversion","Statistical Arbitrage",
32
+ "Momentum","Breakout","Volatility Trading","Market Making",
33
+ "Pattern Recognition","Machine Learning","Options Strategy",
34
+ "High Frequency","Pairs Trading","Carry Trade",
35
+ "Seasonal / Calendar","Risk Management","Position Sizing",
36
+ "Portfolio Construction","Market Microstructure","Other",
37
+ ]
38
+
39
+ EXTRACTION_PROMPT = """
40
+ You are a quantitative finance knowledge extraction engine.
41
+
42
+ Extract ALL trading strategies, mathematical formulas, and complete trading systems
43
+ from the text below (taken from an algorithmic trading book).
44
+
45
+ Output ONLY valid JSON β€” no markdown fences, no preamble:
46
+ {
47
+ "strategies": [{
48
+ "name": "string", "category": "string", "description": "string",
49
+ "entry_rules": ["string"], "exit_rules": ["string"],
50
+ "filters": ["string"], "timeframes": ["string"], "instruments": ["string"],
51
+ "parameters": {"name": "description with typical value"},
52
+ "mathematical_basis": "string", "source_context": "string"
53
+ }],
54
+ "formulas": [{
55
+ "name": "string", "category": "string",
56
+ "latex": "LaTeX string", "plain_text": "string",
57
+ "variables": {"symbol": "description"},
58
+ "purpose": "string", "usage_context": "string", "source_context": "string"
59
+ }],
60
+ "systems": [{
61
+ "name": "string", "components": ["string"],
62
+ "entry_system": "string", "exit_system": "string",
63
+ "risk_management": "string", "position_sizing": "string",
64
+ "backtesting_notes": "string", "source_context": "string"
65
+ }]
66
+ }
67
+
68
+ Rules: empty arrays [] if nothing found. Preserve exact math. Include LaTeX.
69
+ Source: {source_file} | Pages: {page_start}–{page_end}
70
+ --- TEXT ---
71
+ {text}
72
+ --- END ---
73
+ """.strip()
74
+
75
+ COMPILER_PROMPT = """
76
+ You are a Julia algorithmic trading code generator.
77
+
78
+ Convert the strategy JSON below into executable Julia code.
79
+ Output ONLY the Julia code β€” no markdown fences, no explanation, no module/using declarations.
80
+
81
+ EXACT REQUIRED FORMAT (two functions, nothing else):
82
+
83
+ function get_param_grid() :: Dict{{String, Vector{{Float64}}}}
84
+ return Dict(
85
+ "param_name" => [val1, val2, val3],
86
+ )
87
+ end
88
+
89
+ function generate_signals(
90
+ open_p :: Vector{{Float64}},
91
+ high :: Vector{{Float64}},
92
+ low :: Vector{{Float64}},
93
+ close :: Vector{{Float64}},
94
+ volume :: Vector{{Float64}},
95
+ params :: Dict{{String, Float64}},
96
+ ) :: Vector{{Int}}
97
+ n = length(close)
98
+ signals = zeros(Int, n)
99
+ # ... your logic here ...
100
+ return signals
101
+ end
102
+
103
+ RULES (CRITICAL β€” violations cause compile failure):
104
+ 1. NO module, NO using, NO include statements
105
+ 2. ALWAYS check isnan() before using indicator values
106
+ 3. Return signals[i] = 0 during indicator warmup period
107
+ 4. Values: 1=long, -1=short, 0=flat only
108
+ 5. Get int params: Int(round(get(params, "key", default)))
109
+ 6. Get float params: get(params, "key", default)
110
+
111
+ AVAILABLE FUNCTIONS (pre-injected, call directly without prefix):
112
+ Trend: sma(s,n) ema(s,n) wma(s,n) tema(s,n) dema(s,n)
113
+ Momentum: rsi(c,n) macd(c;fast,slow,sig)->(ml,sl,hist) momentum(s,n) roc(s,n)
114
+ Bands: bbands(c,n,k)->(up,mid,lo) keltner(h,l,c,n,k)->(up,mid,lo)
115
+ Channel: donchian(h,l,n)->(up,mid,lo) highest(s,n) lowest(s,n)
116
+ Volatility: atr(h,l,c,n) std_dev(s,n) zscore(s,n)
117
+ Oscillators: stoch(h,l,c;k,d)->(K,D) cci(h,l,c,n) williams_r(h,l,c,n)
118
+ Volume: vwap(h,l,c,v) obv(c,v) cmf(h,l,c,v,n)
119
+ Trend strength: adx(h,l,c,n)->(adx,pdi,ndi)
120
+ Crosses: crossover(a,b)->Bool[] crossunder(a,b)->Bool[]
121
+ Math: mean(v) std(v) diff(v) cumsum(v) abs(x) sqrt(x)
122
+
123
+ Strategy:
124
+ {strategy_json}
125
+ """.strip()
utils/hf_io.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """utils/hf_io.py β€” All HuggingFace Hub read/write."""
2
+ import io, json
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ import pandas as pd
6
+ from huggingface_hub import HfApi, hf_hub_download, list_repo_files, CommitOperationAdd
7
+ from loguru import logger
8
+ import utils.config as cfg
9
+
10
+ def _api(): return HfApi(token=cfg.HF_TOKEN)
11
+
12
+ # ── Knowledge base ─────────────────────────────────────
13
+
14
+ def kb_load() -> dict:
15
+ empty = {"strategies": {}, "formulas": {}, "systems": {}}
16
+ if not cfg.HF_DATASET_REPO: return empty
17
+ try:
18
+ path = hf_hub_download(
19
+ repo_id=cfg.HF_DATASET_REPO, filename="knowledge_base.jsonl",
20
+ repo_type="dataset", token=cfg.HF_TOKEN,
21
+ local_dir=str(cfg.TMP), force_download=True,
22
+ )
23
+ result = {"strategies": {}, "formulas": {}, "systems": {}}
24
+ with open(path, encoding="utf-8") as f:
25
+ for line in f:
26
+ line = line.strip()
27
+ if not line: continue
28
+ rec = json.loads(line)
29
+ kind = rec.get("_type", ""); cid = rec.get("canonical_id", "")
30
+ if kind in result and cid: result[kind][cid] = rec
31
+ logger.info(f"KB: {len(result['strategies'])} strats, {len(result['formulas'])} formulas")
32
+ return result
33
+ except Exception as e:
34
+ logger.warning(f"KB load (may not exist yet): {e}")
35
+ return empty
36
+
37
+ def kb_save(kb: dict) -> bool:
38
+ if not cfg.HF_DATASET_REPO: return False
39
+ try:
40
+ lines = []
41
+ for kind in ("strategies","formulas","systems"):
42
+ for rec in kb[kind].values():
43
+ lines.append(json.dumps({**rec, "_type": kind}))
44
+ _api().upload_file(
45
+ path_or_fileobj=io.BytesIO("\n".join(lines).encode()),
46
+ path_in_repo="knowledge_base.jsonl",
47
+ repo_id=cfg.HF_DATASET_REPO, repo_type="dataset",
48
+ commit_message="Update knowledge base",
49
+ )
50
+ return True
51
+ except Exception as e:
52
+ logger.error(f"KB save: {e}"); return False
53
+
54
+ # ── Tick data ──────────────────────────────────────────
55
+
56
+ def tick_list_symbols() -> list[str]:
57
+ if not cfg.HF_TICK_REPO: return []
58
+ try:
59
+ files = list(list_repo_files(repo_id=cfg.HF_TICK_REPO,
60
+ repo_type="dataset", token=cfg.HF_TOKEN))
61
+ seen = set(); syms = []
62
+ for f in files:
63
+ parts = f.split("/")
64
+ if len(parts) >= 2 and parts[0] not in seen:
65
+ seen.add(parts[0]); syms.append(parts[0])
66
+ return sorted(syms)
67
+ except Exception as e:
68
+ logger.warning(f"Tick symbols: {e}"); return []
69
+
70
+ def tick_load(symbol: str, timeframe: str = "1h") -> Optional[pd.DataFrame]:
71
+ cache = cfg.TMP / "tick_cache" / f"{symbol}_{timeframe}.parquet"
72
+ if cache.exists(): return pd.read_parquet(cache)
73
+ if not cfg.HF_TICK_REPO: return None
74
+ for fname in [f"{timeframe}.parquet", f"{timeframe}.csv",
75
+ "ticks.parquet", "data.parquet"]:
76
+ df = _try_dl(symbol, fname)
77
+ if df is not None:
78
+ df = _norm_ohlcv(df, timeframe if fname.startswith("tick") or fname=="data.parquet" else None)
79
+ if df is not None and not df.empty:
80
+ df.to_parquet(cache); return df
81
+ return None
82
+
83
+ def _try_dl(sym, fname):
84
+ try:
85
+ local = cfg.TMP / "tick_cache" / sym
86
+ local.mkdir(parents=True, exist_ok=True)
87
+ path = hf_hub_download(repo_id=cfg.HF_TICK_REPO,
88
+ filename=f"{sym}/{fname}", repo_type="dataset",
89
+ token=cfg.HF_TOKEN, local_dir=str(local), force_download=False)
90
+ return pd.read_parquet(path) if fname.endswith(".parquet") else pd.read_csv(path)
91
+ except Exception:
92
+ return None
93
+
94
+ _TF_MAP = {"1m":"1min","5m":"5min","15m":"15min","30m":"30min",
95
+ "1h":"1h","4h":"4h","1d":"1D","1w":"1W"}
96
+
97
+ def _norm_ohlcv(df: pd.DataFrame, resample_to=None) -> Optional[pd.DataFrame]:
98
+ import numpy as np
99
+ df = df.copy()
100
+ ts = next((c for c in df.columns if "time" in c.lower() or "date" in c.lower()), None)
101
+ if ts: df.index = pd.to_datetime(df[ts], utc=True); df = df.drop(columns=[ts])
102
+ else:
103
+ try: df.index = pd.to_datetime(df.index, utc=True)
104
+ except: return None
105
+ df.index = df.index.tz_convert("UTC") if df.index.tz else df.index.tz_localize("UTC")
106
+ df = df.sort_index()
107
+ if resample_to:
108
+ price_col = next((c for c in df.columns if c.lower() in ("bid","mid","price","close")), None)
109
+ if price_col is None: return None
110
+ if "bid" in df.columns and "ask" in df.columns:
111
+ df["_price"] = (df["bid"] + df["ask"]) / 2
112
+ else: df["_price"] = df[price_col]
113
+ rule = _TF_MAP.get(resample_to, "1h")
114
+ ohlcv = df["_price"].resample(rule).ohlc()
115
+ ohlcv.columns = ["open","high","low","close"]
116
+ vcol = next((c for c in df.columns if "vol" in c.lower()), None)
117
+ ohlcv["volume"] = df[vcol].resample(rule).sum() if vcol else df["_price"].resample(rule).count()
118
+ return ohlcv.dropna()
119
+ renames = {}
120
+ for c in df.columns:
121
+ lc = c.lower()
122
+ if lc in ("o","open"): renames[c]="open"
123
+ elif lc in ("h","high"): renames[c]="high"
124
+ elif lc in ("l","low"): renames[c]="low"
125
+ elif lc in ("c","close"): renames[c]="close"
126
+ elif lc in ("v","vol","volume","tick_volume"): renames[c]="volume"
127
+ df = df.rename(columns=renames)
128
+ for col in ["open","high","low","close"]:
129
+ if col not in df.columns: return None
130
+ if "volume" not in df.columns: df["volume"] = 0.0
131
+ df = df[["open","high","low","close","volume"]].astype(float).dropna(subset=["open","high","low","close"])
132
+ bad = df["high"] < df["low"]
133
+ if bad.any(): df.loc[bad,["high","low"]] = df.loc[bad,["low","high"]].values
134
+ return df
135
+
136
+ # ── Batch push ─────────────────────────────────────────
137
+
138
+ def push_batch(files: list[tuple[str, bytes]], msg="Update") -> int:
139
+ if not cfg.HF_DATASET_REPO or not files: return 0
140
+ ops = [CommitOperationAdd(path_in_repo=p, path_or_fileobj=io.BytesIO(c)) for p,c in files]
141
+ pushed = 0
142
+ for i in range(0, len(ops), 100):
143
+ try:
144
+ _api().create_commit(repo_id=cfg.HF_DATASET_REPO, repo_type="dataset",
145
+ operations=ops[i:i+100], commit_message=f"{msg} [{i+1}–{i+len(ops[i:i+100])}]")
146
+ pushed += len(ops[i:i+100])
147
+ except Exception as e: logger.error(f"Batch push: {e}")
148
+ return pushed
149
+
150
+ def push_result(name, symbol, tf, report, opt_json, mt5_set, julia_cfg) -> bool:
151
+ from pipeline.exporter import slugify
152
+ sl = slugify(name); pre = f"{sl}_{symbol}_{tf}"
153
+ files = [
154
+ (f"backtests/{sl}/{pre}_report.md", report.encode()),
155
+ (f"optimal_sets/{pre}_optimal.json", json.dumps(opt_json,indent=2).encode()),
156
+ (f"optimal_sets/{pre}.set", mt5_set.encode()),
157
+ (f"optimal_sets/{pre}_config.jl", julia_cfg.encode()),
158
+ ]
159
+ return push_batch(files, f"Backtest: {name} {symbol} {tf}") == 4
160
+
161
+ def push_index(md: str, data: dict) -> bool:
162
+ return push_batch([
163
+ ("optimal_sets/BACKTEST_INDEX.md", md.encode()),
164
+ ("optimal_sets/backtest_index.json", json.dumps(data,indent=2).encode()),
165
+ ], "Update index") == 2
166
+
167
+ def fetch_index() -> dict:
168
+ try:
169
+ path = hf_hub_download(repo_id=cfg.HF_DATASET_REPO,
170
+ filename="optimal_sets/backtest_index.json",
171
+ repo_type="dataset", token=cfg.HF_TOKEN,
172
+ local_dir=str(cfg.TMP), force_download=True)
173
+ return json.loads(Path(path).read_text())
174
+ except: return {}
175
+
176
+ def fetch_file(remote: str) -> Optional[bytes]:
177
+ try:
178
+ path = hf_hub_download(repo_id=cfg.HF_DATASET_REPO,
179
+ filename=remote, repo_type="dataset", token=cfg.HF_TOKEN,
180
+ local_dir=str(cfg.TMP/"downloads"), force_download=True)
181
+ return Path(path).read_bytes()
182
+ except: return None
183
+
184
+ def pdf_upload(pdf_path: Path) -> str:
185
+ if not cfg.HF_DATASET_REPO: return ""
186
+ try:
187
+ return str(_api().upload_file(path_or_fileobj=str(pdf_path),
188
+ path_in_repo=f"pdfs/{pdf_path.name}",
189
+ repo_id=cfg.HF_DATASET_REPO, repo_type="dataset",
190
+ commit_message=f"Add PDF: {pdf_path.name}"))
191
+ except Exception as e:
192
+ logger.warning(f"PDF upload: {e}"); return ""