"""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"