Mikecode123 commited on
Commit
391ef4f
·
verified ·
1 Parent(s): 7b349af

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +970 -222
app.py CHANGED
@@ -1,251 +1,999 @@
 
1
  """
2
- AI Trading Backend FastAPI entry point.
3
-
4
- Endpoints:
5
- GET /health
6
- GET /models
7
- GET /portfolio?mode=demo
8
- GET /history?mode=demo&limit=50
9
- GET /performance?mode=demo
10
- GET /confidence?symbol=R_10
11
- POST /predict
12
- POST /reason
13
- POST /paper-trade
14
- POST /trade (live, requires DERIV_API_TOKEN)
15
- POST /feedback
16
- POST /retrain-request
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  """
 
18
  from __future__ import annotations
19
- import logging
20
- from typing import Optional
21
- from fastapi import FastAPI, HTTPException, Query
22
- from fastapi.middleware.cors import CORSMiddleware
23
-
24
- from . import supabase_client as sb
25
- from .config import get_settings
26
- from .deriv_client import DerivClient, DerivError
27
- from .paper_trader import (open_paper_trade, close_paper_trade,
28
- get_or_create_portfolio)
29
- from .prediction_engine import ensemble, heuristic_forecast
30
- from .qwen_reasoner import reason as qwen_reason
31
- from .risk import validate_trade
32
- from .schemas import (PredictRequest, ReasonRequest, PaperTradeRequest,
33
- TradeRequest, TradeResponse, FeedbackRequest,
34
- RetrainRequest, StrategySignal)
35
- from .strategy_ob_fvg import generate_signal
36
-
37
- log = logging.getLogger("uvicorn.error")
38
- settings = get_settings()
39
-
40
- app = FastAPI(title="AI Trading Backend", version="0.1.0")
41
- app.add_middleware(
42
- CORSMiddleware,
43
- allow_origins=settings.CORS_ORIGINS,
44
- allow_credentials=True,
45
- allow_methods=["*"],
46
- allow_headers=["*"],
47
- )
48
-
49
-
50
- # -------------------------- system ------------------------------------------
51
-
52
- @app.get("/health")
53
- async def health():
54
- return {
55
- "ok": True,
56
- "qwen_configured": bool(settings.HF_TOKEN),
57
- "supabase_configured": bool(sb.sb()),
58
- "deriv_live_enabled": bool(settings.DERIV_API_TOKEN),
59
- "model": settings.QWEN_MODEL,
60
- }
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
- @app.get("/models")
64
- async def models():
65
- return {
66
- "reasoner": settings.QWEN_MODEL,
67
- "forecasters": ["heuristic-momentum-v1", "lstm-stub", "xgboost-stub",
68
- "ensemble-v1"],
69
- "strategies": ["ob_fvg"],
70
- }
71
 
 
72
 
73
- # -------------------------- portfolio / history ------------------------------
74
 
75
- @app.get("/portfolio")
76
- async def portfolio(mode: str = "demo"):
77
- return await get_or_create_portfolio(None, mode)
78
 
79
 
80
- @app.get("/history")
81
- async def history(mode: str = "demo", limit: int = 50):
82
- return sb.select("trade_history", eq={"mode": mode},
83
- order="opened_at", desc=True, limit=limit)
 
84
 
85
 
86
- @app.get("/performance")
87
- async def performance(mode: str = "demo"):
88
- trades = sb.select("trade_history", eq={"mode": mode, "status": "closed"},
89
- order="closed_at", desc=True, limit=500)
90
- if not trades:
91
- return {"total_trades": 0, "win_rate": 0, "total_pnl": 0,
92
- "profit_factor": 0}
93
- wins = [t for t in trades if (t.get("pnl") or 0) > 0]
94
- losses = [t for t in trades if (t.get("pnl") or 0) < 0]
95
- gross_win = sum(t["pnl"] for t in wins) or 0.0
96
- gross_loss = abs(sum(t["pnl"] for t in losses)) or 1e-9
97
- return {
98
- "total_trades": len(trades),
99
- "winning_trades": len(wins),
100
- "losing_trades": len(losses),
101
- "win_rate": len(wins) / len(trades),
102
- "total_pnl": sum(t["pnl"] or 0 for t in trades),
103
- "profit_factor": gross_win / gross_loss,
104
- }
105
 
106
 
107
- @app.get("/confidence")
108
- async def confidence(symbol: str = "R_10"):
109
- rows = sb.select("predictions", eq={"symbol": symbol},
110
- order="created_at", desc=True, limit=20)
111
- if not rows:
112
- return {"symbol": symbol, "avg_confidence": 0, "samples": 0}
113
- avg = sum(float(r.get("confidence") or 0) for r in rows) / len(rows)
114
- return {"symbol": symbol, "avg_confidence": avg, "samples": len(rows)}
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
 
117
- # -------------------------- core AI loop ------------------------------------
 
 
118
 
