From 04cb572535f2fee954a3f50ad620654fb800e350 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 30 Jul 2026 21:57:15 +0000 Subject: [PATCH] research(vrp): profit-take al 50% REFUTED, e una correzione a un numero di stamattina MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Book, pesi, cron, config INVARIATI. VRP01 tiene fino a scadenza (S1 = px[i+tn], nessuna gestione infra-settimana) e il profit-take era l'unico grado di liberta' non misurato: i 4 overlay del 03/07 erano tutti sul lato del RISCHIO, questo e' sul lato del PROFITTO. Replica del sleeve bit-exact (max|diff| = 0.0) prima di ogni delta. VERDETTO con fee reali per gamba: canonico ShFULL 1.32 -> PT25 0.22 / PT50 -0.18 / PT75 -0.45. Null del de-levering REFUTED 6/6: a PT25 basta k=0.818 per lo stesso DD con Sharpe 1.32 invece di 0.22. MECCANISMO (confronto appaiato per ingresso): scatta sull'86-91% dei VINCENTI e sul 19-25% dei perdenti -> tronca i vincenti del 27% e salva un perdente su cinque. La peggior settimana e' IDENTICA (-7.27%) in ogni variante: nelle settimane brutte lo spread non tocca mai +50%, quindi e' pura troncatura dell'upside. IPOTESI MIA REFUTATA IN SESSIONE: 'su 7g chi tocca +50% e' chi sarebbe scaduto senza valore, quindi a scadenze lunghe paga'. Testata su 7/10/14/18/21/28g: delta Sharpe negativo a tutti e sei. A 18g (il tenore di cerbero-bite) il DD migliora 7.1->3.8% ma lo Sharpe crolla = firma del de-levering. CORREZIONE a un numero pubblicato oggi: il sleeve modella le fee come 12.5% del credito netto, il listino vero e' 0.03% del sottostante per gamba (cap 12.5% del premio della singola opzione, che quasi mai morde) -> sovrastima di ~2x. Il numero onesto di VRP01 con f=0.73 e' ShFULL 0.47, non 0.31. fee_frac NON cambiato: il forfait e' conservativo e i numeri di ammissione di questo progetto si tengono conservativi. A $3.000 la domanda non e' il rendimento: BTC min 0.1 = $6.210 di collaterale per lotto -> FUORI; ETH min 1 contratto = $1.832 -> 1 lotto. Il sleeve 50/50 diventa ETH-only e al peso di book (12% = $360) sono 0 lotti. REGOLE: (a) un'uscita si giudica appaiata per INGRESSO, mai allineando le serie sulla data di USCITA — e' quella che la variante cambia, e l'inner-join tiene solo i casi in cui non e' successo niente (errore commesso e corretto in sessione, congelato in un test); (b) una regola d'uscita che scatta piu' spesso sui vincenti che sui perdenti non e' protezione, e' troncatura; (c) prima del rendimento a un capitale dato, misurare il lotto minimo del venue. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 35 ++ docs/diary/2026-07-30-vrp-profit-take.md | 157 ++++++++ scripts/research/r0730_vrp_profit_take.py | 445 ++++++++++++++++++++++ tests/test_vrp_profit_take.py | 178 +++++++++ 4 files changed, 815 insertions(+) create mode 100644 docs/diary/2026-07-30-vrp-profit-take.md create mode 100644 scripts/research/r0730_vrp_profit_take.py create mode 100644 tests/test_vrp_profit_take.py diff --git a/CLAUDE.md b/CLAUDE.md index ab6ab32..8cb51a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,6 +169,41 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis (qui 1.02, implementazione indipendente). **REGOLA: il f di una struttura multi-gamba non è il f di una sua gamba — misurare la sola gamba venduta dà la risposta sbagliata con segno rassicurante.** Congelata in `tests/test_cb_chain_vrp.py`. + ✅ **PROFIT-TAKE al 50% del credito — REFUTED (2026-07-30, 3° filone).** + `scripts/research/r0730_vrp_profit_take.py`, test `tests/test_vrp_profit_take.py` (16), diario + `2026-07-30-vrp-profit-take.md`. **Book/pesi/cron/config INVARIATI.** VRP01 tiene FINO A + SCADENZA (`S1 = px[i+tn]`, nessuna gestione infra-settimana) e il profit-take era l'unico grado + di liberta' non misurato: gli overlay del 03/07 erano tutti sul lato del RISCHIO. Replica del + sleeve **bit-exact** (max|diff| = 0.0) prima di ogni delta. Esito con **fee reali per gamba**: + canonico ShFULL **1.32** → PT25 0.22 / **PT50 −0.18** / PT75 −0.45; **null del de-levering + REFUTED 6/6** (a PT25 basta k=0.818 per lo stesso DD con Sharpe 1.32 invece di 0.22). + **Meccanismo (confronto appaiato per ingresso):** scatta sull'**86-91% dei VINCENTI** e sul + **19-25% dei perdenti** → tronca i vincenti del **27%** e salva un perdente su cinque; la + **peggior settimana e' IDENTICA** (−7.27%) in ogni variante = pura troncatura dell'upside. + ❌ **Ipotesi mia refutata in sessione:** "su 7g chi tocca +50% e' chi sarebbe scaduto senza + valore, quindi a scadenze lunghe paga" — testata su 7/10/14/18/21/28g, **ΔSh negativo a tutti e + sei** (a 18g, il tenore di cerbero-bite, il DD migliora 7.1→3.8% ma lo Sharpe crolla = firma del + de-levering). ⚠️ La colonna `Sh hold` di quello sweep **NON e' un risultato** (6 celle sul + campione pieno); il tenore >10g resta un buco reale della griglia 03/07, da chiudere con + `study_family_honest`, non con uno sweep. **Con questo gli overlay refutati su VRP01 sono 5/5.** + ⚠️ **CORREZIONE a un numero pubblicato lo stesso giorno:** il sleeve modella le fee come 12.5% + del credito NETTO, il listino vero e' **0.03% del sottostante per gamba** (cap 12.5% del premio + della singola opzione, che quasi mai morde) → il modello **sovrastima le fee di ~2x**. Sleeve + ufficiale 1.08/0.58/DD 11.8% vs fee reali **1.32/0.82/DD 10.5%**. Quindi il numero onesto di + VRP01 con **f=0.73** e' **ShFULL 0.47 / DD 14.5%, non 0.31** (che era calcolato col forfait). + `fee_frac` **NON cambiato**: il forfait e' conservativo e questo progetto tiene i numeri di + ammissione conservativi. **Rientro immediato** dopo l'uscita: Sh 1.19 < 1.32 canonico; il suo + ShHOLD 2.42 non e' selezionabile in-sample → **non e' un lead**. + 💰 **A $3.000 la domanda non e' il rendimento** (`min_trade_amount` letti dal venue il 30/07): + **BTC min 0.1 = $6.210 di collaterale/lotto → FUORI**; ETH min 1 contratto = $1.832 → **1 lotto**. + Il sleeve 50/50 **diventa ETH-only**, e **al peso di book (12% = $360) sono 0 lotti** — servirebbe + il **61% del conto** su un solo sleeve. **REGOLE:** (a) un'uscita si giudica su un confronto + **appaiato per INGRESSO**, mai allineando le serie sulla data di USCITA — e' proprio quella che + la variante cambia, e l'inner-join tiene solo i casi in cui non e' successo niente (errore + commesso e corretto in sessione, 3ª occorrenza della trappola dopo venue-watch e GTAA; congelato + in un test); (b) **una regola d'uscita che scatta piu' spesso sui vincenti che sui perdenti non + e' protezione, e' troncatura** — si vede in una riga, la peggior settimana non cambia; (c) prima + di misurare il rendimento a un capitale dato, misurare il **lotto minimo del venue**. **`VRP_CFG["f"]` NON cambiato** (15 osservazioni, 7 settimane, e per giunta **0/8 settimane passano il gate IV-rank>0.30**: il f è misurato nel regime in cui il sleeve sta FLAT, DVOL max raccolto al 24°/28° pctl della storia) → **caveat quantificato, non nuovo parametro**; il criterio diff --git a/docs/diary/2026-07-30-vrp-profit-take.md b/docs/diary/2026-07-30-vrp-profit-take.md new file mode 100644 index 0000000..a7e26fc --- /dev/null +++ b/docs/diary/2026-07-30-vrp-profit-take.md @@ -0,0 +1,157 @@ +# 2026-07-30 (3º filone) — profit-take al 50% su VRP01: REFUTED, e una correzione a un numero di stamattina + +**Domanda dell'operatore:** *«fai la misura del profit-take al 50%, simula con 3K».* +**Esito: book, pesi, cron, config INVARIATI.** Il profit-take **non entra**. Ma il filone ha +prodotto una correzione favorevole a un numero pubblicato **oggi stesso**, e una mia ipotesi +refutata nella stessa sessione. + +Script `scripts/research/r0730_vrp_profit_take.py`, test `tests/test_vrp_profit_take.py` (16 casi). + +## Perché questa misura e non un'altra + +VRP01 come codificato **tiene fino a scadenza**: in `_vrp_weekly_asset` il payoff è su +`S1 = px[i + tn]`, non esiste gestione infra-settimana. L'onda 03/07 aveva testato 4 overlay di +protezione DD (exit-spike, **stop-loss MTM 1.5/2/3×**, ala di coda, cooldown) e li aveva refutati +4/4 col null del de-levering — ma il profit-take **non era fra quelli**: è un'uscita sul lato del +*profitto*, non del rischio. Era l'ultimo grado di libertà non misurato su questo sleeve, e la +misura del 30/07 sulle quote reali diceva che sarebbe scattato in **13 settimane su 15**. + +L'idea è la regola 1 del motore di cerbero-bite (`debit <= 50% del credito`), progetto dismesso lo +stesso giorno. **Non se ne eredita il giudizio: bite non ne ha uno** (59 decisioni, tutte +`no_entry`, 0 posizioni, in testnet). Si misura. + +## Fondazione: replica bit-exact + +Prima di ogni delta, la re-implementazione riproduce `_vrp_weekly_asset` a **max|diff| = 0.00e+00 +su entrambi gli asset, zero date non condivise**. Senza questa, ogni numero sotto sarebbe rumore di +implementazione. Congelata in un test: se qualcuno tocca il sleeve, si rompe. + +## Il verdetto + +Fee **reali per gamba** (`min(0.03% del sottostante, 12.5% del premio)` = listino Deribit letto da +`public/get_instruments`), identiche a canonico e variante. + +| variante | ShFULL | ShHOLD | maxDD | CAGR | peggior sett. | usciti prima | giorni | +|---|---|---|---|---|---|---|---| +| **canonico (a scadenza)** | **1.32** | **0.82** | 10.5% | 10.5% | −7.27% | — | 7.0 | +| PT 25% | 0.22 | −0.44 | 8.6% | 0.9% | −7.27% | 87% | 2.7 | +| **PT 50%** | **−0.18** | **−0.65** | 17.3% | −1.3% | −7.27% | 79% | 3.6 | +| PT 75% | −0.45 | −1.24 | 19.5% | −3.0% | −7.27% | 71% | 4.6 | + +**Null del de-levering: REFUTED 6/6** (3 livelli × vol-matched e DD-matched). ⚠️ Nota onesta: sotto +scalatura moltiplicativa lo Sharpe è **invariante**, quindi il null si riduce a «la variante ha +Sharpe più alto?». Il `k` dice solo *quanto* de-levering ottiene lo stesso DD: per PT25 basta +**k=0.818** per avere lo stesso 8.6% di DD **con Sharpe 1.32 invece di 0.22**. + +## Il meccanismo — e perché serviva il confronto appaiato + +⚠️ **Errore di metodo commesso e corretto in sessione.** Le due varianti sono indicizzate sulla +**data di uscita**, che il profit-take cambia: un `join="inner"` fra le serie tiene solo le +settimane *senza* uscita anticipata, dove i valori coincidono per costruzione. La prima +diagnostica stampava «troncamento 0%, variazione +0%» su tutto — potenza zero, e una conclusione +esattamente opposta al vero. È la trappola dell'inner-join già codificata due volte nel progetto +(venue watch, GTAA), qui in veste nuova. Congelata in `test_confrontare_per_data_di_uscita_da_una_risposta_falsa`. + +Rifatto **appaiato per ingresso** (stesso trade, due esiti): + +| | BTC (71 trade) | ETH (92 trade) | +|---|---|---| +| scatta sui **vincenti** | **86%** | **91%** | +| scatta sui **perdenti** | 25% | 19% | +| vincenti | +1.419% → +1.023% (**−28%**) | +1.638% → +1.218% (**−26%**) | +| perdenti | −3.649% → −2.941% (−19%) | −4.248% → −3.505% (−17%) | +| somma PnL | +60.2% → **+40.9%** | +56.6% → **+36.5%** | + +**Il profit-take tronca del 27% i trade che avrebbero vinto comunque e salva un perdente su +cinque.** La peggior settimana è **identica** (−7.27%) in tutte le varianti: nelle settimane brutte +lo spread non raggiunge mai +50%, quindi si arriva a scadenza e si prende la perdita piena. È pura +troncatura dell'upside. + +## ❌ Ipotesi mia, nata e refutata nella stessa sessione + +Il meccanismo suggeriva una spiegazione: *su 7 giorni chi tocca +50% è proprio chi sarebbe scaduto +senza valore; a scadenze lunghe — 18g, quella di bite, e i 30-45 DTE dello standard di mestiere — +la regola dovrebbe pagare.* Testata su 6 tenori: + +| tenore | Sh hold | Sh PT50 | ΔSh | +|---|---|---|---| +| 7g | 1.32 | −0.18 | −1.50 | +| 10g | 1.51 | 0.12 | −1.40 | +| 14g | 1.14 | −0.56 | −1.71 | +| **18g** (bite) | **1.51** | **0.37** | **−1.14** | +| 21g | 1.09 | −0.27 | −1.36 | +| 28g | 1.31 | 0.06 | −1.25 | + +**Negativo a tutti e sei.** A 18g è il meno peggio e il DD migliora davvero (7.1% → 3.8%), ma lo +Sharpe crolla: **firma del de-levering**, non di una protezione. Ipotesi refutata. + +⚠️ **La colonna `Sh hold` NON è un risultato.** Sei celle scelte sul campione pieno: il tenore 18 +sembra migliore del 7 (1.51 vs 1.32) ma è esattamente la selezione che `study_family_honest` + +deflated-Sharpe esistono per uccidere. La griglia 03/07 si fermava a 10 giorni, quindi il buco è +reale — ma va chiuso con lo strumento giusto, non con questo sweep. + +## ✅ Correzione a un numero pubblicato stamattina + +Il sleeve modella le fee come **12.5% del credito netto** round-trip. Il listino vero è **0.03% del +sottostante per gamba** (cap 12.5% *del premio della singola opzione*, che quasi mai morde): su ETH +sono $1.14 d'ingresso contro i $2.26 del forfait, cioè il modello **sovrastima le fee di ~2×**. + +| lente | ShFULL | ShHOLD | maxDD | CAGR | +|---|---|---|---|---| +| sleeve ufficiale (forfait) | 1.08 | 0.58 | 11.8% | 8.2% | +| **fee reali per gamba** | **1.32** | **0.82** | 10.5% | 10.5% | + +Combinato col **f = 0.73** misurato stamattina sulle quote reali, il numero onesto di VRP01 è +**ShFULL 0.47 / DD 14.5%**, non lo **0.31** che ho scritto oggi in CLAUDE.md — che era calcolato +con le fee forfettarie. Il f resta il difetto dominante; la fee lo attenua di ~+0.16. + +**Nessuna azione:** `fee_frac` **non è stato cambiato**. Il forfait è conservativo, e questo +progetto preferisce i numeri di ammissione conservativi (stessa logica per cui l'audit d'ancora +del 03/07 su VRP01 è stato accolto come «numeri conservativi, non gonfiati»). Cambiarlo alzerebbe +i numeri di uno sleeve senza aggiungere edge. + +## Rientro immediato dopo l'uscita + +Testato come strategia a sé (non è un overlay: cambia la cadenza). PT50 + rientro fa **Sh 1.19** +contro 1.32 del canonico, CAGR 4.9% contro 10.5%, 200 trade contro 163. Il suo **ShHOLD 2.42** è +vistoso — ed è precisamente la firma che questo progetto ha imparato a non credere: non è +selezionabile in-sample (Sharpe FULL più basso), quindi crederci sarebbe selezione sull'hold-out. +**Non è un lead.** + +## $3.000 — la domanda che viene prima + +Parametri veri da `public/get_instruments` (30/07): **ETH `min_trade_amount` = 1 contratto = 1 ETH; +BTC = 0.1 BTC**. Collaterale = strike corto (convenzione cash-secured del sleeve). + +| | spot | lotto min | collaterale/lotto | a $3.000 | fee round-trip | +|---|---|---|---|---|---| +| BTC | $63.917 | 0.1 | **$6.210** | **0 lotti — FUORI** | $7.67 = 17.8% del credito | +| ETH | $1.906 | 1.0 | $1.832 | 1 lotto | $2.29 = 12.6% del credito | + +Tre conseguenze: +1. **A $3.000 il sleeve 50/50 diventa ETH-only** — cioè non è più il sleeve misurato. +2. **Al peso di book (12%) l'allocazione è $360 = 0 lotti.** Per un solo lotto ETH servirebbe il + **61% del conto** su un singolo sleeve. +3. Quindi a $3.000 la domanda «il profit-take migliora VRP01?» **non è la domanda che decide**: + VRP01 non è collocabile a quel capitale in nessuna forma sensata. + +## Cosa resta + +- **Profit-take: REFUTED**, a ogni livello, a ogni f, a ogni tenore, contro il null del de-levering. + Con questo il conto degli overlay refutati su VRP01 sale a **5 su 5**, e il filone «gestire VRP01 + dentro il modello» è chiuso su tutti i lati misurabili. +- **Buco reale rimasto:** il tenore oltre i 10 giorni non è mai passato per `study_family_honest`. + Non è urgente (le opzioni non sono eseguibili prima di ~$2.6k, e a $3k solo ETH e a peso assurdo). +- **Il vincolo binding non è la ricerca.** Vale la nota del 26/07: qui si è misurato un ΔSharpe che + non cambia niente, mentre le leve che decidono restano il capitale che entra e il conto che non + sparisce. + +## Regole + +- **Un'uscita si giudica su un confronto APPAIATO PER INGRESSO**, mai allineando due serie sulla + data di uscita: la variante che stai testando è esattamente quella che cambia quella data, e il + join tiene solo i casi in cui non è successo niente. +- **Una regola d'uscita che scatta più spesso sui vincenti che sui perdenti non è protezione, è + troncatura** — e si vede in una riga: la peggior settimana non cambia. +- **Prima di misurare il rendimento a un capitale dato, misurare il lotto minimo del venue.** Qui + la risposta operativa («non è collocabile») non dipendeva da nessuno Sharpe. diff --git a/scripts/research/r0730_vrp_profit_take.py b/scripts/research/r0730_vrp_profit_take.py new file mode 100644 index 0000000..845cd42 --- /dev/null +++ b/scripts/research/r0730_vrp_profit_take.py @@ -0,0 +1,445 @@ +"""R0730 VRP-PT — profit-take al 50% del credito su VRP01, e cosa ne resta a $3.000. + +DOMANDA. VRP01 come codificato tiene il credit spread FINO A SCADENZA: in +`sleeves._vrp_weekly_asset` il payoff e' calcolato su `S1 = px[i + tn]` e non esiste +nessuna gestione infra-settimana. La misura del 30/07 sulle quote REALI ha trovato che +un profit-take al 50% del credito sarebbe scattato in **13 settimane su 15**, e che il +peggior mark infra-settimana e' mediana -6.9% / minimo -81.7% su trade finiti in utile. +Questo script chiede se quella gestione MIGLIORA lo sleeve, e cosa sopravvive a $3.000. + +L'idea non e' nuova ed e' la regola 1 del motore di cerbero-bite (`debit <= 50% credito`), +progetto dismesso il 30/07. Qui non si eredita il suo giudizio — non ne ha uno: 59 +decisioni, tutte no_entry, 0 posizioni. Si misura. + +COSA E' GIA' STATO RIFIUTATO, e non si rifa'. L'onda 03/07 (`r0703_vrpimp_ddoverlay`) +ha testato 4 overlay di protezione DD — exit su spike DVOL, **stop-loss MTM a 1.5/2/3x +il credito**, ala di coda, cooldown — e li ha REFUTATI 4/4 col null del de-levering. +Il profit-take NON era fra quelli: e' un'uscita sul lato del PROFITTO, non del rischio. +E' l'unico grado di liberta' rimasto non misurato su questo sleeve. + +METODO. + (1) Replica bit-exact di `_vrp_weekly_asset` (assert max|diff| == 0). Senza questa, + ogni delta misurato dopo e' rumore di implementazione. + (2) Marks giornalieri: dentro la settimana lo spread e' riprezzato con lo STESSO + pricer dell'ingresso (BS su DVOL corrente, T residuo). Chiudo quando + `debit <= (1 - pt) * credito`. + (3) NULL DEL DE-LEVERING, obbligatorio: un claim "meno drawdown" parte REFUTED + finche' non batte il canonico semplicemente ridotto di size. Confronto a + vol-matched E a DD-matched. + (4) Fee REALI per gamba invece della convenzione forfettaria dello sleeve: + `min(0.03% del sottostante, 12.5% del premio dell'opzione)`, cioe' il listino + Deribit vero. Applicata IDENTICA a canonico e variante: un'uscita anticipata + paga 2 gambe in piu', ed e' il costo principale di questa idea. + (5) $3.000: contratti INTERI (ETH min_trade_amount=1 -> 1 ETH), BTC fuori + (min 0.1 BTC = ~$6.500 di nozionale per gamba), collaterale = strike corto + (convenzione cash-secured dello sleeve). + +f. Il fattore di prezzo misurato il 30/07 (f=0.73 sulle quote reali) e' applicato +UNIFORMEMENTE al valore dello spread, all'ingresso e all'uscita: se il modello sbaglia +il prezzo di quella struttura, lo sbaglia anche quando la si ricompra. Conseguenza utile: +il TRIGGER del profit-take e' invariante in f (`f*debit <= 0.5*f*credito`), quindi f +scala il PnL ma non cambia QUANDO si esce. + + uv run python scripts/research/r0730_vrp_profit_take.py + uv run python scripts/research/r0730_vrp_profit_take.py --capital 3000 +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.data.downloader import load_data # noqa: E402 +from src.portfolio.portfolio import metrics # noqa: E402 +from src.portfolio.sleeves import ( # noqa: E402 + VRP_CFG, + _bs_put, + _strike_from_delta, + _vrp_weekly_asset, + _HL_DIR, +) +from src.strategies.trend_portfolio import resample_1d # noqa: E402 + +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +ASSETS = ("BTC", "ETH") +PT_GRID = (0.25, 0.50, 0.75) +F_GRID = (1.00, 0.80, 0.73) +TENOR_GRID = (7, 10, 14, 18, 21, 28) # 18 = la scadenza di cerbero-bite + +# listino Deribit opzioni, letto da public/get_instruments il 2026-07-30 +FEE_UNDERLYING = 0.0003 # 0.03% del sottostante per contratto, per gamba +FEE_PREMIUM_CAP = 0.125 # ...ma mai piu' del 12.5% del premio dell'opzione +MIN_TRADE = {"BTC": 0.1, "ETH": 1.0} + + +# --------------------------------------------------------------------------- +# dati: identici a quelli del sleeve +# --------------------------------------------------------------------------- +def series(asset: str): + df = resample_1d(load_data(asset, "1h")) + s = pd.Series(df["close"].values.astype(float), index=pd.to_datetime(df["datetime"])) + if s.index.tz is None: + s.index = s.index.tz_localize("UTC") + dv = pd.read_parquet(_HL_DIR / f"dvol_{asset.lower()}.parquet") + d = pd.Series(dv["close"].values.astype(float), + index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)) + J = pd.concat({"px": s, "dvol": d}, axis=1, join="inner").sort_index().dropna() + return J["px"].values, J["dvol"].values / 100.0, J.index + + +def leg_fee(spot: float, premium: float) -> float: + """Fee Deribit di UNA gamba: 0.03% del sottostante, cap al 12.5% del premio.""" + return min(FEE_UNDERLYING * spot, FEE_PREMIUM_CAP * max(premium, 0.0)) + + +# --------------------------------------------------------------------------- +# il motore: un solo grado di liberta' per volta +# --------------------------------------------------------------------------- +def run_asset(asset: str, pt: float | None = None, f: float = 1.0, + real_fee: bool = True, redeploy: bool = False, tenor: int | None = None): + """Ritorna (serie settimanale, diagnostica). `pt=None` = canonico (tieni a scadenza). + + `real_fee=False` riproduce la convenzione forfettaria dello sleeve (per la replica + bit-exact). `redeploy=True` rientra subito dopo un'uscita anticipata invece di + aspettare la cadenza settimanale (strategia DIVERSA, non un overlay). + """ + px, dvf, idx = series(asset) + n = len(px) + cfg = VRP_CFG + tn = int(tenor if tenor is not None else cfg["tenor_d"]) + T0 = tn / 365.25 + rets: dict[pd.Timestamp, float] = {} + n_trades = n_early = 0 + days_held: list[int] = [] + worst_mark: list[float] = [] + + i = 60 + while i + tn < n: + S0 = px[i] + sig = dvf[i] + + skip = False + if cfg["gate_vrp"] and i >= 31: # VRP>0: DVOL > RV30 causale + rv = np.std(np.diff(np.log(px[i - 30:i + 1]))) * np.sqrt(365.25) + if (sig - rv) <= 0: + skip = True + if not skip and (cfg["gate_ivr"] > 0 or cfg["crash_skip"] < 1.0) and i >= 60: + ivr = float((dvf[:i] < dvf[i]).mean()) # IV-rank espandente causale + if cfg["gate_ivr"] > 0 and ivr < cfg["gate_ivr"]: + skip = True + if cfg["crash_skip"] < 1.0 and ivr > cfg["crash_skip"]: + skip = True + if skip: + rets[idx[i + tn]] = 0.0 + i += tn + continue + + Ks = _strike_from_delta(S0, T0, sig, cfg["short_delta"]) + Kl = _strike_from_delta(S0, T0, sig, cfg["long_delta"]) + ps0, pl0 = _bs_put(S0, Ks, T0, sig), _bs_put(S0, Kl, T0, sig) + credit = (ps0 - pl0) * f + + # --- fee d'ingresso --- + if real_fee: + fee = leg_fee(S0, ps0 * f) + leg_fee(S0, pl0 * f) + else: + fee = 0.0 # forfait applicato a valle + + # --- cammino infra-settimana --- + exit_j = i + tn # default: scadenza + early = False + mk_worst = 0.0 + if pt is not None: + for j in range(i + 1, i + tn): + Trem = (i + tn - j) / 365.25 + Sj, sj = px[j], dvf[j] + debit = (_bs_put(Sj, Ks, Trem, sj) - _bs_put(Sj, Kl, Trem, sj)) * f + mk_worst = min(mk_worst, (credit - debit) / Ks) + if debit <= (1.0 - pt) * credit: + exit_j = j + early = True + break + + if early: + Trem = (i + tn - exit_j) / 365.25 + Sj, sj = px[exit_j], dvf[exit_j] + psx, plx = _bs_put(Sj, Ks, Trem, sj), _bs_put(Sj, Kl, Trem, sj) + cost = (psx - plx) * f + if real_fee: + fee += leg_fee(Sj, psx * f) + leg_fee(Sj, plx * f) + pnl = credit - cost - fee + else: + S1 = px[i + tn] + payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1) + if real_fee: + # a scadenza: fee di regolamento solo sulle gambe ITM (0.015% del sottostante) + if Ks > S1: + fee += 0.5 * FEE_UNDERLYING * S1 + if Kl > S1: + fee += 0.5 * FEE_UNDERLYING * S1 + pnl = credit - payoff - fee + + if not real_fee: + pnl -= cfg["fee_frac"] * abs(credit) # convenzione dello sleeve + + rets[idx[exit_j]] = rets.get(idx[exit_j], 0.0) + pnl / Ks + n_trades += 1 + n_early += int(early) + days_held.append(exit_j - i) + worst_mark.append(mk_worst) + + i = (exit_j + 1) if (redeploy and early) else (i + tn) + + diag = dict(trades=n_trades, early=n_early, + early_pct=100.0 * n_early / max(n_trades, 1), + days_held=float(np.mean(days_held)) if days_held else float("nan"), + worst_mark=float(np.median(worst_mark)) if worst_mark else float("nan")) + return pd.Series(rets).sort_index(), diag + + +def paired_trades(asset: str, pt: float, f: float = 1.0) -> pd.DataFrame: + """Per OGNI ingresso, l'esito hold-to-expiry E quello col profit-take: unico confronto + valido, perche' le due varianti escono in date diverse.""" + px, dvf, idx = series(asset) + n = len(px) + cfg = VRP_CFG + tn = cfg["tenor_d"] + T0 = tn / 365.25 + recs = [] + i = 60 + while i + tn < n: + S0, sig = px[i], dvf[i] + skip = False + if cfg["gate_vrp"] and i >= 31: + rv = np.std(np.diff(np.log(px[i - 30:i + 1]))) * np.sqrt(365.25) + if (sig - rv) <= 0: + skip = True + if not skip and i >= 60: + ivr = float((dvf[:i] < dvf[i]).mean()) + if ivr < cfg["gate_ivr"] or ivr > cfg["crash_skip"]: + skip = True + if skip: + i += tn + continue + Ks = _strike_from_delta(S0, T0, sig, cfg["short_delta"]) + Kl = _strike_from_delta(S0, T0, sig, cfg["long_delta"]) + credit = (_bs_put(S0, Ks, T0, sig) - _bs_put(S0, Kl, T0, sig)) * f + ej = None + for j in range(i + 1, i + tn): + Trem = (i + tn - j) / 365.25 + debit = (_bs_put(px[j], Ks, Trem, dvf[j]) - _bs_put(px[j], Kl, Trem, dvf[j])) * f + if debit <= (1.0 - pt) * credit: + ej = j + break + S1 = px[i + tn] + hold = (credit - (max(0.0, Ks - S1) - max(0.0, Kl - S1))) / Ks + if ej is None: + ptv, early = hold, False + else: + Trem = (i + tn - ej) / 365.25 + cost = (_bs_put(px[ej], Ks, Trem, dvf[ej]) - _bs_put(px[ej], Kl, Trem, dvf[ej])) * f + ptv, early = (credit - cost) / Ks, True + recs.append((hold, ptv, early)) + i += tn + return pd.DataFrame(recs, columns=["hold", "pt", "early"]) + + +def combo(pt=None, f=1.0, real_fee=True, redeploy=False, tenor=None): + """Book 50/50 BTC+ETH su griglia giornaliera, come `_vrp_combo_returns`.""" + out, diags = {}, {} + for a in ASSETS: + out[a], diags[a] = run_asset(a, pt=pt, f=f, real_fee=real_fee, + redeploy=redeploy, tenor=tenor) + wk = pd.concat({"B": out["BTC"], "E": out["ETH"]}, axis=1, join="inner").mean(axis=1).sort_index() + if wk.empty: + return wk, diags + days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC") + daily = pd.Series(0.0, index=days) + daily.loc[wk.index.normalize()] = wk.values + return daily, diags + + +# --------------------------------------------------------------------------- +# metriche e null +# --------------------------------------------------------------------------- +def stats(daily: pd.Series) -> dict: + m = metrics(daily) + h = metrics(daily[daily.index >= HOLDOUT]) + return dict(sh=m["sharpe"], dd=m["maxdd"], cagr=m["cagr"], sh_h=h["sharpe"], + worst=float(daily.min())) + + +def delever_null(canon: pd.Series, variant: pd.Series, match: str) -> dict: + """Il canonico ridotto di size fa lo stesso lavoro? `match` in {'vol','dd'}.""" + sv, sc = stats(variant), stats(canon) + if match == "vol": + k = variant.std() / canon.std() if canon.std() > 0 else 1.0 + else: # DD-matched, per bisezione + lo, hi = 0.01, 3.0 + for _ in range(60): + k = 0.5 * (lo + hi) + if stats(canon * k)["dd"] < sv["dd"]: + lo = k + else: + hi = k + k = 0.5 * (lo + hi) + sk = stats(canon * k) + return dict(k=k, sh_null=sk["sh"], dd_null=sk["dd"], sh_var=sv["sh"], dd_var=sv["dd"], + d_sh=sv["sh"] - sk["sh"], batte=(sv["sh"] > sk["sh"])) + + +# --------------------------------------------------------------------------- +# eseguibilita' a capitale dato +# --------------------------------------------------------------------------- +def executability(capital: float) -> dict: + """Quanti contratti INTERI entrano, per asset, con collaterale = strike corto.""" + rows = {} + for a in ASSETS: + px, dvf, idx = series(a) + S0, sig = px[-1], dvf[-1] + T0 = VRP_CFG["tenor_d"] / 365.25 + Ks = _strike_from_delta(S0, T0, sig, VRP_CFG["short_delta"]) + Kl = _strike_from_delta(S0, T0, sig, VRP_CFG["long_delta"]) + unit = MIN_TRADE[a] + collat = Ks * unit # cash-secured, per lotto minimo + ps, pl = _bs_put(S0, Ks, T0, sig), _bs_put(S0, Kl, T0, sig) + credit = (ps - pl) * unit + fee_rt = 2 * (leg_fee(S0, ps) + leg_fee(S0, pl)) * unit # ingresso + uscita anticipata + rows[a] = dict(spot=S0, Ks=Ks, Kl=Kl, unit=unit, collat=collat, + lots=int(capital // collat), credit=credit, fee_rt=fee_rt, + fee_pct_credit=100.0 * fee_rt / credit if credit > 0 else float("nan"), + width=(Ks - Kl) * unit) + return rows + + +# --------------------------------------------------------------------------- +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--capital", type=float, default=3000.0) + args = ap.parse_args() + + print("=" * 92) + print(" R0730 VRP-PT — profit-take al 50% del credito su VRP01") + print("=" * 92) + + # ---- (0) replica bit-exact ------------------------------------------------ + print("\n (0) REPLICA del sleeve (senza questa, ogni delta dopo e' rumore)") + for a in ASSETS: + mine, _ = run_asset(a, pt=None, f=VRP_CFG["f"], real_fee=False) + ref = _vrp_weekly_asset(a) + j = pd.concat({"m": mine, "r": ref}, axis=1, join="inner") + d = float((j["m"] - j["r"]).abs().max()) + n_only = len(mine) + len(ref) - 2 * len(j) + flag = "OK" if (d == 0.0 and n_only == 0) else "⚠ DIVERGE" + print(f" {a}: max|diff| = {d:.2e} date non condivise = {n_only} {flag}") + if d != 0.0 or n_only != 0: + print(" REPLICA FALLITA — mi fermo, il confronto non sarebbe valido.") + return 1 + + # ---- (1) griglia profit-take ---------------------------------------------- + print(f"\n (1) PROFIT-TAKE — fee REALI Deribit per gamba, f={F_GRID[0]:.2f}" + f" (trigger invariante in f)") + canon, dcan = combo(pt=None, f=1.0) + sc = stats(canon) + print(f" {'variante':<22}{'ShFULL':>8}{'ShHOLD':>8}{'maxDD':>8}{'CAGR':>8}" + f"{'peggior sett':>14}{'usciti prima':>14}{'giorni':>8}") + print(f" {'canonico (scadenza)':<22}{sc['sh']:>8.2f}{sc['sh_h']:>8.2f}" + f"{sc['dd']*100:>7.1f}%{sc['cagr']*100:>7.1f}%{sc['worst']*100:>13.2f}%" + f"{'—':>14}{7.0:>8.1f}") + variants = {} + for pt in PT_GRID: + v, dg = combo(pt=pt, f=1.0) + variants[pt] = v + s = stats(v) + early = np.mean([dg[a]["early_pct"] for a in ASSETS]) + held = np.mean([dg[a]["days_held"] for a in ASSETS]) + print(f" {'PT ' + str(int(pt*100)) + '% del credito':<22}{s['sh']:>8.2f}{s['sh_h']:>8.2f}" + f"{s['dd']*100:>7.1f}%{s['cagr']*100:>7.1f}%{s['worst']*100:>13.2f}%" + f"{early:>13.0f}%{held:>8.1f}") + + # ---- (2) null del de-levering --------------------------------------------- + print("\n (2) NULL DEL DE-LEVERING — il canonico ridotto di size fa lo stesso lavoro?") + print(" (regola del progetto: un claim 'meno DD' parte REFUTED finche' non lo batte)") + for pt in PT_GRID: + for match in ("vol", "dd"): + r = delever_null(canon, variants[pt], match) + verdict = "batte il null" if r["batte"] else "REFUTED" + print(f" PT {int(pt*100):>2}% match {match:>3}: k={r['k']:.3f} " + f"Sh null {r['sh_null']:>5.2f} vs variante {r['sh_var']:>5.2f} " + f"(Δ {r['d_sh']:+.2f}) DD {r['dd_null']*100:.1f}% vs {r['dd_var']*100:.1f}% {verdict}") + + # ---- (3) sensibilita' a f -------------------------------------------------- + print("\n (3) BANDA f — f scala il PnL ma NON il trigger (f*debit <= 0.5*f*credito)") + print(f" {'f':>6}{'Sh canonico':>14}{'Sh PT50':>10}{'ΔSh':>8}" + f"{'DD canonico':>14}{'DD PT50':>10}") + for f in F_GRID: + c, _ = combo(pt=None, f=f) + v, _ = combo(pt=0.50, f=f) + s0, s1 = stats(c), stats(v) + print(f" {f:>6.2f}{s0['sh']:>14.2f}{s1['sh']:>10.2f}{s1['sh']-s0['sh']:>+8.2f}" + f"{s0['dd']*100:>13.1f}%{s1['dd']*100:>9.1f}%") + + # ---- (4) redeploy ---------------------------------------------------------- + print("\n (4) RIENTRO IMMEDIATO dopo l'uscita (strategia DIVERSA, non un overlay)") + rd, drd = combo(pt=0.50, f=1.0, redeploy=True) + s = stats(rd) + tr = sum(drd[a]["trades"] for a in ASSETS) + tr0 = sum(dcan[a]["trades"] for a in ASSETS) + print(f" PT50 + rientro: Sh {s['sh']:.2f} ShHOLD {s['sh_h']:.2f} DD {s['dd']*100:.1f}%" + f" CAGR {s['cagr']*100:.1f}% trade {tr} (canonico {tr0}, +{100*(tr/tr0-1):.0f}%)") + + # ---- (4-bis) confronto APPAIATO per ingresso: il meccanismo --------------- + print("\n (4-bis) MECCANISMO — confronto appaiato PER INGRESSO") + print(" (le serie sono indicizzate sulla data di USCITA, che il PT cambia: un inner-join") + print(" fra le due confronterebbe solo le settimane senza uscita anticipata)") + for a in ASSETS: + R = paired_trades(a, pt=0.50) + W, L = R[R.hold > 0], R[R.hold <= 0] + print(f" {a}: {len(R)} trade, uscita anticipata {100*R.early.mean():.0f}%" + f" — scatta sul {100*W.early.mean():.0f}% dei VINCENTI e sul {100*L.early.mean():.0f}% dei PERDENTI") + print(f" vincenti {W.hold.mean()*100:+.3f}% -> {W.pt.mean()*100:+.3f}% ({100*(W.pt.mean()/W.hold.mean()-1):+.0f}%)" + f" perdenti {L.hold.mean()*100:+.3f}% -> {L.pt.mean()*100:+.3f}% ({100*(L.pt.mean()/L.hold.mean()-1):+.0f}%)") + + # ---- (4-ter) ipotesi: funziona a scadenze piu' lunghe? -------------------- + print("\n (4-ter) IPOTESI DA VERIFICARE: su 7g chi tocca +50% e' chi sarebbe scaduto") + print(" senza valore, quindi il PT dovrebbe pagare a scadenze LUNGHE (bite usava 18g).") + print(f" {'tenore':>8}{'Sh hold':>10}{'Sh PT50':>10}{'ΔSh':>8}{'DD hold':>10}{'DD PT50':>10}{'usciti':>9}") + for tn in TENOR_GRID: + c, _ = combo(pt=None, f=1.0, tenor=tn) + v, dg = combo(pt=0.50, f=1.0, tenor=tn) + s0, s1 = stats(c), stats(v) + early = np.mean([dg[a]["early_pct"] for a in ASSETS]) + print(f" {tn:>6}g{s0['sh']:>10.2f}{s1['sh']:>10.2f}{s1['sh']-s0['sh']:>+8.2f}" + f"{s0['dd']*100:>9.1f}%{s1['dd']*100:>9.1f}%{early:>8.0f}%") + print(" -> IPOTESI REFUTATA se ΔSh resta negativo ovunque. ⚠ La colonna 'Sh hold' NON e'") + print(" un risultato: 6 celle scelte sul campione pieno; per crederci serve") + print(" study_family_honest + deflated-Sharpe, non questo sweep.") + + # ---- (5) $3.000 ------------------------------------------------------------- + print(f"\n (5) ESEGUIBILITA' A ${args.capital:,.0f} — contratti INTERI, collaterale = strike corto") + ex = executability(args.capital) + for a, r in ex.items(): + stato = "ESEGUIBILE" if r["lots"] >= 1 else "FUORI" + print(f" {a}: spot ${r['spot']:,.0f} lotto minimo {r['unit']} " + f"collaterale/lotto ${r['collat']:,.0f} -> {r['lots']} lotti {stato}") + print(f" credito ${r['credit']:.2f}/lotto · larghezza ${r['width']:.2f} · " + f"fee round-trip ${r['fee_rt']:.2f} = {r['fee_pct_credit']:.1f}% del credito") + tot = sum(r["lots"] for r in ex.values()) + print(f"\n Il sleeve e' 50/50 BTC+ETH: a ${args.capital:,.0f} " + f"{'diventa ETH-only' if ex['BTC']['lots'] == 0 and ex['ETH']['lots'] > 0 else 'non e collocabile'}.") + print(f" Al PESO DI BOOK (12%) l'allocazione sarebbe ${args.capital*0.12:,.0f} = " + f"{int(args.capital*0.12 // ex['ETH']['collat'])} lotti ETH.") + if tot == 0: + print(" -> a questo capitale lo sleeve NON e' eseguibile in nessuna forma.") + print() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_vrp_profit_take.py b/tests/test_vrp_profit_take.py new file mode 100644 index 0000000..16e1894 --- /dev/null +++ b/tests/test_vrp_profit_take.py @@ -0,0 +1,178 @@ +"""Test della misura del profit-take su VRP01 (scripts/research/r0730_vrp_profit_take.py). + +Tre cose vanno congelate qui, e una sola e' il risultato: + +* **La replica bit-exact.** Tutto il confronto poggia su una re-implementazione del sleeve: + se qualcuno tocca `_vrp_weekly_asset`, il test deve rompersi invece di far passare un delta + misurato fra due cose diverse. +* **La trappola dell'inner-join.** Le due varianti escono in date DIVERSE, quindi un + `join="inner"` fra le due serie confronta solo le settimane senza uscita anticipata e + risponde "identiche" con potenza zero. E' l'errore che ho commesso in sessione; il test lo + rende irripetibile. +* **Il verdetto** (PT50 non batte il canonico) — con il **controllo positivo** obbligatorio sul + null del de-levering: un test che dice sempre REFUTED e' indistinguibile da uno rotto. +""" +from __future__ import annotations + +import importlib.util +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)) + +_spec = importlib.util.spec_from_file_location( + "r0730_pt", ROOT / "scripts" / "research" / "r0730_vrp_profit_take.py") +PT = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(PT) + +from src.portfolio.sleeves import VRP_CFG, _vrp_weekly_asset # noqa: E402 + + +# --------------------------------------------------------------------------- +# cache: ogni corsa rilegge 7 anni di storia +# --------------------------------------------------------------------------- +@pytest.fixture(scope="module") +def canon(): + return PT.combo(pt=None, f=1.0, real_fee=True)[0] + + +@pytest.fixture(scope="module") +def pt50(): + return PT.combo(pt=0.50, f=1.0, real_fee=True)[0] + + +# =========================================================================== +# la fondazione: senza questa nessun delta significa niente +# =========================================================================== +@pytest.mark.parametrize("asset", ["BTC", "ETH"]) +def test_la_replica_e_bit_exact_col_sleeve(asset): + mine, _ = PT.run_asset(asset, pt=None, f=VRP_CFG["f"], real_fee=False) + ref = _vrp_weekly_asset(asset) + j = pd.concat({"m": mine, "r": ref}, axis=1, join="inner") + assert len(j) == len(ref) == len(mine) # nessuna data persa da nessuna delle due + assert float((j["m"] - j["r"]).abs().max()) == 0.0 + + +# =========================================================================== +# l'errore di metodo commesso in sessione, congelato +# =========================================================================== +def test_confrontare_per_data_di_uscita_da_una_risposta_falsa(canon, pt50): + """Un inner-join fra canonico e PT tiene solo le settimane in cui il PT NON e' scattato, + dove per costruzione i due valori coincidono: la conclusione sarebbe 'nessuna differenza'.""" + a = canon[canon != 0.0] + b = pt50[pt50 != 0.0] + j = pd.concat({"a": a, "b": b}, axis=1, join="inner") + assert len(j) < 0.9 * min(len(a), len(b)) # il join butta via la maggior parte dei dati + # ...e su cio' che resta le due sono IDENTICHE: la risposta sarebbe "nessuna differenza" + assert float((j["a"] - j["b"]).abs().max()) == 0.0 + # mentre la differenza vera, misurata appaiata, e' enorme + assert abs(PT.stats(a)["sh"] - PT.stats(b)["sh"]) > 1.0 + + +def test_il_confronto_appaiato_per_ingresso_vede_la_differenza(): + """La lente giusta: stesso ingresso, due esiti. Qui la differenza c'e' ed e' grande.""" + R = PT.paired_trades("ETH", pt=0.50) + assert len(R) > 50 + assert R["early"].mean() > 0.5 # il trigger e' raggiungibile + assert R["pt"].sum() < R["hold"].sum() # e costa + + +# =========================================================================== +# il meccanismo +# =========================================================================== +@pytest.mark.parametrize("asset", ["BTC", "ETH"]) +def test_il_profit_take_scatta_sui_vincenti_non_sui_perdenti(asset): + """E' il motivo per cui perde: tronca i trade che sarebbero finiti bene e non salva + quelli che finiscono male.""" + R = PT.paired_trades(asset, pt=0.50) + W, L = R[R["hold"] > 0], R[R["hold"] <= 0] + assert W["early"].mean() > 0.75 # >75% dei vincenti escono prima + assert L["early"].mean() < 0.40 # <40% dei perdenti + assert W["early"].mean() > 2.0 * L["early"].mean() + assert W["pt"].mean() < W["hold"].mean() # i vincenti vengono troncati + + +# =========================================================================== +# il trigger non dipende da f — proprieta', non coincidenza +# =========================================================================== +def test_il_trigger_e_invariante_in_f(): + """`f*debit <= (1-pt)*f*credito` non contiene f: cambiare il prezzo del modello sposta il + PnL ma non QUANDO si esce. Se qualcuno applicasse f asimmetricamente, questo si rompe.""" + a = PT.paired_trades("ETH", pt=0.50, f=1.00) + b = PT.paired_trades("ETH", pt=0.50, f=0.73) + assert len(a) == len(b) + assert (a["early"].values == b["early"].values).all() + + +# =========================================================================== +# fee: la forma del listino, non solo il livello +# =========================================================================== +def test_la_fee_di_gamba_usa_il_sottostante_quando_il_premio_e_alto(): + # opzione cara: 12.5% del premio (12.5) e' piu' del 3bps del sottostante (0.57) -> vince il 3bps + assert PT.leg_fee(1900.0, 100.0) == pytest.approx(0.0003 * 1900.0) + + +def test_la_fee_di_gamba_e_tappata_al_premio_quando_l_opzione_e_quasi_senza_valore(): + # opzione da 1 dollaro: il cap al 12.5% (0.125) morde, altrimenti si pagherebbe piu' del premio + assert PT.leg_fee(1900.0, 1.0) == pytest.approx(0.125) + + +def test_la_fee_non_e_mai_negativa_su_premio_negativo(): + assert PT.leg_fee(1900.0, -5.0) == 0.0 + + +# =========================================================================== +# il verdetto, e il controllo positivo che lo rende credibile +# =========================================================================== +def test_pt50_non_batte_il_canonico(canon, pt50): + for match in ("vol", "dd"): + r = PT.delever_null(canon, pt50, match) + assert not r["batte"], f"match={match}: il verdetto e' cambiato, va rivisto il diario" + assert r["d_sh"] < -0.5 + + +def test_controllo_positivo_il_null_sa_dire_di_si(canon): + """Obbligatorio: un null che risponde sempre REFUTED e' indistinguibile da uno rotto. + Una variante COSTRUITA migliore (stesso rendimento, meta' della volatilita' di rumore) + deve passare.""" + better = canon.copy() + better[better < 0] *= 0.5 # perdite dimezzate = davvero meglio + r = PT.delever_null(canon, better, "vol") + assert r["batte"] and r["d_sh"] > 0 + + +# =========================================================================== +# eseguibilita': a $3.000 la domanda viene prima del rendimento +# =========================================================================== +@pytest.fixture(scope="module") +def ex3k(): + return PT.executability(3000.0) + + +def test_a_3000_le_opzioni_btc_sono_fuori(ex3k): + """min_trade_amount 0.1 BTC = ~$6.000 di collaterale cash-secured per lotto.""" + assert ex3k["BTC"]["lots"] == 0 + assert ex3k["BTC"]["collat"] > 3000.0 + + +def test_a_3000_eth_entra_per_un_solo_lotto(ex3k): + assert ex3k["ETH"]["lots"] == 1 + assert ex3k["ETH"]["collat"] < 3000.0 < 2 * ex3k["ETH"]["collat"] + + +def test_al_peso_di_book_lo_sleeve_non_e_collocabile(ex3k): + """VRP01 pesa il 12%: $360 su $3.000, sotto il collaterale di un solo lotto ETH. + E' il vincolo che decide, non lo Sharpe.""" + assert int(3000.0 * 0.12 // ex3k["ETH"]["collat"]) == 0 + + +def test_il_lotto_minimo_e_quello_letto_dal_venue(): + """Congelato dal listino Deribit del 2026-07-30: se cambia, i numeri di questo studio + cambiano e il test deve dirlo.""" + assert PT.MIN_TRADE == {"BTC": 0.1, "ETH": 1.0} + assert PT.FEE_UNDERLYING == 0.0003 and PT.FEE_PREMIUM_CAP == 0.125