From d733196564eef34fa4a439ab1c97c2173fc114d4 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 18 Jun 2026 12:29:25 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(analysis):=20ladder=5Fsearch=20?= =?UTF-8?q?=E2=80=94=20harness=20caccia=20Price=20Ladder=20che=20passi=20i?= =?UTF-8?q?l=20gate=20PORT06?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goal "decine di agenti a cercare strategie Price Ladder" (Deribit). Riusa il motore grid_mtm mark-to-market ONESTO (SL gap-aware, flat-skip, fee 0.10% RT taker = CONSERVATIVO su Deribit, dove i fill ai livelli LIMIT sono maker ~0%) ed espone: - eval - scan (sub-griglia struttura, gate PORT06, baseline cachata) -> top celle con verdetto gate + max_corr coi 19 sleeve + FULL DD. Leva NUOVA: regime-gate `range` (deploy_mask in grid_mtm, retro-compatibile) = deploya la griglia SOLO in regime di range (|close-EMA200|/ATR < trend_max), dove la griglia vive, e non in trend dove muore. Contesto della ricerca: la griglia ETH del gioco e' BOCCIATA (ridondante, corr 0.40); i ladder BTC sono meno correlati (~0.18) e passano il gate, MA il nodo e' la FULL DD (coda di trend 2021/22, ~60% standalone) che il verdetto del gate NON controlla -> la harness la espone (full_dd standalone + full_full_dd di portafoglio). grid_mtm: aggiunto param deploy_mask (None = comportamento storico, parita' col gate). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/analysis/grid_game_gate.py | 9 +- scripts/analysis/ladder_search.py | 191 +++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 scripts/analysis/ladder_search.py diff --git a/scripts/analysis/grid_game_gate.py b/scripts/analysis/grid_game_gate.py index 9e7c694..63ed7a0 100644 --- a/scripts/analysis/grid_game_gate.py +++ b/scripts/analysis/grid_game_gate.py @@ -68,11 +68,15 @@ 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): + close_only=False, deploy_mask=None): """Griglia STRATEGIA_GRIGLIA.md con contabilita' mark-to-market. Ritorna (equity daily Series base 1.0, stats dict). Causale: deploy sul close, fill dalle barre successive lungo il percorso O->L->H->C / O->H->L->C. + `deploy_mask` (opzionale, np.bool array lungo come la serie, causale): se + 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 = _load(asset, tf) op = df["open"].to_numpy(float) @@ -106,6 +110,9 @@ def grid_mtm(asset="ETH", *, tf, range_down, range_up, levels, sl_buf, tp_buf, i = 20 for j in range(20, n): if not active: + if deploy_mask is not None and not deploy_mask[j]: + eq[j] = capital # regime-gate: niente deploy, resta in cash + continue # deploy sul close di j (fill da j+1) px = cl[j] rl_ = px * (1 - range_down) diff --git a/scripts/analysis/ladder_search.py b/scripts/analysis/ladder_search.py new file mode 100644 index 0000000..aac3b4d --- /dev/null +++ b/scripts/analysis/ladder_search.py @@ -0,0 +1,191 @@ +"""LADDER SEARCH — harness per la caccia multi-agente a strategie Price Ladder (griglia). + +Goal 2026-06-18 (branch price_ladder_research): "decine di agenti a cercare strategie +Price Ladder". CONTESTO: il gioco "Grid Traders" trovo' gia' una griglia ETH asimmetrica +fortissima standalone (FULL Sharpe 5.61, OOS 4.21, plateau 16/16) ma BOCCIATA al gate +PORT06 -- ridondante con le fade ETH (corr +0.40 con MR02_ETH): full-size alza FULL ma +abbassa OOS 10.86->10.47. Quindi la ricerca NON e' "trovare un edge griglia" (gia' fatto) +ma trovarne uno che PASSI IL GATE = aggiunga DIVERSIFICAZIONE. Leve nuove: + - ASSET diverso da ETH (BTC: meno ridondante con la reversione ETH); + - REGIME-GATE: deployare la griglia SOLO in regime di range (non trend) -- il doc + STRATEGIA_GRIGLIA.md dice che la griglia muore in trend; gateare i deploy concentra + l'edge dove funziona E riduce la correlazione coi trend-follower; + - STRUTTURA: livelli, range asimmetrico, sl/tp buffer, max_bars, tf. + +Motore = grid_mtm (mark-to-market ONESTO: SL gap-aware, flat-skip, fee 0.10% RT) di +grid_game_gate.py, esteso con deploy_mask per il regime-gate (retro-compatibile). +Tutto NETTO, OOS held-out, leva 3x. Il giudizio che CONTA e' il gate PORT06. + +CLI (JSON): + uv run python scripts/analysis/ladder_search.py eval ETH 15m 0.171 0.046 4 0.124 0.048 2143 + uv run python scripts/analysis/ladder_search.py eval BTC 1h 0.13 0.05 4 0.12 0.05 1500 range 2.0 +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from scripts.analysis.grid_game_gate import grid_mtm, std, _load +from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT, IDX +from scripts.portfolios._defs import PORTFOLIOS +from src.portfolio import weighting as W + +_BASE = None + + +def _baseline(): + global _BASE + if _BASE is None: + from src.portfolio.sleeves import all_sleeve_equities + _BASE = dict(all_sleeve_equities()) + return _BASE + + +def _atr(df, n=14): + h, l, c = df["high"].to_numpy(float), df["low"].to_numpy(float), df["close"].to_numpy(float) + pc = np.roll(c, 1); pc[0] = c[0] + tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) + return pd.Series(tr).rolling(n).mean().to_numpy() + + +def regime_mask(asset, tf, ema_n=200, trend_max=2.0): + """Mask CAUSALE 'range-bound' allineata alle righe di _load(asset,tf): + True dove |close - EMA(ema_n)| / ATR(14) < trend_max (prezzo vicino al trend = + regime di range -> la griglia puo' deployare). Decisione a close[j] con dati <= j.""" + df = _load(asset, tf) + c = df["close"].to_numpy(float) + ema = pd.Series(c).ewm(span=ema_n, adjust=False).mean().to_numpy() + a = _atr(df, 14) + with np.errstate(invalid="ignore", divide="ignore"): + dist = np.abs(c - ema) / np.where(a == 0, np.nan, a) + m = dist < trend_max + m[~np.isfinite(dist)] = False # warmup / ATR nan -> niente deploy + return m + + +def _gate(grid_eq): + """Gate PORT06 (stesso metodo di grid_game_gate): baseline vs +LADDER full/half. + Ritorna metriche base/full/half + max corr coi 19 sleeve (segnale ridondanza).""" + p = PORTFOLIOS["PORT06"] + base = _baseline() + + def port_m(extra): + members = dict(base); ids = list(p.sleeve_ids) + if extra is not None: + members["LADDER"] = extra; ids = ids + ["LADDER"] + dr = pd.DataFrame({i: members[i].reindex(IDX).ffill().bfill() + .pct_change().fillna(0.0) for i in ids}) + w = W.weight_vector(p.weighting, ids, dr, weights=p.weights, + caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback) + drp = port_returns({i: members[i].reindex(IDX).ffill().bfill() for i in ids}, w) + return metrics(drp), metrics(drp, lo=SPLIT) + + fb, ob = port_m(None) + gr = grid_eq.reindex(IDX).ffill().bfill().pct_change().fillna(0.0) + maxcorr = max(abs(gr.corr(e.reindex(IDX).ffill().bfill().pct_change().fillna(0.0))) + for e in base.values()) + half = (1 + 0.5 * gr).cumprod() + ff, of = port_m(grid_eq) + fh, oh = port_m(half) + + def ok(f, o): + return bool(o["sharpe"] >= ob["sharpe"] - 0.02 and o["dd"] <= ob["dd"] + 1e-9 + and f["sharpe"] >= fb["sharpe"] - 0.02) + return { + "base_full_sh": round(fb["sharpe"], 2), "base_full_dd": round(fb["dd"], 2), + "base_oos_sh": round(ob["sharpe"], 2), "base_oos_dd": round(ob["dd"], 2), + "full_oos_sh": round(of["sharpe"], 2), "full_oos_dd": round(of["dd"], 2), + "full_full_sh": round(ff["sharpe"], 2), "full_full_dd": round(ff["dd"], 2), + "half_oos_sh": round(oh["sharpe"], 2), "half_oos_dd": round(oh["dd"], 2), + "half_full_sh": round(fh["sharpe"], 2), "half_full_dd": round(fh["dd"], 2), + "max_corr_existing": round(float(maxcorr), 3), + "verdict_full": "PROMOSSO" if ok(ff, of) else "bocciato", + "verdict_half": "PROMOSSO" if ok(fh, oh) else "bocciato", + } + + +def evaluate(asset, tf, rd, ru, levels, sl_buf, tp_buf, max_bars, + regime="none", trend_max=2.0, ema_n=200, do_gate=True, do_fee2x=True, + flat_skip=True, close_only=False) -> dict: + mask = regime_mask(asset, tf, ema_n=ema_n, trend_max=trend_max) if regime == "range" else None + cfg = dict(tf=tf, range_down=rd, range_up=ru, levels=levels, + sl_buf=sl_buf, tp_buf=tp_buf, max_bars=max_bars) + try: + eqd, st = grid_mtm(asset, **cfg, deploy_mask=mask, flat_skip=flat_skip, close_only=close_only) + except ValueError as e: + return {"asset": asset, "tf": tf, "regime": regime, "error": str(e)} + f, o = std(eqd) + row = { + "asset": asset, "tf": tf, "regime": regime, "trend_max": trend_max, + "rd": rd, "ru": ru, "levels": levels, "sl_buf": sl_buf, "tp_buf": tp_buf, "max_bars": max_bars, + "trades": st["trades"], "win": round(st["win"], 1), "stops": st["stops"], + "full_sh": round(f["sharpe"], 2), "full_dd": round(f["dd"], 2), "full_ret": round(f["ret"], 0), + "oos_sh": round(o["sharpe"], 2), "oos_dd": round(o["dd"], 2), + } + if do_fee2x: + eq2, _ = grid_mtm(asset, **cfg, fee_side=0.001, deploy_mask=mask, flat_skip=flat_skip) + row["fee2x_oos_sh"] = round(std(eq2)[1]["sharpe"], 2) + if do_gate: + row.update(_gate(eqd)) + return row + + +# griglia di struttura coarse per lo scan (range asimmetrico favorito, come il vincitore) +SCAN_RD = [0.08, 0.12, 0.16, 0.20] +SCAN_RU = [0.04, 0.06] +SCAN_LEVELS = [3, 4, 6] +SCAN_SLB = [0.12] +SCAN_TP = 0.05 +MAXBARS_TF = {"15m": 2880, "30m": 1440, "1h": 720} # ~30 giorni di episodio + + +def scan(asset, tf, regime="none", trend_max=2.0, top=10) -> list: + """Sweep coarse della struttura (rd x ru x levels) col gate PORT06, baseline + cachata (una load per processo). Ritorna le top-`top` celle per qualita' di gate.""" + mb = MAXBARS_TF.get(tf, 720) + rows = [] + for rd in SCAN_RD: + for ru in SCAN_RU: + for lv in SCAN_LEVELS: + for slb in SCAN_SLB: + r = evaluate(asset, tf, rd, ru, lv, slb, SCAN_TP, mb, + regime=regime, trend_max=trend_max, + do_gate=True, do_fee2x=False) + if "error" not in r: + rows.append(r) + # score: PROMOSSO half/full premiati; poi OOS migliorato col candidato; penalita' FULL DD del portafoglio + def score(r): + promo = (r.get("verdict_half") == "PROMOSSO") + (r.get("verdict_full") == "PROMOSSO") + return (promo, r.get("half_oos_sh", 0) - 0.1 * r.get("full_full_dd", 99)) + rows.sort(key=score, reverse=True) + return rows[:top] + + +def main(): + a = sys.argv + if len(a) >= 2 and a[1] == "scan": + asset, tf = a[2], a[3] + regime = a[4] if len(a) > 4 else "none" + trend_max = float(a[5]) if len(a) > 5 else 2.0 + print(json.dumps(scan(asset, tf, regime=regime, trend_max=trend_max))) + return + if len(a) < 2 or a[1] != "eval": + print(__doc__); return + asset, tf = a[2], a[3] + rd, ru = float(a[4]), float(a[5]) + levels = int(a[6]); sl_buf, tp_buf = float(a[7]), float(a[8]); max_bars = int(a[9]) + regime = a[10] if len(a) > 10 else "none" + trend_max = float(a[11]) if len(a) > 11 else 2.0 + print(json.dumps(evaluate(asset, tf, rd, ru, levels, sl_buf, tp_buf, max_bars, + regime=regime, trend_max=trend_max))) + + +if __name__ == "__main__": + main() From e631e1b89cb556f7cb0ff598a80af70c2076482e Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 18 Jun 2026 14:16:54 +0000 Subject: [PATCH 2/7] =?UTF-8?q?docs(diary):=20price-ladder=20search=202026?= =?UTF-8?q?-06-18=20=E2=80=94=20verdetto=20RETTIFICATO=20(nessun=20deploy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caccia multi-agente (34 agenti, 200 celle) a Price Ladder che passi il gate PORT06 su Deribit. La sintesi iniziale trovava candidati "deployabili" (ETH 1h range-gated half-size, OOS 10.86->11.0; BTC half-size OOS->11.34). Il critico + verifica dati ROVESCIANO il verdetto (sez. 6): (1) la "coda BTC ~54%" e' DATI CORROTTI da spike-print (37 barre >20% intrabar, feb-2024 wick fasullo -23%, min 22.7k/max 104.7k vs BTC reale ~47-57k = stesso problema TP_PHANTOM/feed testnet di CLAUDE.md) -> BTC non affidabile, lo stress-2022 non toccherebbe la coda vera; (2) il +0.14 OOS Sharpe e' rumore al peso deployabile (~2.7% half, 592g regime calmo, no significativita'); (3) il finalista ETH e' corr 0.305 (vicino allo 0.40 che fece bocciare la griglia originale) e il regime_mask condivide il filtro trend con le fade ETH live; (4) FULL DD standalone mascherata dal compounding (31x); (5) close_only disponibile ma non girato sui finalisti. VERDETTO: nessun Price Ladder deployabile dimostrato; il multi-agente+critico ha evitato un deploy ingiustificato. Config live INVARIATA. Harness validata resta come record. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/diary/2026-06-18-price-ladder-search.md | 189 +++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/diary/2026-06-18-price-ladder-search.md diff --git a/docs/diary/2026-06-18-price-ladder-search.md b/docs/diary/2026-06-18-price-ladder-search.md new file mode 100644 index 0000000..444480e --- /dev/null +++ b/docs/diary/2026-06-18-price-ladder-search.md @@ -0,0 +1,189 @@ +# 2026-06-18 — Price Ladder (griglia) vs gate PORT06 + +**Branch:** `price_ladder_research` · **Harness:** `scripts/analysis/ladder_search.py` +(engine `grid_mtm`: mark-to-market onesto, SL gap-aware, flat-skip, fee 0.10% RT taker). +**Baseline PORT06:** FULL Sharpe 8.13 / DD 2.47% — OOS Sharpe 10.86 / DD 2.09%. +**Metodo:** caccia multi-agente su asset × TF (15m/30m/1h) × regime-gate (none / range +trend_max 1.5/2.0/2.5), poi `verify` avversariale sui finalisti (plateau, fee 2x, corr, +FULL DD di portafoglio). **Goal di progetto:** trovare un Price Ladder che *diversifichi* +(PASSI il gate) senza accendere la coda di trend. + +--- + +## 1. Cosa era già noto + +Il gioco-griglia precedente aveva trovato una **griglia ETH fortissima standalone** +(Sharpe ~5.61) ma **BOCCIATA al gate PORT06**: corr ~0.40 con le fade ETH già deployate +→ **ridondante**, non un diversificatore. La lezione: lo standalone Sharpe non basta, conta +quanto il ladder *aggiunge ortogonalmente* al portafoglio. Questa ricerca cerca proprio un +ladder che PASSI il gate = che abbia corr bassa con gli sleeve esistenti. + +--- + +## 2. Risultato NUOVO — i ladder PASSANO il gate + +Sì. Su **tutti gli asset liquidi** (BTC e ETH) e su quasi tutte le combinazioni TF×regime, +i ladder migliorano l'OOS Sharpe di portafoglio (10.86 → ~11.0–11.35) **con corr bassa**: + +- **BTC** (15m/30m/1h, range o none): corr **0.15–0.27**, molto più ortogonale della + griglia ETH bocciata (0.40). OOS di portafoglio 11.1–11.35 (il guadagno OOS più grosso). +- **ETH** (15m/30m/1h): corr **0.21–0.57** (15m la peggiore, fino a 0.57; a 1h scende a + 0.22–0.39). OOS di portafoglio ~10.9–11.1. +- **SOL/LTC**: SOL passa solo 2 celle, half-only, edge standalone quasi nullo + (full_sh ~0.2); **LTC è negativo ovunque** (full_sh −0.4…−0.8, bocciato) → solo BTC/ETH + liquidi danno un ladder vivo, come atteso. + +Il gate **misura corr + OOS**, e su questi due assi i ladder BTC vincono nettamente. + +### Migliori candidati (dai `verify` avversariali) + +| Asset/TF/regime/struttura | OOS base→new | FULL DD standalone | FULL DD portafoglio (full / half) | corr | fee2x OOS | robust? | +|---|---|---|---|---|---|---| +| **ETH 1h range2 rd0.16 ru0.04 L4** | 10.86→**11.00** | 11.79% | **2.70** / 2.52 | 0.305 | 4.89 | ✅ sì | +| **ETH 1h range2 rd0.16 ru0.06 L6** | 10.86→11.01 | 12.09% | 2.74 / 2.54 | 0.328 (al filo) | 5.05 | ✅ sì | +| **ETH 1h range2 rd0.16 ru0.06 L4** | 10.86→10.98 | 12.64% | **2.68** / 2.51 | 0.249 | 4.44 | ✅ sì | +| **ETH 30m range2 rd0.16 ru0.04 L3** | 10.86→10.93 | 12.56% | **2.48** / 2.41 | 0.223 | 4.21 | ✅ sì | +| **ETH 1h none rd0.20 ru0.06 L6** | 10.86→11.03 | 17.89% | 2.59 / 2.46 | 0.271 | 5.43 | ✅ sì | +| **ETH 30m none rd0.16 ru0.06 L4** | 10.86→10.92 | 18.68% | **2.37** / 2.35 | 0.319 | 5.42 | ✅ sì | +| **ETH 1h none rd0.16 ru0.06 L4** | 10.86→10.96 | 23.13% | **2.40** / 2.37 | 0.224 | 4.59 | ✅ sì | +| **BTC 1h none rd0.08 ru0.06 L3** | 10.86→**11.34** | **53.69%** | 3.11 / **2.48** | 0.216 | 5.78 | ⚠ half-only | +| **BTC 30m range2.5 rd0.08 ru0.06 L3** | 10.86→11.22 | 55.33% | 3.27 / **2.50** | 0.265 | 5.19 | ⚠ half-only | +| **BTC 30m none rd0.08 ru0.06 L3** | 10.86→11.32 | 55.33% | 3.27 / **2.44** | 0.220 | 5.37 | ⚠ half-only (tail) | +| **BTC 1h range1.5 rd0.20 ru0.06 L6** | 10.86→11.13 | 54.43% | 3.19 / **2.33** | **0.161** | 5.46 | ⚠ half-only (tail) | + +`rd` = spacing down, `ru` = spacing up, `L` = livelli. Tutti i finalisti hanno plateau +robusto (4–7 vicini di struttura restano PROMOSSO) e fee2x ~= OOS standalone. + +--- + +## 3. IL NODO — la FULL DD (che il gate IGNORA) + +**Il gate verdict NON guarda la FULL DD.** Misura solo corr + OOS Sharpe/DD. Una griglia +long-only ha una coda di trend 2021/22 (viene comprata fino in fondo mentre il prezzo crolla +in un trend persistente) → la **FULL DD standalone** è il vero rischio nascosto. Il filtro +onesto è la **FULL DD di PORTAFOGLIO** (`full_full_dd` / `half_full_dd`), con soglia di +tail-risk **~+0.8pp sopra il baseline 2.47%** (cioè restare sotto ~3.3%). + +Due famiglie nette emergono: + +- **BTC** — FULL DD standalone **~54–67%** (la coda long-only è devastante, come temuto). + Il regime-gate range *taglia gli ingressi nei trend* ma **non immunizza** dalle posizioni + già aperte → a FULL size la FULL DD di portafoglio sale a **3.1–6.6%** (+0.6/+4.0pp, + **oltre la soglia tail** in quasi tutte le celle). **A HALF size invece rientra a 2.33–2.54%** + (~baseline) preservando l'OOS ~11.0–11.3. → **BTC deployabile SOLO a half-size**, e il + guadagno OOS più grosso (11.3+) viene proprio da queste celle a coda alta. + +- **ETH** — FULL DD standalone **molto più bassa** del previsto: **12–33%** (1h range scende + a **~12%**), NON il ~60% temuto. Lo SL-buffer + flat-skip + il regime-gate range tengono la + griglia fuori dai trend estesi. Conseguenza: la FULL DD di portafoglio resta **2.37–2.74%** + (+0.0/+0.27pp, **sotto soglia anche a FULL size**) — in diversi casi *scende* sotto il + baseline (es. ETH 30m none L4 → 2.37%). → **ETH è la famiglia tail-safe.** Il prezzo è una + corr più alta (0.22–0.33 sui finalisti 1h/30m, fino a 0.57 sul 15m da scartare). + +**Lettura chiave:** il regime-gate *abbassa* la FULL DD ma su BTC **non a sufficienza** a +full size (la coda 54% è troppo grande); è l'**half-size** a fare il lavoro. Su ETH la coda +è già piccola e il portafoglio l'assorbe. Quindi il discriminante reale è **asset (ETH vs +BTC) + size**, non solo il regime-gate. + +--- + +## 4. Vantaggio Deribit — fill MAKER + +Un Price Ladder è una griglia di **ordini LIMIT ai livelli** → su Deribit i fill sono +**MAKER (~0% fee)**, non taker. L'harness assume **0.10% RT taker** ovunque → è +**CONSERVATIVA**: la stessa griglia live pagherebbe meno fee e potrebbe rendere PIÙ del +backtest. Lo confermano i `verify`: il **fee2x** (raddoppio fee) lascia l'OOS Sharpe quasi +intatto (~98% trattenuto su quasi tutti i finalisti) → nessun fee-cliff, e con i fill maker +reali c'è upside, non downside, sul lato costi. (Il rischio resta la coda di trend e lo +slippage/fill in esecuzione reale, non le fee.) + +--- + +## 5. Verdetto operativo + +**C'è un Price Ladder deployabile.** Il candidato più pulito su tutti gli assi +(PROMOSSO + FULL DD di portafoglio controllata a full size + plateau robusto + corr genuina): + +> **ETH 1h, regime=range trend_max 2.0, rd=0.16 / ru=0.04 / 4 livelli, sl_buf 0.12 / tp_buf 0.05, max_bars 720.** +> OOS 10.86→11.00, FULL DD portafoglio **2.70% a full / 2.52% a half** (standalone 11.79%), +> corr 0.305, fee2x OOS 4.89, plateau 4 vicini PROMOSSO. **Deploy raccomandato a HALF size** +> (FULL DD +0.05pp, marginale; il caveat slippage live giustifica la prudenza). + +**Alternative ETH equivalenti** (stessa banda di sicurezza, scegliere per corr/coda): +`ETH 1h range2 rd0.16 ru0.06 L4` (corr 0.249, la più bassa) e `ETH 30m range2 rd0.16 ru0.04 L3` +(FULL DD portafoglio 2.48, ~baseline). Tutte ETH 1h/30m **range-gated rd≥0.16** → NON scendere +sotto rd 0.16 (rd 0.12 fa risalire la corr verso 0.38 = ricomincia a somigliare alle fade ETH; +il 15m ha corr 0.43–0.57 = troppo ridondante, scartare). + +**I BTC NON a full size.** Danno il guadagno OOS più grande (fino a 11.34) e la corr più bassa +(0.16–0.22 = la diversificazione migliore), MA la FULL DD standalone ~54% li rende +**tail-risky a full size** (FULL DD portafoglio 3.1–6.6%, oltre soglia). **Deployabili SOLO a +half-size** (FULL DD portafoglio 2.33–2.50), dove restano un diversificatore eccellente. Il +migliore BTC half-size è `BTC 1h range1.5 rd0.20 ru0.06 L6` (corr **0.161**, FULL DD portafoglio +half **2.33**). + +**BLOCCO prima di qualsiasi deploy:** il gate PORT06 (corr + OOS) è **necessario ma non +sufficiente** — non controlla la FULL DD, e l'OOS held-out è un **regime calmo** (2024–25). La +coda di trend 2021/22 è reale e standalone è enorme su BTC. **Nessun deploy senza un gate +stressato sul 2022** (verificare la FULL DD di portafoglio attraverso il trend duro, non solo +l'OOS calmo) e una conferma su **ledger reale shadow** per slippage/fill maker. Il verdetto del +gate va sempre incrociato con la FULL DD di portafoglio (filtro vero) + fattibilità feed live + +regime recente — la stessa lezione del timing-sweep pairs/honest (2026-06-14). + +**Sintesi:** ETH 1h/30m range-gated rd≥0.16 a half-size è il **deploy-candidate sano e +tail-safe**; BTC è il diversificatore migliore ma **solo a half-size** per la coda di trend. +Prossimo passo obbligatorio: stress-test 2022 + shadow ledger reale (i fill maker possono solo +migliorare il risultato sul lato fee). + +--- + +## 6. ⚠️ RETTIFICA CRITICA — il verdetto §5 è ROVESCIATO (critico + verifica dati) + +Il critico di completezza + una verifica diretta dei dati **ribaltano la raccomandazione di +deploy della §5**. Messo a verbale: la §5 è SUPERATA da quanto segue. + +**(1) I risultati BTC poggiano su DATI CORROTTI (spike-print), verificato.** La "coda di trend +BTC ~54%" su cui ruota la §3 NON è un crollo di mercato: è **feed sporco**. Misurato su `btc_1h`: +**37 barre con range intrabar >20%**, concentrate a **feb-2024** (es. 2024-02-13: low=38.580 con +close~49.968 = wick fasullo −23% che rimbalza nella stessa barra), con min 22.706 / max 104.688 in +un trimestre dove BTC stava ~47–57k. È lo **stesso spike-print testnet documentato ovunque in +CLAUDE.md** (TP_PHANTOM / feed congelato). L'engine SL-gap-aware filla su questi wick fantasma e la +griglia "compra" i dip fasulli → la coda BTC ~54% e parte dell'edge OOS sono **artefatto, non +mercato**. Conseguenza: i candidati BTC NON sono affidabili, e lo "stress 2022" proposto NON +toccherebbe la coda vera (che è a feb-2024, da dati sporchi). Serve `close_only` + pulizia feed. + +**(2) Il guadagno OOS è RUMORE al peso deployabile.** +0.14 OOS Sharpe su base 10.86, con il ladder +che pesa **~5.3% full / ~2.7% half** in un portafoglio già a Sharpe ~11, su **592 giorni di un +solo regime calmo** (2024-10→2026-05, niente 2022). Nessun test di significatività +(block-bootstrap). A half-size il contributo è quasi inerte (a volte l'OOS DD *migliora*): è dentro +l'errore di campionamento, non un edge dimostrato. + +**(3) La diversificazione ETH è più sottile di quanto venduto.** Il finalista ETH "raccomandato" è +**corr 0.305** (non 0.15–0.20: quella è BTC half-only non deployabile), già vicino allo **0.40 che +fece bocciare la griglia ETH originale**. Peggio: il `regime_mask` usa **lo stesso filtro +trend-distance** (|close−EMA200|/ATR) delle fade ETH già live → la "diversificazione" è in parte +illusoria (gate e sleeve condividono il filtro). La corr misurata è **daily in regime calmo**, NON +co-tail: una griglia long-only e una fade reversion possono co-crollare in un trend (entrambe +"comprano il calo") — la metrica che conterebbe (co-movimento nella coda) non è calcolata. + +**(4) La FULL DD standalone è mascherata dal compounding.** Il finalista ETH compone a **~31x +(+3.1M%) in 5.4 anni**: una "FULL DD 11.79%" accanto a un ritorno di milioni-% è artefatto di +notional reinvestito, e il daily-resample **nasconde l'inventory-risk intrabar** che il docstring +di `grid_game_gate.py` (righe 5–7) avverte esplicitamente di non vedere. + +**(5) Stress disponibile NON usato.** `ladder_search`/`grid_mtm` hanno `close_only=True` (filla solo +sul close, neutralizza gli spike-print): NON è stato girato sui finalisti — proprio il test che +smonterebbe la coda BTC fasulla. + +### Verdetto rettificato + +**NON c'è un Price Ladder deployabile dimostrato.** La ricerca ha prodotto (a) una **harness onesta +e validata** (`ladder_search.py` + regime-gate, riproduce esatto il gate noto), (b) un **risultato +negativo/guardingo** robusto: ogni candidato o è ridondante (ETH, corr→0.40), o poggia su dati +sporchi (BTC spike-print), o è rumore al peso deployabile. **Il multi-agente + critico ha evitato +un deploy ingiustificato** — esattamente la lezione anti-squeeze del progetto. + +**Prima di riconsiderare QUALSIASI ladder servono, in ordine:** (1) **pulire il feed BTC** dagli +spike-print + girare `close_only` sui finalisti; (2) **test di significatività** del delta OOS +(block-bootstrap) al peso reale; (3) **co-tail / corr nei crash** (2022), non corr daily calma; +(4) isolare e spiegare l'**evento feb-2024**. Config live **invariata**. From b9e3388f7668e8a7193d08cd9897032601e24976 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 18 Jun 2026 14:25:34 +0000 Subject: [PATCH 3/7] =?UTF-8?q?feat(data):=20clean=5Ffeed=20=E2=80=94=20ri?= =?UTF-8?q?para=20spike-print=20del=20feed=20Deribit=20coi=20dati=20reali?= =?UTF-8?q?=20Binance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La ricerca Price Ladder ha rivelato spike-print nei parquet storici (es. BTC 2024-02-13 low 38.580 / high fasulli, BTC reale ~50k) = stesso problema TP_PHANTOM/feed testnet di CLAUDE.md, che avvelena i backtest (stop/entry su prezzi mai avvenuti) e gonfia le code. Tool conservativo: DETECT (wick >15% oltre il cluster close locale) -> ARBITRA con Binance spot (ccxt, gia' cablato): sostituisce O/H/L/C solo se Binance dissente >2% su high/low (un wick VERO confermato da Binance resta intatto), backup in data/_feed_backup/ + scrittura atomica + validazione. RIPARATE 254 barre (BTC 132, ETH 122) su 8 file BTC/ETH x TF; 2 wick reali confermati da Binance e TENUTI (ETH 30m/1h flash-crash veri). Impatto validato: il BTC ladder che dava FULL DD 53.69% (artefatto) ora ne da 10.8% (la "coda di trend" era spike fasulli); PORT06 baseline FULL Sharpe 8.13->8.18, DD/OOS invariati (canonici stabili). data/raw e' gitignored: e' committato il TOOL, non i parquet (rigenerabili + backup locale). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/analysis/clean_feed.py | 140 +++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 scripts/analysis/clean_feed.py diff --git a/scripts/analysis/clean_feed.py b/scripts/analysis/clean_feed.py new file mode 100644 index 0000000..e21742b --- /dev/null +++ b/scripts/analysis/clean_feed.py @@ -0,0 +1,140 @@ +"""CLEAN FEED — ripara gli spike-print del feed Deribit/Cerbero coi dati reali di Binance. + +Motivo (2026-06-18): la ricerca Price Ladder ha rivelato che data/raw/btc_1h.parquet (e gli +altri TF/asset) contengono barre con WICK FASULLI (es. BTC 2024-02-13: low 38.580 con +close ~49.968, BTC reale ~50k) — lo stesso spike-print testnet documentato in CLAUDE.md +(TP_PHANTOM / feed congelato). Sono pochi (decine per file) ma avvelenano i backtest +(stop/entry su prezzi mai avvenuti) e gonfiano le code (la "FULL DD BTC ~54%" del ladder era +in gran parte questo). + +Metodo (conservativo, fonte di verita' = Binance spot via ccxt, gia' cablato nel progetto): + 1. DETECT: barra sospetta = high/low che sfora >15% il cluster di close locale [i-1,i,i+1] + (close sano + wick fasullo). Soglia larga: tanto e' Binance ad arbitrare. + 2. ARBITRA: per ogni sospetta, scarica la barra Binance reale (BTC/USDT, ETH/USDT) allo + stesso tf/timestamp. Sostituisce O/H/L/C SOLO se Binance dissente materialmente (>2% su + high o low) -> un wick VERO confermato da Binance resta intatto. Volume/timestamp invariati. + 3. BACKUP (data/_feed_backup/) + scrittura atomica + VALIDAZIONE (re-scan = 0 sospette, + n righe invariato). Log dettagliato di ogni barra riparata (old OHLC -> new). + + uv run python scripts/analysis/clean_feed.py [ASSET_TF ...] # default: tutti BTC/ETH x TF + uv run python scripts/analysis/clean_feed.py BTC_1h # un solo file +""" +from __future__ import annotations + +import shutil +import sys +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.data.downloader import _parquet_path, DATA_DIR + +BACKUP = PROJECT_ROOT / "data" / "_feed_backup" +SYMBOL = {"BTC": "BTC/USDT", "ETH": "ETH/USDT"} +WICK_THR = 0.15 # detect: wick oltre 15% il cluster di close locale +REPLACE_THR = 0.02 # arbitra: sostituisci solo se Binance dissente >2% su high/low +_EX = None + + +def _binance(): + global _EX + if _EX is None: + import ccxt + _EX = ccxt.binance({"enableRateLimit": True}) + return _EX + + +def suspect_mask(df: pd.DataFrame) -> np.ndarray: + c = df["close"].to_numpy(float); h = df["high"].to_numpy(float); l = df["low"].to_numpy(float) + cp = np.roll(c, 1); cp[0] = c[0]; cn = np.roll(c, -1); cn[-1] = c[-1] + locmax = np.maximum.reduce([c, cp, cn]); locmin = np.minimum.reduce([c, cp, cn]) + return (h > locmax * (1 + WICK_THR)) | (l < locmin * (1 - WICK_THR)) + + +def _binance_bar(symbol: str, tf: str, ts_ms: int): + """OHLC reale Binance alla barra ts_ms (None se assente).""" + try: + rows = _binance().fetch_ohlcv(symbol, tf, since=ts_ms - 1, limit=3) + except Exception as e: + print(f" ! binance err: {type(e).__name__}: {str(e)[:80]}") + return None + for r in rows: + if int(r[0]) == ts_ms: + return float(r[1]), float(r[2]), float(r[3]), float(r[4]) + return None + + +def clean_file(asset: str, tf: str) -> dict: + path = _parquet_path(asset, tf) + if not path.exists(): + return {"file": f"{asset}_{tf}", "skip": "no-file"} + df = pd.read_parquet(path) + mask = suspect_mask(df) + idx = np.where(mask)[0] + n0 = len(df) + if len(idx) == 0: + return {"file": f"{asset}_{tf}", "suspect": 0, "repaired": 0, "kept_real": 0, + "missing_binance": 0, "rows_before": n0, "rows_after": n0, + "still_suspect": 0, "log": []} + repaired, kept, missing = 0, 0, 0 + log = [] + for i in idx: + ts = int(df.iloc[i]["timestamp"]) + b = _binance_bar(SYMBOL[asset], tf, ts) + oh, ol = float(df.iloc[i]["high"]), float(df.iloc[i]["low"]) + if b is None: + missing += 1 + continue + bo, bh, bl, bc = b + if abs(oh - bh) / bh > REPLACE_THR or abs(ol - bl) / max(bl, 1e-9) > REPLACE_THR: + df.iat[i, df.columns.get_loc("open")] = bo + df.iat[i, df.columns.get_loc("high")] = bh + df.iat[i, df.columns.get_loc("low")] = bl + df.iat[i, df.columns.get_loc("close")] = bc + repaired += 1 + ts_s = pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M") + log.append(f" {ts_s} H {oh:,.0f}->{bh:,.0f} L {ol:,.0f}->{bl:,.0f}") + else: + kept += 1 # Binance conferma il wick: barra reale, intatta + if repaired: + BACKUP.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, BACKUP / f"{asset.lower()}_{tf}.parquet.bak") + tmp = path.with_suffix(".parquet.tmp") + df.to_parquet(tmp, index=False) + tmp.replace(path) + # validazione + df2 = pd.read_parquet(path) + still = int(suspect_mask(df2).sum()) + return {"file": f"{asset}_{tf}", "suspect": len(idx), "repaired": repaired, + "kept_real": kept, "missing_binance": missing, "rows_before": n0, + "rows_after": len(df2), "still_suspect": still, "log": log} + + +def main(): + targets = sys.argv[1:] or [f"{a}_{tf}" for a in ("BTC", "ETH") + for tf in ("5m", "15m", "30m", "1h")] + print(f"CLEAN FEED — backup in {BACKUP}\n") + grand = 0 + for t in targets: + asset, tf = t.split("_", 1) + r = clean_file(asset, tf) + if r.get("skip"): + print(f" {t:<9} SKIP ({r['skip']})"); continue + grand += r.get("repaired", 0) + print(f" {r['file']:<9} sospette={r['suspect']:>3} riparate={r['repaired']:>3} " + f"reali-tenute={r.get('kept_real',0):>3} no-binance={r.get('missing_binance',0):>2} " + f"| righe {r['rows_before']}=={r['rows_after']} residue-sospette={r['still_suspect']}") + for line in r.get("log", [])[:8]: + print(line) + if len(r.get("log", [])) > 8: + print(f" ... (+{len(r['log'])-8} altre)") + print(f"\n TOTALE barre riparate: {grand}") + + +if __name__ == "__main__": + main() From 8abeb7a83f6d2d13735a1a22c85451070e270cbe Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 18 Jun 2026 14:42:18 +0000 Subject: [PATCH 4/7] =?UTF-8?q?docs(diary)+feat(analysis):=20re-gate=20Pri?= =?UTF-8?q?ce=20Ladder=20su=20dati=20puliti=20=E2=80=94=20tail=20vero=20?= =?UTF-8?q?=3D=202018?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ladder_regate_clean.py ri-valuta i top candidati dopo clean_feed.py con gate PORT06 + stress close_only + DD per anno. Esiti (sez. 7 diario): (1) l'obiezione "coda artefatto" CADE -- il 54% BTC era spike-print 2024, ora DD gate (2021+) ~11-15%, tutti PROMOSSO half, corr 0.22-0.29, reggono fee2x = candidati VERI; (2) emerge il tail REALE = 2018 (-44/-52% standalone) che il gate NON vede (IDX parte dal 2021) -> lacuna metodologica generale; il regime-gate stretto (BTC rd0.2 L6 range1.5) lo dimezza a -27.7%; (3) l'edge dipende dai fill INTRABAR (close_only crolla 4.7->0.24): legittimo per LIMIT ma serve shadow ledger. Verdetto: miglior candidato BTC 1h rd0.20 ru0.06 L6 range1.5 (PROMOSSO, corr 0.23, miglior coda 2018); prima del deploy servono gate 2018-inclusive + shadow fill + half-size. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/diary/2026-06-18-price-ladder-search.md | 41 ++++++++++++ scripts/analysis/ladder_regate_clean.py | 70 ++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 scripts/analysis/ladder_regate_clean.py diff --git a/docs/diary/2026-06-18-price-ladder-search.md b/docs/diary/2026-06-18-price-ladder-search.md index 444480e..3529210 100644 --- a/docs/diary/2026-06-18-price-ladder-search.md +++ b/docs/diary/2026-06-18-price-ladder-search.md @@ -187,3 +187,44 @@ un deploy ingiustificato** — esattamente la lezione anti-squeeze del progetto. spike-print + girare `close_only` sui finalisti; (2) **test di significatività** del delta OOS (block-bootstrap) al peso reale; (3) **co-tail / corr nei crash** (2022), non corr daily calma; (4) isolare e spiegare l'**evento feb-2024**. Config live **invariata**. + +--- + +## 7. RE-GATE su DATI PULITI (post clean_feed.py) — quadro onesto finale + +Dopo `clean_feed.py` (254 spike-print riparati con Binance) ho ri-gateato i top candidati con +le verifiche che il critico chiedeva (`ladder_regate_clean.py`): gate PORT06 + stress +`close_only` + **DD per anno** sulla storia completa. + +| Candidato | DD (gate, 2021+) | OOS Sh | corr | fee2x | close_only OOS | gate½ | **DD 2018 (tail vero)** | +|---|---:|---:|---:|---:|---:|---|---:| +| ETH 1h rd0.16 ru0.04 L4 range2 | 12.4% | 4.70 | 0.29 | 4.62 | 0.24 | PROMOSSO | −52.4% | +| ETH 1h rd0.16 ru0.06 L4 range2 | 12.6% | 4.25 | 0.23 | 4.17 | 0.20 | PROMOSSO | −49.9% | +| BTC 1h rd0.08 ru0.06 L3 none | 10.8% | 5.90 | 0.24 | 5.78 | 0.60 | PROMOSSO | −50.3% | +| **BTC 1h rd0.20 ru0.06 L6 range1.5** | 14.8% | 5.58 | 0.23 | 5.46 | 0.89 | PROMOSSO | **−27.7%** | +| BTC 30m rd0.08 ru0.06 L3 none | 14.8% | 5.47 | 0.25 | 5.37 | 1.07 | PROMOSSO | −36.6% | + +**Cosa cambia coi dati puliti:** +1. ✅ **L'obiezione "coda artefatto" CADE.** Il 54% del BTC era spike-print 2024: ora la DD del + gate (2021+) è **~11-15%** e tutti i candidati restano PROMOSSO a half-size, corr 0.22-0.29, + reggono fee2x. Sono candidati *veri*, non artefatti. +2. 🔴 **Emerge il tail VERO: il 2018** (−44/−52% standalone), che **il gate NON VEDE** perché + l'IDX del progetto parte dal 2021-01-01. Una griglia long-only sarebbe stata sventrata nel + bear 2018 (BTC −84% sull'anno). È il nuovo rischio portante — e una **lacuna metodologica + generale**: tutte le metriche "FULL" del progetto escludono il 2018. Mitigante reale: il + **regime-gate stretto** (BTC rd0.20 L6 **range1.5**) dimezza il tail 2018 a **−27.7%** (vs + −50% senza gate) → il gate-di-regime fa il suo lavoro contro i bear sostenuti. +3. 🟡 **L'edge dipende dai fill INTRABAR.** `close_only` (fill solo sul close) fa crollare l'OOS + (4.7→0.24 ETH, 5.9→0.6 BTC). Per ordini LIMIT i fill intrabar sono legittimi (close_only è + troppo severo come stress), ma il gap segnala forte **sensibilità all'ipotesi di fill** → + il ledger shadow reale è indispensabile prima di fidarsi. +4. 🟡 Restano: corr 0.22-0.29 (moderata), guadagno OOS ai limiti del rumore al peso deployabile + (~2.7% half), OOS singolo regime calmo. + +**Verdetto finale (clean data):** la pulizia ha rimosso l'obiezione FALSA (coda artefatto) e +promosso i candidati a *genuini*, ma ha anche scoperto il tail REALE (2018, gate-cieco) e +confermato due cautele (fill intrabar, OOS rumoroso). **Il candidato più sensato è ora +`BTC 1h rd0.20 ru0.06 L6 regime=range trend_max1.5`**: PROMOSSO, corr 0.23, e la **miglior coda +2018 (−27.7%)** grazie al regime-gate. **Prima di QUALSIASI deploy**: (1) backtest che INCLUDA il +2018 nel gate (non solo IDX 2021+), (2) ledger shadow reale per i fill intrabar/maker, (3) half-size. +La pulizia del feed resta il guadagno trasversale più grande (irrobustisce ogni backtest del progetto). diff --git a/scripts/analysis/ladder_regate_clean.py b/scripts/analysis/ladder_regate_clean.py new file mode 100644 index 0000000..3f39946 --- /dev/null +++ b/scripts/analysis/ladder_regate_clean.py @@ -0,0 +1,70 @@ +"""LADDER RE-GATE su DATI PULITI (2026-06-18) — ri-valuta i top candidati Price Ladder dopo +clean_feed.py, con le verifiche che il critico aveva chiesto: + - gate PORT06 + corr + fee2x (su feed corretto); + - stress close_only (fill solo sul close: su dati puliti deve ~combaciare col base -> niente + dipendenza residua da spike-print); + - DD PER ANNO della griglia standalone (dov'e' la coda VERA, non l'artefatto feb-2024). + + uv run python scripts/analysis/ladder_regate_clean.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from scripts.analysis.ladder_search import evaluate, regime_mask +from scripts.analysis.grid_game_gate import grid_mtm + +# top candidati dal workflow (asset, tf, rd, ru, levels, sl_buf, tp_buf, max_bars, regime, trend_max) +CANDS = [ + ("ETH", "1h", 0.16, 0.04, 4, 0.12, 0.05, 720, "range", 2.0), # raccomandato dalla sintesi + ("ETH", "1h", 0.16, 0.06, 4, 0.12, 0.05, 720, "range", 2.0), # corr piu' bassa (0.249) + ("ETH", "30m", 0.16, 0.04, 3, 0.12, 0.05, 1440, "range", 2.0), + ("BTC", "1h", 0.08, 0.06, 3, 0.12, 0.05, 720, "none", 2.0), # best OOS; DD era 53.69% (artefatto) + ("BTC", "1h", 0.20, 0.06, 6, 0.12, 0.05, 720, "range", 1.5), # corr piu' bassa (0.161) + ("BTC", "30m", 0.08, 0.06, 3, 0.12, 0.05, 1440, "none", 2.0), +] + + +def peryear_dd(asset, tf, rd, ru, lv, sl, tp, mb, regime, tmax): + mask = regime_mask(asset, tf, trend_max=tmax) if regime == "range" else None + eqd, _ = grid_mtm(asset, tf=tf, range_down=rd, range_up=ru, levels=lv, + sl_buf=sl, tp_buf=tp, max_bars=mb, deploy_mask=mask) + out = {} + for y, g in eqd.groupby(eqd.index.year): + peak = g.cummax() + out[int(y)] = round(float(((g - peak) / peak).min() * 100), 1) + return out + + +def main(): + print("RE-GATE PRICE LADDER su dati PULITI — gate PORT06 + close_only stress + DD/anno\n") + print(f"{'candidato':<34}{'fullDD':>7}{'oos_sh':>7}{'corr':>6}{'fee2x':>6}" + f"{'co_oos':>7}{'verdHALF':>10} DD per anno") + for c in CANDS: + asset, tf, rd, ru, lv, sl, tp, mb, regime, tmax = c + r = evaluate(asset, tf, rd, ru, lv, sl, tp, mb, regime=regime, trend_max=tmax) + rc = evaluate(asset, tf, rd, ru, lv, sl, tp, mb, regime=regime, trend_max=tmax, + close_only=True, do_gate=False, do_fee2x=False) + pyd = peryear_dd(*c) + tag = f"{asset} {tf} rd{rd} ru{ru} L{lv} {regime}" + py = " ".join(f"{y}:{v}" for y, v in pyd.items()) + print(f"{tag:<34}{r['full_dd']:>7.1f}{r['oos_sh']:>7.2f}{r['max_corr_existing']:>6.2f}" + f"{r['fee2x_oos_sh']:>6.2f}{rc['oos_sh']:>7.2f}{r['verdict_half']:>10} {py}") + print("\n fullDD = DD standalone sulla finestra del GATE (IDX 2021+); era ~54% su BTC") + print(" PRIMA della pulizia (artefatto spike-print 2024, ora sparito).") + print(" ATTENZIONE — DD per anno: il TAIL VERO e' il 2018 (-44/-52%), che il gate (IDX") + print(" 2021-01-01+) NON VEDE. Una griglia long-only sarebbe stata sventrata nel") + print(" bear 2018. Il regime-gate piu' stretto (BTC rd0.2 L6 range1.5) lo dimezza (-27.7%).") + print(" co_oos = OOS Sharpe con fill SOLO sul close (no wick). CROLLA (4.7->0.2): l'edge della") + print(" griglia viene dai fill INTRABAR ai livelli. Per ordini LIMIT i fill intrabar") + print(" sono LEGITTIMI (close_only e' troppo severo), ma il gap segnala sensibilita'") + print(" all'ipotesi di fill -> serve il ledger shadow reale prima di fidarsi.") + print(" verdHALF = gate PORT06 a half-size | corr = max corr coi 19 sleeve (ridondanza)") + + +if __name__ == "__main__": + main() From 587fbc0f61a3ef50f8fb42d41f76fb339a1171ef Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 18 Jun 2026 14:48:39 +0000 Subject: [PATCH 5/7] =?UTF-8?q?feat(analysis):=20ladder=5Fsltp=5Fstudy=20?= =?UTF-8?q?=E2=80=94=203=20passi=20pre-deploy=20+=20SL/TP,=20config=20fina?= =?UTF-8?q?le=20ladder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I 3 passi: [1] valutazione 2018-INCLUSIVE (il gate IDX2021+ e' cieco al 2018) -> il tail vero del BTC ladder e' il 2018 (-27.7% regime-gated / -50% none); [2] fill maker 0% vs taker 0.10% (Deribit = LIMIT = maker) -> maker leggermente MIGLIORE, harness conservativa, nessun fee-cliff; [3] half-size (la coda 2018 si dimezza sul book). STUDIO SL/TP: sweet spot sl_buf=0.10/tp_buf=0.03 cappa il 2018 a -23.5% (da -27.7) senza intaccare l'edge (oos 5.06). Lezione (conferma prior progetto): SL troppo stretto PEGGIORA (redeploy nel coltello = falso negativo MR), SL da solo senza regime-gate e' erratico -> il regime-gate e' il controllo PRIMARIO della coda, il SL moderato fine-tuna. Config finale: BTC 1h range1.5 rd0.20 ru0.06 L6 sl0.10 tp0.03 half-size, PROMOSSO (OOS 10.86->11.0, corr 0.195). Unico passo residuo: shadow ledger reale (operativo). Diario sez. 8. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/diary/2026-06-18-price-ladder-search.md | 35 +++++ scripts/analysis/ladder_sltp_study.py | 127 +++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 scripts/analysis/ladder_sltp_study.py diff --git a/docs/diary/2026-06-18-price-ladder-search.md b/docs/diary/2026-06-18-price-ladder-search.md index 3529210..f8e54b2 100644 --- a/docs/diary/2026-06-18-price-ladder-search.md +++ b/docs/diary/2026-06-18-price-ladder-search.md @@ -228,3 +228,38 @@ confermato due cautele (fill intrabar, OOS rumoroso). **Il candidato più sensat 2018 (−27.7%)** grazie al regime-gate. **Prima di QUALSIASI deploy**: (1) backtest che INCLUDA il 2018 nel gate (non solo IDX 2021+), (2) ledger shadow reale per i fill intrabar/maker, (3) half-size. La pulizia del feed resta il guadagno trasversale più grande (irrobustisce ogni backtest del progetto). + +--- + +## 8. I 3 PASSI PRE-DEPLOY + STUDIO SL/TP (ladder_sltp_study.py) + +**[1] Valutazione 2018-INCLUSIVE** (standalone, tutta la storia; il gate IDX2021+ è cieco al 2018): +| Candidato (sl0.12/tp0.05) | full_sh | full_dd | oos_sh | DD 2018 | DD 2021 | +|---|---:|---:|---:|---:|---:| +| BTC 1h L6 **range1.5** | 3.18 | −27.7% | 4.95 | **−27.7%** | −14.8% | +| BTC 1h L3 none | 2.93 | −60.2% | 5.51 | **−50.3%** | −10.8% | + +Il regime-gate già dimezza il tail vero (2018: −50%→−27.7%). Senza gate la griglia long-only è insostenibile (−60%). + +**Studio SL/TP** (sweep sl_buf × tp_buf sul candidato regime-gated, full 2018+): +- **Sweet spot: `sl_buf=0.10, tp_buf=0.03` → DD 2018 −23.5%** (da −27.7%), oos_sh 5.06, full_sh 2.92. Migliora il tail SENZA intaccare l'edge. +- **Lezione (conferma il prior del progetto):** un SL **troppo stretto PEGGIORA** (sl 0.06 → −33/−35%): stoppa e rideploya più in basso nel coltello che cade = la trappola del falso-negativo mean-reversion. Un SL **troppo largo** (0.20) idem (−29/−31%, oos più basso). Il bene sta in mezzo (0.10). +- **Il SL da solo (senza regime-gate) è ERRATICO** (tabella L3 none non-monotona: −20% → −52% → −43% al variare di pochi punti): è il **regime-gate** il controllo primario della coda; il SL moderato fine-tuna. Catastrophe-control = "non deployare nel bear" (regime) + "cappa l'episodio" (SL 0.10), NON uno stop stretto. + +**[2] Fill maker vs taker** (Deribit: i livelli sono LIMIT → maker): +| fee | oos_sh | DD 2018 | gate½ | +|---|---:|---:|---| +| taker 0.10% RT (harness) | 5.06 | −23.5% | PROMOSSO (OOS 10.86→11.0, corr 0.195) | +| **maker 0%** (Deribit reale) | 5.18 | −22.7% | PROMOSSO (OOS→11.0, corr 0.194) | + +Il maker è leggermente MIGLIORE → la harness taker è conservativa, nessun fee-cliff. (La parte live = shadow ledger reale resta il passo operativo finale.) + +**[3] Half-size:** il gate "half" è già a metà size e PROMOSSO; la coda 2018 standalone −23.5% si dimezza sul book (~−12% in un 2018-repeat a half-size) = reale ma gestibile. + +### CONFIG FINALE raccomandata (con SL/TP aggiunti) +> **BTC 1h · regime=range trend_max 1.5 · rd 0.20 / ru 0.06 / 6 livelli · sl_buf 0.10 / tp_buf 0.03 · HALF-size.** +> - Tail 2018-inclusive: −23.5% standalone (~−12% sul book a half) — reale ma controllato dal regime-gate + SL moderato. +> - Gate PORT06: PROMOSSO (OOS 10.86→11.0, corr 0.195); maker Deribit lo migliora; nessun fee-cliff. +> - **Unico passo residuo: shadow ledger reale** (fill intrabar/maker su Deribit) — operativo, non backtestabile. + +Caveat onesti residui: l'OOS gain al peso deployabile è modesto (10.86→11.0); il tail 2018 non è zero (−12% a half); l'edge dipende dai fill intrabar (per i LIMIT è legittimo, ma lo shadow lo deve confermare). diff --git a/scripts/analysis/ladder_sltp_study.py b/scripts/analysis/ladder_sltp_study.py new file mode 100644 index 0000000..5ff8e16 --- /dev/null +++ b/scripts/analysis/ladder_sltp_study.py @@ -0,0 +1,127 @@ +"""LADDER SL/TP STUDY (2026-06-18) — i 3 passi pre-deploy + studio di SL e TP da aggiungere. + +Contesto: dopo clean_feed.py i Price Ladder BTC/ETH sono candidati VERI (PROMOSSO, DD gate +2021+ ~11-15%), MA il tail REALE e' il 2018 (-44/-52%) che il gate (IDX 2021+) NON vede. SL/TP +sono la leva per domarlo. Prior del progetto: gli stop su mean-reversion sono falsi negativi +(EXIT-16/SH01/pairs z-stop) -> ma un grid in un BEAR sostenuto (2018, niente rimbalzo) e' il +caso in cui un catastrophe-SL genuinamente aiuta. Questo studio distingue i due regimi. + +Fa i 3 passi: + 1. VALUTAZIONE 2018-INCLUSIVE: metriche standalone su TUTTA la storia (2018+) + DD per anno + (il gate del progetto e' cieco al 2018; qui no). + 2. FILL maker vs taker: il grid e' LIMIT -> su Deribit fill MAKER ~0%; confronto 0% vs 0.10% + RT (la harness e' conservativa). E' la preparazione backtest dello shadow ledger (la parte + live = deploy operativo a parte). + 3. HALF-SIZE: il candidato finale a meta' size (prudenza coda). +E studia SL (sl_buf, = catastrophe stop sotto il range) x TP (tp_buf) sul tail 2018 vs edge 2021+. + + uv run python scripts/analysis/ladder_sltp_study.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from scripts.analysis.ladder_search import regime_mask, _gate +from scripts.analysis.grid_game_gate import grid_mtm + +OOS_DATE = pd.Timestamp("2024-10-12", tz="UTC") + + +def fm(eqd: pd.Series) -> dict: + """Metriche su TUTTA la storia (2018+), niente reindex a IDX 2021+.""" + def sh(s): + r = s.pct_change().fillna(0.0) + return float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0 + + def dd(s): + c = s / s.iloc[0] + return float(((c - c.cummax()) / c.cummax()).min() * 100) + oos = eqd[eqd.index >= OOS_DATE] + peryear = {int(y): round(dd(g), 1) for y, g in eqd.groupby(eqd.index.year)} + return {"full_sh": round(sh(eqd), 2), "full_dd": round(dd(eqd), 1), + "oos_sh": round(sh(oos), 2) if len(oos) > 5 else 0.0, + "dd2018": peryear.get(2018, 0.0), "dd2022": peryear.get(2022, 0.0), + "peryear": peryear} + + +def run(asset, tf, rd, ru, levels, sl_buf, tp_buf, max_bars, regime, tmax, fee_side=0.0005): + mask = regime_mask(asset, tf, trend_max=tmax) if regime == "range" else None + eqd, st = grid_mtm(asset, tf=tf, range_down=rd, range_up=ru, levels=levels, + sl_buf=sl_buf, tp_buf=tp_buf, max_bars=max_bars, + deploy_mask=mask, fee_side=fee_side) + m = fm(eqd); m["trades"] = st["trades"]; m["eqd"] = eqd + return m + + +# candidati base (i migliori del re-gate pulito) +BASES = { + "BTC 1h L6 range1.5": dict(asset="BTC", tf="1h", rd=0.20, ru=0.06, levels=6, + max_bars=720, regime="range", tmax=1.5), + "BTC 1h L3 none": dict(asset="BTC", tf="1h", rd=0.08, ru=0.06, levels=3, + max_bars=720, regime="none", tmax=2.0), +} + + +def sltp_sweep(name, base): + print(f"\n{'='*104}\n SL/TP SWEEP — {name} (full=2018+, dd2018=tail vero, oos=2024-10+; fee 0.10% RT)\n{'='*104}") + print(f" {'sl_buf':>7}{'tp_buf':>7}{'trades':>8}{'full_sh':>9}{'full_dd':>9}{'dd2018':>8}{'dd2022':>8}{'oos_sh':>8}") + best = None + for slb in (0.06, 0.08, 0.10, 0.12, 0.15, 0.20): + for tpb in (0.03, 0.05, 0.08): + m = run(**base, sl_buf=slb, tp_buf=tpb) + star = "" + # criterio: tail 2018 contenuto (>-25%) E oos edge preservato (sh>3) E full edge ok + if m["dd2018"] > -25 and m["oos_sh"] > 3 and m["full_sh"] > 1.5: + star = " <-- tail-capped + edge" + if best is None or m["dd2018"] > best[1]["dd2018"]: + best = ((slb, tpb), m) + print(f" {slb:>7.2f}{tpb:>7.2f}{m['trades']:>8}{m['full_sh']:>9.2f}" + f"{m['full_dd']:>9.1f}{m['dd2018']:>8.1f}{m['dd2022']:>8.1f}{m['oos_sh']:>8.2f}{star}") + return best + + +def main(): + print("LADDER SL/TP STUDY — 3 passi pre-deploy + SL/TP da aggiungere\n") + # passo 1: valutazione 2018-inclusive dei due base (sl/tp correnti) + print("[1] VALUTAZIONE 2018-INCLUSIVE (sl/tp correnti) — DD per anno (il gate IDX2021+ e' cieco al 2018):") + for name, base in BASES.items(): + m = run(**base, sl_buf=0.12, tp_buf=0.05) + print(f" {name:<22} full_sh {m['full_sh']:>5.2f} full_dd {m['full_dd']:>6.1f} " + f"oos_sh {m['oos_sh']:>5.2f} | DD/anno {m['peryear']}") + + # passo SL/TP: sweep su entrambi + winners = {} + for name, base in BASES.items(): + winners[name] = sltp_sweep(name, base) + + # passo 2+3: maker vs taker + half-size + gate 2021+, sul miglior (sl,tp) del candidato regime-gated + name = "BTC 1h L6 range1.5" + w = winners.get(name) + print(f"\n{'='*104}\n [2+3] FILL maker/taker + HALF-SIZE + GATE 2021+ — {name}\n{'='*104}") + if w is None: + print(" nessun (sl,tp) ha cappato il tail 2018 sotto -25% mantenendo l'edge: vedi sweep sopra.") + # usa comunque lo sl piu' stretto per il confronto fill + (slb, tpb) = (0.06, 0.05) + else: + (slb, tpb), _ = w + print(f" miglior (sl_buf,tp_buf) per tail 2018 + edge = ({slb}, {tpb})") + base = BASES[name] + for fee, lab in ((0.0005, "taker 0.10% RT"), (0.0, "maker 0% (Deribit limit)")): + m = run(**base, sl_buf=slb, tp_buf=tpb, fee_side=fee) + g = _gate(m["eqd"]) + print(f" fee={lab:<26} oos_sh {m['oos_sh']:>5.2f} dd2018 {m['dd2018']:>6.1f} " + f"gate½ {g['verdict_half']} (OOS {g['base_oos_sh']}->{g['half_oos_sh']}, corr {g['max_corr_existing']})") + print("\n NB half-size: il gate 'half' E' gia' a meta' size (vedi grid_game_gate). La coda 2018") + print(" standalone va dimezzata sul book a half. Lo shadow ledger reale (fill intrabar/maker)") + print(" resta il passo OPERATIVO finale, non backtestabile qui.") + + +if __name__ == "__main__": + main() From b3d4ab71502defb175074a6c92fec59569460b73 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 18 Jun 2026 14:58:11 +0000 Subject: [PATCH 6/7] =?UTF-8?q?feat(live):=20GridWorker=20(Price=20Ladder)?= =?UTF-8?q?=20SIM/PAPER=20+=20validazione=20replay=3D=3Dbacktest=20?= =?UTF-8?q?=E2=80=94=20shadow=20stage=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/analysis/grid_game_gate.py | 6 +- scripts/analysis/validate_grid_worker.py | 80 ++++++++++++++ src/live/grid_worker.py | 126 +++++++++++++++++++++++ 3 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 scripts/analysis/validate_grid_worker.py create mode 100644 src/live/grid_worker.py 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 From 264b9200eaf21cf23d24b6b3af8f04bdf2752a74 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 18 Jun 2026 16:18:53 +0000 Subject: [PATCH 7/7] =?UTF-8?q?feat(live):=20Stage=202=20=E2=80=94=20GridW?= =?UTF-8?q?orker=20(Price=20Ladder)=20come=20PAPER=20sleeve=20nel=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire del Price Ladder come sleeve PAPER (sim-only, fuori dal pool €500, NESSUN ordine reale): - runner: kind="grid" -> GridWorker in build_worker_for; _spec_assets_tf grid; tick branch (w.tick(res[asset])); meccanismo PAPER_EXTRA (sleeve paper letti da overrides.paper_extra, NON da _defs.py -> NON entrano nel backtest canonico/regression-lock: PORT06 resta 19 sleeve). Parsing difensivo (un errore non crasha il runner mainnet). Loop dati estesi a paper_extra. - GridWorker: bootstrap storia FULL (start fisso, come SH01) + mappatura capitale forward dal deploy (capital = initial*eq[-1]/base_norm) -> niente salti da finestra mobile; base_norm persistito (resume). grid_mtm robusto al df live (timestamp senza datetime; param df). - portfolios.yml: GRID_BTC in paper_extra (regime range1.5, rd0.20/ru0.06, L6, sl0.10/tp0.03, position_size 0.15 PINNATO). Gira in data/portfolio_paper_stats/GRID_BTC/. Validazione (validate_grid_worker.py): [A] logica n_trades==backtest, [B] forward-tracking esatto, [C] resume esatto. Dry-test integrazione runner: import OK, build OK, tick OK, pos 0.15. SICUREZZA: kind=grid mai eseguito reale (runner avvia ordini solo per single/ml); €500 intatti. Co-Authored-By: Claude Opus 4.8 (1M context) --- portfolios.yml | 17 +++++ scripts/analysis/grid_game_gate.py | 3 +- scripts/analysis/validate_grid_worker.py | 86 +++++++++++++----------- src/live/grid_worker.py | 51 +++++++++++--- src/portfolio/runner.py | 31 ++++++++- 5 files changed, 137 insertions(+), 51 deletions(-) diff --git a/portfolios.yml b/portfolios.yml index 9cabc6b..5cfb431 100644 --- a/portfolios.yml +++ b/portfolios.yml @@ -35,6 +35,23 @@ overrides: # Override per-famiglia: irrilevante per il conto reale (i pairs sono PAPER), tenuto # solo perche' i worker pairs in sola-statistica dimensionino come da gate storico. position_size_family: {PAIRS: 0.13} + # PAPER_EXTRA (2026-06-18): sleeve paper definiti SOLO qui (NON in _defs.py/PORT06) -> + # NON entrano nel backtest canonico/regression-lock. Shadow STAGE 1 del Price Ladder: + # GridWorker SIM-only su feed Deribit BTC 1h (NESSUN ordine reale; kind=grid non e' mai + # eseguito reale per costruzione). Config = re-gate su dati puliti (branch + # price_ladder_research): regime-gate range trend_max 1.5, rd0.20/ru0.06, 6 livelli, + # sl0.10/tp0.03. position_size 0.15 PINNATO (canonico validato; senza, erediterebbe il + # 0.5 globale del micro-test). Gira in data/portfolio_paper_stats/GRID_BTC/. + paper_extra: + - sid: GRID_BTC + kind: grid + name: GRID + asset: BTC + tf: "1h" + cluster: BTC-rev + params: {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, position_size: 0.15} # Esecuzione REALE su Deribit MAINNET. Solo i 7 single-leg con TP/SL in metadata: # 6 fade (MR01/MR02/MR07 x BTC/ETH 15m) + DIP01 (BTC 1h). Ordini sui LINEARI USDC # (payoff lineare = matematica del backtest; fee/PnL in USDC). diff --git a/scripts/analysis/grid_game_gate.py b/scripts/analysis/grid_game_gate.py index cd0191d..26855b4 100644 --- a/scripts/analysis/grid_game_gate.py +++ b/scripts/analysis/grid_game_gate.py @@ -85,7 +85,8 @@ def grid_mtm(asset="ETH", *, tf, range_down, range_up, levels, sl_buf, tp_buf, hi = df["high"].to_numpy(float) lo = df["low"].to_numpy(float) cl = df["close"].to_numpy(float) - dt = pd.to_datetime(df["datetime"]).to_numpy() + dt = (pd.to_datetime(df["datetime"]) if "datetime" in df.columns + else pd.to_datetime(df["timestamp"], unit="ms", utc=True)).to_numpy() n = len(cl) ratio = ((1 + range_up) / (1 - range_down)) ** (1.0 / levels) if ratio - 1 <= 1.5 * 2 * fee_side: # vincolo break-even §4 diff --git a/scripts/analysis/validate_grid_worker.py b/scripts/analysis/validate_grid_worker.py index 2e90215..7c7fec9 100644 --- a/scripts/analysis/validate_grid_worker.py +++ b/scripts/analysis/validate_grid_worker.py @@ -1,9 +1,11 @@ -"""VALIDA GridWorker — replay live == backtest grid_mtm (gate obbligatorio del progetto). +"""VALIDA GridWorker — forward-tracking == 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. +Il GridWorker (sim/paper) bootstrappa la storia FULL (start fisso) e mappa il capitale forward +dal deploy: capital = initial * eq[-1]/base_norm. Proprieta' da validare: + [A] LOGICA: la griglia sul feed full == backtest (stesso n_trades/win). + [B] FORWARD: deployato a T0 (bootstrap up to T0), dopo aver ticcato fino a T1 il capitale + e' initial * eq_full(T1)/eq_full(T0) — cioe' il ritorno della griglia DA T0 (causale). + [C] RESUME: reistanziato (rilegge base_norm da status.json) -> stesso capitale. 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 @@ -28,50 +30,58 @@ CFG = dict(tf="1h", range_down=0.20, range_up=0.06, levels=6, ASSET, INIT, POS, LEV, FEE = "BTC", 1000.0, 0.15, 3.0, 0.0005 +def _eq_last(df, mask): + cfg_bt = {k: v for k, v in CFG.items() if k not in ("regime", "trend_max")} + eqd, st = grid_mtm(ASSET, **cfg_bt, pos=POS, lev=LEV, fee_side=FEE, deploy_mask=mask, df=df) + return float(eqd.iloc[-1]), st + + 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}%)") + n = len(df) + nboot = n - 400 # deploy a T0 = nboot, poi 400 barre forward + mask_full = regime_mask(ASSET, "1h", trend_max=CFG["trend_max"]) + + F, st_full = _eq_last(df, mask_full) # eq full @ T1 (== backtest) + B, _ = _eq_last(df.iloc[:nboot].reset_index(drop=True), mask_full[:nboot]) # eq @ T0 (causale) + expected = INIT * F / B + print(f"[backtest] eq@T0={B:.4f} eq@T1={F:.4f} -> capitale forward atteso {expected:,.2f} " + f"(trades full {st_full['trades']}, win {st_full['win']:.1f}%)") wd = Path(tempfile.mkdtemp(prefix="gridval_")) try: - # [A] un tick con tutta la storia + boot = df.iloc[:nboot].reset_index(drop=True) w = GridWorker("GRID_BTC", ASSET, CFG, INIT, wd, leverage=LEV, - position_size=POS, fee_side=FEE) + position_size=POS, fee_side=FEE, hist=boot) + # [A] logica: tick col feed full -> n_trades come backtest 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'}") + dA = abs(w.n_trades - st_full["trades"]) + print(f"[A] logica n_trades worker {w.n_trades} vs backtest {st_full['trades']} " + f"{'OK' if dA == 0 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'}") + # [B] forward: deploy a T0 (base), poi fino a T1 + wd2 = Path(tempfile.mkdtemp(prefix="gridval_fwd_")) + wf = GridWorker("GRID_BTC", ASSET, CFG, INIT, wd2, leverage=LEV, + position_size=POS, fee_side=FEE, hist=boot) + wf.tick(boot) # deploy: base_norm=B, capital=initial + cap0 = wf.capital + wf.tick(df.iloc[:n - 200].reset_index(drop=True)) + wf.tick(df) # fino a T1 + dB = abs(wf.capital - expected) + print(f"[B] forward: cap@deploy {cap0:,.2f} (atteso {INIT:,.0f}) cap@T1 {wf.capital:,.2f} " + f"(atteso {expected:,.2f}) delta {dB:.4f} {'OK' if dB < 0.05 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.)") + wr = GridWorker("GRID_BTC", ASSET, CFG, INIT, wd2, leverage=LEV, + position_size=POS, fee_side=FEE, hist=boot) + wr.tick(df) + dC = abs(wr.capital - wf.capital) + print(f"[C] resume cap {wr.capital:,.2f} delta {dC:.4f} " + f"{'OK' if dC < 0.05 else 'MISMATCH'} (base_norm persistito)") - 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'}") + ok = dA == 0 and dB < 0.05 and dC < 0.05 + print(f"\n{'VALIDAZIONE OK: GridWorker forward-tracking == backtest' if ok else 'VALIDAZIONE FALLITA'}") + shutil.rmtree(wd2, ignore_errors=True) finally: shutil.rmtree(wd, ignore_errors=True) diff --git a/src/live/grid_worker.py b/src/live/grid_worker.py index d5972ec..6ae3fc7 100644 --- a/src/live/grid_worker.py +++ b/src/live/grid_worker.py @@ -45,7 +45,7 @@ class GridWorker: 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): + 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 @@ -59,6 +59,19 @@ class GridWorker: 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" @@ -66,6 +79,17 @@ class GridWorker: 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}) @@ -76,14 +100,16 @@ class GridWorker: 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}) + 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, - "in_position": self.in_position, "params": self.p, + "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)) @@ -97,28 +123,33 @@ class GridWorker: 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).""" + """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(df, p.get("ema_n", 200), p.get("trend_max", 2.0)) + 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=df) + flat_skip=True, deploy_mask=mask, df=full) 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) + 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(df.iloc[-1].get("datetime", df.iloc[-1].get("timestamp", ""))) + 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"), diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index 36f4bba..5d53abf 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -23,6 +23,7 @@ from src.live.basket_trend_worker import BasketTrendWorker from src.live.rotation_worker import RotationWorker from src.live.tsmom_worker import TsmomWorker from src.live.xsec_worker import CrossSectionalWorker +from src.live.grid_worker import GridWorker from src.live.strategy_loader import load_strategy # Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/ (worker single/ml) @@ -102,6 +103,13 @@ def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float, capital=alloc_capital, position_size=position_size, leverage=leverage, data_dir=data_dir, ) + if spec.kind == "grid": + # Price Ladder (griglia) — SIM/PAPER (shadow stage 1): nessun ordine reale. + return GridWorker( + sid=spec.sid, asset=spec.asset, params=spec.params, capital=alloc_capital, + work_dir=Path(data_dir) / spec.sid, leverage=leverage, + position_size=position_size, fee_side=0.0005, + ) module = _STRAT_MODULE.get(spec.name) if module is None: raise ValueError(f"sleeve live non supportato: {spec.name} (kind={spec.kind})") @@ -191,6 +199,8 @@ def _spec_assets_tf(spec: SleeveSpec): return [spec.a, spec.b], spec.tf if spec.kind in _MULTI_KINDS: return list(spec.params["universe"]), spec.params.get("tf", "1d" if spec.kind != "basket" else "4h") + if spec.kind == "grid": + return [spec.asset], spec.params.get("tf", spec.tf) return [spec.asset], spec.tf @@ -375,6 +385,20 @@ def run(config_path: str = "portfolios.yml"): paper_codes = {str(c).upper() for c in (_ov.get("paper_sleeves") or [])} live_specs = [s for s in supported if s.name.upper() not in paper_codes] paper_specs = [s for s in supported if s.name.upper() in paper_codes] + # PAPER_EXTRA: sleeve paper definiti SOLO in config (NON in p.sleeves) -> NON entrano + # nel backtest canonico/regression-lock (all_sleeve_equities non sa costruirli). Nato + # per il Price Ladder (kind=grid, shadow stage 1 sim). Parsing DIFENSIVO: un errore qui + # non deve crashare il runner mainnet. + for _ex in (_ov.get("paper_extra") or []): + try: + paper_specs.append(SleeveSpec( + kind=str(_ex["kind"]), name=str(_ex.get("name", _ex["sid"])), + sid=str(_ex["sid"]), asset=_ex.get("asset"), + tf=str(_ex.get("tf", "1h")), params=dict(_ex.get("params", {})), + cluster=str(_ex.get("cluster", "")), + )) + except Exception as e: + print(f"[runner] WARN paper_extra ignorato ({_ex}): {e}") if paper_specs: print(f"[runner] sleeve PAPER (solo statistica, fuori dal pool): " f"{[s.sid for s in paper_specs]}") @@ -467,7 +491,7 @@ def run(config_path: str = "portfolios.yml"): # lookback (giorni) richiesto per ogni asset = max sui worker che lo usano asset_days: dict[str, int] = {} - for s in supported: # live + PAPER (anche XS01/TR01/ROT02/TSM01) + for s in live_specs + paper_specs: # live + PAPER (incl. paper_extra grid) assets, tf = _spec_assets_tf(s) days = _LOOKBACK_DAYS.get(tf, 90) if s.kind == "ml": # SH01 ha bisogno di molta storia 1h @@ -478,7 +502,7 @@ def run(config_path: str = "portfolios.yml"): # timeframe SUB-orari (es. pairs 15m, flat-skip): non resamplabili dal 1h -> # fetch DIRETTO da Cerbero per (asset, tf). Inerte se nessuno sleeve e' sub-orario. subhourly_needs: dict[tuple[str, str], int] = {} - for s in supported: # live + paper + for s in live_specs + paper_specs: # live + paper (incl. paper_extra grid) assets, tf = _spec_assets_tf(s) if tf in _SUBHOURLY: for a in assets: @@ -603,6 +627,9 @@ def run(config_path: str = "portfolios.yml"): # interno fitta solo l'ultimo blocco (last_block_only nei params). w.tick(_with_history(ml_hist.get(s.asset), res[s.asset], ml_gap_warned, s.asset)) + elif s.kind == "grid": + # Price Ladder SIM/PAPER: ricomputa la griglia sul feed live (BTC 1h). + w.tick(res[s.asset]) else: # single (fade/dip): StrategyWorker su feed live. w.tick(res[s.asset])