research(vrp): f misurato sul 10g — il mio sospetto era sbagliato, e il campione non basta ancora

Book, pesi, config INVARIATI. Nuovo sorvegliante in cron_daily.

IL CAMPIONE C'ERA GIA': 8 scadenze utilizzabili per asset su 8, entrambe le
strutture, 16 osservazioni ciascuna. Non serviva aspettare per fare la misura;
serve aspettare per rispondere alla DIFFERENZA, che e' un'altra domanda.

CORREZIONE A UN ARGOMENTO PUBBLICATO POCHE ORE FA. Nel gate del tenore avevo
scritto che la cella vincente 'sta massimizzando l'errore di modello' perche'
compra l'ala piu' lontana. Misurato: il meccanismo e' confermato e piu' forte
del previsto (f_long 5.85 contro 2.23) ma la conclusione era ROVESCIATA —
quell'ala 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. Con f_net = (f_short - k*f_long)/(1-k), un f_long grande fa danno
solo moltiplicato per un k grande: avevo guardato il fattore e non il peso.
La decisione (nessun cambio) regge, ma su tre gambe invece di quattro.

Artefatto di tick escluso prima di crederci: l'ask dell'ala sta a 22 tick
mediani, minimo 15, 0% delle osservazioni a <=2 tick.

LA DIFFERENZA NON E' STABILITA: appaiata per (asset, scadenza) fa +0.109 con
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 stamattina dava 0.73.

CRITERIO PRE-REGISTRATO, 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, cioe' il 5%
atteso. Il test iniziale su seed fisso falliva perche' quel seed era uno dei 5%
legittimi: sostituito con un test sulla proprieta'.

REGOLE: (a) un fattore di errore si giudica moltiplicato per il suo peso;
(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 verificarne la
copertura; (d) 'non abbastanza campione' non e' 'nessuna differenza'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-30 22:17:11 +00:00
parent 36dc55748e
commit fb01c5714c
6 changed files with 479 additions and 6 deletions
+1
View File
@@ -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/
+37 -6
View File
@@ -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, IVDVOL +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
+99
View File
@@ -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.
+4
View File
@@ -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.
+194
View File
@@ -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())
+144
View File
@@ -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"]