119
- @app.post("/predict", response_model=StrategySignal)
120
- async def predict(req: PredictRequest):
121
- """Run OB+FVG strategy on latest Deriv candles and emit a signal."""
122
- granularity = _granularity_seconds(req.timeframe)
123
- client = DerivClient()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  try:
125
- candles = await client.candles(req.symbol, granularity=granularity,
126
- count=req.lookback)
127
- except DerivError as e:
128
- raise HTTPException(502, f"Deriv error: {e}")
129
-
130
- signal = generate_signal(req.symbol, req.timeframe, candles)
131
- forecast = ensemble(candles)
132
-
133
- # persist
134
- pred = sb.insert("predictions", {
135
- "symbol": req.symbol,
136
- "timeframe": req.timeframe,
137
- "decision": signal.decision,
138
- "confidence": round(signal.confidence, 3),
139
- "risk_score": round(1 - signal.confidence, 3),
140
- "success_probability": round(signal.confidence, 3),
141
- "reasoning": signal.rationale,
142
- "trade_plan": {"entry": signal.entry, "sl": signal.sl, "tp": signal.tp},
143
- "indicators": signal.indicators,
144
- "market_state": {"forecast": forecast},
145
- "suggested_entry": signal.entry,
146
- "suggested_sl": signal.sl,
147
- "suggested_tp": signal.tp,
148
- "model_version": "ob_fvg-v1",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  })
150
- sb.insert("live_signals", {
151
- "symbol": req.symbol,
152
- "decision": signal.decision,
153
- "confidence": round(signal.confidence, 3),
154
- "price": signal.price,
155
- "ob_zone": signal.ob.model_dump() if signal.ob else None,
156
- "fvg_zone": signal.fvg.model_dump() if signal.fvg else None,
157
- "reasoning": signal.rationale,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  })
159
- return signal
160
-
161
-
162
- @app.post("/reason")
163
- async def reason_endpoint(req: ReasonRequest):
164
- return await qwen_reason(req.symbol, req.timeframe,
165
- req.indicators, req.prediction, req.market_state)
166
-
167
-
168
- # -------------------------- trading ------------------------------------------
169
-
170
- @app.post("/paper-trade", response_model=TradeResponse)
171
- async def paper_trade(req: PaperTradeRequest):
172
- client = DerivClient()
173
- tick = await client.tick(req.symbol)
174
- price = float(tick.get("quote") or 0)
175
- if not price:
176
- raise HTTPException(502, "Could not fetch current price")
177
-
178
- pf = await get_or_create_portfolio(None, "demo")
179
- check = validate_trade(
180
- balance=float(pf.get("balance") or 0),
181
- open_positions=int(pf.get("open_positions") or 0),
182
- today_pnl=float(pf.get("realized_pnl") or 0),
183
- trade_size=req.size,
184
- confidence=0.7, # passed when called from /predict; user override OK
185
- max_daily_loss=settings.MAX_DAILY_LOSS_DEFAULT,
186
- max_open_trades=settings.MAX_OPEN_TRADES_DEFAULT,
187
- risk_percent=2.0,
188
- )
189
- if not check.ok:
190
- return TradeResponse(ok=False, message=check.reason or "Rejected")
191
-
192
- row = await open_paper_trade(req, current_price=price)
193
- return TradeResponse(ok=True, trade_id=row.get("id"),
194
- message="Paper trade opened")
195
-
196
-
197
- @app.post("/trade", response_model=TradeResponse)
198
- async def live_trade(req: TradeRequest):
199
- if not settings.DERIV_API_TOKEN:
200
- raise HTTPException(403,
201
- "Live trading disabled: set DERIV_API_TOKEN in Space secrets.")
202
- client = DerivClient(token=settings.DERIV_API_TOKEN)
203
- contract_type = "CALL" if req.side == "BUY" else "PUT"
204
- try:
205
- buy = await client.buy_contract(
206
- symbol=req.symbol, contract_type=contract_type,
207
- amount=req.size, duration=5, duration_unit="m",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  )
209
- except DerivError as e:
210
- raise HTTPException(502, f"Deriv: {e}")
211
- row = sb.insert("trade_history", {
212
- "mode": "live",
213
- "symbol": req.symbol,
214
- "side": req.side,
215
- "entry_price": float(buy.get("buy_price") or 0),
216
- "size": req.size,
217
- "stop_loss": req.sl,
218
- "take_profit": req.tp,
219
- "status": "open",
220
- "deriv_contract_id": str(buy.get("contract_id") or ""),
221
- "prediction_id": req.prediction_id,
222
- })
223
- return TradeResponse(ok=True, trade_id=(row or {}).get("id"),
224
- contract_id=str(buy.get("contract_id") or ""),
225
- message="Live contract bought")
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
- # -------------------------- feedback / retrain -------------------------------
229
 
230
- @app.post("/feedback")
231
- async def feedback(req: FeedbackRequest):
232
- sb.insert("feedback", req.model_dump())
233
- return {"ok": True}
 
 
 
 
234
 
235
 
236
- @app.post("/retrain-request")
237
- async def retrain(req: RetrainRequest):
238
- sb.insert("logs", {
239
- "level": "info", "source": "retrain",
240
- "message": f"Retrain requested for {req.model_name}",
241
- "meta": req.model_dump(),
242
- })
243
- return {"ok": True, "queued": True}
244
 
245
 
246
- # -------------------------- helpers ------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
 
248
- def _granularity_seconds(tf: str) -> int:
249
- table = {"1m": 60, "5m": 300, "15m": 900, "30m": 1800,
250
- "1h": 3600, "4h": 14400, "1d": 86400}
251
- return table.get(tf, 60)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
  """
3
+ 100optimization.py safe Hugging Face CRT strategy optimizer.
4
+
5
+ This script is designed for a Hugging Face Space. It:
6
+
7
+ 1. Calls load_dataset("Mikecode123/volatility_100_index") with remote dataset
8
+ code disabled.
9
+ 2. Locates volatility_100_index.zip in the dataset snapshot.
10
+ 3. Inspects the archive and extracts CSV files only. Scripts, executables,
11
+ symlinks, traversal paths, and oversized archives are rejected.
12
+ 4. Loads M1.csv, M5.csv, M15.csv, M30.csv, H1.csv, and H4.csv case-insensitively.
13
+ 5. Implements a clearly documented Candle Range Theory (CRT) rule:
14
+ - use a completed H1 or H4 reference candle;
15
+ - wait for a sweep of its high/low;
16
+ - require a close back inside the reference range;
17
+ - require the next-bar confirmation through the sweep candle extreme;
18
+ - enter at confirmation close;
19
+ - target the opposite edge of the reference range and evaluate staged
20
+ ATR-based TP/SL outcomes.
21
+ 6. Uses a bounded deterministic candidate search. The supervisor model can
22
+ select only from supplied candidate IDs; it cannot generate code or change
23
+ the evaluation rules.
24
+ 7. Saves every new best result and milestone result (20%, 30%, 80%) to the
25
+ Space checkpoint directory, then creates a downloadable ZIP.
26
+
27
+ There is no guarantee that 80% accuracy is achievable. The target is a stopping
28
+ criterion, not a promise. Validation is used for optimization; the final test
29
+ period remains untouched until the end.
30
+
31
+ Supervisor model (pinned and approved):
32
+ google/flan-t5-small
33
+ revision=0fc9ddf78a1e988dac52e2dac162b0ede4fd74ab
34
+ trust_remote_code=False
35
+
36
+ Expected Space requirements:
37
+ datasets
38
+ huggingface_hub
39
+ transformers
40
+ torch
41
+ pandas
42
+ numpy
43
+
44
+ Research sources for the CRT specification:
45
+ https://innercircletrader.net/tutorials/candle-range-theory-crt/
46
+ https://tradingwyckoff.com/en/crt/
47
+
48
+ The public CRT descriptions are educational retail sources, not peer-reviewed
49
+ proof of profitability. The implementation therefore reports validation and
50
+ test results separately and records coverage beside accuracy.
51
  """
