research(wave-0822): ledger degli esiti — ADAPTIVE-HORIZON scartato
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user