diff --git a/.gitignore b/.gitignore index 52349f0..c5d6fc7 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ data/paper_prevday/ data/paper_combo/ data/paper_statarb/ data/paper_xsr/ +data/paper_dvolspread/ # log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale) data/live/ diff --git a/CLAUDE.md b/CLAUDE.md index 5427d17..c74d1fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -591,6 +591,20 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis i parametri ONESTI** (zwin=180 k=2.0 lw=0.6 zw=1.1 tgt=0.17 svw=60), **NON nel book**: campione **ATTIVO 1949/2691 g** (prima del 2021-03 non c'e' DVOL, book flat) con **hold-out attivo 1.6 anni**, margine DSR sul filo, e `weights_tilt_null` mai affrontato. + ✅ **MONITOR CABLATO** (stessa sessione): `scripts/live/paper_dvolspread.py` in `cron_daily.sh` + dopo `fetch_dvol.py`, stato `data/paper_dvolspread/` (gitignored), test + `tests/test_paper_dvolspread.py` (10 casi). Inception **2026-07-25**, apertura +0.184 = + **$111/gamba** (cap $300), 2 libri MODELED $2000 / REAL $600. + ⚠️ **Strumentazione specifica:** il book va **flat quando manca il DVOL** → un feed rotto + produrrebbe zeri che, contati come evidenza, direbbero "nessuna perdita" invece di "nessuna + misura". Contabilita' a **3 stati** (ATTIVE / flat-da-segnale / **flat-senza-dato**) e finestra + misurata in **barre attive**, non giorni di calendario; guardia sulla config che **esce 1** se + `FROZEN` diverge dallo stato salvato. **GATE PRE-REGISTRATO:** kill **2026-10-24** se Sharpe + forward < −0.50; decisione **2027-01-24** solo se TUTTE — (a) Sharpe>0 [debole di proposito: + con ~180 barre SE(Sharpe)≈1.4, una soglia alta sarebbe finta precisione] (b) marginale ancora + ADDS+robust_oos+insample_edge (c) **deflated-Sharpe ricalcolato ≥0.95** [e' qui il peso: se lo + 0.953 gia' sul filo NON migliora con piu' dati, l'edge non c'e'] (d) `weights_tilt_null`; + **veto d'integrita'** se barre attive <80% → si ESTENDE, non si decide su dati mancanti. (3) ❌ **T3 — XSR01 NON GENERALIZZA fuori dal crypto** (meccanismo CONGELATO W=45/sgn=+1 su **9 settoriali SPDR 1998+ / 28 ETF 30 anni**, residuo vs SPY, demean giornaliero, split IWM/EFA riparati, **annualizzazione √252**). Diverso dal test del 25/07: quello era a **coppie**, questo diff --git a/docs/diary/2026-07-26-wave-esecuzione-dvolspread-xsr-equity.md b/docs/diary/2026-07-26-wave-esecuzione-dvolspread-xsr-equity.md index 0b4d19e..d875e83 100644 --- a/docs/diary/2026-07-26-wave-esecuzione-dvolspread-xsr-equity.md +++ b/docs/diary/2026-07-26-wave-esecuzione-dvolspread-xsr-equity.md @@ -133,6 +133,34 @@ a w=15%, **+0.11 / +0.25** a w=25%. **DVOLSPREAD esce dal limbo e diventa un candidato in forward-monitor con parametri ONESTI** (`zwin=180, k=2.0, lw=0.6, zw=1.1, tgt=0.17, svw=60` — la cella in-sample), non quelli pubblicati. +### ✅ Monitor CABLATO (stessa sessione) + +`scripts/live/paper_dvolspread.py`, in `cron_daily.sh` dopo `fetch_dvol.py` (quindi con DVOL +fresco), stato in `data/paper_dvolspread/` (gitignored), test `tests/test_paper_dvolspread.py` +(10 casi). Inception **2026-07-25**, posizione d'apertura +0.184 → **$111/gamba** (cap $300). + +**Strumentazione specifica di questo lead** — la ragione per cui non bastava copiare +`paper_statarb`: il book va **flat quando il DVOL manca**, quindi un feed rotto produrrebbe una +fila di zeri che, contati come evidenza, direbbero *"nessuna perdita"* invece di *"nessuna +misura"*. Il monitor tiene una contabilità a **tre stati** — ATTIVE / flat-da-segnale / +**flat-senza-dato** — e la finestra forward si misura in **barre attive**, non in giorni di +calendario. C'è anche una **guardia sulla config**: se `FROZEN` diverge dallo stato salvato il +monitor esce con codice 1 invece di continuare su una finestra non più valida (verificato; nel +cron non c'è `set -e`, quindi segnala senza rompere la catena). + +**Gate pre-registrato (scritto oggi, prima di vedere qualsiasi barra forward):** +- **kill anticipato 2026-10-24 (~90g):** Sharpe forward < −0.50 → ritiro; +- **decisione 2027-01-24 (~180g):** promozione solo se *tutte e quattro* — (a) Sharpe > 0, + (b) marginale ancora ADDS + robust_oos + has_insample_edge, (c) deflated-Sharpe ricalcolato + sul campione esteso ≥ 0.95, (d) `weights_tilt_null` superato; +- **veto d'integrità:** barre attive < 80% → la finestra non conta, si **estende** invece di + decidere su dati mancanti. + +⚠️ La soglia (a) è deliberatamente debole — **necessaria, non sufficiente**. Con ~180 barre attive +l'errore standard dello Sharpe è ≈1.4: una soglia più alta sarebbe finta precisione. Il peso della +decisione sta su (c) — se il margine 0.953, già sul filo, **non migliora** con più dati, l'edge +non c'è. + **Perché NON entra nel book, dichiarato:** - **campione ATTIVO 1949 giorni su 2691** (72%): prima del 2021-03 non esiste DVOL e il book è correttamente flat. L'**hold-out attivo è 1.6 anni**; diff --git a/scripts/cron_daily.sh b/scripts/cron_daily.sh index 8772253..486deb5 100755 --- a/scripts/cron_daily.sh +++ b/scripts/cron_daily.sh @@ -12,6 +12,7 @@ mkdir -p logs 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) uv run python scripts/live/paper_xsr.py # forward-monitor XSR01 cross-sectional residual 50 alt HL (PAPER, gate 2026-10-23) + uv run python scripts/live/paper_dvolspread.py # forward-monitor DVOLSPREAD implied-vol RV BTC/ETH (PAPER, kill 2026-10-24 / gate 2027-01-24) uv run python scripts/live/cc01_regime_watch.py # trigger regime CC01 (read-only: WARN>=10%/ALERT>=15% funding 30g) uv run python scripts/research/r0724_stable_snapshot.py # snapshot point-in-time supply stablecoin (sblocca WATCH STABLE a 12 mesi) # NB: l'esecuzione Deribit e' passata al BOOK (TP01+SKH01 nettati) via scripts/cron_book.sh a diff --git a/scripts/live/paper_dvolspread.py b/scripts/live/paper_dvolspread.py new file mode 100644 index 0000000..0f997cd --- /dev/null +++ b/scripts/live/paper_dvolspread.py @@ -0,0 +1,303 @@ +"""FORWARD-MONITOR — DVOLSPREAD (implied-vol relative-value BTC/ETH, 2 gambe), PAPER. + +NON e' esecuzione reale. E' il monitoraggio forward-only del lead riabilitato il 2026-07-26 +(diario `2026-07-26-wave-esecuzione-dvolspread-xsr-equity.md`, filone T2). + +DA DOVE VIENE. `agent_14_dvol_spread` (onda ortho, 2026-06-21) fu **l'unico sopravvissuto** al +marginal scorer indurito: dei 18 book relative-value che facevano ADDS con lo scorer vecchio, +l'indurito ne lascio' in piedi uno solo, questo. Poi resto' in limbo **35 giorni** — non nel book, +non in un monitor, non rifiutato. Il T2 del 26/07 l'ha ripreso e passato ai due gate che nel giugno +non esistevano (`deflated_sharpe`, `select_cell_insample`): li supera, ma solo con i parametri +scelti ONESTAMENTE in-sample, e con un margine sottile. Questo file esiste perche' la lezione (c) +di quell'ondata e' **"un lead in forward-monitor senza monitor e senza scadenza e' un lead perso"**. + +COS'E'. Segnale = `log(DVOL_btc) - log(DVOL_eth)`: quando il mercato delle opzioni paga di piu' la +vol implicita di una gamba (la gamba "temuta"), ci si posiziona **verso** quella gamba — tesi +volatility-risk-premium / fear-reversal: la gamba le cui opzioni sono comprate per paura e' quella +premiata quando il movimento temuto non si materializza. Market-neutral per costruzione +(w_eth == -w_btc) -> corr a TP01 ~0.11, ed **eseguibile a $600** (2 gambe, cap $300/asset). + +CONFIG CONGELATA — i parametri ONESTI, non quelli pubblicati (ogni ritocco AZZERA la finestra): + zwin=180, tanh_k=2.0, lw=0.6, zw=1.1, tgt=0.17, svw=60 +La cella pubblicata dall'agente (zwin=150, k=1.8, lw=0.45, zw=1.0, tgt=0.13, svw=50) era +**83a/729 sull'hold-out ma 471a/729 in-sample** = selection-on-holdout, e **fallisce** il +deflated-Sharpe (0.947). La cella qui congelata e' scelta col solo in-sample e lo **passa (0.953)**. +⚠️ Il numero da citare per questo lead e' **hold-out 0.69**, NON lo 0.93 che circolava. +Riusa la fabbrica ESATTA di `scripts/research/r0726_dvolspread_gate.make_book` (nessuna +reimplementazione -> niente drift fra monitor e backtest). + +PERCHE' MONITOR E NON BOOK (i tre motivi, dichiarati): + 1. **campione ATTIVO 1949 giorni su 2691 (72%)**: prima del 2021-03 il DVOL non esiste e il book + e' correttamente flat -> l'**hold-out attivo e' solo 1.6 anni**; + 2. **margine deflated-Sharpe sul filo** (0.953 vs soglia 0.95) su 729 trial: un passaggio, non + un'assoluzione; + 3. **`weights_tilt_null` mai affrontato** — ogni proposta di cambio pesi deve superarlo. + +STRUMENTAZIONE SPECIFICA DI QUESTO LEAD (la ragione per cui non basta copiare paper_statarb): +il book va **flat quando il DVOL manca**. Un feed DVOL rotto produrrebbe giorni piatti che, contati +come evidenza, direbbero "nessuna perdita" invece di "nessun dato". Il monitor quindi separa + * giorni ATTIVI (DVOL presente, posizione != 0) + * giorni FLAT-DA-SEGNALE (DVOL presente, il segnale sceglie 0) + * giorni FLAT-SENZA-DATO (DVOL assente/stantio -> NON contano come finestra forward) +e riporta l'eta' dell'ultimo DVOL. La finestra forward si misura in **barre attive**, non in giorni +di calendario. + +GATE PRE-REGISTRATO (scritto PRIMA di vedere la finestra forward — 2026-07-26): + * **kill anticipato 2026-10-24 (~90g):** se lo Sharpe forward MODELED < -0.50 -> RITIRO. + * **decisione 2027-01-24 (~180g):** promozione a candidato di book SOLO se, sul campione esteso, + tutte e quattro: + (a) Sharpe forward > 0 [barra NECESSARIA, non sufficiente: con ~180 + barre attive SE(Sharpe) ~ 1.4, quindi una + soglia piu' alta sarebbe finta precisione] + (b) marginale vs TP01 ancora ADDS + robust_oos + has_insample_edge + (c) deflated-Sharpe ricalcolato sul campione esteso >= 0.95 [se il margine 0.953 non + MIGLIORA con piu' dati, + l'edge non c'e'] + (d) `weights_tilt_null` superato al peso proposto + * **veto d'integrita':** se le barre ATTIVE sono < 80% della finestra, la finestra NON conta -> + si estende, non si decide su dati mancanti. + +DUE LIBRI (onesta' sull'esecuzione): + MODELED $2000 : ribilanciamento continuo di entrambe le gambe. + REAL $600 : min-order $5 per gamba -> salta i ribilanci sub-min-order. Il gap fra i due + E' l'haircut di fill reale. + +Stato: data/paper_dvolspread/{state.json, returns.jsonl} (append-only). + + uv run python scripts/live/paper_dvolspread.py # avanza col dato disponibile + uv run python scripts/live/paper_dvolspread.py --status # solo stato, non avanza + uv run python scripts/live/paper_dvolspread.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")) +sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt")) +sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "ortho")) + +import altlib as al # noqa: E402 +import ortholib as ol # noqa: E402 +from r0726_dvolspread_gate import make_book # noqa: E402 (fabbrica ESATTA del T2) + +STATE_DIR = PROJECT_ROOT / "data" / "paper_dvolspread" +STATE_FILE = STATE_DIR / "state.json" +RETURNS_FILE = STATE_DIR / "returns.jsonl" + +# --- CONFIG CONGELATA: la cella scelta IN-SAMPLE (non quella pubblicata) -------------------- +FROZEN = dict(zwin=180, tanh_k=2.0, lw=0.6, zw=1.1, tgt=0.17, svw=60) +FEE_SIDE = 0.0005 # 0.05%/lato per gamba (Deribit taker) +MODELED_CAPITAL = 2000.0 +REAL_CAPITAL = 600.0 +MIN_ORDER = 5.0 +CAP_NOTIONAL = 300.0 # guardrail live: $300 di nozionale per asset + +# --- GATE PRE-REGISTRATO (2026-07-26) ------------------------------------------------------ +KILL_DATE = "2026-10-24" # se Sharpe forward < -0.50 -> ritiro +KILL_SHARPE = -0.50 +GATE_DATE = "2027-01-24" # decisione piena +MIN_ACTIVE_FRAC = 0.80 # veto d'integrita': sotto, la finestra non conta +DVOL_MAX_AGE_D = 3.0 # oltre, il DVOL e' stantio -> giorni non contati come attivi + + +def _panel(): + """BTC/ETH 1d allineati + posizioni del book congelato + eta' del DVOL per barra.""" + btc, eth = ol.aligned() + wb, we = make_book(**FROZEN)(btc, eth) + rb = al.simple_returns(btc["close"].values.astype(float)) + re = al.simple_returns(eth["close"].values.astype(float)) + # il book e' flat dove il DVOL manca: lo si rileva dalla fonte, non dal peso (un peso 0 puo' + # essere una scelta del segnale in regime compatto, ed e' un'informazione DIVERSA) + db = al.dvol(btc, "BTC") + de = al.dvol(eth, "ETH") + has_dvol = np.isfinite(db) & np.isfinite(de) + return dict(ts=btc["timestamp"].values.astype("int64"), + dt=pd.to_datetime(btc["datetime"]).values, + wb=np.nan_to_num(np.asarray(wb, float)), + we=np.nan_to_num(np.asarray(we, float)), + rb=rb, re=re, has_dvol=has_dvol) + + +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(P: dict) -> dict: + last = int(P["ts"][-1]) + return dict(start_ts=last, last_ts=last, n_bars=0, n_active=0, + n_flat_signal=0, n_flat_nodata=0, + wb_modeled=float(P["wb"][-1]), wb_real=float(P["wb"][-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_flips=0, frozen=FROZEN) + + +def advance(st: dict, P: dict) -> dict: + ts, dt = P["ts"], P["dt"] + new = [i for i in range(len(ts)) if ts[i] > st["last_ts"]] + if not new: + return st + hm, hr = st["wb_modeled"], st["wb_real"] # peso BTC TENUTO (w_eth = -w_btc) + cm, cr = st["cap_modeled"], st["cap_real"] + pkm, pkr = st["peak_modeled"], st["peak_real"] + ddm, ddr = st["dd_modeled"], st["dd_real"] + nfl, nac, nfs, nfn = (st["n_flips"], st["n_active"], + st["n_flat_signal"], st["n_flat_nodata"]) + for i in new: + # rendimento del book TENUTO durante la barra i (spread: long BTC / short ETH per unita') + s = float(P["rb"][i] - P["re"][i]) + tgt = float(P["wb"][i]) # bersaglio deciso a close[i], eseguito da i+1 + # MODELED: ribilanciamento continuo, fee su ENTRAMBE le gambe + net_m = hm * s - FEE_SIDE * 2.0 * abs(tgt - hm) + # REAL $600: salta i ribilanci sotto il min-order per gamba + executed = abs(tgt - hr) * REAL_CAPITAL >= MIN_ORDER + new_hr = tgt if executed else hr + net_r = hr * s - FEE_SIDE * 2.0 * abs(new_hr - hr) + # contabilita' dell'ATTIVITA' — la distinzione che rende il monitor leggibile + if not bool(P["has_dvol"][i]): + nfn += 1 + elif abs(tgt) < 1e-12: + nfs += 1 + else: + nac += 1 + if np.sign(new_hr) != np.sign(hr) and abs(new_hr) > 1e-12: + nfl += 1 + cm *= (1.0 + max(net_m, -0.99)) + cr *= (1.0 + max(net_r, -0.99)) + pkm, pkr = max(pkm, cm), 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) + hm, hr = tgt, new_hr + _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), + w_btc=round(new_hr, 4), has_dvol=bool(P["has_dvol"][i]), + cap_modeled=round(cm, 2), cap_real=round(cr, 2))) + st.update(last_ts=int(ts[new[-1]]), n_bars=st["n_bars"] + len(new), + n_active=nac, n_flat_signal=nfs, n_flat_nodata=nfn, + wb_modeled=hm, wb_real=hr, cap_modeled=cm, cap_real=cr, + peak_modeled=pkm, peak_real=pkr, dd_modeled=ddm, dd_real=ddr, n_flips=nfl) + return st + + +def _forward_sharpe(key: str = "net_modeled") -> float: + """Sharpe della sola finestra forward, sulle barre registrate (annualizzato a 365).""" + if not RETURNS_FILE.exists(): + return float("nan") + v = [] + with open(RETURNS_FILE) as f: + for line in f: + try: + v.append(float(json.loads(line)[key])) + except (ValueError, KeyError): + continue + a = np.asarray(v, float) + a = a[np.isfinite(a)] + return float(a.mean() / a.std() * np.sqrt(365.25)) if len(a) > 30 and a.std() > 0 else float("nan") + + +def print_status(st: dict, P: dict) -> None: + now = int(P["ts"][-1]) + days = (now - st["start_ts"]) / 86400_000 + rm = st["cap_modeled"] / MODELED_CAPITAL - 1 + rr = st["cap_real"] / REAL_CAPITAL - 1 + counted = st["n_active"] + st["n_flat_signal"] + frac = counted / st["n_bars"] if st["n_bars"] else 1.0 + dvol_ok = bool(P["has_dvol"][-1]) + sh = _forward_sharpe() + + print("\n DVOLSPREAD forward-monitor (PAPER — implied-vol relative-value BTC/ETH, NON deploy)") + print(f" config CONGELATA (cella scelta IN-SAMPLE): {FROZEN}") + print(" ⚠ numero da citare per questo lead: hold-out 0.69 (NON lo 0.93 della cella pubblicata)") + 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_flips']}") + print(f" attivita': ATTIVE {st['n_active']} | flat-da-segnale {st['n_flat_signal']} | " + f"FLAT-SENZA-DATO {st['n_flat_nodata']} -> valide {frac:.0%} " + f"(veto sotto {MIN_ACTIVE_FRAC:.0%})") + print(f" DVOL all'ultima barra: {'presente' if dvol_ok else '*** ASSENTE ***'}") + print(f" posizione corrente (w_BTC, w_ETH = -w_BTC): {st['wb_real']:+.3f} " + f"-> nozionale ${abs(st['wb_real']) * REAL_CAPITAL:,.0f}/gamba " + f"(cap ${CAP_NOTIONAL:,.0f})") + print(f" MODELED ($2000): {rm * 100:+6.2f}% eq ${st['cap_modeled']:.2f} " + f"maxDD {st['dd_modeled'] * 100:.1f}%") + print(f" REAL-$600 : {rr * 100:+6.2f}% eq ${st['cap_real']:.2f} " + f"maxDD {st['dd_real'] * 100:.1f}%") + print(f" -> fill-haircut MODELED-REAL: {(rm - rr) * 100:+.2f} pp") + if np.isfinite(sh): + print(f" Sharpe forward (MODELED, {st['n_bars']} barre): {sh:+.2f}") + else: + print(f" Sharpe forward: n/d (servono >30 barre, ora {st['n_bars']})") + + today = pd.Timestamp(now, unit="ms", tz="UTC").normalize() + print(f"\n GATE PRE-REGISTRATO (26/07)") + if today >= pd.Timestamp(KILL_DATE, tz="UTC"): + if np.isfinite(sh) and sh < KILL_SHARPE: + print(f" *** KILL {KILL_DATE}: Sharpe {sh:+.2f} < {KILL_SHARPE:+.2f} -> RITIRARE ***") + else: + print(f" kill-check {KILL_DATE}: superato (Sharpe {sh:+.2f} >= {KILL_SHARPE:+.2f})") + else: + print(f" kill anticipato il {KILL_DATE} se Sharpe < {KILL_SHARPE:+.2f} " + f"(fra {(pd.Timestamp(KILL_DATE, tz='UTC') - today).days}g)") + if today >= pd.Timestamp(GATE_DATE, tz="UTC"): + if frac < MIN_ACTIVE_FRAC: + print(f" *** VETO D'INTEGRITA': solo {frac:.0%} di barre valide -> ESTENDERE ***") + else: + print(f" *** DECISIONE DOVUTA ({GATE_DATE}): valutare (a) Sharpe>0 [{sh:+.2f}] " + f"(b) marginale ADDS (c) deflated-Sharpe>=0.95 (d) weights_tilt_null ***") + else: + print(f" decisione piena il {GATE_DATE} " + f"(fra {(pd.Timestamp(GATE_DATE, tz='UTC') - today).days}g): Sharpe>0 E marginale ADDS " + f"E deflated-Sharpe>=0.95 E weights_tilt_null") + print(f" log: {RETURNS_FILE}\n") + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--status", action="store_true") + ap.add_argument("--reset", action="store_true") + args = ap.parse_args() + P = _panel() + if args.reset: + for p in (STATE_FILE, RETURNS_FILE): + if p.exists(): + p.unlink() + st = _state_io(init_state(P)) + print("forward-monitor DVOLSPREAD inizializzato (forward-only da ora).") + print_status(st, P) + return + st = _state_io() + if st is None: + st = _state_io(init_state(P)) + print("forward-monitor DVOLSPREAD inizializzato (forward-only da ora).") + print_status(st, P) + return + if st.get("frozen") != FROZEN: + print("*** CONFIG CAMBIATA rispetto allo stato salvato: la finestra forward non e' piu'") + print(" valida. Rilanciare con --reset (e dichiararlo nel diario).") + print(f" salvata: {st.get('frozen')}\n attuale: {FROZEN}") + sys.exit(1) + if not args.status: + st = advance(st, P) + _state_io(st) + print_status(st, P) + + +if __name__ == "__main__": + main() diff --git a/tests/test_paper_dvolspread.py b/tests/test_paper_dvolspread.py new file mode 100644 index 0000000..a27ebf5 --- /dev/null +++ b/tests/test_paper_dvolspread.py @@ -0,0 +1,168 @@ +"""Test del forward-monitor DVOLSPREAD (scripts/live/paper_dvolspread.py). + +Blindano le proprieta' che rendono la finestra forward INTERPRETABILE. Il rischio specifico di +questo lead non e' che il segnale sbagli — e' che il monitor **conti come evidenza dei giorni in +cui non c'era il dato**: il book va flat quando il DVOL manca, e una serie di zeri sembra +"nessuna perdita" quando invece e' "nessuna misura". Da qui la contabilita' a tre stati. + +I test scrivono in una directory temporanea: NON toccano lo stato reale del monitor. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +for p in (ROOT, ROOT / "scripts" / "research", ROOT / "scripts" / "research" / "alt", + ROOT / "scripts" / "research" / "ortho", ROOT / "scripts" / "live"): + sys.path.insert(0, str(p)) + +pytest.importorskip("pandas") +import pandas as pd # noqa: E402 + +import paper_dvolspread as pm # noqa: E402 + + +@pytest.fixture() +def sandbox(tmp_path, monkeypatch): + """Reindirizza lo stato del monitor in tmp: i test non devono mai scrivere in data/.""" + monkeypatch.setattr(pm, "STATE_DIR", tmp_path) + monkeypatch.setattr(pm, "STATE_FILE", tmp_path / "state.json") + monkeypatch.setattr(pm, "RETURNS_FILE", tmp_path / "returns.jsonl") + return tmp_path + + +def _fake_panel(n=10, has_dvol=None, wb=None): + ts = np.arange(n, dtype=np.int64) * 86_400_000 + 1_700_000_000_000 + return dict(ts=ts, dt=pd.to_datetime(ts, unit="ms", utc=True).values, + wb=np.full(n, 0.2) if wb is None else np.asarray(wb, float), + we=np.full(n, -0.2) if wb is None else -np.asarray(wb, float), + rb=np.full(n, 0.001), re=np.zeros(n), + has_dvol=np.ones(n, bool) if has_dvol is None else np.asarray(has_dvol, bool)) + + +# ------------------------------------------------------------------ config congelata + +def test_la_config_congelata_e_la_cella_IN_SAMPLE_non_quella_pubblicata(): + """La cella pubblicata dall'agente e' selection-on-holdout e FALLISCE il deflated-Sharpe + (0.947). Il monitor deve girare quella scelta in-sample (DSR 0.953). Se qualcuno rimettesse + i parametri pubblicati, il monitor misurerebbe l'oggetto sbagliato.""" + assert pm.FROZEN == dict(zwin=180, tanh_k=2.0, lw=0.6, zw=1.1, tgt=0.17, svw=60) + pubblicata = dict(zwin=150, tanh_k=1.8, lw=0.45, zw=1.0, tgt=0.13, svw=50) + assert pm.FROZEN != pubblicata + + +def test_il_gate_e_pre_registrato_e_ordinato(): + kill = pd.Timestamp(pm.KILL_DATE, tz="UTC") + gate = pd.Timestamp(pm.GATE_DATE, tz="UTC") + assert kill < gate, "il kill anticipato deve precedere la decisione piena" + assert pm.KILL_SHARPE < 0.0 + assert 0.5 <= pm.MIN_ACTIVE_FRAC <= 1.0 + + +# ------------------------------------------------------------------ market-neutralita' + +def test_il_book_e_market_neutral_per_costruzione(): + """w_eth == -w_btc: e' l'unica ragione per cui il lead ha corr ~0.11 a TP01.""" + import ortholib as ol + btc, eth = ol.aligned() + wb, we = pm.make_book(**pm.FROZEN)(btc, eth) + assert np.allclose(np.asarray(wb, float), -np.asarray(we, float), atol=1e-12) + + +def test_la_posizione_sta_dentro_il_cap_di_nozionale(): + """A $600 il guardrail live e' $300/asset -> |w| <= 0.5 sempre.""" + import ortholib as ol + btc, eth = ol.aligned() + wb, _ = pm.make_book(**pm.FROZEN)(btc, eth) + w = np.nan_to_num(np.asarray(wb, float)) + assert np.abs(w).max() <= pm.CAP_NOTIONAL / pm.REAL_CAPITAL + 1e-9 + + +# ------------------------------------------------------------------ contabilita' dell'attivita' + +def test_i_giorni_senza_DVOL_non_sono_contati_come_attivi(sandbox): + """IL test centrale. Se il feed DVOL si rompe il book va flat: quei giorni devono finire in + 'flat-senza-dato', non passare per giorni buoni senza perdite.""" + P = _fake_panel(n=10, has_dvol=[True] * 4 + [False] * 6) + st = pm.init_state(P) + st["last_ts"] = int(P["ts"][0]) # forza il processing delle barre 1..9 + st = pm.advance(st, P) + assert st["n_flat_nodata"] == 6, st + assert st["n_active"] == 3, st # barre 1,2,3 con dvol e posizione != 0 + assert st["n_active"] + st["n_flat_signal"] + st["n_flat_nodata"] == st["n_bars"] + + +def test_flat_da_segnale_e_flat_senza_dato_sono_stati_distinti(sandbox): + """Posizione 0 CON dvol = scelta del segnale (informazione). Posizione 0 SENZA dvol = + assenza di misura. Confonderli renderebbe il veto d'integrita' inutile.""" + P = _fake_panel(n=6, has_dvol=[True] * 6, wb=[0.2, 0.0, 0.0, 0.2, 0.0, 0.2]) + st = pm.init_state(P) + st["last_ts"] = int(P["ts"][0]) + st = pm.advance(st, P) + assert st["n_flat_nodata"] == 0 + assert st["n_flat_signal"] == 3, st + assert st["n_active"] == 2, st + + +# ------------------------------------------------------------------ macchina a stati + +def test_advance_e_idempotente(sandbox): + """Rilanciare il cron due volte lo stesso giorno non deve raddoppiare la finestra.""" + P = _fake_panel(n=8) + st = pm.init_state(P) + st["last_ts"] = int(P["ts"][0]) + st = pm.advance(st, P) + snap = dict(st) + st2 = pm.advance(st, P) + for k in ("n_bars", "n_active", "cap_modeled", "cap_real", "last_ts"): + assert st2[k] == snap[k], k + + +def test_inception_e_forward_only(sandbox): + """All'inizializzazione la finestra parte dall'ultima barra chiusa: lo storico NON entra.""" + P = _fake_panel(n=50) + st = pm.init_state(P) + assert st["n_bars"] == 0 + assert st["start_ts"] == int(P["ts"][-1]) + assert st["cap_modeled"] == pm.MODELED_CAPITAL + assert pm.advance(st, P)["n_bars"] == 0 # nessuna barra nuova -> nessun avanzamento + + +def test_il_libro_reale_salta_i_ribilanci_sotto_il_min_order(sandbox): + """A $600 un Delta-peso da meno di $5 di nozionale NON si esegue. + + ⚠️ La soglia si misura sul gap CUMULATO contro la posizione TENUTA, non sul passo di una + singola barra: un drift da $3/barra esegue comunque ogni due barre. Il test copre entrambi + i rami — drift che resta sotto soglia per tutta la finestra, e salto singolo sopra soglia.""" + step = 0.5 / pm.REAL_CAPITAL # $0.50/barra -> dopo 5 barre $2.50, sempre < $5 + P = _fake_panel(n=6, wb=[0.2 + k * step for k in range(6)]) + st = pm.init_state(P) + st["last_ts"] = int(P["ts"][0]) + st["wb_real"] = st["wb_modeled"] = 0.2 + st = pm.advance(st, P) + assert abs(st["wb_real"] - 0.2) < 1e-12, "sotto soglia il libro reale non deve ribilanciare" + assert abs(st["wb_modeled"] - (0.2 + 5 * step)) < 1e-9, "il modellato ribilancia sempre" + + # ramo opposto: un salto ampio DEVE eseguire anche sul libro reale + P2 = _fake_panel(n=3, wb=[0.2, 0.45, 0.45]) + st2 = pm.init_state(P2) + st2["last_ts"] = int(P2["ts"][0]) + st2["wb_real"] = st2["wb_modeled"] = 0.2 + st2 = pm.advance(st2, P2) + assert abs(st2["wb_real"] - 0.45) < 1e-9, "sopra soglia il libro reale deve ribilanciare" + + +def test_le_barre_registrate_portano_il_flag_del_dvol(sandbox): + """Il log deve permettere di ricostruire a posteriori quali barre erano valide.""" + P = _fake_panel(n=5, has_dvol=[True, True, False, True, False]) + st = pm.init_state(P) + st["last_ts"] = int(P["ts"][0]) + pm.advance(st, P) + rows = [json.loads(x) for x in pm.RETURNS_FILE.read_text().splitlines()] + assert len(rows) == 4 + assert [r["has_dvol"] for r in rows] == [True, False, True, False]