Files
Adriano Dal Pastro 05816c49f9 COLLAR01 (§71): il pavimento funziona, il tetto lo paga troppo — e cio' che vince e' VRP01
Chiesto dall'operatore in quattro battute: hold BTC long o short coperto in
opzioni, scadenza <=15gg, "ridurre la vincita ma bloccare la perdita" (=>
collar, non put protettiva), entrata gated da indicatori ("forte bull"), e
uscita dalle opzioni fra il 50% e il 75% del tempo.

PERCHE' SI POTEVA RIAPRIRE DOPO §46. §46 (tail-hedge) fu refutato sul beta
+0,076 del libro — "non si assicura un libro che nei crash e' gia' quasi
piatto". Qui il sottostante e' un hold di BTC, beta 1,0: quel motivo non si
applica. E §46 dichiarava non provata proprio la copertura gated su regime.

L'entrata non aggiunge un solo parametro: tsmom_blend media tre np.sign() su
(30,90,180) => valori in {-1,-1/3,+1/3,+1}, quindi "forte bull" = |blend|==1
(52,5% dei giorni) contro il confronto dichiarato |blend|>=1/3 (97,1%).

RISULTATI (lente lunga 2021-03 -> 2026-09, 5,44 anni; griglia 48 celle
dichiarata prima + 36 di estensione dichiarata):
- A1 CONFERMATA: il pavimento FUNZIONA, maxDD scende in 36/48 (§46: saliva
  in 162/162). La meta' della domanda ha risposta positiva.
- A3 CONFERMATA: il de-levering lo fa meglio in 45/48. Δdrift/ΔmaxDD 7,90
  (gate forte) / 1,98 (largo): 2-8 punti di drift per punto di DD.
- C9 in forma pura: il tetto taglia il 46,2% dei cicli VINCENTI, il pavimento
  para il 6,7% dei PERDENTI — 7x piu' spesso sui vincenti: troncatura.
- M1: collar Sharpe 0,508 vs TP01 0,852; TP01+10% => +0,000 di Sharpe e
  +2,32pp di maxDD.

IL FATTO CHE VALE PIU' DEL VERDETTO. Le 3 celle vincenti stavano tutte sul
BORDO; estesa la famiglia vince 35/36 nell'ANGOLO (dput 0,02 / dcall 0,50,
Sharpe 1,471) — e il limite di quell'angolo e' una COVERED CALL: la pendenza
porta fuori dalla domanda posta e dentro lo short-vol. E quel 1,471 e' il
prezzatore che si paga da solo: DVOL/RV-forward 1,320 a 7g (sopra nel 76,9%
dei giorni) => riprezzato alla vol vera l'angolo cade a 0,511, che e' VRP01
(0,47). Non una scoperta: VRP01 per una strada piu' lunga. §3 lo blocca.

USCITA ANTICIPATA: implementata (exit_frac) e COSTA. Cella onesta gate forte:
drift +9,48% (scadenza) -> +3,84% (50%), esito da VINCE a perde sotto 0,75.
Il meccanismo previsto c'e' (VRP residuo +3,38 -> +1,27pp) ma lo spread lo
travolge. Corregge l'applicazione di §46: "un roll anticipato non paga f"
vale per una copertura solo LONG; in un collar la gamba venduta va
RICOMPRATA, quindi si paga f sulla parte che a scadenza si regolava gratis.
L'asimmetria si INVERTE quando la struttura ha una gamba corta.

CONTROLLI DELL'APPARATO 3/3 (M15): pranzo gratis riconosciuto (maxDD
51,83%->36,76%, drift +10,04%->+35,69%), premio x10 rifiutato, zero-cost
finito. Cinque difetti miei catturati dai controlli, non a occhio: bisezione
zero-cost invertita (dava Sharpe -3,9), dcall=NaN nella cassa, C9 non
consapevole della direzione (S1>S0 non e' "vincente" per uno short), e due di
contabilita' che avrebbero ADULATO il collar (base che rollava lo spot
pagando ~3,6%/a di fee inesistenti; roll che chiudeva lo spot senza motivo).

Corregge anche un muro di §46: il tick da 5 USDC e' della famiglia USDC; la
catena che raccogliamo e' 100% inverse, quindi li' non si applica.

Regole nuove in CLAUDE.md: M29 (un edge da opzioni prezzate a modello si
riprezza alla vol REALIZZATA prima di crederci), M8 esteso (un argmax sul
BORDO e' una pendenza, non una cella), C4 esteso (il segno dell'asimmetria di
f dipende dal verso della gamba).

Libro, pesi, cron, config INVARIATI. Nessun ordine. Suite: 807 passati.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 17:30:18 +00:00

716 lines
36 KiB
Python

