feat(live): GridWorker (Price Ladder) SIM/PAPER + validazione replay==backtest — shadow stage 1
Stage 1 dello shadow per il Price Ladder (config finale: BTC 1h range1.5 rd0.20 ru0.06 L6 sl0.10 tp0.03). GridWorker (src/live/grid_worker.py) gira sul feed LIVE e contabilizza l'equity mark-to-market col motore CANONICO grid_mtm (parita' col backtest per costruzione), SENZA piazzare ordini reali (sim/paper). Stato persistente + resume. grid_mtm esteso con param df=None (retro-compatibile: il feed live passa il df; None = _load come prima, gate invariato — BTC ladder 10.8/5.9, PORT06 base 8.18 identici). Validazione validate_grid_worker.py: [A] full-tick == grid_mtm esatto, [B] replay incrementale converge esatto, [C] resume entro la persistenza (4 dec) -> VALIDAZIONE OK. NB SICUREZZA: nessuna modifica a runner/portfolios.yml/_defs -> il sistema mainnet (€500 reali) e' INTATTO; il worker e' inerte finche' non wirato. L'esecuzione REALE (griglia di LIMIT resting su Deribit, fill parziali/episodi) e' lo stage 2-3, dietro testnet + autorizzazione esplicita. Il runner avvia ordini reali solo per kind in (single,ml); kind=grid resta sim per costruzione. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user