14522262e6
Reset del progetto su fondamenta verificate dopo la scoperta che l'intera libreria "validata OOS" era artefatto di feed contaminato (print fantasma del feed Cerbero TESTNET + storico Binance/USDT). - Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE 50-82% barre flat; XRP/BNB non certificabili). - Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST con segnale residuo, da ri-validare in isolamento. - Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio, runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/ portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/ (preservati, non cancellati). Diario consolidato in un unico documento. - Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal + src/backtest/engine + load_data; tool dati certificati (rebuild_history, certify_feed, audit_feed, multi_source_check). - Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
7.5 KiB
Python
158 lines
7.5 KiB
Python
"""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
|