52
+
53
  from __future__ import annotations
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
+ import gc
56
+ import itertools
57
+ import json
58
+ import math
59
+ import os
60
+ import random
61
+ import re
62
+ import shutil
63
+ import stat
64
+ import time
65
+ import zipfile
66
+ from dataclasses import asdict, dataclass
67
+ from datetime import datetime, timezone
68
+ from pathlib import Path
69
+ from typing import Any, Optional
70
+
71
+ import numpy as np
72
+ import pandas as pd
73
+
74
+ try:
75
+ from datasets import load_dataset
76
+ except ImportError as exc: # pragma: no cover - dependency supplied by the Space
77
+ raise RuntimeError(
78
+ "Install the Space requirements first: datasets, huggingface_hub, "
79
+ "transformers, torch, pandas, numpy"
80
+ ) from exc
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Configuration
85
+ # ---------------------------------------------------------------------------
86
+
87
+ DATASET_ID = os.environ.get("HF_DATASET_ID", "Mikecode123/volatility_100_index")
88
+ SUPERVISOR_MODEL_ID = "google/flan-t5-small"
89
+ SUPERVISOR_REVISION = "0fc9ddf78a1e988dac52e2dac162b0ede4fd74ab"
90
+
91
+ TF_MINUTES = {
92
+ "M1": 1,
93
+ "M5": 5,
94
+ "M15": 15,
95
+ "M30": 30,
96
+ "H1": 60,
97
+ "H4": 240,
98
+ }
99
+
100
+ CLASS_NAMES = {
101
+ 0: "NO_TRADE",
102
+ 1: "SL",
103
+ 2: "BE",
104
+ 3: "TP1",
105
+ 4: "TP2",
106
+ 5: "TP3",
107
+ }
108
+ LOCAL_R_VALUE = np.array([0.0, -1.0, 0.0, 2.0, 4.0, 6.0], dtype=np.float32)
109
+ LOCAL_RANK = np.array([1, 0, 2, 3, 4, 5], dtype=np.int8)
110
+
111
+
112
+ @dataclass
113
+ class Config:
114
+ dataset_id: str = DATASET_ID
115
+ seed: int = int(os.environ.get("OPTIMIZER_SEED", "42"))
116
+ target_accuracy: float = float(os.environ.get("TARGET_ACCURACY", "0.80"))
117
+ max_trials: int = int(os.environ.get("MAX_TRIALS", "30"))
118
+ min_trades: int = int(os.environ.get("MIN_TRADES", "100"))
119
+ min_coverage: float = float(os.environ.get("MIN_COVERAGE", "0.001"))
120
+ max_rows: int = int(os.environ.get("MAX_ROWS", "0"))
121
+ reference_timeframe: str = os.environ.get("CRT_REFERENCE_TF", "H1").upper()
122
+ confirm_bars: int = int(os.environ.get("CRT_CONFIRM_BARS", "1"))
123
+ atr_period: int = int(os.environ.get("ATR_PERIOD", "14"))
124
+ default_sl_atr_mult: float = float(os.environ.get("SL_ATR_MULT", "1.0"))
125
+ default_horizon: int = int(os.environ.get("MAX_HORIZON", "60"))
126
+ checkpoint_dir: Path = Path(
127
+ os.environ.get("CHECKPOINT_DIR", "/data/100optimization_checkpoints")
128
+ )
129
+ data_dir: Path = Path(
130
+ os.environ.get("DATA_EXTRACT_DIR", "/tmp/volatility_100_index_data")
131
+ )
132
 
 
 
 
 
 
 
 
 
133
 
134
+ CFG = Config()
135
 
 
136
 
137
+ # ---------------------------------------------------------------------------
138
+ # Logging and filesystem helpers
139
+ # ---------------------------------------------------------------------------
140
 
141
 
142
+ def log(message: str, *args: Any, level: str = "INFO") -> None:
143
+ if args:
144
+ message = message.format(*args)
145
+ stamp = datetime.now().strftime("%H:%M:%S")
146
+ print(f"[{stamp}] [{level}] {message}", flush=True)
147
 
148
 
149
+ def utc_now() -> str:
150
+ return datetime.now(timezone.utc).isoformat()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
 
153
+ def ensure_checkpoint_dir() -> Path:
154
+ try:
155
+ CFG.checkpoint_dir.mkdir(parents=True, exist_ok=True)
156
+ return CFG.checkpoint_dir
157
+ except PermissionError:
158
+ fallback = Path("./100optimization_checkpoints")
159
+ fallback.mkdir(parents=True, exist_ok=True)
160
+ log(
161
+ "Cannot write to {}; using {} instead",
162
+ CFG.checkpoint_dir,
163
+ fallback,
164
+ level="WARN",
165
+ )
166
+ CFG.checkpoint_dir = fallback
167
+ return fallback
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Hugging Face dataset discovery and safe archive handling
172
+ # ---------------------------------------------------------------------------
173
 
174
 
175
+ def _snapshot_dataset_files() -> Path:
176
+ """Download only dataset files, never executable model or code files."""
177
+ from huggingface_hub import snapshot_download
178
 
