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:
@@ -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())
|
||||
Reference in New Issue
Block a user