diff --git a/CLAUDE.md b/CLAUDE.md index 9874695..d78d7ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -487,12 +487,11 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis Logica: ogni conto serve Sharpe per sopravvivere alla PROPRIA barriera (uccide SPARSO), ma i conti servono decorrelati fra loro (penalizza CONC). **Con lente INTRADAY (wick)** tutto crolla: P(≥50/g) 1.6-2.5%, P(zero) 78-90%. ⚠ Il mio wick e' un **PAVIMENTO**: gap estratto INDIPENDENTE - dal rendimento del giorno (i wick veri sono accoppiati alle giornate brutte) → breach spuri; il - modo giusto e' il bootstrap delle TUPLE (ritorno,gap) di `r0724_goal50_intraday_mc.py`, che pero' - ha il recon solo per TP01+SKH01 → **follow-up dichiarato**. **VERDETTO IN BANDA (risposta al goal): - una via da €600 a €50/g ESISTE (scala di conti funded) con P ~2-21% in 3 anni e P(bruciare i €600) - ~33-78% → scommessa a coda destra, NON una rendita.** Il confronto fra POLITICHE e' robusto (stessa - lente per tutte): **se si apre il fronte prop, NON mandarci il book live attuale**. + dal rendimento del giorno → breach spuri → **follow-up dichiarato, poi CHIUSO lo stesso giorno + (bullet successivo): la stima onesta e' P(≥50/g) ~6%, P(zero) 52-65%.** Il confronto fra POLITICHE + e' robusto (stessa lente per tutte) — **verificato a posteriori sulla lente accoppiata: l'ordine + MISTO ≥ CONC-DIV > CONC-2SL ≈ SPARSO regge a tutte e 3 le lenti** → **se si apre il fronte prop, + NON mandarci il book live attuale**. (6) **ADDENDUM "e se metto 10K su IB?"** (`r0725_ib10k.py`): allocazione fra venue, non domanda su GTAA01. Deribit = **motore** (CAGR ~11% de-luck), IB/GTAA01 = **diversificatore** (CAGR ~3.9%). A $11.5k totali: 0% su IB → **€2.16/g**; 50% → €1.54/g (Sharpe migliore 1.13, DD 15.3→11.2%); @@ -515,16 +514,49 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis "costo 0" puo' essere un bug): su un book con profitto concentrato in 1-2 giorni la regola porta P(pass) da >90% a <5% (test cablato). **Il vincolo vero e' il maxDD 6% STATICO**: config A (book live) ha maxDD 9.5% > 6% = strutturalmente incompatibile a leva piena — intraday a 1.0x - sopravvive l'**1.4%**; config B (+XS01) ha maxDD 6.2%, Sharpe 1.13. **Funded consigliato: config B - a 0.50x** (intraday P(vivo) 71% vs 32% a 0.75x, E[payout] €7.69 vs €8.66/g — la sopravvivenza - COMPONE su piu' anni). EV del biglietto $100k **positivo in entrambe le lenti** (+$6.789 + sopravvive l'**1.4%**; config B (+XS01) ha maxDD 6.2%, Sharpe 1.13. ~~**Funded consigliato: config B + a 0.50x**~~ → **CORRETTO a 0.75x dalla lente accoppiata (bullet successivo)**: il 0.50x era scelto + perche' il wick indipendente dava a 0.75x P(vivo) 55%; la lente onesta da' **76%**, e 0.75x + **massimizza il payout atteso su 3 anni** ($2.674 vs $1.230 a 0.50x e $1.992 a 1.00x). L'argomento + "la sopravvivenza COMPONE su piu' anni" resta valido: si sposta il punto in cui morde. + EV del biglietto $100k **positivo in entrambe le lenti** (+$6.789 close-only, +$1.166 intraday), ma ~69% di perdere la fee nella lente pessimista. Cap $200k/trader - ⇒ ~€15-30/g max → per €50/g servono piu' firm (punto 5). ⚠ Discrepanza col 24/07 (P(vivo) A@0.75x - 58% vs 10% mio): mio wick indipendente = piu' severo + finestra 2024+ dove A e' debole; **la - direzione concorda, sul LIVELLO il numero del 24/07 (recon MTM vero) e' piu' affidabile**. + ⇒ ~€15-30/g max → per €50/g servono piu' firm (punto 5). ✅ Discrepanza col 24/07 (P(vivo) A@0.75x + 58% vs 10% mio) **RISOLTA: non era la finestra, era la lente** — sulla stessa finestra piena la + lente accoppiata da' **63.2%** vs il 58% del 24/07 (accordo entro 5pp, implementazioni separate). ⚠ Bug catturato in sessione: base di prelievo funded aggiornata giornalmente come HWM mobile → guadagno al checkpoint ≈ 0 → **payout $230/anno invece di ~$7.000**; regole vere = max-loss STATICO dal saldo iniziale + base ripristinata dopo il prelievo. Test di regressione cablato. +- ✅ **LENTE WICK ACCOPPIATA (2026-07-25, follow-up del bullet precedente — CHIUSO).** Script + `r0725_prop_coupled.py`, test `tests/test_prop_coupled.py` (13 casi), diario + `2026-07-25-prop-wick-accoppiato.md`. Book/pesi/cron **INVARIATI**. Generalizza il recon MTM di + `r0724_goal50_intraday_mc.py`: (a) sleeve TP01/SKH01 **separati** a risoluzione oraria → il minimo + intraday si compone ESATTO per QUALSIASI vettore di pesi (prima erano cablati 75/25); (b) **XS01 + accoppiato** dagli OHLC giornalieri HL (4 checkpoint, ordine condiviso avverso; recon = sleeve + ufficiale a `max|Δ|=0.0`). + ⚠️ **IL FINDING: la CALIBRAZIONE del wick era giusta, l'errore era l'INDIPENDENZA.** Le marginali + coincidono quasi (p50 −0.17pp **identico**, p90 −1.03 vs −0.90, p99 −2.70 vs −3.50) — sbagliava + **su quali giorni** cadono i tuffi. E la dipendenza va nel **verso inatteso**: il gap e' ~**3× piu' + profondo nei giorni che finiscono BENE** (−1.58pp nel decile migliore vs −0.48pp nel peggiore), + perche' un giorno brutto scende tutto il giorno e **chiude sul minimo** (`m==R` nel **26%** dei + giorni). Il breach si valuta sul minimo → l'estrazione indipendente carica i giorni brutti con la + coda dei giorni buoni e **raddoppia i breach da daily-loss (2.0-2.9×, misurato sui giorni storici)**. + **`close-only` non e' conservativa, e' CIECA**: 0.00% di breach da daily-loss su OGNI configurazione + (la regola non scatta mai sulle chiusure) → usarla come controllo, mai come stima. + **Verifiche (un "nessuna differenza" va provato):** (1) **1h vs 5m** sulla gamba TP01 = identici + (p99 −3.06 vs −3.14pp) → **il caveat di risoluzione portato avanti dal 24/07 e' quantificato e + trascurabile**, l'ora cattura gia' il minimo del giorno; (2) riconciliazione col 24/07 (sopra); + (3) **bound severo su XS01** (ogni gamba al proprio peggio insieme): config B resta sopra config A + (P(vivo) 57.6% vs 39.4%) → la conclusione non dipende dalla convenzione. + **Decisioni:** funded **0.75x** (non 0.50x, vedi sopra); scala di conti **P(≥50/g) ~5.6-6.5%** in + 3 anni (non 1.6-2.5% ne' 20.7%) con **P(zero) 52% a 0.75x / 65% a 1.00x**, e **P(≥10/g) massima a + 0.75x (29.7%)** = stesso ottimo di leva del conto singolo. **Ordine fra politiche INVARIATO a + tutte e 3 le lenti.** Verdetto ristretto: **€600→€50/g ≈ 6% in 3 anni, P(perdere i €600) ≈ 52-65%** + — resta coda destra, ma con probabilita' conoscibile invece di una banda di un ordine di grandezza. + **REGOLA NUOVA: un modello di rischio intraday NON si valida sulla marginale del wick ma sul suo + ACCOPPIAMENTO al rendimento del giorno** — qui un test "i percentili coincidono" sarebbe PASSATO + con la stima sbagliata di 2-3×. Ogni regola valutata sul minimo (daily-loss, trailing DD, stop di + conto) va misurata su **tuple accoppiate**, mai su un wick estratto a parte. - **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso: cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55. - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.** diff --git a/docs/diary/2026-07-25-prop-wick-accoppiato.md b/docs/diary/2026-07-25-prop-wick-accoppiato.md new file mode 100644 index 0000000..ae79783 --- /dev/null +++ b/docs/diary/2026-07-25-prop-wick-accoppiato.md @@ -0,0 +1,234 @@ +# 2026-07-25 — La lente wick ACCOPPIATA: chiusura del follow-up dichiarato + +**Script:** `scripts/research/r0725_prop_coupled.py` · **Test:** `tests/test_prop_coupled.py` (13 casi) +**Segue:** `2026-07-25-rendita-capcurve-prop.md` §5 (scala prop) e §7 (HyroTrader) +**Book / pesi / cron: INVARIATI.** Nessuna decisione di portafoglio dipende da questo lavoro. + +--- + +## 1. Cosa era rimasto aperto, e perché contava + +La scala di conti funded (§5 del diario di stamattina) era stata giudicata con **due lenti** che +differiscono di un **ordine di grandezza** su P(≥50 €/g in 3 anni): + +| lente | P(≥50/g) | com'era descritta | +|---|---|---| +| close-only | **20.7%** | tetto ottimista — le regole prop scattano sull'equity intraday, non sulla chiusura | +| wick indipendente | **1.6-2.5%** | pavimento pessimista — gap lognormale estratto **indipendente** dal rendimento del giorno | + +Una banda 1.6%-20.7% non decide niente: è la differenza fra "scommessa a coda destra che vale i +600 euro" e "non vale la pena". E il difetto del pavimento era **già dichiarato** nel docstring: + +> *«nella realtà i wick profondi stanno sulle giornate brutte (sono ACCOPPIATI): estrarli +> indipendenti aggiunge finti tuffi anche nelle giornate buone → breach spuri → stima troppo +> severa. Il modo giusto è il bootstrap delle TUPLE (ritorno, gap) come in +> r0724_goal50_intraday_mc.py, che però ha il recon solo per TP01+SKH01 → FOLLOW-UP dichiarato.»* + +Questo lavoro chiude quel follow-up. + +## 2. Metodo + +Tre pezzi, tutti verificati contro qualcosa di indipendente: + +1. **Recon per-sleeve a risoluzione oraria.** `r0724` calcola il minimo intraday del book Deribit + con i pesi 75/25 **cablati dentro**. Qui i pesi si dividono via per ottenere gli sleeve nudi + (ret, wick) di TP01 e SKH01 — così il minimo si compone **esattamente** sul path orario per + **qualsiasi** vettore di pesi. Logica invariata: exit-al-livello, SL prioritario, fee. +2. **Lente accoppiata per XS01**, che su Hyperliquid è 1d nativo. L'escursione intraday si + ricostruisce dagli **OHLC giornalieri** dei 19 alt: il book si valuta ai 4 checkpoint + O / primo estremo / secondo estremo / C, con **ordinamento condiviso** fra le gambe (le alt + co-muovono) preso nell'ordine **avverso**. Non è path-exact, ma è **accoppiato al giorno vero**, + che è tutto il punto. + *Sanity:* il recon riproduce lo sleeve ufficiale con `max|Δ| = 0.0` esatto. +3. **Le tre lenti sulla stessa macchina di simulazione** (close / indipendente / accoppiata), così + il confronto non mescola differenze di modello con differenze di lente. + +--- + +## 3. Il risultato principale: la calibrazione era giusta, l'**indipendenza** era l'errore + +Confronto delle distribuzioni **marginali** del gap (book live TP01/SKH01, 2691 giorni): + +| | p50 | p90 | p99 | peggiore | +|---|---|---|---|---| +| **accoppiato (recon)** | −0.17pp | −1.03pp | −2.70pp | −5.96pp | +| indipendente (calibrazione 25/07) | −0.17pp | −0.90pp | −3.50pp | — | + +**Sono quasi identiche.** La calibrazione lognormale di stamattina era buona. Quello che era +sbagliato non è *quanto* sono profondi i tuffi, ma **su quali giorni cadono**. + +### 3.1 E la dipendenza va nel verso inatteso + +Mi aspettavo — e lo avevo scritto — che i gap profondi stessero sui giorni brutti. **È il +contrario:** + +| book | giorni | m==R | gap medio | gap \| decile PEGGIORE | gap \| decile MIGLIORE | +|---|---|---|---|---|---| +| A TP01/SKH01 (live) | 2691 | 26% | −0.38pp | **−0.48pp** | **−1.58pp** | +| B +XS01 (diversif.) | 937 | 20% | −0.36pp | −0.39pp | −1.30pp | +| TP01 solo | 2691 | 33% | −0.39pp | −0.59pp | −1.57pp | +| SKH01 solo | 2691 | 76% | −0.39pp | −0.40pp | −2.97pp | +| XS01 solo | 937 | 53% | −0.44pp | −0.22pp | −2.32pp | + +L'escursione intraday è **~3× più profonda nei giorni che finiscono BENE** che in quelli che +finiscono male. Il meccanismo, una volta visto, è ovvio: un giorno brutto **scende tutto il +giorno e chiude sul minimo** (`m == R` nel 26% dei giorni: la chiusura *è* il peggio), mentre +un recupero a V ha un minimo profondo e una chiusura alta. + +Il breach però si valuta **sul minimo**. Quindi ciò che conta è quanto gap si aggiunge **nei +giorni brutti** — e lì il gap vero è ~1/3 di quello dei giorni buoni. Un'estrazione indipendente +usa la stessa distribuzione ovunque, e così **carica i giorni brutti con la coda che nella realtà +appartiene ai giorni buoni**. + +### 3.2 Quanto costa, misurato sui giorni storici (nessun bootstrap) + +Frazione di giorni che sfondano la regola di daily-loss: + +| book | leva | regola | close-only | **ACCOPPIATO** | indipendente | gonfiaggio | +|---|---|---|---|---|---|---| +| A (live) | 0.75 | daily 4% (HYRO) | 0.00% | 0.21% | 0.40% | **1.9×** | +| A (live) | 1.00 | daily 4% (HYRO) | 0.00% | 0.32% | 0.78% | **2.4×** | +| B (+XS01) | 0.75 | daily 4% (HYRO) | 0.00% | 0.11% | 0.25% | **2.4×** | +| B (+XS01) | 1.00 | daily 5% (FTMO) | 0.00% | 0.11% | 0.31% | **2.9×** | + +Due letture: +- la lente indipendente **raddoppia** i breach da daily-loss (2.0-2.9×); +- **close-only non è "leggermente ottimista": è strutturalmente cieca.** Fa `0.00%` su ogni riga — + la regola di daily-loss **non scatta mai** sulle chiusure. Non è una lente approssimata, è una + lente che non vede affatto quel vincolo. + +--- + +## 4. Tre verifiche — perché un "nessuna differenza" va provato, non assunto + +### (1) Risoluzione: l'1h basta? — **sì, misurato** + +TP01 tiene peso costante nel giorno, quindi il suo minimo intraday si può calcolare **anche a 5m** +ed è un confronto pulito: + +| | p50 | p90 | p99 | peggiore | +|---|---|---|---|---| +| gamba TP01 @1h | −0.337pp | −1.358pp | −3.056pp | −5.34pp | +| gamba TP01 @5m | −0.336pp | −1.366pp | −3.136pp | −5.33pp | + +Identici. **Il caveat "i wick a 1h sono un tetto, i 5m sarebbero più profondi" — portato avanti dal +24/07 — è quantificato e trascurabile.** L'ora cattura già il minimo del giorno: i 5m aggiungono +solo rumore dentro un'ora di minimo già individuata. + +*Nota di metodo:* entrambe le righe riportavano `n=1865` e la cosa sembrava un errore (stessa +serie confrontata due volte). Non lo era: il filtro scarta i giorni in cui TP01 è **flat** (~30%), +che sono gli stessi a qualsiasi risoluzione. Verificato prima di accettare il risultato. + +### (2) Riconciliazione col 24/07 — **la discrepanza dichiarata è risolta** + +CLAUDE.md portava un ⚠ aperto: *«Discrepanza col 24/07 (P(vivo) A@0.75x 58% vs 10% mio)»*, con +l'ipotesi che fosse un effetto-finestra. **Non era la finestra, era la lente:** + +| finestra | lente | P(pass) | P(vivo 1a) | E[payout/a] | +|---|---|---|---|---| +| 2019-03+ FULL | close | 53.8% | 79.7% | $2.085 | +| **2019-03+ FULL** | **accoppiato** | 48.1% | **63.2%** | $1.618 | +| 2024+ comune | close | 47.0% | 74.8% | $1.632 | +| 2024+ comune | accoppiato | 35.6% | 39.4% | $770 | + +Sulla **stessa finestra piena** del 24/07 (recon MTM indipendente, che dava **58%**) la mia lente +accoppiata dà **63.2%** — accordo entro 5pp, con due implementazioni scritte separatamente. Il +**10%** del 25/07 era l'artefatto della lente indipendente. La finestra spiega il resto +(63.2% full → 39.4% sul 2024+, dove config A è debole). + +### (3) Bound severo su XS01 — **non ribalta niente** + +È XS01 a reggere il vantaggio della config diversificata, e il suo gap dipende da una convenzione +(ordinamento condiviso). Girato anche il bound opposto — ogni gamba al **proprio** peggio nello +stesso istante, fisicamente impossibile su 10 gambe co-moventi ma utile a delimitare l'errore: + +| convenzione | gap p90 | peggiore | P(pass) | P(vivo 1a) | E[payout/a] | +|---|---|---|---|---|---| +| ordine condiviso (base) | −0.86pp | −4.81pp | 42.9% | **76.0%** | $1.144 | +| per-gamba (bound severo) | −1.83pp | −15.56pp | 32.3% | **57.6%** | $814 | +| *config A, per confronto* | — | — | 35.6% | **39.4%** | $770 | + +Anche al bound severo **config B resta sopra config A** (57.6% vs 39.4%, $814 vs $770). La +conclusione non dipende dalla convenzione. + +--- + +## 5. Cosa cambia nelle decisioni + +### 5.1 La raccomandazione HyroTrader si sposta da 0.50x a **0.75x** + +Config B (book diversificato, +XS01), lente accoppiata, finestra 2024+: + +| leva | P(pass eval) | P(vivo 1a) | E[payout/a] | **E[payout] su 3 anni** | +|---|---|---|---|---| +| 0.50x | 28.6% | 99.5% | $412 | ~$1.230 | +| **0.75x** | 42.9% | 76.0% | $1.144 | **~$2.674** | +| 1.00x | 46.6% | 42.1% | $1.246 | ~$1.992 | + +Stamattina la scelta era **0.50x**, perché la lente indipendente dava a 0.75x una sopravvivenza +del 55%. Con la lente onesta la sopravvivenza a 0.75x è **76%**, e 0.75x **massimizza il payout +atteso su 3 anni** — batte 0.50x di 2,2× e 1.00x di 1,3× (a 1.00x la sopravvivenza crolla e +l'effetto composto la punisce). L'argomento del 25/07 — *"la sopravvivenza compone su più anni"* — +resta valido; è il punto in cui morde che si sposta. + +### 5.2 La scala di conti: il ranking regge, i livelli no + +| lente | leva | MISTO EUR/g med | P(≥10/g) | P(≥50/g) | P(zero) | +|---|---|---|---|---|---| +| close-only | 1.00x | 11.11 | 50.8% | **22.0%** | 33.9% | +| **accoppiata** | **0.75x** | 0.00 | **29.7%** | 5.6% | **52.2%** | +| **accoppiata** | **1.00x** | 0.00 | 20.8% | **6.5%** | 65.5% | +| indipendente | 1.00x | 0.00 | 5.2% | 1.0% | 81.5% | + +- **L'ordinamento fra politiche SOPRAVVIVE a tutte e tre le lenti**: MISTO ≥ CONC-DIV > + CONC-2SL ≈ SPARSO. L'affermazione di stamattina — *«il confronto fra POLITICHE è robusto: + è la stessa lente applicata a tutte»* — è confermata su una lente che allora non esisteva. + **CONC-2SL (= il book live attuale su ogni conto) resta la peggiore o penultima ovunque.** +- **I livelli si spostano parecchio.** La stima onesta di P(≥50/g) in 3 anni è **~5,6-6,5%**, non + 1,6-2,5% e non 20,7%. La scala è **~3-4× più probabile** di quanto diceva il pavimento e + **~3,4× meno** di quanto diceva il tetto. +- **P(≥10/g) è massima a 0.75x (29,7%)**, non alla leva piena: l'ottimo di leva della scala + coincide con quello del conto singolo. +- Il prezzo resta alto: **P(bruciare i €600) = 52% a 0.75x, 65% a 1.00x.** + +### 5.3 Il verdetto sul goal, ristretto + +Il 25/07 concludeva: *«una via da €600 a €50/g ESISTE con P ~2-21% in 3 anni e P(bruciare i €600) +~33-78% → scommessa a coda destra, NON una rendita»*. Con la lente onesta la banda si stringe: + +> **P(≥50 €/g entro 3 anni) ≈ 6%, con P(perdere i €600) ≈ 52-65%.** +> Resta una scommessa a coda destra. Ma è una scommessa con una probabilità **conoscibile**, +> non un intervallo di un ordine di grandezza. + +--- + +## 6. Cosa NON cambia + +- **Book, pesi, cron: invariati.** Niente qui tocca il portafoglio. +- Il fronte prop **non è aperto**: questo lavoro dice *cosa costerebbe e con quale probabilità*, + non che vada fatto. +- Resta il caveat di finestra del 25/07: il **2024+** è la finestra in cui XS01 è stato scoperto e + affinato → la **taglia** del vantaggio di config B è ottimista; il **meccanismo** (correlazione + bassa → meno DD → più sopravvivenza sotto vincolo di DD) è robusto. La verifica (2) qui sopra + quantifica per la prima volta quanto pesa la finestra da sola (63.2% → 39.4% su config A). +- XS01 resta **STAT-MODE sul capitale proprio** (serve ~$20k). Su un conto funded da $100k è + eseguibile: è esattamente l'asimmetria capitale-proprio vs funded del §4 di stamattina. + +--- + +## 7. Lezione da portare avanti + +> **Un modello di rischio intraday non si valida sulla distribuzione marginale del wick, ma sul suo +> ACCOPPIAMENTO al rendimento del giorno.** Qui la marginale era calibrata quasi perfettamente +> (p50 −0.17pp identico) e ciò nonostante il modello sbagliava di 2-3× sui breach, perché metteva +> i tuffi sui giorni sbagliati. Un test "i percentili del mio wick sintetico coincidono con quelli +> del recon" sarebbe **passato**, e la stima sarebbe rimasta sbagliata. + +Corollario controintuitivo, da ricordare perché è il contrario dell'intuizione: **l'escursione +intraday è più profonda nei giorni che chiudono bene**. I giorni che chiudono male chiudono *sul* +minimo. Ogni regola valutata sul minimo (daily-loss, trailing DD, stop di conto) va misurata su +tuple accoppiate, mai su un wick estratto a parte. + +Terzo: **close-only non è una lente conservativa, è una lente cieca** per le regole intraday — +0.00% di breach da daily-loss su ogni configurazione testata. Va usata come controllo, mai come +stima. diff --git a/scripts/research/r0725_prop_coupled.py b/scripts/research/r0725_prop_coupled.py new file mode 100644 index 0000000..746e847 --- /dev/null +++ b/scripts/research/r0725_prop_coupled.py @@ -0,0 +1,693 @@ +"""r0725_prop_coupled — CHIUDE il follow-up dichiarato: la lente WICK ACCOPPIATA (2026-07-25). + +`r0725_prop_ladder.py` ha giudicato la scala di conti funded con DUE lenti che differiscono di +un ordine di grandezza su P(>=50 EUR/g): **20.7% close-only** (tetto) vs **1.6-2.5% wick +indipendente** (pavimento). La banda e' cosi' larga da non decidere nulla: e' la differenza fra +"scommessa a coda destra che vale 600 euro" e "non vale la pena". + +La ragione della larghezza e' un DIFETTO DICHIARATO del pavimento: il gap intraday era estratto +**lognormale INDIPENDENTE dal rendimento del giorno**. Ma i wick profondi NON sono indipendenti: +stanno sulle giornate brutte. Estrarli indipendenti aggiunge finti tuffi anche nelle giornate +buone -> breach spuri -> P(pass) e P(vivo) troppo basse. Il modo giusto e' il bootstrap delle +**TUPLE (ritorno, gap)** accoppiate, che r0724_goal50_intraday_mc.py fa — ma solo per il book +Deribit 75/25 (TP01+SKH01), non per XS01 ne' a pesi arbitrari. Qui si generalizza. + +COSA FA QUESTO SCRIPT + 1) **Recon per-sleeve** a risoluzione oraria: (ret, wick) unitari di TP01 e SKH01 separati, + cosi' il minimo intraday si puo' calcolare esattamente per QUALSIASI vettore di pesi + (non solo 75/25). Riusa la macchina di r0724 (exit-al-livello, SL prioritario, fee). + 2) **Lente accoppiata per XS01**, che su Hyperliquid e' 1d nativo: l'escursione intraday si + ricostruisce dagli OHLC GIORNALIERI dei 19 alt con ordinamento condiviso (tutte le gambe + toccano prima il minimo, o prima il massimo — le alt co-muovono), prendendo l'ordine + AVVERSO. Non e' path-exact, ma e' **accoppiato al giorno vero**, che e' il punto. + 3) **Prova empirica del difetto**: gap per decile di rendimento + conteggio dei breach sui + giorni STORICI (nessun bootstrap) sotto le tre lenti. + 4) Le 4 politiche di scala e la decisione HyroTrader config A vs B, girate a **TRE lenti** + (close-only / indipendente / accoppiata) sulla stessa macchina di simulazione. + 5) Tre VERIFICHE, perche' un "nessuna differenza" va provato: risoluzione 1h vs 5m, + riconciliazione col numero indipendente del 24/07, bound severo su XS01. + +RISULTATO IN UNA RIGA: la CALIBRAZIONE del wick del 25/07 era giusta (p50 -0.17pp identico), +l'errore era l'INDIPENDENZA. L'escursione intraday e' ~3x piu' profonda nei giorni che finiscono +BENE (recuperi a V) che in quelli che finiscono male (che chiudono sul minimo: m==R nel 26% dei +giorni). Il breach si valuta sul minimo -> estrarre il gap indipendente carica i giorni brutti +con la coda che appartiene ai giorni buoni, e RADDOPPIA i breach da daily-loss (2.0-2.9x). + +CONVENZIONI DICHIARATE (le stesse del 24/07, piu' una nuova) + * il minimo di un book multi-sleeve e' preso come **somma dei minimi** (worst simultaneo) fra + la parte crypto e XS01: severo, ma coerente con la convenzione gia' in uso fra TP01 e SKH01 + dentro l'ora. Fra TP01 e SKH01 il minimo e' invece ESATTO (path orario condiviso). + * wick a risoluzione 1h sul crypto. ⚠️ NON e' un tetto di risoluzione: MISURATO contro i 5m + sulla gamba TP01, p99 -3.06pp (1h) vs -3.14pp (5m) -> l'ora cattura gia' il minimo del + giorno. (Il caveat "i 5m sarebbero piu' profondi" del 24/07 e' quantificato e trascurabile.) + * XS01 a risoluzione di barra giornaliera (4 checkpoint O/H/L/C, ordine condiviso avverso); + il bound opposto (ogni gamba al proprio peggio insieme) e' girato come sensitivita' e NON + ribalta nessuna conclusione. + +Uso: `uv run python scripts/research/r0725_prop_coupled.py` (~2-3 min: recon SKH su 5m full) +""" +from __future__ import annotations + +import sys +from functools import lru_cache +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +from src.data.downloader import load_data # noqa: E402 +from src.portfolio.portfolio import metrics # noqa: E402 +from src.portfolio.sleeves import XS_CFG, XS_UNIVERSE, _HL_DIR, _xsec_returns # noqa: E402 + +import r0724_goal50_intraday_mc as mc # noqa: E402 +import r0725_prop_ladder as pl # noqa: E402 +from r0725_capcurve import EURUSD, TAX_RATE # noqa: E402 + +ASSETS = ("BTC", "ETH") +DELUCK = pl.DELUCK +START_EUR = pl.START_EUR +HORIZON_MONTHS = pl.HORIZON_MONTHS +MAX_CONCURRENT = pl.MAX_CONCURRENT +TARGET_EUR_DAY = pl.TARGET_EUR_DAY +SPLIT = pl.SPLIT +FIRM_DEATH_PER_YEAR = pl.FIRM_DEATH_PER_YEAR +FIRMS = pl.FIRMS + + +# ================================================================= 1. RECON PER-SLEEVE (1h) + +@lru_cache(maxsize=1) +def _hourly_legs() -> tuple[pd.DataFrame, ...]: + """(ret, wick) ORARI UNITARI di TP01 e SKH01 — cioe' a peso di sleeve 1.0, gia' sommati + sui due asset (ogni asset pesa 0.5 dentro lo sleeve). + + r0724 cabla i pesi di book (W_TP=0.75, W_SKH=0.25) dentro il recon; qui si dividono via + per ottenere gli sleeve nudi, che si possono poi ricombinare a qualsiasi peso. + """ + tp_r = tp_w = skh_r = skh_w = None + for a in ASSETS: + df = load_data(a, "1h") + idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) + close = df["close"].values.astype(float) + low = df["low"].values.astype(float) + high = df["high"].values.astype(float) + prev = np.concatenate(([np.nan], close[:-1])) + r_close = close / prev - 1.0 + + # --- TP01: peso costante nel giorno (deciso a close[d-1]), fee alla prima ora + tpf = mc.tp01_daily_frame(a) # gia' scalato *0.5 (leg), senza W_TP + day_of = idx.floor("D") + w_tp = np.nan_to_num(tpf["w"].reindex(day_of).values) + fee_tp = np.nan_to_num(tpf["fee"].reindex(day_of).values) + first = np.concatenate(([True], day_of[1:] != day_of[:-1])) + r_wick = np.where(w_tp >= 0, low / prev - 1.0, high / prev - 1.0) + t_r = pd.Series(w_tp * r_close - np.where(first, fee_tp, 0.0), index=idx) + t_w = pd.Series(w_tp * r_wick - np.where(first, fee_tp, 0.0), index=idx) + + # --- SKH01: MTM per-trade con exit AL LIVELLO (r0724 cabla w = W_SKH*0.5) + s_r_arr, s_w_arr = mc.skh_hourly_contrib(a, idx, close, low, high) + s_r = pd.Series(s_r_arr / mc.W_SKH, index=idx) + s_w = pd.Series(s_w_arr / mc.W_SKH, index=idx) + + tp_r = t_r if tp_r is None else tp_r.add(t_r, fill_value=np.nan) + tp_w = t_w if tp_w is None else tp_w.add(t_w, fill_value=np.nan) + skh_r = s_r if skh_r is None else skh_r.add(s_r, fill_value=np.nan) + skh_w = s_w if skh_w is None else skh_w.add(s_w, fill_value=np.nan) + + J = pd.concat({"tp_r": tp_r, "tp_w": tp_w, "skh_r": skh_r, "skh_w": skh_w}, + axis=1).dropna() + return (J,) + + +def crypto_daily_tuples(w_tp: float, w_skh: float) -> pd.DataFrame: + """Per giorno UTC: R (chiusura) e m (minimo intraday) del sub-book crypto a pesi arbitrari. + + Il minimo fra TP01 e SKH01 e' ESATTO: si compone il path ORARIO del book e si guarda il + minimo, includendo il wick dentro ogni ora. (Il wick intra-ora dei due sleeve e' sommato: + li' resta la convenzione worst-simultaneo di r0724.) + """ + (J,) = _hourly_legs() + r_h = (w_tp * J["tp_r"] + w_skh * J["skh_r"]).values + w_h = (w_tp * J["tp_w"] + w_skh * J["skh_w"]).values + idx = J.index + days = idx.floor("D") + rows = [] + for day, pos in pd.Series(np.arange(len(idx)), index=days).groupby(level=0): + sl = pos.values + r = r_h[sl] + wk = w_h[sl] + cum = np.cumprod(1.0 + r) + cum_prev = np.concatenate(([1.0], cum[:-1])) + m = min(cum.min(), (cum_prev * (1.0 + wk)).min()) - 1.0 + rows.append((day, cum[-1] - 1.0, min(m, cum[-1] - 1.0))) + return pd.DataFrame(rows, columns=["day", "R", "m"]).set_index("day") + + +# ================================================================= 2. XS01 ACCOPPIATO (1d OHLC) + +@lru_cache(maxsize=2) +def xsec_daily_tuples(wick_mode: str = "shared") -> pd.DataFrame: + """XS01 con escursione intraday ricostruita dagli OHLC GIORNALIERI dei 19 alt. + + Replica la costruzione delle posizioni di `sleeves._xsec_returns` (verificata a valle contro + lo sleeve ufficiale: `assert` sulla chiusura) e in piu', per ogni giorno, valuta il book ai + 4 checkpoint O / primo estremo / secondo estremo / C, con **ordinamento CONDIVISO** fra le + gambe (le alt co-muovono) e prendendo l'**ordine avverso** fra "prima i minimi" e "prima i + massimi". Su un book market-neutral l'ordinamento condiviso si auto-compensa in larga parte + (i long perdono, gli short guadagnano): il rischio intraday vero e' la DISPERSIONE, che + questa lente cattura solo in parte -> il gap XS01 esce PICCOLO, e va letto come tale. + + wick_mode='perleg' e' il BOUND SEVERO opposto: ogni gamba tocca il PROPRIO peggio nello stesso + istante (long al minimo E short al massimo). E' impossibile in pratica su 10 gambe co-moventi, + ma delimita l'errore: se la conclusione regge a entrambi gli estremi, non dipende dalla + convenzione. Serve perche' e' XS01 a reggere il vantaggio della config diversificata. + """ + o = {}; h = {}; lo = {}; c = {} + for sym in XS_UNIVERSE: + p = _HL_DIR / f"hl_{sym.lower()}_1d.parquet" + if not p.exists(): + continue + d = pd.read_parquet(p) + i = pd.to_datetime(d["timestamp"], unit="ms", utc=True) + o[sym] = pd.Series(d["open"].astype(float).values, index=i) + h[sym] = pd.Series(d["high"].astype(float).values, index=i) + lo[sym] = pd.Series(d["low"].astype(float).values, index=i) + c[sym] = pd.Series(d["close"].astype(float).values, index=i) + C = pd.concat(c, axis=1, join="inner").sort_index().dropna() + O = pd.concat(o, axis=1).reindex(C.index)[C.columns] + H = pd.concat(h, axis=1).reindex(C.index)[C.columns] + L = pd.concat(lo, axis=1).reindex(C.index)[C.columns] + + px = C.values + n, A = px.shape + lookbacks, Hh, k, mode, tv = (XS_CFG["lookbacks"], XS_CFG["H"], XS_CFG["k"], + XS_CFG["mode"], XS_CFG["target_vol"]) + disp_pct, minhist = XS_CFG.get("disp_pct", 0), XS_CFG.get("disp_minhist", 20) + mlb = max(lookbacks) + dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]) + W = np.zeros((n, A)); w = np.zeros(A); disp_hist = [] + for i in range(n): + if i >= mlb and i % Hh == 0: + rLs = [px[i] / px[i - Lk] - 1.0 for Lk in lookbacks] + disp_i = float(np.mean([r.std() for r in rLs])) + thr = (np.percentile(disp_hist, disp_pct) + if (disp_pct > 0 and len(disp_hist) >= minhist) else -np.inf) + if disp_i >= thr: + score = np.zeros(A); cnt = 0 + for rL in rLs: + sd = rL.std() + if sd > 0: + score += (rL - rL.mean()) / sd; cnt += 1 + if cnt: + score /= cnt + order = np.argsort(score) + w = np.zeros(A); low_i, hi_i = order[:k], order[-k:] + if mode == "mom": + w[hi_i] = 0.5 / k; w[low_i] = -0.5 / k + else: + w[low_i] = 0.5 / k; w[hi_i] = -0.5 / k + else: + w = np.zeros(A) + disp_hist.append(disp_i) + W[i] = w + + gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1) + turn = np.zeros(n); turn[0] = np.abs(W[0]).sum() + turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1) + net = gross - turn * (0.001 / 2.0) + s = pd.Series(net, index=C.index) + rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25) + scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0) + + # --- escursione intraday, gambe tenute = W[i-1], prezzo base = close[i-1] + prev_c = np.vstack([np.full((1, A), np.nan), px[:-1]]) + held = np.vstack([np.zeros((1, A)), W[:-1]]) + with np.errstate(invalid="ignore"): + rO = O.values / prev_c - 1.0 + rH = H.values / prev_c - 1.0 + rL = L.values / prev_c - 1.0 + rC = px / prev_c - 1.0 + vO = np.nansum(held * rO, axis=1) + vH = np.nansum(held * rH, axis=1) + vL = np.nansum(held * rL, axis=1) + vC = np.nansum(held * rC, axis=1) + # ordinamento condiviso: low-first (O -> L -> H -> C) oppure high-first (O -> H -> L -> C). + # Il book market-neutral vede i due estremi come punti, non come somma di worst per gamba. + if wick_mode == "perleg": + # bound severo: ogni gamba al proprio peggio simultaneamente + worst_leg = np.where(held >= 0, rL, rH) + m_gross = np.minimum(np.nansum(held * worst_leg, axis=1), vC) + elif wick_mode == "shared": + m_lowfirst = np.minimum.reduce([vO, vL, vH, vC]) + m_highfirst = np.minimum.reduce([vO, vH, vL, vC]) + m_gross = np.minimum(m_lowfirst, m_highfirst) # ordine AVVERSO + else: + raise ValueError(wick_mode) + # il costo di turnover si paga comunque; il vol-target scala tutto + m_net = (m_gross - turn * (0.001 / 2.0)) * scale + R_net = net * scale + out = pd.DataFrame({"R": R_net, "m": np.minimum(m_net, R_net)}, index=C.index) + return out + + +# ================================================================= 3. PANNELLO A 3 SLEEVE + +def _d(s: pd.Series) -> pd.Series: + s = s.dropna().sort_index() + if s.index.tz is None: + s.index = s.index.tz_localize("UTC") + return s + + +_TUP_CACHE: dict = {} + + +def book_tuples(w_tp: float, w_skh: float, w_xs: float, start: str | None = None, + xs_mode: str = "shared") -> tuple[np.ndarray, np.ndarray, pd.DatetimeIndex]: + """(R, gap) giornalieri di un book a pesi arbitrari. gap = minimo_intraday - chiusura (<=0). + + Crypto: minimo ESATTO sul path orario condiviso di TP01+SKH01. XS01: il suo minimo si SOMMA + a quello crypto (worst simultaneo — convenzione dichiarata, severa). + La finestra si interseca con XS01 SOLO se XS01 ha peso: a peso zero il book crypto tiene + tutta la sua storia (serve per misurare la geometria dei gap del book live). + NB: niente round-trip int64 dell'indice — su indici tz-aware non-ns pandas 2.x cambia scala + silenziosamente (lezione 2026-07-01). Si cachea il DatetimeIndex cosi' com'e'. + """ + key = (float(w_tp), float(w_skh), float(w_xs), xs_mode) + if key not in _TUP_CACHE: + cry = None if (w_tp == 0.0 and w_skh == 0.0) else crypto_daily_tuples(w_tp, w_skh) + xs = xsec_daily_tuples(xs_mode) + if cry is None: + idx, R, m = xs.index, w_xs * xs["R"].values, w_xs * xs["m"].values + elif w_xs == 0.0: + idx, R, m = cry.index, cry["R"].values, cry["m"].values + else: + idx = cry.index.intersection(xs.index) + cr, xr = cry.reindex(idx), xs.reindex(idx) + R = cr["R"].values + w_xs * xr["R"].values + m = cr["m"].values + w_xs * xr["m"].values + keep = np.isfinite(R) & np.isfinite(m) + R, m, idx = R[keep], m[keep], idx[keep] + _TUP_CACHE[key] = (R, np.minimum(m, R) - R, idx) + R, G, I = _TUP_CACHE[key] + if start is not None: + k = I >= pd.Timestamp(start, tz="UTC") + return R[k], G[k], I[k] + return R.copy(), G.copy(), I + + +# ================================================================= 4. SIMULAZIONE (3 lenti) + +def simulate(cfgs: list[tuple[float, float, float]], slots: list[int], n_paths: int, lev: float, + seed: int, lens: str, start: str, block: int = 20) -> dict: + """Scala di conti funded. `cfgs` = vettori di peso distinti; `slots` = indice di cfg per + ciascuno dei MAX_CONCURRENT slot. Il bootstrap a blocchi usa lo STESSO indice per tutte le + configurazioni -> la correlazione fra conti e' quella REALE (e le tuple (R,gap) restano + accoppiate al loro giorno). + + lens: 'close' -> breach sulla chiusura (tetto) + 'indip' -> gap lognormale indipendente (il pavimento del 25/07) + 'coupled'-> gap dal recon, accoppiato al giorno (la lente onesta) + """ + rng = np.random.default_rng(seed) + n_days = HORIZON_MONTHS * 30 + mats = [book_tuples(*c, start=start) for c in cfgs] + L = min(len(m[0]) for m in mats) + Rm = np.stack([m[0][-L:] for m in mats], axis=1) # (giorni, cfg) + Gm = np.stack([m[1][-L:] for m in mats], axis=1) + Rm = Rm - (1.0 - DELUCK) * Rm.mean(axis=0, keepdims=True) # de-luck sul drift + + n_blocks = int(np.ceil(n_days / block)) + starts = rng.integers(0, L - block, size=(n_paths, n_blocks)) + idx = (starts[:, :, None] + np.arange(block)[None, None, :]).reshape(n_paths, -1)[:, :n_days] + + n_slot = len(slots) + sel = np.asarray(slots) + Rs = Rm[idx][:, :, sel] * lev # (path, day, slot) + if lens == "close": + G = np.zeros_like(Rs) + elif lens == "coupled": + G = Gm[idx][:, :, sel] * lev + elif lens == "indip": + vol_k = Rs.reshape(-1, n_slot).std(axis=0) * np.sqrt(365.0) + scale = np.clip(vol_k / pl.WICK_REF_VOL, 0.0, 3.0) + G = -np.exp(rng.normal(pl.WICK_MU, pl.WICK_SIGMA, size=Rs.shape)) * scale[None, None, :] + else: + raise ValueError(lens) + + cash = np.full(n_paths, START_EUR * EURUSD) + phase = np.full((n_paths, n_slot), -1, np.int8) + eq = np.ones((n_paths, n_slot)) + firm = np.full((n_paths, n_slot), -1, np.int8) + notional = np.zeros((n_paths, n_slot)) + firm_alive = np.ones((n_paths, len(FIRMS)), bool) + cum_payout = np.zeros(n_paths) + spent = np.zeros(n_paths) + p_death_day = 1.0 - (1.0 - FIRM_DEATH_PER_YEAR) ** (1 / 365.0) + + for t in range(n_days): + died = rng.random((n_paths, len(FIRMS))) < p_death_day + newly_dead = died & firm_alive + firm_alive &= ~died + if newly_dead.any(): + for fi in range(len(FIRMS)): + kill = newly_dead[:, fi] + if kill.any(): + hit = (firm == fi) & kill[:, None] + phase[hit] = -1 + notional[hit] = 0.0 + + for k in range(n_slot): + free = phase[:, k] == -1 + if not free.any(): + continue + for fi, F in enumerate(FIRMS): + used = np.where(firm == fi, notional, 0.0).sum(axis=1) + for (size, price) in sorted(F["tickets"], key=lambda x: -x[0]): + buy = (free & firm_alive[:, fi] & (cash >= price) + & (used + size <= F["cap"])) + if not buy.any(): + continue + cash[buy] -= price + spent[buy] += price + phase[buy, k] = 0 + eq[buy, k] = 1.0 + firm[buy, k] = fi + notional[buy, k] = size + free = free & ~buy + if not free.any(): + break + if not free.any(): + break + + r = Rs[:, t, :] + g = G[:, t, :] + act = phase >= 0 + eq_prev = eq + eq = np.where(act, eq * (1.0 + r), eq) + eq_low = np.where(act, eq_prev * (1.0 + r + g), eq) + r_low = r + g + + for fi, F in enumerate(FIRMS): + s_ = act & (firm == fi) + if not s_.any(): + continue + ev_t, ev_dd, ev_dl = F["ev"] + fu_ml, fu_dl = F["fu"] + in_ev = s_ & (phase == 0) + in_fu = s_ & (phase == 1) + bust = ((in_ev & ((eq_low < 1.0 - ev_dd) | (r_low < -ev_dl))) + | (in_fu & ((eq_low < 1.0 - fu_ml) | (r_low < -fu_dl)))) + phase[bust] = -1 + notional[bust] = 0.0 + passed = in_ev & (eq >= 1.0 + ev_t) & ~bust + phase[passed] = 1 + eq[passed] = 1.0 + + if (t + 1) % 30 == 0: + fu = phase == 1 + gain = np.where(fu & (eq > 1.0), (eq - 1.0) * notional, 0.0) + net = gain.sum(axis=1) * SPLIT * (1.0 - TAX_RATE) + cum_payout += net + cash += net + eq = np.where(fu & (eq > 1.0), 1.0, eq) + + eur_day = cum_payout / EURUSD / (n_days / 365.0) / 365.0 + return dict(eur_day=eur_day, med=float(np.median(eur_day)), + p10=float(np.percentile(eur_day, 10)), p90=float(np.percentile(eur_day, 90)), + p_ge10=float((eur_day >= 10.0).mean()), + p_target=float((eur_day >= TARGET_EUR_DAY).mean()), + p_zero=float((cum_payout <= 0).mean()), med_spent=float(np.median(spent))) + + +# --------------------------------------------------- eval/funded a conto singolo (HYRO) + +def single_account(cfg: tuple[float, float, float], lev: float, lens: str, start: str, + n_paths: int = 6000, seed: int = 725, block: int = 20, + xs_mode: str = "shared") -> dict: + """P(pass eval), P(vivo 1 anno da funded), E[payout annuo] per UN conto HYRO $100k.""" + rng = np.random.default_rng(seed) + R0, G0, _ = book_tuples(*cfg, start=start, xs_mode=xs_mode) + R0 = R0 - (1.0 - DELUCK) * R0.mean() + n = len(R0) + n_days = 365 + n_blocks = int(np.ceil(n_days / block)) + st = rng.integers(0, n - block, size=(n_paths, n_blocks)) + idx = (st[:, :, None] + np.arange(block)[None, None, :]).reshape(n_paths, -1)[:, :n_days] + R = R0[idx] * lev + if lens == "close": + G = np.zeros_like(R) + elif lens == "coupled": + G = G0[idx] * lev + else: + vol = R.std() * np.sqrt(365.0) + G = -np.exp(rng.normal(pl.WICK_MU, pl.WICK_SIGMA, size=R.shape)) \ + * np.clip(vol / pl.WICK_REF_VOL, 0.0, 3.0) + + ev_t, ev_dd, ev_dl = FIRMS[0]["ev"] + fu_ml, fu_dl = FIRMS[0]["fu"] + NOTIONAL = 100_000.0 + + eq = np.ones(n_paths) + phase = np.zeros(n_paths, np.int8) # 0 eval, 1 funded, -1 morto + days_funded = np.zeros(n_paths, int) + payout = np.zeros(n_paths) + passed_ev = np.zeros(n_paths, bool) + for t in range(n_days): + alive = phase >= 0 + r = R[:, t]; g = G[:, t] + eq_prev = eq + eq = np.where(alive, eq * (1 + r), eq) + eq_low = np.where(alive, eq_prev * (1 + r + g), eq) + r_low = r + g + in_ev = alive & (phase == 0) + in_fu = alive & (phase == 1) + bust = ((in_ev & ((eq_low < 1 - ev_dd) | (r_low < -ev_dl))) + | (in_fu & ((eq_low < 1 - fu_ml) | (r_low < -fu_dl)))) + phase[bust] = -1 + ok = in_ev & (eq >= 1 + ev_t) & ~bust + phase[ok] = 1 + eq[ok] = 1.0 + passed_ev |= ok + days_funded += (phase == 1) + if (t + 1) % 30 == 0: + fu = phase == 1 + gain = np.where(fu & (eq > 1), (eq - 1) * NOTIONAL, 0.0) + payout += gain * SPLIT * (1 - TAX_RATE) + eq = np.where(fu & (eq > 1), 1.0, eq) + return dict(p_pass=float(passed_ev.mean()), + p_alive=float((phase[passed_ev] == 1).mean()) if passed_ev.any() else 0.0, + e_payout=float(payout.mean()), + e_payout_if_pass=float(payout[passed_ev].mean()) if passed_ev.any() else 0.0) + + +# ------------------------------------------------- verifica di risoluzione (1h vs 5m) + +@lru_cache(maxsize=4) +def _tp_leg_gap(tf: str) -> pd.DataFrame: + """(R, m) giornalieri della SOLA gamba TP01, misurati a risoluzione `tf`. + + TP01 tiene un peso COSTANTE nel giorno, quindi il suo minimo intraday e' esattamente il + minimo del path di prezzo: si puo' calcolare a 5m e confrontare con l'1h. E' il test che + dice se l'1h basta a catturare il minimo giornaliero, invece di assumerlo. + """ + tot_r = tot_w = None + for a in ASSETS: + df = load_data(a, tf) + idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) + c = df["close"].values.astype(float) + lo = df["low"].values.astype(float) + hi = df["high"].values.astype(float) + prev = np.concatenate(([np.nan], c[:-1])) + tpf = mc.tp01_daily_frame(a) + day = idx.floor("D") + w = np.nan_to_num(tpf["w"].reindex(day).values) + fee = np.nan_to_num(tpf["fee"].reindex(day).values) + first = np.concatenate(([True], day[1:] != day[:-1])) + rw = np.where(w >= 0, lo / prev - 1.0, hi / prev - 1.0) + r = pd.Series(w * (c / prev - 1.0) - np.where(first, fee, 0.0), index=idx) + wk = pd.Series(w * rw - np.where(first, fee, 0.0), index=idx) + tot_r = r if tot_r is None else tot_r.add(r, fill_value=np.nan) + tot_w = wk if tot_w is None else tot_w.add(wk, fill_value=np.nan) + J = pd.concat({"r": tot_r, "w": tot_w}, axis=1).dropna() + rows = [] + for d, g in J.groupby(J.index.floor("D")): + r = g["r"].values; wk = g["w"].values + cum = np.cumprod(1 + r); cp = np.concatenate(([1.0], cum[:-1])) + m = min(cum.min(), (cp * (1 + wk)).min()) - 1.0 + rows.append((d, cum[-1] - 1.0, min(m, cum[-1] - 1.0))) + return pd.DataFrame(rows, columns=["d", "R", "m"]).set_index("d") + + +# ================================================================= main + +START_WIN = "2024-01-01" # finestra comune ai 3 sleeve (XS01 nasce qui) +CFG_A = (0.75, 0.25, 0.0) # book live Deribit (TP01/SKH01) — quello che gira oggi +CFG_B = (0.55, 0.20, 0.25) # book diversificato (config B del 25/07) +CFG_TP = (1.0, 0.0, 0.0) +CFG_SKH = (0.0, 1.0, 0.0) +CFG_XS = (0.0, 0.0, 1.0) + + +def main() -> None: + print("=" * 106) + print(" LENTE WICK ACCOPPIATA — chiusura del follow-up dichiarato (r0725_prop_ladder §ONESTA')") + print("=" * 106) + + print("\n recon MTM orario per-sleeve (TP01 daily + SKH01 230m exit-al-livello)...") + R_A, G_A, I_A = book_tuples(*CFG_A) + print(f" book A (TP01 75 / SKH01 25): {len(R_A)} giorni {I_A[0].date()} -> {I_A[-1].date()}") + + # --- sanity: il recon deve riprodurre lo sleeve ufficiale + xs_off = _d(_xsec_returns()) + xs_rec = xsec_daily_tuples() + common = xs_off.index.intersection(xs_rec.index) + dmax = float(np.abs(xs_off.reindex(common).values - xs_rec["R"].reindex(common).values).max()) + print(f" sanity XS01 recon vs sleeve ufficiale: max|delta| chiusura = {dmax:.2e}" + f" ({'OK' if dmax < 1e-9 else 'DIVERGE'})") + + # ---------------------------------------------------------- 3. la prova del difetto + print("\n" + "-" * 106) + print(" A. IL GAP NON E' INDIPENDENTE DAL GIORNO — e la dipendenza va nel verso INATTESO") + print("-" * 106) + print(f" {'book':>26} {'giorni':>7} {'m==R':>7} {'gap medio':>10} " + f"{'gap | decile PEGGIORE':>22} {'gap | decile MIGLIORE':>22}") + for nm, cfg in [("A TP01/SKH01 (live)", CFG_A), ("B +XS01 (diversif.)", CFG_B), + ("TP01 solo", CFG_TP), ("SKH01 solo", CFG_SKH), ("XS01 solo", CFG_XS)]: + R, G, _ = book_tuples(*cfg) + if R.std() == 0: + continue + q = np.quantile(R, [0.1, 0.9]) + print(f" {nm:>26} {len(R):>7d} {(G > -1e-12).mean():>6.0%} {G.mean()*100:>9.2f}pp " + f"{G[R <= q[0]].mean()*100:>21.2f}pp {G[R >= q[1]].mean()*100:>21.2f}pp") + print("\n Lettura — il contrario di quello che si assume di solito: l'escursione intraday e'") + print(" piu' PROFONDA nei giorni che finiscono BENE (recuperi a V) che in quelli che finiscono") + print(" male, perche' un giorno brutto scende tutto il giorno e CHIUDE sul minimo (m==R nel") + print(" ~29% dei giorni). Il breach pero' si valuta sul minimo, quindi cio' che conta e' quanto") + print(" gap si aggiunge NEI GIORNI BRUTTI — e li' il gap vero e' ~1/3 di quello nei giorni buoni.") + print(" Un'estrazione indipendente usa la STESSA distribuzione ovunque -> carica i giorni") + print(" brutti con la coda che nella realta' appartiene ai giorni buoni.") + + # --- la misura che chiude la questione: quanti breach in piu' inventa la lente indipendente + print("\n" + "-" * 106) + print(" A-bis. CONTEGGIO EMPIRICO DEI BREACH sui giorni STORICI (nessun bootstrap)") + print("-" * 106) + rng = np.random.default_rng(725) + print(f" {'book':>26} {'leva':>5} {'regola':>22} {'close-only':>11} " + f"{'ACCOPPIATO':>11} {'indipendente':>13} {'gonfiaggio':>11}") + for nm, cfg in [("A TP01/SKH01 (live)", CFG_A), ("B +XS01 (diversif.)", CFG_B)]: + R0, G0, _ = book_tuples(*cfg, start=START_WIN) + for lev in (0.75, 1.0): + R, G = R0 * lev, G0 * lev + vol = R.std() * np.sqrt(365.0) + n_rep = 400 + Gi = -np.exp(rng.normal(pl.WICK_MU, pl.WICK_SIGMA, size=(n_rep, len(R)))) \ + * np.clip(vol / pl.WICK_REF_VOL, 0.0, 3.0) + for lab, thr in (("daily-loss 4% (HYRO)", 0.04), ("daily-loss 5% (FTMO)", 0.05)): + b_cl = float((R < -thr).mean()) + b_co = float((R + G < -thr).mean()) + b_in = float((R[None, :] + Gi < -thr).mean()) + infl = (b_in / b_co) if b_co > 0 else np.inf + print(f" {nm:>26} {lev:>5.2f} {lab:>22} {b_cl:>10.2%} " + f"{b_co:>10.2%} {b_in:>12.2%} {infl:>10.1f}x") + + # geometria dei gap: confronto diretto fra le due lenti + print("\n" + "-" * 106) + print(" B. GEOMETRIA DEI GAP — accoppiato (recon) vs indipendente (calibrazione 25/07)") + print("-" * 106) + q_ind = pl.wick_percentiles() + print(f" {'book':>28} {'p50':>9} {'p90':>9} {'p99':>9} {'peggiore':>10} {'vol annua':>10}") + for nm, cfg in [("A TP01/SKH01 (live)", CFG_A), ("B +XS01 (diversificato)", CFG_B), + ("XS01 solo", CFG_XS)]: + R, G, _ = book_tuples(*cfg) + if R.std() == 0: + continue + p = np.percentile(-G, [50, 90, 99]) + print(f" {nm:>28} {-p[0]*100:>8.2f}pp {-p[1]*100:>8.2f}pp {-p[2]*100:>8.2f}pp " + f"{G.min()*100:>9.2f}pp {R.std()*np.sqrt(365)*100:>9.1f}%") + print(f" {'INDIPENDENTE (25/07)':>28} {-q_ind[0.50]*100:>8.2f}pp {-q_ind[0.90]*100:>8.2f}pp " + f"{-q_ind[0.99]*100:>8.2f}pp {'—':>10} {'(scalato)':>10}") + + # ---------------------------------------------------------- 4. conto singolo HYRO + START = START_WIN + print("\n" + "-" * 106) + print(f" C. UN CONTO HYRO $100k (max-loss 6% statico, daily 4%) — finestra {START}+, de-luck x{DELUCK}") + print("-" * 106) + print(f" {'config':>26} {'leva':>5} {'lente':>10} {'P(pass eval)':>13} " + f"{'P(vivo 1a)':>11} {'E[payout/a]':>12}") + for nm, cfg in [("A book live 75/25", CFG_A), ("B +XS01 55/20/25", CFG_B)]: + for lev in (0.50, 0.75, 1.00): + for lens in ("close", "indip", "coupled"): + r = single_account(cfg, lev, lens, START) + print(f" {nm:>26} {lev:>5.2f} {lens:>10} {r['p_pass']:>13.1%} " + f"{r['p_alive']:>11.1%} {r['e_payout']:>11,.0f}$") + print() + + # ------------------------------------------------- 4-bis. le tre verifiche che possono ribaltare + print("-" * 106) + print(" C-bis. VERIFICHE — un risultato 'nessuna differenza' va provato, non assunto") + print("-" * 106) + + print("\n (1) RISOLUZIONE: il wick 1h cattura davvero il minimo del giorno? (gamba TP01, 5m vs 1h)") + for tf in ("1h", "5m"): + D = _tp_leg_gap(tf) + G = (D["m"] - D["R"]).values + k = np.isfinite(G) & (np.abs(D["R"].values) > 0) # i giorni flat non hanno escursione + print(f" @{tf}: p50 {-np.percentile(-G[k], 50)*100:>6.3f}pp " + f"p90 {-np.percentile(-G[k], 90)*100:>6.3f}pp " + f"p99 {-np.percentile(-G[k], 99)*100:>6.3f}pp " + f"peggiore {G[k].min()*100:>6.2f}pp (n={k.sum()} giorni non-flat)") + print(" -> se coincidono, la lente accoppiata a 1h NON e' un tetto di risoluzione.") + + print("\n (2) RICONCILIAZIONE col 24/07 (recon MTM indipendente, finestra piena): " + "config A @0.75x, P(vivo 1a)") + for st, lab in (("2019-01-01", "2019-03+ FULL"), (START, "2024+ comune")): + for lens in ("close", "coupled"): + r = single_account(CFG_A, 0.75, lens, st) + print(f" {lab:>14} {lens:>8}: P(pass) {r['p_pass']:>5.1%} " + f"P(vivo) {r['p_alive']:>5.1%} E[payout] {r['e_payout']:>6,.0f}$") + print(" -> il 24/07 riportava 58% sulla finestra piena; il 25/07 (wick indipendente) 10%.") + + print("\n (3) BOUND SEVERO su XS01 (ogni gamba al proprio peggio insieme) — config B @0.75x") + for xm, lab in (("shared", "ordine condiviso (base)"), ("perleg", "per-gamba (bound severo)")): + R, G, _ = book_tuples(*CFG_B, start=START, xs_mode=xm) + r = single_account(CFG_B, 0.75, "coupled", START, xs_mode=xm) + print(f" {lab:>26}: gap p90 {-np.percentile(-G, 90)*100:>6.2f}pp " + f"peggiore {G.min()*100:>6.2f}pp | P(pass) {r['p_pass']:>5.1%} " + f"P(vivo) {r['p_alive']:>5.1%} E[payout] {r['e_payout']:>6,.0f}$") + print(" -> se la config B regge a ENTRAMBI gli estremi, la conclusione non dipende dalla convenzione.") + + # ---------------------------------------------------------- 5. la scala di conti + print("\n" + "-" * 106) + print(f" D. LA SCALA DI CONTI — {HORIZON_MONTHS} mesi, EUR {START_EUR:.0f} di cassa, " + f"max {MAX_CONCURRENT} conti, morte-firm {FIRM_DEATH_PER_YEAR:.0%}/a") + print("-" * 106) + cfgs = [CFG_A, CFG_B, CFG_TP, CFG_SKH, CFG_XS] + POLS = { + "CONC-2SL ogni conto = book live": [0] * MAX_CONCURRENT, + "CONC-DIV ogni conto = diversificato": [1] * MAX_CONCURRENT, + "SPARSO 1 sleeve per conto": [2, 4, 3, 2, 4, 3], + "MISTO meta' div / meta' single": [1, 4, 1, 2, 1, 4], + } + for lens in ("close", "indip", "coupled"): + tag = {"close": "CLOSE-ONLY (tetto)", "indip": "WICK INDIPENDENTE (pavimento 25/07)", + "coupled": "WICK ACCOPPIATO (la lente onesta)"}[lens] + for lev in (0.50, 1.00): + print(f"\n {tag} — LEVA {lev:.2f}x") + print(f" {'politica':>40} {'EUR/g med':>10} {'p10':>7} {'p90':>7} " + f"{'P(>=10/g)':>10} {'P(>=50/g)':>10} {'P(zero)':>8}") + for pname, slots in POLS.items(): + r = simulate(cfgs, slots, n_paths=3000, lev=lev, seed=20260725, + lens=lens, start=START) + print(f" {pname:>40} {r['med']:>10.2f} {r['p10']:>7.2f} {r['p90']:>7.2f} " + f"{r['p_ge10']:>10.1%} {r['p_target']:>10.1%} {r['p_zero']:>8.1%}") + + print("\n" + "=" * 106) + + +if __name__ == "__main__": + main() diff --git a/tests/test_prop_coupled.py b/tests/test_prop_coupled.py new file mode 100644 index 0000000..8fbed39 --- /dev/null +++ b/tests/test_prop_coupled.py @@ -0,0 +1,137 @@ +"""Test della lente WICK ACCOPPIATA (scripts/research/r0725_prop_coupled.py). + +Blindano le proprieta' che rendono la lente *onesta* — sono le stesse che, se si rompessero, +farebbero tornare silenziosamente il difetto del 25/07 (gap indipendente dal giorno): + * il recon XS01 deve riprodurre ESATTAMENTE lo sleeve ufficiale (se diverge, si sta + misurando il wick di una strategia che non e' quella in produzione); + * il gap dev'essere <= 0 per costruzione (il minimo intraday non puo' stare sopra la chiusura); + * la dipendenza dal giorno dev'esserci (gap piu' profondo nei giorni buoni) — e' il fatto + su cui poggia tutta la conclusione; + * le tre lenti devono ordinarsi close >= coupled >= indip sui breach; + * il bound severo su XS01 dev'essere effettivamente piu' severo di quello condiviso. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +pytest.importorskip("pandas") + +import r0725_prop_coupled as pc # noqa: E402 +from src.portfolio.sleeves import _xsec_returns # noqa: E402 + + +# --------------------------------------------------------------- fedelta' del recon + +def test_xsec_recon_riproduce_lo_sleeve_ufficiale(): + """Se il recon XS01 divergesse dallo sleeve, misureremmo il wick di un'altra strategia.""" + off = _xsec_returns().dropna().sort_index() + if off.index.tz is None: + off.index = off.index.tz_localize("UTC") + rec = pc.xsec_daily_tuples() + common = off.index.intersection(rec.index) + assert len(common) > 500 + assert np.abs(off.reindex(common).values - rec["R"].reindex(common).values).max() < 1e-12 + + +def test_crypto_recon_riproduce_il_book_7525_del_2407(): + """La decomposizione per-sleeve, ricombinata a 75/25, deve dare il book di r0724. + Tolleranza larga: r0724 fa il recon in un colpo solo, qui si somma dopo aver diviso + per i pesi — devono coincidere a meno dell'aritmetica in virgola mobile.""" + R, G, I = pc.book_tuples(*pc.CFG_A) + assert len(R) > 2000 + vol = R.std() * np.sqrt(365.0) + assert 0.08 < vol < 0.16, f"vol book live fuori scala: {vol:.3f}" + # i percentili del gap devono ritrovare quelli dichiarati dal 24/07 (-0.17 / -1.03 / -2.70) + p50, p90 = -np.percentile(-G, 50), -np.percentile(-G, 90) + assert -0.004 < p50 < -0.0005, p50 + assert -0.020 < p90 < -0.004, p90 + + +# --------------------------------------------------------------- proprieta' del gap + +@pytest.mark.parametrize("cfg", [pc.CFG_A, pc.CFG_B, pc.CFG_XS]) +def test_gap_sempre_non_positivo(cfg): + """m <= R per costruzione: il minimo intraday non puo' stare sopra la chiusura.""" + _, G, _ = pc.book_tuples(*cfg) + assert G.max() <= 1e-15 + + +def test_gap_piu_profondo_nei_giorni_buoni(): + """IL fatto su cui poggia la conclusione. Se questo si invertisse, la lente indipendente + non sarebbe piu' sbilanciata e il follow-up perderebbe senso.""" + R, G, _ = pc.book_tuples(*pc.CFG_A) + q = np.quantile(R, [0.1, 0.9]) + gap_bad = G[R <= q[0]].mean() + gap_good = G[R >= q[1]].mean() + assert gap_good < gap_bad, (gap_good, gap_bad) + assert gap_good < 2.0 * gap_bad, "il gap nei giorni buoni dev'essere nettamente piu' profondo" + + +def test_una_quota_di_giorni_chiude_sul_minimo(): + """Nei giorni brutti la chiusura E' il minimo: e' il motivo per cui il gap indipendente + (che non lo sa) aggiunge un tuffo che non c'e'.""" + _, G, _ = pc.book_tuples(*pc.CFG_A) + frac = float((G > -1e-12).mean()) + assert 0.10 < frac < 0.60, frac + + +# --------------------------------------------------------------- ordinamento delle lenti + +def test_le_lenti_si_ordinano_sui_breach(): + """close-only non vede mai il minimo -> meno breach di tutti; l'indipendente ne inventa + -> piu' della accoppiata. L'ordine e' la ragion d'essere dell'esercizio.""" + R, G, _ = pc.book_tuples(*pc.CFG_A, start=pc.START_WIN) + rng = np.random.default_rng(0) + vol = R.std() * np.sqrt(365.0) + Gi = -np.exp(rng.normal(pc.pl.WICK_MU, pc.pl.WICK_SIGMA, size=(200, len(R)))) * np.clip( + vol / pc.pl.WICK_REF_VOL, 0.0, 3.0) + thr = 0.04 + b_close = float((R < -thr).mean()) + b_coup = float((R + G < -thr).mean()) + b_indip = float((R[None, :] + Gi < -thr).mean()) + assert b_close <= b_coup <= b_indip, (b_close, b_coup, b_indip) + assert b_indip > 1.3 * max(b_coup, 1e-9), "l'indipendente deve gonfiare in modo misurabile" + + +def test_bound_severo_xs01_e_davvero_piu_severo(): + _, g_sh, _ = pc.book_tuples(*pc.CFG_B, xs_mode="shared") + _, g_pl, _ = pc.book_tuples(*pc.CFG_B, xs_mode="perleg") + assert g_pl.mean() < g_sh.mean() + assert g_pl.min() < g_sh.min() + + +def test_wick_mode_sconosciuto_alza_errore(): + with pytest.raises(ValueError): + pc.xsec_daily_tuples("nonesiste") + + +# --------------------------------------------------------------- simulazione + +def test_single_account_close_domina_coupled_domina_indip(): + """A parita' di tutto il resto, una lente piu' severa non puo' far sopravvivere di piu'.""" + out = {ln: pc.single_account(pc.CFG_A, 1.0, ln, pc.START_WIN, n_paths=800) + for ln in ("close", "coupled", "indip")} + assert out["close"]["p_alive"] >= out["coupled"]["p_alive"] >= out["indip"]["p_alive"] + + +def test_leva_piu_alta_non_aumenta_la_sopravvivenza(): + lo = pc.single_account(pc.CFG_B, 0.50, "coupled", pc.START_WIN, n_paths=800) + hi = pc.single_account(pc.CFG_B, 1.00, "coupled", pc.START_WIN, n_paths=800) + assert lo["p_alive"] > hi["p_alive"] + + +def test_book_tuples_non_condivide_lo_stato_fra_chiamate(): + """La cache restituisce copie: un chiamante che modifica l'array non deve avvelenare + il successivo (stesso guardrail messo su r0725_capcurve).""" + R1, _, _ = pc.book_tuples(*pc.CFG_A) + R1[:] = 999.0 + R2, _, _ = pc.book_tuples(*pc.CFG_A) + assert not np.allclose(R2, 999.0)