"""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, hist: pd.DataFrame | None = 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 = "" # base_norm = valore dell'equity-norm (cumulata da inizio storia) al DEPLOY: la # capital forward = initial * eq[-1]/base_norm -> parte da `initial` e segue il # ritorno della griglia DA QUEL MOMENTO (start FISSO: niente salti da finestra mobile). self.base_norm = None # bootstrap STORIA FULL (start fisso, come SH01): il feed live e' una finestra mobile, # ma normalizzando su una serie a start fisso l'equity forward e' stabile. if hist is None: try: from src.data.downloader import load_data hist = load_data(asset, self.p.get("tf", "1h")) except Exception: hist = None self.hist = hist 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 _merge(self, live_df: pd.DataFrame) -> pd.DataFrame: """Storia bootstrap + feed live, dedup su timestamp (il live prevale), start FISSO.""" if self.hist is None or len(self.hist) == 0: return live_df cols = ["timestamp", "open", "high", "low", "close", "volume"] h = self.hist[[c for c in cols if c in self.hist.columns]] l = live_df[[c for c in cols if c in live_df.columns]] m = pd.concat([h, l], ignore_index=True) m = m.drop_duplicates(subset="timestamp", keep="last").sort_values("timestamp") return m.reset_index(drop=True) 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.base_norm = s.get("base_norm") self._log("RESUME", {"capital": round(self.capital, 2), "n_trades": self.n_trades, "base_norm": self.base_norm}) 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, "base_norm": self.base_norm, "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 (finestra mobile) fino ad ora. Merge con la storia bootstrap (start FISSO), ricomputa la griglia col motore canonico, e mappa il capitale forward: capital = initial * eq[-1]/base_norm (parte da `initial` al deploy, segue la griglia da li' in poi). SIM only (nessun ordine reale).""" if df is None or len(df) < 40: return full = self._merge(df) p = self.p regime = p.get("regime", "none") mask = (_regime_mask(full, 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=full) if eqd is None or len(eqd) == 0: return cur = float(eqd.iloc[-1]) if self.base_norm is None or self.base_norm <= 0: self.base_norm = cur # baseline al primo tick (deploy) self.capital = max(self.initial_capital * cur / self.base_norm, 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(full.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