crolli: il libro perde nel giorno e chiude > 0 in 8/8 finestre dal 2022 (SKH01 short); crollo catturato a vol bassa, XRP diluisce come SOL
Quattro misure (r0909_*), nessun cambio a libro/pesi/config: - libro nei crolli: −0,48%/g nei 160 giorni ≤ −5%, positivo per finestra dal 2022 per la gamba short di SKH01 (108% dei guadagni); beta 0,0769 riprodotto (§46); il peso di SKH01 resta chiuso dal gate (in-sample). - crollo catturato 1-5/06/2026 a quote vere: f_net 0,74 = rally; put δ−0,10 1,92× il modello; a vol bassa e fuori dal gate di VRP01 → §3 non si riapre. - XRP terza gamba (harness r0822_sol_leg): hold-out −0,169 in 0/24, un anno buono. - universo Deribit: liquidi solo BTC/ETH/XRP/SOL; XS01 13/19; BTCDVOL future non negoziabile; PAXG non misurato. Revisione fable: due conclusioni smontate (MTM 2,6× era un artefatto di quote; «8/12 guadagna» era l'ordine delle classi). Debito §5.18: cblib.spot_series + asof guarda un'ora avanti (feed 1h etichettato all'apertura). Test Opus: 92 nuovi, suite 1008/1008; due difetti del verdetto XRP corretti. Docs: diario, RESULTS §74-77, CLAUDE.md, memoria 20/50, README; journal 07-08/09. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zJjHUS7mf4pnGE6pq9RTt
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
"""r0909 — IL CROLLO CATTURATO: il `f` di stress delle opzioni, misurato su quote VERE.
|
||||
|
||||
PERCHE' ESISTE. La decisione vincolante del 19/06 ("niente short-vol da modello in deploy", e la
|
||||
gemella "niente long-vol scalp") dichiara UNA condizione che la riapre: *un `f` di stress reale
|
||||
misurato su un crash catturato*. COLLAR01 (§71) e PAVIMENTO-LEVA (§72) chiudono con la stessa
|
||||
condizione. Il 22/08 (§11) il campione con quote vere era "la strategia mentre STA FERMA":
|
||||
19 settimane di rally, "l'unico episodio ad alta vol del campione e' un rally, non un crash".
|
||||
|
||||
FATTO NUOVO (2026-09-09): l'archivio ereditato `bite_archive.parquet` copre con bid E ask
|
||||
il crollo del 1-5 giugno 2026 — BTC −25% e ETH −30% in 5 giorni (peggior giorno ETH −10,6%
|
||||
il 05/06), su UNA scadenza (19 giugno, 14-21 DTE agli ingressi). Il campione del 22/08
|
||||
partiva DOPO, dal fondo. Nessuno lo aveva usato: e' il crash catturato che la regola aspettava.
|
||||
|
||||
COSA MISURA (a ogni ora d'ingresso dal 30/05 al 04/06, BTC e ETH — l'ora e' un'ancora, si
|
||||
riportano mediana e banda, mai il massimo):
|
||||
A. la geometria di VRP01 (vendi put δ−0,28, compra put δ−0,10, stessa scadenza) a quote REALI:
|
||||
credito al bid/ask, mark-to-market ora per ora dalle standing quotes, regolamento a scadenza
|
||||
sullo spot certificato. Contro lo STESSO trade prezzato dal modello del sleeve (BS su DVOL,
|
||||
`sleeves._bs_put`). f_stress = perdita reale / perdita modellata, a scadenza e al peggior MTM.
|
||||
B. lo specchio LONG (compra la put δ−0,10 all'ask; e il debit spread −0,28/−0,10): quanto del
|
||||
payoff modellato si incassa davvero al bid — a scadenza (uscita passiva) e al miglior punto
|
||||
del percorso (limite superiore con look-ahead, dichiarato).
|
||||
C. il contesto: DVOL, RV realizzata, IV-rank causale (il gate di VRP01 era aperto o chiuso?),
|
||||
e cosa dice il modello del sleeve (`_vrp_weekly_asset`) per le settimane di giugno.
|
||||
|
||||
LIMITI DICHIARATI PRIMA DEI NUMERI: n = 1 crollo, 1 scadenza per asset, tenor 14-21 giorni
|
||||
(VRP01 e' a 7), 2-5 mila righe/giorno (40-125 strumenti/ora), `underlying_price` assente
|
||||
nell'archivio (spot dal feed 1h certificato, D1), regolamento approssimato allo spot dell'ora
|
||||
di scadenza (Deribit usa una TWAP di 30 min). La famiglia dell'archivio e' INVERSE (premi in
|
||||
BTC/ETH; `ETH-19JUN26-1900-P`): VRP01 opererebbe sulla USDC-lineare, che il conto puo' marginare
|
||||
e che NON e' nell'archivio (§11) — il f qui misurato e' della famiglia che il conto non negozia.
|
||||
Un episodio e' un episodio: da' la TAGLIA di f nello stress, non la sua distribuzione.
|
||||
|
||||
DUE CORREZIONI DELLA REVISIONE (fable, 09/09), applicate:
|
||||
* il feed 1h e' etichettato all'APERTURA (la barra 20:00 chiude alle 21:00): `S.asof(ts)`
|
||||
guardava un'ora AVANTI. Qui lo spot e' `spot_causale` = chiusura dell'ultima barra chiusa a ts.
|
||||
⚠️ `cblib.spot_series` ha lo stesso difetto (e quindi il f 0,714 di §11): registrato, non toccato.
|
||||
* il costo di chiusura `(ask corta − bid lunga)·spot` puo' superare la LARGHEZZA dello spread
|
||||
(una quota da 158% di spread relativo a T→0): uno spread a rischio definito non costa mai piu'
|
||||
della larghezza per chiudere (si lascia regolare). Il MTM e' tagliato alla larghezza e le ore
|
||||
tagliate sono contate. Senza il taglio il "peggior MTM" era un artefatto di quote (2,6×).
|
||||
* `f_exp` e' TAUTOLOGICO quando entrambe le gambe finiscono ITM (payoff = larghezza per reale
|
||||
e modello): si riduce a una funzione di f_net. Si stampa la quota di trade in cui succede.
|
||||
|
||||
Nessun file di produzione toccato. Nessun ordine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
import cblib as CB # noqa: E402
|
||||
from src.portfolio.sleeves import VRP_CFG, _vrp_weekly_asset # noqa: E402
|
||||
|
||||
ARCHIVIO = ROOT / "data" / "raw" / "cb_chain" / "bite_archive.parquet"
|
||||
ASSETS = ("BTC", "ETH")
|
||||
EXP = pd.Timestamp("2026-06-19 08:00", tz="UTC")
|
||||
INGRESSI = (pd.Timestamp("2026-05-30", tz="UTC"), pd.Timestamp("2026-06-05", tz="UTC")) # [da, a)
|
||||
CROLLO = (pd.Timestamp("2026-06-01", tz="UTC"), pd.Timestamp("2026-06-06", tz="UTC"))
|
||||
PRE = pd.Timestamp("2026-06-02", tz="UTC") # ingressi "prima del crollo" = prima del primo giorno a −7%/−8% (il 01/06 BTC fece −3,1%: e' DENTRO "pre", dichiarato)
|
||||
TOL_D = (0.08, 0.06) # geometria VRP01: |δ corta + 0,28| ≤ 0,08 e |δ lunga + 0,10| ≤ 0,06, altrimenti "fuori geometria"
|
||||
SHORT_D, LONG_D = VRP_CFG["short_delta"], VRP_CFG["long_delta"] # P1: dal sleeve, non ridichiarati
|
||||
FEE_FRAC = VRP_CFG["fee_frac"]
|
||||
# listino Deribit opzioni (stesse costanti di r0822_vrp_real_quotes, verificate al venue il 22/08)
|
||||
FEE_UNDERLYING, FEE_PREMIUM_CAP, FEE_DELIVERY = 0.0003, 0.125, 0.00015
|
||||
|
||||
|
||||
def leg_fee(prem_base: float, spot: float) -> float:
|
||||
return min(FEE_UNDERLYING * spot, FEE_PREMIUM_CAP * prem_base * spot)
|
||||
|
||||
|
||||
def spot_causale(S: pd.Series) -> pd.Series:
|
||||
"""Il feed 1h e' etichettato all'apertura: la chiusura della barra T e' nota a T+1h. Spostando
|
||||
l'indice di +1h, `asof(ts)` restituisce l'ultima chiusura CONOSCIUTA a ts, mai una futura."""
|
||||
out = S.copy(); out.index = out.index + pd.Timedelta(hours=1)
|
||||
return out
|
||||
|
||||
|
||||
def carica() -> pd.DataFrame:
|
||||
a = pd.read_parquet(ARCHIVIO)
|
||||
a["ts"] = pd.to_datetime(a["ts"], utc=True)
|
||||
a["exp"] = pd.to_datetime(a["exp"], utc=True)
|
||||
a = a[(a["option_type"] == "P") & (a["exp"] == EXP) & (a["ts"] >= INGRESSI[0]) & (a["ts"] <= EXP)]
|
||||
a = a.drop_duplicates(subset=["ts", "instrument_name"], keep="last")
|
||||
a["dte"] = (a["exp"] - a["ts"]).dt.total_seconds() / 86400.0
|
||||
return a.reset_index(drop=True)
|
||||
|
||||
|
||||
def bs_spread(S: float, Ks: float, Kl: float, T: float, sig: float) -> float:
|
||||
return CB.bs_put(S, Ks, T, sig) - CB.bs_put(S, Kl, T, sig)
|
||||
|
||||
|
||||
def bs_put_vec(S, K, T, sig):
|
||||
"""`cblib.bs_put` vettorizzata (stessa formula, r=0); T≤0 → intrinseco."""
|
||||
from scipy.stats import norm
|
||||
S = np.asarray(S, float); T = np.asarray(T, float); sig = np.asarray(sig, float)
|
||||
out = np.maximum(K - S, 0.0)
|
||||
ok = (T > 0) & (sig > 0)
|
||||
d1 = (np.log(S[ok] / K) + 0.5 * sig[ok] ** 2 * T[ok]) / (sig[ok] * np.sqrt(T[ok]))
|
||||
out[ok] = K * norm.cdf(-(d1 - sig[ok] * np.sqrt(T[ok]))) - S[ok] * norm.cdf(-d1)
|
||||
return out
|
||||
|
||||
|
||||
class Quote:
|
||||
"""Tabella ora × strumento (bid, ask, iv) costruita UNA volta per asset, con spot e DVOL per ora."""
|
||||
|
||||
def __init__(self, seg: pd.DataFrame, S: pd.Series, V: pd.Series):
|
||||
q = seg.dropna(subset=["bid", "ask"]); q = q[(q["bid"] > 0) & (q["ask"] > 0)]
|
||||
self.bid = q.pivot_table(index="ts", columns="instrument_name", values="bid")
|
||||
self.ask = q.pivot_table(index="ts", columns="instrument_name", values="ask")
|
||||
self.iv = q.pivot_table(index="ts", columns="instrument_name", values="iv")
|
||||
idx = self.bid.index
|
||||
self.spot = pd.Series([float(S.asof(t)) for t in idx], index=idx)
|
||||
self.dvol = pd.Series([float(V.asof(t)) for t in idx], index=idx) / 100.0
|
||||
self.T = pd.Series(np.maximum((EXP - idx).total_seconds().values / 86400.0, 0.0) / 365.25, index=idx)
|
||||
|
||||
|
||||
def percorso(Q: Quote, dopo: pd.Timestamp, inst_s: str, inst_l: str, Ks: float, Kl: float) -> pd.DataFrame:
|
||||
"""Ora per ora dopo l'ingresso: costo REALE di chiudere lo short spread (ricompra la corta
|
||||
all'ask, rivende la lunga al bid), valore REALE di liquidare il debit spread (vende la corta
|
||||
al bid, ricompra la lunga all'ask) e la put lunga (bid); piu' il valore MODELLATO (BS a DVOL_t)."""
|
||||
if inst_s not in Q.bid.columns or inst_l not in Q.bid.columns:
|
||||
return pd.DataFrame()
|
||||
m = (Q.bid.index > dopo) & Q.bid[inst_s].notna() & Q.bid[inst_l].notna()
|
||||
if not m.any():
|
||||
return pd.DataFrame()
|
||||
idx = Q.bid.index[m]
|
||||
sp = Q.spot.loc[idx].values; dv = Q.dvol.loc[idx].values; T = Q.T.loc[idx].values
|
||||
bs_, as_ = Q.bid.loc[idx, inst_s].values, Q.ask.loc[idx, inst_s].values
|
||||
bl, al = Q.bid.loc[idx, inst_l].values, Q.ask.loc[idx, inst_l].values
|
||||
mod_s, mod_l = bs_put_vec(sp, Ks, T, dv), bs_put_vec(sp, Kl, T, dv)
|
||||
grezzo = (as_ - bl) * sp
|
||||
return pd.DataFrame(dict(ts=idx, spot=sp, dvol=dv,
|
||||
chiudi_short=np.minimum(grezzo, Ks - Kl), chiudi_grezzo=grezzo,
|
||||
liquida_long=np.clip((bs_ - al) * sp, 0.0, Ks - Kl), liquida_put=bl * sp,
|
||||
mod_spread=mod_s - mod_l, mod_put=mod_l,
|
||||
iv_s=Q.iv.loc[idx, inst_s].values, iv_l=Q.iv.loc[idx, inst_l].values))
|
||||
|
||||
|
||||
def trade(asset: str, ts: pd.Timestamp, snap: pd.DataFrame, Q: Quote,
|
||||
S: pd.Series, V: pd.Series) -> dict | None:
|
||||
legs = CB.pick_legs(snap, SHORT_D, LONG_D)
|
||||
if legs is None:
|
||||
return None
|
||||
spot = float(S.asof(ts)); dvol = float(V.asof(ts)); T = (EXP - ts).total_seconds() / 86400.0 / 365.25
|
||||
hist = V[V.index < ts]; ivr = float((hist < dvol).mean()) if len(hist) else np.nan
|
||||
Ks, Kl = legs["k_short"], legs["k_long"]
|
||||
# --- A. short spread (VRP01) — reale al bid/ask, modello a DVOL
|
||||
cred = (legs["bid_short"] - legs["ask_long"]) * spot
|
||||
if cred <= 0:
|
||||
return None
|
||||
cred_mod = bs_spread(spot, Ks, Kl, T, dvol / 100.0)
|
||||
ST = float(S.asof(EXP))
|
||||
payoff = max(Ks - ST, 0.0) - max(Kl - ST, 0.0)
|
||||
fee_in = leg_fee(legs["bid_short"], spot) + leg_fee(legs["ask_long"], spot)
|
||||
fee_out = sum(min(FEE_DELIVERY * ST, FEE_PREMIUM_CAP * max(K - ST, 0.0)) for K in (Ks, Kl) if K > ST)
|
||||
pnl_real = cred - payoff - fee_in - fee_out
|
||||
pnl_mod = cred_mod - payoff - FEE_FRAC * cred_mod
|
||||
p = percorso(Q, ts, legs["inst_short"], legs["inst_long"], Ks, Kl)
|
||||
if p.empty:
|
||||
return None
|
||||
mtm_real = cred - p["chiudi_short"] # P&L aperto se si chiude ORA a quote vere (tagliato alla larghezza)
|
||||
mtm_mod = cred_mod - p["mod_spread"] # lo stesso trade riprezzato a BS/DVOL_t (costruzione nostra: il sleeve tiene a scadenza)
|
||||
i_min = int(mtm_real.idxmin())
|
||||
n_tagli = int((p["chiudi_grezzo"] > Ks - Kl).sum())
|
||||
# --- B. specchio long: put δ−0,10 comprata all'ask; debit spread −0,28/−0,10
|
||||
costo_put = legs["ask_long"] * spot
|
||||
mod_put = CB.bs_put(spot, Kl, T, dvol / 100.0)
|
||||
costo_deb = (legs["ask_short"] - legs["bid_long"]) * spot
|
||||
mod_deb = cred_mod
|
||||
pay_put_exp = max(Kl - ST, 0.0)
|
||||
pay_deb_exp = payoff
|
||||
# il picco del percorso e' un limite SUPERIORE con look-ahead: si dichiara, non si vende
|
||||
i_put = int(p["liquida_put"].idxmax()); i_deb = int(p["liquida_long"].idxmax())
|
||||
risk = (Ks - Kl) - cred
|
||||
in_geom = abs(legs["d_short"] - SHORT_D) <= TOL_D[0] and abs(legs["d_long"] - LONG_D) <= TOL_D[1]
|
||||
return dict(
|
||||
in_geom=bool(in_geom),
|
||||
asset=asset, ts=ts, dte=(EXP - ts).total_seconds() / 86400.0, spot=spot, dvol=dvol, ivrank=ivr,
|
||||
k_short=Ks, k_long=Kl, d_short=legs["d_short"], d_long=legs["d_long"], width=Ks - Kl,
|
||||
iv_s0=legs["iv_short"], iv_l0=legs["iv_long"], ST=ST, und_ret=ST / spot - 1.0,
|
||||
cred_real=cred, cred_mod=cred_mod, f_net=cred / cred_mod if cred_mod > 0 else np.nan,
|
||||
risk=risk, pnl_real=pnl_real, pnl_mod=pnl_mod,
|
||||
perdita_su_risk=-pnl_real / risk, perdita_su_coll=-pnl_real / Ks,
|
||||
f_exp=(-pnl_real / -pnl_mod) if pnl_mod < 0 and pnl_real < 0 else np.nan,
|
||||
mtm_real_min=float(mtm_real.min()), mtm_mod_min=float(mtm_mod.min()),
|
||||
mtm_min_ts=p["ts"].iloc[i_min], n_tagli=n_tagli, n_ore=int(len(p)),
|
||||
iv_s_at_min=float(p["iv_s"].iloc[i_min]), iv_l_at_min=float(p["iv_l"].iloc[i_min]), dvol_at_min=float(p["dvol"].iloc[i_min]) * 100,
|
||||
itm_pieno=bool(ST <= Kl), # entrambe le gambe ITM: payoff = larghezza, f_exp si riduce a f_net
|
||||
f_mtm=(mtm_real.min() / mtm_mod.min()) if mtm_mod.min() < 0 and mtm_real.min() < 0 else np.nan,
|
||||
spot_min=float(p["spot"].min()), iv_s_max=float(p["iv_s"].max()), iv_l_max=float(p["iv_l"].max()),
|
||||
n_ore_percorso=int(len(p)),
|
||||
# long
|
||||
costo_put=costo_put, mod_put=mod_put, f_put_in=costo_put / mod_put if mod_put > 0 else np.nan,
|
||||
put_exp=pay_put_exp - costo_put, put_exp_mod=pay_put_exp - mod_put,
|
||||
put_best=float(p["liquida_put"].iloc[i_put]) - costo_put, put_best_mod=float(p["mod_put"].iloc[i_put]) - mod_put,
|
||||
put_best_ts=p["ts"].iloc[i_put], bleed_put=costo_put / spot,
|
||||
costo_deb=costo_deb, mod_deb=mod_deb,
|
||||
deb_exp=pay_deb_exp - costo_deb, deb_exp_mod=pay_deb_exp - mod_deb,
|
||||
deb_best=float(p["liquida_long"].iloc[i_deb]) - costo_deb, deb_best_mod=float(p["mod_spread"].iloc[i_deb]) - mod_deb,
|
||||
)
|
||||
|
||||
|
||||
def misura() -> dict:
|
||||
a = carica()
|
||||
out = {"trades": {}, "contesto": {}, "sleeve": {}}
|
||||
for asset in ASSETS:
|
||||
seg = a[a["asset"] == asset]
|
||||
S, V = spot_causale(CB.spot_series(asset)), CB.dvol_series(asset)
|
||||
Q = Quote(seg, S, V)
|
||||
rows = []
|
||||
for ts in sorted(seg["ts"].unique()):
|
||||
ts = pd.Timestamp(ts)
|
||||
if not (INGRESSI[0] <= ts < INGRESSI[1]):
|
||||
continue
|
||||
t = trade(asset, ts, seg[seg["ts"] == ts], Q, S, V)
|
||||
if t:
|
||||
rows.append(t)
|
||||
out["trades"][asset] = pd.DataFrame(rows)
|
||||
# contesto: DVOL, RV5 e RV1 sul crollo
|
||||
px = S[(S.index >= pd.Timestamp("2026-05-20", tz="UTC")) & (S.index <= EXP)]
|
||||
pd_ = px.resample("1D").last().dropna() # chiusura del giorno = ultima chiusura nota entro le 24:00
|
||||
hist_all = V[V.index < CROLLO[0]]
|
||||
lr = np.log(pd_).diff()
|
||||
rv5 = lr.rolling(5).std() * np.sqrt(365.25) * 100
|
||||
dv = V[(V.index >= pd.Timestamp("2026-05-20", tz="UTC")) & (V.index <= EXP)]
|
||||
hist = V[V.index < CROLLO[0]]
|
||||
out["contesto"][asset] = dict(
|
||||
spot=pd_, ret=lr, rv5=rv5, dvol=dv,
|
||||
dvol_max_crollo=float(dv[(dv.index >= CROLLO[0]) & (dv.index <= CROLLO[1])].max()),
|
||||
dvol_pre=float(V.asof(CROLLO[0])),
|
||||
pctl_picco=float((hist_all < V[(V.index >= CROLLO[0]) & (V.index <= CROLLO[1])].max()).mean()),
|
||||
ivr_pre=float((hist < V.asof(CROLLO[0])).mean()),
|
||||
rv5_max=float(rv5[(rv5.index >= CROLLO[0]) & (rv5.index <= CROLLO[1] + pd.Timedelta(days=3))].max()),
|
||||
caduta=float(pd_[CROLLO[0]:CROLLO[1]].min() / pd_[:CROLLO[0]].iloc[-1] - 1.0),
|
||||
)
|
||||
wk = _vrp_weekly_asset(asset)
|
||||
out["sleeve"][asset] = wk[(wk.index >= pd.Timestamp("2026-05-10", tz="UTC")) & (wk.index <= pd.Timestamp("2026-07-05", tz="UTC"))]
|
||||
return out
|
||||
|
||||
|
||||
def sintesi(m: dict) -> dict:
|
||||
s = {}
|
||||
for asset in ASSETS:
|
||||
t = m["trades"][asset]
|
||||
if t.empty:
|
||||
s[asset] = None; continue
|
||||
n_fuori = int((~t["in_geom"]).sum())
|
||||
t = t[t["in_geom"]] # la griglia di strike dell'archivio e' rada: fuori geometria = altra struttura
|
||||
if t.empty:
|
||||
s[asset] = dict(n=0, n_fuori=n_fuori); continue
|
||||
pre = t[t["ts"] < PRE] # ingressi PRIMA del crollo: il caso della regola
|
||||
dur = t[(t["ts"] >= PRE) & (t["ts"] < CROLLO[1] - pd.Timedelta(days=1))] # DURANTE (02-04/06): lente secondaria
|
||||
med = lambda c, d=t: float(np.nanmedian(d[c])) if len(d) else np.nan
|
||||
b = lambda c, d=t: (float(np.nanpercentile(d[c], 10)), float(np.nanpercentile(d[c], 90))) if len(d) else (np.nan, np.nan)
|
||||
s[asset] = dict(
|
||||
n=int(len(t)), n_pre=int(len(pre)), n_fuori=n_fuori,
|
||||
d_short=med("d_short", pre), d_long=med("d_long", pre), width=med("width", pre), ST=float(t["ST"].iloc[0]),
|
||||
spot_pre=med("spot", pre),
|
||||
dte=(float(t["dte"].min()), float(t["dte"].max())),
|
||||
f_net=med("f_net"), f_net_b=b("f_net"),
|
||||
ivrank=(float(t["ivrank"].min()), float(t["ivrank"].max())),
|
||||
gate_aperto=float((t["ivrank"] >= VRP_CFG["gate_ivr"]).mean()),
|
||||
perdita_su_risk=med("perdita_su_risk", pre), perdita_su_risk_b=b("perdita_su_risk", pre),
|
||||
perdita_su_coll=med("perdita_su_coll", pre),
|
||||
pnl_real=med("pnl_real", pre), pnl_mod=med("pnl_mod", pre),
|
||||
f_exp=med("f_exp", pre), f_exp_b=b("f_exp", pre), n_f_exp=int(pre["f_exp"].notna().sum()),
|
||||
f_mtm=med("f_mtm", pre), f_mtm_b=b("f_mtm", pre),
|
||||
mtm_real_min=med("mtm_real_min", pre), mtm_mod_min=med("mtm_mod_min", pre),
|
||||
n_tagli=int(pre["n_tagli"].sum()) if len(pre) else 0, n_ore=int(pre["n_ore"].sum()) if len(pre) else 0,
|
||||
iv_s_at_min=med("iv_s_at_min", pre), dvol_at_min=med("dvol_at_min", pre),
|
||||
q_itm_pieno=float(pre["itm_pieno"].mean()) if len(pre) else np.nan,
|
||||
n_strutture=int(pre.groupby(["k_short", "k_long"]).ngroups) if len(pre) else 0,
|
||||
f_put_in=med("f_put_in", pre), bleed_put=med("bleed_put", pre),
|
||||
put_exp=med("put_exp", pre), put_exp_mod=med("put_exp_mod", pre),
|
||||
put_best=med("put_best", pre), put_best_mod=med("put_best_mod", pre),
|
||||
f_put_exp=med_ratio(pre, "put_exp", "put_exp_mod"), f_put_best=med_ratio(pre, "put_best", "put_best_mod"),
|
||||
deb_exp=med("deb_exp", pre), deb_exp_mod=med("deb_exp_mod", pre),
|
||||
deb_best=med("deb_best", pre), deb_best_mod=med("deb_best_mod", pre),
|
||||
f_deb_exp=med_ratio(pre, "deb_exp", "deb_exp_mod"), f_deb_best=med_ratio(pre, "deb_best", "deb_best_mod"),
|
||||
iv_s0=med("iv_s0", pre), iv_s_max=med("iv_s_max", pre), iv_l0=med("iv_l0", pre), iv_l_max=med("iv_l_max", pre),
|
||||
und_ret=med("und_ret", pre), spot_min=med("spot_min", pre),
|
||||
durante=dict(n=int(len(dur)), f_exp=med("f_exp", dur), f_mtm=med("f_mtm", dur),
|
||||
perdita_su_risk=med("perdita_su_risk", dur), f_net=med("f_net", dur),
|
||||
q_perde=float((dur["pnl_real"] < 0).mean()) if len(dur) else np.nan,
|
||||
pnl_p10=float(np.nanpercentile(dur["pnl_real"], 10)) if len(dur) else np.nan,
|
||||
pnl_p90=float(np.nanpercentile(dur["pnl_real"], 90)) if len(dur) else np.nan,
|
||||
d_short=med("d_short", dur), d_long=med("d_long", dur), width=med("width", dur),
|
||||
pnl_real=med("pnl_real", dur), pnl_mod=med("pnl_mod", dur)),
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def med_ratio(d: pd.DataFrame, a: str, b: str, minimo: float = 5.0) -> float:
|
||||
"""Mediana del rapporto reale/modello sulle righe in cui il modello vale almeno `minimo` $
|
||||
(un payoff modellato ≈ 0 non da' un rapporto, da' un'altra domanda: si stampano i dollari)."""
|
||||
x = d[(d[b] >= minimo)]
|
||||
return float(np.nanmedian(x[a] / x[b])) if len(x) else np.nan
|
||||
|
||||
|
||||
def verdetto(s: dict) -> str:
|
||||
parti = []
|
||||
for asset in ASSETS:
|
||||
r = s.get(asset)
|
||||
if not r or r.get("n", 0) == 0:
|
||||
parti.append(f"{asset}: NON misurabile — {r.get('n_fuori', 0) if r else 0} snapshot tutti FUORI geometria (griglia di strike rada)"); continue
|
||||
if not np.isfinite(r["f_exp"]):
|
||||
parti.append(f"{asset}: nessun ingresso pre-crollo in geometria (solo DURANTE: f_net {r['durante']['f_net']:.2f}, perde il {r['durante']['q_perde']*100:.0f}%)"); continue
|
||||
parti.append(f"{asset}: f_net {r['f_net']:.2f} (22/08 in rally 0,714); a scadenza f {r['f_exp']:.2f} ma TAUTOLOGICO nel "
|
||||
f"{r['q_itm_pieno']*100:.0f}% dei trade (entrambe le gambe ITM ⇒ dipende solo da f_net; {r['n_strutture']} strutture, non {r['n_pre']} osservazioni); "
|
||||
f"peggior MTM tagliato alla larghezza f {r['f_mtm']:.2f} (quote oltre la larghezza nel {r['n_tagli']/max(1, r['n_ore'])*100:.1f}% delle ore); "
|
||||
f"gate IV-rank {'APERTO' if r['gate_aperto'] > 0.5 else 'CHIUSO'} nel {r['gate_aperto']*100:.0f}% delle ore")
|
||||
esito = ("IL CROLLO E' CATTURATO MA A VOL BASSA E FUORI DAL GATE DI VRP01: il f di stress che la regola del 19/06 aspettava "
|
||||
"NON e' misurato nella sostanza — cio' che e' misurato e' che il rapporto di credito f_net non cambia col regime")
|
||||
return esito + " — " + "; ".join(parti) + " — n = 1 crollo, 1 scadenza per asset, famiglia INVERSE"
|
||||
|
||||
|
||||
def pct(x): return f"{x*100:+.1f}%"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 104)
|
||||
print(" r0909 — IL CROLLO CATTURATO (1-5 giugno 2026): f di stress su quote VERE, scadenza 19/06")
|
||||
print("=" * 104)
|
||||
m = misura(); s = sintesi(m)
|
||||
for asset in ASSETS:
|
||||
c = m["contesto"][asset]; r = s[asset]
|
||||
print(f"\n [{asset}] CONTESTO — caduta {pct(c['caduta'])} dal 31/05 al minimo di 1-5/06; "
|
||||
f"DVOL pre {c['dvol_pre']:.1f} (IV-rank causale {c['ivr_pre']:.2f}) → max {c['dvol_max_crollo']:.1f} "
|
||||
f"(= {c['pctl_picco']*100:.0f}° percentile storico: il PICCO del crollo sta sotto la mediana); RV5 max {c['rv5_max']:.0f}")
|
||||
print(" giorno spot ret DVOL RV5")
|
||||
for d in pd.date_range("2026-05-28", "2026-06-08", tz="UTC"):
|
||||
if d in c["spot"].index:
|
||||
dv = c["dvol"].asof(d)
|
||||
print(f" {d.date()} {c['spot'][d]:>8.0f} {pct(c['ret'].get(d, np.nan)):>7} {dv:>5.1f} {c['rv5'].get(d, np.nan):>5.0f}")
|
||||
if r is None or r.get("n", 0) == 0:
|
||||
print(f" nessun trade in geometria VRP01: {r.get('n_fuori', 0) if r else 0} snapshot fuori geometria "
|
||||
f"(la griglia di strike dell'archivio in quei giorni non ha le δ−0,28/−0,10)"); continue
|
||||
t = m["trades"][asset]; t = t[t["in_geom"]]
|
||||
print(f"\n [{asset}] A. VRP01 a quote vere — {r['n']} snapshot d'ingresso in geometria ({r['n_pre']} prima del {PRE.date()}; "
|
||||
f"{r['n_fuori']} scartati fuori geometria), DTE {r['dte'][0]:.0f}-{r['dte'][1]:.0f}, IV-rank {r['ivrank'][0]:.2f}-{r['ivrank'][1]:.2f}")
|
||||
print(f" geometria mediana pre-crollo: δ corta {r['d_short']:+.2f} / δ lunga {r['d_long']:+.2f}, larghezza {r['width']:.0f}, "
|
||||
f"spot d'ingresso {r['spot_pre']:.0f}, regolamento {EXP.date()} a {r['ST']:.0f}")
|
||||
print(f" credito reale/modello f_net {r['f_net']:.2f} [{r['f_net_b'][0]:.2f}, {r['f_net_b'][1]:.2f}] "
|
||||
f"(22/08: 0,714 in rally)")
|
||||
print(f" ingressi PRE-crollo, mediana: P&L reale ${r['pnl_real']:+.0f} vs modello ${r['pnl_mod']:+.0f} per unita' "
|
||||
f"→ perdita {r['perdita_su_risk']*100:.0f}% del rischio [{r['perdita_su_risk_b'][0]*100:.0f}, {r['perdita_su_risk_b'][1]*100:.0f}] "
|
||||
f"(100% = perdita MASSIMA dello spread; oltre 100 sono le fee), "
|
||||
f"{r['perdita_su_coll']*100:.1f}% del collaterale")
|
||||
print(f" f a SCADENZA {r['f_exp']:.2f} [{r['f_exp_b'][0]:.2f}, {r['f_exp_b'][1]:.2f}] su {r['n_f_exp']} snapshot = {r['n_strutture']} strutture "
|
||||
f"— TAUTOLOGICO nel {r['q_itm_pieno']*100:.0f}% (entrambe le gambe ITM: payoff = larghezza per reale e modello, resta solo f_net)")
|
||||
print(f" peggior MTM (chiusura tagliata alla larghezza; quote oltre la larghezza nel {r['n_tagli']/max(1, r['n_ore'])*100:.1f}% delle ore-percorso): "
|
||||
f"f {r['f_mtm']:.2f} [{r['f_mtm_b'][0]:.2f}, {r['f_mtm_b'][1]:.2f}] (reale ${r['mtm_real_min']:+.0f} / modello ${r['mtm_mod_min']:+.0f}); "
|
||||
f"a quell'ora IV corta {r['iv_s_at_min']:.0f} vs DVOL {r['dvol_at_min']:.0f}")
|
||||
print(f" IV gamba corta {r['iv_s0']:.0f} → max {r['iv_s_max']:.0f} (il max e' a T→0, non nel crollo); gamba lunga {r['iv_l0']:.0f} → max {r['iv_l_max']:.0f}; "
|
||||
f"sottostante a scadenza {pct(r['und_ret'])}, minimo {r['spot_min']:.0f}")
|
||||
du = r["durante"]
|
||||
print(f" lente secondaria — ingressi DURANTE il crollo (02-04/06, {du['n']} snapshot): δ {du['d_short']:+.2f}/{du['d_long']:+.2f}, "
|
||||
f"larghezza {du['width']:.0f}, f_net {du['f_net']:.2f}, P&L reale ${du['pnl_real']:+.0f} vs modello ${du['pnl_mod']:+.0f}, "
|
||||
f"perde nel {du['q_perde']*100:.0f}% (p10 ${du['pnl_p10']:+.0f} / p90 ${du['pnl_p90']:+.0f}: bimodale, la mediana nasconde le perdite), "
|
||||
f"f scadenza {du['f_exp']:.2f} / peggior MTM {du['f_mtm']:.2f}")
|
||||
print(f"\n [{asset}] B. LO SPECCHIO LONG (ingressi PRE-crollo, mediana per unita')")
|
||||
print(f" put δ−0,10 all'ask: costo reale/modello {r['f_put_in']:.2f}, bleed {r['bleed_put']*100:.2f}% dello spot")
|
||||
print(f" a scadenza: reale ${r['put_exp']:+.0f} vs modello ${r['put_exp_mod']:+.0f} → f_payoff {r['f_put_exp']:.2f}")
|
||||
print(f" al PICCO del percorso (look-ahead, limite sup.): reale ${r['put_best']:+.0f} vs modello ${r['put_best_mod']:+.0f} → f {r['f_put_best']:.2f}")
|
||||
print(f" debit spread −0,28/−0,10: a scadenza reale ${r['deb_exp']:+.0f} vs modello ${r['deb_exp_mod']:+.0f} → f {r['f_deb_exp']:.2f}; "
|
||||
f"al picco ${r['deb_best']:+.0f} vs ${r['deb_best_mod']:+.0f} → f {r['f_deb_best']:.2f}")
|
||||
print(f"\n [{asset}] C. IL MODELLO DEL SLEEVE (`_vrp_weekly_asset`, 0.0 = settimana saltata dai gate) — rendimento settimanale su collaterale:")
|
||||
print(" " + " ".join(f"{d.date()} {v*100:+.2f}%" for d, v in m["sleeve"][asset].items()))
|
||||
print(f"\n snapshot d'ingresso (primi 6): " + " | ".join(
|
||||
f"{row.ts.strftime('%m-%d %H')}h K{row.k_short:.0f}/{row.k_long:.0f} cred ${row.cred_real:.0f} pnl ${row.pnl_real:+.0f} mtm ${row.mtm_real_min:+.0f}"
|
||||
for row in t.head(6).itertuples()))
|
||||
print("\n VERDETTO: " + verdetto(s))
|
||||
print("=" * 104)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""r0909 — L'UNIVERSO DERIBIT LETTO OGGI: quali "altre monete" esistono davvero sul venue.
|
||||
|
||||
Domanda dell'operatore: "anche piu' monete, ma sempre in Deribit". Prima di misurare una
|
||||
moneta si legge il venue (N10: la verifica esterna a €0 si fa PRIMA, e sul venue). Questo
|
||||
script interroga l'API PUBBLICA Deribit (nessuna credenziale, nessun ordine) e stampa:
|
||||
1. i perpetual USDC-lineari aperti, con data di listing, lotto minimo in $, volume 24h,
|
||||
spread top-of-book, open interest e funding — ordinati per volume;
|
||||
2. quante delle 19 gambe di XS01 sono quotate (il 23/08 erano 14/19, 11 con ≥1 anno);
|
||||
3. le famiglie di opzioni (inverse BTC/ETH; USDC-lineari per sottostante).
|
||||
Una lettura e' UN istante (come r0822_alt_options): il file salva il JSON grezzo in
|
||||
`data/_cache/` con l'ora della lettura, cosi' il numero stampato ha la sua fonte.
|
||||
|
||||
Cosa NON fa: nessun backtest, nessuna proposta. Dice solo quali strumenti esistono e quanto
|
||||
sono negoziabili oggi — il filtro che decide cosa vale la pena misurare (C6).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from src.portfolio.sleeves import XS_UNIVERSE # noqa: E402 (P1: l'universo si importa)
|
||||
|
||||
API = "https://www.deribit.com/api/v2/public/"
|
||||
CACHE = ROOT / "data" / "_cache" / "r0909_deribit_universo.json"
|
||||
SOGLIA_LIQUIDO_USD = 10e6 # volume 24h da cui una gamba e' "liquida" per un libro da $4-5k
|
||||
|
||||
|
||||
def get(path: str) -> dict:
|
||||
req = urllib.request.Request(API + path, headers={"User-Agent": "PythagorasGoal-research"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.load(r)["result"]
|
||||
|
||||
|
||||
def leggi(usa_cache: bool = True) -> dict:
|
||||
if usa_cache and CACHE.exists():
|
||||
return json.loads(CACHE.read_text())
|
||||
d = dict(letto_a=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
fut=get("get_instruments?currency=USDC&kind=future"),
|
||||
bs=get("get_book_summary_by_currency?currency=USDC&kind=future"),
|
||||
opt_usdc=get("get_instruments?currency=USDC&kind=option"),
|
||||
opt_inv={c: len(get(f"get_instruments?currency={c}&kind=option")) for c in ("BTC", "ETH")})
|
||||
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
CACHE.write_text(json.dumps(d))
|
||||
return d
|
||||
|
||||
|
||||
def tabella(d: dict) -> list[dict]:
|
||||
perps = {i["base_currency"]: i for i in d["fut"] if i["instrument_name"].endswith("PERPETUAL") and i["state"] == "open"}
|
||||
bs = {b["instrument_name"]: b for b in d["bs"]}
|
||||
rows = []
|
||||
for c, i in perps.items():
|
||||
b = bs.get(i["instrument_name"], {})
|
||||
bid, ask, mark = b.get("bid_price"), b.get("ask_price"), b.get("mark_price") or 0.0
|
||||
rows.append(dict(
|
||||
coin=c, listato=time.strftime("%Y-%m-%d", time.gmtime(i["creation_timestamp"] / 1000)),
|
||||
min_amt=i["min_trade_amount"], min_usd=i["min_trade_amount"] * mark,
|
||||
vol_usd=b.get("volume_usd") or 0.0,
|
||||
spread_bps=((ask - bid) / ((ask + bid) / 2) * 1e4) if bid and ask else float("nan"),
|
||||
oi_usd=(b.get("open_interest") or 0.0) * mark, funding_8h=b.get("funding_8h"),
|
||||
xs01=c in XS_UNIVERSE))
|
||||
return sorted(rows, key=lambda r: -r["vol_usd"])
|
||||
|
||||
|
||||
def sintesi(rows: list[dict], d: dict) -> dict:
|
||||
listati = {r["coin"] for r in rows}
|
||||
liquidi = [r["coin"] for r in rows if r["vol_usd"] >= SOGLIA_LIQUIDO_USD]
|
||||
bs = {b["instrument_name"]: b for b in d["bs"]}
|
||||
inattivi = sorted(i["instrument_name"] for i in d["fut"] if i["instrument_name"].endswith("PERPETUAL") and i["state"] != "open")
|
||||
datati = [i for i in d["fut"] if not i["instrument_name"].endswith("PERPETUAL")]
|
||||
dvol_fut = []
|
||||
for i in datati:
|
||||
if "DVOL" not in i["instrument_name"]:
|
||||
continue
|
||||
b = bs.get(i["instrument_name"], {})
|
||||
dvol_fut.append(dict(nome=i["instrument_name"], stato=i["state"], min_amt=i["min_trade_amount"],
|
||||
vol_usd=b.get("volume_usd") or 0.0, oi=b.get("open_interest") or 0.0,
|
||||
bid=b.get("bid_price"), ask=b.get("ask_price")))
|
||||
return dict(
|
||||
n_perp=len(rows), liquidi=liquidi, inattivi=inattivi, n_datati=len(datati), dvol_fut=dvol_fut,
|
||||
xs01_listate=sorted(c for c in XS_UNIVERSE if c in listati),
|
||||
xs01_mancanti=[c for c in XS_UNIVERSE if c not in listati],
|
||||
opt_inv=d["opt_inv"], opt_usdc=Counter(i["base_currency"] for i in d["opt_usdc"]).most_common(),
|
||||
letto_a=d["letto_a"])
|
||||
|
||||
|
||||
def verdetto(s: dict) -> str:
|
||||
return (f"{s['n_perp']} perpetual USDC; LIQUIDI (≥${SOGLIA_LIQUIDO_USD/1e6:.0f}M/g): {', '.join(s['liquidi'])}; "
|
||||
f"XS01 {len(s['xs01_listate'])}/19 quotate, mancano {', '.join(s['xs01_mancanti'])}; "
|
||||
f"opzioni USDC-lineari su {len(s['opt_usdc'])} sottostanti — lettura del {s['letto_a']}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
d = leggi(usa_cache="--fresh" not in sys.argv)
|
||||
rows = tabella(d); s = sintesi(rows, d)
|
||||
print("=" * 110)
|
||||
print(f" r0909 — UNIVERSO DERIBIT (API pubblica, lettura {s['letto_a']}; --fresh per rileggere)")
|
||||
print("=" * 110)
|
||||
print(f"\n [1] PERPETUAL USDC-LINEARI APERTI: {s['n_perp']}")
|
||||
print(f" {'coin':<9}{'listato':>11}{'min $':>8}{'vol 24h M$':>12}{'spread bps':>12}{'OI M$':>8}{'funding 8h':>12} XS01")
|
||||
for r in rows:
|
||||
print(f" {r['coin']:<9}{r['listato']:>11}{r['min_usd']:>8.2f}{r['vol_usd']/1e6:>12.2f}{r['spread_bps']:>12.1f}"
|
||||
f"{r['oi_usd']/1e6:>8.2f}{(r['funding_8h'] or 0)*1e4:>+11.2f}bp {'<--' if r['xs01'] else ''}")
|
||||
print(f" perpetual NON aperti (stato ≠ open): {s['inattivi'] or 'nessuno'}")
|
||||
print(f"\n [1bis] FUTURE USDC DATATI: {s['n_datati']} — fra cui il future sul DVOL, l'unico strumento long-vol DIRETTO del venue "
|
||||
f"(visto dalla revisione del 09/09: non in memoria):")
|
||||
for f in s["dvol_fut"]:
|
||||
spr = (f["ask"] - f["bid"]) / ((f["ask"] + f["bid"]) / 2) * 100 if f["bid"] and f["ask"] else float("nan")
|
||||
print(f" {f['nome']:<28} {f['stato']:<8} min {f['min_amt']} vol24h ${f['vol_usd']/1e6:.2f}M OI {f['oi']:.0f} "
|
||||
f"bid/ask {f['bid']}/{f['ask']} (spread {spr:.0f}%) → non negoziabile a questi numeri; e in contango sanguina come la put")
|
||||
print(f"\n [2] XS01: {len(s['xs01_listate'])}/19 gambe quotate; mancano {s['xs01_mancanti']}")
|
||||
print(f" (23/08, §50: 14/19 quotate, 11 con ≥1 anno; sulle 11 il meccanismo collassa 1,265 → 0,116 — breadth, non capitale)")
|
||||
print(f"\n [3] OPZIONI: inverse {s['opt_inv']}; USDC-lineari per sottostante: {s['opt_usdc']}")
|
||||
print("\n VERDETTO: " + verdetto(s))
|
||||
print("=" * 110)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,338 @@
|
||||
"""r0909 — IL LIBRO NEI CROLLI: revisione dei sistemi adottati, misurata invece che ricordata.
|
||||
|
||||
DOMANDA DELL'OPERATORE (2026-09-09): "trova una soluzione per guadagnare anche nei crolli".
|
||||
Prima di cercare un meccanismo nuovo, questo script misura cosa fanno OGGI i due sleeve live
|
||||
(TP01 long-flat 75% + SKH01 long/short 25%) nei crolli — giorno per giorno e per episodio —
|
||||
e da dove viene, se c'e', il guadagno. La memoria dice tre cose che qui vengono RIPRODOTTE
|
||||
(M23: far riprodurre alla macchina il numero vecchio prima di pubblicarne uno nuovo):
|
||||
(a) beta del libro al sottostante +0,076 (§46) → "non si assicura un libro gia' piatto";
|
||||
(b) nel sinistro TP01 va flat e SKH01 si gira short e guadagna (§26);
|
||||
(c) il peggior giorno del libro: §33 lo trova in uno short squeeze di SKH01 (−15,77% di sleeve,
|
||||
2020-03-13) sulla lente HOURLY — su questa lente (canonica, sleeve giornalieri) NON si
|
||||
riproduce: §33 stesso nota che la canonica da' il 2025-10-10. Qui si stampa e si dichiara.
|
||||
|
||||
LENTE, dichiarata: sleeve di ricerca `sleeves._tp01_returns` / `_skyhook_returns` (ancora
|
||||
canonica 00:00 UTC, fee 0,10% RT, funding NON modellato, come ogni numero del progetto).
|
||||
⚠️ SKH01 e' MARCATO ALL'USCITA del trade (`harness.backtest_signals` muove l'equity alla
|
||||
chiusura): la serie giornaliera e' zero nell'87,7% dei giorni, e uno short aperto in una finestra
|
||||
e chiuso fuori e' accreditato FUORI. E' il motivo per cui la tabella per GIORNO mostra SKH01 ≈ 0
|
||||
e quella per FINESTRA mostra il guadagno. NON e' la serie del libro live (armato dal 2026-06-20): i crolli del 2026 (5 febbraio
|
||||
−14%/−15%, 1-5 giugno −25%/−30%) sono PRIMA dell'arming, quindi qui sono backtest, non fill.
|
||||
|
||||
DEFINIZIONI, scritte prima di guardare i numeri:
|
||||
- indice = 50/50 BTC/ETH, ribilanciato ogni giorno (la stessa geometria del libro);
|
||||
- giorno di crollo = indice ≤ −5% (severo: ≤ −10%);
|
||||
- episodio = le 12 peggiori finestre di 20 giorni NON sovrapposte dell'indice, piu' un elenco
|
||||
NOMINATO di episodi noti (COVID, maggio 2021, LUNA, FTX, agosto 2024, febbraio e giugno 2026);
|
||||
- classi ESCLUSIVE, in quest'ordine: "immune" = |libro| < 1/10 dell'indice (col segno stampato);
|
||||
altrimenti "POSITIVO" se libro > 0, "PERDE" se < 0. Si stampa anche il conteggio nudo libro > 0.
|
||||
(Prima stesura: POSITIVO provato prima di immune → 8/12; con l'ordine dichiarato qui 4/12 + 6
|
||||
immune. L'ordine delle regole ERA il verdetto: corretto in revisione, si stampano entrambi.)
|
||||
- beta al ribasso = pendenza OLS del libro sull'indice nei soli giorni con indice < 0.
|
||||
|
||||
PESI: 75/25 e' decisione vincolante (confermata 3 volte, §3). Il 37,5/62,5 di §26 compare qui
|
||||
SOLO come riga informativa negli stessi episodi — non e' una proposta: la riapre solo
|
||||
`weights_tilt_null`, che finora fallisce.
|
||||
|
||||
Nessun file di produzione toccato. Nessun ordine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.backtest.harness import backtest_signals # noqa: E402
|
||||
from src.data.downloader import load_data # noqa: E402
|
||||
from src.live.book import W_SKH, W_TP01 # noqa: E402 (i pesi si IMPORTANO, non si ridichiarano: P1)
|
||||
from src.portfolio.sleeves import _skyhook_returns, _tp01_returns # noqa: E402
|
||||
from src.strategies.skyhook import SKH01_V2_DD, build_frames, skyhook_entries # noqa: E402
|
||||
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d # noqa: E402
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
SOGLIA_CROLLO = -0.05
|
||||
SOGLIA_SEVERO = -0.10
|
||||
FINESTRA = 20
|
||||
N_EPISODI = 12
|
||||
BETA_MEMORIA = 0.076 # §46, da riprodurre SULLA SUA FINESTRA (DVOL: 2021-03-24+)
|
||||
FINESTRA_46 = pd.Timestamp("2021-03-24", tz="UTC")
|
||||
TOL_BETA = 0.01
|
||||
W_INFO = (0.375, 0.625) # §26: riga informativa, NON proposta
|
||||
|
||||
EPISODI_NOMINATI = {
|
||||
"COVID 2020-03": ("2020-03-01", "2020-03-20"),
|
||||
"maggio 2021": ("2021-05-08", "2021-05-24"),
|
||||
"LUNA 2022-05": ("2022-05-04", "2022-05-14"),
|
||||
"giugno 2022": ("2022-06-06", "2022-06-19"),
|
||||
"FTX 2022-11": ("2022-11-06", "2022-11-22"),
|
||||
"agosto 2024": ("2024-07-28", "2024-08-06"),
|
||||
"febbraio 2025": ("2025-01-31", "2025-03-11"),
|
||||
"5 febbraio 2026": ("2026-01-28", "2026-02-07"),
|
||||
"1-5 giugno 2026": ("2026-05-28", "2026-06-06"),
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ serie
|
||||
|
||||
def indice_5050() -> pd.Series:
|
||||
r = {}
|
||||
for a in ASSETS:
|
||||
d = resample_1d(load_data(a, "1h"))
|
||||
s = pd.Series(d["close"].values.astype(float), index=pd.to_datetime(d["datetime"], utc=True))
|
||||
r[a] = s.pct_change()
|
||||
J = pd.concat(r, axis=1, join="inner").dropna()
|
||||
return 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
|
||||
|
||||
def tp01_esposizione() -> pd.Series:
|
||||
"""Esposizione lorda media (BTC+ETH)/2 tenuta nel giorno (decisa a close[i-1])."""
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
e = {}
|
||||
for a in ASSETS:
|
||||
d = resample_1d(load_data(a, "1h"))
|
||||
tgt = tp.target_series(d)
|
||||
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
|
||||
e[a] = pd.Series(held, index=pd.to_datetime(d["datetime"], utc=True))
|
||||
J = pd.concat(e, axis=1, join="inner")
|
||||
return 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
|
||||
|
||||
def skh_per_direzione() -> dict[str, pd.Series]:
|
||||
"""SKH01-V2-DD 50/50 su griglia giornaliera, in tre versioni: intera, SOLO long, SOLO short.
|
||||
|
||||
Le versioni per direzione filtrano gli entry-dict prima del backtest: stesso motore, stessi
|
||||
stop. In principio filtrare potrebbe cambiare il non-overlap; MISURATO (revisione 09/09):
|
||||
long + short == intera su 2735/2735 giorni (max|diff| 3,5e-18) — la scomposizione e' ESATTA,
|
||||
e `misura()` lo verifica con un assert (P1: la versione "tutto" e' confrontata bit-exact con
|
||||
`sleeves._skyhook_returns`).
|
||||
"""
|
||||
out = {k: {} for k in ("tutto", "long", "short")}
|
||||
for a in ASSETS:
|
||||
ltf, htf = build_frames(load_data(a, "5m"))
|
||||
ent = skyhook_entries(ltf, htf, SKH01_V2_DD)
|
||||
for k, keep in (("tutto", (1, -1)), ("long", (1,)), ("short", (-1,))):
|
||||
e2 = [x if (x is not None and x["dir"] in keep) else None for x in ent]
|
||||
m = backtest_signals(ltf, e2, fee_rt=0.001, leverage=1.0, asset=a, tf="230m")
|
||||
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
|
||||
out[k][a] = s.resample("1D").last().ffill().pct_change().dropna()
|
||||
res = {}
|
||||
for k in out:
|
||||
J = pd.concat(out[k], axis=1, join="inner").fillna(0.0)
|
||||
res[k] = 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
return res
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ misure
|
||||
|
||||
def sh(s: pd.Series) -> float:
|
||||
s = s.dropna()
|
||||
return float(s.mean() / s.std() * np.sqrt(365.0)) if len(s) > 5 and s.std() > 0 else float("nan")
|
||||
|
||||
|
||||
def maxdd(s: pd.Series) -> float:
|
||||
eq = (1 + s.fillna(0)).cumprod(); return float((eq / eq.cummax() - 1).min())
|
||||
|
||||
|
||||
def cum(s: pd.Series) -> float:
|
||||
return float((1 + s.fillna(0)).prod() - 1)
|
||||
|
||||
|
||||
def finestre_peggiori(idx: pd.Series, n: int = N_EPISODI, w: int = FINESTRA) -> list[tuple]:
|
||||
"""Le n peggiori finestre di w giorni NON sovrapposte dell'indice (greedy dal peggiore)."""
|
||||
roll = (1 + idx).rolling(w).apply(np.prod, raw=True) - 1
|
||||
roll = roll.dropna().sort_values()
|
||||
prese, occupati = [], []
|
||||
for end, v in roll.items():
|
||||
start = end - pd.Timedelta(days=w - 1)
|
||||
if any(not (end < s or start > e) for s, e in occupati):
|
||||
continue
|
||||
prese.append((start, end, float(v))); occupati.append((start, end))
|
||||
if len(prese) >= n:
|
||||
break
|
||||
return sorted(prese)
|
||||
|
||||
|
||||
def classifica(libro: float, indice: float) -> str:
|
||||
"""Classi esclusive nell'ordine dichiarato: immune (col segno) → POSITIVO → PERDE."""
|
||||
if abs(libro) < abs(indice) / 10:
|
||||
return "immune+" if libro > 0 else "immune-"
|
||||
return "POSITIVO" if libro > 0 else "PERDE"
|
||||
|
||||
|
||||
def beta_ribasso(libro: pd.Series, idx: pd.Series, soglia: float = 0.0) -> float:
|
||||
J = pd.concat({"b": libro, "i": idx}, axis=1, join="inner").dropna()
|
||||
J = J[J["i"] < soglia]
|
||||
if len(J) < 20:
|
||||
return float("nan")
|
||||
x, y = J["i"].values, J["b"].values
|
||||
return float(np.cov(x, y)[0, 1] / np.var(x, ddof=1))
|
||||
|
||||
|
||||
def beta_pieno(libro: pd.Series, idx: pd.Series) -> float:
|
||||
J = pd.concat({"b": libro, "i": idx}, axis=1, join="inner").dropna()
|
||||
x, y = J["i"].values, J["b"].values
|
||||
return float(np.cov(x, y)[0, 1] / np.var(x, ddof=1))
|
||||
|
||||
|
||||
def misura() -> dict:
|
||||
idx = indice_5050()
|
||||
tp = _tp01_returns()
|
||||
if tp.index.tz is None:
|
||||
tp.index = tp.index.tz_localize("UTC")
|
||||
skh = skh_per_direzione()
|
||||
esp = tp01_esposizione()
|
||||
J = pd.concat({"idx": idx, "tp": tp, "skh": skh["tutto"], "skl": skh["long"],
|
||||
"sks": skh["short"], "esp": esp}, axis=1, join="inner").dropna()
|
||||
ref = _skyhook_returns()
|
||||
if ref.index.tz is None:
|
||||
ref.index = ref.index.tz_localize("UTC")
|
||||
comune = J.index.intersection(ref.index)
|
||||
assert float((J.loc[comune, "skh"] - ref.loc[comune]).abs().max()) == 0.0, "SKH01 'tutto' non e' bit-exact col sleeve"
|
||||
assert float((J["skl"] + J["sks"] - J["skh"]).abs().max()) < 1e-12, "long + short != intera: la scomposizione non e' esatta"
|
||||
J["libro"] = W_TP01 * J["tp"] + W_SKH * J["skh"]
|
||||
J["info"] = W_INFO[0] * J["tp"] + W_INFO[1] * J["skh"]
|
||||
J["c_tp"] = W_TP01 * J["tp"]
|
||||
J["c_skh"] = W_SKH * J["skh"]
|
||||
J["c_sks"] = W_SKH * J["sks"]
|
||||
|
||||
# giorni di crollo
|
||||
giorni = {}
|
||||
for nome, soglia in (("≤ −5%", SOGLIA_CROLLO), ("≤ −10%", SOGLIA_SEVERO)):
|
||||
g = J[J["idx"] <= soglia]
|
||||
giorni[nome] = dict(n=int(len(g)), idx=float(g["idx"].mean()), libro=float(g["libro"].mean()),
|
||||
tp=float(g["c_tp"].mean()), skh=float(g["c_skh"].mean()),
|
||||
sks=float(g["c_sks"].mean()), esp=float(g["esp"].mean()),
|
||||
libro_pos=float((g["libro"] > 0).mean()),
|
||||
per_anno={int(y): dict(n=int(len(gg)), idx=float(gg["idx"].sum()),
|
||||
libro=float(gg["libro"].sum()),
|
||||
sks=float(gg["c_sks"].sum()))
|
||||
for y, gg in g.groupby(g.index.year)})
|
||||
|
||||
# episodi: finestre peggiori + nominati
|
||||
def riga_ep(nome, s, e):
|
||||
w = J.loc[s:e]
|
||||
if len(w) < 3:
|
||||
return None
|
||||
return dict(nome=nome, da=str(w.index[0].date()), a=str(w.index[-1].date()), n=int(len(w)),
|
||||
idx=cum(w["idx"]), libro=cum(w["libro"]), tp=cum(w["c_tp"]), skh=cum(w["c_skh"]),
|
||||
sks=cum(w["c_sks"]), skl=cum(W_SKH * w["skl"]), esp=float(w["esp"].mean()),
|
||||
info=cum(w["info"]), classe=classifica(cum(w["libro"]), cum(w["idx"])))
|
||||
ep_auto = [r for r in (riga_ep(f"peggiore #{i+1}", s, e)
|
||||
for i, (s, e, _) in enumerate(finestre_peggiori(J["idx"]))) if r]
|
||||
ep_nom = [r for r in (riga_ep(k, pd.Timestamp(a, tz="UTC"), pd.Timestamp(b, tz="UTC"))
|
||||
for k, (a, b) in EPISODI_NOMINATI.items()) if r]
|
||||
|
||||
# peggior giorno del libro (riproduce §33: e' uno squeeze, non un crollo?)
|
||||
peggiori = J.nsmallest(5, "libro")[["idx", "libro", "c_tp", "c_skh"]]
|
||||
|
||||
n_auto = len(ep_auto)
|
||||
guad = sum(r["classe"] == "POSITIVO" for r in ep_auto)
|
||||
imm = sum(r["classe"].startswith("immune") for r in ep_auto)
|
||||
pos = sum(r["libro"] > 0 for r in ep_auto)
|
||||
dal22 = [r for r in ep_auto if r["da"] >= "2022"]
|
||||
J46 = J[J.index >= FINESTRA_46]
|
||||
quota_short = (sum(r["sks"] for r in ep_auto if r["libro"] > 0)
|
||||
/ max(1e-12, sum(r["libro"] for r in ep_auto if r["libro"] > 0)))
|
||||
return dict(
|
||||
finestra=(str(J.index[0].date()), str(J.index[-1].date()), int(len(J))),
|
||||
beta=beta_pieno(J["libro"], J["idx"]), beta_46=beta_pieno(J46["libro"], J46["idx"]),
|
||||
beta_giu=beta_ribasso(J["libro"], J["idx"]),
|
||||
beta_giu3=beta_ribasso(J["libro"], J["idx"], -0.03),
|
||||
sharpe=dict(libro=sh(J["libro"]), info=sh(J["info"]), tp=sh(J["tp"]), skh=sh(J["skh"]),
|
||||
sks=sh(J["sks"]), skl=sh(J["skl"])),
|
||||
maxdd=dict(libro=maxdd(J["libro"]), info=maxdd(J["info"]), idx=maxdd(J["idx"])),
|
||||
giorni=giorni, episodi=ep_auto, nominati=ep_nom,
|
||||
peggiori=[(str(t.date()), float(r.idx), float(r.libro), float(r.c_tp), float(r.c_skh))
|
||||
for t, r in peggiori.iterrows()],
|
||||
sks_anno={int(y): cum(g) for y, g in J["c_sks"].groupby(J.index.year)},
|
||||
sintesi=dict(n_episodi=n_auto, guadagna=guad, immune=imm, perde=n_auto - guad - imm, positivi=pos,
|
||||
dal22=(sum(r["libro"] > 0 for r in dal22), len(dal22)),
|
||||
giorni_pos=giorni["≤ −5%"]["libro_pos"], giorni_libro=giorni["≤ −5%"]["libro"],
|
||||
quota_short_nei_guadagni=float(quota_short),
|
||||
info_batte_live=sum(r["info"] > r["libro"] for r in ep_auto)),
|
||||
)
|
||||
|
||||
|
||||
def verdetto(m: dict) -> str:
|
||||
"""Tre affermazioni separate, perche' rispondono a tre domande diverse:
|
||||
(1) nel GIORNO del crollo (media sui giorni ≤ −5%); (2) per FINESTRA (classi esclusive);
|
||||
(3) da dove viene il guadagno. Un solo aggettivo ("guadagna"/"immune") sarebbe l'ordine delle regole."""
|
||||
s = m["sintesi"]
|
||||
beta_ok = abs(m["beta_46"] - BETA_MEMORIA) < TOL_BETA
|
||||
giorno = ("PERDE" if s["giorni_libro"] < 0 else "GUADAGNA")
|
||||
if s["guadagna"] >= 2 * s["n_episodi"] / 3:
|
||||
finestra = "POSITIVO per finestra"
|
||||
elif s["guadagna"] + s["immune"] >= 2 * s["n_episodi"] / 3:
|
||||
finestra = "IMMUNE-O-POSITIVO per finestra"
|
||||
else:
|
||||
finestra = "PERDE per finestra"
|
||||
return (f"NEL GIORNO DEL CROLLO IL LIBRO {giorno} ({s['giorni_libro']*100:+.2f}%/g, positivo nel {s['giorni_pos']*100:.0f}% dei giorni ≤ −5%); "
|
||||
f"{finestra}: {s['guadagna']} POSITIVO / {s['immune']} immune / {s['perde']} PERDE su {s['n_episodi']} (libro > 0 in {s['positivi']}, "
|
||||
f"dal 2022 in {s['dal22'][0]}/{s['dal22'][1]}); beta {m['beta_46']:+.4f} sulla finestra di §46 "
|
||||
f"({'riproduce' if beta_ok else 'NON riproduce'} il +{BETA_MEMORIA:.3f}); "
|
||||
f"la gamba short di SKH01 (marcata all'uscita) fa il {s['quota_short_nei_guadagni']*100:.0f}% dei guadagni negli episodi con libro > 0")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ stampa
|
||||
|
||||
def pct(x: float) -> str:
|
||||
return f"{x*100:+.2f}%"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 100)
|
||||
print(" r0909 — IL LIBRO NEI CROLLI (TP01 75 / SKH01 25, lente di ricerca, ancora canonica)")
|
||||
print("=" * 100)
|
||||
m = misura()
|
||||
a, b, n = m["finestra"]
|
||||
print(f" finestra {a} → {b} ({n} giorni) pesi importati da src/live/book: TP01 {W_TP01} / SKH01 {W_SKH}")
|
||||
|
||||
print("\n [1] BETA DEL LIBRO ALL'INDICE 50/50")
|
||||
print(f" pieno {m['beta']:+.3f} · sulla finestra di §46 (2021-03-24+) {m['beta_46']:+.4f} (memoria: +{BETA_MEMORIA:.3f}) "
|
||||
f"al ribasso (idx<0) {m['beta_giu']:+.3f} nei giorni ≤ −3% {m['beta_giu3']:+.3f}")
|
||||
|
||||
print("\n [2] GIORNI DI CROLLO — media per giorno")
|
||||
print(f" {'soglia':<8}{'n':>5}{'indice':>9}{'libro':>9}{'TP01':>9}{'SKH01':>9}{'SKH short':>11}{'esp TP01':>10}{'libro>0':>9}")
|
||||
for k, g in m["giorni"].items():
|
||||
print(f" {k:<8}{g['n']:>5}{pct(g['idx']):>9}{pct(g['libro']):>9}{pct(g['tp']):>9}"
|
||||
f"{pct(g['skh']):>9}{pct(g['sks']):>11}{g['esp']:>10.3f}{g['libro_pos']*100:>8.0f}%")
|
||||
print("\n per anno (giorni ≤ −5%, SOMMA nel gruppo):")
|
||||
print(f" {'anno':<6}{'n':>4}{'indice':>10}{'libro':>10}{'SKH short':>11}")
|
||||
for y, g in m["giorni"]["≤ −5%"]["per_anno"].items():
|
||||
print(f" {y:<6}{g['n']:>4}{pct(g['idx']):>10}{pct(g['libro']):>10}{pct(g['sks']):>11}")
|
||||
|
||||
def tab(rows, titolo):
|
||||
print(f"\n {titolo}")
|
||||
print(f" {'episodio':<18}{'da':>11}{'a':>11}{'indice':>9}{'libro':>9}{'TP01':>8}{'SKH01':>8}"
|
||||
f"{'SKH-S':>8}{'SKH-L':>8}{'esp':>6}{'37/63':>8} classe")
|
||||
for r in rows:
|
||||
print(f" {r['nome']:<18}{r['da']:>11}{r['a']:>11}{pct(r['idx']):>9}{pct(r['libro']):>9}"
|
||||
f"{pct(r['tp']):>8}{pct(r['skh']):>8}{pct(r['sks']):>8}{pct(r['skl']):>8}{r['esp']:>6.2f}"
|
||||
f"{pct(r['info']):>8} {r['classe']}")
|
||||
tab(m["episodi"], f"[3] LE {N_EPISODI} PEGGIORI FINESTRE DI {FINESTRA} GIORNI DELL'INDICE (non sovrapposte)")
|
||||
tab(m["nominati"], "[4] EPISODI NOMINATI (elenco scritto prima di misurare)")
|
||||
|
||||
print("\n [5] I 5 PEGGIORI GIORNI DEL LIBRO (§33 su lente hourly: squeeze SKH01 2020-03-13; qui lente canonica: NON si riproduce, e' un crollo con TP01 mezzo long)")
|
||||
print(f" {'giorno':<12}{'indice':>9}{'libro':>9}{'TP01':>9}{'SKH01':>9}")
|
||||
for t, i, l, tpv, sk in m["peggiori"]:
|
||||
print(f" {t:<12}{pct(i):>9}{pct(l):>9}{pct(tpv):>9}{pct(sk):>9}")
|
||||
|
||||
print("\n [6] LA GAMBA SHORT DI SKH01 (peso 0,25 gia' applicato) — contributo per anno al libro")
|
||||
print(" " + " ".join(f"{y} {pct(v)}" for y, v in m["sks_anno"].items()))
|
||||
s = m["sharpe"]; d = m["maxdd"]
|
||||
print(f"\n Sharpe: libro {s['libro']:.2f} · TP01 {s['tp']:.2f} · SKH01 {s['skh']:.2f} "
|
||||
f"(solo long {s['skl']:.2f} / solo short {s['sks']:.2f}) maxDD libro {d['libro']*100:.1f}% · indice {d['idx']*100:.1f}%")
|
||||
print(f" riga INFORMATIVA 37,5/62,5 (§26, NON proposta): Sharpe {s['info']:.2f}, maxDD {d['info']*100:.1f}%, "
|
||||
f"batte il 75/25 in {m['sintesi']['info_batte_live']}/{m['sintesi']['n_episodi']} episodi")
|
||||
|
||||
print("\n VERDETTO: " + verdetto(m))
|
||||
print("=" * 100)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,242 @@
|
||||
"""r0909 — XRP COME TERZA GAMBA DEL BOOK DERIBIT (meccanismi CONGELATI): la misura che manca.
|
||||
|
||||
PERCHE' ESISTE. L'operatore chiede "piu' monete, ma sempre in Deribit". Letto il venue OGGI
|
||||
(09/09/2026, `public/get_book_summary_by_currency`): dei 32 perpetual USDC-lineari, solo
|
||||
QUATTRO hanno volume a 8 cifre — BTC, ETH, **XRP ($38M/g, il PIU' scambiato del venue, spread
|
||||
4 bps, listato 2022-03-16)** e SOL ($13M/g). Tutto il resto sta fra $0,01M e $7M al giorno.
|
||||
SOL come terza gamba e' stato misurato il 22/08 e ESCLUSO dall'operatore (hold-out −0,166 in
|
||||
0/24 ancore, guadagno di UN anno, il 2023). XRP non e' mai stato misurato come gamba
|
||||
direzionale: e' l'unico candidato "piu' monete" del venue che sia liquido E non misurato.
|
||||
|
||||
IPOTESI A PRIORI, dichiarata prima di misurare: DILUISCE, come SOL — il trend multi-asset del
|
||||
19/06 fu scartato a corr 0,74 con TP01, e SOL ha confermato (corr 0,40-0,56, in salita col dato
|
||||
che migliora). Se XRP aggiunge, il primo sospetto e' l'anno del suo evento idiosincratico (la
|
||||
sentenza SEC dell'estate 2023 e la corsa di fine 2024): M9, "24/24 ancore positive possono
|
||||
essere un anno solo".
|
||||
|
||||
METODO — identico a `r0822_sol_leg.py`, da cui IMPORTA il harness (non riscritto): TP01 e
|
||||
SKH01-V2-DD congelati, book a 2 vs 3 gambe, 24 ancore orarie, mediana delle differenze
|
||||
APPAIATE (M7), null del de-levering a iso-maxDD (M5), scomposizione per anno (M9), due lenti
|
||||
DICHIARATE PRIMA dalla certificazione del dato, non dal risultato:
|
||||
- L-FULL: dal listing (2022-03-16);
|
||||
- L-PULITA: dal primo anno in cui la quota di barre 1h a >1% da Coinbase USD scende sotto
|
||||
lo 0,5% (SOL 2023 aveva 0,6% e fu giudicata sporca: e' il precedente che fissa la soglia).
|
||||
Il dato XRP vive in `data/raw/alt_xrp_*.parquet` (namespace di ricerca, come SOL: fuori dal
|
||||
feed attivo, NON rinfrescato dal cron, `load_data("XRP")` continua a fallire).
|
||||
|
||||
Nessun file di produzione toccato. Nessun ordine. Book/pesi/universo/cron INVARIATI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
from r0822_sol_leg import ( # noqa: E402 — harness importato, non riscritto
|
||||
DUE, HOLDOUT, W_SKH, W_TP, book, cagr, dati, k_iso_dd, maxdd, sh, skh_leg, taglia, tp01_leg,
|
||||
)
|
||||
|
||||
ASSET = "XRP"
|
||||
TRE = ("BTC", "ETH", ASSET)
|
||||
LISTING = pd.Timestamp("2022-03-16", tz="UTC")
|
||||
SOGLIA_PULITA = 0.005 # quota di barre 1h a >1% da Coinbase: sotto = anno pulito
|
||||
CACHE = ROOT / "data" / "_cache" / "r0909_xrp_riferimento_1h.parquet"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ certificazione (D1: Coinbase = audit)
|
||||
|
||||
def _fetch_1h(ex, symbol: str, start: pd.Timestamp, limit: int) -> pd.Series:
|
||||
"""OHLCV 1h paginato in avanti; un batch vuoto NON ferma il ciclo (Coinbase ha un buco
|
||||
2021-01 → 2023-07 su XRP: delistato dopo la causa SEC, rilistato dopo la sentenza)."""
|
||||
out, since = [], int(start.timestamp() * 1000)
|
||||
end = int(time.time() * 1000)
|
||||
while since < end:
|
||||
r = ex.fetch_ohlcv(symbol, "1h", since=since, limit=limit)
|
||||
r = [x for x in r if x[0] >= since]
|
||||
if not r:
|
||||
since += limit * 3_600_000
|
||||
continue
|
||||
out.extend(r)
|
||||
nxt = r[-1][0] + 3_600_000
|
||||
since = nxt if nxt > since else since + limit * 3_600_000
|
||||
df = pd.DataFrame(out, columns=["ts", "o", "h", "l", "close", "v"]).drop_duplicates("ts")
|
||||
return pd.Series(df["close"].values.astype(float),
|
||||
index=pd.to_datetime(df["ts"], unit="ms", utc=True)).sort_index()
|
||||
|
||||
|
||||
def riferimento_1h(start: pd.Timestamp) -> pd.DataFrame:
|
||||
"""Riferimento indipendente in USD (D1: audit, mai ancora): Coinbase dove c'e' (dal rilisting
|
||||
2023-07), Bitstamp altrove — entrambi gia' usati dal progetto come venue di audit. Con cache."""
|
||||
if CACHE.exists():
|
||||
df = pd.read_parquet(CACHE); df.index = pd.to_datetime(df.index, utc=True)
|
||||
if len(df):
|
||||
return df
|
||||
import ccxt
|
||||
bs = _fetch_1h(ccxt.bitstamp({"enableRateLimit": True}), "XRP/USD", start, 1000)
|
||||
cb = _fetch_1h(ccxt.coinbase({"enableRateLimit": True}), "XRP/USD", start, 300)
|
||||
df = pd.concat({"bitstamp": bs, "coinbase": cb}, axis=1).sort_index()
|
||||
df["close"] = df["coinbase"].where(df["coinbase"].notna(), df["bitstamp"])
|
||||
df["fonte"] = np.where(df["coinbase"].notna(), "CB", np.where(df["bitstamp"].notna(), "BS", ""))
|
||||
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
df.to_parquet(CACHE)
|
||||
return df
|
||||
|
||||
|
||||
def certifica() -> dict:
|
||||
"""Per anno: quota di barre 1h a >1% da Coinbase, mediana in bps, quota di barre flat (1h e 5m)."""
|
||||
d1 = dati(ASSET, "1h")
|
||||
dz = pd.Series(d1["close"].values.astype(float), index=pd.to_datetime(d1["datetime"], utc=True))
|
||||
ref = riferimento_1h(LISTING)
|
||||
J = pd.concat({"d": dz, "c": ref["close"], "f": ref["fonte"]}, axis=1, join="inner").dropna(subset=["d", "c"])
|
||||
dev = (J["d"] - J["c"]).abs() / J["c"]
|
||||
d5 = dati(ASSET, "5m")
|
||||
f5 = pd.Series((d5["high"].values == d5["low"].values), index=pd.to_datetime(d5["datetime"], utc=True))
|
||||
f1 = pd.Series((d1["high"].values == d1["low"].values), index=dz.index)
|
||||
per_anno = {}
|
||||
for y in sorted(set(J.index.year)):
|
||||
m = dev[dev.index.year == y]
|
||||
fy = J["f"][J.index.year == y]
|
||||
per_anno[int(y)] = dict(n=int(len(m)), q_1pct=float((m > 0.01).mean()), med_bps=float(m.median() * 1e4),
|
||||
fonte=f"CB {float((fy == 'CB').mean())*100:.0f}% / BS {float((fy == 'BS').mean())*100:.0f}%",
|
||||
flat_1h=float(f1[f1.index.year == y].mean()), flat_5m=float(f5[f5.index.year == y].mean()))
|
||||
puliti = [y for y, r in per_anno.items() if r["q_1pct"] <= SOGLIA_PULITA]
|
||||
# L-PULITA = dal primo anno pulito in poi SOLO se anche i successivi restano puliti (un anno pulito
|
||||
# isolato non e' un regime); altrimenti dal primo della coda pulita finale.
|
||||
anni = sorted(per_anno)
|
||||
inizio = None
|
||||
for y in anni:
|
||||
if all(per_anno[z]["q_1pct"] <= SOGLIA_PULITA for z in anni if z >= y):
|
||||
inizio = y; break
|
||||
return dict(per_anno=per_anno, puliti=puliti,
|
||||
pulita_da=pd.Timestamp(f"{inizio}-01-01", tz="UTC") if inizio else None)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ misura (stesso schema di r0822_sol_leg)
|
||||
|
||||
def gamba(asset: str) -> pd.Series:
|
||||
return W_TP * tp01_leg(asset, 0).reindex(skh_leg(asset).index).fillna(0.0) + W_SKH * skh_leg(asset)
|
||||
|
||||
|
||||
def differenze(anchors, start) -> dict:
|
||||
d = {"sh": [], "hold": [], "dd": [], "cagr": []}
|
||||
for h in anchors:
|
||||
A, B = book(DUE, h), book(TRE, h)
|
||||
idx = A.index.intersection(B.index)
|
||||
A, B = taglia(A.loc[idx], start), taglia(B.loc[idx], start)
|
||||
if len(A) < 200:
|
||||
continue
|
||||
d["sh"].append(sh(B) - sh(A)); d["hold"].append(sh(taglia(B, HOLDOUT)) - sh(taglia(A, HOLDOUT)))
|
||||
d["dd"].append((maxdd(B) - maxdd(A)) * 100); d["cagr"].append((cagr(B) - cagr(A)) * 100)
|
||||
return {k: np.array(v, float) for k, v in d.items()}
|
||||
|
||||
|
||||
def misura(n_anc: int = 24) -> dict:
|
||||
anchors = list(range(0, 24, max(1, 24 // n_anc)))[:n_anc]
|
||||
cert = certifica()
|
||||
lenti = {"L-FULL": None}
|
||||
if cert["pulita_da"] is not None:
|
||||
lenti[f"L-PULITA ({cert['pulita_da'].year}+)"] = cert["pulita_da"]
|
||||
lenti["2024+ (lente di SOL)"] = pd.Timestamp("2024-01-01", tz="UTC")
|
||||
out = dict(cert=cert, lenti={}, gambe={}, anno={}, delev={}, corr={})
|
||||
for a in TRE:
|
||||
out["gambe"][f"TP01 {a}"] = tp01_leg(a, 0); out["gambe"][f"SKH01 {a}"] = skh_leg(a)
|
||||
g = gamba(ASSET); b2 = book(DUE, 0)
|
||||
com = g.index.intersection(b2.index)
|
||||
for nome, start in lenti.items():
|
||||
c = com if start is None else com[com >= start]
|
||||
out["corr"][nome] = float(np.corrcoef(g.loc[c], b2.loc[c])[0, 1]) if len(c) > 100 else np.nan
|
||||
out["lenti"][nome] = differenze(anchors, start)
|
||||
A, B = book(DUE, 0), book(TRE, 0); idx = A.index.intersection(B.index)
|
||||
A, B = taglia(A.loc[idx], start), taglia(B.loc[idx], start)
|
||||
k = k_iso_dd(A, B)
|
||||
out["delev"][nome] = dict(k=k, sh3=sh(B), sh2k=sh(k * A), cagr3=cagr(B), cagr2k=cagr(k * A))
|
||||
A0, B0 = book(DUE, 0), book(TRE, 0); i0 = A0.index.intersection(B0.index); A0, B0 = A0.loc[i0], B0.loc[i0]
|
||||
for y in sorted(set(A0.index.year)):
|
||||
a_, b_ = A0[A0.index.year == y], B0[B0.index.year == y]
|
||||
if len(a_) >= 60:
|
||||
out["anno"][int(y)] = dict(sh2=sh(a_), sh3=sh(b_), dsh=sh(b_) - sh(a_), dcagr=(cagr(b_) - cagr(a_)) * 100,
|
||||
sh_gamba=sh(g[g.index.year == y]))
|
||||
out["gamba_xrp"] = g
|
||||
out["n_anchors"] = len(anchors)
|
||||
return out
|
||||
|
||||
|
||||
def verdetto(m: dict) -> str:
|
||||
"""AGGIUNGE solo se, nella lente PULITA (o 2024+ se non c'e' una coda pulita): dSharpe hold-out
|
||||
mediano > 0 in ≥ 75% delle ancore, E il null del de-levering passa (Sh 3 gambe > Sh 2 gambe
|
||||
riscalate + 0,02), E il contributo per anno e' positivo in piu' anni di quanti sia negativo —
|
||||
contati sugli anni DENTRO la lente (derivati dal suo nome, non cablati: P1).
|
||||
Un campione vuoto e' "NON MISURABILE", non un verdetto (P5: "non vedo" non e' "va tutto bene")."""
|
||||
nomi = [n for n in m["lenti"] if n.startswith("L-PULITA")] or ["2024+ (lente di SOL)"]
|
||||
n = nomi[0]; d = m["lenti"][n]; dl = m["delev"][n]
|
||||
import re
|
||||
anno0 = int(re.search(r"(\d{4})\+", n).group(1)) # "L-PULITA (2024+)" / "2024+ (lente di SOL)"
|
||||
if not len(d["hold"]) or not np.isfinite(dl["sh3"]) or not np.isfinite(dl["sh2k"]):
|
||||
return f"XRP: NON MISURABILE — lente {n} senza ancore valide o de-levering non calcolabile"
|
||||
c_hold = np.median(d["hold"]) > 0 and np.mean(d["hold"] > 0) >= 0.75
|
||||
c_delev = dl["sh3"] > dl["sh2k"] + 0.02
|
||||
anni = [r["dsh"] for y, r in m["anno"].items() if y >= anno0]
|
||||
c_anni = sum(x > 0 for x in anni) > sum(x < 0 for x in anni)
|
||||
esito = "XRP AGGIUNGE" if (c_hold and c_delev and c_anni) else ("XRP DILUISCE" if (not c_hold and not c_delev) else "XRP: PARI / NON SELEZIONABILE")
|
||||
return (f"{esito} — lente {n}: dSharpe hold-out mediano {np.median(d['hold']):+.3f} "
|
||||
f"(>0 nel {np.mean(d['hold'] > 0) * 100:.0f}% di {len(d['hold'])} ancore); "
|
||||
f"de-levering: Sh 3 gambe {dl['sh3']:.2f} vs 2 gambe×{dl['k']:.3f} {dl['sh2k']:.2f}; "
|
||||
f"anni {anno0}+ con dSh>0: {sum(x > 0 for x in anni)}/{len(anni)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
n_anc = int(sys.argv[sys.argv.index("--anchors") + 1]) if "--anchors" in sys.argv else 24
|
||||
print("=" * 104)
|
||||
print(" r0909 — XRP COME TERZA GAMBA DEL BOOK DERIBIT (meccanismi CONGELATI; harness di r0822_sol_leg)")
|
||||
print("=" * 104)
|
||||
print(" ipotesi a priori dichiarata: DILUISCE (come SOL; trend multi-asset 19/06 corr 0,74)")
|
||||
m = misura(n_anc)
|
||||
c = m["cert"]
|
||||
print(f"\n [0/5] CERTIFICAZIONE XRP vs Coinbase USD (1h) — soglia anno pulito: quota >1% ≤ {SOGLIA_PULITA*100:.1f}%")
|
||||
print(f" {'anno':<6}{'barre':>7}{'>1%':>8}{'med bps':>9}{'flat 1h':>9}{'flat 5m':>9} riferimento")
|
||||
for y, r in c["per_anno"].items():
|
||||
print(f" {y:<6}{r['n']:>7}{r['q_1pct']*100:>7.2f}%{r['med_bps']:>9.1f}{r['flat_1h']*100:>8.1f}%{r['flat_5m']*100:>8.1f}% {r['fonte']}")
|
||||
print(f" L-PULITA da: {c['pulita_da'].date() if c['pulita_da'] is not None else 'NESSUN anno pulito in coda'}")
|
||||
|
||||
print(f"\n [1/5] LE GAMBE DA SOLE (ancora canonica h=0)")
|
||||
print(f"\n {'gamba':<26}{'barre':>7}{'Sharpe':>9}{'maxDD':>10}{'CAGR':>9}{'Sh hold':>10}")
|
||||
for k, s in m["gambe"].items():
|
||||
print(f" {k:<26}{len(s):>7}{sh(s):>9.2f}{maxdd(s)*100:>9.1f}%{cagr(s)*100:>8.1f}%{sh(taglia(s, HOLDOUT)):>10.2f}")
|
||||
g = m["gamba_xrp"]
|
||||
print(f"\n [2/5] LA GAMBA XRP (75/25) — Sharpe {sh(g):.2f}, maxDD {maxdd(g)*100:.1f}%, CAGR {cagr(g)*100:.1f}%, hold-out {sh(taglia(g, HOLDOUT)):.2f}")
|
||||
for n, v in m["corr"].items():
|
||||
print(f" corr(gamba XRP, book BTC/ETH) {n}: {v:+.3f} (SOL: +0,40 / +0,56; soglia del 19/06: 0,74)")
|
||||
|
||||
print(f"\n [3/5] BOOK A 2 vs 3 GAMBE — differenze APPAIATE per ancora ({m['n_anchors']} ancore)")
|
||||
for n, d in m["lenti"].items():
|
||||
if not len(d["sh"]):
|
||||
print(f"\n {n}: campione insufficiente"); continue
|
||||
print(f"\n {n} ({len(d['sh'])} ancore)")
|
||||
for lab, v, u in (("dSharpe FULL", d["sh"], ""), ("dSharpe hold-out", d["hold"], ""), ("dMaxDD", d["dd"], " pp"), ("dCAGR", d["cagr"], " pp")):
|
||||
print(f" {lab:<18} mediana {np.median(v):+7.3f}{u} banda [{np.percentile(v, 10):+.3f}, {np.percentile(v, 90):+.3f}] >0 nel {np.mean(v > 0)*100:.0f}% delle ancore")
|
||||
|
||||
print(f"\n [3bis] PER ANNO (h=0): {'anno':<6}{'2g Sh':>7}{'3g Sh':>7}{'dSh':>7}{'dCAGR':>9}{'gamba XRP':>11}")
|
||||
for y, r in m["anno"].items():
|
||||
print(f" {'':<19}{y:<6}{r['sh2']:>7.2f}{r['sh3']:>7.2f}{r['dsh']:>+7.2f}{r['dcagr']:>+8.1f}pp{r['sh_gamba']:>+11.2f}")
|
||||
|
||||
print(f"\n [4/5] NULL DEL DE-LEVERING (iso-maxDD): {'lente':<24}{'k':>7}{'Sh 3g':>8}{'Sh 2g×k':>9}{'CAGR 3g':>9}{'CAGR 2g×k':>11}")
|
||||
for n, dl in m["delev"].items():
|
||||
v = "AGGIUNGE" if dl["sh3"] > dl["sh2k"] + 0.02 else ("de-levering" if dl["sh2k"] > dl["sh3"] + 0.02 else "pari")
|
||||
print(f" {'':<39}{n:<24}{dl['k']:>7.3f}{dl['sh3']:>8.2f}{dl['sh2k']:>9.2f}{dl['cagr3']*100:>8.1f}%{dl['cagr2k']*100:>10.1f}% {v}")
|
||||
|
||||
px = float(dati(ASSET, "1h")["close"].iloc[-1])
|
||||
print(f"\n [5/5] ESEGUIBILITA': XRP_USDC-PERPETUAL min 1 XRP = ${px:.2f} < pavimento min_order $5 — non e' il vincolo (come SOL)")
|
||||
print("\n VERDETTO: " + verdetto(m))
|
||||
print("=" * 104)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user