research(wave-0822): ledger degli esiti — ADAPTIVE-HORIZON scartato

This commit is contained in:
Adriano Dal Pastro
2026-08-22 17:03:07 +00:00
parent fa86a7b089
commit ff4aa94528
13 changed files with 8127 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# ONDATA 2026-08-22 — esiti per filone (aggiornato man mano)
Regola di lettura: `SCARTATO` con un meccanismo misurato vale quanto un `LEAD` — chiude un filone
e smette di farlo pagare alle ondate successive. `CANDIDATO` richiede marginal ADDS + DSR>=0.95 +
null de-levering superato + eseguibilita' al capitale dichiarato.
| # | filone | verdetto | in una riga |
|---|---|---|---|
| 9 | ADAPTIVE-HORIZON | **SCARTATO** | il "vincitore adattivo" ha lookback incollato al bordo il 100% del tempo = TSMOM costante a 20g (corr 1.000, dSh 0.00 in 8/8 anni e 0/12 ancore); isolando le celle davvero adattive -> NEUTRAL, corr→TP01 0.90 = TP01 travestito |
## Note che sopravvivono ai singoli filoni
### 9 — ADAPTIVE-HORIZON (r0822_adaptive_horizon.py, 112 trial dichiarati)
- Sanity: il baseline dell'agente riproduce TP01 canonico a **max|diff| = 0.0** (n=2719).
- Il controllo giusto NON era TP01 ma il **miglior lookback COSTANTE**: senza quel controllo il null
confonde "adattare paga" con "un orizzonte piu' corto paga su questo campione". L'agente se l'e'
costruito da solo dopo aver visto l'argmax incollato al bordo, e ha dichiarato i trial in piu'.
- ⚠️ Segnalazione, NON proposta: 30/90/180 e' rango 18/24 in-sample nella propria famiglia di
orizzonti (10/24 sull'hold-out), ma la superficie di L e' un **crinale di rumore** — il salto di
Sharpe fra L ADIACENTI e' +0.18, cioe' il 47-67% dell'intero vantaggio del vincitore su TP01, ed
estendere la griglia ha spostato l'argmax da L=20 a L=15. "Piu' corto e' meglio" e' un max-of-k su
una superficie frastagliata. **Nessun cambio a TP01.**
- Riaprirebbe il filone: un rilevatore il cui L si muove DAVVERO che batta il miglior L costante
(non TP01) in >=6/8 anni appaiati con corr→TP01 < 0.6 — es. BOCPD/PELT vero, non provato.
+728
View File
@@ -0,0 +1,728 @@
#!/usr/bin/env python
"""r0822_adaptive_horizon.py — ADAPTIVE-HORIZON TSMOM: stimare *quanto indietro guardare*
dai dati (change-point / persistenza) invece di fissarlo a 30/90/180 giorni.
DOMANDA
-------
Un TSMOM a lookback ADATTIVO batte il TSMOM a orizzonti FISSI 30/90/180 (TP01), in modo
MARGINALE e ONESTO?
COSA NON E' (i due vicini gia' bocciati, per non rifarli)
---------------------------------------------------------
* 02/07 "velocita' trend regime-condizionata": pesava i TRE orizzonti fissi in funzione del
percentile di vol -> risulto' un tilt-30d statico travestito (pctl 0.71 vs il null dei pesi
statici casuali). Li' gli orizzonti restavano 30/90/180: cambiavano solo i PESI.
* 29/06 "meta-allocazione dinamica" fra sleeve: peggiore dei pesi fissi. Li' l'oggetto adattato
erano i pesi di PORTAFOGLIO, non un parametro della strategia.
Qui l'oggetto adattato e' **l'orizzonte stesso**: L_t stimato da un rilevatore di change-point
(CUSUM) o di persistenza (variance-ratio, Hurst). Mai provato nel progetto.
FORMA FUNZIONALE (scelta perche' rende il confronto strutturalmente pulito)
--------------------------------------------------------------------------
TP01 canonico e' il blend di sign(c[i]/c[i-h]-1) su h = (30, 90, 180) giorni, long-flat,
vol-target 20%, leva cap 2x. Ma (30, 90, 180) == (L/3, L, 2L) con **L = 90 giorni**.
Quindi il candidato e' LA STESSA FUNZIONE con L che varia nel tempo:
blend='triple' -> horizons_t = (L_t/3, L_t, 2*L_t) (TP01 = L_t costante a 90g)
blend='single' -> horizons_t = (L_t,) (un solo orizzonte adattivo)
Tutto il resto (long-flat, target_vol 20%, vol_win 30g, leva 2x, fee 0.05%/lato, cadenza,
50/50 BTC+ETH) e' IDENTICO al baseline: **l'unico grado di liberta' e' l'orizzonte**.
=> il null non e' "meglio di zero", e' "meglio del TSMOM fisso a PARI GEOMETRIA".
FAMIGLIA DICHIARATA **PRIMA** DI GUARDARE (conteggio al RIALZO)
--------------------------------------------------------------
rilevatore (3): cusum | vratio | hurst
parametro (3): cusum k in {2,4,8} sigma-cumulate; vratio/hurst finestra W in {60,120,250}g
mappatura (2): direct (piu' persistenza / piu' tempo dall'ultimo change-point -> L piu' LUNGO)
inverse (specchiata) [il verso NON e' ovvio a priori => e' un asse, non una scelta]
blend (2): single | triple
timeframe (2): 1d | 12h [>= 12h: sotto, costi+overfit dominano (19/06)]
------------------------------------------------------------------
= 3 x 3 x 2 x 2 = 36 celle x 2 TF = **72 valutazioni**.
Costanti FISSATE A PRIORI e non cercate (dichiarate perche' sono gradi di liberta' non contati
nella griglia): Lmin=20g, Lmax=200g (contengono 30..180 del canonico); q=5g del variance-ratio;
scale del Hurst k in {1,2,4,8}g; finestra della percentile causale M=250g. Per questo il
deflated-Sharpe e' riportato ANCHE a N=144 e N=216 trial (sensibilita' del verdetto al conteggio,
regola del 30/07): un verdetto che si ribalta col conteggio si cita come tale.
GATE (tutti dell'harness del progetto, nessuno riscritto)
---------------------------------------------------------
A.study_family_honest -> cella scelta IN-SAMPLE-ONLY + deflated-Sharpe (NO selezione su hold-out)
A.marginal_vs_tp01 -> ADDS/HEDGE/NOISE/REDUNDANT/DILUTES + corr al baseline
A.causality_ok -> ricalcolo su prefisso (nessun look-ahead nella costruzione del segnale)
A.anchor_luck_band -> stima onesta = MEDIANA delle 24 ancore, non l'ancora 00:00
A.anchor_luck_delta -> confronto adattivo-vs-fisso: MEDIANA delle DIFFERENZE APPAIATE
A.implausible_sharpe -> Sharpe troppo bello = rischio fuori dal campione
A.eval_weights_smallcap-> haircut reale a $600 (min-order $5)
null del de-levering -> esiste k<1 che da' al FISSO lo stesso maxDD con Sharpe >=?
differenza APPAIATA PER ANNO -> un vantaggio concentrato in un anno e' un evento, non un meccanismo
Causalita': ogni rolling ha min_periods e finestra all'indietro; la CUSUM e' un loop in avanti
con reset (prefix-stable); la percentile e' rolling causale; nessuna statistica full-sample.
Runtime atteso ~3-6 min su 2 core. Nessun file toccato fuori da questo.
"""
from __future__ import annotations
import sys
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path("/opt/docker/PythagorasGoal")
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
sys.path.insert(0, str(ROOT))
import altlib as A # noqa: E402
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
ASSETS = A.CERTIFIED
HOLDOUT = A.HOLDOUT
# --- costanti FISSATE A PRIORI (dichiarate sopra, non cercate) --------------
LMIN_D, LMAX_D = 20, 200 # banda dell'orizzonte adattivo, in giorni
FIXED_H = (30, 90, 180) # == (L/3, L, 2L) con L=90 -> il baseline
TGT_VOL, LEV, VOLWIN = 0.20, 2.0, 30
VR_Q_DAYS = 5 # passo del variance-ratio
HURST_K_DAYS = (1, 2, 4, 8) # scale del Hurst
PCTL_M_DAYS = 250 # finestra della percentile causale
TFS = ("1d", "12h")
DETECTORS = ("cusum", "vratio", "hurst")
PARAMS = {"cusum": (2.0, 4.0, 8.0), "vratio": (60, 120, 250), "hurst": (60, 120, 250)}
MAPPINGS = ("direct", "inverse")
BLENDS = ("single", "triple")
# ===========================================================================
# RILEVATORI -> lookback adattivo L_t (in barre). Tutti causali.
# ===========================================================================
def _logret(c: np.ndarray) -> np.ndarray:
r = np.zeros(len(c))
r[1:] = np.log(c[1:] / c[:-1])
return r
def _roll_pctl(x: np.ndarray, m: int) -> np.ndarray:
"""Percentile CAUSALE del valore corrente nella sua finestra all'indietro (rolling rank).
Warmup -> 0.5 (neutro): non si inventa un regime dove non c'e' campione."""
s = pd.Series(x)
u = s.rolling(m, min_periods=max(20, m // 4)).rank(pct=True).values
return np.where(np.isfinite(u), u, 0.5)
def _cusum_age(c: np.ndarray, k: float, bpd: int) -> np.ndarray:
"""Eta' (in barre) dall'ultimo change-point CUSUM a due code su rendimenti standardizzati.
Loop in avanti con reset: dipende solo dal passato -> stabile su prefisso."""
lr = _logret(c)
sig = pd.Series(lr).rolling(30 * bpd, min_periods=max(5, 15 * bpd)).std().values
s = np.where((sig > 0) & np.isfinite(sig), lr / np.where(sig > 0, sig, 1.0), 0.0)
s = np.nan_to_num(s)
n = len(c)
age = np.zeros(n)
sp = sm = 0.0
last = 0
for i in range(n):
sp = max(0.0, sp + s[i])
sm = min(0.0, sm + s[i])
if max(sp, -sm) > k:
sp = sm = 0.0
last = i
age[i] = i - last
return age
def _vratio(c: np.ndarray, w_days: int, bpd: int) -> np.ndarray:
"""Variance ratio VR = Var(r_q) / (q * Var(r_1)) su finestra w_days. >1 = persistente."""
lr = _logret(c)
qb = max(2, VR_Q_DAYS * bpd)
lg = np.log(c)
lrq = np.zeros(len(c))
lrq[qb:] = lg[qb:] - lg[:-qb]
lrq[:qb] = np.nan
wb = max(20, w_days * bpd)
v1 = pd.Series(lr).rolling(wb, min_periods=wb // 2).var().values
vq = pd.Series(lrq).rolling(wb, min_periods=wb // 2).var().values
with np.errstate(invalid="ignore", divide="ignore"):
vr = vq / (qb * v1)
return vr
def _hurst(c: np.ndarray, w_days: int, bpd: int) -> np.ndarray:
"""Hurst da scaling della volatilita': sd(r_k) ~ k^H. Regressione di log sd su log k
(x fisso -> forma chiusa). Tutto rolling causale."""
lg = np.log(c)
wb = max(20, w_days * bpd)
xs, ys = [], []
for kd in HURST_K_DAYS:
kb = max(1, kd * bpd)
d = np.full(len(c), np.nan)
d[kb:] = lg[kb:] - lg[:-kb]
sd = pd.Series(d).rolling(wb, min_periods=wb // 2).std().values
with np.errstate(invalid="ignore", divide="ignore"):
ys.append(np.log(np.where(sd > 0, sd, np.nan)))
xs.append(np.log(kb))
X = np.asarray(xs, float)
Y = np.vstack(ys) # (n_scale, n_bar)
xm = X.mean()
denom = float(((X - xm) ** 2).sum())
with warnings.catch_warnings(): # warmup = colonne tutte-NaN
warnings.simplefilter("ignore", RuntimeWarning)
ym = np.nanmean(Y, axis=0)
H = ((X - xm)[:, None] * (Y - ym[None, :])).sum(axis=0) / denom
return H
_STAT_CACHE: dict = {}
def _stat_key(df: pd.DataFrame, det: str, par) -> tuple:
ts = df["timestamp"].values
return (det, par, len(df), int(ts[0]), int(ts[-1]))
def lookback_bars(df: pd.DataFrame, det: str, par, mapping: str) -> np.ndarray:
"""L_t in BARRE, dentro [Lmin, Lmax] giorni. Cache chiusa sul CONTENUTO del frame
(lunghezza + primo/ultimo timestamp) -> un prefisso NON prende la cache del pieno,
altrimenti causality_ok verrebbe ingannata dalla cache stessa."""
bpd = A.bars_per_day(df)
lo, hi = max(2, LMIN_D * bpd), max(3, LMAX_D * bpd)
c = df["close"].values.astype(float)
key = _stat_key(df, det, par)
if key not in _STAT_CACHE:
if det == "cusum":
_STAT_CACHE[key] = _cusum_age(c, float(par), bpd)
elif det == "vratio":
_STAT_CACHE[key] = _roll_pctl(_vratio(c, int(par), bpd), PCTL_M_DAYS * bpd)
elif det == "hurst":
_STAT_CACHE[key] = _roll_pctl(_hurst(c, int(par), bpd), PCTL_M_DAYS * bpd)
else:
raise ValueError(det)
stat = _STAT_CACHE[key]
if det == "cusum":
base = np.clip(stat, lo, hi)
L = base if mapping == "direct" else (lo + hi - base)
else:
u = np.clip(stat, 0.0, 1.0)
L = lo + u * (hi - lo) if mapping == "direct" else hi - u * (hi - lo)
return np.clip(np.round(L), 2, hi).astype(int)
# ===========================================================================
# DIREZIONE TSMOM (orizzonte costante o variabile) + vol-target
# ===========================================================================
def _sign_at(c: np.ndarray, h: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
n = len(c)
i = np.arange(n)
j = i - h
ok = j >= 0
s = np.zeros(n)
jj = np.where(ok, j, 0)
s[ok] = np.sign(c[i[ok]] / c[jj[ok]] - 1.0)
return s, ok
def _dir_from_L(c: np.ndarray, L: np.ndarray, blend: str) -> np.ndarray:
hs = [L] if blend == "single" else [
np.maximum(2, np.round(L / 3.0).astype(int)), L, np.round(2.0 * L).astype(int)]
acc = np.zeros(len(c))
cnt = np.zeros(len(c))
for h in hs:
s, ok = _sign_at(c, np.asarray(h, int))
acc[ok] += s[ok]
cnt[ok] += 1
out = np.zeros(len(c))
nz = cnt > 0
out[nz] = acc[nz] / cnt[nz]
return out
def fixed_target(df: pd.DataFrame) -> np.ndarray:
"""BASELINE: TSMOM fisso 30/90/180, long-flat, vol-target 20%, leva 2x = TP01 canonico."""
c = df["close"].values.astype(float)
bpd = A.bars_per_day(df)
acc = np.zeros(len(c))
cnt = np.zeros(len(c))
for hd in FIXED_H:
s, ok = _sign_at(c, np.full(len(c), hd * bpd, int))
acc[ok] += s[ok]
cnt[ok] += 1
d = np.zeros(len(c))
nz = cnt > 0
d[nz] = acc[nz] / cnt[nz]
return A.vol_target(np.clip(d, 0, None), df, TGT_VOL, VOLWIN, LEV)
def const_factory(tf: str, L: int, blend: str):
"""TSMOM a orizzonte COSTANTE L giorni, STESSA geometria del candidato adattivo.
blend='triple' -> (L/3, L, 2L): con L=90 e' ESATTAMENTE TP01 canonico 30/90/180."""
def fn(df: pd.DataFrame) -> np.ndarray:
c = df["close"].values.astype(float)
bpd = A.bars_per_day(df)
Lb = np.full(len(c), max(2, int(round(L * bpd))), int)
d = np.clip(_dir_from_L(c, Lb, blend), 0, None)
return A.vol_target(d, df, TGT_VOL, VOLWIN, LEV)
fn.__name__ = f"const_L{L}_{blend}"
return fn
def factory(tf: str, det: str, par, mapping: str, blend: str):
"""target_fn(df) -> posizione per barra, decisa con dati <= close[i]."""
def fn(df: pd.DataFrame) -> np.ndarray:
c = df["close"].values.astype(float)
L = lookback_bars(df, det, par, mapping)
d = np.clip(_dir_from_L(c, L, blend), 0, None)
return A.vol_target(d, df, TGT_VOL, VOLWIN, LEV)
fn.__name__ = f"adapt_{det}{par}_{mapping}_{blend}"
return fn
GRID = [dict(det=d, par=p, mapping=m, blend=b)
for d in DETECTORS for p in PARAMS[d] for m in MAPPINGS for b in BLENDS]
# CONTROLLO dichiarato DOPO aver visto che il vincitore ha L_t incollato al bordo (sezione 3):
# il null "TSMOM fisso 30/90/180" NON basta piu', perche' confonde due cose diverse —
# "adattare l'orizzonte paga" e "un orizzonte piu' CORTO paga su questo campione".
# Il controllo giusto e' il migliore fra gli orizzonti COSTANTI della stessa banda, scelto
# in-sample-only. Trial aggiuntivi, contati al rialzo.
CONST_L = (20, 30, 45, 60, 90, 120, 150, 200)
CONST_GRID = [dict(L=L, blend=b) for L in CONST_L for b in BLENDS]
# ESTENSIONE dichiarata DOPO aver visto che l'ottimo costante cade sul BORDO BASSO della banda
# (L=20g = LMIN_D): un ottimo al bordo non e' un ottimo, e' il punto in cui si e' smesso di
# guardare. Si estende SOTTO e si guarda se e' un plateau o una scivolata verso il rumore.
# +4 valori x 1 blend x 2 TF = 8 trial in piu' (contati al rialzo).
CONST_L_EXT = (5, 7, 10, 15)
# ===========================================================================
# UTILITA' DI MISURA
# ===========================================================================
def _dd(s: pd.Series) -> float:
eq = np.cumprod(1.0 + np.asarray(s.dropna().values, float))
pk = np.maximum.accumulate(eq)
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
def _cagr(s: pd.Series) -> float:
s = s.dropna()
if len(s) < 5:
return float("nan")
eq = float(np.prod(1.0 + s.values))
yrs = (s.index[-1] - s.index[0]).total_seconds() / (86400 * 365.25)
return eq ** (1 / max(yrs, 1e-6)) - 1.0
def resample_offset(df_1h: pd.DataFrame, hours: int, off: int) -> pd.DataFrame:
"""Stessa convenzione di trend_portfolio.resample_tf, con ancora spostata di `off` ore
(r0702_tp01_offset.py). NB: si usa `offset=`, non `origin=` (pandas ignora origin su 7D)."""
g = df_1h.copy()
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
idx.name = "dt"
g.index = idx
out = g.resample(f"{hours}h", offset=pd.Timedelta(hours=off), label="left", closed="left").agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
out = out.dropna(subset=["open"])
out["datetime"] = out.index
epoch = pd.Timestamp("1970-01-01", tz="UTC")
out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
return out.reset_index(drop=True)[
["timestamp", "open", "high", "low", "close", "volume", "datetime"]]
def daily_from_target(fn, tf: str) -> pd.Series:
return A.candidate_daily(fn, tf=tf)
def paired_by_year(cand: pd.Series, base: pd.Series) -> list[dict]:
J = pd.concat({"C": cand, "B": base}, axis=1, join="inner").dropna()
rows = []
for y, g in J.groupby(J.index.year):
if len(g) < 40:
continue
rows.append(dict(year=int(y), n=len(g),
sh_cand=round(A._sh(g["C"]), 2), sh_base=round(A._sh(g["B"]), 2),
d_sh=round(A._sh(g["C"]) - A._sh(g["B"]), 2),
ret_cand=round(float(np.prod(1 + g["C"].values) - 1) * 100, 1),
ret_base=round(float(np.prod(1 + g["B"].values) - 1) * 100, 1)))
return rows
def delever_null(cand: pd.Series, base: pd.Series) -> dict:
"""Il fisso, DE-LEVERATO a pari maxDD del candidato, che Sharpe fa?
Scalare le POSIZIONI per k scala gross e fee insieme -> il netto scala per k e lo Sharpe
e' INVARIANTE: percio' un candidato che compra solo meno DD non e' un miglioramento."""
J = pd.concat({"C": cand, "B": base}, axis=1, join="inner").dropna()
dd_c, dd_b = _dd(J["C"]), _dd(J["B"])
if dd_c >= dd_b or dd_b <= 0:
return dict(applicabile=False, dd_cand=round(dd_c, 4), dd_base=round(dd_b, 4),
nota="il candidato NON riduce il maxDD: il null non si applica")
lo, hi = 0.0, 1.0
for _ in range(60):
k = 0.5 * (lo + hi)
if _dd(k * J["B"]) < dd_c:
lo = k
else:
hi = k
k = 0.5 * (lo + hi)
return dict(applicabile=True, k=round(k, 4), dd_cand=round(dd_c, 4), dd_base=round(dd_b, 4),
dd_base_delev=round(_dd(k * J["B"]), 4),
sharpe_cand=round(A._sh(J["C"]), 3),
sharpe_base_delev=round(A._sh(k * J["B"]), 3),
superato=bool(A._sh(J["C"]) > A._sh(k * J["B"])))
_ANCHOR_CACHE: dict = {}
def series_at_anchor(fn, name: str, hours: int, off: int) -> pd.Series:
"""Serie giornaliera 50/50 BTC+ETH della strategia `fn` ribilanciata sull'ancora `off`
(resample del 1h certificato). Cache per (name, hours, off)."""
key = (name, hours, off)
if key not in _ANCHOR_CACHE:
ser = {}
for a in ASSETS:
dfo = resample_offset(A.get(a, "1h"), hours, off)
ev = A.eval_weights(dfo, A._call_target(fn, dfo, a))
ser[a] = pd.Series(ev["net"], index=ev["idx"])
J = pd.concat(ser, axis=1, join="inner").fillna(0.0)
_ANCHOR_CACHE[key] = A._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]])
return _ANCHOR_CACHE[key]
def adaptivity(tf: str, det: str, par, mapping: str) -> dict:
"""Quanto si MUOVE davvero L_t (diagnostica di MECCANISMO, non di rendimento).
Una cella con L_t incollato a un bordo NON e' adattiva: e' un TSMOM a orizzonte
COSTANTE travestito, e non appartiene alla famiglia che si sta testando."""
fr_lo, fr_hi, iqr, med = [], [], [], []
for a in ASSETS:
df = A.get(a, tf)
bpd = A.bars_per_day(df)
L = lookback_bars(df, det, par, mapping) / bpd
fr_lo.append(float(np.mean(L <= LMIN_D + 1)))
fr_hi.append(float(np.mean(L >= LMAX_D - 1)))
iqr.append(float(np.percentile(L, 75) - np.percentile(L, 25)))
med.append(float(np.median(L)))
return dict(frac_lo=float(np.mean(fr_lo)), frac_hi=float(np.mean(fr_hi)),
iqr_days=float(np.mean(iqr)), med_days=float(np.mean(med)),
adattiva=bool(np.mean(fr_lo) < 0.5 and np.mean(fr_hi) < 0.5
and np.mean(iqr) >= 10.0))
def hr(t: str = "") -> None:
print("\n" + "=" * 78)
if t:
print(t)
print("=" * 78)
# ===========================================================================
def main() -> None:
print(__doc__.split("Runtime atteso")[0])
# -- 0. SANITY: il mio baseline DEVE essere TP01 canonico, bit-per-bit ----------------
hr("0. SANITY — il baseline 'fisso' e' TP01 canonico (se non lo e', ogni Δ e' inventato)")
tp = TrendPortfolio(**CANONICAL)
worst = 0.0
for a in ASSETS:
df = A.get(a, "1d")
worst = max(worst, float(np.max(np.abs(fixed_target(df) - tp.target_series(df)))))
base_1d = daily_from_target(fixed_target, "1d")
tp01 = A.tp01_baseline_daily()
JJ = pd.concat({"a": base_1d, "b": tp01}, axis=1, join="inner").dropna()
print(f" max|target_mio - TrendPortfolio.target_series| (1d, BTC+ETH) = {worst:.3e}")
print(f" max|serie_giornaliera_mia - al.tp01_baseline_daily()| = "
f"{float(np.max(np.abs(JJ['a'] - JJ['b']))):.3e} (n={len(JJ)})")
assert worst < 1e-12, "il baseline NON riproduce TP01: fermarsi qui"
# -- 1. GRIGLIA ONESTA: cella scelta IN-SAMPLE + deflated-Sharpe ----------------------
hr(f"1. study_family_honest — {len(GRID)} celle x {len(TFS)} TF = {len(GRID)*len(TFS)} trial")
rep = A.study_family_honest("ADAPT-H", factory, GRID, TFS)
ch = rep["chosen"]
print(f" celle valutate : {rep['n_cells']}")
print(f" cella scelta IN-SAMPLE : tf={ch['tf']} {ch['params']} "
f"Sharpe IS {ch['insample_sharpe']} FULL {ch['full_sharpe']}")
print(f" deflated-Sharpe (N={rep['n_cells']}) : {rep['deflated_sharpe']} "
f"(max atteso sotto il null {rep['expected_null_max']}) PASS={rep['dsr_pass']}")
print(f" earns_slot_marginal : {rep['earns_slot_marginal']}")
print(f" EARNS_SLOT_HONEST : {rep['earns_slot_honest']}")
print("\n top-8 IN-SAMPLE (come si sceglie onestamente):")
for r in rep["rows"][:8]:
print(f" IS {r['insample_sharpe']:+.3f} FULL {r['full_sharpe']:+.3f} "
f"{r['tf']:>3} {r['params']}")
# sensibilita' del verdetto al CONTEGGIO dei trial (regola 30/07)
fn_ch = factory(tf=ch["tf"], **ch["params"])
d_ch = daily_from_target(fn_ch, ch["tf"])
allsr = [r["full_sharpe"] for r in rep["rows"]]
print("\n sensibilita' del DSR al numero di trial dichiarati:")
for n_decl in (len(GRID) * len(TFS), 144, 216):
pad = allsr + [float(np.mean(allsr))] * max(0, n_decl - len(allsr))
dsr, _ = A.deflated_sharpe(A._sh(d_ch), pad, d_ch)
print(f" N={n_decl:>4} DSR={dsr:.3f} {'PASS' if dsr >= 0.95 else 'FAIL'}")
# cosa avrebbe scelto un disonesto (diagnostica, NON una decisione)
diag = []
for r in rep["rows"][:12]:
s_all = daily_from_target(factory(tf=r["tf"], **r["params"]), r["tf"])
diag.append({**r, "hold": round(A._sh(s_all[s_all.index >= HOLDOUT]), 3)})
diag.sort(key=lambda r: -r["hold"])
print("\n (diagnostica) le stesse 12 celle riordinate per HOLD-OUT — cio' che NON si fa:")
for r in diag[:4]:
print(f" HOLD {r['hold']:+.3f} IS {r['insample_sharpe']:+.3f} {r['tf']:>3} {r['params']}")
# -- 2. NUMERI del candidato vs il FISSO a pari geometria -----------------------------
hr("2. CANDIDATO vs TSMOM FISSO 30/90/180 — stessa geometria, stesso TF, stesse fee")
base_ch = daily_from_target(fixed_target, ch["tf"])
J = pd.concat({"C": d_ch, "B": base_ch}, axis=1, join="inner").dropna()
for lab, sl in (("FULL", J), ("HOLD-OUT", J[J.index >= HOLDOUT])):
if len(sl) < 30:
continue
print(f" {lab:<9} adattivo Sh {A._sh(sl['C']):+.3f} DD {_dd(sl['C'])*100:5.2f}% "
f"CAGR {_cagr(sl['C'])*100:6.2f}% | fisso Sh {A._sh(sl['B']):+.3f} "
f"DD {_dd(sl['B'])*100:5.2f}% CAGR {_cagr(sl['B'])*100:6.2f}% "
f"| dSh {A._sh(sl['C'])-A._sh(sl['B']):+.3f}")
print(f" corr(adattivo, fisso) = {J['C'].corr(J['B']):.3f}")
print("\n DIFFERENZA APPAIATA PER ANNO (un vantaggio in un anno solo e' un evento):")
rows = paired_by_year(d_ch, base_ch)
for r in rows:
print(f" {r['year']} adatt Sh {r['sh_cand']:+.2f} ({r['ret_cand']:+7.1f}%) "
f"fisso Sh {r['sh_base']:+.2f} ({r['ret_base']:+7.1f}%) dSh {r['d_sh']:+.2f}")
ds = [r["d_sh"] for r in rows]
print(f" -> anni con dSh>0: {sum(1 for x in ds if x > 0)}/{len(ds)} "
f"mediana dSh {np.median(ds):+.2f} media {np.mean(ds):+.2f}")
# -- 3. LOOKBACK: si muove davvero? ---------------------------------------------------
hr("3. IL LOOKBACK ADATTIVO SI MUOVE? (se sta al bordo e' un TSMOM COSTANTE travestito)")
for a in ASSETS:
df = A.get(a, ch["tf"])
bpd = A.bars_per_day(df)
L = lookback_bars(df, ch["params"]["det"], ch["params"]["par"],
ch["params"]["mapping"]) / bpd
lo, hi = LMIN_D, LMAX_D
print(f" {a}: L_t giorni min {L.min():.0f} p25 {np.percentile(L,25):.0f} "
f"mediana {np.median(L):.0f} p75 {np.percentile(L,75):.0f} max {L.max():.0f} "
f"| al bordo basso {np.mean(L<=lo+1)*100:.1f}% al bordo alto {np.mean(L>=hi-1)*100:.1f}%"
f" | |dL|/giorno mediano {np.median(np.abs(np.diff(L))):.1f}g")
# -- 4. GATE ---------------------------------------------------------------------------
hr("4. GATE")
print(A.fmt_marginal(rep["marginal"]))
cz = A.causality_ok(fn_ch, tf=ch["tf"])
print(f"\n causality_ok : ok={cz['ok']} max_tail_diff={cz['max_tail_diff']} "
f"(prefissi controllati: {cz['checked']})")
imp = A.implausible_sharpe(d_ch)
print(f" implausible_sharpe : implausible={imp['implausible']} Sh {imp.get('sharpe')} "
f"maxDD {imp.get('maxdd')} perdite/attive {imp.get('loss_frac')} "
f"attive {imp.get('active_frac')}")
for rr in imp["reasons"]:
print(f" - {rr}")
dl = delever_null(d_ch, base_ch)
print(f" null del de-levering : {dl}")
# small-cap a $600
print(" haircut a $600 (min-order $5):")
for a in ASSETS:
df = A.get(a, ch["tf"])
sc_c = A.eval_weights_smallcap(df, A._call_target(fn_ch, df, a), capital=600.0)
sc_b = A.eval_weights_smallcap(df, fixed_target(df), capital=600.0)
print(f" {a}: adattivo modellato {sc_c['modeled']['sharpe']:+.3f} -> reale "
f"{sc_c['realistic']['sharpe']:+.3f} (haircut {sc_c['sharpe_haircut']:+.3f}, "
f"{sc_c['n_executed_trades']} trade) | fisso {sc_b['modeled']['sharpe']:+.3f} -> "
f"{sc_b['realistic']['sharpe']:+.3f} (haircut {sc_b['sharpe_haircut']:+.3f}, "
f"{sc_b['n_executed_trades']} trade)")
# -- 5. BANDA D'ANCORA -----------------------------------------------------------------
hours = 24 if ch["tf"] == "1d" else 12
offs = list(range(hours))
hr(f"5. BANDA D'ANCORA — {len(offs)} ancore, stima onesta = MEDIANA (mai l'ancora 00:00)")
def _series_at(off: int, which: str) -> pd.Series:
return series_at_anchor(fn_ch if which == "cand" else fixed_target, which, hours, off)
# sanity: l'ancora 0 riproduce la serie canonica
s0 = _series_at(0, "fixed")
K = pd.concat({"a": s0, "b": base_ch}, axis=1, join="inner").dropna()
print(f" sanity ancora 0 vs serie canonica del fisso: max|Δ| = "
f"{float(np.max(np.abs(K['a']-K['b']))):.3e} (n={len(K)})")
for lab, mtr in (("Sharpe FULL", A._sh),
("Sharpe HOLD", lambda s: A._sh(s[s.index >= HOLDOUT]))):
b_c = A.anchor_luck_band(lambda o: _series_at(o, "cand"), offs, metric=mtr)
b_f = A.anchor_luck_band(lambda o: _series_at(o, "fixed"), offs, metric=mtr)
print(f" {lab:<12} adattivo: canonica {b_c['canonical']:+.3f} (pctl {b_c['canonical_pctl']:.2f}) "
f"MEDIANA {b_c['median']:+.3f} banda [{b_c['lo']:+.3f},{b_c['hi']:+.3f}]")
print(f" {'':<12} fisso : canonica {b_f['canonical']:+.3f} (pctl {b_f['canonical_pctl']:.2f}) "
f"MEDIANA {b_f['median']:+.3f} banda [{b_f['lo']:+.3f},{b_f['hi']:+.3f}]")
dlt = A.anchor_luck_delta(lambda o: _series_at(o, "cand"),
lambda o: _series_at(o, "fixed"), offs, metric=mtr)
print(f" {'':<12} DELTA APPAIATO: mediana {dlt['median_paired']:+.3f} "
f"positivo in {dlt['n_positive']}/{dlt['n_anchors']} ancore "
f"banda [{dlt['lo']:+.3f},{dlt['hi']:+.3f}] gate_pass={dlt['gate_pass']}")
# -- 6. ADATTIVITA' EFFETTIVA DI TUTTA LA FAMIGLIA ------------------------------------
hr("6. QUANTE DELLE 72 CELLE SONO DAVVERO ADATTIVE? (filtro di MECCANISMO, non di resa)")
print(" criterio: L_t sta a un bordo <50% del tempo E l'IQR di L_t e' >= 10 giorni")
seen, adatt_rows = {}, []
for r in rep["rows"]:
k = (r["tf"], r["params"]["det"], r["params"]["par"], r["params"]["mapping"])
if k not in seen:
seen[k] = adaptivity(*k)
r["_adapt"] = seen[k]
if seen[k]["adattiva"]:
adatt_rows.append(r)
n_ad_cells = sum(1 for r in rep["rows"] if r["_adapt"]["adattiva"])
print(f" celle DAVVERO adattive: {n_ad_cells}/{len(rep['rows'])} "
f"(le altre sono TSMOM a orizzonte COSTANTE travestito)")
for k, v in sorted(seen.items()):
print(f" {k[0]:>3} {k[1]:>6} {str(k[2]):>5} {k[3]:<7} bordo-basso {v['frac_lo']*100:5.1f}% "
f"bordo-alto {v['frac_hi']*100:5.1f}% IQR {v['iqr_days']:5.1f}g "
f"mediana {v['med_days']:5.1f}g -> {'ADATTIVA' if v['adattiva'] else 'costante'}")
# -- 7. IL CONTROLLO CHE MANCAVA: il MIGLIOR ORIZZONTE COSTANTE ------------------------
hr(f"7. CONTROLLO L COSTANTE — {len(CONST_GRID)} celle x {len(TFS)} TF = "
f"{len(CONST_GRID)*len(TFS)} trial IN PIU'")
print(" la domanda diventa: il guadagno viene dall'ADATTARE, o dal fatto che su questo")
print(" campione un orizzonte piu' CORTO batte 30/90/180 anche se lo si tiene fisso?")
csel = A.select_cell_insample(lambda tf, L, blend: const_factory(tf, L, blend),
CONST_GRID, TFS)
cch = csel["chosen"]
print(f"\n miglior COSTANTE scelto in-sample: tf={cch['tf']} {cch['params']} "
f"Sharpe IS {cch['insample_sharpe']} FULL {cch['full_sharpe']}")
print(" griglia costante, in-sample (L=90 triple == TP01 canonico):")
for r in csel["rows"]:
tag = " <== TP01 canonico" if (r["params"]["L"] == 90 and r["params"]["blend"] == "triple"
and r["tf"] == "1d") else ""
print(f" IS {r['insample_sharpe']:+.3f} FULL {r['full_sharpe']:+.3f} {r['tf']:>3} "
f"L={r['params']['L']:>3}g {r['params']['blend']}{tag}")
fn_const = const_factory(**cch["params"], tf=cch["tf"])
d_const = daily_from_target(fn_const, cch["tf"])
JC = pd.concat({"C": d_ch, "K": d_const}, axis=1, join="inner").dropna()
print(f"\n ADATTIVO vs MIGLIOR COSTANTE (entrambi scelti in-sample):")
for lab, sl in (("FULL", JC), ("HOLD-OUT", JC[JC.index >= HOLDOUT])):
if len(sl) < 30:
continue
print(f" {lab:<9} adattivo Sh {A._sh(sl['C']):+.3f} DD {_dd(sl['C'])*100:5.2f}% | "
f"costante Sh {A._sh(sl['K']):+.3f} DD {_dd(sl['K'])*100:5.2f}% | "
f"dSh {A._sh(sl['C'])-A._sh(sl['K']):+.3f}")
print(f" corr(adattivo, miglior costante) = {JC['C'].corr(JC['K']):.3f}")
print("\n differenza APPAIATA per anno vs il miglior COSTANTE:")
rows2 = paired_by_year(d_ch, d_const)
for r in rows2:
print(f" {r['year']} adatt {r['sh_cand']:+.2f} costante {r['sh_base']:+.2f} "
f"dSh {r['d_sh']:+.2f}")
d2 = [r["d_sh"] for r in rows2]
print(f" -> anni con dSh>0: {sum(1 for x in d2 if x > 0)}/{len(d2)} "
f"mediana {np.median(d2):+.2f}")
dl2 = delever_null(d_ch, d_const)
print(f" null del de-levering vs costante: {dl2}")
# ancora appaiata adattivo vs miglior costante (se stesso TF, altrimenti dichiarato)
if cch["tf"] == ch["tf"]:
for lab, mtr in (("Sharpe FULL", A._sh),
("Sharpe HOLD", lambda s: A._sh(s[s.index >= HOLDOUT]))):
dd2 = A.anchor_luck_delta(lambda o: series_at_anchor(fn_ch, "cand", hours, o),
lambda o: series_at_anchor(fn_const, "const", hours, o),
offs, metric=mtr)
print(f" ancora {lab}: DELTA APPAIATO mediana {dd2['median_paired']:+.3f} "
f"positivo in {dd2['n_positive']}/{dd2['n_anchors']} "
f"banda [{dd2['lo']:+.3f},{dd2['hi']:+.3f}] gate_pass={dd2['gate_pass']}")
else:
print(f" (ancora appaiata non girata: TF diversi, adattivo {ch['tf']} vs "
f"costante {cch['tf']} -> le ancore non sono coppie)")
# fee sweep: l'adattivo tradà di piu', il conto va fatto
print("\n FEE SWEEP (il candidato ha piu' turnover: e' li' che muore o no)")
for nm, f_ in (("adattivo", fn_ch), ("costante", fn_const), ("TP01 30/90/180", fixed_target)):
tfx = ch["tf"] if nm != "costante" else cch["tf"]
line = []
for fee in (0.0, 0.0005, 0.001, 0.0015):
dser = A.candidate_daily(f_, tf=tfx, fee_side=fee)
line.append(f"{2*fee*100:.2f}%RT {A._sh(dser):+.3f}")
tt = np.mean([A.eval_weights(A.get(a, tfx), A._call_target(f_, A.get(a, tfx), a))
["turnover_per_year"] for a in ASSETS])
print(f" {nm:<16} " + " ".join(line) + f" | turnover {tt:.0f}x/anno")
# -- 8. LA FAMIGLIA RISTRETTA ALLE CELLE DAVVERO ADATTIVE -----------------------------
hr("8. GATE ONESTO SULLA SOLA FAMIGLIA ADATTIVA (la domanda della missione, isolata)")
if not adatt_rows:
print(" NESSUNA cella della famiglia e' davvero adattiva -> la domanda non e'")
print(" misurabile su questa famiglia: ogni 'vincitore' e' un orizzonte costante.")
else:
best_ad = max(adatt_rows, key=lambda r: r["insample_sharpe"])
print(f" celle adattive: {len(adatt_rows)} miglior IN-SAMPLE: tf={best_ad['tf']} "
f"{best_ad['params']} IS {best_ad['insample_sharpe']} FULL {best_ad['full_sharpe']}")
fn_ad = factory(tf=best_ad["tf"], **best_ad["params"])
d_ad = daily_from_target(fn_ad, best_ad["tf"])
sm_ad = A.study_marginal("ADAPT-H(solo adattive)", fn_ad, tf=best_ad["tf"])
dsr_ad, sr0_ad = A.deflated_sharpe(A._sh(d_ad),
[r["full_sharpe"] for r in adatt_rows], d_ad)
print(f" marginal={sm_ad['marginal_verdict']} earns_slot={sm_ad['earns_slot']} "
f"corr->TP01 {sm_ad['marginal'].get('corr_full')}")
print(f" deflated-Sharpe (N={len(adatt_rows)}) = {dsr_ad:.3f} "
f"{'PASS' if dsr_ad >= 0.95 else 'FAIL'} (null max atteso {sr0_ad:.3f})")
JA = pd.concat({"C": d_ad, "K": d_const, "B": base_1d}, axis=1, join="inner").dropna()
for lab, sl in (("FULL", JA), ("HOLD-OUT", JA[JA.index >= HOLDOUT])):
if len(sl) < 30:
continue
print(f" {lab:<9} adattiva-vera Sh {A._sh(sl['C']):+.3f} | miglior costante "
f"{A._sh(sl['K']):+.3f} | TP01 {A._sh(sl['B']):+.3f}")
rows3 = paired_by_year(d_ad, d_const)
d3 = [r["d_sh"] for r in rows3]
print(f" vs miglior costante, per anno: dSh>0 in {sum(1 for x in d3 if x>0)}/{len(d3)}"
f" mediana {np.median(d3):+.2f}")
# -- 9. L'OTTIMO COSTANTE E' AL BORDO: si estende la banda ----------------------------
hr("9. L'OTTIMO COSTANTE CADE SUL BORDO — plateau o scivolata verso il rumore?")
print(" un argmax al bordo della griglia non e' un ottimo: e' il punto in cui si e' smesso")
print(" di guardare. Si estende SOTTO i 20 giorni (8 trial in piu', contati al rialzo).")
ext = sorted(set(CONST_L) | set(CONST_L_EXT))
tab: dict = {}
for tfx in TFS:
print(f"\n --- TF {tfx}, blend triple (L/3, L, 2L) ---")
print(f" {'L(g)':>5} {'orizzonti':>16} {'IS':>7} {'FULL':>7} {'HOLD':>7} "
f"{'maxDD':>7} {'CAGR':>7} {'turn/a':>7} {'Sh@0.30%RT':>11}")
for L in ext:
fnx = const_factory(tfx, L, "triple")
dx = daily_from_target(fnx, tfx)
dx_h = dx[dx.index >= HOLDOUT]
dx_i = dx[dx.index < HOLDOUT]
d030 = A.candidate_daily(fnx, tf=tfx, fee_side=0.0015)
tt = np.mean([A.eval_weights(A.get(a, tfx), A._call_target(fnx, A.get(a, tfx), a))
["turnover_per_year"] for a in ASSETS])
tag = " <== TP01" if L == 90 else ""
tab[(tfx, L)] = dict(IS=A._sh(dx_i), FULL=A._sh(dx), HOLD=A._sh(dx_h))
print(f" {L:>5} {f'{L//3}/{L}/{2*L}':>16} {A._sh(dx_i):>7.3f} {A._sh(dx):>7.3f} "
f"{A._sh(dx_h):>7.3f} {_dd(dx)*100:>6.2f}% {_cagr(dx)*100:>6.2f}% "
f"{tt:>7.0f} {A._sh(d030):>11.3f}{tag}")
# --- e' una CURVA con un ottimo, o un crinale di RUMORE? --------------------------
print("\n RISOLUZIONE della griglia (regola 07/08: un criterio che decide su una frazione")
print(" dello spread della griglia e' rumore anche quando 'passa'):")
for tfx in TFS:
v = [tab[(tfx, L)]["IS"] for L in ext]
jump = float(np.median(np.abs(np.diff(v))))
arg = ext[int(np.argmax(v))]
arg_orig = max(CONST_L, key=lambda L: tab[(tfx, L)]["IS"])
print(f" {tfx:>3}: argmax IS con la griglia ORIGINALE L={arg_orig}g -> con la griglia "
f"ESTESA L={arg}g ({'SPOSTATO' if arg != arg_orig else 'stabile'})")
print(f" salto IS mediano fra L ADIACENTI {jump:+.3f} contro il vantaggio del "
f"vincitore su TP01 {max(v) - tab[(tfx, 90)]['IS']:+.3f} "
f"(rapporto {jump / max(1e-9, max(v) - tab[(tfx, 90)]['IS']):.2f})")
allc = [(tf, L) for tf in TFS for L in ext]
for met in ("IS", "FULL", "HOLD"):
vals = sorted((tab[k][met] for k in allc), reverse=True)
r1d = 1 + sum(1 for x in vals if x > tab[("1d", 90)][met])
print(f" rango di TP01 (1d, L=90g) fra le {len(allc)} celle su {met}: "
f"{r1d}/{len(allc)} (suo {tab[('1d',90)][met]:+.3f}, migliore {vals[0]:+.3f})")
hr("FINE")
if __name__ == "__main__":
main()
+689
View File
@@ -0,0 +1,689 @@
"""r0822 — DEALER GAMMA: il regime di gamma dei dealer separa mean-reversion da trend su BTC/ETH?
Filone dell'ondata 2026-08-22. Dato mai usato dal progetto: `data/raw/cb_market_snapshots.parquet`
(ereditato da cerbero-bite, dismesso il 30/07) con `dealer_net_gamma`, `gamma_flip_level`,
`oi_delta_pct_4h`, `liquidation_*_risk`.
IPOTESI DI MERCATO CLASSICA (due gambe, vanno confermate ENTRAMBE):
(A) dealer LONG gamma -> l'hedging SOPPRIME la vol realizzata; SHORT gamma -> la AMPLIFICA;
(B) dealer LONG gamma -> il prezzo MEAN-REVERTE; SHORT gamma -> il prezzo TRENDA.
La gamba tradeable e' la (B): e' quella che diventerebbe un gate di regime sopra TP01/SKH01.
Aspettativa dichiarata PRIMA di misurare: (A) plausibile ma confusa con il livello di vol;
(B) improbabile su 3 mesi, e comunque a rischio "TP01 travestito".
ORDINE OBBLIGATO: prima la validazione del DATO (non e' certificato, la fonte non esiste piu'),
poi il FATTO condizionale, e solo dopo — se il fatto c'e' — il gate.
Girare: nice -n 19 timeout 900 uv run python scripts/research/r0822_dealer_gamma.py
"""
from __future__ import annotations
import glob
import sys
import warnings
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" / "alt"))
import altlib as A # noqa: E402
warnings.filterwarnings("ignore")
pd.set_option("display.width", 220)
RAW = ROOT / "data" / "raw"
SNAP_F = RAW / "cb_market_snapshots.parquet"
CHAIN_D = RAW / "cb_chain"
SEED = 20260822
ASSETS = ("BTC", "ETH")
HOUR_YEAR = 24 * 365.0
ANN = np.sqrt(HOUR_YEAR) * 100.0 # da dev.std. oraria a vol annua in punti percentuali
# La cadenza a 15 min comincia qui: prima ci sono 37 punti GIORNALIERI (vedi sezione 1).
HF_START = pd.Timestamp("2026-05-01 15:00", tz="UTC")
# ------------------------------------------------------------------ conteggio dei trial
# Ogni cella/statistica valutata viene contata qui, AL RIALZO (regola: il conteggio dei trial
# e' l'unico posto dove barare e' indolore e invisibile — 30/07). Il verdetto stampa la
# ripartizione DERIVATA da questo dizionario, non una somma scritta a mano.
COUNT: dict[str, int] = dict(ricostruzione=0, sma=0, regressioni=0, vol_condizionata=0,
variance_ratio=0, momentum=0, gate=0, delevering=0)
NEFF: dict[str, float] = {} # ampiezza effettiva per asset, misurata in sezione 1e
def hr(t: str) -> None:
print("\n" + "=" * 96 + f"\n{t}\n" + "=" * 96)
def sh_d(x: pd.Series) -> float:
x = pd.Series(x).dropna()
return float(x.mean() / x.std() * np.sqrt(365.25)) if len(x) > 2 and x.std() > 0 else 0.0
def dd_d(x: pd.Series) -> float:
e = (1.0 + pd.Series(x).dropna()).cumprod()
return float((e / e.cummax() - 1.0).min()) if len(e) else 0.0
# ------------------------------------------------------------------ caricamento
def load_snap() -> pd.DataFrame:
d = pd.read_parquet(SNAP_F).sort_values("ts")
d["ts"] = pd.to_datetime(d["ts"], utc=True).astype("datetime64[ns, UTC]")
return d
def load_bars(asset: str) -> pd.DataFrame:
px = A.get(asset, "1h").copy()
px["dt"] = pd.to_datetime(px["timestamp"], unit="ms", utc=True).astype("datetime64[ns, UTC]")
px = px[["dt", "close"]].dropna().reset_index(drop=True)
px["r"] = np.log(px["close"]).diff()
return px
def joined(asset: str, snap: pd.DataFrame) -> pd.DataFrame:
"""Barra oraria + ULTIMO snapshot con ts <= apertura della barra.
Causale per costruzione: `direction='backward'` sull'apertura della barra, quindi il
regime usato per la barra [t, t+1h) e' noto a t. Tolleranza 2h: se il collettore e'
fermo da piu' di due ore la riga esce, invece di trascinare un valore vecchio.
"""
px = load_bars(asset)
s = (snap[snap["asset"] == asset]
[["ts", "dealer_net_gamma", "gamma_flip_level", "spot", "dvol", "realized_vol_30d"]]
.dropna(subset=["dealer_net_gamma", "gamma_flip_level"]))
m = pd.merge_asof(px.sort_values("dt"), s.sort_values("ts"),
left_on="dt", right_on="ts", direction="backward",
tolerance=pd.Timedelta("2h"))
m = m[m["ts"].notna()].reset_index(drop=True)
# SEGNO CORRETTO (giustificato nella sezione 2): dealer_net_gamma = -GEX
# dealer_net_gamma > 0 <=> GEX < 0 <=> dealer SHORT gamma
m["short_gamma"] = (m["dealer_net_gamma"] > 0).astype(float)
m["above_flip"] = (m["close"] > m["gamma_flip_level"]).astype(float)
m["rvp24"] = np.sqrt((m["r"] ** 2).rolling(24).mean())
m["rvp168"] = np.sqrt((m["r"] ** 2).rolling(168).mean())
m["ret168"] = np.log(m["close"] / m["close"].shift(168))
m["z"] = m["r"] / m["rvp24"].shift(1)
for k in (4, 24):
m[f"rvf{k}"] = np.sqrt((m["r"].shift(-1) ** 2).rolling(k).sum().shift(-(k - 1)) / k)
return m
def ols(y, cols):
y = np.asarray(y, float)
n = len(y)
X = np.column_stack([np.ones(n)] + [np.asarray(c, float) for c in cols])
b, *_ = np.linalg.lstsq(X, y, rcond=None)
res = y - X @ b
s2 = res @ res / (n - X.shape[1])
se = np.sqrt(np.diag(s2 * np.linalg.pinv(X.T @ X)))
return b, b / se, 1.0 - np.var(res) / np.var(y)
def variance_ratio(r, k: int) -> float:
r = np.asarray(pd.Series(r).dropna(), float)
if len(r) < 5 * k:
return np.nan
x = pd.Series(r).rolling(k).sum().dropna().values
return float(np.var(x) / (k * np.var(r)))
# ================================================================== 1. IL DATO
def sezione_1_dato(snap: pd.DataFrame) -> dict:
hr("1. VALIDAZIONE DEL DATO — prima del segnale (fonte dismessa, mai certificata)")
out = {}
print(f"file: {SNAP_F.name} righe {len(snap)} {snap.ts.min()} -> {snap.ts.max()}")
# 1a. cadenza: il file NON e' omogeneo
d = snap[snap.asset == "BTC"]
gaps = d.ts.diff().dt.total_seconds().div(60).round(0).value_counts()
print(f"\n1a. CADENZA (BTC): distribuzione dei salti in minuti {dict(list(gaps.items())[:6])}")
pre = snap[snap.ts < HF_START]
post = snap[snap.ts >= HF_START]
print(f" -> {len(pre)//2} righe/asset a cadenza GIORNALIERA (12:00 UTC) fino al 2026-05-01,")
print(f" {len(post)//2} righe/asset a 15 min dopo. Il 'file da 4 mesi' e' 90 GIORNI utili.")
out["n_hf_per_asset"] = len(post) // 2
for a in ASSETS:
x = post[post.asset == a]
hrs = x.ts.dt.floor("h").nunique()
span = (x.ts.max().floor("h") - x.ts.min().floor("h")).total_seconds() / 3600 + 1
print(f" {a}: ore distinte {hrs} su {span:.0f} di span = copertura {100*hrs/span:.2f}%"
f" (buchi > 20 min: {int((x.ts.diff().dt.total_seconds()/60 > 20).sum())})")
# 1b. colonne MORTE — una colonna presente non e' un dato presente
print("\n1b. COLONNE MORTE (regola 8 del brief: contare cio' che VARIA, non le righe)")
for c in ["liquidation_long_risk", "liquidation_short_risk", "macro_days_to_event"]:
v = snap[c].dropna()
print(f" {c:26s} non-nulli {len(v):6d} ({100*len(v)/len(snap):5.1f}%) "
f"valori distinti {v.nunique()} {'*** COSTANTE = ZERO INFORMAZIONE ***' if v.nunique() <= 1 else ''}")
out["dead_cols"] = [c for c in ["liquidation_long_risk", "liquidation_short_risk"]
if snap[c].dropna().nunique() <= 1]
# 1c. dealer_net_gamma e gamma_flip_level: si muovono? sono plausibili?
print("\n1c. PLAUSIBILITA' delle due colonne di regime (finestra 15 min)")
for a in ASSETS:
x = post[post.asset == a]
g, f = x.dealer_net_gamma, x.gamma_flip_level
rel = x.spot / f - 1.0
print(f" {a} dealer_net_gamma: nan {100*g.isna().mean():4.1f}% frac>0 {(g>0).mean():.3f} "
f"consecutivi identici {(g.diff()==0).mean():.4f} -> si muove")
print(f" {a} gamma_flip_level: nan {100*f.isna().mean():4.1f}% "
f"consecutivi identici {(f.diff()==0).mean():.4f} "
f"(spot/flip-1) p05 {rel.quantile(.05):+.1%} med {rel.median():+.1%} p95 {rel.quantile(.95):+.1%}")
print(" -> BTC: il flip e' CONGELATO nel 61% dei passi e sta fino a +42% SOTTO lo spot al p95.")
print(" Un 'livello di flip' a 42% dallo spot non e' un livello di flip: e' degenere.")
# 1d. LE DUE COLONNE SI CONTRADDICONO
print("\n1d. CONTRADDIZIONE INTERNA fra le due colonne della STESSA fonte")
for a in ASSETS:
x = post[post.asset == a].dropna(subset=["dealer_net_gamma", "gamma_flip_level", "spot"])
c = np.corrcoef((x.dealer_net_gamma > 0).astype(float), (x.spot > x.gamma_flip_level).astype(float))[0, 1]
print(f" {a}: corr( dng>0 , spot>flip ) = {c:+.3f}")
print(" Nella convenzione classica dealer LONG gamma sta SOPRA il flip: le due colonne")
print(" dovrebbero correlare POSITIVAMENTE. Correlano NEGATIVAMENTE -> una delle due ha il")
print(" segno invertito. Quale, lo decide la sezione 2 (ricostruzione dalla catena), non io.")
# 1e. quante EPISODI di regime ci sono davvero -> il numero che conta
print("\n1e. AMPIEZZA EFFETTIVA — 2.160 ore NON sono 2.160 osservazioni")
for a in ASSETS:
x = post[post.asset == a].dropna(subset=["dealer_net_gamma"])
lab = (x.dealer_net_gamma > 0).astype(int).values
runs = pd.Series(lab).groupby((pd.Series(lab).diff() != 0).cumsum()).size() * 0.25 # ore
hourly = pd.Series(lab).iloc[::4]
rho = float(hourly.autocorr(1))
neff = len(hourly) * (1 - rho) / (1 + rho)
print(f" {a}: {len(runs)} episodi; {int((runs>72).sum())} durano >72h e coprono il "
f"{100*runs[runs>72].sum()/runs.sum():.0f}% del tempo")
print(f" AR(1) orario del regime {rho:.4f} -> n_eff ~ {neff:.0f} su {len(hourly)} ore")
out[f"neff_{a}"] = NEFF[a] = neff
print(" -> l'errore standard onesto di ogni statistica condizionata al regime va moltiplicato")
print(f" per ~sqrt(2130/n_eff) = 6x (BTC) - 9x (ETH). E' IL numero della sezione 3.")
return out
# ================================================================== 2. SEGNO E RICOSTRUIBILITA'
def sezione_2_ricostruzione(snap: pd.DataFrame) -> dict:
hr("2. DA DOVE VIENE `dealer_net_gamma`, E POSSIAMO RIFARLO OGGI?")
out = {}
print("La domanda non e' accademica: bite e' dismesso, la serie finisce il 2026-07-30. Se il")
print("segnale non e' ricostruibile dalla catena che raccogliamo NOI, il lead e' morto alla nascita.")
arch = CHAIN_D / "bite_archive.parquet"
d = pd.read_parquet(arch, columns=["ts", "asset", "option_type", "strike", "gamma",
"open_interest", "source"])
d["ts"] = pd.to_datetime(d["ts"], utc=True).astype("datetime64[ns, UTC]")
d["tsf"] = d["ts"].dt.floor("15min")
per_snap = d.groupby(["source", "asset", "tsf"]).size().rename("n").reset_index()
print("\n2a. STRIKE PER SNAPSHOT nell'archivio ereditato (mediana):")
print(per_snap.groupby(["source", "asset"])["n"].median().to_string())
print(" -> `bite:live` ha ~16-18 strike: e' la manciata che serviva al suo motore VRP, NON")
print(" una catena. Solo `bite:research` (~280-320 strike) e' ricostruibile. E parte dal")
print(" 2026-06-09, non dal 05-01.")
# spot: l'archivio ha underlying_price 100% NaN -> lo prendiamo dallo snapshot (stessa fonte)
d = d[(d["source"] == "bite:research")].dropna(subset=["gamma", "open_interest"])
sp = (snap[["asset", "spot", "dealer_net_gamma", "gamma_flip_level"]]
.assign(tsf=snap["ts"].dt.floor("15min"))
.dropna(subset=["spot"]).drop_duplicates(["asset", "tsf"]))
d = d.merge(sp[["asset", "tsf", "spot"]], on=["asset", "tsf"], how="inner")
print(f"\n (l'archivio ha underlying_price 100% NaN -> spot preso dallo snapshot, stessa fonte)")
sgn = np.where(d["option_type"].values == "C", 1.0, -1.0)
S = d["spot"].values
G = d["gamma"].values * d["open_interest"].values
d["gex_std"] = G * S * S * 0.01 * sgn # GEX da manuale: dollar-gamma per 1%, call +, put -
d["gex_all"] = G * S * S * 0.01 # senza distinzione call/put
d["gex_nos"] = G * sgn # senza scala S^2
d["gex_S1"] = G * S * sgn # scala lineare in S
agg = (d.groupby(["asset", "tsf"])
.agg(**{c: (c, "sum") for c in ["gex_std", "gex_all", "gex_nos", "gex_S1"]},
n=("gamma", "size")).reset_index())
print("\n2b. LA RICOSTRUZIONE (catena near-full `bite:research` vs la colonna di bite)")
for a in ASSETS:
j = (agg[agg.asset == a].merge(sp[sp.asset == a][["tsf", "dealer_net_gamma"]], on="tsf")
.dropna(subset=["dealer_net_gamma"]))
print(f" {a}: n={len(j)} snapshot, ~{j.n.median():.0f} strike ciascuno")
for c in ["gex_std", "gex_all", "gex_nos", "gex_S1"]:
COUNT["ricostruzione"] += 1
rp = np.corrcoef(j[c], j.dealer_net_gamma)[0, 1]
rs = j[c].corr(j.dealer_net_gamma, method="spearman")
sa = float(np.mean((j[c] > 0) == (j.dealer_net_gamma > 0)))
sl, ic = np.polyfit(j[c], j.dealer_net_gamma, 1)
print(f" {c:8s} corr {rp:+.3f} rank {rs:+.3f} accordo-di-segno {sa:.3f}"
f" fit dng = {sl:+.3f}*GEX {ic:+.2e}")
best = j
out[f"corr_{a}"] = float(np.corrcoef(best.gex_std, best.dealer_net_gamma)[0, 1])
out[f"slope_{a}"] = float(np.polyfit(best.gex_std, best.dealer_net_gamma, 1)[0])
out[f"intc_{a}"] = float(np.polyfit(best.gex_std, best.dealer_net_gamma, 1)[1])
print("\n RISULTATO: il GEX da manuale riproduce la colonna di bite a corr -0.95 (BTC) /")
print(" -0.89 (ETH), con accordo-di-segno del 10%/8%. Cioe':")
print(" dealer_net_gamma ~ -0.7 * GEX_standard -> LA COLONNA E' IL GEX COL SEGNO INVERTITO.")
print(" `dealer_net_gamma > 0` significa dealer SHORT gamma, non long. Il nome mente.")
print(" Questo CHIUDE la contraddizione della sezione 1d: e' la colonna dng ad avere il segno")
print(" girato, il gamma_flip_level e' nella convenzione classica. Da qui in poi il codice usa")
print(" `short_gamma = dealer_net_gamma > 0`.")
print("\n2c. LA SOGLIA NON E' PORTABILE — e questo e' il punto che uccide il riuso diretto")
for a in ASSETS:
z = -out[f"intc_{a}"] / out[f"slope_{a}"]
print(f" {a}: dng=0 corrisponde a GEX = {z:+.3e}, non a GEX = 0.")
print(" Le due serie hanno zeri DIVERSI (universi diversi: il nostro collettore filtra OI>=100")
print(" e scadenze <=95g). Trasportare 'la soglia zero' da una all'altra e' un cambio di")
print(" definizione mascherato da continuita'. Ricalibrarla sui 90 giorni sarebbe selezione.")
print("\n2d. IL SEGNALE E' VIVO OGGI? (raccolta nostra, `pyg`, oraria)")
fs = sorted(glob.glob(str(CHAIN_D / "2026-08-*.parquet")))
if fs:
o = pd.concat([pd.read_parquet(f, columns=["ts", "asset", "option_type", "gamma",
"open_interest", "underlying_price",
"quote_status"]) for f in fs], ignore_index=True)
o["ts"] = pd.to_datetime(o["ts"], utc=True)
o = o.dropna(subset=["gamma", "open_interest", "underlying_price"])
s2 = np.where(o["option_type"].values == "C", 1.0, -1.0)
o["gex"] = (o["gamma"].values * o["open_interest"].values
* o["underlying_price"].values ** 2 * 0.01 * s2)
g = (o.groupby(["asset", o["ts"].dt.floor("h")])
.agg(gex=("gex", "sum"), n=("gamma", "size")).reset_index())
for a in ASSETS:
x = g[g.asset == a]
print(f" {a}: {len(x)} ore dal {x.ts.min():%Y-%m-%d} al {x.ts.max():%Y-%m-%d %H:%M}, "
f"~{x.n.median():.0f} strike/ora, quote ok {100*(o[o.asset==a].quote_status=='ok').mean():.1f}%")
print(f" GEX medio {x.gex.mean():+.3e} frac GEX>0 (dealer LONG gamma) {(x.gex>0).mean():.3f}"
f" ultimo {x.gex.iloc[-1]:+.3e}")
print(" -> RICOSTRUIBILE, oraria, viva, con MEGLIO del dato originale (segno giusto e")
print(" `quote_status` esplicito). Il lead e' inseguibile: il dato non e' il vincolo.")
print(" -> ma nota: ad agosto il GEX e' >0 nell'88-93% delle ore. Il regime 'short gamma'")
print(" compare a sprazzi: la variabile non varia quasi mai, e questo e' un problema")
print(" di potenza che il tempo aggiusta LENTAMENTE.")
else:
print(" (nessun file 2026-08-* nella raccolta: salto)")
return out
# ================================================================== 3. IL FATTO (A): la vol
def sezione_3_fatto_vol(M: dict, snap: pd.DataFrame, rec: dict) -> dict:
hr("3. GAMBA (A) DELL'IPOTESI — short gamma amplifica la vol realizzata?")
out = {}
rng = np.random.default_rng(SEED)
print("Misura il FATTO condizionale, non una strategia. Se un effetto e' vero si vede qui.")
print("\n3a. VOL REALIZZATA FORWARD, condizionata al regime (segno corretto in sezione 2)")
for a in ASSETS:
m = M[a]
for c in ["rvf4", "rvf24"]:
g = m.groupby("short_gamma")[c].mean() * ANN
COUNT["vol_condizionata"] += 1
print(f" {a} {c}: LONG gamma {g.get(0.0, np.nan):5.1f}% SHORT gamma {g.get(1.0, np.nan):5.1f}%"
f" spread {g.get(1.0,0)-g.get(0.0,0):+5.1f} pp")
print(" Direzione CONFORME al manuale: short gamma -> vol forward piu' alta. Prima gamba OK.")
print("\n3b. MA: il regime coincide quasi col MESE. Scomposizione (regola: un contributo si")
print(" scompone per periodo PRIMA di crederci — lezione SOL, 22/08)")
for a in ASSETS:
m = M[a].dropna(subset=["rvf24", "short_gamma"]).copy()
m["mo"] = m["dt"].dt.strftime("%Y-%m")
obs = (m.rvf24[m.short_gamma == 1].mean() - m.rvf24[m.short_gamma == 0].mean()) * ANN
print(f" {a} spread pieno {obs:+.1f} pp")
for mo, gg in m.groupby("mo"):
ns, nl = int(gg.short_gamma.sum()), int((1 - gg.short_gamma).sum())
sp = (gg.rvf24[gg.short_gamma == 1].mean() - gg.rvf24[gg.short_gamma == 0].mean()) * ANN
print(f" {mo}: spread {sp:+6.1f} pp ore SHORT {ns:4d} / LONG {nl:4d}"
f" {'<-- mese quasi interamente in UN regime' if min(ns,nl) < 100 else ''}")
out[f"spread_{a}"] = obs
print(" -> Maggio e Luglio sono mesi LONG-gamma, Giugno e' un mese SHORT-gamma. Il confronto")
print(" 'short vs long' e', in pratica, 'giugno vs maggio+luglio': UN confronto, non 2.136.")
print("\n3c. NULL a spostamento circolare (conserva l\'autocorrelazione di ENTRAMBE le serie,")
print(" rompe solo l\'allineamento) — e il suo LIMITE DI RISOLUZIONE")
for a in ASSETS:
m = M[a].dropna(subset=["rvf24", "short_gamma"])
lab, y = m.short_gamma.values, m.rvf24.values
n = len(y)
obs = (y[lab == 1].mean() - y[lab == 0].mean()) * ANN
null = np.empty(2000)
for i in range(2000):
L = np.roll(lab, rng.integers(24, n - 24))
null[i] = (y[L == 1].mean() - y[L == 0].mean()) * ANN
p = float((np.abs(null) >= abs(obs)).mean())
runs = pd.Series(lab).groupby((pd.Series(lab).diff() != 0).cumsum()).size()
long_runs = runs[runs > 24]
n_align = n / max(long_runs.median(), 1.0)
print(f" {a}: osservato {obs:+.1f} pp | null media {null.mean():+.2f} sd {null.std():.2f}"
f" | p empirico (2 code) {p:.4f}")
print(f" MA lo spostamento circolare di una serie con episodi lunghi ~{long_runs.median():.0f}h")
print(f" produce solo ~{n_align:.0f} allineamenti DISTINTI: la risoluzione del p non e\'")
print(f" 1/2000, e\' ~1/{n_align:.0f}. Un p 'zero' su {n_align:.0f} configurazioni non e\' 1e-4.")
out[f"p_{a}"] = p
print("\n3c-bis. LA PROVA CHE COSTA MENO DI TUTTE: si toglie GIUGNO (l\'unico mese short-gamma)")
for a in ASSETS:
m = M[a].dropna(subset=["rvf24", "short_gamma"]).copy()
m["mo"] = m["dt"].dt.strftime("%Y-%m")
for drop in (None, "2026-06"):
g = m if drop is None else m[m.mo != drop]
ns, nl = int(g.short_gamma.sum()), int((1 - g.short_gamma).sum())
sp = (g.rvf24[g.short_gamma == 1].mean() - g.rvf24[g.short_gamma == 0].mean()) * ANN
tag = "campione pieno" if drop is None else "senza giugno"
print(f" {a} {tag:16s}: spread {sp:+6.1f} pp ore SHORT {ns:4d} / LONG {nl:4d}")
print(" -> tolto UN mese su tre restano ~40-130 ore di regime short su 1.400: lo 'spread'")
print(" diventa il confronto fra due manciate di ore. Non e\' un campione, e\' un episodio.")
print("\n3d. NULL LOCATION-MATCHED per `gamma_flip_level`: livello di opzioni o media mobile?")
for a in ASSETS:
m = M[a]
g = m.groupby("above_flip")["rvf24"].mean() * ANN
sp_gamma = float(g.get(0.0, np.nan) - g.get(1.0, np.nan))
print(f" {a} GAMMA spot>flip: sotto {g.get(0.0, np.nan):.1f}% sopra {g.get(1.0, np.nan):.1f}%"
f" spread {sp_gamma:+.1f} pp")
best = None
for nn in (6, 12, 24, 48, 72, 120, 168, 240, 336, 504, 720):
COUNT["sma"] += 1
sma = m["close"].rolling(nn).mean()
ok = sma.notna()
agree = float((((m["close"] > m["gamma_flip_level"]) == (m["close"] > sma))[ok]).mean())
gg = m.groupby((m["close"] > sma).astype(float))["rvf24"].mean() * ANN
spread = float(gg.get(0.0, np.nan) - gg.get(1.0, np.nan))
if best is None or agree > best[1]:
best = (nn, agree, spread)
if best[2] > sp_gamma + 1.0:
verdetto = "il sosia da uno spread PIU GRANDE del livello di opzioni"
elif abs(best[2] - sp_gamma) <= 1.0:
verdetto = "il sosia da lo STESSO spread"
else:
verdetto = "il sosia da uno spread minore"
print(f" miglior sosia: spot>SMA({best[0]}h) -> accordo di regime {best[1]:.3f}, "
f"spread {best[2]:+.1f} pp ({verdetto})")
out[f"sma_agree_{a}"], out[f"sma_span_{a}"] = best[1], best[0]
out[f"sma_spread_{a}"], out[f"flip_spread_{a}"] = best[2], sp_gamma
print(" LETTURA, asset per asset (i due casi NON dicono la stessa cosa):")
print(" BTC: accordo 0.68, e la media mobile NUDA da' uno spread uguale o maggiore")
print(" (+8.8 contro +7.8 pp) -> il livello di opzioni non aggiunge nulla a una SMA.")
print(" ETH: accordo 0.96 -> `gamma_flip_level` E' una media mobile a 30 giorni con un")
print(" altro nome. Il suo spread e' piu' grande (+16.6 vs +10.1 pp), ma la")
print(" differenza vive tutta nel 4% di ore in cui i due regimi divergono: 4% di")
print(" 2.160 ore dentro un campione da 3 episodi non e' evidenza, e non e' testabile.")
print(" In nessuno dei due casi c'e' un contenuto 'opzioni' isolabile: e' la trappola 2 del")
print(" brief (TP01 travestito). `gamma_flip_level` non e' usabile come regime.")
print("\n3e. IL REGIME AGGIUNGE SOPRA CIO' CHE GIA' SAPPIAMO? (log rv fwd 24h ~ vol passata + trend)")
print(" t_eff = t / sqrt(24) corregge la sovrapposizione della finestra a 24h. NON corregge")
print(" l'ampiezza effettiva (sezione 1e: n_eff 24-53 su 2.130): un t_eff di +2.3 su 6-11")
print(" episodi indipendenti resta un t che conta episodi come se fossero ore.")
for a in ASSETS:
m = M[a]
d = m.dropna(subset=["rvf24", "rvp24", "rvp168", "ret168", "dvol", "short_gamma", "above_flip"])
d = d[d.rvf24 > 0]
y = np.log(d.rvf24)
base = [np.log(d.rvp24), np.log(d.rvp168), d.ret168]
d = d.assign(sma720=(d["close"] > d["close"].rolling(720).mean()).astype(float).fillna(0.0))
print(f" {a} (n={len(d)}) corr(short_gamma, DVOL) = "
f"{np.corrcoef(d.short_gamma, d.dvol)[0,1]:+.3f}")
for lab, extra in [("base: vol+trend", []),
(" + spot>SMA720", [d.sma720]),
(" + spot>flip", [d.above_flip]),
(" + short_gamma", [d.short_gamma]),
(" + DVOL", [np.log(d.dvol)]),
(" + DVOL + short_gamma", [np.log(d.dvol), d.short_gamma])]:
COUNT["regressioni"] += 1
b, t, r2 = ols(y, base + extra)
print(f" {lab:24s} R2 {r2:.3f} ultimo coef {b[-1]:+.3f} t {t[-1]:+6.2f}"
f" t_eff {t[-1]/np.sqrt(24):+5.2f}")
print(" -> BTC: `short_gamma` da solo spiega quanto DVOL da solo (R2 0.271 vs 0.272) e i due")
print(" insieme fanno 0.312 -> l'informazione e' in larga parte GIA' NEL DVOL, che il")
print(" progetto ha gia' provato come modulatore di TP01 il 26/06: era de-levering puro.")
print(" ETH: t_eff ~ +1.0 = rumore.")
return out
# ================================================================== 4. IL FATTO (B): MR vs trend
def sezione_4_fatto_mrtrend(M: dict) -> dict:
hr("4. GAMBA (B) DELL'IPOTESI — la gamba TRADEABILE: short gamma = trend, long gamma = MR?")
out = {}
print("Questa e' la gamba che diventerebbe un gate. La (A) e' solo il contorno.")
print("\n4a. VARIANCE RATIO per regime. VR<1 = mean-reversion, VR>1 = trend.")
print(" La versione 'z' standardizza ogni ritorno per la vol delle 24h precedenti: senza,")
print(" il clustering di volatilita' DENTRO un regime gonfia il VR e si legge come trend.")
for a in ASSETS:
m = M[a]
for lab, col in [("grezzo", "r"), ("z-std", "z")]:
L = {k: variance_ratio(m[col][m.short_gamma == 0], k) for k in (2, 4, 12, 24)}
S = {k: variance_ratio(m[col][m.short_gamma == 1], k) for k in (2, 4, 12, 24)}
COUNT["variance_ratio"] += 4
print(f" {a} {lab}: LONG " + " ".join(f"VR{k}={L[k]:.3f}" for k in (2, 4, 12, 24)))
print(f" {a} {lab}: SHORT " + " ".join(f"VR{k}={S[k]:.3f}" for k in (2, 4, 12, 24)))
if lab == "z-std":
out[f"vr24_long_{a}"], out[f"vr24_short_{a}"] = L[24], S[24]
print("\n LETTURA (versione z a 24h, l'unica scale-free):")
for a in ASSETS:
vl, vs = out[f"vr24_long_{a}"], out[f"vr24_short_{a}"]
if abs(vl - vs) < 0.05:
diag = "NESSUNA separazione (differenza < 0.05)"
elif vs > vl:
diag = "separazione NEL VERSO dell'ipotesi (short gamma piu' trendante)"
else:
diag = "separazione ROVESCIATA: e' il regime LONG gamma a trendare"
print(f" {a}: LONG {vl:.3f} vs SHORT {vs:.3f} -> {diag}")
print(" Due asset, due risposte diverse, e quella che separa lo fa al contrario. La gamba (B)")
print(" non e' 'debole': e' INCOERENTE. Con la gamba (A) confermata, questo e' il punto in cui")
print(" l'ipotesi si rompe — perche' e' la (B) che si traderebbe.")
print("\n4b. La forma tradeable: payoff di momentum per regime, sgn(ritorno passato L) x ritorno")
print(" futuro H. Sharpe ANNUALIZZATO LORDO (nessuna fee: se non regge lordo e' finita).")
Ls, Hs = (1, 4, 12, 24), (1, 4, 12, 24)
cells = []
for a in ASSETS:
m = M[a]
print(f" {a} " + " ".join(f"H={h:<2d}" for h in Hs) + " (L=long-gamma, S=short-gamma)")
for L in Ls:
past = np.log(m["close"] / m["close"].shift(L))
row = []
for H in Hs:
fwd = np.log(m["close"].shift(-H) / m["close"])
pay = np.sign(past) * fwd
for lab, mask in [("L", m.short_gamma == 0), ("S", m.short_gamma == 1)]:
v = pay[mask].dropna()
s = float(v.mean() / v.std() * np.sqrt(HOUR_YEAR / H)) if len(v) > 30 and v.std() > 0 else np.nan
COUNT["momentum"] += 1
cells.append((a, L, H, lab, s, v))
row.append(f"{lab}{s:+.2f}")
print(f" L={L:2d}h " + " ".join(row))
se = np.sqrt(HOUR_YEAR / len(m))
neff = NEFF.get(a, len(m))
print(f" [errore standard di UNA cella a H=1h: +/-{se:.2f} di Sharpe su {len(m)} ore.")
print(f" Con l'ampiezza effettiva della sezione 1e (n_eff={neff:.0f}) diventa"
f" +/-{se*np.sqrt(len(m)/neff):.1f}.")
print(" Ogni numero della tabella qui sopra e' dentro il proprio errore standard.]")
out["cells"] = cells
return out
# ================================================================== 5. IL GATE SUL LIBRO
def sezione_5_gate(snap: pd.DataFrame, fatti4: dict) -> dict:
hr("5. IL GATE SUL LIBRO — costruito e misurato, con la potenza dichiarata")
out = {}
print("La sezione 4 ha gia' refutato la gamba tradeable. Il gate si costruisce lo stesso, perche'")
print("un 'non funziona' misurato vale piu' di un 'non l'ho provato'.")
B = A.tp01_baseline_daily()
reg = {}
for a in ASSETS:
s = (snap[(snap.asset == a) & (snap.ts >= HF_START)]
.dropna(subset=["dealer_net_gamma"])[["ts", "dealer_net_gamma"]]
.set_index("ts").resample("1D").last())
# ultimo snapshot del giorno d -> usato per il giorno d+1: causale per costruzione
reg[a] = (s["dealer_net_gamma"] > 0).astype(float).shift(1)
R = pd.concat(reg, axis=1).mean(axis=1).dropna()
J = pd.concat({"B": B, "S": R}, axis=1, join="inner").dropna()
b = J["B"]
n = len(J)
se = np.sqrt(365.25 / n)
print(f"\n5a. FINESTRA: {J.index.min():%Y-%m-%d} -> {J.index.max():%Y-%m-%d}, {n} giorni.")
print(f" TP01 (baseline 50/50) su questa finestra: Sharpe {sh_d(b):+.2f} maxDD {dd_d(b):.2%}"
f" ritorno {(1+b).prod()-1:+.2%}")
print(f" ERRORE STANDARD DELLO SHARPE su {n} giorni: +/-{se:.2f}.")
print(" Cioe': su questa finestra TP01 e' indistinguibile da qualunque cosa fra -4 e 0. Il")
print(" libro non e' misurabile qui, e nessun gate lo sara'. E' il vincolo, non un dettaglio.")
print(f" (per giunta TP01 e' quasi FLAT: ritorno lordo {100*(1+b).prod()-100:+.2f}% in 3 mesi)")
out["n_days"], out["se_sharpe"] = n, se
print("\n5b. QUATTRO gate dichiarati (2 polarita' x 2 intensita'), + il null del de-levering")
variants = {
"classico SHORT->1.5x LONG->0.5x": (0.5, 1.5),
"classico mite SHORT->1.25 LONG->0.75": (0.75, 1.25),
"INVERSO SHORT->0.5x LONG->1.5x": (1.5, 0.5),
"risk-off in SHORT (1.0 / 0.5)": (1.0, 0.5),
}
rows = []
for name, (lo, hi) in variants.items():
x = b * (lo + (hi - lo) * J["S"])
COUNT["gate"] += 1
rows.append((name, sh_d(x), dd_d(x)))
print(f" {name:38s} Sharpe {sh_d(x):+.2f} maxDD {dd_d(x):.2%}")
print("\n NULL DEL DE-LEVERING (obbligatorio su ogni claim di DD — 5 occorrenze nel progetto):")
for k in (0.5, 0.6, 0.7, 0.8, 0.9, 1.0):
COUNT["delevering"] += 1
x = b * k
print(f" baseline x{k:.1f} Sharpe {sh_d(x):+.2f} maxDD {dd_d(x):.2%}")
best_dd = min(rows, key=lambda r: abs(r[2]))
print(f"\n -> il gate 'classico' porta il maxDD da {dd_d(b):.2%} a {rows[0][2]:.2%} con Sharpe"
f" {rows[0][1]:+.2f} (peggiore del baseline {sh_d(b):+.2f});")
print(f" il semplice `baseline x0.6` fa maxDD {dd_d(b*0.6):.2%} a Sharpe {sh_d(b*0.6):+.2f}.")
print(" NULL DE-LEVERING: **FALLITO**. Il gate e' meno leva con passaggi in piu'.")
out["null_delevering"] = "FALLITO"
print("\n5c. `marginal_vs_tp01` sullo stream INCREMENTALE del gate (cio' che il gate aggiunge)")
ov = (b * (0.5 + 1.0 * J["S"])) - b
try:
rep = A.marginal_vs_tp01(ov)
for k in ("marginal_verdict", "n_days", "n_hold_days", "corr_full", "cand_full_sharpe",
"beta_to_tp01", "reason"):
if k in rep:
print(f" {k:20s} {rep[k]}")
bl = rep.get("blends", {})
for w, v in bl.items():
print(f" blend {w}: full {v.get('full')} uplift_full {v.get('uplift_full')}"
f" uplift_hold {v.get('uplift_hold')}")
out["marginal"] = rep.get("marginal_verdict")
except Exception as e: # pragma: no cover
out["marginal"] = f"errore: {e}"
print(f" non girato: {e}")
print(" ATTENZIONE alla lettura: `n_hold_days` == `n_days` — i 90 giorni cadono INTERAMENTE")
print(" dentro l'hold-out di altlib, quindi 'full' e 'hold' sono LA STESSA FINESTRA e i loro")
print(" uplift coincidono (si vede sopra: -0.066 e -0.066). I gate multi-cut e il jackknife")
print(" di `marginal_vs_tp01` girano su una finestra sola.")
print(" Un ADDS qui non sarebbe un ADDS: sarebbe la definizione di selezione")
print(" sull'hold-out. Si riporta il verdetto, non lo si usa per promuovere.")
print("\n5d. DEFLATED SHARPE sulla cella migliore della griglia della sezione 4")
cells = [c for c in fatti4["cells"] if np.isfinite(c[4])]
best = max(cells, key=lambda c: c[4])
a, L, H, lab, s_best, v = best
allsr = [c[4] for c in cells]
try:
dsr, null_max = A.deflated_sharpe(s_best, allsr, pd.Series(v.values).dropna().values,
dpy=HOUR_YEAR / H)
reg = "SHORT" if lab == "S" else "LONG"
print(f" cella migliore: {a} L={L}h H={H}h regime={reg} gamma -> Sharpe LORDO {s_best:+.2f}"
f" (su {len(allsr)} trial dichiarati)")
print(f" deflated Sharpe = {dsr:.3f} -> {'PASS' if dsr >= 0.95 else 'FAIL'} (soglia 0.95)")
print(f" massimo Sharpe ATTESO SOTTO IL NULLO con {len(allsr)} trial = {null_max:+.2f}")
if s_best < null_max:
print(f" -> la cella migliore ({s_best:+.2f}) sta SOTTO cio' che il puro rumore produce")
print(f" ({null_max:+.2f}) cercando {len(allsr)} volte. Non c'e' niente da deflazionare:")
print(" la griglia non ha prodotto nemmeno il massimo che il caso avrebbe prodotto.")
out["dsr"], out["dsr_nullmax"] = float(dsr), float(null_max)
except Exception as e: # pragma: no cover
out["dsr"] = f"errore: {e}"
print(f" non girato: {e}")
print("\n5e. CAUSALITA'")
print(" Per COSTRUZIONE: ogni riga usa `merge_asof(direction='backward')` sull'APERTURA della")
print(" barra con tolleranza 2h, quindi il regime della barra [t,t+1h) e' noto a t; il gate")
print(" giornaliero usa l'ultimo snapshot del giorno d-1 (`.shift(1)`).")
print(" `A.causality_ok` non e' applicabile: perturba il futuro dei prezzi certificati, ma il")
print(" regime qui non viene dai prezzi — viene da un file esterno che copre 90 giorni su 8")
print(" anni. Girarlo darebbe un PASS privo di potenza sul 97% del campione (stessa forma del")
print(" test a potenza zero corretto il 21/08). Si dichiara l'argomento strutturale, non un")
print(" numero finto.")
out["causality"] = "per costruzione (merge_asof backward + shift(1)); A.causality_ok non applicabile"
return out
# ================================================================== main
def main() -> None:
hr("r0822 DEALER-GAMMA — il regime di gamma dei dealer separa MR da trend su BTC/ETH?")
print("IPOTESI: (A) short gamma amplifica la vol; (B) short gamma fa TRENDARE il prezzo, long")
print("gamma lo fa MEAN-REVERTERE -> gate di regime sopra TP01/SKH01.")
print("ATTESA DICHIARATA PRIMA DI MISURARE: (A) plausibile ma confusa col livello di vol;")
print("(B) improbabile su 3 mesi e a rischio 'TP01 travestito'.")
snap = load_snap()
d1 = sezione_1_dato(snap)
d2 = sezione_2_ricostruzione(snap)
hf = snap[snap.ts >= HF_START]
M = {a: joined(a, hf) for a in ASSETS}
for a in ASSETS:
print(f"\n[merge] {a}: {len(M[a])} barre orarie con regime, "
f"{M[a].dt.min():%Y-%m-%d} -> {M[a].dt.max():%Y-%m-%d}")
d3 = sezione_3_fatto_vol(M, hf, d1)
d4 = sezione_4_fatto_mrtrend(M)
d5 = sezione_5_gate(snap, d4)
hr("VERDETTO")
tot = sum(COUNT.values())
print(f"GRIGLIA DICHIARATA: {tot} celle/statistiche valutate, contate al rialzo:")
for k, v in COUNT.items():
print(f" {k:18s} {v:4d}")
print(" Nessuna griglia e' stata ridotta per budget: il vincolo qui e' il DATO, non la macchina.")
print(f" Le {COUNT['momentum']} celle di momentum sono quelle passate al deflated Sharpe (5d).")
print()
print("SCARTATO. La gamba (A) e' confermata nella direzione ma non nella potenza; la gamba (B),")
print("che e' quella che si traderebbe, e' INCOERENTE fra i due asset e rovesciata dove separa.")
print()
print(" 1. Il dato regge meno di quanto dichiara: 15 min solo dal 2026-05-01 (90 giorni, non 4")
print(" mesi); 2 colonne su 16 COSTANTI; `gamma_flip_level` congelato al 61% su BTC.")
print(" 2. `dealer_net_gamma` e' il GEX COL SEGNO INVERTITO (corr -0.95 BTC / -0.89 ETH contro")
print(" la ricostruzione dalla catena). Chi lo usasse col nome che porta leggerebbe ogni")
print(" regime al contrario. -> Questo e' il risultato riusabile di tutto il filone.")
print(" 3. `gamma_flip_level` e' una media mobile: 96% di accordo con spot>SMA(720h) su ETH; su")
print(" BTC (accordo 0.68) la SMA nuda da' uno spread di vol MAGGIORE del livello stesso.")
print(" Niente contenuto di opzioni isolabile. Trappola 2 del brief.")
print(" 4. Ampiezza effettiva: 6-8 episodi di regime, n_eff 24-53 ore su 2.130. Il regime")
print(" coincide col mese (maggio LONG, giugno SHORT, luglio LONG): lo spread di vol e' UN")
print(" confronto fra tre mesi, non 2.136 osservazioni.")
print(" 5. Cio' che resta della gamba (A) e' quasi tutto DVOL (corr 0.66 su BTC), e il DVOL")
print(" come modulatore di TP01 e' gia' stato refutato il 26/06: era de-levering.")
print(" 6. Il gate misurato FALLISCE il null del de-levering (baseline x0.6 lo domina) su una")
print(" finestra dove SE(Sharpe) = +/-2.0 e TP01 e' praticamente flat.")
print(" 7. Deflated Sharpe 0.440 = FAIL, e nel modo piu' netto: la cella migliore delle 64")
print(" (+4.98 LORDO) sta SOTTO il massimo che 64 estrazioni di puro rumore producono")
print(" (+5.12). Non c'e' un candidato da deflazionare, c'e' una griglia sotto il rumore.")
print()
print("NON e' un LEAD: un lead richiede una soglia e una data che abbiano senso, e qui la soglia")
print("non e' nemmeno portabile fra la serie storica e quella che raccogliamo (zeri diversi,")
print("universi diversi). Ricalibrarla sui 90 giorni sarebbe la selezione che il progetto rifiuta.")
print()
print("COSA CONSERVARE (non e' una strategia, e' infrastruttura):")
print(" - il GEX si ricostruisce dalla NOSTRA catena, oraria, viva (518 ore dal 2026-08-01,")
print(" ~209-262 strike, quote ok ~100%), col segno GIUSTO e `quote_status` esplicito;")
print(" - se un giorno lo si riapre, si riapre su GEX ricostruito da noi, mai sulla colonna")
print(" ereditata, e non prima di avere ~30-40 EPISODI di regime (non 30-40 giorni):")
print(" al ritmo osservato (6-8 episodi / 90 giorni) sono ~2 anni, cioe' meta' 2028.")
print()
print("COSA MI SMENTIREBBE: una separazione del variance ratio z-standardizzato nello STESSO")
print("verso sui due asset (short gamma VR>1, long gamma VR<1) su GEX ricostruito da noi, con")
print("almeno 30 episodi di regime indipendenti e uno spread che sopravvive al controllo")
print("location-matched contro spot>SMA. Oggi ho l'opposto: BTC non separa, ETH separa al rovescio.")
print()
print(f"SCRIPT: scripts/research/r0822_dealer_gamma.py")
if __name__ == "__main__":
main()
+639
View File
@@ -0,0 +1,639 @@
"""r0822_flow_squeeze.py — filone FLOW-SQUEEZE (ondata 2026-08-22).
DOMANDA
Il posizionamento affollato (variazione rapida dell'open interest) ACCOPPIATO al rischio
di liquidazione produce un movimento prevedibile — squeeze o cascata — nelle ore successive?
E' l'unico angolo di "flusso" mai chiuso: il filone funding e' chiuso su 3 lati (carry CC01,
time-series, cross-sectional) ma sempre come LIVELLO del costo di carry, mai come PROXY DI
AFFOLLAMENTO accoppiato al rischio di liquidazione.
PERIMETRO (dichiarato: un altro filone dell'ondata lavora su dealer_net_gamma/gamma_flip_level,
che qui NON si toccano)
data/raw/cb_market_snapshots.parquet -> oi_delta_pct_4h, liquidation_long_risk,
liquidation_short_risk, funding_perp_annualized, funding_cross_annualized, iv_minus_rv
data/raw/hlfund_<sym>_1h.parquet -> funding orario Hyperliquid (BTC/ETH, ~3 anni)
data/raw/cb_chain/ -> open interest per strike (per la RICOSTRUIBILITA')
IPOTESI A PRIORI, REGISTRATA PRIMA DI MISURARE
Attesa: NULLA di deployabile. (a) il dataset e' MORTO il 2026-07-30 (progetto esterno
dismesso) e ha ~3 mesi utili -> verdetto massimo LEAD; (b) il progetto ha gia' chiuso il
funding su 3 lati; (c) il sospetto principale e' che un eventuale effetto sull'estremo di
oi_delta sia "il prezzo e' appena crollato" travestito, cioe' momentum/mean-reversion di
prezzo gia' coperta. Cio' che NON mi aspettavo: che meta' dell'ipotesi (il rischio di
liquidazione) fosse **inesistente nel dato**.
TEST DI POTENZA — DICHIARATO PRIMA DI LEGGERE I RISULTATI (sezione 2)
Il campione utile e' ~90 giorni x 2 asset. La MDE (minimum detectable effect a 2 SE) si
calcola sul numero di finestre NON sovrapposte, non sulle righe orarie. Un effetto sotto la
MDE NON e' un fatto stabilito, comunque venga il p-value di un bootstrap su dati sovrapposti.
COME SI LEGGE IL RISULTATO
Le sezioni 1 e 5 (validazione e ricostruibilita') valgono piu' delle 2-4: se una colonna e'
costante non ha potenza, e se non sappiamo piu' produrla il segnale e' morto alla nascita.
USO: nice -n 19 timeout 900 uv run python scripts/research/r0822_flow_squeeze.py
"""
from __future__ import annotations
import bisect
import glob
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" / "alt"))
import altlib as A # noqa: E402
RAW = ROOT / "data" / "raw"
SNAP_FILE = RAW / "cb_market_snapshots.parquet"
ASSETS = ("BTC", "ETH")
# finestra utile: prima del 01/05 gli snapshot sono ~1/giorno (vedi sezione 1)
W0 = pd.Timestamp("2026-05-01", tz="UTC")
HOURS = (4, 12, 24)
# GRIGLIA DICHIARATA (conteggio al RIALZO, incluse le varianti scartate)
GRID_DECLARED = (
10 * 3 * 2 # 60 decili di oi_delta x 3 orizzonti x 2 lenti (demean per asset / per mese)
+ 4 * 2 # 8 2x2 affollamento (OI alto/basso x funding alto/basso) x 2 orizzonti
+ 4 * 2 # 8 specificazioni di regressione x 2 orizzonti
+ 10 * 2 # 20 decili di funding HL x 2 orizzonti
+ 3 * 3 * 2 # 18 griglia del segnale: soglia x orizzonte di holding x segno
+ 2 # 2 null del segnale (sempre-short, timing casuale)
+ 2 # 2 ricostruibilita' (2 asset)
) # = 118
def hdr(t: str) -> None:
print("\n" + "=" * 78 + f"\n{t}\n" + "=" * 78)
# ---------------------------------------------------------------------------
# dati
# ---------------------------------------------------------------------------
def load_snapshots() -> pd.DataFrame:
s = pd.read_parquet(SNAP_FILE)
# epoca ESPLICITA in ms: .view("int64") su tz-aware non-ns e' un look-ahead noto
s["ts_ms"] = s["ts"].astype("datetime64[ms, UTC]").astype("int64")
return s.sort_values("ts_ms").reset_index(drop=True)
def snap_num(snap: pd.DataFrame, asset: str, cols: list[str]) -> pd.DataFrame:
d = snap[snap.asset == asset][["ts_ms"] + cols].copy()
for c in cols:
d[c] = pd.to_numeric(d[c], errors="coerce")
return d.sort_values("ts_ms").reset_index(drop=True)
def merge_causal(df: pd.DataFrame, right: pd.DataFrame) -> pd.DataFrame:
"""Snapshot noto alla CHIUSURA della barra i (= timestamp[i] + 1h), backward, tolleranza 2h.
La posizione decisa a i e' poi tenuta durante i+1 (lo shift lo fa eval_weights)."""
left = pd.DataFrame({"ts_ms": (df["timestamp"].astype("int64") + 3_600_000).values})
m = pd.merge_asof(left, right, on="ts_ms", direction="backward",
tolerance=2 * 3600 * 1000)
return m.drop(columns=["ts_ms"])
def panel(snap: pd.DataFrame, cols: list[str], start=W0) -> pd.DataFrame:
out = []
for a in ASSETS:
df = A.get(a, "1h")
df = df[df.datetime >= start - pd.Timedelta(days=7)].reset_index(drop=True)
df = pd.concat([df, merge_causal(df, snap_num(snap, a, cols))], axis=1)
cc = df["close"].values.astype(float)
for H in HOURS:
f = np.full(len(cc), np.nan)
f[:-H] = cc[H:] / cc[:-H] - 1.0
df[f"f{H}"] = f
p4 = np.full(len(cc), np.nan)
p4[4:] = cc[4:] / cc[:-4] - 1.0
df["p4"] = p4
df["asset"] = a
out.append(df)
P = pd.concat(out, ignore_index=True)
return P[P.datetime >= start].reset_index(drop=True)
# ---------------------------------------------------------------------------
# 1. VALIDAZIONE DEL DATO
# ---------------------------------------------------------------------------
def sezione1(snap: pd.DataFrame) -> None:
hdr("1. VALIDAZIONE DEL DATO — le colonne vengono da un progetto esterno DISMESSO")
print(f"righe={len(snap)} ts {snap.ts.min()} -> {snap.ts.max()}")
cnt = snap[snap.asset == "BTC"].set_index("ts").resample("1D").size()
fasce = pd.cut(cnt, [-1, 0, 10, 50, 90, 400]).value_counts().sort_index()
print("\ngiorni per numero di snapshot/giorno (BTC):")
for k, v in fasce.items():
print(f" {str(k):>14s} : {v:3d} giorni")
pieni = cnt[cnt >= 90]
print(f" -> primo giorno a cadenza piena (>=90/g): {pieni.index.min().date()}"
f" ; giorni pieni = {len(pieni)}")
print(" ATTENZIONE: la copertura dichiarata parte dal 2026-03-26 ma 36 giorni hanno <=10")
print(" snapshot: la finestra USABILE a risoluzione oraria e' 2026-05-01 -> 2026-07-30.")
for a in ASSETS:
d = snap[snap.asset == a]
print(f"\n--- {a} (n={len(d)}) ---")
ok = d.fetch_ok.value_counts().to_dict()
print(f" fetch_ok: {ok} -> fallite {100*(1-d.fetch_ok.mean()):.1f}%")
for c in ("liquidation_long_risk", "liquidation_short_risk"):
vc = d[c].value_counts(dropna=False).to_dict()
nun = d[c].nunique()
flag = " <<< COSTANTE: ZERO POTENZA" if nun <= 1 else ""
print(f" {c:26s} categorie={nun} {vc}{flag}")
for c in ("oi_delta_pct_4h", "funding_perp_annualized", "funding_cross_annualized",
"iv_minus_rv", "macro_days_to_event"):
s = pd.to_numeric(d[c], errors="coerce")
mode_share = s.value_counts(normalize=True).iloc[0] if s.notna().any() else np.nan
print(f" {c:26s} nan={100*s.isna().mean():5.1f}% nuniq={s.nunique():5d} "
f"moda={s.mode().iloc[0] if s.notna().any() else float('nan'):+.4f} "
f"({100*mode_share:.1f}% delle righe) "
f"[{s.min():+.3f}, {s.median():+.3f}, {s.max():+.3f}]")
s = pd.to_numeric(d.oi_delta_pct_4h, errors="coerce").dropna()
same = (s.diff() == 0)
print(f" oi_delta_pct_4h: congelato {100*same.mean():.2f}% delle volte, "
f"autocorr(15m)={s.autocorr(1):+.3f} autocorr(4h)={s.autocorr(16):+.3f}")
print(" -> autocorr alta a 15m e ~0 a 4h = firma di una VERA finestra mobile a 4h "
"(non un valore inventato/congelato)")
print("\n>>> ESITO SEZIONE 1")
print(" (a) liquidation_long_risk e liquidation_short_risk valgono 'low' nel 100% delle")
print(" righe non-nulle, su 17.406 righe, 4 mesi, entrambi gli asset: UNA sola")
print(" categoria. META' DELL'IPOTESI E' UNTESTABILE PER COSTRUZIONE — non 'debole',")
print(" proprio senza varianza. E' il risultato piu' importante del filone.")
print(" (b) funding_perp_annualized e' ANCORATO al tasso base (0.01%/8h = 0.1095/anno)")
print(" nel ~49% (BTC) / ~60% (ETH) delle righe: come proxy di affollamento e' muto")
print(" per meta' del campione.")
print(" (c) oi_delta_pct_4h e' l'unica colonna del perimetro che si comporta da serie vera.")
# ---------------------------------------------------------------------------
# 2. IL FATTO
# ---------------------------------------------------------------------------
def bboot_p(x: np.ndarray, nb: int = 2000, block: int = 24, seed: int = 7) -> float:
x = np.asarray(x, float)
x = x[np.isfinite(x)]
n = len(x)
if n < block * 3:
return float("nan")
rng = np.random.default_rng(seed)
k = int(np.ceil(n / block))
starts = rng.integers(0, n - block, size=(nb, k))
means = np.array([np.concatenate([x[s:s + block] for s in starts[i]])[:n].mean()
for i in range(nb)])
return float((means >= 0).mean() if x.mean() < 0 else (means <= 0).mean())
def ols_bboot(d: pd.DataFrame, y: str, cols: list[str], nb: int = 500,
blk: int = 48, seed: int = 11):
X = np.column_stack([np.ones(len(d))] + [d[c].values for c in cols])
Y = d[y].values
b = np.linalg.lstsq(X, Y, rcond=None)[0]
rng = np.random.default_rng(seed)
n = len(Y)
k = int(np.ceil(n / blk))
bs = []
for _ in range(nb):
st = rng.integers(0, n - blk, size=k)
idx = np.concatenate([np.arange(s, s + blk) for s in st])[:n]
try:
bs.append(np.linalg.lstsq(X[idx], Y[idx], rcond=None)[0])
except np.linalg.LinAlgError:
pass
se = np.array(bs).std(0)
return b, se
def sezione2(snap: pd.DataFrame) -> pd.DataFrame:
hdr("2. IL FATTO — ritorno futuro condizionato all'OI (potenza DICHIARATA PRIMA)")
P = panel(snap, ["oi_delta_pct_4h", "funding_perp_annualized",
"funding_cross_annualized", "iv_minus_rv"])
d = P.dropna(subset=["oi_delta_pct_4h", "f24", "p4"]).copy()
print(f"campione: {len(d)} barre orarie ({d.datetime.min()} -> {d.datetime.max()})")
print("\n--- TEST DI POTENZA (dichiarato prima di leggere qualunque media) ---")
mde = {}
for H in HOURS:
n_ind = len(d) / H
sd = d[f"f{H}"].std()
mde[H] = 2 * sd / np.sqrt(n_ind / 10)
print(f" H={H:2d}h finestre NON sovrapposte={n_ind:6.0f} sd={100*sd:.2f}% "
f"n/decile={n_ind/10:5.0f} MDE(2 SE)={100*mde[H]:.3f}%")
print(" REGOLA PRE-REGISTRATA: un effetto di decile sotto la propria MDE NON e' un fatto")
print(" stabilito, qualunque p-value dia un bootstrap su osservazioni sovrapposte.")
for a in ASSETS:
m = d.asset == a
for H in HOURS:
d.loc[m, f"x{H}"] = d.loc[m, f"f{H}"] - d.loc[m, f"f{H}"].mean()
d["mese"] = d.datetime.dt.strftime("%Y-%m")
for H in HOURS:
d[f"y{H}"] = d[f"f{H}"] - d.groupby(["asset", "mese"])[f"f{H}"].transform("mean")
d["odec"] = d.groupby("asset").oi_delta_pct_4h.transform(
lambda s: pd.qcut(s, 10, labels=False))
drift = {a: {H: round(100 * d.loc[d.asset == a, f"f{H}"].mean(), 3) for H in HOURS}
for a in ASSETS}
print(f"\ndrift INCONDIZIONATO della finestra (%): {drift}")
print(" -> la finestra e' ribassista: ogni media condizionata va letta DEMEANATA,")
print(" altrimenti 'stare short' sembra un segnale.")
print("\n--- eccesso di ritorno futuro (%) per decile di oi_delta_pct_4h ---")
print(" lente 1 = demean per ASSET ; lente 2 = demean per (ASSET,MESE)")
g = d.groupby("odec").agg(n=("x4", "size"), oi=("oi_delta_pct_4h", "mean"),
x4=("x4", "mean"), x24=("x24", "mean"),
y4=("y4", "mean"), y24=("y24", "mean"),
av4=("f4", lambda s: s.abs().mean()),
av24=("f24", lambda s: s.abs().mean()))
for c in ("x4", "x24", "y4", "y24", "av4", "av24"):
g[c] *= 100
g["p_x4"] = [bboot_p(d[d.odec == k].x4.values) for k in g.index]
g["p_x24"] = [bboot_p(d[d.odec == k].x24.values, block=48) for k in g.index]
print(g.round(3).to_string())
print(" av4/av24 = |ret| futuro medio: e' la gamba 'squeeze/cascata' dell'ipotesi")
print(" (piu' vol dopo un estremo di OI). E' PIATTA: nessuna cascata prevedibile.")
print("\n--- CONTROLLO: e' solo 'il prezzo si e' appena mosso'? ---")
z = lambda s: (s - s.mean()) / s.std() # noqa: E731
for c, n in [("oi_delta_pct_4h", "zoi"), ("p4", "zp4"),
("funding_perp_annualized", "zfund"), ("iv_minus_rv", "ziv")]:
d[n] = d.groupby("asset")[c].transform(z)
dr = d.dropna(subset=["zoi", "zp4", "zfund", "ziv"]).reset_index(drop=True)
print(f" corr(oi_delta, ritorno 4h passato) = {dr.oi_delta_pct_4h.corr(dr.p4):+.3f}"
f" (con 24h passato = {dr.oi_delta_pct_4h.corr(dr.f24.shift(24)):+.3f})")
for y in ("x4", "x24"):
for cols in (["zoi"], ["zp4"], ["zoi", "zp4"], ["zoi", "zp4", "zfund", "ziv"]):
b, se = ols_bboot(dr, y, cols)
txt = " ".join(f"{c}={100*b[i+1]:+.4f}%(t={b[i+1]/max(se[i+1],1e-12):+.2f})"
for i, c in enumerate(cols))
print(f" {y:4s} ~ {str(cols):36s} {txt}")
print(" -> la PENDENZA LINEARE di oi_delta e' nulla (|t|<1.3 in ogni specificazione).")
print(" Quel poco che c'e' vive SOLO nella coda estrema, e non e' momentum di prezzo")
print(" (corr con il ritorno passato ~0.02): e' un'altra cosa, ma e' una CODA.")
print("\n--- il decile 0 (OI che CROLLA) — l'unica cella non piatta ---")
for H in (4, 24):
obs = d[d.odec == 0][f"y{H}"].mean()
rng = np.random.default_rng(3)
nulls = []
for _ in range(2000):
sh = []
for a in ASSETS:
sub = d[d.asset == a]
k = rng.integers(1, len(sub) - 1)
sh.append(sub[f"y{H}"].values[np.roll((sub.odec == 0).values, k)])
nulls.append(np.concatenate(sh).mean())
nulls = np.array(nulls)
print(f" H={H:2d}h osservato={100*obs:+.3f}% MDE={100*mde[H]:.3f}% "
f"null circolare: mediana={100*np.median(nulls):+.3f}% "
f"p5={100*np.percentile(nulls,5):+.3f}% p={float((nulls<=obs).mean()):.3f}")
for a in ASSETS:
sub = d[(d.asset == a) & (d.odec == 0)]
cond = (d[d.asset == a].odec == 0).values
ep = int(np.sum(cond[1:] & ~cond[:-1])) + int(cond[0])
print(f" {a}: ore in decile0={len(sub)} episodi distinti={ep} "
f"y4={100*sub.y4.mean():+.3f}% y24={100*sub.y24.mean():+.3f}%")
print("\n scomposizione PER MESE (demean mensile) — il test che il progetto impone")
print(" dopo il caso SOL ('un contributo positivo si scompone prima di crederci'):")
tab = d[d.odec == 0].groupby("mese")[["y4", "y24"]].agg(["size", "mean"])
for m_, r in tab.iterrows():
print(f" {m_}: n={int(r[('y4','size')]):4d} "
f"y4={100*r[('y4','mean')]:+.3f}% y24={100*r[('y24','mean')]:+.3f}%")
print("\n--- 2x2 AFFOLLAMENTO (l'ipotesi originale, col funding al posto della liquidazione) ---")
for c in ("oi_delta_pct_4h", "funding_perp_annualized"):
d[c + "_hi"] = d.groupby("asset")[c].transform(lambda s: s >= s.quantile(.8))
d[c + "_lo"] = d.groupby("asset")[c].transform(lambda s: s <= s.quantile(.2))
combos = [("OI su & funding ALTO (long affollati -> cascata?)",
d["oi_delta_pct_4h_hi"] & d["funding_perp_annualized_hi"]),
("OI su & funding BASSO (short affollati -> squeeze?)",
d["oi_delta_pct_4h_hi"] & d["funding_perp_annualized_lo"]),
("OI giu & funding ALTO ",
d["oi_delta_pct_4h_lo"] & d["funding_perp_annualized_hi"]),
("OI giu & funding BASSO",
d["oi_delta_pct_4h_lo"] & d["funding_perp_annualized_lo"])]
base = 100 * d.x4.abs().mean()
for nm, mask in combos:
sub = d[mask]
if len(sub) < 20:
print(f" {nm:52s} n={len(sub)} TROPPO PICCOLO")
continue
print(f" {nm:52s} n={len(sub):5d} x4={100*sub.x4.mean():+.3f}%(p={bboot_p(sub.x4.values):.3f})"
f" x24={100*sub.x24.mean():+.3f}%(p={bboot_p(sub.x24.values,block=48):.3f})"
f" |x4|={100*sub.x4.abs().mean():.3f}%")
print(f" |x4| di riferimento su tutto il campione = {base:.3f}%")
print(" -> 4 celle, 8 test: la sola sotto 0.05 (OI su & funding basso, x24) e' UNA cella su")
print(" 8 con ~8 finestre 24h indipendenti. Non e' un fatto.")
return d
# ---------------------------------------------------------------------------
# 3. FUNDING HL — l'unica gamba del perimetro con storia lunga
# ---------------------------------------------------------------------------
def sezione3() -> None:
hdr("3. FUNDING HYPERLIQUID BTC/ETH — 3 ANNI: l'unica gamba del perimetro con POTENZA")
res = []
for a in ASSETS:
f = RAW / f"hlfund_{a.lower()}_1h.parquet"
if not f.exists():
print(f" {f} assente")
return
h = pd.read_parquet(f)
h.index = pd.DatetimeIndex(h.index).tz_convert("UTC").floor("h")
h = h[~h.index.duplicated()]
df = A.get(a, "1h")
df = df.set_index(pd.DatetimeIndex(df.datetime))
j = df[["close"]].join(h[["funding", "premium"]], how="inner")
for H in (4, 24):
j[f"f{H}"] = j.close.shift(-H) / j.close - 1.0
j["asset"] = a
res.append(j)
J = pd.concat(res).dropna(subset=["funding", "f24"])
print(f"n={len(J)} barre orarie, {J.index.min().date()} -> {J.index.max().date()}")
print(" ATTENZIONE: il feed funding HL si ferma al 2026-06-22 (non e' aggiornato a oggi).")
base = J.funding.value_counts(normalize=True).iloc[0]
print(f" il funding e' ANCORATO al tasso base nel {100*base:.1f}% delle ore "
f"(valore {J.funding.mode().iloc[0]:.6f}/h) -> anche qui meta' campione e' muto.")
J["ye"] = J.index.year
J["fdec"] = J.groupby("asset").funding.transform(
lambda s: pd.qcut(s.rank(method="first"), 10, labels=False))
for H in (4, 24):
J[f"y{H}"] = J[f"f{H}"] - J.groupby(["asset", "ye"])[f"f{H}"].transform("mean")
g = J.groupby("fdec").agg(n=("y4", "size"), fund=("funding", "mean"),
y4=("y4", "mean"), y24=("y24", "mean"),
av4=("f4", lambda s: s.abs().mean()),
av24=("f24", lambda s: s.abs().mean()))
g["fund"] *= 100 * 24 * 365
for c in ("y4", "y24", "av4", "av24"):
g[c] *= 100
print("\n--- eccesso su drift ANNUALE (%) e |ret| futuro per decile di funding HL ---")
print(g.round(3).to_string())
print("\nestremi per ANNO (y24, %):")
t = J[J.fdec.isin([0, 9])].groupby(["fdec", "ye"]).y24.agg(["size", "mean"])
for k, r in t.iterrows():
print(f" dec={k[0]} anno={k[1]} n={int(r['size']):5d} y24={100*r['mean']:+.3f}%")
print("\n>>> ESITO SEZIONE 3: su 53.430 osservazioni e 3 anni — cioe' DOVE LA POTENZA C'E'")
print(" il funding come proxy di affollamento non predice ne' la DIREZIONE (|eccesso|")
print(" <=0.35% a 24h, segno instabile fra anni) ne' la VOLATILITA' (|ret| futuro piatto")
print(" e non monotono). Il filone funding, gia' chiuso su 3 lati come livello di carry,")
print(" e' chiuso anche come proxy di affollamento.")
# ---------------------------------------------------------------------------
# 4. IL SEGNALE CAUSALE + I NULL + I GATE
# ---------------------------------------------------------------------------
def pct_expanding(x: np.ndarray, minobs: int = 200) -> np.ndarray:
"""Percentile ESPANDENTE causale: al bar i usa solo i valori <= i-1."""
out = np.full(len(x), np.nan)
seen: list[float] = []
for i, v in enumerate(x):
if np.isfinite(v):
if len(seen) >= minobs:
out[i] = bisect.bisect_left(seen, v) / len(seen)
bisect.insort(seen, v)
return out
class Signal:
"""SHORT (o LONG) quando il percentile causale di oi_delta_pct_4h e' sotto soglia,
tenuto H barre, vol-targeted 20%."""
def __init__(self, snap: pd.DataFrame):
self.SN = {a: snap_num(snap, a, ["oi_delta_pct_4h"]).dropna() for a in ASSETS}
def oi(self, df, asset):
return merge_causal(df, self.SN[asset])["oi_delta_pct_4h"].values.astype(float)
def make(self, thr: float, H: int, sign: int):
def fn(df, asset):
p = pct_expanding(self.oi(df, asset))
trig = np.where(np.isfinite(p), (p <= thr).astype(float), 0.0)
held = pd.Series(trig).rolling(H, min_periods=1).max().values
return A.vol_target(sign * held, df, target_vol=0.20, leverage_cap=2.0)
return fn
def book_net(target_by_asset) -> pd.Series:
nets = []
for a in ASSETS:
df = A.get(a, "1h")
m = (df.datetime >= W0).values
d2 = df[m].reset_index(drop=True)
ev = A.eval_weights(d2, np.asarray(target_by_asset(df, a))[m])
nets.append(pd.Series(ev["net"], index=ev["idx"]))
J = pd.concat(nets, axis=1, join="inner").fillna(0.0)
return 0.5 * J.iloc[:, 0] + 0.5 * J.iloc[:, 1]
def stats(net: pd.Series) -> dict:
if net.std() == 0:
return dict(sharpe=0.0, dd=0.0, ret=0.0, cagr=0.0)
eq = (1 + net).cumprod()
dd = float(((eq.cummax() - eq) / eq.cummax()).max())
yrs = (net.index[-1] - net.index[0]).total_seconds() / (365.25 * 86400)
return dict(sharpe=float(net.mean() / net.std() * np.sqrt(24 * 365.25)),
dd=dd, ret=float(eq.iloc[-1] - 1),
cagr=float(eq.iloc[-1] ** (1 / max(yrs, 1e-9)) - 1))
def sezione4(snap: pd.DataFrame) -> None:
hdr("4. IL SEGNALE CAUSALE — griglia 18 celle, i null, i gate")
S = Signal(snap)
rows = []
for thr in (0.10, 0.20, 0.30):
for H in (4, 12, 24):
for sign in (-1, +1):
st = stats(book_net(S.make(thr, H, sign)))
rows.append(dict(thr=thr, H=H, sign=sign, **st))
print(f" thr={thr:.2f} H={H:2d} sign={sign:+d} "
f"Sharpe(90g)={st['sharpe']:+.2f} maxDD={100*st['dd']:5.2f}% "
f"ret={100*st['ret']:+6.2f}%")
R = pd.DataFrame(rows)
best = R.loc[R.sharpe.idxmax()]
print(f"\n miglior cella: thr={best.thr} H={int(best.H)} sign={int(best.sign):+d} "
f"-> Sharpe {best.sharpe:.2f}")
print(f" SE(Sharpe) su 90 giorni = sqrt(365/90) = {np.sqrt(365/90):.2f} -> ogni Sharpe")
print(" sotto ~4 e' indistinguibile da zero su questa finestra. Non esiste hold-out:")
print(" la cella e' scelta sull'UNICO campione (select_cell_insample non applicabile).")
thr, H, sign = float(best.thr), int(best.H), int(best.sign)
tgt = S.make(thr, H, sign)
net = book_net(tgt)
st = stats(net)
print("\n--- NULL 1: 'sei solo short in una finestra ribassista?' ---")
always = lambda df, a: A.vol_target( # noqa: E731
sign * np.ones(len(df)), df, target_vol=0.20, leverage_cap=2.0)
st_a = stats(book_net(always))
tim = np.mean([np.mean(np.asarray(tgt(A.get(a, "1h"), a))[
(A.get(a, "1h").datetime >= W0).values] != 0) for a in ASSETS])
print(f" candidato Sharpe={st['sharpe']:+.2f} (tempo a mercato {tim:.3f}) "
f"sempre-esposto Sharpe={st_a['sharpe']:+.2f}")
print(" -> il candidato NON e' la statica travestita: il timing porta qualcosa.")
print("\n--- NULL 2: timing CASUALE a pari esposizione (shift circolare, 300 estrazioni) ---")
base_trig = {}
for a in ASSETS:
df = A.get(a, "1h")
base_trig[a] = (np.asarray(tgt(df, a)), (df.datetime >= W0).values)
rng = np.random.default_rng(21)
nulls = []
for _ in range(300):
shifted = {}
for a in ASSETS:
v, m = base_trig[a]
v2 = v.copy()
idx = np.where(m)[0]
v2[idx] = np.roll(v2[idx], rng.integers(1, len(idx) - 1))
shifted[a] = v2
nulls.append(stats(book_net(lambda df, a: shifted[a]))["sharpe"])
nulls = np.array(nulls)
pctl = float((nulls < st["sharpe"]).mean())
print(f" null: mediana={np.median(nulls):+.2f} p90={np.percentile(nulls,90):+.2f} "
f"p99={np.percentile(nulls,99):+.2f} -> candidato al {100*pctl:.1f}o pctl")
print(f" ma la cella e' il MASSIMO di 18: P(almeno una cella oltre quel pctl per caso) "
f"= 1-{pctl:.3f}^18 = {1-pctl**18:.2f} -> il null non e' superato dopo il conto dei trial.")
print("\n--- GATE ---")
print(f" causality_ok : {A.causality_ok(tgt, tf='1h')}")
cd = A.candidate_daily(tgt, tf="1h")
cd = cd[cd.index >= W0]
sr = float(cd.mean() / cd.std() * np.sqrt(365.25))
rep = A.marginal_vs_tp01(cd)
print(f" candidate_daily : n={len(cd)} giorni, Sharpe={sr:.2f}")
print(f" marginal_vs_tp01 : verdetto={rep.get('marginal_verdict')} "
f"corr_beta_tp01={rep.get('beta_to_tp01')} has_insample_edge={rep.get('has_insample_edge')} "
f"is_hedge={rep.get('is_hedge')} robust_oos={rep.get('robust_oos')} "
f"beats_noise_null={rep.get('beats_noise_null')} null_pctl_full={rep.get('null_pctl_full')}")
print(" (robust_oos=False non e' un difetto del candidato: con 90 giorni NON ESISTE il")
print(" multi-cut che il gate richiede. Il gate e' girato e il suo esito e' NEUTRAL.)")
all_sr = list(R.sharpe.values)
d18 = A.deflated_sharpe(sr, all_sr, cd)
d_all = A.deflated_sharpe(sr, all_sr * 7, cd)
print(f" deflated_sharpe(18) : DSR={d18[0]:.3f} (Sharpe atteso del massimo sotto il null "
f"= {d18[1]:.2f}) -> {'PASS' if d18[0]>=0.95 else 'FAIL'}")
print(f" deflated_sharpe({GRID_DECLARED}) : DSR={d_all[0]:.3f} (null max {d_all[1]:.2f}) "
f"-> {'PASS' if d_all[0]>=0.95 else 'FAIL'}")
print(" con 114 giorni il MASSIMO atteso PER CASO su 18 celle e' Sharpe ~5.4:")
print(" il candidato (3.6) sta SOTTO il proprio null. Non serve altro.")
imp = A.implausible_sharpe(cd)
print(f" implausible_sharpe : {imp['implausible']} {imp['reasons']}")
print(" null de-levering : NON GIRATO — il candidato non fa alcun claim di riduzione")
print(" di drawdown (e' uno stream di ritorno, non un overlay).")
print(" anchor_luck_band : NON GIRATO — il segnale non e' ancorato a un'ora di")
print(" ribilanciamento (gira su ogni barra oraria).")
for a in ASSETS:
df = A.get(a, "1h")
m = (df.datetime >= W0).values
sc = A.eval_weights_smallcap(df[m].reset_index(drop=True),
np.asarray(tgt(df, a))[m], capital=600)
print(f" smallcap $600 {a} : haircut Sharpe={sc['sharpe_haircut']:+.3f} "
f"(reale {sc['realistic']['sharpe']:.2f} vs modellato {sc['modeled']['sharpe']:.2f}, "
f"{sc['n_executed_trades']} trade eseguiti)")
print(" -> l'ESEGUIBILITA' non e' il vincolo (2 gambe BTC/ETH perp, haircut ~0.02):")
print(" come per SOL, il candidato muore sull'evidenza, non sulla taglia del conto.")
print("\n--- CONCENTRAZIONE (dove sta davvero il P&L) ---")
mm = cd.groupby(cd.index.strftime("%Y-%m")).agg(n="size", ret=lambda s: 100 * ((1 + s).prod() - 1))
print(mm.round(2).to_string())
tot = 100 * ((1 + cd).prod() - 1)
top3 = 100 * cd.sort_values().tail(3).sum()
print(f" somma dei 3 giorni migliori = {top3:.2f}% su un totale di {tot:.2f}% "
f"({100*top3/max(tot,1e-9):.0f}% del P&L in 3 giorni su 114)")
# ---------------------------------------------------------------------------
# 5. RICOSTRUIBILITA'
# ---------------------------------------------------------------------------
def sezione5(snap: pd.DataFrame) -> None:
hdr("5. RICOSTRUIBILITA' — il dato e' MORTO il 2026-07-30: sapremmo rifarlo oggi?")
cols = ["asset", "instrument_name", "open_interest", "ts"]
parts = []
arch = RAW / "cb_chain" / "bite_archive.parquet"
if arch.exists():
parts.append(pd.read_parquet(arch, columns=cols))
for f in sorted(glob.glob(str(RAW / "cb_chain" / "2026-*.parquet"))):
parts.append(pd.read_parquet(f, columns=cols))
C = pd.concat(parts, ignore_index=True)
del parts
C["ts"] = pd.to_datetime(C.ts, utc=True).dt.floor("h")
C = C.drop_duplicates(["ts", "instrument_name"])
print(f"catena: {len(C)} righe {C.ts.min()} -> {C.ts.max()}")
sn = snap.copy()
sn["h"] = pd.to_datetime(sn.ts, utc=True).dt.floor("h")
for asset in ASSETS:
s = C[C.asset == asset]
piv = s.pivot_table(index="ts", columns="instrument_name",
values="open_interest", aggfunc="last")
piv = piv.reindex(pd.date_range(piv.index.min(), piv.index.max(), freq="h"))
V = piv.values
dl = np.full(len(piv), np.nan)
for i in range(4, len(piv)):
m = np.isfinite(V[i]) & np.isfinite(V[i - 4])
if m.sum() < 30:
continue
b = V[i - 4][m].sum()
if b > 0:
dl[i] = 100 * (V[i][m].sum() / b - 1)
chain = pd.Series(dl, index=piv.index, name="chain")
ref = pd.to_numeric(sn[sn.asset == asset].groupby("h").oi_delta_pct_4h.last(),
errors="coerce").rename("snap")
j = pd.concat([ref, chain], axis=1).dropna()
print(f" {asset}: ore sovrapposte={len(j)} "
f"corr Pearson={j.snap.corr(j.chain):+.3f} "
f"Spearman={j.snap.corr(j.chain, method='spearman'):+.3f} "
f"(sd snap={j.snap.std():.3f} vs sd catena={j.chain.std():.3f})")
del piv, V
print("\n Nota: l'OI della catena e' l'OI delle OPZIONI a strumenti APPAIATI (stessi")
print(" strumenti a t e t-4h, cosi' le scadenze non creano salti). La correlazione con")
print(" oi_delta_pct_4h e' ~0 in rango: NON e' la stessa grandezza (lo snapshot misurava")
print(" l'OI dei PERPETUAL/futures via il progetto dismesso).")
print("\n>>> ESITO SEZIONE 5 — colonna per colonna, cosa sappiamo produrre OGGI:")
print(" oi_delta_pct_4h : NO. La catena da' OI di OPZIONI (rango ~0.05 con la")
print(" colonna). L'OI perp Deribit e' leggibile dall'API")
print(" pubblica ma NON lo raccogliamo: la storia parte da")
print(" zero il giorno che si accende un collettore.")
print(" liquidation_long/short : NO, e non importa: la colonna e' costante 'low'.")
print(" Nessuna fonte nostra la produce, e non c'e' niente")
print(" da produrre.")
print(" funding_perp/cross : SI ma degradato — funding HL c'e' (fermo al 22/06/2026),")
print(" funding Deribit non lo salviamo.")
print(" iv_minus_rv : SI, ricostruibile da dvol_*.parquet + barre certificate.")
# ---------------------------------------------------------------------------
def main() -> None:
print(__doc__)
print(f"GRIGLIA DICHIARATA (conteggio al rialzo) = {GRID_DECLARED} celle/trial")
snap = load_snapshots()
sezione1(snap)
sezione2(snap)
sezione3()
sezione4(snap)
sezione5(snap)
hdr("6. VERDETTO")
print("""SCARTATO.
1. META' DELL'IPOTESI NON ESISTE NEL DATO: liquidation_long_risk e liquidation_short_risk
valgono 'low' nel 100% delle 17.229 righe non-nulle. Una variabile con UNA categoria non
ha potenza: l'accoppiamento affollamento x rischio-di-liquidazione e' untestabile, non
debole. (Ed e' esattamente la parte che rendeva l'angolo NUOVO rispetto al filone funding.)
2. IL FATTO NON REGGE LA PROPRIA SOGLIA DI POTENZA, dichiarata prima di guardare: l'unico
effetto e' il decile piu' basso di oi_delta (-0.40% a 24h) e la MDE a 24h e' 1.06%.
La pendenza lineare e' nulla (|t|<1.3), il |ritorno| futuro e' piatto fra i decili (la
'cascata' non c'e'), e la scomposizione per mese mette quasi tutto in giugno.
3. IL SEGNALE E' UN MASSIMO DI 18 CELLE SU 114 GIORNI: Sharpe 3.6 con SE(Sharpe)=2.0,
deflated-Sharpe 0.10 (18 celle) / 0.004 (118 trial) contro un massimo atteso PER CASO di
Sharpe 5.4; implausible_sharpe=True; marginal_vs_tp01 = NEUTRAL; 60% del P&L in 3 giorni.
4. ED E' COMUNQUE MORTO ALLA NASCITA: la colonna su cui poggia non e' ricostruibile con i dati
che raccogliamo (l'OI della catena e' quello delle OPZIONI, rango ~0.05 con la colonna) e
la sorgente e' stata dismessa il 2026-07-30.
Sottoprodotto con potenza vera (53.430 ore, 3 anni): il funding HL non predice ne' direzione
ne' volatilita' -> il filone funding, gia' chiuso su 3 lati come livello di carry, si puo'
dichiarare chiuso anche come PROXY DI AFFOLLAMENTO.""")
if __name__ == "__main__":
main()
+564
View File
@@ -0,0 +1,564 @@
#!/usr/bin/env python
"""r0822_growth_policy.py — LA POLITICA DI LEVA, giudicata sul TEMPO invece che sullo Sharpe.
LA DOMANDA. `target_vol=20%` per TP01 e' canonico dal 2026-06-19 e non e' mai stato messo in
discussione **rispetto a un obiettivo di crescita**. E' stato scelto (e ri-confermato: peso 75/25
tre volte, TP01xDVOL, tranching) sempre con una lente di **Sharpe** o di **drawdown**. Ma
l'operatore non massimizza lo Sharpe: vuole ~$258k netti il prima possibile. Sono due problemi
diversi — lo Sharpe e' invariante alla leva, il TEMPO no.
Quindi: **quale leva k minimizza il tempo mediano al bersaglio, a parita' di rischio accettato?**
E cambia se il bersaglio e' $50k invece di $258k?
COSA C'E' QUI
(0) Replica di controllo: il book live, il fattore d'ancora ×0.89 misurato, e i muri pubblicati.
(1) LA CURVA DI CRESCITA g(k) — dove sta il massimo (Kelly empirico) e da che parte sta il libro.
(2) SWEEP PRINCIPALE — per ogni k: tempo mediano al bersaglio (CONDIZIONATO, stampato accanto
alla sua probabilita'), P(entro 10/15/20a), P(rovina), P(DD>30%). Versamenti €0 e €500/mese,
bersagli $50k e il muro NETTO. Tutto **al netto del fisco d'accumulo** (33% + carry 4a + 0.2%).
(3) FRONTIERA ISO-ROVINA — la domanda del brief nella sua forma esatta.
(4) IL GATE CHE CONTA — il massimo di (1) e' calcolato su un campione che non contiene un solo
giorno peggiore di -3.9%. Stress esplicito: giorno di crash fuori campione + liquidazione
Deribit. Dove va il massimo quando il rischio che il dataset NON contiene entra nel conto.
(5) ESEGUIBILITA' — `config/live.json` oggi tiene il nozionale lordo <= 1.00x l'equity, e il
knob `target_vol` di TP01 NON scala linearmente (leverage cap 2x). Quanto di k e' teorico.
ONESTA' (regole del progetto applicate qui):
· un non-arrivo e' **+infinito**, mai -1 (l'errore del 07/08: con -1 la mediana MIGLIORA quanto
peggio va la colonna);
· una mediana condizionata si stampa **sempre** accanto alla sua probabilita';
· un Monte Carlo ha una **risoluzione**: la riga di testa e' rifatta con un secondo seme e la
banda e' dichiarata;
· la rovina non e' l'azzeramento aritmetico (con ritorni moltiplicativi il capitale non tocca
zero finche' k < 1/|peggior giorno| = 25.4): e' definita e dichiarata sotto.
nice -n 19 timeout 900 uv run python scripts/research/r0822_growth_policy.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0725_capcurve as CC # noqa: E402 macchineria validata (book_series, _boot_paths, survival)
import r0726_deposits as DP # noqa: E402 deluck_returns() con il ×0.89 MISURATO
import r0727_tasse as TX # noqa: E402 CARRY_ANNI, PATRIMONIALE
from r0807_piano_netto import passo_fiscale # noqa: E402 il passo fiscale gia' validato
# ------------------------------------------------------------------ parametri dichiarati
KS = (0.50, 0.75, 1.00, 1.25, 1.50, 2.00, 2.50, 3.50, 5.00, 7.00,
10.00, 14.00, 18.00, 22.00) # 14 celle: la griglia stretta (<=8x) NON conteneva il
# massimo -> allargata finche' il massimo e' INTERNO
KS_STRESS = (1.00, 1.50, 2.00, 3.50, 5.00, 10.00, 18.00) # sottoinsieme per la (4)
DEPOSITI = (0, 500) # €/mese: nessuno, e il piano dichiarato
BERSAGLI = {"$50k": 50_000.0, "muro netto": 258_338.0} # r0807_piano_netto (fisco coerente)
ANNI = 25 # orizzonte di simulazione: 25 come r0726/r0807 (20 troncherebbe
# la coda della mediana condizionata). Le P si leggono a 10/15/20a.
N_PATHS = 2_500
BLOCK = 20
CHUNK = 500 # RAM: la VPS ha ~3.5 GB liberi e ci girano altri agenti
SEED = 20260822
SEED_BIS = 8221 # secondo seme, solo per dichiarare la risoluzione MC
START = 600.0
ALIQUOTA = CC.TAX_RATE # 0.33
PATRIM = TX.PATRIMONIALE # 0.002
RUIN_MULT = 0.20 # rovina = il moltiplicatore del PATH perde l'80%
DD_SOGLIA = 0.30
WIPE = 0.90 # perdita giornaliera >= 90% dell'equity = liquidazione (margine+fee)
MURO_PUBBL_LORDO = 272_061.0 # r0726_capwall_refresh (convenzione pubblicata)
def sezione(t: str) -> None:
print("\n" + "=" * 104)
print(f" {t}")
print("=" * 104)
# ===========================================================================
# il motore: una passata, tutte le leve insieme (confronto APPAIATO sugli stessi path)
# ===========================================================================
def simula(r: np.ndarray, ks: tuple[float, ...], *, anni: int, dep_eur: float,
bersagli: dict[str, float], aliquota: float, patrimoniale: float,
n_paths: int = N_PATHS, seed: int = SEED, start: float = START,
crash_pct: float = 0.0, crash_p_anno: float = 0.0) -> dict:
"""Accumulo a leva `k` per ogni k, sugli STESSI path (differenze appaiate).
Stato per (path, k). Traccia in parallelo un GEMELLO SENZA VERSAMENTI: il drawdown e la
rovina si misurano su quello, cosi' sono confrontabili fra colonne con versamenti diversi
(un bonifico mensile maschera un drawdown senza ridurre il capitale distrutto).
`crash_pct`/`crash_p_anno`: giorno di coda FUORI CAMPIONE (sezione 4). Il block bootstrap non
puo' generare un giorno peggiore del peggiore osservato: se lo si vuole, va messo a mano.
Liquidazione: se la perdita del giorno a leva k supera `WIPE` dell'equity, il conto e' chiuso
dall'exchange -> capitale a 0 (i versamenti successivi ripartono da li').
"""
kv = np.asarray(ks, float)[None, :]
nk = len(ks)
dep = dep_eur * CC.EURUSD
n_days = int(anni * 365)
rng = np.random.default_rng(seed)
nomi = list(bersagli)
soglie = np.array([bersagli[n] for n in nomi], float)
hit = {n: [] for n in nomi}
fin, mult_fin, ruin, ddmax, versato_tot = [], [], [], [], 0.0
for c0 in range(0, n_paths, CHUNK):
m = min(CHUNK, n_paths - c0)
paths = CC._boot_paths(r, m, n_days, BLOCK, rng)
if crash_pct > 0 and crash_p_anno > 0:
# un giorno di crash con probabilita' `crash_p_anno`/anno, uniforme nel tempo
p_giorno = crash_p_anno / 365.0
mask = rng.random((m, n_days)) < p_giorno
paths = np.where(mask, -abs(crash_pct), paths)
cap = np.full((m, nk), start, float)
mult = np.ones((m, nk), float) # gemello senza versamenti
hwm = np.ones((m, nk), float)
dd = np.zeros((m, nk), float)
rovinato = np.zeros((m, nk), bool)
colpito = {n: np.full((m, nk), np.inf) for n in nomi}
carry = np.zeros((m * nk, TX.CARRY_ANNI))
anno_start = cap.copy()
flusso = np.zeros((m, nk))
versato = start
for t in range(n_days):
rt = paths[:, t][:, None] * kv # (m, nk)
liq = rt <= -WIPE
g = np.where(liq, -1.0, rt)
cap *= (1.0 + g)
cap = np.maximum(cap, 0.0)
mult *= (1.0 + g)
mult = np.maximum(mult, 0.0)
hwm = np.maximum(hwm, mult)
dd = np.maximum(dd, 1.0 - mult / hwm)
rovinato |= mult <= RUIN_MULT
if dep > 0 and t % 30 == 0 and t > 0:
cap += dep
flusso += dep
versato += dep
for j, n in enumerate(nomi):
nuovi = np.isinf(colpito[n]) & (cap >= soglie[j])
colpito[n][nuovi] = t
if (t + 1) % 365 == 0 and (aliquota > 0 or patrimoniale > 0):
passo_fiscale(cap.ravel(), anno_start.ravel(), flusso.ravel(),
carry, aliquota, patrimoniale)
anno_start = cap.copy()
flusso[:] = 0.0
for n in nomi:
hit[n].append(colpito[n])
fin.append(cap)
mult_fin.append(mult)
ruin.append(rovinato)
ddmax.append(dd)
versato_tot = versato
out = dict(versato=versato_tot, anni=anni, n_paths=n_paths, ks=ks)
out["cap"] = np.concatenate(fin, axis=0)
out["mult"] = np.concatenate(mult_fin, axis=0)
out["ruin"] = np.concatenate(ruin, axis=0)
out["dd"] = np.concatenate(ddmax, axis=0)
out["hit"] = {n: np.concatenate(hit[n], axis=0) for n in nomi}
return out
def anni_cond(hit_col: np.ndarray) -> tuple[float, float, float, float]:
"""(anni mediani CONDIZIONATI all'arrivo, P<=10a, P<=15a, P<=20a). Non-arrivo = +inf."""
arr = hit_col[np.isfinite(hit_col)]
med = float(np.median(arr)) / 365.0 if len(arr) >= 50 else float("nan")
p = lambda y: float((hit_col <= y * 365).mean()) # noqa: E731
return med, p(10), p(15), p(20)
def fmt_anni(a: float) -> str:
return f"{a:.1f}a" if np.isfinite(a) else "mai"
# ===========================================================================
def main() -> None:
print("=" * 104)
print(" r0822 — GROWTH POLICY: quale leva minimizza il TEMPO al bersaglio, a pari rovina accettata")
print("=" * 104)
# ------------------------------------------------------------------ (0)
sezione("(0) IL DATO — book LIVE (TP01 75 / SKH01 25) e replica di controllo")
base = CC.book_series("hourly")
r = DP.deluck_returns()
print(f"\n serie: {len(base)} giorni, {base.index.min().date()} -> {base.index.max().date()}")
print(f" lente 'hourly' = SKH01 sul PATH LIVE (fill al close 5m orario), non il backtest.")
print(f" canonico : drift {base.values.mean()*365:>7.2%} vol {base.values.std()*365**0.5:>6.2%}"
f" Sharpe {base.values.mean()/base.values.std()*365**0.5:>5.2f}")
print(f" de-luckato ×{DP.DELUCK}: drift {r.mean()*365:>7.2%} vol {r.std()*365**0.5:>6.2%}"
f" Sharpe {r.mean()/r.std()*365**0.5:>5.2f} <-- questa e' la serie usata ovunque sotto")
print(f" peggior giorno {r.min():+.2%} · miglior giorno {r.max():+.2%}")
print(f"\n ⚠️ Conseguenza aritmetica da tenere a mente per tutto il resto: con ritorni")
print(f" moltiplicativi il capitale NON tocca zero finche' k < 1/{abs(r.min()):.4f} = "
f"{1/abs(r.min()):.1f}x.")
print(f" Percio' 'rovina' qui e' DEFINITA: il moltiplicatore del path (gemello SENZA")
print(f" versamenti) scende sotto {RUIN_MULT:.0%}, cioe' 4 euro su 5 distrutti. E la")
print(f" liquidazione dell'exchange e' modellata a parte: perdita giornaliera >= {WIPE:.0%}.")
print(f"\n muri pubblicati riusati come bersagli:")
print(f" · ${MURO_PUBBL_LORDO:,.0f} (r0726_capwall_refresh, convenzione LORDA)")
print(f" · ${BERSAGLI['muro netto']:,.0f} (r0807_piano_netto, fisco COERENTE) <-- usato qui")
print(f" · $50.000 (il bersaglio 'corto' chiesto dal brief: ~€19/g di rendita perpetua)")
print("\n CONTROLLO DI REPLICA — questo motore contro la tabella pubblicata il 07/08")
print(" (`r0807_piano_netto` §2: stesso fisco, stesso bersaglio, stessa convenzione di")
print(" mediana condizionata). I path sono diversi: si chiede accordo entro il rumore MC,")
print(" non l'identita'. Se questa riga non torna, nessun numero nuovo sarebbe")
print(" distinguibile da un bug.")
print(f"\n {'EUR/mese':>9}{'anni (pubbl.)':>16}{'P<=20a (pubbl.)':>18}{'anni (qui)':>13}{'P<=20a (qui)':>15}")
for dep, a_pub, p_pub in ((250, 19.8, 0.52), (500, 14.7, 0.99)):
sr = simula(r, (1.00,), anni=25, dep_eur=dep, bersagli={"muro": BERSAGLI["muro netto"]},
aliquota=ALIQUOTA, patrimoniale=PATRIM, n_paths=3_000, seed=725)
a, _, _, p20 = anni_cond(sr["hit"]["muro"][:, 0])
print(f" {dep:>9}{a_pub:>15.1f}a{p_pub:>18.0%}{fmt_anni(a):>13}{p20:>15.0%}")
# ------------------------------------------------------------------ (1)
sezione("(1) LA CURVA DI CRESCITA g(k) — dove sta il massimo, e DI QUANTO il libro ne dista")
print(f"\n Crescita geometrica MEDIANA annua, orizzonte {ANNI} anni, ZERO versamenti,")
print(f" {N_PATHS} path, fisco d'accumulo DENTRO. Un vol-target e' esattamente questo:")
print(" uno scalare sui pesi. Griglia allargata a 22x perche' a 8x il massimo non era")
print(" ancora interno — una griglia che si ferma prima del massimo non lo misura, lo tronca.")
s0 = simula(r, KS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI,
aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED)
s0b = simula(r, KS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI,
aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED_BIS)
mu, sig = float(r.mean()), float(r.std())
kelly_gauss = mu / sig ** 2
def g_curve(sim: dict) -> list[float]:
return [float(np.median(sim["mult"][:, j])) ** (1 / ANNI) - 1 for j in range(sim["mult"].shape[1])]
g_med, g_medb = g_curve(s0), g_curve(s0b)
jstar = int(np.argmax(g_med))
kbest, gbest = KS[jstar], g_med[jstar]
j1 = KS.index(1.00)
lg = lambda g: float(np.log1p(g)) # noqa: E731
print(f"\n {'k':>6}{'g mediana/anno':>17}{'(2° seme)':>12}{'ln(1+g)/ln(1+g*)':>19}"
f"{'maxDD mediano':>16}{'P(DD>30%)':>11}{'P(rovina)':>11}{'cap mediano a 25a':>20}")
for j, k in enumerate(KS):
marca = " <-- LIBRO" if j == j1 else (" <-- k*" if j == jstar else "")
print(f" {k:>6.2f}{g_med[j]:>17.2%}{g_medb[j]:>12.2%}{lg(g_med[j])/lg(gbest):>19.2f}"
f"{np.median(s0['dd'][:, j]):>16.1%}{(s0['dd'][:, j] > DD_SOGLIA).mean():>11.1%}"
f"{s0['ruin'][:, j].mean():>11.1%}{np.median(s0['cap'][:, j]):>20,.0f}{marca}")
esec = [j for j, k in enumerate(KS) if k <= 2.50]
ris = max(abs(a - b) for a, b in zip(g_med, g_medb))
ris_e = max(abs(g_med[j] - g_medb[j]) for j in esec)
print(f"\n RISOLUZIONE MONTE CARLO (due semi indipendenti a {N_PATHS} path): scarto massimo")
print(f" {ris:.2%}/anno sull'intera griglia, ma solo {ris_e:.2%}/anno nella regione")
print(f" k<={KS[esec[-1]]:.2f} — il rumore vive tutto nelle celle a leva alta, dove la mediana e'")
print(" presa su una distribuzione enormemente dispersa. Conseguenza da tenere: la")
print(" POSIZIONE del massimo non e' risolvibile a questa taglia (vedi sotto), i confronti")
print(" nella regione eseguibile si'. Ogni confronto e' APPAIATO (stessi path per ogni k).")
print(f"\n RISPOSTA ALLA DOMANDA 'da che parte, e di quanto':")
print(f" massimo EMPIRICO della crescita k* = {kbest:.2f}x (g = {gbest:.1%}/anno)")
print(f" massimo GAUSSIANO (Kelly, mu/sigma²) k = {kelly_gauss:.1f}x")
print(f" il LIBRO gira a k = 1.00x (g = {g_med[j1]:.1%}/anno)")
print(f" -> il libro sta a SINISTRA, a {1.0/kbest:.0%} di Kelly empirico, e raccoglie il")
print(f" {lg(g_med[j1])/lg(gbest):.0%} della crescita massima (in crescita LOGARITMICA, che e' la")
print(" grandezza che Kelly massimizza). NON e' un plateau: e' il ramo ripido.")
print(f"\n ⚠️ La POSIZIONE del massimo non e' risolta a questa taglia: la sezione (4) rifa'")
print(" la stessa curva con 1500 path e il massimo cade a 10x invece di 14x. La regione")
print(" 8-14x e' piatta entro il rumore -> si cita 'k* ~ 10-14x', non un numero.")
print(" Cio' che E' risolto, e con margine enorme, e' che 1.00x non ci sta vicino.")
print(f"\n ⚠️ PREVISIONE MIA, REFUTATA IN SESSIONE. Mi aspettavo che il massimo empirico")
print(f" cadesse molto a SINISTRA di quello gaussiano ({kelly_gauss:.1f}x), perche' il bootstrap a")
print(" blocchi conserva code e clustering che la formula ignora. Non succede: i due")
print(" coincidono entro il rumore. Il motivo e' informativo e prepara la sezione (4) —")
print(" la distribuzione delle CHIUSURE giornaliere di questo libro non ha code abbastanza")
print(" spesse da spostare Kelly. Il rischio che conta a leva non e' in quella")
print(" distribuzione, quindi non lo si trovera' ricampionandola meglio.")
print(f"\n ⚠️ Questo e' il risultato della lente, non una raccomandazione. Tre cose lo")
print(" ridimensionano, e sono misurate sotto: (a) k* dipende dal DRIFT, che e' stimato")
print(f" su {len(r)/365:.1f} anni (sez. 1-bis); (b) la coda che lo sostiene NON e' nel")
print(" campione (sez. 4); (c) meta' della griglia non e' eseguibile (sez. 5).")
print(f" ⚠️ E c'e' un controllo di plausibilita' che il modello fallisce da solo: a k={KS[jstar]:g}x")
print(f" il capitale mediano a 25 anni partendo da $600 e' ${np.median(s0['cap'][:, jstar]):,.0f}.")
print(" Un modello che promette piu' della capitalizzazione del mercato in cui trada ha")
print(" lasciato il dominio in cui vuol dire qualcosa: assume che il ritorno sia")
print(" indipendente dalla SIZE, il che e' falso ben prima di quella cifra.")
# ------------------------------------------------------------------ (1-bis)
sezione("(1-bis) QUANTO E' SOLIDO k* — Kelly e' ipersensibile al drift, e il drift e' una STIMA")
se_drift = float(r.std() * 365 ** 0.5) / (len(r) / 365) ** 0.5
print(f"\n drift stimato {mu*365:.2%}/anno su {len(r)/365:.1f} anni con vol {r.std()*365**0.5:.2%}")
print(f" -> errore standard della MEDIA = vol/sqrt(T) = {se_drift:.2%}/anno.")
print(" Kelly va come mu/sigma²: e' LINEARE nell'errore del drift, quindi l'incertezza")
print(" sul numeratore si trasferisce intera su k*. Stessa macchineria di `deluck_returns`")
print(" (si sottrae una costante al drift, la vol resta quella misurata).")
print(f"\n {'ipotesi sul drift':>34}{'drift/anno':>13}{'k* gaussiano':>15}{'k* empirico':>14}"
f"{'g(1x)':>9}{'g(k*)':>9}")
scen = [("punto (de-luckato ×0.89)", 0.0),
("-1 errore standard", se_drift),
("-2 errori standard", 2 * se_drift),
("drift dimezzato (lente 07/08)", mu * 365 / 2),
("drift a zero", mu * 365)]
for nome, taglio in scen:
rs = r - taglio / 365.0
mus = float(rs.mean())
kg = mus / sig ** 2
ss = simula(rs, KS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA,
patrimoniale=PATRIM, seed=SEED, n_paths=1_500)
gs = g_curve(ss)
js = int(np.argmax(gs))
print(f" {nome:>34}{mus*365:>13.2%}{kg:>15.1f}{KS[js]:>13.2f}x"
f"{gs[j1]:>9.1%}{gs[js]:>9.1%}")
print("\n Lettura: un errore di stima di UN solo errore standard sul drift — che a 7 anni di")
print(" storia e' l'ordine di grandezza normale — sposta k* di parecchie unita'. E' la")
print(" ragione classica per cui si opera a una FRAZIONE di Kelly: non prudenza morale, ma")
print(" il fatto che k* e' un numero stimato con una barra d'errore larga quanto se stesso.")
# ------------------------------------------------------------------ (2)
sezione(f"(2) SWEEP PRINCIPALE — tempo al bersaglio, orizzonte {ANNI} anni, AL NETTO del fisco")
print(f"\n da ${START:.0f} · {N_PATHS} path · blocchi {BLOCK}g · fisco {ALIQUOTA:.0%} + carry "
f"{TX.CARRY_ANNI}a + patrimoniale {PATRIM:.1%}")
print(" 'anni' = mediana CONDIZIONATA all'arrivo (non-arrivo = +infinito, MAI -1): si legge")
print(" sempre insieme a P<=20a accanto. 'p90' = il decile lento, cioe' il rischio di TEMPO.")
print(" ⚠️ Convenzione dichiarata: il fisco e' dentro il CAPITALE (colonne anni/P), NON dentro")
print(" il gemello di rischio (colonne DD/rovina). Un drawdown e' un fatto di mercato: metterci")
print(" l'imposta dentro sommerebbe due cose diverse in un numero solo.")
risultati = {}
for dep in DEPOSITI:
s = s0 if dep == 0 else simula(r, KS, anni=ANNI, dep_eur=dep, bersagli=BERSAGLI,
aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED)
risultati[dep] = s
for nome in BERSAGLI:
print(f"\n --- versamento €{dep}/mese · bersaglio {nome} (${BERSAGLI[nome]:,.0f}) · "
f"versato in {ANNI}a ${s['versato']:,.0f} ---")
print(f" {'k':>6}{'anni (p50)':>13}{'anni (p90)':>13}{'P<=10a':>9}{'P<=15a':>9}"
f"{'P<=20a':>9}{'P(rovina)':>11}{'P(DD>30%)':>11}{'P(DD>50%)':>11}")
for j, k in enumerate(KS):
h = s["hit"][nome][:, j]
a, p10, p15, p20 = anni_cond(h)
arr = h[np.isfinite(h)]
a90 = float(np.percentile(arr, 90)) / 365.0 if len(arr) >= 50 else float("nan")
print(f" {k:>6.2f}{fmt_anni(a):>13}{fmt_anni(a90):>13}{p10:>9.1%}{p15:>9.1%}"
f"{p20:>9.1%}{s['ruin'][:, j].mean():>11.1%}"
f"{(s['dd'][:, j] > DD_SOGLIA).mean():>11.1%}"
f"{(s['dd'][:, j] > 0.50).mean():>11.1%}")
# ------------------------------------------------------------------ (3)
sezione("(3) FRONTIERA ISO-ROVINA — la domanda del brief nella sua forma esatta")
print("\n 'a parita' di probabilita' di rovina accettata': per ogni budget di rischio, la")
print(" leva PIU' ALTA che lo rispetta, e il tempo che quel budget compra. Il rischio si")
print(" misura sul gemello SENZA versamenti (deposit-independent: un bonifico mensile")
print(" maschera un drawdown senza ridurre il capitale distrutto), ed e' la stessa")
print(" grandezza su cui passa la regola di DD per-conto del canale funded.")
for dep in DEPOSITI:
s = risultati[dep]
print(f"\n --- versamento €{dep}/mese ---")
print(f" {'budget':>8}{'metrica':>13}{'k max':>9}"
+ "".join(f"{'anni ' + n:>22}" for n in BERSAGLI))
for metrica, col in (("P(DD>30%)", lambda j: (s["dd"][:, j] > DD_SOGLIA).mean()),
("P(rovina)", lambda j: s["ruin"][:, j].mean())):
for budget in (0.01, 0.05, 0.10, 0.25, 0.50):
amm = [j for j in range(len(KS)) if col(j) <= budget]
if not amm:
print(f" {budget:>7.0%}{metrica:>13}{'nessuna':>9}")
continue
j = max(amm)
celle = []
for n in BERSAGLI:
a, _, _, p20 = anni_cond(s["hit"][n][:, j])
celle.append(f"{fmt_anni(a)} (P20 {p20:.0%})")
print(f" {budget:>7.0%}{metrica:>13}{KS[j]:>8.2f}x" + "".join(f"{c:>22}" for c in celle))
print("\n ⚠️ Le due metriche danno risposte MOLTO diverse, e la ragione conta: P(rovina)")
print(" (perdere l'80% del moltiplicatore) e' quasi zero fino a leve altissime perche'")
print(" il libro non ha mai perso l'80% in nessuna finestra ricampionabile; P(DD>30%)")
print(" morde subito. Chi sceglie la leva sul solo azzeramento sceglie sulla metrica")
print(" che il dataset non puo' informare — vedi (4).")
# ------------------------------------------------------------------ (4)
sezione("(4) IL GATE CHE CONTA — k* e' calcolato su un campione senza un solo giorno di crash")
print(f"\n Il block bootstrap ricampiona i giorni VISSUTI: non puo' produrre un giorno")
print(f" peggiore di {r.min():.2%}. Il libro ha attraversato il 2022 (LUNA/FTX) restando")
print(" difensivo — quindi per k=1 la coda del campione e' onesta. Ma a leva il rischio")
print(" cambia NATURA: entra la liquidazione, che su una chiusura giornaliera non si vede")
print(" (lezione della lente wick accoppiata, 25/07: 'close-only non e' conservativa, e'")
print(" CIECA'). Il rischio fuori campione non si stima dal campione: si mette a mano e si")
print(" dichiara. Ampiezze scelte a priori sull'unico precedente misurato nel progetto:")
print(" il gap-through-stop di SKH01 (sl 2% modellato -> -11/-23% realizzato).")
print(f"\n liquidazione se la perdita a leva supera {WIPE:.0%} dell'equity in un giorno.")
print(f"\n {'scenario':>22}" + "".join(f"{f'k={k:g}':>10}" for k in KS_STRESS) + f"{' k*':>8}")
s0s = simula(r, KS_STRESS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA,
patrimoniale=PATRIM, seed=SEED, n_paths=1_500)
g0s = [float(np.median(s0s["mult"][:, j])) ** (1 / ANNI) - 1 for j in range(len(KS_STRESS))]
print(f" {'nessuno stress':>22}" + "".join(f"{g:>10.1%}" for g in g0s)
+ f"{f'{KS_STRESS[int(np.argmax(g0s))]:g}x':>8}")
for cpct in (0.10, 0.15, 0.20):
for panno in (0.20, 1.00):
ss = simula(r, KS_STRESS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA,
patrimoniale=PATRIM, seed=SEED, crash_pct=cpct, crash_p_anno=panno,
n_paths=1_500)
gg = [float(np.median(ss["mult"][:, j])) ** (1 / ANNI) - 1 for j in range(len(KS_STRESS))]
print(f" {f'-{cpct:.0%} ogni {1/panno:.0f}a':>22}"
+ "".join(f"{g:>10.1%}" for g in gg)
+ f"{f'{KS_STRESS[int(np.argmax(gg))]:g}x':>8}")
print("\n Lettura: basta UN giorno fuori campione all'anno perche' il massimo collassi da")
print(" due cifre a poche unita', e il ramo destro della curva e' il primo ad andare a")
print(" -100%. La crescita a leva alta e' comprata vendendo la coda che il dataset non")
print(" contiene: e' la stessa firma che il gate `implausible_sharpe` cerca (rischio FUORI")
print(" dal dataset), applicata alla politica invece che alla strategia.")
# ------------------------------------------------------------------ (5)
sezione("(5) ESEGUIBILITA' — quanto di questa griglia esiste davvero sul conto")
import json
cfg = json.loads((ROOT / "config" / "live.json").read_text())
frac = cfg["max_notional_per_asset_frac"]
lordo_max = frac * 2 # 2 asset (BTC, ETH)
print(f"\n (a) IL CAP DI CONFIG. max_notional_per_asset_frac = {frac} su 2 asset")
print(f" -> nozionale LORDO massimo = {lordo_max:.2f}x l'equity. **k > {lordo_max:.2f}x NON e'")
print(" eseguibile senza toccare `config/live.json`**, e il 26/07 il progetto ha legato")
print(" una conclusione a quel parametro con un test apposta")
print(" (`test_leva_massima_da_config_resta_sotto_o_uguale_a_1x`): alzare il cap ROMPE")
print(" quel test, che e' esattamente il suo scopo. Quindi 11 delle 14 celle di questa")
print(" griglia sono TEORICHE per costruzione.")
print(f" min_order ${cfg['min_order_usd']} · disaster-SL {cfg['disaster_sl_pct']:.0%} · "
f"cap $ {cfg['max_notional_per_asset_usd']:,} (binding: min(quello, equity×{frac}))")
print("\n (b) IL KNOB `target_vol` DI TP01 — ipotesi mia: il leverage cap 2x lo strozza.")
from src.data.downloader import load_data # noqa: E402
from src.strategies.trend_portfolio import ( # noqa: E402
CANONICAL, TrendPortfolio, resample_1d, simple_returns)
import pandas as pd # noqa: E402
dfs = {a: resample_1d(load_data(a, "1h")) for a in ("BTC", "ETH")}
def tp01_knob(tv: float, lev: float) -> tuple[float, float, float]:
"""(vol realizzata annua, Sharpe, quota di barre incollate al leverage cap) del solo TP01
50/50 BTC-ETH, netto fee, con `target_vol=tv` e `leverage=lev`."""
serie, atcap = {}, []
for a, df in dfs.items():
tp = TrendPortfolio(**{**CANONICAL, "target_vol": tv, "leverage": lev})
tgt = np.nan_to_num(np.asarray(tp.target_series(df), float))
ret = simple_returns(df["close"].values.astype(float))
pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1]
net = pos * ret - 0.0005 * np.abs(np.diff(pos, prepend=0.0))
net[0] = 0.0
serie[a] = pd.Series(net, index=pd.to_datetime(df["datetime"]))
atcap.append(float((np.abs(tgt) >= lev - 1e-9).mean()))
J = pd.concat(serie, axis=1, join="inner").fillna(0.0) # le due serie hanno lunghezze
x = (0.5 * J["BTC"] + 0.5 * J["ETH"]).values # diverse: si allineano sulle date
return (float(x.std() * 365 ** 0.5), float(x.mean() / x.std() * 365 ** 0.5),
float(np.mean(atcap)))
rif = tp01_knob(0.20, 2.0)[0] # il canonico, calcolato PRIMA di usarlo come riferimento
print(f"\n {'target_vol':>11}{'lev cap':>9}{'x NOMINALE':>12}{'vol realizz.':>14}"
f"{'x OTTENUTO':>12}{'Sharpe':>9}{'quota barre al cap':>21}")
for lev in (2.0, 4.0):
for tv in (0.10, 0.20, 0.30, 0.40, 0.60):
v, sh, ac = tp01_knob(tv, lev)
print(f" {tv:>11.0%}{lev:>9.1f}{tv/0.20:>12.2f}{v:>14.2%}{v/rif:>12.2f}{sh:>9.2f}"
f"{ac:>21.1%}")
v20, sh20, _ = tp01_knob(0.20, 2.0)
print(f"\n ❌ IPOTESI REFUTATA, e la refutazione e' piu' utile dell'ipotesi. 'x OTTENUTO'")
print(" segue 'x NOMINALE' fino a target_vol 60% (3.00 nominale -> 2.98 ottenuto) e le")
print(" barre incollate al cap sono l'1.0%: **il leverage cap 2x NON e' binding**, quindi")
print(" il knob e' uno scalare lineare pulito. Lo Sharpe e' invariante a ogni cella")
print(f" (1.31 ovunque) — che e' esattamente il punto: la lente con cui `target_vol=20%`")
print(" fu scelto NON puo' distinguere queste righe, e infatti non le distingue.")
print(f"\n ⚠️ Sottoprodotto da registrare: a `target_vol=20%` la vol REALIZZATA di TP01 e'")
print(f" {v20:.2%}, non 20%. Il target vale sulla posizione quando c'e'; TP01 e' long-flat e")
print(" sta flat una parte del tempo, quindi l'etichetta sovrastima il rischio preso di")
print(" ~40%. Chi legge '20%' e lo confronta con un altro sistema sta confrontando un")
print(" parametro con una misura.")
print("\n Cio' che resta binding e' altro: (i) il cap di nozionale di config, punto (a);")
print(" (ii) SKH01 (25% del libro) NON e' vol-targeted affatto (misurato 26/07) — li'")
print(" l'unico modo di alzare la leva e' moltiplicare la size, cioe' margine e")
print(" liquidazione vere, non un parametro di sizing.")
print("\n (c) COSA COSTA LA LEVA E NON E' NEL MODELLO SOPRA (tutti a sfavore di k alto):")
print(" · la fee scala LINEARMENTE con k (3,5 bps/lato oggi) e SKH01 e' ~4x piu'")
print(" fee-sensibile di TP01 (curva misurata il 26/07: -0.017 Sharpe/bps di book);")
print(" · il funding dei perp e il costo del margine oltre 1x non sono modellati;")
print(" · il min_order $5 su un conto da $635 e la granularita' che ne consegue;")
print(" · l'impatto: il modello assume ritorno indipendente dalla size (vedi sez. 1).")
print(" -> i numeri a k>1 sono un TETTO, non una stima.")
# ------------------------------------------------------------------ verdetto
sezione("VERDETTO")
s500 = risultati[500]
jj = {k: KS.index(k) for k in (1.00, 1.50, 2.00)}
righe = []
for nome in BERSAGLI:
for k, j in jj.items():
a, _, _, p20 = anni_cond(s500["hit"][nome][:, j])
righe.append((nome, k, a, p20, (s500["dd"][:, j] > DD_SOGLIA).mean()))
print(f"\n A €500/mese (il piano dichiarato), zona ESEGUIBILE e primo passo oltre:")
print(f" {'bersaglio':>14}{'k':>7}{'anni (p50)':>13}{'P<=20a':>9}{'P(DD>30%)':>11}")
for nome, k, a, p20, pdd in righe:
print(f" {nome:>14}{k:>6.2f}x{fmt_anni(a):>13}{p20:>9.0%}{pdd:>11.0%}")
print(f"""
GATE — cosa e' girato e cosa no (un gate non girato si dichiara, non si omette):
· `marginal_vs_tp01` NON APPLICABILE. Non c'e' un flusso di ritorni nuovo da giudicare
marginalmente: k e' uno SCALARE sul libro esistente, quindi la
correlazione col baseline e' 1.00 per costruzione.
· `deflated_sharpe` NON APPLICABILE, e vale la pena dire perche': lo Sharpe e'
INVARIANTE a k (misurato, sez. 5b: 1.31 a ogni cella del knob).
Un gate costruito sullo Sharpe da' lo stesso verdetto su tutta la
griglia — non e' che lo passi, e' che non vede la variabile.
· null del de-levering NON e' un gate da superare qui: e' L'OGGETTO della misura, percorso
in entrambi i versi (k<1 e k>1). E lo conferma: ogni 'meno DD' via
leva e' de-levering per definizione, ogni 'piu' crescita' e' leva.
· fortuna d'ancora GESTITA col fattore MISURATO ×{DP.DELUCK} sul drift (26/07), non con una
banda nuova: la leva non ha una propria ancora, moltiplica il drift.
· `implausible_sharpe` Non girato come funzione (darebbe lo stesso verdetto a ogni k, vedi
sopra); il controllo di plausibilita' e' stato fatto sull'OUTPUT ed
e' FALLITO dal modello a leva alta: capitale mediano ${np.median(s0['cap'][:, jstar]):,.0f}
da $600 in 25 anni. La firma 'rischio fuori dal dataset' c'e', ma
vive nella POLITICA, non nella strategia.
· causalita' Ereditata: la serie viene da `CC.book_series('hourly')`, macchineria
gia' validata; qui non si costruisce nessun segnale nuovo.
· risoluzione MC DICHIARATA: {ris_e:.2%}/anno nella regione eseguibile, {ris:.2%}/anno sulla
griglia intera. La posizione del massimo NON e' risolta.
1. IL LIBRO STA A SINISTRA DEL MASSIMO, E DI MOLTO: k* empirico ~10-14x (banda, non un
numero: a 1500 path cade a 10x, a 2500 a {kbest:.0f}x), il libro gira a 1x = {1.0/kbest:.0%} di Kelly
e raccoglie il {lg(g_med[j1])/lg(gbest):.0%} della crescita massima in log. Non e' un plateau
(come lo era il peso 75/25): e' il ramo ripido della curva. Sulla lente della CRESCITA
la scelta canonica `target_vol=20%` non e' ottimale — ed e' un fatto, perche' quella
scelta fu fatta su una lente di Sharpe, che alla leva e' INVARIANTE.
2. MA k* NON E' UN NUMERO SU CUI SI PUO' AGIRE, per tre ragioni misurate qui:
(a) e' lineare nell'errore del drift, stimato su {len(r)/365:.1f} anni (sez. 1-bis);
(b) e' comprato vendendo una coda che il dataset NON contiene — un giorno di crash
all'anno lo riporta a poche unita' (sez. 4);
(c) 11 celle su 14 non sono eseguibili: il cap di config tiene il nozionale lordo a
{lordo_max:.2f}x l'equity. (Il knob `target_vol` invece FUNZIONA — misurato, sez. 5b: il
leverage cap 2x non morde. L'ostacolo e' la config e SKH01, non il sizing di TP01.)
3. IL BERSAGLIO CAMBIA LA RISPOSTA, e nel verso opposto a quello che la domanda suggerisce.
Su $50k a €500/mese la leva quasi non serve: a k=1 ci si arriva in ~5 anni con P=100%,
perche' fino a quella taglia il capitale lo portano i BONIFICI, non il rendimento
(misura indipendente del 26/07: al traguardo l'80% viene dal rendimento, ma i primi
anni no). Sul muro netto la leva morde: e' proprio dove il rischio di rovina diventa
non-recuperabile, perche' zero e' assorbente e si ricomincia con lo stesso stipendio.
4. IL BUCO STRUTTURALE, che e' il risultato piu' trasferibile: **ogni gate di questo
progetto e' invariante alla leva**. `study_family_honest`, il deflated-Sharpe,
`marginal_vs_tp01`, `implausible_sharpe`, il null del de-levering, `weights_tilt_null`
— tutti giudicano forma della distribuzione, correlazioni o PESI RELATIVI, e nessuno
vede la SCALA. Per questo `target_vol=20%` e' sopravvissuto due mesi di scrutinio
senza mai essere esaminato: non c'era una lente che potesse bocciarlo.
5. COSA RESTA DI OPERATIVO: nulla da cambiare oggi. Il gradino ESEGUIBILE (k da 1.00x a
1.25-1.50x, cioe' alzare il cap di config) esiste e non e' rumore MC, ma passa per una
modifica a un file di produzione su un conto con soldi veri, e nessun gate del progetto
lo autorizza: `weights_tilt_null` giudica i PESI, non la scala, e non esiste un gate
'politica di leva'. Il candidato naturale sarebbe misurare la stessa curva sul canale
FUNDED, dove il vincolo binding e' gia' una regola di DD per-conto e il nozionale non
e' il proprio.
""")
if __name__ == "__main__":
main()
+660
View File
@@ -0,0 +1,660 @@
#!/usr/bin/env python
"""r0822_oi_pin.py — OI-PIN: il prezzo e' attratto verso gli strike a massimo open interest
nelle ore che precedono la scadenza Deribit? (ondata 2026-08-22, filone OI-PIN)
CONTESTO / PERCHE' QUESTO FILONE NON E' "CALENDARIO GIA' MORTO"
--------------------------------------------------------------
Il 02/07 il progetto ha bocciato il **calendario** delle scadenze Deribit (drift post-expiry
weekly/monthly/quarterly): 0/24 celle a Bonferroni, e il pattern si INVERTE proprio sul
quarterly, dove l'open interest massimo dovrebbe amplificarlo. Quel test guardava le DATE.
Qui si guarda l'OPEN INTEREST VERO, per strike, ora per ora (`data/raw/cb_chain/`):
il meccanismo proposto non e' "il venerdi' e' speciale" ma "il market maker corto gamma
ricompra/rivende verso lo strike dove sta la sua esposizione". E' un meccanismo NON di
calendario, quindi ammissibile secondo la sezione 5 del brief.
IPOTESI (dichiarata PRIMA di misurare)
--------------------------------------
H1 Il prezzo deriva VERSO il livello a massima concentrazione di OI / gamma / max-pain nelle
ultime ore prima della scadenza (statistica `toward = sign(K - S_t) * r_{t->T}` > 0).
H2 L'effetto e' piu' forte a poche ore dalla scadenza e sulle scadenze grandi (venerdi').
H0-ATTESA DELL'AUTORE: **negativa**. Motivi dichiarati in anticipo:
(a) il crypto e' 24/7, senza il "chiusura di borsa" che genera il pinning azionario classico;
(b) l'OI Deribit e' grande ma il perp/spot che lo dovrebbe muovere e' molto piu' grande;
(c) il progetto ha gia' misurato che ogni effetto di finestra su BTC/ETH e' rumore;
(d) e soprattutto: `K_oi` sta quasi sempre VICINO allo spot, quindi qualunque statistica
"il prezzo va verso K" e' contaminata dalla POSIZIONE del livello, non dal suo OI.
Per questo il null location-matched (regola codificata il 02/07 sui livelli di Fibonacci)
non e' un contorno: e' IL test.
CONVENZIONE CAUSALE (verificata da un assert nel codice)
--------------------------------------------------------
Barre 1h open-labeled: la barra etichettata `tau` copre [tau, tau+1h) e il suo close e' il
prezzo a `tau+1h`.
* snapshot catena all'ora `hh` -> righe con ts in [hh, hh+1h)
* decisione a fine barra `hh` -> prezzo eseguibile = close(hh) = prezzo a hh+1h
* quindi entrata a `hte` ore dalla scadenza <=> snapshot a dte = hte+1 ore
* uscita al prezzo di regolamento = close della barra (E-1h) = prezzo a E (08:00 UTC)
`A.causality_ok` NON e' applicabile (il segnale non e' funzione del solo feed OHLC): la
causalita' e' garantita per costruzione e verificata da `_assert_causalita()`.
Uso: nice -n 19 timeout 900 uv run python scripts/research/r0822_oi_pin.py
"""
from __future__ import annotations
import glob
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" / "alt"))
import altlib as A # noqa: E402
STORE = ROOT / "data" / "raw" / "cb_chain"
ASSETS = ("BTC", "ETH")
FEE_SIDE_REAL = 0.00035 # 3,5 bps/lato = fee Deribit REALE del libro (7 bps RT)
FEE_SIDE_CONS = A.FEE_SIDE # 5 bps/lato = convenzione conservativa dei backtest del progetto
MAX_H = 50 # ore prima della scadenza da tenere in memoria
HTE_GRID = (1, 2, 3, 6, 12, 24, 36, 48) # ore-alla-scadenza all'INGRESSO
RULES = ("oi", "gex", "mp") # max OI · max gamma*OI · max pain
PLACEBOS = ("rand", "adj", "jit", "mid", "mr7")
SEED = 20260822
RNG = np.random.default_rng(SEED)
# =========================================================================== dati
def _files() -> list[str]:
fs = sorted(glob.glob(str(STORE / "*.parquet")))
if not fs:
raise FileNotFoundError(f"{STORE} vuoto o assente")
return fs
def load_near(max_h: int = MAX_H) -> pd.DataFrame:
"""Catena filtrata alle sole ore vicine a scadenza, letta file per file (RAM: 7 GB in due)."""
cols = ["asset", "strike", "option_type", "ts", "exp",
"open_interest", "gamma", "quote_status"]
out = []
for p in _files():
d = pd.read_parquet(p, columns=cols)
d["ts"] = pd.to_datetime(d["ts"], utc=True)
d["exp"] = pd.to_datetime(d["exp"], utc=True)
d["hh"] = d["ts"].dt.floor("h")
d["dte_h"] = (d["exp"] - d["hh"]).dt.total_seconds() / 3600.0
d = d[(d["dte_h"] > 0) & (d["dte_h"] <= max_h)]
if len(d):
out.append(d)
del d
d = pd.concat(out, ignore_index=True)
# una riga presente non e' un dato presente: OI mancante o quota rotta non conta
d = d[d["open_interest"].notna() & (d["open_interest"] > 0)]
d = (d.sort_values("ts")
.drop_duplicates(subset=["asset", "exp", "hh", "strike", "option_type"], keep="last")
.reset_index(drop=True))
return d
def spot_1h() -> dict[str, pd.Series]:
s = {}
for a in ASSETS:
df = A.get(a, "1h")
s[a] = pd.Series(df["close"].to_numpy(float),
index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)))
return s
# =========================================================================== livelli
def _max_pain(K: np.ndarray, oi_c: np.ndarray, oi_p: np.ndarray) -> float:
"""Strike che minimizza il payout complessivo verso i detentori (max pain classico)."""
d = K[None, :] - K[:, None] # d[i,j] = K_j - K_i (K_i = candidato)
pay = (np.maximum(-d, 0.0) * oi_c[None, :]).sum(1) + (np.maximum(d, 0.0) * oi_p[None, :]).sum(1)
return float(K[int(np.argmin(pay))])
def levels_table(chain: pd.DataFrame, spot: dict[str, pd.Series]) -> pd.DataFrame:
"""Un record per (asset, scadenza, ora): i 3 livelli candidati + i 4 placebo di strike
+ le misure di concentrazione. I placebo sono LOCATION-MATCHED (regola 02/07)."""
rows = []
piv = (chain.groupby(["asset", "exp", "hh", "strike", "option_type"], sort=False)
.agg(oi=("open_interest", "sum"), gm=("gamma", "mean")).reset_index())
for (asset, exp, hh), g in piv.groupby(["asset", "exp", "hh"], sort=False):
S = spot[asset].asof(hh) # prezzo a hh+1h = close(barra hh)
if not np.isfinite(S) or S <= 0:
continue
K = np.sort(g["strike"].unique().astype(float))
if len(K) < 3:
continue
oi_c = np.zeros(len(K)); oi_p = np.zeros(len(K)); gx = np.zeros(len(K))
idx = {k: i for i, k in enumerate(K)}
for k, ot, o, gm in zip(g["strike"].to_numpy(float), g["option_type"].to_numpy(),
g["oi"].to_numpy(float), g["gm"].to_numpy(float)):
i = idx[float(k)]
(oi_c if ot == "C" else oi_p)[i] += o
gx[i] += o * (gm if np.isfinite(gm) else 0.0)
oi = oi_c + oi_p
tot = oi.sum()
if tot <= 0:
continue
i_oi = int(np.argmax(oi))
lv = {
"oi": float(K[i_oi]),
"gex": float(K[int(np.argmax(gx))]) if gx.max() > 0 else np.nan,
"mp": _max_pain(K, oi_c, oi_p),
}
# ---- placebo location-matched -------------------------------------------------
# rand: permutare l'OI fra gli strike e riprendere l'argmax equivale ESATTAMENTE a
# estrarre uno strike uniforme dalla griglia (l'argmax di un vettore permutato
# e' uniforme sulle posizioni) -> lo si implementa cosi', ed e' esatto.
p_rand = float(K[RNG.integers(len(K))])
# adj: lo strike ADIACENTE al max-OI (stesso quartiere, OI diverso)
j = i_oi + (1 if (i_oi == 0 or (i_oi < len(K) - 1 and RNG.random() < 0.5)) else -1)
p_adj = float(K[int(np.clip(j, 0, len(K) - 1))])
# jit: strike arbitrario alla STESSA distanza dallo spot ("0.618 vs 0.58?")
dist = abs(K - S)
d0 = abs(lv["oi"] - S)
cand = np.where((dist >= 0.5 * d0) & (dist <= 1.5 * d0) & (K != lv["oi"]))[0]
p_jit = float(K[cand[RNG.integers(len(cand))]]) if len(cand) else np.nan
# mid: punto medio della griglia quotata = puro artefatto di copertura, zero info OI
p_mid = float(0.5 * (K[0] + K[-1]))
rows.append(dict(
asset=asset, exp=exp, hh=hh, dte_h=float((exp - hh).total_seconds() / 3600.0),
S=float(S), n_k=len(K), tot_oi=float(tot),
k_oi=lv["oi"], k_gex=lv["gex"], k_mp=lv["mp"],
p_rand=p_rand, p_adj=p_adj, p_jit=p_jit, p_mid=p_mid,
top_share=float(oi[i_oi] / tot),
hhi=float(((oi / tot) ** 2).sum()),
k_step=float(np.median(np.diff(K))) if len(K) > 1 else np.nan,
))
return pd.DataFrame(rows)
# =========================================================================== il FATTO
def build_events(lev: pd.DataFrame, spot: dict[str, pd.Series]) -> pd.DataFrame:
"""Un evento per (asset, scadenza, hte): livelli, prezzo d'ingresso, prezzo di regolamento."""
ev = []
mr7 = {a: spot[a].rolling(24 * 7, min_periods=24).mean() for a in ASSETS}
for a in ASSETS:
s = spot[a]
sub = lev[lev["asset"] == a]
for exp, g in sub.groupby("exp"):
t_set = pd.Timestamp(exp) - pd.Timedelta(hours=1) # barra il cui close e' a E
if t_set not in s.index:
continue
S_T = float(s.loc[t_set])
gi = g.set_index("hh")
for hte in HTE_GRID:
hh = pd.Timestamp(exp) - pd.Timedelta(hours=hte + 1)
if hh not in gi.index or hh not in s.index:
continue
r = gi.loc[hh]
S = float(s.loc[hh]) # close(hh) = prezzo a E-hte
if not np.isfinite(S) or S <= 0:
continue
rec = dict(asset=a, exp=exp, hte=hte, hh=hh, S=S, S_T=S_T,
ret=S_T / S - 1.0, n_k=int(r["n_k"]), tot_oi=float(r["tot_oi"]),
top_share=float(r["top_share"]), hhi=float(r["hhi"]),
k_step=float(r["k_step"]),
dow=int(pd.Timestamp(exp).dayofweek))
lvls = {"oi": r["k_oi"], "gex": r["k_gex"], "mp": r["k_mp"],
"rand": r["p_rand"], "adj": r["p_adj"], "jit": r["p_jit"],
"mid": r["p_mid"], "mr7": float(mr7[a].asof(hh))}
for nm, K in lvls.items():
K = float(K) if K is not None else np.nan
if not np.isfinite(K) or K <= 0:
rec[f"d_{nm}"] = np.nan; rec[f"tw_{nm}"] = np.nan
rec[f"sh_{nm}"] = np.nan
continue
d = (K - S) / S
rec[f"K_{nm}"] = K
rec[f"d_{nm}"] = d
rec[f"tw_{nm}"] = np.sign(d) * rec["ret"] # verso il livello
rec[f"sh_{nm}"] = (abs(S - K) - abs(S_T - K)) / S # avvicinamento
ev.append(rec)
E = pd.DataFrame(ev)
# NULL STATICO CAUSALE (lezione 25/07: il primo null statico di STATARB usava
# sign(mean(segnale)) su tutto il campione = look-ahead; qui la maggioranza e' ESPANDENTE
# e shiftata). Risponde a: "il candidato e' solo 'sempre nello stesso verso' prima della
# scadenza?" — che su 3,7 mesi di mercato direzionale e' l'ipotesi piu' probabile.
E = E.sort_values(["asset", "hte", "hh"]).reset_index(drop=True)
sgn = np.sign(E["d_mp"].to_numpy(float))
maj = (pd.Series(sgn).groupby([E["asset"], E["hte"]])
.transform(lambda x: x.expanding().mean().shift(1)))
E["d_maj"] = np.where(np.isfinite(maj), np.sign(maj) * 0.01, np.nan)
E["tw_maj"] = np.sign(E["d_maj"]) * E["ret"]
E["sh_maj"] = np.nan
E["d_long"] = 0.01; E["tw_long"] = E["ret"]; E["sh_long"] = np.nan
E["d_short"] = -0.01; E["tw_short"] = -E["ret"]; E["sh_short"] = np.nan
return E
def _assert_causalita(chain: pd.DataFrame, ev: pd.DataFrame) -> str:
"""Il livello usato a `hh` deve venire da quote osservate PRIMA del prezzo d'ingresso."""
bad = int((chain["ts"] < chain["hh"]).sum()
+ (chain["ts"] >= chain["hh"] + pd.Timedelta("1h")).sum())
assert bad == 0, "snapshot fuori dalla propria ora"
dte = (pd.to_datetime(ev["exp"]) - pd.to_datetime(ev["hh"])).dt.total_seconds() / 3600.0
assert bool((dte == ev["hte"] + 1).all()), "hte non coerente con lo snapshot"
assert bool(((pd.to_datetime(ev["hh"]) + pd.Timedelta(hours=1))
<= pd.to_datetime(ev["exp"])).all()), "ingresso oltre la scadenza"
return (f"OK — 0/{len(chain)} righe di catena fuori dalla propria ora; "
f"{len(ev)} eventi con dte == hte+1 (ingresso = close(hh), quota vista in [hh,hh+1h))")
# =========================================================================== inferenza
def cluster_boot(x: np.ndarray, clusters: np.ndarray, n: int = 2000, seed: int = SEED):
"""IC95 della media con ricampionamento dei CLUSTER (una scadenza = 1 cluster: BTC ed ETH
della stessa scadenza non sono osservazioni indipendenti)."""
x = np.asarray(x, float)
m = np.isfinite(x)
x, clusters = x[m], np.asarray(clusters)[m]
if len(x) < 5:
return np.nan, np.nan, np.nan, 0
uc = np.unique(clusters)
groups = [x[clusters == c] for c in uc]
rng = np.random.default_rng(seed)
draws = np.empty(n)
for i in range(n):
pick = rng.integers(0, len(groups), len(groups))
draws[i] = np.mean(np.concatenate([groups[j] for j in pick]))
return float(np.mean(x)), float(np.percentile(draws, 2.5)), float(np.percentile(draws, 97.5)), len(uc)
def mde(x: np.ndarray, clusters: np.ndarray) -> float:
"""Effetto minimo rilevabile (alpha .05 bilaterale, potenza 80%) con questo campione."""
x = np.asarray(x, float)
m = np.isfinite(x)
x, cl = x[m], np.asarray(clusters)[m]
if len(x) < 3:
return np.nan
nc = max(len(np.unique(cl)), 1)
return float(2.802 * np.std(x) / np.sqrt(nc))
# =========================================================================== strategia
_W: dict = {}
def win_1h(asset: str, lo: pd.Timestamp, hi: pd.Timestamp) -> pd.DataFrame:
"""Feed 1h tagliato alla finestra della catena: fuori da li' la posizione e' zero per
costruzione, e valutare 70k barre 288 volte costerebbe minuti per nulla."""
key = (asset, lo, hi)
if key not in _W:
df = A.get(asset, "1h")
dt = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
m = (dt >= lo - pd.Timedelta("2D")) & (dt <= hi + pd.Timedelta("2D"))
_W[key] = df.loc[m].reset_index(drop=True)
return _W[key]
def hourly_target(ev: pd.DataFrame, asset: str, rule: str, hte: int,
band: tuple[float, float] | None, sizing: str,
subsample: str, df1h: pd.DataFrame) -> np.ndarray:
"""Posizione oraria: entra `hte` ore prima della scadenza nel verso del livello, esce a
scadenza. Se piu' finestre si sovrappongono vince la scadenza PIU' VICINA (una posizione
alla volta = cio' che il conto Deribit puo' davvero fare: strumento unico, netting)."""
idx = pd.DatetimeIndex(pd.to_datetime(df1h["datetime"], utc=True))
pos = np.zeros(len(idx))
loc = pd.Series(np.arange(len(idx)), index=idx)
e = ev[(ev["asset"] == asset) & (ev["hte"] == hte)].copy()
if subsample == "fri":
e = e[e["dow"] == 4]
elif subsample == "big":
e = e[e["tot_oi"] >= e["tot_oi"].median()]
d = e[f"d_{rule}"].to_numpy(float)
keep = np.isfinite(d)
if band is not None:
keep &= (np.abs(d) >= band[0]) & (np.abs(d) <= band[1])
e = e[keep]
d = d[keep]
if len(e) == 0:
return pos
w = np.sign(d) if sizing == "sign" else np.clip(d / 0.01, -1.0, 1.0)
order = np.argsort(e["exp"].to_numpy())[::-1] # lontane prima: la scadenza VICINA sovrascrive
for j in order:
hh = pd.Timestamp(e["hh"].iloc[j]); exp = pd.Timestamp(e["exp"].iloc[j])
last = exp - pd.Timedelta(hours=2)
if hh not in loc.index or last not in loc.index:
continue
i0, i1 = int(loc[hh]), int(loc[last])
if i1 >= i0:
pos[i0:i1 + 1] = w[j]
return pos
def strat_daily(ev: pd.DataFrame, rule: str, hte: int, band, sizing: str, subsample: str,
fee_side: float) -> tuple[pd.Series, dict]:
"""Serie giornaliera netta 50/50 BTC+ETH + metriche, sulla sola finestra della catena."""
nets, info = {}, {}
lo_w = pd.Timestamp(ev["hh"].min()); hi_w = pd.Timestamp(ev["exp"].max())
for a in ASSETS:
df = win_1h(a, lo_w, hi_w)
tgt = hourly_target(ev, a, rule, hte, band, sizing, subsample, df)
r = A.eval_weights(df, tgt, fee_side=fee_side)
nets[a] = pd.Series(r["net"], index=r["idx"])
info[a] = int(np.sum(np.abs(np.diff(np.r_[0.0, tgt])) > 1e-12))
J = pd.concat(nets, axis=1, join="inner").fillna(0.0)
daily = A._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]])
lo = pd.Timestamp(ev["hh"].min()).normalize()
daily = daily[daily.index >= lo]
return daily, info
def metr(s: pd.Series) -> dict:
r = s.dropna()
if len(r) < 5:
return dict(sharpe=np.nan, dd=np.nan, cagr=np.nan, n=len(r))
eq = np.cumprod(1 + r.to_numpy(float)); pk = np.maximum.accumulate(eq)
yrs = max((r.index[-1] - r.index[0]).total_seconds() / 86400 / 365.25, 1e-6)
return dict(sharpe=A._sh(r), dd=float(np.max((pk - eq) / pk)),
cagr=float(eq[-1] ** (1 / yrs) - 1), n=int(len(r)))
# =========================================================================== main
def main() -> None:
P = print
P("=" * 100)
P("OI-PIN — pinning verso gli strike a massimo open interest (catena Deribit oraria)")
P("=" * 100)
# ---------------------------------------------------------------- 1. DATO
chain = load_near()
spot = spot_1h()
lev = levels_table(chain, spot)
ev = build_events(lev, spot)
P("\n[1] DATO")
P(f" righe catena a <= {MAX_H}h dalla scadenza : {len(chain):,}")
P(f" scadenze distinte : {ev.groupby('asset').exp.nunique().to_dict()}")
P(f" finestra : {ev.hh.min()} -> {ev.exp.max()}")
P(f" eventi (asset x scadenza x hte) : {len(ev)}")
P(f" strike per snapshot mediana/min : {lev.n_k.median():.0f} / {lev.n_k.min():.0f}")
P(f" causalita' : {_assert_causalita(chain, ev)}")
P(" ⚠ il collettore scarta gli strumenti con OI<100 -> la griglia e' TRONCATA sulle code:")
P(" il max-OI ne soffre poco (e' un argmax), il MAX PAIN si', perche' e' una somma su tutta")
P(" la catena. Il verdetto su 'mp' va letto come 'max pain sulla catena OI>=100'.")
# ---------------------------------------------------------------- 2. POTENZA (PRIMA del risultato)
P("\n[2] POTENZA DICHIARATA — calcolata PRIMA di leggere qualunque effetto")
P(" Unita' statistica = una SCADENZA (BTC ed ETH della stessa data non sono indipendenti).")
P(f" {'hte':>4} {'n oss':>6} {'n clus':>7} {'sd(ret)':>9} {'MDE medio/trade':>16} {'MDE ann.':>10}")
for hte in HTE_GRID:
e = ev[ev["hte"] == hte]
m = mde(e["ret"].to_numpy(float), e["exp"].to_numpy())
ann = m * (365.25 * 24 / hte) if np.isfinite(m) else np.nan
P(f" {hte:>4} {len(e):>6} {e.exp.nunique():>7} {np.nanstd(e['ret']):>9.4f}"
f" {m:>16.4f} {ann:>9.1%}")
P(" LETTURA OBBLIGATORIA: il campione rileva solo effetti ENORMI. A 24h l'MDE e' ~0,5-0,7%")
P(" per trade contro 0,07% di fee: un pinning realistico (pochi bps) e' INVISIBILE qui.")
P(" Quindi un 'non significativo' NON prova assenza; un 'significativo' sarebbe cosi' grande")
P(" da essere sospetto. Il verdetto massimo raggiungibile e' LEAD.")
# ---------------------------------------------------------------- 3. IL FATTO
P("\n[3] IL FATTO — dove sta il livello e dove va il prezzo")
P(" |ret| verso la scadenza e distanza |d| del livello (mediane):")
P(f" {'hte':>4} {'|ret| med':>10} {'|d_oi| med':>11} {'|d_gex|':>9} {'|d_mp|':>9}"
f" {'d_oi<0':>8} {'top_share':>10}")
for hte in HTE_GRID:
e = ev[ev["hte"] == hte]
P(f" {hte:>4} {e['ret'].abs().median():>10.4f} {e['d_oi'].abs().median():>11.4f}"
f" {e['d_gex'].abs().median():>9.4f} {e['d_mp'].abs().median():>9.4f}"
f" {(e['d_oi'] < 0).mean():>8.2f} {e['top_share'].median():>10.3f}")
P(" ⚠ se 'd_oi<0' e' lontano da 0.50 il livello ha un BIAS DI SEGNO (griglia troncata o")
P(" mercato in trend): una statistica 'il prezzo va verso K' lo erediterebbe.")
P("\n Statistica di pinning tw = sign(K-S)*ret (media, IC95 cluster-bootstrap su scadenze):")
P(f" {'hte':>4} {'rule':>5} {'media tw':>10} {'IC95 lo':>9} {'IC95 hi':>9} {'sig':>4}"
f" {'shrink med':>11} {'n':>5}")
fact = []
for hte in HTE_GRID:
e = ev[ev["hte"] == hte]
for rl in RULES:
m, lo, hi, nc = cluster_boot(e[f"tw_{rl}"].to_numpy(float), e["exp"].to_numpy())
sg = "***" if (np.isfinite(lo) and lo > 0) else ("neg" if (np.isfinite(hi) and hi < 0) else "-")
P(f" {hte:>4} {rl:>5} {m:>10.5f} {lo:>9.5f} {hi:>9.5f} {sg:>4}"
f" {np.nanmedian(e[f'sh_{rl}']):>11.5f} {int(np.isfinite(e[f'tw_{rl}']).sum()):>5}")
fact.append(dict(hte=hte, rule=rl, mean=m, lo=lo, hi=hi))
F = pd.DataFrame(fact)
P(f" celle con IC95 tutto sopra zero: {int((F.lo > 0).sum())}/{len(F)}"
f" | tutto sotto zero: {int((F.hi < 0).sum())}/{len(F)}")
P("\n LA STESSA STATISTICA SUI LIVELLI PLACEBO — se sale anche li', NON e' pinning:")
P(f" {'hte':>4} " + " ".join(f"{('tw_' + n):>10}" for n in
("oi", "mp", "rand", "jit", "mid", "mr7", "maj", "short")))
for hte in HTE_GRID:
e = ev[ev["hte"] == hte]
P(f" {hte:>4} " + " ".join(
f"{np.nanmean(e[f'tw_{n}']):>10.5f}" for n in
("oi", "mp", "rand", "jit", "mid", "mr7", "maj", "short")))
P("\n QUANTO SONO LO STESSO SEGNALE? accordo del SEGNO di (K-S) fra max-pain e i placebo:")
for n in ("mid", "mr7", "oi", "maj"):
agr = [float(np.nanmean(np.sign(ev.loc[ev.hte == h, "d_mp"])
== np.sign(ev.loc[ev.hte == h, f"d_{n}"]))) for h in HTE_GRID]
P(f" mp vs {n:<5}: " + " ".join(f"h{h}={a:.2f}" for h, a in zip(HTE_GRID, agr)))
P(f" base rate sign<0 : " + " ".join(
f"{n}={np.nanmean(np.sign(ev[f'd_{n}']) < 0):.2f}" for n in ("oi", "gex", "mp", "mid", "mr7")))
for a in ASSETS:
sp_ = spot[a]
w = sp_[(sp_.index >= ev.hh.min()) & (sp_.index <= ev.exp.max())]
P(f" deriva {a} nella finestra: {w.iloc[-1] / w.iloc[0] - 1:+.1%}"
f" (se il livello sta quasi sempre da un lato, il candidato e' una scommessa direzionale)")
# ---------------------------------------------------------------- 4. NULL LOCATION-MATCHED
P("\n[4] NULL LOCATION-MATCHED (regola 02/07: e' la POSIZIONE o e' l'OI?)")
P(" Confronto APPAIATO per evento: tw(livello speciale) - tw(placebo alla stessa distanza).")
P(" placebo: rand=strike uniforme (== permutazione dell'OI, equivalenza esatta) · adj=strike")
P(" adiacente al max-OI · jit=strike arbitrario a distanza 0.5-1.5x (analogo Fib±jitter) ·")
P(" mid=centro della griglia quotata (zero info OI) · mr7=media 7g dello spot (mean-reversion).")
P(f" {'hte':>4} {'rule':>5} " + " ".join(f"{('Δ vs ' + p):>12}" for p in PLACEBOS))
npos = ntot = 0
for hte in HTE_GRID:
e = ev[ev["hte"] == hte]
for rl in RULES:
cells = []
for p in PLACEBOS:
dd = e[f"tw_{rl}"].to_numpy(float) - e[f"tw_{p}"].to_numpy(float)
m, lo, hi, _ = cluster_boot(dd, e["exp"].to_numpy())
mark = "*" if (np.isfinite(lo) and lo > 0) else (" ")
cells.append(f"{m:>11.5f}{mark}")
ntot += 1
npos += int(np.isfinite(lo) and lo > 0)
P(f" {hte:>4} {rl:>5} " + " ".join(cells))
P(f" celle (rule x hte x placebo) in cui lo SPECIALE batte il placebo con IC95>0:"
f" {npos}/{ntot} (atteso per caso ~{0.025 * ntot:.0f})")
# ---------------------------------------------------------------- 5. CONDIZIONAMENTO
P("\n[5] CONDIZIONAMENTO — la concentrazione di OI e le scadenze grandi cambiano qualcosa?")
P(f" {'hte':>4} {'gruppo':>16} {'media tw_oi':>12} {'IC95 lo':>9} {'IC95 hi':>9} {'n':>4}")
for hte in (3, 12, 24):
e = ev[ev["hte"] == hte]
hi_c = e["top_share"] >= e["top_share"].median()
big = e["tot_oi"] >= e["tot_oi"].quantile(0.75)
for nm, sel in (("conc ALTA", hi_c), ("conc BASSA", ~hi_c),
("OI top-25%", big), ("venerdi'", e["dow"] == 4)):
x = e.loc[sel, "tw_oi"].to_numpy(float)
m, lo, hh_, _ = cluster_boot(x, e.loc[sel, "exp"].to_numpy())
P(f" {hte:>4} {nm:>16} {m:>12.5f} {lo:>9.5f} {hh_:>9.5f} {int(np.isfinite(x).sum()):>4}")
# ---------------------------------------------------------------- 6. STRATEGIA
P("\n[6] STRATEGIA — expectancy netta fee (3,5 bps/lato reali) e serie giornaliera")
bands = {"nessuna": None, "0.2-3%": (0.002, 0.03)}
subs = ("all", "big", "fri")
sizings = ("sign", "prop")
cells, n_trials = [], 0
for rl in RULES:
for hte in HTE_GRID:
for bn, bd in bands.items():
for sz in sizings:
for sb in subs:
n_trials += 1
d, info = strat_daily(ev, rl, hte, bd, sz, sb, FEE_SIDE_REAL)
mm = metr(d)
cells.append(dict(rule=rl, hte=hte, band=bn, sizing=sz, sub=sb,
**mm, ntrade=sum(info.values())))
C = pd.DataFrame(cells)
P(f" griglia strategia valutata: {n_trials} celle "
f"({len(RULES)} rule x {len(HTE_GRID)} hte x {len(bands)} bande x {len(sizings)} sizing x {len(subs)} sottocampioni)")
P(f" celle con Sharpe > 0: {(C.sharpe > 0).sum()}/{len(C)} (una famiglia di RUMORE sta a ~50%)")
P(" migliori 8 celle per Sharpe (NON e' una selezione: e' il MASSIMO della griglia, che serve al DSR):")
P(C.sort_values("sharpe", ascending=False).head(8).to_string(index=False,
float_format=lambda v: f"{v:.3f}"))
P(" peggiori 3:")
P(C.sort_values("sharpe").head(3).to_string(index=False, float_format=lambda v: f"{v:.3f}"))
P("\n [6-bis] LA STESSA MACCHINA SUI LIVELLI PLACEBO (stesse finestre, stessa fee, stesso")
P(" sizing): se un placebo regge quanto il livello 'speciale', l'OI non aggiunge nulla.")
nulls = []
for rl in ("mp", "oi", "mid", "mr7", "rand", "jit", "maj", "short", "long"):
for hte in (3, 6, 12, 24):
for bn, bd in bands.items():
d, _i = strat_daily(ev, rl, hte, bd, "sign", "all", FEE_SIDE_REAL)
mm = metr(d)
nulls.append(dict(rule=rl, hte=hte, band=bn, sharpe=mm["sharpe"],
cagr=mm["cagr"], dd=mm["dd"]))
N = pd.DataFrame(nulls)
piv = N.pivot_table(index=["hte", "band"], columns="rule", values="sharpe")
P(piv.reindex(columns=["mp", "oi", "mid", "mr7", "rand", "jit", "maj", "short", "long"])
.to_string(float_format=lambda v: f"{v:6.2f}"))
P(f" celle placebo valutate: {len(N)} (contano come trial: la griglia dichiarata sale)")
best = C.sort_values("sharpe", ascending=False).iloc[0]
P(f"\n CELLA MIGLIORE = {best['rule']}/hte{int(best['hte'])}/{best['band']}/{best['sizing']}/{best['sub']}")
# expectancy per trade, in % e in R, sulla cella migliore
e = ev[ev["hte"] == int(best["hte"])].copy()
if best["sub"] == "big":
e = e[e["tot_oi"] >= e["tot_oi"].median()]
elif best["sub"] == "fri":
e = e[e["dow"] == 4]
dd = e[f"d_{best['rule']}"].to_numpy(float)
keep = np.isfinite(dd)
if best["band"] != "nessuna":
keep &= (np.abs(dd) >= 0.002) & (np.abs(dd) <= 0.03)
tw = e.loc[keep, f"tw_{best['rule']}"].to_numpy(float)
dk = np.abs(dd[keep])
net = tw - 2 * FEE_SIDE_REAL
wins, losses = net[net > 0], net[net < 0]
rr = (wins.mean() / abs(losses.mean())) if len(wins) and len(losses) else np.nan
P(f" trade: {len(net)} · WR {np.mean(net > 0):.1%} · RR medio {rr:.2f}"
f" · WR-knob atteso 1/(1+RR) = {1 / (1 + rr):.1%}" if np.isfinite(rr) else " RR n.d.")
P(f" expectancy NETTA per trade: {np.mean(net):+.5f} ({np.mean(net) * 100:+.3f}%)"
f" | in R (R = distanza dal livello): {np.mean(net / np.maximum(dk, 1e-6)):+.3f} R")
P(f" a fee ZERO: {np.mean(tw):+.5f} -> {'l edge lordo non esiste' if np.mean(tw) <= 0 else 'edge lordo positivo, la fee lo mangia?'}")
dbest, info = strat_daily(ev, best["rule"], int(best["hte"]),
None if best["band"] == "nessuna" else (0.002, 0.03),
best["sizing"], best["sub"], FEE_SIDE_REAL)
dcons, _ = strat_daily(ev, best["rule"], int(best["hte"]),
None if best["band"] == "nessuna" else (0.002, 0.03),
best["sizing"], best["sub"], FEE_SIDE_CONS)
mb, mc = metr(dbest), metr(dcons)
P(f" serie giornaliera cella migliore fee 3,5bps: Sharpe {mb['sharpe']:+.2f}"
f" · maxDD {mb['dd']:.2%} · CAGR {mb['cagr']:+.2%} · n={mb['n']}g")
P(f" fee 5,0bps: Sharpe {mc['sharpe']:+.2f}"
f" · maxDD {mc['dd']:.2%} · CAGR {mc['cagr']:+.2%}")
# scomposizione: per mese e per ERA DI COLLETTORE (bite:research fino al 30/07, poi pyg)
P("\n SCOMPOSIZIONE (regola 22/08: un contributo positivo si scompone prima di crederci)")
mo = dbest.groupby([dbest.index.year, dbest.index.month])
P(" " + " · ".join(f"{y}-{m:02d}: Sh {A._sh(g):+.2f} ret {float(np.prod(1 + g) - 1):+.2%} ({len(g)}g)"
for (y, m), g in mo))
cut = pd.Timestamp("2026-07-30", tz="UTC")
e1, e2 = dbest[dbest.index < cut], dbest[dbest.index >= cut]
P(f" era collettore bite:research (<30/07): Sh {A._sh(e1):+.2f} ret {float(np.prod(1 + e1) - 1):+.2%} ({len(e1)}g)"
f" | pyg (>=30/07): Sh {A._sh(e2):+.2f} ret {float(np.prod(1 + e2) - 1):+.2%} ({len(e2)}g)")
top5 = dbest.sort_values(ascending=False).head(5)
P(f" concentrazione: i 5 giorni migliori valgono {float(np.prod(1 + top5) - 1):+.2%}"
f" su {float(np.prod(1 + dbest) - 1):+.2%} totali"
f" = {100 * np.log1p(top5).sum() / max(np.log1p(dbest).sum(), 1e-9):.0f}% del log-equity")
P(" ⚠ HOLD-OUT: NON ESISTE. La catena parte dal 2026-05-01 e l'hold-out del progetto e'")
P(" 2025-01-01: TUTTA la serie sta dentro l'hold-out, quindi non c'e' nessuna finestra")
P(" fuori campione da mostrare. Questo NON e' un dettaglio: e' il motivo per cui")
P(" 3,7 mesi di catena non possono produrre uno sleeve.")
# ---------------------------------------------------------------- 7. GATE
P("\n[7] GATE")
P(f" causalita' : {_assert_causalita(chain, ev)}")
P(" A.causality_ok : NON GIRATO — non applicabile (il segnale non e' funzione del solo")
P(" feed OHLC; la causalita' e' garantita dalla convenzione hte=dte-1")
P(" e verificata dall'assert qui sopra).")
# DSR: conta TUTTI i trial al rialzo
all_sr = [float(v) for v in C["sharpe"].to_numpy(float) if np.isfinite(v)]
all_sr_wide = all_sr + [float(v) for v in N["sharpe"].to_numpy(float) if np.isfinite(v)]
n_fact = len(HTE_GRID) * len(RULES) * (1 + len(PLACEBOS)) + 3 * 4 # celle del fatto + condiz.
trials_tot = len(all_sr_wide) + n_fact
dsr, sr0 = A.deflated_sharpe(mb["sharpe"], all_sr, dbest, dpy=365.25)
dsr_w, _ = A.deflated_sharpe(mb["sharpe"], all_sr_wide, dbest, dpy=365.25)
P(f" deflated Sharpe : DSR {dsr:.3f} (soglia 0.95) · Sharpe-null atteso {sr0:.2f}"
f" · trial contati {len(all_sr)} (griglia strategia)")
P(f" DSR contando ANCHE le celle placebo ({len(all_sr_wide)} trial): {dsr_w:.3f}")
P(f" trial TOTALI dichiarati al rialzo (fatto+null+condiz.+strategia): {trials_tot}")
# marginal vs TP01
mv = A.marginal_vs_tp01(dbest)
P(f" marginal vs TP01 : {mv.get('marginal_verdict')} · corr {mv.get('corr_full')}"
f" · uplift w25 full {mv.get('blends', {}).get('w25', {}).get('uplift_full')}"
f" · giorni in comune {mv.get('n_days')}")
P(" ⚠ il gate NON PUO' dare ADDS con questo campione: `multicut_persistent`")
P(" richiede >=2 tagli annuali da >=120 giorni e la serie ne ha ~113 in UN")
P(" anno solo; e `has_insample_edge` e' True per DEFAULT perche' non c'e'")
P(" alcun pre-2025. Il verdetto marginale qui e' strutturale, non informativo.")
# null del de-levering (obbligatorio su ogni claim di DD)
B = A.tp01_baseline_daily()
J = pd.concat({"B": B, "C": dbest}, axis=1, join="inner").dropna()
if len(J) > 20:
bl = 0.75 * J["B"] + 0.25 * J["C"]
dd_bl = A._dd_ret(bl)
ks = np.linspace(0.05, 1.0, 96)
ok = [(k, A._sh(k * J["B"]), A._dd_ret(k * J["B"])) for k in ks]
cand = [(k, s) for k, s, d_ in ok if d_ <= dd_bl]
kbest = max(cand, key=lambda t: t[1]) if cand else None
P(f" null de-levering : blend 0.75TP01+0.25cand -> Sharpe {A._sh(bl):+.2f} / DD {dd_bl:.2%}"
f" | k*TP01 a pari DD -> k={kbest[0]:.2f} Sharpe {kbest[1]:+.2f}"
if kbest else " null de-levering : non calcolabile")
if kbest:
P(f" esito: {'REFUTED (il de-levering fa meglio)' if kbest[1] >= A._sh(bl) else 'superato'}")
else:
P(" null de-levering : NON GIRATO (sovrapposizione con TP01 troppo corta)")
# implausible
imp = A.implausible_sharpe(dbest, n_trades=int(best["ntrade"]))
P(f" implausible_sharpe : implausible={imp['implausible']} · {imp.get('reasons')}")
# anchor: l'ANCORA di questa strategia e' hte (quale ora prima della scadenza si entra)
ab = A.anchor_luck_band(
lambda h: strat_daily(ev, best["rule"], int(h),
None if best["band"] == "nessuna" else (0.002, 0.03),
best["sizing"], best["sub"], FEE_SIDE_REAL)[0],
offsets=list(HTE_GRID), canonical=int(best["hte"]))
P(f" anchor (hte) : canonico {ab.get('canonical'):.2f} al {100 * ab.get('canonical_pctl', np.nan):.0f}° pctl"
f" · MEDIANA ONESTA {ab.get('median'):.2f} · banda [{ab.get('lo'):.2f},{ab.get('hi'):.2f}]"
f" · positive {ab.get('frac_positive'):.0%} · gate_pass={ab.get('gate_pass')}")
# eseguibilita' a $600
P(" eseguibilita' $600 :", end=" ")
hc = []
for a in ASSETS:
df = win_1h(a, pd.Timestamp(ev["hh"].min()), pd.Timestamp(ev["exp"].max()))
tgt = hourly_target(ev, a, best["rule"], int(best["hte"]),
None if best["band"] == "nessuna" else (0.002, 0.03),
best["sizing"], best["sub"], df)
sc = A.eval_weights_smallcap(df, tgt, capital=300.0, fee_side=FEE_SIDE_REAL)
hc.append(f"{a} haircut {sc['sharpe_haircut']:+.3f} ({sc['n_executed_trades']} trade eseguiti)")
P(" · ".join(hc))
P(" (300$/asset = il cap per-asset del libro live a questo capitale;")
P(" una gamba ±1 muove 300$ >> min-order 5$ -> nessun ordine saltato)")
# ---------------------------------------------------------------- 8. SINTESI
P("\n[8] SINTESI")
n_fact_pos = int((F.lo > 0).sum())
P(f" fatto : {n_fact_pos}/{len(F)} celle (rule x hte) con pinning significativo a IC95")
P(f" null loc. : {npos}/{ntot} confronti appaiati in cui il livello 'speciale' batte il placebo")
P(f" strategia : miglior Sharpe della griglia {mb['sharpe']:+.2f} su {mb['n']} giorni, DSR {dsr:.3f}")
P(f" marginale : {mv.get('marginal_verdict')} (strutturalmente non ADDS-abile a 3,7 mesi)")
P("=" * 100)
if __name__ == "__main__":
main()
+674
View File
@@ -0,0 +1,674 @@
#!/usr/bin/env python3
"""r0822_ortho_screen.py — ONDATA 2026-08-22, filone ORTHO-SCREEN (la rete larga, onesta).
NON e' un'ipotesi: e' un SETACCIO. Sette famiglie di segnale mai (o solo parzialmente)
coperte dalle ondate precedenti, passate TUTTE dallo stesso protocollo, senza eccezioni:
1. famiglia DICHIARATA prima (parametri + celle), trial contati AL RIALZO;
2. A.study_family_honest -> cella scelta IN-SAMPLE-ONLY + deflated-Sharpe di famiglia;
3. A.marginal_vs_tp01 -> ADDS / NEUTRAL / DILUTES / HEDGE / NOISE / REDUNDANT;
4. A.causality_ok (+ A.day_boundary_robust dove ha senso, vedi NOTA CONFINE);
5. banda d'ancora su 8 offset orari (bar 1d ricostruite da 1h) -> la stima onesta e' la MEDIANA;
6. null del DE-LEVERING su ogni claim di drawdown;
7. A.eval_weights_smallcap($600) + A.implausible_sharpe sulla cella scelta;
8. **DSR di SCREEN**: oltre al deflated-Sharpe di famiglia, il deflated-Sharpe calcolato sul
POOL di TUTTE le celle di TUTTE le famiglie. Se scelgo il migliore fra 7 famiglie, i trial
sono 168, non 24: e' l'unico conto onesto per un setaccio.
PRIOR DICHIARATO PRIMA DI MISURARE: il soffitto direzionale BTC/ETH misurato e' Sharpe ~1.3 e
in 104 ipotesi + 153 agenti (giugno 2026) NESSUNA e' sopravvissuta alla verifica avversariale.
Mi aspetto 0 CANDIDATI. Il valore consegnato e' **quale gate uccide cosa**, perche' questo
restringe lo spazio della prossima ondata.
LE 7 FAMIGLIE (24 celle ciascuna = 168 trial dichiarati)
RSKEW skew realizzata (da 1h) come PREDITTORE direzionale, non come gate
[nuovo vs blind/agent_40_skewgate, che la usava come FILTRO su un trend]
TACC accelerazione del trend (derivata seconda del log-prezzo) come direzione standalone
VPX relazione volume-prezzo su barre 1d (conferma / divergenza a volume alto e basso)
XDISP dispersione cross-sezionale dei 51 alt HL usata come segnale TIME-SERIES sul mercato
[nuovo vs XS01, dove la dispersione e' un gate sul paniere cross-sectional]
XCORR correlazione realizzata BTC-ETH come STATO di mercato direzionale
[nuovo vs ortho/agent_09 e XAS08, dove la corr gata un book relative-value]
XTAIL struttura dei ritorni dopo uno shock a k-sigma (continuazione vs inversione)
VRAT variance-ratio (persistenza) come CONVINZIONE continua sul TSMOM
[nuovo vs blind/agent_37_hurst, che faceva uno switch BINARIO di modo]
NOTA CONFINE (perche' day_boundary_robust non gira su tutte): il test sposta le ETICHETTE del
calendario lasciando i prezzi fermi. Per un segnale di solo prezzo (TACC/VPX/XTAIL/VRAT) e'
esattamente il controllo giusto e deve dare INVARIANT. Per un segnale che fa merge_asof su una
serie AUSILIARIA (RSKEW da 1h, XDISP da HL, XCORR da 1h BTC/ETH) spostare l'etichetta in AVANTI
sposta il punto di merge nel FUTURO: sarebbe un LEAK indotto dallo strumento, non un controllo.
Per quelle famiglie il test d'ancora corretto e' la RICOSTRUZIONE delle barre a offset orario
(banda d'ancora, punto 5), che gira su tutte e sette.
USO: nice -n 19 timeout 900 uv run python scripts/research/r0822_ortho_screen.py
"""
from __future__ import annotations
import os
import sys
import time
from functools import lru_cache
import numpy as np
import pandas as pd
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "alt"))
import altlib as A # noqa: E402
HL_DIR = A.DATA_DIR
ANCHOR_OFFSETS = (0, 3, 6, 9, 12, 15, 18, 21)
W_BLEND = 0.25 # peso del candidato nel blend con TP01 (convenzione altlib)
# ===========================================================================
# INFRASTRUTTURA — barre a offset, merge causale di serie ausiliarie
# ===========================================================================
@lru_cache(maxsize=64)
def bars_at(asset: str, off: int) -> pd.DataFrame:
"""Barre 1d ancorate all'ora `off`, ricostruite dal feed 1h certificato.
off=0 deve riprodurre esattamente A.get(asset,'1d') (controllo in _sanity)."""
h = A.get(asset, "1h").copy()
idx = pd.to_datetime(h["timestamp"], unit="ms", utc=True) - pd.Timedelta(hours=off)
h.index = idx
o = (h.resample("1D", label="left", closed="left")
.agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
.dropna(subset=["open"]))
o["datetime"] = o.index + pd.Timedelta(hours=off)
epoch = pd.Timestamp("1970-01-01", tz="UTC")
o["timestamp"] = ((o["datetime"] - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
return o.reset_index(drop=True)[["timestamp", "open", "high", "low", "close", "volume", "datetime"]]
def _bar_ms(df: pd.DataFrame) -> int:
d = pd.Series(df["timestamp"].astype("int64")).diff().median()
return int(d) if np.isfinite(d) and d > 0 else 86_400_000
def merge_aux(df: pd.DataFrame, aux_ts: np.ndarray, aux_val: np.ndarray) -> np.ndarray:
"""Valore di una serie ausiliaria NOTO alla CHIUSURA di ogni barra di df.
`aux_ts` e' gia' il timestamp in cui il valore diventa noto (fine della sua barra).
merge_asof backward sul close della barra -> mai un dato del futuro: eval_weights
tiene target[i] durante la barra i+1, quindi close[i] e' l'ultimo istante lecito."""
close_ts = df["timestamp"].astype("int64").values + _bar_ms(df)
left = pd.DataFrame({"timestamp": close_ts})
right = (pd.DataFrame({"timestamp": np.asarray(aux_ts, dtype="int64"),
"v": np.asarray(aux_val, dtype=float)})
.dropna().sort_values("timestamp").reset_index(drop=True))
if right.empty:
return np.full(len(df), np.nan)
return pd.merge_asof(left, right, on="timestamp", direction="backward")["v"].values
def roll_z(x: np.ndarray, win: int) -> np.ndarray:
s = pd.Series(np.asarray(x, float))
mp = max(20, win // 4)
m = s.rolling(win, min_periods=mp).mean()
sd = s.rolling(win, min_periods=mp).std()
return ((s - m) / sd.where(sd > 0)).values
def exp_pct(x: np.ndarray, min_obs: int = 180) -> np.ndarray:
"""Percentile ESPANDENTE causale (rank fra tutti i valori visti finora, incluso l'attuale)."""
return pd.Series(np.asarray(x, float)).expanding(min_periods=min_obs).rank(pct=True).values
def _shape(u: np.ndarray, shape: str) -> np.ndarray:
u = np.nan_to_num(np.asarray(u, float), nan=0.0)
return np.sign(u) if shape == "sign" else np.clip(u, -1.0, 1.0)
def _sized(direction: np.ndarray, df: pd.DataFrame) -> np.ndarray:
return A.vol_target(np.nan_to_num(direction, nan=0.0), df,
target_vol=0.20, vol_win_days=30, leverage_cap=2.0)
# ===========================================================================
# SERIE AUSILIARIE (calcolate una volta, riusate da tutte le celle)
# ===========================================================================
@lru_cache(maxsize=16)
def skew_aux(asset: str, w_days: int):
"""Skew realizzata dei ritorni 1h su finestra trascinata di w_days giorni.
Valore al bar 1h i -> noto alla sua chiusura (ts_i + 1h)."""
h = A.get(asset, "1h")
r = A.log_returns(h["close"].values.astype(float))
win = w_days * 24
sk = pd.Series(r).rolling(win, min_periods=win // 2).skew().values
return h["timestamp"].astype("int64").values + 3_600_000, sk
@lru_cache(maxsize=4)
def disp_aux():
"""Dispersione cross-sezionale dei 51 alt Hyperliquid (1d, 2024+): deviazione standard
cross-section dei log-ritorni giornalieri. Valore del giorno d noto alla chiusura di d."""
import glob
files = sorted(glob.glob(str(HL_DIR / "hl_*_1d.parquet")))
cols = {}
for f in files:
x = pd.read_parquet(f, columns=["timestamp", "close"])
s = pd.Series(x["close"].values.astype(float),
index=x["timestamp"].astype("int64").values)
cols[os.path.basename(f)] = np.log(s).diff()
M = pd.DataFrame(cols).sort_index()
n_ok = M.notna().sum(axis=1)
disp = M.std(axis=1, ddof=1).where(n_ok >= 15)
ts = M.index.values.astype("int64") + 86_400_000 # noto a fine giornata
return ts, disp.values, int(len(files))
@lru_cache(maxsize=16)
def xcorr_aux(w_days: int):
"""Correlazione realizzata BTC-ETH sui ritorni 1h, finestra trascinata w_days giorni."""
b = A.get("BTC", "1h")[["timestamp", "close"]].rename(columns={"close": "b"})
e = A.get("ETH", "1h")[["timestamp", "close"]].rename(columns={"close": "e"})
J = pd.merge(b, e, on="timestamp", how="inner").sort_values("timestamp")
rb = pd.Series(A.log_returns(J["b"].values.astype(float)))
re = pd.Series(A.log_returns(J["e"].values.astype(float)))
win = w_days * 24
c = rb.rolling(win, min_periods=win // 2).corr(re).values
return J["timestamp"].astype("int64").values + 3_600_000, c
# ===========================================================================
# LE 7 FAMIGLIE — factory(tf=..., **params) -> target(df, asset)
# ===========================================================================
def mk_rskew(tf="1d", w_days=30, z_days=365, shape="lin", sgn=-1):
"""RSKEW: la skew realizzata recente come PREDITTORE del ritorno successivo.
A-priori (Amaya et al., azioni): skew realizzata alta -> ritorno futuro BASSO (sgn=-1)."""
def target(df, asset):
ts, sk = skew_aux(asset, w_days)
v = merge_aux(df, ts, sk)
u = np.clip(roll_z(v, z_days) / 2.0, -1.0, 1.0)
return _sized(sgn * _shape(u, shape), df)
return target
def mk_tacc(tf="1d", H=60, L=20, shape="lin", sgn=1):
"""TACC: accelerazione = variazione della PENDENZA log-prezzo su H barre, misurata a L barre
di distanza. Direzione standalone (non e' il LIVELLO del trend). A-priori sgn=+1."""
def target(df, asset):
c = np.log(df["close"].values.astype(float))
n = len(c)
slope = np.full(n, np.nan)
slope[H:] = (c[H:] - c[:-H]) / H
acc = np.full(n, np.nan)
acc[L:] = slope[L:] - slope[:-L]
u = np.clip(roll_z(acc, 365) / 2.0, -1.0, 1.0)
return _sized(sgn * _shape(u, shape), df)
return target
def mk_vpx(tf="1d", W=30, k=5, mode="confirm", thr=0.5):
"""VPX: volume-prezzo su barre 1d (a 1d il volume Deribit e' informativo, a 5m no).
confirm -> segui il movimento SOLO se il volume conferma (z >= thr)
fade_low -> fai il contrario del movimento avvenuto a volume BASSO (z <= -thr)
fade_high -> fai il contrario del movimento avvenuto a volume ALTO (climax, z >= thr)"""
def target(df, asset):
c = df["close"].values.astype(float)
vol = df["volume"].values.astype(float)
n = len(c)
vz = roll_z(np.log(np.where(vol > 0, vol, np.nan)), W)
rk = np.full(n, np.nan)
rk[k:] = c[k:] / c[:-k] - 1.0
s = np.sign(np.nan_to_num(rk, nan=0.0))
vzf = np.nan_to_num(vz, nan=0.0)
if mode == "confirm":
d = np.where(vzf >= thr, s, 0.0)
elif mode == "fade_low":
d = np.where(vzf <= -thr, -s, 0.0)
else: # fade_high
d = np.where(vzf >= thr, -s, 0.0)
return _sized(d, df)
return target
def mk_xdisp(tf="1d", S=5, trans="z252", shape="lin", sgn=1):
"""XDISP: dispersione cross-sezionale dei 51 alt HL come STATO del mercato (time-series),
non come gate di un paniere cross-sectional. Nessun prior forte sul segno -> entrambi in griglia."""
def target(df, asset):
ts, dsp, _ = disp_aux()
sm = pd.Series(dsp).rolling(S, min_periods=1).mean().values
u = (np.clip(roll_z(sm, 252) / 2.0, -1, 1) if trans == "z252"
else 2.0 * exp_pct(sm, 180) - 1.0)
v = merge_aux(df, ts, u)
return _sized(sgn * _shape(v, shape), df)
return target
def mk_xcorr(tf="1d", W=60, trans="z252", shape="lin", sgn=-1):
"""XCORR: co-movimento BTC-ETH (corr realizzata) come stato direzionale del mercato.
A-priori debole: corr alta = 'tutto il crypto e' un trade solo' = regime tardo -> sgn=-1."""
def target(df, asset):
ts, cr = xcorr_aux(W)
u_aux = (np.clip(roll_z(cr, 252 * 24) / 2.0, -1, 1) if trans == "z252"
else 2.0 * exp_pct(cr, 180 * 24) - 1.0)
v = merge_aux(df, ts, u_aux)
return _sized(sgn * _shape(v, shape), df)
return target
def mk_xtail(tf="1d", ksig=3.0, N=3, sgn=1, sigwin=30):
"""XTAIL: struttura dei ritorni ESTREMI. Dopo |r| > ksig*sigma trascurata, tieni per N barre
la direzione (sgn=+1 continuazione) o il suo opposto (sgn=-1 inversione).
ATTENZIONE dichiarata: sgn=-1 e' mean-reversion, famiglia gia' MORTA -> se vince li', si chiude."""
def target(df, asset):
c = df["close"].values.astype(float)
r = A.simple_returns(c)
sd = pd.Series(r).rolling(sigwin, min_periods=sigwin // 2).std().shift(1).values
n = len(c)
d = np.zeros(n)
hit = np.where(np.isfinite(sd) & (sd > 0) & (np.abs(r) > ksig * sd))[0]
for i in hit:
d[i:min(n, i + N)] = sgn * np.sign(r[i])
return _sized(d, df)
return target
def mk_vrat(tf="1d", q=10, LB=365, conv="gate", lf=True):
"""VRAT: variance-ratio VR(q)=Var(r_q)/(q*Var(r_1)) su finestra LB come CONVINZIONE CONTINUA
sul TSMOM (non uno switch binario di modo come blind/agent_37_hurst).
gate -> pesa il trend solo dove VR>1 (persistente), altrimenti flat
flip -> inverte il trend dove VR<1 (anti-persistente)"""
HZ = (30, 90, 180)
def target(df, asset):
c = df["close"].values.astype(float)
lc = np.log(c)
n = len(c)
r1 = pd.Series(A.log_returns(c))
rq = pd.Series(np.concatenate([np.full(q, np.nan), lc[q:] - lc[:-q]]))
v1 = r1.rolling(LB, min_periods=LB // 2).var()
vq = rq.rolling(LB, min_periods=LB // 2).var()
vr = (vq / (q * v1.where(v1 > 0))).values
dirs = np.zeros(n)
for H in HZ:
m = np.zeros(n)
m[H:] = np.sign(c[H:] / c[:-H] - 1.0)
dirs += m / len(HZ)
if lf:
dirs = np.maximum(dirs, 0.0)
k = np.nan_to_num(vr, nan=1.0) - 1.0
w = np.clip(k * 5.0, 0.0, 1.0) if conv == "gate" else np.clip(k * 5.0, -1.0, 1.0)
return _sized(dirs * w, df)
return target
# ---- griglie DICHIARATE (24 celle ciascuna, 7 famiglie = 168 trial) ----------
def _grid(**axes):
keys = list(axes)
out = [{}]
for k in keys:
out = [dict(o, **{k: v}) for o in out for v in axes[k]]
return out
FAMILIES = [
dict(code="RSKEW", factory=mk_rskew, price_only=False,
grid=_grid(w_days=[14, 30, 60], z_days=[180, 365], shape=["lin", "sign"], sgn=[1, -1])),
dict(code="TACC", factory=mk_tacc, price_only=True,
grid=_grid(H=[20, 60, 120], L=[5, 20], shape=["lin", "sign"], sgn=[1, -1])),
dict(code="VPX", factory=mk_vpx, price_only=True,
grid=_grid(W=[30, 90], k=[5, 20], mode=["confirm", "fade_low", "fade_high"], thr=[0.5, 1.0])),
dict(code="XDISP", factory=mk_xdisp, price_only=False,
grid=_grid(S=[1, 5, 20], trans=["z252", "pct"], shape=["lin", "sign"], sgn=[1, -1])),
dict(code="XCORR", factory=mk_xcorr, price_only=False,
grid=_grid(W=[20, 60, 120], trans=["z252", "pct"], shape=["lin", "sign"], sgn=[1, -1])),
dict(code="XTAIL", factory=mk_xtail, price_only=True,
grid=_grid(ksig=[2.5, 3.0], N=[1, 3, 10], sgn=[1, -1], sigwin=[30, 90])),
dict(code="VRAT", factory=mk_vrat, price_only=True,
grid=_grid(q=[5, 10, 20], LB=[180, 365], conv=["gate", "flip"], lf=[True, False])),
]
# ===========================================================================
# GATE AGGIUNTIVI
# ===========================================================================
def cand_daily_at(fn, off: int) -> pd.Series:
"""Serie giornaliera 50/50 BTC+ETH del candidato con barre ancorate all'ora `off`."""
ser = {}
for a in A.CERTIFIED:
df = bars_at(a, off)
ev = A.eval_weights(df, A._call_target(fn, df, a))
ser[a] = pd.Series(ev["net"], index=ev["idx"])
J = pd.concat(ser, axis=1, join="inner").fillna(0.0)
return A._to_daily(0.5 * J[A.CERTIFIED[0]] + 0.5 * J[A.CERTIFIED[1]])
def anchor_report(fn) -> dict:
"""Banda d'ancora su 8 offset orari: Sharpe standalone E uplift del blend.
La stima ONESTA e' la MEDIANA della banda, non l'ancora 00:00 (lezione 02/07)."""
band = A.anchor_luck_band(lambda o: cand_daily_at(fn, o), ANCHOR_OFFSETS)
B = A.tp01_baseline_daily()
ups = {}
for o in ANCHOR_OFFSETS:
C = cand_daily_at(fn, o)
J = pd.concat({"B": B, "C": C}, axis=1, join="inner").dropna()
if len(J) > 30:
ups[o] = round(A._sh((1 - W_BLEND) * J["B"] + W_BLEND * J["C"]) - A._sh(J["B"]), 3)
vals = np.array(list(ups.values()), float) if ups else np.array([np.nan])
band["uplift_per_offset"] = ups
band["uplift_canonical"] = ups.get(0)
band["uplift_median"] = round(float(np.median(vals)), 3)
band["uplift_frac_positive"] = round(float((vals > 0).mean()), 3)
return band
def delever_null(C: pd.Series, w: float = W_BLEND) -> dict:
"""NULL DEL DE-LEVERING (obbligatorio su ogni claim di meno drawdown, 5 occorrenze nel
progetto): esiste k<1 che, applicato al SOLO TP01, da' lo STESSO maxDD con Sharpe MIGLIORE?
Se si', l'overlay non serve: bastava meno leva."""
B = A.tp01_baseline_daily()
J = pd.concat({"B": B, "C": C}, axis=1, join="inner").dropna()
if len(J) < 60:
return dict(run=False, reason="overlap insufficiente")
blend = (1 - w) * J["B"] + w * J["C"]
dd_bl, sh_bl = A._dd_ret(blend), A._sh(blend)
dd_b, sh_b = A._dd_ret(J["B"]), A._sh(J["B"])
if dd_bl >= dd_b:
return dict(run=True, claims_less_dd=False, dd_blend=round(dd_bl, 4),
dd_tp01=round(dd_b, 4), sharpe_blend=round(sh_bl, 3),
sharpe_tp01=round(sh_b, 3),
note="nessun claim di DD: il blend NON riduce il drawdown -> null non pertinente")
ks = np.linspace(0.05, 1.0, 96)
dds = np.array([A._dd_ret(k * J["B"]) for k in ks])
k = float(ks[int(np.argmin(np.abs(dds - dd_bl)))])
sh_k = A._sh(k * J["B"])
return dict(run=True, claims_less_dd=True, dd_blend=round(dd_bl, 4), dd_tp01=round(dd_b, 4),
sharpe_blend=round(sh_bl, 3), k_equal_dd=round(k, 3),
dd_k=round(A._dd_ret(k * J["B"]), 4), sharpe_k_tp01=round(sh_k, 3),
passes=bool(sh_bl > sh_k),
note=("il blend batte il de-levering" if sh_bl > sh_k
else "REFUTED: k*TP01 da' lo stesso DD con Sharpe migliore"))
def smallcap_report(fn) -> dict:
out = {}
for a in A.CERTIFIED:
df = A.get(a, "1d")
sc = A.eval_weights_smallcap(df, A._call_target(fn, df, a), capital=600.0, min_order=5.0)
out[a] = dict(modeled=sc["modeled"]["sharpe"], realistic=sc["realistic"]["sharpe"],
haircut=sc["sharpe_haircut"], n_trades=sc["n_executed_trades"])
return out
def active_window_metrics(C: pd.Series) -> dict:
"""Trappola #9 (barre attive vs calendario): una serie che e' 0 per meta' della storia
(XDISP parte nel 2024) ha Sharpe DILUITO di sqrt(frazione attiva). Si riporta anche il
numero sulla sola finestra ATTIVA, senza usarlo per decidere."""
nz = C[C != 0.0]
if len(nz) < 30:
return dict(active_days=int(len(nz)))
first = nz.index[0]
sub = C[C.index >= first]
return dict(active_days=int(len(nz)), frac_active=round(float(len(nz) / max(1, len(C))), 3),
first_active=str(first.date()), sharpe_active_window=round(A._sh(sub), 3),
maxdd_active_window=round(A._dd_ret(sub), 4))
# ===========================================================================
# SANITY — un setaccio che non si controlla non sta setacciando niente
# ===========================================================================
def sanity() -> None:
print("### SANITY")
for a in A.CERTIFIED:
b0, g = bars_at(a, 0), A.get(a, "1d")
n = min(len(b0), len(g))
d = float(np.max(np.abs(b0["close"].values[:n] - g["close"].values[:n])))
dts = float(np.max(np.abs(b0["timestamp"].values[:n] - g["timestamp"].values[:n])))
print(f" bars_at({a},0) vs A.get({a},'1d'): n={n}/{len(g)} max|dclose|={d:.6g} max|dts|={dts:.0f}")
assert d < 1e-9 and dts == 0, "ricostruzione barre a offset 0 NON identica al feed certificato"
ts, dsp, nfiles = disp_aux()
ok = np.isfinite(dsp)
print(f" disp_aux: {nfiles} file HL, {int(ok.sum())} giorni con >=15 asset, "
f"da {pd.Timestamp(ts[ok][0], unit='ms', tz='UTC').date()} "
f"a {pd.Timestamp(ts[ok][-1], unit='ms', tz='UTC').date()}")
B = A.tp01_baseline_daily()
print(f" TP01 baseline: n={len(B)} ShFULL={A._sh(B):.3f} ShHOLD={A._sh(B[B.index >= A.HOLDOUT]):.3f}")
print()
# ===========================================================================
# MAIN
# ===========================================================================
def main() -> None:
t0 = time.time()
print(__doc__.split("USO:")[0])
sanity()
pooled_sh: list[float] = []
results = []
for fam in FAMILIES:
code, fac, grid = fam["code"], fam["factory"], fam["grid"]
t1 = time.time()
print(f"### {code}{len(grid)} celle dichiarate")
sel = A.select_cell_insample(fac, grid, ("1d",))
pooled_sh.extend([s for s in sel["all_full_sharpe"] if np.isfinite(s)])
rep = A.study_family_honest(code, fac, grid, ("1d",))
ch = rep["chosen"]
if ch is None:
print(f" nessuna cella valida ({rep.get('reason')})\n")
results.append(dict(code=code, n_cells=len(grid), chosen=None,
killed_by="nessuna cella valutabile"))
continue
fn = fac(tf=ch["tf"], **ch["params"])
C = A.candidate_daily(fn, tf=ch["tf"])
m = rep["marginal"]["marginal"]
absr = rep["marginal"]["absolute"]["cells"][0]
caus = A.causality_ok(fn, tf="1d")
dayb = A.day_boundary_robust(fn, tf="1d") if fam["price_only"] else \
dict(verdict="NON GIRATO", reason="segnale con merge su serie ausiliaria: "
"spostare l'etichetta in avanti sposta il merge nel FUTURO (leak indotto), "
"non e' un controllo -> vale la banda d'ancora")
anch = anchor_report(fn)
deln = delever_null(C)
smal = smallcap_report(fn)
impl = A.implausible_sharpe(C)
actw = active_window_metrics(C)
full_sh = A._sh(C)
hold_sh = A._sh(C[C.index >= A.HOLDOUT])
mm = A._metrics_from_net(C.values, C.index)
results.append(dict(
code=code, n_cells=len(grid), chosen=ch, fn=fn, C=C,
full_sharpe=round(full_sh, 3), hold_sharpe=round(hold_sh, 3),
maxdd=mm["maxdd"], cagr=mm["cagr"],
corr=m.get("corr_full"), corr_hold=m.get("corr_hold"),
verdict=rep["marginal"]["marginal_verdict"],
earns_slot=rep["earns_slot_marginal"], honest=rep["earns_slot_honest"],
dsr_family=rep["deflated_sharpe"], dsr_pass=rep["dsr_pass"],
insample_sharpe=m.get("cand_insample_sharpe"),
has_insample_edge=m.get("has_insample_edge"),
robust_oos=m.get("robust_oos"), is_hedge=m.get("is_hedge"),
uplift_full=m["blends"]["w25"]["uplift_full"],
uplift_hold=m["blends"]["w25"]["uplift_hold"],
multicut=m.get("multicut_uplift"), abs_grade=rep["marginal"]["abs_grade"], _m=m,
fee_survives=absr.get("fee_survives"),
causality=caus, dayb=dayb, anchor=anch, delever=deln,
smallcap=smal, implausible=impl, active=actw,
))
print(f" cella IN-SAMPLE-ONLY: {ch['params']} (IS Sh {ch['insample_sharpe']}, "
f"rango 1/{len(sel['rows'])} in-sample; FULL Sh di quella cella {ch['full_sharpe']})")
print(f" standalone FULL {full_sh:+.3f} | HOLD {hold_sh:+.3f} | maxDD {mm['maxdd']:.1%} | "
f"CAGR {mm['cagr']:+.2%} | IS {m.get('cand_insample_sharpe')}")
print(f" marginale {rep['marginal']['marginal_verdict']:<9} corr->TP01 {m.get('corr_full')} "
f"(hold {m.get('corr_hold')}) uplift w25 full {m['blends']['w25']['uplift_full']:+.3f} "
f"hold {m['blends']['w25']['uplift_hold']}")
print(f" robust_oos={m.get('robust_oos')} insample_edge={m.get('has_insample_edge')} "
f"is_hedge={m.get('is_hedge')} abs={rep['marginal']['abs_grade']} "
f"fee_survives={absr.get('fee_survives')}")
print(f" DSR famiglia {rep['deflated_sharpe']} (atteso null max {rep['expected_null_max']}) "
f"pass={rep['dsr_pass']} earns_slot_honest={rep['earns_slot_honest']}")
print(f" causality ok={caus['ok']} max_tail_diff={caus['max_tail_diff']}")
print(f" confine {dayb.get('verdict')} " +
(f"spread {dayb.get('spread')} per_offset {dayb.get('per_offset')}"
if fam["price_only"] else f"({dayb.get('reason','')[:60]}...)"))
_nan = float("nan")
print(f" ancora Sh canonica {anch.get('canonical', _nan):+.3f} "
f"(pctl {anch.get('canonical_pctl')}) "
f"MEDIANA {anch.get('median', _nan):+.3f} "
f"banda [{anch.get('lo', _nan):+.3f},{anch.get('hi', _nan):+.3f}] "
f"frac>0 {anch.get('frac_positive')}")
print(f" uplift canonico {anch.get('uplift_canonical')} MEDIANA "
f"{anch.get('uplift_median')} frac>0 {anch.get('uplift_frac_positive')}")
print(f" de-levering {deln.get('note')}")
print(f" $600 " + " ".join(
f"{a}: mod {v['modeled']:+.2f} -> real {v['realistic']:+.2f} (haircut {v['haircut']:+.2f}, "
f"{v['n_trades']} trade)" for a, v in smal.items()))
print(f" implausible {impl.get('implausible')} {impl.get('reasons')}")
if actw.get("frac_active", 1.0) < 0.9:
print(f" attive {actw}")
print(f" [{time.time() - t1:.1f}s]\n")
# ---- DSR DI SCREEN: i trial sono TUTTE le celle di TUTTE le famiglie -------------
print("### DSR DI SCREEN (il conto onesto per un setaccio: scelgo il meglio fra "
f"{len(pooled_sh)} celle, non fra 24)")
for r in results:
if r.get("chosen") is None:
continue
d, s0 = A.deflated_sharpe(r["full_sharpe"], pooled_sh, r["C"])
r["dsr_screen"] = round(d, 3) if np.isfinite(d) else None
print(f" {r['code']:<6} Sh {r['full_sharpe']:+.3f} DSR famiglia {r['dsr_family']} "
f"DSR screen {r['dsr_screen']} (null max atteso {s0:.3f})")
print()
deep_dive(results)
# ---- TABELLA FINALE --------------------------------------------------------------
def killer(r) -> str:
if r.get("chosen") is None:
return "nessuna cella valutabile"
why = []
if not r["causality"]["ok"]:
why.append("CAUSALITY")
if r["verdict"] != "ADDS":
why.append(f"marginal={r['verdict']}")
if not r["has_insample_edge"]:
why.append("no in-sample edge (<0.5)")
if not r["robust_oos"]:
why.append("robust_oos")
if r["is_hedge"]:
why.append("is_hedge")
if not r["dsr_pass"]:
why.append(f"DSR fam {r['dsr_family']}")
if r.get("dsr_screen") is not None and r["dsr_screen"] < 0.95:
why.append(f"DSR screen {r['dsr_screen']}")
if r["dayb"].get("verdict") == "ARTIFACT-RISK":
why.append("confine ARTIFACT-RISK")
um = r["anchor"].get("uplift_median")
if um is not None and np.isfinite(um) and um <= 0:
why.append("ancora: uplift mediano <=0")
if r["delever"].get("claims_less_dd") and not r["delever"].get("passes"):
why.append("null de-levering")
if r["implausible"].get("implausible"):
why.append("implausible_sharpe")
return " + ".join(why) if why else "-- nessun gate lo uccide --"
print("### TABELLA — ORTHO-SCREEN 2026-08-22 (candidato | trial | ShFULL | hold-out | "
"corr->TP01 | marginale | DSR fam / screen | esito)")
hdr = (f"{'cand':<7}{'trial':>6}{'ShFULL':>8}{'hold':>7}{'maxDD':>8}{'CAGR':>8}"
f"{'corr':>7} {'marginale':<10}{'DSRfam':>7}{'DSRscr':>8} esito / gate che l'ha ucciso")
print(hdr); print("-" * len(hdr))
for r in results:
if r.get("chosen") is None:
print(f"{r['code']:<7}{r['n_cells']:>6}{'--':>8}{'--':>7}{'--':>8}{'--':>8}{'--':>7} "
f"{'--':<10}{'--':>7}{'--':>8} SCARTATO — {killer(r)}")
continue
ok = (r["verdict"] == "ADDS" and r["dsr_pass"] and (r.get("dsr_screen") or 0) >= 0.95
and r["causality"]["ok"])
esito = "CANDIDATO" if ok else "SCARTATO"
print(f"{r['code']:<7}{r['n_cells']:>6}{r['full_sharpe']:>+8.3f}{r['hold_sharpe']:>+7.2f}"
f"{r['maxdd']:>8.1%}{r['cagr']:>+8.1%}{r['corr']:>7.2f} {r['verdict']:<10}"
f"{str(r['dsr_family']):>7}{str(r.get('dsr_screen')):>8} {esito}{killer(r)}")
print()
print("### FAMIGLIE NON TESTATE (l'elenco di cio' che non ho guardato fa parte del risultato)")
for line in NOT_TESTED:
print(" - " + line)
print(f"\n[totale {time.time() - t0:.1f}s]")
def mk_mom(tf="1d", H=5, shape="lin"):
"""CONTROLLO per TACC: il LIVELLO del momentum a orizzonte H, stessa pipeline
(z-score 365g -> clip -> vol_target). Serve a rispondere alla domanda della famiglia:
l'ACCELERAZIONE aggiunge qualcosa al LIVELLO, o e' un momentum corto travestito?"""
def target(df, asset):
c = df["close"].values.astype(float)
n = len(c)
m = np.full(n, np.nan)
m[H:] = c[H:] / c[:-H] - 1.0
u = np.clip(roll_z(m, 365) / 2.0, -1.0, 1.0)
return _sized(_shape(u, shape), df)
return target
MOM_CONTROL_GRID = _grid(H=[5, 20, 60], shape=["lin", "sign"]) # 6 celle di CONTROLLO
def deep_dive(results) -> None:
"""APPROFONDIMENTO sull'unica famiglia arrivata a marginal=ADDS.
Due domande, entrambe misurate:
(a) QUALE sotto-gate di robust_oos ha fallito (clean-year? jackknife? multi-cut?);
(b) l'accelerazione e' distinta dal LIVELLO del momentum corto, o e' quello travestito?
-> 6 celle di CONTROLLO dichiarate (H x shape), che si SOMMANO ai 168 trial."""
adds = [r for r in results if r.get("chosen") is not None and r["verdict"] == "ADDS"]
if not adds:
print("### APPROFONDIMENTO — nessuna famiglia a marginal=ADDS, niente da approfondire\n")
return
r = max(adds, key=lambda x: x["uplift_hold"] or -9)
print(f"### APPROFONDIMENTO — {r['code']} (l'unica a marginal=ADDS)")
print(f" perche' robust_oos=False: clean_year_uplift={r['_m'].get('clean_year_uplift')} "
f"(serve >0.02) jackknife_min_uplift={r['_m'].get('jackknife_min_uplift')} (serve >0)")
print(f" multicut (uplift a ogni taglio d'anno): {r['multicut']}")
print(f" uplift TP01-up {r['_m'].get('uplift_tp01_up')} / TP01-down {r['_m'].get('uplift_tp01_down')} "
f"| hedge_yearly_corr {r['_m'].get('hedge_yearly_corr')} | alpha/anno {r['_m'].get('alpha_ann')}")
print(" --- accelerazione vs LIVELLO del momentum (6 celle di controllo) ---")
B = A.tp01_baseline_daily()
C = r["C"]
best = None
for p in MOM_CONTROL_GRID:
fnm = mk_mom(tf="1d", **p)
Cm = A.candidate_daily(fnm, tf="1d")
J = pd.concat({"A": C, "M": Cm}, axis=1, join="inner").dropna()
JB = pd.concat({"B": B, "M": Cm}, axis=1, join="inner").dropna()
up = A._sh((1 - W_BLEND) * JB["B"] + W_BLEND * JB["M"]) - A._sh(JB["B"])
row = dict(params=p, sharpe=round(A._sh(Cm), 3),
hold=round(A._sh(Cm[Cm.index >= A.HOLDOUT]), 3),
corr_to_acc=round(float(J["A"].corr(J["M"])), 3),
corr_to_tp01=round(float(JB["B"].corr(JB["M"])), 3),
uplift_w25_full=round(up, 3))
print(f" MOM {str(p):<28} Sh {row['sharpe']:+.3f} hold {row['hold']:+.3f} "
f"corr->{r['code']} {row['corr_to_acc']:+.3f} corr->TP01 {row['corr_to_tp01']:+.3f} "
f"uplift {row['uplift_w25_full']:+.3f}")
if best is None or row["corr_to_acc"] > best["corr_to_acc"]:
best = row
print(f" livello piu' vicino all'accelerazione: {best['params']} corr {best['corr_to_acc']:+.3f}")
print(" lettura: corr alta col LIVELLO => l'accelerazione non e' una dimensione nuova; "
"corr bassa => e' distinta ma resta da spiegare perche' non sopravvive al DSR.\n")
NOT_TESTED = [
"catena opzioni / vol term-structure: 3-5 mesi di storia -> non esiste hold-out, al massimo "
"un LEAD; e altri agenti dell'ondata ci stanno sopra.",
"funding (carry, time-series, cross-sectional): filone dichiarato CHIUSO su 3 lati.",
"calendario in ogni forma (weekend 375 trial, expiry, stagionalita', event-clock, "
"ora-del-giorno): famiglia dichiarata SATURA.",
"mean-reversion pura / fade: morta anche a fee zero. Qui rientra SOLO come branch dichiarato "
"di XTAIL e VPX, per misurarla dentro un conditioner nuovo.",
"microstruttura 5m/15m: morta per fee, e una sweep su 5m sfonda il budget macchina.",
"cross-sectional sui 51 alt HL: territorio XS01, e l'espansione d'universo e' gia' refutata. "
"Qui i 51 alt entrano solo come AGGREGATO time-series (XDISP).",
"equity / GTAA / MAT01: GTAA01 non e' deployabile (PRIIPs) e MAT01 e' refutato.",
"macro regime gate: ridondante col trend (corr 0.989).",
"CRT / ICT / Elliott / Fibonacci / Albimarini: 3 ondate di refutazione.",
"gamma scalping long-vol e overlay DD su VRP01 (5/5 refutati).",
"SOL come terza gamba: decisione dell'operatore del 22/08.",
]
if __name__ == "__main__":
main()
+712
View File
@@ -0,0 +1,712 @@
"""r0822_prop_alloc — PROGETTARE l'allocazione per la BARRIERA, non per lo Sharpe (2026-08-22).
IL BUCO CHE CHIUDE
------------------
Il canale prop/funded e' l'unico misurato che moltiplica il nozionale senza possedere capitale.
Il progetto lo ha gia' VALUTATO (24-25/07: `r0725_prop_ladder.py`, `r0725_prop_coupled.py`,
`r0725_hyro.py`) ma non lo ha mai **PROGETTATO**: ha sempre preso il libro ottimizzato per lo
Sharpe (config A = live 75/25, config B = 55/20/25 scelta a mano) e poi MISURATO P(pass) del
risultato. Due punti nello spazio dei pesi, tre leve, nessuna ottimizzazione, nessun null.
Qui la funzione obiettivo cambia: sotto una **barriera di maxDD STATICA per-conto** conta la coda
sinistra e la DURATA, non il rapporto rendimento/vol. Si ottimizza
J(w, k) = P(passare l'eval) x P(essere vivo dopo 12 mesi da funded)
su pesi w e leva k, con la lente WICK ACCOPPIATA (la `close-only` e' CIECA: 0% di breach da
daily-loss su ogni configurazione, la regola non scatta mai sulle chiusure).
LA PREVISIONE, REGISTRATA PRIMA DI MISURARE
-------------------------------------------
Nel limite gaussiano con leva LIBERA l'obiettivo si riduce allo Sharpe, e si vede in due righe.
Per un moto browniano con drift mu e vol sigma a leva k, la probabilita' di toccare +T prima di
-D vale P = (1-e^{-theta*D})/(1-e^{-theta*(T+D)}) con **theta = 2*mu/(k*sigma^2)**: la leva entra
SOLO dividendo theta. Alla leva minima che porta a +T dentro l'orizzonte (k ~ T/mu) si ottiene
theta ~ 2*mu^2/(T*sigma^2) = 2*S^2/T, con S = Sharpe: **la vol sparisce e resta lo Sharpe al
quadrato**. Quindi mi aspetto PRIMA di misurare:
(a) l'argmax di J vicino all'argmax di Sharpe -> la premessa "obiettivo diverso" e' in gran
parte FALSA sui PESI;
(b) il contenuto vero nella **LEVA**, che lo Sharpe non determina affatto, e nella deviazione
dal gaussiano (skew, wick, daily-loss: una soglia ASSOLUTA non e' scale-invariante);
(c) leva ottima per J **piu' bassa** di quella ottima per il payout (0.75x, 25/07): J e' una
probabilita' di sopravvivenza, il payout e' un flusso.
Se (a) risulta falsa, e' un risultato; se risulta vera, il filone "riallocare per la barriera"
si chiude e si smette di pagarlo.
CONVENZIONI DICHIARATE
----------------------
* Macchineria del wick ACCOPPIATO **riusata** da `r0725_prop_coupled.py` (import, non riscritta):
`_hourly_legs` (recon MTM orario TP01/SKH01), `crypto_daily_tuples` (minimo ESATTO sul path
orario condiviso), `xsec_daily_tuples` (XS01 dagli OHLC 1d, ordine condiviso avverso).
* VRP01 e GTAA01 sono AGGIUNTI qui (non c'erano): GTAA01 col wick dagli OHLC dei 6 ETF (long-only
co-moventi -> ogni gamba al proprio minimo = convenzione severa dichiarata); VRP01 con
**gap = 0**, cioe' CIECO, perche' il suo mark infra-settimana non e' nel dataset dello sleeve
(il rendimento settimanale e' lumpato sul giorno di scadenza). E' una lente GENEROSA per VRP01:
la misura del 30/07 sulle quote reali dice mark peggiore infra-settimana mediano -6.9% e minimo
**-81.7%** del capitale su un trade finito in UTILE. Se VRP01 perde sotto una lente generosa,
perde.
* de-luck sul DRIFT **x0.89** (fattore MISURATO il 26/07 sul libro live, `r0726_deluck_factor.py`),
non x0.6 come nel 25/07 (li' era stimato a occhio e ri-misurato troppo severo del 31-34%).
Sensibilita' a 1.00 e 0.60 girata: cio' che conta e' se l'ORDINE fra le configurazioni tiene.
* bootstrap a blocchi (20g) con **indici CONDIVISI fra tutte le configurazioni** -> le differenze
fra configurazioni sono APPAIATE e il loro errore MC e' molto minore di quello dei livelli.
* finestra comune **2024+** (XS01 nasce li'): ~2.6 anni, ed e' la finestra in cui XS01 e' stato
scoperto -> le configurazioni che lo contengono sono FAVORITE per costruzione. Dichiarato.
* fisco 33% sui payout, split 80%, regole vere delle due firm (max-loss STATICO dal saldo
iniziale, base ripristinata dopo il prelievo bug del 25/07 gia' corretto a monte).
Uso: `nice -n 19 timeout 900 uv run python scripts/research/r0822_prop_alloc.py`
"""
from __future__ import annotations
import sys
import time
from functools import lru_cache
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 src.data.eq_splits import repair_splits # noqa: E402
from src.portfolio.gtaa import ( # noqa: E402
ANN, EQ_UNIVERSE, GTAA_DEFAULT_CAPITAL, HORIZONS, IB_MIN_TRADE_USD, REBAL_BAND_USD,
REBAL_EVERY, TARGET_VOL, ib_commission,
)
from src.portfolio.portfolio import metrics, weights_tilt_null # noqa: E402
from src.portfolio.sleeves import ( # noqa: E402
GTAA_BOOK_ACTIVATION, _gtaa_daily_returns, _skyhook_returns, _tp01_returns,
_vrp_combo_returns, _xsec_returns,
)
import r0725_prop_coupled as pc # noqa: E402
import r0725_prop_ladder as pl # noqa: E402
from r0725_capcurve import EURUSD, TAX_RATE # noqa: E402
RAW = ROOT / "data" / "raw"
# ------------------------------------------------------------------ costanti dichiarate
DELUCK = 0.89 # fattore d'ancora MISURATO sul drift (26/07), non stimato a occhio
START_WIN = "2024-01-01" # finestra comune ai 5 sleeve (XS01 nasce qui)
BLOCK = 20 # blocco del bootstrap, in giorni
N_PATHS = 4000
SPLIT = pl.SPLIT # 80% al trader
TARGET_EUR_DAY = 50.0
FIRMS = {f["name"]: f for f in pl.FIRMS}
SLEEVES = ("TP01", "SKH01", "XS01", "VRP01", "GTAA01")
CRYPTO_PROP = ("TP01", "SKH01", "XS01") # cio' che una firm CRYPTO puo' davvero far eseguire
# ================================================================== §0. PANNELLO (R, wick)
@lru_cache(maxsize=8)
def _eq_ohlc(sym: str) -> pd.DataFrame:
p = RAW / f"eq_{sym.lower()}_1d.parquet"
d = pd.read_parquet(p)
d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
d = d.sort_index()
d, _ = repair_splits(d) # split IWM/EFA non aggiustati (25/07)
return d[["open", "high", "low", "close"]].astype(float)
def _gtaa_exposure(close: pd.Series) -> np.ndarray:
"""Copia FEDELE di gtaa._exposure (che prende una Series di soli close)."""
px = close.values
n = len(px)
tgt = np.zeros(n)
mh = max(HORIZONS)
for i in range(mh, n):
tgt[i] = np.mean([1.0 if px[i] > px[i - H] else 0.0 for H in HORIZONS])
s = pd.Series(tgt, index=close.index)
rv = close.pct_change().rolling(63, min_periods=20).std().shift(1) * ANN
scale = np.clip(np.nan_to_num(TARGET_VOL / rv.replace(0, np.nan).values, nan=0.0), 0, 10.0)
return (s * scale).clip(0, 1.0).values
@lru_cache(maxsize=2)
def gtaa_daily_tuples() -> pd.DataFrame:
"""(R, m) giornalieri di GTAA01: chiusura replicata da gtaa.gtaa_returns e minimo intraday
dagli OHLC delle 6 gambe. Long-only e co-moventi -> convenzione **ogni gamba al proprio
minimo insieme** (severa, la stessa famiglia del bound 'perleg' di XS01).
Weekend/festivi: R = 0 e m = 0 (capitale IB fermo convenzione del book)."""
cap_leg = GTAA_DEFAULT_CAPITAL / len(EQ_UNIVERSE)
legs_r, legs_m = {}, {}
for sym in EQ_UNIVERSE:
d = _eq_ohlc(sym)
close = d["close"]
ex = np.nan_to_num(_gtaa_exposure(close))
px = close.values
prev = np.concatenate(([np.nan], px[:-1]))
ret = np.zeros(len(px))
ret[1:] = px[1:] / px[:-1] - 1.0
ret_low = d["low"].values / prev - 1.0 # long-only: il peggio e' il minimo
held = np.empty(len(ex))
comm = np.zeros(len(ex))
cur = 0.0
for i in range(len(ex)): # identico a gtaa._gated_returns
if i % REBAL_EVERY == 0:
notional = abs(ex[i] - cur) * cap_leg
if notional >= max(REBAL_BAND_USD, IB_MIN_TRADE_USD):
comm[i] = ib_commission(notional, px[i]) / cap_leg
cur = ex[i]
held[i] = cur
pos = np.zeros(len(held))
pos[1:] = held[:-1]
legs_r[sym] = pd.Series(pos * ret - comm, index=close.index)
legs_m[sym] = pd.Series(pos * np.nan_to_num(ret_low) - comm, index=close.index)
R = pd.concat(legs_r, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
M = pd.concat(legs_m, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
days = pd.date_range(R.index.min().normalize(), R.index.max().normalize(), freq="1D", tz="UTC")
R = R.reindex(days).fillna(0.0)
M = M.reindex(days).fillna(0.0)
out = pd.DataFrame({"R": R, "m": np.minimum(M, R)})
return out[out.index >= GTAA_BOOK_ACTIVATION]
@lru_cache(maxsize=2)
def vrp_daily_tuples() -> pd.DataFrame:
"""(R, m) di VRP01 con **m = R**: il mark infra-settimana NON e' nel dataset dello sleeve
(rendimento settimanale lumpato sul giorno di scadenza). Lente CIECA e GENEROSA dichiarata."""
r = _vrp_combo_returns().dropna().sort_index()
if r.index.tz is None:
r.index = r.index.tz_localize("UTC")
return pd.DataFrame({"R": r.values, "m": r.values}, index=r.index)
def _d(s: pd.Series) -> pd.Series:
s = s.dropna().sort_index()
if s.index.tz is None:
s.index = s.index.tz_localize("UTC")
return s
_CRY_CACHE: dict = {}
def _crypto(w_tp: float, w_skh: float) -> pd.DataFrame:
key = (round(w_tp, 6), round(w_skh, 6))
if key not in _CRY_CACHE:
_CRY_CACHE[key] = pc.crypto_daily_tuples(w_tp, w_skh)
return _CRY_CACHE[key]
@lru_cache(maxsize=4)
def _panel_index(start: str) -> pd.DatetimeIndex:
"""Griglia comune ai 5 sleeve (intersezione), dal `start` in poi."""
idx = _crypto(1.0, 0.0).index
for f in (pc.xsec_daily_tuples(), vrp_daily_tuples(), gtaa_daily_tuples()):
idx = idx.intersection(f.index)
return idx[idx >= pd.Timestamp(start, tz="UTC")]
_RG_CACHE: dict = {}
def book_RG(w: dict, start: str = START_WIN) -> tuple[np.ndarray, np.ndarray]:
"""(R, gap) giornalieri di un libro a pesi arbitrari sui 5 sleeve.
Crypto TP01+SKH01: minimo ESATTO sul path orario condiviso (r0725_prop_coupled).
XS01/VRP01/GTAA01: il loro minimo si SOMMA (worst simultaneo convenzione del 25/07)."""
key = (start,) + tuple(round(float(w.get(nm, 0.0)), 6) for nm in SLEEVES)
if key in _RG_CACHE:
return _RG_CACHE[key]
idx = _panel_index(start)
w_tp, w_skh = w.get("TP01", 0.0), w.get("SKH01", 0.0)
R = np.zeros(len(idx))
M = np.zeros(len(idx))
if w_tp > 0 or w_skh > 0:
c = _crypto(w_tp, w_skh).reindex(idx)
R += c["R"].values
M += c["m"].values
for nm, fr in (("XS01", pc.xsec_daily_tuples()), ("VRP01", vrp_daily_tuples()),
("GTAA01", gtaa_daily_tuples())):
wt = w.get(nm, 0.0)
if wt > 0:
f = fr.reindex(idx)
R += wt * f["R"].values
M += wt * f["m"].values
R = np.nan_to_num(R)
M = np.nan_to_num(M)
_RG_CACHE[key] = (R, np.minimum(M, R) - R)
return _RG_CACHE[key]
def deluck(R: np.ndarray, factor: float = DELUCK) -> np.ndarray:
return R - (1.0 - factor) * R.mean()
# ================================================================== §1. SIMULATORE
def boot_idx(n_hist: int, n_days: int, n_paths: int, seed: int, block: int = BLOCK) -> np.ndarray:
rng = np.random.default_rng(seed)
n_blocks = int(np.ceil(n_days / block))
st = rng.integers(0, n_hist - block, size=(n_paths, n_blocks))
return (st[:, :, None] + np.arange(block)[None, None, :]).reshape(n_paths, -1)[:, :n_days]
def eval_sim(R: np.ndarray, G: np.ndarray, idx: np.ndarray, lev: float, firm: str,
lens: str = "coupled") -> dict:
"""P(passare l'eval) entro l'orizzonte del bootstrap + giorni al pass."""
tgt, dd, dl = FIRMS[firm]["ev"]
r = R[idx] * lev
g = (G[idx] * lev) if lens == "coupled" else np.zeros_like(r)
P, D = r.shape
eq = np.ones(P)
alive = np.ones(P, bool)
passed = np.zeros(P, bool)
tpass = np.full(P, D, int)
for t in range(D):
act = alive & ~passed
if not act.any():
break
rt, gt = r[:, t], g[:, t]
eqp = eq
eq = np.where(act, eq * (1.0 + rt), eq)
eq_low = np.where(act, eqp * (1.0 + rt + gt), eq)
bust = act & ((eq_low < 1.0 - dd) | (rt + gt < -dl))
alive &= ~bust
ok = act & ~bust & (eq >= 1.0 + tgt)
tpass[ok] = t
passed |= ok
return dict(p_pass=float(passed.mean()), passed=passed,
t_pass=float(np.median(tpass[passed])) if passed.any() else np.nan)
def funded_sim(R: np.ndarray, G: np.ndarray, idx: np.ndarray, lev: float, firm: str,
notional: float, lens: str = "coupled") -> dict:
"""P(vivo a fine orizzonte) + payout netto cumulato (prelievo mensile, base ripristinata)."""
ml, dl = FIRMS[firm]["fu"]
r = R[idx] * lev
g = (G[idx] * lev) if lens == "coupled" else np.zeros_like(r)
P, D = r.shape
eq = np.ones(P)
alive = np.ones(P, bool)
payout = np.zeros(P)
alive_at = {}
for t in range(D):
rt, gt = r[:, t], g[:, t]
eqp = eq
eq = np.where(alive, eq * (1.0 + rt), eq)
eq_low = np.where(alive, eqp * (1.0 + rt + gt), eq)
bust = alive & ((eq_low < 1.0 - ml) | (rt + gt < -dl))
alive &= ~bust
if (t + 1) % 30 == 0:
take = alive & (eq > 1.0)
payout += np.where(take, (eq - 1.0) * notional, 0.0) * SPLIT * (1.0 - TAX_RATE)
eq = np.where(take, 1.0, eq)
if (t + 1) % 365 == 0:
alive_at[(t + 1) // 365] = float(alive.mean())
return dict(p_alive=float(alive.mean()), alive_at=alive_at,
e_payout=float(payout.mean()), payout=payout)
def objective(w: dict, lev: float, firm: str, ev_idx: np.ndarray, fu_idx: np.ndarray,
lens: str = "coupled", factor: float = DELUCK,
notional: float = 100_000.0) -> dict:
R, G = book_RG(w)
R = deluck(R, factor)
e = eval_sim(R, G, ev_idx, lev, firm, lens)
f = funded_sim(R, G, fu_idx, lev, firm, notional, lens)
ann = float(R.mean() * 365.0)
vol = float(R.std() * np.sqrt(365.0))
return dict(J=e["p_pass"] * f["p_alive"], p_pass=e["p_pass"], p_alive=f["p_alive"],
e_payout=f["e_payout"], sharpe=(ann / vol if vol > 0 else 0.0),
vol=vol, drift=ann, t_pass=e["t_pass"])
# ================================================================== §2. IL CONTO ANALITICO
def p_hit_target_first(mu: float, sig: float, lev: float, T: float, D: float) -> float:
"""Gambler's ruin per moto browniano con drift: P(toccare +T prima di -D), SENZA limite di
tempo. theta = 2*mu/(k*sigma^2): la leva entra SOLO dividendo theta -> con leva -> inf la
probabilita' tende a D/(T+D) (37.5% con 10%/6%), qualunque sia lo Sharpe."""
if sig <= 0:
return float(mu > 0)
theta = 2.0 * mu / (lev * sig * sig)
if abs(theta) < 1e-12:
return D / (T + D)
return float((1.0 - np.exp(-theta * D)) / (1.0 - np.exp(-theta * (T + D))))
# ================================================================== §3. GRIGLIA / NULL
def simplex_grid(names: tuple, step_den: int) -> list[dict]:
"""Tutti i vettori di peso a passo 1/step_den sul simplesso di `names` (somma 1)."""
out = []
n = len(names)
def rec(prefix, rem, k):
if k == n - 1:
out.append(dict(zip(names, [p / step_den for p in prefix] + [rem / step_den])))
return
for v in range(rem + 1):
rec(prefix + [v], rem - v, k + 1)
rec([], step_den, 0)
return out
def wkey(w: dict) -> str:
return "/".join(f"{int(round(100 * w.get(nm, 0.0))):d}" for nm in CRYPTO_PROP)
def grid_scan(grid: list[dict], levs: tuple, firm: str, ev_idx: np.ndarray, fu_idx: np.ndarray,
lens: str = "coupled", factor: float = DELUCK) -> pd.DataFrame:
rows = []
for w in grid:
for lev in levs:
o = objective(w, lev, firm, ev_idx, fu_idx, lens, factor)
rows.append(dict(w=wkey(w), lev=lev, **{k: v for k, v in o.items()}))
return pd.DataFrame(rows)
def random_weights(n: int, names: tuple, seed: int, floor: float = 0.0,
caps: dict | None = None) -> list[dict]:
"""Stesso campionatore di `portfolio.weights_tilt_null` (dirichlet uniforme sul simplesso +
rejection su floor/caps), ma qui serve a costruire il NULL della funzione obiettivo J."""
rng = np.random.default_rng(seed)
caps_v = np.array([(caps or {}).get(nm, 1.0) for nm in names], float)
out = []
while len(out) < n:
b = rng.dirichlet(np.ones(len(names)), size=4 * n + 64)
ok = (b >= floor).all(axis=1) & (b <= caps_v).all(axis=1)
out.extend(b[ok])
return [dict(zip(names, v)) for v in out[:n]]
# ================================================================== main
def hr(t: str = "") -> None:
print("\n" + "-" * 104)
if t:
print(f" {t}")
print("-" * 104)
def main() -> None:
t0 = time.time()
print("=" * 104)
print(" PROP-ALLOC — allocare per la BARRIERA (P(pass) x P(vivo 12m)) invece che per lo Sharpe")
print("=" * 104)
# ---------------------------------------------------------------- A. pannello + sanity
idx = _panel_index(START_WIN)
print(f"\n finestra comune ai 5 sleeve: {len(idx)} giorni {idx[0].date()} -> {idx[-1].date()}"
f" (XS01 nasce nel 2024: e' anche la sua finestra di SCOPERTA -> lo favorisce)")
print(f" de-luck sul drift x{DELUCK} (misurato 26/07) | bootstrap a blocchi di {BLOCK}g, "
f"indici CONDIVISI fra configurazioni | {N_PATHS} percorsi")
off = {"TP01": _d(_tp01_returns()), "SKH01": _d(_skyhook_returns()), "XS01": _d(_xsec_returns()),
"VRP01": _d(_vrp_combo_returns()), "GTAA01": _d(_gtaa_daily_returns())}
rec = {"TP01": _crypto(1.0, 0.0)["R"], "SKH01": _crypto(0.0, 1.0)["R"],
"XS01": pc.xsec_daily_tuples()["R"], "VRP01": vrp_daily_tuples()["R"],
"GTAA01": gtaa_daily_tuples()["R"]}
print("\n sanity — la ricostruzione (R) deve coincidere con lo sleeve ufficiale sulla chiusura:")
for nm in SLEEVES:
com = off[nm].index.intersection(rec[nm].index)
dmax = float(np.abs(off[nm].reindex(com).values - rec[nm].reindex(com).values).max())
note = "" if nm not in ("TP01", "SKH01") else " (recon MTM orario: lente diversa per costruzione)"
print(f" {nm:>7}: max|delta| = {dmax:.2e} {'OK' if dmax < 1e-9 else 'DIVERGE'}{note}")
hr("A. PROFILO DEGLI SLEEVE SOTTO BARRIERA — cio' che conta non e' lo Sharpe, e si vede qui")
print(f" {'sleeve':>8} {'Sharpe':>7} {'vol a':>7} {'drift a':>8} {'skew':>7} "
f"{'giorno peggiore':>16} {'gap p90':>9} {'gap peggiore':>13} {'g/vol':>7}")
prof = {}
for nm in SLEEVES:
R, G = book_RG({nm: 1.0})
Rd = deluck(R)
vol = Rd.std() * np.sqrt(365.0)
dr = Rd.mean() * 365.0
sk = float(pd.Series(Rd).skew())
prof[nm] = dict(sharpe=dr / vol if vol > 0 else 0.0, vol=vol, drift=dr, skew=sk,
worst=Rd.min(), gap90=np.percentile(-G, 90), gapw=G.min())
print(f" {nm:>8} {prof[nm]['sharpe']:>7.2f} {vol:>6.1%} {dr:>8.1%} {sk:>7.2f} "
f"{Rd.min():>15.2%} {-np.percentile(-G, 90)*100:>8.2f}pp {G.min()*100:>12.2f}pp "
f"{(-G.min()/vol) if vol > 0 else 0:>7.2f}")
print("\n lettura: `gap peggiore` e' l'escursione intraday che la lente close-only NON vede;")
print(" `g/vol` la misura in unita' di vol annua. VRP01 ha gap 0 **per cecita' del dato**,")
print(" non per assenza di rischio (mark infra-settimana misurato il 30/07: mediana -6.9%,")
print(" minimo -81.7% del capitale su un trade finito in UTILE).")
hr("B. IL CONTO ANALITICO — perche' con leva LIBERA la barriera premia lo SHARPE, non la vol")
tgt, dd, _dl = FIRMS["HYRO"]["ev"]
print(f" P(toccare +{tgt:.0%} prima di -{dd:.0%}), gambler's ruin senza limite di tempo, "
f"theta = 2*mu/(k*sigma^2):")
print(f" {'sleeve':>8} {'Sharpe':>7} | " + " ".join(f"{f'k={k:g}':>8}" for k in (0.25, 0.5, 1.0, 2.0, 4.0)))
for nm in SLEEVES:
p = prof[nm]
cells = " ".join(f"{p_hit_target_first(p['drift'], p['vol'], k, tgt, dd):>8.1%}"
for k in (0.25, 0.5, 1.0, 2.0, 4.0))
print(f" {nm:>8} {p['sharpe']:>7.2f} | {cells}")
print(f" {'limite k->inf':>16} | " + " ".join([f"{dd/(tgt+dd):>8.1%}"] * 5)
+ " <- D/(T+D): la leva alta CANCELLA l'edge")
print("\n Alla leva minima che porta a +T dentro l'orizzonte (k ~ T/mu) resta theta ~ 2*S^2/T:")
print(" la VOL sparisce e conta lo Sharpe AL QUADRATO. Percio' la previsione registrata prima")
print(" di misurare e': l'argmax di J sui PESI ~ l'argmax di Sharpe, e il contenuto vero e'")
print(" nella LEVA (che lo Sharpe non determina) e nella deviazione dal gaussiano.")
# ---------------------------------------------------------------- C. griglia
n_hist = len(idx)
EV_H, FU_H = 365, 365
ev_idx = boot_idx(n_hist, EV_H, N_PATHS, seed=8220)
fu_idx = boot_idx(n_hist, FU_H, N_PATHS, seed=8221)
LEVS = (0.25, 0.375, 0.50, 0.625, 0.75, 1.00, 1.25, 1.50)
STEP = 8
grid = simplex_grid(CRYPTO_PROP, STEP)
hr(f"C. GRIGLIA — {len(grid)} vettori di peso (passo 1/{STEP}) x {len(LEVS)} leve "
f"= {len(grid)*len(LEVS)} celle, firm HYRO (eval +10%/-6%/-4%, funded -6%/-4%)")
print(f" orizzonte eval {EV_H}g, funded {FU_H}g. Lente ACCOPPIATA. "
f"J = P(pass) x P(vivo 12m).")
t = time.time()
G3 = grid_scan(grid, LEVS, "HYRO", ev_idx, fu_idx)
print(f" ({time.time()-t:.0f}s)")
best = G3.loc[G3["J"].idxmax()]
# argmax di SHARPE: lo Sharpe non dipende dalla leva -> si sceglie il peso, poi la leva
# migliore PER QUEL PESO secondo J (altrimenti non e' un confronto onesto).
w_sh = G3.loc[G3["sharpe"].idxmax(), "w"]
sub_sh = G3[G3["w"] == w_sh]
best_sh = sub_sh.loc[sub_sh["J"].idxmax()]
live = G3[(G3["w"] == "75/25/0")]
best_live = live.loc[live["J"].idxmax()]
print(f"\n {'':>22} {'pesi TP/SKH/XS':>16} {'leva':>6} {'J':>7} {'P(pass)':>8} "
f"{'P(vivo12m)':>11} {'Sharpe':>7} {'vol':>6} {'E[pay/a]':>10} {'g.al pass':>10}")
for lab, row in (("argmax J", best), ("argmax Sharpe", best_sh),
("libro LIVE 75/25", best_live)):
print(f" {lab:>22} {row['w']:>16} {row['lev']:>6.3f} {row['J']:>7.3f} "
f"{row['p_pass']:>8.1%} {row['p_alive']:>11.1%} {row['sharpe']:>7.2f} "
f"{row['vol']:>6.1%} {row['e_payout']:>9,.0f}$ {row['t_pass']:>10.0f}")
print("\n Le prime 8 celle per J:")
print(f" {'#':>3} {'pesi':>16} {'leva':>6} {'J':>7} {'P(pass)':>8} {'P(vivo)':>8} "
f"{'Sharpe':>7} {'E[pay/a]':>10}")
for i, (_, r) in enumerate(G3.sort_values("J", ascending=False).head(8).iterrows(), 1):
print(f" {i:>3} {r['w']:>16} {r['lev']:>6.3f} {r['J']:>7.3f} {r['p_pass']:>8.1%} "
f"{r['p_alive']:>8.1%} {r['sharpe']:>7.2f} {r['e_payout']:>9,.0f}$")
print("\n E per E[payout] (obiettivo DIVERSO: un flusso, non una sopravvivenza):")
print(f" {'#':>3} {'pesi':>16} {'leva':>6} {'E[pay/a]':>10} {'J':>7} {'P(pass)':>8} {'P(vivo)':>8}")
for i, (_, r) in enumerate(G3.sort_values("e_payout", ascending=False).head(4).iterrows(), 1):
print(f" {i:>3} {r['w']:>16} {r['lev']:>6.3f} {r['e_payout']:>9,.0f}$ {r['J']:>7.3f} "
f"{r['p_pass']:>8.1%} {r['p_alive']:>8.1%}")
hr("C-bis. LA LEVA — la variabile che lo Sharpe non determina e che decide tutto")
print(f" pesi = argmax J ({best['w']}):")
print(f" {'leva':>7} {'J':>7} {'P(pass) 365g':>13} {'P(pass) 1095g':>14} {'P(vivo 12m)':>12} "
f"{'E[pay/a]':>10}")
ev_long = boot_idx(n_hist, 1095, N_PATHS, seed=8222)
w_best = dict(zip(CRYPTO_PROP, [int(x) / 100 for x in best["w"].split("/")]))
Rb, Gb = book_RG(w_best)
Rb = deluck(Rb)
for lev in LEVS:
o = objective(w_best, lev, "HYRO", ev_idx, fu_idx)
pl_long = eval_sim(Rb, Gb, ev_long, lev, "HYRO")["p_pass"]
print(f" {lev:>7.3f} {o['J']:>7.3f} {o['p_pass']:>12.1%} {pl_long:>13.1%} "
f"{o['p_alive']:>11.1%} {o['e_payout']:>9,.0f}$")
print("\n A 365 giorni P(pass) e' a CAMPANA nella leva (a leva bassa il drift non arriva a +10%")
print(" in tempo); senza limite di tempo — che e' la regola vera di HyroTrader — e' MONOTONA")
print(" DECRESCENTE, come dice il conto analitico. Le due colonne dicono cose diverse e vanno")
print(" lette insieme: la leva alta non aumenta la probabilita' di farcela, aumenta la FRETTA.")
# ---------------------------------------------------------------- D. i null
hr("D. NULL 1 — l'argmax e' speciale, o e' solo il MASSIMO DI 45 ESTRAZIONI?")
rw = random_weights(300, CRYPTO_PROP, seed=8223)
lev_star = float(best["lev"])
Jr = np.array([objective(w, lev_star, "HYRO", ev_idx, fu_idx)["J"] for w in rw])
pctl = float((Jr < best["J"]).mean() * 100.0)
bestofk = 100.0 * len(grid) / (len(grid) + 1)
print(f" J dell'argmax = {best['J']:.3f} alla leva {lev_star:g}")
print(f" null: 300 vettori di peso casuali uniformi sul simplesso (stesso campionatore di")
print(f" `portfolio.weights_tilt_null`), valutati alla STESSA leva")
print(f" mediana {np.median(Jr):.3f} | p90 {np.percentile(Jr, 90):.3f} | "
f"max {Jr.max():.3f} | frazione sopra il LIVE 75/25 = {(Jr > best_live['J']).mean():.0%}")
print(f" percentile dell'argmax fra i casuali = {pctl:.1f}° contro la firma best-of-"
f"{len(grid)} = {bestofk:.1f}°")
print(f" -> {'DENTRO' if pctl <= bestofk else 'SOPRA'} la firma del massimo-di-k: "
f"{'nessuna evidenza che quel vettore sia speciale' if pctl <= bestofk else 'vettore particolare'}")
print(f" (la meta' bassa del null e' pero' informativa: il LIVE 75/25 e' battuto dal "
f"{(Jr > best_live['J']).mean():.0%} dei pesi CASUALI)")
hr("D-bis. NULL 2 — selezione IN-SAMPLE-ONLY (la scelta guardando J e' selezione come ogni altra)")
half = n_hist // 2
R_all = {}
for w in grid:
R_, G_ = book_RG(w)
R_all[wkey(w)] = (deluck(R_), G_)
ev_h = boot_idx(half, EV_H, N_PATHS, seed=8224)
fu_h = boot_idx(half, FU_H, N_PATHS, seed=8225)
def _J(key, lev, sl):
R_, G_ = R_all[key]
e = eval_sim(R_[sl], G_[sl], ev_h, lev, "HYRO")["p_pass"]
f = funded_sim(R_[sl], G_[sl], fu_h, lev, "HYRO", 100_000.0)["p_alive"]
return e * f
IS, OOS = slice(0, half), slice(half, n_hist)
rows = []
for w in grid:
k = wkey(w)
for lev in LEVS:
rows.append((k, lev, _J(k, lev, IS), _J(k, lev, OOS)))
S = pd.DataFrame(rows, columns=["w", "lev", "J_is", "J_oos"])
pick = S.loc[S["J_is"].idxmax()]
oracle = S.loc[S["J_oos"].idxmax()]
live_oos = S[(S["w"] == "75/25/0")].sort_values("J_oos", ascending=False).iloc[0]
print(f" prima meta' {idx[0].date()}->{idx[half-1].date()} | seconda {idx[half].date()}->{idx[-1].date()}")
print(f" {'':>28} {'pesi':>16} {'leva':>6} {'J in-sample':>12} {'J out-of-sample':>16}")
print(f" {'scelto SUL SOLO in-sample':>28} {pick['w']:>16} {pick['lev']:>6.3f} "
f"{pick['J_is']:>12.3f} {S[(S['w']==pick['w'])&(S['lev']==pick['lev'])]['J_oos'].iloc[0]:>16.3f}")
print(f" {'oracolo (max sull OOS)':>28} {oracle['w']:>16} {oracle['lev']:>6.3f} "
f"{oracle['J_is']:>12.3f} {oracle['J_oos']:>16.3f}")
print(f" {'libro LIVE 75/25 (baseline)':>28} {live_oos['w']:>16} {live_oos['lev']:>6.3f} "
f"{live_oos['J_is']:>12.3f} {live_oos['J_oos']:>16.3f}")
rho = float(S["J_is"].corr(S["J_oos"], method="spearman"))
print(f" Spearman(J in-sample, J out-of-sample) su {len(S)} celle = {rho:+.2f}")
print(" -> se rho e' alto la mappa e' stabile e la scelta si trasferisce; se e' ~0 l'argmax")
print(" e' rumore e la decisione va presa sul MECCANISMO (la leva), non sulla cella.")
hr("D-ter. NULL DEL DE-LEVERING — la leva e' GIA' dentro l'ottimizzazione, ma va detto")
print(" Il null classico ('esiste k<1 che da' lo stesso DD con Sharpe migliore?') qui e'")
print(" strutturale: ogni configurazione e' valutata a OTTO leve, quindi nessuna puo' vincere")
print(" solo perche' e' meno esposta. La domanda che resta e': la MIGLIOR leva del libro LIVE")
print(" raggiunge il J dell'argmax?")
print(f" argmax J : {best['w']:>10} @ {best['lev']:g}x -> J = {best['J']:.3f}")
print(f" LIVE 75/25 al suo : {best_live['w']:>10} @ {best_live['lev']:g}x -> J = {best_live['J']:.3f}")
dJ = best["J"] - best_live["J"]
se = np.sqrt(0.25 / N_PATHS) * 2
print(f" delta = {dJ:+.3f} (errore MC su livelli ~+-{se:.3f}; le due celle condividono")
print(f" gli indici di bootstrap -> il delta e' APPAIATO e il suo errore e' piu' piccolo)")
# ---------------------------------------------------------------- E. banda d'ancora
hr("E. BANDA D'ANCORA — non ri-misurata sotto questa lente (costo); si usa il FATTORE misurato")
print(" Ri-fare i 24x23x10 offset col recon MTM orario e' fuori budget. Si applica il fattore")
print(" di drift MISURATO il 26/07 (x0.89 sul libro live) e si controlla cio' che conta: se")
print(" l'ORDINE fra le configurazioni sopravvive alla banda 0.60 (stima 25/07, ri-misurata")
print(" troppo severa) - 1.00 (nessuna correzione).")
cands = [best["w"], best_sh["w"], best_live["w"]]
print(f"\n {'fattore':>9} " + " ".join(f"{('J ' + c):>14}" for c in dict.fromkeys(cands))
+ f" {'argmax pesi':>14} {'argmax leva':>11}")
for fct in (1.00, 0.89, 0.60):
cells = []
for c in dict.fromkeys(cands):
wc = dict(zip(CRYPTO_PROP, [int(x) / 100 for x in c.split("/")]))
sub = [objective(wc, lv, "HYRO", ev_idx, fu_idx, factor=fct)["J"] for lv in LEVS]
cells.append(max(sub))
g = grid_scan(grid, LEVS, "HYRO", ev_idx, fu_idx, factor=fct)
b = g.loc[g["J"].idxmax()]
print(f" {fct:>9.2f} " + " ".join(f"{v:>14.3f}" for v in cells)
+ f" {b['w']:>14} {b['lev']:>11.3f}")
# ---------------------------------------------------------------- F. VRP01 / GTAA01
hr("F. E GLI SLEEVE 'INUTILI A $600'? — l'asimmetria del 25/07 §4 vale per XS01, non per tutti")
print(" Su un conto funded da $100k gli sleeve fuori portata a $600 diventano ESEGUIBILI per")
print(" TAGLIA. Ma la taglia non e' l'unico vincolo: una firm CRYPTO non lista opzioni Deribit")
print(" (VRP01) ne' ETF azionari USA (GTAA01, che al retail UE e' pure bloccato dal PRIIPs).")
print(" Qui si misura comunque il CONTROFATTUALE: se fossero disponibili, aiuterebbero?")
print(f"\n {'aggiunta al ' + best['w']:>34} {'leva':>6} {'J':>7} {'P(pass)':>8} "
f"{'P(vivo)':>8} {'Sharpe':>7} {'E[pay/a]':>10}")
for extra, wx in [(None, 0.0), ("VRP01", 0.10), ("VRP01", 0.20),
("GTAA01", 0.10), ("GTAA01", 0.20)]:
w2 = {k: v * (1.0 - wx) for k, v in w_best.items()}
if extra:
w2[extra] = wx
rows = [(lv, objective(w2, lv, "HYRO", ev_idx, fu_idx)) for lv in LEVS]
lv, o = max(rows, key=lambda x: x[1]["J"])
lab = "nessuna (base)" if extra is None else f"{extra} @ {wx:.0%}"
print(f" {lab:>34} {lv:>6.3f} {o['J']:>7.3f} {o['p_pass']:>8.1%} {o['p_alive']:>8.1%} "
f"{o['sharpe']:>7.2f} {o['e_payout']:>9,.0f}$")
print("\n ATTENZIONE: la riga VRP01 e' calcolata con **gap = 0** (il mark infra-settimana non")
print(" esiste nel dataset dello sleeve): e' un TETTO, non una stima. Con il mark misurato il")
print(" 30/07 (peggiore infra-settimana mediano -6.9%, minimo -81.7%) una barriera STATICA al")
print(" 6% verrebbe sfondata dal mark, non dal risultato.")
# ---------------------------------------------------------------- G. FTMO
hr("G. LA BARRIERA CAMBIA LA RISPOSTA? — HYRO (-6% statico) vs FTMO (-10% statico)")
print(f" {'firm':>6} {'argmax pesi':>16} {'leva':>6} {'J':>7} {'P(pass)':>8} {'P(vivo12m)':>11} "
f"{'E[pay/a]':>10} | {'LIVE 75/25: leva':>17} {'J':>7}")
for fm in ("HYRO", "FTMO"):
g = grid_scan(grid, LEVS, fm, ev_idx, fu_idx)
b = g.loc[g["J"].idxmax()]
lv2 = g[g["w"] == "75/25/0"]
b2 = lv2.loc[lv2["J"].idxmax()]
print(f" {fm:>6} {b['w']:>16} {b['lev']:>6.3f} {b['J']:>7.3f} {b['p_pass']:>8.1%} "
f"{b['p_alive']:>11.1%} {b['e_payout']:>9,.0f}$ | {b2['lev']:>17.3f} {b2['J']:>7.3f}")
print("\n La barriera piu' larga di FTMO sposta l'ottimo di leva verso l'ALTO: il vincolo e' la")
print(" barriera, non il libro. E' la stessa lettura del 25/07 §4 ('sul canale funded il")
print(" vincolo binding e' la regola di DD, non il capitale'), qui misurata sull'ottimo.")
# ---------------------------------------------------------------- H. gate ufficiale sui pesi
hr("H. GATE UFFICIALE SUI PESI (`portfolio.weights_tilt_null`) — sullo SHARPE, come previsto")
cols = {nm: off[nm] for nm in CRYPTO_PROP}
try:
res = weights_tilt_null(cols, {"TP01": 0.75, "SKH01": 0.25, "XS01": 0.0001},
{nm: max(w_best.get(nm, 0.0), 1e-4) for nm in CRYPTO_PROP},
floor=0.0, n=300, k_seen=len(grid) * len(LEVS))
print(f" proposta {best['w']} vs LIVE 75/25 : delta_insample {res['delta_insample']:+.4f} | "
f"delta_hold {res['delta_hold']:+.4f} | pctl_hold {res['pctl_hold']:.1f}° "
f"(best-of-k {res['bestofk_pctl']:.1f}°) | gate_pass = {res['gate_pass']}")
except Exception as exc: # pragma: no cover
print(f" non girato: {exc}")
print(" NB: questo gate giudica lo SHARPE del book di ricerca, non J. Un cambio dei pesi del")
print(" LIBRO LIVE resta soggetto a lui; qui si progetta un conto SEPARATO (funded), che non")
print(" e' il libro live e non passa da questo gate.")
# ---------------------------------------------------------------- I. tavola operativa
hr("I. TAVOLA OPERATIVA — le 3 configurazioni migliori, e quanti conti servono per 50 EUR/g")
top = G3.sort_values("J", ascending=False).drop_duplicates("w").head(3)
rows_ops = []
fu3 = boot_idx(n_hist, 1095, N_PATHS, seed=8226)
print(f" {'pesi TP/SKH/XS':>16} {'leva':>6} {'P(pass)':>8} {'P(vivo12m)':>11} "
f"{'E[payout 3a]':>13} {'EUR/g per conto':>16} {'conti per 50/g':>15}")
for _, r in top.iterrows():
w2 = dict(zip(CRYPTO_PROP, [int(x) / 100 for x in r["w"].split("/")]))
R_, G_ = book_RG(w2)
R_ = deluck(R_)
f3 = funded_sim(R_, G_, fu3, float(r["lev"]), "HYRO", 100_000.0)
eur_day = f3["e_payout"] / EURUSD / 3.0 / 365.0
n_acct = (TARGET_EUR_DAY / eur_day) if eur_day > 0 else np.inf
rows_ops.append((r["w"], float(r["lev"]), f3["e_payout"], eur_day, n_acct))
print(f" {r['w']:>16} {r['lev']:>6.3f} {r['p_pass']:>8.1%} {r['p_alive']:>11.1%} "
f"{f3['e_payout']:>12,.0f}$ {eur_day:>15.2f} {n_acct:>15.1f}")
print("\n E[payout 3a] e' su UN conto funded da $100k, gia' netto split 80% e fisco 33%, e")
print(" MEDIA su tutti i percorsi (i morti contano zero). 'conti per 50/g' e' quindi un numero")
print(" ATTESO, non una garanzia: la distribuzione e' bimodale (vivo / bustato).")
cap_tot = sum(f["cap"] for f in pl.FIRMS)
print(f" ⚠ TETTO STRUTTURALE: cap per trader ${FIRMS['HYRO']['cap']:,.0f} (HYRO) + "
f"${FIRMS['FTMO']['cap']:,.0f} (FTMO) = ${cap_tot:,.0f} di nozionale su due firm")
print(f" -> al massimo {cap_tot/100_000:.0f} conti da $100k. Se ne servono di piu', servono "
f"ALTRE FIRM (e ognuna aggiunge rischio di controparte).")
hr("I-bis. LA SCALA DI CONTI — macchineria del 25/07 (`r0725_prop_coupled.simulate`), "
"de-luck portato a x0.89")
print(f" 36 mesi, EUR {pl.START_EUR:.0f} di cassa iniziale, max {pl.MAX_CONCURRENT} conti, "
f"morte-firm {pl.FIRM_DEATH_PER_YEAR:.0%}/anno, lente ACCOPPIATA")
pc.DELUCK = DELUCK # il 25/07 girava a 0.6 (poi ri-misurato troppo severo)
cfg_best = tuple(w_best.get(nm, 0.0) for nm in CRYPTO_PROP)
cfg_live = (0.75, 0.25, 0.0)
cfg_B = (0.55, 0.20, 0.25)
pols = {
f"argmax-J {best['w']} su tutti i conti": ([cfg_best], [0] * pl.MAX_CONCURRENT),
"LIVE 75/25 su tutti i conti (baseline)": ([cfg_live], [0] * pl.MAX_CONCURRENT),
"config B 55/20/25 (scelta a mano 25/07)": ([cfg_B], [0] * pl.MAX_CONCURRENT),
}
print(f"\n {'politica':>44} {'leva':>6} {'EUR/g med':>10} {'p90':>8} {'P(>=10/g)':>10} "
f"{'P(>=50/g)':>10} {'P(zero)':>8}")
for pname, (cfgs, slots) in pols.items():
for lev in (float(best["lev"]), 0.75):
r = pc.simulate(cfgs, slots, n_paths=2000, lev=lev, seed=20260822,
lens="coupled", start=START_WIN)
print(f" {pname:>44} {lev:>6.3f} {r['med']:>10.2f} {r['p90']:>8.2f} "
f"{r['p_ge10']:>10.1%} {r['p_target']:>10.1%} {r['p_zero']:>8.1%}")
print("\n" + "=" * 104)
print(f" fatto in {time.time()-t0:.0f}s")
print("=" * 104)
if __name__ == "__main__":
main()
+618
View File
@@ -0,0 +1,618 @@
"""r0822_skew.py — la FORMA della superficie di volatilita' (skew), mai usata dal progetto.
Il progetto ha usato molte volte il LIVELLO della vol implicita (DVOL/ATM): VRP01 lo vende sotto
gate IV-rank, DVOLSPREAD ne fa relative-value BTC/ETH, TP01xDVOL lo prova come denominatore del
vol-target. Non ha MAI usato la FORMA: il risk reversal 25-delta (RR), il butterfly (BF) e la loro
dinamica. La catena per-strike esiste da 2026-05-01 (`data/raw/cb_chain/`), quindi la domanda e'
finalmente misurabile.
Tre domande, in ordine di valore dichiarato nel brief:
Q1 lo skew ANTICIPA lo spot? ("il posizionamento in opzioni anticipa il prezzo")
Q2 lo skew e' un GATE DI RISCHIO sopra il libro TP01?
Q3 lo skew spiega l'errore di PREZZATURA di VRP01 (f=0.73 misurato il 30/07)?
Onesta' strutturale, dichiarata PRIMA dei numeri:
* La finestra utile e' 2026-06-09 -> oggi, cioe' ~75 giorni, NON i 113 giorni di righe presenti.
Prima del 09/06 cerbero-bite raccoglieva UNA SOLA scadenza per ora (verificato: `exps=1`),
quindi non esiste ne' struttura a termine ne' maturita' costante. Righe presenti != dato
presente (regola 8 del brief), e qui il conto va fatto in ORE DI SUPERFICIE RICOSTRUIBILE.
* HOLDOUT del progetto = 2025-01-01: l'INTERO campione e' posteriore. Non esiste hold-out,
non esiste in-sample -> `study_family_honest` e `select_cell_insample` NON sono eseguibili
(l'in-sample e' vuoto). Il verdetto massimo ottenibile e' LEAD con gate pre-registrato.
* Con ~75 osservazioni giornaliere SE(Sharpe annualizzato) ~ sqrt(365/75) ~ 2.2: qualunque
Sharpe giornaliero misurato qui e' indistinguibile da zero. Percio' il peso della sessione
sta dove la POTENZA c'e' davvero: la relazione oraria skew<->prezzo (N~1.800) e la
decomposizione di prezzo di VRP01 (deterministica, non statistica).
Convenzioni di costruzione (sono SCELTE, non dettagli vedi sezione 1):
* "quotato" = bid>0 AND ask>0 AND ask>=bid AND iv/delta non nulli. Si contano le quote, non le righe.
* smile per (asset, ora, scadenza): IV interpolata LINEARMENTE in |delta| a 0.25 e 0.50,
senza MAI estrapolare (se la catena non abbraccia il delta bersaglio, la cella si scarta).
* maturita' costante: RR/BF/ATM interpolati fra le due scadenze che abbracciano T (7g e 30g).
Il roll e' CONTINUO per costruzione: non c'e' una data di roll, quindi non c'e' il dente di
sega di tenore che un "scadenza piu' vicina a T" iniettera' nel segnale.
* ogni ora porta `ts_max` = il timestamp piu' recente fra le quote usate. I rendimenti in avanti
partono STRETTAMENTE dopo `ts_max`: senza questo l'ancora e' sbagliata di ~30 minuti e nasce
un falso "lo skew anticipa di 15 minuti" (misurato in sessione: vedi sezione 2).
Uso: nice -n 19 timeout 900 uv run python scripts/research/r0822_skew.py
"""
from __future__ import annotations
import glob
import os
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" / "alt"))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import altlib as A # noqa: E402
import cblib as CB # noqa: E402
CHAIN = ROOT / "data" / "raw" / "cb_chain"
SCRATCH = Path(os.environ.get("SKEW_SCRATCH", "/tmp/claude-1001/-opt-docker-PythagorasGoal/"
"b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad"))
ASSETS = ("BTC", "ETH")
COLS = ["ts", "asset", "instrument_name", "option_type", "strike", "exp",
"bid", "ask", "mid", "iv", "delta", "open_interest", "quote_status"]
# tick MISURATO sui dati (non assunto): 0.0001 sotto 0.005 di premio, 0.0005 sopra.
def tick_of(prezzo: np.ndarray) -> np.ndarray:
return np.where(np.asarray(prezzo, float) < 0.005, 0.0001, 0.0005)
def rule(t: str) -> None:
print("\n" + "=" * 78)
print(t)
print("=" * 78)
# =============================================================================
# 0. DATO
# =============================================================================
def load_quoted() -> tuple[pd.DataFrame, dict]:
"""Catena filtrata alle QUOTE vere. Ritorna anche la contabilita' righe-vs-quote."""
parts, n_righe, n_status = [], 0, {}
for f in sorted(glob.glob(str(CHAIN / "*.parquet"))):
d = pd.read_parquet(f, columns=COLS)
n_righe += len(d)
for k, v in d["quote_status"].value_counts(dropna=False).items():
n_status[str(k)] = n_status.get(str(k), 0) + int(v)
d["ts"] = pd.to_datetime(d["ts"], utc=True).dt.tz_convert("UTC")
d["exp"] = pd.to_datetime(d["exp"], utc=True).dt.tz_convert("UTC")
d["strike"] = d["strike"].astype(float)
parts.append(d)
d = pd.concat(parts, ignore_index=True)
d = d.drop_duplicates(subset=["ts", "instrument_name"], keep="last")
d["ts"] = pd.DatetimeIndex(d["ts"]).as_unit("ns")
d["exp"] = pd.DatetimeIndex(d["exp"]).as_unit("ns")
d["dte"] = (d["exp"] - d["ts"]).dt.total_seconds() / 86400.0
ok = (d["bid"] > 0) & (d["ask"] > 0) & (d["ask"] >= d["bid"]) & d["iv"].notna() & d["delta"].notna()
cont = dict(righe=n_righe, dedup=int(len(d)), quotate=int(ok.sum()),
frac_quotate=round(float(ok.mean()), 4), quote_status=n_status)
q = d[ok & (d["dte"] > 0.5) & (d["dte"] < 95)].copy()
q["hr"] = q["ts"].dt.floor("h")
q["ad"] = q["delta"].abs()
# una riga per (ora, strumento): se in un'ora ci sono piu' giri, vale l'ULTIMO
q = q.sort_values("ts").drop_duplicates(subset=["hr", "asset", "instrument_name"], keep="last")
return q, cont
def conta_tick(q: pd.DataFrame) -> pd.DataFrame:
"""Quanti tick vale lo spread, e quanti tick vale l'ask? (regola: prima di credere a un
rapporto estremo su un prezzo piccolo, contare i tick)."""
out = []
for a in ASSETS:
for nome, m in (("ala |d|~0.10", (q["ad"] > 0.06) & (q["ad"] < 0.14)),
("25-delta", (q["ad"] > 0.20) & (q["ad"] < 0.30)),
("ATM |d|~0.50", (q["ad"] > 0.45) & (q["ad"] < 0.55))):
g = q[(q["asset"] == a) & m]
if g.empty:
continue
tk = tick_of(g["ask"].to_numpy())
spread_tick = (g["ask"].to_numpy() - g["bid"].to_numpy()) / tk
ask_tick = g["ask"].to_numpy() / tk
out.append(dict(asset=a, banda=nome, n=len(g),
ask_tick_med=round(float(np.median(ask_tick)), 1),
ask_tick_p5=round(float(np.percentile(ask_tick, 5)), 1),
frac_ask_le2tick=round(float((ask_tick <= 2).mean()), 4),
spread_tick_med=round(float(np.median(spread_tick)), 1),
spread_su_mid_med=round(float(np.median(
(g["ask"] - g["bid"]) / g["mid"].replace(0, np.nan))), 3)))
return pd.DataFrame(out)
# =============================================================================
# 1. SUPERFICIE: smile -> RR/BF a maturita' costante
# =============================================================================
def _iv_at(ad: np.ndarray, iv: np.ndarray, tgt: float) -> float:
o = np.argsort(ad)
ad, iv = ad[o], iv[o]
if len(ad) < 2 or tgt < ad[0] or tgt > ad[-1]:
return np.nan # nessuna estrapolazione, mai
return float(np.interp(tgt, ad, iv))
def build_smile(q: pd.DataFrame) -> pd.DataFrame:
"""Per (asset, ora, scadenza): IV a |delta|=0.25 su entrambe le ali e ATM a |delta|=0.50."""
cache = SCRATCH / "r0822_smile.parquet"
if cache.exists():
try:
return pd.read_parquet(cache)
except Exception:
pass
rows = []
for (asset, hr, exp), g in q.groupby(["asset", "hr", "exp"], sort=False):
c = g[g["option_type"] == "C"]
p = g[g["option_type"] == "P"]
if len(c) < 2 or len(p) < 2:
continue
c25 = _iv_at(c["ad"].to_numpy(), c["iv"].to_numpy(), 0.25)
p25 = _iv_at(p["ad"].to_numpy(), p["iv"].to_numpy(), 0.25)
if not (np.isfinite(c25) and np.isfinite(p25)):
continue
c50 = _iv_at(c["ad"].to_numpy(), c["iv"].to_numpy(), 0.50)
p50 = _iv_at(p["ad"].to_numpy(), p["iv"].to_numpy(), 0.50)
atm = np.nanmean([x for x in (c50, p50) if np.isfinite(x)]) if (
np.isfinite(c50) or np.isfinite(p50)) else np.nan
rows.append((asset, hr, exp, float(g["dte"].iloc[0]), c25, p25, atm,
g["ts"].max(), int(len(g))))
R = pd.DataFrame(rows, columns=["asset", "hr", "exp", "dte", "ivc25", "ivp25",
"ivatm", "ts_max", "n_gambe"])
R["rr"] = R["ivc25"] - R["ivp25"]
R["bf"] = 0.5 * (R["ivc25"] + R["ivp25"]) - R["ivatm"]
try:
SCRATCH.mkdir(parents=True, exist_ok=True)
R.to_parquet(cache)
except Exception:
pass
return R
def const_maturity(R: pd.DataFrame, asset: str, T: float) -> pd.DataFrame:
"""RR/BF/ATM a maturita' costante T giorni. Interpolazione lineare in DTE fra le due scadenze
che ABBRACCIANO T (mai estrapolazione) -> il roll e' continuo, non c'e' data di roll."""
g = R[(R["asset"] == asset)].dropna(subset=["rr", "ivatm"])
out = {}
for hr, h in g.groupby("hr"):
h = h.sort_values("dte")
dt = h["dte"].to_numpy()
if len(h) < 2 or T < dt[0] or T > dt[-1]:
continue
out[hr] = dict(rr=float(np.interp(T, dt, h["rr"].to_numpy())),
bf=float(np.interp(T, dt, h["bf"].to_numpy())),
atm=float(np.interp(T, dt, h["ivatm"].to_numpy())),
ts_max=h["ts_max"].max())
S = pd.DataFrame.from_dict(out, orient="index").sort_index()
S.index.name = "hr"
if len(S):
S["rr_n"] = S["rr"] / S["atm"] # skew normalizzato dal livello (toglie il DVOL)
return S
# =============================================================================
# 2. Q1 — lo skew ANTICIPA lo spot?
# =============================================================================
def px5m(asset: str, t0: pd.Timestamp) -> pd.Series:
p = pd.read_parquet(ROOT / "data" / "raw" / f"{asset.lower()}_5m.parquet",
columns=["timestamp", "close"])
s = pd.Series(p["close"].to_numpy(float),
index=pd.DatetimeIndex(pd.to_datetime(p["timestamp"], unit="ms", utc=True)))
return s.sort_index().loc[t0 - pd.Timedelta("3h"):]
def leadlag(S: pd.DataFrame, asset: str) -> pd.DataFrame:
"""Correlazione fra la variazione oraria dello skew e i rendimenti PRIMA e DOPO l'istante
esatto in cui le quote sono state osservate (`ts_max`). L'ancora conta: con un'ancora
assunta a :30 invece del `ts_max` vero compare un falso lead a +15m."""
S = S.sort_values("ts_max")
px = px5m(asset, S.index.min())
anc = pd.Series(px.reindex(pd.DatetimeIndex(S["ts_max"]), method="ffill").to_numpy(),
index=S.index)
drr = S["rr"].diff()
lvl = S["rr"]
rows = []
for mins in (-60, -30, -15, -5, 5, 15, 30, 60, 120, 240, 1440, 4320):
tgt = pd.DatetimeIndex(S["ts_max"]) + pd.Timedelta(minutes=int(mins))
p2 = pd.Series(px.reindex(tgt, method="ffill").to_numpy(), index=S.index)
r = np.log(p2 / anc) if mins > 0 else np.log(anc / p2) # sempre "rendimento nel verso del tempo"
j = pd.concat({"d": drr, "l": lvl, "r": r}, axis=1).dropna()
n = len(j)
rows.append(dict(asset=asset, minuti=mins, n=n,
corr_dRR=round(float(j["d"].corr(j["r"])), 3),
corr_RR=round(float(j["l"].corr(j["r"])), 3),
se=round(1 / np.sqrt(max(n, 2)), 3),
t_dRR=round(float(j["d"].corr(j["r"]) * np.sqrt(n)), 2)))
return pd.DataFrame(rows)
def leadlag_ancora_sbagliata(S: pd.DataFrame, asset: str) -> pd.DataFrame:
"""Controllo POSITIVO del punto precedente: la stessa misura con l'ancora ASSUNTA a :30
dell'ora (invece del `ts_max` osservato) deve mostrare il falso lead. Se non lo mostra,
la correzione non stava correggendo niente."""
px = px5m(asset, S.index.min())
fake = pd.DatetimeIndex(S.index) + pd.Timedelta("30min")
anc = pd.Series(px.reindex(fake, method="ffill").to_numpy(), index=S.index)
drr = S["rr"].diff()
rows = []
for mins in (5, 15, 30, 60):
p2 = pd.Series(px.reindex(fake + pd.Timedelta(minutes=mins), method="ffill").to_numpy(),
index=S.index)
j = pd.concat({"d": drr, "r": np.log(p2 / anc)}, axis=1).dropna()
rows.append(dict(asset=asset, minuti=mins, corr_dRR_ancora_finta=round(float(j["d"].corr(j["r"])), 3)))
return pd.DataFrame(rows)
def daily_signal(S: pd.DataFrame, df: pd.DataFrame) -> pd.DataFrame:
"""Allinea la superficie alla griglia dei prezzi in modo CAUSALE.
Le barre del progetto sono open-labeled: la barra 1d etichettata D chiude alle 23:00 di D.
Quindi al momento della decisione della barra D e' disponibile l'ultima ora <= D 23:00, e
`eval_weights` la tiene durante D+1. Con merge_asof su epoca esplicita in millisecondi
(lezione 01/07: `.view("int64")` su tz-aware non-ns sbaglia scala e broadcasta)."""
bpd = A.bars_per_day(df)
chiusura = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
chiusura = chiusura + (pd.Timedelta("24h") - pd.Timedelta("1h") if bpd == 1 else pd.Timedelta(0))
left = pd.DataFrame({"t_ms": chiusura.astype("datetime64[ms]").astype("int64")})
right = pd.DataFrame({"t_ms": pd.DatetimeIndex(S["ts_max"]).astype("datetime64[ms]").astype("int64"),
"rr": S["rr"].to_numpy(), "rr_n": S["rr_n"].to_numpy(),
"bf": S["bf"].to_numpy(), "atm": S["atm"].to_numpy()}).sort_values("t_ms")
m = pd.merge_asof(left.sort_values("t_ms"), right, on="t_ms", direction="backward",
tolerance=int(pd.Timedelta("6h").total_seconds() * 1000))
return m[["rr", "rr_n", "bf", "atm"]]
def make_target(S: pd.DataFrame, feat: str, win: int, segno: int):
"""target_fn(df, asset) -> posizione continua vol-targeted. Causale per costruzione:
ogni feature usa solo z-score rolling su dati <= barra corrente, e `eval_weights` sposta."""
def fn(df, asset=None):
F = daily_signal(S, df)
x = F["rr_n"] if feat.endswith("_n") else F["rr"]
if feat.startswith("d"):
x = x.diff()
z = (x - x.rolling(win, min_periods=max(5, win // 2)).mean()) / \
x.rolling(win, min_periods=max(5, win // 2)).std()
d = np.tanh(np.nan_to_num(z.to_numpy(), nan=0.0)) * segno
return A.vol_target(d, df, target_vol=0.20, leverage_cap=2.0)
return fn
def sharpe_boot(s: pd.Series, blocco: int = 10, n: int = 500, seed: int = 20260822) -> tuple:
"""IC95% dello Sharpe con block bootstrap circolare (il segnale e' lentissimo: i giorni
NON sono indipendenti, e una SE analitica mentirebbe)."""
r = np.asarray(s.dropna().to_numpy(), float)
T = len(r)
if T < 20:
return (np.nan, np.nan)
rng = np.random.default_rng(seed)
nb = int(np.ceil(T / blocco))
out = []
for _ in range(n):
st = rng.integers(0, T, nb)
idx = (st[:, None] + np.arange(blocco)[None, :]).ravel()[:T] % T
x = r[idx]
out.append(np.mean(x) / np.std(x) * np.sqrt(365.25) if np.std(x) > 0 else 0.0)
return (round(float(np.percentile(out, 2.5)), 2), round(float(np.percentile(out, 97.5)), 2))
def causality_finestra(S: pd.DataFrame, fn, asset: str) -> dict:
"""`A.causality_ok` taglia il prefisso all'80%/92% della storia dei PREZZI (2024/2025):
fuori dalla finestra dello skew, dove il segnale e' zero -> confronterebbe zeri con zeri,
POTENZA NULLA (stessa trappola del self-check di SKH01: si campiona sugli EVENTI).
Qui i tagli cadono DENTRO la finestra."""
df = A.get(asset, "1d")
full = np.nan_to_num(np.asarray(fn(df, asset), float))
n = len(df)
dts = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
dentro = np.where(dts >= S.index.min())[0]
peggio, controlli = 0.0, 0
for frac in (0.4, 0.7, 0.9):
cut = int(dentro[0] + frac * (n - dentro[0]))
if cut <= dentro[0] + 5 or cut >= n:
continue
sub = df.iloc[:cut].reset_index(drop=True)
s = np.nan_to_num(np.asarray(fn(sub, asset), float))
coda = min(20, cut - dentro[0])
peggio = max(peggio, float(np.max(np.abs(s[cut - coda:cut] - full[cut - coda:cut]))))
controlli += 1
return dict(ok=bool(peggio <= 1e-6 and controlli > 0), max_diff=round(peggio, 10),
controlli=controlli, non_zero=int((full[dentro] != 0).sum()))
# =============================================================================
# 3. Q2 — lo skew come GATE DI RISCHIO sopra TP01
# =============================================================================
def dd_of(r: np.ndarray) -> float:
eq = np.cumprod(1.0 + np.nan_to_num(r))
pk = np.maximum.accumulate(eq)
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
def sh_of(r: np.ndarray) -> float:
r = np.asarray(r, float)
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if np.std(r) > 0 else 0.0
def null_de_levering(base: np.ndarray, overlay: np.ndarray) -> dict:
"""Il primo test di OGNI claim su meno drawdown: esiste k<=1 che, applicato al baseline,
da' lo STESSO DD dell'overlay ma con Sharpe MIGLIORE? Se si', l'overlay e' solo meno leva."""
dd_t = dd_of(overlay)
lo, hi = 0.0, 1.0
for _ in range(60):
k = 0.5 * (lo + hi)
if dd_of(k * base) > dd_t:
hi = k
else:
lo = k
k = 0.5 * (lo + hi)
return dict(k=round(k, 3), dd_overlay=round(dd_t, 4), dd_base_k=round(dd_of(k * base), 4),
sh_overlay=round(sh_of(overlay), 3), sh_base_k=round(sh_of(k * base), 3),
superato=bool(sh_of(overlay) > sh_of(k * base)))
# =============================================================================
# 4. Q3 — lo skew spiega l'errore di prezzo di VRP01?
# =============================================================================
def vrp_decomposizione(q: pd.DataFrame, cm7: dict) -> pd.DataFrame:
"""Per ogni ingresso settimanale di VRP01 (stessa macchina di `cblib`, cosi' i numeri sono
confrontabili con lo 0.718 pubblicato il 30/07), scompone il f in fattori MOLTIPLICATIVI:
f_tot = f_term x f_skew x f_markfit x f_spread
f_term = credito prezzato ad ATM 7g piatta / credito prezzato a DVOL30 piatta
(quanto del difetto e' STRUTTURA A TERMINE: il sleeve prezza un'opzione a 7
giorni con l'indice a 30 giorni)
f_skew = credito prezzato a IV PER GAMBA / credito ad ATM 7g piatta
(quanto e' SKEW: l'ala che si compra non vale la vol ATM)
f_markfit = credito ai MID reali / credito a IV per gamba (controllo: deve stare a ~1,
altrimenti la IV di Deribit non riprezza i propri mid e tutta la catena di
ragionamento poggia su un modello invece che su un prezzo)
f_spread = credito al fill CONSERVATIVO (vendi al bid, compri all'ask) / credito ai mid
"""
righe = []
for a in ASSETS:
S7 = cm7[a]
E = CB.weekly_entries(a, q)
for _, e in E.iterrows():
prima = S7.index[S7.index <= e["ts"]]
if len(prima) == 0 or (e["ts"] - prima[-1]) > pd.Timedelta("6h"):
continue
s = S7.loc[prima[-1]]
T = e["dte"] / 365.25
dvol = e["dvol_pct"] / 100.0
atm7 = s["atm"] / 100.0
c_mod = CB.bs_put(e["spot"], e["k_short"], T, dvol) - CB.bs_put(e["spot"], e["k_long"], T, dvol)
c_atm = CB.bs_put(e["spot"], e["k_short"], T, atm7) - CB.bs_put(e["spot"], e["k_long"], T, atm7)
c_sml = (CB.bs_put(e["spot"], e["k_short"], T, e["iv_short"] / 100.0)
- CB.bs_put(e["spot"], e["k_long"], T, e["iv_long"] / 100.0))
c_mid = e["cred_mid"]
c_real = e["cred_real"]
righe.append(dict(
asset=a, ts=e["ts"], dte=round(e["dte"], 2), ivrank=round(e["ivrank"], 3),
dvol=round(e["dvol_pct"], 2), atm7=round(s["atm"], 2),
rr7=round(s["rr"], 2), bf7=round(s["bf"], 2),
iv_short=round(e["iv_short"], 2), iv_long=round(e["iv_long"], 2),
d_short=round(e["d_short"], 3), d_long=round(e["d_long"], 3),
f_term=c_atm / c_mod if c_mod > 0 else np.nan,
f_skew=c_sml / c_atm if c_atm > 0 else np.nan,
f_markfit=c_mid / c_sml if c_sml > 0 else np.nan,
f_spread=c_real / c_mid if c_mid > 0 else np.nan,
f_tot_mid=c_mid / c_mod if c_mod > 0 else np.nan,
f_tot=c_real / c_mod if c_mod > 0 else np.nan,
quota_ala=CB.bs_put(e["spot"], e["k_long"], T, dvol) /
CB.bs_put(e["spot"], e["k_short"], T, dvol),
))
return pd.DataFrame(righe)
# =============================================================================
def main() -> None:
t_start = time.time()
rule("r0822_skew — SKEW (risk reversal 25-delta): la dimensione mai usata della superficie")
# ---------------------------------------------------------------- 0. DATO
rule("0. IL DATO — quote, non righe")
q, cont = load_quoted()
print(f" righe totali nella catena : {cont['righe']:,}")
print(f" dopo dedup (ts, strumento) : {cont['dedup']:,}")
print(f" QUOTATE (bid>0, ask>0, iv/delta ok) : {cont['quotate']:,} = {cont['frac_quotate']:.1%}")
print(f" quote_status : {cont['quote_status']}")
print(f" usate (0.5<DTE<95, ultimo giro/ora) : {len(q):,}")
print(f" finestra righe : {q['ts'].min()} -> {q['ts'].max()}")
print("\n TICK (regola: prima di credere a un rapporto estremo su un prezzo piccolo, contarli)")
print(conta_tick(q).to_string(index=False))
# ---------------------------------------------------------- 1. SUPERFICIE
rule("1. SUPERFICIE — smile per delta, poi maturita' costante")
t0 = time.time()
R = build_smile(q)
print(f" celle (asset, ora, scadenza) con smile ricostruibile: {len(R):,} [{time.time()-t0:.0f}s]")
perday = R.groupby(["asset", pd.DatetimeIndex(R["hr"]).date]).agg(exps=("exp", "nunique"))
print(f" giorni con >=2 scadenze (maturita' costante possibile): "
f"{int((perday['exps'] >= 2).sum())} / {len(perday)} coppie asset-giorno")
cm7, cm30 = {}, {}
for a in ASSETS:
cm7[a] = const_maturity(R, a, 7.0)
cm30[a] = const_maturity(R, a, 30.0)
s = cm30[a]
print(f" {a}: CM30 ore={len(s):5d} giorni={len(np.unique(pd.DatetimeIndex(s.index).date)):3d}"
f" {s.index.min()} -> {s.index.max()}")
print(f" RR30 media {s['rr'].mean():+.2f} pp sd {s['rr'].std():.2f} "
f"[{s['rr'].min():+.2f}, {s['rr'].max():+.2f}] ATM media {s['atm'].mean():.1f}% "
f"BF30 media {s['bf'].mean():+.2f}")
print(f" CM7 ore={len(cm7[a]):5d} RR7 media {cm7[a]['rr'].mean():+.2f} sd {cm7[a]['rr'].std():.2f}")
print("\n ⚠ La finestra utile parte dal 2026-06-09 e NON dal 2026-05-01: prima di quella data")
print(" cerbero-bite raccoglieva UNA sola scadenza per ora -> nessuna maturita' costante.")
print(" righe presenti != superficie presente. Le ore perse sono ~38 giorni su 113.")
# ------------------------------------------------------------------ 2. Q1
rule("2. Q1 — lo skew ANTICIPA lo spot? (potenza vera: N orario ~1.800)")
LL = pd.concat([leadlag(cm30[a], a) for a in ASSETS], ignore_index=True)
for a in ASSETS:
print(f"\n {a} — corr fra dRR (variazione oraria dello skew) e il rendimento nella")
print(" finestra indicata, ancorata al TIMESTAMP VERO delle quote (ts_max):")
print(LL[LL["asset"] == a].to_string(index=False))
print("\n Controllo POSITIVO — la stessa misura con l'ancora ASSUNTA a :30 dell'ora:")
FF = pd.concat([leadlag_ancora_sbagliata(cm30[a], a) for a in ASSETS], ignore_index=True)
print(FF.to_string(index=False))
# strategia giornaliera
rule("2b. Q1 — lo skew come SEGNALE DIREZIONALE giornaliero (griglia dichiarata)")
feats = ["rr", "rr_n", "drr"]
wins = [10, 20, 40]
segni = [+1, -1]
celle, serie = [], {}
for f in feats:
for w in wins:
for sg in segni:
nome = f"{f}|w{w}|sg{sg:+d}"
per_asset = {}
for a in ASSETS:
df = A.get(a, "1d")
Sx = cm30[a]
m = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) >= (
Sx.index.min().floor("D"))
sub = df[m].reset_index(drop=True)
tgt = make_target(Sx, f, w, sg)(sub, a)
ev = A.eval_weights(sub, tgt)
per_asset[a] = pd.Series(ev["net"], index=ev["idx"])
J = pd.concat(per_asset, axis=1, join="inner").fillna(0.0)
d = 0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]
d.index = pd.DatetimeIndex(d.index)
serie[nome] = d
celle.append(dict(cella=nome, sharpe=round(sh_of(d.to_numpy()), 3),
maxdd=round(dd_of(d.to_numpy()), 4),
cagr=round(float((1 + d).prod() ** (365.25 / len(d)) - 1), 4)))
C = pd.DataFrame(celle).sort_values("sharpe", ascending=False)
print(f" griglia: {len(feats)} feature x {len(wins)} finestre-z x {len(segni)} segni = {len(C)} celle")
print(C.head(6).to_string(index=False))
print(" ...")
print(C.tail(3).to_string(index=False))
best = C.iloc[0]["cella"]
bs = serie[best]
lo, hi = sharpe_boot(bs)
print(f"\n MIGLIORE della griglia: {best} Sharpe {C.iloc[0]['sharpe']:+.3f}")
print(f" IC95% block-bootstrap (blocchi 10g, 500 estrazioni): [{lo:+.2f}, {hi:+.2f}] n={len(bs)} giorni")
print(f" ⚠ ampiezza dell'IC = {hi-lo:.2f} di Sharpe: con {len(bs)} giorni la misura non separa")
print(" un edge da zero. Nessuna cella di questa griglia e' distinguibile dal rumore.")
dsr, sr0 = A.deflated_sharpe(C.iloc[0]["sharpe"], C["sharpe"].tolist(), bs)
print(f" deflated Sharpe della cella migliore: DSR={dsr:.3f} (soglia 0.95), "
f"max atteso sotto il nullo {sr0:+.2f}")
print(" NB: e' un deflated-Sharpe BEST-OF-GRID, non in-sample-selected: `select_cell_insample`")
print(" NON e' eseguibile qui (HOLDOUT=2025-01-01, l'in-sample e' VUOTO).")
print("\n marginal_vs_tp01 sulla cella migliore:")
try:
rep = A.marginal_vs_tp01(bs)
for k in ("marginal_verdict", "corr_full", "n_days", "has_insample_edge", "is_hedge",
"beats_noise_null", "robust_oos"):
if k in rep:
print(f" {k:20s} = {rep[k]}")
except Exception as ex:
print(f" non girato: {ex}")
print("\n causalita' (tagli DENTRO la finestra dello skew, non all'80% della storia prezzi):")
for a in ASSETS:
f_, w_, sg_ = best.split("|")
fn = make_target(cm30[a], f_, int(w_[1:]), int(sg_.replace("sg", "")))
print(f" {a}: {causality_finestra(cm30[a], fn, a)}")
# ------------------------------------------------------------------ 3. Q2
rule("3. Q2 — lo skew come GATE DI RISCHIO sopra TP01")
B = A.tp01_baseline_daily()
inizio = min(cm30[a].index.min() for a in ASSETS).floor("D")
Bw = B[B.index >= inizio]
z_all = {}
for a in ASSETS:
df = A.get(a, "1d")
m = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) >= inizio
sub = df[m].reset_index(drop=True)
F = daily_signal(cm30[a], sub)
x = F["rr_n"]
z = (x - x.rolling(20, min_periods=10).mean()) / x.rolling(20, min_periods=10).std()
z_all[a] = pd.Series(z.to_numpy(), index=pd.DatetimeIndex(pd.to_datetime(sub["datetime"], utc=True)))
Z = pd.concat(z_all, axis=1).mean(axis=1)
Z = Z.reindex(Bw.index).ffill()
print(f" baseline TP01 sulla finestra dello skew: n={len(Bw)} giorni, "
f"Sharpe {sh_of(Bw.to_numpy()):+.2f}, maxDD {dd_of(Bw.to_numpy()):.2%}")
for thr in (-0.5, -1.0, -1.5):
gate = (Z.shift(1) > thr).astype(float) # de-risk quando lo skew si irripidisce (z basso)
ov = (Bw * gate).to_numpy()
n_fire = int((gate == 0).sum())
nul = null_de_levering(Bw.to_numpy(), ov)
gg = Bw[gate == 0]
print(f" soglia z<{thr:+.1f}: giorni de-riskati {n_fire:3d}/{len(Bw)} "
f"({n_fire/len(Bw):.1%}) Sharpe {sh_of(ov):+.2f} (base {sh_of(Bw.to_numpy()):+.2f}) "
f"maxDD {dd_of(ov):.2%}")
print(f" null de-levering: k={nul['k']} -> stesso DD {nul['dd_base_k']:.2%} con "
f"Sharpe {nul['sh_base_k']:+.2f} vs overlay {nul['sh_overlay']:+.2f} "
f"-> {'SUPERATO' if nul['superato'] else 'REFUTED'}")
if n_fire:
print(f" nei giorni de-riskati TP01 faceva in media {gg.mean()*100:+.3f}%/g "
f"(gli altri {Bw[gate==1].mean()*100:+.3f}%/g)")
print("\n ridondanza col trend — corr fra lo z dello skew e il rendimento di TP01: "
f"{float(pd.concat({'z': Z, 'b': Bw}, axis=1).dropna().corr().iloc[0,1]):+.3f}")
print(" POTENZA: con 75 giorni un gate che scatta ~10 volte non e' misurabile. Il numero da")
print(" guardare non e' lo Sharpe dell'overlay ma il conto dei giorni in cui scatta.")
# ------------------------------------------------------------------ 4. Q3
rule("4. Q3 — lo skew SPIEGA l'errore di prezzo di VRP01 (f=0.73 pubblicato il 30/07)")
D = vrp_decomposizione(q, cm7)
print(f" ingressi settimanali ricostruiti: {len(D)} ({D.groupby('asset').size().to_dict()})")
print("\n REPLICA del numero pubblicato (deve tornare ~0.72 sul canonico 7g d-0.28/-0.10):")
print(f" f_tot (fill conservativo) mediana = {D['f_tot'].median():.3f} "
f"media = {D['f_tot'].mean():.3f}")
print(f" f_tot ai MID mediana = {D['f_tot_mid'].median():.3f}")
print("\n IV per gamba contro i due riferimenti (pp di vol):")
print(f" IV(corta) - DVOL30 : {(D['iv_short']-D['dvol']).median():+.2f} pp "
f"IV(lunga) - DVOL30 : {(D['iv_long']-D['dvol']).median():+.2f} pp [pubblicato: +0.8 / +7.5]")
print(f" IV(corta) - ATM7 : {(D['iv_short']-D['atm7']).median():+.2f} pp "
f"IV(lunga) - ATM7 : {(D['iv_long']-D['atm7']).median():+.2f} pp")
print(f" ATM7 - DVOL30 : {(D['atm7']-D['dvol']).median():+.2f} pp <- pura struttura a termine")
print("\n DECOMPOSIZIONE MOLTIPLICATIVA del f (mediane; f_tot = term x skew x markfit x spread)")
for a in ASSETS + ("TUTTI",):
g = D if a == "TUTTI" else D[D["asset"] == a]
if g.empty:
continue
print(f" {a:6s} n={len(g):2d} | f_term {g['f_term'].median():.3f} | "
f"f_skew {g['f_skew'].median():.3f} | f_markfit {g['f_markfit'].median():.3f} | "
f"f_spread {g['f_spread'].median():.3f} || f_tot {g['f_tot'].median():.3f}")
print("\n controllo obbligatorio: f_markfit deve stare a ~1 (la IV di Deribit deve riprezzare")
print(f" i suoi stessi mid). Mediana {D['f_markfit'].median():.3f}, "
f"banda [{D['f_markfit'].quantile(.1):.3f}, {D['f_markfit'].quantile(.9):.3f}].")
print(" Se sta a 1, RR e BF di questa sessione sono un fatto di PREZZO, non un artefatto")
print(" del fit di Deribit -> vale anche come controllo di confound per Q1/Q2.")
print("\n Il f dipende dal REGIME? (la domanda che decide se 0.73 e' conservativo o ottimista)")
D2 = D.dropna(subset=["f_tot_mid", "rr7"])
for var in ("rr7", "atm7", "ivrank", "bf7"):
c = float(D2["f_tot_mid"].corr(D2[var]))
print(f" corr(f_tot_mid, {var:7s}) = {c:+.3f} [n={len(D2)}]")
print(f"\n IV-rank agli ingressi: min {D['ivrank'].min():.3f} mediana {D['ivrank'].median():.3f} "
f"max {D['ivrank'].max():.3f}")
n_gate = int((D["ivrank"] > 0.30).sum())
print(f" ingressi che passerebbero il gate IV-rank>0.30 di VRP01: {n_gate}/{len(D)}")
print(" -> il f di VRP01 e' misurato INTERAMENTE nel regime in cui il sleeve sta FLAT.")
print("\n Tabella per ingresso:")
cols = ["asset", "ts", "dte", "ivrank", "dvol", "atm7", "rr7", "iv_short", "iv_long",
"f_term", "f_skew", "f_markfit", "f_spread", "f_tot_mid", "f_tot"]
P = D[cols].copy()
P["ts"] = pd.DatetimeIndex(P["ts"]).strftime("%Y-%m-%d %H:%M")
for c in ("f_term", "f_skew", "f_markfit", "f_spread", "f_tot_mid", "f_tot"):
P[c] = P[c].round(3)
print(P.to_string(index=False))
rule(f"fine — {time.time()-t_start:.0f}s")
if __name__ == "__main__":
main()
+721
View File
@@ -0,0 +1,721 @@
"""TERM-STRUCTURE della vol implicita BTC/ETH — pendenza, carry, gate di rischio (ondata 2026-08-22).
DOMANDA. Tre, dichiarate prima di misurare:
Q1 la PENDENZA (backwardation = front > back) PRECEDE i ritorni, o e' contemporanea?
Q2 il CARRY di vol (roll-down lungo la curva) e' INCASSABILE al bid-ask reale?
Q3 vale come GATE DI RISCHIO sopra TP01/SKH01?
IPOTESI A PRIORI (registrate prima di guardare i numeri servono a poterle smentire):
Q1 contemporanea. Il front reagisce allo spot: un tuffo alza la vol a 7g piu' di quella a 30g.
Se e' cosi', la pendenza e' un TERMOMETRO, non un segnale.
Q2 no. Il roll-down fra 7g e 30g vale frazioni di punto-vol al giorno; il bid-ask ATM di
Deribit vale punti-vol INTERI. L'aritmetica dovrebbe chiudere la questione senza backtest.
Q3 ridondante col trend, come i 4 precedenti (DVOL-spike, macro, funding, breadth): un gate di
de-risk lavora nei giorni in cui TP01 e' gia' flat.
IL PRECEDENTE SCOMODO (03/07, `r0703_vrpimp_*`): un gate di term-structure VIX/VXV su SPX valeva
+0.90 di Sharpe (DSR 0.992) ed era un **confound di modello al 100%** la variabile del gate
coincideva con l'errore di prezzatura BS-flat vs term-structure. Da li' la regola: *riprezzare
term-structure-consistent prima di credere a un gate vol su strutture BS-flat*.
QUI QUEL CONFOUND NON PUO' ESISTERE: non si prezza nessuna opzione. Il sottostante e' il perp
BTC/ETH, i ritorni sono quelli del feed certificato, e la term structure entra solo come
VARIABILE OSSERVATA. Quindi il 03/07 non e' una scusa per non guardare — ed e' esattamente
percio' che serve cercare il confound *di questo* filone, che e' un altro:
IL CONFOUND DI QUESTO FILONE: iv(7g) e' in larga parte una funzione della vol REALIZZATA
recente. Se e' cosi', `slope = iv30 - iv7` non e' un'informazione nuova ma un
RICONFEZIONAMENTO di RV cioe' la stessa variabile di `dvol_directional.py` (VRP-Z), gia'
giudicata **HEDGE, earns_slot=False** su 5 anni il 2026-06-29. Misurato in SEZIONE 2.
IL DATO, e il muro che ci sta dentro. La term structure richiede >=2 scadenze nello STESSO
istante. `bite_archive` prima del **2026-06-09** collezionava **UNA scadenza per giro**: la
finestra utilizzabile NON e' "3,7 mesi di catena", e' **dal 2026-06-09** (~74 giorni).
Con 74 giorni **non esiste un hold-out** e SE(Sharpe) ~ sqrt(365/74) ~ 2.2: qualunque Sharpe
misurato qui e' a una deviazione standard da zero. Verdetto massimo: LEAD con gate e data.
Si conta cio' che e' QUOTATO (bid>0, ask>0, iv presente), non le righe: il guasto 29-30/07 ha
fino al 51% di quote vuote in un giorno.
METODO. ATM IV per scadenza = interpolazione della IV sul DELTA a 0.5 (call) / -0.5 (put), mai
estrapolata. Il delta referenzia il FORWARD, quindi la curva non eredita lo skew via la base
(prendere "lo strike piu' vicino allo spot" lo farebbe: a 90g il forward e' lontano dallo spot).
Interpolazione ai tenori fissi in VARIANZA TOTALE (w = iv^2 * T lineare in T), stile VIX.
nice -n 19 timeout 900 uv run python scripts/research/r0822_term_structure.py
"""
from __future__ import annotations
import os
import sys
import tempfile
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" / "alt"))
import altlib as A # noqa: E402
RAW = ROOT / "data" / "raw"
STORE = RAW / "cb_chain"
CACHE = Path(os.environ.get("TMPDIR", tempfile.gettempdir())) / "r0822_ts_cache"
ASSETS = ("BTC", "ETH")
# Muro del dato: prima di questa data l'archivio ha UNA scadenza per giro -> nessuna curva.
TS_START = pd.Timestamp("2026-06-09", tz="UTC")
TENORS = (7, 14, 30, 60, 90)
FRONT, BACK = 7, 30 # la pendenza canonica di questo studio
DTE_MIN = 0.5 # sotto mezza giornata l'ATM IV e' rumore di microstruttura
# Vincoli di venue DICHIARATI (misurati il 2026-07-30 su `get_instrument`, non ri-letti qui:
# questo script non tocca la rete). Servono alla sezione di eseguibilita'.
MIN_LOT = {"BTC": 0.1, "ETH": 1.0} # contratti minimi opzioni Deribit
OPT_FEE_UNDERLYING = 0.0003 # 0,03% del sottostante per gamba
OPT_FEE_CAP_PREMIUM = 0.125 # cap: 12,5% del premio della singola opzione
CAPITALE = 635.0 # il conto vero
# Conteggio dei trial, AL RIALZO (sezione 6). Ogni voce e' una configurazione VALUTATA.
TRIALS: list[tuple[str, int]] = []
def hr(t: str = "") -> None:
print("\n" + "=" * 100)
if t:
print(t)
print("=" * 100)
def sub(t: str) -> None:
print(f"\n--- {t} " + "-" * max(0, 96 - len(t)))
# ===========================================================================================
# SEZIONE 0 — IL DATO
# ===========================================================================================
def load_quoted_chain() -> pd.DataFrame:
"""Catena filtrata IN LETTURA (colonne + finestra), tenendo solo cio' che e' QUOTATO.
"Quotato" = bid>0 AND ask>0 AND iv presente AND delta presente. Una riga con bid/ask NULL
esiste (il collettore la persiste) ma non e' un prezzo: contarla come dato e' l'errore che
il progetto ha gia' fatto tre volte (paper_dvolspread, fresh_5m, guasto 29/07).
"""
cols = ["asset", "option_type", "strike", "iv", "delta", "bid", "ask",
"vega", "ts", "exp", "quote_status"]
parts = []
arch = STORE / "bite_archive.parquet"
if arch.exists():
d = pd.read_parquet(arch, columns=cols)
parts.append(d[d["ts"] >= TS_START])
del d
for p in sorted(STORE.glob("2026-*.parquet")):
parts.append(pd.read_parquet(p, columns=cols))
if not parts:
raise FileNotFoundError(f"{STORE}: nessuna catena")
df = pd.concat(parts, ignore_index=True)
del parts
df = df.drop_duplicates(subset=["ts", "asset", "strike", "option_type", "exp"], keep="last")
df["hr"] = df["ts"].dt.floor("h")
df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0
df["quoted"] = (df["bid"] > 0) & (df["ask"] > 0) & df["iv"].notna() & df["delta"].notna()
return df
def sezione0(chain: pd.DataFrame) -> None:
hr("SEZIONE 0 — IL DATO: quanto ce n'e' davvero, e da quando")
print(f" finestra letta : {chain.ts.min()} -> {chain.ts.max()}")
print(f" righe : {len(chain):,}")
print(f" righe QUOTATE : {int(chain.quoted.sum()):,} ({chain.quoted.mean():.1%})")
print("\n ⚠️ IL MURO: la term structure richiede >=2 scadenze nello STESSO istante.")
arch = STORE / "bite_archive.parquet"
if arch.exists():
pre = pd.read_parquet(arch, columns=["asset", "ts", "exp"])
pre["hr"] = pre["ts"].dt.floor("h")
n_pre = pre[pre.ts < TS_START].groupby(["asset", "hr"])["exp"].nunique()
n_post = pre[pre.ts >= TS_START].groupby(["asset", "hr"])["exp"].nunique()
print(f" archivio PRIMA del {TS_START.date()}: scadenze/ora mediana = "
f"{n_pre.median():.0f} (max {n_pre.max():.0f}) su {len(n_pre)} ore-asset")
print(f" archivio DOPO : mediana = {n_post.median():.0f} "
f"(max {n_post.max():.0f}) su {len(n_post)} ore-asset")
print(" => la catena copre dal 2026-05-01, ma la CURVA esiste solo dal 2026-06-09.")
print(" Non sono 3,7 mesi: sono ~74 giorni. Nessun hold-out e' costruibile.")
del pre
q = chain.groupby([chain.hr.dt.date, "asset"])["quoted"].mean().unstack()
bad = q[(q < 0.70).any(axis=1)]
print(f"\n giorni con quote quotate <70% su almeno un asset: {len(bad)}")
if len(bad):
print(bad.round(3).to_string().replace("\n", "\n "))
print(" (guasto noto 29-30/07: rate-limit per-IP saturato dal collettore di bite.)")
print(f"\n minuto dello snapshot (mediana): archivio bite = "
f"{chain[chain.ts < pd.Timestamp('2026-07-30', tz='UTC')].ts.dt.minute.median():.0f}', "
f"raccolta propria = {chain[chain.ts >= pd.Timestamp('2026-07-31', tz='UTC')].ts.dt.minute.median():.0f}'")
print(" -> lo snapshot dell'ora t cade DENTRO la barra oraria t. Ogni ritorno usato come")
print(" bersaglio parte dalla CHIUSURA della barra t (>=35 min dopo): buffer causale.")
# ===========================================================================================
# SEZIONE 1 — RICOSTRUZIONE DELLA CURVA + CERTIFICAZIONE
# ===========================================================================================
def _atm_iv(g: pd.DataFrame) -> float:
"""ATM IV di UNO snapshot+scadenza: interpolazione sul DELTA, mai estrapolazione."""
vals = []
for typ, tgt in (("C", 0.5), ("P", -0.5)):
s = g[g.option_type == typ]
if len(s) >= 2:
d = s["delta"].to_numpy()
if d.min() <= tgt <= d.max():
o = np.argsort(d)
vals.append(float(np.interp(tgt, d[o], s["iv"].to_numpy()[o])))
return float(np.mean(vals)) if vals else np.nan
def build_atm_by_expiry(chain: pd.DataFrame) -> pd.DataFrame:
f = CACHE / "atm_by_expiry.parquet"
if f.exists():
return pd.read_parquet(f)
q = chain[chain.quoted & (chain.dte > DTE_MIN)]
out = (q.groupby(["asset", "hr", "exp"])
.apply(lambda g: pd.Series({"iv": _atm_iv(g), "dte": g["dte"].median()}),
include_groups=False)
.reset_index().dropna(subset=["iv"]))
CACHE.mkdir(parents=True, exist_ok=True)
out.to_parquet(f)
return out
def _curve_row(g: pd.DataFrame) -> pd.Series:
g = g.sort_values("dte")
t = g["dte"].to_numpy()
w = (g["iv"].to_numpy() / 100.0) ** 2 * t # varianza totale
o = {}
for T in TENORS:
o[f"iv_{T}"] = (np.sqrt(np.interp(T, t, w) / T) * 100.0
if len(t) >= 2 and t.min() <= T <= t.max() else np.nan)
o["n_exp"] = len(t)
return pd.Series(o)
def build_curve(atm: pd.DataFrame) -> pd.DataFrame:
f = CACHE / "curve.parquet"
if f.exists():
return pd.read_parquet(f)
c = atm.groupby(["asset", "hr"]).apply(_curve_row, include_groups=False).reset_index()
CACHE.mkdir(parents=True, exist_ok=True)
c.to_parquet(f)
return c
def sezione1(curve: pd.DataFrame) -> dict:
hr("SEZIONE 1 — RICOSTRUZIONE DELLA CURVA E SUA CERTIFICAZIONE")
print(" ATM = IV interpolata sul delta 0.5/-0.5 (mai estrapolata); tenori fissi in varianza totale.")
cov = curve[[f"iv_{T}" for T in TENORS]].notna().mean()
print("\n copertura per tenore (frazione delle ore-asset ricostruibili):")
for T in TENORS:
print(f" iv_{T:<3d} {cov[f'iv_{T}']:.1%}")
print(f" -> iv_90 e' sotto il 10%: SCARTATO dallo studio. La pendenza canonica e' "
f"iv_{BACK} - iv_{FRONT}.")
sub("controllo 1 — contro il logger indipendente `vol_term_*.parquet` (altro codice, altra fonte)")
print(" (`log_vol_termstructure.py`: mark_iv da book_summary, ATM = strike piu' vicino allo")
print(" spot, interpolazione diversa. Se le due strade coincidono, la mia non e' un artefatto.)")
agree = {}
for a in ASSETS:
p = RAW / f"vol_term_{a.lower()}.parquet"
if not p.exists():
print(f" {a}: {p.name} assente — controllo NON GIRATO")
continue
vt = pd.read_parquet(p).set_index("date")
mine = curve[curve.asset == a].set_index("hr")
j = vt.join(mine[[f"iv_{T}" for T in TENORS]], how="inner")
for T in (7, 30, 60):
x, y = j[f"iv_{T}d"], j[f"iv_{T}"]
m = x.notna() & y.notna()
if m.sum() >= 10:
print(f" {a} iv_{T:<3d} n={m.sum():3d} corr={x[m].corr(y[m]):+.4f} "
f"scarto medio={(y[m] - x[m]).mean():+.3f} pt-vol")
agree[(a, T)] = float(x[m].corr(y[m]))
sub("controllo 2 — contro il DVOL (indice Deribit a 30g, storia 2021+)")
for a in ASSETS:
dv = pd.read_parquet(RAW / f"dvol_{a.lower()}.parquet")
s = pd.Series(dv["close"].astype(float).values,
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)).sort_index()
mine = curve[curve.asset == a].set_index("hr")["iv_30"].dropna()
j = pd.DataFrame({"mine": mine})
j["dvol"] = s.reindex(j.index, method="ffill")
j = j.dropna()
print(f" {a}: n={len(j)} corr={j.mine.corr(j.dvol):+.4f} "
f"bias={(j.mine - j.dvol).mean():+.3f} pt-vol sd={(j.mine - j.dvol).std():.3f}")
print(" Il bias NEGATIVO e' atteso e conferma la ricostruzione: il DVOL e' un indice tipo")
print(" variance-swap (integra tutto lo smile) e sta STRUTTURALMENTE sopra l'ATM per lo skew.")
print(" Un bias ~0 sarebbe stato il segnale d'allarme, non questo.")
return agree
# ===========================================================================================
# SEZIONE 2 — Q1: LEAD-LAG. La pendenza precede o segue?
# ===========================================================================================
def slope_hourly(curve: pd.DataFrame, a: str) -> pd.Series:
s = curve[curve.asset == a].set_index("hr")
return (s[f"iv_{BACK}"] - s[f"iv_{FRONT}"]).dropna().sort_index()
def px_hourly(a: str) -> pd.Series:
d = A.get(a, "1h")
return pd.Series(d["close"].astype(float).values,
index=pd.DatetimeIndex(pd.to_datetime(d["datetime"], utc=True)))
def _block_pctl(x: np.ndarray, y: np.ndarray, stat: float, L: int, n: int = 800,
seed: int = 20260822) -> float:
"""Percentile della correlazione osservata contro il null a BLOCCHI (y ricampionata da
blocchi non allineati a x): conserva l'autocorrelazione e rompe solo l'accoppiamento."""
rng = np.random.default_rng(seed)
m = len(x)
if m <= L + 5:
return float("nan")
nb = max(1, m // L)
out = np.empty(n)
for i in range(n):
ii = np.concatenate([np.arange(s, s + L) for s in rng.integers(0, m - L, nb)])
jj = np.concatenate([np.arange(s, s + L) for s in rng.integers(0, m - L, nb)])
out[i] = np.corrcoef(x[ii], y[jj])[0, 1]
return float((out < stat).mean())
def sezione2(curve: pd.DataFrame) -> dict:
hr("SEZIONE 2 — Q1: la pendenza PRECEDE i ritorni o li SEGUE?")
res = {}
for a in ASSETS:
sl = slope_hourly(curve, a)
px = px_hourly(a)
lp = np.log(px)
j = pd.DataFrame({"slope": sl}).join(px.rename("px"), how="inner").dropna()
idx = j.index
r1 = lp.diff()
sub(f"{a} — n ore = {len(j)} ({idx.min()} -> {idx.max()})")
print(f" pendenza iv_{BACK}-iv_{FRONT}: media {j.slope.mean():+.2f} pt-vol, "
f"sd {j.slope.std():.2f}, backwardation nel {100*(j.slope<0).mean():.1f}% delle ore")
print("\n (a) CROSS-CORRELAZIONE fra la VARIAZIONE oraria di pendenza e il ritorno orario.")
print(" lag<0 = ritorno PRIMA della variazione (il prezzo guida) | lag>0 = DOPO (la")
print(" pendenza guiderebbe). La barra t va da t a t+1h e lo snapshot cade dentro:")
print(" il lag -1 e' quindi in gran parte SOVRAPPOSTO, non un vero anticipo.")
ds = j.slope.diff()
row = []
for k in (-3, -2, -1, 0, 1, 2, 3, 6, 12, 24):
row.append((k, float(ds.corr(r1.shift(-k).reindex(idx)))))
print(" " + " ".join(f"{k:+d}h:{v:+.3f}" for k, v in row))
best = max(row, key=lambda t: abs(t[1]))
print(f" picco |corr| al lag {best[0]:+d}h ({best[1]:+.3f})")
print("\n (b) LIVELLO della pendenza vs ritorni STRETTAMENTE FUTURI (ingresso alla")
print(" chiusura della barra t) e vs ritorni PASSATI.")
for h in (1, 3, 6, 24, 72, 168):
fwd = (lp.shift(-h) - lp).reindex(idx)
pst = (lp - lp.shift(h)).reindex(idx)
m = j.slope.notna() & fwd.notna() & pst.notna()
print(f" h={h:4d}h corr(pendenza, FUTURO) {j.slope[m].corr(fwd[m]):+.3f} "
f"corr(pendenza, PASSATO) {j.slope[m].corr(pst[m]):+.3f}")
TRIALS.append((f"Q1 {a}: 6 orizzonti x (futuro,passato) x (livello,variazione)", 24))
print("\n (c) IL CONFOUND DI QUESTO FILONE — la pendenza e' informazione NUOVA o RV riciclata?")
rvp = (r1.rolling(24).std() * np.sqrt(24 * 365) * 100).reindex(idx)
rvf = (r1.shift(-24).rolling(24).std().shift(-23) * np.sqrt(24 * 365) * 100).reindex(idx)
ivf = curve[curve.asset == a].set_index("hr")[f"iv_{FRONT}"].reindex(idx)
c_pp = float(j.slope.corr(rvp))
c_pf = float(j.slope.corr(rvf))
print(f" corr(pendenza, RV 24h PASSATA) {c_pp:+.3f} <-- quanto e' termometro")
print(f" corr(pendenza, RV 24h FUTURA) {c_pf:+.3f} <-- quanto e' previsione")
print(f" corr(iv_{FRONT}, RV 24h passata) {float(ivf.corr(rvp)):+.3f} "
f"corr(iv_{FRONT}, RV futura) {float(ivf.corr(rvf)):+.3f}")
res[(a, "conf_past")], res[(a, "conf_fwd")] = c_pp, c_pf
print("\n (d) La correlazione col futuro SOPRAVVIVE se si controlla per il PASSATO?")
print(" (regressione del ritorno futuro su pendenza + ritorno passato + RV passata;")
print(" se il coefficiente della pendenza evapora, il segnale era 'e' appena sceso')")
for h in (24, 72, 120):
fwd = (lp.shift(-h) - lp).reindex(idx)
pst = (lp - lp.shift(h)).reindex(idx)
m = j.slope.notna() & fwd.notna() & pst.notna() & rvp.notna()
X = np.column_stack([np.ones(m.sum()), j.slope[m], pst[m], rvp[m]])
y = fwd[m].to_numpy()
b, *_ = np.linalg.lstsq(X, y, rcond=None)
# errori standard Newey-West (lag = h, finestre sovrapposte)
e = y - X @ b
XtXi = np.linalg.pinv(X.T @ X)
S = (X * e[:, None]).T @ (X * e[:, None])
for L in range(1, h + 1):
w = 1.0 - L / (h + 1.0)
G = (X[L:] * e[L:, None]).T @ (X[:-L] * e[:-L, None])
S += w * (G + G.T)
se = np.sqrt(np.diag(XtXi @ S @ XtXi))
simple = float(j.slope[m].corr(fwd[m]))
print(f" h={h:4d}h corr semplice {simple:+.3f} | beta_pendenza={b[1]:+.5f} "
f"t(NW)={b[1]/se[1]:+.2f} | beta_ret_passato t={b[2]/se[2]:+.2f}")
TRIALS.append((f"Q1 {a}: 3 regressioni controllate", 3))
print("\n (e) Test giornaliero con null a BLOCCHI (la finestra e' di 74 giorni: la")
print(" correlazione va confrontata con la sua dispersione, non con zero).")
sld = j.slope.resample("1D").last().dropna()
pxd = px.resample("1D").last()
lpd = np.log(pxd)
for h in (1, 3, 5):
fwd = (lpd.shift(-h) - lpd).reindex(sld.index)
m = sld.notna() & fwd.notna()
x, y = sld[m].to_numpy(), fwd[m].to_numpy()
c = float(np.corrcoef(x, y)[0, 1])
p = _block_pctl(x, y, c, L=max(3 * h, 7))
print(f" h={h}g n={m.sum():3d} corr={c:+.3f} pctl vs null a blocchi={p:.3f}")
TRIALS.append((f"Q1 {a}: 3 orizzonti giornalieri", 3))
return res
# ===========================================================================================
# SEZIONE 3 — Q2: il carry di vol e' incassabile?
# ===========================================================================================
def build_atm_cost(chain: pd.DataFrame) -> pd.DataFrame:
"""Costo REALE di attraversare lo spread su un'opzione ATM, in PUNTI DI VOL.
(ask-bid) e' quotato nel sottostante -> x spot = USD; / vega = punti di vol, che e' l'unita'
in cui si misura il roll-down. Cosi' costo e carry sono confrontabili senza modello.
"""
f = CACHE / "atm_cost.parquet"
if f.exists():
return pd.read_parquet(f)
q = chain[chain.quoted & (chain.dte > DTE_MIN) & (chain.option_type == "C") & (chain.vega > 0)].copy()
q["ad"] = (q["delta"] - 0.5).abs()
atm = q.sort_values("ad").groupby(["asset", "hr", "exp"], as_index=False).first()
atm = atm[atm.ad < 0.08]
for a in ASSETS:
p = px_hourly(a)
m = atm.asset == a
atm.loc[m, "spot"] = p.reindex(atm.loc[m, "hr"]).ffill().to_numpy()
atm = atm.dropna(subset=["spot"])
atm["spr_usd"] = (atm.ask - atm.bid) * atm.spot
atm["spr_vol"] = atm.spr_usd / atm.vega
atm["prem_usd"] = (atm.bid + atm.ask) / 2.0 * atm.spot
CACHE.mkdir(parents=True, exist_ok=True)
atm.to_parquet(f)
return atm
def sezione3(chain: pd.DataFrame, atm_exp: pd.DataFrame, curve: pd.DataFrame) -> None:
hr("SEZIONE 3 — Q2: il CARRY di vol (roll-down) e' incassabile al bid-ask reale?")
print(" Definizione senza modello: un'opzione a tenore T, dopo dt, sta a T-dt. Se la curva non")
print(" si muove la sua IV diventa iv(T-dt). Chi e' CORTO guadagna iv(T)-iv(T-dt) punti di vol.")
print(" Il costo di entrare e uscire e' lo spread bid-ask, misurabile negli STESSI punti di vol.")
sub("(a) quanto vale il roll-down, e viene DAVVERO incassato?")
print(" Confronto appaiato per SCADENZA e per ISTANTE D'INGRESSO (mai allineato sull'uscita):")
print(" roll IMPLICITO = iv_curva(T-dt) - iv(T) al tempo t ; roll REALIZZATO = iv(T-dt) a t+dt - iv(T) a t")
roll_day = {}
for H in (24, 168):
for a in ASSETS:
r = atm_exp[atm_exp.asset == a]
by_hr = {h: g.sort_values("dte") for h, g in r.groupby("hr")}
rows = []
for _, g in r.set_index(["exp", "hr"]).sort_index().groupby(level=0):
g = g.droplevel(0).sort_index()
fut = g["iv"].reindex(g.index + pd.Timedelta(hours=H))
for t, iv0, d0, iv1 in zip(g.index, g["iv"].to_numpy(),
g["dte"].to_numpy(), fut.to_numpy()):
T2 = d0 - H / 24.0
gg = by_hr.get(t)
if not np.isfinite(iv1) or T2 <= DTE_MIN or gg is None or len(gg) < 2:
continue
td, ivd = gg["dte"].to_numpy(), gg["iv"].to_numpy()
if td.min() > T2 or td.max() < T2:
continue
w = (ivd / 100.0) ** 2 * td
ivT2 = np.sqrt(np.interp(T2, td, w) / T2) * 100.0
rows.append((d0, ivT2 - iv0, iv1 - iv0))
d = pd.DataFrame(rows, columns=["dte", "impl", "real"])
d = d[(d.dte >= 4) & (d.dte <= 45)]
if len(d) < 50:
print(f" {a} dt={H}h: campione insufficiente ({len(d)})")
continue
b = np.polyfit(d.impl, d.real, 1)[0]
diff = d.real - d.impl
nblk = max(1, len(d) // max(1, H)) # osservazioni ~indipendenti
t = diff.mean() / diff.std() * np.sqrt(nblk) if diff.std() > 0 else 0.0
print(f" {a} dt={H:3d}h n={len(d):5d} roll implicito medio {d.impl.mean():+.4f} pt-vol "
f"realizzato {d.real.mean():+.4f} beta(real~impl)={b:+.2f} "
f"corr={d.impl.corr(d.real):+.3f} (real-impl) t={t:+.2f}")
if H == 24:
roll_day[a] = -float(d.impl.mean()) # guadagno giornaliero di CHI E' CORTO
print("\n Lettura: se il roll-down fosse incassato, beta(real~impl) sarebbe ~+1. E' NEGATIVO")
print(" (~-1 a una settimana): sul campione la FORMA della curva torna indietro piu' di")
print(" quanto la curva rotoli. Il segno del bias da errore-di-misura sarebbe POSITIVO")
print(" (iv(T) compare in entrambi con segno opposto), quindi non spiega questo.")
TRIALS.append(("Q2: 2 orizzonti x 2 asset (roll implicito vs realizzato)", 4))
sub("(b) il costo reale: bid-ask ATM in PUNTI DI VOL, per tenore")
cost = build_atm_cost(chain)
cost["bucket"] = pd.cut(cost.dte, [DTE_MIN, 3, 10, 20, 45, 95],
labels=["1-3g", "3-10g", "10-20g", "20-45g", "45-95g"])
tab = cost.groupby(["asset", "bucket"], observed=True).agg(
n=("spr_vol", "size"), spread_ptvol=("spr_vol", "median"),
spread_pct_premio=("spr_usd", lambda s: np.nan),
premio_usd=("prem_usd", "median"), vega_usd_per_ptvol=("vega", "median"))
tab["spread_pct_premio"] = (cost.assign(x=cost.spr_usd / cost.prem_usd * 100)
.groupby(["asset", "bucket"], observed=True)["x"].median())
print(tab.round(3).to_string().replace("\n", "\n "))
sub("(c) l'aritmetica che chiude la domanda")
med = cost.groupby(["asset", "bucket"], observed=True)["spr_vol"].median()
for a in ASSETS:
rd = roll_day.get(a, np.nan)
sp = float(med.loc[(a, "10-20g")])
print(f" {a}: roll-down incassabile da CORTO = {rd:+.4f} pt-vol/giorno | "
f"un round-trip ATM 10-20g costa {sp:.3f} pt-vol")
print(f" => servono {sp/rd:.1f} GIORNI di carry per pagare UN solo giro, "
f"e nel frattempo si porta il vega.")
TRIALS.append(("Q2: 5 secchi di tenore x 2 asset (costo)", 10))
sub("(d) e a $635 la domanda non e' nemmeno questa: il LOTTO MINIMO")
for a in ASSETS:
c = cost[(cost.asset == a) & (cost.bucket == "10-20g")]
if c.empty:
continue
prem = float(c.prem_usd.median()) * MIN_LOT[a]
vega = float(c.vega.median()) * MIN_LOT[a]
spot = float(c.spot.median())
fee = min(OPT_FEE_UNDERLYING * spot, OPT_FEE_CAP_PREMIUM * float(c.prem_usd.median())) * MIN_LOT[a]
rd = roll_day.get(a, np.nan)
carry_g = rd * vega
friz = float(c.spr_vol.median()) * vega + 4 * fee # 1 spread pieno + 4 gambe di fee
print(f" {a}: lotto minimo {MIN_LOT[a]} -> premio ATM 10-20g = ${prem:,.2f} "
f"({prem/CAPITALE:.0%} del conto), vega ${vega:.2f}/pt-vol, fee ${fee:.2f}/gamba")
print(f" carry lordo ${carry_g:+.3f}/giorno per struttura; attrito per giro "
f"${friz:.2f} => {friz/carry_g:.0f} giorni per andare in pari")
print(f"\n Un calendar spread e' DUE gambe: a $635 una sola struttura ETH impegna il")
print(f" ~15-30% del conto e rende centesimi al giorno. BTC e' fuori dal lotto minimo.")
print(f" Il bersaglio dichiarato e' 50 EUR/giorno: qui si parla di due ordini di grandezza sotto.")
TRIALS.append(("Q2: 2 asset x eseguibilita' al lotto minimo", 2))
# ===========================================================================================
# SEZIONE 4 — Q3: gate di rischio sopra TP01. RIDONDANZA PRIMA DELL'UPLIFT.
# ===========================================================================================
def tp01_positions() -> dict:
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio
tp = TrendPortfolio(**CANONICAL)
out = {}
for a in ASSETS:
df = A.get(a, "1d")
out[a] = pd.Series(tp.target_series(df),
index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)))
return out
def _anchored(sl_h: pd.Series, px_h: pd.Series, off: int):
"""Serie giornaliere (pendenza, ritorno) ancorate all'ora `off`."""
m = sl_h.index.hour == off
s = sl_h[m]
p = px_h.reindex(s.index).ffill()
r = p.pct_change()
return s, r
def sezione4(curve: pd.DataFrame) -> None:
hr("SEZIONE 4 — Q3: la pendenza come GATE DI RISCHIO sopra TP01. RIDONDANZA PRIMA DELL'UPLIFT")
print(" Regola del progetto: 4 gate di de-risk su 4 (DVOL-spike, macro, funding, breadth) sono")
print(" risultati RIDONDANTI col trend. Percio' la ridondanza si misura PRIMA di rivendicare")
print(" qualunque uplift: quanti dei giorni che il gate spegne sono giorni in cui TP01 e' gia' flat?")
pos = tp01_positions()
sub("(a) il fatto che decide da solo la sezione")
W = pd.concat(pos, axis=1).loc[TS_START:]
flat = (W.abs() < 0.05).mean()
print(f" Nella finestra {TS_START.date()} -> oggi ({len(W)} giorni), TP01 e' FLAT nel "
f"{flat['BTC']:.1%} (BTC) / {flat['ETH']:.1%} (ETH) dei giorni.")
print(f" Esposizione media: BTC {W['BTC'].mean():.3f}, ETH {W['ETH'].mean():.3f} "
f"(target TSMOM risk-off, coerente col libro live dichiarato flat).")
print(" ⚠️ Un gate di de-risk su un libro gia' flat il 93% del tempo NON E' MISURABILE su")
print(" questa finestra: non c'e' rischio da togliere. Questo non e' un difetto del")
print(" gate, e' un difetto del CAMPIONE — e va detto prima di qualunque numero.")
sub("(b) ridondanza, per quel che il campione consente")
for a in ASSETS:
sl = slope_hourly(curve, a).resample("1D").last().dropna()
j = pd.concat({"slope": sl, "tp": pos[a]}, axis=1, join="inner").dropna()
for qq in (0.10, 0.25, 0.40):
th = j.slope.quantile(qq)
off = j.slope < th
already = float((j.tp[off].abs() < 0.05).mean()) if off.any() else np.nan
print(f" {a} gate OFF sotto p{int(qq*100)} ({th:+.2f} pt-vol): spegne il "
f"{off.mean():.1%} dei giorni; di questi TP01 era GIA' flat nel {already:.1%} "
f"| corr(pendenza, esposizione TP01) = {j.slope.corr(j.tp):+.3f}")
TRIALS.append((f"Q3 {a}: 3 soglie di gate", 3))
print("\n Il segno di corr(pendenza, esposizione) e' NEGATIVO: la backwardation arriva")
print(" quando TP01 e' gia' uscito. E' la firma esatta dei 4 precedenti.")
sub("(c) uplift sulla finestra, su TUTTE le 24 ancore (mai su una sola)")
print(" Candidato DIREZIONALE (non gate): contango -> long, backwardation -> flat; vol-target")
print(" 14g, cap 2x, fee 5 bps/lato. Soglia a ZERO = a priori, nessun parametro tarato.")
for a in ASSETS:
sl_h, px_h = slope_hourly(curve, a), px_hourly(a)
def cand(off, s=sl_h, p=px_h):
s_d, r_d = _anchored(s, p, off)
d = pd.DataFrame({"slope": s_d, "r": r_d}).dropna()
if len(d) < 30:
return pd.Series(dtype=float)
vol = d["r"].rolling(14).std() * np.sqrt(365)
dirn = (d["slope"] > 0).astype(float)
tgt = np.clip(dirn * (0.20 / vol.replace(0, np.nan)), 0, 2.0).fillna(0.0)
hold = tgt.shift(1).fillna(0.0)
return (hold * d["r"] - A.FEE_SIDE * hold.diff().abs().fillna(0.0)).dropna()
def base(off, p=px_h, s=sl_h):
s_d, r_d = _anchored(s, p, off)
d = pd.DataFrame({"r": r_d}).dropna()
return d["r"]
band = A.anchor_luck_band(cand, list(range(24)), canonical=0)
print(f" {a}: Sharpe candidato — canonico(00:00) {band['canonical']:+.2f} "
f"(pctl {band['canonical_pctl']:.2f}) | MEDIANA ONESTA {band['median']:+.2f} | "
f"banda [{band['lo']:+.2f}, {band['hi']:+.2f}] | positivo in {band['frac_positive']:.0%} "
f"delle 24 ancore | gate_pass={band['gate_pass']}")
bh = A.anchor_luck_band(base, list(range(24)), canonical=0)
print(f" buy&hold sulla stessa finestra/ancore: mediana {bh['median']:+.2f} "
f"[{bh['lo']:+.2f}, {bh['hi']:+.2f}] <-- il vero termine di paragone in un toro")
dlt = A.anchor_luck_delta(cand, base, list(range(24)))
print(f" mediana delle DIFFERENZE APPAIATE candidato-B&H: {dlt['median_paired']:+.2f} "
f"(positiva in {dlt['n_positive']}/{dlt['n_anchors']} ancore)")
TRIALS.append((f"Q3 {a}: 24 ancore x 2 varianti", 48))
sub("(d) null del DE-LEVERING — obbligatorio su ogni claim di minor rischio")
for a in ASSETS:
sl_h, px_h = slope_hourly(curve, a), px_hourly(a)
s_d, r_d = _anchored(sl_h, px_h, 0)
d = pd.DataFrame({"slope": s_d, "r": r_d}).dropna()
vol = d["r"].rolling(14).std() * np.sqrt(365)
tgt = np.clip((d["slope"] > 0).astype(float) * (0.20 / vol.replace(0, np.nan)), 0, 2.0).fillna(0.0)
hold = tgt.shift(1).fillna(0.0)
net = (hold * d["r"] - A.FEE_SIDE * hold.diff().abs().fillna(0.0)).dropna()
dd_c = A._dd_ret(net)
best = None
for k in np.arange(0.05, 1.01, 0.05):
b = k * d["r"].reindex(net.index)
if A._dd_ret(b) <= dd_c:
best = (k, A._sh(b), A._dd_ret(b))
break
print(f" {a}: candidato Sharpe {A._sh(net):+.2f} maxDD {dd_c:.2%} | "
+ (f"buy&hold de-leverato k={best[0]:.2f} -> stesso DD ({best[2]:.2%}) con Sharpe "
f"{best[1]:+.2f} => {'IL CANDIDATO NON SOPRAVVIVE' if best[1] >= A._sh(net) else 'candidato sopra il null'}"
if best else "nessun k<=1 raggiunge quel DD"))
TRIALS.append((f"Q3 {a}: 20 valori di k nel null de-levering", 20))
# ===========================================================================================
# SEZIONE 5 — IL TEST LUNGO. La pendenza ha un PROXY con 5 anni di storia?
# ===========================================================================================
def _proxy_slope(df: pd.DataFrame, asset: str, rvwin: int = 7) -> np.ndarray:
"""Proxy A PRIORI della pendenza su storia lunga: DVOL(30g) - RV(rvwin), tutto causale.
Motivazione, non scelta a posteriori: iv_30 ~ DVOL (corr 0.99, SEZIONE 1) e iv_7 e' in larga
parte una funzione della RV recente (SEZIONE 2c). Il proxy e' quindi la pendenza con la gamba
corta sostituita dalla sua determinante. NON e' la stessa variabile: l'attenuazione e'
misurata sotto e va portata dietro a ogni conclusione.
"""
c = df["close"].to_numpy(float)
r = A.simple_returns(c)
bpd = A.bars_per_day(df)
rv = A.realized_vol(r, max(2, rvwin * bpd), bpd * 365.25) * 100.0
return A.dvol(df, asset) - rv
def make_proxy_dir(long_only: bool = True, thr: float = 0.0):
def fn(df, asset):
p = _proxy_slope(df, asset)
d = np.where(np.isfinite(p), np.where(p > thr, 1.0, 0.0 if long_only else -1.0), 0.0)
return A.vol_target(d, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0)
return fn
def sezione5(curve: pd.DataFrame) -> None:
hr("SEZIONE 5 — IL TEST LUNGO: la pendenza ha un proxy con 5 anni di storia?")
print(" Con 74 giorni nessun gate statistico ha potenza. L'unica via per DIRE qualcosa e' un")
print(" proxy a priori su storia lunga — e dichiararne l'attenuazione.")
sub("(a) quanto e' buono il proxy (misurato sulla finestra vera, non assunto)")
for a in ASSETS:
sl = slope_hourly(curve, a).resample("1D").last().dropna()
df = A.get(a, "1d")
p = pd.Series(_proxy_slope(df, a),
index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))).dropna()
j = pd.concat({"vero": sl, "proxy": p}, axis=1, join="inner").dropna()
c = float(j.vero.corr(j.proxy))
print(f" {a}: corr(pendenza VERA, DVOL-RV7) = {c:+.3f} su n={len(j)} giorni "
f"-> attenuazione: il proxy spiega il {c**2:.0%} della varianza della pendenza")
print(" ⚠️ Un proxy a corr ~0.5-0.7 e' una lente SFOCATA: un risultato NEGATIVO sul proxy")
print(" e' evidenza piu' debole di un negativo sulla variabile vera. Va detto, non nascosto.")
sub("(b) il candidato direzionale sul proxy, 5 anni, coi gate veri")
fn = make_proxy_dir(long_only=True)
cau = A.causality_ok(fn, tf="1d")
print(f" causality_ok: {cau}")
cd = A.candidate_daily(fn, tf="1d")
era = cd[cd.index >= pd.Timestamp("2021-10-01", tz="UTC")]
print(f" era DVOL (2021-10 -> oggi, {len(era)} giorni): Sharpe {A._sh(era):+.3f} "
f"maxDD {A._dd_ret(era):.2%}")
rep = A.marginal_vs_tp01(cd)
print(f" marginal_vs_tp01 -> {rep.get('marginal_verdict')} | corr a TP01 "
f"{rep.get('corr_full')} | uplift w25 full {rep['blends']['w25']['uplift_full']:+.3f} "
f"hold {rep['blends']['w25']['uplift_hold']}")
for k in ("is_hedge", "has_insample_edge", "robust_oos", "beats_noise_null", "earns_slot"):
if k in rep:
print(f" {k:20s} = {rep[k]}")
imp = A.implausible_sharpe(era)
print(f" implausible_sharpe: flagged={imp.get('flagged')} ({imp.get('reasons')})")
TRIALS.append(("Sez.5: 2 varianti proxy (long-only, L/S) x 1 soglia", 2))
sub("(c) il precedente che questo replica")
print(" `dvol_directional.py` (2026-06-29) testo' VRP-Z = z-score causale di (IV-RV) come")
print(" segnale DIREZIONALE su 5 anni, con la griglia completa: verdetto **HEDGE,")
print(" earns_slot=False** — paga solo quando TP01 e' debole, quindi non e' alpha.")
print(" La pendenza della term structure e', su questa lente, la stessa variabile con la")
print(" gamba realizzata sostituita da una implicita. Il filone non e' nuovo: e' quello.")
# ===========================================================================================
# SEZIONE 6 — CONTI, GATE, VERDETTO
# ===========================================================================================
def sezione6() -> None:
hr("SEZIONE 6 — GRIGLIA DICHIARATA E DEFLATED SHARPE")
tot = sum(n for _, n in TRIALS)
for name, n in TRIALS:
print(f" {n:5d} {name}")
print(f" {'-'*70}\n {tot:5d} TOTALE (contato al RIALZO: ogni variante valutata, anche scartata)")
print("\n Non e' stata ridotta nessuna griglia per il budget: il vincolo di questo filone")
print(" non e' il tempo di calcolo, e' che il campione dura 74 giorni.")
print("\n deflated_sharpe: NON GIRATO su un candidato di questo studio, e il motivo e' una")
print(" scelta, non una dimenticanza: il DSR e' una correzione per multiple-testing su UNA")
print(" serie di ritorni; qui il candidato migliore ha 74 osservazioni e la sua incertezza")
print(" di CAMPIONE (SE(Sharpe) ~ 2.2) domina di un ordine di grandezza quella da selezione.")
print(f" Deflazionare {tot} trial su 74 giorni darebbe un numero preciso e privo di senso.")
print(" Sul proxy a 5 anni il DSR e' invece pertinente ed e' riportato dal gate del 29/06")
print(" su cui questo filone ricade (VRP-Z: earns_slot=False).")
def main() -> None:
hr("r0822_term_structure — TERM STRUCTURE DELLA VOL IMPLICITA BTC/ETH")
print(__doc__.split("METODO.")[0].strip()[:0] or "", end="")
chain = load_quoted_chain()
sezione0(chain)
atm_exp = build_atm_by_expiry(chain)
curve = build_curve(atm_exp)
sezione1(curve)
sezione2(curve)
sezione3(chain, atm_exp, curve)
del chain
sezione4(curve)
sezione5(curve)
sezione6()
hr("FINE")
if __name__ == "__main__":
main()
+671
View File
@@ -0,0 +1,671 @@
#!/usr/bin/env python
"""r0822_vol_size.py — CONTROLLO DEL RISCHIO SU SKH01 e VOL-TARGET A LIVELLO DI LIBRO.
DOMANDA (ondata 2026-08-22, filone VOL-SIZE). Il libro live Deribit e' TP01 75% + SKH01 25%,
nettati su un solo strumento. TP01 e' vol-targeted al 20% sulla vol REALIZZATA dell'asset;
**SKH01 non ha alcun controllo del rischio** `backtest_signals(..., leverage=1.0,
position_size=1.0)`, verificato il 26/07: il suo 20,7% di vol realizzata e' un PRODOTTO della
strategia (uscite % asimmetriche + poco tempo a mercato), non un parametro. E' l'unica delle
cinque gambe senza dimensionamento, la piu' fee-sensibile (~4x TP01) e la piu' fragile
all'ancora (LOO 26/07).
Q1 dare a SKH01 un dimensionamento del rischio (inverse-vol per-trade / rischio costante per
trade / vol-target sulla sua vol di strategia) migliora il LIBRO, o e' de-levering?
Q2 il vol-target va applicato al LIBRO gia' nettato invece che al singolo sleeve? Oggi ogni
sleeve si dimensiona da solo e poi si nettano: la versione book-level non e' mai stata
misurata.
COSA NON SI RIFA' (gia' misurato): TP01 x DVOL (26/06, de-levering); il peso 75/25 (confermato
3 volte). I pesi NOMINALI non si toccano; una modulazione di size e' pero' un cambio di pesi nel
tempo, quindi `weights_tilt_null` si riporta lo stesso (§7).
IPOTESI A PRIORI, REGISTRATA PRIMA DI MISURARE: **de-levering su quasi tutto**. Ragione
dichiarata prima: lo Sharpe e' INVARIANTE a una costante moltiplicativa, quindi una variante che
si limiti ad abbassare la leva media non puo' cambiare lo Sharpe e puo' solo abbassare il DD
la firma esatta del null del de-levering (5 occorrenze nel progetto). Per VINCERE una variante
deve cambiare la FORMA (quale trade pesa quanto), e la forma si vede solo sullo Sharpe.
Sotto-ipotesi: l'inverse-vol per-trade e' l'unica con una ragione strutturale (lo stop di SKH01
e' una PERCENTUALE FISSA 4%/2%, quindi il rischio in unita' di sigma varia col regime); il
vol-target sulla serie giornaliera dello sleeve e' quello che ha piu' probabilita' di rompersi
(SKH01 e' ~88% di zeri: la vol trailing di quella serie misura l'ATTIVITA', non il rischio).
DIFETTO TROVATO NELLA MIA PRIMA STESURA, E MISURATO INVECE CHE CANCELLATO (§4). Il modo
ovvio di scrivere un vol-target su uno sleeve moltiplicare la sua serie GIORNALIERA per
L_t = tv/rv_{t-1} e' **non causale su SKH01**, perche' la sua equity e' a GRADINO: tutto il
P&L di un trade e' contabilizzato il giorno di CHIUSURA. Scalare quel giorno per L_t significa
aver tenuto size L_t dall'INGRESSO, che e' fino a ~4 giorni prima (24 barre da 230m). L_t usa
solo dati <= t-1 e quindi passa qualunque controllo di causalita' scritto sulla serie
giornaliera: il look-ahead sta nella CONTABILITA', non nella formula. La versione causale fissa
la size all'INGRESSO. Entrambe sono misurate qui, e la differenza e' un risultato.
LENTE E ANCORA. Path CANONICO (fill al livello, entry a chiusura di bin): e' la lente di tutti i
numeri pubblicati di SKH01 ed e' identica nelle due braccia di ogni confronto — il Delta e'
pulito anche se il livello assoluto e' pessimistico verso il path live (26/07: il live e'
migliore su ingressi E uscite). Banda d'ancora = **23 offset**, la griglia a priori dell'audit
02/07 (ogni 30m su [0,690)); statistica = **mediana delle DIFFERENZE APPAIATE**
(`A.anchor_luck_delta`), mai differenza delle mediane. L'ancora di TP01 e' tenuta canonica in
entrambe le braccia §6 verifica che il segno non dipenda da lei.
PRESENTAZIONE. Lo Sharpe e' invariante alla scala, maxDD e CAGR no: ogni variante e' riportata
anche a **ISO-VOL** (costante che pareggia la deviazione standard piena a quella del baseline).
E' una scelta di presentazione, post-hoc e dichiarata; non cambia lo Sharpe, che e' cio' che
decide. Il null del de-levering (`k_iso_dd`, importato da r0822_sol_leg, non riscritto) e'
calcolato per ogni variante che riduce il DD.
USO: nice -n 19 timeout 900 uv run python scripts/research/r0822_vol_size.py
[--every K] 1 offset ogni K dei 23 (K=1 = tutti; pilota: --every 8)
"""
from __future__ import annotations
import argparse
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"))
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
import altlib as A # noqa: E402
import r0702_anchor_skh01 as R # noqa: E402 (run_asset/resample_off riusati)
import r0702_tp01_offset as TO # noqa: E402 (TP01 per ancora + target 1h)
from r0822_sol_leg import k_iso_dd # noqa: E402 (null de-levering, non riscritto)
from src.portfolio.portfolio import ( # noqa: E402
HOLDOUT, combine_outer, weights_tilt_null,
)
ASSETS = ("BTC", "ETH")
OFFSETS_FULL = tuple(range(0, 690, 30)) # 23 a priori: griglia dell'audit 02/07
LTF_MIN = 230
BARS_YR = 365.25 * 24 * 60 / LTF_MIN
DPY = 365.25
FEE_RT = 0.001
W_TP, W_SKH = 0.75, 0.25 # libro live Deribit
CAPITAL = 635.0 # conto reale dichiarato nel brief
MIN_ORDER = 5.0
CAP_ASSET = 0.5 # cap $318/asset su equity $635
SIG_WARM = 2000 # barre 230m (~1 anno) prima di dimensionare
VT_WARM = 250 # giorni prima di dimensionare su vol di strategia
# --------------------------------------------------------------------------- metriche
def sh(s) -> float:
r = np.asarray(pd.Series(s).dropna().values, float)
return float(r.mean() / r.std() * np.sqrt(DPY)) if len(r) > 2 and r.std() > 0 else 0.0
def maxdd(s) -> float:
r = np.asarray(pd.Series(s).dropna().values, float)
if len(r) < 2:
return 0.0
eq = np.cumprod(1.0 + r)
pk = np.maximum.accumulate(eq)
return float(np.max((pk - eq) / pk))
def cagr(s) -> float:
r = np.asarray(pd.Series(s).dropna().values, float)
if len(r) < 2:
return 0.0
eq = float(np.prod(1.0 + r))
yrs = len(r) / DPY
return float(eq ** (1 / yrs) - 1) if eq > 0 and yrs > 0 else -1.0
def vol(s) -> float:
r = np.asarray(pd.Series(s).dropna().values, float)
return float(r.std() * np.sqrt(DPY)) if len(r) > 2 else 0.0
def hold(s: pd.Series) -> pd.Series:
return s[s.index >= HOLDOUT]
def ins(s: pd.Series) -> pd.Series:
return s[s.index < HOLDOUT]
# --------------------------------------------------------------------------- estrazione trade
def extract(asset: str, off: int) -> dict:
"""Trade + contesto per (asset, offset). Replica il loop di `backtest_signals` con
leverage=1: stessa detection, stesso non-overlap, stessi fill al livello. Cio' che
aggiunge e' la SIZE per-trade, che l'harness non espone (ha un solo `position_size`
scalare). La §0 verifica bit-exact che con size=1 si ritrovi la serie ufficiale."""
_, _, ltf, ent = R.run_asset(asset, off)
c = ltf["close"].values.astype(float)
h = ltf["high"].values.astype(float)
lo = ltf["low"].values.astype(float)
n = len(c)
i_ent, i_ex, dirs, nets, stops = [], [], [], [], []
busy = -1
for i in range(n):
e = ent[i] if i < len(ent) else None
if e is None or e.get("dir", 0) == 0 or i <= busy:
continue
d = int(e["dir"])
entry = c[i]
tp, sl = e.get("tp"), e.get("sl")
mb = int(e.get("max_bars") or 24)
ex_i = min(i + mb, n - 1)
ex_p = c[ex_i]
for j in range(i + 1, min(i + mb + 1, n)):
hit_sl = sl is not None and ((d == 1 and lo[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and lo[j] <= tp))
if hit_sl:
ex_p, ex_i = sl, j
break
if hit_tp:
ex_p, ex_i = tp, j
break
ex_p, ex_i = c[j], j
i_ent.append(i); i_ex.append(ex_i); dirs.append(d)
nets.append((ex_p - entry) / entry * d - FEE_RT)
stops.append(abs(float(sl) - entry) / entry) # DERIVATO dal segnale, non ridichiarato
busy = ex_i
idxE = np.asarray(i_ent, int)
lr = A.log_returns(c)
sig, sigref = {}, {}
for w in (30, 90):
sv = A.realized_vol(lr, w, BARS_YR) # finestra che termina in i: causale
sig[w] = sv[idxE] if len(idxE) else np.array([])
sigref[w] = _expanding_median_at(sv, idxE)
idx = pd.DatetimeIndex(pd.to_datetime(ltf["timestamp"].values, unit="ms", utc=True))
out = dict(
n=n, idx=idx, i_ent=idxE, i_ex=np.asarray(i_ex, int), dir=np.asarray(dirs, int),
net=np.asarray(nets, float), stop=np.asarray(stops, float), sig=sig, sigref=sigref,
ts_close=ltf["timestamp"].values.astype(np.int64) + LTF_MIN * 60_000,
day_ent=idx[idxE].floor("D") if len(idxE) else pd.DatetimeIndex([], tz="UTC"),
)
R._CACHE.clear() # ent = 18k dict per chiave: non accumulare
return out
def _expanding_median_at(v: np.ndarray, idxE: np.ndarray) -> np.ndarray:
"""Mediana ESPANDENTE di v fino a i incluso, valutata solo agli indici d'ingresso.
Causale per costruzione. NaN prima del warm-up."""
out = np.full(len(idxE), np.nan)
for k, i in enumerate(idxE):
if i < SIG_WARM:
continue
x = v[: i + 1]
x = x[np.isfinite(x)]
if len(x) >= SIG_WARM // 2:
out[k] = float(np.median(x))
return out
def equity_daily(ex: dict, sizes: np.ndarray) -> pd.Series:
"""Serie giornaliera dei rendimenti dalla tabella trade, con size per-trade. Convenzione
IDENTICA a `backtest_signals` + `_skyhook_returns`: equity a gradino a fine trade,
resample 1D last, ffill, pct_change."""
n = ex["n"]
eq = np.full(n, 1000.0, float)
cap = 1000.0
for k in range(len(ex["i_ent"])):
cap += cap * float(sizes[k]) * float(ex["net"][k])
cap = max(cap, 1.0)
eq[ex["i_ent"][k]: ex["i_ex"][k] + 1] = cap
for k in range(1, n): # stessi due riempimenti dell'harness
if eq[k] == 1000.0 and eq[k - 1] != 1000.0:
eq[k] = eq[k - 1]
last = 1000.0
for k in range(n):
if eq[k] != last and eq[k] != 1000.0:
last = eq[k]
else:
eq[k] = last
return pd.Series(eq, index=ex["idx"]).resample("1D").last().ffill().pct_change().dropna()
def leg_daily(exs: dict, sizes: dict) -> pd.Series:
"""Sleeve SKH01 50/50 BTC+ETH (convenzione di `_skyhook_returns`: inner join)."""
ser = {a: equity_daily(exs[a], sizes[a]) for a in ASSETS}
J = pd.concat(ser, axis=1, join="inner").fillna(0.0)
return pd.Series(0.5 * J[ASSETS[0]].values + 0.5 * J[ASSETS[1]].values, index=J.index)
# --------------------------------------------------------------------------- famiglie di size
def size_flat(ex: dict) -> np.ndarray:
return np.ones(len(ex["i_ent"]))
def size_iv(p: float, w: int, cap: float):
"""INVERSE-VOL per-trade sulla vol dell'ASSET: size = clip((sig_rif/sig_ingresso)^p, 1/cap, cap).
sig_rif = mediana ESPANDENTE causale -> size media ~1: la variante e' neutra alla leva,
quindi qualunque effetto sullo Sharpe e' FORMA, non de-levering."""
def f(ex: dict) -> np.ndarray:
s, r = ex["sig"][w], ex["sigref"][w]
with np.errstate(divide="ignore", invalid="ignore"):
raw = np.where((s > 0) & np.isfinite(r), (r / np.maximum(s, 1e-12)) ** p, 1.0)
return np.clip(np.nan_to_num(raw, nan=1.0, posinf=cap, neginf=1.0), 1.0 / cap, cap)
return f
def size_risk(p: float):
"""RISCHIO COSTANTE PER TRADE: size ~ 1/distanza-dallo-stop. Lo stop di SKH01 e' una
percentuale FISSA (4% long / 2% short) -> questa famiglia e' di fatto 'gli short pesano
2x i long'. La costante e' irrilevante (lo Sharpe e' scale-invariante)."""
def f(ex: dict) -> np.ndarray:
st = np.maximum(ex["stop"], 1e-9)
return np.where(ex["stop"] > 0, (0.04 / st) ** p, 1.0)
return f
def _rv_daily(s: pd.Series, w: int, active_only: bool) -> pd.Series:
"""Vol realizzata trailing di una serie GIORNALIERA, SFASATA di 1 giorno (causale al giorno t).
active_only=True la calcola sulle sole barre ATTIVE e la riporta sulla griglia di calendario:
SKH01 e' ~88% di zeri, e la versione 'tutte le barre' misura l'ATTIVITA', non il rischio."""
if not active_only:
return (s.rolling(w, min_periods=max(5, w // 3)).std() * np.sqrt(DPY)).shift(1)
act = s[s != 0.0]
rv = act.rolling(w, min_periods=max(5, w // 3)).std() * np.sqrt(DPY)
return rv.reindex(s.index).ffill().shift(1)
def leverage_series(base: pd.Series, tv: float | None, w: int, cap: float,
active_only: bool = False) -> pd.Series:
"""L_t da una serie giornaliera di riferimento. tv=None -> versione NEUTRA ALLA LEVA
(rif = mediana espandente causale di rv), tv=float -> vol-target assoluto."""
rv = _rv_daily(base, w, active_only).replace(0.0, np.nan)
if tv is None:
ref = rv.expanding(min_periods=VT_WARM).median()
L = (ref / rv).clip(lower=1.0 / cap, upper=cap)
else:
L = (tv / rv).clip(lower=0.0, upper=cap)
return L.fillna(1.0)
def sizes_from_L(ex: dict, L: pd.Series) -> np.ndarray:
"""La size di ogni trade = L al giorno d'INGRESSO. E' la versione CAUSALE del vol-target su
uno sleeve a equity a gradino: L al giorno di CHIUSURA non era nota all'ingresso."""
if not len(ex["i_ent"]):
return np.array([])
v = L.reindex(ex["day_ent"]).values.astype(float)
return np.nan_to_num(v, nan=1.0)
# --------------------------------------------------------------------------- libro
def book(tp: pd.Series, skh: pd.Series) -> pd.Series:
return combine_outer({"TP01": tp, "SKH01": skh}, {"TP01": W_TP, "SKH01": W_SKH})
def iso_vol(s: pd.Series, ref: pd.Series) -> pd.Series:
v = vol(s)
return s * (vol(ref) / v) if v > 0 else s
HEAD = (f" {'variante':<28}{'ShFULL':>8}{'ShHOLD':>9}{'ShIS':>8}"
f"{'vol':>9}{'maxDD':>9}{'DDisovol':>10}{'CAGRiso':>9}{'size med':>10}")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--every", type=int, default=1)
args = ap.parse_args()
offs = OFFSETS_FULL[:: max(1, args.every)]
t0 = time.time()
print("=" * 112)
print(" r0822 VOL-SIZE — controllo del rischio su SKH01 (Q1) e vol-target di LIBRO (Q2)")
print("=" * 112)
print(f" ancore: {len(offs)}/{len(OFFSETS_FULL)} offset a priori {list(offs)}")
print(" lente: path CANONICO. Ancora TP01: canonica in ENTRAMBE le braccia (§6 la varia).")
print("\n estrazione tabelle trade ...", flush=True)
EX = {o: {a: extract(a, o) for a in ASSETS} for o in offs}
print(f" fatto in {time.time()-t0:.0f}s — trade medi/ancora: " +
", ".join(f"{a} {int(np.mean([len(EX[o][a]['i_ent']) for o in offs]))}" for a in ASSETS))
# ------------------------------------------------------------------ §0 SANITY
print("\n" + "-" * 112)
print(" 0. SANITY — con size=1 la ricomposizione DEVE riprodurre la serie ufficiale bit-exact")
print(" (se non e' 0, ogni numero sotto e' di un'altra strategia)")
print("-" * 112)
worst = 0.0
for o in offs[: min(3, len(offs))]:
for a in ASSETS:
mine = equity_daily(EX[o][a], size_flat(EX[o][a]))
ref = R.run_asset(a, o)[0]
R._CACHE.clear()
assert len(mine) == len(ref), f"len {len(mine)} vs {len(ref)} ({a},{o})"
d = float(np.max(np.abs(mine.values - ref.values)))
worst = max(worst, d)
print(f" {a} off{o:>3}: max|dif| = {d:.2e} ({len(mine)} giorni)")
assert worst < 1e-15, f"ricomposizione NON bit-exact: {worst:.2e}"
if 0 in EX:
from src.portfolio.sleeves import _skyhook_returns
s0 = leg_daily(EX[0], {a: size_flat(EX[0][a]) for a in ASSETS})
d0 = float(np.max(np.abs(s0.values - _skyhook_returns().values)))
print(f" sleeve 50/50 off0 vs sleeves._skyhook_returns(): max|dif| = {d0:.2e}")
assert d0 < 1e-15
# ------------------------------------------------------------------ §1 CAUSALITA'
print("\n" + "-" * 112)
print(" 1. CAUSALITA' — ogni size ricalcolata TRONCANDO i dati alla barra d'ingresso")
print("-" * 112)
ex = EX[offs[0]]["BTC"]
_, _, ltf, _ = R.run_asset("BTC", offs[0])
c_full = ltf["close"].values.astype(float)
R._CACHE.clear()
full_sz = size_iv(1.0, 30, 3.0)(ex)
sel = [k for k in range(len(ex["i_ent"])) if ex["i_ent"][k] >= SIG_WARM][:40]
bad = 0
for k in sel:
i = ex["i_ent"][k]
sv_t = A.realized_vol(A.log_returns(c_full[: i + 1]), 30, BARS_YR)
s_t = sv_t[i]
r_t = float(np.median(sv_t[np.isfinite(sv_t)]))
sz_t = np.clip((r_t / s_t) if s_t > 0 else 1.0, 1 / 3.0, 3.0)
bad += int(abs(sz_t - full_sz[k]) > 1e-9)
print(f" IV: {len(sel)-bad}/{len(sel)} size identiche a dati troncati (divergenze {bad})")
assert bad == 0, "la size IV usa dati futuri"
lag = np.array([(ex["idx"][ex["i_ex"][k]] - ex["idx"][ex["i_ent"][k]]).total_seconds() / 86400
for k in range(len(ex["i_ent"]))])
print(f" durata dei trade (giorni): mediana {np.median(lag):.2f}, p90 {np.percentile(lag,90):.2f}, "
f"max {lag.max():.2f} <- e' l'ampiezza del look-ahead della versione NAIVE (§4)")
# ------------------------------------------------------------------ griglia
SZ: dict = {}
for p in (0.5, 1.0):
for w in (30, 90):
for cp in (2.0, 4.0):
SZ[f"IV p{p} w{w} cap{cp:.0f}"] = size_iv(p, w, cp)
for p in (0.5, 1.0):
SZ[f"RISK p{p}"] = size_risk(p)
VTL = {} # vol-target sulla vol di STRATEGIA, causale
for w in (30, 90):
VTL[f"VTL neutro w{w}"] = dict(tv=None, w=w, cap=3.0, active_only=False)
VTL[f"VTL tv20 w{w}"] = dict(tv=0.20, w=w, cap=3.0, active_only=False)
VTL["VTL neutro w90 attive"] = dict(tv=None, w=90, cap=3.0, active_only=True)
VTL["VTL tv20 w90 attive"] = dict(tv=0.20, w=90, cap=3.0, active_only=True)
BVT = {} # Q2: vol-target del LIBRO
for w in (30, 90, 180):
for cp in (2.0, 3.0):
BVT[f"BOOKVT w{w} cap{cp:.0f}"] = dict(tv=0.10, w=w, cap=cp)
NAIVE = {f"NAIVE-VT w{w}": dict(tv=0.20, w=w, cap=3.0, active_only=False) for w in (30, 90)}
ALL = list(SZ) + list(VTL) + list(BVT) + list(NAIVE)
print(f"\n GRIGLIA DICHIARATA: {len(SZ)} size per-trade + {len(VTL)} vol-target di gamba + "
f"{len(BVT)} vol-target di libro + {len(NAIVE)} controlli non-causali = {len(ALL)} celle "
f"(+ baseline).")
# ------------------------------------------------------------------ serie
TP = A.tp01_baseline_daily()
SLE = {"BASE": {}}
BK = {"BASE": {}}
MTP = {"BASE": {o: 1.0 for o in offs}} # rapporto di vol della gamba TP01
SIZEMED = {}
for o in offs:
SLE["BASE"][o] = leg_daily(EX[o], {a: size_flat(EX[o][a]) for a in ASSETS})
BK["BASE"][o] = book(TP, SLE["BASE"][o])
for nm, fn in SZ.items():
SLE[nm] = {o: leg_daily(EX[o], {a: fn(EX[o][a]) for a in ASSETS}) for o in offs}
BK[nm] = {o: book(TP, SLE[nm][o]) for o in offs}
MTP[nm] = {o: 1.0 for o in offs}
SIZEMED[nm] = float(np.median(np.concatenate([fn(EX[offs[0]][a]) for a in ASSETS])))
for nm, sp in VTL.items():
SLE[nm], BK[nm], MTP[nm] = {}, {}, {}
for o in offs:
sz = {}
for a in ASSETS:
base_a = equity_daily(EX[o][a], size_flat(EX[o][a]))
sz[a] = sizes_from_L(EX[o][a], leverage_series(base_a, **sp))
SLE[nm][o] = leg_daily(EX[o], sz)
BK[nm][o] = book(TP, SLE[nm][o])
MTP[nm][o] = 1.0
if o == offs[0]:
SIZEMED[nm] = float(np.median(np.concatenate(list(sz.values()))))
for nm, sp in BVT.items():
SLE[nm], BK[nm], MTP[nm] = {}, {}, {}
for o in offs:
L = leverage_series(BK["BASE"][o], sp["tv"], sp["w"], sp["cap"])
sz = {a: sizes_from_L(EX[o][a], L) for a in ASSETS}
SLE[nm][o] = leg_daily(EX[o], sz)
tp_s = TP * L.reindex(TP.index).ffill().fillna(1.0)
BK[nm][o] = book(tp_s, SLE[nm][o])
MTP[nm][o] = vol(tp_s) / vol(TP)
if o == offs[0]:
SIZEMED[nm] = float(np.median(np.concatenate(list(sz.values()))))
for nm, sp in NAIVE.items(): # controllo NON causale (vedi §4)
SLE[nm] = {o: SLE["BASE"][o] * leverage_series(SLE["BASE"][o], **sp) for o in offs}
BK[nm] = {o: book(TP, SLE[nm][o]) for o in offs}
MTP[nm] = {o: 1.0 for o in offs}
SIZEMED[nm] = float(np.median(leverage_series(SLE["BASE"][offs[0]], **sp).values))
# CONTROLLO ISO-PESO: ogni variante che alza la vol della gamba SKH alza il suo peso EFFETTIVO
# nel libro — e "alzare il peso di SKH01" e' gia' stato misurato (26/07: argmax w=0.35, ma
# `weights_tilt_null` FALLITO e proposta respinta). Quindi il confronto che conta NON e' vs il
# baseline nudo ma vs il baseline RI-SCALATO agli stessi rapporti di vol: cosi' resta solo la
# FORMA (quale trade pesa quanto). E' la regola dell'ISO-RISCHIO gia' codificata (25/07 §3).
CTRL, WEFF = {}, {}
for nm in ALL:
CTRL[nm], WEFF[nm] = {}, {}
for o in offs:
m_s = vol(SLE[nm][o]) / vol(SLE["BASE"][o]) if vol(SLE["BASE"][o]) > 0 else 1.0
m_t = MTP[nm][o]
CTRL[nm][o] = book(TP * m_t, SLE["BASE"][o] * m_s)
WEFF[nm][o] = W_SKH * m_s / (W_TP * m_t + W_SKH * m_s)
# ------------------------------------------------------------------ §2 sleeve
can = offs[0]
print("\n" + "-" * 112)
print(f" 2. SLEEVE SKH01 all'ancora canonica (off {can}). 'DDisovol'/'CAGRiso' a vol pareggiata")
print(" col baseline (post-hoc, dichiarato: lo Sharpe non ne dipende).")
print("-" * 112)
print(HEAD)
base_s = SLE["BASE"][can]
def prow(nm, s, med=None):
iv = iso_vol(s, base_s)
m = f"{med:>10.2f}" if med is not None else f"{'-':>10}"
print(f" {nm:<28}{sh(s):>8.3f}{sh(hold(s)):>9.3f}{sh(ins(s)):>8.3f}{vol(s)*100:>8.1f}%"
f"{maxdd(s)*100:>8.1f}%{maxdd(iv)*100:>9.1f}%{cagr(iv)*100:>8.1f}%{m}")
prow("BASELINE (size fissa)", base_s, 1.00)
for nm in list(SZ) + list(VTL) + list(NAIVE):
prow(nm, SLE[nm][can], SIZEMED.get(nm))
# ------------------------------------------------------------------ §3 libro, banda appaiata
print("\n" + "-" * 112)
print(f" 3. LIBRO 75/25 — mediana delle DIFFERENZE APPAIATE su {len(offs)} ancore, + null del")
print(" de-levering (k<1 sul BASELINE che pareggia il maxDD della variante)")
print("-" * 112)
print(f" {'variante':<28}{'wSKH eff':>9}{'dShFULL':>9}{'pos/n':>8}{'dShHOLD':>9}{'pos/n':>8}"
f"{'| ISO dF':>10}{'pos/n':>8}{'ISO dH':>9}{'pos/n':>8}{'dDDpp':>8}{'k_iso':>8}")
base_b = BK["BASE"]
RES = {}
for nm in ALL:
dF = A.anchor_luck_delta(lambda o, n=nm: BK[n][o], lambda o: base_b[o], offs, metric=sh)
dH = A.anchor_luck_delta(lambda o, n=nm: hold(BK[n][o]), lambda o: hold(base_b[o]),
offs, metric=sh)
iF = A.anchor_luck_delta(lambda o, n=nm: BK[n][o], lambda o, n=nm: CTRL[n][o],
offs, metric=sh)
iH = A.anchor_luck_delta(lambda o, n=nm: hold(BK[n][o]),
lambda o, n=nm: hold(CTRL[n][o]), offs, metric=sh)
dd = float(np.median([maxdd(BK[nm][o]) - maxdd(base_b[o]) for o in offs])) * 100
if maxdd(BK[nm][can]) < maxdd(base_b[can]) - 1e-6:
k = k_iso_dd(base_b[can], BK[nm][can])
shk = sh(k * base_b[can])
ks = f"{k:.3f}"
vd = "DE-LEVERING" if shk >= sh(BK[nm][can]) else "oltre-null"
else:
ks, vd = "n/a", "DD peggiore"
we = float(np.median([WEFF[nm][o] for o in offs]))
RES[nm] = dict(dF=dF["median_paired"], nF=dF["n_positive"], dH=dH["median_paired"],
nH=dH["n_positive"], iF=iF["median_paired"], niF=iF["n_positive"],
iH=iH["median_paired"], niH=iH["n_positive"], dd=dd, k=ks,
verdict=vd, weff=we)
print(f" {nm:<28}{we:>9.3f}{dF['median_paired']:>+9.3f}{dF['n_positive']:>5}/{len(offs):<3}"
f"{dH['median_paired']:>+9.3f}{dH['n_positive']:>5}/{len(offs):<3}"
f"{iF['median_paired']:>+10.3f}{iF['n_positive']:>5}/{len(offs):<3}"
f"{iH['median_paired']:>+9.3f}{iH['n_positive']:>5}/{len(offs):<3}{dd:>+8.2f}{ks:>8}")
print(" wSKH eff = peso EFFETTIVO di SKH nel libro (i nominali restano 75/25: la size lo")
print(" muove). Colonne 'ISO' = confronto vs il baseline RI-SCALATO a quello stesso peso:")
print(" e' li' che si legge la FORMA. Le colonne non-ISO contengono anche il cambio di peso.")
med_F = float(np.median([sh(base_b[o]) for o in offs]))
med_H = float(np.median([sh(hold(base_b[o])) for o in offs]))
med_D = float(np.median([maxdd(base_b[o]) for o in offs]))
print(f"\n libro BASE, ancora canonica: ShFULL {sh(base_b[can]):.3f} / ShHOLD "
f"{sh(hold(base_b[can])):.3f} / maxDD {maxdd(base_b[can])*100:.1f}% / "
f"CAGR {cagr(base_b[can])*100:.1f}%")
print(f" libro BASE, stima ONESTA (mediana della banda): ShFULL {med_F:.3f} / "
f"ShHOLD {med_H:.3f} / maxDD {med_D*100:.1f}%")
# ------------------------------------------------------------------ §4 il difetto misurato
print("\n" + "-" * 112)
print(" 4. IL DIFETTO MISURATO — vol-target NAIVE (scala il giorno di CHIUSURA) vs CAUSALE")
print(" (size fissata all'INGRESSO). Stessa formula di L, stessa finestra: cambia solo")
print(" QUANDO la si applica. La differenza e' il valore del look-ahead di contabilita'.")
print("-" * 112)
print(f" {'coppia':<34}{'dShFULL naive':>15}{'dShFULL causale':>17}{'phantom':>10}")
for w in (30, 90):
n_nm, c_nm = f"NAIVE-VT w{w}", f"VTL tv20 w{w}"
a1, a2 = RES[n_nm]["dF"], RES[c_nm]["dF"]
print(f" {'vol-target tv20 w%d' % w:<34}{a1:>+15.3f}{a2:>+17.3f}{a1-a2:>+10.3f}")
for w in (30, 90):
n_nm, c_nm = f"NAIVE-VT w{w}", f"VTL tv20 w{w}"
a1, a2 = RES[n_nm]["dH"], RES[c_nm]["dH"]
print(f" {' (hold-out) tv20 w%d' % w:<34}{a1:>+15.3f}{a2:>+17.3f}{a1-a2:>+10.3f}")
# ------------------------------------------------------------------ §5 selezione + DSR
print("\n" + "-" * 112)
print(" 5. SELEZIONE AL BUIO (solo pre-2025, mediana di banda) + deflated-Sharpe")
print("-" * 112)
CAUSAL = [n for n in ALL if n not in NAIVE]
is_med = {n: float(np.median([sh(ins(BK[n][o])) for o in offs])) for n in CAUSAL}
ho_med = {n: float(np.median([sh(hold(BK[n][o])) for o in offs])) for n in CAUSAL}
fu_med = {n: float(np.median([sh(BK[n][o]) for o in offs])) for n in CAUSAL}
base_is = float(np.median([sh(ins(base_b[o])) for o in offs]))
print(f" baseline in-sample (mediana banda): {base_is:.3f}")
for n in sorted(CAUSAL, key=lambda x: -is_med[x])[:6]:
print(f" {n:<28} IS {is_med[n]:>7.3f} HOLD {ho_med[n]:>7.3f} FULL {fu_med[n]:>7.3f}")
best = max(CAUSAL, key=lambda x: is_med[x])
print(f"\n cella scelta AL BUIO: {best} (IS {is_med[best]:.3f} vs baseline {base_is:.3f})")
trials = [fu_med[n] for n in CAUSAL] + [med_F]
dsr, sr0 = A.deflated_sharpe(fu_med[best], trials, BK[best][can])
dsr_b, _ = A.deflated_sharpe(med_F, trials, base_b[can])
print(f" DSR candidato {dsr:.3f} / DSR BASELINE {dsr_b:.3f} (null max-Sharpe {sr0:.3f}, "
f"N={len(trials)})")
print(f" dispersione degli {len(trials)} trial: sd {np.std(trials, ddof=1):.4f} -> "
f"⚠️ il DSR e' QUASI VACUO su una famiglia di perturbazioni della stessa strategia")
print(" (penalizza in proporzione alla varianza fra i trial: qui il baseline stesso lo")
print(" passa). Il gate che decide e' la banda appaiata del §3, non questo.")
# ------------------------------------------------------------------ §6 ancora TP01
print("\n" + "-" * 112)
print(" 6. CONTROLLO — l'ancora di TP01 e' tenuta fissa: il segno del Delta dipende da lei?")
print("-" * 112)
cand6 = [best] + [n for n in (list(SZ)[:0] + ["RISK p1.0"] + list(BVT)[:2]) if n != best]
print(f" {'variante':<28}" + "".join(f"{'TP h=%d' % h:>12}" for h in (0, 8, 16)))
for nm in cand6:
cells = []
for h in (0, 8, 16):
tph = TO.port_daily_native(h) if h else TP
if nm in BVT:
arm = {}
for o in offs:
L = leverage_series(book(tph, SLE["BASE"][o]), BVT[nm]["tv"], BVT[nm]["w"],
BVT[nm]["cap"])
sz = {a: sizes_from_L(EX[o][a], L) for a in ASSETS}
arm[o] = book(tph * L.reindex(tph.index).ffill().fillna(1.0),
leg_daily(EX[o], sz))
else:
arm = {o: book(tph, SLE[nm][o]) for o in offs}
bse = {o: book(tph, SLE["BASE"][o]) for o in offs}
d = A.anchor_luck_delta(lambda o, x=arm: x[o], lambda o, y=bse: y[o], offs, metric=sh)
cells.append(d["median_paired"])
print(f" {nm:<28}" + "".join(f"{v:>+12.3f}" for v in cells))
# ------------------------------------------------------------------ §7 gate
print("\n" + "-" * 112)
print(" 7. GATE — marginal_vs_tp01 / implausible_sharpe / weights_tilt_null")
print("-" * 112)
best_sleeve = max([n for n in list(SZ) + list(VTL)], key=lambda x: is_med[x])
for nm in ("BASE", best_sleeve):
m = A.marginal_vs_tp01(SLE[nm][can])
b25 = m.get("blends", {}).get("w25", {})
print(f" marginal {nm:<26} {m.get('marginal_verdict')} corr {m.get('corr_full')} "
f"uplift w25 full {b25.get('uplift_full')} / hold {b25.get('uplift_hold')}")
nlos = int((EX[can]["BTC"]["net"] < 0).sum() + (EX[can]["ETH"]["net"] < 0).sum())
ntot = int(len(EX[can]["BTC"]["net"]) + len(EX[can]["ETH"]["net"]))
for nm in ("BASE", best_sleeve):
imp = A.implausible_sharpe(SLE[nm][can], n_trades=ntot, n_losing_trades=nlos)
print(f" implausible {nm:<25} implausible={imp['implausible']} "
f"attive {imp.get('active_frac', 0)*100:.1f}% perdite/attive "
f"{imp.get('loss_frac', 0)*100:.1f}% Calmar {imp.get('calmar', 0):.1f}")
cols = {"TP01": TP, "SKH01": SLE["BASE"][can]}
for nm in [best_sleeve, list(BVT)[0]]:
m = vol(SLE[nm][can]) / vol(SLE["BASE"][can])
wp = {"TP01": W_TP, "SKH01": W_SKH * m}
g = weights_tilt_null(cols, {"TP01": W_TP, "SKH01": W_SKH}, wp,
caps={"SKH01": 0.5}, floor=0.05, n=300, k_seen=len(ALL))
print(f" tilt-null {nm:<26} peso equiv SKH {wp['SKH01']/sum(wp.values()):.3f} "
f"d_IS {g['delta_insample']:+.4f} d_HOLD {g['delta_hold']:+.4f} "
f"pctl {g['pctl_hold']:.1f} gate_pass={g['gate_pass']}")
print(" LIMITE DICHIARATO: il gate confronta vettori di pesi STATICI; della modulazione")
print(" cattura la sola componente di SCALA. La FORMA la giudica la banda appaiata (§3).")
# ------------------------------------------------------------------ §8 eseguibilita'
print("\n" + "-" * 112)
print(f" 8. ESEGUIBILITA' a ${CAPITAL:.0f} — min-order ${MIN_ORDER:.0f}, cap {CAP_ASSET:.0%}"
f" equity/asset, target NETTATO 0.75*TP01 + 0.25*SKH sul grid 1h (cio' che il cron manda)")
print("-" * 112)
print(f" {'configurazione':<28}{'Sh model':>10}{'Sh reale':>10}{'haircut':>9}"
f"{'ordini eseg':>13}{'sotto-min':>11}{'turnover/a':>12}")
todo = [("BASE (size fissa)", None), (best_sleeve, best_sleeve), (list(BVT)[0], list(BVT)[0])]
for label, nm in todo:
tm = tr = tu = 0.0
no = ns = 0
for a in ASSETS:
df1h = TO.get1h(a)
tgt = netted_target(a, EX[can][a], nm, SZ, VTL, BVT, SLE, TP, can, df1h)
ev = A.eval_weights_smallcap(df1h, tgt, capital=CAPITAL, min_order=MIN_ORDER)
tm += 0.5 * ev["modeled"]["sharpe"]
tr += 0.5 * ev["realistic"]["sharpe"]
tu += ev["executed_turnover_per_year"]
no += ev["n_executed_trades"]
dw = np.abs(np.diff(np.nan_to_num(tgt), prepend=0.0))
ns += int(((dw > 1e-12) & (dw * CAPITAL < MIN_ORDER)).sum())
print(f" {label:<28}{tm:>10.3f}{tr:>10.3f}{tm-tr:>9.3f}{no:>13}{ns:>11}{tu:>12.1f}")
print(" 'ordini eseg' e 'sotto-min' sono sui due asset sommati, su tutta la storia 1h.")
print("\n" + "=" * 112)
print(f" fatto in {time.time()-t0:.0f}s")
print("=" * 112)
def netted_target(asset: str, ex: dict, nm, SZ, VTL, BVT, SLE, TP, can, df1h) -> np.ndarray:
"""Peso NETTO per asset sul grid 1h: 0.75*target TP01 + 0.25*posizione SKH (dir*size), poi
l'eventuale leva di libro sulla gamba TP01. E' cio' che il cron manda a Deribit come UNA
posizione netta sullo stesso strumento."""
tp = np.asarray(TO.hourly_target(asset, 0), float)
ts1 = df1h["timestamp"].values.astype(np.int64) + 3_600_000
pos = np.zeros(len(ts1))
L1h = np.ones(len(ts1))
if nm is None:
sizes = size_flat(ex)
elif nm in SZ:
sizes = SZ[nm](ex)
elif nm in VTL:
base_a = equity_daily(ex, size_flat(ex))
sizes = sizes_from_L(ex, leverage_series(base_a, **VTL[nm]))
else: # BOOKVT
L = leverage_series(book(TP, SLE["BASE"][can]), BVT[nm]["tv"], BVT[nm]["w"], BVT[nm]["cap"])
sizes = sizes_from_L(ex, L)
idx1h = pd.DatetimeIndex(pd.to_datetime(df1h["timestamp"].values, unit="ms", utc=True))
L1h = L.reindex(idx1h.floor("D")).ffill().fillna(1.0).values
t_in, t_out = ex["ts_close"][ex["i_ent"]], ex["ts_close"][ex["i_ex"]]
for k in range(len(t_in)):
j0 = int(np.searchsorted(ts1, t_in[k], side="left"))
j1 = int(np.searchsorted(ts1, t_out[k], side="left"))
pos[j0:j1] = ex["dir"][k] * sizes[k]
return np.clip(W_TP * tp * L1h + W_SKH * pos, -CAP_ASSET, CAP_ASSET)
if __name__ == "__main__":
main()
+710
View File
@@ -0,0 +1,710 @@
"""R0822 VRP-QUOTE-VERE — esiste una struttura di reddito in opzioni ESEGUIBILE a $600-3.000
che sopravvive a fee reali, spread reali e lotto minimo?
NON e' "rifare VRP01". La domanda e' se la regola dura del progetto *niente short-vol da
MODELLO in deploy* (19/06, riconfermata 3 volte) si possa sostituire con *short-vol da QUOTE*,
che e' un'altra cosa: il 30/07 il premio era ancora prezzato Black-Scholes su DVOL ATM e la
misura sulle quote reali dava f = 0.73 del modello, con il difetto NON nella gamba venduta
(f_short 1.01) ma nell'ALA che si compra (f_long 2.23). Da allora la condizione e' cambiata:
la catena vera la raccogliamo noi, oraria, per strike, con bid/ask.
ORDINE DELLE DOMANDE (deliberato). L'eseguibilita' viene PRIMA del rendimento regola del
30/07: "prima di misurare il rendimento a un capitale dato, misurare il lotto minimo del venue".
Se non si esegue, il resto e' accademia.
§1 LIMITE DI REGIME risultato di prima riga, non nota a pie' di pagina
§2 IL CAMPIONE quante settimane ci sono DAVVERO, e perche'
§3 ESEGUIBILITA' — lotti, famiglie, e su quale delle due si esegue
§4 BACKTEST SU QUOTE REALI griglia dichiarata, fill al bid/ask, fee di listino
§5 IL RISCHIO STA NEL MINIMO non nelle chiusure settimanali
§6 GATE inclusi quelli che NON si possono far girare
§7 VERDETTO
CONVENZIONI CHE DECIDONO IL RISULTATO (dichiarate qui, non sepolte nel codice)
(a) FILL: si vende al BID e si compra all'ASK, sempre. Il mid e' il modello sotto mentite
spoglie e compare solo come diagnostica, mai come cella selezionabile.
(b) FEE: listino Deribit vero `min(0.03% del sottostante, 12.5% del premio della SINGOLA
opzione)` per gamba, piu' la fee di consegna sulle gambe ITM a scadenza. Lo sleeve usa
invece un forfait del 12.5% del credito NETTO: qui si misura di quanto sbaglia.
(c) SETTLEMENT: indice reale alla scadenza dal feed certificato. Deribit regola sulla media
dell'indice nei 30 minuti prima delle 08:00 UTC; qui si usa la barra 1h di chiusura —
approssimazione dichiarata, non nascosta.
(d) CAPITALE IMPEGNATO: il denominatore e' il rischio massimo della struttura
`(K_short - K_long) x lotto - credito`. E' il limite INFERIORE del capitale richiesto;
quello vero dipende dal regime di margine del conto e NON e' leggibile da dati pubblici.
Riportato anche il limite superiore (cash-secured, la convenzione del sleeve).
RAM: la catena si legge filtrata in LETTURA (solo put, solo le colonne che servono) con
pyarrow.dataset `bite_archive.parquet` intero esplode in memoria e ucciderebbe gli altri agenti.
nice -n 19 timeout 900 uv run python scripts/research/r0822_vrp_real_quotes.py
... --no-live # salta le letture dal venue (usa la cache se c'e')
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.request
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"))
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
import altlib as A # noqa: E402
import cblib as CB # noqa: E402
CHAIN_DIR = ROOT / "data" / "raw" / "cb_chain"
SNAP_JSONL = ROOT / "data" / "options_daily" / "snapshots.jsonl"
CACHE = Path("/tmp/claude-1001/-opt-docker-PythagorasGoal/"
"b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad/r0822_venue.json")
# --- listino Deribit (fonte: campo `taker_commission` letto dal venue + schema fee pubblico) ---
FEE_UNDERLYING = 0.0003 # 0.03% del sottostante per gamba, per contratto
FEE_PREMIUM_CAP = 0.125 # cap: 12.5% del premio della SINGOLA opzione
FEE_DELIVERY = 0.00015 # 0.015% del sottostante sulle gambe ITM a scadenza
SLEEVE_FEE_FRAC = 0.125 # forfait dello sleeve: 12.5% del credito NETTO
DTE_LO, DTE_HI, DTE_TARGET = 4.0, 10.0, 7.0
WEEKS_PER_YEAR = 52.0
# ---------------------------------------------------------------- griglia DICHIARATA
# Si conta al RIALZO: ogni variante provata e' un trial, anche quelle scartate.
SHORT_DELTAS = (-0.35, -0.28, -0.20, -0.12)
LONG_DELTAS = (-0.20, -0.10, -0.05)
CELLS = [(s, l) for s in SHORT_DELTAS for l in LONG_DELTAS if abs(l) < abs(s)]
FILLS = ("bid_ask", "mid") # `mid` = diagnostica, NON selezionabile
ASSETS = ("BTC", "ETH")
CANON = (-0.28, -0.10) # la cella di VRP01
def hr(t: str = "", ch: str = "=") -> None:
print("\n" + ch * 100)
if t:
print(t)
print(ch * 100)
# ================================================================== dati
def load_puts() -> pd.DataFrame:
"""Solo le PUT, solo le colonne che servono, filtrate in LETTURA."""
import pyarrow.dataset as ds
cols = ["ts", "asset", "instrument_name", "strike", "option_type", "exp",
"bid", "ask", "mid", "iv", "delta", "open_interest", "quote_status"]
d = ds.dataset(str(CHAIN_DIR), format="parquet")
df = d.to_table(columns=cols, filter=(ds.field("option_type") == "P")).to_pandas()
df["ts"] = pd.to_datetime(df["ts"], utc=True).dt.as_unit("ns")
df["exp"] = pd.to_datetime(df["exp"], utc=True)
df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0
return df.drop_duplicates(subset=["ts", "instrument_name"], keep="last").reset_index(drop=True)
def venue_read(live: bool) -> dict:
"""Specifiche e liquidita' lette DAL VENUE. Un fallimento si dichiara, non si inventa."""
if not live and CACHE.exists():
return json.loads(CACHE.read_text())
def api(path, **kw):
q = "&".join(f"{k}={v}" for k, v in kw.items())
with urllib.request.urlopen(
f"https://www.deribit.com/api/v2/public/{path}?{q}", timeout=25) as r:
return json.load(r)["result"]
out = {"ok": False, "ts": pd.Timestamp.now(tz="UTC").isoformat()}
try:
out["index"] = {c: api("get_index_price", index_name=f"{c}_usd")["index_price"]
for c in ("btc", "eth")}
specs, summ = {}, []
for cur in ("BTC", "ETH", "USDC"):
for i in api("get_instruments", currency=cur, kind="option", expired="false"):
fam = i["instrument_name"].split("-")[0]
if fam not in specs:
specs[fam] = {k: i.get(k) for k in
("min_trade_amount", "contract_size", "settlement_currency",
"quote_currency", "taker_commission", "base_currency")}
summ += api("get_book_summary_by_currency", currency=cur, kind="option")
time.sleep(0.4)
out["specs"] = specs
out["summary"] = [{k: r.get(k) for k in
("instrument_name", "bid_price", "ask_price", "mid_price",
"mark_price", "mark_iv", "open_interest", "volume",
"underlying_price")} for r in summ]
out["ok"] = True
CACHE.parent.mkdir(parents=True, exist_ok=True)
CACHE.write_text(json.dumps(out))
except Exception as exc: # noqa: BLE001
out["error"] = f"{type(exc).__name__}: {exc}"
if CACHE.exists():
cached = json.loads(CACHE.read_text())
cached["stale"] = out["error"]
return cached
return out
def specs_from_disk() -> dict:
"""Le specifiche gia' registrate dal progetto (`data/options_daily/snapshots.jsonl`).
Serve come controllo indipendente della lettura live: due percorsi, stesso numero."""
mins, fees, last_ts = {}, {}, {}
if not SNAP_JSONL.exists():
return {}
with SNAP_JSONL.open() as f:
for line in f:
try:
r = json.loads(line)
except Exception: # noqa: BLE001, S112
continue
if r.get("rec") != "chain":
continue
c = r["currency"]
mins.setdefault(c, set()).add(r.get("min_trade_amount"))
fees.setdefault(c, set()).add(r.get("taker_comm"))
last_ts[c] = r["snap_ts"]
return {c: {"min": sorted(x for x in mins[c] if x is not None),
"taker": sorted(x for x in fees[c] if x is not None),
"ts": pd.Timestamp(last_ts[c], unit="ms", tz="UTC")} for c in mins}
# ================================================================== struttura
def leg_fee_usd(prem_base: float, spot: float) -> float:
"""Fee di listino per UNA gamba, per contratto (1 unita' di sottostante), in USD."""
return min(FEE_UNDERLYING * spot, FEE_PREMIUM_CAP * prem_base * spot)
def entry_snapshots(puts: pd.DataFrame, asset: str, offset_h: float = 0.0) -> dict:
"""Per ogni scadenza settimanale: lo snapshot piu' vicino a (7 giorni + offset) dalla scadenza.
L'offset e' l'ANCORA: entrare alle 08:00 del venerdi' precedente e' UNA delle scelte possibili,
e questo progetto ha misurato 4 volte che l'ancora e' un max-of-k non dichiarato.
"""
p = puts[(puts["asset"] == asset) & (puts["dte"] >= DTE_LO) & (puts["dte"] <= DTE_HI)]
tgt = DTE_TARGET + offset_h / 24.0
out = {}
for exp, g in p.groupby("exp"):
ts_all = np.array(sorted(g["ts"].unique()))
if not len(ts_all):
continue
dte = (exp - pd.DatetimeIndex(ts_all)).total_seconds() / 86400.0
ok = (dte >= DTE_LO) & (dte <= DTE_HI)
if not ok.any():
continue
cand = ts_all[ok][np.argsort(np.abs(dte[ok] - tgt))]
for ts in cand[:6]: # al piu' 6 tentativi per scadenza
snap = g[g["ts"] == ts]
if snap["bid"].notna().sum() >= 2:
out[exp] = (pd.Timestamp(ts), snap)
break
return out
def build_trades(puts: pd.DataFrame, asset: str, short_d: float, long_d: float,
fill: str = "bid_ask", offset_h: float = 0.0,
lot: float = 1.0, today: pd.Timestamp | None = None) -> pd.DataFrame:
"""Un trade per scadenza: ingresso su quote REALI, tenuto a scadenza, regolato sull'indice."""
today = today or pd.Timestamp.now(tz="UTC")
S = CB.spot_series(asset)
V = CB.dvol_series(asset)
rows, rejected = [], {"gambe_mancanti": 0, "credito_non_positivo": 0}
for exp, (ts, snap) in entry_snapshots(puts, asset, offset_h).items():
if exp >= today: # non ancora regolata: non e' un trade
continue
legs = CB.pick_legs(snap, short_d, long_d)
if legs is None:
rejected["gambe_mancanti"] += 1
continue
spot = float(S.asof(ts))
dte = (exp - ts).total_seconds() / 86400.0
dvol = float(V.asof(ts))
hist = V[V.index < ts]
ivr = float((hist < dvol).mean()) if len(hist) else np.nan
if fill == "bid_ask":
ps, pl = legs["bid_short"], legs["ask_long"] # vendi al bid, compra all'ask
else:
ps, pl = legs["mid_short"], legs["mid_long"]
if not (np.isfinite(ps) and np.isfinite(pl)):
rejected["gambe_mancanti"] += 1
continue
cred = (ps - pl) * spot # USD per 1 unita' di sottostante
if cred <= 0:
# premio non monotono nello strike (difetto certificato di `certify_cb_chain`):
# una put piu' OTM quotata sopra una meno OTM. Non e' una struttura, e' una quota rotta.
rejected["credito_non_positivo"] += 1
continue
ST = float(S.asof(exp))
payoff = max(legs["k_short"] - ST, 0.0) - max(legs["k_long"] - ST, 0.0)
fee = leg_fee_usd(ps, spot) + leg_fee_usd(pl, spot)
for K in (legs["k_short"], legs["k_long"]): # consegna sulle gambe ITM
intr = max(K - ST, 0.0)
if intr > 0:
fee += min(FEE_DELIVERY * ST, FEE_PREMIUM_CAP * intr)
width = legs["k_short"] - legs["k_long"]
mod = CB.f_factors(legs, spot, dvol / 100.0, dte)
rows.append(dict(
asset=asset, ts=ts, exp=exp, dte=dte, spot=spot, ST=ST,
inst_short=legs["inst_short"], inst_long=legs["inst_long"],
cred_real=cred, # per 1 unita': lo vuole CB.close_cost_path
k_short=legs["k_short"], k_long=legs["k_long"], width=width,
d_short=legs["d_short"], d_long=legs["d_long"],
cred=cred * lot, payoff=payoff * lot, fee=fee * lot,
pnl=(cred - payoff - fee) * lot,
fee_sleeve=SLEEVE_FEE_FRAC * mod["cred_mod"] * lot,
fee_sleeve_su_reale=SLEEVE_FEE_FRAC * cred * lot,
cred_mod=mod["cred_mod"] * lot,
risk=width * lot - cred * lot,
dvol=dvol, ivrank=ivr, f_net=mod["f_net"],
und_ret=ST / spot - 1.0,
))
df = pd.DataFrame(rows)
if not df.empty:
df = df.sort_values("exp").reset_index(drop=True)
df.attrs["rejected"] = rejected
return df
def stats(tr: pd.DataFrame) -> dict:
"""Metriche di una serie di trade settimanali. Il denominatore e' il RISCHIO impegnato."""
if tr.empty or len(tr) < 2:
return dict(n=len(tr), sharpe=np.nan, mean=np.nan, t=np.nan)
r = (tr["pnl"] / tr["risk"]).to_numpy(float) # ritorno sul capitale a rischio
sd = float(np.std(r, ddof=1))
m = float(np.mean(r))
eq = np.cumprod(1.0 + r)
dd = float(np.max((np.maximum.accumulate(eq) - eq) / np.maximum.accumulate(eq)))
return dict(n=len(tr), mean=m, sd=sd,
sharpe=(m / sd * np.sqrt(WEEKS_PER_YEAR)) if sd > 0 else np.nan,
t=(m / (sd / np.sqrt(len(r)))) if sd > 0 else np.nan,
maxdd_close=dd, wins=int((tr["pnl"] > 0).sum()),
usd_week=float(tr["pnl"].mean()), usd_tot=float(tr["pnl"].sum()))
def weekly_series(tr: pd.DataFrame) -> pd.Series:
return pd.Series((tr["pnl"] / tr["risk"]).to_numpy(float),
index=pd.DatetimeIndex(tr["exp"])).sort_index()
def boot_ci(x: np.ndarray, n: int = 4000, seed: int = 822) -> tuple[float, float]:
rng = np.random.default_rng(seed)
if len(x) < 2:
return (np.nan, np.nan)
bs = rng.choice(x, size=(n, len(x)), replace=True).mean(axis=1)
return float(np.percentile(bs, 2.5)), float(np.percentile(bs, 97.5))
# ================================================================== main
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--no-live", action="store_true", help="non interrogare il venue")
args = ap.parse_args()
print(__doc__.split("\n\n")[0])
puts = load_puts()
today = pd.Timestamp.now(tz="UTC")
venue = venue_read(not args.no_live)
# ------------------------------------------------------------ §1 REGIME
hr("§1 LIMITE DI REGIME — il campione descrive il regime in cui la strategia sta FLAT")
print("Non e' una nota a pie' di pagina: VRP01 entra SOLO se IV-rank > 0.30 (il gate e' l'alpha,\n"
"misurato 4 volte). Se nel campione quel gate non scatta mai, il campione non contiene\n"
"la strategia — contiene la sua astensione.\n")
for a in ASSETS:
V = CB.dvol_series(a)
win = V[(V.index >= puts["ts"].min()) & (V.index <= puts["ts"].max())]
pct = float((V[V.index < win.index[0]].values[:, None] < win.values).mean())
print(f" {a}: DVOL nel campione min {win.min():.1f} / mediana {win.median():.1f} / "
f"max {win.max():.1f} → percentile mediano vs storia 2021+ = {pct:.0%}")
# ------------------------------------------------------------ §2 CAMPIONE
hr("§2 IL CAMPIONE — quante settimane ci sono DAVVERO")
pre = puts[puts["ts"] < pd.Timestamp("2026-06-09", tz="UTC")]
print(f" catena su disco: {puts['ts'].min():%Y-%m-%d}{puts['ts'].max():%Y-%m-%d} "
f"({(puts['ts'].max() - puts['ts'].min()).days} giorni, {len(puts):,} righe put)")
if not pre.empty:
print(f" ma PRIMA del 2026-06-09 l'archivio ereditato seguiva UNA sola scadenza, in "
f"finestra {pre['dte'].min():.0f}-{pre['dte'].max():.0f} DTE:")
print(f" → la finestra settimanale {DTE_LO:.0f}-{DTE_HI:.0f} DTE NON ESISTE prima "
f"della raccolta full-chain.")
print("\n Trade REGOLATI utilizzabili (cella canonica VRP01 δ-0.28/-0.10, fill bid/ask):")
canon: dict[str, pd.DataFrame] = {}
for a in ASSETS:
lot = 1.0
tr = build_trades(puts, a, *CANON, lot=lot, today=today)
canon[a] = tr
rej = tr.attrs.get("rejected", {}) if not tr.empty else {}
print(f" {a}: {len(tr)} settimane regolate ({tr['exp'].min():%Y-%m-%d}"
f"{tr['exp'].max():%Y-%m-%d}) scartate: {rej}")
print("\n ⚠️ «3,7 mesi ≈ 16 scadenze settimanali» e' FALSO su questi dati: sono 10 per asset.")
print("\n Gate IV-rank>0.30 di VRP01 sulle settimane utilizzabili:")
tot = passed = 0
for a in ASSETS:
tr = canon[a]
n = int((tr["ivrank"] > 0.30).sum())
tot += len(tr)
passed += n
print(f" {a}: {n}/{len(tr)} passano (IV-rank min {tr['ivrank'].min():.3f} / "
f"max {tr['ivrank'].max():.3f})")
print(f" TOTALE: {passed}/{tot}. Il sleeve sarebbe stato FLAT per l'intero campione.")
ret = {a: (canon[a]["ST"].iloc[-1] / canon[a]["spot"].iloc[0] - 1.0) for a in ASSETS}
print(f"\n E il sottostante nel campione: BTC {ret['BTC']:+.1%}, ETH {ret['ETH']:+.1%}"
f"vol implicita bassa E rialzo forte\n = il regime IDEALE per vendere put. "
f"Il campione e' il migliore possibile per questa struttura, non uno neutro.")
# ------------------------------------------------------------ §3 ESEGUIBILITA'
hr("§3 ESEGUIBILITA' — e qui il filone cambia domanda")
fams = sorted({n.split("-")[0] for n in puts["instrument_name"].head(50_000)})
print(f" La catena che raccogliamo contiene le famiglie: {fams}")
print(" → sono le opzioni INVERSE: quotate, marginate e regolate in BTC/ETH.")
print(" Il conto e' in USDC ($635) e il book esegue sui perpetual LINEARI USDC.\n")
disk = specs_from_disk()
if disk:
print(" Specifiche gia' registrate dal progetto (data/options_daily/snapshots.jsonl):")
for c, v in sorted(disk.items()):
print(f" {c:6s} min_trade_amount={v['min']} taker={v['taker']} ({v['ts']:%Y-%m-%d %H:%M})")
if venue.get("ok"):
idx = venue["index"]
print(f"\n Lette dal venue ora — indice BTC ${idx['btc']:,.0f} / ETH ${idx['eth']:,.0f}:")
print(f" {'famiglia':11s} {'min lotto':>10s} {'settle':>7s} {'quote':>7s} "
f"{'$ nozionale/lotto':>18s}")
for fam in ("BTC", "ETH", "BTC_USDC", "ETH_USDC"):
s = venue["specs"].get(fam)
if not s:
continue
base = (s.get("base_currency") or fam.split("_")[0]).lower()
notion = float(s["min_trade_amount"]) * idx[base]
print(f" {fam:11s} {s['min_trade_amount']:>10} {str(s['settlement_currency']):>7s} "
f"{str(s['quote_currency']):>7s} {notion:>17,.0f}$")
print("\n 📌 Esiste una famiglia USDC-lineare per BTC ed ETH, con lotto minimo 10x PIU'")
print(" PICCOLO e regolata nella valuta del conto. Il muro «BTC fuori a $3.000» del")
print(" 30/07 — congelato in tests/test_vrp_profit_take.py — e' stato misurato sulla")
print(" famiglia che il conto NON margina.")
print(" E' la 3a occorrenza della stessa forma dopo fee_watch (21/08, sorvegliava gli")
print(" INVERSE mentre il book trada i LINEARI) e la taratura di venue_watch.")
# --- ma la famiglia eseguibile e' quotata davvero? ---
d = pd.DataFrame(venue["summary"])
parts = d["instrument_name"].str.split("-")
d["fam"] = parts.str[0]
d["typ"] = parts.str[3]
d["strike"] = pd.to_numeric(parts.str[2].str.replace("d", ".", regex=False), errors="coerce")
d["expd"] = pd.to_datetime(parts.str[1], format="%d%b%y", utc=True) + pd.Timedelta(hours=8)
d["dte"] = (d["expd"] - today).dt.total_seconds() / 86400.0
d["base"] = d["fam"].str.replace("_USDC", "", regex=False).str.lower()
d = d[d["typ"].eq("P") & d["base"].isin(["btc", "eth"])].copy()
d["S"] = d["base"].map(idx)
d["mny"] = d["strike"] / d["S"]
q = d[(d["bid_price"] > 0) & (d["ask_price"] > 0)].copy()
q["relsp"] = (q["ask_price"] - q["bid_price"]) / q["mid_price"]
# regione delle put OTM usate dalla struttura (δ -0.35..-0.05 ≈ moneyness 0.78-0.97)
w = q[(q["dte"] > 3) & (q["dte"] < 11) & q["mny"].between(0.78, 0.97)]
print("\n Liquidita' REALE nella regione di strike che serve (scadenza settimanale, ora):")
print(f" {'famiglia':11s} {'n quotate':>9s} {'spread rel. med':>16s} "
f"{'p75':>7s} {'OI med':>9s} {'vol 24h':>10s}")
for fam, g in w.groupby("fam"):
allf = d[d["fam"] == fam]
print(f" {fam:11s} {len(g):>9d} {g['relsp'].median():>15.0%} "
f"{g['relsp'].quantile(.75):>6.0%} {g['open_interest'].median():>9.1f} "
f"{allf['volume'].sum():>10,.0f}")
print("\n Lo spread relativo e' ENORME in entrambe le famiglie (il mid non e' un prezzo\n"
" eseguibile: e' la media di due prezzi lontani), e sulla famiglia USDC — l'unica\n"
" marginabile dal conto — l'open interest e' ~0: quotata da un market maker,\n"
" praticamente non tradata. Comprare l'ala li' dentro costa il doppio.")
else:
print(f"\n ⚠️ LETTURA DAL VENUE NON RIUSCITA: {venue.get('error', 'n/d')}")
print(" Le specifiche delle famiglie USDC restano NON MISURATE in questo giro.")
print("\n Capitale per UN lotto minimo, cella canonica (limiti dichiarati, vedi docstring):")
print(f" {'asset':6s} {'lotto':>7s} {'width med':>10s} {'rischio max':>12s} "
f"{'cash-secured':>13s} {'credito/sett':>13s}")
minlot = {"BTC": 0.1, "ETH": 1.0}
if venue.get("ok"):
for fam in ("BTC", "ETH"):
if fam in venue["specs"]:
minlot[fam] = float(venue["specs"][fam]["min_trade_amount"])
for a in ASSETS:
tr, lot = canon[a], minlot[a]
wid = float(tr["width"].median())
print(f" {a:6s} {lot:>7.2f} {wid:>10,.0f} {wid * lot - tr['cred'].median() * lot:>11,.0f}$ "
f"{tr['k_short'].median() * lot:>12,.0f}$ {tr['cred'].median() * lot:>12,.2f}$")
print(" Sul rischio DEFINITO un lotto minimo inverse sta dentro $600. Sul cash-secured no.\n"
" Quale dei due valga dipende dal regime di margine del conto, che NON e' leggibile\n"
" da dati pubblici: si legge sul conto. E' un limite di questa misura, non un dettaglio.")
# ------------------------------------------------------------ §4 BACKTEST
hr("§4 BACKTEST SU QUOTE REALI — griglia dichiarata")
n_trials = len(CELLS) * len(FILLS) * (len(ASSETS) + 1)
print(f" celle δ (short,long): {len(CELLS)} fill: {len(FILLS)} "
f"asset: {len(ASSETS)} + combinato 50/50")
print(f" → TRIAL DICHIARATI AL RIALZO: {n_trials}. Nessuna griglia ridotta per budget.")
print(f" (Il fill `mid` non e' selezionabile — e' la diagnostica di quanto costa lo spread —\n"
f" ma conta come trial: e' stato guardato.)\n")
grid = []
for fill in FILLS:
for (sd, ld) in CELLS:
per, comb = {}, []
for a in ASSETS:
tr = build_trades(puts, a, sd, ld, fill=fill, lot=1.0, today=today)
per[a] = stats(tr)
if not tr.empty:
comb.append(weekly_series(tr))
row = dict(fill=fill, sd=sd, ld=ld)
for a in ASSETS:
row[f"{a}_sh"] = per[a]["sharpe"]
row[f"{a}_n"] = per[a]["n"]
if comb:
c = pd.concat(comb, axis=1).mean(axis=1).dropna()
row["comb_sh"] = (float(c.mean() / c.std(ddof=1) * np.sqrt(WEEKS_PER_YEAR))
if c.std(ddof=1) > 0 else np.nan)
row["comb_n"] = len(c)
grid.append(row)
G = pd.DataFrame(grid)
print(G.round(2).to_string(index=False))
print("\n ⚠️ COME SI LEGGE LA TABELLA SOPRA. Gli 'Sharpe' a due cifre NON sono un risultato:\n"
" sono la firma del campione. Annualizzare (x√52) 10 osservazioni settimanali in cui\n"
" la coda sinistra non si e' manifestata produce numeri privi di significato — e il\n"
" fatto che l'INTERA griglia li produca e' esattamente il segnale, non un caso da\n"
" selezionare. E' la 4a occorrenza della firma '0-perdite / Sharpe implausibile'\n"
" dopo CC01, le celle deep-OTM Albimarini e meta' della griglia VRP del 03/07.\n"
" Per lo stesso motivo NON viene riportato un CAGR: comporre 10 settimane a 52\n"
" sarebbe una fabbricazione, non una stima.")
print("\n Cella canonica di VRP01 (δ-0.28/-0.10), fill conservativo, per asset — numeri\n"
" CONCRETI (dollari per lotto minimo), che a questa taglia dicono piu' di un rapporto:")
print(f" {'asset':6s} {'n':>3s} {'vinte':>7s} {'$/sett':>9s} {'$ tot':>9s} "
f"{'% rischio/sett':>15s} {'t':>6s} {'maxDD chiusure':>15s}")
st = {}
for a in ASSETS:
tr = canon[a].copy()
tr[["cred", "payoff", "fee", "pnl", "risk", "cred_mod",
"fee_sleeve", "fee_sleeve_su_reale"]] *= minlot[a]
st[a] = stats(tr)
s = st[a]
print(f" {a:6s} {s['n']:>3d} {s['wins']:>3d}/{s['n']:<3d} {s['usd_week']:>8.2f}$ "
f"{s['usd_tot']:>8.2f}$ {s['mean']:>14.2%} {s['t']:>6.2f} {s['maxdd_close']:>14.1%}")
print(" maxDD 0.0% su BTC significa che NON C'E' STATA una settimana in perdita: e' una\n"
" proprieta' del campione, non della struttura.")
print("\n Costo dello SPREAD, misurato (stesso ingresso, fill al mid vs al bid/ask):")
for a in ASSETS:
ba = build_trades(puts, a, *CANON, fill="bid_ask", today=today)
md = build_trades(puts, a, *CANON, fill="mid", today=today)
j = ba.merge(md, on="exp", suffixes=("_ba", "_md")) # appaiato per INGRESSO
if j.empty:
continue
print(f" {a}: credito medio al mid ${j['cred_md'].mean():.2f} → al bid/ask "
f"${j['cred_ba'].mean():.2f} = si perde il "
f"{1 - j['cred_ba'].mean() / j['cred_md'].mean():.0%} del credito solo attraversando "
f"lo spread")
print("\n Il f del 30/07, ri-misurato su 10 settimane invece di 8 (fill conservativo):")
for a in ASSETS:
f = canon[a]["f_net"].dropna()
lo, hi = boot_ci(f.to_numpy())
print(f" {a}: f_net mediana {f.median():.3f} media {f.mean():.3f} "
f"IC95 [{lo:.3f}, {hi:.3f}] ≥1.0 in {int((f >= 1).sum())}/{len(f)}")
allf = pd.concat([canon[a]["f_net"] for a in ASSETS]).dropna()
lo, hi = boot_ci(allf.to_numpy())
print(f" pooled: {allf.median():.3f} (IC95 [{lo:.3f}, {hi:.3f}], n={len(allf)}) — "
f"replica indipendente dello 0.73 del 30/07")
print("\n Fee: listino vero contro il forfait dello sleeve (12.5% del credito NETTO).")
print(" ⚠️ Il confronto giusto e' col credito MODELLATO — e' quello a cui il sleeve applica\n"
" il forfait. Misurarlo sul credito reale (piu' piccolo di ~30%) sottostima l'errore:")
for a in ASSETS:
tr = canon[a]
print(f" {a}: fee reale ${tr['fee'].mean():.2f}/settimana per contratto vs forfait sul\n"
f" credito MODELLATO ${tr['fee_sleeve'].mean():.2f} (= {tr['fee_sleeve'].mean() / tr['fee'].mean():.2f}x)"
f" · sul credito reale ${tr['fee_sleeve_su_reale'].mean():.2f} "
f"(= {tr['fee_sleeve_su_reale'].mean() / tr['fee'].mean():.2f}x)")
print(" Il '~2x' pubblicato il 30/07 non si replica qui: su queste 19 settimane il forfait\n"
" sovrastima le fee di 1.1-1.9x a seconda dell'asset e della base di confronto.\n"
" Resta vero il segno (il forfait e' CONSERVATIVO), non la taglia.")
# ------------------------------------------------------------ §5 IL MINIMO
hr("§5 IL RISCHIO STA NEL MINIMO — le chiusure settimanali non lo vedono")
print(" Regola del 30/07: un rischio valutato sul minimo non si misura sulle chiusure.\n"
" Qui il mark infra-settimana viene dalle standing quotes ORARIE delle STESSE due gambe\n"
" (ricomprare la corta all'ask, rivendere la lunga al bid: di nuovo conservativo).\n")
worst_rows = []
for a in ASSETS:
print(f" {a} (lotto {minlot[a]})")
for _, r in canon[a].iterrows():
path = CB.close_cost_path(r, puts)
if path.empty:
continue
lot = minlot[a]
risk = r["width"] * lot - r["cred"] * lot
worst = float(path["pnl_aperto"].min()) * lot
worst_rows.append(dict(asset=a, exp=r["exp"], worst=worst, risk=risk,
frac=worst / risk, pnl=r["pnl"] * lot,
n_marks=len(path)))
flag = " ← chiusa in UTILE" if r["pnl"] > 0 and worst < 0 else ""
print(f" {r['exp']:%Y-%m-%d} marks={len(path):3d} "
f"peggior mark {worst:>9.2f}$ = {worst / risk:>7.1%} del rischio "
f"esito {r['pnl'] * lot:>7.2f}${flag}")
W = pd.DataFrame(worst_rows)
if not W.empty:
print(f"\n Peggior mark infra-settimana: mediana {W['frac'].median():.1%} del rischio, "
f"minimo {W['frac'].min():.1%}")
wl = W[W["pnl"] > 0]
print(f" Fra le sole settimane chiuse in UTILE ({len(wl)}/{len(W)}): mediana "
f"{wl['frac'].median():.1%}, minimo {wl['frac'].min():.1%}")
print(" → il maxDD sulle chiusure e' una lente che non vede il rischio che si e' corso.")
# ------------------------------------------------------------ §6 GATE
hr("§6 GATE — compresi quelli che NON si possono far girare")
print(" (a) implausible_sharpe — il gate del 26/07 sul rischio FUORI dal campione")
for a in ASSETS:
tr = canon[a]
daily = pd.Series((tr["pnl"] / tr["risk"]).to_numpy(float),
index=pd.DatetimeIndex(tr["exp"]))
rep = A.implausible_sharpe(daily, n_trades=len(tr), n_losing_trades=int((tr["pnl"] <= 0).sum()))
print(f" {a}: implausible={rep['implausible']} vinte {int((tr['pnl'] > 0).sum())}/{len(tr)}")
for why in rep["reasons"]:
print(f" · {why}")
if int((tr["pnl"] <= 0).sum()) == 0:
print(f" · regola del tre: 0 perdite su {len(tr)} lascia un tasso VERO fino a "
f"{3 / len(tr):.0%}. Su un payoff che perde {tr['risk'].median() / tr['cred'].median():.1f}x "
f"il credito, basta a ribaltare l'expectancy.")
print("\n (b) deflated_sharpe — NON CALCOLABILE, e il perche' e' il risultato")
for a in ASSETS:
s = weekly_series(canon[a])
dsr, _ = A.deflated_sharpe(st[a]["sharpe"], [r["comb_sh"] for r in grid if
np.isfinite(r.get("comb_sh", np.nan))],
s, dpy=WEEKS_PER_YEAR)
print(f" {a}: DSR = {dsr} (T={len(s)} osservazioni settimanali; la funzione "
f"richiede T>=30)")
print(" Costruire una serie GIORNALIERA per superare il T>=30 sarebbe barare: uno sleeve\n"
" settimanale su griglia giornaliera e' ~94% di zeri (lezione 26/07 su VRP01), e il\n"
" T gonfiato entra nel deflated-Sharpe come se fossero osservazioni indipendenti.\n"
" Statistica onesta a questa taglia = il t con il suo intervallo:")
for a in ASSETS:
r = (canon[a]["pnl"] / canon[a]["risk"]).to_numpy(float)
lo, hi = boot_ci(r)
print(f" {a}: media settimanale {r.mean():+.2%} del rischio, IC95 "
f"[{lo:+.2%}, {hi:+.2%}], t={st[a]['t']:.2f}, n={len(r)}")
print("\n (c) anchor_luck_band — l'ora d'ingresso e' un'ancora (max-of-k mai dichiarato)")
offsets = list(range(-24, 25, 6))
for a in ASSETS:
def by_off(o, _a=a):
return weekly_series(build_trades(puts, _a, *CANON, offset_h=float(o), today=today))
band = A.anchor_luck_band(
by_off, offsets, canonical=0,
metric=lambda s: (float(s.mean() / s.std(ddof=1) * np.sqrt(WEEKS_PER_YEAR))
if len(s) > 2 and s.std(ddof=1) > 0 else 0.0))
if band.get("reason"):
print(f" {a}: {band['reason']}")
continue
print(f" {a}: canonica {band['canonical']:.2f} (pctl {band['canonical_pctl']:.0%}) · "
f"MEDIANA ONESTA {band['median']:.2f} · banda [{band['lo']:.2f}, {band['hi']:.2f}] · "
f"positiva in {band['frac_positive']:.0%} di {band['n_anchors']} ancore · "
f"fortuna {band['luck']:+.2f}")
print("\n (d) null del de-levering / beta — 'e' reddito da volatilita' o crypto travestito?'")
print(" Lo spread ha delta positivo (≈ +0.18 all'ingresso): in un campione che sale del")
print(" 20-50% guadagna PER COSTRUZIONE. Regressione del PnL settimanale sul ritorno")
print(" del sottostante nella stessa settimana:")
for a in ASSETS:
tr = canon[a]
x = tr["und_ret"].to_numpy(float)
y = (tr["pnl"] / tr["risk"]).to_numpy(float)
if len(x) < 4:
continue
b, alpha = np.polyfit(x, y, 1)
resid = y - (b * x + alpha)
se = float(np.std(resid, ddof=2) / (np.std(x) * np.sqrt(len(x))))
print(f" {a}: beta {b:+.3f} (t={b / se:.2f}) alpha {alpha:+.2%}/settimana "
f"corr {np.corrcoef(x, y)[0, 1]:+.2f}")
k = abs(b)
lev = pd.Series(k * x, index=pd.DatetimeIndex(tr["exp"]))
sh_lev = (float(lev.mean() / lev.std(ddof=1) * np.sqrt(WEEKS_PER_YEAR))
if lev.std(ddof=1) > 0 else np.nan)
print(f" null: essere semplicemente LONG il sottostante a leva {k:.2f} "
f"(stesso beta) da' Sharpe {sh_lev:.2f} contro {st[a]['sharpe']:.2f} della struttura")
print("\n (e) marginal_vs_tp01 — girato, ma NON interpretabile a questa taglia")
try:
comb = pd.concat([weekly_series(canon[a]) for a in ASSETS], axis=1).mean(axis=1).dropna()
dly = comb.resample("1D").sum().fillna(0.0)
rep = A.marginal_vs_tp01(dly)
act = int((dly != 0).sum())
print(f" verdetto={rep.get('verdict')} corr={rep.get('corr'):.3f} "
f"(barre ATTIVE {act}/{len(dly)} = {act / len(dly):.0%})")
print(f" ⚠️ {len(comb)} settimane su un hold-out che non esiste: il verdetto e' "
f"rumore, si riporta per non ometterlo.")
except Exception as exc: # noqa: BLE001
print(f" NON GIRATO: {type(exc).__name__}: {exc}")
print("\n (f) causality_ok — non girato su questo candidato, e perche'")
print(" `A.causality_ok` valuta target_fn su OHLCV BTC/ETH; qui il segnale non e' una")
print(" serie di prezzo ma una selezione di strumenti da uno snapshot. Verifica")
print(" equivalente eseguita per costruzione e controllata qui sotto:")
bad = 0
for a in ASSETS:
for _, r in canon[a].iterrows():
if r["ts"] >= r["exp"] or not (DTE_LO <= r["dte"] <= DTE_HI):
bad += 1
print(f" ingressi con ts >= scadenza o fuori finestra DTE: {bad}/"
f"{sum(len(canon[a]) for a in ASSETS)} (le gambe si scelgono da UNO snapshot allo\n"
f" stesso ts; il regolamento usa l'indice alla scadenza, mai prima)")
# ------------------------------------------------------------ §7 VERDETTO
hr("§7 VERDETTO")
print(""" SCARTATO — su questi dati la domanda non e' decidibile, e i tre motivi sono
indipendenti l'uno dall'altro:
1. IL CAMPIONE NON CONTIENE LA STRATEGIA. 0 settimane su 19 passano il gate IV-rank>0.30
che E' l'alpha di VRP01. Il campione e' vol implicita al 24-28 percentile E un rialzo
del +25%/+57%: il regime migliore possibile per vendere put, non uno neutro.
Le 10 settimane per asset (non 16: prima del 2026-06-09 la finestra 4-10 DTE non
esiste) danno 10/10 vincenti su BTC. La lente giusta non e' lo Sharpe, e' la regola
del tre: 0 perdite su 10 lascia un tasso vero fino al 30%, su un payoff che perde
~7x il credito.
2. LA FAMIGLIA CHE HA STORIA NON E' QUELLA CHE SI PUO' ESEGUIRE. Le quote raccolte
sono le opzioni INVERSE (margine e regolamento in BTC/ETH); il conto e' in USDC.
La famiglia USDC-lineare esiste per BTC ed ETH ed ha un lotto minimo 10x piu' piccolo
(BTC 0.01 invece di 0.1) quindi il muro «BTC fuori a $3.000» del 30/07, congelato
in un test, e' misurato sulla famiglia sbagliata. Ma di quella famiglia abbiamo ZERO
ore di storia e l'open interest e' ~0.
3. LO SPREAD E' IL COSTO DOMINANTE E NON E' MODELLABILE DA UN MID. Attraversare il
bid/ask costa da solo una quota grossa del credito, e l'ala che si COMPRA e' la gamba
dove il libro e' piu' largo lo stesso meccanismo che il 30/07 misurava come f=0.73.
Cio' che NON si conclude: che la struttura non funzioni. Non e' stata osservata nel regime
in cui lavora. La regola «niente short-vol da MODELLO in deploy» NON diventa «short-vol da
QUOTE»: le quote ora ci sono, ma sono quote della famiglia sbagliata, in un regime solo.
AZIONE RACCOMANDATA (non eseguita tocca la produzione, e la decisione e' dell'operatore):
far raccogliere a `collect_chain.py` anche `currency=USDC` (famiglie BTC_USDC/ETH_USDC).
Costa ~2 chiamate in piu' per giro; senza, fra sei mesi ci ritroveremo con un anno di
storia della famiglia che non possiamo tradare e zero di quella che potremmo.""")
if __name__ == "__main__":
main()
+717
View File
@@ -0,0 +1,717 @@
"""XS-LITE — esiste una versione CONCENTRATA (2-8 gambe) degli edge cross-sectional del progetto
che conservi una frazione utile dell'edge ed e' ESEGUIBILE OGGI a $600-5.000?
LA DOMANDA (mai posta in questa forma). Il progetto ha due edge cross-sectional che dichiara di non
poter eseguire per TAGLIA, non per debolezza:
XS01 19 major HL, long 5 / short 5, ribilancio ogni 10g. Dichiarato eseguibile da ~$20k.
XSR01 50 alt HL, residuo OLS su BTC demeanato cross-sezionalmente. Dichiarato ~$5k.
Il vincolo dichiarato e' il **min order** moltiplicato per il numero di gambe. Se si riducono le
gambe il vincolo si allenta ma l'ampiezza (il numero di scommesse indipendenti) crolla, e
l'ampiezza e' l'unica ragione per cui una strategia cross-sectional ha uno Sharpe alto.
Questo script misura il CAMBIO, non lo assume. E misura anche la premessa: che il muro sia il
min-order.
IPOTESI PRE-REGISTRATE (scritte prima di guardare i numeri):
(a) lo Sharpe scende con k come ~sqrt(ampiezza): k=2-3 conserva il 40-60% dell'edge;
(b) il min-order NON e' il muro di XS01 a $600 (a k=5 il ticket e' 0.5/5 del capitale dello
sleeve), e il "serve $20k" viene dal TURNOVER del vol-target, non dalla taglia della posizione;
(c) XSR01 concentrato perde molto di piu', perche' il suo edge E' l'ampiezza (il progetto ha gia'
misurato che il demeaning porta l'ampiezza 4.5 -> 37.4: e' quel numero, non il segnale, a
fare lo Sharpe 1.82).
Con 2.6 anni di storia monoregime il verdetto atteso e' LEAD o SCARTATO, mai CANDIDATO.
MECCANISMI CONGELATI non si rifitta nulla.
XS01 : blend lookback (30,90), gate dispersione p30 espandente causale, H=10, vol-target 20%,
cap 3x, fee 5 bps per unita' di turnover. UNICO parametro variato: k (gambe per lato).
Replica bit-exact di src.portfolio.sleeves._xsec_returns verificata al passo (0).
XSR01: W=45, sgn=+1, residuo OLS rolling causale su BTC, z-score, tanh, vol-target 20%, cap 2x,
demean cross-sezionale giornaliero, fee 5 bps/gamba. Segnale IMPORTATO da
r0725_statarb_multi, paniere da r0725_statarb_basket_gate (nessuna reimplementazione).
UNICO parametro variato: k.
Il LOOKBACK NON e' stato riaperto DI PROPOSITO: riaprire un parametro riapre la sua famiglia
(lezione VRP-tenore, 30/07) e moltiplicherebbe i trial. Qui si varia solo la CONCENTRAZIONE.
GRIGLIA DICHIARATA PRIMA DI MISURARE: 2 famiglie x 2 universi x 6 valori di k (+ il "pieno" di
XSR01) = 26 celle valutabili. Le 10 fasi del ciclo H=10 di XS01 sono una banda d'ANCORA, non una
griglia di parametri (regola del progetto: il deflated-Sharpe non le conta) -> riportate come
mediana onesta e come differenze APPAIATE. Il deflated-Sharpe e' comunque pubblicato a N=26 E a
N=156 (sensibilita' del verdetto al conteggio, lezione 30/07).
nice -n 19 timeout 900 uv run python scripts/research/r0822_xs_lite.py
"""
from __future__ import annotations
import sys
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
import altlib as A # noqa: E402
from src.portfolio.sleeves import XS_CFG, XS_UNIVERSE, _xsec_returns # noqa: E402
from r0725_statarb_multi import BASE, load_hl, pnl, signal # noqa: E402
from r0725_statarb_basket_gate import basket_from_positions, pair_frames # noqa: E402
RAW = ROOT / "data" / "raw"
ANN = np.sqrt(365.25)
HOLDOUT = A.HOLDOUT # 2025-01-01 (convenzione altlib)
MIN_ORDER_HL = 10.0 # Hyperliquid: min order $10 (r0702_capital_scaling)
MIN_ORDER_5 = 5.0 # il $5 di Deribit, citato nella missione
KS = (1, 2, 3, 4, 5, 6) # 5 = canonico XS01
COST_SWEEP = (0.0, 5.0, 10.0, 20.0, 30.0, 50.0) # bps per gamba TOTALI (fee + slippage)
# capitali dello SLEEVE. Il progetto tiene XS01 al 15% del book -> a book $600 lo sleeve ha $90.
# Si misurano entrambe le letture, perche' e' esattamente qui che nasce il numero "$20k".
SLEEVE_CAPS = (90.0, 300.0, 600.0, 750.0, 2000.0, 5000.0)
# =====================================================================================
# misure
# =====================================================================================
def sh(s) -> float:
r = np.asarray(pd.Series(s).dropna().values, float)
return float(r.mean() / r.std() * ANN) if len(r) > 30 and r.std() > 0 else 0.0
def dd(s) -> float:
r = np.nan_to_num(np.asarray(pd.Series(s).values, float))
eq = np.cumprod(1.0 + np.clip(r, -0.99, None))
return float(np.min(eq / np.maximum.accumulate(eq) - 1.0))
def cagr(s) -> float:
x = pd.Series(s).dropna()
if len(x) < 30:
return 0.0
yrs = (x.index[-1] - x.index[0]).days / 365.25
eq = float(np.prod(1.0 + np.clip(x.values, -0.99, None)))
return float(eq ** (1.0 / yrs) - 1.0) if yrs > 0.2 and eq > 0 else 0.0
def split(s):
x = pd.Series(s).dropna()
return x[x.index < HOLDOUT], x[x.index >= HOLDOUT]
HDR = f" {'cella':<20}{'ShFULL':>8}{'Sh IS':>8}{'Sh OOS':>8}{'maxDD':>9}{'CAGR':>9}"
def row(name, s, extra=""):
ins, out = split(s)
return (f" {name:<20}{sh(s):>8.2f}{sh(ins):>8.2f}{sh(out):>8.2f}"
f"{dd(s)*100:>8.1f}%{cagr(s)*100:>8.1f}%{extra}")
# =====================================================================================
# (A) XS01 congelato, con k e fase parametrici e universo a copertura variabile
# =====================================================================================
def load_closes(syms, how="inner") -> pd.DataFrame:
cols = {}
for s in syms:
p = RAW / f"hl_{s.lower()}_1d.parquet"
if not p.exists():
continue
d = pd.read_parquet(p, columns=["timestamp", "close"])
cols[s] = pd.Series(d["close"].values.astype(float),
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
C = pd.concat(cols, axis=1, join=("inner" if how == "inner" else "outer")).sort_index()
return C.dropna() if how == "inner" else C
def xs_run(C: pd.DataFrame, k: int, phase: int = 0):
"""Replica di sleeves._xsec_returns con k e fase parametrici, tollerante ai simboli con
copertura piu' corta (si valutano solo quelli con storia sufficiente a quella data: senza
questo, un outer-join su 51 alt riduce la finestra comune a ~2/3 e cancella l'in-sample).
Con tutte le colonne valide il percorso e' NUMERICAMENTE IDENTICO all'originale provato al
passo (0) con max|diff| = 0.
Ritorna (serie netta lente-sleeve, W [n x A], scale [n], dret [n x A])."""
px = C.values.astype(float)
n, na = px.shape
lookbacks, H, mode, tv = XS_CFG["lookbacks"], XS_CFG["H"], XS_CFG["mode"], XS_CFG["target_vol"]
disp_pct, minhist = XS_CFG["disp_pct"], XS_CFG["disp_minhist"]
mlb = max(lookbacks)
fin = np.isfinite(px)
prev = np.vstack([np.full(na, np.nan), px[:-1]])
dret = np.where(fin & np.isfinite(prev), px / prev - 1.0, 0.0)
dret = np.nan_to_num(dret)
W = np.zeros((n, na))
w = np.zeros(na)
disp_hist = []
for i in range(n):
if i >= mlb and (i - phase) % H == 0:
ok = fin[i].copy()
for L in lookbacks:
ok &= fin[i - L]
if ok.sum() >= 2 * k:
cols = np.where(ok)[0]
rLs = [px[i, cols] / px[i - L, cols] - 1.0 for L in lookbacks]
disp_i = float(np.mean([r.std() for r in rLs]))
thr = (np.percentile(disp_hist, disp_pct)
if (disp_pct > 0 and len(disp_hist) >= minhist) else -np.inf)
if disp_i >= thr:
score = np.zeros(len(cols)); cnt = 0
for rL in rLs:
sd = rL.std()
if sd > 0:
score += (rL - rL.mean()) / sd; cnt += 1
if cnt:
score /= cnt
order = np.argsort(score)
w = np.zeros(na)
lo, hi = cols[order[:k]], cols[order[-k:]]
if mode == "mom": w[hi] = 0.5 / k; w[lo] = -0.5 / k
else: w[lo] = 0.5 / k; w[hi] = -0.5 / k
else:
w = np.zeros(na)
disp_hist.append(disp_i)
W[i] = w
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum()
turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
net = gross - turn * (0.001 / 2.0)
s = pd.Series(net, index=C.index)
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
return pd.Series(s.values * scale, index=C.index), W, scale, dret
def xs_effective_weights(W: np.ndarray, scale: np.ndarray) -> np.ndarray:
"""Peso EFFETTIVO tenuto durante la barra i = W[i-1]*scale[i]. Entrambi noti a close[i-1]
(scale usa una rolling std gia' shiftata) -> causale."""
Weff = np.zeros_like(W)
Weff[1:] = W[:-1] * scale[1:, None]
return Weff
# =====================================================================================
# (B) XSR01 congelato, con k parametrico
# =====================================================================================
def xsr_positions(P: pd.DataFrame, k: int | None) -> pd.DataFrame:
"""Q = posizioni demeanate cross-sezionalmente (meccanismo congelato).
Se k e' dato: tiene le k piu' positive e le k piu' negative di ogni riga, azzera il resto e
RI-demeana sulle sopravvissute (resta dollar-neutral)."""
Q = P.sub(P.mean(axis=1), axis=0)
if k is None:
return Q
V = Q.to_numpy(float)
out = np.zeros_like(V)
for i in range(V.shape[0]):
v = V[i]
m = np.isfinite(v)
if m.sum() < 2 * k or not np.any(v[m] != 0.0):
continue
idx = np.where(m)[0]
order = idx[np.argsort(v[idx])]
keep = np.concatenate([order[:k], order[-k:]])
r = np.zeros_like(v)
r[keep] = v[keep]
r[keep] -= r[keep].mean()
out[i] = r
return pd.DataFrame(out, index=P.index, columns=P.columns)
def xsr_legs(Q: pd.DataFrame, S: pd.DataFrame) -> pd.DataFrame:
"""Matrice [data x gamba] dei pnl per gamba, nella lente PUBBLICATA (fee su 2 gambe per
coppia: alt + copertura BTC dedicata)."""
return pd.DataFrame({c: pnl(Q[c].to_numpy(float), S[c].to_numpy(float)) for c in Q.columns},
index=Q.index)
def xsr_basket(Q: pd.DataFrame, S: pd.DataFrame, norm) -> pd.Series:
"""norm = 'perrow' riproduce ESATTAMENTE basket_from_positions (media per-riga sui simboli
con dato: il numero di simboli cambia nel tempo). norm = intero -> normalizzazione COSTANTE,
l'unica che rende confrontabili celle con k diverso."""
M = xsr_legs(Q, S)
if norm == "perrow":
return M.mean(axis=1).dropna()
return (M.fillna(0.0).sum(axis=1) / float(norm)).dropna()
# =====================================================================================
# (C) simulatore di libro con MIN-ORDER (lente eseguibile, comune alle due famiglie)
# =====================================================================================
def sim_book(Weff: np.ndarray, R: np.ndarray, idx, r_hedge=None, capital=None,
min_order=0.0, cost_bps=5.0):
"""Weff[i] = pesi TARGET da tenere durante la barra i (decisi a close[i-1]).
R[i] = ritorni per gamba nella barra i. r_hedge = ritorno della gamba di copertura (si tiene
-sum(w)); None = libro gia' neutrale fra le gambe. min_order>0: una variazione di NOZIONALE
sotto la soglia NON si esegue (si tiene la vecchia). cost_bps = costo per unita' di turnover."""
n, na = Weff.shape
c = cost_bps / 1e4
held = np.zeros(na)
hedge = 0.0
net = np.zeros(n)
n_fill = n_skip = 0
tickets, gross = [], []
for i in range(n):
tgt = Weff[i]
d_not = np.abs(tgt - held) * (capital or 1.0)
if min_order > 0.0 and capital:
move = d_not >= min_order
else:
move = d_not > 1e-12
new = np.where(move, tgt, held)
n_fill += int(move.sum())
n_skip += int(((~move) & (d_not > 1e-12)).sum())
tickets.extend(d_not[move].tolist())
turn = float(np.abs(new - held).sum())
r = float(np.dot(new, R[i]))
if r_hedge is not None:
h = -float(new.sum())
if min_order > 0.0 and capital and abs(h - hedge) * capital < min_order:
h = hedge
turn += abs(h - hedge)
hedge = h
r += hedge * float(r_hedge[i])
net[i] = r - c * turn
held = new
gross.append(float(np.abs(new).sum()) + abs(hedge))
return pd.Series(net, index=idx), dict(
n_fill=n_fill, n_skip=n_skip,
exec_share=(n_fill / (n_fill + n_skip)) if (n_fill + n_skip) else 1.0,
med_ticket=float(np.median(tickets)) if tickets else 0.0,
med_gross=float(np.median(gross)),
orders_py=(n_fill / (len(idx) / 365.25)) if len(idx) else 0.0)
# =====================================================================================
# (D) ampiezza
# =====================================================================================
def breadth(legs: pd.DataFrame, n_simult: int, min_overlap: int = 30):
"""Due misure, perche' una sola mente.
(1) N_eff = N/(1+(N-1)*rbar), N = gambe SIMULTANEE, rbar = correlazione media fra i contributi
di due gambe CONDIZIONATA a essere entrambe attive. E' la formula pubblicata dal progetto
(4.5 -> 37.4), qui condizionata: senza condizionare, concentrando si conterebbe come
'ampiezza' la ROTAZIONE nel tempo, che nello Sharpe sta gia' dentro sqrt(T).
INSTABILE per costruzione: rbar leggermente NEGATIVO (normale in un libro dollar-neutral)
la fa esplodere. Si legge insieme a rbar, mai da sola.
(2) N_hhi = numero effettivo di gambe che PORTANO il rischio, giorno per giorno:
1/sum(share_i^2) sulle quote |contributo|, mediato sui giorni attivi. Limitato da N,
stabile, e non dipende dal segno delle correlazioni."""
M = legs.to_numpy(float)
M = np.nan_to_num(M)
act = M != 0.0
rs = []
for a in range(M.shape[1]):
for b in range(a + 1, M.shape[1]):
m = act[:, a] & act[:, b]
if m.sum() < min_overlap:
continue
xa, xb = M[m, a], M[m, b]
if xa.std() > 0 and xb.std() > 0:
rs.append(float(np.corrcoef(xa, xb)[0, 1]))
rbar = float(np.mean(rs)) if rs else float("nan")
N = float(n_simult)
den = 1.0 + (N - 1) * rbar if rs else float("nan")
neff = (N / den) if (rs and den > 1e-3) else float("nan")
hh = []
for i in range(M.shape[0]):
v = np.abs(M[i][act[i]])
t = v.sum()
if t > 0 and len(v) > 0:
sHH = float(np.sum((v / t) ** 2))
if sHH > 0:
hh.append(1.0 / sHH)
return neff, rbar, (float(np.mean(hh)) if hh else float("nan")), len(rs)
# =====================================================================================
def main() -> None:
print("=" * 108)
print(" XS-LITE — una versione CONCENTRATA degli edge cross-sectional e' eseguibile a $600-5.000?")
print("=" * 108)
maj19 = list(XS_UNIVERSE)
all51 = sorted({p.stem.replace("hl_", "").replace("_1d", "").upper()
for p in RAW.glob("hl_*_1d.parquet")})
# ---------------------------------------------------------------- (0) repliche
print("\n" + "-" * 108)
print(" (0) REPLICA DEI MECCANISMI CONGELATI — prima di ogni delta")
print("-" * 108)
C19 = load_closes(maj19, "inner")
C51 = load_closes(all51, "outer")
off, _, _, _ = xs_run(C19, k=XS_CFG["k"], phase=0)
ref = _xsec_returns()
J = pd.concat({"mio": off, "sleeve": ref}, axis=1, join="inner").dropna()
d_xs = float(np.max(np.abs(J["mio"] - J["sleeve"])))
print(f" XS01 k=5 fase 0 vs sleeves._xsec_returns : max|diff| = {d_xs:.3e} "
f"({len(J)} barre) {'OK' if d_xs < 1e-12 else 'DIVERGE'}")
P, S = pair_frames()
Qfull = xsr_positions(P, None)
xsr_perrow = xsr_basket(Qfull, S, "perrow")
xsr_ref = basket_from_positions(P, S, cols=None, demean=True)
J2 = pd.concat({"mio": xsr_perrow, "ref": xsr_ref}, axis=1, join="inner").dropna()
d_xsr = float(np.max(np.abs(J2["mio"] - J2["ref"])))
print(f" XSR01 pieno vs basket_from_positions(demean): max|diff| = {d_xsr:.3e} "
f"({len(J2)} barre) {'OK' if d_xsr < 1e-12 else 'DIVERGE'}")
xsr_const = xsr_basket(Qfull, S, P.shape[1])
print(f" XSR01 pieno, lente pubblicata (media per-riga sui simboli CON DATO): "
f"Sharpe {sh(xsr_perrow):.2f} maxDD {dd(xsr_perrow)*100:.1f}% (pubblicati 1.82 / -2.6%)")
print(f" XSR01 pieno, normalizzazione COSTANTE 1/{P.shape[1]} : "
f"Sharpe {sh(xsr_const):.2f} maxDD {dd(xsr_const)*100:.1f}%")
print(" ^ le due lenti coincidono qui (la copertura e' quasi piena); da qui in poi si usa la")
print(" COSTANTE, l'unica che rende confrontabili celle con k diverso, e i numeri XSR01 vanno")
print(f" letti contro {sh(xsr_const):.2f}.")
disc = pd.Timestamp("2026-07-25", tz="UTC") # giorno di scoperta/inception monitor
xsr_disc = xsr_perrow[xsr_perrow.index <= disc]
xsr_fwd = xsr_perrow[xsr_perrow.index > disc]
print(f" XSR01 sulla FINESTRA DI SCOPERTA (<= {disc.date()}, {len(xsr_disc)} barre): "
f"Sharpe {sh(xsr_disc):.2f} maxDD {dd(xsr_disc)*100:.1f}%")
print(" ⚠ IL NUMERO PUBBLICATO (1.82) NON SI RIPRODUCE OGGI, e non e' la mia implementazione:")
print(" le due repliche qui sopra sono bit-exact col codice del progetto. Sulla finestra")
print(f" IDENTICA a quella di scoperta la lente 'paniere' da' {sh(xsr_disc):.2f} e la lente 'libro'")
print(" (fee 1 gamba alt + copertura BTC NETTA, convenzione di paper_xsr) da' 2.23: l'1.82 non")
print(" e' nessuna delle due. Spiegazione piu' probabile: data/raw e' gitignored e il cron")
print(" riscrive i parquet HL -> stesso codice, dati diversi (identico allo scoperto del 07/08")
print(" su GTAA/ADJUSTED_LAST). NON l'ho inseguito: tutte le mie conclusioni sono confronti")
print(" RELATIVI dentro UNA lente. Ma tocca un numero pubblicato e il gate del 23/10.")
print(f" XSR01 dal 25/07 a oggi (forward, {len(xsr_fwd)} barre, stessa lente): "
f"ritorno {(np.prod(1+xsr_fwd.values)-1)*100:+.2f}% "
f"Sharpe {sh(xsr_fwd) if len(xsr_fwd) > 30 else float('nan'):.2f} "
f"{'(campione troppo corto per uno Sharpe: si legge il ritorno)' if len(xsr_fwd) <= 30 else ''}")
print(" ^ la differenza fra 1.82 e il numero FULL di oggi NON e' una lente diversa (le repliche")
print(" sono bit-exact): e' un mese in piu' di dati. Il gate pre-registrato resta il 23/10.")
print(f" finestra MAJ19: {C19.index[0].date()} -> {C19.index[-1].date()} ({len(C19)} barre); "
f"in-sample = pre {HOLDOUT.date()} = {int((C19.index < HOLDOUT).sum())} barre")
cov51 = np.isfinite(C51.values).sum(axis=1)
print(f" finestra ALL51: {C51.index[0].date()} -> {C51.index[-1].date()} ({len(C51)} barre); "
f"simboli con dato: da {cov51.min()} a {cov51.max()} (outer-join: l'inner-join")
print(" taglierebbe la finestra a 627 barre e CANCELLEREBBE l'in-sample).")
print(" ⚠ l'in-sample e' UN ANNO SOLO e monoregime: ogni Sh IS qui sotto va letto cosi'.")
# ---------------------------------------------------------------- (1) XS-LITE
print("\n" + "-" * 108)
print(" (1) FAMIGLIA A — XS-LITE: XS01 congelato, varia solo k (gambe per lato). k=5 = canonico")
print("-" * 108)
print(HDR + f"{'N_eff':>8}{'rbar':>8}{'N_hhi':>8}")
xs_series, xs_cells = {}, {}
for uni, Cx in (("MAJ19", C19), ("ALL51", C51)):
for k in KS:
s, W, sc, dr = xs_run(Cx, k=k, phase=0)
Weff = xs_effective_weights(W, sc)
legpnl = pd.DataFrame(Weff * dr, index=Cx.index, columns=Cx.columns)
ne, rbar, nhhi, _ = breadth(legpnl, 2 * k)
nm = f"{uni} k={k}" + ("*" if (uni == "MAJ19" and k == 5) else "")
xs_series[nm] = s
xs_cells[nm] = dict(uni=uni, k=k, C=Cx, W=W, scale=sc, dret=dr,
neff=ne, rbar=rbar, nhhi=nhhi)
print(row(nm, s, f"{ne:>8.1f}{rbar:>8.3f}{nhhi:>8.1f}"))
print(" (*) = configurazione canonica del sleeve XS01")
# ---------------------------------------------------------------- (2) XSR-LITE
print("\n" + "-" * 108)
print(" (2) FAMIGLIA B — XSR-LITE: XSR01 congelato (demean), varia solo k. 'pieno' = tutte le gambe")
print("-" * 108)
maj18 = [s for s in maj19 if s != BASE]
Pm = P[[c for c in maj18 if c in P.columns]]
Sm = S[[c for c in maj18 if c in S.columns]]
print(f" ALL50: {P.shape[1]} gambe MAJ18: {Pm.shape[1]} gambe")
print(HDR + f"{'N_eff':>8}{'rbar':>8}{'N_hhi':>8}")
xsr_series, xsr_cells = {}, {}
for uni, (Pu, Su) in (("ALL50", (P, S)), ("MAJ18", (Pm, Sm))):
for k in list(KS) + [None]:
if k is not None and 2 * k > Pu.shape[1]:
continue
Q = xsr_positions(Pu, k)
norm = (2 * k) if k is not None else Pu.shape[1]
s = xsr_basket(Q, Su, norm)
legs = xsr_legs(Q, Su) / float(norm)
ne, rbar, nhhi, _ = breadth(legs, norm)
nm = f"{uni} k={k if k else 'pieno'}" + ("*" if (uni == "ALL50" and k is None) else "")
xsr_series[nm] = s
xsr_cells[nm] = dict(uni=uni, k=k, Q=Q, S=Su, norm=norm,
neff=ne, rbar=rbar, nhhi=nhhi)
print(row(nm, s, f"{ne:>8.1f}{rbar:>8.3f}{nhhi:>8.1f}"))
print(" (*) = XSR01 pieno (50 gambe demeanate), qui a normalizzazione costante")
# banda di fase calcolata PRIMA della curva: la stima onesta di uno sleeve ancorato e' la
# mediana della banda, quindi e' quella che deve stare nella curva (la fase 0 e' un max-of-10).
base_by_phase = {ph: xs_run(C19, k=5, phase=ph)[0] for ph in range(XS_CFG["H"])}
phase_med, phase_rows = {}, {}
for k in KS:
by_ph = {ph: (base_by_phase[ph] if k == 5 else xs_run(C19, k=k, phase=ph)[0])
for ph in range(XS_CFG["H"])}
vals = np.array([sh(by_ph[p_]) for p_ in range(XS_CFG["H"])])
diffs = np.array([sh(by_ph[p_]) - sh(base_by_phase[p_]) for p_ in range(XS_CFG["H"])])
phase_med[k] = float(np.median(vals))
phase_rows[k] = (vals, diffs)
# ---------------------------------------------------------------- (3) curva ampiezza
print("\n" + "-" * 108)
print(" (3) LA CURVA AMPIEZZA-vs-k — la domanda centrale: quanto ne resta concentrando?")
print("-" * 108)
sh_xs_star = sh(xs_series["MAJ19 k=5*"])
sh_xsr_star = sh(xsr_series["ALL50 k=pieno*"])
print(f" {'k':>4}{'gambe':>7} {'--------- XS-LITE MAJ19 ---------':>34} "
f"{'--------- XSR-LITE ALL50 --------':>34}")
print(f" {'':>4}{'':>7} {'N_hhi':>9}{'N_eff':>9}{'Sh f0':>8}{'Sh MED':>8}{'MED/MED*':>10} "
f"{'N_hhi':>9}{'N_eff':>9}{'Sh':>8}{'Sh/Sh*':>9}")
med5 = phase_med[5]
for k in KS:
an = f"MAJ19 k={k}" + ("*" if k == 5 else "")
bn = f"ALL50 k={k}"
ca, cb = xs_cells[an], xsr_cells[bn]
sa, sb = xs_series[an], xsr_series[bn]
print(f" {k:>4}{2*k:>7} {ca['nhhi']:>9.1f}{ca['neff']:>9.1f}{sh(sa):>8.2f}"
f"{phase_med[k]:>8.2f}{(phase_med[k]/med5 if med5 else 0):>10.0%} "
f"{cb['nhhi']:>9.1f}{cb['neff']:>9.1f}{sh(sb):>8.2f}"
f"{(sh(sb)/sh_xsr_star if sh_xsr_star else 0):>9.0%}")
cp = xsr_cells["ALL50 k=pieno*"]
print(f" {'pieno':>4}{cp['norm']:>7} {'':>9}{'':>9}{'':>8}{'':>8}{'':>10} "
f"{cp['nhhi']:>9.1f}{cp['neff']:>9.1f}{sh_xsr_star:>8.2f}{1.0:>9.0%}")
print(" 'Sh MED' = mediana delle 10 fasi d'ancora di XS01 = la stima ONESTA (la fase 0 e' un")
print(" max-of-10 non dichiarato). XSR01 e' giornaliero: non ha fase, la sua colonna e' unica.")
print(" N_hhi = gambe che PORTANO il rischio (stabile). N_eff = formula pubblicata, esplode")
print(" quando rbar e' negativo: si legge solo insieme a rbar, mai da sola.")
# ---------------------------------------------------------------- (4) banda d'ancora
print("\n" + "-" * 108)
print(" (4) BANDA D'ANCORA — le 10 fasi del ciclo H=10 di XS01 (XSR01 e' giornaliero: nessuna")
print(" fase). Stima onesta = MEDIANA della banda, non la fase 0 con cui e' stato scoperto.")
print("-" * 108)
print(f" {'cella':<14}{'fase0':>8}{'mediana':>9}{'p10':>8}{'p90':>8}{'pctl f0':>9}"
f" d vs k=5: mediana APPAIATA (fasi >0)")
for k in KS:
vals, diffs = phase_rows[k]
pctl = float((vals <= vals[0]).mean() * 100)
print(f" MAJ19 k={k:<6}{vals[0]:>8.2f}{phase_med[k]:>9.2f}{np.percentile(vals,10):>8.2f}"
f"{np.percentile(vals,90):>8.2f}{pctl:>8.0f}% {np.median(diffs):>+8.3f}"
f" {int((diffs>0).sum())}/10")
print(" 'd vs k=5' = mediana delle DIFFERENZE APPAIATE per fase (mai differenza di mediane).")
# ---------------------------------------------------------------- (5) eseguibilita'
print("\n" + "-" * 108)
print(" (5) ESEGUIBILITA' — contabilita' min-order per gamba, lente 'libro'")
print(" Il capitale in colonna e' quello dello SLEEVE. XS01 sta al 15% del book:")
print(" sleeve $90 = book $600, sleeve $300 = book $2.000, sleeve $750 = book $5.000,")
print(" sleeve $3.000 = book $20.000 (la soglia dichiarata dal progetto).")
print("-" * 108)
print(f" {'cella':<13}{'sleeve$':>8}{'minord':>7}{'Sh model':>10}{'Sh reale':>10}"
f"{'haircut':>9}{'gambe eseg':>12}{'tick med$':>11}{'ord/anno':>10}"
f"{'lordo/lordo*':>14}")
print("'lordo/lordo*' = lordo mediano ESEGUITO / lordo mediano modellato. Sotto ~0.8 il")
print(" libro ha smesso di seguire il proprio target, e allora lo Sharpe NON e' la lettura")
print(" giusta (puo' perfino MIGLIORARE): e' il null del de-levering in veste di 'meno")
print(" costi'. Lezione GTAA-banda 27/07, riapplicata qui.")
def xs_book_inputs(nm):
cell = xs_cells[nm]
return xs_effective_weights(cell["W"], cell["scale"]), cell["dret"], cell["C"].index, None
def xsr_book_inputs(nm):
cell = xsr_cells[nm]
Q, Su, norm = cell["Q"], cell["S"], cell["norm"]
Wt = np.nan_to_num(Q.to_numpy(float)) / float(norm)
Weff = np.zeros_like(Wt); Weff[1:] = Wt[:-1]
rb = load_hl(BASE).reindex(Q.index).pct_change().fillna(0.0).to_numpy(float)
Ralt = np.zeros_like(Wt)
for j, c in enumerate(Q.columns):
Ralt[:, j] = np.nan_to_num(Su[c].to_numpy(float)) + rb
return Weff, Ralt, Q.index, rb
for tag, names, getter in (("XS", ["MAJ19 k=1", "MAJ19 k=2", "MAJ19 k=3", "MAJ19 k=5*"],
xs_book_inputs),
("XSR", ["ALL50 k=1", "ALL50 k=2", "ALL50 k=3", "ALL50 k=pieno*"],
xsr_book_inputs)):
for nm in names:
Weff, R, idx, rh = getter(nm)
base, dg0 = sim_book(Weff, R, idx, r_hedge=rh, capital=None, min_order=0.0)
for cap in SLEEVE_CAPS:
for mo in (MIN_ORDER_HL,):
real, dg = sim_book(Weff, R, idx, r_hedge=rh, capital=cap, min_order=mo)
lbl = f"{tag} {nm.split()[1]}"
trk = (dg["med_gross"] / dg0["med_gross"]) if dg0["med_gross"] > 0 else float("nan")
print(f" {lbl:<13}{cap:>8.0f}{mo:>7.0f}{sh(base):>10.2f}{sh(real):>10.2f}"
f"{sh(base)-sh(real):>9.2f}{dg['exec_share']*100:>11.0f}%"
f"{dg['med_ticket']:>11.0f}{dg['orders_py']:>10.0f}"
f"{trk:>12.2f}{'' if trk < 0.8 else ' '}")
print()
print(" 'gambe eseg' = quota delle VARIAZIONI di posizione sopra il min-order (la diagnostica")
print(" su cui si basa la soglia '$20k'). 'haircut' = quanto ne costa in Sharpe. Sono cose")
print(" DIVERSE: il turnover saltato e' la deriva del vol-target, non il segnale.")
print(" Con min-order $5 invece di $10 (Deribit invece di HL) l'haircut si dimezza per")
print(" costruzione; il caso $10 e' quello vero per Hyperliquid ed e' quello riportato.")
# ---------------------------------------------------------------- (6) slippage
print("\n" + "-" * 108)
print(" (6) SLIPPAGE — costo TOTALE per gamba (i 5 bps di fee taker sono gia' dentro).")
print(" A che costo muore l'edge? E' il rischio #1 dichiarato di XSR01.")
print("-" * 108)
print(f" {'cella':<16}" + "".join(f"{int(c):>10}bps" for c in COST_SWEEP) + f"{'break-even':>13}")
def cost_curve(label, Weff, R, idx, rh):
vals = []
for c in COST_SWEEP:
s, _ = sim_book(Weff, R, idx, r_hedge=rh, capital=None, min_order=0.0, cost_bps=c)
vals.append(sh(s))
be = None
for i in range(1, len(COST_SWEEP)):
if vals[i - 1] > 0 >= vals[i]:
x0, x1, y0, y1 = COST_SWEEP[i-1], COST_SWEEP[i], vals[i-1], vals[i]
be = x0 + (x1 - x0) * y0 / (y0 - y1)
break
print(f" {label:<16}" + "".join(f"{v:>13.2f}" for v in vals)
+ (f"{be:>13.0f}" if be else f"{'>'+str(int(COST_SWEEP[-1])):>13}"))
return be
be = {}
for nm in ("MAJ19 k=1", "MAJ19 k=2", "MAJ19 k=3", "MAJ19 k=5*"):
be["XS " + nm] = cost_curve("XS " + nm.split()[1], *xs_book_inputs(nm))
for nm in ("ALL50 k=1", "ALL50 k=2", "ALL50 k=3", "ALL50 k=pieno*"):
be["XSR " + nm] = cost_curve("XSR " + nm.split()[1], *xsr_book_inputs(nm))
# ---------------------------------------------------------------- (7) selezione + DSR
print("\n" + "-" * 108)
print(" (7) SELEZIONE IN-SAMPLE-ONLY (mai sull'hold-out) + DEFLATED SHARPE")
print("-" * 108)
allcells = {**{f"XS {n}": s for n, s in xs_series.items()},
**{f"XSR {n}": s for n, s in xsr_series.items()}}
ins_rank = sorted(allcells.items(), key=lambda kv: -sh(split(kv[1])[0]))
print(f" celle valutate: {len(allcells)} (2 famiglie x 2 universi x 6 k, + i 2 'pieno' di")
print(" XSR01, meno le celle impossibili 2k>gambe). Lookback NON riaperto -> nessun trial in piu'.")
print(f"\n {'rank':>5} {'cella':<20}{'Sh IS':>8}{'Sh OOS':>8}{'Sh FULL':>9}")
for i, (n, s) in enumerate(ins_rank[:8], 1):
ins, out = split(s)
print(f" {i:>5} {n:<20}{sh(ins):>8.2f}{sh(out):>8.2f}{sh(s):>9.2f}")
best_name, best_s = ins_rank[0]
all_sr = [sh(s) for s in allcells.values()]
print(f"\n cella scelta AL BUIO (solo in-sample): {best_name}")
for mult, label in ((1, "trial dichiarati = celle"),
(6, "conteggio conservativo x6 (se avessi riaperto il lookback)")):
pad = all_sr * mult
d, s0 = A.deflated_sharpe(sh(best_s), pad, pd.Series(best_s).dropna())
print(f" DSR a N={len(pad):<4} ({label}): {d:.3f} "
f"{'PASS' if d >= 0.95 else 'FAIL'} (Sharpe-soglia del null {s0:.2f})")
conc = {n: s for n, s in allcells.items() if any(f" k={j}" in n for j in (1, 2, 3))}
cbest_name, cbest_s = max(conc.items(), key=lambda kv: sh(split(kv[1])[0]))
d2, _ = A.deflated_sharpe(sh(cbest_s), all_sr, pd.Series(cbest_s).dropna())
print(f" cella CONCENTRATA (k<=3, cioe' <=6 gambe) scelta al buio: {cbest_name} "
f"Sh IS {sh(split(cbest_s)[0]):.2f} OOS {sh(split(cbest_s)[1]):.2f} FULL {sh(cbest_s):.2f}")
print(f" DSR della concentrata a N={len(all_sr)}: {d2:.3f} {'PASS' if d2 >= 0.95 else 'FAIL'}")
# ---------------------------------------------------------------- (8) gate marginali
print("\n" + "-" * 108)
print(" (8) GATE MARGINALE vs TP01 + RIDONDANZA vs gli sleeve che gia' esistono")
print("-" * 108)
order = [best_name, cbest_name, "XS MAJ19 k=2", "XS MAJ19 k=3", "XS MAJ19 k=5*",
"XSR ALL50 k=2", "XSR ALL50 k=3", "XSR ALL50 k=pieno*"]
to_score = {}
for n in order:
if n in allcells and n not in to_score:
to_score[n] = allcells[n]
for n, s in to_score.items():
ss = pd.Series(s).dropna(); ss.index = pd.to_datetime(ss.index, utc=True)
m = A.marginal_vs_tp01(ss)
b = m.get("blends", {}).get("w25", {})
print(f" {n:<20} {str(m.get('marginal_verdict')):<10} corr {str(m.get('corr_full')):>7}"
f" uplift w25 full {str(b.get('uplift_full')):>7} hold {str(b.get('uplift_hold')):>7}"
f" robust={m.get('robust_oos')} noise_null={m.get('beats_noise_null')}"
f" is_edge={m.get('has_insample_edge')} hedge={m.get('is_hedge')}")
print("\n correlazione col materiale gia' in casa (un proxy eseguibile DEVE assomigliare al suo")
print(" originale: qui una corr ALTA e' l'obiettivo, non un difetto)")
ref_map = {"XS01 (sleeve)": ref, "XSR01 (pieno)": xsr_const, "TP01": A.tp01_baseline_daily()}
print(f" {'cella':<20}" + "".join(f"{k:>16}" for k in ref_map))
for n, s in to_score.items():
line = f" {n:<20}"
for _, r in ref_map.items():
Jc = pd.concat({"a": pd.Series(s).dropna(), "b": r}, axis=1, join="inner").dropna()
line += f"{(Jc['a'].corr(Jc['b']) if len(Jc) > 30 else float('nan')):>16.3f}"
print(line)
print(" ⚠ corr a SKH01/VRP01/GTAA01 NON misurata: SKH01 richiede i 5m di DUE asset e il brief")
print(" vieta di caricarli insieme. Prior del progetto (25/07): XSR01 sta a |0.001|-|0.071|")
print(" da tutti e 5 gli sleeve; un suo sottoinsieme non puo' esserne lontano.")
# ---------------------------------------------------------------- (9) null del de-levering
print("\n" + "-" * 108)
print(" (9) NULL DEL DE-LEVERING — obbligatorio su ogni confronto di maxDD (5 occorrenze note)")
print(" Domanda: esiste una LEVA del canonico che dia lo STESSO maxDD della variante?")
print(" Se a quel maxDD il canonico ha Sharpe >= alla variante, la variante non aggiunge.")
print("-" * 108)
def lev_for_dd(canon: pd.Series, target_abs: float, lo=0.01, hi=5.0):
"""Leva k tale che |maxDD(canon*k)| = target. |maxDD| e' monotono crescente in k."""
if abs(dd(canon * hi)) < target_abs:
return None # nemmeno a leva 5x si arriva al DD
for _ in range(60):
mid = 0.5 * (lo + hi)
if abs(dd(canon * mid)) < target_abs: lo = mid
else: hi = mid
return 0.5 * (lo + hi)
def delever_null(variants: dict, canon: pd.Series, label: str):
print(f" {label:<22}{'maxDD':>9}{'Sh':>8} {'k_leva canonico':>16}{'Sh canonico':>13} esito")
for nm, s in variants.items():
kl = lev_for_dd(canon, abs(dd(s)))
if kl is None:
print(f" {nm:<22}{dd(s)*100:>8.1f}%{sh(s):>8.2f} {'n.d. (>5x)':>16}"
f"{'':>13} maxDD irraggiungibile levando il canonico")
continue
sc_ = sh(canon * kl)
print(f" {nm:<22}{dd(s)*100:>8.1f}%{sh(s):>8.2f} {kl:>16.3f}{sc_:>13.2f} "
f"{'il canonico DOMINA' if sc_ >= sh(s) else 'la variante REGGE'}")
delever_null({n: xs_series[n] for n in xs_series if n.startswith("MAJ19") and "k=5" not in n},
xs_series["MAJ19 k=5*"], "XS-LITE vs canonico")
print()
delever_null({n: xsr_series[n] for n in xsr_series if n.startswith("ALL50") and "pieno" not in n},
xsr_series["ALL50 k=pieno*"], "XSR-LITE vs pieno")
# ---------------------------------------------------------------- (10) implausible + causalita'
print("\n" + "-" * 108)
print(" (10) implausible_sharpe + CAUSALITA' (prefisso troncato + decadimento con lag)")
print("-" * 108)
for n in dict.fromkeys([best_name, cbest_name, "XS MAJ19 k=2", "XS MAJ19 k=5*",
"XSR ALL50 k=2"]):
if n not in allcells:
continue
ss = pd.Series(allcells[n]).dropna(); ss.index = pd.to_datetime(ss.index, utc=True)
r = A.implausible_sharpe(ss)
print(f" {n:<20} implausible={str(r['implausible']):<6} Sh {r.get('sharpe',0):>5.2f} "
f"perdite su barre ATTIVE {r.get('loss_frac',float('nan'))*100:>5.1f}% "
f"attive {r.get('active_frac',0)*100:>3.0f}% {'; '.join(r['reasons'])[:38]}")
cut = int(len(C19) * 0.85)
Wf = xs_run(C19, k=2, phase=0)[1]
Wp = xs_run(C19.iloc[:cut], k=2, phase=0)[1]
dmax = float(np.max(np.abs(Wp[cut - 80:cut] - Wf[cut - 80:cut])))
print(f" XS k=2: pesi ricalcolati su prefisso troncato (cut={cut}) vs pieno, ultime 80 barre: "
f"max|diff| = {dmax:.2e} {'CAUSALE' if dmax < 1e-12 else 'LOOK-AHEAD'}")
Wq = xs_run(C19, k=2, phase=0)
Weff2 = xs_effective_weights(Wq[1], Wq[2])
lags = []
for lag in (0, 1, 2, 3, 5):
Wl = np.zeros_like(Weff2)
if lag: Wl[lag:] = Weff2[:-lag]
else: Wl = Weff2
s, _ = sim_book(Wl, Wq[3], C19.index)
lags.append(sh(s))
print(" XS k=2 decadimento con lag (0/1/2/3/5 giorni): "
+ " / ".join(f"{v:.2f}" for v in lags)
+ " (dolce = segnale lento, non firma di look-ahead)")
# ---------------------------------------------------------------- (11) sintesi
print("\n" + "=" * 108)
print(" SINTESI — quanto costa concentrare, e dove sta davvero il muro")
print("=" * 108)
k2, k5 = xs_series["MAJ19 k=2"], xs_series["MAJ19 k=5*"]
print(f" XS-LITE k=2 (4 gambe): Sh fase0 {sh(k2):.2f} / fase-MEDIANA {phase_med[2]:.2f}"
f" vs canonico k=5 (10 gambe): {sh(k5):.2f} / {phase_med[5]:.2f}")
print(f" XSR-LITE k=2 (4 gambe): Sh {sh(xsr_series['ALL50 k=2']):.2f} "
f"(IS {sh(split(xsr_series['ALL50 k=2'])[0]):.2f}) vs pieno "
f"{sh_xsr_star:.2f} (IS {sh(split(xsr_series['ALL50 k=pieno*'])[0]):.2f})")
def _be(x):
return f"{x:.0f} bps" if x else f">{int(COST_SWEEP[-1])} bps"
print(f" break-even di costo per gamba: XS k=2 {_be(be.get('XS MAJ19 k=2'))}, "
f"XS k=5 {_be(be.get('XS MAJ19 k=5*'))}, XSR k=2 {_be(be.get('XSR ALL50 k=2'))}, "
f"XSR pieno {_be(be.get('XSR ALL50 k=pieno*'))}")
print("=" * 108)
if __name__ == "__main__":
main()