Files
PythagorasGoal/tests/test_collar_apparato.py
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

125 lines
5.8 KiB
Python

"""Guardie su COLLAR01 (§71): i difetti che l'apparato ha gia' pagato non devono tornare.
Non testano il VERDETTO (quello e' una misura, e cambia col dato): testano che il MOTORE sappia
riconoscere un successo e non regali soldi al collar per errore di contabilita'. E' la lezione di
§46 — *"un controllo positivo rotto dichiara guasto l'apparato"* — trasformata in test.
Calibrazione SINTETICA (non la catena vera): il test dev'essere veloce e hermetico. Quello che
verifica e' l'algebra del motore, non i parametri di mercato.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts" / "research"))
@pytest.fixture(scope="module")
def C():
import r0901_btc_collar as mod
return mod
@pytest.fixture(scope="module")
def cal():
"""Skew/termine/spread piatti e neutri: isola l'algebra dai parametri di mercato."""
d = np.array([0.02, 0.10, 0.20, 0.30, 0.50])
return dict(
skew={"put": (d, np.ones_like(d)), "call": (d, np.ones_like(d))},
term=(np.array([1.0, 30.0]), np.array([1.0, 1.0])),
spread={"put": (d, np.zeros_like(d)), "call": (d, np.zeros_like(d))},
)
@pytest.fixture(scope="module")
def P():
"""Serie deterministica di 800 giorni con un trend e un crollo: abbastanza per il gate a 180g."""
rng = np.random.default_rng(7)
n = 800
r = rng.normal(0.0015, 0.03, n)
r[600:640] = -0.045 # un crollo vero, dove il pavimento deve mordere
px = 30000.0 * np.exp(np.cumsum(r))
idx = pd.date_range("2022-01-01", periods=n, freq="D", tz="UTC")
return pd.DataFrame({"px": px, "iv": np.full(n, 0.60)}, index=idx)
def test_pranzo_gratis_e_riconosciuto(C, P, cal):
"""CONTROLLO POSITIVO (M15). Un pavimento a premio ZERO e senza tetto e' denaro regalato:
DEVE abbassare il maxDD E alzare il drift. Se questo test fallisce, ogni verdetto NEGATIVO
del filone e' illeggibile — non e' il collar che perde, e' l'apparato che e' rotto."""
dirs = C.gate_trend(P, True)
base = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs, senza_opzioni=True)["ret"]
free = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs,
prem_scale=0.0, solo_pavimento=True)["ret"]
assert C._dd(free) < C._dd(base), "un pavimento gratis non abbassa il maxDD: apparato guasto"
assert C._drift(free) > C._drift(base), "un pavimento gratis non alza il drift: apparato guasto"
def test_premio_esagerato_deve_perdere(C, P, cal):
"""CONTROLLO NEGATIVO: a premio x10 la struttura deve perdere contro la base."""
dirs = C.gate_trend(P, True)
base = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs, senza_opzioni=True)["ret"]
caro = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs, prem_scale=10.0)["ret"]
assert C._drift(caro) < C._drift(base)
def test_nav_short_parte_positivo(C, P, cal):
"""Difetto reale trovato il 2026-09-01: scritto come `direzione*S_t + ...` il NAV di uno SHORT
partirebbe NEGATIVO. Lo short non e' un'attivita' che vale -S: e' capitale a garanzia."""
dirs = np.where(np.arange(len(P)) >= 200, -1.0, 0.0)
out = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs)
assert (out["nav"] > 0).all(), "NAV non positivo in direzione short"
def test_zerocost_bisezione_non_invertita(C, P, cal):
"""Difetto reale: la bisezione dello zero-cost era INVERTITA (dava Sharpe -3,9 e drift -53%/a).
L'invariante e' sulla BISEZIONE, non sul drift: il premio cresce col delta, quindi per
finanziare una put PIU' CARA il tetto va AVVICINATO => il delta scelto per il tetto deve
CRESCERE col delta del pavimento. (Il drift NON e' monotono e non va usato come proxy: un
pavimento piu' stretto aiuta nei crolli e danneggia nei rally.)
"""
dirs = C.gate_trend(P, True)
scelti = []
for dp in (0.05, 0.15, 0.30):
o = C.collar(P, cal, dput=dp, dcall=np.nan, tenor=14, dirs=dirs, zerocost=True)
assert o["dc_scelti"], "lo zero-cost non ha aperto nessuna struttura"
assert np.isfinite(C._drift(o["ret"])), "zero-cost non finito (NaN nella cassa)"
scelti.append(float(np.median(o["dc_scelti"])))
assert scelti[0] < scelti[1] < scelti[2], f"bisezione invertita: dc scelti {scelti}"
def test_base_senza_opzioni_non_paga_roll_fantasma(C, P, cal):
"""Difetto reale: la base del null rollava lo SPOT ogni `tenor` giorni pagando fee che nella
realta' non esistono (~3,6%/anno a 7 giorni) — avrebbe ADULATO il collar. La base non deve
dipendere dal tenor, che senza opzioni non ha significato."""
dirs = C.gate_trend(P, True)
a = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"]
b = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs, senza_opzioni=True)["ret"]
assert C._drift(a) == pytest.approx(C._drift(b), abs=1e-9), "la base dipende dal tenor"
def test_gate_forte_e_piu_selettivo_del_largo(C, P):
"""`tsmom_blend` assume solo {-1,-1/3,+1/3,+1}: il gate FORTE (3/3 concordi) deve essere un
SOTTOINSIEME stretto del LARGO, o "forte bull" non sta selezionando nulla."""
f = C.gate_trend(P, True)
l = C.gate_trend(P, False)
assert np.mean(f != 0) < np.mean(l != 0)
assert np.all((f == 0) | (f == l)), "il gate forte non e' un sottoinsieme del largo"
def test_entrata_non_guarda_il_futuro(C, P):
"""L'entrata e' il giorno DOPO il segnale (§8.1): la direzione del giorno i non puo' dipendere
dal prezzo del giorno i."""
d0 = C.gate_trend(P, True)
P2 = P.copy()
P2.iloc[-1, P2.columns.get_loc("px")] *= 1.5 # muovo SOLO l'ultimo prezzo
d1 = C.gate_trend(P2, True)
assert np.array_equal(d0, d1), "la direzione reagisce a un prezzo che non ha ancora visto"