From c52e0ab3f83c3e747f203fcb5c164623832c45fa Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Mon, 29 Jun 2026 20:58:29 +0000 Subject: [PATCH] feat(forward): cabla STATARB-RESID nel forward-monitor PAPER (lead ortogonale ETH/BTC) Forward-monitor del LEAD dello sweep 2026-06-29 (relative-value ETH/BTC, dollar-neutral 2 gambe), il primo stream insieme ORTOGONALE (corr->book 0.027, beta-mkt 0.013) ED eseguibile a $600. - scripts/live/paper_statarb.py: forward-only, doppio libro MODELED($2000)/REAL-$600 (haircut fill), riusa il segnale ESATTO di orthogonal_signals.py (niente reimplementazione). Config CONGELATA W=45 sgn=+1. - Cablato in scripts/cron_daily.sh accanto a paper_prevday. Stato runtime in data/paper_statarb/ (gitignored). - test tests/test_paper_statarb.py (frozen config + advance forward/idempotente + haircut $600 basso). Correzione di etichetta (verificata): la cella vincente e' sgn=+1 -> NON mean-reversion ma relative-MOMENTUM sul residuo (dislocazioni ETH-vs-BTC continuano a 1d; sgn=-1 perde -1.4 IS). Diario + CLAUDE.md aggiornati. Test 146/146. Nessun deploy, forward-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + CLAUDE.md | 16 +- .../2026-06-29-strategy-search-5threads.md | 15 +- scripts/cron_daily.sh | 1 + scripts/live/paper_statarb.py | 176 ++++++++++++++++++ tests/test_paper_statarb.py | 60 ++++++ 6 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 scripts/live/paper_statarb.py create mode 100644 tests/test_paper_statarb.py diff --git a/.gitignore b/.gitignore index 261e124..a2a560c 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ scripts/research/blind/leaderboard.json # forward-monitor runtime state (regenerable, forward-only) data/paper_prevday/ data/paper_combo/ +data/paper_statarb/ diff --git a/CLAUDE.md b/CLAUDE.md index aa6c928..0c7e204 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,12 +139,16 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis candidato ucciso dal gate giusto (deflated-Sharpe, is_hedge, selection-on-holdout, sostituzione-XS01, multi-cut). Niente batte/diversifica XS01 (varianti = REDUNDANT); meta-allocazione < pesi fissi (i 4 sleeve già quasi-risk-parity); 1-gamba a segnale = TP01 travestito (trend) o hedge a DSR<0.95. - **LEAD forward-monitor:** **STATARB-RESID** (mean-reversion del residuo ETH−β·BTC, β OLS rolling, 2 gambe) - — primo stream **insieme ortogonale (corr→book 0.027, β-mkt 0.013) ED eseguibile a $600** (haircut ~0, - NON STAT-MODE come XS01/opzioni): marginal ADDS, robust_oos, fee-survive 0.30%/gamba; resta sotto soglia - solo sull'**edge** (Sharpe 0.84, DSR 0.929 same-sign <0.95). Se una finestra forward conferma l'edge è - *deployabile*. Altri LEAD: IVOL (idio-vol XS, STAT-MODE), DVOLSPREAD (storia DVOL corta). Diario - `2026-06-29-strategy-search-5threads.md`. Script `scripts/research/{xsec_v3_lowrisk,xsec_v3_momstruct,meta_allocation,orthogonal_signals,signal_inout_1leg}.py`. + **LEAD forward-monitor:** **STATARB-RESID** (relative-MOMENTUM del residuo ETH−β·BTC, β OLS rolling, 2 gambe, + cella vincente **sgn=+1**: le dislocazioni ETH-vs-BTC CONTINUANO a 1d, la MR pura sgn=−1 perde) — primo + stream **insieme ortogonale (corr→book 0.027, β-mkt 0.013) ED eseguibile a $600** (haircut ~0, NON + STAT-MODE come XS01/opzioni): marginal ADDS, robust_oos, fee-survive 0.30%/gamba; resta sotto soglia solo + sull'**edge** (Sharpe 0.84, DSR 0.929 same-sign <0.95). **CABLATO in forward-monitor PAPER:** + `scripts/live/paper_statarb.py` (W=45/sgn=+1 congelati, doppio libro MODELED/REAL-$600), nel cron + giornaliero accanto a `paper_prevday`; test `tests/test_paper_statarb.py`. Se la finestra forward conferma + l'edge è *deployabile* (2 gambe BTC/ETH perp). Altri LEAD: IVOL (idio-vol XS, STAT-MODE), DVOLSPREAD + (storia DVOL corta). Diario `2026-06-29-strategy-search-5threads.md`. Script + `scripts/research/{xsec_v3_lowrisk,xsec_v3_momstruct,meta_allocation,orthogonal_signals,signal_inout_1leg}.py`. - **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso: cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55. - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.** diff --git a/docs/diary/2026-06-29-strategy-search-5threads.md b/docs/diary/2026-06-29-strategy-search-5threads.md index 5a05602..043466a 100644 --- a/docs/diary/2026-06-29-strategy-search-5threads.md +++ b/docs/diary/2026-06-29-strategy-search-5threads.md @@ -51,7 +51,7 @@ scelta in-sample-only. | segnale | FULL/HOLD | corr→book | β-mkt | marginal | DSR (grid/same-sign) | exec $600 | verdetto | |---|---|---|---|---|---|---|---| -| **STATARB-RESID** (mean-rev residuo ETH−β·BTC, W45) | 0.84/0.56 | **0.027** | 0.013 | **ADDS** | 0.056 / 0.929 | haircut ~0 | **LEAD** | +| **STATARB-RESID** (residual relative-mom ETH−β·BTC, W45 **sgn+1**) | 0.84/0.56 | **0.027** | 0.013 | **ADDS** | 0.056 / 0.929 | haircut ~0 | **LEAD** | | DVOLSPREAD (IV relativa, W60) | 0.74/0.77 | 0.017 | 0.012 | ADDS | 0.082 / 0.907 | haircut ~0 | LEAD | | RATIO-MOM/REV/ACCEL | 0.25-0.68 / ≤0 | ~0 | ~0 | NEUTRAL | <0.23 | ok | NEUTRAL (diversification-math) | | VOLSPREAD | −0.24/−1.47 | ~0 | 0.013 | DILUTES | — | — | SCARTATO | @@ -61,10 +61,15 @@ mercato 0.013 — meglio di SKH ~0.09) **ED eseguibile a $600** (book a 2 gambe haircut ≈ 0, fee-survive fino a 0.30%/gamba → **NON STAT-MODE**, a differenza di XS01 e delle opzioni). Il muro che **resta** è l'**edge**: Sharpe 0.84 / DSR 0.929 same-sign (ottimistico) ma comunque <0.95. Verificato indipendentemente dal coordinatore: residuo causale (β OLS rolling backward, decisione a -close[i], return in i+1), fee a 2 gambe corretta. → **LEAD forward-monitor** (monitorabile a costo reale -~0 grazie all'eseguibilità), NON deploy. **DVOLSPREAD** ri-valida l'ex-lead `dvol_spread`: ADDS ma storia -DVOL corta (2021+) → resta forward-monitor come già noto. Secondario crypto-vs-macro = "TSMOM travestito" -(corr→book 0.17-0.20), non ortogonale. +close[i], return in i+1), fee a 2 gambe corretta. **⚠ Correzione di etichetta:** la cella vincente è +**sgn=+1**, NON sgn=−1 → NON è mean-reversion ma **momentum relativo sul residuo** (long lo spread quando +ETH è ricco vs il suo fit su BTC: le dislocazioni ETH-vs-BTC *continuano* a 1d; la MR pura sgn=−1 perde +−1.4 in-sample). L'ortogonalità (β~0) viene dalla struttura dollar-neutral, non dal verso. → **LEAD +forward-monitor** (monitorabile a costo reale ~0), NON deploy. **CABLATO:** `scripts/live/paper_statarb.py` +(config CONGELATA W=45/sgn=+1, doppio libro MODELED/REAL-$600, riusa il segnale esatto di +`orthogonal_signals.py`), aggiunto al cron giornaliero accanto a `paper_prevday`; test `tests/test_paper_statarb.py`. +**DVOLSPREAD** ri-valida l'ex-lead `dvol_spread`: ADDS ma storia DVOL corta (2021+) → resta forward-monitor +come già noto. Secondario crypto-vs-macro = "TSMOM travestito" (corr→book 0.17-0.20), non ortogonale. ### Thread 5 — 1-gamba a segnale (eseguibile) 12 famiglie (MACD/RSI/Supertrend/Donchian/BBands/EMA, ±ADX) su 1d/12h/8h. **0/12 earns_slot_honest.** diff --git a/scripts/cron_daily.sh b/scripts/cron_daily.sh index bf744c4..573ec9e 100755 --- a/scripts/cron_daily.sh +++ b/scripts/cron_daily.sh @@ -10,6 +10,7 @@ mkdir -p logs uv run python scripts/research/fetch_dvol.py # DVOL (per ricerca opzioni) uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01 uv run python scripts/live/paper_prevday.py # forward-monitor lead prevday-breakout (PAPER, non deploy) + uv run python scripts/live/paper_statarb.py # forward-monitor lead STATARB-RESID ETH/BTC ortogonale (PAPER, non deploy) # NB: l'esecuzione Deribit e' passata al BOOK (TP01+SKH01 nettati) via scripts/cron_book.sh a # cadenza ORARIA (SKH01 e' a 230m: il daily mancherebbe gli ingressi). live_execute.py # (TP01-only) NON va piu' eseguito qui, sennò i due farebbero a pugni sullo stesso strumento. diff --git a/scripts/live/paper_statarb.py b/scripts/live/paper_statarb.py new file mode 100644 index 0000000..109a2ad --- /dev/null +++ b/scripts/live/paper_statarb.py @@ -0,0 +1,176 @@ +"""FORWARD-MONITOR — STATARB-RESID (relative-value ETH/BTC, dollar-neutral 2 gambe), PAPER. + +NON è esecuzione reale. È il monitoraggio forward-only del LEAD dello sweep 2026-06-29 (thread +"segnali ortogonali"): il PRIMO stream visto che è INSIEME ortogonale al book (corr→book 0.027, +beta-mkt 0.013) ED eseguibile a $600 (book a 2 gambe BTC+ETH perp, NON STAT-MODE come XS01). Resta +sotto la soglia solo sull'EDGE (Sharpe ~0.84, deflated-Sharpe <0.95 su storia ~2.5 anni). Questo +monitor accumula la finestra OUT-OF-SAMPLE VERA per decidere se l'edge regge — stesso trattamento di +PREVDAY/STA05/XS01 STAT-MODE. Nessun edge creduto prima. + +ⓘ NATURA DEL SEGNALE (onesto): la cella vincente è W=45 **sgn=+1**, NON sgn=-1. Cioè il residuo di +ETH dopo β·BTC (β OLS rolling causale) viene seguito in MOMENTUM (long lo spread quando ETH è ricco +vs il suo fit su BTC), non in mean-reversion: le dislocazioni relative ETH-vs-BTC CONTINUANO a 1d +(la MR pura, sgn=-1, perde -1.4 in-sample). È relative-MOMENTUM, dollar-neutral → ortogonale al +trend assoluto di TP01. + +CONFIG CONGELATA (dallo sweep, selezione IN-SAMPLE-only, mai sul max hold-out): + W=45, sgn=+1, tf=1d, vol-target 20%, leva cap 2x. Riusa il segnale ESATTO di + scripts/research/orthogonal_signals.py (nessuna reimplementazione → niente drift). + +DESIGN (onesto, come paper_prevday): + - Legge BTC/ETH 1d certificati (build_joint), calcola il segnale CAUSALE su tutta la storia. + - Alla prima esecuzione fissa l'INCEPTION all'ultima barra 1d chiusa (forward-only: lo storico + NON entra nel PnL di paper). + - Ogni run processa le NUOVE barre 1d chiuse: applica il rendimento dello spread tenuto, + addebita le fee su 2 GAMBE (ogni Δpos muove ETH e BTC → 2×|Δpos|), registra i flip di segno. + - DUE libri in parallelo (onestà sull'esecuzione a piccolo capitale): + * MODELED : capitale nominale $2000, ribilanciamento continuo (fee su ogni |Δpos|). + * REAL-$600: capitale reale $600, salta i ribilanci di nozionale-per-gamba < min_order ($5) + → il gap MODELED-REAL = l'haircut di fill reale (qui ~0: turnover bassissimo a 1d). + - Per barre fresche aggiornare prima i dati: uv run python scripts/analysis/rebuild_history.py --asset BTC ETH + +Stato: data/paper_statarb/{state.json, trades.jsonl, returns.jsonl} (append-only). + + uv run python scripts/live/paper_statarb.py # avanza col dato disponibile + uv run python scripts/live/paper_statarb.py --status # solo stato, non avanza + uv run python scripts/live/paper_statarb.py --reset # azzera (riparte da ora) +""" +from __future__ import annotations + +import argparse +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)) +sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research")) + +# Segnale ESATTO dello sweep (nessuna reimplementazione → identità garantita col backtest). +from orthogonal_signals import build_joint, f_statarb_resid, spread_ret # noqa: E402 + +STATE_DIR = PROJECT_ROOT / "data" / "paper_statarb" +STATE_FILE = STATE_DIR / "state.json" +TRADES_FILE = STATE_DIR / "trades.jsonl" +RETURNS_FILE = STATE_DIR / "returns.jsonl" + +# --- CONFIG CONGELATA (frozen) ----------------------------------------------------------- +W_FROZEN = 45 +SGN_FROZEN = +1 +FEE_SIDE = 0.0005 # 0.05%/side = 0.10% round-trip (Deribit taker) +MODELED_CAPITAL = 2000.0 # nominale, ribilanciamento continuo +REAL_CAPITAL = 600.0 # capitale mainnet reale +MIN_ORDER = 5.0 # min order Deribit -> sotto, il conto vero NON ribilancia + + +def _signal(j: pd.DataFrame): + """(ts, dt, pos, sr): posizione vol-targeted sullo spread (decisa <= close[i]) + ritorno spread.""" + pos = np.nan_to_num(f_statarb_resid(W=W_FROZEN, sgn=SGN_FROZEN)(j)) + return (j["timestamp"].values.astype("int64"), + pd.to_datetime(j["datetime"]).values, pos, spread_ret(j)) + + +def _state_io(write: dict | None = None): + if write is not None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + STATE_FILE.write_text(json.dumps(write, indent=2)) + return write + return json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else None + + +def _append(path: Path, rec: dict): + STATE_DIR.mkdir(parents=True, exist_ok=True) + with open(path, "a") as f: + f.write(json.dumps(rec) + "\n") + + +def init_state(j: pd.DataFrame) -> dict: + ts, _, pos, _ = _signal(j) + last = int(ts[-1]) + return dict(start_ts=last, last_ts=last, n_bars=0, + pos_modeled=float(pos[-1]), pos_real=float(pos[-1]), + cap_modeled=MODELED_CAPITAL, cap_real=REAL_CAPITAL, + peak_modeled=MODELED_CAPITAL, peak_real=REAL_CAPITAL, + dd_modeled=0.0, dd_real=0.0, n_trades=0) + + +def advance(st: dict, j: pd.DataFrame) -> dict: + ts, dt, pos, sr = _signal(j) + new = [i for i in range(len(ts)) if ts[i] > st["last_ts"]] + if not new: + return st + pm, pr = st["pos_modeled"], st["pos_real"] # posizioni TENUTE (decise alla barra precedente) + cm, cr = st["cap_modeled"], st["cap_real"] + pkm, pkr, ddm, ddr = st["peak_modeled"], st["peak_real"], st["dd_modeled"], st["dd_real"] + ntr = st.get("n_trades", 0) + for i in new: + s = float(sr[i]) + tgt = float(pos[i]) # bersaglio deciso a close[i] (eseguito da i+1) + # MODELED: rendimento dello spread tenuto durante la barra i, poi ribilancia al target + net_m = pm * s - FEE_SIDE * 2.0 * abs(tgt - pm) + # REAL-$600: salta i ribilanci sub-min_order (nozionale per-gamba |Δpos|*cap < $5) + executed = abs(tgt - pr) * REAL_CAPITAL >= MIN_ORDER + new_pr = tgt if executed else pr + net_r = pr * s - FEE_SIDE * 2.0 * abs(new_pr - pr) + if np.sign(new_pr) != np.sign(pr): + _append(TRADES_FILE, dict(ts=int(ts[i]), dt=str(pd.Timestamp(dt[i])), + action="FLIP", from_pos=round(pr, 4), to_pos=round(new_pr, 4))) + ntr += 1 + cm *= (1.0 + max(net_m, -0.99)); cr *= (1.0 + max(net_r, -0.99)) + pkm = max(pkm, cm); pkr = max(pkr, cr) + ddm = max(ddm, (pkm - cm) / pkm if pkm > 0 else 0.0) + ddr = max(ddr, (pkr - cr) / pkr if pkr > 0 else 0.0) + pm, pr = tgt, new_pr + _append(RETURNS_FILE, dict(ts=int(ts[i]), dt=str(pd.Timestamp(dt[i])), + net_modeled=round(net_m, 6), net_real=round(net_r, 6), + pos=round(new_pr, 4), cap_modeled=round(cm, 2), cap_real=round(cr, 2))) + st.update(last_ts=int(ts[new[-1]]), n_bars=st.get("n_bars", 0) + len(new), + pos_modeled=pm, pos_real=pr, cap_modeled=cm, cap_real=cr, + peak_modeled=pkm, peak_real=pkr, dd_modeled=ddm, dd_real=ddr, n_trades=ntr) + return st + + +def print_status(st: dict, j: pd.DataFrame): + last_data = int(j["timestamp"].iloc[-1]) + days = (last_data - st["start_ts"]) / 86400_000 + rm = st["cap_modeled"] / MODELED_CAPITAL - 1 + rr = st["cap_real"] / REAL_CAPITAL - 1 + print("\n STATARB-RESID forward-monitor (PAPER — relative-value ETH/BTC, ortogonale, NON deploy)") + print(f" config CONGELATA: W={W_FROZEN} sgn={SGN_FROZEN:+d} (residual relative-momentum, dollar-neutral)") + print(f" forward da {pd.Timestamp(st['start_ts'], unit='ms', tz='UTC').date()} " + f"({st['n_bars']} barre 1d ~{days:.0f}g) flip di segno: {st['n_trades']}") + print(f" posizione spread corrente (long ETH / short BTC per unità): {st['pos_real']:+.3f}") + print(f" MODELED ($2000 nominale): {rm*100:+6.2f}% eq ${st['cap_modeled']:.2f} maxDD {st['dd_modeled']*100:.1f}%") + print(f" REAL-$600 (min-order $5) : {rr*100:+6.2f}% eq ${st['cap_real']:.2f} maxDD {st['dd_real']*100:.1f}%") + print(f" -> fill-haircut MODELED-REAL: {(rm-rr)*100:+.2f} pp (atteso ~0: turnover basso a 1d)") + print(f" log: {RETURNS_FILE}\n") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--status", action="store_true") + ap.add_argument("--reset", action="store_true") + args = ap.parse_args() + j = build_joint("1d") + if args.reset: + for p in (STATE_FILE, TRADES_FILE, RETURNS_FILE): + if p.exists(): + p.unlink() + st = init_state(j); _state_io(st) + print("forward-monitor STATARB-RESID inizializzato (forward-only da ora).") + print_status(st, j); return + st = _state_io() + if st is None: + st = init_state(j); _state_io(st) + print("forward-monitor STATARB-RESID inizializzato (forward-only da ora).") + print_status(st, j); return + if not args.status: + st = advance(st, j); _state_io(st) + print_status(st, j) + + +if __name__ == "__main__": + main() diff --git a/tests/test_paper_statarb.py b/tests/test_paper_statarb.py new file mode 100644 index 0000000..ee1fdcf --- /dev/null +++ b/tests/test_paper_statarb.py @@ -0,0 +1,60 @@ +"""Lock del forward-monitor STATARB-RESID (scripts/live/paper_statarb.py): + * config CONGELATA (W=45, sgn=+1) — non deve cambiare silenziosamente; + * advance() processa SOLO le barre forward ed è IDEMPOTENTE alla punta; + * a 1d il libro REAL-$600 traccia il MODELED (haircut di fill piccolo). +Il segnale è quello esatto dello sweep (orthogonal_signals) → niente reimplementazione. +""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts" / "live")) +import paper_statarb as ps # noqa: E402 + + +def _fresh_state(j, back): + ts, _, pos, _ = ps._signal(j) + start = int(ts[-back - 1]) + return dict(start_ts=start, last_ts=start, n_bars=0, + pos_modeled=float(pos[-back - 1]), pos_real=float(pos[-back - 1]), + cap_modeled=ps.MODELED_CAPITAL, cap_real=ps.REAL_CAPITAL, + peak_modeled=ps.MODELED_CAPITAL, peak_real=ps.REAL_CAPITAL, + dd_modeled=0.0, dd_real=0.0, n_trades=0) + + +def _redirect(tmp_path, monkeypatch): + monkeypatch.setattr(ps, "STATE_DIR", tmp_path) + monkeypatch.setattr(ps, "STATE_FILE", tmp_path / "state.json") + monkeypatch.setattr(ps, "RETURNS_FILE", tmp_path / "returns.jsonl") + monkeypatch.setattr(ps, "TRADES_FILE", tmp_path / "trades.jsonl") + + +def test_frozen_config(): + """La cella vincente in-sample è W=45 sgn=+1 (relative-momentum, non mean-reversion).""" + assert ps.W_FROZEN == 45 + assert ps.SGN_FROZEN == +1 + + +def test_advance_processes_forward_then_idempotent(tmp_path, monkeypatch): + _redirect(tmp_path, monkeypatch) + j = ps.build_joint("1d") + ts, *_ = ps._signal(j) + st = _fresh_state(j, back=60) + st = ps.advance(st, j) + assert st["n_bars"] == 60 # ha processato le 60 barre forward + assert st["last_ts"] == int(ts[-1]) + assert st["cap_modeled"] != ps.MODELED_CAPITAL # l'equity si è mossa + # idempotente: ri-avanzare alla punta non aggiunge barre né muove l'equity + eq_before = st["cap_modeled"] + st2 = ps.advance(dict(st), j) + assert st2["n_bars"] == 60 + assert st2["cap_modeled"] == eq_before + + +def test_real600_tracks_modeled_low_turnover(tmp_path, monkeypatch): + _redirect(tmp_path, monkeypatch) + j = ps.build_joint("1d") + st = ps.advance(_fresh_state(j, back=250), j) + rm = st["cap_modeled"] / ps.MODELED_CAPITAL - 1 + rr = st["cap_real"] / ps.REAL_CAPITAL - 1 + assert abs(rm - rr) < 0.05 # haircut di fill piccolo a 1d ($600)