179
+ return Path(
180
+ snapshot_download(
181
+ repo_id=CFG.dataset_id,
182
+ repo_type="dataset",
183
+ allow_patterns=["*.zip", "*.ZIP", "*.csv", "*.CSV"],
184
+ )
185
+ )
186
+
187
+
188
+ def locate_zip(snapshot_dir: Path) -> Path:
189
+ candidates = sorted(
190
+ p for p in snapshot_dir.rglob("*")
191
+ if p.is_file() and p.suffix.lower() == ".zip"
192
+ )
193
+ if not candidates:
194
+ raise FileNotFoundError(
195
+ f"No ZIP file was found in the dataset snapshot {snapshot_dir}."
196
+ )
197
+
198
+ exact = [p for p in candidates if p.name.lower() == "volatility_100_index.zip"]
199
+ if exact:
200
+ return exact[0]
201
+ if len(candidates) == 1:
202
+ log("Using the only ZIP in the dataset snapshot: {}", candidates[0], level="WARN")
203
+ return candidates[0]
204
+ raise FileNotFoundError(
205
+ "Multiple ZIP files were found and none was named "
206
+ f"volatility_100_index.zip: {candidates}"
207
+ )
208
+
209
+
210
+ def inspect_zip(zip_path: Path) -> list[zipfile.ZipInfo]:
211
+ """Inspect the archive before extraction; allow data CSVs only."""
212
+ max_entries = 2_000
213
+ max_file_size = 25 * 1024 * 1024
214
+ max_total_size = 250 * 1024 * 1024
215
+
216
+ with zipfile.ZipFile(zip_path, "r") as archive:
217
+ infos = archive.infolist()
218
+ if len(infos) > max_entries:
219
+ raise ValueError(f"Archive contains too many entries: {len(infos)}")
220
+
221
+ total_size = 0
222
+ files: list[zipfile.ZipInfo] = []
223
+ log("Inspecting archive {}", zip_path)
224
+ for info in infos:
225
+ name = Path(info.filename.replace("\\", "/"))
226
+ if name.is_absolute() or ".." in name.parts:
227
+ raise ValueError(f"Unsafe archive path rejected: {info.filename}")
228
+
229
+ file_mode = (info.external_attr >> 16) & 0o170000
230
+ if file_mode == stat.S_IFLNK:
231
+ raise ValueError(f"Symlink rejected: {info.filename}")
232
+
233
+ if info.is_dir():
234
+ log(" [directory] {}", info.filename)
235
+ continue
236
+
237
+ if name.suffix.lower() != ".csv":
238
+ raise ValueError(
239
+ f"Non-CSV/script/configuration member rejected: {info.filename}. "
240
+ "This optimizer accepts a data-only archive."
241
+ )
242
+ if info.file_size > max_file_size:
243
+ raise ValueError(f"Archive member is too large: {info.filename}")
244
+
245
+ total_size += info.file_size
246
+ if total_size > max_total_size:
247
+ raise ValueError("Archive expanded size exceeds safety limit")
248
+
249
+ log(" [csv] {} ({:,} bytes)", info.filename, info.file_size)
250
+ files.append(info)
251
+
252
+ if not files:
253
+ raise ValueError("Archive contains no CSV files")
254
+ return files
255
+
256
+
257
+ def safe_extract_zip(zip_path: Path, destination: Path) -> Path:
258
+ infos = inspect_zip(zip_path)
259
+ if destination.exists():
260
+ shutil.rmtree(destination)
261
+ destination.mkdir(parents=True, exist_ok=True)
262
+
263
+ root = destination.resolve()
264
+ with zipfile.ZipFile(zip_path, "r") as archive:
265
+ for info in infos:
266
+ target = (destination / info.filename).resolve()
267
+ if target != root and root not in target.parents:
268
+ raise ValueError(f"Extraction escaped destination: {info.filename}")
269
+ target.parent.mkdir(parents=True, exist_ok=True)
270
+ with archive.open(info, "r") as source, target.open("wb") as sink:
271
+ shutil.copyfileobj(source, sink, length=1024 * 1024)
272
+ return destination
273
+
274
+
275
+ def find_timeframe_file(directory: Path, timeframe: str) -> Optional[Path]:
276
+ wanted = f"{timeframe}.csv".lower()
277
+ for candidate in directory.iterdir():
278
+ if candidate.is_file() and candidate.name.lower() == wanted:
279
+ return candidate
280
+ return None
281
+
282
+
283
+ def find_data_directory(root: Path) -> Path:
284
+ for m1 in sorted(root.rglob("*.csv")):
285
+ if m1.is_file() and m1.stem.lower() == "m1":
286
+ required = ("M1", "M5", "M15", "M30", "H1", "H4")
287
+ missing = [tf for tf in required if find_timeframe_file(m1.parent, tf) is None]
288
+ if not missing:
289
+ return m1.parent
290
+ log("Ignoring {} because it is missing {}", m1.parent, missing, level="WARN")
291
+ raise FileNotFoundError(
292
+ f"Could not find a directory containing M1/M5/M15/M30/H1/H4 CSVs below {root}"
293
+ )
294
+
295
+
296
+ def load_dataset_archive() -> Path:
297
+ """Call load_dataset as requested, then use the ZIP snapshot safely."""
298
  try:
299
+ loaded = load_dataset(CFG.dataset_id, trust_remote_code=False)
300
+ if hasattr(loaded, "keys"):
301
+ log("load_dataset opened splits: {}", list(loaded.keys()))
302
+ else:
303
+ log("load_dataset opened {} rows", len(loaded))
304
+ except Exception as exc:
305
+ # A repository containing only a ZIP may not have a Datasets builder.
306
+ # This fallback downloads data files only and still inspects the ZIP
307
+ # before extraction.
308
+ log(
309
+ "load_dataset could not parse the ZIP-only repository: {}. "
310
+ "Using the data-file snapshot fallback.",
311
+ exc,
312
+ level="WARN",
313
+ )
314
+
315
+ snapshot = _snapshot_dataset_files()
316
+ archive = locate_zip(snapshot)
317
+ log("Dataset archive: {}", archive)
318
+ return archive
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # OHLC loading and CRT feature construction
323
+ # ---------------------------------------------------------------------------
324
+
325
+ COLUMN_ALIASES = {
326
+ "timestamp": ["timestamp", "time", "date", "datetime", "open_time"],
327
+ "open": ["open", "o"],
328
+ "high": ["high", "h"],
329
+ "low": ["low", "l"],
330
+ "close": ["close", "c", "adj_close"],
331
+ "volume": ["volume", "vol", "v", "tick_volume"],
332
+ }
333
+
334
+
335
+ def resolve_column(columns: list[str], aliases: list[str]) -> Optional[str]:
336
+ mapping = {column.lower(): column for column in columns}
337
+ for alias in aliases:
338
+ if alias in mapping:
339
+ return mapping[alias]
340
+ return None
341
+
342
+
343
+ def load_ohlcv(path: Path) -> pd.DataFrame:
344
+ header = pd.read_csv(path, nrows=0)
345
+ columns = list(header.columns)
346
+ resolved = {
347
+ key: resolve_column(columns, aliases)
348
+ for key, aliases in COLUMN_ALIASES.items()
349
+ }
350
+ required = ("timestamp", "open", "high", "low", "close")
351
+ missing = [key for key in required if resolved[key] is None]
352
+ if missing:
353
+ raise ValueError(f"{path} is missing required columns: {missing}")
354
+
355
+ usecols = [resolved[key] for key in required]
356
+ if resolved["volume"] is not None:
357
+ usecols.append(resolved["volume"])
358
+ dtype = {
359
+ resolved[key]: np.float32
360
+ for key in ("open", "high", "low", "close")
361
+ }
362
+ if resolved["volume"] is not None:
363
+ dtype[resolved["volume"]] = np.float32
364
+
365
+ raw = pd.read_csv(
366
+ path,
367
+ usecols=list(dict.fromkeys(usecols)),
368
+ dtype=dtype,
369
+ parse_dates=[resolved["timestamp"]],
370
+ )
371
+ out = pd.DataFrame({
372
+ "timestamp": raw[resolved["timestamp"]],
373
+ "open": raw[resolved["open"]],
374
+ "high": raw[resolved["high"]],
375
+ "low": raw[resolved["low"]],
376
+ "close": raw[resolved["close"]],
377
  })
