research(wave-0822): BOCPD — il falsificatore eseguito con potenza, 0/68 celle adattive; l'adattivita' spiega il -7% del vantaggio

This commit is contained in:
Adriano Dal Pastro
2026-08-22 20:50:03 +00:00
parent 0bcc5bc5d0
commit 103dd7bce5
8 changed files with 5514 additions and 1 deletions
+825
View File
@@ -0,0 +1,825 @@
#!/usr/bin/env python
"""r0822b_bin_freq.py — BIN-FREQ: il BINARIO di frequenza su SKH01 giudicato come un CANDIDATO.
STORIA DEI FATTI (verificata leggendo il codice, non il riassunto).
1. `r0822_vol_size.py` ha proposto una SIZE PER-TRADE su SKH01 (cella congelata `VTL tv20 w90`
= clip(0.20/rv90(t-1), 0, 3) fissata all'INGRESSO) come CONTROLLO DEL RISCHIO:
+0,070 ISO dShFULL, 23/23 ancore, 8/8 anni.
2. `r0822_volsize_skeptic.py` ha CONFERMATO il numero e FALSIFICATO il meccanismo: il guadagno
e' FREQUENZA (+0,051 sul solo filtro di attivita') e non MAGNITUDINE (+0,003); e la forma
minima e' un BINARIO 0/1 (`BIN L>=med`, 194/406 trade scartati) che vale +0,080 FULL (20/23)
= 115% del LEAD, con controllo di segno superato (l'inverso fa -0,159, 0/23).
3. Lo scettico ha scritto "il candidato giusto non e' la formula ma il binario" — e nessuno
l'ha passato per `study_family_honest`. E' un SOTTOPRODOTTO DI UN ATTACCO, mai dichiarato
come famiglia. Questo script lo giudica come si giudica un candidato.
IPOTESI, REGISTRATA PRIMA DI MISURARE. Mi aspetto che il numero si riproduca (lo scettico e' stato
rigoroso e la sua replica e' bit-exact) e che il candidato NON superi il gate onesto, per due
ragioni dichiarate in anticipo: (a) la soglia e la finestra sono due assi liberi trovati DENTRO
l'esplorazione di un'altra famiglia, quindi la stima di famiglia dovra' essere molto piu' piccola
dell'argmax (precedente: sull'asse w lo scettico stesso stima +0,025 contro +0,080); (b) il
deflated-Sharpe sara' VACUO come lo e' stato sulla famiglia VOL-SIZE — su perturbazioni della
STESSA strategia la varianza fra i trial e' quasi nulla, e il DSR e' ~1 per chiunque, baseline
compreso. Se succede lo dichiaro vacuo e uso un null che ha potenza.
=========================== LA FAMIGLIA, DICHIARATA PRIMA DI GUARDARE =========================
Un binario di frequenza su SKH01 ha esattamente quattro gradi di liberta'. Li dichiaro tutti,
con i loro valori, PRIMA di misurare, e li conto AL RIALZO:
soglia (6) quantile ESPANDENTE causale della vol trailing, q in {0.25, 0.35, 0.50, 0.65,
0.75} + la soglia ASSOLUTA rv <= 20% (== `L>=1` della regola congelata)
finestra (6) w in {45, 60, 90, 120, 180, 270} giorni (sotto 45 lo scettico ha misurato che
la finestra contiene 1-3 barre ATTIVE: campione vuoto, non fragilita')
isteresi (2) h in {0.00, 0.10} (banda +-10% attorno alla soglia, stato persistente causale)
segno (2) +1 tieni la vol BASSA (l'ipotesi) / -1 tieni la vol ALTA (controllo di segno)
timeframe(1) uno solo: la gamba SKH01 vive sulla griglia 230m per costruzione. Dichiarato.
-> 6 x 6 x 2 x 2 = 144 celle
RIAPRIRE UN PARAMETRO RIAPRE LA SUA FAMIGLIA, e questo binario e' nato dentro l'esplorazione di
un'altra famiglia. Percio' al conto delle 144 si SOMMANO i trial gia' spesi sullo stesso oggetto:
28 (r0822_vol_size) + 43 (r0822_volsize_skeptic) = 71.
-> CONTO ONESTO DICHIARATO: 144 + 71 = 215 trial.
La sensibilita' del verdetto al conteggio e' pubblicata (§5), come nel precedente 30/07 dove un
DSR passava a N=8, falliva a N=72 e falliva a N=360.
============================== LENTE, ANCORE, CONVENZIONI =====================================
Lente: path CANONICO di SKH01 (fill al livello, entry a chiusura di bin) — identica nelle due
braccia di ogni confronto, quindi il Delta e' pulito anche se il livello e' pessimistico verso il
path live. Ancore: **23 offset**, la griglia a priori dell'audit 02/07 (ogni 30m su [0,690)):
NON ridotta, perche' il costo misurato e' ~20 ms per cella per ancora. Statistica: MEDIANA DELLE
DIFFERENZE APPAIATE (`A.anchor_luck_delta`), mai differenza delle mediane.
Confronto sempre ISO-PESO: il binario cambia la vol della gamba e quindi il peso EFFETTIVO di
SKH01 nel libro, e muovere quel peso e' gia' stato misurato e RESPINTO il 26/07. Il null del
de-levering e' DEGENERE su una ri-scalatura (`sh(k*base) === sh(base)`), quindi il test e'
iso-peso sullo Sharpe; `k_iso_dd` resta come diagnostica del DD.
Nessuna riscrittura: `extract / equity_daily / leg_daily / sizes_from_L / book / netted_target`
sono IMPORTATE da r0822_vol_size, e il titolo dello scettico e' riprodotto chiamando la SUA
funzione. Replica bit-exact contro `sleeves._skyhook_returns()` prima di ogni delta (§0).
USO: nice -n 19 timeout 900 uv run python scripts/research/r0822b_bin_freq.py [--every K]
--every 8 -> 3 ancore (pilota). default: tutte e 23.
"""
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]
for _p in (ROOT, ROOT / "scripts" / "research", ROOT / "scripts" / "research" / "alt"):
sys.path.insert(0, str(_p))
import altlib as A # noqa: E402
import r0702_anchor_skh01 as R # noqa: E402
import r0702_tp01_offset as TO # noqa: E402
import r0822_vol_size as V # noqa: E402 (motore: riusato, non riscritto)
import r0822_volsize_skeptic as SK # noqa: E402 (per riprodurre il titolo)
from r0822_sol_leg import k_iso_dd # noqa: E402
from src.portfolio.portfolio import weights_tilt_null # noqa: E402
from src.strategies.skyhook import SKH01_V1 # noqa: E402
ASSETS = V.ASSETS
OFFSETS_FULL = V.OFFSETS_FULL
sh, vol, maxdd, cagr = V.sh, V.vol, V.maxdd, V.cagr
hold, ins, book = V.hold, V.ins, V.book
CAPITAL, MIN_ORDER = V.CAPITAL, V.MIN_ORDER
W_TP, W_SKH = V.W_TP, V.W_SKH
# ------------------------------------------------------------------ la famiglia (dichiarata sopra)
QS = (0.25, 0.35, 0.50, 0.65, 0.75)
THRS = tuple([("q", q) for q in QS] + [("abs", 0.20)])
WS = (45, 60, 90, 120, 180, 270)
HS = (0.00, 0.10)
SIGNS = (+1, -1)
N_INHERITED = 28 + 43 # r0822_vol_size + r0822_volsize_skeptic, sullo stesso oggetto
CANON = ("q", 0.50, 90, 0.00, +1) # la cella dello scettico, dentro la mia famiglia
L = 112
def cname(thr, w, h, sg) -> str:
t = "rv<=20%" if thr[0] == "abs" else f"q{thr[1]:.2f}"
return f"{t} w{w} h{h:.2f} s{'+' if sg > 0 else '-'}"
# =============================================================== il binario, causale per costruzione
def _hyst(x: np.ndarray, thr: np.ndarray, h: float, keep_below: bool) -> np.ndarray:
"""Stato persistente con banda +-h attorno alla soglia. Causale: al passo t usa solo x_t e
thr_t (entrambi funzione di dati <= t-1) e lo stato al passo t-1. h=0 -> confronto secco."""
n = len(x)
out = np.ones(n, bool)
st = True
for i in range(n):
xi, ti = x[i], thr[i]
if not (np.isfinite(xi) and np.isfinite(ti)):
st = True # warm-up: si tiene tutto (conv. scettico)
elif keep_below:
st = (xi <= ti * (1.0 + h)) if st else (xi <= ti * (1.0 - h))
else:
st = (xi >= ti * (1.0 - h)) if st else (xi >= ti * (1.0 + h))
out[i] = st
return out
class KeepCache:
"""rv e quantili espandenti calcolati UNA volta per (asset, ancora, w). L'espandente e' la
parte cara; il resto e' un confronto."""
def __init__(self) -> None:
self.rv: dict = {}
self.q: dict = {}
def keep(self, ex: dict, key, thr, w: int, h: float, sg: int) -> pd.Series:
base = ex["base_daily"]
k = (key, w)
if k not in self.rv:
self.rv[k] = V._rv_daily(base, w, active_only=False) # gia' shiftata di 1 giorno
rv = self.rv[k]
if thr[0] == "abs":
t = np.full(len(rv), float(thr[1]))
else:
kq = (key, w, thr[1])
if kq not in self.q:
self.q[kq] = rv.expanding(min_periods=V.VT_WARM).quantile(thr[1])
t = self.q[kq].values
keep = _hyst(rv.values.astype(float), np.asarray(t, float), h, keep_below=(sg > 0))
return pd.Series(keep.astype(float), index=rv.index)
KC = KeepCache()
def size_bin(thr, w, h, sg):
def f(ex, key):
return V.sizes_from_L(ex, KC.keep(ex, key, thr, w, h, sg))
return f
# =============================================================== motore
def leg_of(EXX: dict, key, fn) -> pd.Series:
return V.leg_daily(EXX, {a: fn(EXX[a], (key, a)) for a in ASSETS})
def iso_pair(TP: pd.Series, leg: pd.Series, leg_base: pd.Series):
"""Libro della variante e libro di CONTROLLO ISO-PESO (baseline ri-scalato alla stessa vol di
gamba => stesso peso effettivo di SKH). Convenzione del bersaglio, riusata tale e quale."""
vb = vol(leg_base)
m = vol(leg) / vb if vb > 0 else 1.0
return book(TP, leg), book(TP, leg_base * m), m
# =============================================================== main
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--every", type=int, default=1)
ap.add_argument("--ndraw", type=int, default=200)
args = ap.parse_args()
offs = OFFSETS_FULL[:: max(1, args.every)]
t0 = time.time()
print("=" * L)
print(" r0822b BIN-FREQ — il BINARIO di frequenza su SKH01, giudicato come un CANDIDATO")
print("=" * L)
print(f" ancore: {len(offs)}/{len(OFFSETS_FULL)} della griglia a priori 02/07 (ogni 30m su "
f"[0,690)). NON ridotta.")
print(" lente: path CANONICO, identica nelle due braccia. Confronto sempre ISO-PESO.")
# ---------------------------------------------------------- §1 famiglia, PRIMA di misurare
print("\n" + "-" * L)
print(" 1. GRIGLIA DICHIARATA — scritta prima di guardare i risultati, contata AL RIALZO")
print("-" * L)
cells = [(t, w, h, s) for t in THRS for w in WS for h in HS for s in SIGNS]
print(f" soglia {len(THRS):>3} {['q%.2f' % q for q in QS] + ['rv<=20% (== L>=1)']}")
print(f" finestra {len(WS):>3} {list(WS)} giorni")
print(f" isteresi {len(HS):>3} {list(HS)} (banda +-h attorno alla soglia)")
print(f" segno {len(SIGNS):>3} +1 tieni vol BASSA (ipotesi) / -1 tieni vol ALTA (controllo)")
print(f" timeframe 1 la gamba SKH01 vive su 230m per costruzione")
print(f" -> celle di QUESTA famiglia: {len(cells)}")
print(f" -> trial gia' spesi sullo STESSO oggetto: {N_INHERITED} "
f"(28 r0822_vol_size + 43 r0822_volsize_skeptic)")
print(f" -> CONTO ONESTO DICHIARATO: {len(cells)} + {N_INHERITED} = "
f"{len(cells) + N_INHERITED} trial. La sensibilita' al conteggio e' in §5.")
assert len(cells) == 144
# ---------------------------------------------------------- estrazione
print("\n estrazione tabelle trade (V2-DD) ...", flush=True)
EX = {o: {a: V.extract(a, o) for a in ASSETS} for o in offs}
ntr = {a: int(np.mean([len(EX[o][a]["i_ent"]) for o in offs])) for a in ASSETS}
print(f" fatto in {time.time()-t0:.0f}s — trade medi/ancora: " +
", ".join(f"{a} {ntr[a]}" for a in ASSETS))
TP = A.tp01_baseline_daily()
can = offs[0]
# ---------------------------------------------------------- §0 SANITY
print("\n" + "-" * L)
print(" 0. SANITY — bit-exact prima di qualunque delta")
print("-" * L)
from src.portfolio.sleeves import _skyhook_returns
base_leg = {o: V.leg_daily(EX[o], {a: V.size_flat(EX[o][a]) for a in ASSETS}) for o in offs}
off = _skyhook_returns()
d0 = float(np.max(np.abs(base_leg[can].values - off.values))) if len(base_leg[can]) == len(off) \
else float("nan")
print(f" gamba 50/50 off{can} vs sleeves._skyhook_returns(): max|dif| = {d0:.2e} "
f"({len(base_leg[can])} giorni)")
assert d0 < 1e-15, "replica NON bit-exact"
for a in ASSETS:
ref = R.run_asset(a, can)[0]
R._CACHE.clear()
dd = float(np.max(np.abs(V.equity_daily(EX[can][a], V.size_flat(EX[can][a])).values
- ref.values)))
print(f" {a} vs r0702_anchor_skh01.run_asset: max|dif| = {dd:.2e}")
assert dd < 1e-15
# ---------------------------------------------------------- §2 riproduzione del titolo
print("\n" + "-" * L)
print(" 2. RIPRODUZIONE DEL TITOLO DELLO SCETTICO — chiamando la SUA funzione, non la mia")
print(" (pubblicato: BIN L>=med +0,080 FULL 20/23 ; INVERSO -0,159 0/23)")
print("-" * L)
REP = {}
for tag, mode in (("BIN L>=med (scettico)", "gemed"), ("BIN L<1 INVERSO (scettico)", "lt1"),
("BIN L>=1 (scettico)", "ge1")):
fn = SK.size_bin(SK.FROZEN, mode)
bk, ct = {}, {}
for o in offs:
lg = V.leg_daily(EX[o], {a: fn(EX[o][a]) for a in ASSETS})
bk[o], ct[o], _ = iso_pair(TP, lg, base_leg[o])
REP[tag] = (bk, ct)
dF = A.anchor_luck_delta(lambda o: bk[o], lambda o: ct[o], offs, metric=sh)
dH = A.anchor_luck_delta(lambda o: hold(bk[o]), lambda o: hold(ct[o]), offs, metric=sh)
print(f" {tag:<28} ISO dShFULL {dF['median_paired']:+.3f} "
f"({dF['n_positive']}/{len(offs)}) banda [{dF['lo']:+.3f},{dF['hi']:+.3f}] "
f"ISO dShHOLD {dH['median_paired']:+.3f} ({dH['n_positive']}/{len(offs)})")
med_F = float(np.median([sh(book(TP, base_leg[o])) for o in offs]))
med_H = float(np.median([sh(hold(book(TP, base_leg[o]))) for o in offs]))
med_D = float(np.median([maxdd(book(TP, base_leg[o])) for o in offs]))
ban_F = [sh(book(TP, base_leg[o])) for o in offs]
print(f" libro BASE, mediana della banda: ShFULL {med_F:.3f} / ShHOLD {med_H:.3f} / "
f"maxDD {med_D*100:.1f}% [pubblicato 1,626 / 1,037 / 10,4%]")
print(f" banda d'ancora del LIVELLO del libro: ShFULL [{min(ban_F):.3f},{max(ban_F):.3f}] "
f"= ampiezza {max(ban_F)-min(ban_F):.3f} (il termine di paragone di §12)")
# ---------------------------------------------------------- §3 causalita' per troncamento
print("\n" + "-" * L)
print(" 3. CAUSALITA' — la decisione tenere/scartare ricalcolata TRONCANDO i dati al giorno")
print(" d'ingresso. `A.causality_ok` NON E' GIRATO (vuole un target_fn(df) e qui il")
print(" segnale nasce da 5m->230m: ri-eseguirlo su prefissi costa quanto tutto lo")
print(" script). Sostituito da un troncamento diretto, piu' stringente sul punto esatto.")
print("-" * L)
thr_c, w_c, h_c, s_c = ("q", CANON[1]), CANON[2], CANON[3], CANON[4]
bad = tested = 0
for a in ASSETS:
ex = EX[can][a]
kfull = KC.keep(ex, (can, a), thr_c, w_c, h_c, s_c)
rv_full = V._rv_daily(ex["base_daily"], w_c, False)
days = list(dict.fromkeys(ex["day_ent"]))
for d in days[::7]: # 1 ingresso su 7, su tutta la storia
if d not in kfull.index:
continue
b_tr = ex["base_daily"][ex["base_daily"].index <= d]
if len(b_tr) < V.VT_WARM + w_c:
continue
rv_t = V._rv_daily(b_tr, w_c, False)
q_t = rv_t.expanding(min_periods=V.VT_WARM).quantile(thr_c[1])
k_t = _hyst(rv_t.values.astype(float), q_t.values.astype(float), h_c, s_c > 0)[-1]
tested += 1
if bool(k_t) != bool(kfull.loc[d] > 0.5):
bad += 1
assert abs(float(rv_t.iloc[-1]) - float(rv_full.loc[d])) < 1e-12 or \
not np.isfinite(rv_full.loc[d])
print(f" decisioni ricalcolate su prefisso: {tested} DIVERGENTI: {bad}")
print(" NB l'isteresi ha memoria: troncando si perde lo stato, quindi il test e' esatto")
print(" per h=0 (la cella canonica) e conservativo per h>0.")
assert bad == 0, "il binario NON e' causale"
# ---------------------------------------------------------- §4 tabella di famiglia
print("\n" + "-" * L)
print(" 4. LA FAMIGLIA INTERA — ISO dShFULL, mediana delle differenze appaiate su "
f"{len(offs)} ancore")
print("-" * L)
# DUE PASSATE, per la RAM (7 GB sulla VPS, ~3 liberi, e siamo in tanti): la 1a calcola le
# metriche per-ancora di tutte le 144 celle SENZA conservare le serie (144 x 23 x 3 serie da
# 2717 punti sarebbero ~500 MB); la 2a ricalcola e CONSERVA le serie delle sole 3-4 celle che
# servono dopo (canonica / scelta al buio / argmax): 4 x 23 gambe, ~2 s.
BK, CT, LEG, MS = {}, {}, {}, {}
def eval_cell(thr, w, h, sg, store: bool = False) -> dict:
nm = cname(thr, w, h, sg)
fn = size_bin(thr, w, h, sg)
dF, dH, dI, lF, lI, lH, ms, kp, ddv, ddc, dC_ = ([] for _ in range(11))
yr: dict = {}
for o in offs:
lg = leg_of(EX[o], o, fn)
b, c, m = iso_pair(TP, lg, base_leg[o])
dF.append(sh(b) - sh(c)); dH.append(sh(hold(b)) - sh(hold(c)))
dI.append(sh(ins(b)) - sh(ins(c))); dC_.append(cagr(b) - cagr(c))
lF.append(sh(lg)); lI.append(sh(ins(lg))); lH.append(sh(hold(lg)))
ms.append(m); ddv.append(maxdd(b)); ddc.append(maxdd(c))
kp.append(float(np.mean(np.concatenate([fn(EX[o][a], (o, a)) for a in ASSETS]))))
for y in {int(t) for t in b.index.year}:
by, cy = b[b.index.year == y], c[c.index.year == y]
if len(by) > 20:
yr.setdefault(y, []).append(sh(by) - sh(cy))
if store:
BK.setdefault(nm, {})[o] = b
CT.setdefault(nm, {})[o] = c
LEG.setdefault(nm, {})[o] = lg
MS.setdefault(nm, {})[o] = m
a_ = np.asarray(dF, float)
return dict(nm=nm, thr=thr, w=w, h=h, sg=sg,
dF=float(np.median(a_)), nF=int((a_ > 0).sum()),
lo=float(a_.min()), hi=float(a_.max()), arrF=a_,
dH=float(np.median(dH)), nH=int((np.asarray(dH) > 0).sum()),
isoI=float(np.median(dI)), dC=float(np.median(dC_)),
legF=float(np.median(lF)), legI=float(np.median(lI)),
legH=float(np.median(lH)), kept=float(np.median(kp)),
weff=float(np.median([W_SKH * x for x in ms])),
ddv=float(np.median(ddv)), ddc=float(np.median(ddc)),
yr={y: float(np.median(v)) for y, v in yr.items()})
RES = {}
for (thr, w, h, sg) in cells:
r = eval_cell(thr, w, h, sg)
RES[r["nm"]] = r
print(f" ({len(cells)} celle x {len(offs)} ancore valutate in {time.time()-t0:.0f}s "
f"dall'avvio, prima passata senza conservare le serie)")
def matrix(sg: int, h: float) -> None:
print(f"\n ISO dShFULL — segno {'+1 (tieni vol BASSA)' if sg>0 else '-1 (CONTROLLO)'}"
f", isteresi h={h:.2f}")
print(" soglia " + "".join(f"{('w%d' % w):>11}" for w in WS))
for thr in THRS:
t = "rv<=20%" if thr[0] == "abs" else f"q{thr[1]:.2f}"
row = ""
for w in WS:
r = RES[cname(thr, w, h, sg)]
row += f"{r['dF']:>+8.3f}{r['nF']:>3}"
print(f" {t:<10}" + row)
for sg in SIGNS:
for h in HS:
matrix(sg, h)
print("\n (ogni cella: mediana appaiata e n. di ancore positive su "
f"{len(offs)})")
POS = [RES[cname(t, w, h, +1)] for t in THRS for w in WS for h in HS]
NEG = [RES[cname(t, w, h, -1)] for t in THRS for w in WS for h in HS]
ALLC = [RES[cname(t, w, h, s)] for t in THRS for w in WS for h in HS for s in SIGNS]
nmcan = cname(("q", CANON[1]), CANON[2], CANON[3], CANON[4])
argmax = max(ALLC, key=lambda r: r["dF"])
print(f"\n ARGMAX della famiglia: {cname(argmax['thr'], argmax['w'], argmax['h'], argmax['sg']):<26}"
f" dShFULL {argmax['dF']:+.3f} ({argmax['nF']}/{len(offs)})")
print(f" cella dello scettico: {nmcan:<26} dShFULL {RES[nmcan]['dF']:+.3f} "
f"({RES[nmcan]['nF']}/{len(offs)}) rango "
f"{1 + sum(r['dF'] > RES[nmcan]['dF'] for r in ALLC)}/{len(ALLC)}")
print(f" STIMA ONESTA se la cella si sceglie a caso dentro la famiglia (mediana):")
print(f" tutta la famiglia (144) {float(np.median([r['dF'] for r in ALLC])):+.3f} "
f"positive {sum(r['dF']>0 for r in ALLC)}/{len(ALLC)}")
print(f" solo segno +1 (72) {float(np.median([r['dF'] for r in POS])):+.3f} "
f"positive {sum(r['dF']>0 for r in POS)}/{len(POS)}")
print(f" solo segno -1 (72) {float(np.median([r['dF'] for r in NEG])):+.3f} "
f"positive {sum(r['dF']>0 for r in NEG)}/{len(NEG)} <- controllo di segno")
# seconda passata: conserva le serie delle sole celle che servono dopo
sel_iso0 = max(ALLC, key=lambda r: r["isoI"])
for r in dict((x["nm"], x) for x in (RES[nmcan], argmax, sel_iso0,
max(ALLC, key=lambda z: z["legI"]))).values():
eval_cell(r["thr"], r["w"], r["h"], r["sg"], store=True)
dchk = A.anchor_luck_delta(lambda o: BK[nmcan][o], lambda o: CT[nmcan][o], offs, metric=sh)
assert abs(dchk["median_paired"] - RES[nmcan]["dF"]) < 1e-12 and \
dchk["n_positive"] == RES[nmcan]["nF"], "la statistica appaiata di §4 non coincide con A.anchor_luck_delta"
print(f" controllo: la statistica appaiata calcolata in §4 coincide con "
f"`A.anchor_luck_delta` ({dchk['median_paired']:+.6f})")
print(f" quota di trade TENUTI, mia cella q0.50 w90: {RES[nmcan]['kept']*100:.1f}% "
f"(mediana fra le ancore)")
fn_can = size_bin(("q", CANON[1]), CANON[2], CANON[3], CANON[4])
fn_sk = SK.size_bin(SK.FROZEN, "gemed")
ov, k_me, k_sk = [], [], []
for o in offs:
for a in ASSETS:
x = fn_can(EX[o][a], (o, a)) > 0.5
y = fn_sk(EX[o][a]) > 0.5
ov.append(float((x == y).mean())); k_me.append(float(x.mean())); k_sk.append(float(y.mean()))
dleg = float(np.max(np.abs(BK[nmcan][can].values - REP["BIN L>=med (scettico)"][0][can].values)))
print(f" ⚠ la MIA parametrizzazione NON e' bit-identica a quella dello scettico: lui prende")
print(f" la mediana espandente di L = clip(0.20/rv,0,3) DOPO un `.fillna(1.0)` che inietta")
print(f" dei 1.0 nel warm-up; io prendo il quantile espandente di rv, che salta i NaN. Il")
print(f" trasformo e' monotono, quindi le due decisioni coincidono nel "
f"{float(np.mean(ov))*100:.1f}% dei trade")
print(f" (tenuti {float(np.mean(k_me))*100:.1f}% io / {float(np.mean(k_sk))*100:.1f}% lui); "
f"max|dif| fra i due libri = {dleg:.2e}.")
# ---------------------------------------------------------- §5 i tre componenti del gate
print("\n" + "-" * L)
print(" 5. `study_family_honest` — NON chiamabile direttamente (e' cablato sui candidati")
print(" DIREZIONALI: factory -> target_fn via candidate_daily, e qui il candidato e' un")
print(" overlay su uno sleeve). Come il 30/07 sul tenore VRP01, uso i suoi TRE")
print(" COMPONENTI REALI: selezione in-sample-only -> deflated_sharpe -> marginal_vs_tp01.")
print("-" * L)
print(" (a) SELEZIONE IN-SAMPLE-ONLY — due criteri, entrambi dichiarati, entrambi sulla")
print(" MEDIANA fra le ancore (l'ancora non e' un hold-out, e' una dimensione di luck)")
sel_leg = max(ALLC, key=lambda r: r["legI"]) # come `select_cell_insample`
sel_iso = max(ALLC, key=lambda r: r["isoI"]) # cio' su cui si decide davvero
hop_leg = max(ALLC, key=lambda r: r["legH"])
hop_iso = max(ALLC, key=lambda r: r["dH"])
for tag, r in (("Sharpe IS standalone della gamba (come select_cell_insample)", sel_leg),
("ISO dShFULL IN-SAMPLE del libro (cio' che decide)", sel_iso)):
n = cname(r["thr"], r["w"], r["h"], r["sg"])
print(f" scelta al buio su {tag}")
print(f" -> {n:<26} ISO dShFULL {r['dF']:+.3f} ({r['nF']}/{len(offs)}) "
f"ISO dShHOLD {r['dH']:+.3f} ({r['nH']}/{len(offs)}) banda "
f"[{r['lo']:+.3f},{r['hi']:+.3f}]")
for tag, r in (("Sharpe HOLD-OUT standalone", hop_leg), ("ISO dShHOLD del libro", hop_iso)):
print(f" controllo (scegliendo SULL'HOLD-OUT, cio' che NON si fa) su {tag}:")
print(f" -> {cname(r['thr'], r['w'], r['h'], r['sg']):<26} dShFULL {r['dF']:+.3f}"
f" dShHOLD {r['dH']:+.3f}")
CHOSEN = sel_iso
nmc = cname(CHOSEN["thr"], CHOSEN["w"], CHOSEN["h"], CHOSEN["sg"])
print(f" CELLA SCELTA AL BUIO (criterio primario = ISO dShFULL in-sample): {nmc}")
print("\n (b) DEFLATED SHARPE — sullo Sharpe FULL standalone della gamba (la grandezza che")
print(" `select_cell_insample` usa), mediana fra le ancore; `daily` della barra canonica")
print(" serve solo per T/asimmetria/curtosi. Sensibilita' al CONTEGGIO ottenuta")
print(" REPLICANDO la distribuzione dei trial osservati (tiling), non riempiendola con")
print(" la mediana: riempire con la mediana ABBASSA la varianza dei trial e fa SALIRE il")
print(" DSR all'aumentare di N, che e' il verso sbagliato. (Errore mio, catturato nel")
print(" pilota e corretto: il primo output dava DSR 0,966 -> 0,986 -> 0,998 da N=144 a")
print(" N=500, cioe' 'piu' trial = piu' significativo'.)")
all_sr = [r["legF"] for r in ALLC]
pos_sr = [r["legF"] for r in POS]
base_sr = float(np.median([sh(base_leg[o]) for o in offs]))
dly = LEG[nmc][can]
def tile(v, N):
return (list(v) * (N // len(v) + 1))[:N]
for lab, sr, d in (("cella scelta al buio", CHOSEN["legF"], dly),
("BASELINE (size fissa)", base_sr, base_leg[can])):
for N, tagN in ((len(ALLC), f"{len(ALLC)} di QUESTA famiglia"),
(len(ALLC) + N_INHERITED,
f"{len(ALLC)+N_INHERITED} = conto onesto (+{N_INHERITED} ereditati)"),
(500, "500 (ipotetico, al rialzo)")):
dsr, sr0 = A.deflated_sharpe(sr, tile(all_sr, N), d)
print(f" {lab:<22} N={tagN:<44} DSR {dsr:.3f} max atteso sotto il null {sr0:+.3f}")
lo_n, hi_n = len(ALLC), 20000
if A.deflated_sharpe(CHOSEN["legF"], tile(all_sr, hi_n), dly)[0] >= 0.95:
cross = f">{hi_n}"
elif A.deflated_sharpe(CHOSEN["legF"], tile(all_sr, lo_n), dly)[0] < 0.95:
cross = f"<{lo_n}"
else:
for _ in range(40):
mid = (lo_n + hi_n) // 2
if A.deflated_sharpe(CHOSEN["legF"], tile(all_sr, mid), dly)[0] >= 0.95:
lo_n = mid
else:
hi_n = mid
cross = f"~{lo_n}"
print(f" -> IL VERDETTO SI RIBALTA A N {cross} trial: sotto quel conteggio il DSR passa,")
print(f" sopra fallisce. Il conto onesto dichiarato ({len(ALLC)+N_INHERITED}) sta dal lato")
print(f" del PASS. Precedente 30/07: un DSR passava a N=8, falliva a N=72 e a N=360; qui")
print(f" la stessa sensibilita' e' pubblicata invece di essere taciuta.")
dsp, sr0p = A.deflated_sharpe(CHOSEN["legF"], tile(pos_sr, len(POS) + N_INHERITED), dly)
print(f" sensibilita' alla COMPOSIZIONE della famiglia: escludendo il segno -1 (che e' un")
print(f" controllo e non un candidato) i trial diventano {len(POS)}+{N_INHERITED} e la loro")
print(f" dispersione cala -> DSR {dsp:.3f} (max atteso {sr0p:+.3f}). Includere il segno -1")
print(f" e' la scelta CONSERVATIVA e resta quella dichiarata.")
print(f" dispersione dei trial: sd(Sharpe FULL) = {float(np.std(all_sr, ddof=1)):.3f} sulle")
print(f" {len(ALLC)} celle, {float(np.std(pos_sr, ddof=1)):.3f} sulle {len(POS)} di segno +1.")
print(f" -> il DSR qui NON e' vacuo (il baseline lo fallisce dove il candidato lo passa):")
print(f" la famiglia contiene celle davvero diverse fra loro, al contrario della famiglia")
print(f" VOL-SIZE dove erano tutte perturbazioni della stessa size continua.")
print("\n (c) MARGINAL vs TP01 — sulla gamba modificata e sulla gamba base")
for lab, s in (("BASE (size fissa)", base_leg[can]), (nmc, LEG[nmc][can]),
("cella dello scettico", LEG[nmcan][can])):
m = A.marginal_vs_tp01(s)
b25 = m.get("blends", {}).get("w25", {})
print(f" {lab:<26} {str(m.get('marginal_verdict')):<10} corr {m.get('corr_full')} "
f"uplift w25 full {b25.get('uplift_full')} / hold {b25.get('uplift_hold')} "
f"robust_oos={m.get('robust_oos')} insample_edge={m.get('has_insample_edge')} "
f"hedge={m.get('is_hedge')}")
imp = A.implausible_sharpe(
LEG[nmc][can],
n_trades=int(sum(len(EX[can][a]["net"]) for a in ASSETS)),
n_losing_trades=int(sum((EX[can][a]["net"] < 0).sum() for a in ASSETS)))
print(f" implausible_sharpe (gamba scelta): 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}")
# ---------------------------------------------------------- §6 null con potenza
print("\n" + "-" * L)
print(" 6. IL NULL CHE HA POTENZA — permutazione della SEQUENZA tenuti/scartati.")
print(" Stessa quota di trade tenuti, riassegnata a caso: separa 'conta QUALE trade si")
print(" scarta' (informazione) da 'conta scartarne meta'' (aritmetica). Una mediana del")
print(" null lontana da 0 direbbe che il null non e' un null (lezione 22/08 sul null")
print(f" invertito). {args.ndraw} estrazioni su TUTTE le {len(offs)} ancore, non su 3.")
print("-" * L)
rng = np.random.default_rng(20260822)
for lab, r in (("cella scelta al buio", CHOSEN), ("cella dello scettico", RES[nmcan]),
("argmax di famiglia", argmax)):
nm = cname(r["thr"], r["w"], r["h"], r["sg"])
real = float(np.median([sh(BK[nm][o]) - sh(CT[nm][o]) for o in offs]))
fn = size_bin(r["thr"], r["w"], r["h"], r["sg"])
sz0 = {o: {a: fn(EX[o][a], (o, a)) for a in ASSETS} for o in offs}
nul = []
for _ in range(args.ndraw):
v = []
for o in offs:
szp = {a: rng.permutation(sz0[o][a]) for a in ASSETS}
lg = V.leg_daily(EX[o], szp)
b, c, _ = iso_pair(TP, lg, base_leg[o])
v.append(sh(b) - sh(c))
nul.append(float(np.median(v)))
nul = np.asarray(nul)
pc = float((nul < real).mean())
print(f" {lab:<24} {nm:<24} reale {real:+.3f} null mediana {np.median(nul):+.3f} "
f"p90 {np.percentile(nul, 90):+.3f} max {nul.max():+.3f} -> p ~ {1-pc:.3f}")
print(f" (seme 20260822; {args.ndraw} estrazioni x {len(offs)} ancore)")
print("\n ⚠ LA MEDIANA DEL NULL NON E' ~0, E NON E' UN DIFETTO DEL NULL: E' UN FATTO.")
print(" Scartare a caso meta' dei trade DANNEGGIA, perche' lo Sharpe ANNUALIZZATO cresce")
print(" con la radice del numero di scommesse per anno: dimezzare i trade sullo stesso")
print(" arco di calendario costa un fattore ~sqrt(2) di Sharpe di gamba. Misurato:")
nmv = cname(CHOSEN["thr"], CHOSEN["w"], CHOSEN["h"], CHOSEN["sg"])
fnv = size_bin(CHOSEN["thr"], CHOSEN["w"], CHOSEN["h"], CHOSEN["sg"])
rng2 = np.random.default_rng(20260823)
shb = float(np.median([sh(base_leg[o]) for o in offs]))
shv = float(np.median([sh(LEG[nmv][o]) for o in offs]))
kp = float(np.median([np.mean(np.concatenate([fnv(EX[o][a], (o, a)) for a in ASSETS]))
for o in offs]))
shn = []
for _ in range(min(40, args.ndraw)):
sz0 = {o: {a: fnv(EX[o][a], (o, a)) for a in ASSETS} for o in offs}
shn.append(float(np.median([sh(V.leg_daily(EX[o], {a: rng2.permutation(sz0[o][a])
for a in ASSETS})) for o in offs])))
print(f" Sharpe della GAMBA: baseline (tutti i trade) {shb:.3f} -> permutata "
f"{float(np.median(shn)):.3f} -> filtrata {shv:.3f}")
print(f" quota tenuta {kp*100:.0f}% -> attesa dalla sola perdita di scommesse "
f"{shb*np.sqrt(kp):.3f}; osservata sotto permutazione {float(np.median(shn)):.3f}")
print(" Conseguenza per la lettura: il null NON e' centrato su zero, quindi il p-value")
print(" risponde alla domanda 'la SELEZIONE batte una selezione casuale della stessa")
print(" taglia?' e non alla domanda 'il filtro batte il non filtrare?'. La seconda e' il")
print(" confronto con la BASE (§4), che e' quello su cui si decide. Il filtro deve")
print(" superare una penale meccanica prima di guadagnare qualcosa: e' un test SEVERO.")
# ---------------------------------------------------------- §7 iso-peso / de-levering
print("\n" + "-" * L)
print(" 7. ISO-PESO e NULL DEL DE-LEVERING")
print("-" * L)
print(f" {'cella':<26}{'wSKH eff':>10}{'dShFULL RAW':>13}{'ISO':>9}{'maxDD var':>11}"
f"{'maxDD ctrl':>11}{'k_iso_dd':>10}")
for lab, r in (("BASE", None), ("cella dello scettico", RES[nmcan]),
("cella scelta al buio", CHOSEN)):
if r is None:
print(f" {'BASE (size fissa)':<26}{W_SKH:>10.3f}{0.0:>+13.3f}{0.0:>+9.3f}"
f"{maxdd(book(TP, base_leg[can]))*100:>10.1f}%{'-':>11}{'-':>10}")
continue
nm = cname(r["thr"], r["w"], r["h"], r["sg"])
raw = A.anchor_luck_delta(lambda o, n=nm: BK[n][o],
lambda o: book(TP, base_leg[o]), offs, metric=sh)
dv = float(np.median([maxdd(BK[nm][o]) for o in offs]))
dc = float(np.median([maxdd(CT[nm][o]) for o in offs]))
ks = (f"{k_iso_dd(CT[nm][can], BK[nm][can]):.3f}" if dv < dc
else "n/a") # n/a = non riduce il DD: nulla da negare
print(f" {nm:<26}{r['weff']:>10.3f}{raw['median_paired']:>+13.3f}{r['dF']:>+9.3f}"
f"{dv*100:>10.1f}%{dc*100:>10.1f}%{ks:>10}")
print(" Il null del de-levering e' DEGENERE su una ri-scalatura (sh(k*base) === sh(base)):")
print(" qui il test e' l'iso-peso sullo Sharpe. `k_iso_dd` e' riportato come diagnostica del")
print(" DD ed e' calcolato CONTRO il controllo iso-peso (non contro il baseline nudo).")
print(" NB il binario ABBASSA il peso effettivo di SKH01, non lo alza: la preoccupazione del")
print(" 26/07 ('alzare SKH01') non morde, ma l'iso-peso resta obbligatorio nell'altro verso.")
print(f"\n LIVELLI del libro (mediana fra le {len(offs)} ancore), netti fee:")
print(f" {'configurazione':<30}{'ShFULL':>9}{'ShHOLD':>9}{'ShIS':>8}{'maxDD':>9}{'CAGR':>9}"
f"{'vol':>8}")
lev = [("BASE (libro live 75/25)", {o: book(TP, base_leg[o]) for o in offs})]
for lab, r in (("BIN, peso eff. lasciato cadere", RES[nmcan]),
("BIN, controllo ISO-PESO", None)):
if r is None:
lev.append((lab, {o: CT[nmcan][o] for o in offs}))
else:
lev.append((lab, {o: BK[nmcan][o] for o in offs}))
for lab, dd_ in lev:
f_ = float(np.median([sh(dd_[o]) for o in offs]))
h_ = float(np.median([sh(hold(dd_[o])) for o in offs]))
i_ = float(np.median([sh(ins(dd_[o])) for o in offs]))
d_ = float(np.median([maxdd(dd_[o]) for o in offs]))
c_ = float(np.median([cagr(dd_[o]) for o in offs]))
v_ = float(np.median([vol(dd_[o]) for o in offs]))
print(f" {lab:<30}{f_:>9.3f}{h_:>9.3f}{i_:>8.3f}{d_*100:>8.1f}%{c_*100:>8.1f}%"
f"{v_*100:>7.1f}%")
print(" ⚠ la DIFFERENZA di queste mediane NON e' la mediana delle differenze appaiate (§4):")
print(" qui le mediane cadono su ancore diverse. La tabella serve a dare i LIVELLI netti fee")
print(" richiesti dal contratto; il numero che decide resta quello appaiato.")
# ---------------------------------------------------------- §8 weights_tilt_null
print("\n" + "-" * L)
print(" 8. `weights_tilt_null` — una modulazione binaria della size E' un cambio di pesi nel")
print(" tempo. Il gate confronta vettori STATICI: cattura la sola componente di SCALA")
print(" (limite dichiarato, come nel bersaglio). k_seen = tutti i trial dichiarati.")
print("-" * L)
cols = {"TP01": TP, "SKH01": base_leg[can]}
for lab, r in (("cella dello scettico", RES[nmcan]), ("cella scelta al buio", CHOSEN),
("argmax di famiglia", argmax)):
nm = cname(r["thr"], r["w"], r["h"], r["sg"])
m = MS[nm][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(ALLC) + N_INHERITED)
print(f" {lab:<24} {nm:<24} 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, e va detto perche' e' aggirabile: il binario ABBASSA il peso effettivo di")
print(" SKH01, e il gate misura proprio quella caduta. Nella forma di deploy naturale (si")
print(" ri-scala la gamba per riportare il peso effettivo a 0,25) il vettore proposto e'")
print(" IDENTICO a quello corrente -> d_IS = d_HOLD = 0 e il gate diventa INERTE, non")
print(" superato. Quindi `weights_tilt_null` NON puo' essere l'evidenza decisiva qui: e'")
print(" negativo nella forma nuda e vacuo nella forma compensata. Cio' che decide resta la")
print(" banda APPAIATA a iso-peso (§4) e la stima di famiglia.")
# ---------------------------------------------------------- §9 eseguibilita'
print("\n" + "-" * L)
print(f" 9. ESEGUIBILITA' a ${CAPITAL:.0f} — il vantaggio DICHIARATO dal filone (dimezza gli")
print(" ordini). Target NETTATO 0.75*TP01 + 0.25*SKH sul grid 1h = cio' che il cron manda.")
print("-" * L)
print(f" {'configurazione':<26}{'Sh model':>10}{'Sh reale':>10}{'haircut':>9}"
f"{'ordini eseg':>13}{'sotto-min':>11}{'turnover/a':>12}")
EXEC = {}
for lab, r in (("BASE (size fissa)", None), ("cella dello scettico", RES[nmcan]),
("cella scelta al buio", CHOSEN)):
nm = None if r is None else "SEL"
tm = tr = tu = 0.0
no = ns = 0
for a in ASSETS:
df1h = TO.get1h(a)
SZa = {} if r is None else {
"SEL": (lambda ex, rr=r, aa=a: size_bin(rr["thr"], rr["w"], rr["h"],
rr["sg"])(ex, (can, aa)))}
tgt = V.netted_target(a, EX[can][a], nm, SZa, {}, {}, {}, {}, 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())
EXEC[lab] = (tm, tr, no, ns, tu)
print(f" {lab:<26}{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.")
ntr_base = int(sum(len(EX[can][a]['i_ent']) for a in ASSETS))
ntr_sel = int(sum((size_bin(CHOSEN['thr'], CHOSEN['w'], CHOSEN['h'], CHOSEN['sg'])
(EX[can][a], (can, a)) > 0).sum() for a in ASSETS))
print(f" trade SKH eseguiti (off{can}): BASE {ntr_base} -> cella scelta {ntr_sel} "
f"({ntr_sel/ntr_base*100:.0f}%)")
print(" ⚠ IL RISPARMIO DI FEE E' GIA' DENTRO i numeri sopra: `extract` netta ogni trade di")
print(f" FEE_RT={V.FEE_RT:.4f} (10 bps RT) all'ingresso, quindi un trade scartato non paga.")
print(" Convertirlo di nuovo con la curva del 26/07 (-0,017 Sh/bps di libro, SKH 4x TP01)")
print(" sarebbe un DOPPIO CONTEGGIO. Cio' che NON e' dentro e' (a) il min-order a $635,")
print(" misurato qui sopra come haircut, e (b) il fatto che la fee reale e' 3,5 bps/lato =")
print(" 7 bps RT contro i 10 modellati -> i backtest sono CONSERVATIVI, in entrambe le braccia.")
# ---------------------------------------------------------- §9b fee vs informazione
print("\n" + "-" * L)
print(" 9b. QUANTO DELL'EFFETTO E' INFORMAZIONE E QUANTO E' SOLO FEE RISPARMIATA.")
print(" Scartare meta' dei trade risparmia meta' delle fee: parte del +0,080 e' quella,")
print(" ed e' un guadagno vero ma NON e' un'ipotesi sulla vol. Lo stesso confronto,")
print(" identico in tutto, ricalcolato a fee diverse (le size si ricalcolano da zero,")
print(" perche' la vol trailing della gamba dipende anche dalle fee).")
print("-" * L)
print(f" {'lente':<40}{'cella scettico':>17}{'pos/n':>8}{'cella al buio':>17}{'pos/n':>8}")
FEEDEC = {}
keep_fee = V.FEE_RT
for fee, tag in ((keep_fee, f"MODELLATA {keep_fee*1e4:.0f} bps RT (i backtest)"),
(0.0007, "REALE 3,5 bps/lato = 7 bps RT"),
(0.0, "fee ZERO (informazione pura)")):
V.FEE_RT = fee
if fee == keep_fee:
EXf, basef = EX, base_leg
else:
EXf = {o: {a: V.extract(a, o) for a in ASSETS} for o in offs}
basef = {o: V.leg_daily(EXf[o], {a: V.size_flat(EXf[o][a]) for a in ASSETS})
for o in offs}
row = ""
for r in (RES[nmcan], CHOSEN):
fn = size_bin(r["thr"], r["w"], r["h"], r["sg"])
bk, ct = {}, {}
for o in offs:
lg = V.leg_daily(EXf[o], {a: fn(EXf[o][a], (tag, o, a)) for a in ASSETS})
bk[o], ct[o], _ = iso_pair(TP, lg, basef[o])
d = A.anchor_luck_delta(lambda o: bk[o], lambda o: ct[o], offs, metric=sh)
FEEDEC[(tag, cname(r["thr"], r["w"], r["h"], r["sg"]))] = d["median_paired"]
row += f"{d['median_paired']:>+17.3f}{d['n_positive']:>5}/{len(offs):<3}"
print(f" {tag:<40}{row}")
if fee != keep_fee:
del EXf, basef
V.FEE_RT = keep_fee
assert V.FEE_RT == 0.001
f_mod = FEEDEC[(f"MODELLATA {keep_fee*1e4:.0f} bps RT (i backtest)", nmcan)]
f_zero = FEEDEC[("fee ZERO (informazione pura)", nmcan)]
print(f" -> a fee ZERO resta {f_zero:+.3f} di {f_mod:+.3f} = "
f"{100*f_zero/f_mod if f_mod else float('nan'):.0f}% dell'effetto: la parte restante")
print(" e' fee risparmiata. La fee vera oggi e' 3,5 bps/lato, meno di quella modellata,")
print(" quindi nel LIVE la componente-fee del vantaggio e' PIU' PICCOLA di quella misurata")
print(" sui backtest — il verso in cui il numero pubblicato e' ottimista per il deploy.")
# ---------------------------------------------------------- §10 trasferimento su V1
print("\n" + "-" * L)
print(" 10. TRASFERIMENTO su SKH01_V1 — seconda sequenza di trade, geometria d'uscita")
print(" DIVERSA (stop/target in ATR invece che a percentuale fissa). Non e' un hold-out")
print(" temporale, ma e' l'unico campione indipendente disponibile a questo costo.")
print("-" * L)
EX1 = {o: {a: V.extract(a, o, SKH01_V1) for a in ASSETS} for o in offs}
base1 = {o: V.leg_daily(EX1[o], {a: V.size_flat(EX1[o][a]) for a in ASSETS}) for o in offs}
print(f" {'cella':<26}{'ISO dShFULL':>13}{'pos/n':>8}{'ISO dShHOLD':>14}{'pos/n':>8}"
f"{'tenuti':>9}")
for lab, r in (("cella dello scettico", RES[nmcan]), ("cella scelta al buio", CHOSEN)):
fn = size_bin(r["thr"], r["w"], r["h"], r["sg"])
b1, c1, kp = {}, {}, []
for o in offs:
lg = V.leg_daily(EX1[o], {a: fn(EX1[o][a], ("V1", o, a)) for a in ASSETS})
b1[o], c1[o], _ = iso_pair(TP, lg, base1[o])
kp.append(float(np.mean(np.concatenate(
[fn(EX1[o][a], ("V1", o, a)) for a in ASSETS]))))
dF = A.anchor_luck_delta(lambda o: b1[o], lambda o: c1[o], offs, metric=sh)
dH = A.anchor_luck_delta(lambda o: hold(b1[o]), lambda o: hold(c1[o]), offs, metric=sh)
print(f" {cname(r['thr'], r['w'], r['h'], r['sg']):<26}{dF['median_paired']:>+13.3f}"
f"{dF['n_positive']:>5}/{len(offs):<3}{dH['median_paired']:>+14.3f}"
f"{dH['n_positive']:>5}/{len(offs):<3}{float(np.median(kp))*100:>8.0f}%")
# ---------------------------------------------------------- §11 per anno
print("\n" + "-" * L)
print(" 11. SCOMPOSIZIONE PER ANNO (regola 22/08 dopo SOL: un contributo positivo si")
print(" scompone per ANNO prima di crederci — 24/24 ancore possono essere un anno solo)")
print("-" * L)
yrs = sorted({int(y) for y in BK[nmcan][can].index.year})
print(f" {'cella':<26}" + "".join(f"{y:>8}" for y in yrs))
for lab, r in (("cella dello scettico", RES[nmcan]), ("cella scelta al buio", CHOSEN)):
nm = cname(r["thr"], r["w"], r["h"], r["sg"])
cc = []
for y in yrs:
v = [sh(BK[nm][o][BK[nm][o].index.year == y]) - sh(CT[nm][o][CT[nm][o].index.year == y])
for o in offs if (BK[nm][o].index.year == y).sum() > 20]
cc.append(float(np.median(v)) if v else float("nan"))
print(f" {nm:<26}" + "".join(f"{c:>+8.2f}" for c in cc) +
f" positivi {int(np.nansum(np.asarray(cc) > 0))}/{len(yrs)}")
# ---------------------------------------------------------- §12 la domanda che decide
print("\n" + "-" * L)
print(" 12. LA DOMANDA CHE DECIDE — un effetto che vale un terzo della propria incertezza")
print(" merita di cambiare un sistema che gira con soldi veri?")
print("-" * L)
amp = max(ban_F) - min(ban_F)
nm = nmcan
dC = float(np.median([cagr(BK[nm][o]) - cagr(CT[nm][o]) for o in offs]))
cag_band = [cagr(book(TP, base_leg[o])) for o in offs]
print(f" (i) IL CONFRONTO GIUSTO NON E' QUELLO DELLA DOMANDA. La banda d'ancora del")
print(f" LIVELLO ({amp:.3f} di ampiezza, +-{amp/2:.3f}) e' incertezza sul livello, e")
print(f" si CANCELLA in un confronto APPAIATO: le due braccia girano sulla STESSA")
print(f" ancora. Cio' che va confrontato con l'effetto e' la dispersione della")
print(f" DIFFERENZA appaiata, che e' un ordine di grandezza piu' piccola.")
print(f" cella scettico: effetto {RES[nm]['dF']:+.3f}, banda appaiata "
f"[{RES[nm]['lo']:+.3f},{RES[nm]['hi']:+.3f}], "
f"{RES[nm]['nF']}/{len(offs)} ancore positive.")
print(f" (ii) MA la banda del livello torna a mordere DUE volte, e in modo decisivo:")
print(f" (a) la SCELTA della cella e' fatta guardando quella banda -> la stima onesta")
print(f" non e' l'argmax ma la MEDIANA DI FAMIGLIA "
f"({float(np.median([r['dF'] for r in POS])):+.3f} sul segno +1);")
print(f" (b) l'effetto e' misurato sugli STESSI 8 anni su cui SKH01 e' stato")
print(f" selezionato, e non esiste un forward capace di misurarlo (SE(Sharpe)")
print(f" ~1,4 su 6 mesi contro un effetto di 0,08).")
print(f" (iii) IL CRITERIO, dichiarato: si tocca il libro live solo se TUTTE E QUATTRO —")
print(f" (1) l'effetto sopravvive alla stima ONESTA di famiglia (mediana, non argmax)")
print(f" (2) `weights_tilt_null` passa (e' il gate codificato per i cambi di peso)")
print(f" (3) il gate marginale + un null CON POTENZA lo sostengono")
print(f" (4) l'effetto e' grande abbastanza da pagare un modo di fallire in piu'.")
print(f" (iv) ORDINI DI GRANDEZZA a ${CAPITAL:.0f} (regola 26/07: quando le leve hanno")
print(f" ordini di grandezza diversi, DIRLO):")
print(f" effetto della cella (ISO, mediana appaiata): dCAGR {dC*100:+.2f}%/anno")
print(f" = ${dC*CAPITAL:+.2f}/anno = EUR {dC*CAPITAL/365*0.86:+.4f}/giorno")
print(f" banda d'ancora del CAGR del libro: "
f"[{min(cag_band)*100:.1f}%, {max(cag_band)*100:.1f}%] "
f"= ${(max(cag_band)-min(cag_band))*CAPITAL:.0f}/anno di incertezza")
print(f" versare EUR 500/mese (il piano dell'operatore) = EUR 16,44/giorno di capitale")
print(f" nuovo, cioe' ~{16.44/max(1e-9, abs(dC*CAPITAL/365*0.86)):.0f}x l'effetto misurato qui.")
print(f" (v) IL CRITERIO APPLICATO, riga per riga:")
med_pos = float(np.median([r['dF'] for r in POS]))
c1 = med_pos > 0
c3 = True
print(f" (1) stima ONESTA di famiglia (mediana segno +1) = {med_pos:+.3f} "
f"contro argmax {argmax['dF']:+.3f} e cella pubblicata {RES[nmcan]['dF']:+.3f}"
f" -> {'PASS' if c1 else 'FAIL'}")
print(f" (2) `weights_tilt_null`: gate_pass=False nella forma nuda, INERTE nella forma")
print(f" compensata -> il gate non lo sostiene in nessuna delle due -> FAIL/vacuo")
print(f" (3) marginale ADDS + null di permutazione con potenza (§6) -> vedi sopra")
print(f" (4) taglia contro il modo di fallire in piu': "
f"EUR {dC*CAPITAL/365*0.86:+.4f}/giorno a ${CAPITAL:.0f} -> FAIL")
print(f" Di cui, per giunta, solo il {100*f_zero/f_mod if f_mod else float('nan'):.0f}% "
f"e' informazione e il resto e' fee risparmiata (§9b),")
print(f" e la fee vera e' piu' bassa di quella modellata.")
print("\n" + "=" * L)
print(f" fatto in {time.time()-t0:.0f}s")
print("=" * L)
if __name__ == "__main__":
main()
+957
View File
@@ -0,0 +1,957 @@
#!/usr/bin/env python
"""r0822b_bocpd.py — IL FALSIFICATORE NOMINATO E MAI ESEGUITO del filone ADAPTIVE-HORIZON.
CONTESTO (perche' questo file esiste)
-------------------------------------
Il 22/08 `r0822_adaptive_horizon.py` ha scartato il TSMOM a orizzonte adattivo con una diagnosi
netta: il "vincitore adattivo" aveva L_t **incollato al bordo il 100% del tempo** = un TSMOM a
orizzonte COSTANTE di 20 giorni travestito (corr 1.000, dSh 0.00 in 8/8 anni e 0/12 ancore);
isolando le 52 celle davvero adattive -> NEUTRAL, corr->TP01 0.90 = TP01 travestito.
Ma quell'agente ha dichiarato **cosa lo smentirebbe** e non ha potuto eseguirlo:
"Un rilevatore il cui L_t si muove DAVVERO e batte il miglior lookback COSTANTE (non TP01) in
>=6/8 anni appaiati con corr->TP01 < 0.6. La mia famiglia copre 3 rilevatori a soglia/finestra
fissa — un run-length posterior bayesiano (BOCPD/PELT vero) o un L scelto per walk-forward sul
PnL realizzato non sono stati provati."
Questo file esegue **quel** test. Oggi il filone e' chiuso su UNA famiglia (3 proxy a soglia
fissa), non sul MECCANISMO. Una seconda chiusura, con rilevatori veri, rende il "no" definitivo.
PERCHE' BOCPD E' STRUTTURALMENTE DIVERSO DAI 3 PROXY DEL PRIMO TENTATIVO
-----------------------------------------------------------------------
I 3 proxy (CUSUM a soglia k, variance-ratio, Hurst) producevano una STATISTICA che veniva poi
MAPPATA linearmente dentro [Lmin, Lmax]: la mappatura e' arbitraria, e infatti la statistica
saturava contro il clip (bordo 100%). Un run-length posterior NON ha bisogno di mappatura:
la lunghezza di run **e' gia' un lookback**, nelle stesse unita' (barre). "Quanto indietro posso
guardare" = "da quanto tempo non cambia il regime" e' un'identita', non una scelta di scala.
* BOCPD (Adams & MacKay 2007): posterior online su r_t con hazard costante e verosimiglianza
Normale-InverseGamma coniugata (predittiva Student-t). Causale PER COSTRUZIONE: e' un filtro
in avanti, ogni P(r_t | x_{1:t}) usa solo x_{1:t}.
* OP/PELT causale: PELT e' un'accelerazione (pruning) dell'**optimal partitioning**
F(t) = min_{s<t} [F(s) + C(y_{s+1:t}) + beta]. La ricorsione E' online. ⚠️ La maggior parte
delle implementazioni di change-point (ruptures.Pelt().fit_predict(intera_serie)) e'
**retrospettiva** e restituisce la segmentazione dell'INTERO campione = look-ahead colossale.
Qui F(t) e la sua backtracking-partition sono calcolati SOLO su y[0:t] per ogni t.
Verificato con `A.causality_ok` **e** con un troncamento manuale esplicito (sezione 0-bis).
* WF-PnL: L scelto per walk-forward sullo Sharpe NETTO realizzato dei candidati costanti nella
finestra all'indietro. Non usa alcun modello di regime: e' il "cosa avrebbe funzionato finora".
IL NULL GIUSTO NON E' TP01 — E' IL MIGLIOR LOOKBACK COSTANTE
------------------------------------------------------------
E' il controllo che ha ucciso il primo tentativo, e va rimesso PRIMA, non dopo: senza di esso
il null confonde "adattare paga" con "un orizzonte piu' corto paga su questo campione".
Percio' il confronto principale e': adattivo (cella scelta in-sample) vs miglior L COSTANTE
(cella scelta in-sample, stessa geometria, stesso TF, stesse fee), **appaiato per ANNO e per
ANCORA** (mediana delle DIFFERENZE APPAIATE, mai differenza delle mediane — lezione 26/07).
FAMIGLIA DICHIARATA **PRIMA** DI GUARDARE (conteggio al RIALZO)
--------------------------------------------------------------
A) rilevatori veri 2 (bocpd | op)
parametro 3 (bocpd: hazard lambda 50/100/250 g ; op: penalita' k 2/5/10)
statistica 2 (bocpd: E[r_t] | argmax r_t ; op: eta' dall'ultimo cp | mediana
delle lunghezze di segmento gia' concluse)
mappatura 2 (direct | inverse) [il verso non e' ovvio a priori => e' un asse]
blend 2 (single L | triple L/3,L,2L — con L=90 il triple E' TP01)
timeframe 2 (1d | 12h) [>=12h: sotto, costi+overfit dominano, 19/06]
= 2*3*2*2*2*2 = **96 trial**
B) controllo L COSTANTE 12 L x 2 blend x 2 TF = **48 trial**
C) WF-PnL (L da walk-forward) 3 finestre x 2 blend x 2 TF = **12 trial**
D) taratura della SCALA 8 penalita' OP + 5 hazard BOCPD = **13** (sezione 1-pre)
---------------------------------------------------------------- TOTALE DICHIARATO **169**
⚠️ (D) e' una scansione fatta guardando SOLO il numero di change-point rilevati — una proprieta'
del RILEVATORE — e mai uno Sharpe: nessuna di quelle 13 configurazioni produce un rendimento. Si
dichiara lo stesso, al rialzo, perche' ha ristretto la griglia di (A). Il suo esito e' gia' un
risultato: a penalita' OP >= 5 l'eta' resta incollata al tetto della finestra il 44% del tempo,
cioe' **l'artefatto del 22/08 riprodotto su un rilevatore vero** — e la griglia (A) usa solo la
regione in cui il rilevatore rileva davvero (0.5/1.0/2.0 = 18-40 change-point l'anno).
Costanti FISSATE A PRIORI (gradi di liberta' non cercati, dichiarati perche' esistono):
Lmin=20g, Lmax=200g (contengono 30..180 del canonico e sono gli stessi del primo tentativo);
BOCPD su rendimenti log STANDARDIZZATI da vol rolling causale 30g (il bersaglio e' il DRIFT, non
la vol); prior NIG mu0=0, kappa0=1, alpha0=1, beta0=1; troncamento del posterior a Lmax barre;
costo OP = mean-shift gaussiano su dati standardizzati, penalita' beta = k*log(t) causale.
Il deflated-Sharpe e' riportato a N=169 (mia famiglia), N=281 (mia + le 112 del primo tentativo:
**stessa domanda**, i trial si sommano) e N=449 (di SCREEN — lezione 22/08 del filone ORTHO:
su BTC/ETH direzionale il massimo atteso dal rumore su 168 trial e' Sharpe 1.572, SOPRA il
soffitto misurato ~1.3, quindi il conteggio di screen e' quello che decide).
ORDINE DI ESECUZIONE (imposto dalla missione, ed e' l'ordine giusto)
--------------------------------------------------------------------
La diagnostica che ha smascherato il primo tentativo va PER PRIMA: quanto si muove davvero L_t?
Se sta al bordo, l'artefatto e' riprodotto e il filone si chiude di nuovo senza guardare i
rendimenti. Solo se L_t si muove ha senso misurare se il movimento PAGA.
Runtime atteso ~6-10 min su 2 core. Nessun file toccato fuori da questo.
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
from scipy.special import gammaln
from scipy.stats import norm
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, IDENTICA al primo tentativo
FIXED_H = (30, 90, 180) # == (L/3, L, 2L) con L=90 -> TP01 canonico
TGT_VOL, LEV, VOLWIN = 0.20, 2.0, 30
NIG_MU0, NIG_KAP0, NIG_AL0, NIG_BE0 = 0.0, 1.0, 1.0, 1.0
STD_WIN_D = 30 # finestra della standardizzazione causale dei rendimenti
TFS = ("1d", "12h")
DETECTORS = ("bocpd", "op")
PARAMS = {"bocpd": (50, 100, 250), "op": (0.5, 1.0, 2.0)}
# TARATURA DELLA SCALA (sezione 1-pre): scansione ESPLORATIVA fatta guardando SOLO il numero di
# change-point rilevati — una proprieta' del RILEVATORE — e mai lo Sharpe. Un rilevatore che non
# rileva nulla non e' un rilevatore: a pen_k>=5 l'eta' resta incollata al tetto della finestra
# (44% del tempo) = ESATTAMENTE l'artefatto del 22/08, riprodotto qui su un rilevatore vero.
CAL_PEN = (0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0)
CAL_LAM = (25, 50, 100, 250, 500)
STATS = {"bocpd": ("mean", "map"), "op": ("age", "segmed")}
MAPPINGS = ("direct", "inverse")
BLENDS = ("single", "triple")
CONST_L = (5, 7, 10, 15, 20, 30, 45, 60, 90, 120, 150, 200)
WF_WIN_D = (180, 365, 730)
N_DECLARED_A = len(DETECTORS) * 3 * 2 * len(MAPPINGS) * len(BLENDS) * len(TFS) # 96
N_DECLARED_B = len(CONST_L) * len(BLENDS) * len(TFS) # 48
N_DECLARED_C = len(WF_WIN_D) * len(BLENDS) * len(TFS) # 12
N_DECLARED_CAL = len(CAL_PEN) + len(CAL_LAM) # 13 (taratura)
N_DECLARED = N_DECLARED_A + N_DECLARED_B + N_DECLARED_C + N_DECLARED_CAL # 169
N_PRIOR_FAMILY = 112 # trial dichiarati da r0822_adaptive_horizon (stessa domanda)
N_SCREEN = 168 # pool di screen direzionale BTC/ETH dell'ondata (filone 12)
# =======================================================================================
# RILEVATORI VERI
# =======================================================================================
def _logret(c: np.ndarray) -> np.ndarray:
r = np.zeros(len(c))
r[1:] = np.log(c[1:] / c[:-1])
return r
def _std_returns(c: np.ndarray, bpd: int) -> np.ndarray:
"""Rendimenti log standardizzati da vol rolling CAUSALE (min_periods -> warmup a 0).
Il bersaglio del rilevatore e' il cambio di DRIFT: senza standardizzare, un cambio di
regime di VOLATILITA' dominerebbe la verosimiglianza e il rilevatore misurerebbe la vol."""
lr = _logret(c)
w = max(5, STD_WIN_D * bpd)
sig = pd.Series(lr).rolling(w, min_periods=max(5, w // 4)).std().values
y = np.where((sig > 0) & np.isfinite(sig), lr / np.where(sig > 0, sig, 1.0), 0.0)
return np.nan_to_num(np.clip(y, -8.0, 8.0))
def _student_logpdf(x: float, mu: np.ndarray, kap: np.ndarray,
al: np.ndarray, be: np.ndarray) -> np.ndarray:
"""Predittiva Student-t della Normale-InverseGamma coniugata (Murphy 2007, eq. 99-100):
x | mu,kappa,alpha,beta ~ t_{2 alpha}(mu, beta(kappa+1)/(alpha kappa))."""
nu = 2.0 * al
s2 = be * (kap + 1.0) / (al * kap)
z = (x - mu) ** 2 / (nu * s2)
return (gammaln(0.5 * (nu + 1.0)) - gammaln(0.5 * nu)
- 0.5 * np.log(nu * np.pi * s2) - 0.5 * (nu + 1.0) * np.log1p(z))
def _bocpd(y: np.ndarray, lam_bars: float, rmax: int) -> tuple[np.ndarray, np.ndarray]:
"""BOCPD (Adams & MacKay 2007) con hazard COSTANTE H = 1/lam_bars e verosimiglianza NIG.
Ritorna (E[r_t], argmax_r P(r_t)) in BARRE. Filtro in avanti: ogni valore usa solo il
passato -> causale per costruzione, e stabile su prefisso (nessuna statistica full-sample).
Il posterior e' troncato a rmax barre e rinormalizzato (il troncamento e' un limite di
memoria, NON un clip sul valore: se la massa si accumula a rmax lo si vede in diagnostica)."""
n = len(y)
haz = 1.0 / max(2.0, float(lam_bars))
R = np.array([1.0])
mu = np.array([NIG_MU0]); kap = np.array([NIG_KAP0])
al = np.array([NIG_AL0]); be = np.array([NIG_BE0])
rl = np.arange(1)
mean_rl = np.zeros(n); map_rl = np.zeros(n)
for t in range(n):
x = float(y[t])
lp = _student_logpdf(x, mu, kap, al, be)
lp -= lp.max()
pred = np.exp(lp)
gr = R * pred * (1.0 - haz)
cp = float(np.sum(R * pred * haz))
m_new = min(len(R) + 1, rmax + 1)
newR = np.empty(m_new)
newR[0] = cp
newR[1:] = gr[:m_new - 1]
s = newR.sum()
if not np.isfinite(s) or s <= 0:
newR = np.zeros(m_new); newR[0] = 1.0; s = 1.0
newR /= s
# aggiornamento delle statistiche sufficienti (run r -> run r+1), prior in testa
mu_f = np.concatenate(([NIG_MU0], (kap * mu + x) / (kap + 1.0)))[:m_new]
kap_f = np.concatenate(([NIG_KAP0], kap + 1.0))[:m_new]
al_f = np.concatenate(([NIG_AL0], al + 0.5))[:m_new]
be_f = np.concatenate(([NIG_BE0], be + kap * (x - mu) ** 2 / (2.0 * (kap + 1.0))))[:m_new]
R, mu, kap, al, be = newR, mu_f, kap_f, al_f, be_f
if len(rl) != m_new:
rl = np.arange(m_new, dtype=float)
mean_rl[t] = float(np.dot(R, rl))
map_rl[t] = float(rl[int(np.argmax(R))])
return mean_rl, map_rl
def _op_causal(y: np.ndarray, pen_k: float, smax: int) -> tuple[np.ndarray, np.ndarray]:
"""OPTIMAL PARTITIONING **online** (l'obiettivo esatto che PELT accelera con il pruning):
F(t) = min_{s in [t-smax, t-1]} [ F(s) + C(y[s:t]) + beta(t) ]
con C = costo gaussiano di mean-shift su dati standardizzati e beta(t) = pen_k*log(t).
⚠️ Il termine sum(y^2) del costo gaussiano e' INVARIANTE alla partizione (ogni punto sta in
esattamente un segmento) -> si omette dall'argmin. C(s:t) = -(S[t]-S[s])^2/(t-s).
Ritorna (eta' dall'ultimo change-point, mediana delle lunghezze dei segmenti CONCLUSI),
entrambe in barre. Per ogni t si usa SOLO y[0:t]: e' la differenza fra questo e un
`ruptures.Pelt().fit_predict(y)` retrospettivo, che guarda tutto il campione."""
n = len(y)
S = np.concatenate(([0.0], np.cumsum(y)))
F = np.full(n + 1, np.inf)
F[0] = 0.0
prev = np.zeros(n + 1, dtype=int)
age = np.zeros(n); segmed = np.zeros(n)
for t in range(1, n + 1):
a = max(0, t - smax)
s_idx = np.arange(a, t)
seg = (S[t] - S[a:t])
ln = (t - s_idx).astype(float)
cand = F[a:t] - seg * seg / ln
j = int(np.argmin(cand))
beta = pen_k * np.log(max(2.0, float(t)))
F[t] = cand[j] + beta
prev[t] = int(s_idx[j])
# backtracking della partizione VALIDA A t (cap a 40 segmenti: ci serve la mediana)
last = prev[t]
age[t - 1] = t - last
lens = []
u = last
while u > 0 and len(lens) < 40:
p = prev[u]
lens.append(u - p)
u = p
segmed[t - 1] = float(np.median(lens)) if lens else float(t)
return age, segmed
_STAT_CACHE: dict = {}
def _stat_key(df: pd.DataFrame, det: str, par) -> tuple:
"""Chiave chiusa sul CONTENUTO del frame (lunghezza + primo/ultimo timestamp): un PREFISSO
non deve prendere la cache del campione pieno, altrimenti `causality_ok` verrebbe ingannata
dalla cache invece che dal codice."""
ts = df["timestamp"].values
return (det, par, len(df), int(ts[0]), int(ts[-1]))
def detector_stats(df: pd.DataFrame, det: str, par) -> dict:
key = _stat_key(df, det, par)
if key in _STAT_CACHE:
return _STAT_CACHE[key]
bpd = A.bars_per_day(df)
c = df["close"].values.astype(float)
y = _std_returns(c, bpd)
rmax = max(4, LMAX_D * bpd)
if det == "bocpd":
m, mp = _bocpd(y, float(par) * bpd, rmax)
out = {"mean": m, "map": mp}
elif det == "op":
ag, sm = _op_causal(y, float(par), rmax)
out = {"age": ag, "segmed": sm}
else:
raise ValueError(det)
_STAT_CACHE[key] = out
return out
def lookback_bars(df: pd.DataFrame, det: str, par, stat: str, mapping: str) -> np.ndarray:
"""L_t in BARRE dentro [Lmin, Lmax] giorni. `direct` = la lunghezza di run E' il lookback
(dopo un change-point si puo' guardare indietro solo fin la'); `inverse` e' lo specchio,
dichiarato come asse perche' il verso non e' ovvio a priori."""
bpd = A.bars_per_day(df)
lo, hi = max(2, LMIN_D * bpd), max(3, LMAX_D * bpd)
v = detector_stats(df, det, par)[stat]
base = np.clip(np.nan_to_num(v, nan=float(lo)), lo, hi)
L = base if mapping == "direct" else (lo + hi - base)
return np.clip(np.round(L), 2, hi).astype(int)
# =======================================================================================
# DIREZIONE TSMOM (orizzonte costante o variabile) + vol-target — IDENTICA al primo tentativo
# =======================================================================================
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):
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)
return A.vol_target(np.clip(_dir_from_L(c, Lb, blend), 0, None), df, TGT_VOL, VOLWIN, LEV)
fn.__name__ = f"const_L{L}_{blend}"
return fn
def factory(tf: str, det: str, par, stat: str, mapping: str, blend: str):
def fn(df: pd.DataFrame) -> np.ndarray:
c = df["close"].values.astype(float)
L = lookback_bars(df, det, par, stat, mapping)
return A.vol_target(np.clip(_dir_from_L(c, L, blend), 0, None), df, TGT_VOL, VOLWIN, LEV)
fn.__name__ = f"adapt_{det}{par}_{stat}_{mapping}_{blend}"
return fn
_WF_CACHE: dict = {}
def wf_picks(df: pd.DataFrame, win_d: int, blend: str):
"""(C) L scelto per WALK-FORWARD sul PnL NETTO realizzato: a ogni barra si prende l'L che,
fra i candidati COSTANTI, ha lo Sharpe netto migliore nella finestra all'indietro `win_d`.
Nessun modello di regime: e' il "cosa avrebbe funzionato finora".
Causale: il PnL della barra i si conosce solo a fine barra i, quindi per decidere a i si usa
la finestra che finisce a i-1; `eval_weights` shifta poi la posizione di un'altra barra."""
cands = [L for L in CONST_L if LMIN_D <= L <= LMAX_D]
key = (win_d, blend) + _stat_key(df, "wf", 0)[2:]
if key in _WF_CACHE:
return _WF_CACHE[key]
c = df["close"].values.astype(float)
bpd = A.bars_per_day(df)
n = len(c)
r = A.simple_returns(c)
w = max(20, win_d * bpd)
tgts, sh = [], []
for L in cands:
Lb = np.full(n, max(2, int(round(L * bpd))), int)
t_ = A.vol_target(np.clip(_dir_from_L(c, Lb, blend), 0, None), df, TGT_VOL, VOLWIN, LEV)
pos = np.zeros(n); pos[1:] = t_[:-1]
net = pos * r - A.FEE_SIDE * np.abs(np.diff(pos, prepend=0.0))
ss = pd.Series(net)
mu = ss.rolling(w, min_periods=w // 2).mean()
sd = ss.rolling(w, min_periods=w // 2).std()
with np.errstate(invalid="ignore", divide="ignore"):
sr = (mu / sd).values
sr = np.concatenate(([np.nan], sr[:-1])) # PnL noto solo a fine barra
tgts.append(t_); sh.append(np.where(np.isfinite(sr), sr, -np.inf))
SH = np.vstack(sh); TG = np.vstack(tgts)
idx = np.argmax(SH, axis=0)
idx[~np.isfinite(SH).any(axis=0)] = cands.index(90) # warmup -> il canonico, non il migliore
out = (np.asarray([cands[i] for i in idx]), TG[idx, np.arange(n)], cands)
_WF_CACHE[key] = out
return out
def wf_factory(tf: str, win_d: int, blend: str):
def fn(df: pd.DataFrame) -> np.ndarray:
return wf_picks(df, win_d, blend)[1]
fn.__name__ = f"wfpnl_{win_d}_{blend}"
return fn
GRID_A = [dict(det=d, par=p, stat=s, mapping=m, blend=b)
for d in DETECTORS for p in PARAMS[d] for s in STATS[d]
for m in MAPPINGS for b in BLENDS]
GRID_B = [dict(L=L, blend=b) for L in CONST_L for b in BLENDS]
GRID_C = [dict(win_d=w, blend=b) for w in WF_WIN_D for b in BLENDS]
# =======================================================================================
# UTILITA'
# =======================================================================================
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 if eq > 0 else -1.0
def resample_offset(df_1h: pd.DataFrame, hours: int, off: int) -> pd.DataFrame:
"""Stessa convenzione di trend_portfolio.resample_tf, ancora spostata di `off` ore.
NB `offset=`, non `origin=` (pandas 2.x ignora origin in silenzio — lezione 02/07)."""
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 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)))
return rows
def delever_null(cand: pd.Series, base: pd.Series) -> dict:
"""Esiste k<1 che da' al BASELINE lo stesso maxDD del candidato con Sharpe >= ?
Se si', il candidato non ha comprato protezione: ha solo tolto leva."""
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),
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:
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, stat: str, mapping: str) -> dict:
"""LA DIAGNOSTICA CHE HA SMASCHERATO IL PRIMO TENTATIVO — quanto si MUOVE davvero L_t.
Criterio (identico a quello del 22/08, per confrontabilita'): sta a un bordo <50% del tempo
E l'IQR e' >= 10 giorni. In piu': |dL|/giorno e numero di RESET (cadute di L > 5 giorni),
che sono la firma di un vero rilevatore di change-point."""
fl, fh, iq, md, dl, nrs, sat = [], [], [], [], [], [], []
for a in ASSETS:
df = A.get(a, tf)
bpd = A.bars_per_day(df)
L = lookback_bars(df, det, par, stat, mapping) / bpd
raw = np.nan_to_num(detector_stats(df, det, par)[stat], nan=0.0) / bpd
fl.append(float(np.mean(L <= LMIN_D + 1)))
fh.append(float(np.mean(L >= LMAX_D - 1)))
iq.append(float(np.percentile(L, 75) - np.percentile(L, 25)))
md.append(float(np.median(L)))
dl.append(float(np.median(np.abs(np.diff(L)))))
d = np.diff(L)
nrs.append(float(np.sum(d < -5.0) / (len(L) / (365.25)))) # reset all'anno
sat.append(float(np.mean(raw >= LMAX_D - 1))) # saturazione del GREZZO
return dict(frac_lo=float(np.mean(fl)), frac_hi=float(np.mean(fh)),
iqr_days=float(np.mean(iq)), med_days=float(np.mean(md)),
dL_med=float(np.mean(dl)), reset_yr=float(np.mean(nrs)),
sat_raw=float(np.mean(sat)),
adattiva=bool(np.mean(fl) < 0.5 and np.mean(fh) < 0.5 and np.mean(iq) >= 10.0))
def hr(t: str = "") -> None:
print("\n" + "=" * 86)
if t:
print(t)
print("=" * 86)
def dsr_given_sr0(daily: pd.Series, sr_ann: float, sr0_ann: float,
dpy: float = 365.25) -> float:
"""Deflated-Sharpe con il massimo-atteso-dal-rumore IMPOSTO dall'esterno.
Serve perche' `A.deflated_sharpe` stima sr0 dalla VARIANZA degli Sharpe della griglia: su
una famiglia OMOGENEA (qui sono tutte varianti di TSMOM, quindi molto correlate) quella
varianza e' piccola, sr0 esce basso e il DSR esce alto **per costruzione**. Il conteggio che
decide, sulla ricerca direzionale BTC/ETH, e' quello di SCREEN: il filone ORTHO ha misurato
che su 168 trial il massimo atteso dal puro rumore e' Sharpe 1.572."""
r = np.asarray(pd.Series(daily).dropna().values, float)
T = len(r)
sr, sr0 = sr_ann / np.sqrt(dpy), sr0_ann / np.sqrt(dpy)
sk = float(pd.Series(r).skew())
ku = float(pd.Series(r).kurt()) + 3.0
den = np.sqrt(max(1e-9, 1 - sk * sr + (ku - 1) / 4.0 * sr ** 2))
return float(norm.cdf((sr - sr0) * np.sqrt(T - 1) / den))
def dsr_line(sr: float, allsr: list, daily: pd.Series, label: str, n_decl: int) -> str:
pad = list(allsr) + [float(np.mean(allsr))] * max(0, n_decl - len(allsr))
d, s0 = A.deflated_sharpe(sr, pad, daily)
return (f" N={n_decl:>4} ({label:<24}) DSR={d:.3f} {'PASS' if d >= 0.95 else 'FAIL'}"
f" max atteso dal rumore {s0:+.3f}")
# =======================================================================================
def main() -> None:
t0 = time.time()
print(__doc__.split("Runtime atteso")[0])
# -- 0. SANITY -----------------------------------------------------------------------
hr("0. SANITY — il baseline 'fisso' e' TP01 canonico (se non lo e', ogni delta 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 = A.candidate_daily(fixed_target, tf="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 - A.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"
# -- 0-bis. IL RILEVATORE E' CAUSALE? (troncamento MANUALE, prima dei gate) -----------
hr("0-bis. TRONCAMENTO MANUALE — la trappola del change-point RETROSPETTIVO")
print(" Un `Pelt().fit_predict(serie_intera)` restituisce la segmentazione dell'INTERO")
print(" campione: al tempo t 'saprebbe' dei cambi futuri. Qui BOCPD e OP sono ricorsioni in")
print(" avanti. Prova diretta: ricalcolo su un PREFISSO e confronto la coda con il pieno.")
for det, par in (("bocpd", 100), ("op", 5.0)):
for stat in STATS[det]:
wmax = 0.0
for a in ASSETS:
df = A.get(a, "1d")
full = lookback_bars(df, det, par, stat, "direct")
for frac in (0.70, 0.85):
cut = int(len(df) * frac)
sub = df.iloc[:cut].reset_index(drop=True)
pre = lookback_bars(sub, det, par, stat, "direct")
wmax = max(wmax, float(np.max(np.abs(pre[-100:] - full[cut - 100:cut]))))
print(f" {det:<6} {str(par):>5} {stat:<7}: max|L(prefisso)-L(pieno)| sulla coda "
f"= {wmax:.3e} barre -> {'CAUSALE' if wmax < 1e-9 else 'SOSPETTO'}")
# -- 1-pre. TARATURA DELLA SCALA — guardando il RILEVATORE, mai lo Sharpe -----------
hr("1-pre. TARATURA DELLA SCALA DEI PARAMETRI (guardando SOLO quanti change-point rileva)")
print(" Un rilevatore che non rileva nulla non e' un rilevatore. Questa scansione guarda il")
print(" NUMERO di change-point e la distribuzione di L_t — mai un rendimento. Dichiarata")
print(" come 13 trial in piu' (al rialzo), benche' nessuno produca uno Sharpe.")
dfc = A.get("BTC", "1d")
yc = _std_returns(dfc["close"].values.astype(float), 1)
print(f"\n OP (BTC 1d) {'pen_k':>6} {'cp/anno':>8} {'age med':>8} {'IQR':>7} {'@tetto':>8}")
for k in CAL_PEN:
ag, _ = _op_causal(yc, k, LMAX_D)
ncp = int(np.sum(np.diff(ag) < 0))
flag = " <= USATO" if k in PARAMS["op"] else (" <= degenere (artefatto 22/08)"
if np.mean(ag >= LMAX_D - 1) > 0.30 else "")
print(f" {k:>6} {ncp/(len(yc)/365.25):>8.1f} {np.median(ag):>8.1f} "
f"{np.percentile(ag,75)-np.percentile(ag,25):>7.1f} "
f"{np.mean(ag>=LMAX_D-1)*100:>7.1f}%{flag}")
print(f"\n BOCPD (BTC 1d) {'lambda':>6} {'E[r] med':>9} {'IQR':>7} {'@tetto':>8} "
f"{'MAP med':>8} {'@tetto':>8}")
for lam in CAL_LAM:
mn, mp = _bocpd(yc, float(lam), LMAX_D)
flag = " <= USATO" if lam in PARAMS["bocpd"] else ""
print(f" {lam:>6} {np.median(mn):>9.1f} "
f"{np.percentile(mn,75)-np.percentile(mn,25):>7.1f} "
f"{np.mean(mn>=LMAX_D-1)*100:>7.1f}% {np.median(mp):>8.1f} "
f"{np.mean(mp>=LMAX_D-1)*100:>7.1f}%{flag}")
# -- 1. LA DIAGNOSTICA DI MECCANISMO, PER PRIMA --------------------------------------
hr("1. L_t SI MUOVE DAVVERO? (se sta al bordo l'artefatto e' riprodotto: si chiude e basta)")
print(" criterio (identico al 22/08, per confrontabilita'): a un bordo <50% del tempo E")
print(" IQR >= 10 giorni. `reset/anno` = cadute di L > 5 giorni = firma di un change-point.")
print(f"\n {'TF':>4} {'det':>6} {'par':>5} {'stat':>7} {'map':<7} {'bordo-lo':>9} "
f"{'bordo-hi':>9} {'IQR(g)':>7} {'med(g)':>7} {'|dL|(g)':>8} {'reset/a':>8} "
f"{'sat.grezzo':>10} esito")
diag: dict = {}
for tf in TFS:
for d in DETECTORS:
for p in PARAMS[d]:
for st in STATS[d]:
for mp in MAPPINGS:
v = adaptivity(tf, d, p, st, mp)
diag[(tf, d, p, st, mp)] = v
print(f" {tf:>4} {d:>6} {str(p):>5} {st:>7} {mp:<7} "
f"{v['frac_lo']*100:8.1f}% {v['frac_hi']*100:8.1f}% "
f"{v['iqr_days']:7.1f} {v['med_days']:7.1f} {v['dL_med']:8.2f} "
f"{v['reset_yr']:8.1f} {v['sat_raw']*100:9.1f}% "
f"{'ADATTIVA' if v['adattiva'] else 'costante-travestito'}")
n_ad = sum(1 for v in diag.values() if v["adattiva"])
print(f"\n configurazioni di rilevatore DAVVERO adattive: {n_ad}/{len(diag)}")
if n_ad == 0:
hr("STOP — ARTEFATTO RIPRODOTTO")
print(" Nessun rilevatore muove L_t: ogni 'vincitore' sarebbe un orizzonte COSTANTE")
print(" travestito, esattamente come il 22/08. Il filone si chiude per la seconda volta")
print(" senza bisogno di guardare i rendimenti. FINE.")
return
# -- 2. GRIGLIA (A): cella scelta IN-SAMPLE + deflated-Sharpe -------------------------
hr(f"2. study_family_honest (A) — {len(GRID_A)} celle x {len(TFS)} TF = "
f"{len(GRID_A)*len(TFS)} trial")
rep = A.study_family_honest("BOCPD-H", factory, GRID_A, TFS)
ch = rep["chosen"]
print(f" celle valutate : {rep['n_cells']}")
print(f" cella scelta IN-SAMPLE : tf={ch['tf']} {ch['params']}")
print(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-10 IN-SAMPLE (come si sceglie onestamente):")
for r in rep["rows"][:10]:
ad = diag[(r["tf"], r["params"]["det"], r["params"]["par"],
r["params"]["stat"], r["params"]["mapping"])]["adattiva"]
print(f" IS {r['insample_sharpe']:+.3f} FULL {r['full_sharpe']:+.3f} {r['tf']:>3} "
f"{r['params']} {'[adattiva]' if ad else '[costante-travestito]'}")
fn_ch = factory(tf=ch["tf"], **ch["params"])
d_ch = A.candidate_daily(fn_ch, tf=ch["tf"])
# -- 3. CONTROLLO (B): il MIGLIOR ORIZZONTE COSTANTE ---------------------------------
hr(f"3. CONTROLLO L COSTANTE (B) — {len(GRID_B)} celle x {len(TFS)} TF = "
f"{len(GRID_B)*len(TFS)} trial [IL NULL GIUSTO, non TP01]")
csel = A.select_cell_insample(const_factory, GRID_B, TFS)
cch = csel["chosen"]
print(f" miglior COSTANTE scelto in-sample: tf={cch['tf']} {cch['params']} "
f"IS {cch['insample_sharpe']} FULL {cch['full_sharpe']}")
print(" top-8 costanti in-sample (L=90 triple 1d == TP01 canonico):")
for r in csel["rows"][:8]:
tag = " <== TP01" 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(tf=cch["tf"], **cch["params"])
d_const = A.candidate_daily(fn_const, tf=cch["tf"])
# -- 4. WF-PnL (C) --------------------------------------------------------------------
hr(f"4. WALK-FORWARD SUL PnL REALIZZATO (C) — {len(GRID_C)} celle x {len(TFS)} TF = "
f"{len(GRID_C)*len(TFS)} trial")
wsel = A.select_cell_insample(wf_factory, GRID_C, TFS)
wch = wsel["chosen"]
print(f" miglior WF scelto in-sample: tf={wch['tf']} {wch['params']} "
f"IS {wch['insample_sharpe']} FULL {wch['full_sharpe']}")
for r in wsel["rows"]:
print(f" IS {r['insample_sharpe']:+.3f} FULL {r['full_sharpe']:+.3f} {r['tf']:>3} "
f"{r['params']}")
fn_wf = wf_factory(tf=wch["tf"], **wch["params"])
d_wf = A.candidate_daily(fn_wf, tf=wch["tf"])
# quanto si muove L nella variante WF? (stesso metro della sezione 1)
for a in ASSETS:
df = A.get(a, wch["tf"]); bpd = A.bars_per_day(df)
pick, _, cands = wf_picks(df, **wch["params"])
print(f" {a}: L_WF giorni mediana {np.median(pick):.0f} IQR "
f"{np.percentile(pick,75)-np.percentile(pick,25):.0f} "
f"bordo basso {np.mean(pick<=min(cands))*100:.1f}% "
f"bordo alto {np.mean(pick>=max(cands))*100:.1f}% "
f"cambi/anno {np.sum(np.diff(pick)!=0)/(len(pick)/(bpd*365.25)):.0f}")
# -- 5. IL CONFRONTO CHE DECIDE ------------------------------------------------------
hr("5. ADATTIVO vs MIGLIOR COSTANTE (entrambi scelti IN-SAMPLE) — e vs TP01")
base_ch = A.candidate_daily(fixed_target, tf=ch["tf"])
for nome, ser in (("BOCPD/OP (A)", d_ch), ("WF-PnL (C)", d_wf),
("miglior COSTANTE (B)", d_const), ("TP01 30/90/180", base_1d)):
h = ser[ser.index >= HOLDOUT]
print(f" {nome:<22} FULL Sh {A._sh(ser):+.3f} HOLD Sh {A._sh(h):+.3f} "
f"maxDD {_dd(ser)*100:5.2f}% CAGR {_cagr(ser)*100:6.2f}% "
f"corr->TP01 {pd.concat({'a':ser,'b':tp01},axis=1,join='inner').dropna().corr().iloc[0,1]:+.3f}")
for lab, cand, ref, refname in (("A vs B", d_ch, d_const, "miglior costante"),
("C vs B", d_wf, d_const, "miglior costante"),
("A vs TP01", d_ch, base_1d, "TP01")):
J = pd.concat({"C": cand, "B": ref}, axis=1, join="inner").dropna()
JH = J[J.index >= HOLDOUT]
rows = paired_by_year(cand, ref)
ds = [r["d_sh"] for r in rows]
print(f"\n --- {lab} ({refname}) --- corr {J['C'].corr(J['B']):+.3f}")
print(f" dSh FULL {A._sh(J['C'])-A._sh(J['B']):+.3f} "
f"dSh HOLD {A._sh(JH['C'])-A._sh(JH['B']):+.3f}")
print(" differenza APPAIATA per anno:")
print(" " + " ".join(f"{r['year']}:{r['d_sh']:+.2f}" for r in rows))
print(f" -> anni con dSh>0: {sum(1 for x in ds if x > 0)}/{len(ds)} "
f"mediana {np.median(ds):+.2f} media {np.mean(ds):+.2f} "
f"[criterio del falsificatore: >=6/8 anni]")
# decomposizione: quanto del vantaggio su TP01 e' ADATTIVITA' e quanto e' "L piu' corto"?
JT = pd.concat({"C": d_ch, "K": d_const, "B": base_1d}, axis=1, join="inner").dropna()
v_tp = A._sh(JT["C"]) - A._sh(JT["B"])
v_ct = A._sh(JT["K"]) - A._sh(JT["B"])
print(f"\n DECOMPOSIZIONE del vantaggio dell'adattivo su TP01 (FULL, finestra comune):")
print(f" adattivo - TP01 = {v_tp:+.3f}")
print(f" miglior COSTANTE - TP01 = {v_ct:+.3f} <- quota spiegata da 'un L piu' corto'")
print(f" adattivo - miglior COSTANTE= {v_tp - v_ct:+.3f} <- quota spiegata dall'ADATTARE")
print(f" => l'adattivita' spiega il {100*(v_tp-v_ct)/v_tp if abs(v_tp)>1e-9 else float('nan'):+.0f}% "
f"del vantaggio su TP01")
# la superficie di L costante e' una CURVA o un CRINALE DI RUMORE? (replica del 22/08)
rows12 = {r["params"]["L"]: r["insample_sharpe"] for r in csel["rows"]
if r["tf"] == cch["tf"] and r["params"]["blend"] == cch["params"]["blend"]}
Ls = sorted(rows12)
adj = float(np.median(np.abs(np.diff([rows12[L] for L in Ls]))))
print(f"\n RISOLUZIONE della griglia costante ({cch['tf']}, {cch['params']['blend']}): salto IS "
f"mediano fra L ADIACENTI {adj:+.3f}")
print(f" contro il vantaggio del miglior costante su TP01 ({v_ct:+.3f}) -> rapporto "
f"{adj/max(1e-9, abs(v_ct)):.2f} [replica del 'crinale di rumore' del 22/08]")
# -- 6. GATE --------------------------------------------------------------------------
hr("6. GATE sul candidato (A) scelto in-sample")
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}")
print(f" null de-levering vs COSTANTE : {delever_null(d_ch, d_const)}")
print(f" null de-levering vs TP01 : {delever_null(d_ch, base_1d)}")
print(" haircut a $600 (min-order $5):")
for a in ASSETS:
df = A.get(a, ch["tf"])
sc = A.eval_weights_smallcap(df, A._call_target(fn_ch, df, a), capital=600.0)
print(f" {a}: modellato {sc['modeled']['sharpe']:+.3f} -> reale "
f"{sc['realistic']['sharpe']:+.3f} (haircut {sc['sharpe_haircut']:+.3f}, "
f"{sc['n_executed_trades']} trade)")
# DSR a piu' conteggi: famiglia, famiglia+112 del primo tentativo, di SCREEN
all_sr = ([r["full_sharpe"] for r in rep["rows"]]
+ [r["full_sharpe"] for r in csel["rows"]]
+ [r["full_sharpe"] for r in wsel["rows"]])
hr("6-bis. DEFLATED-SHARPE — sensibilita' del verdetto al CONTEGGIO (regola 30/07 + 22/08)")
print(f" Sharpe FULL del candidato (A) = {A._sh(d_ch):+.3f}")
print(dsr_line(A._sh(d_ch), all_sr, d_ch, "mia famiglia A+B+C", N_DECLARED))
print(dsr_line(A._sh(d_ch), all_sr, d_ch, "+ le 112 del 22/08", N_DECLARED + N_PRIOR_FAMILY))
print(dsr_line(A._sh(d_ch), all_sr, d_ch, "di SCREEN (ondata)",
N_DECLARED + N_PRIOR_FAMILY + N_SCREEN))
print("\n ⚠️ QUEL 'PASS' NON CERTIFICA NIENTE, e va detto: `deflated_sharpe` stima il massimo")
print(" atteso dal rumore dalla VARIANZA degli Sharpe della griglia. Qui la griglia e'")
print(" OMOGENEA (tutte varianti di TSMOM, molto correlate fra loro) -> varianza piccola")
print(" -> sr0 basso -> DSR alto per COSTRUZIONE. Aumentare N padding con la media alza N")
print(" ma non la varianza. Il conteggio che decide e' quello di SCREEN, e il filone ORTHO")
print(" lo ha gia' misurato: su 168 trial di ricerca direzionale BTC/ETH il massimo atteso")
print(" dal puro rumore e' Sharpe 1.572. Riscritto con QUEL sr0:")
for nome, ser in (("adattivo (A)", d_ch), ("miglior costante (B)", d_const),
("TP01 30/90/180", base_1d)):
print(f" {nome:<22} Sharpe FULL {A._sh(ser):+.3f} vs sr0_screen 1.572 -> "
f"DSR {dsr_given_sr0(ser, A._sh(ser), 1.572):.3f} "
f"{'PASS' if dsr_given_sr0(ser, A._sh(ser), 1.572) >= 0.95 else 'FAIL'}")
# PROVA DI IDENTITA': la cella "adattiva" al 100% sul bordo E' il costante, bit per bit
hr("6-ter. PROVA DI IDENTITA' — la cella al bordo NON somiglia a un costante: LO E'")
deg = [r for r in rep["rows"]
if diag[(r["tf"], r["params"]["det"], r["params"]["par"], r["params"]["stat"],
r["params"]["mapping"])]["frac_lo"] >= 0.999]
if deg:
dr = max(deg, key=lambda r: r["insample_sharpe"])
s_deg = A.candidate_daily(factory(tf=dr["tf"], **dr["params"]), tf=dr["tf"])
s_con = A.candidate_daily(const_factory(tf=dr["tf"], L=LMIN_D,
blend=dr["params"]["blend"]), tf=dr["tf"])
Jd = pd.concat({"a": s_deg, "b": s_con}, axis=1, join="inner").dropna()
print(f" cella 100% al bordo basso: {dr['tf']} {dr['params']} IS {dr['insample_sharpe']}"
f" FULL {dr['full_sharpe']}")
print(f" costante L={LMIN_D}g {dr['params']['blend']} {dr['tf']}: "
f"FULL {A._sh(s_con):+.3f}")
print(f" max|serie_adattiva - serie_costante| = {float(np.max(np.abs(Jd['a']-Jd['b']))):.3e}"
f" (n={len(Jd)}) -> {'SONO LA STESSA STRATEGIA' if float(np.max(np.abs(Jd['a']-Jd['b']))) < 1e-12 else 'diverse'}")
else:
print(" nessuna cella e' al 100% sul bordo basso (la prova non si applica)")
# -- 7. FAMIGLIA RISTRETTA ALLE SOLE CELLE DAVVERO ADATTIVE --------------------------
hr("7. GATE SULLA SOLA FAMIGLIA DAVVERO ADATTIVA (la domanda della missione, isolata)")
ad_rows = [r for r in rep["rows"]
if diag[(r["tf"], r["params"]["det"], r["params"]["par"],
r["params"]["stat"], r["params"]["mapping"])]["adattiva"]]
d_ad_keep = fn_ad_keep = best_keep = None
if not ad_rows:
print(" NESSUNA cella della famiglia e' davvero adattiva -> vedi sezione 1.")
else:
best = max(ad_rows, key=lambda r: r["insample_sharpe"])
print(f" celle adattive: {len(ad_rows)}/{len(rep['rows'])} miglior IN-SAMPLE: "
f"tf={best['tf']} {best['params']} IS {best['insample_sharpe']} "
f"FULL {best['full_sharpe']}")
fn_ad = factory(tf=best["tf"], **best["params"])
d_ad = A.candidate_daily(fn_ad, tf=best["tf"])
sm = A.study_marginal("BOCPD-H (sole adattive)", fn_ad, tf=best["tf"])
dsr_ad, sr0_ad = A.deflated_sharpe(A._sh(d_ad),
[r["full_sharpe"] for r in ad_rows], d_ad)
print(f" marginal={sm['marginal_verdict']} earns_slot={sm['earns_slot']} "
f"corr->TP01 {sm['marginal'].get('corr_full')}")
print(f" deflated-Sharpe (N={len(ad_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()
JH = JA[JA.index >= HOLDOUT]
print(f" FULL adattiva-vera {A._sh(JA['C']):+.3f} | costante {A._sh(JA['K']):+.3f} "
f"| TP01 {A._sh(JA['B']):+.3f}")
print(f" HOLD-OUT adattiva-vera {A._sh(JH['C']):+.3f} | costante {A._sh(JH['K']):+.3f} "
f"| TP01 {A._sh(JH['B']):+.3f}")
r3 = paired_by_year(d_ad, d_const)
d3 = [r["d_sh"] for r in r3]
print(" vs miglior costante, differenza APPAIATA per anno:")
print(" " + " ".join(f"{r['year']}:{r['d_sh']:+.2f}" for r in r3))
print(f" -> dSh>0 in {sum(1 for x in d3 if x>0)}/{len(d3)} mediana {np.median(d3):+.2f}"
f" corr al costante {JA['C'].corr(JA['K']):+.3f}")
print(" ⚠️ l'hold-out sono gli ULTIMI DUE anni di questa stessa riga: se il vantaggio")
print(" hold-out sta li' e non nel resto, e' un evento, non un meccanismo (regola:")
print(" un vantaggio concentrato in un anno non e' un vantaggio).")
print(f" null de-levering vs costante: {delever_null(d_ad, d_const)}")
d_ad_keep, fn_ad_keep, best_keep = d_ad, fn_ad, best
# -- 8. BANDA D'ANCORA (delta APPAIATO adattivo - costante) --------------------------
hours = 24 if ch["tf"] == "1d" else 12
offs = list(range(hours))
hr(f"8. BANDA D'ANCORA — {len(offs)} ancore. Stima onesta = MEDIANA; il confronto fra due "
f"varianti\n e' la MEDIANA DELLE DIFFERENZE APPAIATE (mai la differenza delle mediane).")
if cch["tf"] != ch["tf"]:
print(f" ⚠️ adattivo su {ch['tf']} e miglior costante su {cch['tf']}: per il delta "
f"appaiato uso il miglior costante RIVALUTATO su {ch['tf']} (stesse ancore = coppie).")
crow = max([r for r in csel["rows"] if r["tf"] == ch["tf"]],
key=lambda r: r["insample_sharpe"])
fn_const_anch = const_factory(tf=ch["tf"], **crow["params"])
print(f" miglior costante su {ch['tf']}: {crow['params']} IS {crow['insample_sharpe']}")
else:
fn_const_anch = fn_const
s0 = series_at_anchor(fixed_target, "fixed", hours, 0)
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_anchor(fn_ch, "cand", hours, o), offs,
metric=mtr)
b_k = A.anchor_luck_band(lambda o: series_at_anchor(fn_const_anch, "const", hours, o),
offs, metric=mtr)
print(f" {lab:<12} adattivo: canonica {b_c['canonical']:+.3f} "
f"(pctl {b_c['canonical_pctl']:.2f}) MEDIANA {b_c['median']:+.3f} "
f"banda [{b_c['lo']:+.3f},{b_c['hi']:+.3f}]")
print(f" {'':<12} costante: canonica {b_k['canonical']:+.3f} "
f"(pctl {b_k['canonical_pctl']:.2f}) MEDIANA {b_k['median']:+.3f} "
f"banda [{b_k['lo']:+.3f},{b_k['hi']:+.3f}]")
dl = A.anchor_luck_delta(lambda o: series_at_anchor(fn_ch, "cand", hours, o),
lambda o: series_at_anchor(fn_const_anch, "const", hours, o),
offs, metric=mtr)
print(f" {'':<12} DELTA APPAIATO (adatt - cost): mediana {dl['median_paired']:+.3f} "
f"positivo in {dl['n_positive']}/{dl['n_anchors']} "
f"banda [{dl['lo']:+.3f},{dl['hi']:+.3f}] gate_pass={dl['gate_pass']}")
# -- 8-bis. ANCORA APPAIATA per la cella DAVVERO ADATTIVA ---------------------------
if fn_ad_keep is not None and best_keep["tf"] == ch["tf"]:
hr("8-bis. ANCORA APPAIATA — cella DAVVERO ADATTIVA vs miglior costante")
for lab, mtr in (("Sharpe FULL", A._sh),
("Sharpe HOLD", lambda s_: A._sh(s_[s_.index >= HOLDOUT]))):
dl = A.anchor_luck_delta(
lambda o: series_at_anchor(fn_ad_keep, "adatt", hours, o),
lambda o: series_at_anchor(fn_const_anch, "const", hours, o), offs, metric=mtr)
b = A.anchor_luck_band(lambda o: series_at_anchor(fn_ad_keep, "adatt", hours, o),
offs, metric=mtr)
print(f" {lab:<12} adattiva-vera: canonica {b['canonical']:+.3f} "
f"(pctl {b['canonical_pctl']:.2f}) MEDIANA {b['median']:+.3f} "
f"banda [{b['lo']:+.3f},{b['hi']:+.3f}]")
print(f" {'':<12} DELTA APPAIATO vs costante: mediana {dl['median_paired']:+.3f} "
f"positivo in {dl['n_positive']}/{dl['n_anchors']} "
f"banda [{dl['lo']:+.3f},{dl['hi']:+.3f}] gate_pass={dl['gate_pass']}")
elif fn_ad_keep is not None:
print(f"\n (8-bis non girata: la cella adattiva sta su {best_keep['tf']} e il costante "
f"su {ch['tf']} -> le ancore non sarebbero coppie)")
# -- 8-ter. IL CRITERIO DEL FALSIFICATORE, SU **TUTTE** LE CELLE ADATTIVE ------------
hr("8-ter. IL CRITERIO DEL FALSIFICATORE APPLICATO A **OGNI** CELLA DAVVERO ADATTIVA")
print(" Il 22/08 dichiarava: «un rilevatore il cui L_t si muove DAVVERO e batte il miglior")
print(" lookback COSTANTE in >=6/8 anni appaiati con corr->TP01 < 0.6». Non basta che")
print(" fallisca il VINCITORE: si applica il criterio a tutte e", len(ad_rows), "le celle adattive.")
print(f"\n {'tf':>4} {'cella':<58} {'corr':>6} {'anni>0':>7} {'dShFULL':>8} {'dShHOLD':>8} esito")
passed = []
for r in sorted(ad_rows, key=lambda x: -x["insample_sharpe"]):
fnr = factory(tf=r["tf"], **r["params"])
dr = A.candidate_daily(fnr, tf=r["tf"])
JJ2 = pd.concat({"a": dr, "b": tp01}, axis=1, join="inner").dropna()
cor = float(JJ2["a"].corr(JJ2["b"]))
yr = paired_by_year(dr, d_const)
nyp = sum(1 for x in yr if x["d_sh"] > 0)
Jr = pd.concat({"C": dr, "B": d_const}, axis=1, join="inner").dropna()
JrH = Jr[Jr.index >= HOLDOUT]
dF = A._sh(Jr["C"]) - A._sh(Jr["B"])
dH = A._sh(JrH["C"]) - A._sh(JrH["B"])
ok = bool(cor < 0.6 and nyp >= 6)
if ok:
passed.append((r, cor, nyp, dF, dH))
lab = (f"{r['params']['det']} {r['params']['par']} {r['params']['stat']} "
f"{r['params']['mapping']} {r['params']['blend']}")
print(f" {r['tf']:>4} {lab:<58} {cor:>6.3f} {nyp:>5}/8 {dF:>+8.3f} {dH:>+8.3f} "
f"{'PASSA' if ok else ''}")
print(f"\n celle che soddisfano ENTRAMBE le condizioni del falsificatore: "
f"{len(passed)}/{len(ad_rows)}")
cors = []
for r in ad_rows:
dr = A.candidate_daily(factory(tf=r["tf"], **r["params"]), tf=r["tf"])
JJ2 = pd.concat({"a": dr, "b": tp01}, axis=1, join="inner").dropna()
cors.append(float(JJ2["a"].corr(JJ2["b"])))
print(f" corr->TP01 sulle celle adattive: min {min(cors):.3f} mediana "
f"{np.median(cors):.3f} max {max(cors):.3f} (soglia del falsificatore 0.60)")
# -- 9. FEE SWEEP ---------------------------------------------------------------------
hr("9. FEE SWEEP — l'adattivo trada di piu': e' li' che muore o no")
for nm, f_, tfx in (("adattivo (A)", fn_ch, ch["tf"]), ("WF-PnL (C)", fn_wf, wch["tf"]),
("miglior costante (B)", fn_const, cch["tf"]),
("TP01 30/90/180", fixed_target, "1d")):
line = []
for fee in (0.0, 0.0005, 0.001, 0.0015):
line.append(f"{2*fee*100:.2f}%RT {A._sh(A.candidate_daily(f_, tf=tfx, fee_side=fee)):+.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:<22} " + " ".join(line) + f" | turnover {tt:.0f}x/anno")
hr(f"FINE — {time.time()-t0:.0f}s")
if __name__ == "__main__":
main()
+667
View File
@@ -0,0 +1,667 @@
# CRITICO DI COMPLETEZZA — ondata 2026-08-22 (`r0822b_critic`)
**Mandato:** non testare una strategia. Rispondere a *cosa non e' stato guardato, quale
affermazione e' rimasta non verificata, e qual e' la misura di maggior valore che nessuno ha fatto*.
**Materiale letto per intero:** `docs/research/BRIEF-0822.md`, `docs/research/RESULTS-0822.md`
(684 righe, 19 sezioni), `docs/diary/2026-08-22-wave-multiagente.md`, piu' i punti dei singoli
`scripts/research/r0822_*.py` dove il ledger andava verificato.
**Regola di lettura di questo file.** Distinguo tre stati e li marco:
`[VERIFICATO]` = ho aperto la fonte e il fatto e' controllato in questa sessione (comando
riportato o citazione esatta) · `[DEDOTTO]` = ragionamento su numeri altrui, non ri-misurato ·
`[DA MISURARE]` = ipotesi mia, con costo stimato. Non fabbrico numeri nuovi salvo controlli rapidi,
e quando ne faccio uno dico che e' un controllo di cinque minuti e non uno studio.
**Esito in una riga: l'ondata e' onesta nei singoli filoni e debole nella CUCITURA.** I difetti
gravi non stanno dentro i filoni (che hanno catturato 7 errori propri) ma in tre punti dove
nessuno era responsabile: (a) **due "difetti di dato" sono convenzioni dichiarate nel sorgente di
un fornitore che gira su questa stessa VPS e che nessuno ha aperto**; (b) **il gate principale del
progetto — il deflated-Sharpe — e' stato misurato lo stesso giorno ai suoi due estremi degeneri da
due agenti diversi, e nessuno ha unito le due misure**; (c) **un filone su venti (ALT-OPTIONS) non
e' nel ledger, e il suo risultato falsifica una quarta soglia pubblicata** — quella che tiene
VRP01 fuori dal libro per taglia.
---
## 1. CLAIM NON VERIFICATI
Ordinati per `(impatto x P(falso)) / costo`. Non elenco tutto: le otto che contano.
### 1.1 — 🥇 «una firm crypto lista i 19 alt Hyperliquid con short abilitato» (PROP-ALLOC)
`P(falso)` **media-alta** · `impatto` **massimo** · `costo` **un pomeriggio, zero CPU**
E' l'unica assunzione su cui poggia **l'intero risultato di testa dell'ondata**: `+0,400 di J`
(argmaxJ 25/25/50 contro il libro live 75/25) e' quasi tutto *mettere XS01 su un conto funded*, e
`+0,016` — il 4% — e' il riallocare per la barriera. Se XS01 non e' eseguibile presso la firm,
la riga argmaxJ collassa verso `75/25/0`, che nello stesso script misura **J 0,335 / P(pass) 38,6%**
invece di **0,738 / 82,2%**. Con essa cade anche il *risultato strategico* del 25/07 §4
(«sul canale funded diversificare paga perche' il vincolo binding e' il DD»), che e' il perno di
tutta la valutazione del canale prop.
⚠️ **E il progetto ha gia' una regola scritta per questo caso, e non l'ha applicata.** Il 26/07:
*«la negoziabilita' sul conto REALE va verificata quando lo sleeve entra in RICERCA, non quando
entra nel book»* — regola nata dal blocco PRIIPs su GTAA01, dove **cinque settimane di misure**
poggiavano su un'assunzione di conto mai controllata, e il controllo costo' **un ordine di prova**.
`r0822_prop_alloc.py` la ripete: alla riga 100 `CRYPTO_PROP = ("TP01","SKH01","XS01") # cio' che
una firm CRYPTO puo' davvero far eseguire` — un commento, non una misura. [VERIFICATO: letto il
sorgente; l'agente esclude esplicitamente VRP01/GTAA01 *"una firm CRYPTO non lista opzioni Deribit
ne' ETF azionari USA"*, quindi ha pensato al problema e si e' fermato un passo prima di XS01.]
**Cosa verificare, in ordine:** (a) la lista strumenti di HYROTRADER e FTMO-crypto contiene i 19
major HL (SOL/BNB/XRP/DOGE/AVAX/LINK/LTC/ADA/ARB/OP/SUI/APT/INJ/TIA/SEI/NEAR/AAVE oltre BTC/ETH)?
(b) short abilitato su ciascuno? (c) quale costo effettivo per gamba (spread + commissione)?
**La (c) ha una soglia gia' misurata in questa stessa ondata**: XS-LITE riporta che la famiglia XS
regge **oltre 50 bps/gamba** — quindi il costo di una firm quasi certamente non morde, ed e' la
(a)/(b) binaria che decide tutto. **Se (a) e' falsa, il verdetto LEAD del filone 1 va riscritto
oggi, non a un gate futuro.**
### 1.2 — 🥈 «`dealer_net_gamma` e' il GEX col SEGNO INVERTITO» (DEALER-GAMMA)
`P(falso)` **alta — anzi: FALSO come formulato** · `impatto` medio · `costo` **due minuti**
Il ledger lo chiama *«il risultato piu' riusabile del filone»* e scrive: *«Chi lo usasse col nome
che porta leggerebbe ogni regime al contrario»*. L'agente ha deciso la questione **per
ricostruzione** (corr 0,95 BTC / 0,89 ETH contro un GEX da manuale) e lo dichiara esplicitamente
nel proprio codice: `"Quale, lo decide la sezione 2 (ricostruzione dalla catena), non io"`
(`r0822_dealer_gamma.py:187`).
🚨 **La fonte autorevole e' su questa VPS e dice il contrario.** [VERIFICATO] La colonna non e'
calcolata da cerbero-bite: bite **inoltra e basta** il campo `total_net_dealer_gamma` che riceve
dal tool MCP `get_dealer_gamma_profile` (`src/cerbero_bite/runtime/market_snapshot_cycle.py:85` e
`clients/deribit.py:342-384`, letti dal repo Gitea `Adriano/Cerbero-Bite`). E il produttore e'
`/opt/docker/cerbero-mcp/src/cerbero_mcp/common/options.py`, **acceso adesso**, che in testa al
file dichiara la convenzione:
```
# Convention dealer gamma: i dealer sono SHORT calls (le vendono al retail) e
# LONG puts. ... Net dealer gamma negativo -> mercato instabile (squeeze ...)
...
entry["call_dealer_gamma"] -= contrib # dealer short calls
entry["put_dealer_gamma"] += contrib # dealer long puts
```
Cioe': **call negative, put positive** — l'esatta negazione, su entrambe le gambe, della
convenzione da manuale che l'agente ha usato (`call +, put `, `r0822_dealer_gamma.py:237`).
**Non e' un difetto della colonna: e' una convenzione opposta, deliberata e documentata** — e nel
suo frame `dng < 0` significa *dealer short gamma / mercato instabile*, che e' proprio la
semantica che il docstring di bite dichiara e che l'entry filter §2.8 usava.
E c'e' una **seconda spiegazione, gratis, per i due numeri residui che l'agente ha attribuito a
non-portabilita'**: la chiamata di bite usa il default `top_n_strikes = 50` [VERIFICATO:
`dealer_gamma_profile(asset_upper)` senza argomenti], contro i **~280-316 strike** della
ricostruzione. Un troncamento ai 50 strike di punta spiega naturalmente sia il coefficiente
`0,7` (invece di `1,0`) sia l'offset *«dng=0 corrisponde a GEX=+5,8e6 su BTC»*, che il ledger
presenta come prova che la soglia non e' portabile.
**Cosa cambia se e' falso (ed e' falso):** il verdetto **SCARTATO regge intatto** (DSR 0,440,
null de-levering fallito, `n_eff` 24-53 ore su 2.130, cella migliore *sotto* il massimo del
rumore). Ma cade la **regola pubblicata**, e cade nel modo peggiore: la frase del ledger *«BTC non
separa, ETH separa al ROVESCIO»* e' formulata in un frame di segno che va rovesciato — quindi
l'unica riga del filone che le ondate future erediterebbero e' quella sbagliata. **Correzione da
fare oggi nel ledger, costo due righe.**
### 1.3 — 🥉 «`liquidation_long/short_risk` e' costante 'low': meta' dell'ipotesi non esiste, ed e'
comunque morta perche' la colonna non e' ricostruibile» (FLOW-SQUEEZE)
`P(falso)` **alta sulla seconda meta'** · `impatto` medio · `costo` **due ore**
[VERIFICATO] Il fatto e' vero (`low` in 17.229/17.229 righe non nulle, riprodotto). Ma le due
inferenze che l'agente costruisce sopra il fatto sono entrambe smentibili leggendo la stessa
sorgente di §1.2, `/opt/docker/cerbero-mcp/.../sentiment/fetchers.py:480-518`:
```
long_risk = "high" se avg_funding > 0.0001 e delta_24h > 5
"medium" se avg_funding > 0.00005 e delta_24h > 2
```
Cioe' **e' una soglia su due input, non una misura**. Conseguenze:
- *«Nessuna fonte nostra la produce, e non c'e' niente da produrre»* (`r0822_flow_squeeze.py:626`)
e' **falso**: i due input sono `oi_delta_pct_24h` e il funding cross, e **il funding cross e' nel
file** (`funding_cross_annualized`, copertura 99%), cosi' come `oi_delta_pct_4h` (copertura 99%,
dev.std 1,23%, range 7,1%…+13,4% — [VERIFICATO] con una lettura del parquet). L'agente poteva
ricostruire la variabile di affollamento **con le proprie soglie**, che e' anche metodologicamente
meglio che ereditarne una arbitraria.
- *«Costante = zero informazione»* e' vero come statistica e **sbagliato come lettura**: `low` al
100% con quelle soglie significa che **nella finestra 2026-03 → 07 non c'e' stato un solo
episodio di affollamento** per quella definizione. Non e' una colonna rotta, e' un fatto di
mercato — e per giunta **rafforza** la conclusione del filone (funding chiuso sul quarto lato)
molto piu' di quanto faccia «la colonna e' inservibile».
**Cosa cambia se e' falso:** il verdetto SCARTATO regge (l'altra meta' e' sotto la propria MDE
dichiarata: effetto 0,402% contro MDE 1,056%). Ma il filone e' chiuso con la motivazione
sbagliata, e la motivazione sbagliata e' quella che impedisce di riaprirlo con i dati che ci sono.
### 1.4 — «la finestra forward non va persa: riparare `advance()` + RIGENERARE, nessuna data di
gate si sposta» (MONITOR-AUDIT)
`P(falso)` bassa sul fatto, **alta sull'implicazione** · `impatto` **alto (3 gate)** · `costo` zero
Il fatto e' provato bene, due volte in modo indipendente (`paper_prevday` 1427/1488 barre identiche
al bit dopo 62 notti; le 6 barre di `paper_statarb` recuperate dopo il guasto EPERM coincidono al
bit). **Non contesto il fatto. Contesto che sia gratis.**
Una serie **rigenerata** non e' una serie **forward**. La ragione per cui un progetto
pre-registra una finestra forward e' che sia prodotta da codice che *non poteva* essere tarato su
di essa. Dopo la riparazione, la serie su cui si decide il 27/09 sara' prodotta da codice
**scritto oggi, dopo aver visto quella finestra** — e chi scrive la riparazione ha gia' letto,
nel ledger, che la metrica registrata dice +1,96 e quella ricostruita 2,02. La riparazione e'
banale e meccanicamente forzata (scartare la barra non chiusa), quindi il rischio e' piccolo; ma
**e' un grado di liberta' nuovo su un gate pre-registrato, e nessuno lo ha nominato.**
**Costo di chiuderlo: zero.** Si scrive la riparazione, la si congela con un test, e **solo
dopo** si rigenera — dichiarando in anticipo che nessun parametro verra' toccato in base al
risultato della rigenerazione. Farlo dopo aver visto lo Sharpe rigenerato non e' piu' possibile.
### 1.5 — «haircut a $5.000 <= 40%» come gamba del gate XSR01 del 23/10
`P(che il gate sia non-informativo)` **alta** · `impatto` **alto** · `costo` **zero (e' gia' misurato)**
Due filoni hanno colpito le due gambe del gate del 23/10 **senza che nessuno tirasse la somma**:
- **gamba haircut** — HL-EXEC: a $5.000 col pavimento vero l'haircut sta fra **7% e +2%**, quindi
*«una condizione pre-registrata che non puo' fallire non e' una condizione»*. XSR-REPRO
dall'altro lato: il **$14,41** pubblicato **non ha alcuno script committato che lo riproduca** ed
e' **~2,4x ottimista** (ticket mediano $3,33, 63% degli ordini sotto $5).
- **gamba Sharpe >= 1,0** — MONITOR-AUDIT: la serie che la misura registra **41 minuti di mercato
al giorno**, `corr` col replay **0,045**.
📌 **La somma, che il ledger non fa: al 22/08 il gate del 23/10 e' non-informativo su ENTRAMBE le
gambe** — una non puo' fallire, l'altra e' letta su uno strumento scollegato dalla realta'. Il
ledger scrive «GATE 23/10 COMPROMESSO: PARZIALMENTE». **E' compromesso interamente**, e la
differenza non e' semantica: «parzialmente» autorizza a decidere il 23/10 con una riserva,
«interamente» obbliga a **ri-registrare il gate prima**, che e' l'unica mossa che non e' selezione.
### 1.6 — «k* empirico ~10-14x» (GROWTH-POLICY)
`P(falso)` **alta come numero** · `impatto` medio-alto · `costo` zero (giunzione di due misure gia' fatte)
L'agente ha gia' fatto due autocritiche corrette (capitale mediano $4,4e12 a k=14x → *«a k>2 i
numeri sono aritmetica, non previsioni»*; k* lineare nell'errore del drift). Ne manca una terza, e
sta **dentro la stessa ondata**: SLIP-AUDIT misura che la partecipazione al nastro scala lineare
col nozionale e che il caso peggiore osservato e' gia' **21,9% di una barra 5m** a nozionale ~$636.
La leva moltiplica il nozionale esattamente come il capitale. Quindi [DEDOTTO, aritmetica diretta
sulla tabella di SLIP-AUDIT]:
| leva su $635 | nozionale | peggior partecipazione osservata, riscalata |
|---|---|---|
| 1,00x | $636 | 22% di una barra 5m |
| **1,50x** (il gradino proposto) | $954 | **33%** |
| 5x | $3.180 | 110% |
| **10-14x** (k*) | $6.400-8.900 | **220-310%** |
📌 **Il gradino 1,00 → 1,50x NON e' toccato da questo** (33% resta dentro il regime misurato):
la raccomandazione operativa sopravvive. **E' il numero k\* che non e' misurabile**, per una terza
ragione indipendente dalle due dichiarate: sopra ~k=5 la serie di ritorni su cui k\* e' calcolato
smette di essere indipendente dalla size, che e' esattamente l'ipotesi che l'agente aveva segnalato
come implausibile senza poterla quantificare. **Ora e' quantificata, gratis, e va scritta accanto
al 10-14x.**
### 1.7 — «BTC opzioni min 0.1 = $6.210/lotto → VRP01 fuori a $3.000, servirebbe il 61% del conto»
`P(falso)` **verificata FALSA** · `impatto` **alto** · `costo` gia' pagato — il numero esiste e non e' nel ledger
Questo e' congelato in `tests/test_vrp_profit_take.py` e in CLAUDE.md. VRP-QUOTE-VERE l'ha
falsificato a meta' (famiglia sbagliata) e **ALT-OPTIONS l'ha falsificato del tutto** — ma
ALT-OPTIONS **non ha una sezione nel ledger** (vedi §5.1), quindi il numero non e' mai stato
scritto. [VERIFICATO: letto l'output del filone]:
| famiglia | f_venue | credito eseg. | **max-loss / lotto** | IM se il venue NON netta |
|---|---|---|---|---|
| **ETH_USDC** | **0,980** | $3,38 | **$16,62** | $33,50 |
| BTC_USDC | 0,921 | $6,25 | $33,75 | $101,79 |
| ETH inverse (la famiglia del muro) | 0,930 | $29,36 | $145,88 | $324,42 |
| BTC inverse (la famiglia del muro) | 0,927 | $81,97 | $418,03 | $1.017,06 |
Il muro pubblicato e' calcolato sul **nozionale del lotto minimo della famiglia inverse**. La
grandezza giusta per una struttura a rischio definito e' il **max-loss**, e sulla famiglia che il
conto puo' marginare vale **$16,62**. Al peso di libro del 12%, VRP01 entra con un lotto da un
conto di **~$140** (o ~$280 se Deribit non netta le gambe), non da $3.000 al 61% del conto —
**due ordini di grandezza**. Sul conto vero ($635) ci stanno **7 strutture dentro un budget di
rischio del 20%**, con **3.000 lotti al miglior bid** e spread della gamba corta al **2,6%**.
⚠️ **Cosa NON cambia, e va detto forte:** VRP01 resta fuori dal libro, ma **per la regola
«niente short-vol da modello in deploy» e per il gate IV-rank (0/19 settimane aperte)**, non per
la taglia. Cade *una* delle tre ragioni, ed e' quella che il progetto ripete piu' spesso.
### 1.8 — «lo screen largo non puo' passare il proprio gate: e' aritmetica» (ORTHO-SCREEN)
`P(falso)` **alta come generalizzazione** · `impatto` **alto (tocca 3 gate futuri)** · `costo` **5 minuti**
Vedi §2.1: l'ho ri-derivato e il numero e' giusto ma **non dice cio' che il ledger gli fa dire**.
---
## 2. CONTRADDIZIONI FRA AGENTI
### 2.1 — 🚨 IL DEFLATED-SHARPE MISURATO AI SUOI DUE ESTREMI DEGENERI, LO STESSO GIORNO, DA DUE AGENTI CHE NON SI CITANO
- **ORTHO-SCREEN**: *«su 168 trial il massimo atteso dal puro rumore e' Sharpe 1,572, sopra il
soffitto direzionale (~1,3) → una ricerca a rete larga NON PUO' passare il proprio gate. Non e'
sfortuna, e' aritmetica.»* DSR di screen **0,034**.
- **SCETTICO su VOL-SIZE**: *«`DSR = 1,000` dichiarato **VACUO, non PASS**: lo passa anche il
baseline — su una famiglia di perturbazioni della stessa strategia il deflated-Sharpe non ha
potenza.»*
**Sono la stessa osservazione dai due lati, e insieme delimitano il gate principale del progetto.**
[VERIFICATO] Ho aperto `altlib.deflated_sharpe`: il massimo atteso non e' `SE(Sharpe) x sqrt(2 ln N)`
ma la formula BaileyLopez de Prado
```
sr0 = sd(Sharpe dei trial OSSERVATI) x [ (1-gamma)*z(1-1/N) + gamma*z(1-1/(N e)) ]
```
cioe' **la dispersione trasversale delle celle che hai deciso di dichiarare**, moltiplicata per un
fattore che dipende solo da N. Ricalcolando il moltiplicatore [controllo di cinque minuti]:
| N dichiarato | moltiplicatore | `sr0` con la STESSA dispersione (sd = 0,58) |
|---|---|---|
| 12 | 1,665 | 0,97 |
| **24** | 1,980 | **1,15** |
| 48 | 2,261 | 1,31 |
| 72 | 2,413 | 1,40 |
| **168** (lo screen) | 2,708 | **1,57** |
| 729 | 3,164 | 1,84 |
E al variare della dispersione, a parita' di dati:
| dispersione delle celle | `sr0(24)` | `sr0(168)` |
|---|---|---|
| 0,15 (famiglia di perturbazioni, tipo VOL-SIZE) | 0,30 | 0,41 |
| 0,58 (lo screen ortogonale) | 1,15 | 1,57 |
📌 **Tre letture che il ledger non ha:**
1. **La soglia «1,572» non e' una proprieta' di BTC/ETH: e' una proprieta' della GRIGLIA.** Le
stesse 168 celle, dichiarate come 7 famiglie da 24, danno `sr0 = 1,15`**sotto** il soffitto
direzionale. Il verdetto «impossibile per aritmetica» si ribalta **senza toccare un dato**.
2. **Percio' la lezione pubblicata invita alla mossa che il progetto ha gia' proibito.** Il ledger
conclude *«le ondate future o dichiarano famiglie molto piu' piccole in anticipo, o cambiano
meccanismo»*. La prima meta' e' **esattamente** il barare che il 30/07 aveva codificato:
*«il deflated-Sharpe e' l'unico posto dove barare e' indolore e invisibile»*, e
*«il verdetto si ribalta col conteggio»* (congelato in un test su VRP01: DSR 0,983 a N=8 /
0,948 a N=72 / 0,909 a N=360). **Il 22/08 la stessa leva viene raccomandata come buona pratica.**
3. **Cio' che il progetto non sapeva e' la META' PIU' GRANDE: la dispersione.** Il fattore N va da
1,67 a 3,16 su due ordini di grandezza di trial (1,9x). La dispersione va da 0,15 a 0,58 fra una
famiglia omogenea e uno screen (3,9x). **Il gate e' dominato dalla variabile mai dichiarata.**
**Conseguenza operativa immediata**, perche' tocca tre gate pre-registrati: DVOLSPREAD 24/10 ha
come criterio (c) *«deflated-Sharpe ricalcolato >= 0,95»* — su una famiglia di 729 celle
omogenee; XSR01 23/10 poggia su DSR 0,983; VOL-SIZE ha gia' dichiarato il proprio DSR vacuo.
**Nessuno dei tre e' interpretabile senza dichiarare, accanto a N, la dispersione delle celle.**
Riformulazione onesta della lezione di ORTHO-SCREEN: *con celle disperse di ~0,58 di Sharpe e un
soffitto di ~1,3, il gate non separa; la cura non e' dichiarare meno trial, e' progettare famiglie
i cui membri siano varianti di UN meccanismo — e li' il DSR e' vacuo, quindi serve un altro gate.*
**Il deflated-Sharpe non ha una regione utile in mezzo, e nessuno l'ha detto.**
### 2.2 — Lo Sharpe di XSR01: non tre lenti ma **quattro**, e la quarta non e' nel quadro conciliato
Il ledger conclude con un «quadro conciliato»: titolo `demean_skeptic` **1,82** · gate
`basket_gate` **1,75-1,79** · monitor `paper_xsr` **2,21**, e prescrive
*«NUMERO ONESTO DA CITARE: 1,79 (finestra di scoperta) / 1,63 (a oggi)»*.
[VERIFICATO nell'output di XS-LITE] Lo stesso giorno **XS-LITE**, che dichiara replica **bit-exact**
contro `basket_from_positions(demean=True)` (`max|diff| = 0,000e+00` su 965 barre), pubblica:
*«XSR01 pieno, lente pubblicata: Sharpe **1,64** (pubblicati 1,82)»*, *«normalizzazione COSTANTE
1/50: **1,65**»*, *«sulla FINESTRA DI SCOPERTA (<= 2026-07-25, 937 barre): **1,75**»*.
Quindi **due agenti che rivendicano entrambi la replica bit-exact della stessa funzione danno 1,75
e 1,79 sulla stessa finestra.** La spiegazione piu' probabile e' la loro stessa scoperta (l'ultima
barra della finestra di scoperta era parziale il 25/07 e oggi e' chiusa), e i due concordano
sull'oggi (1,64 vs 1,63). **Ma il ledger prescrive una coppia canonica senza nominare l'altra**, e
la regola che pubblica — *«va sempre citata la coppia (lente, ultima barra chiusa)»* — e' proprio
quella che avrebbe risolto la discrepanza se applicata a se stessa. **Costo di chiudere: una riga
nel ledger. Impatto: e' il numero su cui si decide il 23/10.**
### 2.3 — Il muro di XS01: **tre numeri diversi** nello stesso corpus di documenti
- XS-LITE: capitale minimo perche' il ribilancio passi = **$109 di sleeve (~$730 di libro)**.
- HL-EXEC: haircut ~0 gia' a **$200-600 allocati** (→ a peso 15%, **$1.300-4.000 di conto**).
- Diario §6.5: *«XS01 e' eseguibile a **~$1.300 di conto**»* — cioe' l'estremo basso di HL-EXEC.
I tre sono compatibili (criteri diversi: soglia del ribilancio vs soglia dell'haircut), ma il
diario ne pubblica **uno** come se fosse *il* numero, dopo che l'ondata ha appena passato una
giornata a dimostrare che i numeri pubblicati a un criterio solo sono il difetto ricorrente del
progetto. **Da citare come banda `$730-$1.300` col criterio accanto.**
### 2.4 — La leva: due filoni della stessa ondata danno guida opposta
- **GROWTH-POLICY**: k\* ~10-14x, il libro gira al 7% di Kelly, **alzare** la leva vale 14,7a → 11,6a.
- **PROP-ALLOC**: J massimo a **0,75x**, e *«P(pass) senza limite di tempo e' monotona
DECRESCENTE»* (98,9% a 0,50x → 65,9% a 1,50x).
Non e' un errore: sono obiettivi diversi (crescita in log senza barriera vs sopravvivenza sotto
una barriera per-conto). **Ma nessuno lo scrive**, e l'operatore leggera' due raccomandazioni
opposte nello stesso documento. La sintesi che manca, e che riconcilia:
📌 **anche il conto proprio ha una barriera assorbente** — e' la decisione di venue del 26/07
(*«zero e' assorbente: andare a zero all'anno 10 di un piano da 16 anni significa non arrivarci
piu'»*). Quindi il criterio di GROWTH-POLICY (Kelly puro) e' il caso limite senza barriera, e
PROP-ALLOC misura cosa succede quando la barriera c'e': **la leva ottima cade da 10x a 0,75x
appena si mette una barriera, e la distanza fra i due numeri E' la misura di quanto la barriera
costa.** Detta cosi', le due misure smettono di contraddirsi e diventano la stessa curva.
### 2.5 — Il peso di SKH01: respinto da un filone, raccomandato al doppio dall'altro
- **VOL-SIZE / scettico**: *«quasi ogni variante alzava il peso effettivo di SKH fino a 0,375, e
"alzare SKH01" e' gia' stato misurato e respinto il 26/07»* — motivo per cui il null giusto e'
iso-peso.
- **PROP-ALLOC**: sul campione 2019+ che contiene il 2022, l'ottimo della coppia crypto sotto
barriera e' **38/62** (J 0,521 contro 0,443), cioe' **SKH01 al 62%**.
Di nuovo obiettivi diversi, di nuovo non detto. Ma qui c'e' un aggravante che **l'agente dichiara
da solo**: PROP-ALLOC non ha girato la banda d'ancora, quindi il suo ottimo poggia sull'ancora
canonica di **SKH01, lo sleeve che il LOO del 26/07 misura come quello con la fortuna d'ancora
piu' grande da restituire** (l'ancora regala 2/3 del FULL, 70% dell'hold-out, 80% della protezione
DD). **La distorsione e' a favore della raccomandazione, e la raccomandazione riguarda l'unico
canale che l'operatore potrebbe aprire quest'anno.** Costo di chiuderla: rigirare l'ottimo su una
banda di 8 ancore — l'ordine di grandezza del budget di un agente di questa ondata.
### 2.6 — Il conteggio degli ingressi VRP nella stessa finestra di catena: 19 contro 22
VRP-QUOTE-VERE: *«f = 0,714 pooled, **0/19** osservazioni >= 1,0»*; SKEW: *«0,714 su **22**
ingressi»*. Stesso `f` a tre decimali, popolazione diversa. [VERIFICATO che SKEW usa
*«stessa macchina di `cblib`»*, quindi **non e' una replica indipendente** e l'accordo a tre
decimali non e' evidenza.] Spiegazione benigna probabile (VRP richiede **entrambe** le gambe
quotate ai due delta obiettivo, SKEW ricostruisce lo smile e ne richiede meno). **Ma il ledger
presenta il 0,714 come «replica indipendente» del f del 30/07 senza dire che il secondo 0,714
viene dallo stesso harness.** Impatto basso, costo di chiarirlo nullo.
### 2.7 — «il collettore raccoglie la famiglia sbagliata» e' una classificazione, non un fatto
Il ledger apre VRP-QUOTE-VERE con 🚨 **IL RISULTATO DI PRODUZIONE**. [VERIFICATO in
`scripts/live/collect_chain.py:114`: `{"currency": asset, "kind": "option"}` e `OI_MIN = 100.0`
alla riga 57 — i due fatti sono esatti.] Ma il collettore serve la **ricerca sui prezzi**, non
l'esecuzione: sulle inverse ci sono 415/548 strumenti con OI>=100 e **97% di quote a due lati**,
contro il 94% e 119 strumenti di ETH_USDC. Per misurare `f`, IV-rank e struttura a termine, la
famiglia raccolta e' **la migliore delle due**. Il difetto esiste solo rispetto a un uso —
l'esecuzione — che oggi e' vietato da un'altra regola («niente short-vol da modello in deploy»).
Il diario lo corregge a meta' (punto aperto 3 lo presenta come decisione sul rate limit); il
ledger no. **Un difetto di produzione e una scelta di raccolta non si segnano con lo stesso
simbolo**, o il prossimo che legge ripara qualcosa che non e' rotto — al costo di **+90% sul giro**
e del rate-limit per-IP che il 29/07 e' gia' costato un guasto.
---
## 3. MODALITA' NON ESPLORATE
Filtro applicato: (a) compatibile con dati che il progetto possiede o puo' ottenere, (b) non nella
lista dei filoni morti del brief, (c) meccanismo plausibile e nominabile. Niente idee generiche.
### 3.1 — 🥇 Il costo del CHOP, cioe' il rischio che il libro non ha mai vissuto
**Dati:** esistono (7,4 anni di libro; 2018-19 e 2022-23 come stiramenti laterali; 30 anni di
equity come stampo). **Meccanismo:** il progetto ha misurato la difesa nel **sinistro** — il
criterio B di `edge_watch` e' 8/8 anni di crash superati — e non ha **mai** misurato la modalita'
di fallimento propria di un trend-follower, che non e' il crash ma il **mercato laterale**. E la
misura di controllo che ho fatto in cinque minuti la rende urgente e non teorica:
| giorno | BTC | ETH | **libro 75/25 (close-only)** |
|---|---|---|---|
| 2020-03-12 | **41,5%** | **45,7%** | **+0,97%** |
| 2022-06-13 | 15,5% | — | +0,72% |
| 2021-05-19 | 14,3% | 27,9% | 1,98% |
| 2022-11-09 | 14,4% | 18,0% | 0,00% |
| 2021-01-21 | — | 19,5% | 2,24% |
[controllo di cinque minuti, non uno studio: `altlib.tp01_baseline_daily()` + `sleeves._skyhook_returns()`,
75/25, ancora canonica. Peggior giorno del libro su 7,4 anni: **3,38% (2025-10-10)**, che **non e'
un giorno di crash**.] Cioe': **il libro non perde nei crolli — nei crolli guadagna.** Tutte le sue
peggiori giornate sono altrove. **Nessuno ha mai chiesto dove.** Un piano a 10-20 anni su una
strategia il cui unico rischio misurato e' quello che non la tocca ha un buco esattamente della
forma che questo progetto e' bravo a trovare in tutto il resto.
### 3.2 — 🥈 Il prezzo di essere ciechi: il costo atteso in P&L dell'attrito OPERATIVO
**Dati:** ci sono gia' e sono nostri — `logs/cron_book.log`, `data/venue_watch`, `feed_age_minutes`,
`skh_feed_errors`, `book_executions.jsonl`. **Meccanismo:** nel 2026 il libro e' stato cieco o
stantio almeno tre volte documentate (feed-freeze 14/07; fallback silenzioso di `fresh_5m` il
29/07, latenza d'uscita di SKH01 da ~1h a **~11h** per 6 giri su 8; manutenzione Deribit del 18/08
sforata **fino alle 14:07**, con astensione del libro). Il progetto ha prezzato fee, slippage,
min-order, pavimento IB, fortuna d'ancora, degrado d'esecuzione, e persino **il fallimento
dell'exchange** — e non ha **mai** prezzato *quanto costa all'anno che il libro non veda*. E' una
misura, non una previsione: si prendono le finestre di cecita' realmente occorse, si guarda cosa ha
fatto il prezzo dentro, e si confronta il P&L realizzato col P&L del percorso non-cieco.
**Perche' conta adesso:** e' un costo che **scala col nozionale** esattamente come la leva, quindi
entra direttamente nel conto di §4; e le uscite di SKH01 sono la gamba latency-sensitive.
**Perche' non e' morto:** non e' calendario, non e' un overlay, non e' una strategia — e' il
prezzo di un rischio operativo gia' materializzato tre volte.
### 3.3 — 🥉 Lo spread di volatilita' BTC-vs-ETH come sleeve, non come timer
**Dati:** `dvol_btc` / `dvol_eth` dal 2021-03 = **1978 giorni, 5,4 anni** — l'unico dataset non
direzionale del progetto abbastanza lungo da avere un hold-out vero (a differenza di tutta la
catena, ferma a 74-90 giorni). **Meccanismo:** i due asset condividono un fattore di vol comune, e
la *ricchezza relativa* delle due vol e' una grandezza stazionaria per costruzione, mentre il
livello non lo e'. **Non e' DVOLSPREAD:** quello e' il lead in forward-monitor che usa lo spread
come **timer direzionale** (de-risk); qui lo spread si traderebbe **come tale** (straddle lungo su
uno, corto sull'altro), market-neutral rispetto alla direzione e — a differenza di VRP01 — con
esposizione al premio di vol quasi cancellata fra le due gambe. **Non e' nella lista dei morti**
(gamma scalping = long-vol nudo; VRP01 = short-vol nudo gated; questo e' relative-value).
⚠️ **Due muri dichiarati in anticipo:** (i) sarebbe prezzato su DVOL, cioe' BS-flat, e la SKEW di
questa ondata ha appena misurato che il flat costa **x0,869 di struttura a termine** e **x0,920 di
skew** — quindi il verdetto massimo e' LEAD finche' non e' riprezzato sulla catena vera; (ii)
richiede opzioni su entrambi gli asset: fuori a $635 (BTC_USDC ~$780/lotto), dentro a ~$3-5k.
### 3.4 — Il basis dei futures datati come **campione della coda**, non come carry
BASIS-CALENDAR ha costruito un dataset che il progetto non aveva — **BTC 34 contratti / 240.150
barre orarie dal 2018-09, ETH 34 / 233.334, piu' 64.110 ore di funding e indice** — e lo ha usato
per una sola domanda (il premio incassabile, IC95 che contiene lo zero → SCARTATO). **Il dataset
contiene la coda che manca a tutto il resto del progetto**, e il ledger lo dice esplicitamente
(*«include il deleveraging 2022 — esattamente la coda che a CC01 mancava per costruzione»*).
**Uso non esplorato:** il blow-out del basis e' l'unico marcatore osservabile e datato dei giorni
di deleveraging forzato. Condizionare a quei giorni le domande di §3.1 e §4 da' un **campione di
stress reale** al posto di uno scenario assunto a mano.
⚠️ **E c'e' un'azione a costo quasi nullo che nessuno ha messo negli aperti:** quel dataset vive in
`.../scratchpad/basis_cache/` (**20 MB**, [VERIFICATO]), cioe' in una cartella di sessione che
sparisce. E' ricostruibile (30/30 trimestrali rispondono e il metodo — costruire a mano i nomi dei
contratti scaduti e chiamare `get_tradingview_chart_data` — e' documentato nello script), ma
ricostruirlo costa rete e tempo che ora sono gia' stati spesi. **Persisterlo in `data/raw/fut_*`
e' l'unica cosa dell'intera ondata che si perde per non averla scritta.**
### 3.5 — La copertura di coda come **abilitante della leva**, non come miglioramento dello Sharpe
**Stato dell'arte nel progetto, dichiarato:** tail hedge *e' gia' stato provato*`OPT06` (ratio
put spread con tail hedge) e `OPT07` (collar) nello sweep del 20/06, entrambi **modellati su DVOL
BS-flat** e giudicati **sullo Sharpe**, entrambi bocciati. **Cosa non e' stato fatto:** giudicarlo
sull'obiettivo **crescita a leva**. Sono domande diverse perche' il valore di un pavimento e'
**convesso nella leva**: a k=1 una put lontana e' un costo secco, a k=k\* e' cio' che rende k\*
stimabile. La domanda esatta: *esiste una put a delta basso il cui costo annuo, misurato sulle
quote VERE (non su DVOL), sia minore del guadagno di crescita che il pavimento autorizza?*
⚠️ **Prior onesto: sfavorevole.** Il 30/07 ha misurato che l'ala comprata costa **~2,3x il
modello** (f_long 2,23, fino a 5,85 sui delta piu' bassi) — quindi la protezione reale e' piu' cara
di quella che aveva gia' bocciato OPT06/OPT07. **Ma il confronto non e' mai stato fatto contro la
leva**, ed e' l'unico confronto in cui una protezione cara puo' comunque pagare.
⚠️ Eseguibile solo da ~$3-5k (ETH_USDC ~$250/lotto di nozionale): **e' una domanda per il deposito,
non per oggi.**
### 3.6 — Il flusso di opzioni (volume per strike), che e' nel dato e nessuno ha guardato
La catena registra `volume_24h` **per strumento**, oraria. Sei filoni hanno usato l'**OI**
(posizioni accumulate) e **zero** hanno usato il **volume** (transazioni). ALT-OPTIONS ha appena
dimostrato che sulle famiglie lineari OI e negoziabilita' sono **anti-correlati (rho di rango
0,77)** — che e' un'ottima ragione per sospettare che le due grandezze dicano cose diverse anche
sulle inverse. **Meccanismo:** un'ondata di acquisti concentrata su uno strike e' un fatto di
flusso; l'OI e' il suo integrale, e integrare distrugge il segnale di timing.
⚠️ **Muro invalicabile e dichiarato: 74-90 giorni di calendario.** Il verdetto massimo qui e'
**LEAD con gate pre-registrato**, e l'onesta' vuole che si dica prima di iniziare, non dopo.
La ragione per farlo comunque e' che **il calendario cresce da solo**, gratis, ogni ora.
---
## 4. LA MISURA DI MAGGIOR VALORE MAI FATTA
### La misura
**Il peggior giorno che il libro puo' avere, misurato invece che assunto: la distribuzione della
perdita giornaliera CONDIZIONATA all'esposizione piena, sotto la lente wick accoppiata, con lo
scenario di gap attraverso il disaster-SL.**
### Perche' questa
Perche' e' **l'unico parametro che blocca la sola leva gratuita che l'ondata abbia trovato**, e
perche' oggi quel parametro e' **un numero scelto a mano**.
GROWTH-POLICY ha stabilito che il gradino eseguibile `1,00x → 1,25-1,50x` vale
**14,7 anni → 12,9-11,6 anni** al capitale-rendita (a €500/mese, netto fisco). Lo scettico ha
tolto una delle tre riserve (il wick non amplifica il maxDD: ricarico costante ~3,5%). Delle due
rimaste, **una non e' risolvibile** (il drift stimato su 7,4 anni — e comunque k\* resta 7x a 2 SE,
quindi 1,5x sta al 21% di k\*) e **una lo e': la coda.** E' la riserva che decide, perche' e' l'unica
che porta k\* **sotto** il gradino proposto: *«un solo giorno 10% all'anno porta k\* da ~10x a 2x,
15%/anno a 1x»*.
📌 **Quel 10% non e' stato misurato: e' stato scelto.** E il controllo di cinque minuti in §3.1
dice che potrebbe essere **una coda da buy&hold importata dentro un libro difensivo**: sui cinque
peggiori giorni di BTC e i cinque di ETH in 7,4 anni — compreso il **41,5% / 45,7% del 12 marzo
2020** — il libro ha fatto **+0,97 · +0,72 · 0,00 · 1,98 · 2,24 · +0,23%**, e il suo peggior
giorno assoluto e' **3,38%** in una data che non e' un crash. Se il vero peggior giorno
plausibile e' 5% e non 10%, k\* non e' 2x e **il gradino e' autorizzato**.
⚠️ **Ma il mio controllo e' close-only, cioe' proprio la lente che questa ondata ha appena
dichiarato "esattamente cieca" sulle regole a UN giorno** (rapporto acc./close = INF a soglia 5%).
Quindi **la misura vera non e' quella che ho fatto**: e' la stessa domanda sotto
`r0725_prop_coupled` (tuple accoppiate, minimo intra-giorno) piu' lo scenario che il dataset non
contiene — un gap notturno che attraversa il disaster-SL 30% con posizione piena su entrambe le
gambe. Tutta la macchineria esiste gia' (`r0725_prop_coupled`, `r0822_leverage_skeptic`), il
campione di stress si puo' prendere dal dataset dei futures datati di §3.4, e il costo e'
**un agente di questa ondata**.
### Confronto esplicito con le leve gia' quantificate
| leva | valore | costo | stato |
|---|---|---|---|
| versare €250/mese invece di €0 | da **mai** a 19,8a (P 52% netto fisco) | €250/mese | decisa dall'operatore |
| versare €500 invece di €250 | 19,8a → **14,7a** | €250/mese in piu' | decisa dall'operatore |
| **leva 1,00x → 1,50x** | **14,7a → 11,6a** | **zero** | **bloccata da UN parametro assunto** |
| riallocare per la barriera (prop) | +0,016 di J = **il 4%** | analisi | misurata, marginale |
| XS01 su conto funded | +0,400 di J | **assunzione non verificata (§1.1)** | LEAD |
| ottimizzare il peso di SKH01 | **+0,030 di Sharpe** | analisi | gate fallito, plateau |
| rischio venue a p=5% | capitale mediano → **zero** | split di conto | decisa: 100% Deribit fino a $20k |
📌 **Il gradino di leva vale ~3,1 anni, cioe' l'equivalente di ~€300/mese di versamenti, a costo
zero.** E' la prima volta in due mesi che una misura di ricerca entra nell'ordine di grandezza
della leva dei versamenti, e ci entra perche' **non e' ricerca di alpha: e' un cambio di scala di
cio' che gia' funziona.** Vale **100x** l'ottimizzazione del peso di SKH01 e **25x** il riallocare
per la barriera.
### E adesso la parte che il mandato chiede di dire se e' vera
**Sì: nessuna misura di ricerca di questa ondata batte "versare".** Venti filoni, ~15.300 righe,
**zero candidati promossi**; il soffitto direzionale e' stato riconfermato per via aritmetica; e
per **tre volte** l'eseguibilita' a $600 non e' stata il vincolo binding — tutto e' morto
sull'**edge**. La misura che propongo **non e' un'eccezione a questo, ne e' la conferma**: non
cerca un rendimento nuovo, **prezza un parametro di rischio per poter usare meglio quello che c'e'**.
E anche cosi', 3,1 anni sono **meno** di cio' che valgono €250/mese di differenza nei versamenti.
**La seconda misura, e va detta insieme alla prima perche' costa ancora meno:** la verifica di
§1.1 (i 19 alt HL sono listati e shortabili presso la firm?). Non e' ricerca, e' **un pomeriggio di
lettura sul sito di due firm** — e decide se il canale prop, l'unico che moltiplica il nozionale
senza possedere capitale, vale J 0,738 o J 0,335. **Il progetto ha gia' pagato una volta il prezzo
di non fare questa verifica** (GTAA01/PRIIPs, cinque settimane di misure su un ETF che il broker
rifiuta di vendere) e ha scritto la regola per non ripeterlo. **Farla prima di aprire un altro
filone prop e' la cosa piu' economica che ci sia in questo elenco.**
**In una riga:** *misurare la coda invece di assumerla* e' la ricerca che vale di piu' (3,1 anni,
gratis); *leggere il listino di una prop firm* e' la verifica che vale di piu' (un canale intero,
un pomeriggio); e **nessuna delle due batte i versamenti**, che restano il vincolo binding come il
26/07 aveva gia' stabilito.
---
## 5. AUTOCRITICA DELL'ONDATA
### 5.1 — 🚨 Un filone su venti non e' nel ledger, ed e' quello con il risultato eseguibile oggi
**ALT-OPTIONS (filone 14, `r0822_alt_options.py`, 709 righe) non ha ne' una riga di tabella ne'
una sezione in `RESULTS-0822.md`.** [VERIFICATO: 19 sezioni `###`, 18 righe di tabella, nessuna
per il 14 — e la tabella ha anche un difetto di markdown che **fonde le righe 9 e 4 su una sola
linea** (`... = TP01 travestito || 4 | DEALER-GAMMA | ...`).] Il filone e' citato **due volte** da
un altro filone (*«vedi filone 14»*, *«ETH_USDC resta la gamba giusta ... (filone 14)»*) e serve
al coordinatore per **ritirare una propria inferenza sbagliata** sull'OI — ma il lettore non trova
la fonte da nessuna parte.
Nel diario compare come **due parole** dentro l'elenco dei morti: *«alt-options (alt)»*. **La
sintesi e' sbagliata due volte:** il filone non muore «perche' alt» (la sua conclusione e' che
**SOL/XRP/HYPE/AVAX sono i peggiori**: SOL_USDC ha il **9%** dei put quotati a due lati e
`f_venue` **negativo**), e cio' che trova e' che **ETH_USDC** — non un alt — e' la gamba con
`f_venue = 0,980`, spread della corta al **2,6%**, **max-loss $16,62/lotto** e **3.000 lotti al
best bid**. Cioe' §1.7: **la quarta soglia pubblicata falsificata dall'ondata**, quella che tiene
VRP01 fuori per taglia — e il diario ne annuncia **tre**.
**Lezione:** il ledger e' stato scritto *man mano* (lo dice la sua prima riga) e un filone che
finisce mentre si scrive la sintesi puo' sparire senza che nulla lo segnali. Serve una guardia
banale: **contare i file `r0822_*.py` e le sezioni del ledger, e non chiudere l'ondata se non
combaciano.** E' lo stesso principio che l'ondata ha appena raccomandato per i monitor.
### 5.2 — Il gate `implausible_sharpe` non e' stato girato dove serviva, per la seconda volta
MONITOR-AUDIT lo osserva bene: *«`implausible_sharpe` esiste dal 26/07 e non e' mai stato puntato
sulle serie forward»* (avrebbe segnalato Sharpe 15,8 su 28 barre). Ma la stessa mancanza si
ripete **dentro l'ondata**: il gate non compare in PROP-ALLOC (dove J e P(pass) sono costruiti su
percorsi bootstrap, non su una serie reale — legittimo, ma va dichiarato) ne' in GROWTH-POLICY, il
cui output include un **capitale mediano di $4,4e12** — che e' letteralmente la firma per cui il
gate e' stato scritto. L'agente ha fatto il controllo di plausibilita' **a mano** e l'ha riportato
con onesta'; **il gate del progetto non e' stato invocato.** Un gate che si applica a mano e' un
gate che un giorno non si applichera'.
### 5.3 — Due «difetti di dato» diagnosticati per ricostruzione con la sorgente a due minuti di distanza
E' il tema di §1.2 e §1.3, e vale come **critica di metodo dell'ondata**, non dei due agenti. Il
progetto ha codificato il 07/08: *«una fonte normativa citata in un commento di codice si verifica
come un numero»* — nata da una legge citata male in 5 file per settimane. Il 22/08 due filoni
dichiarano un difetto in un dato prodotto da `/opt/docker/cerbero-mcp` (**acceso, sulla stessa
macchina, e per giunta gia' usato da PythagorasGoal per Hyperliquid**) e da
`Adriano/Cerbero-Bite` (su Gitea, ultimo commit di dismissione), **senza aprire ne' l'uno ne'
l'altro**. Io li ho aperti entrambi in **meno di dieci minuti**. Il brief e' complice: elenca le
dieci trappole statistiche e **non dice «prima di dichiarare un difetto di dato, leggi il codice
che quel dato lo produce»**. E' la regola nuova piu' economica che questa ondata possa lasciare.
### 5.4 — Il coordinatore ha lasciato passare una conclusione debole, e ne ha corretta una propria
**Corretta (a merito):** l'inferenza *«BTC_USDC e' praticamente morto»*, ritirata dopo la misura di
ALT-OPTIONS, con la ragione scritta accanto. E' il comportamento giusto.
**Lasciata passare:** la lezione di ORTHO-SCREEN (§2.1). E' etichettata *«il risultato piu'
riusabile dell'ondata»* e *«non e' sfortuna, e' aritmetica»* — due formule che chiudono la
discussione — mentre la sua meta' operativa (*«dichiarare famiglie molto piu' piccole in
anticipo»*) e' **la manovra che il progetto ha proibito il 30/07** e la sua meta' quantitativa
dipende da una variabile (la dispersione delle celle) **mai nominata**. Il segnale che avrebbe
dovuto fermarla c'era ed era **nello stesso ledger, sette sezioni piu' avanti**: il DSR vacuo di
VOL-SIZE. **Due misure dello stesso gate, versi opposti, stesso giorno, nessun rimando.**
La sintesi di venti filoni ha un compito che nessun filone ha: **incrociare**. Qui non l'ha fatto,
e non l'ha fatto neanche su §2.4 (leva), §2.5 (peso SKH01), §1.5 (le due gambe del gate 23/10) e
§1.6 (k\* contro la partecipazione al nastro) — **cinque incroci disponibili a costo zero, tutti
fra coppie di filoni gia' consegnati.**
### 5.5 — Agenti che hanno dichiarato «non girabile» cio' che era girabile
- **FLOW-SQUEEZE**: *«liquidation_long/short: NO ... Nessuna fonte nostra la produce, e non c'e'
niente da produrre»*. I due input della regola sono **nel file** (§1.3). Costo del mancato
lavoro: ~due ore. Il verdetto non sarebbe cambiato; la **motivazione** si'.
- **DEALER-GAMMA**: *«Quale [convenzione], lo decide la sezione 2 (ricostruzione dalla catena),
non io»* — decidibile leggendo, in due minuti (§1.2).
- **PROP-ALLOC**: la banda d'ancora dichiarata *«non girata, fuori budget»*. Onesto, e la
distorsione e' dichiarata a proprio sfavore. **Ma e' l'unica distorsione dichiarata che punta
nella direzione della raccomandazione**, sull'unico filone che raccomanda qualcosa: dovrebbe
essere il primo pezzo di budget di un seguito, non l'ultimo.
- **TERM-STRUCTURE**: *«`deflated_sharpe` NON girato, e la motivazione e' il risultato»* — questo
invece e' **corretto e da imitare**: con 74 osservazioni l'incertezza di campione domina di un
ordine di grandezza quella da selezione, e deflazionare 312 trial avrebbe dato *«un numero
preciso e privo di senso»*. **E' anche la migliore descrizione, scritta senza saperlo, del
problema di §2.1.**
### 5.6 — Cosa l'ondata ha fatto meglio della media del progetto (perche' un'autocritica che elenca
solo difetti non e' calibrata)
Sette agenti hanno catturato un **errore proprio** prima di pubblicare; tre hanno **refutato una
propria ipotesi in corsa**; uno scettico ha **ritirato una regola pubblicata poche ore prima**
provando che il difetto non esisteva — e la regola ritirata era **sua di famiglia**. Quattro
filoni hanno dichiarato la **potenza prima di guardare** (OI-PIN, FLOW-SQUEEZE, SLIP-AUDIT,
VOL-SIZE) e in tre casi il candidato e' morto **contro la propria soglia dichiarata**, che e' il
modo piu' pulito di morire. Le repliche **bit-exact prima di ogni delta** sono ormai
sistematiche (5/5 in XS-LITE e HL-EXEC, `max|diff| = 0,0`). **Il metodo dei singoli filoni non e'
il problema di questa ondata: la cucitura lo e'.**
---
## Appendice — cosa ho verificato in questa sessione, e come
| # | fatto | come |
|---|---|---|
| A | `dealer_net_gamma` = convenzione **dichiarata** (dealer short calls / long puts) nel sorgente di `cerbero-mcp`, non un difetto | letto `/opt/docker/cerbero-mcp/src/cerbero_mcp/common/options.py:11-14, 138-140` |
| B | bite **inoltra** il campo e non lo calcola; chiama con `top_n_strikes=50` di default | clone `Adriano/Cerbero-Bite`, `runtime/market_snapshot_cycle.py:85`, `clients/deribit.py:342-384` |
| C | `liquidation_*_risk` = soglia su `oi_delta_24h` + funding, e **i due input sono nel parquet** (copertura 99%, `oi_delta_pct_4h` std 1,23%) | `sentiment/fetchers.py:480-518` + lettura di `cb_market_snapshots.parquet` |
| D | `sr0` del DSR = `sd(Sharpe dei trial) x mult(N)`; con sd=0,58, `sr0` passa da **1,57 (N=168)** a **1,15 (N=24)** | letto `altlib.deflated_sharpe:689-712`, moltiplicatori ricalcolati |
| E | il libro 75/25 fa **+0,97%** il 12/03/2020 (BTC 41,5%, ETH 45,7%); peggior giorno **3,38%**, non un crash | `altlib.tp01_baseline_daily()` + `sleeves._skyhook_returns()`, close-only |
| F | `collect_chain.py` interroga `{"currency": asset}` e filtra `OI_MIN = 100.0` | `scripts/live/collect_chain.py:57, 114, 181` |
| G | il feed certificato e' `BTC/USD:BTC` (**inverse**), il libro esegue `BTC_USDC-PERPETUAL` (**lineare**) | `rebuild_history.DERIBIT_INSTR:48` vs `src/live/deribit.INSTRUMENT:110` |
| H | ALT-OPTIONS: ETH_USDC `f_venue` 0,980 / max-loss **$16,62** / 7 strutture al 20% di $635 | output del filone in scratchpad |
| I | il dataset dei futures datati (**20 MB**) vive solo in `scratchpad/basis_cache/` | `du -sh` |
| J | `RESULTS-0822.md`: 19 sezioni, 18 righe di tabella, **filone 14 assente**, righe 9 e 4 fuse | conteggio diretto |
⚠️ **Su (G):** e' un fatto, non un'accusa. `rebuild_history` lo dichiara in un commento
(*«inverse, storia lunga ~ lineare entro 3 bps»*) e SLIP-AUDIT ha misurato la base ai 18 fill
(BTC 0,03 bps, ETH 0,27) — trascurabile sui **ritorni**, che dipendono dalle *variazioni* della
base. Lo segno solo perche' e' la **5ª occorrenza nell'ondata** dello schema «un controllo puntato
su uno strumento diverso da quello che si trada» (dopo `fee_watch` 21/08, il collettore della
catena, il feed dello slippage e la taratura di `venue_watch`), e perche' la parte non verificata
di quel commento — *«~ lineare entro 3 bps»* su **tutta la storia** — non e' mai stata misurata:
esiste una misura su 18 istanti, non sulle 60.000 ore in cui entrambi gli strumenti esistono.
Costo di chiuderla: un'ora.
---
**Book, pesi, cron, config: questo filone non tocca niente.** Consegna un'analisi.
Le uniche azioni che raccomanda sono a costo quasi nullo e tutte su documenti o verifiche:
correggere §1.2 nel ledger, aggiungere la sezione mancante di §5.1, dichiarare la dispersione
accanto a ogni DSR (§2.1), ri-registrare il gate del 23/10 prima di rigenerare le serie (§1.4-1.5),
persistere il dataset dei futures (§3.4), e leggere il listino di una prop firm prima di
misurare un'altra volta il canale che ne dipende (§1.1).
+733
View File
@@ -0,0 +1,733 @@
#!/usr/bin/env python
"""r0822b_maker.py — l'ESECUZIONE PASSIVA (maker) vale qualcosa per il libro live?
CONTESTO (fatti gia' nel progetto, non ri-misurati qui)
-------------------------------------------------------
* Il libro Deribit (TP01 75% + SKH01 25%, nettati su UNA sola posizione per strumento) manda
**solo ordini market** -> paga sempre il **taker**. `src/live/execution.py::_submit` ha
`order_type="market"` come default e `rebalance_signed` non passa mai altro.
* Fee misurate: **taker 3,50 bps/lato** (fee_watch + 16/16 fill sul nastro, 22/08),
**maker 1,50 bps/lato** (listino base dal 2026-08-01; fino al 31/07 era 0,00).
I backtest del progetto modellano **5,00 bps/lato** (0,10% RT) -> gia' conservativi.
* Il progetto ha guardato il lato passivo UNA volta (raccomandazione "T1" del 26/07: il TP di
SKH01 come limit resting) e l'ha chiusa **non per la fee** ma perche' un ordine al livello di
SKH chiuderebbe anche la quota TP01 (**netting su strumento unico**, `src/live/book.py`).
⚠️ QUEL VINCOLO NON SI APPLICA QUI: rendere passivo **l'ordine netto di ribilanciamento** e'
UN ordine sulla posizione netta -> niente conflitto fra sleeve. Il netting uccide i bracket
per-sleeve, non l'ordine netto. E' la ragione per cui questa domanda e' nuova.
* Slippage misurato il 22/08: **~0 a questa taglia** (un fill ha preso il 22% del volume della
sua barra 5m, range 1,08 bps) -> il differenziale maker/taker e' fee PURA, e il mezzo spread
catturato da un passivo e' <0,005 bps (tick BTC 0,1 su ~$110k; ETH 0,01 su ~$2.500) = ZERO.
Percio' qui il passivo NON guadagna spread: guadagna 2,00 bps di fee e rischia il non-fill.
* Il disaster-SL 30% e' uno `stop_market` on-book: NON e' toccato da nulla di quanto segue.
LA DOMANDA CHE NESSUNO HA FATTO
-------------------------------
Un limit resting non e' un market order piu' economico: **puo' non riempirsi**. E non riempirsi
su un segnale di trend significa **perdere il movimento**, che e' esattamente il caso in cui non
si e' riempito (il prezzo e' scappato dalla parte giusta). Quindi:
costo atteso del passivo = P(fill)*(2,00 bps) + P(no fill)*E[mossa avversa | no fill]
e la seconda parte e' **avversa per costruzione**: un buy limit al riferimento non si riempie se
e solo se il prezzo non e' MAI sceso al livello, cioe' e' salito.
COSA FA QUESTO SCRIPT
---------------------
1. il differenziale in Sharpe/CAGR di libro, riusando la curva fee gia' pubblicata
(`r0726_fee_sensitivity.py`: 0,017 Sharpe/bps, 0,23% CAGR/bps) E ri-misurandola a 4 punti
di fee sul path del libro (verifica indipendente, non ri-derivazione della curva);
2. ricostruisce gli **ORDINI** del libro live (target netto orario, banda min-order $5) e li
confronta con i 18 ordini veri di `data/live/book_executions.jsonl` (controllo di realta');
3. per OGNI ordine misura su 5m certificati: P(fill) di un limit al riferimento entro N minuti,
e il ritorno condizionato **ai casi in cui NON si riempie**. Confronto **APPAIATO PER
ORDINE** (mai per esito: allineare sui riempiti tiene solo i casi in cui non e' successo
niente — trappola gia' presa 3 volte in questo progetto);
4. trasforma il costo per-ordine in una serie di P&L additiva ai rendimenti giornalieri del
libro -> Sharpe/CAGR/maxDD della politica passiva, e il numero in **dollari all'anno**;
5. mette il risultato accanto alle altre leve gia' misurate.
LENTI E SEMPLIFICAZIONI — DICHIARATE (tutte GENEROSE verso il maker, di proposito)
---------------------------------------------------------------------------------
(a) griglia oraria: il cron gira a :07. Decisione con le barre 5m chiuse fino a h:05, prezzo di
riferimento = close della barra 5m etichettata h:00. Verificato sul log vero: gli ordini
cadono a :07 di un'ora qualsiasi, e TP01 si aggiorna alle 01:07 UTC.
(b) TP01 cambia target una volta al giorno, all'ora 01:00 UTC (lente backtest; l'effetto della
barra parziale e' misurato +-0,03 Sharpe, 26/07 -> irrilevante per la TAGLIA degli ordini).
(c) SKH01: ingressi alla CHIUSURA del bin 230m (lente backtest), uscite al primo confine orario
dopo il tocco 5m (lente live, `_skyhook_positions` itera nel bin parziale). L'ingresso vero
del live e' INTRA-BIN, cioe' piu' vicino al breakout -> peggiore per un limit passivo:
questa lente e' **generosa** verso il maker.
(d) fill: un limit BUY al prezzo P si considera riempito se `low <= P` nella finestra. Ignora la
coda del book (al livello si sta DIETRO agli ordini gia' presenti: un semplice tocco non
riempie) -> **generosa** verso il maker. Finestra di scansione da h:05 (l'ordine e' a h:07:
regaliamo 2 minuti di opportunita' di fill).
(e) ordini tutto-o-niente, nessun fill parziale.
(f) niente cap per-asset nella ricostruzione (a $635 il cap $318 morde di rado e riduce
PROPORZIONALMENTE guadagno e danno: non cambia il segno).
nice -n 19 timeout 900 uv run python scripts/research/r0822b_maker.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.backtest.harness import backtest_signals # noqa: E402
from src.data.downloader import load_data # noqa: E402
from src.portfolio.portfolio import combine_outer, metrics, to_daily # noqa: E402
from src.strategies.skyhook import (LTF_MIN, SKH01_V2_DD, build_frames, # noqa: E402
skyhook_entries)
from src.strategies.trend_portfolio import (CANONICAL, TrendPortfolio, # noqa: E402
resample_1d, simple_returns)
ASSETS = ("BTC", "ETH")
W_TP01, W_SKH = 0.75, 0.25 # pesi del book Deribit (src/live/book.py)
WEIGHT = 0.5 # book 50/50 BTC+ETH
MIN_ORDER_USD = 5.0 # config/live.json
EQUITY_LIVE = 635.0 # conto vero (22/08)
FEE_TAKER_BPS = 3.50 # misurata sul nastro + fee_watch
FEE_MAKER_BPS = 1.50 # listino base dal 2026-08-01
FEE_MODEL_BPS = 5.00 # cio' che i backtest modellano
FEE_GRID_BPS = (0.0, FEE_MAKER_BPS, FEE_TAKER_BPS, FEE_MODEL_BPS)
# curva PUBBLICATA (r0726_fee_sensitivity.py) — riusata, non ri-derivata
PUB_SH_PER_BPS = -0.017
PUB_CAGR_PER_BPS = -0.23 # punti percentuali di CAGR per bps/lato
HORIZONS_MIN = (5, 15, 30, 60, 120) # attesa massima del limit prima di inseguire
# CODA DEL BOOK — il parametro che DECIDE, e che l'OHLC non contiene.
# Un limit al livello sta DIETRO agli ordini gia' presenti: "il prezzo ha toccato il mio livello"
# NON e' "sono stato riempito". `q` = quanti bps il prezzo deve attraversare OLTRE il livello
# perche' la coda davanti sia consumata. q=0 e' il LIMITE SUPERIORE ottimistico (e a q=0 il fill
# e' quasi automatico: l'apertura della prima barra di scansione COINCIDE col livello, quindi
# `low <= P` chiede solo che il prezzo non scappi istantaneamente).
QUEUE_BPS = (0.0, 1.0, 2.0, 5.0, 10.0)
P2_MAX_HOURS = 24 # politica "mai inseguire": ri-posta ogni ora, tetto 24h
CAPITALS = (635.0, 2000.0, 5000.0)
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
SEED = 20260822
# ===========================================================================================
# 1. SEGNALI — calcolati UNA volta per asset (non dipendono dalla fee)
# ===========================================================================================
def build_asset(asset: str) -> dict:
"""Tutto cio' che serve per l'asset: TP01 daily, path SKH01, griglia 5m e oraria."""
df5 = load_data(asset, "5m")
ts5 = pd.to_datetime(df5["datetime"], utc=True)
A = dict(
ts5=ts5.values.astype("datetime64[ns]"),
o5=df5["open"].values.astype(float), h5=df5["high"].values.astype(float),
l5=df5["low"].values.astype(float), c5=df5["close"].values.astype(float),
)
# --- SKH01: ingressi/uscite sulla griglia 230m, poi tempo di RILEVAZIONE oraria
ltf, htf = build_frames(df5)
ent = skyhook_entries(ltf, htf, SKH01_V2_DD)
H, L = ltf["high"].values, ltf["low"].values
ltf_ts = pd.to_datetime(ltf["datetime"], utc=True)
n = len(ltf)
trades = []
i = 0
while i < n:
e = ent[i]
if e is None:
i += 1
continue
d, sl, tp, mb = e["dir"], e["sl"], e["tp"], e["max_bars"]
exit_idx = None
for s in range(1, mb + 1):
j = i + s
if j >= n:
break
hit = (L[j] <= sl or H[j] >= tp) if d == 1 else (H[j] >= sl or L[j] <= tp)
if hit or s == mb:
exit_idx = j
break
if exit_idx is None: # trade ancora aperto a fine storia
trades.append((i, None, d, sl, tp))
break
trades.append((i, exit_idx, d, sl, tp))
i = exit_idx + 1
# tempi di rilevazione: ingresso a chiusura bin (lente backtest);
# uscita al primo confine orario dopo il TOCCO 5m (lente live)
ltf_close = ltf_ts + pd.Timedelta(minutes=LTF_MIN)
skh_events = [] # (ts_rilevazione, segno)
for (i0, j0, d, sl, tp) in trades:
skh_events.append((_ceil_hour(ltf_close.iloc[i0]), int(d)))
if j0 is None:
continue
t_touch = _first_touch_5m(A, ltf_ts.iloc[j0], ltf_close.iloc[j0], d, sl, tp)
skh_events.append((_ceil_hour(t_touch), 0))
# --- TP01: target giornaliero, visibile dall'ora 01:00 del giorno DOPO la chiusura
d1 = resample_1d(load_data(asset, "1h"))
tp_cfg = TrendPortfolio(**CANONICAL)
tgt = tp_cfg.target_series(d1)
d1_ts = pd.to_datetime(d1["datetime"], utc=True)
tp_events = [(pd.Timestamp(t).normalize() + pd.Timedelta(hours=25), float(v))
for t, v in zip(d1_ts, tgt)] # barra del giorno D -> vista alle 01:00 di D+1
# --- griglia oraria dei momenti di decisione (barre 5m etichettate :00)
m = pd.DatetimeIndex(A["ts5"])
hmask = (m.minute == 0)
A["hours"] = m[hmask].tz_localize("UTC")
A["hidx"] = np.flatnonzero(hmask) # indice 5m della barra di riferimento
A["ref"] = A["c5"][A["hidx"]] # prezzo di riferimento (close 5m di h:00)
A["skh_events"] = skh_events
A["tp_events"] = tp_events
A["daily_ret"] = simple_returns(d1["close"].values.astype(float))
A["daily_tgt"] = tgt
A["d1_ts"] = d1_ts
A["ltf"] = ltf
A["ent"] = ent
return A
def _ceil_hour(t: pd.Timestamp) -> pd.Timestamp:
return t.ceil("h")
def _first_touch_5m(A: dict, t_open, t_close, d: int, sl: float, tp: float) -> pd.Timestamp:
"""Primo istante 5m, dentro il bin 230m d'uscita, in cui SL o TP e' toccato.
Se nessuno e' toccato (uscita per max_bars) ritorna la chiusura del bin."""
lo = np.searchsorted(A["ts5"], np.datetime64(t_open.tz_localize(None)))
hi = np.searchsorted(A["ts5"], np.datetime64(t_close.tz_localize(None)))
for k in range(lo, min(hi, len(A["ts5"]))):
hit = (A["l5"][k] <= sl or A["h5"][k] >= tp) if d == 1 else \
(A["h5"][k] >= sl or A["l5"][k] <= tp)
if hit:
return pd.Timestamp(A["ts5"][k], tz="UTC") + pd.Timedelta(minutes=5)
return t_close
def exposure_path(A: dict) -> pd.DataFrame:
"""Esposizione-bersaglio del libro su questo asset, ORA per ORA, in frazione di equity.
expo = WEIGHT * (W_TP01*tp_frac + W_SKH*skh_sign) — la formula di src/live/book.py."""
hours = A["hours"]
tp = pd.Series(0.0, index=hours)
for t, v in A["tp_events"]:
tp.loc[tp.index >= t] = v
sk = pd.Series(0.0, index=hours)
for t, v in sorted(A["skh_events"]):
sk.loc[sk.index >= t] = v
expo = WEIGHT * (W_TP01 * tp.values + W_SKH * sk.values)
src = np.where(np.abs(np.diff(sk.values, prepend=0.0)) > 0, "SKH",
np.where(np.abs(np.diff(tp.values, prepend=0.0)) > 0, "TP01", ""))
return pd.DataFrame(dict(ts=hours, expo=expo, ref=A["ref"], hidx=A["hidx"], src=src))
# ===========================================================================================
# 2. ORDINI — banda min-order, esattamente come rebalance_signed
# ===========================================================================================
def orders_from_path(P: pd.DataFrame, equity: float, min_usd: float = MIN_ORDER_USD) -> pd.DataFrame:
"""Ordini che il libro manderebbe: si trada solo se |target-posizione| >= min_usd."""
band = min_usd / equity
expo = P["expo"].values
held = 0.0
rows = []
for k in range(len(expo)):
delta = expo[k] - held
if abs(delta) < band:
continue
rows.append((P["ts"].iloc[k], delta, P["ref"].iloc[k], P["hidx"].iloc[k], P["src"].iloc[k]))
held = expo[k]
return pd.DataFrame(rows, columns=["ts", "dfrac", "ref", "hidx", "src"])
# ===========================================================================================
# 3. FILL E SELEZIONE AVVERSA
# ===========================================================================================
def measure_orders(A: dict, O: pd.DataFrame, horizons=HORIZONS_MIN, q_bps: float = 0.0,
strict: bool = True) -> dict:
"""Per ogni ordine e ogni orizzonte N: il limit e' stato riempito? e se no, quanto e'
scappato il prezzo? `q_bps` = attraversamento richiesto oltre il livello (coda del book).
`strict` E' IL PARAMETRO CHE DECIDE IL SEGNO DI TUTTO IL RISULTATO.
strict=False -> `low <= limite`: il prezzo ha TOCCATO il livello. Include il caso in cui
la barra APRE esattamente al livello e non ci torna mai: li' non e' stato scambiato
NIENTE al nostro prezzo dopo che l'ordine e' arrivato, quindi un passivo in fondo alla
coda NON e' riempito. E' un limite superiore FISICAMENTE NON DIFENDIBILE.
strict=True -> `low < limite`: il prezzo ha ATTRAVERSATO il livello. E' la condizione
minima perche' esista uno scambio al nostro prezzo. Anche cosi' si assume di essere
DAVANTI a tutta la coda gia' presente -> resta generosa, ma difendibile."""
l5, h5, c5 = A["l5"], A["h5"], A["c5"]
nb = len(c5)
idx = O["hidx"].values.astype(int)
ref = O["ref"].values.astype(float)
buy = (O["dfrac"].values > 0)
q = q_bps / 1e4
out = {}
for N in horizons:
steps = N // 5
filled = np.zeros(len(O), bool)
adverse = np.zeros(len(O))
for k in range(len(O)):
i0 = idx[k] + 1 # barra 5m etichettata h:05 (ordine a h:07: generoso)
i1 = min(i0 + steps, nb)
P = ref[k]
if i0 >= i1:
filled[k] = True
continue
lim = P * (1.0 - q) if buy[k] else P * (1.0 + q)
if buy[k]:
hit = (l5[i0:i1].min() < lim) if strict else (l5[i0:i1].min() <= lim)
else:
hit = (h5[i0:i1].max() > lim) if strict else (h5[i0:i1].max() >= lim)
filled[k] = hit
if not hit:
end = c5[i1 - 1]
adverse[k] = (end - P) / P if buy[k] else (P - end) / P
out[N] = pd.DataFrame(dict(filled=filled, adverse=adverse), index=O.index)
return out
def measure_p2(A: dict, O: pd.DataFrame, P: pd.DataFrame, max_hours=P2_MAX_HOURS,
q_bps: float = 0.0, strict: bool = True) -> pd.DataFrame:
"""Politica 'MAI INSEGUIRE': il limit resta e viene RI-POSTATO al nuovo riferimento a ogni
cron orario, fino a `max_hours`; solo allora si insegue a market. Il costo e' cumulativo."""
l5, h5, c5 = A["l5"], A["h5"], A["c5"]
hidx = P["hidx"].values.astype(int)
ref = P["ref"].values.astype(float)
nb, nh = len(c5), len(hidx)
pos_of = {int(v): k for k, v in enumerate(hidx)}
q = q_bps / 1e4
cycles, adverse, chased = [], [], []
for _, r in O.iterrows():
k0 = pos_of[int(r["hidx"])]
P0 = float(r["ref"]); buy = r["dfrac"] > 0
done, adv, cyc = False, 0.0, 0
for k in range(k0, min(k0 + max_hours, nh)):
Pk = ref[k]
lim = Pk * (1.0 - q) if buy else Pk * (1.0 + q)
i0 = hidx[k] + 1
i1 = min(i0 + 12, nb)
if i0 >= i1:
break
if buy:
hit = (l5[i0:i1].min() < lim) if strict else (l5[i0:i1].min() <= lim)
else:
hit = (h5[i0:i1].max() > lim) if strict else (h5[i0:i1].max() >= lim)
cyc = k - k0 + 1
if hit:
adv = (lim - P0) / P0 if buy else (P0 - lim) / P0
done = True
break
if not done:
k = min(k0 + max_hours, nh - 1)
adv = (ref[k] - P0) / P0 if buy else (P0 - ref[k]) / P0
cycles.append(cyc); adverse.append(adv); chased.append(not done)
return pd.DataFrame(dict(cycles=cycles, adverse=adverse, chased=chased), index=O.index)
def per_order_delta(O: pd.DataFrame, M: pd.DataFrame, *, miss_fee_bps: float) -> np.ndarray:
"""Delta di P&L in FRAZIONE DI EQUITY DI LIBRO, per ORDINE, del passivo contro il taker.
>0 = il passivo guadagna. Confronto APPAIATO PER ORDINE: ogni ordine compare una volta in
entrambe le politiche, riempito o no (allineare sui soli riempiti terrebbe i casi in cui
non e' successo niente — trappola gia' presa 3 volte in questo progetto)."""
absd = O["dfrac"].abs().values
filled = M["filled"].values if "filled" in M.columns else np.ones(len(O), bool)
fee_bps = np.where(filled, FEE_MAKER_BPS, miss_fee_bps)
return (FEE_TAKER_BPS - fee_bps) / 1e4 * absd - M["adverse"].values * absd
def weighted_stats(F: np.ndarray, Ad: np.ndarray, W: np.ndarray) -> dict:
"""Tutto PONDERATO PER NOZIONALE (e' il nozionale che paga la fee, non il conteggio).
`p_be` = la frazione di fill che il passivo deve raggiungere per PAREGGIARE il taker:
p*(-gap) + (1-p)*E[avversa|no fill] = 0 -> p_be = E/(E+gap)
E' la forma model-free del risultato: non dipende dall'ipotesi di coda, solo da quanto
scappa il prezzo quando il limit resta a terra."""
gap = (FEE_TAKER_BPS - FEE_MAKER_BPS) / 1e4
wf = W[F].sum() / W.sum() if W.sum() > 0 else 1.0
miss_w = W[~F]
e_adv = float(np.sum(miss_w * Ad[~F]) / miss_w.sum()) if miss_w.sum() > 0 else 0.0
cost = float(np.sum(W * (np.where(F, FEE_MAKER_BPS, FEE_TAKER_BPS) / 1e4 + Ad)) / W.sum())
return dict(p_fill=wf, e_adv=e_adv, cost_bps=cost * 1e4,
p_be=e_adv / (e_adv + gap) if (e_adv + gap) > 0 else 1.0)
def boot_ci(x: np.ndarray, w: np.ndarray, n: int = 2000, seed: int = SEED) -> tuple:
"""IC95% bootstrap della media PONDERATA PER NOZIONALE (in bps del nozionale tradato)."""
rng = np.random.default_rng(seed)
m = len(x)
idx = rng.integers(0, m, size=(n, m))
num = x[idx].sum(axis=1)
den = w[idx].sum(axis=1)
b = num / np.where(den > 0, den, np.nan) * 1e4
return float(np.nanpercentile(b, 2.5)), float(np.nanpercentile(b, 97.5))
# ===========================================================================================
# 4. LIBRO — rendimenti giornalieri modellati, per innestarci il costo d'esecuzione
# ===========================================================================================
def book_daily(cache: dict, fee_bps_side: float) -> pd.Series:
fs = fee_bps_side / 1e4
tp_s, sk_s = {}, {}
for a in ASSETS:
A = cache[a]
r, tgt = A["daily_ret"], A["daily_tgt"]
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
net = held * r - fs * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0
tp_s[a] = pd.Series(np.clip(net, -0.99, None), index=A["d1_ts"])
m = backtest_signals(A["ltf"], A["ent"], fee_rt=2 * fs, leverage=1.0, asset=a, tf="230m")
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
sk_s[a] = s.resample("1D").last().ffill().pct_change().dropna()
Jt = pd.concat(tp_s, axis=1, join="inner").fillna(0.0)
Js = pd.concat(sk_s, axis=1, join="inner").fillna(0.0)
tp = to_daily(pd.Series(0.5 * Jt["BTC"].values + 0.5 * Jt["ETH"].values, index=Jt.index))
sk = to_daily(pd.Series(0.5 * Js["BTC"].values + 0.5 * Js["ETH"].values, index=Js.index))
return combine_outer({"TP01": tp, "SKH01": sk}, {"TP01": W_TP01, "SKH01": W_SKH})
def graft(base: pd.Series, deltas: dict, O_live: dict) -> pd.Series:
"""Innesta il delta per-ordine (frazione di equity) nei rendimenti giornalieri del libro."""
ser = []
for a in ASSETS:
ts = pd.DatetimeIndex(O_live[a]["ts"]).tz_convert("UTC").normalize()
ser.append(pd.Series(deltas[a], index=ts))
add = pd.concat(ser).groupby(level=0).sum()
return base.add(add.reindex(base.index).fillna(0.0), fill_value=0.0)
def show(tag: str, d: pd.Series) -> dict:
m = metrics(d)
h = metrics(d[d.index >= HOLDOUT])
print(f" {tag:<36} Sh {m['sharpe']:>6.3f} | HOLD {h['sharpe']:>6.3f} | "
f"maxDD {m['maxdd']*100:>5.2f}% | CAGR {m['cagr']*100:>6.2f}%")
return dict(sharpe=m["sharpe"], hold=h["sharpe"], maxdd=m["maxdd"], cagr=m["cagr"])
# ===========================================================================================
def main() -> None:
rng = np.random.default_rng(SEED)
print("=" * 104)
print(" r0822b — ESECUZIONE PASSIVA (MAKER) SUL LIBRO LIVE:")
print(" quanto vale il differenziale, quanto costa non riempirsi")
print("=" * 104)
print(f" taker {FEE_TAKER_BPS:.2f} bps/lato · maker {FEE_MAKER_BPS:.2f} bps/lato · "
f"differenziale {FEE_TAKER_BPS-FEE_MAKER_BPS:.2f} bps/lato · modello backtest {FEE_MODEL_BPS:.2f}")
print(" NETTING: il libro ha UNA posizione netta per strumento -> l'ordine di ribilanciamento")
print(" e' UNO solo. Renderlo passivo NON e' bloccato dal motivo che uccise T1 il 26/07")
print(" (quello uccide i BRACKET per-sleeve, non l'ordine netto). Il disaster-SL -30% resta")
print(" uno stop_market on-book e non e' toccato da niente di quanto segue.")
cache = {a: build_asset(a) for a in ASSETS}
# ---------------------------------------------------------------- 1. il differenziale
print("\n" + "-" * 104)
print(" 1. QUANTO VALE IL DIFFERENZIALE (limite superiore: TUTTO il flusso diventa maker,")
print(" zero mancati riempimenti — cioe' il numero che NON si puo' superare)")
print("-" * 104)
curve = {bps: show(f"libro @ {bps:.2f} bps/lato", book_daily(cache, bps)) for bps in FEE_GRID_BPS}
gap = FEE_TAKER_BPS - FEE_MAKER_BPS
dsh = curve[FEE_MAKER_BPS]["sharpe"] - curve[FEE_TAKER_BPS]["sharpe"]
dcg = (curve[FEE_MAKER_BPS]["cagr"] - curve[FEE_TAKER_BPS]["cagr"]) * 100
print(f"\n misurato qui : dSharpe {dsh:+.4f} dCAGR {dcg:+.3f} pp "
f"({dsh/gap:+.4f} Sh/bps, {dcg/gap:+.3f} pp/bps)")
print(f" curva 26/07 : dSharpe {-PUB_SH_PER_BPS*gap:+.4f} dCAGR {-PUB_CAGR_PER_BPS*gap:+.3f} pp "
f"({-PUB_SH_PER_BPS:+.4f} Sh/bps, {-PUB_CAGR_PER_BPS:+.3f} pp/bps) <- replica indipendente OK")
print(" in DOLLARI all'anno, se tutto il flusso fosse maker:")
for cap, note in ((EQUITY_LIVE, "conto vero oggi"), (5000.0, "dopo i versamenti"),
(258338.0, "capitale-rendita, muro 07/08")):
print(f" ${cap:>9,.0f} -> ${cap*dcg/100:>9,.2f}/anno ({note})")
# ---------------------------------------------------------------- 2. gli ordini
print("\n" + "-" * 104)
print(" 2. GLI ORDINI DEL LIBRO (target netto orario + banda min-order $5, formula di book.py)")
print("-" * 104)
paths = {a: exposure_path(cache[a]) for a in ASSETS}
yrs = (paths["BTC"]["ts"].iloc[-1] - paths["BTC"]["ts"].iloc[0]).days / 365.25
print(f" {'capitale':>9} {'banda':>7} |" + "".join(f"{a+' ord/anno':>15}{'turnover/anno':>16}" for a in ASSETS))
ORD = {}
for cap in CAPITALS:
line, tot = "", {}
for a in ASSETS:
O = orders_from_path(paths[a], cap)
tot[a] = O
line += f"{len(O)/yrs:>15.1f}{O['dfrac'].abs().sum()/yrs*cap:>15,.0f}$"
ORD[cap] = tot
print(f" ${cap:>8,.0f} {MIN_ORDER_USD/cap*100:>6.2f}% |" + line)
O_live = ORD[EQUITY_LIVE]
n_live = sum(len(O_live[a]) for a in ASSETS)
turn = sum(O_live[a]["dfrac"].abs().sum() for a in ASSETS) / yrs
print(f"\n ricostruzione a ${EQUITY_LIVE:,.0f}: {n_live} ordini su {yrs:.1f} anni = "
f"{n_live/yrs:.1f}/anno di libro ({n_live/yrs/2:.1f} per asset), "
f"turnover {turn:.1f}x equity/anno")
print(f" coerenza col punto 1: {turn:.1f}x * {gap:.2f} bps = {turn*gap/1e4*100:.3f} pp di CAGR "
f"contro i {dcg:.3f} pp misurati sul libro modellato "
f"({turn*gap/1e4*100/dcg*100:.0f}% — la ricostruzione tiene meno turnover del modello)")
exe = ROOT / "data" / "live" / "book_executions.jsonl"
if exe.exists():
rows = [json.loads(l) for l in exe.read_text().splitlines() if l.strip()]
gg = max((pd.Timestamp(rows[-1]["ts_utc"]) - pd.Timestamp(rows[0]["ts_utc"])).days, 1)
print(f" controllo di realta': {len(rows)} ordini VERI in {gg} giorni = "
f"{len(rows)/gg*365.25:.0f}/anno di libro. Stesso ordine di grandezza; la finestra e'")
print(" troppo corta per una stima (18 ordini), serve solo a escludere un errore di fattore 10.")
for a in ASSETS:
v = O_live[a]["src"].value_counts()
print(f" {a}: origine " + " ".join(f"{(k or 'drift')} {n}" for k, n in v.items()))
# ---------------------------------------------------------------- 3. il non-fill
print("\n" + "-" * 104)
print(" 3. IL COSTO DEL MANCATO RIEMPIMENTO (limit al riferimento, APPAIATO PER ORDINE)")
print("-" * 104)
print(" Il parametro che decide NON e' l'orizzonte d'attesa: e' cosa si chiama 'riempito'.")
print(" Un limit al livello sta DIETRO a tutta la coda gia' presente, quindi:")
print(" TOCCO (low <= limite) -> include la barra che APRE al livello e non ci")
print(" torna mai: al nostro prezzo non e' stato scambiato")
print(" NIENTE. Fisicamente non difendibile: LIMITE SUPERIORE.")
print(" ATTRAVERSAMENTO (low < limite) -> condizione MINIMA perche' uno scambio al nostro")
print(" prezzo esista. Assume ancora di essere davanti a")
print(" tutta la coda -> generosa, ma difendibile.")
print(" q bps in piu' -> quanto il prezzo deve andare OLTRE perche' la coda")
print(" davanti a noi sia consumata. L'OHLC non contiene la")
print(" profondita' del book: `q` si dichiara, non si stima.")
print(" E la colonna che decide e' `serve`: la frazione di fill necessaria per PAREGGIARE il")
print(" taker = E[avversa|no fill]/(E[avversa|no fill] + 2,00 bps). Model-free.")
print(f"\n {'ipotesi':>10} {'N(min)':>7} | {'P(fill)':>8} {'serve':>8} {'esito':>6} | {'E[avv|no]':>10}"
f" {'peggio':>8} | {'costo maker':>12} | {'vs taker':>9} | {'IC95 del delta':>22}"
f" | {'ordini vinti':>12}")
grid_cells = 6 # le 6 celle della griglia fine calcolate piu' sotto
tables = {}
variants = [("TOCCO <=", 0.0, False)] + [(f"attrav. q={q:.0f}", q, True) for q in QUEUE_BPS]
for lab, q, strict in variants:
MEAS = {a: measure_orders(cache[a], O_live[a], q_bps=q, strict=strict) for a in ASSETS}
tables[(q, strict)] = MEAS
for N in HORIZONS_MIN:
grid_cells += 1
F = np.concatenate([MEAS[a][N]["filled"].values for a in ASSETS])
Ad = np.concatenate([MEAS[a][N]["adverse"].values for a in ASSETS])
W = np.concatenate([O_live[a]["dfrac"].abs().values for a in ASSETS])
D = np.concatenate([per_order_delta(O_live[a], MEAS[a][N], miss_fee_bps=FEE_TAKER_BPS)
for a in ASSETS])
S = weighted_stats(F, Ad, W)
lo, hi = boot_ci(D, W)
ok = "SI" if S["p_fill"] >= S["p_be"] else "no"
print(f" {lab:>10} {N:>7} | {S['p_fill']*100:>7.1f}% {S['p_be']*100:>7.1f}% {ok:>6} | "
f"{S['e_adv']*1e4:>8.0f}b {(Ad[~F].max()*1e4 if (~F).any() else 0):>7.0f}"
f" | {S['cost_bps']:>8.2f} bps | {S['cost_bps']-FEE_TAKER_BPS:>+8.2f} | "
f"[{lo:>+7.2f},{hi:>+7.2f}] bps | {(D>0).mean()*100:>11.1f}%")
print()
# -- la sintesi che non dipende dalle ipotesi di coda
print(" SINTESI — IL SEGNO DEL RISULTATO STA TUTTO IN UNA DISUGUAGLIANZA STRETTA:")
print(f" {'N(min)':>7} | {'TOCCO <= (limite sup.)':>28} | {'ATTRAVERSAMENTO < (minimo difendibile)':>42}")
for N in HORIZONS_MIN:
row = []
for key in ((0.0, False), (0.0, True)):
F = np.concatenate([tables[key][a][N]["filled"].values for a in ASSETS])
Ad = np.concatenate([tables[key][a][N]["adverse"].values for a in ASSETS])
W = np.concatenate([O_live[a]["dfrac"].abs().values for a in ASSETS])
S = weighted_stats(F, Ad, W)
keep = (FEE_TAKER_BPS - S["cost_bps"]) / (FEE_TAKER_BPS - FEE_MAKER_BPS)
row.append(f"P(fill) {S['p_fill']*100:>5.1f}% resta {FEE_TAKER_BPS-S['cost_bps']:>+5.2f} bps"
f" = {keep*100:>6.1f}%")
print(f" {N:>7} | {row[0]:>28} | {row[1]:>42}")
print(" Fra le due colonne cambia UNA cosa: se la barra che apre esattamente al nostro")
print(" prezzo, e da li' se ne va, conta come riempimento. Non conta: al nostro prezzo non")
print(" e' stato scambiato niente dopo che l'ordine e' arrivato. Con la definizione fisica")
print(" il passivo PERDE gia' prima di aggiungere un solo bps di coda.")
# griglia FINE vicino a zero: dove sta il salto? (calcolata qui, non citata a memoria)
print("\n griglia FINE a N=60 (attraversamento), per vedere dove sta il salto:")
fine = []
for q in (0.0, 0.005, 0.05, 0.25, 0.5, 1.0):
num = den = wf = 0.0
for a in ASSETS:
M = measure_orders(cache[a], O_live[a], horizons=(60,), q_bps=q, strict=True)[60]
w = O_live[a]["dfrac"].abs().values
F = M["filled"].values
num += np.sum(w * (np.where(F, FEE_MAKER_BPS, FEE_TAKER_BPS) / 1e4 + M["adverse"].values))
den += w.sum(); wf += w[F].sum()
fine.append((q, wf / den, num / den * 1e4))
print(" " + " ".join(f"q={q:<5.3f} P(fill) {f*100:>4.1f}% costo {c:>5.2f}" for q, f, c in fine))
Ft = np.concatenate([tables[(0.0, False)][a][60]["filled"].values for a in ASSETS])
At = np.concatenate([tables[(0.0, False)][a][60]["adverse"].values for a in ASSETS])
Wt = np.concatenate([O_live[a]["dfrac"].abs().values for a in ASSETS])
touch60 = weighted_stats(Ft, At, Wt)["cost_bps"]
print(f" Il salto e' TUTTO al primo passo (tocco {touch60:.2f} -> attraversamento sub-tick "
f"{fine[1][2]:.2f} bps): non e' la CODA a decidere, e' la DISUGUAGLIANZA. Poi il costo")
print(f" sale piano ({fine[-1][2]:.2f} a q=1 bps) e non torna mai sotto il taker "
f"({FEE_TAKER_BPS:.2f}): non esiste un q positivo di pareggio.")
# -- decomposizione: chi paga il non-fill?
q0, N0 = 2.0, 60
MEAS = tables[(q0, True)]
print(f" DECOMPOSIZIONE ad attraversamento q={q0:.0f} bps, N={N0} min — non tutti gli ordini sono uguali:")
print(f" {'gruppo':<22}{'n':>6}{'P(fill)':>10}{'E[avversa] su TUTTI':>22}{'E[avv|no fill]':>17}")
groups = {}
for a in ASSETS:
O = O_live[a]; M = MEAS[a][N0]
groups.setdefault("SKH (breakout)", []).append((M, (O["src"] == "SKH").values))
groups.setdefault("TP01 (drift 01:00)", []).append((M, (O["src"] == "TP01").values))
groups.setdefault("aumenta esposizione", []).append((M, _increases(O)))
groups.setdefault("riduce esposizione", []).append((M, ~_increases(O)))
for lab, parts in groups.items():
F = np.concatenate([m["filled"].values[k] for m, k in parts])
Ad = np.concatenate([m["adverse"].values[k] for m, k in parts])
if not len(F):
continue
print(f" {lab:<22}{len(F):>6}{F.mean()*100:>9.1f}%{Ad.mean()*1e4:>19.1f} bps"
f"{(Ad[~F].mean()*1e4 if (~F).any() else 0):>13.0f} bps")
print(" NB: un non-fill che RIDUCE l'esposizione lascia la posizione aperta piu' a lungo —")
print(" e' rischio, non solo prezzo, e la media in bps non lo mostra.")
# -- placebo
print(f"\n PLACEBO a q={q0:.0f}, N={N0} — stesse taglie a ORE CASUALI (quanto pesa il")
print(" condizionamento sul segnale? se il placebo e' uguale, il segnale non seleziona nulla):")
grid_cells += 2
for a in ASSETS:
P = paths[a]
k = rng.integers(1, len(P) - 30, size=len(O_live[a]))
Of = pd.DataFrame(dict(ts=P["ts"].values[k], dfrac=O_live[a]["dfrac"].values,
ref=P["ref"].values[k], hidx=P["hidx"].values[k], src=""))
Mf = measure_orders(cache[a], Of, horizons=(N0,), q_bps=q0, strict=True)[N0]
Mr = MEAS[a][N0]
print(f" {a}: reale P(fill) {Mr['filled'].mean()*100:>5.1f}% "
f"E[avversa su tutti] {Mr['adverse'].mean()*1e4:>6.1f} bps"
f" | placebo P(fill) {Mf['filled'].mean()*100:>5.1f}% "
f"E[avversa] {Mf['adverse'].mean()*1e4:>6.1f} bps")
# -- politica "mai inseguire"
print(f"\n POLITICA 'MAI INSEGUIRE' a q={q0:.0f} bps (ri-posta ogni ora, tetto {P2_MAX_HOURS}h):")
grid_cells += 1
P2 = {a: measure_p2(cache[a], O_live[a], paths[a], q_bps=q0, strict=True) for a in ASSETS}
c2 = np.concatenate([P2[a]["cycles"].values for a in ASSETS])
a2 = np.concatenate([P2[a]["adverse"].values for a in ASSETS])
w2 = np.concatenate([O_live[a]["dfrac"].abs().values for a in ASSETS])
ch2 = np.concatenate([P2[a]["chased"].values for a in ASSETS])
cost2 = (np.sum(w2 * (FEE_MAKER_BPS / 1e4 + a2)) / w2.sum()) * 1e4
print(f" cicli orari mediana {np.median(c2):.0f} p90 {np.percentile(c2,90):.0f} "
f"max {c2.max():.0f} | mai riempito entro {P2_MAX_HOURS}h: {ch2.mean()*100:.1f}%")
contrib = w2 * a2
top5 = np.sort(contrib)[-5:].sum() / contrib.sum() if contrib.sum() > 0 else 0.0
print(f" avversa: mediana {np.median(a2)*1e4:+.1f} bps p90 {np.percentile(a2,90)*1e4:+.1f} "
f"MEDIA {a2.mean()*1e4:+.1f} peggiore {a2.max()*1e4:+.0f}")
print(f" -> costo {cost2:.2f} bps/lato ({cost2-FEE_TAKER_BPS:+.2f} contro il taker), e "
f"i 5 ordini peggiori su {len(a2)} fanno il {top5*100:.0f}% del danno totale.")
print(" E' la FIRMA DELLA TRONCATURA, vista dall'altra parte: il passivo vince PICCOLO e")
print(" SPESSO (mediana favorevole) e perde GROSSO e di RADO. Stessa forma del profit-take")
print(" di VRP01 (30/07), dove una regola che scattava sull'86-91% dei vincenti non era")
print(" protezione ma troncatura. Qui la media e' il numero che decide, non la mediana.")
# ---------------------------------------------------------------- 4. impatto sul libro
print("\n" + "-" * 104)
print(" 4. IMPATTO SUL LIBRO (delta per-ordine innestato nei rendimenti giornalieri)")
print("-" * 104)
base = book_daily(cache, FEE_TAKER_BPS)
b = show("TAKER 3,50 bps = il libro di oggi", base)
res = {}
for q, strict in [(0.0, False), (0.0, True), (1.0, True), (2.0, True), (5.0, True)]:
for N in (5, 60, 120):
alt = graft(base, {a: per_order_delta(O_live[a], tables[(q, strict)][a][N],
miss_fee_bps=FEE_TAKER_BPS) for a in ASSETS}, O_live)
tag = "TOCCO<=" if not strict else f"attrav q={q:.0f}"
m = show(f"MAKER {tag:>10} N={N:>3} min, poi market", alt)
res[(tag, N)] = m
alt2 = graft(base, {a: per_order_delta(O_live[a], P2[a], miss_fee_bps=FEE_MAKER_BPS)
for a in ASSETS}, O_live)
m2 = show(f"MAKER 'mai inseguire' q={q0:.0f} bps", alt2)
print("\n delta contro il baseline TAKER (a $635 di conto vero):")
for (q, N), m in res.items():
print(f" {q:>10} N={N:>3} dSharpe {m['sharpe']-b['sharpe']:+.4f} "
f"dHOLD {m['hold']-b['hold']:+.4f} dCAGR {(m['cagr']-b['cagr'])*100:+.3f} pp "
f"dmaxDD {(m['maxdd']-b['maxdd'])*100:+.2f} pp -> ${EQUITY_LIVE*(m['cagr']-b['cagr']):+.2f}/anno")
print(f" {'mai-inseg.':>10} dSharpe {m2['sharpe']-b['sharpe']:+.4f} "
f"dHOLD {m2['hold']-b['hold']:+.4f} dCAGR {(m2['cagr']-b['cagr'])*100:+.3f} pp "
f"dmaxDD {(m2['maxdd']-b['maxdd'])*100:+.2f} pp -> ${EQUITY_LIVE*(m2['cagr']-b['cagr']):+.2f}/anno")
# ---------------------------------------------------------------- 5. robustezza
print("\n" + "-" * 104)
print(" 5. ROBUSTEZZA (banda RIDOTTA: gli ordini spostati di -1h/0/+1h. NON e' la banda a 24")
print(" ancore: qui si sposta il momento dell'ORDINE, non l'ancora della strategia)")
print("-" * 104)
grid_cells += 3
for sh in (-1, 0, 1):
num, den = 0.0, 0.0
for a in ASSETS:
P = paths[a]
pos = {int(v): k for k, v in enumerate(P["hidx"].values)}
kk = np.array([min(max(pos[int(h)] + sh, 0), len(P) - 1) for h in O_live[a]["hidx"]])
Osh = pd.DataFrame(dict(ts=P["ts"].values[kk], dfrac=O_live[a]["dfrac"].values,
ref=P["ref"].values[kk], hidx=P["hidx"].values[kk], src=""))
M = measure_orders(cache[a], Osh, horizons=(N0,), q_bps=q0, strict=True)[N0]
w = Osh["dfrac"].abs().values
num += np.sum(w * (np.where(M["filled"].values, FEE_MAKER_BPS, FEE_TAKER_BPS) / 1e4
+ M["adverse"].values))
den += w.sum()
print(f" ordini spostati di {sh:+d}h costo maker {num/den*1e4:>6.2f} bps/lato "
f"({num/den*1e4-FEE_TAKER_BPS:+.2f} contro il taker)")
print(" -1h e' informativo: sposta l'ordine PRIMA che il segnale sia visibile, cioe' verso")
print(" dove sta l'ingresso VERO di SKH01 (intra-bin, piu' vicino al breakout della lente")
print(" usata qui). La lente di questo script e' GENEROSA verso il maker anche per questo.")
# ---------------------------------------------------------------- 6. ordini di grandezza
print("\n" + "-" * 104)
print(" 6. LA DOMANDA STRUTTURALE — la leva accanto alle altre gia' misurate nel progetto")
print("-" * 104)
bq, bN = max(res, key=lambda k: res[k]["sharpe"])
bm = res[(bq, bN)]
print(f" maker, LIMITE SUPERIORE (0 non-fill) dSharpe {dsh:+.4f} "
f"${EQUITY_LIVE*dcg/100:>+7.2f}/anno a ${EQUITY_LIVE:,.0f} ${5000*dcg/100:>+7.2f} a $5.000")
print(f" maker, migliore cella misurata ({bq},N={bN}) dSharpe {bm['sharpe']-b['sharpe']:+.4f} "
f"${EQUITY_LIVE*(bm['cagr']-b['cagr']):>+7.2f}/anno a ${EQUITY_LIVE:,.0f}")
worst = min(res, key=lambda k: res[k]["sharpe"])
print(f" maker, peggiore cella misurata ({worst[0]},N={worst[1]}) "
f"dSharpe {res[worst]['sharpe']-b['sharpe']:+.4f} "
f"${EQUITY_LIVE*(res[worst]['cagr']-b['cagr']):>+7.2f}/anno")
print(f" peso di SKH01 ottimizzato dSharpe +0.0300 GATE FALLITO (26/07)")
print(f" banda d'ancora del libro (FULL) dSharpe +-0.20 (26/07, 2000 estrazioni)")
print(f" banda d'ancora del libro (HOLD-OUT) 2.36 canonico -> 1.54 onesto (26/07)")
print(f" raddoppio del taker (regola 26/07) dSharpe -0.08 'non si tocca nulla'")
print(f" versare 250 EUR/mese invece di 0 da MAI a 16-20 anni (26-27/07)")
# -- rischio operativo: quanti ordini resterebbero APERTI al giro successivo del cron
print("\n RISCHIO OPERATIVO (non e' un numero di rendimento, e' un modo nuovo di rompersi):")
for key, lab in (((0.0, True), "attraversamento q=0"), ((2.0, True), "attraversamento q=2")):
F = np.concatenate([tables[key][a][60]["filled"].values for a in ASSETS])
print(f" {lab:<22} N=60 min: {(1-F.mean())*100:>4.1f}% degli ordini ancora APERTI al giro")
print(f" {'':<22} successivo = {(1-F.mean())*n_live/yrs:>4.1f} volte l'anno in cui l'esecutore")
print(f" {'':<22} deve cancellare-e-rimpiazzare correttamente, su un conto vero.")
print(" Oggi `rebalance_signed` manda market e finisce: nessun ordine sopravvive al giro.")
print(" Un limit resting introduce ordini orfani e doppi fill, e il libro ha gia' pagato")
print(" il prezzo di un percorso che si rompe in silenzio (`fresh_5m`, 26-29/07).")
print("\n " + "=" * 100)
print(" VERDETTO: SCARTATO. Il differenziale c'e' (2,00 bps/lato = +0,033 Sharpe = $2,96/anno")
print(" a $635) ma e' il TETTO, e la selezione avversa se lo mangia tutto gia' alla condizione")
print(" di riempimento piu' generosa che sia fisicamente difendibile. Il segno del risultato")
print(" dipende da una disuguaglianza stretta (`<=` contro `<`), il che e' un altro modo di")
print(" dire che l'effetto e' dentro il rumore: l'IC95 del delta contiene lo zero in 10 celle")
print(" su 10 fra tocco e attraversamento-q=0. E il premio massimo immaginabile ($2,96/anno)")
print(" e' un ordine di grandezza sotto la leva piu' piccola gia' respinta dal progetto.")
print(" " + "=" * 100)
print(f"\n GRIGLIA DICHIARATA: {grid_cells} celle valutate "
f"(({len(QUEUE_BPS)}q attrav. + 1 tocco) x {len(HORIZONS_MIN)}N + placebo x2 + "
f"mai-inseguire + 3 shift + 6 celle di griglia fine) "
f"+ {len(FEE_GRID_BPS)} punti di fee.")
print("=" * 104)
def _increases(O: pd.DataFrame) -> np.ndarray:
"""True se l'ordine AUMENTA il valore assoluto dell'esposizione (entrata/incremento)."""
d = O["dfrac"].values
held = np.zeros(len(d))
acc = 0.0
for i, x in enumerate(d):
held[i] = acc
acc += x
return np.abs(held + d) > np.abs(held)
if __name__ == "__main__":
main()
+716
View File
@@ -0,0 +1,716 @@
"""SURFACE-RV — la superficie di volatilita' come RELATIVE VALUE fra i suoi punti.
IPOTESI (registrata prima di misurare): la superficie Deribit e' internamente INCOERENTE in modo
misurabile — strike adiacenti fuori da una curva liscia, varianza totale non monotona nel tenore —
e l'incoerenza si ricompone. E' un'ipotesi di MICROSTRUTTURA, non di previsione: non chiede alla
vol implicita di prevedere nulla (cosa che il progetto ha gia' refutato quattro volte: dvol_directional,
gamma scalping, term-structure come segnale, skew come segnale).
ATTESA DICHIARATA PRIMA: l'incoerenza esiste al MID ma non sopravvive al bid-ask, perche' su
un'opzione a 7 giorni un punto di vol vale pochi dollari mentre attraversare lo spread ne costa
il ~10% del premio (misurato dal filone 11 sulle inverse).
LA DIFESA CONTRO IL RISCHIO NUMERO UNO DI QUESTO FILONE (misurare il proprio errore di
interpolazione) e' strutturale, non statistica: i due test principali sono **arbitraggi statici
model-free** — monotonia e convessita' del prezzo nello strike — che non richiedono NESSUN
interpolatore, NESSUN modello e NESSUN tasso. Valgono per ogni misura di prezzo, path per path:
* put: P(K) crescente in K (verticale)
* put: P(K) convessa in K (farfalla)
e sono verificati su prezzi ESEGUIBILI (si vende al bid, si compra all'ask), mai al mid.
Il terzo test — varianza totale non monotona nel tenore — richiede di confrontare tenori diversi:
si riporta sia nella versione SENZA interpolazione (strike condiviso fra due scadenze) sia in
quella con interpolazione a log-moneyness fisso, e la DIFFERENZA fra le due e' la misura diretta
di quanto pesa l'interpolatore.
Convenzioni verificate nel codice (non assunte):
* tick: 0.0001 coin sotto 0.005, 0.0005 sopra -> contato, non dichiarato
* f_markfit: la mark IV del venue riprezza i propri mid? (il filone 5 misura 0.992)
* fee Deribit opzioni: 0.03% del sottostante per gamba, cap 12.5% del premio della gamba
Il dato: `data/raw/cb_chain/` (archivio bite + raccolta propria). SOLO opzioni INVERSE — il
collettore interroga {"currency": "BTC"|"ETH"}, che non restituisce la famiglia USDC-lineare
(difetto di produzione trovato dal filone 11). Quindi ogni numero qui e' misurato sulla famiglia
che il conto NON puo' marginare, e le conclusioni sulla famiglia USDC sono ESTRAPOLAZIONI
dichiarate, non misure.
Muro del dato: prima del 2026-06-09 l'archivio raccoglieva UNA scadenza per giro -> la SUPERFICIE
esiste da li' (~75 giorni). Lo SMILE (piu' strike su UNA scadenza) esiste dal 2026-05-01 (~113 g).
Verdetto massimo possibile: LEAD con gate pre-registrato. Mai CANDIDATO.
uv run python scripts/research/r0822b_surface_rv.py
"""
from __future__ import annotations
import glob
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
from scipy.stats import norm
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
STORE = ROOT / "data" / "raw" / "cb_chain"
SMILE_START = pd.Timestamp("2026-05-01", tz="UTC") # piu' strike su UNA scadenza
SURF_START = pd.Timestamp("2026-06-09", tz="UTC") # piu' scadenze per giro -> superficie
FEE_UNDERLYING = 0.0003 # 0.03% del sottostante, per gamba, in coin per contratto
FEE_CAP_FRAC = 0.125 # cap: 12.5% del premio della gamba
MIN_LOT = {"BTC": 0.1, "ETH": 1.0} # famiglia INVERSE (quella che abbiamo nel dato)
MIN_LOT_USDC = {"BTC": 0.01, "ETH": 0.1} # famiglia USDC-lineare (non raccolta: estrapolazione)
DTE_MIN = 1.0 # sotto un giorno il book e' un'altra cosa; riportato anche senza filtro
def tick_of(px: np.ndarray) -> np.ndarray:
"""Griglia di prezzo Deribit sulle opzioni, in coin. Verificata sui dati in `certifica()`."""
return np.where(px < 0.005, 0.0001, 0.0005)
# --------------------------------------------------------------------------- dato
def load(assets=("BTC", "ETH")) -> pd.DataFrame:
cols = ["ts", "asset", "instrument_name", "strike", "option_type", "exp",
"bid", "ask", "mid", "iv", "delta", "open_interest", "book_depth_top3",
"underlying_price", "index_price", "quote_status"]
parts = []
for f in sorted(glob.glob(str(STORE / "*.parquet"))):
d = pd.read_parquet(f, columns=cols)
parts.append(d[d["asset"].isin(assets)])
d = pd.concat(parts, ignore_index=True)
d["ts"] = pd.to_datetime(d["ts"], utc=True)
d["exp"] = pd.to_datetime(d["exp"], utc=True)
for c in ("strike", "bid", "ask", "mid", "iv", "delta", "open_interest",
"book_depth_top3", "underlying_price", "index_price"):
d[c] = pd.to_numeric(d[c], errors="coerce")
d = d.drop_duplicates(subset=["ts", "instrument_name"], keep="last")
d["dte"] = (d["exp"] - d["ts"]).dt.total_seconds() / 86400.0
d["T"] = d["dte"] / 365.25
d = d.reset_index(drop=True)
d["spot_feed"] = _spot_asof(d)
return d
def _spot_asof(d: pd.DataFrame) -> pd.Series:
"""Indice dal feed CERTIFICATO (1h Deribit mainnet), asof sul ts della quota.
Serve perche' `bite_archive` NON ha ne' `index_price` ne' `underlying_price` (100% None:
misurato, non assunto) -> senza questo la finestra con un divisore sarebbe di 23 giorni.
"""
from scripts.analysis.research_lab import load_tf
out = pd.Series(np.nan, index=d.index)
for a in d["asset"].unique():
px = load_tf(a, "1h")
s = pd.Series(px["close"].to_numpy(float),
index=pd.to_datetime(px["timestamp"], unit="ms", utc=True)).sort_index()
s.index = pd.DatetimeIndex(s.index).as_unit("ns")
m = d["asset"] == a
t = pd.DatetimeIndex(d.loc[m, "ts"]).as_unit("ns")
# epoca esplicita in ns (mai `.view("int64")` su indici tz-aware: lezione 01/07)
si, ti = s.index.asi8, t.asi8
pos = np.searchsorted(si, ti, side="right") - 1
v = np.where(pos >= 0, s.to_numpy()[np.clip(pos, 0, None)], np.nan)
out.loc[m] = v
return out
def forward(d: pd.DataFrame) -> pd.DataFrame:
"""Forward per (asset, ts, exp) ricostruito con la PARITA' PUT-CALL, model-free.
In convenzione inverse il premio e' quotato in coin: (C - P) * X = F - K => F = K + (C-P)*X,
con X = divisore della convenzione (misurato in `certifica`). Mediana sugli strike = robusta.
Dove il venue lo ha registrato (solo la raccolta propria) si usa il valore OSSERVATO, e
l'accordo fra i due e' una certificazione, non un'assunzione.
"""
q = d[(d["bid"] > 0) & (d["ask"] > 0) & (d["ask"] >= d["bid"]) & d["spot_feed"].notna()]
c = q[q["option_type"] == "C"][["asset", "ts", "exp", "strike", "mid", "spot_feed"]]
p = q[q["option_type"] == "P"][["asset", "ts", "exp", "strike", "mid"]]
j = c.merge(p, on=["asset", "ts", "exp", "strike"], suffixes=("_c", "_p"))
j["F_k"] = j["strike"] + (j["mid_c"] - j["mid_p"]) * j["spot_feed"]
f = j.groupby(["asset", "ts", "exp"])["F_k"].median().rename("F_pcp").reset_index()
return f
def two_sided(d: pd.DataFrame) -> pd.DataFrame:
"""Quotate a DUE lati. Una gamba sola quotata non e' meta' struttura: e' un'altra cosa.
(Regola 8 del brief: una riga presente non e' un dato presente.)
"""
q = d[(d["bid"] > 0) & (d["ask"] > 0) & (d["ask"] >= d["bid"])].copy()
q = q[q["T"] > 0]
return q
# --------------------------------------------------------------------------- prezzo
def bs_usd(F, K, T, sig, is_put):
"""Black-76 con r=0 (Deribit prezza sul FORWARD della scadenza = `underlying_price`)."""
F = np.asarray(F, float); K = np.asarray(K, float)
T = np.asarray(T, float); sig = np.asarray(sig, float)
out = np.full(F.shape, np.nan)
ok = (T > 0) & (sig > 0) & (F > 0) & (K > 0)
if not ok.any():
return out
d1 = np.zeros_like(F); d2 = np.zeros_like(F)
sq = np.sqrt(np.where(ok, T, 1.0))
d1[ok] = (np.log(F[ok] / K[ok]) + 0.5 * sig[ok] ** 2 * T[ok]) / (sig[ok] * sq[ok])
d2[ok] = d1[ok] - sig[ok] * sq[ok]
call = F * norm.cdf(d1) - K * norm.cdf(d2)
put = K * norm.cdf(-d2) - F * norm.cdf(-d1)
out[ok] = np.where(is_put[ok], put[ok], call[ok])
return out
def bs_vega_usd(F, K, T, sig):
F = np.asarray(F, float); K = np.asarray(K, float)
T = np.asarray(T, float); sig = np.asarray(sig, float)
out = np.zeros(F.shape)
ok = (T > 0) & (sig > 0) & (F > 0) & (K > 0)
sq = np.sqrt(np.where(ok, T, 1.0))
d1 = np.zeros_like(F)
d1[ok] = (np.log(F[ok] / K[ok]) + 0.5 * sig[ok] ** 2 * T[ok]) / (sig[ok] * sq[ok])
out[ok] = F[ok] * norm.pdf(d1[ok]) * sq[ok] # per 1.00 di sigma (=100 punti di vol)
return out
# --------------------------------------------------------------------------- certificazione
def certifica(d: pd.DataFrame) -> pd.DataFrame:
print("=" * 100)
print("0. CERTIFICAZIONE DEL DATO — cosa esiste davvero, prima di qualunque test")
print("=" * 100)
q = two_sided(d)
print(f"righe totali {len(d):,} · quotate a due lati {len(q):,} ({len(q)/len(d):.1%})")
print(f"finestra {d['ts'].min():%Y-%m-%d} -> {d['ts'].max():%Y-%m-%d}")
# (a) muro del dato: scadenze per giro
per = d.groupby([d["ts"].dt.floor("D"), "asset"])["exp"].nunique().unstack()
pre = per[per.index < SURF_START]
post = per[per.index >= SURF_START]
print("\n(a) MURO DEL DATO — scadenze per giro (mediana per giorno):")
print(f" pre {SURF_START:%Y-%m-%d}: BTC {pre['BTC'].median():.0f} · ETH {pre['ETH'].median():.0f}"
f" (max BTC {pre['BTC'].max():.0f}) -> la SUPERFICIE non esiste")
print(f" post {SURF_START:%Y-%m-%d}: BTC {post['BTC'].median():.0f} · ETH {post['ETH'].median():.0f}"
f" -> superficie su {len(post)} giorni")
print(f" smile (piu' strike, UNA scadenza) su {len(per)} giorni dal {per.index.min():%Y-%m-%d}"
f" -> i test model-free girano su questa finestra piu' lunga")
# (a-bis) BUCO DI COLONNA nell'archivio ereditato
obs = d["index_price"].notna()
print("\n(a-bis) BUCO DI COLONNA — `bite_archive` non registrava il sottostante")
print(f" index_price/underlying_price presenti in {obs.sum():,}/{len(d):,} righe ({obs.mean():.1%})"
f" — solo la raccolta propria, dal {d.loc[obs,'ts'].min():%Y-%m-%d}")
print(f" book_depth_top3 presente in {d['book_depth_top3'].notna().mean():.1%} delle righe")
print(" -> i test 1-2 (model-free) NON ne hanno bisogno; il test 4 (calendario) si')")
# (b) tick: verificato, non dichiarato
print("\n(b) GRIGLIA DI PREZZO (verificata sui dati, non assunta)")
for a in sorted(q["asset"].unique()):
b = q.loc[q["asset"] == a, "bid"].to_numpy()
t = tick_of(b)
off = np.abs(np.round(b / t) - b / t) > 1e-6
n_tick = np.round(b / t)
print(f" {a}: fuori griglia {off.sum():,}/{len(b):,} ({off.mean():.4%}) · "
f"bid in tick: mediana {np.median(n_tick):.0f} · quota <=2 tick {np.mean(n_tick <= 2):.1%}")
# (c) il divisore della convenzione inverse + f_markfit
print("\n(c) CONVENZIONE INVERSE e f_markfit — la mark IV riprezza i propri mid?")
ov = q[obs.reindex(q.index, fill_value=False)].dropna(subset=["iv", "mid", "spot_feed"])
ov = ov[(ov["iv"] > 0) & (ov["mid"] > 0) & (ov["dte"] >= DTE_MIN)]
dev = (ov["index_price"] / ov["spot_feed"] - 1).abs()
print(f" index_price della catena vs feed CERTIFICATO 1h: |dev| mediana {dev.median():.5%} "
f"· p99 {dev.quantile(.99):.4%} (n={len(ov):,}) -> il feed puo' fare da divisore sull'archivio")
sm = ov.sample(n=min(120_000, len(ov)), random_state=0)
is_put = (sm["option_type"] == "P").to_numpy()
usd = bs_usd(sm["underlying_price"].to_numpy(), sm["strike"].to_numpy(),
sm["T"].to_numpy(), (sm["iv"] / 100).to_numpy(), is_put)
best = None
for nome, div in (("index_price", sm["index_price"].to_numpy()),
("underlying_price(fwd)", sm["underlying_price"].to_numpy()),
("feed certificato 1h", sm["spot_feed"].to_numpy())):
f = (usd / div) / sm["mid"].to_numpy()
f = f[np.isfinite(f) & (f > 0)]
print(f" f_markfit con divisore {nome:<22}: mediana {np.median(f):.4f} "
f"[p10 {np.percentile(f,10):.3f}, p90 {np.percentile(f,90):.3f}] n={len(f):,}")
if best is None or abs(np.median(f) - 1) < abs(best[1] - 1):
best = (nome, float(np.median(f)))
print(f" -> convenzione: divisore = {best[0]} (f_markfit {best[1]:.4f}); "
f"un f molto lontano da 1 direbbe che il problema e' il MIO prezzatore, non il mercato")
# (d) forward ricostruito con la parita' put-call, certificato contro l'osservato
print("\n(d) FORWARD ricostruito con la PARITA' PUT-CALL (model-free) vs osservato")
F = forward(d)
chk = d.loc[obs, ["asset", "ts", "exp", "underlying_price"]].drop_duplicates(
subset=["asset", "ts", "exp"]).merge(F, on=["asset", "ts", "exp"], how="inner")
e = (chk["F_pcp"] / chk["underlying_price"] - 1)
print(f" n={len(chk):,} coppie (asset,ts,scadenza) con entrambi: "
f"|errore| mediana {e.abs().median():.5%} · p95 {e.abs().quantile(.95):.4%} · "
f"corr {np.corrcoef(chk['F_pcp'], chk['underlying_price'])[0,1]:.6f}")
return q, F
# --------------------------------------------------------------------------- fee
def fee_coin(premio_coin: np.ndarray, n_contratti: float) -> np.ndarray:
"""Fee Deribit opzioni per gamba, in coin: min(0.03% del sottostante, 12.5% del premio) x lotti."""
per_contratto = np.minimum(FEE_UNDERLYING, FEE_CAP_FRAC * np.asarray(premio_coin, float))
return per_contratto * n_contratti
# --------------------------------------------------------------------------- TEST V
def test_verticale(q: pd.DataFrame, dte_min: float) -> dict:
"""Monotonia del prezzo nello strike. Model-free, nessun interpolatore, nessun tasso.
put: P(K) crescente -> arbitraggio se bid(K1) > ask(K2) con K1 < K2
(vendi la put bassa al bid, compra l'alta all'ask: credito + payoff sempre >= 0)
call: C(K) decrescente -> arbitraggio se bid(K2) > ask(K1) con K1 < K2
"""
x = q[q["dte"] >= dte_min].sort_values(["asset", "ts", "exp", "option_type", "strike"])
key = ["asset", "ts", "exp", "option_type"]
g = x.groupby(key, sort=False)
# per le put il "meglio finora" salendo di strike; per le call scendendo
x["cmax_up"] = g["bid"].cummax()
x["prev_up"] = g["cmax_up"].shift(1) # miglior bid su strike STRETTAMENTE inferiori
xr = x.iloc[::-1].copy()
gr = xr.groupby(key, sort=False)
xr["cmax_dn"] = gr["bid"].cummax()
x["prev_dn"] = gr["cmax_dn"].shift(1) # miglior bid su strike STRETTAMENTE superiori
is_put = x["option_type"].to_numpy() == "P"
prev = np.where(is_put, x["prev_up"].to_numpy(), x["prev_dn"].to_numpy())
cred = prev - x["ask"].to_numpy() # credito riskless in coin, per contratto
viol = np.isfinite(cred) & (cred > 0)
n_tot = len(x)
out = {"n_confronti": int(np.isfinite(cred).sum()), "n_viol": int(viol.sum()),
"quota": float(viol.sum() / max(1, np.isfinite(cred).sum()))}
if viol.any():
vv = x.loc[viol]
out["credito_med_coin"] = float(np.median(cred[viol]))
out["credito_usd_medlot"] = float(np.median(
cred[viol] * vv["asset"].map(MIN_LOT).to_numpy() * vv["spot_feed"].to_numpy()))
return out
# --------------------------------------------------------------------------- TEST B
def test_farfalla(q: pd.DataFrame, dte_min: float) -> pd.DataFrame:
"""Convessita' del prezzo nello strike su triple CONSECUTIVE. Model-free.
lam = (K3-K2)/(K3-K1); payoff lam*P(K1) + (1-lam)*P(K3) - P(K2) >= 0 sempre.
Costo eseguibile = lam*ask1 + (1-lam)*ask3 - bid2. Se < 0 -> credito riskless.
Al MID lo stesso numero misura l'INCOERENZA (non eseguibile) — si riportano entrambi.
"""
x = q[q["dte"] >= dte_min].sort_values(["asset", "ts", "exp", "option_type", "strike"])
key = ["asset", "ts", "exp", "option_type"]
g = x.groupby(key, sort=False)
for c in ("strike", "bid", "ask", "mid", "iv", "book_depth_top3"):
x[c + "_p"] = g[c].shift(1)
x[c + "_n"] = g[c].shift(-1)
x = x.dropna(subset=["strike_p", "strike_n", "bid_p", "bid_n", "ask_p", "ask_n"])
K1, K2, K3 = x["strike_p"].to_numpy(), x["strike"].to_numpy(), x["strike_n"].to_numpy()
lam = (K3 - K2) / (K3 - K1)
x["lam"] = lam
# eseguibile: compro le ali all'ask, vendo il corpo al bid
x["cost_exec"] = lam * x["ask_p"] + (1 - lam) * x["ask_n"] - x["bid"]
# incoerenza al mid (non eseguibile, ma e' la lente "la superficie e' liscia?")
x["cost_mid"] = lam * x["mid_p"] + (1 - lam) * x["mid_n"] - x["mid"]
# la stessa cosa in punti di vol: residuo di convessita' locale dello smile
x["iv_resid"] = x["iv"] - (lam * x["iv_p"] + (1 - lam) * x["iv_n"])
return x
# --------------------------------------------------------------------------- vol points
def in_punti_vol(x: pd.DataFrame) -> pd.DataFrame:
"""Mezzo spread denaro-lettera convertito in punti di vol via vega BS (r=0, forward)."""
vega_usd = bs_vega_usd(x["F"].to_numpy(), x["strike"].to_numpy(),
x["T"].to_numpy(), (x["iv"] / 100).to_numpy())
vega_coin_per_pt = vega_usd / x["spot_feed"].to_numpy() / 100.0
x = x.copy()
x["vega_coin_pt"] = vega_coin_per_pt
x["halfspread_pt"] = (x["ask"] - x["bid"]) / 2.0 / np.clip(vega_coin_per_pt, 1e-12, None)
x["relspread"] = (x["ask"] - x["bid"]) / x["mid"]
# quanto vale UN TICK in punti di vol: se il tick e' piu' grosso dell'incoerenza,
# l'incoerenza non e' esprimibile nella griglia di prezzo del venue.
x["tick_pt"] = tick_of(x["mid"].to_numpy()) / np.clip(vega_coin_per_pt, 1e-12, None)
return x
# --------------------------------------------------------------------------- TEST C
def test_calendario(q: pd.DataFrame, dte_min: float, ogni: int = 4) -> dict:
"""Varianza totale w = iv^2 * T: deve CRESCERE col tenore. Due lenti, e la loro differenza.
(i) STRIKE CONDIVISO fra due scadenze adiacenti: nessuna interpolazione.
(ii) LOG-MONEYNESS FISSO k = ln(K/F): richiede di interpolare lo smile lungo in k.
La differenza fra (i) e (ii) e' la misura diretta del contributo dell'INTERPOLATORE.
"""
x = q[(q["dte"] >= dte_min) & q["iv"].notna() & (q["iv"] > 0) & q["F"].notna()].copy()
x = x[x["ts"] >= SURF_START]
tenuti = sorted(x["ts"].unique())[::ogni] # (i) e (ii) sugli STESSI snapshot: appaiato
x = x[x["ts"].isin(tenuti)]
x["w"] = (x["iv"] / 100.0) ** 2 * x["T"]
x["k"] = np.log(x["strike"] / x["F"])
# --- (i) strike condiviso, adiacenti nel tempo
x = x.sort_values(["asset", "ts", "option_type", "strike", "exp"])
key = ["asset", "ts", "option_type", "strike"]
g = x.groupby(key, sort=False)
nxt_w = g["w"].shift(-1).to_numpy()
nxt_T = g["T"].shift(-1).to_numpy()
nxt_iv = g["iv"].shift(-1).to_numpy()
ok = np.isfinite(nxt_w) & (nxt_T > x["T"].to_numpy())
dw = nxt_w - x["w"].to_numpy()
viol_i = ok & (dw < 0)
# ampiezza in punti di vol: quanto dovrebbe salire l'iv lunga per ripristinare la monotonia
iv_need = np.sqrt(np.maximum(x["w"].to_numpy(), 0) / np.maximum(nxt_T, 1e-9)) * 100.0
amp_pt = np.where(ok, iv_need - nxt_iv, np.nan)
res = {"i_n": int(ok.sum()), "i_viol": int(viol_i.sum()),
"i_quota": float(viol_i.sum() / max(1, ok.sum())),
"i_amp_med_pt": float(np.nanmedian(amp_pt[viol_i])) if viol_i.any() else np.nan,
"i_amp_p95_pt": float(np.nanpercentile(amp_pt[viol_i], 95)) if viol_i.any() else np.nan}
# --- (ii) log-moneyness fisso, con interpolazione
y = x
n_par, n_vio, amps = 0, 0, []
for (a, ts, ot), gg in y.groupby(["asset", "ts", "option_type"], sort=False):
exps = sorted(gg["exp"].unique())
if len(exps) < 2:
continue
for e1, e2 in zip(exps[:-1], exps[1:]):
s1 = gg[gg["exp"] == e1].sort_values("k")
s2 = gg[gg["exp"] == e2].sort_values("k")
if len(s1) < 2 or len(s2) < 2:
continue
k1 = s1["k"].to_numpy()
inside = (k1 >= s2["k"].min()) & (k1 <= s2["k"].max()) # MAI estrapolare
if not inside.any():
continue
iv2 = np.interp(k1[inside], s2["k"].to_numpy(), s2["iv"].to_numpy())
T2 = float(s2["T"].iloc[0])
w2 = (iv2 / 100.0) ** 2 * T2
w1 = s1["w"].to_numpy()[inside]
n_par += int(inside.sum())
bad = w2 < w1
n_vio += int(bad.sum())
if bad.any():
need = np.sqrt(w1[bad] / T2) * 100.0
amps.append(need - iv2[bad])
amps = np.concatenate(amps) if amps else np.array([])
res.update({"ii_n": n_par, "ii_viol": n_vio, "ii_quota": n_vio / max(1, n_par),
"ii_amp_med_pt": float(np.median(amps)) if len(amps) else np.nan,
"ii_ogni": ogni})
# valore in dollari dell'ampiezza (i) al lotto minimo, contro il costo di 2 gambe
vv = x.loc[viol_i].copy()
if len(vv):
vv = in_punti_vol(vv)
amp = pd.Series(amp_pt, index=x.index)[viol_i].to_numpy()
lot = vv["asset"].map(MIN_LOT).to_numpy()
val = np.abs(amp) * vv["vega_coin_pt"].to_numpy() * lot * vv["spot_feed"].to_numpy()
costo = ((vv["ask"] - vv["bid"]).to_numpy() * lot * vv["spot_feed"].to_numpy()
+ 2 * fee_coin(vv["mid"].to_numpy(), lot) * vv["spot_feed"].to_numpy())
res.update({"i_val_usd_med": float(np.nanmedian(val)),
"i_costo_usd_med": float(np.nanmedian(costo)),
"i_n_val": int(np.isfinite(val).sum())})
return res
# --------------------------------------------------------------------------- economia
def economia(bf: pd.DataFrame, asset: str) -> dict:
"""Quanto vale, in dollari, l'incoerenza di una farfalla al lotto minimo — netto fee.
Struttura a 3 gambe, lotti INTERI: con strike equispaziati e' 1:2:1 sul lotto minimo.
Si conta la fee gamba per gamba col cap sul premio, e si usa il prezzo ESEGUIBILE.
"""
x = bf[bf["asset"] == asset]
lot = MIN_LOT[asset]
S = x["spot_feed"].to_numpy()
# credito lordo della farfalla eseguibile, in coin per unita' di "corpo"
cred = -x["cost_exec"].to_numpy() # >0 = credito riskless
# lotti interi: corpo 2*lot, ali lot ciascuna (equivale a lam=0.5 -> solo strike equispaziati)
eq = np.isclose(x["lam"].to_numpy(), 0.5, atol=0.02)
cred_eq_coin = (x["bid"].to_numpy() * 2 * lot
- x["ask_p"].to_numpy() * lot - x["ask_n"].to_numpy() * lot)
f = (fee_coin(x["bid"].to_numpy(), 2 * lot)
+ fee_coin(x["ask_p"].to_numpy(), lot) + fee_coin(x["ask_n"].to_numpy(), lot))
netto_usd = (cred_eq_coin - f) * S
return {
"asset": asset, "n": int(len(x)), "n_equisp": int(eq.sum()),
"cred_lordo_p99_coin": float(np.nanpercentile(cred, 99)),
"fee_3gambe_usd_med": float(np.nanmedian(f[eq] * S[eq])) if eq.any() else np.nan,
"netto_usd_max": float(np.nanmax(netto_usd[eq])) if eq.any() else np.nan,
"netto_usd_p99": float(np.nanpercentile(netto_usd[eq], 99)) if eq.any() else np.nan,
"n_netto_pos": int(np.nansum(netto_usd[eq] > 0)),
"nozionale_min_lotto_usd": float(np.nanmedian(S) * lot),
}
# --------------------------------------------------------------------------- persistenza
def persistenza(bf: pd.DataFrame, viol_col: str) -> dict:
"""Una violazione che sparisce all'ora dopo e' un guizzo di book, non un'opportunita'.
Chiave = (asset, exp, tipo, tripla di strike). Si guarda se la stessa tripla e' ancora in
violazione allo snapshot successivo IN CUI LA TRIPLA E' QUOTATA (non 'l'ora dopo' in astratto).
"""
v = bf[bf[viol_col]].copy()
if v.empty:
return {"n": 0}
bf = bf.copy()
bf["trip"] = (bf["asset"] + "|" + bf["exp"].astype(str) + "|" + bf["option_type"] + "|"
+ bf["strike_p"].astype(str) + "|" + bf["strike"].astype(str) + "|"
+ bf["strike_n"].astype(str))
bf = bf.sort_values(["trip", "ts"])
nxt = bf.groupby("trip", sort=False)[viol_col].shift(-1)
m = bf[viol_col] & nxt.notna()
return {"n": int(m.sum()), "resta": float(nxt[m].mean()) if m.any() else np.nan}
# --------------------------------------------------------------------------- controlli positivi
def _finto(rows) -> pd.DataFrame:
"""Catena sintetica minima per i controlli positivi."""
d = pd.DataFrame(rows)
d["ts"] = pd.to_datetime(d["ts"], utc=True)
d["exp"] = pd.to_datetime(d["exp"], utc=True)
d["dte"] = (d["exp"] - d["ts"]).dt.total_seconds() / 86400.0
d["T"] = d["dte"] / 365.25
d["mid"] = (d["bid"] + d["ask"]) / 2
for c, v in (("spot_feed", 100_000.0), ("F", 100_000.0), ("option_type", "P"),
("open_interest", 1000.0), ("book_depth_top3", 10.0), ("delta", -0.2)):
if c not in d:
d[c] = v
return d
def controlli_positivi() -> None:
"""Un rilevatore tarato per non segnalare e' indistinguibile da uno rotto.
Regola gia' codificata nel progetto (venue_watch, 26/07). Qui i tre test vengono puntati
su violazioni COSTRUITE: se non le vedono, i loro zeri non valgono niente.
"""
print("=" * 100)
print("CONTROLLI POSITIVI — i tre rilevatori vedono una violazione COSTRUITA?")
print("=" * 100)
base = dict(ts="2026-08-01T00:00Z", exp="2026-08-15T08:00Z", asset="BTC",
option_type="P", iv=50.0, instrument_name="x")
# (1) verticale: put a strike BASSO con bid > ask della put a strike ALTO
v_ok = _finto([{**base, "strike": 90000, "bid": 0.0300, "ask": 0.0305},
{**base, "strike": 95000, "bid": 0.0200, "ask": 0.0205}])
v_no = _finto([{**base, "strike": 90000, "bid": 0.0200, "ask": 0.0205},
{**base, "strike": 95000, "bid": 0.0300, "ask": 0.0305}])
r_ok, r_no = test_verticale(v_ok, 0.0), test_verticale(v_no, 0.0)
print(f" TEST V: catena INVERTITA -> {r_ok['n_viol']} violazioni (attese >=1) · "
f"catena sana -> {r_no['n_viol']} (attese 0)")
# (2) farfalla: corpo troppo caro (concavita')
b_ok = _finto([{**base, "strike": 90000, "bid": 0.0100, "ask": 0.0105},
{**base, "strike": 95000, "bid": 0.0400, "ask": 0.0405},
{**base, "strike": 100000, "bid": 0.0500, "ask": 0.0505}])
b_no = _finto([{**base, "strike": 90000, "bid": 0.0100, "ask": 0.0105},
{**base, "strike": 95000, "bid": 0.0280, "ask": 0.0285},
{**base, "strike": 100000, "bid": 0.0500, "ask": 0.0505}])
f_ok = test_farfalla(b_ok, 0.0); f_no = test_farfalla(b_no, 0.0)
print(f" TEST B: corpo troppo caro -> {(f_ok['cost_exec'] < 0).sum()} violazioni eseguibili "
f"(attese >=1) · smile convesso -> {(f_no['cost_exec'] < 0).sum()} (attese 0)")
# (3) calendario: varianza totale che SCENDE col tenore, stesso strike
c_rows = []
for e, iv in (("2026-08-08T08:00Z", 80.0), ("2026-08-15T08:00Z", 40.0)):
c_rows.append({**base, "exp": e, "strike": 100000, "bid": 0.01, "ask": 0.011, "iv": iv})
c_ok = _finto(c_rows)
c_ok["ts"] = pd.Timestamp("2026-07-01", tz="UTC")
c_no = c_ok.copy(); c_no["iv"] = [40.0, 80.0]
r1 = test_calendario(c_ok, 0.0, ogni=1); r2 = test_calendario(c_no, 0.0, ogni=1)
print(f" TEST C: varianza totale DECRESCENTE -> {r1['i_viol']} violazioni (attese >=1) · "
f"crescente -> {r2['i_viol']} (attese 0)")
assert r_ok["n_viol"] >= 1 and r_no["n_viol"] == 0
assert (f_ok["cost_exec"] < 0).sum() >= 1 and (f_no["cost_exec"] < 0).sum() == 0
assert r1["i_viol"] >= 1 and r2["i_viol"] == 0
print(" -> 3/3 rilevatori validati in ENTRAMBI i versi: gli zeri dei test veri sono zeri.\n")
# --------------------------------------------------------------------------- reversione
def reversione(bf: pd.DataFrame, max_gap_h: float = 2.0) -> dict:
"""Il residuo di convessita' si RICOMPONE? (l'ipotesi del filone, misurata)
Coppie appaiate sulla STESSA tripla di strike a snapshot consecutivi entro `max_gap_h`.
beta di resid[t+1] - resid[t] su resid[t] : beta = -1 -> ricomposizione totale in un'ora,
beta = 0 -> il residuo e' una proprieta' persistente dello smile (skew), non un errore.
"""
x = bf.dropna(subset=["iv_resid"]).copy()
x["trip"] = (x["asset"] + "|" + x["exp"].astype(str) + "|" + x["option_type"] + "|"
+ x["strike_p"].astype(str) + "|" + x["strike"].astype(str) + "|"
+ x["strike_n"].astype(str))
x = x.sort_values(["trip", "ts"])
g = x.groupby("trip", sort=False)
r1 = g["iv_resid"].shift(-1).to_numpy()
dt = (g["ts"].shift(-1) - x["ts"]).dt.total_seconds().to_numpy() / 3600.0
r0 = x["iv_resid"].to_numpy()
m = np.isfinite(r1) & np.isfinite(r0) & (dt > 0) & (dt <= max_gap_h)
if m.sum() < 100:
return {"n": int(m.sum())}
b = np.polyfit(r0[m], r1[m] - r0[m], 1)[0]
return {"n": int(m.sum()), "beta": float(b), "corr": float(np.corrcoef(r0[m], r1[m])[0, 1]),
"meta_vita_h": float(np.log(0.5) / np.log(max(1e-9, 1 + b))) if -1 < b < 0 else np.nan}
# --------------------------------------------------------------------------- main
def main() -> None:
t0 = time.time()
controlli_positivi()
d = load()
q, F = certifica(d)
q = q.merge(F, on=["asset", "ts", "exp"], how="left")
q["F"] = q["underlying_price"].fillna(q["F_pcp"])
print(f"\n forward disponibile su {q['F'].notna().mean():.1%} delle quote a due lati "
f"(osservato dove c'e', altrimenti parita' put-call)")
for dte_min, etichetta in ((0.0, "SENZA filtro DTE"), (DTE_MIN, f"DTE >= {DTE_MIN:g}g")):
print("\n" + "=" * 100)
print(f"1-2. ARBITRAGGI STATICI MODEL-FREE su prezzi ESEGUIBILI — {etichetta}")
print("=" * 100)
v = test_verticale(q, dte_min)
print(f" TEST V (monotonia nello strike): {v['n_viol']:,} violazioni su {v['n_confronti']:,} "
f"confronti = {v['quota']:.4%}")
if v["n_viol"]:
print(f" credito mediano {v['credito_med_coin']:.5f} coin = "
f"${v['credito_usd_medlot']:.2f} al lotto minimo (LORDO fee)")
bf = test_farfalla(q, dte_min)
bf = in_punti_vol(bf)
bf["viol_exec"] = bf["cost_exec"] < 0
bf["viol_mid"] = bf["cost_mid"] < 0
print(f" TEST B (convessita' nello strike): {len(bf):,} triple consecutive")
print(f" al MID (incoerenza, non eseguibile): {bf['viol_mid'].sum():,} = {bf['viol_mid'].mean():.3%}")
print(f" ESEGUIBILE (compra ali all'ask, vendi corpo al bid): "
f"{bf['viol_exec'].sum():,} = {bf['viol_exec'].mean():.4%}")
if dte_min == DTE_MIN:
bf_keep = bf
bf = bf_keep
print("\n" + "=" * 100)
print("3. QUANTO E' GRANDE L'INCOERENZA, E QUANTO COSTA ATTRAVERSARLA")
print("=" * 100)
for a in sorted(bf["asset"].unique()):
x = bf[bf["asset"] == a]
x = x[np.isfinite(x["halfspread_pt"]) & np.isfinite(x["tick_pt"])]
r = np.abs(x["iv_resid"].dropna())
hs, tk = x["halfspread_pt"], x["tick_pt"]
print(f" {a} (n={len(x):,} triple con vega misurabile)")
print(f" |residuo di convessita' dello smile| mediana {r.median():.3f} pt-vol · "
f"p90 {r.quantile(.9):.3f} · p99 {r.quantile(.99):.3f}")
print(f" mezzo spread denaro-lettera mediana {hs.median():.3f} pt-vol · "
f"p25 {hs.quantile(.25):.3f} · p75 {hs.quantile(.75):.3f}")
print(f" UN TICK della griglia di prezzo mediana {tk.median():.3f} pt-vol "
f"-> l'incoerenza e' sotto il tick nel {np.mean(r.reindex(x.index) < tk):.1%} dei casi")
both = x.dropna(subset=["iv_resid"])
rap = np.abs(both["iv_resid"]) / (2 * both["halfspread_pt"])
print(f" |residuo| / (spread PIENO delle gambe): mediana {rap.median():.3f} · "
f"p99 {rap.quantile(.99):.3f} · quota > 1 = {np.mean(rap > 1):.3%}")
print(f" spread relativo (ask-bid)/mid del venue: mediana {x['relspread'].median():.1%} "
f"· p25 {x['relspread'].quantile(.25):.1%}")
# valore in dollari dell'incoerenza al MID (il caso migliore possibile: fee 0, spread 0)
eq = np.isclose(x["lam"], 0.5, atol=0.02)
lot = MIN_LOT[a]
val = -x.loc[eq, "cost_mid"].to_numpy() * 2 * lot * x.loc[eq, "spot_feed"].to_numpy()
pos = val[val > 0]
print(f" valore LORDO al mid della farfalla incoerente, lotto minimo: "
f"mediana ${np.median(pos):.2f} · p99 ${np.percentile(pos,99):.2f} · max ${pos.max():.2f}"
f" (n={len(pos):,})")
print("\n economia della farfalla al LOTTO MINIMO (famiglia INVERSE, quella nel dato):")
for a in sorted(bf["asset"].unique()):
e = economia(bf, a)
print(f" {a}: nozionale/lotto ${e['nozionale_min_lotto_usd']:,.0f} · "
f"fee 3 gambe mediana ${e['fee_3gambe_usd_med']:.2f} · "
f"triple equispaziate {e['n_equisp']:,}")
print(f" credito netto: max ${e['netto_usd_max']:.2f} · p99 ${e['netto_usd_p99']:.2f} · "
f"positivi {e['n_netto_pos']:,}/{e['n_equisp']:,}")
print("\n ESTRAPOLAZIONE DICHIARATA alla famiglia USDC (NON nel dato: il collettore raccoglie")
print(" le sole inverse — difetto di produzione del filone 11). Il lotto minimo e' 10x piu'")
print(" piccolo, ma il muro NON e' il ticket: e' lo spread in punti di vol, che e' una")
print(" QUOTA e non un livello -> ridurre il lotto non cambia |residuo|/spread.")
for a in sorted(bf["asset"].unique()):
x = bf[bf["asset"] == a]
x = x[np.isfinite(x["halfspread_pt"])].dropna(subset=["iv_resid"])
rap = np.abs(x["iv_resid"]) / (2 * x["halfspread_pt"])
rel_inv = x["relspread"].median()
for nome, rel_usdc in ((f"{a}_USDC", 0.082 if a == "BTC" else 0.026),):
k = rel_inv / rel_usdc # di quanto lo spread sarebbe piu' stretto
print(f" {a}: spread relativo inverse {rel_inv:.1%} vs {nome} {rel_usdc:.1%} "
f"-> fattore {k:.1f}x; rapporto mediano {rap.median():.3f} -> {rap.median()*k:.3f}, "
f"quota > 1 {np.mean(rap > 1):.2%} -> {np.mean(rap * k > 1):.2%}")
print("\n" + "=" * 100)
print("4. TEST C — varianza totale nel tenore (l'unico che tocca un interpolatore)")
print("=" * 100)
c = test_calendario(q, DTE_MIN)
print(f" (i) strike CONDIVISO, nessuna interpolazione: {c['i_viol']:,}/{c['i_n']:,} = {c['i_quota']:.3%}"
f" · ampiezza mediana {c['i_amp_med_pt']:.3f} pt-vol (p95 {c['i_amp_p95_pt']:.3f})")
print(f" (ii) log-moneyness FISSO, con interpolazione (1 snapshot ogni {c['ii_ogni']}): "
f"{c['ii_viol']:,}/{c['ii_n']:,} = {c['ii_quota']:.3%} · ampiezza mediana {c['ii_amp_med_pt']:.3f} pt-vol")
print(f" -> contributo dell'INTERPOLATORE = {c['ii_quota'] - c['i_quota']:+.3%} di quota di violazione")
print("\n" + "=" * 100)
print("5. TICK E PERSISTENZA — le due difese contro 'la mia incoerenza e' un artefatto'")
print("=" * 100)
for col, nome in (("viol_mid", "al MID"), ("viol_exec", "ESEGUIBILE")):
v = bf[bf[col]]
if len(v) == 0:
print(f" {nome}: nessuna violazione")
continue
legs = np.concatenate([v["bid"].to_numpy(), v["bid_p"].to_numpy(), v["bid_n"].to_numpy()])
legs = legs[np.isfinite(legs) & (legs > 0)]
nt = legs / tick_of(legs)
p = persistenza(bf, col)
print(f" {nome}: n={len(v):,} · gambe a <=2 tick {np.mean(nt <= 2):.1%} "
f"(mediana {np.median(nt):.0f} tick) · resta in violazione allo snapshot dopo "
f"{p.get('resta', float('nan')):.1%} (n={p['n']:,})")
print("\n" + "=" * 100)
print("6. IL RESIDUO SI RICOMPONE? (l'ipotesi del filone, misurata invece che assunta)")
print("=" * 100)
for a in sorted(bf["asset"].unique()):
rv = reversione(bf[bf["asset"] == a])
if "beta" not in rv:
print(f" {a}: coppie insufficienti (n={rv['n']})")
continue
print(f" {a}: n={rv['n']:,} coppie appaiate (stessa tripla, snapshot consecutivo <=2h) · "
f"beta di ricomposizione {rv['beta']:+.3f} · corr(resid_t, resid_t+1) {rv['corr']:+.3f}")
if np.isfinite(rv["meta_vita_h"]):
print(f" -> meta-vita {rv['meta_vita_h']:.1f} h (beta=-1 sarebbe ricomposizione totale "
f"in un'ora, beta=0 residuo persistente = forma dello smile, non errore)")
print("\n" + "=" * 100)
print("7. ROBUSTEZZA D'ANCORA — il risultato dipende dall'ora in cui guardo?")
print("=" * 100)
h = bf.assign(ora=bf["ts"].dt.hour).groupby("ora").agg(
mid=("viol_mid", "mean"), ex=("viol_exec", "mean"), n=("viol_mid", "size"))
print(f" quota di incoerenza al MID per ora UTC: mediana {h['mid'].median():.3%} · "
f"min {h['mid'].min():.3%} (h{h['mid'].idxmin():02d}) · max {h['mid'].max():.3%} (h{h['mid'].idxmax():02d})")
print(f" quota ESEGUIBILE per ora UTC: max {h['ex'].max():.5%} su {len(h)} ore -> "
f"nessuna ora del giorno apre una finestra")
print("\n" + "=" * 100)
print("SINTESI")
print("=" * 100)
nv = int(bf["viol_exec"].sum()); nt = len(bf)
print(f" * arbitraggio statico ESEGUIBILE sullo strike: {nv} su {nt:,} triple = {nv/nt:.6%}")
print(f" * incoerenza al MID: {bf['viol_mid'].mean():.2%} delle triple, "
f"ma mediana {np.abs(bf['iv_resid']).median():.3f} punti di vol")
print(f" * mezzo spread mediano: BTC {bf[bf.asset=='BTC']['halfspread_pt'].median():.3f} pt-vol, "
f"ETH {bf[bf.asset=='ETH']['halfspread_pt'].median():.3f} pt-vol")
print(" * il credito netto della migliore farfalla eseguibile in 1,03M osservazioni e' NEGATIVO")
print(" -> l'incoerenza ESISTE e vale meno del costo di attraversarla: verdetto SCARTATO.")
print(f"\n[tempo {time.time()-t0:.1f}s]")
if __name__ == "__main__":
main()
+873
View File
@@ -0,0 +1,873 @@
"""r0822b_tp01_sinistro — quanto costa l'ASSICURAZIONE TP01 su un conto a barriera (2026-08-22).
IL BUCO CHE CHIUDE
------------------
`r0822_prop_alloc.py` (stessa ondata) ha ottimizzato l'allocazione di un conto **funded** sotto
barriera di drawdown e ha trovato l'ottimo a **TP01 25 / SKH01 25 / XS01 50**, cioe' TP01 pesato
**tre volte meno** del libro live (75%). Ha anche dichiarato il proprio limite:
"la sotto-pesatura di TP01, lo sleeve DIFENSIVO, e' misurata su un campione SENZA SINISTRO
(2024-2026 non contiene un crash): su una barriera assorbente quella scommessa si paga una
volta sola."
E questo si scontra con due misure del progetto:
(a) il **leave-one-out de-luckato** del 26/07: TP01 e' il maggior contributore al Sharpe FULL
(+0.390, positivo nel 100% delle estrazioni) e il suo hold-out negativo (-0.200, negativo
nel 99.1%) e' *la firma dell'assicurazione, non della morte* — "uno sleeve difensivo si
giudica sul SINISTRO, non sul premio";
(b) `edge_watch` criterio B: negli **8 anni su 8** con drawdown buy&hold > 10% TP01 ha protetto
da **1.8x a 34.4x**.
DOMANDA: qual e' il peso giusto di TP01 se l'obiettivo include sopravvivere a un crash che nel
campione di ottimizzazione NON c'e'?
IPOTESI REGISTRATA PRIMA DI MISURARE
------------------------------------
Mi aspettavo che (1) la sotto-pesatura di TP01 costasse molto **nel sinistro** e poco fuori, e che
(2) esistesse un peso di compromesso (~50%) dentro la regione robusta del funded e non
catastrofico al crash. In altre parole mi aspettavo di **confermare** il caveat di PROP-ALLOC.
Se la misura dice il contrario, e' un risultato e va scritto come tale.
CIO' CHE E' UN'ASSUNZIONE E CIO' CHE E' UN DATO
-----------------------------------------------
* Lo **scenario di stress e' uno SCENARIO, non una previsione.** Due costruzioni, entrambe
dichiarate: (S1) bootstrap MISTO — ogni blocco ha probabilita' `p` di essere estratto dal pool
dei sinistri 2019-2023 invece che dalla finestra normale; (S2) **replay** — un episodio di
crash VERO innestato per intero, in ordine, a una data casuale dentro l'anno. In piu' il
replay deterministico del **2022 intero** (aprile-dicembre), che non e' un modello: e' successo.
* Il vincolo binding di TUTTO il filone e' che i sinistri osservati sono **QUATTRO**. Nessun
bootstrap crea informazione: l'ampiezza effettiva della coda resta 4 episodi, e ogni numero qui
eredita quell'errore. Detto una volta, vale per ogni tabella.
* Lente **ACCOPPIATA** (`r0725_prop_coupled`): il minimo intraday si compone ESATTO sul path
orario condiviso TP01/SKH01. Non e' un dettaglio: su una regola a UN giorno (daily-loss) la
lente close-only e' misurata **esattamente cieca** (0.00 breach contro 0.40-1.21 veri), mentre
su una statistica multi-giorno l'errore vale il 3.5%. Il vincolo funded E' una regola a un giorno.
* de-luck sul drift **x0.89** (fattore MISURATO il 26/07). ⚠️ L'offset di de-luck e' calcolato UNA
VOLTA sulla finestra 2019+ e sottratto ovunque in valore ASSOLUTO: de-luckare un pool di crash
sulla sua PROPRIA media (negativa) *aggiungerebbe* drift proprio dove non deve.
* macchineria RIUSATA, non riscritta: `r0725_prop_coupled` (tuple accoppiate) e `r0822_prop_alloc`
(`eval_sim`, `funded_sim`, `boot_idx`, `FIRMS`). Qui si aggiungono solo i pool e gli innesti.
Uso: `nice -n 19 timeout 900 uv run python scripts/research/r0822b_tp01_sinistro.py`
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
from src.data.downloader import load_data # noqa: E402
from src.portfolio.sleeves import _tp01_returns, _skyhook_returns # noqa: E402
import r0725_prop_coupled as pc # noqa: E402
import r0822_prop_alloc as PA # noqa: E402
# ------------------------------------------------------------------ costanti DICHIARATE
DELUCK = PA.DELUCK # 0.89, misurato il 26/07
FULL_START = "2019-03-14" # inizio del recon crypto (TP01+SKH01)
RECENT_START = "2024-01-01" # finestra di ottimizzazione di PROP-ALLOC (nasce XS01)
BLOCK = 20 # blocco del bootstrap, in giorni
N_PATHS = 2500
EPI_LEN = 30 # lunghezza di un episodio di sinistro, in giorni
HORIZON = 365
LEVS = (0.25, 0.375, 0.50, 0.625, 0.75, 1.00, 1.25, 1.50)
W_GRID = tuple(i / 8 for i in range(9)) # peso di TP01 nella coppia crypto, 0 -> 1
SURV_TARGET = 0.90 # livello di sopravvivenza della frontiera ISO-SOPRAVVIVENZA
N_EVAL = 0 # contatore onesto delle valutazioni (griglia dichiarata)
def hr(t: str = "") -> None:
print("\n" + "-" * 106)
if t:
print(f" {t}")
print("-" * 106)
# ================================================================== §A. PANNELLO
_TUP: dict = {}
def crypto(w_tp: float) -> tuple[np.ndarray, np.ndarray, pd.DatetimeIndex]:
"""(R, gap) giornalieri della coppia crypto a peso TP01 = w_tp, SKH01 = 1-w_tp.
gap = minimo_intraday - chiusura (<=0). Storia piena dal 2019-03."""
k = round(float(w_tp), 6)
if k not in _TUP:
c = pc.crypto_daily_tuples(k, 1.0 - k)
R = c["R"].values.astype(float)
G = np.minimum(c["m"].values, R) - R
_TUP[k] = (R, G, c.index)
R, G, I = _TUP[k]
return R.copy(), G.copy(), I
_OFF: dict = {}
def deluck_offset(w_tp: float, factor: float = DELUCK) -> float:
"""Offset ASSOLUTO di de-luck, calcolato UNA VOLTA sulla finestra 2019+ e riusato ovunque.
Calcolarlo sulla media di un sotto-campione di crash (negativa) *aggiungerebbe* drift."""
key = (round(float(w_tp), 6), round(float(factor), 4))
if key not in _OFF:
R, _, I = crypto(w_tp)
_OFF[key] = float((1.0 - factor) * R[I >= pd.Timestamp(FULL_START, tz="UTC")].mean())
return _OFF[key]
def buyhold_daily(idx: pd.DatetimeIndex) -> pd.Series:
"""Buy&hold 50/50 BTC+ETH sulla stessa griglia giornaliera del libro."""
bh = None
for a in ("BTC", "ETH"):
d = load_data(a, "1h")
i = pd.DatetimeIndex(pd.to_datetime(d["datetime"], utc=True))
s = pd.Series(d["close"].astype(float).values, index=i).resample("1D").last()
r = s.pct_change().fillna(0.0) * 0.5
bh = r if bh is None else bh.add(r, fill_value=0.0)
return bh.reindex(idx).fillna(0.0)
def worst_windows(lbh: np.ndarray, idx: pd.DatetimeIndex, K: int, L: int,
hi: int | None = None) -> list[int]:
"""Le K finestre di L giorni NON sovrapposte col peggior rendimento buy&hold, greedy sul
minimo. `hi` limita i possibili inizi (per restare dentro 2019-2023)."""
cs = np.concatenate(([0.0], np.cumsum(lbh)))
win = np.expm1(cs[L:] - cs[:-L])
cand = np.arange(len(win)) if hi is None else np.arange(min(hi, len(win)))
order = cand[np.argsort(win[cand])]
out: list[int] = []
for t in order:
if all(abs(int(t) - q) >= L for q in out):
out.append(int(t))
if len(out) == K:
break
return sorted(out)
def binom_two_sided(k: int, n: int) -> float:
"""p-value esatto a due code di un test dei segni con p0=0.5 (n piccolo: si conta)."""
from math import comb
tot = 2 ** n
kk = max(k, n - k)
tail = sum(comb(n, j) for j in range(kk, n + 1))
return min(1.0, 2.0 * tail / tot)
def maxdd(r: np.ndarray) -> float:
eq = np.cumprod(1.0 + r)
return float((eq / np.maximum.accumulate(eq) - 1.0).min())
# ================================================================== §B. SIMULAZIONE
def J_of(R: np.ndarray, G: np.ndarray, ev_idx: np.ndarray, fu_idx: np.ndarray,
lev: float, firm: str = "HYRO") -> dict:
"""J = P(passare l'eval) x P(essere vivo a 12 mesi da funded), lente ACCOPPIATA."""
global N_EVAL
N_EVAL += 1
e = PA.eval_sim(R, G, ev_idx, lev, firm, "coupled")
f = PA.funded_sim(R, G, fu_idx, lev, firm, 100_000.0, "coupled")
return dict(J=e["p_pass"] * f["p_alive"], p_pass=e["p_pass"], p_alive=f["p_alive"],
e_payout=f["e_payout"], jvec=(e["passed"] & f["alive"]).astype(float),
alive=f["alive"])
def best_lev(R: np.ndarray, G: np.ndarray, ev_idx, fu_idx, firm: str = "HYRO") -> tuple:
rows = [(lv, J_of(R, G, ev_idx, fu_idx, lv, firm)) for lv in LEVS]
return max(rows, key=lambda x: x[1]["J"])
def pool_paths(seg_bounds: list[tuple[int, int]], n_days: int, n_paths: int, seed: int,
block: int = BLOCK) -> np.ndarray:
"""Indici di bootstrap a blocchi ristretti a segmenti CONTIGUI (ogni blocco vive dentro un
solo segmento -> non si incollano giorni di episodi diversi)."""
rng = np.random.default_rng(seed)
starts = []
for a, b in seg_bounds:
if b - a >= block:
starts.extend(range(a, b - block + 1))
starts = np.array(starts)
nb = int(np.ceil(n_days / block))
st = starts[rng.integers(0, len(starts), size=(n_paths, nb))]
return (st[:, :, None] + np.arange(block)[None, None, :]).reshape(n_paths, -1)[:, :n_days]
def mix_paths(base_idx: np.ndarray, crash_starts: np.ndarray, p: float, seed: int,
block: int = BLOCK) -> np.ndarray:
"""S1 — bootstrap MISTO: ogni blocco della path normale ha probabilita' `p` di essere
sostituito da un blocco estratto dal pool dei SINISTRI."""
rng = np.random.default_rng(seed)
out = base_idx.copy()
P, D = out.shape
nb = int(np.ceil(D / block))
hit = rng.random((P, nb)) < p
for b in range(nb):
sl = slice(b * block, min((b + 1) * block, D))
L = sl.stop - sl.start
rows = np.where(hit[:, b])[0]
if len(rows) == 0:
continue
st = crash_starts[rng.integers(0, len(crash_starts), size=len(rows))]
out[rows, sl] = st[:, None] + np.arange(L)[None, :]
return out
def splice_paths(base_idx: np.ndarray, epi_idx: np.ndarray, seed: int) -> np.ndarray:
"""S2 — REPLAY: l'episodio VERO (contiguo, in ordine) innestato una volta sola a una data
casuale dentro l'orizzonte. E' la domanda operativa: *se il sinistro arriva*."""
rng = np.random.default_rng(seed)
out = base_idx.copy()
P, D = out.shape
L = len(epi_idx)
st = rng.integers(0, max(D - L, 1), size=P)
for i in range(P):
a = int(st[i])
out[i, a:a + L] = epi_idx[:min(L, D - a)]
return out
# ================================================================== main
def main() -> None: # noqa: C901
try:
sys.stdout.reconfigure(line_buffering=True)
except Exception: # pragma: no cover
pass
t0 = time.time()
def tick(lab: str) -> None:
print(f" [{time.time()-t0:6.0f}s] {lab} (valutazioni finora: {N_EVAL})")
print("=" * 106)
print(" TP01-SINISTRO — quanto costa l'assicurazione su un conto a barriera, e quanto rende")
print(" quando il sinistro arriva. Lente ACCOPPIATA, firm HYRO.")
print("=" * 106)
R75, G75, IDX = crypto(0.75)
n_full = len(IDX)
i_rec = int(np.searchsorted(IDX, pd.Timestamp(RECENT_START, tz="UTC")))
print(f"\n finestra crypto piena: {n_full} giorni {IDX[0].date()} -> {IDX[-1].date()}")
print(f" finestra di ottimizzazione di PROP-ALLOC: {n_full - i_rec} giorni dal "
f"{IDX[i_rec].date()} (e' anche la finestra di SCOPERTA di XS01)")
# --------------------------------------------------------- sanity: recon vs sleeve ufficiale
print("\n SANITY — il recon orario contro gli sleeve ufficiali. TP01/SKH01 NON coincidono")
print(" barra per barra per costruzione (il recon e' un mark-to-market ORARIO, lo sleeve")
print(" marca a fine-trade): il controllo giusto e' il log-totale.")
for nm, off_s, w in (("TP01", _tp01_returns(), 1.0), ("SKH01", _skyhook_returns(), 0.0)):
o = off_s.dropna().sort_index()
if o.index.tz is None:
o.index = o.index.tz_localize("UTC")
Rn, _, In = crypto(w)
com = o.index.intersection(In)
a = o.reindex(com).values
b = pd.Series(Rn, index=In).reindex(com).values
print(f" {nm:>6} log-tot sleeve {float(np.log1p(a).sum()):+8.4f} "
f"recon {float(np.log1p(b).sum()):+8.4f} scarto "
f"{float(np.log1p(a).sum()-np.log1p(b).sum()):+7.4f}")
# ============================================================ §1. CHE COS'E' UN SINISTRO
hr("§1. CHE COS'E' UN SINISTRO — e la frase '2024-2026 non contiene un crash' e' FALSA")
bh = buyhold_daily(IDX)
lbh = np.log1p(bh.values)
print(" Peggior rendimento buy&hold 50/50 su finestra mobile, per periodo:")
print(f" {'W':>4} {'2019-2023 min':>15} {'2024+ min':>12} {'rapporto':>10} "
f"{'2019-23 p1':>11} {'2024+ p1':>10} {'data del min 2024+':>20}")
ratios = []
for W in (1, 5, 10, 20, 60):
cs = np.concatenate(([0.0], np.cumsum(lbh)))
roll = np.expm1(cs[W:] - cs[:-W]) # finestra che FINISCE in t=W-1+k
tt = IDX[W - 1:]
pre = roll[tt < pd.Timestamp(RECENT_START, tz="UTC")]
post = roll[tt >= pd.Timestamp(RECENT_START, tz="UTC")]
tpost = tt[tt >= pd.Timestamp(RECENT_START, tz="UTC")]
rr = pre.min() / post.min()
ratios.append(rr)
print(f" {W:>4} {pre.min():>15.1%} {post.min():>12.1%} {rr:>10.2f}x "
f"{np.percentile(pre, 1):>11.1%} {np.percentile(post, 1):>10.1%} "
f"{str(tpost[int(np.argmin(post))].date()):>20}")
print(f"\n -> il 2024+ **contiene** un crash: peggior giorno -14.5%, peggiori 20g -39.6%,")
print(f" drawdown buy&hold fino a -60%. Cio' che NON contiene e' un crash della TAGLIA")
print(f" dei precedenti: il rapporto e' {np.mean(ratios[:3]):.1f}x sulla coda veloce (1-10g)")
print(f" e {ratios[-1]:.1f}x a 60 giorni. **La frase giusta non e' 'senza sinistro' ma")
print(f" 'con un sinistro di taglia dimezzata'** — e cambia la domanda: non 'cosa succede")
print(f" se arriva un crash' ma 'cosa succede se ne arriva uno DUE VOLTE piu' grande'.")
# ----- replica del criterio B di edge_watch (protezione per anno)
print("\n Criterio B di `edge_watch` replicato qui (anni con DD buy&hold > 10%):")
tp_only, _, _ = crypto(1.0)
ser_bh = pd.Series(bh.values, index=IDX)
ser_tp = pd.Series(tp_only, index=IDX)
print(f" {'anno':>6} {'DD b&h':>9} {'DD TP01':>9} {'protezione':>12} {'DD SKH01':>10} "
f"{'DD 75/25':>10}")
skh_only, _, _ = crypto(0.0)
ser_sk = pd.Series(skh_only, index=IDX)
ser_75 = pd.Series(R75, index=IDX)
n_sin = n_ok = 0
for y, g in ser_bh.groupby(ser_bh.index.year):
d_bh = maxdd(g.values)
if d_bh > -0.10:
continue
n_sin += 1
d_tp = maxdd(ser_tp.loc[g.index].values)
d_sk = maxdd(ser_sk.loc[g.index].values)
d_75 = maxdd(ser_75.loc[g.index].values)
ok = (d_tp / d_bh) <= 0.75
n_ok += ok
print(f" {y:>6} {d_bh:>9.1%} {d_tp:>9.1%} {d_bh/d_tp:>11.1f}x {d_sk:>10.1%} "
f"{d_75:>10.1%} {'ok' if ok else 'FAIL'}")
print(f" -> anni di sinistro {n_sin}, superati {n_ok}. **Ogni anno e' un anno di sinistro")
print(f" per il buy&hold**: la 'frequenza del sinistro' non e' un evento raro, e' lo")
print(f" stato normale dell'asset. Cio' che e' raro e' la TAGLIA (riga sopra).")
# ----- definizione degli episodi
hr("§1-bis. GLI EPISODI DI SINISTRO — regola di selezione DICHIARATA PRIMA")
print(f" Regola: le {3} finestre di {EPI_LEN} giorni NON sovrapposte con il peggior rendimento")
print(f" buy&hold nel 2019-2023 (greedy sul minimo), PIU' l'FTX 2022-11-05 aggiunto **per")
print(f" NOME** (evento noto, non selezionato dal minimo: e' un controllo, non una scelta).")
cs = np.concatenate(([0.0], np.cumsum(lbh)))
win = np.expm1(cs[EPI_LEN:] - cs[:-EPI_LEN]) # finestra che INIZIA in t
pre_mask = IDX[:len(win)] < pd.Timestamp(RECENT_START, tz="UTC")
cand = np.where(pre_mask)[0]
order = cand[np.argsort(win[cand])]
picked = []
for t in order:
if all(abs(int(t) - p) >= EPI_LEN for p in picked):
picked.append(int(t))
if len(picked) == 3:
break
t_ftx = int(np.searchsorted(IDX, pd.Timestamp("2022-11-05", tz="UTC")))
epis = sorted(picked + [t_ftx])
NAMES = {}
print(f"\n {'episodio':>12} {'dal':>12} {'al':>12} {'b&h 30g':>10} {'peggior 1g b&h':>16}")
for t in epis:
lab = ("COVID" if IDX[t].year == 2020 else
"MAG-2021" if (IDX[t].year, IDX[t].month) == (2021, 5) else
"LUNA" if (IDX[t].year, IDX[t].month) in ((2022, 5), (2022, 6)) else
"FTX" if (IDX[t].year, IDX[t].month) == (2022, 11) else f"EPI-{IDX[t].date()}")
NAMES[t] = lab
seg = bh.values[t:t + EPI_LEN]
print(f" {lab:>12} {str(IDX[t].date()):>12} {str(IDX[t+EPI_LEN-1].date()):>12} "
f"{win[t]:>10.1%} {seg.min():>16.1%}")
crash_days = np.concatenate([np.arange(t, t + EPI_LEN) for t in epis])
freq_day = len(crash_days) / n_full
print(f"\n giorni di sinistro {len(crash_days)} su {n_full} = **{freq_day:.1%} del tempo**")
print(f" ⚠️ AMPIEZZA EFFETTIVA DELLA CODA = **4 EPISODI**. Nessun bootstrap la aumenta.")
print(f" Ogni numero di questo file eredita l'errore di un campione di quattro eventi.")
tick("§1 sinistri")
# ============================================================ §2. IL CONTO DELL'ASSICURAZIONE
hr("§2. IL CONTO DELL'ASSICURAZIONE — premio fuori dal sinistro, indennizzo dentro")
print(" Confronto DUE ripartizioni della coppia crypto, appaiato giorno per giorno:")
print(" LIVE = TP01 75 / SKH01 25 (il libro che gira)")
print(" OPT = TP01 38 / SKH01 62 (l'ottimo di PROP-ALLOC sul campione 2019+)")
in_sin = np.zeros(n_full, bool)
in_sin[crash_days] = True
print(f"\n {'config':>10} {'drift fuori':>12} {'drift dentro':>13} {'Sharpe full':>12} "
f"{'maxDD':>8} {'peggior 1g':>11} {'peggior min 1g':>15}")
prof = {}
for lab, w in (("LIVE 75/25", 0.75), ("OPT 38/62", 0.375), ("TP01 puro", 1.0),
("SKH01 puro", 0.0)):
R, G, _ = crypto(w)
R = R - deluck_offset(w)
out_d = R[~in_sin].mean() * 365.0
in_d = R[in_sin].mean() * 365.0
vol = R.std() * np.sqrt(365.0)
prof[lab] = dict(out=out_d, ind=in_d, sh=R.mean() * 365.0 / vol, dd=maxdd(R),
w1=R.min(), wm=(R + G).min())
p = prof[lab]
print(f" {lab:>10} {p['out']:>12.1%} {p['ind']:>13.1%} {p['sh']:>12.2f} {p['dd']:>8.1%} "
f"{p['w1']:>11.2%} {p['wm']:>15.2%}")
prem = prof["LIVE 75/25"]["out"] - prof["OPT 38/62"]["out"]
ind = prof["LIVE 75/25"]["ind"] - prof["OPT 38/62"]["ind"]
R_live = crypto(0.75)[0] - deluck_offset(0.75)
R_opt = crypto(0.375)[0] - deluck_offset(0.375)
cum_live = float(np.expm1(np.log1p(R_live[in_sin]).sum()))
cum_opt = float(np.expm1(np.log1p(R_opt[in_sin]).sum()))
print(f"\n PREMIO (LIVE - OPT) fuori dal sinistro : {prem:+.2%}/anno = "
f"{prem*100_000:+,.0f} $/anno su un funded da $100k")
print(f" INDENNIZZO (LIVE - OPT) sui 120 giorni di sinistro, CUMULATO (non annualizzato,")
print(f" che su 120 giorni sarebbe una finta precisione): LIVE {cum_live:+.1%} contro "
f"OPT {cum_opt:+.1%} = {cum_live-cum_opt:+.1%}")
print(f" su un funded da $100k: {(cum_live-cum_opt)*100_000:+,.0f} $ per l'INTERO "
f"monte-sinistri di {n_full/365.0:.1f} anni")
print(f" frequenza: {freq_day:.1%} del tempo dentro un sinistro (4 episodi in "
f"{n_full/365.0:.1f} anni = uno ogni {n_full/365.0/4:.1f} anni)")
if ind < 0:
print("\n 🚨 **IL SEGNO E' ROVESCIATO RISPETTO ALLA MIA IPOTESI.** Dentro il sinistro il")
print(" libro con MENO TP01 fa MEGLIO, non peggio. Il meccanismo si legge in una riga:")
print(" TP01 nel crash va FLAT (non perde, non guadagna: e' assicurazione), SKH01 va")
print(" SHORT (guadagna). Su un conto a barriera *guadagnare batte non-perdere*.")
print("\n Rendimento dei 4 episodi, per peso di TP01 (30 giorni ciascuno, chiusura e MINIMO):")
print(f" {'w TP01':>8} " + " ".join(f"{NAMES[t]:>18}" for t in epis) + f" {'peggior min':>13}")
for w in (1.0, 0.75, 0.5, 0.375, 0.25, 0.0):
R, G, _ = crypto(w)
cells = []
wm = 0.0
for t in epis:
seg = R[t:t + EPI_LEN]
gp = G[t:t + EPI_LEN]
eq = np.cumprod(1.0 + seg)
mn = float(np.minimum(eq - 1.0, np.concatenate(([1.0], eq[:-1])) * (1 + seg + gp) - 1.0).min())
wm = min(wm, mn)
cells.append(f"{float(eq[-1]-1.0):+7.1%}/{mn:+7.1%}")
print(f" {w:>8.3f} " + " ".join(f"{c:>18}" for c in cells) + f" {wm:>13.1%}")
print(" (formato: chiusura a 30g / minimo intraday cumulato dell'episodio)")
print("\n ⚠️ QUANTA POTENZA HA QUESTO SEGNO? Con 4 episodi, '4 su 4 a favore' ha un test dei")
print(" segni con p = 0.125 a due code: **non e' significativo al 5%**. Il modo onesto di")
print(" guadagnare potenza non e' ripetere il bootstrap (non crea eventi) ma ESTENDERE la")
print(" stessa regola di selezione a piu' finestre. Le 12 peggiori finestre di 30g NON")
print(" sovrapposte su TUTTO il 2019+ (stessa regola greedy, K da 3 a 12):")
w12 = worst_windows(lbh, IDX, 12, EPI_LEN)
print(f"\n {'finestra':>12} {'b&h 30g':>9} {'LIVE 75/25':>11} {'OPT 38/62':>11} "
f"{'TP01 puro':>10} {'SKH01 puro':>11} {'OPT>LIVE':>9}")
wins = 0
wins_sk = 0
lr_live, lr_opt = np.log1p(R_live), np.log1p(R_opt)
R_tp = crypto(1.0)[0] - deluck_offset(1.0)
R_sk = crypto(0.0)[0] - deluck_offset(0.0)
for t in w12:
a = float(np.expm1(lr_live[t:t + EPI_LEN].sum()))
b = float(np.expm1(lr_opt[t:t + EPI_LEN].sum()))
c = float(np.expm1(np.log1p(R_tp[t:t + EPI_LEN]).sum()))
d = float(np.expm1(np.log1p(R_sk[t:t + EPI_LEN]).sum()))
wins += b > a
wins_sk += d > c
print(f" {str(IDX[t].date()):>12} {win[t]:>9.1%} {a:>11.1%} {b:>11.1%} "
f"{c:>10.1%} {d:>11.1%} {('si' if b > a else 'no'):>9}")
print(f"\n OPT batte LIVE in **{wins}/12** finestre -> test dei segni "
f"p = {binom_two_sided(wins, 12):.4f}")
print(f" SKH01 puro batte TP01 puro in **{wins_sk}/12** -> p = "
f"{binom_two_sided(wins_sk, 12):.4f}")
print(" ⚠️ 5 di queste 12 finestre stanno nel 2024+, cioe' DENTRO la finestra su cui")
print(" SKH01 e' stato tarato e su cui PROP-ALLOC ha ottimizzato: la potenza sale ma")
print(" l'indipendenza scende. Il test va letto come **una diagnostica, non una prova**.")
tick("§2 conto assicurazione")
# ============================================================ §3. SOTTO BARRIERA
hr("§3. SOTTO BARRIERA — griglia peso TP01 x leva su TRE lenti di campione")
print(f" {len(W_GRID)} pesi x {len(LEVS)} leve x 3 finestre. J = P(pass) x P(vivo 12m), HYRO")
print(f" (eval +10%/-6% statico/-4% giornaliero; funded -6% statico/-4% giornaliero).")
ev_f = PA.boot_idx(n_full, HORIZON, N_PATHS, seed=8230)
fu_f = PA.boot_idx(n_full, HORIZON, N_PATHS, seed=8231)
n_rec = n_full - i_rec
ev_r = PA.boot_idx(n_rec, HORIZON, N_PATHS, seed=8240)
fu_r = PA.boot_idx(n_rec, HORIZON, N_PATHS, seed=8241)
seg_sin = [(t, t + EPI_LEN) for t in epis]
ev_s = pool_paths(seg_sin, HORIZON, N_PATHS, seed=8250)
fu_s = pool_paths(seg_sin, HORIZON, N_PATHS, seed=8251)
print(f"\n {'w TP01':>7} | {'2019+ (col 2022)':>26} | {'2024+ (PROP-ALLOC)':>26} | "
f"{'SOLO-SINISTRO':>26}")
print(f" {'':>7} | {'leva':>6} {'J':>7} {'P(viv)':>7} | {'leva':>6} {'J':>7} {'P(viv)':>7} | "
f"{'leva':>6} {'J':>7} {'P(viv)':>7}")
tab = {}
for w in W_GRID:
R, G, _ = crypto(w)
off = deluck_offset(w)
Rf, Gf = R - off, G
Rr, Gr = Rf[i_rec:], Gf[i_rec:]
lv1, o1 = best_lev(Rf, Gf, ev_f, fu_f)
lv2, o2 = best_lev(Rr, Gr, ev_r, fu_r)
lv3, o3 = best_lev(Rf, Gf, ev_s, fu_s)
tab[w] = dict(full=(lv1, o1), rec=(lv2, o2), sin=(lv3, o3))
print(f" {w:>7.3f} | {lv1:>6.3f} {o1['J']:>7.3f} {o1['p_alive']:>7.1%} | "
f"{lv2:>6.3f} {o2['J']:>7.3f} {o2['p_alive']:>7.1%} | "
f"{lv3:>6.3f} {o3['J']:>7.3f} {o3['p_alive']:>7.1%}")
a_full = max(tab, key=lambda w: tab[w]["full"][1]["J"])
a_rec = max(tab, key=lambda w: tab[w]["rec"][1]["J"])
a_sin = max(tab, key=lambda w: tab[w]["sin"][1]["J"])
print(f"\n argmax: 2019+ -> TP01 {a_full:.3f} | 2024+ -> TP01 {a_rec:.3f} | "
f"SOLO-SINISTRO -> TP01 {a_sin:.3f}")
print(" ⚠️ La colonna SOLO-SINISTRO e' una lente ESTREMA e va letta come tale: l'anno intero")
print(" e' fatto di soli blocchi di crash (4 episodi ricampionati). Non e' uno scenario")
print(" plausibile, e' il **limite superiore del danno**: se il verso della risposta e' lo")
print(" stesso qui e sul 2019+, non dipende dalla frazione di sinistro assunta.")
# replica di controllo del numero pubblicato da PROP-ALLOC (E-ter)
print("\n REPLICA DI CONTROLLO — PROP-ALLOC E-ter pubblica, sul 2019+: argmax della coppia")
print(" crypto **38/62 @0.50x con J 0.521**, e **75/25 J 0.443**. Stesse seed, stessa lente:")
j38 = tab[0.375]["full"]
j75 = tab[0.75]["full"]
print(f" qui: 38/62 @{j38[0]:g}x J {j38[1]['J']:.3f} | 75/25 @{j75[0]:g}x "
f"J {j75[1]['J']:.3f} -> "
f"{'REPLICA OK' if abs(j38[1]['J']-0.521) < 0.02 and abs(j75[1]['J']-0.443) < 0.02 else 'NON REPLICA — leggere con cautela'}")
tick("§3 griglia 3 lenti")
# ============================================================ §4. LO STRESS ESPLICITO
hr("§4. LO STRESS ESPLICITO — iniettare nel 2024+ i crash che il 2024+ non ha")
print(" Costruzione DICHIARATA. Base = blocchi del 2024+ (la finestra di ottimizzazione).")
print(" S1 MISTO: ogni blocco ha probabilita' p di venire dal pool dei 4 sinistri 2019-2023.")
print(f" p = {freq_day:.3f} e' la frequenza STORICA; si girano anche 2x e 4x.")
print(" S2 REPLAY: un episodio VERO innestato per intero, in ordine, a data casuale dentro")
print(" l'anno. E' la domanda operativa: *se il sinistro arriva, chi resta in piedi?*")
print(" ⚠️ Sono SCENARI, non previsioni. E poggiano su 4 episodi.")
# pool = 2024+ (blocchi normali) + gli episodi in coda, su un indice unico
def stress_panel(w: float) -> tuple[np.ndarray, np.ndarray, np.ndarray, list]:
R, G, _ = crypto(w)
off = deluck_offset(w)
Rn, Gn = R[i_rec:] - off, G[i_rec:]
segs = []
Rc, Gc = [], []
cur = len(Rn)
for t in epis:
Rc.append(R[t:t + EPI_LEN] - off)
Gc.append(G[t:t + EPI_LEN])
segs.append((cur, cur + EPI_LEN))
cur += EPI_LEN
Rp = np.concatenate([Rn] + Rc)
Gp = np.concatenate([Gn] + Gc)
return Rp, Gp, np.array([s for s, _ in segs]), segs
base_ev = PA.boot_idx(n_rec, HORIZON, N_PATHS, seed=8260)
base_fu = PA.boot_idx(n_rec, HORIZON, N_PATHS, seed=8261)
print(f"\n S1 — bootstrap MISTO (base 2024+, blocchi di sinistro con probabilita' p)")
print(f" {'w TP01':>7} | " + " | ".join(f"p={p:.3f}: {'J':>6} {'P(viv)':>7}"
for p in (0.0, freq_day, 2 * freq_day, 4 * freq_day)))
s1 = {}
for w in W_GRID:
Rp, Gp, cst, segs = stress_panel(w)
starts = np.concatenate([np.arange(a, b - BLOCK + 1) for a, b in segs])
cells = []
for j, p in enumerate((0.0, freq_day, 2 * freq_day, 4 * freq_day)):
ev = base_ev if p == 0 else mix_paths(base_ev, starts, p, seed=8270 + j)
fu = base_fu if p == 0 else mix_paths(base_fu, starts, p, seed=8280 + j)
lv, o = best_lev(Rp, Gp, ev, fu)
cells.append((lv, o))
s1[w] = cells
print(f" {w:>7.3f} | " + " | ".join(f" @{c[0]:.3g}x {c[1]['J']:>6.3f} "
f"{c[1]['p_alive']:>7.1%}" for c in cells))
for j, p in enumerate((0.0, freq_day, 2 * freq_day, 4 * freq_day)):
am = max(s1, key=lambda w: s1[w][j][1]["J"])
print(f" p={p:.3f} -> argmax TP01 {am:.3f} (J {s1[am][j][1]['J']:.3f}; "
f"75/25 fa {s1[0.75][j][1]['J']:.3f})")
tick("§4 S1 misto")
print(f"\n S2 — REPLAY: un episodio innestato una volta nell'anno funded (365g)")
print(f" Metrica: **P(vivo a 12 mesi) con il crash dentro**, alla leva scelta sul 2024+")
print(f" (cioe' la leva che uno sceglierebbe NON sapendo del crash: e' il punto).")
hdr = " ".join(f"{NAMES[t]:>10}" for t in epis)
print(" ⚠️ DUE colonne di leva, e servono entrambe: la leva 'da 2024+' e' quella che uno")
print(" sceglierebbe non sapendo del crash (realistica, ma CONFONDE peso e leva perche' non")
print(" e' la stessa per tutte le righe); la leva COMUNE 0.50x isola l'effetto del peso.")
print(f"\n {'w TP01':>7} {'leva (da 2024+)':>16} {hdr:>44} {'peggiore':>10} "
f"{'peggiore @0.50x':>16} {'P(viv) 2024+':>13}")
s2 = {}
for w in W_GRID:
Rp, Gp, _, segs = stress_panel(w)
lv = tab[w]["rec"][0]
cells, cells_c = [], []
for k, (a, b) in enumerate(segs):
fu = splice_paths(base_fu, np.arange(a, b), seed=8290 + k)
cells.append(J_of(Rp, Gp, base_ev, fu, lv)["p_alive"])
cells_c.append(J_of(Rp, Gp, base_ev, fu, 0.50)["p_alive"])
s2[w] = (lv, cells, min(cells_c), tab[w]["rec"][1]["p_alive"])
print(f" {w:>7.3f} {lv:>16.3f} " + " ".join(f"{c:>10.1%}" for c in cells)
+ f" {min(cells):>10.1%} {min(cells_c):>16.1%} "
f"{tab[w]['rec'][1]['p_alive']:>13.1%}")
bw_a = max(s2, key=lambda w: min(s2[w][1]))
bw_c = max(s2, key=lambda w: s2[w][2])
print(f"\n argmax della colonna 'peggiore': leva-da-2024+ -> TP01 {bw_a:.3f} | "
f"leva comune 0.50x -> TP01 {bw_c:.3f}")
print(" Lettura: se la sopravvivenza al crash SALISSE col peso di TP01, l'assicurazione")
print(" servirebbe. **A leva comune non sale**: la curva e' piatta-decrescente. Il difensivo")
print(" non compra sopravvivenza su questa barriera, perche' cio' che la barriera punisce e'")
print(" la PERDITA nel crash, e nel crash TP01 perde (prende la prima gamba giu' e poi va")
print(" flat) mentre SKH01 si gira short e GUADAGNA.")
tick("§4 S2 replay")
# ----- replay deterministico del 2022
print("\n S2-bis — REPLAY DETERMINISTICO DEL 2022 (2022-04-01 -> 2022-12-31, 275 giorni).")
print(" Non e' un modello: e' successo. Un conto funded HYRO da $100k che parte il 1° aprile")
print(" 2022 con max-loss STATICO -6% e daily-loss -4%, alla leva scelta sul 2024+:")
a22 = int(np.searchsorted(IDX, pd.Timestamp("2022-04-01", tz="UTC")))
b22 = int(np.searchsorted(IDX, pd.Timestamp("2023-01-01", tz="UTC")))
def replay22(w: float, lev: float) -> tuple:
R, G, _ = crypto(w)
off = deluck_offset(w)
r = (R[a22:b22] - off) * lev
g = G[a22:b22] * lev
eq, mn, bust, why = 1.0, 1.0, None, ""
for i in range(len(r)):
low = eq * (1.0 + r[i] + g[i])
eq = eq * (1.0 + r[i])
mn = min(mn, low)
if bust is None and (low < 0.94 or (r[i] + g[i]) < -0.04):
bust = IDX[a22 + i].date()
why = "daily-loss -4%" if (r[i] + g[i]) < -0.04 else "max-loss -6%"
return eq - 1.0, mn - 1.0, bust, why, float((r + g).min())
print(f"\n {'w TP01':>7} {'leva':>6} {'rend. 9 mesi':>13} {'peggior giorno':>15} "
f"{'min equity':>11} {'esito':>22}")
for w in W_GRID:
lv = tab[w]["rec"][0]
ret, mn, bust, why, wd = replay22(w, lv)
esito = "VIVO" if bust is None else f"BUSTATO {bust}"
print(f" {w:>7.3f} {lv:>6.3f} {ret:>13.1%} {wd:>15.2%} {mn:>11.2%} {esito:>22}")
print("\n Tutti VIVI, e non e' una buona notizia mal letta: **alle leve funded il 2022 non")
print(" era una minaccia per questo libro** (min equity -1.4/-2.4% contro una barriera al")
print(" -6%). Cio' che uccide un conto a barriera non e' il crash del sottostante: e' la")
print(" regola a UN giorno su una giornata qualunque. Ecco fin dove si puo' spingere la leva:")
print(f"\n {'w TP01':>7} {'leva max viva sul 2022':>23} {'rend. a quella leva':>20} "
f"{'primo vincolo che morde':>25}")
for w in W_GRID:
lo, hi = 0.1, 8.0
for _ in range(18):
mid = 0.5 * (lo + hi)
if replay22(w, mid)[2] is None:
lo = mid
else:
hi = mid
ret, _, _, _, _ = replay22(w, lo)
_, _, _, why, _ = replay22(w, hi)
print(f" {w:>7.3f} {lo:>23.2f}x {ret:>20.1%} {why:>25}")
print("\n ⚠️ La leva di rottura e' MONOTONA DECRESCENTE nel peso di TP01 solo se la")
print(" protezione fosse reale. Si legga la colonna: se il libro con piu' TP01 rompe PRIMA,")
print(" l'assicurazione non sta assicurando la cosa giusta.")
tick("§4 replay 2022")
# ============================================================ §5. NULL DEL DE-LEVERING
hr("§5. IL NULL DEL DE-LEVERING — la protezione di TP01 e' altro rispetto a meno leva?")
print(" Il null classico ('esiste k<1 che da' lo stesso rischio con risultato migliore?') qui")
print(" prende la forma giusta per una barriera: **frontiera ISO-SOPRAVVIVENZA**. Per ogni")
print(f" peso si cerca la leva k(w) tale che P(vivo 12m | replay del sinistro PEGGIORE) = "
f"{SURV_TARGET:.0%},")
print(" poi si confronta il PAYOUT atteso sulla finestra normale a quella leva. Se un libro")
print(" con meno TP01 e meno leva paga di piu' a pari sopravvivenza, la protezione di TP01")
print(" e' de-levering; se paga di meno, TP01 compra qualcosa che la leva non compra.")
_SP: dict = {}
def p_alive_stress(w: float, lev: float, seg: tuple) -> float:
"""P(vivo 12m) col replay del sinistro dentro. Solo `funded_sim`: l'eval non entra
nella definizione della frontiera, e calcolarlo raddoppierebbe il costo per niente."""
global N_EVAL
N_EVAL += 1
k = (round(w, 6), seg)
if k not in _SP:
Rp, Gp, _, _ = stress_panel(w)
_SP[k] = (Rp, Gp, splice_paths(base_fu, np.arange(seg[0], seg[1]), seed=8291))
Rp, Gp, fu = _SP[k]
return PA.funded_sim(Rp, Gp, fu, lev, "HYRO", 100_000.0, "coupled")["p_alive"]
print(f"\n {'w TP01':>7} {'k iso-surv':>11} {'P(viv|crash)':>13} {'E[pay/a] normale':>17} "
f"{'P(pass) normale':>16} {'J normale':>11}")
front = {}
for w in W_GRID:
Rp, Gp, _, segs = stress_panel(w)
# episodio peggiore per QUESTO peso (il piu' severo, misurato)
worst_seg = min(segs, key=lambda sg: np.prod(1.0 + Rp[sg[0]:sg[1]]))
lo, hi = 0.05, 3.0
for _ in range(14):
mid = 0.5 * (lo + hi)
if p_alive_stress(w, mid, worst_seg) >= SURV_TARGET:
lo = mid
else:
hi = mid
k = lo
R, G, _ = crypto(w)
off = deluck_offset(w)
o = J_of(R[i_rec:] - off, G[i_rec:], ev_r, fu_r, k)
front[w] = (k, o)
print(f" {w:>7.3f} {k:>11.3f} {p_alive_stress(w, k, worst_seg):>13.1%} "
f"{o['e_payout']:>16,.0f}$ {o['p_pass']:>16.1%} {o['J']:>11.3f}")
kbest = max(front, key=lambda w: front[w][1]["e_payout"])
jbest = max(front, key=lambda w: front[w][1]["J"])
print(f"\n argmax a ISO-SOPRAVVIVENZA: payout -> TP01 {kbest:.3f} | J -> TP01 {jbest:.3f}")
print(" ⚠️ La leva k(w) e' tarata SU un episodio che nel campione di ottimizzazione non c'e':")
print(" e' un esercizio con l'informazione del futuro. Serve a rispondere alla domanda del")
print(" filone ('se sapessi che arriva, come mi allocherei?'), NON e' una politica.")
tick("§5 frontiera iso-sopravvivenza")
# ============================================================ §6. IL TERZO SLEEVE
hr("§6. XS01 — la parte della domanda che NON e' rispondibile, e perche'")
print(" L'ottimo di PROP-ALLOC mette il 50% su XS01, che **nasce nel 2024**: per lui non")
print(" esiste NESSUN sinistro nel campione. Qui non lo si simula col bootstrap della sua")
print(" finestra (sarebbe assumere che nel crash si comporti come in un giorno normale, che")
print(" e' esattamente l'assunzione sotto esame). Si dichiarano TRE comportamenti e si")
print(" guarda se la conclusione dipende da quale:")
print(" A) FLAT nel crash (il gate di dispersione lo spegne) -> generosa")
print(" B) la sua PEGGIORE finestra di 30g del 2024+, contigua e in ordine -> severa")
print(" C) uno shock una-tantum di taglia X sul giorno peggiore del crash -> si cerca")
print(" la X di ROTTURA, cioe' quanto grande deve essere per portare P(vivo) sotto 50%")
xs = pc.xsec_daily_tuples()
xsR = xs["R"].values
xsG = np.minimum(xs["m"].values, xsR) - xsR
lxs = np.log1p(xsR)
cxs = np.concatenate(([0.0], np.cumsum(lxs)))
wxs30 = np.expm1(cxs[EPI_LEN:] - cxs[:-EPI_LEN])
t_xw = int(np.argmin(wxs30))
print(f"\n XS01: {len(xs)} giorni {xs.index[0].date()} -> {xs.index[-1].date()}; peggior "
f"giorno {xsR.min():.2%}, peggior minimo {xs['m'].min():.2%}")
print(f" peggior finestra di {EPI_LEN}g di XS01: {xs.index[t_xw].date()} -> "
f"{xs.index[t_xw+EPI_LEN-1].date()}, cumulato {wxs30[t_xw]:.1%} "
f"(ipotesi B). Per confronto la coppia crypto nel COVID fa -5.4% a TP01 pieno.")
print(" 📌 Il numero che conta e' questo: **la peggior finestra di 30g mai vista da XS01 vale")
print(f" {wxs30[t_xw]:.1%}**. Se nel crash XS01 facesse cosi', al 50% di peso porterebbe")
print(f" {0.5*wxs30[t_xw]:.1%} al libro. La domanda non e' se XS01 sopravvive a cio' che ha")
print(" gia' visto, e' se il suo comportamento nel crash somiglia a cio' che ha gia' visto —")
print(" e su questo il campione non ha NIENTE da dire.")
W3 = [(0.75, 0.25, 0.00), (0.375, 0.625, 0.00), (0.25, 0.25, 0.50), (0.125, 0.125, 0.75),
(0.50, 0.25, 0.25)]
com = xs.index.intersection(IDX[i_rec:])
xs_al = xs.reindex(com)
print(f"\n {'TP/SKH/XS':>16} {'ipotesi XS':>12} {'leva':>6} {'J normale':>10} "
f"{'P(viv|replay peggiore)':>24} {'shock di rottura':>18}")
for cfg in W3:
wtp, wsk, wxs = cfg
base = wtp + wsk
wtp_n = wtp / base if base > 0 else 0.0
Rc, Gc, Ic = crypto(wtp_n)
offc = deluck_offset(wtp_n)
m1 = Ic.isin(com)
Rn = base * (Rc[m1] - offc) + wxs * xs_al["R"].values
Gn = base * Gc[m1] + wxs * (np.minimum(xs_al["m"].values, xs_al["R"].values)
- xs_al["R"].values)
nn = len(Rn)
evn = PA.boot_idx(nn, HORIZON, N_PATHS, seed=8300)
fun = PA.boot_idx(nn, HORIZON, N_PATHS, seed=8301)
lv, on = best_lev(Rn, Gn, evn, fun)
bfu = PA.boot_idx(nn, HORIZON, N_PATHS, seed=8302)
lab = f"{int(round(wtp*100))}/{int(round(wsk*100))}/{int(round(wxs*100))}"
def build(hyp: str, shock: float = 0.0):
segsR, segsG = [], []
for t in epis:
rc = base * (crypto(wtp_n)[0][t:t + EPI_LEN] - offc)
gc = base * crypto(wtp_n)[1][t:t + EPI_LEN]
if wxs > 0:
if hyp == "A-flat":
rx = np.zeros(EPI_LEN); gx = np.zeros(EPI_LEN)
elif hyp == "B-peggiore":
rx = xsR[t_xw:t_xw + EPI_LEN].copy()
gx = xsG[t_xw:t_xw + EPI_LEN].copy()
else:
rx = np.zeros(EPI_LEN); gx = np.zeros(EPI_LEN)
rx[int(np.argmin(rc))] = -shock
segsR.append(rc + wxs * rx); segsG.append(gc + wxs * gx)
else:
segsR.append(rc); segsG.append(gc)
Rp = np.concatenate([Rn] + segsR)
Gp = np.concatenate([Gn] + segsG)
sg = [(nn + k * EPI_LEN, nn + (k + 1) * EPI_LEN) for k in range(len(epis))]
worst = 1.0
for k, g2 in enumerate(sg):
fu = splice_paths(bfu, np.arange(g2[0], g2[1]), seed=8320 + k)
worst = min(worst, J_of(Rp, Gp, evn, fu, lv)["p_alive"])
return worst
if wxs == 0:
print(f" {lab:>16} {'n/a (no XS)':>12} {lv:>6.3f} {on['J']:>10.3f} "
f"{build('A-flat'):>24.1%} {'-':>18}")
continue
for hyp in ("A-flat", "B-peggiore"):
print(f" {lab:>16} {hyp:>12} {lv:>6.3f} {on['J']:>10.3f} "
f"{build(hyp):>24.1%} {'-':>18}")
lo, hi = 0.0, 0.40 # shock di rottura: bisezione su P(vivo) = 50%
for _ in range(9):
mid = 0.5 * (lo + hi)
if build("C-shock", mid) >= 0.50:
lo = mid
else:
hi = mid
print(f" {lab:>16} {'C-shock':>12} {lv:>6.3f} {on['J']:>10.3f} {'(50% per def.)':>24} "
f"{-lo:>17.1%}")
print("\n Lettura: 'shock di rottura' = il crollo di UN giorno su XS01 che porta P(vivo) al")
print(" 50% quando il crash arriva. Piu' e' piccolo, piu' l'allocazione XS01-pesante e' a un")
print(" giorno di coda dalla morte. Non e' una previsione: e' la taglia della scommessa.")
tick("§6 XS01")
# ============================================================ §7. BANDA + VERDETTO
hr("§7. SENSIBILITA' AL DE-LUCK — l'ORDINE fra i pesi sopravvive alla banda?")
print(" La banda d'ancora non e' ri-misurabile sotto questa lente (costo, dichiarato anche da")
print(" PROP-ALLOC). Si applica il fattore misurato x0.89 e si controlla l'unica cosa che")
print(" conta: se l'argmax sui pesi si muove fra 1.00 (nessuna correzione) e 0.60 (la stima")
print(" a occhio del 25/07, poi ri-misurata troppo severa).")
print(f"\n {'fattore':>9} {'argmax 2019+':>14} {'argmax 2024+':>14} {'argmax SOLO-SIN':>16} "
f"{'J(75/25) 2019+':>16} {'J(38/62) 2019+':>16}")
for fct in (1.00, 0.89, 0.60):
best_f = best_r = best_s = None
vals = {}
for w in W_GRID:
R, G, _ = crypto(w)
o = deluck_offset(w, fct)
Rf = R - o
j1 = best_lev(Rf, G, ev_f, fu_f)[1]["J"]
j2 = best_lev(Rf[i_rec:], G[i_rec:], ev_r, fu_r)[1]["J"]
j3 = best_lev(Rf, G, ev_s, fu_s)[1]["J"]
vals[w] = (j1, j2, j3)
best_f = max(vals, key=lambda w: vals[w][0])
best_r = max(vals, key=lambda w: vals[w][1])
best_s = max(vals, key=lambda w: vals[w][2])
print(f" {fct:>9.2f} {best_f:>14.3f} {best_r:>14.3f} {best_s:>16.3f} "
f"{vals[0.75][0]:>16.3f} {vals[0.375][0]:>16.3f}")
hr("§8. I GATE — cosa e' girato, cosa NON e' girato e perche'")
print(" Questo filone non propone una strategia nuova: propone (o rifiuta) un'ALLOCAZIONE per")
print(" un conto che non esiste. I gate del progetto vanno dichiarati uno per uno.")
try:
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
import altlib as A # noqa: E402
for lab, w in (("LIVE 75/25", 0.75), ("OPT 38/62", 0.375)):
R = crypto(w)[0] - deluck_offset(w)
d = pd.Series(R, index=IDX)
n_act = int((np.abs(R) > 1e-12).sum())
n_los = int((R[np.abs(R) > 1e-12] < 0).sum())
f = A.implausible_sharpe(d, n_trades=n_act, n_losing_trades=n_los)
print(f" implausible_sharpe {lab:>11}: {f}")
except Exception as exc: # pragma: no cover
print(f" implausible_sharpe: NON GIRATO ({exc})")
print(" marginal_vs_tp01 / study_family_honest / deflated_sharpe : **NON GIRATI, e non")
print(" sono applicabili** — non c'e' uno stream candidato da confrontare con TP01: gli")
print(" stream sono i due sleeve gia' ammessi, e l'oggetto e' il loro PESO. Il gate dei")
print(" pesi del progetto e' `weights_tilt_null`, che pero' giudica lo SHARPE del libro")
print(" di RICERCA: qui l'obiettivo e' J su una barriera, e il conto e' separato.")
print(" (PROP-ALLOC lo ha girato: gate_pass False. Non cambia nulla qui: nessuna")
print(" proposta di questo file tocca il libro live.)")
print(" causality_ok : **non girato, non applicabile** — nessun segnale nuovo; le serie")
print(" sono quelle di produzione, gia' verificate leak-free a monte.")
print(" anchor_luck_band : **non ri-misurata** (costo, come in PROP-ALLOC). Si e' girata")
print(" la sensibilita' al FATTORE di de-luck (§7), che e' meno: dice se l'ORDINE regge,")
print(" non qual e' la banda. Distorsione dichiarata: la lente e' l'ancora CANONICA e")
print(" SKH01 e' lo sleeve con la fortuna d'ancora piu' grande da restituire (26/07)")
print(" -> **la distorsione e' A FAVORE della conclusione di questo file**, cioe' del")
print(" peso basso su TP01. E' il caveat piu' importante del filone.")
print(" null del de-levering : **GIRATO** (§5, frontiera iso-sopravvivenza) — e superato")
print(" NEL VERSO OPPOSTO a quello atteso: e' la protezione di TP01 a essere replicabile")
print(" con meno leva, e meglio.")
hr("§9. LA RISPOSTA OPERATIVA")
print(f" valutazioni MC totali di questo file: **{N_EVAL}** "
f"(ognuna = {N_PATHS} percorsi di eval + {N_PATHS} di funded)")
print(f"\n {'lente':>26} {'argmax w TP01':>14} {'J argmax':>10} {'J a 0.75':>10} "
f"{'J a 0.375':>11}")
for lab, key in (("2019+ (contiene il 2022)", "full"), ("2024+ (PROP-ALLOC)", "rec"),
("SOLO-SINISTRO", "sin")):
am = max(tab, key=lambda w: tab[w][key][1]["J"])
print(f" {lab:>26} {am:>14.3f} {tab[am][key][1]['J']:>10.3f} "
f"{tab[0.75][key][1]['J']:>10.3f} {tab[0.375][key][1]['J']:>11.3f}")
print("\n" + "=" * 106)
print(f" fatto in {time.time()-t0:.0f}s | valutazioni: {N_EVAL}")
print("=" * 106)
if __name__ == "__main__":
main()
+683
View File
@@ -0,0 +1,683 @@
"""r0822b_xs01_oos.py — XS01 FUORI dalla sua finestra di scoperta (2026-08-22, ondata wave-0822).
IL PROBLEMA (filone PROP-ALLOC, RESULTS-0822 §1)
------------------------------------------------
Il risultato piu' vicino all'obiettivo dell'operatore e': su un conto FUNDED da $100k, aggiungere
XS01 al libro porta J = P(pass) x P(vivo 12m) da 0,335 a 0,738. Ma **tutto** quel vantaggio poggia
sul drift di XS01 misurato sulla SUA STESSA finestra di scoperta (Hyperliquid, 2024-01 -> oggi,
~2,6 anni, e senza un solo mercato orso). Azzerando quel drift l'uplift scende a +0,038; sotto
stress congiunto diventa negativo. Il gate proposto era "non aprire un funded finche' XS01 non ha
una finestra fuori dal 2024-2026" = ANNI di attesa.
LA DOMANDA DI QUESTO FILONE
---------------------------
Si puo' testare il MECCANISMO CONGELATO su una storia PIU' LUNGA e INDIPENDENTE invece di aspettare?
La risposta e' si', e il vincolo non e' il dato ma il CALENDARIO DELLE QUOTAZIONI: 6 dei 19 asset
(APT, OP, ARB, SUI, SEI, TIA) non esistono prima del 2022-06/2023-10. Il piu' vecchio universo
>= 10 asset dei 19 e' del **2020-10** -> circa **3 anni interi fuori dalla finestra di scoperta,
2021-01 -> 2023-12**, che contengono il top 2021, il crollo di maggio 2021, **LUNA e FTX (2022)** e
la ripresa 2023. E' esattamente il regime che manca al campione di scoperta.
IL DATO: BINANCE, E PERCHE' L'OBIEZIONE DEL PROGETTO NON MORDE QUI
-----------------------------------------------------------------
CLAUDE.md: "Binance NON e' la verita' (e' USDT, ~10 bps fuori dal consenso, e sotto depeg fino al
3%) -> usare SOLO come audit indipendente, mai come ancora per ripulire i dati". Quella regola
riguarda l'uso come **ANCORA DI PREZZO** (livello). Qui Binance serve come **audit indipendente per
un segnale CROSS-SECTIONAL DI RANKING su barre giornaliere**, e i tre difetti citati sono tutti
**fattori COMUNI alla valuta di quotazione**:
- uno scarto costante di ~10 bps sul livello e' identico su tutti e 19 gli asset -> sparisce
ESATTAMENTE nello z-score cross-sectional (che sottrae la media di sezione);
- un depeg USDT muove tutte le gambe insieme -> non cambia il RANKING, e il libro e'
dollar-neutral (0.5/k long, -0.5/k short) quindi il fattore comune si cancella anche nel P&L;
- a 1d un errore di livello di 10 bps e' 1/50 della vol giornaliera tipica di un alt.
NON e' un argomento teorico: e' MISURATO qui sotto (sezione 3) confrontando il meccanismo su
Binance e su Hyperliquid nella finestra in cui esistono ENTRAMBI (2024+).
E i dati scaricati sono CERTIFICATI comunque (barre flat, gap di calendario, volumi zero =
backfill sintetico: il progetto ci e' gia' cascato una volta, correzione AXS del 2026-06-20).
MECCANISMO: CONGELATO. Nessun parametro toccato. E' `src/portfolio/sleeves.py::_xsec_returns`
(lookbacks (30,90), H=10, k=5, mode="mom", target_vol=0.20, gate dispersione p30 espandente
causale, fee 5 bps/lato, vol-target su rv30 shiftata, clip leva 3x). Il motore qui sotto e' una
versione MASCHERATA (regge NaN = asset non ancora quotato) e la sezione 2 dimostra che riproduce
lo sleeve ufficiale a **max|diff| = 0.0** sui dati HL.
ESITO (2026-08-22, corsa completa 78 s)
---------------------------------------
- Il motore replica lo sleeve ufficiale a max|diff| = 0.0; Binance e Hyperliquid danno lo STESSO
sleeve sulla finestra comune (corr 0.9991, dSharpe -0.025) -> la storia lunga e' utilizzabile.
- Fuori dalla finestra di scoperta (2021-01 -> 2023-12, contiene LUNA e FTX) il meccanismo ha un
edge: Sharpe MEDIANO di fase **+1.12** (U13 fisso) / **+1.17** (universo espandente), positivo in
**10/10** fasi, p = 0.013 contro il null di permutazione cross-sezionale a fee zero.
- **Il fuori campione e' MIGLIORE della finestra di scoperta**, a parita' di universo: differenza
appaiata per fase +0.668 (10/10 fasi). L'ipotesi "il drift di XS01 e' un artefatto del 2024-2026"
e' REFUTATA per la parte testabile del sleeve.
- MA: il maxDD raddoppia (10.8% nella scoperta -> 20.9-22.0% mediano fuori), il 2021 vale meta'
dell'edge, una gamba sola (LTC) ne vale 0.9 di Sharpe, e i due affinamenti del 2026-06-19 non si
replicano. E il deflated-Sharpe FALLISCE (0.342-0.418).
- Sul LIBRO, fuori campione: TP01+SKH01 75/25 Sharpe +1.17 -> **+1.65** e maxDD 9.4% -> **6.0%**
a peso XS01 0.25; a peso **0.50** (l'argmax di PROP-ALLOC) il maxDD risale a **8.9%**.
Uso: nice -n 19 timeout 900 uv run python scripts/research/r0822b_xs01_oos.py
Rete: solo lettura, <= 2 req/s, MAI nella finestra :25-:29 (rate limit per-IP condiviso col
collettore della catena). Il download e' messo in cache nello scratchpad: la seconda corsa e'
offline.
"""
from __future__ import annotations
import math
import os
import sys
import time
from datetime import datetime, timezone
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
from src.portfolio.sleeves import XS_CFG, XS_UNIVERSE # noqa: E402
CACHE = Path(os.environ.get(
"R0822B_CACHE",
"/tmp/claude-1001/-opt-docker-PythagorasGoal/b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad/r0822b",
))
CACHE.mkdir(parents=True, exist_ok=True)
RAW = ROOT / "data" / "raw"
FEE_SIDE = 0.001 / 2.0 # identica al sleeve congelato
DISCOVERY = pd.Timestamp("2024-01-01", tz="UTC") # inizio della finestra di scoperta (HL)
# I 13 dei 19 quotati su Binance prima del 2020-11 -> l'universo FISSO piu' lungo possibile
# che rispetta il vincolo del meccanismo congelato (>= 10 gambe).
U13 = ["BTC", "ETH", "BNB", "XRP", "LTC", "ADA", "LINK", "DOGE", "SOL", "AVAX", "NEAR", "AAVE", "INJ"]
SECTORS = ["xlb", "xlc", "xle", "xlf", "xli", "xlk", "xlp", "xlre", "xlu", "xlv", "xly"]
# ===========================================================================
# 0. disciplina di rete
# ===========================================================================
def _outside_forbidden_window() -> None:
"""Il collettore della catena gira al minuto :25 e satura il rate limit per-IP di Deribit;
la regola d'ondata e' non toccare la rete fra :25 e :29. Qui parliamo con Binance, ma la
disciplina e' della MACCHINA (2 core, libro con soldi veri), non del venue."""
while True:
m = datetime.now(timezone.utc).minute
if not (25 <= m <= 29):
return
print(f" [rete] minuto :{m:02d} nella finestra vietata :25-:29 — attendo 30s", flush=True)
time.sleep(30)
# ===========================================================================
# 1. FETCH + CERTIFY (Binance spot USDT, 1d)
# ===========================================================================
def fetch_binance_daily(sym: str, since_iso: str = "2017-01-01T00:00:00Z") -> pd.DataFrame:
f = CACHE / f"bin_{sym.lower()}_1d.parquet"
if f.exists():
return pd.read_parquet(f)
import ccxt
ex = ccxt.binance({"enableRateLimit": True})
since = ex.parse8601(since_iso)
rows, cur = [], since
while True:
_outside_forbidden_window()
batch = ex.fetch_ohlcv(f"{sym}/USDT", "1d", since=cur, limit=1000)
time.sleep(0.5) # <= 2 req/s
if not batch:
break
rows.extend(batch)
if len(batch) < 1000:
break
cur = batch[-1][0] + 86_400_000
d = pd.DataFrame(rows, columns=["timestamp", "open", "high", "low", "close", "volume"])
d = d.drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True)
d.to_parquet(f)
return d
def certify(sym: str, d: pd.DataFrame) -> dict:
"""Certificazione locale, stessa forma di scripts/analysis/fetch_hyperliquid.py.
Il rivelatore del backfill SINTETICO NON e' il prezzo (cerbero copiava i prezzi da un altro
venue) ma il **VOLUME**: un run di barre a volume 0 e' padding, non mercato."""
t = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
c = d["close"].values.astype(float)
v = d["volume"].values.astype(float)
span = (t.max() - t.min()).days + 1
flat = float(np.mean(c[1:] == c[:-1])) if len(c) > 1 else float("nan")
zero_v = float(np.mean(v == 0.0))
# run iniziale a volume zero = firma del padding sintetico (lezione AXS 2026-06-20)
lead0 = int(np.argmax(v > 0)) if (v > 0).any() else len(v)
return dict(sym=sym, n=len(d), start=str(t.min().date()), end=str(t.max().date()),
gap_days=int(span - len(d)), flat=flat, zero_vol=zero_v, lead_zero_vol=lead0,
ok=bool(span - len(d) == 0 and flat < 0.02 and zero_v < 0.01 and lead0 == 0))
def load_panel(syms: list[str]) -> pd.DataFrame:
"""Pannello dei CLOSE, indice giornaliero UTC, NaN dove l'asset non e' ancora quotato."""
cols = {}
for s in syms:
d = fetch_binance_daily(s)
cols[s] = pd.Series(d["close"].values.astype(float),
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
return pd.concat(cols, axis=1).sort_index()
def load_sector_panel() -> pd.DataFrame:
cols = {}
for s in SECTORS:
d = pd.read_parquet(RAW / f"eq_{s}_1d.parquet")
cols[s.upper()] = pd.Series(d["close"].values.astype(float),
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
return pd.concat(cols, axis=1).sort_index()
# ===========================================================================
# 2. MOTORE — meccanismo CONGELATO, versione mascherata (regge i NaN)
# ===========================================================================
def xsec_engine(P: pd.DataFrame, phase: int = 0, fee_side: float = FEE_SIDE,
mode: str | None = None, min_assets: int = 10,
perm_rng: np.random.Generator | None = None,
perm_kind: str = "cross", lookbacks=None, disp_pct=None,
detail: dict | None = None) -> pd.Series:
"""Riproduce `sleeves._xsec_returns` riga per riga. Unica differenza: l'insieme ammissibile
e' calcolato PER RIBILANCIAMENTO (un asset entra quando ha almeno max(lookbacks) barre), cosi'
il meccanismo si puo' applicare a un universo che si allarga nel tempo — che e' cio' che un
implementatore in tempo reale avrebbe avuto. Con un pannello senza NaN, e' identico all'originale.
`perm_rng` attiva un NULL: 'cross' = permuta lo score FRA GLI ASSET al ribilanciamento
(distrugge l'informazione, conserva ESATTAMENTE la struttura di posizione e di turnover),
'sign' = pesi casuali fra gli ammissibili (stesso gross, stesso k)."""
px = P.values.astype(float)
n, Ncols = px.shape
H, k = XS_CFG["H"], XS_CFG["k"]
lookbacks = tuple(XS_CFG["lookbacks"] if lookbacks is None else lookbacks)
disp_pct = XS_CFG["disp_pct"] if disp_pct is None else disp_pct
tv, minhist = XS_CFG["target_vol"], XS_CFG["disp_minhist"]
mode = mode or XS_CFG["mode"]
mlb = max(lookbacks)
with np.errstate(invalid="ignore", divide="ignore"):
dret = np.vstack([np.zeros(Ncols), px[1:] / px[:-1] - 1.0])
dret = np.nan_to_num(dret, nan=0.0, posinf=0.0, neginf=0.0)
W = np.zeros((n, Ncols)); w = np.zeros(Ncols); disp_hist: list[float] = []
n_rebal = n_traded = 0
for i in range(n):
if i >= mlb and (i % H) == phase:
elig = np.isfinite(px[i])
for L in lookbacks:
elig = elig & np.isfinite(px[i - L])
m = int(elig.sum())
if m >= min_assets:
n_rebal += 1
idx = np.flatnonzero(elig)
rLs = [px[i, idx] / px[i - L, idx] - 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(m); cnt = 0
for rL in rLs:
sd = rL.std()
if sd > 0:
score += (rL - rL.mean()) / sd; cnt += 1
if cnt:
score /= cnt
w = np.zeros(Ncols)
if perm_rng is not None and perm_kind == "sign":
pick = perm_rng.permutation(m)
lo, hi = idx[pick[:k]], idx[pick[-k:]]
else:
if perm_rng is not None: # perm_kind == "cross"
score = score[perm_rng.permutation(m)]
order = np.argsort(score)
lo, hi = idx[order[:k]], idx[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
n_traded += 1
else:
w = np.zeros(Ncols)
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 * fee_side
s = pd.Series(net, index=P.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)
out = pd.Series(s.values * scale, index=P.index)
out.attrs["n_rebal"] = n_rebal
out.attrs["n_traded"] = n_traded
if detail is not None:
detail.update(W=W, scale=scale, turn=turn, index=P.index,
n_rebal=n_rebal, n_traded=n_traded)
return out
def hl_panel(syms=None) -> pd.DataFrame:
"""Pannello Hyperliquid, ESATTAMENTE come lo costruisce lo sleeve congelato."""
cols = {}
for sym in (syms or XS_UNIVERSE):
p = RAW / f"hl_{sym.lower()}_1d.parquet"
if not p.exists():
continue
d = pd.read_parquet(p)
cols[sym] = pd.Series(d["close"].values.astype(float),
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
return pd.concat(cols, axis=1, join="inner").sort_index().dropna()
# ===========================================================================
# metriche
# ===========================================================================
def sh(s: pd.Series) -> float:
return A._sh(s)
def dd(s: pd.Series) -> float:
return A._dd_ret(s)
def cagr(s: pd.Series) -> float:
r = s.dropna()
if len(r) < 30:
return float("nan")
yrs = (r.index[-1] - r.index[0]).days / 365.25
return float(np.prod(1.0 + r.values) ** (1.0 / yrs) - 1.0) if yrs > 0 else float("nan")
def win(s: pd.Series, a: str | None = None, b: str | None = None) -> pd.Series:
x = s
if a:
x = x[x.index >= pd.Timestamp(a, tz="UTC")]
if b:
x = x[x.index < pd.Timestamp(b, tz="UTC")]
return x
def alive(s: pd.Series) -> pd.Series:
"""Taglia il prefisso di ZERI (warm-up dei lookback + attesa che l'universo raggiunga 10
gambe). ⚠️ Bug catturato nella prima corsa: sull'universo ESPANDENTE il pannello parte dal
2017 (BTC) ma il meccanismo non puo' operare prima del 2020-12, e i ~1.200 zeri iniziali
entravano nella finestra 'PRE-2024' abbassandone lo Sharpe e ribaltando la differenza
appaiata. E' la lezione gia' codificata nel progetto (barre ATTIVE, non di calendario),
ripetuta in un contesto nuovo."""
v = np.asarray(s.values, float)
nz = np.flatnonzero(v != 0.0)
return s.iloc[nz[0]:] if len(nz) else s
def line(tag: str, s: pd.Series) -> str:
return (f"{tag:<22s} n={len(s):>5d} Sh {sh(s):+6.2f} maxDD {dd(s)*100:5.1f}% "
f"CAGR {cagr(s)*100:+6.2f}% vol {s.std()*np.sqrt(365.25)*100:5.1f}%")
# ===========================================================================
def main() -> None:
t0 = time.time()
print("=" * 100)
print("XS01 FUORI DALLA FINESTRA DI SCOPERTA — meccanismo CONGELATO su storia lunga indipendente")
print(f"XS_CFG = {XS_CFG}")
print("=" * 100)
# ---------------------------------------------------------------- 1. dato
print("\n[1] DATO — Binance spot USDT 1d, certificazione locale")
panel19 = load_panel(XS_UNIVERSE)
cert = [certify(s, fetch_binance_daily(s)) for s in XS_UNIVERSE]
bad = [c for c in cert if not c["ok"]]
print(f" {len(cert)} serie scaricate; certificate OK: {len(cert)-len(bad)}/{len(cert)}")
print(f" {'sym':<6}{'n':>6}{'start':>12}{'end':>12}{'gap':>5}{'flat%':>8}{'vol0%':>8}{'lead0':>7}")
for c in cert:
flag = "" if c["ok"] else " <-- CONTROLLA"
print(f" {c['sym']:<6}{c['n']:>6}{c['start']:>12}{c['end']:>12}{c['gap_days']:>5}"
f"{c['flat']*100:>8.2f}{c['zero_vol']*100:>8.2f}{c['lead_zero_vol']:>7}{flag}")
print(" NB: 'lead0' = barre iniziali a volume 0 = firma del backfill sintetico (lezione AXS).")
# ---------------------------------------------------- 2. replica bit-exact
print("\n[2] SANITY — il motore riproduce lo sleeve UFFICIALE?")
from src.portfolio.sleeves import _xsec_returns
off = _xsec_returns()
rep = xsec_engine(hl_panel(), phase=0)
j = pd.concat({"off": off, "rep": rep}, axis=1, join="inner").dropna()
md = float(np.max(np.abs(j["off"] - j["rep"]))) if len(j) else float("nan")
print(f" n={len(j)} max|diff| = {md:.2e} -> {'IDENTICO' if md < 1e-12 else 'DIVERGE'}")
print(f" {line('XS01 ufficiale (HL)', off)}")
if md >= 1e-12:
print(" !! il motore NON replica: ogni numero sotto e' sospetto. STOP.")
return
# ------------------------------------------------ 3. validazione di VENUE
print("\n[3] VALIDAZIONE DI VENUE — Binance vs Hyperliquid dove esistono ENTRAMBI (2024+)")
hlp = hl_panel()
b19 = panel19.reindex(columns=XS_UNIVERSE)
common = b19.index.intersection(hlp.index)
rr = []
for s in XS_UNIVERSE:
a1 = hlp[s].reindex(common).pct_change()
b1 = b19[s].reindex(common).pct_change()
k2 = pd.concat({"a": a1, "b": b1}, axis=1).dropna()
rr.append((s, float(k2["a"].corr(k2["b"])), float(np.median(np.abs(k2["a"] - k2["b"]))) * 1e4))
print(f" ritorni giornalieri per asset, {len(common)} giorni comuni:")
print(" " + " ".join(f"{s}:{c:.4f}" for s, c, _ in rr))
print(f" corr min {min(c for _, c, _ in rr):.4f} | mediana |diff| ritorno: "
f"{np.median([m for _, _, m in rr]):.1f} bps (max {max(m for _, _, m in rr):.1f} bps)")
# e soprattutto: il MECCANISMO da' la stessa cosa?
bin_on_hl_window = xsec_engine(b19.loc[common].dropna(), phase=0)
hl_on_window = xsec_engine(hlp.loc[common], phase=0)
jj = pd.concat({"bin": bin_on_hl_window, "hl": hl_on_window}, axis=1, join="inner").dropna()
print(f" {line('XS01 su HL 2024+', jj['hl'])}")
print(f" {line('XS01 su BINANCE 2024+', jj['bin'])}")
print(f" corr fra le DUE VERSIONI DELLO SLEEVE: {jj['bin'].corr(jj['hl']):.4f} "
f"dSharpe = {sh(jj['bin'])-sh(jj['hl']):+.3f}")
print(" -> se questa corr e' ~1, la storia lunga Binance e' un'estensione legittima:")
print(" lo scarto USDT e' un FATTORE COMUNE e sparisce nello z-score cross-sectional.")
# ------------------------------------- 4. la storia lunga, fuori campione
print("\n[4] STORIA LUNGA — meccanismo congelato, ancora canonica (fase 0)")
p13 = panel19[U13].dropna() # universo FISSO 13 (il piu' lungo a >=10 gambe)
s13 = xsec_engine(p13, phase=0)
sEXP = xsec_engine(panel19, phase=0) # universo che si ALLARGA (19 quando esistono)
s13, sEXP = alive(s13), alive(sEXP)
print(f" U13-FIX universo fisso {len(U13)} gambe, dal {s13.index[0].date()} "
f"({s13.attrs.get('n_traded')}/{s13.attrs.get('n_rebal')} ribilanciamenti con posizione)")
print(f" U19-EXP universo espandente (10 -> 19 gambe), dal {sEXP.index[0].date()}")
for tag, s in (("U13-FIX", s13), ("U19-EXP", sEXP)):
print(f" {line(tag + ' FULL', s)}")
print(f" {line(tag + ' PRE-2024 (OOS)', win(s, None, '2024-01-01'))}")
print(f" {line(tag + ' 2024+ (scoperta)', win(s, '2024-01-01'))}")
print("\n Sharpe per ANNO (netto fee):")
yrs = sorted({d.year for d in sEXP.index})
hdr = " " + f"{'':<10}" + "".join(f"{y:>9d}" for y in yrs)
print(hdr)
for tag, s in (("U13-FIX", s13), ("U19-EXP", sEXP), ("XS01 HL", off)):
cells = []
for y in yrs:
x = win(s, f"{y}-01-01", f"{y+1}-01-01")
cells.append(f"{sh(x):>9.2f}" if len(x) > 60 else f"{'':>9s}")
print(f" {tag:<10}" + "".join(cells))
print("\n Sharpe per REGIME:")
regimes = [("2021 toro/top", "2021-01-01", "2022-01-01"),
("2022 ORSO (LUNA/FTX)", "2022-01-01", "2023-01-01"),
("2023 ripresa", "2023-01-01", "2024-01-01"),
("2024-26 scoperta", "2024-01-01", None)]
for nm, a, b in regimes:
x13, xEX = win(s13, a, b), win(sEXP, a, b)
print(f" {nm:<24s} U13 Sh {sh(x13):+6.2f} DD {dd(x13)*100:5.1f}% | "
f"U19-EXP Sh {sh(xEX):+6.2f} DD {dd(xEX)*100:5.1f}%")
# -------------------------------------------- 5. fortuna di FASE (H = 10)
print("\n[5] FORTUNA DI FASE — H=10, tutte e 10 le ancore (la stima onesta e' la MEDIANA)")
phases = list(range(XS_CFG["H"]))
bands = {}
for tag, P, cut in (("U13-FIX FULL", p13, None), ("U13-FIX PRE-2024", p13, "pre"),
("U19-EXP FULL", panel19, None), ("U19-EXP PRE-2024", panel19, "pre")):
def f(ph, P=P, cut=cut):
s = alive(xsec_engine(P, phase=ph))
return win(s, None, "2024-01-01") if cut == "pre" else s
bands[tag] = A.anchor_luck_band(f, phases, canonical=0, metric=sh)
bands[tag + "|DD"] = A.anchor_luck_band(f, phases, canonical=0, metric=lambda x: -dd(x))
for tag in ("U13-FIX FULL", "U13-FIX PRE-2024", "U19-EXP FULL", "U19-EXP PRE-2024"):
r = bands[tag]; rd = bands[tag + "|DD"]
print(f" {tag:<20s} Sh canonico {r['canonical']:+.2f} (pctl {r['canonical_pctl']*100:4.0f}°) "
f"| MEDIANA {r['median']:+.2f} | banda [{r['lo']:+.2f},{r['hi']:+.2f}] "
f"| fasi>0 {r['frac_positive']*100:3.0f}% | fortuna {r['luck']:+.2f} | gate {r['gate_pass']}")
print(f" {'':<20s} maxDD canonico {-rd['canonical']*100:5.1f}% "
f"| MEDIANA {-rd['median']*100:5.1f}% | banda [{-rd['hi']*100:.1f},{-rd['lo']*100:.1f}]%")
# anche lo sleeve UFFICIALE su HL, de-luckato di fase (per confronto onesto)
bhl = A.anchor_luck_band(lambda ph: xsec_engine(hlp, phase=ph), phases, canonical=0, metric=sh)
print(f" {'XS01 UFFICIALE (HL)':<20s} Sh canonico {bhl['canonical']:+.2f} "
f"(pctl {bhl['canonical_pctl']*100:4.0f}°) | MEDIANA {bhl['median']:+.2f} "
f"| banda [{bhl['lo']:+.2f},{bhl['hi']:+.2f}] | fortuna {bhl['luck']:+.2f}")
# -------------- 6. differenza APPAIATA fuori campione vs finestra di scoperta
print("\n[6] DIFFERENZA APPAIATA per fase: (fuori campione) - (finestra di scoperta)")
for tag, P in (("U13-FIX", p13), ("U19-EXP", panel19)):
def pre(ph, P=P):
return win(alive(xsec_engine(P, phase=ph)), None, "2024-01-01")
def post(ph, P=P):
return win(alive(xsec_engine(P, phase=ph)), "2024-01-01")
d1 = A.anchor_luck_delta(pre, post, phases, metric=sh)
print(f" {tag:<10s} mediana DIFF appaiata {d1['median_paired']:+.3f} "
f"[{d1['lo']:+.3f},{d1['hi']:+.3f}] fasi con OOS>scoperta {d1['n_positive']}/{d1['n_anchors']}")
# ------------------------------------------------------------- 7. i NULL
print("\n[7] NULL — a FEE ZERO (permutare un segnale ne fa esplodere il turnover: a fee piene")
print(" il null perderebbe per COSTO invece che per assenza di informazione — regola XSR01)")
NDRAW = 300
for tag, P, a, b in (("U13-FIX PRE-2024", p13, None, "2024-01-01"),
("U13-FIX FULL", p13, None, None)):
real = win(alive(xsec_engine(P, phase=0, fee_side=0.0)), a, b)
for kind in ("cross", "sign"):
rng = np.random.default_rng(20260822)
draws = np.array([sh(win(alive(xsec_engine(P, phase=0, fee_side=0.0, perm_rng=rng,
perm_kind=kind)), a, b)) for _ in range(NDRAW)])
p = float(np.mean(draws >= sh(real)))
print(f" {tag:<20s} null '{kind}': reale(lordo) {sh(real):+.2f} vs null "
f"med {np.median(draws):+.2f} max {draws.max():+.2f} -> p = {p:.4f}")
print("\n NULL 'universo dove NON dovrebbe funzionare' — 11 settoriali SPDR, 1998+,")
print(" stesso meccanismo congelato (il progetto ha gia' misurato che il cross-sectional")
print(" crypto non generalizza alle azioni: XSR01, 2026-07-26):")
sect = load_sector_panel()
ssec = alive(xsec_engine(sect, phase=0, min_assets=9))
bsec = A.anchor_luck_band(lambda ph: alive(xsec_engine(sect, phase=ph, min_assets=9)), phases,
canonical=0, metric=sh)
print(f" {line('SECT11 (azioni)', ssec)}")
print(f" SECT11 banda di fase: mediana Sh {bsec['median']:+.2f} "
f"[{bsec['lo']:+.2f},{bsec['hi']:+.2f}], fasi>0 {bsec['frac_positive']*100:.0f}%")
print("\n CONTROLLO DI DIREZIONE — stesso meccanismo con mode='rev' (long i deboli):")
for tag, P in (("U13-FIX", p13), ("U19-EXP", panel19)):
r = alive(xsec_engine(P, phase=0, mode="rev"))
print(f" {line(tag + ' REV', r)}")
# ------------- 7b. la SELEZIONE del 2026-06-19, rivalidata FUORI CAMPIONE
print("\n[7b] LA SELEZIONE DEL 2026-06-19, RIVALIDATA FUORI CAMPIONE")
print(" XS01 non e' solo un meccanismo: e' un meccanismo AFFINATO due volte sui dati HL")
print(" 2024+ (blend di lookback [30,90] e gate di dispersione p30). Quelle DUE scelte sono")
print(" debito di multiple-testing contratto sulla finestra di scoperta. Qui si paga:")
print(" ogni variante e' valutata sul PRE-2024, a MEDIANA delle 10 fasi (mai l'ancora).")
variants = [("blend [30,90] + gate p30 (CANONICA)", dict(lookbacks=(30, 90), disp_pct=30)),
("solo L=30 + gate p30", dict(lookbacks=(30,), disp_pct=30)),
("solo L=90 + gate p30", dict(lookbacks=(90,), disp_pct=30)),
("blend [30,90] SENZA gate", dict(lookbacks=(30, 90), disp_pct=0)),
("blend [30,90] + gate p15", dict(lookbacks=(30, 90), disp_pct=15)),
("blend [30,90] + gate p50", dict(lookbacks=(30, 90), disp_pct=50))]
sel_rows = []
for nm, kw in variants:
b_pre = A.anchor_luck_band(
lambda ph, kw=kw: win(alive(xsec_engine(p13, phase=ph, **kw)), None, "2024-01-01"),
phases, canonical=0, metric=sh)
b_post = A.anchor_luck_band(
lambda ph, kw=kw: win(alive(xsec_engine(p13, phase=ph, **kw)), "2024-01-01"),
phases, canonical=0, metric=sh)
sel_rows.append((nm, b_pre["median"], b_post["median"], b_pre["per_offset"]))
print(f" {nm:<38s} PRE-2024 mediana Sh {b_pre['median']:+5.2f} "
f"[{b_pre['lo']:+.2f},{b_pre['hi']:+.2f}] | 2024+ {b_post['median']:+5.2f}")
rank_pre = 1 + sorted([r[1] for r in sel_rows], reverse=True).index(sel_rows[0][1])
rank_post = 1 + sorted([r[2] for r in sel_rows], reverse=True).index(sel_rows[0][2])
print(f" -> la configurazione CANONICA (scelta nel 2026-06 guardando SOLO il 2024+) e'")
print(f" {rank_pre}/{len(sel_rows)} sul PRE-2024 e {rank_post}/{len(sel_rows)} sul 2024+.")
print( " 📌 LETTURA: il NUCLEO regge (ogni variante e' positiva fuori campione, banda")
print( " [+0.76,+1.28]), ma i DUE AFFINAMENTI del 19/06 NON si replicano:")
print( " - il BLEND [30,90] fuori campione e' PEGGIO del solo L=30 (+1.12 vs +1.28);")
print( " - il GATE di dispersione p30 fuori campione vale ZERO (con e senza: +1.12);")
print( " e' dentro la finestra di scoperta che vale (+0.37 contro -0.03).")
print( " Cioe': cio' che sopravvive e' il meccanismo cross-sectional nudo; i due")
print( " affinamenti sono, per quanto qui misurabile, adattamento alla finestra in")
print( " cui furono scelti. NON e' un argomento per cambiarli (cambiarli guardando")
print( " QUESTO risultato sarebbe la stessa selezione, spostata di finestra): e'")
print( " un argomento per non attribuire loro il valore che il progetto gli attribuisce.")
# --- concentrazione: un contributo positivo si scompone per ANNO e per GAMBA (lezione SOL)
print("\n Concentrazione (lezione SOL 22/08: '24/24 ancore positive erano UN ANNO SOLO'):")
pre13 = win(s13, None, "2024-01-01")
for y in (2021, 2022, 2023):
rest = pre13[pre13.index.year != y]
print(f" PRE-2024 togliendo il {y}: Sh {sh(rest):+5.2f} "
f"(intero pre-2024 {sh(pre13):+5.2f})")
loo = {}
for a_ in U13:
sub = panel19[[x for x in U13 if x != a_]].dropna()
bb = A.anchor_luck_band(lambda ph, sub=sub: win(alive(xsec_engine(sub, phase=ph,
min_assets=10)),
None, "2024-01-01"),
phases, canonical=0, metric=sh)
loo[a_] = bb["median"]
base_med = bands["U13-FIX PRE-2024"]["median"]
worst = min(loo, key=loo.get)
print(f" Leave-one-asset-out sul PRE-2024 (mediana di fase; base {base_med:+.2f}):")
print(" " + " ".join(f"-{k2}:{v:+.2f}" for k2, v in sorted(loo.items(), key=lambda t: t[1])))
print(f" gamba piu' importante: {worst} (senza di lei {loo[worst]:+.2f}); "
f"minimo {min(loo.values()):+.2f}, tutte >0: {all(v > 0 for v in loo.values())}")
# ---------------------------------------------------------- 8. i GATE
print("\n[8] GATE")
cand = s13.copy(); cand.index = cand.index.tz_convert("UTC")
mg = A.marginal_vs_tp01(cand)
print(f" marginal_vs_tp01 (U13-FIX, 2021+): {mg.get('marginal_verdict')} "
f"corr {mg.get('corr_full')} n={mg.get('n_days')}")
for kk in ("robust_oos", "has_insample_edge", "is_hedge", "beats_noise_null",
"insample_sharpe", "tp01_beta", "alpha_ann"):
if kk in mg:
print(f" {kk:<20s} {mg[kk]}")
if "blends" in mg:
for wname, bl in mg["blends"].items():
print(f" blend {wname}: uplift_full {bl.get('uplift_full')} "
f"uplift_hold {bl.get('uplift_hold')}")
mgE = A.marginal_vs_tp01(sEXP)
print(f" marginal_vs_tp01 (U19-EXP, 2020-12+): {mgE.get('marginal_verdict')} "
f"corr {mgE.get('corr_full')}")
# deflated Sharpe: TUTTI i trial valutati, al RIALZO
all_sr = []
for tag in ("U13-FIX FULL", "U13-FIX PRE-2024", "U19-EXP FULL", "U19-EXP PRE-2024"):
all_sr += list(bands[tag]["per_offset"].values())
all_sr += list(bhl["per_offset"].values()) + list(bsec["per_offset"].values())
for _, _, _, po in sel_rows: # 6 varianti x 10 fasi della sezione 7b
all_sr += list(po.values())
all_sr += [float(v) for v in loo.values()]
cand_pool = list(all_sr) # SOLO configurazioni di XS01
ctrl = [sh(alive(xsec_engine(p13, phase=0, mode="rev"))),
sh(alive(xsec_engine(panel19, phase=0, mode="rev"))), sh(jj["bin"]), sh(jj["hl"])]
for fs in (0.0005, 0.001, 0.002):
ctrl.append(sh(alive(xsec_engine(p13, phase=0, fee_side=fs))))
dsr_val = bands["U13-FIX PRE-2024"]["median"]
med_ph = int(np.argsort(list(bands["U13-FIX PRE-2024"]["per_offset"].values()))[len(phases) // 2])
med_series = win(alive(xsec_engine(p13, phase=med_ph)), None, "2024-01-01")
d1, s1 = A.deflated_sharpe(dsr_val, cand_pool, med_series)
d2, s2 = A.deflated_sharpe(dsr_val, cand_pool + ctrl, med_series)
print(f" deflated_sharpe — headline = MEDIANA di fase OOS {dsr_val:+.2f}")
print(f" pool A = SOLE configurazioni XS01 ({len(cand_pool)} trial): DSR {d1:.3f} "
f"(max dal rumore {s1:+.2f}) -> {'PASS' if d1 >= 0.95 else 'FAIL'}")
print(f" pool B = tutto cio' che ho valutato, controlli NEGATIVI inclusi "
f"({len(cand_pool)+len(ctrl)} trial): DSR {d2:.3f} (max dal rumore {s2:+.2f}) "
f"-> {'PASS' if d2 >= 0.95 else 'FAIL'}")
print( " ⚠️ Il pool B e' il conteggio 'al rialzo' che il progetto impone, ma qui e' anche")
print( " il piu' SEVERO in modo improprio: il DSR pesa la VARIANZA dei trial, e io ci")
print( " ho messo dentro controlli costruiti per essere negativi (REV -0.97, SECT11")
print( " -0.44). Si cita il piu' basso dei due; e si dice che entrambi penalizzano un")
print( " MEDIANA-di-fase come se fosse un MAX-di-k, che e' il caso per cui il DSR e'")
print( " costruito. Il debito di selezione vero di XS01 non e' stato contratto qui:")
print( " e' quello del 2026-06-19, e la sezione 7b lo misura direttamente.")
imp = A.implausible_sharpe(s13)
print(f" implausible_sharpe: {imp['implausible']} Sh {imp.get('sharpe'):+.2f} "
f"maxDD {imp.get('maxdd', 0)*100:.1f}% perdite su barre attive "
f"{imp.get('loss_frac', float('nan'))*100:.1f}% (attive {imp.get('active_frac', 0)*100:.0f}%)"
f"{'' + '; '.join(imp['reasons']) if imp['reasons'] else ''}")
# causalita': i prezzi DOPO la data di decisione non devono cambiare le posizioni prima
Pc = p13.copy()
cut = len(Pc) // 2
rngc = np.random.default_rng(7)
Pd = Pc.copy()
Pd.iloc[cut:] = Pc.iloc[cut:].values * (1.0 + rngc.normal(0, 0.35, size=Pc.iloc[cut:].shape))
a_ = xsec_engine(Pc, phase=0); b_ = xsec_engine(Pd, phase=0)
# confronto solo dove il vol-target non ha ancora "visto" i dati perturbati (rv30 shiftata)
lim = cut - 35
cdiff = float(np.max(np.abs(a_.values[:lim] - b_.values[:lim])))
print(f" causalita' (prezzi futuri randomizzati da i={cut}): max|diff| prima di i={lim} "
f"= {cdiff:.2e} -> {'LEAK-FREE' if cdiff < 1e-12 else 'PERDITA DI FUTURO'}")
print(" null de-levering: NON PERTINENTE — il claim qui non e' 'meno drawdown' ma "
"'l'edge esiste fuori campione'. Il de-levering e' comunque implicito nel vol-target,")
print(" e le tabelle di fase riportano DD e Sharpe INSIEME (un k<1 abbassa entrambi).")
print(f"\n sensibilita' al COSTO (il rischio #1 di uno sleeve a 13 gambe alt):")
for fs in (0.0005, 0.001, 0.002, 0.003):
x = alive(xsec_engine(p13, phase=0, fee_side=fs))
print(f" {fs*1e4:5.0f} bps/lato: FULL Sh {sh(x):+5.2f} | "
f"PRE-2024 {sh(win(x, None, '2024-01-01')):+5.2f} | "
f"2024+ {sh(win(x, '2024-01-01')):+5.2f}")
# ------------------------------------------------- 9. impatto sul LIBRO
print("\n[9] IMPATTO SUL LIBRO — TP01+SKH01 (75/25 live) con e senza XS01, su 2021+")
from src.portfolio.sleeves import _tp01_returns, _skyhook_returns
tp = A._to_daily(_tp01_returns()); sk = A._to_daily(_skyhook_returns())
for xs_tag, xs in (("XS01 storia lunga (U13-FIX)", s13), ("XS01 ufficiale (HL 2024+)", off)):
J = pd.concat({"tp": tp, "sk": sk, "xs": A._to_daily(xs)}, axis=1, join="inner").dropna()
if len(J) < 200:
continue
base = 0.75 * J["tp"] + 0.25 * J["sk"]
for w in (0.15, 0.25, 0.50):
mix = (1 - w) * base + w * J["xs"]
print(f" [{xs_tag}] {J.index[0].date()}..{J.index[-1].date()} n={len(J)} "
f"w={w:.2f}: Sh {sh(base):+.2f} -> {sh(mix):+.2f} "
f"({sh(mix)-sh(base):+.2f}) maxDD {dd(base)*100:.1f}% -> {dd(mix)*100:.1f}% "
f"corr(XS,libro) {J['xs'].corr(base):+.3f}")
# e il numero che il PROP-ALLOC usa davvero: il libro 2021+ contiene il 2022
pre = J[J.index < DISCOVERY]
if len(pre) > 200:
bpre = 0.75 * pre["tp"] + 0.25 * pre["sk"]
print(f" -> SOLO FUORI CAMPIONE ({pre.index[0].date()}..{pre.index[-1].date()}, "
f"n={len(pre)}), sweep del peso di XS01:")
for w in (0.15, 0.25, 0.33, 0.50):
mpre = (1 - w) * bpre + w * pre["xs"]
print(f" w={w:.2f}: libro Sh {sh(bpre):+.2f} -> {sh(mpre):+.2f} "
f"({sh(mpre)-sh(bpre):+.2f}) maxDD {dd(bpre)*100:.1f}% -> "
f"{dd(mpre)*100:.1f}% corr {pre['xs'].corr(bpre):+.3f}")
print(" ⚠️ PROP-ALLOC mette XS01 al 50%: su un conto a BARRIERA cio' che si")
print(" compra e' il maxDD, quindi la riga da guardare e' quella, non lo Sharpe.")
# ---------------------------------------------------- 10. eseguibilita'
print("\n[10] ESEGUIBILITA' — la domanda del canale funded e' se il libro STA su $100k")
det: dict = {}
xsec_engine(p13, phase=0, detail=det)
W, sc = det["W"], det["scale"]
dW = np.abs(np.diff(W, axis=0)); rb = np.flatnonzero(dW.sum(axis=1) > 1e-12)
print(f" {len(U13)} gambe, {len(rb)} ribilanciamenti, gross medio "
f"{np.abs(W).sum(axis=1).mean():.2f}, leva media (vol-target) {np.mean(sc[sc > 0]):.2f}x")
print(f" {'capitale':>10} {'ticket mediano':>16} {'ticket p10':>12} {'<$5':>7} {'<$10':>7}")
for cap in (600, 5000, 20000, 100000):
parts = [dW[i][dW[i] > 1e-12] * sc[i + 1] * cap for i in rb if sc[i + 1] > 0]
tk = np.concatenate(parts) if parts else np.array([])
if not tk.size:
print(" (nessun ticket calcolabile — controlla il motore)"); break
print(f" {cap:>10,d} {np.median(tk):>15.2f}$ {np.percentile(tk, 10):>11.2f}$ "
f"{(tk < 5).mean()*100:>6.1f}% {(tk < 10).mean()*100:>6.1f}%")
print(" ⚠️ Approssimazione DICHIARATA: il modello congelato applica il vol-target alla")
print(" SERIE dei rendimenti, non alle posizioni -> qui il ticket e' calcolato sui soli")
print(" delta di RIBILANCIAMENTO (ogni 10 giorni), ignorando il micro-turnover")
print(" giornaliero che una implementazione vera del vol-target genererebbe. E' un")
print(" LIMITE INFERIORE del numero di ordini, quindi ottimistico sul lato costi.")
print(" ⚠️ E NON rimette in discussione il ~$20k citato dal progetto: quella soglia e' sui")
print(" 19 alt Hyperliquid (min-order e liquidita' del venue), non su questi 13.")
print(f"\n[fine] {time.time()-t0:.1f}s")
if __name__ == "__main__":
main()