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:
@@ -78,6 +78,7 @@ data/paper_dvolspread/
|
|||||||
data/fee_watch/
|
data/fee_watch/
|
||||||
# battuta di cuore del collettore catena (stato runtime, come gli altri monitor)
|
# battuta di cuore del collettore catena (stato runtime, come gli altri monitor)
|
||||||
data/chain_collect/
|
data/chain_collect/
|
||||||
|
data/vrp_f_watch/
|
||||||
|
|
||||||
# log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale)
|
# log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale)
|
||||||
data/live/
|
data/live/
|
||||||
|
|||||||
@@ -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.**
|
📌 **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
|
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.
|
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
|
❌ **"Il vincitore sta dove il modello sbaglia di piu'" — MIO ARGOMENTO, MISURATO E REFUTATO
|
||||||
−0.05), come 5/10 della top-10, cioe' esattamente la gamba che il 30/07 ha MISURATO
|
LO STESSO GIORNO** (5° filone, sotto): l'ala a δ−0.05 e' molto piu' mispriced in RELATIVO
|
||||||
sottoprezzata (~2.3x, IV−DVOL +7.5pp). Sospetto motivato, **non dimostrazione**: il mediano non
|
(f_long **5.85** contro 2.23) ma pesa il **3.2%** del premio corto invece del **18.4%**, quindi
|
||||||
separa −0.10 da −0.05 (1.37 vs 1.37), si separa la coda alta. Ma `f` **non e' misurato** fuori
|
sul credito NETTO l'effetto e' MINORE → f_net **0.852** contro **0.718**: il candidato ha un f
|
||||||
dalla struttura canonica, e la sensibilita' a f uniforme (cella scelta 0.84 vs canonico 0.47 a
|
**migliore**, non peggiore. Meccanismo giusto, conclusione rovesciata. **La decisione (nessun
|
||||||
f=0.73) **e' la lente sbagliata** per una struttura il cui errore e' concentrato in una gamba.
|
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** /
|
⚠️ **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
|
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
|
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
|
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
|
difetto di modello e' gia' misurato, **il sospetto vale piu' del numero** — e si dice che e' un
|
||||||
sospetto.
|
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):
|
💰 **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**.
|
**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
|
Il sleeve 50/50 **diventa ETH-only**, e **al peso di book (12% = $360) sono 0 lotti** — servirebbe
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -31,6 +31,10 @@ mkdir -p logs
|
|||||||
# Schema fee Deribit (nuovo dal 2026-08-01, annunciato senza numeri): legge il tier BASE
|
# 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).
|
# 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
|
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
|
# 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.
|
# 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.
|
# Va DOPO tutti i monitor, altrimenti misura lo stato di ieri.
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user