research(prop): lente wick ACCOPPIATA — chiude il follow-up dichiarato del 25/07

La scala di conti funded era giudicata con due lenti a un ordine di grandezza di
distanza su P(>=50 EUR/g): 20.7% close-only vs 1.6-2.5% wick indipendente. Il
difetto era dichiarato (gap estratto indipendente dal rendimento del giorno) ma
il recon accoppiato esisteva solo per il book 75/25.

Qui si generalizza: sleeve TP01/SKH01 separati a risoluzione oraria (minimo
intraday esatto per qualsiasi vettore di pesi) + XS01 accoppiato dagli OHLC
giornalieri HL (recon = sleeve ufficiale a max|delta|=0.0).

FINDING: la calibrazione del wick era giusta, l'errore era l'INDIPENDENZA. Le
marginali coincidono (p50 -0.17pp identico) ma il gap e' ~3x piu' profondo nei
giorni che finiscono BENE (-1.58pp vs -0.48pp), perche' un giorno brutto chiude
sul minimo (m==R nel 26% dei giorni). Il breach si valuta sul minimo -> la lente
indipendente raddoppia i breach da daily-loss (2.0-2.9x, misurato sui giorni
storici). close-only non e' conservativa ma CIECA: 0.00% di breach ovunque.

Verifiche: (1) 1h vs 5m identici (p99 -3.06 vs -3.14pp) -> il caveat di
risoluzione del 24/07 e' trascurabile; (2) riconciliazione col 24/07 RISOLTA
(63.2% vs 58% sulla stessa finestra: non era la finestra, era la lente);
(3) bound severo su XS01 non ribalta config B.

Decisioni: funded 0.75x (non 0.50x — massimizza il payout a 3 anni, $2674 vs
$1230); scala P(>=50/g) ~6% con P(zero) 52-65%; ordine fra politiche invariato
a tutte e 3 le lenti. Book/pesi/cron INVARIATI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-25 19:30:03 +00:00
parent 8831c3ff38
commit 4cb82dc474
4 changed files with 1108 additions and 12 deletions
+44 -12
View File
@@ -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 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: 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 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 dal rendimento del giorno → breach spuri → **follow-up dichiarato, poi CHIUSO lo stesso giorno
modo giusto e' il bootstrap delle TUPLE (ritorno,gap) di `r0724_goal50_intraday_mc.py`, che pero' (bullet successivo): la stima onesta e' P(≥50/g) ~6%, P(zero) 52-65%.** Il confronto fra POLITICHE
ha il recon solo per TP01+SKH01 → **follow-up dichiarato**. **VERDETTO IN BANDA (risposta al goal): e' robusto (stessa lente per tutte) — **verificato a posteriori sulla lente accoppiata: l'ordine
una via da €600 a €50/g ESISTE (scala di conti funded) con P ~2-21% in 3 anni e P(bruciare i €600) MISTO ≥ CONC-DIV > CONC-2SL ≈ SPARSO regge a tutte e 3 le lenti** → **se si apre il fronte prop,
~33-78% → scommessa a coda destra, NON una rendita.** Il confronto fra POLITICHE e' robusto (stessa NON mandarci il book live attuale**.
lente per tutte): **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 (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%). 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%); 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 "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 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 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 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 a 0.50x**~~**CORRETTO a 0.75x dalla lente accoppiata (bullet successivo)**: il 0.50x era scelto
COMPONE su piu' anni). EV del biglietto $100k **positivo in entrambe le lenti** (+$6.789 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 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 ⇒ ~€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 58% vs 10% mio) **RISOLTA: non era la finestra, era la lente** — sulla stessa finestra piena la
direzione concorda, sul LIVELLO il numero del 24/07 (recon MTM vero) e' piu' affidabile**. 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 → ⚠ 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 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. 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: - **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. 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.** - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.**
@@ -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.
+693
View File
@@ -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()
+137
View File
@@ -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)