diff --git a/scripts/analysis/grid_game_gate.py b/scripts/analysis/grid_game_gate.py index 63ed7a0..cd0191d 100644 --- a/scripts/analysis/grid_game_gate.py +++ b/scripts/analysis/grid_game_gate.py @@ -68,7 +68,7 @@ def _load(asset, tf): def grid_mtm(asset="ETH", *, tf, range_down, range_up, levels, sl_buf, tp_buf, max_bars, pos=POS, lev=LEV, fee_side=FEE_SIDE, flat_skip=True, - close_only=False, deploy_mask=None): + close_only=False, deploy_mask=None, df=None): """Griglia STRATEGIA_GRIGLIA.md con contabilita' mark-to-market. Ritorna (equity daily Series base 1.0, stats dict). Causale: deploy sul @@ -77,8 +77,10 @@ def grid_mtm(asset="ETH", *, tf, range_down, range_up, levels, sl_buf, tp_buf, fornito, una NUOVA griglia si deploya SOLO dove mask[j]=True (regime-gate); None = comportamento storico (deploy sempre). Una griglia gia' attiva non viene interrotta dal mask (gestisce il suo episodio fino a SL/TP/timeout). + `df` (opzionale): OHLCV gia' caricato (per il feed LIVE del GridWorker); None + = carica da _load(asset, tf) (comportamento storico, parita' col gate). """ - df = _load(asset, tf) + df = _load(asset, tf) if df is None else df op = df["open"].to_numpy(float) hi = df["high"].to_numpy(float) lo = df["low"].to_numpy(float) diff --git a/scripts/analysis/validate_grid_worker.py b/scripts/analysis/validate_grid_worker.py new file mode 100644 index 0000000..2e90215 --- /dev/null +++ b/scripts/analysis/validate_grid_worker.py @@ -0,0 +1,80 @@ +"""VALIDA GridWorker — replay live == backtest grid_mtm (gate obbligatorio del progetto). + +Confronta il GridWorker (sim/paper, src/live/grid_worker.py) col motore canonico grid_mtm: + [A] un tick con tutta la storia -> capital == initial * grid_mtm(full).equity[-1] (parita'); + [B] replay INCREMENTALE (tick su finestra che cresce) -> converge allo stesso capitale finale; + [C] resume: reistanzia il worker (rilegge status.json) -> capitale persistito. +Config = la finale del re-gate pulito: BTC 1h range1.5 rd0.20 ru0.06 L6 sl0.10 tp0.03. + + uv run python scripts/analysis/validate_grid_worker.py +""" +from __future__ import annotations + +import shutil +import sys +import tempfile +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.data.downloader import load_data +from scripts.analysis.grid_game_gate import grid_mtm +from scripts.analysis.ladder_search import regime_mask +from src.live.grid_worker import GridWorker + +CFG = dict(tf="1h", range_down=0.20, range_up=0.06, levels=6, + sl_buf=0.10, tp_buf=0.03, max_bars=720, regime="range", trend_max=1.5) +ASSET, INIT, POS, LEV, FEE = "BTC", 1000.0, 0.15, 3.0, 0.0005 + + +def main(): + df = load_data(ASSET, "1h") + # backtest canonico + mask = regime_mask(ASSET, "1h", trend_max=CFG["trend_max"]) + cfg_bt = {k: v for k, v in CFG.items() if k not in ("regime", "trend_max")} + eqd_bt, st_bt = grid_mtm(ASSET, **cfg_bt, pos=POS, lev=LEV, fee_side=FEE, deploy_mask=mask) + target_cap = INIT * float(eqd_bt.iloc[-1]) + print(f"[backtest] grid_mtm equity[-1]={eqd_bt.iloc[-1]:.6f} -> capital {target_cap:,.2f} " + f"(trades {st_bt['trades']}, win {st_bt['win']:.1f}%)") + + wd = Path(tempfile.mkdtemp(prefix="gridval_")) + try: + # [A] un tick con tutta la storia + w = GridWorker("GRID_BTC", ASSET, CFG, INIT, wd, leverage=LEV, + position_size=POS, fee_side=FEE) + w.tick(df) + dA = abs(w.capital - target_cap) + print(f"[A] full-tick capital {w.capital:,.2f} delta {dA:.6f} " + f"{'OK' if dA < 1e-6 else 'MISMATCH'}") + + # [B] replay incrementale (ultimi 3 tick su finestra crescente) + n = len(df) + caps = [] + for end in (n - 200, n - 50, n): + wd2 = Path(tempfile.mkdtemp(prefix="gridval_inc_")) + wi = GridWorker("GRID_BTC", ASSET, CFG, INIT, wd2, leverage=LEV, + position_size=POS, fee_side=FEE) + wi.tick(df.iloc[:end].reset_index(drop=True)) + caps.append(wi.capital) + shutil.rmtree(wd2, ignore_errors=True) + dB = abs(caps[-1] - target_cap) + print(f"[B] incrementale capitali {[round(c,2) for c in caps]} " + f"finale delta {dB:.6f} {'OK' if dB < 1e-6 else 'MISMATCH'}") + + # [C] resume + w2 = GridWorker("GRID_BTC", ASSET, CFG, INIT, wd, leverage=LEV, + position_size=POS, fee_side=FEE) + dC = abs(w2.capital - w.capital) + # status.json persiste capital a 4 decimali -> tolleranza = precisione di persistenza + print(f"[C] resume capital {w2.capital:,.2f} delta {dC:.6f} " + f"{'OK' if dC < 1e-3 else 'MISMATCH'} (persistenza 4 dec.)") + + ok = dA < 1e-6 and dB < 1e-6 and dC < 1e-3 + print(f"\n{'VALIDAZIONE OK: GridWorker replay == backtest' if ok else 'VALIDAZIONE FALLITA'}") + finally: + shutil.rmtree(wd, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/src/live/grid_worker.py b/src/live/grid_worker.py new file mode 100644 index 0000000..d5972ec --- /dev/null +++ b/src/live/grid_worker.py @@ -0,0 +1,126 @@ +"""GridWorker — Price Ladder (griglia) live SIM/PAPER, shadow-stage 1. + +Worker live per la strategia Price Ladder (griglia geometrica con regime-gate + SL/TP, +config vincente del branch price_ladder_research). STAGE 1 = SIM/PAPER: gira sul feed LIVE +Deribit (stessi dati di decisione degli altri worker) e contabilizza l'equity mark-to-market +col MOTORE CANONICO `grid_mtm` (parita' col backtest per costruzione), MA non piazza ordini +reali. Accumula un track record paper per validare live-vs-backtest prima dello shadow reale. + +NON esegue ordini: l'esecuzione reale (griglia di LIMIT resting su Deribit, gestione fill +parziali/episodi) e' lo STAGE 2, dietro testnet + autorizzazione esplicita (soldi veri, +siamo su mainnet). Per costruzione il runner avvia ordini reali solo per kind in +('single','ml'); kind='grid' resta sim. + +Stato persistente (status.json): capital, peak, max_dd, n_trades, last_ts -> resume al restart. +""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np +import pandas as pd + +from scripts.analysis.grid_game_gate import grid_mtm + + +def _regime_mask(df: pd.DataFrame, ema_n: int, trend_max: float) -> np.ndarray: + """Mask CAUSALE 'range-bound' allineata a df (== ladder_search.regime_mask, ma su df live).""" + c = df["close"].to_numpy(float) + h = df["high"].to_numpy(float); l = df["low"].to_numpy(float) + ema = pd.Series(c).ewm(span=ema_n, adjust=False).mean().to_numpy() + pc = np.roll(c, 1); pc[0] = c[0] + tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) + atr = pd.Series(tr).rolling(14).mean().to_numpy() + with np.errstate(invalid="ignore", divide="ignore"): + dist = np.abs(c - ema) / np.where(atr == 0, np.nan, atr) + m = dist < trend_max + m[~np.isfinite(dist)] = False + return m + + +class GridWorker: + KIND = "grid" + + def __init__(self, sid: str, asset: str, params: dict, capital: float, + work_dir: Path, leverage: float = 3.0, position_size: float = 0.15, + fee_side: float = 0.0005, notifier=None): + self.sid = sid + self.asset = asset + self.p = dict(params) # tf,range_down,range_up,levels,sl_buf,tp_buf,max_bars,regime,trend_max + self.leverage = leverage + self.position_size = position_size + self.fee_side = fee_side + self.notifier = notifier + self.initial_capital = capital + self.capital = capital + self.peak = capital + self.max_dd = 0.0 + self.n_trades = 0 + self.last_ts = "" + self.work_dir = Path(work_dir) + self.work_dir.mkdir(parents=True, exist_ok=True) + self.status_path = self.work_dir / "status.json" + self.trades_path = self.work_dir / "trades.jsonl" + self.in_position = False # compat dashboard (la griglia non ha una posizione singola) + self._load_state() + + def _load_state(self): + if not self.status_path.exists(): + self._log("INIT", {"capital": round(self.capital, 2), "sid": self.sid}) + return + s = json.loads(self.status_path.read_text()) + self.capital = s.get("capital", self.initial_capital) + self.peak = s.get("peak", self.capital) + self.max_dd = s.get("max_dd", 0.0) + self.n_trades = s.get("n_trades", 0) + self.last_ts = s.get("last_ts", "") + self._log("RESUME", {"capital": round(self.capital, 2), "n_trades": self.n_trades}) + + def _save_state(self): + self.status_path.write_text(json.dumps({ + "sid": self.sid, "kind": self.KIND, "asset": self.asset, + "capital": round(self.capital, 4), "peak": round(self.peak, 4), + "max_dd": round(self.max_dd, 4), "n_trades": self.n_trades, + "in_position": self.in_position, "params": self.p, + "last_ts": self.last_ts, "ts": datetime.now(timezone.utc).isoformat(), + }, indent=2)) + + def _log(self, event: str, extra: dict): + row = {"ts": datetime.now(timezone.utc).isoformat(), "sid": getattr(self, "sid", "?"), + "event": event, **extra} + try: + with open(self.work_dir / "trades.jsonl", "a") as f: + f.write(json.dumps(row) + "\n") + except Exception: + pass + + def tick(self, df: pd.DataFrame): + """df = OHLCV live (open/high/low/close[/datetime]) fino ad ora. Ricomputa la griglia + col motore canonico e aggiorna capital = initial * equity_norm. SIM only (no ordini).""" + if df is None or len(df) < 40: + return + p = self.p + regime = p.get("regime", "none") + mask = (_regime_mask(df, p.get("ema_n", 200), p.get("trend_max", 2.0)) + if regime == "range" else None) + eqd, st = grid_mtm( + self.asset, tf=p["tf"], range_down=p["range_down"], range_up=p["range_up"], + levels=p["levels"], sl_buf=p["sl_buf"], tp_buf=p["tp_buf"], max_bars=p["max_bars"], + pos=self.position_size, lev=self.leverage, fee_side=self.fee_side, + flat_skip=True, deploy_mask=mask, df=df) + if eqd is None or len(eqd) == 0: + return + new_cap = self.initial_capital * float(eqd.iloc[-1]) + self.capital = max(new_cap, 0.0) + self.peak = max(self.peak, self.capital) + if self.peak > 0: + self.max_dd = max(self.max_dd, (self.peak - self.capital) / self.peak) + self.n_trades = int(st.get("trades", self.n_trades)) + self.last_ts = str(df.iloc[-1].get("datetime", df.iloc[-1].get("timestamp", ""))) + self._save_state() + self._log("GRID_MTM", {"capital": round(self.capital, 2), "n_trades": self.n_trades, + "win": st.get("win"), "stops": st.get("stops"), + "pnl_source": "sim"}) + return self.capital