378
+ out["volume"] = (
379
+ raw[resolved["volume"]]
380
+ if resolved["volume"] is not None
381
+ else np.float32(0.0)
382
+ )
383
+ out = (
384
+ out.dropna(subset=["timestamp", "open", "high", "low", "close"])
385
+ .sort_values("timestamp")
386
+ .drop_duplicates("timestamp", keep="last")
387
+ .reset_index(drop=True)
388
+ )
389
+ invalid = (
390
+ (out["high"] < out["low"])
391
+ | (out[["open", "high", "low", "close"]] <= 0).any(axis=1)
392
+ )
393
+ if invalid.any():
394
+ log("{}: dropping {} invalid rows", path.name, int(invalid.sum()), level="WARN")
395
+ out = out.loc[~invalid].reset_index(drop=True)
396
+ return out
397
+
398
+
399
+ def compute_atr(df: pd.DataFrame, period: int) -> pd.Series:
400
+ previous_close = df["close"].shift(1)
401
+ true_range = pd.concat(
402
+ [
403
+ df["high"] - df["low"],
404
+ (df["high"] - previous_close).abs(),
405
+ (df["low"] - previous_close).abs(),
406
+ ],
407
+ axis=1,
408
+ ).max(axis=1)
409
+ return true_range.rolling(period, min_periods=period).mean()
410
+
411
+
412
+ def closed_reference_features(
413
+ base: pd.DataFrame,
414
+ reference: pd.DataFrame,
415
+ timeframe: str,
416
+ ) -> pd.DataFrame:
417
+ """Attach only fully closed reference-candle levels to M1 rows."""
418
+ ref = reference[["timestamp", "open", "high", "low", "close"]].copy()
419
+ ref["timestamp"] = ref["timestamp"] + pd.Timedelta(
420
+ minutes=TF_MINUTES[timeframe]
421
+ )
422
+ ref = ref.rename(columns={
423
+ "open": "crt_open",
424
+ "high": "crt_high",
425
+ "low": "crt_low",
426
+ "close": "crt_close",
427
  })
428
+ return pd.merge_asof(
429
+ base.sort_values("timestamp"),
430
+ ref.sort_values("timestamp"),
431
+ on="timestamp",
432
+ direction="backward",
433
+ )
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # Staged outcomes used for CRT backtesting
438
+ # ---------------------------------------------------------------------------
439
+
440
+
441
+ def simulate_direction(
442
+ close: np.ndarray,
443
+ high: np.ndarray,
444
+ low: np.ndarray,
445
+ risk: np.ndarray,
446
+ direction: int,
447
+ horizon: int,
448
+ ) -> tuple[np.ndarray, np.ndarray]:
449
+ """Simulate SL -> BE -> TP1-lock -> TP3 for one direction."""
450
+ n = len(close)
451
+ sign = 1.0 if direction > 0 else -1.0
452
+ entry = close
453
+ stop = entry - sign * risk
454
+ tp1 = entry + sign * risk * 2.0
455
+ tp2 = entry + sign * risk * 4.0
456
+ tp3 = entry + sign * risk * 6.0
457
+ lock = tp1
458
+
459
+ stage = np.zeros(n, dtype=np.int8)
460
+ resolved = np.zeros(n, dtype=bool)
461
+ outcome = np.zeros(n, dtype=np.int8)
462
+ valid = np.isfinite(risk)
463
+
464
+ for step in range(1, horizon + 1):
465
+ owners = np.arange(n)
466
+ future = owners + step
467
+ active = (~resolved) & valid & (future < n)
468
+ if not active.any():
469
+ continue
470
+ owners = owners[active]
471
+ future = future[active]
472
+ snapshot = stage[owners].copy()
473
+
474
+ for stage_id, favorable, adverse in (
475
+ (0, tp1, stop),
476
+ (1, tp2, entry),
477
+ (2, tp3, lock),
478
+ ):
479
+ mask = snapshot == stage_id
480
+ if not mask.any():
481
+ continue
482
+ rows = owners[mask]
483
+ hi = high[future[mask]]
484
+ lo = low[future[mask]]
485
+ if direction > 0:
486
+ touched_favorable = hi >= favorable[rows]
487
+ touched_adverse = lo <= adverse[rows]
488
+ else:
489
+ touched_favorable = lo <= favorable[rows]
490
+ touched_adverse = hi >= adverse[rows]
491
+
492
+ adverse_rows = rows[touched_adverse]
493
+ favorable_rows = rows[touched_favorable & ~touched_adverse]
494
+ if stage_id == 0:
495
+ outcome[adverse_rows] = 1
496
+ resolved[adverse_rows] = True
497
+ stage[favorable_rows] = 1
498
+ elif stage_id == 1:
499
+ outcome[adverse_rows] = 2
500
+ resolved[adverse_rows] = True
501
+ stage[favorable_rows] = 2
502
+ else:
503
+ outcome[adverse_rows] = 3
504
+ resolved[adverse_rows] = True
505
+ final_rows = rows[touched_favorable & ~touched_adverse]
506
+ outcome[final_rows] = 5
507
+ resolved[final_rows] = True
508
+
509
+ open_rows = (~resolved) & valid
510
+ outcome[open_rows & (stage == 0)] = 0
511
+ outcome[open_rows & (stage == 1)] = 3
512
+ outcome[open_rows & (stage == 2)] = 4
513
+ return outcome, LOCAL_R_VALUE[outcome]
514
+
515
+
516
+ def build_outcomes(
517
+ m1: pd.DataFrame,
518
+ atr_period: int,
519
+ sl_atr_mult: float,
520
+ horizon: int,
521
+ ) -> dict[str, np.ndarray]:
522
+ atr = compute_atr(m1, atr_period).to_numpy(dtype=np.float32)
523
+ risk = np.float32(sl_atr_mult) * atr
524
+ close = m1["close"].to_numpy(dtype=np.float32)
525
+ high = m1["high"].to_numpy(dtype=np.float32)
526
+ low = m1["low"].to_numpy(dtype=np.float32)
527
+
528
+ long_outcome, long_r = simulate_direction(close, high, low, risk, 1, horizon)
529
+ short_outcome, short_r = simulate_direction(close, high, low, risk, -1, horizon)
530
+ long_rank = LOCAL_RANK[long_outcome]
531
+ short_rank = LOCAL_RANK[short_outcome]
532
+ choose_long = (long_rank > short_rank) | (
533
+ (long_rank == short_rank) & (long_r >= short_r)
534
+ )
535
+ chosen_outcome = np.where(choose_long, long_outcome, short_outcome).astype(np.int8)
536
+ chosen_direction = np.where(
537
+ chosen_outcome == 0,
538
+ 0,
539
+ np.where(choose_long, 1, -1),
540
+ ).astype(np.int8)
541
+
542
+ return {
543
+ "long_outcome": long_outcome,
544
+ "short_outcome": short_outcome,
545
+ "long_r": long_r,
546
+ "short_r": short_r,
547
+ "chosen_outcome": chosen_outcome,
548
+ "chosen_direction": chosen_direction,
549
+ }
550
+
551
+
552
+ # ---------------------------------------------------------------------------
553
+ # CRT signals and metrics
554
+ # ---------------------------------------------------------------------------
555
+
556
+
557
+ @dataclass(frozen=True)
558
+ class Candidate:
559
+ candidate_id: int
560
+ reference_timeframe: str
561
+ min_sweep_atr: float
562
+ use_reference_bias: bool
563
+ confirmation_window: int
564
+ cooldown_bars: int
565
+ sl_atr_mult: float
566
+ horizon: int
567
+
568
+
569
+ def make_candidates(seed: int) -> list[Candidate]:
570
+ rng = random.Random(seed)
571
+ candidates: list[Candidate] = []
572
+ candidate_id = 0
573
+ for values in itertools.product(
574
+ ("H1", "H4"),
575
+ (0.00, 0.05, 0.10, 0.25),
576
+ (False, True),
577
+ (1, 2),
578
+ (0, 5, 15),
579
+ (0.75, 1.00, 1.50),
580
+ (30, 60),
581
+ ):
582
+ candidates.append(Candidate(candidate_id=candidate_id, **dict(zip(
583
+ (
584
+ "reference_timeframe",
585
+ "min_sweep_atr",
586
+ "use_reference_bias",
587
+ "confirmation_window",
588
+ "cooldown_bars",
589
+ "sl_atr_mult",
590
+ "horizon",
591
+ ),
592
+ values,
593
+ ))))
594
+ candidate_id += 1
595
+ rng.shuffle(candidates)
596
+ return candidates
597
+
598
+
599
+ def generate_crt_signals(
600
+ m1: pd.DataFrame,
601
+ reference: pd.DataFrame,
602
+ atr: pd.Series,
603
+ candidate: Candidate,
604
+ ) -> np.ndarray:
605
+ base = m1[["timestamp", "open", "high", "low", "close"]].copy()
606
+ merged = closed_reference_features(base, reference, candidate.reference_timeframe)
607
+ atr_values = atr.to_numpy(dtype=np.float32)
608
+
609
+ bullish_sweep = (
610
+ (merged["low"].to_numpy() < merged["crt_low"].to_numpy())
611
+ & (merged["close"].to_numpy() > merged["crt_low"].to_numpy())
612
+ & (
613
+ (merged["crt_low"].to_numpy() - merged["low"].to_numpy())
614
+ >= np.float32(candidate.min_sweep_atr) * atr_values
615
  )
616
+ )
617
+ bearish_sweep = (
618
+ (merged["high"].to_numpy() > merged["crt_high"].to_numpy())
619
+ & (merged["close"].to_numpy() < merged["crt_high"].to_numpy())
620
+ & (
621
+ (merged["high"].to_numpy() - merged["crt_high"].to_numpy())
622
+ >= np.float32(candidate.min_sweep_atr) * atr_values
623
+ )
624
+ )
 
 
 
 
 
 
 
 
625
 
