Spaces:
Sleeping
Sleeping
Upload 21 files
Browse files- Dockerfile +68 -0
- README.md +25 -0
- app.py +482 -0
- pipeline/__init__.py +0 -0
- pipeline/exporter.py +202 -0
- pipeline/extractor.py +153 -0
- pipeline/julia_bridge.py +212 -0
- pipeline/pdf_processor.py +88 -0
- requirements.txt +29 -0
- src/BacktestEngine.jl +326 -0
- src/Indicators.jl +418 -0
- src/Manifest.toml +19 -0
- src/Optimizer.jl +307 -0
- src/Project.toml +7 -0
- src/QuantEngine.jl +124 -0
- src/SignalCompiler.jl +151 -0
- src/strategy_template.jl +118 -0
- src/warmup.jl +87 -0
- utils/__init__.py +0 -0
- utils/config.py +125 -0
- utils/hf_io.py +192 -0
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 ""
|