"""COLLAR01 — hold BTC (long/short) con COLLAR di opzioni a <=15 giorni.
DOMANDA (posta dall'operatore il 2026-09-01):
"crea una strategia di hold BTC in long o short con copertura con options (max 15gg)"
+ due precisazioni in corso d'opera:
"opzione deve essere al max di 15gg" -> vincolo sulla SCADENZA, non sul solo holding
"voglio ridurre la vincita, ma bloccare la perdita" -> NON e' una put protettiva: e' un COLLAR
(pavimento comprato, tetto venduto per finanziarlo).
"l'entrata deve essere gestita da una strategia confortata da indicatori (es. forte bull)"
"dalle opzioni dobbiamo uscire prima del termine (tra 50% e 75% del tempo)" -> `exit_frac`.
PERCHE' QUESTO FILONE NON E' §46 (TAIL-HEDGE, REFUTATO il 2026-08-23).
§46 compro' put deep-OTM sul LIBRO (TP01+SKH01) e fu refutato *prima del prezzo*: il maxDD
saliva in 162/162 celle perche' **il beta del libro al sottostante e' +0,076** — "non si
assicura un libro che nei crash e' gia' quasi piatto".
Qui il sottostante e' un **hold di BTC nudo, beta 1,0 per costruzione**: il motivo che uccise
§46 NON si applica, ed e' l'unica ragione per cui questo filone puo' essere riaperto
(CLAUDE.md: "chi la riapre deve battere il motivo, non ripetere la misura").
§46 dichiara inoltre di NON aver provato la copertura dinamica e di aver misurato la sola
statica: anche quella porta e' aperta, ma qui non la uso — il collar e' statico a griglia.
COSA E' GIA' NOTO E VIENE RIUSATO, NON RIMISURATO:
- metriche e i due null di scala (`k_for_same_dd`, `k_for_same_drift`) da r0823_tail_hedge;
- listino fee opzioni Deribit (taker 0,03% del sottostante, cap 12,5% del premio, consegna
0,015%) dallo stesso;
- la catena reale via `cblib.load_chain()` (archivio 2026-05-01 + raccolta propria 2026-07-30).
LE DUE LENTI, ed e' una scelta dichiarata:
(A) LENTE LUNGA (decide): 2021-03-24 -> oggi, la finestra del DVOL — ~5,4 anni. Opzioni
prezzate a modello (BS, r=0) con DVOL come ancora ATM, CORRETTA da skew e struttura a
termine MISURATI SULLA CATENA VERA, e spread f misurato per ala. E' l'impianto di §46.
(B) LENTE REALE (controlla il segno, NON decide): 2026-05-01 -> oggi, solo quote a due lati.
123 giorni = ~8 cicli a 15 giorni: sotto-potenziata per costruzione, e per giunta su una
finestra in cui BTC e' salito da ~64,7k a ~77,5k, che e' AVVERSA a un collar (il tetto si
paga proprio li'). Si legge il segno, mai la taglia.
M5 (null del de-levering) E' IL PRIMO TEST, NON L'ULTIMO: ogni claim "meno drawdown" si misura
contro "tieni semplicemente meno BTC" a iso-maxDD. Se il collar non batte quello, non e'
protezione: e' una ri-scalatura pagata cara.
C9 e' il secondo: "una regola d'uscita che scatta piu' spesso sui vincenti che sui perdenti non e'
protezione, e' troncatura" — qui il tetto e' letteralmente quella regola.
NESSUN ORDINE. Libro, pesi, cron, config INVARIATI.
"""
from __future__ import annotations
import math
import sys
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))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import cblib # noqa: E402
from src.data.downloader import load_data # noqa: E402
DPY = 365.25
# ---- listino Deribit opzioni (identico a r0823_tail_hedge, non ridichiarato a mano) -----------
OPT_TAKER_UNDERLYING = 0.0003
OPT_FEE_CAP_FRAC = 0.125
OPT_DELIVERY_UNDERLYING = 0.00015
# ---- GRIGLIA DICHIARATA PRIMA DI GUARDARE I RISULTATI -----------------------------------------
G_DPUT = (0.10, 0.20, 0.30) # |delta| della put comprata = il PAVIMENTO
G_DCALL = (0.10, 0.20, 0.30) # delta della call venduta = il TETTO
G_TENOR = (7, 14) # giorni a scadenza: <=15, vincolo dell'operatore
N_CELLS = len(G_DPUT) * len(G_DCALL) * len(G_TENOR)
# varianti dichiarate a parte (non nella griglia, per non gonfiare i trial)
V_ZEROCOST = "zero-cost" # tetto scelto per finanziare esattamente il pavimento
ATTESE = """
ATTESE A PRIORI — scritte e stampate PRIMA di misurare (M12: un follow-up contiene una previsione).
A1 Il maxDD SCENDE davvero, a differenza di §46: il beta e' 1,0 e il pavimento morde.
(se A1 fallisse, il filone cade come §46 e per lo stesso motivo)
A2 Il drift scende PIU' del maxDD, perche' il rendimento di BTC vive nella coda destra e il
tetto la taglia. Previsione: rapporto Δdrift/ΔmaxDD > 1.
A3 Il collar PERDE contro il null del de-levering a iso-maxDD in piu' della meta' delle celle.
E' la previsione centrale del filone.
A4 Lo skew rende il collar delta-simmetrico un DEBITO netto (put piu' cara della call).
[gia' misurato prima di scrivere: +31% / +19% / +12% a |d| 0,10 / 0,20 / 0,30 -> CONFERMATA]
A5 Il tick morde MENO che in §46: quella era la famiglia USDC (tick 5 USDC); la catena che
raccogliamo e' 100% inverse, con tick in BTC.
A6 Nella lente reale (BTC +20% in 123 giorni) il collar sembrera' pessimo: finestra avversa,
non evidenza. Va letta come segno, non come taglia.
A7 Il verso SHORT sara' peggiore del LONG: il drift di BTC e' positivo e lo short lo paga tutto,
col collar che ne cappa il solo rimbalzo favorevole.
"""
# ==================================================================== 0. METRICHE E NULL (da §46)
def _sh(x) -> float:
r = np.asarray(pd.Series(x).dropna().values, float)
return float(np.mean(r) / np.std(r) * math.sqrt(DPY)) if len(r) > 2 and np.std(r) > 0 else 0.0
def _dd(x) -> float:
r = np.asarray(pd.Series(x).dropna().values, float)
eq = np.cumprod(1.0 + r)
pk = np.maximum.accumulate(eq)
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
def _drift(x) -> float:
r = np.asarray(pd.Series(x).dropna().values, float)
if len(r) < 2:
return 0.0
tot = float(np.prod(1.0 + r))
return -1.0 if tot <= 0 else float(tot ** (DPY / len(r)) - 1.0)
def k_for_same_dd(base: np.ndarray, target_dd: float) -> float:
"""La scala costante che porta il maxDD del NUDO esattamente a quello del coperto (bisezione)."""
lo, hi = 1e-4, 1.0
if _dd(hi * base) <= target_dd:
return 1.0
for _ in range(60):
mid = 0.5 * (lo + hi)
if _dd(mid * base) > target_dd:
hi = mid
else:
lo = mid
return 0.5 * (lo + hi)
# ==================================================================== 1. CALIBRAZIONE DALLA CATENA
_BANDS = (0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50)
def calibra_catena() -> dict:
"""Skew, struttura a termine e spread f: MISURATI sulla catena vera, mai assunti.
Restituisce funzioni di correzione applicate al DVOL (che e' un ATM a 30 giorni) per ottenere
l'IV di una gamba a delta e scadenza dati. Senza queste il modello prezzerebbe la put e la
call allo STESSO vol: l'errore andrebbe tutto a favore del collar (A4).
"""
df = cblib.load_chain()
b = df[df["asset"].eq("BTC")].copy()
b["exp"] = pd.to_datetime(b["exp"], utc=True)
b["ts"] = pd.to_datetime(b["ts"], utc=True)
b["dte"] = (b["exp"] - b["ts"]).dt.total_seconds() / 86400.0
q = b[(b["dte"] > 1) & (b["dte"] <= 15) & b["bid"].gt(0) & b["ask"].gt(0)
& b["iv"].notna() & b["delta"].notna()].copy()
q["ad"] = q["delta"].abs()
atm = q[q["ad"].between(0.45, 0.55)].groupby(["ts", "exp"])["iv"].mean().rename("iv_atm")
q = q.join(atm, on=["ts", "exp"])
q = q[q["iv_atm"].notna() & q["iv_atm"].gt(0)]
skew = {}
for t, nm in (("P", "put"), ("C", "call")):
xs, ys = [], []
for d in _BANDS:
lo, hi = d * 0.85, d * 1.15
s = q[q["option_type"].eq(t) & q["ad"].between(lo, hi)]
if len(s) >= 200:
xs.append(d)
ys.append(float((s["iv"] / s["iv_atm"]).median()))
skew[nm] = (np.array(xs), np.array(ys))
# struttura a termine: IV ATM a DTE t contro il DVOL 30g dello stesso istante
dv = cblib.dvol_series("BTC")
a = q[q["ad"].between(0.45, 0.55)].copy()
a["giorno"] = a["ts"].dt.floor("D")
dvd = dv.copy()
dvd.index = pd.to_datetime(dvd.index, utc=True).floor("D")
dvd = dvd.groupby(level=0).last()
a = a.join(dvd.rename("dvol"), on="giorno")
a = a[a["dvol"].notna() & a["dvol"].gt(0)]
a["ratio"] = (a["iv"] / 100.0) / (a["dvol"] / 100.0) if a["iv"].median() > 3 else a["iv"] / (a["dvol"] / 100.0)
ts_x, ts_y = [], []
for lo, hi in ((1, 4), (4, 8), (8, 12), (12, 15)):
s = a[a["dte"].between(lo, hi)]
if len(s) >= 200:
ts_x.append(0.5 * (lo + hi))
ts_y.append(float(s["ratio"].median()))
# spread relativo per ala e per banda di delta (mezza forchetta su mid) = il costo di f
spread = {}
for t, nm in (("P", "put"), ("C", "call")):
xs, ys = [], []
for d in _BANDS:
lo, hi = d * 0.85, d * 1.15
s = q[q["option_type"].eq(t) & q["ad"].between(lo, hi)]
s = s[s["mid"].gt(0)]
if len(s) >= 200:
xs.append(d)
ys.append(float((((s["ask"] - s["bid"]) / 2.0) / s["mid"]).median()))
spread[nm] = (np.array(xs), np.array(ys))
return dict(skew=skew, term=(np.array(ts_x), np.array(ts_y)), spread=spread,
n_quote=len(q), giorni=int(q["ts"].dt.date.nunique()),
span=(q["ts"].min(), q["ts"].max()))
def _interp(tab, x):
xs, ys = tab
if len(xs) == 0:
return 1.0
return float(np.interp(x, xs, ys))
# ==================================================================== 2. PREZZATORE
def _d1(S, K, T, sig):
return (math.log(S / K) + 0.5 * sig * sig * T) / (sig * math.sqrt(T))
def bs_call(S, K, T, sig):
if T <= 0 or sig <= 0:
return max(S - K, 0.0)
d1 = _d1(S, K, T, sig)
return S * norm.cdf(d1) - K * norm.cdf(d1 - sig * math.sqrt(T))
def bs_put(S, K, T, sig):
if T <= 0 or sig <= 0:
return max(K - S, 0.0)
d1 = _d1(S, K, T, sig)
return K * norm.cdf(-(d1 - sig * math.sqrt(T))) - S * norm.cdf(-d1)
def strike_da_delta(S, T, sig_atm, cal, delta, tipo):
"""Strike che realizza |delta| dato, risolvendo la circolarita' skew<->delta per punto fisso.
L'IV dipende dal delta (skew) e il delta dipende dallo strike che dipende dall'IV: due
iterazioni bastano (verificato: la terza muove lo strike di <0,1%).
"""
sig = sig_atm * _interp(cal["skew"][tipo], delta)
K = S
for _ in range(3):
z = norm.ppf(delta) if tipo == "call" else -norm.ppf(delta)
K = S * math.exp(0.5 * sig * sig * T - z * sig * math.sqrt(T))
sig = sig_atm * _interp(cal["skew"][tipo], delta)
return K, sig
def _fee(prem, S):
return min(OPT_TAKER_UNDERLYING * S, OPT_FEE_CAP_FRAC * prem) if prem > 0 else 0.0
# ==================================================================== 3. IL MOTORE DEL COLLAR
def serie_giornaliera() -> pd.DataFrame:
"""BTC giornaliero + DVOL, sulla finestra in cui ESISTONO entrambi (2021-03 in poi)."""
d = load_data("BTC", "1h")
idx = pd.DatetimeIndex(pd.to_datetime(d["datetime"], utc=True))
px = pd.Series(d["close"].values.astype(float), index=idx).resample("1D").last()
dv = pd.read_parquet(ROOT / "data" / "raw" / "dvol_btc.parquet")
iv = pd.Series(dv["close"].values.astype(float) / 100.0,
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)).resample("1D").last()
P = pd.concat({"px": px, "iv": iv}, axis=1, join="inner").dropna()
return P
SPOT_FEE_SIDE = 0.00035 # Deribit taker reale dal 2026-08-01 (3,50 bps/lato), §8
def gate_trend(P: pd.DataFrame, forte: bool) -> np.ndarray:
"""L'ENTRATA, governata da indicatori — e presa dal progetto, non inventata qui.
`trend_portfolio.tsmom_blend` media tre `np.sign()` sugli orizzonti (30, 90, 180) giorni,
quindi assume SOLO i valori {-1, -1/3, +1/3, +1} (il bucket 2/3 non esiste: e' un errore gia'
corretto in §57). Questo da' a "forte bull" una definizione che non aggiunge NEMMENO UN
parametro nuovo:
forte=True -> |blend| == 1 : tutti e tre gli orizzonti concordi ("forte bull/bear")
forte=False -> |blend| >= 1/3: almeno la maggioranza (confronto dichiarato)
Il segno del blend da' la DIREZIONE (long o short), come chiesto.
"""
from src.strategies.trend_portfolio import tsmom_blend
c = P["px"].values.astype(float)
b = tsmom_blend(c, (30, 90, 180))
d = np.sign(b)
d[np.abs(b) < (0.99 if forte else 0.32)] = 0.0
return np.concatenate([[0.0], d[:-1]]) # ESEGUIBILE: si entra il giorno DOPO il segnale
def collar(P: pd.DataFrame, cal: dict, *, dput: float, dcall: float, tenor: int,
dirs: np.ndarray, zerocost: bool = False, senza_opzioni: bool = False,
prem_scale: float = 1.0, solo_pavimento: bool = False,
exit_frac: float = 1.0) -> dict:
"""Hold di BTC gated dal trend, coperto da un collar rollato ogni `tenor` giorni (<=15).
direzione +1 = LONG BTC -> compra PUT (pavimento), vende CALL (tetto)
direzione -1 = SHORT BTC -> compra CALL (pavimento), vende PUT (tetto)
`exit_frac` < 1 esce dalle opzioni PRIMA della scadenza (0,50-0,75 = richiesta dell'operatore
del 2026-09-01). Non e' un dettaglio di rollo: cambia il segno di due cose opposte.
- sulla gamba COMPRATA (il pavimento) uscire presto RECUPERA valore temporale: il theta di una
long accelera verso la scadenza, e §46 aveva gia' misurato che *"f si paga solo sulla parte
di valore che converge a intrinseco, quindi un roll anticipato non lo paga"*;
- sulla gamba VENDUTA (il tetto) uscire presto RINUNCIA al theta piu' veloce, cioe' proprio
alla parte che in questo filone stampava il premio di varianza.
Il prezzo di entrambe e' che si paga lo SPREAD in uscita su due gambe, invece di regolare a
intrinseco. Chi lo attiva compra meno short-vol e piu' protezione onesta: e' misurabile, ed e'
misurato nella sezione dedicata.
`prem_scale` e `solo_pavimento` esistono per i CONTROLLI (M15): con premio 0 e solo il
pavimento la struttura e' un pranzo gratis e DEVE vincere; con premio x10 DEVE perdere ovunque.
Un apparato che non riconosce il pranzo gratis e' guasto, e §46 lo scopri' a proprie spese.
`senza_opzioni=True` produce il MEDESIMO gated hold senza collar: e' la BASE del null M5, e
dev'essere generata dallo stesso motore o il confronto misurerebbe anche le differenze di
contabilita' (P1: il null deriva dal codice sorvegliato, non lo ridichiara).
Marcatura GIORNALIERA a modello: il pavimento vincola il PAYOFF A SCADENZA, non il mark
intermedio. Senza marcatura giornaliera il maxDD del collar sarebbe misurato solo ai roll e la
protezione sembrerebbe migliore di quanto e'.
"""
S = P["px"].values.astype(float)
A = P["iv"].values.astype(float)
n = len(S)
if n < tenor + 5:
return {}
tipo_di = {+1: ("put", "call"), -1: ("call", "put")}
nav = np.full(n, np.nan)
V = 1.0 # capitale normalizzato
cash, q, nopt = 1.0, 0.0, 0.0
Kl = Ks = np.nan
dc_vivo = 0.20 # delta del tetto VIVO: serve allo spread d'uscita anticipata
scad = -1
roll_at = -1 # quando si ESCE (<= scad): con exit_frac<1 e' PRIMA della scadenza
cur = 0
cicli = []
dc_scelti = [] # delta del tetto effettivamente usato a ogni apertura
apre = 0
def _mark(K, T, sig_atm, tipo, S_t):
if T <= 0 or math.isnan(K):
return 0.0 if math.isnan(K) else (max(K - S_t, 0.0) if tipo == "put" else max(S_t - K, 0.0))
sig = sig_atm * _interp(cal["term"], max(T * DPY, 1.0))
d1 = _d1(S_t, K, T, sig)
dl = norm.cdf(d1) if tipo == "call" else 1.0 - norm.cdf(d1)
sig *= _interp(cal["skew"][tipo], min(max(dl, 0.02), 0.60))
return bs_put(S_t, K, T, sig) if tipo == "put" else bs_call(S_t, K, T, sig)
def _chiudi(i, S_t, anche_spot=True):
"""Regola le opzioni vive; liquida lo spot SOLO se richiesto.
⚠️ Il flag esiste per un difetto che falserebbe tutto: al ROLL delle opzioni lo spot non
ha motivo di muoversi. Chiuderlo e riaprirlo pagherebbe 2x3,5 bps ogni `tenor` giorni
(a 7 giorni: ~3,6%/anno) di fee che nella realta' non si pagano.
"""
nonlocal cash, q, nopt, Kl, Ks, cur
if nopt > 0 and not senza_opzioni:
tl, ts = tipo_di[cur]
T_rem = max(scad - i, 0) / DPY
vl = _mark(Kl, T_rem, A[i], tl, S_t)
vs = 0.0 if solo_pavimento else _mark(Ks, T_rem, A[i], ts, S_t)
if T_rem > 0: # smontaggio anticipato: pago lo spread in USCITA
vl *= 1.0 - _interp(cal["spread"][tl], dput)
vs *= 1.0 + _interp(cal["spread"][ts], dc_vivo)
else:
for x in (vl, vs):
if x > 0:
cash -= nopt * OPT_DELIVERY_UNDERLYING * S_t
cash += nopt * (vl - vs)
nopt, Kl, Ks = 0.0, np.nan, np.nan
if anche_spot and q != 0.0:
cash += q * S_t - abs(q) * S_t * SPOT_FEE_SIDE
q = 0.0
for i in range(n):
S_t = S[i]
d = int(dirs[i])
fine_ciclo = (i >= roll_at) and cur != 0 and not senza_opzioni
solo_roll = fine_ciclo and d == cur
if d != cur or fine_ciclo:
if cur != 0:
if nopt > 0 and i >= scad:
tl, ts = tipo_di[cur]
il_ = max(Kl - S_t, 0.0) if tl == "put" else max(S_t - Kl, 0.0)
is_ = 0.0 if solo_pavimento else (
max(Ks - S_t, 0.0) if ts == "put" else max(S_t - Ks, 0.0))
cicli.append(dict(i0=apre, i1=i, S0=S[apre], S1=S_t, Kl=Kl, Ks=Ks,
dirn=cur, cap=is_ > 0, floor=il_ > 0,
scaduto=bool(fine_ciclo)))
_chiudi(i, S_t, anche_spot=not solo_roll)
if not solo_roll:
V = cash
if not solo_roll:
cur = d
if cur != 0:
if not solo_roll:
q = cur * V / S_t
cash = V - q * S_t - abs(q) * S_t * SPOT_FEE_SIDE
apre, scad = i, i + tenor
roll_at = i + max(1, int(round(exit_frac * tenor)))
if not senza_opzioni:
tl, ts = tipo_di[cur]
T = tenor / DPY
Kl, sl = strike_da_delta(S_t, T, A[i], cal, dput, tl)
pl = (bs_put if tl == "put" else bs_call)(S_t, Kl, T, sl)
pl *= 1.0 + _interp(cal["spread"][tl], dput) # compro all'ASK
pl *= prem_scale
if zerocost:
lo, hi = 0.02, 0.60
for _ in range(40):
md = 0.5 * (lo + hi)
Kx, sx = strike_da_delta(S_t, T, A[i], cal, md, ts)
px_ = (bs_put if ts == "put" else bs_call)(S_t, Kx, T, sx)
px_ *= 1.0 - _interp(cal["spread"][ts], md) # vendo al BID
# il premio CRESCE col delta: se incasso meno di quanto pago,
# il tetto va avvicinato (delta PIU' ALTO) -> lo = md.
if px_ < pl:
lo = md
else:
hi = md
dc_use = 0.5 * (lo + hi)
else:
dc_use = dcall
dc_vivo = dc_use
dc_scelti.append(float(dc_use))
Ks, ss = strike_da_delta(S_t, T, A[i], cal, dc_use, ts)
ps = (bs_put if ts == "put" else bs_call)(S_t, Ks, T, ss)
ps *= (1.0 - _interp(cal["spread"][ts], dc_use)) * prem_scale
if solo_pavimento:
ps, Ks = 0.0, np.inf if ts == "call" else 0.0
nopt = abs(q)
cash += nopt * (ps - pl) - nopt * (_fee(pl, S_t) + _fee(ps, S_t))
vl = vs = 0.0
if nopt > 0 and not senza_opzioni:
tl, ts = tipo_di[cur]
T_rem = max(scad - i, 0) / DPY
vl = _mark(Kl, T_rem, A[i], tl, S_t)
vs = 0.0 if solo_pavimento else _mark(Ks, T_rem, A[i], ts, S_t)
nav[i] = cash + q * S_t + nopt * (vl - vs)
v = pd.Series(nav, index=P.index).dropna()
r = (v / v.shift(1) - 1.0).dropna()
return dict(ret=r, nav=v, cicli=cicli, dc_scelti=dc_scelti)
# ==================================================================== 4. MAIN
def _riga(nome, r) -> dict:
return dict(nome=nome, sh=_sh(r), dd=_dd(r), drift=_drift(r))
def main() -> None:
print("=" * 96)
print(" COLLAR01 — hold BTC gated dal trend, coperto da collar di opzioni a <=15 giorni")
print("=" * 96)
print(ATTESE)
cal = calibra_catena()
print(f" CALIBRAZIONE dalla catena VERA: {cal['n_quote']:,} quote a due lati su "
f"{cal['giorni']} giorni, {cal['span'][0].date()} -> {cal['span'][1].date()}")
for nm in ("put", "call"):
xs, ys = cal["skew"][nm]
print(f" skew {nm:5s} IV/ATM : " + " ".join(f"d{x:.2f}={y:.3f}" for x, y in zip(xs, ys)))
xs, ys = cal["term"]
print(f" termine IV/DVOL30 : " + " ".join(f"{x:.0f}g={y:.3f}" for x, y in zip(xs, ys)))
for nm in ("put", "call"):
xs, ys = cal["spread"][nm]
print(f" spread {nm:5s} (mezza forchetta/mid): "
+ " ".join(f"d{x:.2f}={y:.3f}" for x, y in zip(xs, ys)))
P = serie_giornaliera()
print(f"\n LENTE LUNGA: {P.index[0].date()} -> {P.index[-1].date()} "
f"({len(P)} giorni, {len(P)/DPY:.2f} anni)")
bh = (P["px"] / P["px"].shift(1) - 1.0).dropna()
print(f" BTC buy&hold nudo: Sharpe {_sh(bh):.3f} · maxDD {100*_dd(bh):.2f}% · "
f"drift {100*_drift(bh):+.2f}%/a")
righe = []
for forte in (True, False):
dirs = gate_trend(P, forte)
nome_g = "FORTE (3/3 orizzonti)" if forte else "LARGO (>=2/3)"
quota = 100.0 * np.mean(dirs != 0)
qlong = 100.0 * np.mean(dirs > 0)
base = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)
rb = base["ret"]
print(f"\n ── GATE {nome_g}: a mercato {quota:.1f}% dei giorni "
f"(long {qlong:.1f}%, short {quota-qlong:.1f}%)")
print(f" BASE gated SENZA opzioni: Sharpe {_sh(rb):.3f} · maxDD {100*_dd(rb):.2f}% · "
f"drift {100*_drift(rb):+.2f}%/a ← e' il NULL da battere (M5)")
for tenor in G_TENOR:
for dp in G_DPUT:
for dc in G_DCALL:
out = collar(P, cal, dput=dp, dcall=dc, tenor=tenor, dirs=dirs)
if not out:
continue
r = out["ret"]
dd_c, dr_c = _dd(r), _drift(r)
k = k_for_same_dd(rb.values, dd_c)
dr_null = _drift(k * rb.values)
righe.append(dict(gate=nome_g, forte=forte, tenor=tenor, dp=dp, dc=dc,
sh=_sh(r), dd=dd_c, drift=dr_c, k=k, drift_null=dr_null,
vince=(dr_c > dr_null) and (dd_c < _dd(rb)),
dd_scende=dd_c < _dd(rb), cicli=out["cicli"], ret=r))
# varianti zero-cost, dichiarate FUORI griglia
for tenor in G_TENOR:
for dp in G_DPUT:
out = collar(P, cal, dput=dp, dcall=np.nan, tenor=tenor, dirs=dirs, zerocost=True)
if not out:
continue
r = out["ret"]
dd_c, dr_c = _dd(r), _drift(r)
k = k_for_same_dd(rb.values, dd_c)
righe.append(dict(gate=nome_g, forte=forte, tenor=tenor, dp=dp, dc="zc",
sh=_sh(r), dd=dd_c, drift=dr_c, k=k,
drift_null=_drift(k * rb.values),
vince=(dr_c > _drift(k * rb.values)) and (dd_c < _dd(rb)),
dd_scende=dd_c < _dd(rb), cicli=out["cicli"], ret=r))
R = pd.DataFrame([x for x in righe if x["gate"] == nome_g])
print(f"\n {'ten':>4} {'dput':>5} {'dcall':>6} {'Sharpe':>7} {'maxDD':>7} "
f"{'drift':>8} {'k null':>7} {'drift null':>10} esito")
for _, x in R.iterrows():
dcs = x["dc"] if isinstance(x["dc"], str) else f"{x['dc']:.2f}"
print(f" {x['tenor']:>4} {x['dp']:>5.2f} {dcs:>6} {x['sh']:>7.3f} "
f"{100*x['dd']:>6.2f}% {100*x['drift']:>7.2f}% {x['k']:>7.3f} "
f"{100*x['drift_null']:>9.2f}% "
f"{'VINCE' if x['vince'] else ('dd↓ ma perde' if x['dd_scende'] else 'dd↑')}")
R = pd.DataFrame(righe)
n_dd = int(R["dd_scende"].sum())
n_win = int(R["vince"].sum())
print("\n" + "=" * 96)
print(f" A1 maxDD SCENDE in {n_dd}/{len(R)} celle "
f"({'CONFERMATA' if n_dd > len(R)//2 else 'REFUTATA'})")
print(f" A3 il collar BATTE il de-levering in {n_win}/{len(R)} celle "
f"({'REFUTATA (il collar vince)' if n_win > len(R)//2 else 'CONFERMATA (il collar perde)'})")
# ---- A2: rapporto Δdrift/ΔmaxDD ----------------------------------------------------------
for forte in (True, False):
sub = R[R["forte"] == forte]
if not len(sub):
continue
dirs = gate_trend(P, forte)
rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"]
d0, g0 = _dd(rb), _drift(rb)
rap = ((g0 - sub["drift"]) / np.maximum(d0 - sub["dd"], 1e-9)).median()
print(f" A2 gate {'FORTE' if forte else 'LARGO'}: Δdrift/ΔmaxDD mediano {rap:.2f} "
f"({'CONFERMATA' if rap > 1 else 'REFUTATA'}: >1 = il tetto costa piu' di quanto il "
f"pavimento renda)")
# ---- C9: il tetto scatta piu' sui vincenti o sui perdenti? -------------------------------
best = R.sort_values("sh", ascending=False).iloc[0]
cic = pd.DataFrame(best["cicli"])
if len(cic):
# ⚠️ "vincente" dipende dalla DIREZIONE: per un ciclo SHORT un prezzo che sale e' una
# PERDITA. Usare S1>S0 come proxy di vittoria e' il difetto che dava "pavimento 0,0%".
cic["vinc"] = cic["dirn"] * (cic["S1"] - cic["S0"]) > 0
cap_w = 100.0 * cic[cic["vinc"]]["cap"].mean()
flo_l = 100.0 * cic[~cic["vinc"]]["floor"].mean()
print(f"\n C9 cella migliore (gate {best['gate']}, {best['tenor']}g, dput {best['dp']}, "
f"dcall {best['dc']}), {len(cic)} cicli:")
print(f" il TETTO taglia nel {cap_w:.1f}% dei cicli VINCENTI · "
f"il PAVIMENTO para nel {flo_l:.1f}% dei cicli PERDENTI")
print(f" -> {'TRONCATURA' if cap_w > flo_l else 'PROTEZIONE'} "
f"(C9: se scatta piu' sui vincenti che sui perdenti, non e' protezione)")
# ---- M1: il confronto che decide se merita uno slot --------------------------------------
try:
from src.portfolio.sleeves import _tp01_returns
tp = _tp01_returns()
tp.index = pd.to_datetime(tp.index, utc=True)
tpd = (1.0 + tp).resample("1D").prod() - 1.0
j = pd.concat({"c": best["ret"], "tp": tpd}, axis=1, join="inner").dropna()
print(f"\n M1 vs TP01 sulla finestra comune ({len(j)} giorni): "
f"collar Sharpe {_sh(j['c']):.3f} · TP01 {_sh(j['tp']):.3f} · "
f"corr {j['c'].corr(j['tp']):+.3f}")
for w in (0.10, 0.25):
mix = (1 - w) * j["tp"] + w * j["c"]
print(f" TP01 + {100*w:.0f}% collar: Sharpe {_sh(mix):.3f} "
f"({_sh(mix)-_sh(j['tp']):+.3f}) · maxDD {100*_dd(mix):.2f}% "
f"({100*(_dd(mix)-_dd(j['tp'])):+.2f}pp)")
except Exception as e: # pragma: no cover
print(f"\n M1 non calcolato: {type(e).__name__}: {e}")
# ---- ESTENSIONE AL BORDO (M4: si riapre la FAMIGLIA, M8: un argmax sul bordo non decide) --
print("\n" + "=" * 96)
print(" ESTENSIONE DICHIARATA — le celle vincenti stanno tutte sul BORDO (dput min, dcall max).")
print(" Sondo dove punta la pendenza. Il limite dput->0 / dcall->0,50 e' la COVERED CALL pura:")
print(" nessun pavimento, tetto ATM. Trial aggiunti: 2 gate x 2 tenor x 3 x 3 = 36 (dichiarati).")
ext = []
for forte in (True, False):
dirs = gate_trend(P, forte)
rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"]
for tenor in (7, 14):
for dp in (0.02, 0.05, 0.10):
for dc in (0.30, 0.40, 0.50):
r = collar(P, cal, dput=dp, dcall=dc, tenor=tenor, dirs=dirs)["ret"]
k = k_for_same_dd(rb.values, _dd(r))
ext.append(dict(forte=forte, tenor=tenor, dp=dp, dc=dc, sh=_sh(r),
dd=_dd(r), drift=_drift(r),
vince=(_drift(r) > _drift(k * rb.values)) and (_dd(r) < _dd(rb))))
E = pd.DataFrame(ext)
top = E.sort_values("sh", ascending=False).iloc[0]
print(f" -> VINCE in {int(E['vince'].sum())}/{len(E)} celle, e il massimo e' NELL'ANGOLO: "
f"gate {'FORTE' if top['forte'] else 'LARGO'} {top['tenor']}g dput {top['dp']:.2f} "
f"dcall {top['dc']:.2f} -> Sharpe {top['sh']:.3f}, maxDD {100*top['dd']:.2f}%, "
f"drift {100*top['drift']:+.2f}%/a")
print(" ⚠️ Il pavimento non e' cio' che vince: vince il TETTO. La pendenza porta FUORI da cio'")
print(" che l'operatore ha chiesto ('bloccare la perdita') e DENTRO lo short-vol.")
# ---- LA DIAGNOSTICA CHE DECIDE: quanto dell'edge e' il PREMIO DI VARIANZA? ----------------
lr = np.log(P["px"] / P["px"].shift(1))
print("\n RIPREZZATURA ALLA VOLATILITA' REALIZZATA — diagnostica con LOOK-AHEAD DICHIARATO,")
print(" non e' una strategia: e' il valore equo ex-post. Se l'edge sparisce, era IV-RV.")
for h in (7, 14):
fw = (lr[::-1].rolling(h).std()[::-1] * math.sqrt(DPY)).bfill().ffill()
j = pd.concat({"iv": P["iv"], "fw": fw}, axis=1).dropna()
print(f" DVOL / RV-forward a {h}g: mediana {(j['iv']/j['fw']).median():.3f} · "
f"DVOL sopra nel {100*(j['iv']>j['fw']).mean():.1f}% dei giorni")
for tenor in (7, 14):
fw = (lr[::-1].rolling(tenor).std()[::-1] * math.sqrt(DPY)).bfill().ffill()
Pf = P.copy()
Pf["iv"] = fw.reindex(P.index).values
for forte in (True, False):
dirs = gate_trend(P, forte)
rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"]
for dp, dc in ((0.02, 0.50), (0.10, 0.30)):
a = collar(P, cal, dput=dp, dcall=dc, tenor=tenor, dirs=dirs)["ret"]
b = collar(Pf, cal, dput=dp, dcall=dc, tenor=tenor, dirs=dirs)["ret"]
ka, kb = k_for_same_dd(rb.values, _dd(a)), k_for_same_dd(rb.values, _dd(b))
wa = _drift(a) > _drift(ka * rb.values) and _dd(a) < _dd(rb)
wb = _drift(b) > _drift(kb * rb.values) and _dd(b) < _dd(rb)
print(f" {'FORTE' if forte else 'LARGO'} {tenor:>2}g dput {dp:.2f} dcall {dc:.2f}: "
f"a DVOL Sh {_sh(a):6.3f} drift {100*_drift(a):+7.2f}% {'VINCE' if wa else 'perde'}"
f" | a RV Sh {_sh(b):6.3f} drift {100*_drift(b):+7.2f}% {'VINCE' if wb else 'perde'}"
f" | VRP {100*(_drift(a)-_drift(b)):+6.2f} pp")
# ---- USCITA ANTICIPATA (richiesta dell'operatore: fra il 50% e il 75% del tempo) ---------
print("\n" + "=" * 96)
print(" USCITA ANTICIPATA DALLE OPZIONI — exit_frac 0,50 / 0,625 / 0,75 contro la scadenza.")
print(" §46 misuro' che 'f si paga solo sulla parte di valore che converge a intrinseco,")
print(" quindi un roll anticipato NON lo paga'. ⚠️ Vale per una copertura SOLO LONG: in un")
print(" collar c'e' una gamba VENDUTA da RICOMPRARE, e uscire prima paga f esattamente sulla")
print(" parte che a scadenza si sarebbe regolata gratis. L'asimmetria si INVERTE.")
lr2 = np.log(P["px"] / P["px"].shift(1))
for forte in (True, False):
dirs = gate_trend(P, forte)
rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"]
print(f"\n gate {'FORTE' if forte else 'LARGO'} (base drift {100*_drift(rb):+.2f}%/a):")
for dp, dc in ((0.10, 0.30), (0.20, 0.20), (0.02, 0.50)):
for ef in (1.00, 0.75, 0.625, 0.50):
a = collar(P, cal, dput=dp, dcall=dc, tenor=14, dirs=dirs, exit_frac=ef)["ret"]
hh = max(1, int(round(ef * 14)))
fw = (lr2[::-1].rolling(hh).std()[::-1] * math.sqrt(DPY)).bfill().ffill()
Pf = P.copy()
Pf["iv"] = fw.reindex(P.index).values
b = collar(Pf, cal, dput=dp, dcall=dc, tenor=14, dirs=dirs, exit_frac=ef)["ret"]
k = k_for_same_dd(rb.values, _dd(a))
w = (_drift(a) > _drift(k * rb.values)) and (_dd(a) < _dd(rb))
print(f" d{dp:.2f}/{dc:.2f} exit {ef:.3f}: Sh {_sh(a):6.3f} DD {100*_dd(a):6.2f}% "
f"drift {100*_drift(a):+7.2f}% null {100*_drift(k*rb.values):+6.2f}% "
f"{'VINCE' if w else 'perde'} | a RV drift {100*_drift(b):+7.2f}% "
f"VRP {100*(_drift(a)-_drift(b)):+6.2f}pp")
# ---- CONTROLLI DELL'APPARATO (M15) — un rilevatore che non li passa non misura nulla ----
print("\n CONTROLLI DELL'APPARATO:")
dirs = gate_trend(P, True)
rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"]
esiti = []
g = collar(P, cal, dput=0.20, dcall=0.20, tenor=14, dirs=dirs,
prem_scale=0.0, solo_pavimento=True)["ret"]
ok_g = (_dd(g) < _dd(rb)) and (_drift(g) > _drift(rb))
esiti.append(ok_g)
print(f" pranzo gratis (pavimento a premio ZERO): maxDD {100*_dd(rb):.2f}%->{100*_dd(g):.2f}% "
f"drift {100*_drift(rb):+.2f}%->{100*_drift(g):+.2f}% {'OK' if ok_g else 'FALLITO'}")
c = collar(P, cal, dput=0.20, dcall=0.20, tenor=14, dirs=dirs, prem_scale=10.0)["ret"]
ok_c = _drift(c) < _drift(rb)
esiti.append(ok_c)
print(f" premio x10 (deve perdere): drift {100*_drift(c):+.2f}%/a "
f"{'OK' if ok_c else 'FALLITO'}")
zc = collar(P, cal, dput=0.20, dcall=np.nan, tenor=14, dirs=dirs, zerocost=True)
prem_res = np.nan
ok_z = bool(len(zc.get("cicli", [])) > 0) and np.isfinite(_drift(zc["ret"]))
esiti.append(ok_z)
print(f" zero-cost costruibile e finito: drift {100*_drift(zc['ret']):+.2f}%/a "
f"{'OK' if ok_z else 'FALLITO'}")
if not all(esiti):
print(" 🚨 APPARATO GUASTO: il verdetto sotto NON e' leggibile (§46: un controllo "
"positivo rotto dichiara guasto l'apparato)")
# ---- VERDETTO A RUNTIME (N11) ------------------------------------------------------------
print("\n" + "=" * 96)
# il verdetto deve tenere conto della diagnostica VRP: il bordo vince, ma di che cosa?
dirs_f = gate_trend(P, True)
rb_f = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs_f, senza_opzioni=True)["ret"]
fw7 = (np.log(P["px"] / P["px"].shift(1))[::-1].rolling(7).std()[::-1]
* math.sqrt(DPY)).bfill().ffill()
Pf7 = P.copy()
Pf7["iv"] = fw7.reindex(P.index).values
ang_dvol = collar(P, cal, dput=0.02, dcall=0.50, tenor=7, dirs=dirs_f)["ret"]
ang_rv = collar(Pf7, cal, dput=0.02, dcall=0.50, tenor=7, dirs=dirs_f)["ret"]
quota_vrp = (_drift(ang_dvol) - _drift(ang_rv)) / max(_drift(ang_dvol), 1e-9)
if n_dd == 0:
v = "IL PAVIMENTO NON RIDUCE IL maxDD — cade come §46 e per lo stesso motivo"
elif n_win <= len(R) // 8:
v = (f"IL PAVIMENTO FUNZIONA (a differenza di §46: maxDD scende in {n_dd}/{len(R)}) MA IL "
f"TETTO LO PAGA TROPPO: solo {n_win}/{len(R)} celle battono il de-levering. "
f"E cio' che vince sul bordo NON e' il pavimento: e' il premio di varianza "
f"({100*quota_vrp:.0f}% del drift dell'angolo sparisce riprezzando alla vol vera) "
f"— cioe' il meccanismo di VRP01, non una scoperta, e §3 lo blocca in deploy")
else:
v = f"REGGE IL PRIMO GATE: {n_win}/{len(R)} celle battono il de-levering — serve M2/DSR"
print(f" VERDETTO (calcolato a runtime): {v}")
print("=" * 96)
if __name__ == "__main__":
main()