626
+ ref_open = merged["crt_open"].to_numpy()
627
+ ref_close = merged["crt_close"].to_numpy()
628
+ if candidate.use_reference_bias:
629
+ bullish_sweep &= ref_close >= ref_open
630
+ bearish_sweep &= ref_close <= ref_open
631
+
632
+ highs = merged["high"].to_numpy()
633
+ lows = merged["low"].to_numpy()
634
+ closes = merged["close"].to_numpy()
635
+ signals = np.zeros(len(merged), dtype=np.int8)
636
+
637
+ # Confirmation is a close through the sweep candle's opposite extreme.
638
+ for delay in range(1, candidate.confirmation_window + 1):
639
+ prior_bull = np.zeros(len(merged), dtype=bool)
640
+ prior_bear = np.zeros(len(merged), dtype=bool)
641
+ if delay < len(merged):
642
+ prior_bull[delay:] = bullish_sweep[:-delay]
643
+ prior_bear[delay:] = bearish_sweep[:-delay]
644
+ signals[delay:][prior_bull[delay:] & (closes[delay:] > highs[:-delay])] = 1
645
+ signals[delay:][prior_bear[delay:] & (closes[delay:] < lows[:-delay])] = -1
646
+
647
+ if candidate.cooldown_bars > 0:
648
+ last_signal = -candidate.cooldown_bars - 1
649
+ for i in range(len(signals)):
650
+ if signals[i] != 0:
651
+ if i - last_signal <= candidate.cooldown_bars:
652
+ signals[i] = 0
653
+ else:
654
+ last_signal = i
655
+ return signals
656
+
657
+
658
+ def metrics_for_slice(
659
+ signals: np.ndarray,
660
+ outcomes: dict[str, np.ndarray],
661
+ start: int,
662
+ end: int,
663
+ min_trades: int,
664
+ min_coverage: float,
665
+ ) -> dict[str, Any]:
666
+ signal = signals[start:end]
667
+ chosen_direction = outcomes["chosen_direction"][start:end]
668
+ long_r = outcomes["long_r"][start:end]
669
+ short_r = outcomes["short_r"][start:end]
670
+ long_outcome = outcomes["long_outcome"][start:end]
671
+ short_outcome = outcomes["short_outcome"][start:end]
672
+
673
+ trades = signal != 0
674
+ n_rows = len(signal)
675
+ n_trades = int(trades.sum())
676
+ coverage = float(n_trades / n_rows) if n_rows else 0.0
677
+ if n_trades:
678
+ realized_r = np.where(signal > 0, long_r, short_r)
679
+ realized_outcome = np.where(signal > 0, long_outcome, short_outcome)
680
+ trade_accuracy = float((realized_r[trades] > 0).mean())
681
+ direction_accuracy = float(
682
+ (signal[trades] == chosen_direction[trades]).mean()
683
+ )
684
+ tp1_rate = float(np.isin(realized_outcome[trades], [3, 4, 5]).mean())
685
+ tp2_rate = float(np.isin(realized_outcome[trades], [4, 5]).mean())
686
+ tp3_rate = float((realized_outcome[trades] == 5).mean())
687
+ sl_rate = float((realized_outcome[trades] == 1).mean())
688
+ expectancy = float(realized_r[trades].mean())
689
+ total_r = float(realized_r[trades].sum())
690
+ positive = float(realized_r[trades][realized_r[trades] > 0].sum())
691
+ negative = float(-realized_r[trades][realized_r[trades] < 0].sum())
692
+ profit_factor = positive / negative if negative > 0 else math.inf
693
+ else:
694
+ trade_accuracy = direction_accuracy = 0.0
695
+ tp1_rate = tp2_rate = tp3_rate = sl_rate = 0.0
696
+ expectancy = total_r = 0.0
697
+ profit_factor = 0.0
698
+
699
+ eligible = n_trades >= min_trades and coverage >= min_coverage
700
+ return {
701
+ "rows": n_rows,
702
+ "trades": n_trades,
703
+ "coverage": coverage,
704
+ "eligible": eligible,
705
+ "trade_accuracy": trade_accuracy,
706
+ "direction_accuracy": direction_accuracy,
707
+ "tp1_or_better_rate": tp1_rate,
708
+ "tp2_or_better_rate": tp2_rate,
709
+ "tp3_rate": tp3_rate,
710
+ "sl_rate": sl_rate,
711
+ "expectancy_R": expectancy,
712
+ "profit_factor": profit_factor,
713
+ "total_R": total_r,
714
+ }
715
 
 
716
 
