Files
PythagorasGoal/scripts/research/r0822_vol_size.py
T

729 lines
37 KiB
Python

#!/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"),
)
out["base_daily"] = equity_daily(out, size_flat(out)) # riusata da VTL/§9: calcolarla 1 volta
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:
sz[a] = sizes_from_L(EX[o][a], leverage_series(EX[o][a]["base_daily"], **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})")
iso_is = {n: float(np.median([sh(ins(BK[n][o])) - sh(ins(CTRL[n][o])) for o in offs]))
for n in CAUSAL}
best_iso = max(CAUSAL, key=lambda x: iso_is[x])
print(f" cella scelta AL BUIO **a ISO-PESO**: {best_iso} (dIS iso {iso_is[best_iso]:+.3f})")
print(f" la stessa cella a iso-peso: dShFULL {RES[best]['iF']:+.3f} "
f"({RES[best]['niF']}/{len(offs)}), dShHOLD {RES[best]['iH']:+.3f} "
f"({RES[best]['niH']}/{len(offs)})")
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, best_sleeve] + list(BVT)[:2]:
m_s = vol(SLE[nm][can]) / vol(SLE["BASE"][can])
wp = {"TP01": W_TP * MTP[nm][can], "SKH01": W_SKH * m_s}
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.")
# ------------------------------------------------------------------ §9 per anno + deriva
print("\n" + "-" * 112)
print(" 9. SCOMPOSIZIONE PER ANNO del dSharpe a ISO-PESO (regola codificata dopo SOL, 22/08:")
print(" un contributo positivo si scompone per ANNO prima di crederci) + deriva della size")
print("-" * 112)
yrs = sorted({int(y) for y in BK["BASE"][can].index.year})
top = [best, best_iso] + [max(BVT, key=lambda x: RES[x]["iF"])]
top = list(dict.fromkeys(top))
print(f" {'variante':<28}" + "".join(f"{y:>8}" for y in yrs))
for nm in top:
cells = []
for y in yrs:
v = []
for o in offs:
a_ = BK[nm][o][BK[nm][o].index.year == y]
c_ = CTRL[nm][o][CTRL[nm][o].index.year == y]
if len(a_) > 20:
v.append(sh(a_) - sh(c_))
cells.append(float(np.median(v)) if v else float("nan"))
npos = int(np.nansum(np.asarray(cells) > 0))
print(f" {nm:<28}" + "".join(f"{c:>+8.2f}" for c in cells) +
f" anni positivi {npos}/{len(yrs)}")
print()
for nm in top:
if nm in SZ:
szs = np.concatenate([SZ[nm](EX[can][a]) for a in ASSETS])
dys = pd.DatetimeIndex(np.concatenate([EX[can][a]["day_ent"].values for a in ASSETS]))
elif nm in VTL:
szs, dys = [], []
for a in ASSETS:
szs.append(sizes_from_L(EX[can][a],
leverage_series(EX[can][a]["base_daily"], **VTL[nm])))
dys.append(EX[can][a]["day_ent"].values)
szs = np.concatenate(szs); dys = pd.DatetimeIndex(np.concatenate(dys))
else:
L = leverage_series(BK["BASE"][can], BVT[nm]["tv"], BVT[nm]["w"], BVT[nm]["cap"])
szs, dys = [], []
for a in ASSETS:
szs.append(sizes_from_L(EX[can][a], L))
dys.append(EX[can][a]["day_ent"].values)
szs = np.concatenate(szs); dys = pd.DatetimeIndex(np.concatenate(dys))
s = pd.Series(szs, index=dys)
med = s.groupby(s.index.year).median()
rho = float(np.corrcoef(np.arange(len(med)), med.values)[0, 1]) if len(med) > 2 else float("nan")
print(f" size mediana per anno — {nm:<22}" +
"".join(f"{med.get(y, float('nan')):>8.2f}" for y in yrs) +
f" corr col tempo {rho:+.2f}")
print(" ⚠️ una size che DERIVA col tempo non e' controllo del rischio: e' un tilt temporale")
print(" (la vol crypto e' scesa lungo il campione -> qualunque 1/vol pesa di piu' il")
print(" periodo recente, che e' anche l'hold-out). La riga 'corr col tempo' la misura.")
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:
sizes = sizes_from_L(ex, leverage_series(ex["base_daily"], **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()