diff --git a/.gitignore b/.gitignore index d9b0ea7..3da9ac7 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ data/paper_dvolspread/ data/fee_watch/ # battuta di cuore del collettore catena (stato runtime, come gli altri monitor) data/chain_collect/ +data/vrp_f_watch/ # log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale) data/live/ diff --git a/CLAUDE.md b/CLAUDE.md index 56e712e..fbeaa07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,12 +210,13 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis 📌 **Il buco si chiude meglio sul contenuto che sul gate: la regione MAI esplorata PERDE.** Miglior cella >10g = 18g −0.28/−0.05, **rango 8/72**, e solo **3/10** della top-10 stanno oltre i 10 giorni → lo studio **conferma** il 03/07 invece di ribaltarlo. - ⚠️ **Il vincitore sta dove il modello sbaglia di piu':** compra l'ala piu' lontana (δ lungo - −0.05), come 5/10 della top-10, cioe' esattamente la gamba che il 30/07 ha MISURATO - sottoprezzata (~2.3x, IV−DVOL +7.5pp). Sospetto motivato, **non dimostrazione**: il mediano non - separa −0.10 da −0.05 (1.37 vs 1.37), si separa la coda alta. Ma `f` **non e' misurato** fuori - dalla struttura canonica, e la sensibilita' a f uniforme (cella scelta 0.84 vs canonico 0.47 a - f=0.73) **e' la lente sbagliata** per una struttura il cui errore e' concentrato in una gamba. + ❌ **"Il vincitore sta dove il modello sbaglia di piu'" — MIO ARGOMENTO, MISURATO E REFUTATO + LO STESSO GIORNO** (5° filone, sotto): l'ala a δ−0.05 e' molto piu' mispriced in RELATIVO + (f_long **5.85** contro 2.23) ma pesa il **3.2%** del premio corto invece del **18.4%**, quindi + sul credito NETTO l'effetto e' MINORE → f_net **0.852** contro **0.718**: il candidato ha un f + **migliore**, non peggiore. Meccanismo giusto, conclusione rovesciata. **La decisione (nessun + cambio) regge, ma su tre gambe invece di quattro:** gate DSR, regione nuova perdente, + ineseguibilita' sotto ~$2.6k. ⚠️ **Robustezza del verdetto, pubblicata:** DSR **0.983 PASS a N=8** / **0.948 FAIL a N=72** / 0.909 a N=360 → **il verdetto si ribalta col conteggio**. La griglia era dichiarata in anticipo e il conto fatto al rialzo, ma **fallisce per 0.002**: si cita come tale, non come refutazione @@ -228,6 +229,36 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis che dentro **non c'e' niente** che bocciando il candidato; (d) se la selezione converge dove un difetto di modello e' gia' misurato, **il sospetto vale piu' del numero** — e si dice che e' un sospetto. + ✅ **f MISURATO SULLE STRUTTURE, e sorvegliante cablato (2026-07-30, 5° filone).** + `scripts/live/vrp_f_watch.py` (in `cron_daily.sh`), test `tests/test_vrp_f_watch.py` (11), + diario `2026-07-30-vrp-f-strutture.md`. **Book/pesi/config INVARIATI.** Il campione c'era gia': + **8 scadenze utilizzabili per asset su 8**, entrambe le strutture, 16 osservazioni ciascuna. + | struttura | f_short | f_long | quota ala/corta | **f_net** | + |---|---|---|---|---| + | canonico 7g δ−0.10 | 1.013 | 2.233 | 18.4% | **0.718** | + | candidato 10g δ−0.05 | 0.972 | **5.846** | **3.2%** | **0.852** | + Aritmetica che rende ovvio l'errore: `f_net = (f_short − k·f_long)/(1 − k)` con + `k = premio_lungo/premio_corto` → **un f_long grande fa danno solo moltiplicato per un k + grande**. ⚠️ Artefatto di tick ESCLUSO prima di crederci: l'ask dell'ala sta a **22 tick** + mediani (min 15, **0%** a ≤2 tick). + **La DIFFERENZA non e' stabilita:** appaiata per (asset, scadenza) fa **+0.109 IC95 + [−0.047, +0.193]**, 11/16 positive → **contiene lo zero**. ✅ Replica indipendente del canonico + (**0.718** per un percorso con finestra DTE e pairing diversi da quello che dava 0.73). + Al proprio f: canonico **0.43**, candidato **1.18**. + **CRITERIO PRE-REGISTRATO (dichiarato prima del campione pieno, congelato in un test): + ≥40 coppie E ampiezza IC95 ≤0.12** (oggi 16 e 0.241) → prima gamba verso **fine ottobre 2026**; + il sorvegliante rifa' la misura ogni giorno e **notifica una volta sola** quando basta (lezione + DVOLSPREAD: un lead senza sorvegliante e senza data e' un lead perso). + ⚠️ **Calibrazione della soglia verificata prima di fidarsene:** con differenza vera nulla lo + zero e' escluso nel **5.0/4.0/3.0%** dei casi a n=16/40/60 = il 5% atteso. (Il test iniziale su + seed fisso falliva perche' quel seed era uno dei 5% legittimi → sostituito con un test sulla + PROPRIETA', 100 estrazioni.) + **NON promuove nulla:** il candidato resta bocciato sul deflated-Sharpe, che non dipende da f. + **REGOLE:** (a) **un fattore di errore si giudica moltiplicato per il suo peso** — 5.85 al 3% + fa meno danno di 2.23 al 18%, e guardare il fattore senza il peso da' la conclusione opposta a + quella vera; (b) prima di credere a un rapporto estremo su un prezzo piccolo, **contare i tick**; + (c) una soglia sull'ampiezza di un IC richiede di **verificare la copertura** di quell'IC; + (d) *"non abbastanza campione" non e' "nessuna differenza"* — si aspetta con una data. 💰 **A $3.000 la domanda non e' il rendimento** (`min_trade_amount` letti dal venue il 30/07): **BTC min 0.1 = $6.210 di collaterale/lotto → FUORI**; ETH min 1 contratto = $1.832 → **1 lotto**. Il sleeve 50/50 **diventa ETH-only**, e **al peso di book (12% = $360) sono 0 lotti** — servirebbe diff --git a/docs/diary/2026-07-30-vrp-f-strutture.md b/docs/diary/2026-07-30-vrp-f-strutture.md new file mode 100644 index 0000000..5196e4a --- /dev/null +++ b/docs/diary/2026-07-30-vrp-f-strutture.md @@ -0,0 +1,99 @@ +# 2026-07-30 (5º filone) — f misurato sul 10g: il mio sospetto era sbagliato, e il campione non basta ancora + +**Richiesta dell'operatore:** *«misura f sul 10g quando ci sono abbastanza scadenze».* +**Esito: misurato oggi (il campione c'era), campione INSUFFICIENTE per la differenza, un mio +argomento pubblicato oggi REFUTATO. Book, pesi, config INVARIATI.** + +Script `scripts/live/vrp_f_watch.py` (in `cron_daily.sh`), test `tests/test_vrp_f_watch.py` (11). + +## Il campione c'era già + +Prima cosa misurata, non assunta: nella catena (1.235.064 righe, 2026-05-01 → 2026-07-30) ci sono +**8 scadenze utilizzabili per asset su 8**, per **entrambe** le strutture — quindi 16 osservazioni +ciascuna. Non serviva aspettare per fare *la misura*; serve aspettare per rispondere alla +*differenza*, che è un'altra domanda. + +## ❌ La correzione: il mio sospetto aveva il meccanismo giusto e la conclusione sbagliata + +Nel diario del gate sul tenore, poche ore fa, avevo scritto che la cella vincente +*«sta plausibilmente massimizzando l'errore di modello, non l'edge»*, perché compra l'ala più +lontana e il 30/07 aveva misurato quell'ala sottoprezzata ~2.3× dal modello piatto. + +Misurato: + +| struttura | f_short | f_long | quota ala/corta | **f_net** | +|---|---|---|---|---| +| canonico 7g δ−0.10 | 1.013 | 2.233 | **18.4%** | **0.718** | +| candidato 10g δ−0.05 | 0.972 | **5.846** | **3.2%** | **0.852** | + +**Il meccanismo è confermato e più forte del previsto** — l'ala a δ −0.05 costa **5.85×** il +modello contro 2.23× — **ma la conclusione era rovesciata**: quell'ala pesa il **3.2%** del premio +corto invece del 18.4%, quindi il suo errore muove molto meno il credito **netto**. Il candidato ha +un f **migliore**, non peggiore. + +L'aritmetica lo rende ovvio a posteriori: con `k = premio_lungo/premio_corto`, +`f_net = (f_short − k·f_long)/(1 − k)`. Un `f_long` grande fa danno solo se moltiplicato per un `k` +grande. Avevo guardato il fattore e non il peso. + +⚠️ **Artefatto escluso prima di crederci.** Un `f_long` di 5.85 su un'opzione quasi senza valore +poteva essere solo il tick minimo (0.0001). Misurato: l'ask dell'ala sta a **22 tick di mediana** +(minimo 15, **0%** delle osservazioni a ≤2 tick). È un prezzo vero, non discretizzazione. + +## La differenza NON è stabilita + +Confronto **appaiato per (asset, scadenza)** — non due intervalli guardati a occhio: + +| | mediana | IC95 bootstrap | +|---|---|---| +| f canonico | 0.718 | [0.662, 0.797] | +| f candidato | 0.852 | [0.809, 0.892] | +| **differenza appaiata** | **+0.109** | **[−0.047, +0.193]** | + +11 coppie su 16 positive, **l'IC contiene lo zero**. Il punto stimato favorisce il candidato; la +misura non lo stabilisce. ✅ Replica indipendente: il f del canonico esce **0.718** per un percorso +diverso (finestra DTE e pairing diversi) da quello che stamattina dava 0.73 — le due misure si +confermano. + +Al proprio f misurato: canonico **0.43**, candidato **1.18** (a f=1.00 erano 1.32 e 1.55). + +## Criterio pre-registrato, e un sorvegliante invece di un promemoria + +Dichiarato **prima** di avere il campione pieno, congelato in un test: + +> **≥ 40 coppie appaiate** (oggi 16) **E ampiezza IC95 della differenza ≤ 0.12** (oggi 0.241). + +Con ~2 coppie/settimana la prima gamba cade verso **fine ottobre 2026**. `vrp_f_watch.py` gira in +`cron_daily.sh`, rifà la misura a ogni giro e **manda una notifica una volta sola** quando il +criterio è soddisfatto. È la disciplina imparata con DVOLSPREAD (35 giorni di limbo): *un lead +senza sorvegliante e senza data è un lead perso*. + +⚠️ **Verificata la calibrazione della soglia prima di fidarsene.** Una soglia sull'*ampiezza* di un +IC vale solo se l'IC è calibrato. Misurato su differenza vera nulla: lo zero viene escluso nel +**5.0% / 4.0% / 3.0%** dei casi a n=16/40/60 — esattamente il 5% atteso. (Il test iniziale su un +seed fisso falliva proprio perché quel seed era uno dei 5% legittimi: sostituito con un test sulla +**proprietà**, 100 estrazioni.) + +## Cosa questa misura NON fa + +**Non promuove niente.** Il candidato è bocciato sul **deflated-Sharpe (0.948 < 0.95)**, che non +dipende da f, e la regola *niente short-vol da modello in deploy* resta. Un f favorevole toglie +**un argomento di cautela su quattro** — restano il gate pre-registrato, il fatto che la regione +mai esplorata (>10g) perde comunque, e l'ineseguibilità sotto ~$2.6k. + +Ma la decisione ora poggia su **meno gambe di quante ne avevo dichiarate**, e questo va scritto: +uno dei quattro argomenti era mio, era misurabile, e l'ho misurato sbagliato. + +Il valore operativo vero sta sull'altra riga della tabella: **f = 0.718 sul canonico** è il numero +che rende onesti i conti di uno sleeve che **sta nel book**, non di un candidato. + +## Regole + +- **Un fattore di errore si giudica moltiplicato per il suo peso.** `f_long` 5.85 sembra + devastante e conta il 3%; `f_long` 2.23 sembra mite e conta il 18%. Guardare il fattore senza il + peso porta alla conclusione opposta a quella vera. +- **Prima di credere a un rapporto estremo su un prezzo piccolo, contare i tick.** Un ratio di 5× + su qualcosa che vale un tick è aritmetica di griglia, non mercato. +- **Una soglia su un intervallo di confidenza richiede di verificare la copertura di + quell'intervallo** — altrimenti si pre-registra un criterio che non misura ciò che dice. +- **«Non abbastanza campione» non è «nessuna differenza»**: si aspetta con una data, non si + conclude. diff --git a/scripts/cron_daily.sh b/scripts/cron_daily.sh index 730cefb..6598994 100755 --- a/scripts/cron_daily.sh +++ b/scripts/cron_daily.sh @@ -31,6 +31,10 @@ mkdir -p logs # Schema fee Deribit (nuovo dal 2026-08-01, annunciato senza numeri): legge il tier BASE # dall'endpoint pubblico e applica la regola decisa in anticipo (<=5bps nulla, >10bps peso SKH01). uv run python scripts/live/fee_watch.py --quiet || true + # f delle strutture VRP dalle quote REALI (canonico vs candidato bocciato dal gate 30/07). + # Criterio di sufficienza pre-registrato (>=40 coppie, IC95 <=0.12): avvisa da solo quando + # il campione basta, invece di lasciare la misura appesa a un promemoria. + uv run python scripts/live/vrp_f_watch.py --quiet || true # I forward-monitor stanno registrando? Tre gate pre-registrati (27/09, 23/10, 24/10) si # decidono su queste serie: un monitor fermo produce silenzio, e il silenzio sembra uno zero. # Va DOPO tutti i monitor, altrimenti misura lo stato di ieri. diff --git a/scripts/live/vrp_f_watch.py b/scripts/live/vrp_f_watch.py new file mode 100644 index 0000000..aaaabc1 --- /dev/null +++ b/scripts/live/vrp_f_watch.py @@ -0,0 +1,194 @@ +"""VRP-f WATCH — misura f sulle strutture VRP dalle quote REALI, e avvisa quando basta. + +PERCHE' ESISTE. Il gate del tenore (30/07, `r0730_vrp_tenor_gate`) ha bocciato il candidato +**10g / delta -0.28 / -0.05** sul deflated-Sharpe (0.948 < 0.95), e fra le ragioni di cautela +avevo scritto che quel candidato *"sta dove il modello sbaglia di piu'"* — compra l'ala piu' +lontana, che il 30/07 aveva misurato sottoprezzata ~2.3x. + +⚠️ **Quel sospetto e' stato MISURATO e la conclusione era sbagliata.** L'ala lontana e' molto +piu' mispriced in RELATIVO (f_long 5.85 contro 2.23) ma pesa il **3.2%** del premio corto invece +del **18.4%**, quindi sul credito NETTO l'effetto e' minore: f_net **0.852** contro **0.718**. +Meccanismo giusto, segno della conclusione sbagliato. + +E' pero' una misura su **16 coppie**: la differenza appaiata e' **+0.109 con IC95 +[-0.047, +0.193]**, cioe' **compatibile con zero**. Serve piu' campione, e "quando ce ne sara' +abbastanza" e' un criterio, non un promemoria — un lead senza sorvegliante e senza data e' un +lead perso (lezione DVOLSPREAD, 26/07). Questo script fa la misura a ogni giro e **avvisa da +solo** quando il campione basta. + +CRITERIO DI SUFFICIENZA — PRE-REGISTRATO IL 2026-07-30, PRIMA DI AVERE IL CAMPIONE: + (a) >= 40 coppie appaiate (asset x scadenza), contro le 16 di oggi + (b) ampiezza dell'IC95 della differenza appaiata <= 0.12 (oggi 0.240) +Con ~2 coppie/settimana (scadenze settimanali x 2 asset) (a) cade verso **fine ottobre 2026**. +Quando entrambe sono soddisfatte lo script manda una notifica UNA volta e si zittisce. + +⚠️ **COSA QUESTA MISURA NON FA.** Non promuove niente. Il candidato e' bocciato sul +deflated-Sharpe, che non dipende da f; e la regola "niente short-vol da modello in deploy" resta. +Un f favorevole toglie UN argomento di cautela, non aggiunge un edge. Il valore operativo vero e' +sull'altra riga: il f del **canonico** e' cio' che rende onesti i numeri di uno sleeve che sta +nel book. + + uv run python scripts/live/vrp_f_watch.py + uv run python scripts/live/vrp_f_watch.py --quiet # per il cron +""" +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")) + +from cblib import ( # noqa: E402 + dvol_series, f_factors, load_chain, pick_legs, puts, spot_series, +) +from src.live.notifier import notify # noqa: E402 + +STATE = PROJECT_ROOT / "data" / "vrp_f_watch" / "state.json" + +# --- config CONGELATA il 2026-07-30 --------------------------------------- +STRUCTS = { + # nome (dte_lo, dte_hi, dte_target, short_delta, long_delta) + "canonico": (4.0, 10.0, 7.0, -0.28, -0.10), # VRP01 in produzione + "candidato": (8.0, 13.0, 10.0, -0.28, -0.05), # cella scelta al buio dal gate 30/07 +} +MIN_PAIRS = 40 # criterio (a), pre-registrato +MAX_CI_WIDTH = 0.12 # criterio (b), pre-registrato +BOOT = 20000 +SEED = 7 +ASSETS = ("BTC", "ETH") + + +def _boot_median_ci(x: np.ndarray, seed: int = SEED) -> tuple[float, float]: + if len(x) < 3: + return float("nan"), float("nan") + r = np.random.default_rng(seed) + b = np.median(r.choice(x, (BOOT, len(x)), replace=True), axis=1) + return float(np.percentile(b, 2.5)), float(np.percentile(b, 97.5)) + + +def measure(chain: pd.DataFrame | None = None) -> pd.DataFrame: + """Un'osservazione per (asset, struttura, scadenza): lo snapshot col DTE piu' vicino al + bersaglio in cui ENTRAMBE le gambe sono quotate.""" + ch = load_chain() if chain is None else chain + rows = [] + for asset in ASSETS: + p = puts(asset, ch) + S, V = spot_series(asset), dvol_series(asset) + for name, (lo, hi, tgt, sd, ld) in STRUCTS.items(): + w = p[(p["dte"] >= lo) & (p["dte"] <= hi)] + for exp, g in w.groupby("exp"): + order = sorted(g["ts"].unique(), + key=lambda t: abs((exp - pd.Timestamp(t)).total_seconds() / 86400.0 - tgt)) + for ts in order: + legs = pick_legs(g[g["ts"] == ts], sd, ld) + if legs is None: + continue + t = pd.Timestamp(ts).as_unit("ns") + dte = (exp - pd.Timestamp(ts)).total_seconds() / 86400.0 + ff = f_factors(legs, float(S.asof(t)), float(V.asof(t)) / 100.0, dte) + rows.append(dict(asset=asset, struct=name, exp=exp, ts=t, dte=dte, + f_short=ff["f_short"], f_long=ff["f_long"], + f_net=ff["f_net"], f_net_mid=ff["f_net_mid"], + quota_lunga=ff["mod_long"] / ff["mod_short"] + if ff["mod_short"] > 0 else np.nan)) + break + return pd.DataFrame(rows) + + +def assess(R: pd.DataFrame) -> dict: + """Statistica appaiata per (asset, scadenza): stessa scadenza, due strutture.""" + if R.empty: + return dict(pairs=0, ready=False, reason="nessuna osservazione") + piv = R.pivot_table(index=["asset", "exp"], columns="struct", values="f_net").dropna() + if len(piv) < 3: + return dict(pairs=int(len(piv)), ready=False, reason="troppo poche coppie") + d = (piv["candidato"] - piv["canonico"]).to_numpy() + lo, hi = _boot_median_ci(d) + c_lo, c_hi = _boot_median_ci(piv["canonico"].to_numpy()) + k_lo, k_hi = _boot_median_ci(piv["candidato"].to_numpy()) + width = hi - lo + out = dict( + pairs=int(len(piv)), + f_canonico=float(np.median(piv["canonico"])), f_canonico_ci=[c_lo, c_hi], + f_candidato=float(np.median(piv["candidato"])), f_candidato_ci=[k_lo, k_hi], + diff=float(np.median(d)), diff_ci=[lo, hi], ci_width=float(width), + n_positive=int((d > 0).sum()), + esclude_zero=bool(lo > 0 or hi < 0), + ) + out["ready"] = bool(out["pairs"] >= MIN_PAIRS and width <= MAX_CI_WIDTH) + return out + + +def _load_state() -> dict: + try: + return json.loads(STATE.read_text()) + except Exception: + return {} + + +def _save_state(d: dict) -> None: + STATE.parent.mkdir(parents=True, exist_ok=True) + STATE.write_text(json.dumps(d, indent=2, default=str)) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--quiet", action="store_true", help="stampa solo se c'e' qualcosa da dire") + args = ap.parse_args() + + R = measure() + a = assess(R) + prev = _load_state() + + if not args.quiet: + print("=" * 88) + print(" VRP-f WATCH — f dalle quote reali, canonico vs candidato del gate 30/07") + print("=" * 88) + if R.empty: + print("\n nessuna osservazione nella catena.") + else: + print(f"\n {'struttura':<12}{'oss.':>6}{'f_short':>10}{'f_long':>10}" + f"{'f_net':>10}{'quota ala/corta':>18}") + for name, g in R.groupby("struct"): + print(f" {name:<12}{len(g):>6}{g['f_short'].median():>10.3f}" + f"{g['f_long'].median():>10.3f}{g['f_net'].median():>10.3f}" + f"{g['quota_lunga'].median()*100:>17.1f}%") + print(f"\n coppie appaiate (stesso asset, stessa scadenza): {a['pairs']}") + print(f" f canonico {a['f_canonico']:.3f} IC95 [{a['f_canonico_ci'][0]:.3f}, {a['f_canonico_ci'][1]:.3f}]") + print(f" f candidato {a['f_candidato']:.3f} IC95 [{a['f_candidato_ci'][0]:.3f}, {a['f_candidato_ci'][1]:.3f}]") + print(f" DIFFERENZA {a['diff']:+.3f} IC95 [{a['diff_ci'][0]:+.3f}, {a['diff_ci'][1]:+.3f}]" + f" ampiezza {a['ci_width']:.3f} ({a['n_positive']}/{a['pairs']} positive)") + print(f"\n criterio pre-registrato 2026-07-30: >= {MIN_PAIRS} coppie E ampiezza IC95 <= {MAX_CI_WIDTH}") + print(f" coppie {a['pairs']:>3} / {MIN_PAIRS} {'OK' if a['pairs'] >= MIN_PAIRS else 'non ancora'}") + print(f" ampiezza {a['ci_width']:.3f} / {MAX_CI_WIDTH} " + f"{'OK' if a['ci_width'] <= MAX_CI_WIDTH else 'non ancora'}") + print(f"\n -> {'CAMPIONE SUFFICIENTE' if a['ready'] else 'campione ancora insufficiente: si ASPETTA, non si decide'}") + print("\n ⚠ Anche a campione pieno questa misura NON promuove il candidato: e' bocciato") + print(" sul deflated-Sharpe, che non dipende da f. Toglie un argomento di cautela.") + print() + + if a.get("ready") and not prev.get("notified"): + notify("📐 VRP-f WATCH — campione sufficiente", + f"Le coppie appaiate sono {a['pairs']} (soglia {MIN_PAIRS}) e l'IC95 della " + f"differenza è ampio {a['ci_width']:.3f} (soglia {MAX_CI_WIDTH}).\n\n" + f"f canonico {a['f_canonico']:.3f} · f candidato {a['f_candidato']:.3f} · " + f"differenza {a['diff']:+.3f} IC95 [{a['diff_ci'][0]:+.3f}, {a['diff_ci'][1]:+.3f}]" + f"{' — ESCLUDE lo zero' if a['esclude_zero'] else ' — compatibile con zero'}.\n\n" + f"Non promuove nulla: il candidato resta bocciato sul deflated-Sharpe.") + a["notified"] = True + else: + a["notified"] = bool(prev.get("notified", False)) + + _save_state(a) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_vrp_f_watch.py b/tests/test_vrp_f_watch.py new file mode 100644 index 0000000..bdb110e --- /dev/null +++ b/tests/test_vrp_f_watch.py @@ -0,0 +1,144 @@ +"""Test del sorvegliante di f sulle strutture VRP (scripts/live/vrp_f_watch.py). + +Cosa va congelato: + +* **Il criterio di sufficienza**, perche' e' pre-registrato: se un domani non si arriva alla + soglia, la tentazione e' abbassarla. Il test la lega a una costante, e cambiarla e' un atto + visibile in un diff. +* **La statistica appaiata**: la domanda e' una DIFFERENZA fra due strutture sulla stessa + scadenza, non due intervalli guardati a occhio. +* **Il fatto che 'non abbastanza' NON e' 'nessuna differenza'** — con l'IC che contiene lo zero + si aspetta, non si conclude. +* **I controlli positivi**: un criterio che non scatta mai e uno rotto si somigliano troppo. +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +_spec = importlib.util.spec_from_file_location( + "vrp_f_watch", ROOT / "scripts" / "live" / "vrp_f_watch.py") +W = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(W) + + +def _frame(pairs: int, diff: float, spread: float = 0.0, seed: int = 0) -> pd.DataFrame: + """Coppie sintetiche: `pairs` scadenze con canonico ~0.72 e candidato ~0.72+diff.""" + r = np.random.default_rng(seed) + rows = [] + for i in range(pairs): + base = 0.72 + r.normal(0, spread) + for name, v in (("canonico", base), ("candidato", base + diff + r.normal(0, spread))): + rows.append(dict(asset="BTC", struct=name, exp=pd.Timestamp("2026-01-01") + pd.Timedelta(days=i), + ts=pd.Timestamp("2026-01-01"), dte=7.0, f_short=1.0, f_long=2.0, + f_net=v, f_net_mid=v, quota_lunga=0.18)) + return pd.DataFrame(rows) + + +# =========================================================================== +# il criterio, che e' pre-registrato +# =========================================================================== +def test_il_criterio_di_sufficienza_e_quello_dichiarato_il_30_luglio(): + assert W.MIN_PAIRS == 40 + assert W.MAX_CI_WIDTH == 0.12 + + +def test_le_strutture_confrontate_sono_quelle_congelate(): + """Il canonico e' VRP01 in produzione; il candidato e' la cella scelta al buio dal gate.""" + assert W.STRUCTS["canonico"][3:] == (-0.28, -0.10) + assert W.STRUCTS["candidato"][3:] == (-0.28, -0.05) + assert W.STRUCTS["candidato"][2] == 10.0 # il tenore sotto esame + + +# =========================================================================== +# la statistica +# =========================================================================== +def test_la_differenza_e_appaiata_per_scadenza(): + """Con una differenza costante di +0.10 su ogni coppia, la mediana appaiata deve essere + esattamente +0.10 e l'IC strettissimo — cosa che due IC separati non garantirebbero.""" + a = W.assess(_frame(50, diff=0.10, spread=0.05, seed=1)) + assert a["pairs"] == 50 + assert a["diff"] == pytest.approx(0.10, abs=0.03) + assert a["ci_width"] < 0.10 + + +def test_poche_coppie_non_bastano_anche_se_la_differenza_e_grande(): + """Il criterio ha DUE gambe: un effetto grande su 5 coppie non e' una misura.""" + a = W.assess(_frame(5, diff=0.30, spread=0.05, seed=2)) + assert not a["ready"] and a["pairs"] < W.MIN_PAIRS + + +def test_molte_coppie_con_ic_largo_non_bastano(): + """L'altra gamba: tante osservazioni ma rumorose non chiudono la domanda.""" + a = W.assess(_frame(60, diff=0.10, spread=0.60, seed=3)) + assert a["pairs"] >= W.MIN_PAIRS + assert a["ci_width"] > W.MAX_CI_WIDTH and not a["ready"] + + +# =========================================================================== +# CONTROLLI POSITIVI +# =========================================================================== +def test_controllo_positivo_il_criterio_sa_dire_di_si(): + a = W.assess(_frame(60, diff=0.15, spread=0.05, seed=4)) + assert a["ready"] and a["esclude_zero"] + + +def test_una_differenza_nulla_e_misurata_come_nulla_non_come_ignota(): + """A campione pieno e differenza vera zero la domanda E' risolta (`ready`): 'misurato + uguale' e' un risultato, 'non misurato' no.""" + a = W.assess(_frame(60, diff=0.0, spread=0.05, seed=5)) + assert a["ready"] + + +def test_l_intervallo_bootstrap_e_calibrato(): + """La soglia pre-registrata sull'AMPIEZZA dell'IC vale solo se l'IC e' calibrato: se + fosse troppo stretto, 'ready' arriverebbe presto e con un falso positivo in mano. + Misurato: con differenza vera nulla lo zero viene escluso nel ~5% dei casi, come deve. + + (Serve un test sulla PROPRIETA' e non su un seed: la prima stesura fissava seed=5 e + falliva perche' quel seed era uno dei ~5% legittimi.)""" + n_seeds = 100 + esclusi = sum(W.assess(_frame(40, diff=0.0, spread=0.05, seed=s))["esclude_zero"] + for s in range(n_seeds)) + assert esclusi / n_seeds <= 0.12 # 5% atteso, +3 sd binomiali su 100 estrazioni + + +# =========================================================================== +# stato reale del progetto +# =========================================================================== +@pytest.fixture(scope="module") +def reale(): + return W.assess(W.measure()) + + +def test_oggi_il_campione_non_basta_e_lo_script_lo_dice(reale): + """Congelato: al 2026-07-30 sono 16 coppie con IC95 che CONTIENE lo zero. Se un domani + questo test fallisce e' perche' il campione e' cresciuto — ed e' il momento di guardare.""" + assert reale["pairs"] >= 16 + if reale["pairs"] < W.MIN_PAIRS: + assert not reale["ready"] + + +def test_il_f_del_canonico_replica_la_misura_del_30_luglio(reale): + """Percorso indipendente da `r0730_vrp_real_quotes` (finestra DTE e pairing diversi): + deve ritrovare ~0.73. Se diverge, una delle due misure e' rotta.""" + assert 0.65 <= reale["f_canonico"] <= 0.80 + + +def test_l_ala_piu_lontana_e_piu_mispriced_ma_pesa_meno(): + """Il meccanismo, misurato: f_long molto peggiore sul candidato, ma quota sul premio corto + molto minore -> effetto NETTO minore. E' il fatto che ha refutato il mio sospetto.""" + R = W.measure() + med = R.groupby("struct")[["f_long", "quota_lunga", "f_net"]].median() + assert med.loc["candidato", "f_long"] > med.loc["canonico", "f_long"] + assert med.loc["candidato", "quota_lunga"] < med.loc["canonico", "quota_lunga"] + assert med.loc["candidato", "f_net"] > med.loc["canonico", "f_net"]