717
+ def objective(metrics: dict[str, Any]) -> tuple[float, float, float]:
718
+ if not metrics["eligible"]:
719
+ return (-1.0, metrics["coverage"], metrics["expectancy_R"])
720
+ return (
721
+ metrics["trade_accuracy"],
722
+ metrics["coverage"],
723
+ metrics["expectancy_R"],
724
+ )
725
 
726
 
727
+ # ---------------------------------------------------------------------------
728
+ # Pinned FLAN-T5 supervisor
729
+ # ---------------------------------------------------------------------------
 
 
 
 
 
730
 
731
 
732
+ class Supervisor:
733
+ def __init__(self) -> None:
734
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
735
+ import torch
736
+
737
+ self.torch = torch
738
+ log(
739
+ "Loading pinned supervisor {} at revision {}",
740
+ SUPERVISOR_MODEL_ID,
741
+ SUPERVISOR_REVISION,
742
+ )
743
+ self.tokenizer = AutoTokenizer.from_pretrained(
744
+ SUPERVISOR_MODEL_ID,
745
+ revision=SUPERVISOR_REVISION,
746
+ trust_remote_code=False,
747
+ )
748
+ self.model = AutoModelForSeq2SeqLM.from_pretrained(
749
+ SUPERVISOR_MODEL_ID,
750
+ revision=SUPERVISOR_REVISION,
751
+ trust_remote_code=False,
752
+ )
753
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
754
+ self.model.to(self.device)
755
+ self.model.eval()
756
+
757
+ def choose(
758
+ self,
759
+ candidates: list[Candidate],
760
+ history: list[dict[str, Any]],
761
+ ) -> tuple[Optional[int], str]:
762
+ if not candidates:
763
+ return None, "no candidates"
764
+
765
+ candidate_text = "\n".join(
766
+ f"ID {c.candidate_id}: ref={c.reference_timeframe}, "
767
+ f"sweep_atr={c.min_sweep_atr}, bias={c.use_reference_bias}, "
768
+ f"confirm={c.confirmation_window}, cooldown={c.cooldown_bars}, "
769
+ f"sl={c.sl_atr_mult}, horizon={c.horizon}"
770
+ for c in candidates[:24]
771
+ )
772
+ history_text = "\n".join(
773
+ f"trial {row['trial']}: candidate={row['candidate_id']}, "
774
+ f"accuracy={row.get('validation', {}).get('trade_accuracy', 0):.4f}, "
775
+ f"coverage={row.get('validation', {}).get('coverage', 0):.4f}"
776
+ for row in history[-8:]
777
+ ) or "No previous trials."
778
+ prompt = (
779
+ "You are a constrained trading-strategy supervisor. Choose one "
780
+ "candidate ID from the list. Do not invent an ID. Prefer enough "
781
+ "coverage and realistic validation accuracy. Reply exactly as "
782
+ "CANDIDATE_ID=<integer> followed by one short reason.\n\n"
783
+ f"Candidates:\n{candidate_text}\n\nHistory:\n{history_text}"
784
+ )
785
+ inputs = self.tokenizer(
786
+ prompt,
787
+ return_tensors="pt",
788
+ truncation=True,
789
+ max_length=768,
790
+ ).to(self.device)
791
+ with self.torch.no_grad():
792
+ output = self.model.generate(**inputs, max_new_tokens=48)
793
+ reply = self.tokenizer.decode(output[0], skip_special_tokens=True)
794
+ match = re.search(r"CANDIDATE_ID\s*=\s*(\d+)", reply)
795
+ selected = int(match.group(1)) if match else None
796
+ valid_ids = {candidate.candidate_id for candidate in candidates}
797
+ if selected not in valid_ids:
798
+ selected = None
799
+ return selected, reply
800
+
801
+
802
+ # ---------------------------------------------------------------------------
803
+ # Checkpointing and optimization loop
804
+ # ---------------------------------------------------------------------------
805
+
806
+
807
+ def save_json(path: Path, value: Any) -> None:
808
+ path.write_text(json.dumps(value, indent=2, default=str), encoding="utf-8")
809
+
810
+
811
+ def save_checkpoint(
812
+ checkpoint_dir: Path,
813
+ candidate: Candidate,
814
+ result: dict[str, Any],
815
+ milestone: Optional[int] = None,
816
+ ) -> None:
817
+ prefix = "best" if milestone is None else f"milestone_{milestone:02d}pct"
818
+ payload = {
819
+ "saved_at": utc_now(),
820
+ "candidate": asdict(candidate),
821
+ "result": result,
822
+ "supervisor_model": {
823
+ "id": SUPERVISOR_MODEL_ID,
824
+ "revision": SUPERVISOR_REVISION,
825
+ "trust_remote_code": False,
826
+ },
827
+ "crt_sources": [
828
+ "https://innercircletrader.net/tutorials/candle-range-theory-crt/",
829
+ "https://tradingwyckoff.com/en/crt/",
830
+ ],
831
+ }
832
+ save_json(checkpoint_dir / f"{prefix}_checkpoint.json", payload)
833
+ log("Saved {} checkpoint at validation accuracy {:.2%}", prefix, result["validation"]["trade_accuracy"])
834
+
835
+
836
+ def package_checkpoints(checkpoint_dir: Path) -> Path:
837
+ archive_path = checkpoint_dir.parent / "100optimization_checkpoints.zip"
838
+ if archive_path.exists():
839
+ archive_path.unlink()
840
+ with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
841
+ for path in sorted(checkpoint_dir.rglob("*")):
842
+ if path.is_file():
843
+ archive.write(path, arcname=f"{checkpoint_dir.name}/{path.relative_to(checkpoint_dir)}")
844
+ log("Checkpoint archive: {}", archive_path)
845
+ return archive_path
846
+
847
+
848
+ def load_market_data() -> tuple[dict[str, pd.DataFrame], Path]:
849
+ archive = load_dataset_archive()
850
+ extracted = safe_extract_zip(archive, CFG.data_dir)
851
+ data_dir = find_data_directory(extracted)
852
+ raw: dict[str, pd.DataFrame] = {}
853
+ for timeframe in ("M1", "M5", "M15", "M30", "H1", "H4"):
854
+ path = find_timeframe_file(data_dir, timeframe)
855
+ if path is None:
856
+ raise FileNotFoundError(f"Missing {timeframe}.csv in {data_dir}")
857
+ raw[timeframe] = load_ohlcv(path)
858
+ log("Loaded {}: {:,} rows", timeframe, len(raw[timeframe]))
859
+ return raw, data_dir
860
+
861
+
862
+ def run() -> dict[str, Any]:
863
+ checkpoint_dir = ensure_checkpoint_dir()
864
+ random.seed(CFG.seed)
865
+ np.random.seed(CFG.seed)
866
+
867
+ raw, data_dir = load_market_data()
868
+ m1 = raw["M1"]
869
+ if CFG.max_rows and len(m1) > CFG.max_rows:
870
+ m1 = m1.tail(CFG.max_rows).reset_index(drop=True)
871
+ log("Using the last {:,} M1 rows because MAX_ROWS is set", len(m1), level="WARN")
872
+
873
+ atr = compute_atr(m1, CFG.atr_period)
874
+ candidates = make_candidates(CFG.seed)
875
+ outcome_cache: dict[tuple[float, int], dict[str, np.ndarray]] = {}
876
+ signal_cache: dict[int, np.ndarray] = {}
877
+ history: list[dict[str, Any]] = []
878
+ evaluated: set[int] = set()
879
+ best_result: Optional[dict[str, Any]] = None
880
+ best_candidate: Optional[Candidate] = None
881
+ milestones_saved: set[int] = set()
882
+
883
+ n = len(m1)
884
+ train_end = int(n * 0.60)
885
+ validation_end = int(n * 0.80)
886
+ purge = max(CFG.default_horizon, 60)
887
+ validation_start = min(n, train_end + purge)
888
+ test_start = min(n, validation_end + purge)
889
+
890
+ supervisor: Optional[Supervisor]
891
+ try:
892
+ supervisor = Supervisor()
893
+ except Exception as exc:
894
+ log("Supervisor unavailable: {}. Continuing deterministically.", exc, level="WARN")
895
+ supervisor = None
896
+
897
+ log(
898
+ "CRT optimization rows={} | train={} | validation={} | test={}",
899
+ n,
900
+ train_end,
901
+ validation_end - validation_start,
902
+ n - test_start,
903
+ )
904
+
905
+ for trial in range(CFG.max_trials):
906
+ remaining = [candidate for candidate in candidates if candidate.candidate_id not in evaluated]
907
+ if not remaining:
908
+ break
909
+
910
+ selected_id: Optional[int] = None
911
+ supervisor_reply = ""
912
+ if supervisor is not None and history:
913
+ selected_id, supervisor_reply = supervisor.choose(remaining, history)
914
+ if selected_id is None:
915
+ # Deterministic fallback: evaluate candidates in the seeded order.
916
+ selected_id = remaining[0].candidate_id
917
+ candidate = next(c for c in candidates if c.candidate_id == selected_id)
918
+ evaluated.add(candidate.candidate_id)
919
+
920
+ key = (candidate.sl_atr_mult, candidate.horizon)
921
+ if key not in outcome_cache:
922
+ outcome_cache[key] = build_outcomes(
923
+ m1,
924
+ CFG.atr_period,
925
+ candidate.sl_atr_mult,
926
+ candidate.horizon,
927
+ )
928
+ outcomes = outcome_cache[key]
929
+
930
+ if candidate.candidate_id not in signal_cache:
931
+ signal_cache[candidate.candidate_id] = generate_crt_signals(
932
+ m1,
933
+ raw[candidate.reference_timeframe],
934
+ atr,
935
+ candidate,
936
+ )
937
+ signals = signal_cache[candidate.candidate_id]
938
+
939
+ validation = metrics_for_slice(
940
+ signals,
941
+ outcomes,
942
+ validation_start,
943
+ validation_end,
944
+ CFG.min_trades,
945
+ CFG.min_coverage,
946
+ )
947
+ test = metrics_for_slice(
948
+ signals,
949
+ outcomes,
950
+ test_start,
951
+ n,
952
+ CFG.min_trades,
953
+ CFG.min_coverage,
954
+ )
955
+ result = {
956
+ "trial": trial + 1,
957
+ "candidate_id": candidate.candidate_id,
958
+ "candidate": asdict(candidate),
959
+ "validation": validation,
960
+ "test_preview": test,
961
+ "supervisor_reply": supervisor_reply,
962
+ }
963
+ history.append(result)
964
+ save_json(checkpoint_dir / "trials.json", history)
965
+
966
+ log(
967
+ "Trial {} candidate={} validation accuracy={:.2%} coverage={:.2%} "
968
+ "trades={} expectancy={:.3f}R test accuracy={:.2%}",
969
+ trial + 1,
970
+ candidate.candidate_id,
971
+ validation["trade_accuracy"],
972
+ validation["coverage"],
973
+ validation["trades"],
974
+ validation["expectancy_R"],
975
+ test["trade_accuracy"],
976
+ )
977
 
978
+ if best_result is None or objective(validation) > objective(best_result["validation"]):
979
+ best_result = result
980
+ best_candidate = candidate
981
+ save_checkpoint(checkpoint_dir, candidate, result)
982
+
983
+ achieved = validation["eligible"] and validation["trade_accuracy"] >= CFG.target_accuracy
984
+ for milestone in (20, 30, 80):
985
+ if (
986
+ milestone not in milestones_saved
987
+ and validation["eligible"]
988
+ and validation["trade_accuracy"] >= milestone / 100.0
989
+ ):
990
+ save_checkpoint(checkpoint_dir, candidate, result, milestone=milestone)
991
+ milestones_saved.add(milestone)
992
+
993
+ if achieved:
994
+ log(
995
+ "Target validation accuracy reached: {:.2%}. "
996
+ "No further optimization trials will run.",
997
+ validation["trade_accuracy"],
998
+ )
999
+ break