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