Compare commits
15 Commits
ee82e0a056
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b691f48f43 | |||
| 822aa1307e | |||
| cf7de40dc0 | |||
| eef200cf14 | |||
| 116abf5203 | |||
| 26f8d27a61 | |||
| 76120b59c2 | |||
| a74cc69583 | |||
| 73d74c5e53 | |||
| e6657fcb16 | |||
| 491411ac77 | |||
| ccf5e38101 | |||
| 567046953d | |||
| 31369b358c | |||
| 88f5f5a02a |
@@ -40,6 +40,14 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
|||||||
Deploy/paper a **1d**. Diari `2026-06-19-tp01-verification.md` / `-tp01-lookahead-fix-lf.md`.
|
Deploy/paper a **1d**. Diari `2026-06-19-tp01-verification.md` / `-tp01-lookahead-fix-lf.md`.
|
||||||
Paper trader: `scripts/live/paper_trend.py` (1d). Test: `tests/test_trend_portfolio.py`.
|
Paper trader: `scripts/live/paper_trend.py` (1d). Test: `tests/test_trend_portfolio.py`.
|
||||||
Ri-verifica: `scripts/analysis/{verify_tp01,stress_tp01,tp01_lowfreq}.py`.
|
Ri-verifica: `scripts/analysis/{verify_tp01,stress_tp01,tp01_lowfreq}.py`.
|
||||||
|
⚠️ **ANCHOR TIMING-LUCK (2026-07-02, confermato da scettico):** l'hold-out ~0.31 è calcolato
|
||||||
|
sull'ancora daily 00:00 UTC, che è la **migliore delle 24 possibili** (mediana ancore 0.04, banda
|
||||||
|
[−0.13,+0.30]; P~0.86 che una qualsiasi ancora mostri uno spike così per puro caso) → l'hold-out
|
||||||
|
2025-26 NON risolve l'edge di ritorno di TP01; ciò che regge a OGNI ancora è il **taglio del DD**
|
||||||
|
(7-10% vs ~60% B&H). FULL/plateau/deflated-Sharpe/gate INVARIATI (h=0 al 31° pctl su FULL).
|
||||||
|
Regola: i futuri numeri hold-out di strategie a ribilanciamento ancorato si citano con la banda
|
||||||
|
d'ancora. Diario `2026-07-02-timing-crt-wave.md`; script `scripts/research/r0702_tp01_offset.py`
|
||||||
|
+ `r0702_skeptic_offset.py`.
|
||||||
- **XS01 Cross-Sectional Momentum (Hyperliquid) — DIVERSIFICATORE che migliora il portafoglio** —
|
- **XS01 Cross-Sectional Momentum (Hyperliquid) — DIVERSIFICATORE che migliora il portafoglio** —
|
||||||
`src/portfolio/sleeves.py:_xsec_returns`. Market-neutral su **19 alt liquidi major** Hyperliquid (1d,
|
`src/portfolio/sleeves.py:_xsec_returns`. Market-neutral su **19 alt liquidi major** Hyperliquid (1d,
|
||||||
dal 2024): ogni 10g long i 5 più forti / short i 5 più deboli, vol-target 20%. **Scorrelato a TP01
|
dal 2024): ogni 10g long i 5 più forti / short i 5 più deboli, vol-target 20%. **Scorrelato a TP01
|
||||||
@@ -49,16 +57,36 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
|||||||
regime compatto). Standalone FULL Sh **1.50** / HOLD 1.71 / DD 11%, plateau robusto (lookback, gate
|
regime compatto). Standalone FULL Sh **1.50** / HOLD 1.71 / DD 11%, plateau robusto (lookback, gate
|
||||||
p15-35). **Caveat:** storia ~2.5 anni; STAT-MODE (book a 19 gambe non eseguibile a 2k, serve ~20k) →
|
p15-35). **Caveat:** storia ~2.5 anni; STAT-MODE (book a 19 gambe non eseguibile a 2k, serve ~20k) →
|
||||||
monitor forward. NB il gate concentra XS nei regimi dispersi (2025-26 = hold-out alta-dispersione).
|
monitor forward. NB il gate concentra XS nei regimi dispersi (2025-26 = hold-out alta-dispersione).
|
||||||
|
⚠️ **PHASE TIMING-LUCK (2026-07-02):** i numeri headline sono sulla fase 0 del ciclo H=10, che è
|
||||||
|
al **15° pctl di DD** (10.8% vs ~15.5% fase tipica, 29% peggiore) e 85° di FULL fra le 10 fasi
|
||||||
|
(HOLD solo 65°, non estremo); P(spike per caso)≈0.91-0.94. Lens onesta = **ensemble di fase:
|
||||||
|
FULL 1.25 / HOLD 1.31 / DD 10.9%**; a fase mediana FULL 1.08/HOLD 1.10/DD 21%. La decisione di
|
||||||
|
ammissione @15% regge (0 fasi negative, 8/10 FULL≥1.0), i numeri 1.50/1.71/11% no → citarli con
|
||||||
|
banda di fase. Ora-del-giorno NON testabile (solo 1d HL). Script `scripts/research/r0702_anchor_xs01.py`;
|
||||||
|
diario `2026-07-02-anchor-audit-xs01-skh01.md`.
|
||||||
Ricerca `scripts/portfolio/{xsec_research,xsec_blend,xsec_dispgate}.py`. Diari `2026-06-19-hyperliquid-xsec`
|
Ricerca `scripts/portfolio/{xsec_research,xsec_blend,xsec_dispgate}.py`. Diari `2026-06-19-hyperliquid-xsec`
|
||||||
/ `-xsec-blend` / `-xsec-dispgate` / `-xsec-universe-expansion` / `-trend-multiasset`.
|
/ `-xsec-blend` / `-xsec-dispgate` / `-xsec-universe-expansion` / `-trend-multiasset`.
|
||||||
- **PORTAFOGLIO ATTIVO = TP01 (41.25%) + XS01 (18.75%) + VRP01 (15%) + SKH01 (25%)** (`src/portfolio/sleeves.active_sleeves`):
|
- **PORTAFOGLIO ATTIVO = TP01 (33%) + XS01 (15%) + VRP01 (12%) + SKH01 (20%) + GTAA01 (20%)**
|
||||||
TP01+XS01 combinato **FULL Sharpe 1.55, HOLD-OUT 1.55, DD 4.4%**. Aggiunto **VRP01** (options
|
(`src/portfolio/sleeves.active_sleeves`): TP01+XS01 combinato **FULL Sharpe 1.55, HOLD-OUT 1.55,
|
||||||
short-vol, sotto): TP01+VRP01 da solo fa FULL Sh 1.30→1.44 / HOLD 0.31→0.40 a peso 20% (3-way da
|
DD 4.4%**. Aggiunto **VRP01** (options short-vol, sotto): TP01+VRP01 da solo fa FULL Sh 1.30→1.44 /
|
||||||
validare locale con dati HL). **Aggiunto SKH01-V2-DD @25% effettivo (2026-06-23, sotto):** i tre
|
HOLD 0.31→0.40 a peso 20%. **Aggiunto SKH01-V2-DD @25% effettivo (2026-06-23, sotto)**: 4-sleeve
|
||||||
preesistenti scalati nel restante 0.75 (rapporto 55:25:20). Il portafoglio a **4 sleeve** fa
|
**FULL Sharpe 1.68→2.13, HOLD-OUT 1.63→2.30, DD full 14.3%→7.8%** (Skyhook quasi-ortogonale,
|
||||||
**FULL Sharpe 1.68→2.13, HOLD-OUT 1.63→2.30, DD full 14.3%→7.8%** (Skyhook è quasi-ortogonale,
|
corr ~0.09). **Aggiunto GTAA01 @20% effettivo (2026-07-01, i 4 preesistenti scalati ×0.80):**
|
||||||
corr ~0.09). Report `scripts/portfolio/run_portfolio.py`. Sleeve a date d'inizio
|
trend difensivo equity 6-ETF su IB (`src/portfolio/gtaa.py`, ~30 anni storia, validato 2026-06-22
|
||||||
diverse → outer-join con pesi rinormalizzati (TP01/SKH01 dal 2019, VRP dal 2021, XS dal 2024).
|
su OOS equity 2015+ INDIPENDENTE dall'hold-out crypto, corr al book ~+0.10) → 5-sleeve
|
||||||
|
**FULL Sharpe 2.12→2.24, HOLD-OUT 2.21→2.46, DD full 7.8%→6.2%** (costo dichiarato: CAGR full
|
||||||
|
23.3→18.8%; 2022 unico anno con dSh−). Uplift positivo in-sample E su tutte le finestre disgiunte
|
||||||
|
(vs EW-STR refutato lo stesso giorno). Convenzioni: weekend/festivi equity = 0.0 (capitale IB
|
||||||
|
fermo, non riciclato); attivazione nel book all'era crypto 2019-03; **il book live Deribit
|
||||||
|
(`deribit_book_sleeves` TP01+SKH01 75/25) NON lo include** (GTAA in paper_combo dal 2026-06-23).
|
||||||
|
Test `tests/test_gtaa_sleeve.py`; diario `2026-07-01-strategy-wave-6threads.md` (addendum GTAA).
|
||||||
|
Report `scripts/portfolio/run_portfolio.py`. Sleeve a date d'inizio diverse → outer-join con pesi
|
||||||
|
rinormalizzati (TP01/SKH01/GTAA dal 2019*, VRP dal 2021, XS dal 2024; *GTAA troncato all'era book).
|
||||||
|
⚠️ **ANCHOR-LUCK del book (2026-07-02):** l'HOLD 2.46 è calcolato con TUTTI gli sleeve ancorati
|
||||||
|
alla loro ancora canonica, che per TP01/XS01/SKH01 è al top della rispettiva banda (eredità di
|
||||||
|
fortuna ~+0.10/+0.17/+0.5 HOLD). **Stima de-luckata onesta: HOLD ~1.9-2.1, FULL ~2.0-2.2, DD ~6%
|
||||||
|
invariato** — il book resta positivo e diversificato a ogni ancora testata, ma 2.46 è un massimo
|
||||||
|
di configurazioni d'ancora, non la stima centrale. Diario `2026-07-02-anchor-audit-xs01-skh01.md`.
|
||||||
- **SKH01-V2-DD "Skyhook" — DIVERSIFICATORE quasi-ortogonale (research)** — `src/strategies/skyhook.SKH01_V2_DD`,
|
- **SKH01-V2-DD "Skyhook" — DIVERSIFICATORE quasi-ortogonale (research)** — `src/strategies/skyhook.SKH01_V2_DD`,
|
||||||
sleeve `src/portfolio/sleeves._skyhook_returns`. Sistema dual-TF (segnale 690m / exec 230m) regime
|
sleeve `src/portfolio/sleeves._skyhook_returns`. Sistema dual-TF (segnale 690m / exec 230m) regime
|
||||||
(BuzVola/BuzVolume tipo-Chande) AND pattern (Donchian breakout), NON trend-follower, L/S. Vincitrice
|
(BuzVola/BuzVolume tipo-Chande) AND pattern (Donchian breakout), NON trend-follower, L/S. Vincitrice
|
||||||
@@ -68,6 +96,16 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
|||||||
robust_oos multicut 7/7, is_hedge=False); blend 0.75·TP01+0.25·SKH **hold-out 0.31→1.17**. Verificato
|
robust_oos multicut 7/7, is_hedge=False); blend 0.75·TP01+0.25·SKH **hold-out 0.31→1.17**. Verificato
|
||||||
leak-free + 2 scettici. **CAVEAT:** equity daily-step (Sharpe lens), ETH DD margine sottile, book 230m
|
leak-free + 2 scettici. **CAVEAT:** equity daily-step (Sharpe lens), ETH DD margine sottile, book 230m
|
||||||
(costi ribilanciamento da verificare a deploy) → research win, forward-monitor. Diario `2026-06-23-skyhook.md`.
|
(costi ribilanciamento da verificare a deploy) → research win, forward-monitor. Diario `2026-06-23-skyhook.md`.
|
||||||
|
⚠️ **GRID TIMING-LUCK (2026-07-02, più forte di TP01):** i numeri headline sono sull'offset 0 della
|
||||||
|
griglia 230m/690m, al **93-98° pctl dei 23 offset a priori** — minHold +1.26, blend 1.17 e book
|
||||||
|
HOLD 2.44 sono il MASSIMO dei 23 (mediane: minHold +0.39, blend 0.72, book 1.96); spike, non
|
||||||
|
plateau (±30m crolla); P(spike)≈0.70. **Il gate DD<30% (criterio di selezione di V2-DD) fallisce
|
||||||
|
in 15/23 offset** (mediana ETH 29.2%). Regge de-luckato: uplift blend positivo a TUTTE le 23 fasi
|
||||||
|
(min +0.18, med +0.42) + corr 0.05-0.11 → ADDS sopravvive ridimensionato. **LIVE (SKH=25% del book
|
||||||
|
Deribit):** path reale cron orario + exit software → book 50/50 FULL 1.46→1.19 / HOLD 1.64→1.15 /
|
||||||
|
DD 18→25%; nei crash gap-through-stop reale (sl2% modellato → −11/−23% realizzato). Pesi/book
|
||||||
|
INVARIATI (ogni cambio passa weights_tilt_null); follow-up: cadenza 230m, peso live da rivedere.
|
||||||
|
Script `scripts/research/r0702_anchor_skh01.py`; diario `2026-07-02-anchor-audit-xs01-skh01.md`.
|
||||||
- **VRP01 Options Short-Vol — DIVERSIFICATORE da FinanceOld/OptionsAgent** — `src/portfolio/sleeves._vrp_combo_returns`.
|
- **VRP01 Options Short-Vol — DIVERSIFICATORE da FinanceOld/OptionsAgent** — `src/portfolio/sleeves._vrp_combo_returns`.
|
||||||
Put credit spread settimanale (vendi put -0.28, compra put -0.10) gated su IV-rank. Idee portate da
|
Put credit spread settimanale (vendi put -0.28, compra put -0.10) gated su IV-rank. Idee portate da
|
||||||
`../FinanceOld/OptionsAgent` (Bear Call Spread + gate d'ingresso). Migliora il lead VRP nudo
|
`../FinanceOld/OptionsAgent` (Bear Call Spread + gate d'ingresso). Migliora il lead VRP nudo
|
||||||
@@ -77,6 +115,22 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
|||||||
crash incluso). Scorrelato a TP01 (~+0.01-0.07). **CAVEAT:** premio MODELLATO su DVOL ATM (skew non
|
crash incluso). Scorrelato a TP01 (~+0.01-0.07). **CAVEAT:** premio MODELLATO su DVOL ATM (skew non
|
||||||
esplicito), book a 1d, f di stress reale non catturato → LEAD robusto, non deploy pieno. Ricerca
|
esplicito), book a 1d, f di stress reale non catturato → LEAD robusto, non deploy pieno. Ricerca
|
||||||
`scripts/research/options_vrp_v2.py` (vs baseline `options_vrp_lab.py`). Test `tests/test_vrp_sleeve.py`.
|
`scripts/research/options_vrp_v2.py` (vs baseline `options_vrp_lab.py`). Test `tests/test_vrp_sleeve.py`.
|
||||||
|
⚠️ **ANCHOR-AUDIT CHIUSO + ondata "migliora e proteggi" (2026-07-03, 7 filoni + 2 lenti + scettico):
|
||||||
|
VRP01 NON è migliorabile e la protezione DD si compra SOLO con la size.** (a) **Anchor-luck (ciclo
|
||||||
|
settimanale, 7 fasi): PRIMO sleeve SENZA firma di luck** — la fase canonica è la PEGGIORE delle 7 su
|
||||||
|
FULL (1.09 = 7° pctl) e su DD (11.8% = 93° pctl), mediana su HOLD (0.59); spike bootstrap NEGATIVO →
|
||||||
|
i numeri di ammissione FULL 1.10/HOLD 0.60/DD 12% sono CONSERVATIVI, non gonfiati. Da ora si citano
|
||||||
|
con banda: ShFULL [1.09,1.83], ShHOLD [−0.03,1.11], DD [5.7,11.8%]; edge OOS f-dipendente (f=0.8 →
|
||||||
|
HOLD~0). **Con questo l'audit anchor è completo su 4/4 sleeve ancorati.** (b) Griglia 288 strutture:
|
||||||
|
nessuna batte VRP01 (DSR 0.000; metà griglia = 3ª occorrenza "0-perdite/Sharpe implausibile" dopo
|
||||||
|
CC01/ALB-A → gate `implausible_sharpe` alzato di priorità). (c) 4 overlay DD (exit-spike/SL-MTM/
|
||||||
|
ala-coda/cooldown): 4/4 REFUTED dal null de-levering — la protezione crash vive già nel gate
|
||||||
|
d'ingresso IV-rank. (d) Gate nuovi: 4° fallimento su 4 (l'alpha è il binario IV-rank>0.30). (e)
|
||||||
|
Sizing: 12% deploy ≈ 0.27 Kelly onesto (anti-rovina); ⚠️ NON confondere col 12% di PESO del book
|
||||||
|
(~0.014 Kelly, fattore 19x). (f) Gate term-structure VIX/VXV su SPX (ΔSh +0.90, DSR 0.992) =
|
||||||
|
**confound di modello al 100%** (la var del gate coincide con l'errore BS-flat vs term-structure) →
|
||||||
|
nuova regola: riprezzare term-structure-consistent prima di credere a un gate vol su strutture
|
||||||
|
BS-flat. Book/pesi INVARIATI. Diario `2026-07-03-vrp-improve-dd.md`; script `scripts/research/r0703_vrpimp_*.py` (7 file).
|
||||||
Diario `2026-06-20-financeold-analysis-vrp-v2.md`.
|
Diario `2026-06-20-financeold-analysis-vrp-v2.md`.
|
||||||
- **Universo Hyperliquid: ESPANDERLO NON aiuta XS01** (provato): 52-asset / top-liquidità dinamico /
|
- **Universo Hyperliquid: ESPANDERLO NON aiuta XS01** (provato): 52-asset / top-liquidità dinamico /
|
||||||
trend-multi-asset → tutti peggiori (small-cap/memecoin diluiscono il momentum relativo; il trend
|
trend-multi-asset → tutti peggiori (small-cap/memecoin diluiscono il momentum relativo; il trend
|
||||||
@@ -132,6 +186,16 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
|||||||
(hold-out +0.06) è single-window (storia DVOL <5 anni) → sotto la soglia multi-cut. Il gate
|
(hold-out +0.06) è single-window (storia DVOL <5 anni) → sotto la soglia multi-cut. Il gate
|
||||||
DVOL-spike de-risk è **ridondante col trend** (TP01 già flat nei crash, Δ 0.00). **Lezione: per meno
|
DVOL-spike de-risk è **ridondante col trend** (TP01 già flat nei crash, Δ 0.00). **Lezione: per meno
|
||||||
DD sul live la leva è `target_vol`, non un overlay DVOL** (20% resta canonico). Diario `2026-06-26-tp01-dvol-overlay.md`.
|
DD sul live la leva è `target_vol`, non un overlay DVOL** (20% resta canonico). Diario `2026-06-26-tp01-dvol-overlay.md`.
|
||||||
|
- **Filoni 2026-06-29 (1ª ondata) — tutti scartati/forward.** (A) **DVOL-DIREZIONALE** standalone BTC/ETH
|
||||||
|
(buy-the-fear / IV-RV come segnale di livello): l'unico edge è un **HEDGE** (is_hedge=True, paga solo
|
||||||
|
quando TP01 è debole), non alpha → earns_slot=False, forward-monitor come DD-dampener (diario
|
||||||
|
`2026-06-29-dvol-directional.md`). (B) **INTRADAY ERM** (efficiency-ratio regime momentum sub-daily):
|
||||||
|
**falso positivo da selezione-sull'hold-out** → SCARTATO (vedi gate SELECTION-ON-HOLDOUT;
|
||||||
|
`2026-06-29-intraday-regime.md`). (C) **XSEC-V2 NON-MOMENTUM** su HL (reversal/idio-reversal/low-vol/BAB):
|
||||||
|
solo LOWVOL 19-major regge standalone (FULL/HOLD 1.07) ma deflated-Sharpe 0.13 + storia 2.5a → **DEBOLE/
|
||||||
|
forward STAT-MODE** (`2026-06-29-xsec-v2-nonmom.md`). (D) **MACRO regime-gate** (equity/credito/oro/tassi →
|
||||||
|
de-risk crypto): **RIDONDANTE col trend** (corr→TP01 0.989; il gate lavora solo nel 2-3% dei giorni, TP01
|
||||||
|
già flat nei crash) → SCARTATO (`2026-06-29-macro-regime-gate.md`).
|
||||||
- **Sweep strategie a 5 thread (2026-06-29) — 0 nuovi sleeve, 1 LEAD che rompe 2 muri su 3.** Ricerca
|
- **Sweep strategie a 5 thread (2026-06-29) — 0 nuovi sleeve, 1 LEAD che rompe 2 muri su 3.** Ricerca
|
||||||
parallela onesta su aree inesplorate (harness `altlib`+`xsec_v2_nonmom`, tutti i gate incl. il nuovo
|
parallela onesta su aree inesplorate (harness `altlib`+`xsec_v2_nonmom`, tutti i gate incl. il nuovo
|
||||||
`study_family_honest`): (1) low-risk cross-sectional, (2) momentum-structure vs XS01, (3) meta-allocazione
|
`study_family_honest`): (1) low-risk cross-sectional, (2) momentum-structure vs XS01, (3) meta-allocazione
|
||||||
@@ -149,6 +213,100 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
|||||||
l'edge è *deployabile* (2 gambe BTC/ETH perp). Altri LEAD: IVOL (idio-vol XS, STAT-MODE), DVOLSPREAD
|
l'edge è *deployabile* (2 gambe BTC/ETH perp). Altri LEAD: IVOL (idio-vol XS, STAT-MODE), DVOLSPREAD
|
||||||
(storia DVOL corta). Diario `2026-06-29-strategy-search-5threads.md`. Script
|
(storia DVOL corta). Diario `2026-06-29-strategy-search-5threads.md`. Script
|
||||||
`scripts/research/{xsec_v3_lowrisk,xsec_v3_momstruct,meta_allocation,orthogonal_signals,signal_inout_1leg}.py`.
|
`scripts/research/{xsec_v3_lowrisk,xsec_v3_momstruct,meta_allocation,orthogonal_signals,signal_inout_1leg}.py`.
|
||||||
|
- **Ondata 2026-07-01 (6 filoni multi-agente + scettico) — 0 edge nuovi dai filoni, 1 gate nuovo,
|
||||||
|
e 1 sleeve promosso DALL'ARCHIVIO (GTAA01, sotto nel bullet portafoglio).** Filoni su angoli non coperti dalle ondate precedenti: (1) **funding time-series**
|
||||||
|
BTC/ETH (posizionamento) = SCARTATO — FOLLOW è trend-beta ritardato, FADE shorta il toro, il gate è
|
||||||
|
TP01 travestito (DSR 0.215); **il filone funding è chiuso su 3 lati** (FC01 carry, price-clock, TS).
|
||||||
|
(2) **breadth/internals alt** (51 HL) = SCARTATO ma unico NON-ridondante col trend (corr→TP01 0.40);
|
||||||
|
muore su jackknife (uplift su 1 mese) + DSR 0.433 con ~8 mesi IS → **rivisitabile tra 1-2 anni di
|
||||||
|
storia HL nativa**. (3) **residual momentum XS** (β-hedged, 19 major) = REDUNDANT — cross-section la
|
||||||
|
residualizzazione è un no-op (lo z-score di XS01 rimuove già il mercato); l'edge resta solo nella
|
||||||
|
coppia ETH/BTC (STATARB-RESID). (4) **ri-ottimizzazione pesi + guardia-DD**: il candidato EW-STR
|
||||||
|
(TP30/XS25/VRP15/SKH30, HOLD 2.21→2.35) **refutato dallo scettico come selezione-sull'hold-out di 2°
|
||||||
|
ORDINE** — SKH01/XS01 furono ammessi/affinati *perché* forti su quell'hold-out; pre-2025 ΔSh −0.05,
|
||||||
|
finestre disgiunte −0.12/+0.06/+0.14, percentile 94-100° fra 500 tilt casuali ≈ firma best-of-15.
|
||||||
|
Guardia-DD 5%/0.5: inerte OOS (la diversificazione fa già il lavoro; solo circuit-breaker d'emergenza).
|
||||||
|
(5) **affinamento VRP01** = NON MIGLIORA (l'alpha è tutto nel gate binario IV-rank; gate TP01 =
|
||||||
|
trappola in-sample; **3° fallimento → filone "VRP dentro il modello" esaurito** fino a f di stress
|
||||||
|
reale). (6) **stagionalità cross-sectional HL** = morta allo step statistico (null permutato).
|
||||||
|
**GATE nuovo codificato: `weights_tilt_null`** in `src/portfolio/portfolio.py` (+ `combine_outer`
|
||||||
|
riusabile): ogni proposta di CAMBIO PESI si giudica vs il null dei tilt casuali cap-respecting —
|
||||||
|
gate_pass solo se delta_insample≥0 E percentile < firma best-of-k (necessario, non sufficiente);
|
||||||
|
test `tests/test_weights_tilt_null.py`. ⚠️ Lezione tecnica: `DatetimeIndex.view("int64")` su indici
|
||||||
|
tz-aware non-ns (pandas 2.x) → scala sbagliata → `merge_asof` broadcasta = **look-ahead che
|
||||||
|
`causality_ok` non vede**; usare epoca esplicita in ms (altlib verificato pulito). Diario di sintesi
|
||||||
|
`2026-07-01-strategy-wave-6threads.md` + 6 diari di filone; script `scripts/research/r0701_*.py`.
|
||||||
|
- **Ondata 2026-07-02 (TIMING + CRT, 8 filoni multi-agente + scettico) — 0 nuovi sleeve, 1 finding
|
||||||
|
strutturale (anchor timing-luck di TP01, vedi ⚠️ nel bullet TP01).** Goal: "strategie con timing
|
||||||
|
differenti". (1) **Event-clock bars** (volume/vol/range da 5m, TSMOM/Donchian/EWMA in tempo-informazione):
|
||||||
|
batte il wall-clock a pari segnale/frequenza solo in 4/45 coppie; cella best IS 1.45 → HOLD −0.46,
|
||||||
|
NEUTRAL (corr 0.74 = trend travestito) → SCARTATO: il clock non è dove vive l'edge. (2) **Calendario
|
||||||
|
scadenze Deribit** (expiry weekly/monthly/quarterly ven 08:00 UTC): 0/24 celle a Bonferroni; il drift
|
||||||
|
post-expiry monthly fallisce placebo-weekday e permutation e si INVERTE sul quarterly (dove l'OI massimo
|
||||||
|
dovrebbe amplificarlo); unico pattern robusto = gio→ven negativo, ma è day-of-week (SEA morta) a Sharpe
|
||||||
|
netto ~0 → SCARTATO. (3) **Anchor timing-luck TP01 + tranching**: finding confermato (dettagli nel
|
||||||
|
bullet TP01); tranching K=2/4 = sola riduzione della varianza della STIMA (ΔSharpe n.s., ΔDD ~0.5pt),
|
||||||
|
NO deploy a $600 (il min-order lo degenera in K=1; serve feed intraday fuori path certificato) —
|
||||||
|
rivalutare a ≥5-10k. **Audit d'ancora ESEGUITO su XS01 e SKH01 (stesso giorno): il finding si
|
||||||
|
replica su 3/3 sleeve ancorati** — vedi ⚠️ nei rispettivi bullet e la stima de-luckata del book
|
||||||
|
nel bullet portafoglio; diario `2026-07-02-anchor-audit-xs01-skh01.md`.
|
||||||
|
(4) **Clock lenti (2-7g) + bande isteresi**: fee drag di TP01 = ~0.4%/anno = tetto di ogni risparmio;
|
||||||
|
il lag costa più del risparmio (HOLD ensemble 0.34→0.11 da N=2 a 7); a $600 **il min-order $5 è GIÀ la
|
||||||
|
banda ottimale** (ordini −74% a costo ~0) → nessun cambio al book. (5) **Velocità trend
|
||||||
|
regime-condizionata** (pesi tra orizzonti 30/90/180g vs percentile vol RV/DVOL): pctl 0.71 vs null
|
||||||
|
pesi-statici-casuali = tilt-30d statico travestito (trappola EW-STR); pesi canonici 1/3 confermati →
|
||||||
|
SCARTATO. (6-8) **CRT "Candle Range Theory"** (sweep-and-reclaim 3 candele, mai coperto da MRV/MIC):
|
||||||
|
base 864 trial DSR 0.000 + anchor-flip + short "smart-money" negativo perfino in-sample; multi-TF
|
||||||
|
(4h→15m, 1h→5m, ~10k trade) expectancy negativa ovunque anche a fee zero, e **il ritest è informazione
|
||||||
|
negativa** (pattern con-ritest −40bps vs senza +52bps: aspettarlo seleziona i peggiori); contesto
|
||||||
|
(FVG/equal-highs/sessioni, 22 trial) non salva il fade, cella "Asia" = artefatto anchor-flip →
|
||||||
|
SCARTATO 3/3. Sottoprodotto: sugli stessi livelli prior-day **FOLLOW > FADE ogni anno 2019-26**
|
||||||
|
(conferma indipendente del lead prevday in forward-monitor). Lezione: il timing-luck d'ancora è
|
||||||
|
multiple-testing che il deflated-Sharpe NON conta (candidato gate futuro `anchor_luck_band`).
|
||||||
|
Diario `2026-07-02-timing-crt-wave.md`; script `scripts/research/r0702_*.py` (9 file).
|
||||||
|
- **Ondata 2026-07-02-bis ("video claims": Elliott 3 filoni + Albimarini 2 + capital scaling) — 0 nuovi
|
||||||
|
sleeve, 0 forward-monitor, 1 azione config pendente sul deposito.** Meccanizzazione onesta di claim da
|
||||||
|
video didattici: (1) **Elliott range-cycle** (onda1 compressa→onda3 ampia): rumore, 0/24 celle a
|
||||||
|
Bonferroni, nessuna cella weekly regge a tutte le 7 ancore → SCARTATO. (2) **Confluenza Fibonacci**:
|
||||||
|
vs null ingenui sembra buona (pctl 0.82-1.00), vs null **location-matched** (Fib±jitter: "0.618 vs
|
||||||
|
0.58?") crolla a 0.39-0.68 = l'apparente edge è la POSIZIONE dei livelli, non i numeri; confluenza
|
||||||
|
FAIL 4/4 → SCARTATO (il null location-matched è IL test per ogni claim su livelli "speciali").
|
||||||
|
(3) **Tecnica del canale Elliott**: Donchian travestito — non batte il Donchian a pari geometria
|
||||||
|
(corr 0.43-0.53), DSR 0.685, cella in-sample collassa in hold-out (1.40→−0.87), target 1.618 = caso
|
||||||
|
(5/6 celle), anchor-luck di nuovo (4h banda hold [0.35,1.54], 00:00 la migliore) → SCARTATO.
|
||||||
|
(4) **Albimarini double-diagonal** (short T + long T+1, deep OTM, via motore DVOL di VRP01): il
|
||||||
|
condor stessa-scadenza la batte a ogni f (la long T+1 = assicurazione di coda +12/33bps nel tail,
|
||||||
|
~1bps costo medio, non dominanza); senza gate IV-rank TUTTE le strutture perdono (3ª conferma:
|
||||||
|
l'alpha del VRP è il gate); su Deribit fee-negativa a QUALSIASI size (fee 8 gambe = 194-221% del
|
||||||
|
theta); celle deep-OTM 0-perdite/142 trade = 2° caso "Sharpe implausibile" dopo CC01 → gate
|
||||||
|
`implausible_sharpe` raccomandato con più forza. VRP01 resta superiore su tutta la banda skew →
|
||||||
|
nessun LEAD. (5) **Audit claims** (28 trade, 82% win, PF 5.16, "420%/anno"): consistente con ZERO
|
||||||
|
skill (P=20-45%; il 78.6% delle finestre 6-mesi 1996-2026 lo produce); replay con code reali =
|
||||||
|
rovina 1998/2002/2020 col sizing dichiarato; la diagonale lascia passare il 12-40% della perdita
|
||||||
|
naked. (6) **Capital scaling 600→2-5k** (`r0702_capital_scaling.py`): l'unico vincolo binding è
|
||||||
|
`max_notional_per_asset_usd=300` (a 5k il book live girerebbe al 49% del target) → **al deposito
|
||||||
|
alzare il cap a equity/2** ($1000/$1750/$2500 a 2k/3.5k/5k); min_order $5 da LASCIARE; tranching
|
||||||
|
K=2 non cablare (blocco feed intraday); opzioni ETH eseguibili da ~2.6k ma la regola no-short-vol-
|
||||||
|
da-modello non decade col capitale; XS01 ~20k confermata, CC01 fuori per struttura. Aspettativa
|
||||||
|
onesta col CAGR de-luckato (10-15%): 2k ≈ €0.6-0.8/g, 5k ≈ €1.4-2/g (€50/g resta ≈130k).
|
||||||
|
⚠️ Lezione pandas: `resample("7D", origin=...)` IGNORA origin (pandas 2.x, solo RuntimeWarning) →
|
||||||
|
bande d'ancora weekly finte; usare `"168h"`. Diario `2026-07-02-elliott-albimarini-capital.md`;
|
||||||
|
script `scripts/research/r0702_{ell_*,alb_*,capital_scaling}.py` (6 file).
|
||||||
|
- **Video-claim "CRT top-down multi-TF, 74% win rate" — SCARTATO (2026-07-07): il 74% è un KNOB, non
|
||||||
|
un edge.** `scripts/research/r0707_crt_topdown.py`. Metodo ICT/SMC top-down (H1 setup CRT → M15
|
||||||
|
struttura/FVG → M5 conferma → M1 entry) con uscita parziale-70/80%-a-1.5R + break-even + runner verso
|
||||||
|
prev-daily high/low. Il setup H1 CRT è **già triplo-refutato** (onda 2026-07-02: base DSR 0.000, MTF
|
||||||
|
expectancy neg. ovunque "il ritest è informazione negativa", contesto/FVG peggiora, fade<follow) →
|
||||||
|
qui testato l'UNICO angolo nuovo: la **gestione d'uscita** e il **claim 74%**. Su BTC/ETH certificati
|
||||||
|
(H1→5m/15m; M1 non nel feed): **WR reale 30–37%** a RR 1.5–2 (SOTTO il null gambler's-ruin 40% =
|
||||||
|
P(+1.5R prima di −1R)=1/(1+1.5) → il ritest tocca il target MENO di una moneta), **expectancy R
|
||||||
|
negativa a ogni schema/fee/finestra/asset** (−1.2…−3.3R netto). **Il 74% si fabbrica avvicinando il
|
||||||
|
target**: sweep rr1 0.5→2.0 mostra WR salire (51→32%) con expectancy R INVARIANTE (WR alto = target
|
||||||
|
vicino, non direzione). DSR 0.000 (48 trial); a **fee 0** expR −0.10/Sh −0.63 → **non è morte-per-fee,
|
||||||
|
l'edge lordo non c'è** (residuo = beta di trend dei time-exit); parziale+BE+runner = 3-4 ordini/trade,
|
||||||
|
alcuni sub-min-order a $600. **Regola candidata:** il win-rate di uno schema parziale+BE non è merito
|
||||||
|
(≈1/(1+rr1)); convertire ogni claim "WR X%" in **expectancy R netto fee** prima di crederci (helper
|
||||||
|
`winrate_is_a_knob()`). Diario `2026-07-07-crt-topdown-74winrate.md`. Book/pesi INVARIATI.
|
||||||
- **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.**
|
||||||
@@ -234,7 +392,7 @@ src/strategies/indicators.py → indicatori condivisi (ema, atr, keltner, ...)
|
|||||||
src/strategies/trend_portfolio.py → TP01: strategia DIFENSIVA robusta (PORT LF1d, >=12h), causale
|
src/strategies/trend_portfolio.py → TP01: strategia DIFENSIVA robusta (PORT LF1d, >=12h), causale
|
||||||
src/portfolio/ → PORTAFOGLIO DI STRATEGIE estensibile (Sleeve + StrategyPortfolio)
|
src/portfolio/ → PORTAFOGLIO DI STRATEGIE estensibile (Sleeve + StrategyPortfolio)
|
||||||
portfolio.py → combina N sleeve per peso su griglia giornaliera; metriche FULL/hold-out/anno
|
portfolio.py → combina N sleeve per peso su griglia giornaliera; metriche FULL/hold-out/anno
|
||||||
sleeves.py → REGISTRY sleeve attivi: TP01 (55%) + XS01 (25%) + VRP01 (20%). Aggiungere = una riga
|
sleeves.py → REGISTRY sleeve attivi: TP01 33 / XS01 15 / VRP01 12 / SKH01 20 / GTAA01 20. Aggiungere = una riga
|
||||||
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
|
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
|
||||||
src/backtest/engine.py → engine di backtesting riusabile
|
src/backtest/engine.py → engine di backtesting riusabile
|
||||||
src/backtest/harness.py → harness ONESTO (load BTC/ETH, backtest_signals no-leakage, OOS)
|
src/backtest/harness.py → harness ONESTO (load BTC/ETH, backtest_signals no-leakage, OOS)
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"_nota": "Config esecuzione LIVE del BOOK DERIBIT (TP01+SKH01 nettati in software). execution_enabled=true + --execute -> ordini REALI. ARMATO 2026-06-23: esecutore scripts/live/book_execute.py via cron ORARIO scripts/cron_book.sh (SKH01 e' a 230m). cap/asset $300 split 75/25, disaster-SL on-book -30% sulla posizione netta. Tutto flat all'arming -> nessun ordine finche' un segnale non arma.",
|
"_nota": "Config esecuzione LIVE del BOOK DERIBIT (TP01+SKH01 nettati in software). execution_enabled=true + --execute -> ordini REALI. ARMATO 2026-06-23: esecutore scripts/live/book_execute.py via cron ORARIO scripts/cron_book.sh (SKH01 e' a 230m). disaster-SL on-book -30% sulla posizione netta. Tutto flat all'arming -> nessun ordine finche' un segnale non arma.",
|
||||||
|
"_nota_cap": "Cap notional per-asset DINAMICO (frontiera 2026-07-03): con max_notional_per_asset_frac=0.5 il cap = equity/2, cosi' cresce col capitale e un deposito non resta strozzato. A ~$600 equity/2=~$300 -> INERTE (identico al vecchio cap fisso). Il cap dinamico si usa solo con equity reale fidata; su fallback/offline si ripiega su max_notional_per_asset_usd (protezione downside). Per tornare al cap fisso: rimuovere max_notional_per_asset_frac.",
|
||||||
"execution_enabled": true,
|
"execution_enabled": true,
|
||||||
"max_notional_per_asset_usd": 300,
|
"max_notional_per_asset_usd": 300,
|
||||||
|
"max_notional_per_asset_frac": 0.5,
|
||||||
"min_order_usd": 5,
|
"min_order_usd": 5,
|
||||||
"disaster_sl_pct": 0.30
|
"disaster_sl_pct": 0.30
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 2026-07-01 — Breadth / market-internals del mercato ALT come segnale su BTC/ETH — SCARTATO
|
||||||
|
|
||||||
|
**Tesi.** Gli "internals" del mercato crypto — la partecipazione degli alt — come segnale
|
||||||
|
direzionale o gate di de-risk su BTC/ETH perp (2 gambe, eseguibile a ~$600): % di alt sopra la
|
||||||
|
propria SMA(N), advance/decline, % di alt che battono BTC (risk-appetite relativo), breadth-thrust.
|
||||||
|
Script `scripts/research/r0701_breadth_internals.py` (non committato per policy del filone; harness
|
||||||
|
`altlib`). Dati: i 51 parquet certificati `data/raw/hl_*_1d.parquet` → panel di **49 alt**
|
||||||
|
(esclusi hl_btc/hl_eth dalla breadth; hl_btc solo come riferimento per la famiglia RS), barre a
|
||||||
|
volume 0 mascherate (lezione backfill 2026-06-20; il fetch le aveva già ripulite: vol0=0 su tutti
|
||||||
|
i file, gate comunque attivo).
|
||||||
|
|
||||||
|
**Limite strutturale dichiarato in partenza:** l'universo HL parte dal 2024-01 → dopo il warmup
|
||||||
|
(max 120g) la finestra comune è **2024-05-05 → 2026-07-01 (~2.2 anni)** e l'in-sample pre-HOLDOUT
|
||||||
|
è **~8 mesi del toro 2024**. Qualunque esito poteva essere al massimo un lead.
|
||||||
|
|
||||||
|
## Famiglie e griglia (108 celle, tutte contate nel deflated Sharpe)
|
||||||
|
|
||||||
|
4 famiglie × N∈{20,50,100} × soglia∈{0.3,0.5,0.7} × forma∈{LS, long-flat, gate-su-TP01}:
|
||||||
|
|
||||||
|
- **FAM-MA**: % di alt validi con close > SMA(N) (breadth classica).
|
||||||
|
- **FAM-AD**: frazione di advancers (ret 1g>0), SMA(N).
|
||||||
|
- **FAM-RS**: % di alt che battono BTC sul ritorno a N giorni (≈ market-neutral, l'angolo più ortogonale).
|
||||||
|
- **FAM-TH**: breadth-thrust = Δ a N giorni della breadth MA20 (thrust/collapse), score 0.5+Δ.
|
||||||
|
|
||||||
|
Tutte causali (rolling/shift soltanto), MIN_VALID=20 asset per data (osservato: 42-49). LS/LF
|
||||||
|
vol-target 20% cap 2x (sizing TP01-style); GATE = posizione TP01 × gate binario. Fee 0.10% RT +
|
||||||
|
sweep 0-0.30%. Procedura onesta = mirror di `study_family_honest` sulla finestra comune (il
|
||||||
|
padding pre-2024 avrebbe contaminato il ranking full-history: le celle GATE con gate=1 pre-storia
|
||||||
|
erediterebbero lo Sharpe 2019-2024 di TP01): **cella scelta col SOLO Sharpe in-sample**, DSR su
|
||||||
|
tutti i trial, `marginal_vs_tp01` di altlib sulla cella scelta.
|
||||||
|
|
||||||
|
## ⚠️ Bug trovato e corretto: allineamento merge_asof su indice non-ns
|
||||||
|
|
||||||
|
La prima versione di `_align` usava `b.index.view("int64")//10**6` per i timestamp della breadth:
|
||||||
|
su un DatetimeIndex tz-aware **a risoluzione non-ns** (pandas 2.x) la scala esce sbagliata →
|
||||||
|
`merge_asof backward` matchava OGNI barra BTC/ETH all'**ultimo** valore della breadth = **il futuro
|
||||||
|
broadcast su tutta la storia**. Sintomo: decine di celle con lo stesso identico Sharpe (il segnale
|
||||||
|
era una costante). **`causality_ok` NON lo vede** — la serie breadth è un input esterno *fisso*,
|
||||||
|
quindi il target sul prefisso troncato coincide col target full per costruzione. Fix: epoca
|
||||||
|
esplicita `(idx - Timestamp(1970, tz=UTC)) // Timedelta(1ms)` (come `resample_tf`). **Lezione
|
||||||
|
harness:** ogni allineamento di un panel esterno via merge_asof va fatto con epoch-ms esplicita, e
|
||||||
|
il sintomo "molte celle con Sharpe identico" è un red-flag di allineamento rotto (gemella
|
||||||
|
dell'osservazione merge_asof del 2026-06-20).
|
||||||
|
|
||||||
|
## Esito (procedura onesta, finestra comune 2024-05+)
|
||||||
|
|
||||||
|
104/108 celle valutabili (4 degeneri flat), 47 con FULL>0. **Cella scelta in-sample** (IS 2.11,
|
||||||
|
la selezione non tocca mai l'hold-out): **TH N=100 thr=0.7 long-flat** — long solo dopo un
|
||||||
|
breadth-thrust (+0.2 di breadth MA20 in 100g), attiva il 32% dei giorni.
|
||||||
|
|
||||||
|
| metrica | valore |
|
||||||
|
|---|---|
|
||||||
|
| standalone finestra comune | IS **+2.11** → FULL **+0.99**, HOLD **+0.41** |
|
||||||
|
| assoluto (per-asset, trimmed) | **PASS** — minFull +0.68, minHold +0.38, fee-survive 0.30%RT, DD 7-11% |
|
||||||
|
| marginale vs TP01 | **ADDS** — corr full **0.401**/hold 0.358, resid Sharpe 0.87, multicut {2025:+0.076, 2026:+0.366} persistente, is_hedge=False |
|
||||||
|
| **robust_oos** | **False** — jackknife drop-best-month **−0.068** (l'uplift hold-out sta in piedi su UN mese) |
|
||||||
|
| **deflated Sharpe** | **0.433** su 104 trial (null-max atteso 1.10) — lontanissimo dal PASS ≥0.95 |
|
||||||
|
| earns_slot / earns_slot_honest | **False / False** |
|
||||||
|
| causality_ok | True (post-fix) |
|
||||||
|
| smallcap $600 | haircut ≈ **0.00** (BTC −0.01, ETH +0.00; 137-152 trade) — eseguibile |
|
||||||
|
|
||||||
|
**Ridondanza col trend (il rischio n.1, lezione macro-gate):** qui NON è la modalità di morte.
|
||||||
|
corr(pos, TP01pos) 0.15-0.30 e il segnale "lavora" (off mentre TP01 non è flat) nel **24-32% dei
|
||||||
|
giorni** (il macro-gate lavorava nel 2-3%). Le forme direzionali LS/LF sono genuinamente diverse
|
||||||
|
da TP01 (corr→TP01 0.1-0.4). La forma **gate-su-TP01** invece sì: best gate = NEUTRAL a corr
|
||||||
|
0.803, e i gate a soglia bassa = REDUNDANT corr 1.0 (gate mai off → TP01 identico). **La breadth
|
||||||
|
come gate di de-risk non aggiunge nulla; come segnale direzionale è diversa dal trend ma l'edge
|
||||||
|
non è dimostrabile.**
|
||||||
|
|
||||||
|
**Il gemello in hindsight (trasparenza, NON selezionabile).** TH N=100 thr=0.7 **LS** fa FULL 1.13
|
||||||
|
/ HOLD 1.06, corr→TP01 **−0.26**, marginal ADDS con robust_oos=True e jackknife +0.46. MA il suo
|
||||||
|
in-sample (1.30) è sotto quello della cella lf (2.11): sceglierlo = **selezione-sull'hold-out**
|
||||||
|
(esattamente il falso positivo ERM del 2026-06-29). E comunque **DSR 0.518 < 0.95**: anche la
|
||||||
|
lettura più caritatevole muore sulla deflazione multiple-testing.
|
||||||
|
|
||||||
|
## Verdetto: SCARTATO (niente sleeve, niente forward-monitor cablato)
|
||||||
|
|
||||||
|
1. La cella scelta onestamente è ADDS ma **robust_oos=False** (uplift a un mese) e **DSR 0.433**
|
||||||
|
su 104 trial: su ~8 mesi di in-sample (toro 2024) un IS di 2.1 è indistinguibile dal massimo
|
||||||
|
atteso per caso (1.10) e non regge.
|
||||||
|
2. Il candidato migliore col senno di poi (TH-LS) sarebbe selezione-sull'hold-out e fallisce
|
||||||
|
comunque il DSR (0.518) — il gate `study_family_honest` fa esattamente il suo lavoro.
|
||||||
|
3. Il limite è la **storia**: 2.2 anni totali, 8 mesi di in-sample. Non c'è modo onesto di
|
||||||
|
dimostrare l'edge oggi.
|
||||||
|
|
||||||
|
**Cosa resta.** (a) La famiglia **breadth-thrust (TH)** è l'unico angolo con celle OOS-positive e
|
||||||
|
bassa/negativa corr a TP01 — **rivisitabile quando il panel HL avrà ≥1-2 anni di storia nativa in
|
||||||
|
più** (in-sample multi-regime, DSR ricalcolabile); nessun cablaggio forward oggi, il costo
|
||||||
|
dell'attesa è zero. (b) Il breadth-**gate** su TP01 va in bacino con macro-gate e DVOL-gate: terza
|
||||||
|
conferma che TP01 non ha bisogno di de-risk esterno (long-flat, già flat nei crash). (c) Il fix
|
||||||
|
`_align` (epoch esplicita) è la lezione riusabile per ogni futuro panel esterno.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 2026-07-01 — Book live: verifica gate SKH01 + emersione errori silenziosi
|
||||||
|
|
||||||
|
## Contesto
|
||||||
|
Il BOOK DERIBIT armato (TP01 0.75 + SKH01 0.25 nettati) è **flat da quando è stato armato
|
||||||
|
(2026-06-23)**: 193 run orari, tutte HOLD, zero ordini reali. Domanda: il flat è legittimo o il
|
||||||
|
gate di SKH01 è muto per un bug?
|
||||||
|
|
||||||
|
## Verifica del gate SKH01 — SANO
|
||||||
|
- **Logica viva:** `skyhook_entries` produce 335 entry (BTC, 222 long/113 short) e 341 (ETH,
|
||||||
|
226/115) su tutta la storia. Non è degenere.
|
||||||
|
- **Shorta davvero i crash:** ha aperto SHORT sulla gamba di crollo di fine maggio/inizio giugno —
|
||||||
|
BTC 05-28→06-04 ($73k→$64k), ETH 06-02→06-06 ($1865→$1555).
|
||||||
|
- **Flat da 06-23 = legittimo:** ultimo entry 06-06, `max_bars=16 × 230m ≈ 2,5g` → chiuso ~06-08/09,
|
||||||
|
**prima** dell'arming (06-23). Dal grind post-crash nessun setup breakout+regime valido → flat
|
||||||
|
corretto per design ("NON trend-follower"). Contesto prezzo al 01-07: BTC −53% dal picco, ETH
|
||||||
|
−67,5%, momentum negativo su 1/3/6/12 mesi → anche TP01 (long-flat TSMOM) è a 0x.
|
||||||
|
- **Entrambi i feed valutano senza crash:** certificato e live `fresh_5m` → `skh_error=None`.
|
||||||
|
|
||||||
|
## Bug scoperto e corretto — pattern "eccezione ingoiata → stato safe silenzioso"
|
||||||
|
`src/live/` aveva più `try/except` che, in errore, producono uno stato safe (flat/fallback) **senza
|
||||||
|
farlo emergere**. Rischio: quando l'errore è transitorio ma reale, l'esecutore opera su dati sbagliati
|
||||||
|
credendoli legittimi.
|
||||||
|
|
||||||
|
### (1) `skh_error` — CORRETTO
|
||||||
|
`book_report` scriveva `skh_error` in un dict locale **mai incluso nel return** → `r.get("skh_error")`
|
||||||
|
sempre `None`; e `book_execute` non lo leggeva comunque. Un feed SKH rotto avrebbe forzato SKH→flat in
|
||||||
|
silenzio (entry mancato, nessun alert). Fix: `skh_error` esposto nel return + `book_execute` emette
|
||||||
|
log `⚠️ SKH FEED ERRORE` + alert Telegram. (commit 31369b3)
|
||||||
|
|
||||||
|
### (2) Gate fail-safe posizione (Opzione A) — CORRETTO
|
||||||
|
`src/live/shadow.py::_positions`: se la read della posizione reale Deribit lancia, assume flat (0.0)
|
||||||
|
con una `note`, MAI propagata (`pos_src`) a `book_report`/`book_execute`. Severità mitigata a valle
|
||||||
|
da `rebalance_signed` che **rilegge la posizione fresca** (no doppia-posizione), ma resta il caso
|
||||||
|
"target≈0 + posizione aperta + read fallita → close saltato quel run" (transitorio, backstoppato dal
|
||||||
|
disaster-SL −30%). **Scelta A = gate fail-safe:** `_positions` ritorna un `pos_error` esplicito →
|
||||||
|
propagato `shadow_report → book_report → book_execute`; se presente (ONLINE ma posizione IGNOTA)
|
||||||
|
l'esecutore **NON opera a cieco** (return + alert), come già fa per `online`.
|
||||||
|
|
||||||
|
### (3) Diagnostica equity (Opzione B) — CORRETTO
|
||||||
|
`src/live/shadow.py::_equity`: se `account_summary('USDC')` e tutte le per-asset falliscono,
|
||||||
|
`real_eq=None` e `shadow_report` ripiega su `paper_cap` (~$2000 dallo state paper) mentre il conto
|
||||||
|
reale è ~$598 → il book dimensionerebbe su $2000 invece di $598 (**sovradimensiona ~33%**). Il danno
|
||||||
|
è limitato dall'**hard-cap $300/asset** in `book_net_target`. **Scelta B = SOLO DIAGNOSTICA** (niente
|
||||||
|
gate/halt, l'hard-cap già protegge il downside): `shadow_report` calcola `eq_fallback` (ONLINE +
|
||||||
|
equity reale non leggibile + no override), propagato `→ book_report → book_execute`, che stampa
|
||||||
|
`⚠️ EQUITY FALLBACK` + alert Telegram **ma prosegue** (a differenza del gate posizione, che blocca).
|
||||||
|
|
||||||
|
## Test
|
||||||
|
Suite completa **156/156** (era 148; +4 gate A + 4 diagnostica B). Dry-run reale di `book_execute`
|
||||||
|
invariato (nessuna riga d'errore, nessun ordine, conto $598.06 al target). Path live: `pos_error=None`,
|
||||||
|
`skh_error=None`, `eq_fallback=None`.
|
||||||
|
|
||||||
|
## Sintesi finale — i 3 pattern "errore silenzioso" del path live sono ora tutti tracciati
|
||||||
|
| # | Punto | Comportamento | Emersione |
|
||||||
|
|---|-------|---------------|-----------|
|
||||||
|
| 1 | `book_report` feed SKH | SKH → flat | log `⚠️ SKH FEED ERRORE` + alert |
|
||||||
|
| 2 | `_positions` read fallita | posizione → flat | **HALT** (non esegue) + alert (gate A) |
|
||||||
|
| 3 | `_equity` non leggibile | sizing → paper_cap | log `⚠️ EQUITY FALLBACK` + alert (diagnostica B) |
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# 2026-07-01 — Funding rate come segnale TIME-SERIES direzionale su BTC/ETH (R0701): SCARTATO
|
||||||
|
|
||||||
|
## Contesto
|
||||||
|
|
||||||
|
Filone: il **funding orario Hyperliquid come proxy di POSIZIONAMENTO/sentiment** con valenza
|
||||||
|
direzionale a orizzonte giornaliero su BTC/ETH — NON carry. Angolo diverso dal prior art già
|
||||||
|
chiuso: **FC01** (carry cross-sectional delta-neutral 19 alt → LEAD fragile SCARTATO,
|
||||||
|
`2026-06-22-funding-carry-hl.md`) e **funding price-clock** intraday (drift attorno agli stamp →
|
||||||
|
FAIL, onda intraday). Qui l'ipotesi è: funding estremo = affollamento long → fade; funding in
|
||||||
|
espansione = domanda persistente → follow; funding affollato = de-risk del trend (gate).
|
||||||
|
Attrattiva a priori: 2 gambe BTC/ETH perp = **eseguibile al capitale reale ~$600** (non STAT-MODE
|
||||||
|
come XS01/opzioni) — se avesse retto sarebbe stato deployabile.
|
||||||
|
|
||||||
|
Script: `scripts/research/r0701_funding_ts.py`.
|
||||||
|
|
||||||
|
## Dati e causalità (data-first)
|
||||||
|
|
||||||
|
- `data/raw/hlfund_{btc,eth}_1h.parquet`: **2023-05-12 → 2026-06-22, 1138/1138 giorni validi,
|
||||||
|
0 gap** dopo il 2023-06 (primi ~27 giorni a cadenza 8h — la somma giornaliera degli stamp li
|
||||||
|
gestisce indifferentemente). Funding medio **+14.4%/anno BTC, +14.6% ETH**; std del funding
|
||||||
|
giornaliero ~5.5 bps.
|
||||||
|
- **Join causale** (il punto delicato): le barre 1d di `altlib.get` sono **open-labeled**
|
||||||
|
(datetime = 00:00 UTC del giorno D; il close della barra D è noto alle 00:00 di D+1). Il
|
||||||
|
feature-day D aggrega i SOLI stamp in **[D 00:00, D+24h)** — l'ultimo alle 23:00 — quindi tutto
|
||||||
|
è noto PRIMA della decisione al close della barra D; `eval_weights` shifta la posizione al
|
||||||
|
giorno D+1. Prefix-consistency check esplicito: **max diff 0.0** (nessun leak).
|
||||||
|
- **Valutazione su finestra TRONCATA** alla copertura funding, non sul frame prezzi 2018+: fuori
|
||||||
|
copertura il target sarebbe zero per costruzione e i giorni-zero (a) **gonfiano il T del
|
||||||
|
deflated-Sharpe** (anti-conservativo) e (b) diluiscono `cand_insample_sharpe` (il gate
|
||||||
|
`has_insample_edge` scatterebbe a vuoto). La logica di `study_family_honest` è replicata
|
||||||
|
ESATTAMENTE sui frame troncati coi primitivi altlib (selezione IN-SAMPLE-ONLY → gates
|
||||||
|
study_marginal → DSR sull'intera griglia). Cross-check `study_marginal` non-troncato in coda:
|
||||||
|
stessi verdetti (abs WEAK / marginal NEUTRAL / earns_slot False).
|
||||||
|
- ⚠️ **STORIA CORTA**: ~3.1 anni totali → in-sample pre-HOLDOUT ~1.6a (meno warmup z), hold-out
|
||||||
|
2025+ ~1.5a. Dichiarato: nessun numero qui ha il peso statistico di TP01 (7 anni).
|
||||||
|
|
||||||
|
## Metodo
|
||||||
|
|
||||||
|
Famiglia a griglia modesta, 1d, 50/50 BTC+ETH, vol-target 20% cap 2x (convenzione TP01), fee
|
||||||
|
0.10% RT + sweep 0.00-0.30%:
|
||||||
|
|
||||||
|
- **FADE**: z-score(funding daily, lb) ≥ thr → short; ≤ −thr → long (fade dell'affollamento).
|
||||||
|
- **FOLLOW**: z ≥ thr → long; ≤ −thr → short (sentiment momentum).
|
||||||
|
- **GATE**: trend long-flat TP01-like (sgn mom 30d+90d) ma FLAT quando z ≥ thr (de-risk crowding).
|
||||||
|
- **DIVERGE**: momentum prezzo 20d seguito se funding NON affollato (z ≤ −thr), fatto se z ≥ thr.
|
||||||
|
|
||||||
|
Griglia: 4 forme × lb {7,14,30,60}g × thr {0.5,1.0,1.5} = **48 celle**. Selezione cella
|
||||||
|
**in-sample-only** (mai sul hold-out), deflated-Sharpe su tutte le 48, gates marginali
|
||||||
|
(`marginal_vs_tp01`), boundary-shift della finestra di aggregazione funding (−0/3/6/9/12h),
|
||||||
|
`eval_weights_smallcap` a $600.
|
||||||
|
|
||||||
|
## Risultati
|
||||||
|
|
||||||
|
Best in-sample per forma: **fade 0.34, follow 0.81, gate 1.76, diverge −0.34** — il segnale
|
||||||
|
puro-funding è debole ovunque; solo la forma che CONTIENE il trend ha Sharpe.
|
||||||
|
|
||||||
|
**Cella scelta (in-sample): GATE lb=60 thr=1.5** — IS 1.76, FULL 1.16, HOLD 0.29.
|
||||||
|
|
||||||
|
| Gate | Esito |
|
||||||
|
|---|---|
|
||||||
|
| Deflated-Sharpe (48 celle) | **0.215** (null-max atteso 1.59) → **FAIL** (soglia 0.95) |
|
||||||
|
| Assoluto (frame troncati) | **WEAK** (minFull +0.97, ma minHold **+0.04** BTC); fee-survive fino a 0.30% RT |
|
||||||
|
| Marginale vs TP01 | **NEUTRAL**: corr **0.879** (hold 0.92), beta 0.77; uplift w25 hold **+0.001**; robust_oos=False (jackknife −0.074); multicut 2026 **−0.423** |
|
||||||
|
| earns_slot / earns_slot_honest | **False / False** |
|
||||||
|
| Realism | prefix-diff 0.0 (causale); boundary-shift sign-stable (uplift spread 0.047); smallcap $600 haircut ~0.00 (eseguibile, irrilevante senza edge) |
|
||||||
|
|
||||||
|
**Controllo decisivo (lezione TP01-DVOL-overlay)** — lo stesso trend long-flat SENZA gate funding:
|
||||||
|
|
||||||
|
```
|
||||||
|
trend NUDO : IS 1.65 FULL 1.14 HOLD 0.37
|
||||||
|
trend+GATE : IS 1.76 FULL 1.16 HOLD 0.29 corr(gated,nudo)=0.928
|
||||||
|
delta del gate funding: FULL +0.013, HOLD −0.080
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **tutto lo Sharpe della "vincitrice" è il trend**; il contributo del funding è ~zero in-sample
|
||||||
|
e **negativo sul hold-out**. Il gate è TP01 travestito.
|
||||||
|
|
||||||
|
**Miglior cella puro-funding** (la vera tesi del filone): FOLLOW lb=60 thr=0.5 — IS 0.81 ma
|
||||||
|
**FULL −0.28, HOLD −1.69**, marginale **DILUTES** (uplift w25 hold −0.778). L'in-sample 0.81 era
|
||||||
|
"long-il-toro-2023/24" travestito: z-alto di funding = rally già in corso; nel 2025-26 la stessa
|
||||||
|
regola è sistematicamente dalla parte sbagliata. FADE (shortare il crowding) perde sul toro e non
|
||||||
|
recupera abbastanza nel chop; DIVERGE negativa ovunque. NB: esistono celle gate con HOLD migliore
|
||||||
|
(es. gate 7/1.5 HOLD 0.96) — sceglierle sarebbe **selection-on-holdout**, il gate del 2026-06-29
|
||||||
|
esiste esattamente per questo.
|
||||||
|
|
||||||
|
## Perché fallisce (meccanismo)
|
||||||
|
|
||||||
|
1. Su asset MAJOR il funding è **quasi sempre positivo e segue il prezzo con lag**: lo z-score
|
||||||
|
alto coincide col momentum già espresso → FOLLOW è trend-beta ritardato (muore quando il regime
|
||||||
|
gira), FADE è shortare il toro. Non c'è l'anti-persistenza ("crowding → reversal") che la tesi
|
||||||
|
richiede — a 1d, su BTC/ETH, le liquidazioni-da-crowding sono già nel prezzo prima che lo
|
||||||
|
z-score giornaliero diventi estremo.
|
||||||
|
2. L'unico uso "funzionante" (gate di de-risk sul trend) è **ridondante**: TP01-like è già flat
|
||||||
|
quando serve. Stesso verdetto di macro-regime-gate (corr 0.989) e DVOL-overlay: la famiglia
|
||||||
|
"overlay prudenziale su TSMOM" è esaurita — eredita lo Sharpe del trend e non aggiunge nulla.
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
|
||||||
|
**SCARTATO.** `earns_slot=False`, `earns_slot_honest=False` (DSR 0.215). Il funding time-series
|
||||||
|
non ha informazione direzionale propria su BTC/ETH a 1d; l'unica cella con Sharpe è trend
|
||||||
|
travestito con contributo funding OOS negativo. Niente forward-monitor: non c'è una componente
|
||||||
|
funding da monitorare. Con FC01 (carry cross-sectional) e il price-clock intraday, il filone
|
||||||
|
"funding come segnale" su dati certi è ora **chiuso su tre lati**.
|
||||||
|
|
||||||
|
## Lascito
|
||||||
|
|
||||||
|
- `scripts/research/r0701_funding_ts.py` riusabile: (a) loader funding→daily **causale** per barre
|
||||||
|
open-labeled (finestra [D, D+24h), gestione cadenza mista 8h/1h, flag validità copertura);
|
||||||
|
(b) pattern "**truncation-honest**" per dataset a copertura parziale — replica di
|
||||||
|
`study_family_honest` su finestra troncata per non gonfiare il T del DSR né diluire
|
||||||
|
`has_insample_edge` con anni di zeri (riusabile per ogni serie corta tipo DVOL/HL).
|
||||||
|
- I parquet `hlfund_*` restano certificati. Se mai si tornasse sul funding: gli estremi VERI
|
||||||
|
(squeeze) vivono sugli alt, ma è di nuovo cross-sectional → STAT-MODE a $600, soglia di prova
|
||||||
|
alta (e FC01 ha già mostrato la fragilità d'universo).
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# 2026-07-01 — Pesi statici ri-ottimizzati + guardia-DD sul portafoglio a 4 sleeve
|
||||||
|
|
||||||
|
## Mandato
|
||||||
|
Migliorare il portafoglio ATTIVO (TP01 41.25 / XS01 18.75 / VRP01 15 / SKH01 25) SENZA nuove
|
||||||
|
strategie: (A) ri-ottimizzazione onesta dei pesi statici, (B) guardia-drawdown a livello
|
||||||
|
portafoglio (replica del soft-guard vincente del tail-hedge lab 2026-06-23), (C) combinazione.
|
||||||
|
NON meta-allocazione dinamica (già scartata, thread 3 del 2026-06-29).
|
||||||
|
Script: `scripts/research/r0701_portfolio_opt.py`. Solo ricerca: nessun file di src/ o live toccato,
|
||||||
|
i pesi sono PROPOSTI, non applicati.
|
||||||
|
|
||||||
|
## Baseline riprodotta
|
||||||
|
`run_portfolio.py` oggi: **FULL Sh 2.12 / HOLD 2.21 / DD full 7.8% / CAGR 23.3%** (vs 2.13/2.30
|
||||||
|
del diario Skyhook: i dati sono avanzati di una settimana, coerente). Matrice sleeve outer-join
|
||||||
|
2019-03-14 → 2026-07-01 (TP01/SKH01 dal 2019, VRP01 dal 2021-05, XS01 dal 2024-01), combine
|
||||||
|
identico a `portfolio.combined_daily` (pesi rinormalizzati per-riga sugli sleeve attivi).
|
||||||
|
|
||||||
|
## (A) Pesi statici — metodo
|
||||||
|
- Ottimizzazione SOLO in-sample (pre-cut), valutazione OOS (post-cut), multi-cut
|
||||||
|
{2024-01, 2024-07, 2025-01}. Sleeve con <120 giorni in-sample al cut (XS01 al 2024-01)
|
||||||
|
PINNED al peso corrente (l'ottimizzatore non ha dati per giudicarli).
|
||||||
|
- Criteri: MAXSH (max-Sharpe del combo simulato), RP (inverse-vol), ERC, MINVAR-R (min-var con
|
||||||
|
vincolo ritorno ≥ pesi correnti). Null: EW (25×4) e CURRENT. Vincoli 5%≤w≤60%, Σ=1.
|
||||||
|
- **Cap STRUTTURALI dichiarati** (prudenza, non statistica): **VRP01 ≤ 15%** (sleeve MODELLATO su
|
||||||
|
DVOL ATM, stress-f non catturato — "niente short-vol da modello in deploy"), **XS01 ≤ 25%**
|
||||||
|
(STAT-MODE, storia ~2.5 anni). Varianti sotto cap: MAXSH-STR (ottimizzato) e EW-STR
|
||||||
|
(EW proiettato sui cap, parameter-free → TP 30 / XS 25 / VRP 15 / SKH 30).
|
||||||
|
|
||||||
|
### Risultati (pesi ottimizzati pre-2025, valutati FULL e HOLD-OUT 2025-26)
|
||||||
|
| criterio | pesi TP/XS/VRP/SKH | FULL Sh | FULL DD | FULL CAGR | HOLD Sh | HOLD DD | HOLD CAGR | multi-cut ΔSh OOS | anni vinti |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| CURRENT | 41.2/18.8/15/25 | 2.12 | 7.8% | +23.3% | 2.21 | 3.5% | +16.0% | — | — |
|
||||||
|
| MAXSH | 27.6/17.5/**39.5**/15.4 | 2.17 | 6.3% | +21.8% | 2.27 | 2.8% | +12.7% | +0.24/+0.08/+0.06 | **1/8** |
|
||||||
|
| RP-invvol | 26.7/15.5/**41.6**/16.3 | 2.17 | 6.4% | +21.8% | 2.25 | 2.8% | +12.4% | +0.28/+0.07/+0.04 | — |
|
||||||
|
| ERC | 26.6/16.2/**41.6**/15.7 | 2.17 | 6.3% | +21.7% | 2.25 | 2.8% | +12.4% | +0.29/+0.07/+0.05 | — |
|
||||||
|
| MINVAR-R | 26.7/23.3/**29.6**/20.4 | 2.18 | 6.4% | +23.3% | 2.37 | 2.6% | +16.1% | +0.20/+0.10/+0.16 | 4/8 |
|
||||||
|
| MAXSH-STR | 38.6/25/15/21.4 | 2.14 | 7.5% | +23.4% | 2.28 | 3.3% | +16.8% | **−0.00**/+0.03/+0.07 | 3/8 |
|
||||||
|
| EW | 25/25/25/25 | 2.14 | 7.0% | +24.5% | 2.39 | 2.8% | +18.1% | +0.16/+0.17/+0.18 | 6/8 |
|
||||||
|
| **EW-STR** | **30/25/15/30** | 2.10 | 8.5% | +25.2% | **2.35** | 3.2% | **+19.7%** | **+0.06/+0.13/+0.14** | **7/8** |
|
||||||
|
|
||||||
|
### Lettura onesta
|
||||||
|
1. **Gli OTTIMIZZATORI non aggiungono nulla — anzi overfittano.** Tutti (MAXSH/RP/ERC/MINVAR-R)
|
||||||
|
convergono sulla stessa mossa: scaricare TP01 e caricare **VRP01 al 30-42%** perché ha la vol
|
||||||
|
più bassa (7.7%/anno). Ma quella è la vol del MODELLO (premio BS su DVOL, coda di stress non
|
||||||
|
catturata): è esattamente il punto cieco tipo-CC01 ("Sharpe/vol implausibile → rischio
|
||||||
|
nascosto"). E per-anno MAXSH vince **1/8 anni** vs CURRENT — l'uplift multi-cut è smoothing di
|
||||||
|
vol modellata, non alpha di allocazione. **Dentro i cap** l'ottimizzatore (MAXSH-STR) converge
|
||||||
|
quasi sui pesi correnti (38.6/25/15/21.4) e NON è persistente (−0.00 al cut 2024-01) →
|
||||||
|
**i pesi correnti sono già ~ottimi per ciò che il backtest può giudicare lecitamente**.
|
||||||
|
2. **L'unico segnale robusto è la DE-CONCENTRAZIONE parameter-free**: EW-STR (EW sotto i cap,
|
||||||
|
nessun parametro fittato) vince **7/8 anni** (unica perdita 2021 −3.1pp), tutti i cut OOS
|
||||||
|
(ΔSh +0.06/+0.13/+0.14), HOLD Sh 2.21→2.35, HOLD CAGR +16.0→+19.7%. Classico "1/N batte
|
||||||
|
l'ottimizzazione" (DeMiguel). La sostanza: CURRENT dà il 41% al peggior Sharpe FULL del book
|
||||||
|
(TP01 1.30) e EW-STR sposta ~11pp da TP01 verso SKH01 (+5pp) e XS01 (+6.25pp).
|
||||||
|
3. **MA non è un free lunch risk-adjusted**: FULL Sh 2.12→**2.10** e FULL DD 7.8→**8.5%**
|
||||||
|
(più SKH01, che ha DD standalone 18%, pesa di più nei primi anni a 2 sleeve). L'uplift è un
|
||||||
|
TILT di ritorno verso gli sleeve research-grade con la storia più corta/caveat più pesanti
|
||||||
|
(SKH01 = research win, book 230m da verificare a deploy; XS01 = STAT-MODE non eseguibile a 2k).
|
||||||
|
E TP01 resta l'unico sleeve *deployed* live: il suo 41% nel report riflette una scelta di
|
||||||
|
prudenza/eseguibilità, non solo statistica.
|
||||||
|
|
||||||
|
### VERDETTO (A)
|
||||||
|
- **Ottimizzazione formale: NON MIGLIORA** (viola i cap strutturali o converge ai pesi correnti).
|
||||||
|
**Per il book reale i pesi correnti reggono.**
|
||||||
|
- **Candidato PROPOSTO (non applicato): EW-STR = TP01 30 / XS01 25 / VRP01 15 / SKH01 30** —
|
||||||
|
uplift OOS persistente e per-anno (7/8), dentro i cap, al costo di FULL DD +0.7pp e FULL Sh
|
||||||
|
−0.02. È una decisione di risk-appetite (più ritorno atteso spostando peso su sleeve
|
||||||
|
research-grade), non un miglioramento dominante. Se si vuole adottare, farlo consapevoli che
|
||||||
|
aumenta la dipendenza da SKH01/XS01, e NON tocca il book Deribit eseguibile (TP75/SKH25).
|
||||||
|
|
||||||
|
### Caveat metodologici (A)
|
||||||
|
- Le 3 finestre multi-cut sono ANNIDATE (tutte contengono il 2025-26): non 3 conferme
|
||||||
|
indipendenti. Il test disgiunto vero è il per-anno (7/8 per EW-STR è il dato forte).
|
||||||
|
- EW-STR è stato costruito DOPO aver visto EW vincere (proiezione del null sui cap dichiarati):
|
||||||
|
costruzione principled e parameter-free, ma va detto — non era nella griglia iniziale.
|
||||||
|
- I pesi CURRENT stessi furono fissati (2026-06-23) conoscendo l'hold-out → il confronto
|
||||||
|
"in-sample-opt vs CURRENT su hold-out" è semmai sbilanciato A FAVORE di CURRENT; EW-STR vince
|
||||||
|
comunque.
|
||||||
|
|
||||||
|
## (B) Guardia-drawdown a livello portafoglio — metodo
|
||||||
|
Soft-guard causale sul combo 4-sleeve (pesi CURRENT): esposizione del giorno D decisa dal DD
|
||||||
|
dell'equity fino a **D−1**; trigger/re-entry sul **NAV ombra** non-guardato (lezione stops_lab:
|
||||||
|
sull'equity congelata a expo=0 il DD non rientra mai); isteresi re-risk a 0.4·X come nel
|
||||||
|
tail-hedge lab. Griglia X∈{3,4,5,6}%, derisk∈{0,0.5}. Selezione cella IN-SAMPLE (max Calmar
|
||||||
|
pre-2025), verifica hold-out + multi-cut, **null de-levering** (expo costante c che pareggia il
|
||||||
|
MaxDD in-sample della guardia: Sharpe di c·r è invariante → la guardia vale solo se batte quello).
|
||||||
|
|
||||||
|
### Risultati
|
||||||
|
- Cella scelta in-sample: **X=5% / derisk=0.5** (IS Calmar 3.64 vs 3.24 baseline; IS DD 7.8→6.6%,
|
||||||
|
IS CAGR 25.3→23.9%, IS Sh 2.13→2.12). In-sample batte il null de-levering c=0.84 a pari DD
|
||||||
|
(CAGR 23.9% vs 21.0%) — il timing esiste in-sample.
|
||||||
|
- **MA OOS è INERTE**: a ogni cut la cella scelta non scatta MAI (ΔSh +0.00, ΔDD 0.0pp, ΔCAGR
|
||||||
|
0.0pp su tutti e 3 i cut) perché il DD OOS del 4-sleeve resta 3.5-4.8% < trigger 5%.
|
||||||
|
**La diversificazione a 4 sleeve fa già il lavoro della guardia** (il 2022 del book è +11.6%
|
||||||
|
con DD 3.6%: non c'è il grind da proteggere che aveva il combo TP01+GTAA del tail-hedge lab,
|
||||||
|
DD 8.4% / 2022 −4.4%).
|
||||||
|
- Trigger più stretti che scattano davvero COSTANO: X=3%/d=0.5 → HOLD Sh 2.10 (< 2.21), CAGR
|
||||||
|
13.7% (< 16.0%); X=3-4%/d=0 whipsaw pieno (HOLD Sh 1.83-2.09; a X=4/d=0 il DD guardato IS
|
||||||
|
sale addirittura a 8.8% > 7.8% baseline — accumulo di perdite su cicli off/on, lo stesso
|
||||||
|
fenomeno visto in stops_lab con l'uscita totale).
|
||||||
|
|
||||||
|
### VERDETTO (B)
|
||||||
|
**NON MIGLIORA.** La guardia-DD che vinceva sul combo TP01+GTAA (DD 8.4%, 2022 −4.4%) qui non ha
|
||||||
|
bersaglio: il 4-sleeve ha già DD ~3.5-4.8% OOS. Coerente col thread meta-allocazione 2026-06-29
|
||||||
|
(drawdown-control ridondante). Nota operativa: la cella X=5%/d=0.5 è *innocua* (non fa nulla
|
||||||
|
finché il DD non supera il 5%) e in-sample mostrava timing genuino sopra il null de-levering —
|
||||||
|
può valere come **circuit-breaker di emergenza sul live** (risk management, non alpha), ma non è
|
||||||
|
un miglioramento statistico e non la si adotta come tale.
|
||||||
|
|
||||||
|
## (C) Combinazione
|
||||||
|
(B) è inerte → niente da combinare. Per completezza: EW-STR + guard X=5%/d=0.5 → FULL Sh 2.08,
|
||||||
|
DD 6.7%, CAGR 23.7% | HOLD invariato 2.35/3.2%/19.7% (la guardia lima il DD 2021-2023 del tilt
|
||||||
|
a costo di −0.02 Sh FULL). Nessuna adozione proposta.
|
||||||
|
|
||||||
|
## Sintesi
|
||||||
|
| config | FULL Sh | FULL DD | FULL CAGR | HOLD Sh | HOLD DD | HOLD CAGR |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **CURRENT (attivo)** | 2.12 | 7.8% | +23.3% | 2.21 | 3.5% | +16.0% |
|
||||||
|
| EW-STR (proposta A, non applicata) | 2.10 | 8.5% | +25.2% | 2.35 | 3.2% | +19.7% |
|
||||||
|
| CURRENT + guard 5%/0.5 (B) | 2.11 | 6.6% | +22.3% | 2.21 | 3.5% | +16.0% |
|
||||||
|
| EW-STR + guard (C) | 2.08 | 6.7% | +23.7% | 2.35 | 3.2% | +19.7% |
|
||||||
|
|
||||||
|
- **(A) NON MIGLIORA in senso forte** — i pesi correnti reggono; l'unico candidato onesto è il
|
||||||
|
tilt de-concentrante EW-STR (30/25/15/30), un trade-off di risk-appetite da decidere, non un
|
||||||
|
upgrade dominante. Gli ottimizzatori in-sample o violano i cap strutturali (VRP01 40%) o
|
||||||
|
riconvergono sui pesi correnti.
|
||||||
|
- **(B) NON MIGLIORA** — guardia inerte OOS sul 4-sleeve; utile al più come circuit-breaker.
|
||||||
|
- Nessuna modifica a `src/portfolio/sleeves.py` o al live. Test 160/160 verdi.
|
||||||
|
|
||||||
|
Script: `scripts/research/r0701_portfolio_opt.py` (~4s, vettoriale).
|
||||||
|
|
||||||
|
## Verifica avversariale (agente scettico)
|
||||||
|
|
||||||
|
Script: `scripts/research/r0701_portfolio_skeptic.py`. **VERDETTO: NON APPLICARE — CURRENT resta.**
|
||||||
|
I numeri dell'agente riproducono ESATTAMENTE (via `StrategyPortfolio.combined_daily`, path di
|
||||||
|
produzione: CURRENT 2.12/2.21, EW-STR 2.10/2.35, multi-cut +0.06/+0.13/+0.14, |diff|=0). Il claim
|
||||||
|
muore altrove: l'uplift è **selezione-sull'hold-out di 2° ordine**, non alpha di allocazione.
|
||||||
|
|
||||||
|
1. **L'uplift OOS vive TUTTO nel 2025-26 — la finestra che ha selezionato i beneficiari.**
|
||||||
|
Finestre OOS **DISGIUNTE** (i 3 cut del diario sono annidati): ΔSh EW-STR vs CURRENT =
|
||||||
|
**−0.12** (2024H1) / +0.06 (2024H2) / **+0.14** (2025+). L'unica finestra OOS non toccata
|
||||||
|
dalla selezione hold-out è negativa. Sharpe standalone IS→HOLD per sleeve: TP01 1.49→**0.30**,
|
||||||
|
SKH01 1.42→**1.64**, XS01 1.22→**1.51**: EW-STR compra esattamente i due sleeve ammessi/affinati
|
||||||
|
(2026-06-23 / 2026-06-19) PERCHÉ forti sull'hold-out e vende quello che lì collassa. Meccanico.
|
||||||
|
2. **Pre-2025 (finestra non contaminata) EW-STR PERDE risk-adjusted**: ΔSh aggregato **−0.05**,
|
||||||
|
Sharpe per-anno vinti **2/6** (2021 −0.29, 2020 −0.10, 2023/2024 negativi).
|
||||||
|
3. **"7/8 anni vinti" è un artefatto di metrica**: è RITORNO composto (un tilt a più vol/ritorno
|
||||||
|
li vince quasi per costruzione), non risk-adjusted. Su **Sharpe per-anno: 3/8**. E due dei 7
|
||||||
|
"vinti" (2023, 2024) hanno margine **+0.03pp** = tie. Bilancio materiale sul ritorno:
|
||||||
|
5 vinti / 1 perso (2021 −3.1pp) / 2 pari.
|
||||||
|
4. **Plateau: liscio ma il gradiente è solo "meno TP01"**; a TP fisso, alzare SKH 25→30 è
|
||||||
|
piatto/negativo (min multi-cut a TP35: +0.07→+0.02). Scomposizione: il tilt **solo-SKH**
|
||||||
|
(+5pp, TP 36.25) da solo NON è persistente (multi-cut **−0.00**/+0.05/+0.04); il tilt
|
||||||
|
**solo-XS** (TP35/XS25/VRP15/SKH25) è l'unico positivo su tutte le finestre disgiunte
|
||||||
|
(+0.05/+0.02/+0.11) — ma pre-2025 fa ΔSh −0.00 (edge ≈ zero fuori dal 2025-26) e carica lo
|
||||||
|
sleeve MENO eseguibile (STAT-MODE).
|
||||||
|
5. **Realismo**: con l'outer-join rinormalizzato EW-STR tiene SKH01 al **50% effettivo nel
|
||||||
|
2019-20 e 40% nel 2021-23** (book research 230m a metà portafoglio: da lì il FULL DD 8.5%).
|
||||||
|
Punto onesto A FAVORE: un haircut d'esecuzione a drag su SKH01(-20/30%) e SKH+XS non ribalta
|
||||||
|
il ranking hold-out (ΔSh resta +0.08..+0.13), ma i livelli assoluti crollano (HOLD Sh CURRENT
|
||||||
|
2.21→1.64 al −30% su entrambi) e il beneficio resta di CARTA: il book Deribit eseguibile
|
||||||
|
(TP75/SKH25) non è toccato dalla decisione.
|
||||||
|
6. **Forking paths quantificato**: ≥15 config valutate sull'hold-out (7 vettori pesi + 8 celle
|
||||||
|
guardia), EW-STR costruito dopo aver visto EW vincere. Su **500 tilt CASUALI cap-respecting**:
|
||||||
|
51% batte CURRENT sull'hold-out (mediana ΔSh +0.00), solo 11% sul FULL, 30% su tutti e 3 i cut.
|
||||||
|
EW-STR è al **94° percentile** dell'uplift hold-out fra i tilt casuali ≈ il **massimo atteso di
|
||||||
|
~15 tentativi** (k/(k+1)=93.8%): l'uplift osservato è indistinguibile dal "best of 15 tilt
|
||||||
|
casuali scelto sull'hold-out".
|
||||||
|
|
||||||
|
**Conclusione**: il tilt non è disonesto nei conti ma non è evidenza — è la stessa lezione del gate
|
||||||
|
SELECTION-ON-HOLDOUT applicata al livello dei PESI (2° ordine: gli sleeve stessi sono stati
|
||||||
|
selezionati su quell'hold-out). Unica direzione sopravvissuta: XS01 18.75→25 (solo-XS, positivo su
|
||||||
|
tutte le finestre disgiunte), MA edge ≈0 fuori dal 2025-26, sleeve STAT-MODE non eseguibile a $600,
|
||||||
|
e l'hold-out-strength di XS01 è a sua volta concentrata dal gate di dispersione nel regime 2025-26
|
||||||
|
→ da rivalutare SOLO con più storia forward, non ora. **CURRENT (41.25/18.75/15/25) resta.**
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# 2026-07-01 — Ondata multi-agente a 6 filoni: 0 nuovi sleeve, pesi CURRENT confermati, 1 gate nuovo
|
||||||
|
|
||||||
|
**Goal utente:** "cerca con diversi agenti altre strategie di trading. Prova di tutto e arriva a
|
||||||
|
migliorare il portfolio presente. Lo scopo è sempre 50 euro days."
|
||||||
|
|
||||||
|
**Esito in una riga:** 6 filoni nuovi (scelti dopo scan dei ~65 diari per non ripetere strade morte)
|
||||||
|
+ 1 verifica avversariale → nessun candidato supera i gate onesti; il portafoglio a 4 sleeve
|
||||||
|
(41.25/18.75/15/25) **sopravvive a un attacco serio**, che è l'esito corretto per un book onesto.
|
||||||
|
Miglioramento concreto dell'ondata = metodologico: **gate `weights_tilt_null`** codificato (sotto).
|
||||||
|
|
||||||
|
## I 6 filoni (tutti su harness onesto: study_family_honest, marginal scorer, DSR≥0.95, smallcap $600)
|
||||||
|
|
||||||
|
| # | filone | verdetto | perché muore |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Funding time-series** BTC/ETH (posizionamento, non carry) — `r0701_funding_ts.py` | SCARTATO | FOLLOW = trend-beta ritardato (HOLD −1.69), FADE = shortare il toro; la cella gate è TP01 travestito (controllo senza funding = stessi numeri, corr 0.93, ΔHOLD −0.08); DSR 0.215. **Filone funding chiuso su 3 lati** (FC01 carry, price-clock, TS-signal). |
|
||||||
|
| 2 | **Breadth/internals alt** (51 HL) → BTC/ETH — `r0701_breadth_internals.py` | SCARTATO (rivisitabile) | Unico non-ridondante col trend (corr→TP01 0.40, lavora dove TP01 è attivo), assoluto PASS, marginal ADDS — ma jackknife −0.068 (uplift su UN mese) e DSR 0.433/104 celle. Con ~8 mesi di IS il 2024-toro non basta. **Rivisitare tra 1-2 anni di storia HL nativa.** |
|
||||||
|
| 3 | **Residual momentum XS** (β-hedged vs BTC, 19 major) — `r0701_xs_residmom.py` | REDUNDANT | Cross-section la residualizzazione è quasi un no-op (lo z-score di XS01 già rimuove il mercato): corr→XS01 0.54, HOLD −0.24, corr(IS,HOLD) tra le celle **−0.37** (anti-predittivo = rumore). L'edge residuo-momentum vive nella coppia ETH/BTC (STATARB-RESID, resta in `paper_statarb`), non generalizza. |
|
||||||
|
| 4 | **Pesi + guardia-DD** — `r0701_portfolio_opt.py` | vedi sotto | Unico candidato dell'ondata (EW-STR) → refutato dallo scettico. Guardia-DD X5%/d0.5: in-sample batte perfino il null de-levering, ma **OOS non scatta mai** (DD book 3.5-4.8% < trigger): la diversificazione fa già il lavoro. Utilizzabile solo come circuit-breaker di emergenza (risk mgmt, non alpha). |
|
||||||
|
| 5 | **Affinamento VRP01** (sizing IV−RV, DVOL-mom, gate TP01) — `r0701_vrp_refine.py` | NON MIGLIORA | L'alpha è già tutto nel gate binario IV-rank; il gate TP01 è la trappola IS perfetta (schiva il 2022, ma taglia le settimane migliori dell'hold-out: multi-cut 0/5). **3° fallimento del filone "affinare VRP dentro il modello" → esaurito** finché cerbero-bite non cattura un crash reale (f di stress). |
|
||||||
|
| 6 | **Stagionalità cross-sectional** HL — `r0701_xs_seasonal.py` | SCARTATO (allo step statistico) | Nessuna persistenza split-half sopra il null permutato max-statistic (p 0.16-0.23); l'unica struttura è il canale-beta dell'effetto weekday di mercato (famiglia trackF, già morta). Turnover ~2×gross/die = fee-death comunque. |
|
||||||
|
|
||||||
|
## EW-STR: il candidato refutato (caso di scuola di selezione-sull'hold-out di 2° ORDINE)
|
||||||
|
|
||||||
|
Il filone 4 proponeva **EW-STR** (TP01 30 / XS01 25 / VRP01 15 / SKH01 30 = equal-weight proiettato
|
||||||
|
sui cap strutturali): HOLD Sh 2.21→2.35, multi-cut +0.06/+0.13/+0.14, "7/8 anni vinti" — dichiarando
|
||||||
|
onestamente i caveat. La verifica avversariale (`r0701_portfolio_skeptic.py`, addendum nel diario
|
||||||
|
pesi) lo uccide:
|
||||||
|
|
||||||
|
1. **Selezione di 2° ordine**: SKH01 fu ammesso (2026-06-23) *perché* alzava l'hold-out 2025-26;
|
||||||
|
XS01 fu affinato conoscendo l'hold-out. EW-STR vende lo sleeve che collassa OOS (TP01 IS 1.49→
|
||||||
|
HOLD 0.30) e compra i due selezionati su quell'OOS. Pre-2025: **ΔSh −0.05** (perde risk-adjusted).
|
||||||
|
2. **Su finestre OOS disgiunte**: −0.12 (2024H1) / +0.06 (2024H2) / +0.14 (2025+) — tutto l'uplift
|
||||||
|
vive nella finestra che ha selezionato gli sleeve. "7/8 anni" era sul ritorno composto; su
|
||||||
|
Sharpe è 3/8.
|
||||||
|
3. **Firma best-of-k**: su 500 tilt casuali cap-respecting, EW-STR siede al 94-100° percentile ≈
|
||||||
|
k/(k+1) con k≈15 config viste sull'hold-out → uplift indistinguibile dal *best-of-15 scelto
|
||||||
|
sull'hold-out*.
|
||||||
|
4. **Realismo**: in pesi effettivi (outer-join rinormalizzato) EW-STR = SKH01 al 40-50% del book
|
||||||
|
2019-23 (sleeve research a book 230m). Il book Deribit eseguibile (TP+SKH) non cambierebbe comunque.
|
||||||
|
|
||||||
|
Residuo onesto: il sub-tilt XS01 18.75→25 è positivo su tutte le finestre disgiunte ma edge≈0
|
||||||
|
pre-2025 e STAT-MODE → rivalutare solo con storia forward.
|
||||||
|
|
||||||
|
## GATE NUOVO codificato: `weights_tilt_null` (src/portfolio/portfolio.py)
|
||||||
|
|
||||||
|
La lezione è ora codice, come per marginal-scorer e study_family_honest:
|
||||||
|
- **`combine_outer(daily_cols, weights, lo, hi)`** — la combinazione outer-join/rinormalizzata di
|
||||||
|
`combined_daily` estratta come funzione su pesi arbitrari (riusabile per studi di tilt).
|
||||||
|
- **`weights_tilt_null(daily_cols, w_current, w_proposed, caps, floor, n, seed, k_seen)`** —
|
||||||
|
null di tilt casuali uniformi sul simplesso dentro floor/caps: riporta `delta_hold/full/insample`,
|
||||||
|
`frac_random_beat_hold`, `pctl_hold`, `bestofk_pctl=k/(k+1)` e `gate_pass`.
|
||||||
|
**Regola: un cambio-pesi si applica solo se (1) delta_insample ≥ 0 e (2) pctl_hold < firma
|
||||||
|
best-of-k** (necessario, non sufficiente: restano finestre disgiunte + realismo).
|
||||||
|
Sul caso reale EW-STR: delta_hold +0.139, delta_insample **−0.054**, pctl **100°** vs best-of-15
|
||||||
|
93.75° → `gate_pass=False`. Test: `tests/test_weights_tilt_null.py` (5 test, sintetici,
|
||||||
|
deterministici; incluso lo sleeve "forte-solo-nell'hold-out" che DEVE fallire il gate).
|
||||||
|
|
||||||
|
## Lezione tecnica riusabile (dal filone 2)
|
||||||
|
|
||||||
|
`DatetimeIndex.view("int64")` su indici **tz-aware a risoluzione non-ns** (pandas 2.x) produce
|
||||||
|
timestamp in scala sbagliata → un `merge_asof` può broadcastare l'ultimo valore su tutta la storia
|
||||||
|
= **look-ahead che `causality_ok` non vede** (input fisso). Sintomo: decine di celle con Sharpe
|
||||||
|
identico. Fix: epoca esplicita in ms. Verificato: `altlib.py` non è affetto (grep pulito); dettagli
|
||||||
|
nel diario breadth.
|
||||||
|
|
||||||
|
## Onestà sul goal €50/giorno
|
||||||
|
|
||||||
|
Invariata (vedi CLAUDE.md): a capitale reale ~$600 il book onesto fa ~+€1.5/giorno atteso; la via
|
||||||
|
resta capitale + tempo + target-vol, non un tilt di pesi né leva. Questa ondata conferma che il
|
||||||
|
book attuale è vicino al massimo estraibile dai dati certificati disponibili oggi; le opzioni
|
||||||
|
reali di crescita restano (a) storia forward che promuova i lead (STATARB-RESID, breadth tra 1-2
|
||||||
|
anni), (b) capitale, (c) nuove fonti di dato (f di stress reale per VRP).
|
||||||
|
|
||||||
|
**File dell'ondata:** `scripts/research/r0701_{funding_ts,breadth_internals,xs_residmom,portfolio_opt,portfolio_skeptic,vrp_refine,xs_seasonal}.py`; diari `2026-07-01-{funding-ts-signal,alt-breadth-internals,xs-residual-momentum,portfolio-weights-ddguard,vrp-refinement,xs-seasonality}.md`; gate in `src/portfolio/portfolio.py` + `tests/test_weights_tilt_null.py`.
|
||||||
|
|
||||||
|
## ADDENDUM (stessa sera) — GTAA01 promosso a 5° SLEEVE: il miglioramento c'era, ma era in casa
|
||||||
|
|
||||||
|
Il secondo passaggio sull'archivio (non sui dati!) ha trovato il candidato che l'ondata di *ricerca*
|
||||||
|
non poteva trovare: **EQ-GTAA01** (`src/portfolio/gtaa.py` — trend difensivo equity 6-ETF, vt12%,
|
||||||
|
~30 anni di storia, netto fee IB) era stato validato il 2026-06-22 come *"primo miglioramento
|
||||||
|
STRUTTURALE del rischio-aggiustato trovato in tutta la ricerca post-reset"*, messo in paper
|
||||||
|
cross-venue (`paper_combo`, nel cron dal 2026-06-23) e **mai valutato come 5° sleeve del
|
||||||
|
portafoglio attivo** (il giorno dopo arrivò SKH01 e il filone si fermò).
|
||||||
|
|
||||||
|
### Valutazione onesta (`r0701_gtaa_5th_sleeve.py`) — perché GTAA passa dove EW-STR è morto
|
||||||
|
|
||||||
|
| gate | EW-STR (refutato) | +GTAA01 (passa) |
|
||||||
|
|---|---|---|
|
||||||
|
| uplift IN-SAMPLE (pre-2025) | **−0.05** | **+0.09..+0.13** (2.134→2.23-2.26) |
|
||||||
|
| finestre OOS disgiunte | −0.12 / +0.06 / +0.14 | **+0.05 / +0.19 / +0.25** (tutte >0) |
|
||||||
|
| multi-cut (4 tagli) | solo annidate | **+0.21 / +0.21 / +0.22 / +0.25** |
|
||||||
|
| provenienza dell'edge | sleeve selezionati sull'hold-out crypto | validato su OOS equity 2015+, **indipendente** dall'hold-out crypto |
|
||||||
|
| corr al book | — | **+0.10** full, ~0.00 hold-out; is_hedge +0.30 (non-hedge) |
|
||||||
|
| plateau | gradiente generico anti-TP01 | **monotono su w 10-30%, ogni cella migliora IS/FULL/HOLD/DD** |
|
||||||
|
|
||||||
|
Convenzione di realismo cross-venue (nuova, cablata nello sleeve): nei giorni senza barra equity
|
||||||
|
(weekend/festivi US) il rendimento è **0.0** — capitale IB fermo, NON rinormalizzato sul crypto
|
||||||
|
(due conti reali non si travasano nel weekend). Numeri quasi identici alla versione outer-join
|
||||||
|
(differenze al 3° decimale) ma il modello è quello giusto. Attivazione nel book all'era crypto
|
||||||
|
(2019-03-14): senza troncatura l'outer-join farebbe partire il book nel 1996 con 23 anni di
|
||||||
|
solo-GTAA rinormalizzato al 100%.
|
||||||
|
|
||||||
|
### Decisione pesi
|
||||||
|
Cella IS-best = w=30% (bordo griglia), **ingresso @20%** per scelta strutturale dichiarata:
|
||||||
|
(a) cross-venue IB con paper operativo di soli 8 giorni — non diventa lo sleeve più grande del
|
||||||
|
book al day-one; (b) precedente: ogni sleeve nuovo è entrato ≤25% (SKH01 25%, VRP01 15%);
|
||||||
|
(c) costo CAGR (a 30%: 16.8% vs 18.8% a 20%) — e l'obiettivo €50/g vive di CAGR, non solo di
|
||||||
|
Sharpe. Tutte le celle 10-30% passano comunque ogni gate. Pesi nuovi: **TP01 33 / XS01 15 /
|
||||||
|
VRP01 12 / SKH01 20 / GTAA01 20** (i 4 preesistenti scalati ×0.80, rapporti conservati).
|
||||||
|
|
||||||
|
### Portafoglio 5-sleeve (run_portfolio, capitale 2k)
|
||||||
|
| | FULL Sh | FULL DD | FULL CAGR | HOLD Sh | HOLD DD | HOLD CAGR |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 4-sleeve (baseline) | 2.12 | 7.8% | +23.3% | 2.21 | 3.5% | +16.0% |
|
||||||
|
| **5-sleeve (+GTAA01 20%)** | **2.24** | **6.2%** | +18.8% | **2.46** | **2.2%** | +14.6% |
|
||||||
|
|
||||||
|
Per-anno: tutti gli 8 anni positivi, 2022 +7.0% (era +11.6%: GTAA soffre il whipsaw 2022, unico
|
||||||
|
anno con dSh negativo −0.49 — dichiarato). Il trade-off è esplicito: −4.5pp di CAGR full per
|
||||||
|
+0.12/+0.25 di Sharpe e DD 7.8→6.2 / hold-out 3.5→2.2. A pesi research (XS/VRP/SKH modellati o
|
||||||
|
STAT-MODE) il CAGR era comunque di carta; lo Sharpe/DD del book è la metrica di verità.
|
||||||
|
|
||||||
|
### Cosa NON cambia
|
||||||
|
Il **book live Deribit** (`deribit_book_sleeves` TP01+SKH01 75/25, `book_execute`) è INTATTO —
|
||||||
|
GTAA è su IB, già tracciato forward da `paper_combo`. `paper_portfolio` da oggi traccia la
|
||||||
|
composizione a 5 (regime-break dichiarato qui). Test: `tests/test_gtaa_sleeve.py` (registry,
|
||||||
|
weekend-zero, attivazione, book live invariato) — suite 168/168.
|
||||||
|
|
||||||
|
### Lezione dell'addendum
|
||||||
|
Prima di una nuova ondata di *ricerca*, ri-leggere l'archivio dei **validati-ma-non-integrati**:
|
||||||
|
l'unico upgrade onesto del book in 3 ondate (104+5+6 filoni) era un edge già validato che
|
||||||
|
aspettava solo la valutazione da sleeve. La diversificazione strutturale (secondo mercato,
|
||||||
|
corr ~0.1) resta l'unica cosa che alza lo Sharpe del book senza nuovo alpha.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# 2026-07-01 — Affinamento VRP01 (sizing IV−RV / filtro DVOL-momentum / gate TP01): NON MIGLIORA
|
||||||
|
|
||||||
|
**Script:** `scripts/research/r0701_vrp_refine.py` (solo ricerca: `src/` e `scripts/live/` non toccati).
|
||||||
|
**Esito: NON MIGLIORA — VRP01 baseline resta la config canonica.** La cella IS-best (gate TP01-skip)
|
||||||
|
è un falso positivo in-sample da manuale: IS Sharpe 2.07 ma multi-cut **0/5**, hold-out **−0.14**
|
||||||
|
(vs 0.59 del baseline), DSR 0.71. Nessuna modifica a `sleeves._vrp_combo_returns`.
|
||||||
|
|
||||||
|
## Cosa testava (celle NUOVE, verificate non ripetute nei diari)
|
||||||
|
|
||||||
|
Stessa pipeline di `options_vrp_v2.py` (pricing BS su DVOL reale, payoff sul path certificato,
|
||||||
|
fee 12.5% del premio, f=1.0, tenor 7d, spread −0.28/−0.10) — cambiati SOLO gate/sizing:
|
||||||
|
|
||||||
|
1. **Sizing sul gap IV−RV** (carry atteso): size lineare `clip(vrp/scale,0,1)` (scale 8/12 pt) o
|
||||||
|
percentile espandente causale del VRP; in aggiunta o in sostituzione del gate binario IV-rank.
|
||||||
|
NB: il gate composito "IV-rank>0.30 AND IV−RV>0" è **già il baseline** (`gate_vrp=True`).
|
||||||
|
2. **Filtro DVOL-momentum**: non vendere se `dvol[i]−dvol[i−k] > thr` (k∈{5,10}, thr∈{0, 5pt}).
|
||||||
|
(Diverso da `dvol_directional` 2026-06-29, dove il DVOL-mom era segnale direzionale sul perp.)
|
||||||
|
3. **Gate di regime da TP01**: skip o half-size quando TP01 è flat su entrambi gli asset (risk-off).
|
||||||
|
4. Croce completa: **105 celle**, tutte contate nel deflated-Sharpe.
|
||||||
|
|
||||||
|
NON ripetuto (già scartato): gestione attiva intra-trade (PT/SL/vol-stop/delta-exit,
|
||||||
|
`2026-06-20-vrp-active-management.md` → hold-to-expiry vince).
|
||||||
|
|
||||||
|
Metodo: selezione cella **in-sample (pre-2025)** con floor di attività IS ≥20%, hold-out 2025-26,
|
||||||
|
**multi-cut 5 tagli** (2023-01→2025-01, richiesti ≥4/5 uplift>0), `altlib.deflated_sharpe` su tutti
|
||||||
|
i 105 trial, effetto sul **portafoglio 4-sleeve** (TP01 41.25 / XS01 18.75 / VRP 15 / SKH01 25).
|
||||||
|
|
||||||
|
## Risultati (book 50/50 BTC+ETH, rendimenti settimanali)
|
||||||
|
|
||||||
|
Baseline riprodotto esattamente: **FULL Sh 1.09 / IS 1.26 / HOLD 0.59 / DD 12% / worst −7.4% / attivo 41%.**
|
||||||
|
|
||||||
|
| cella | IS Sh | FULL | HOLD | DD | worst | attivo |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **BASELINE** bin+ivr30 | 1.26 | **1.09** | **0.59** | 12% | −7.4% | 41% |
|
||||||
|
| sizing lin 8pt (+ivr30) | 1.20 | 1.05 | 0.61 | 11% | −7.4% | 41% |
|
||||||
|
| sizing lin 12pt (+ivr30) | 1.12 | 0.98 | 0.54 | 11% | −7.4% | 41% |
|
||||||
|
| sizing rank (+ivr30) | 1.07 | 0.94 | 0.54 | 9% | −5.7% | 41% |
|
||||||
|
| sizing lin 8pt **al posto di** ivr | 0.89 | 0.58 | **−0.38** | 21% | −7.4% | 86% |
|
||||||
|
| sizing rank al posto di ivr | 1.03 | 0.69 | **−0.36** | 11% | −5.7% | 86% |
|
||||||
|
| mom k=5 thr=5pt | 1.24 | 1.02 | 0.37 | 9% | −6.5% | 34% |
|
||||||
|
| mom k=10 thr=5pt | 1.26 | 1.08 | 0.55 | 9% | −6.5% | 31% |
|
||||||
|
| mom k=5 thr=0 | 1.78 | 1.24 | −0.10 | 5% | −3.9% | 20% |
|
||||||
|
| mom k=10 thr=0 | 1.36 | 0.78 | **−0.47** | 7% | −3.9% | 18% |
|
||||||
|
| tp01-skip | **2.05** | 1.43 | **−0.14** | 7% | −4.7% | 27% |
|
||||||
|
| tp01-half | 1.72 | 1.34 | 0.27 | 7% | −4.7% | 41% |
|
||||||
|
| IS-best: lin8+tp01-skip | **2.07** | 1.43 | **−0.14** | 6% | −4.2% | 27% |
|
||||||
|
|
||||||
|
**Multi-cut della IS-best vs baseline: uplift negativo in 5/5 tagli** (−0.35 → −0.73, monotono
|
||||||
|
peggiorando verso il presente). **DSR** IS-best 0.709 (<0.95); baseline 0.472 dentro questa griglia
|
||||||
|
(contesto onesto: nemmeno il baseline è statisticamente distinguibile su 105 trial correlati — ma è
|
||||||
|
pre-committed, non selezionato qui). Celle che battono l'hold-out del baseline: **1/105** (lin8pt
|
||||||
|
tp-off, +0.02 — rumore; non selezionabile: sarebbe selection-on-holdout).
|
||||||
|
|
||||||
|
### Frequenza d'intervento dei gate (settimane-trade del baseline = 163)
|
||||||
|
|
||||||
|
- **TP01-skip: blocca il 32.5%** delle settimane tradabili (TP01 flat il 38% dei giorni della
|
||||||
|
finestra DVOL). NON è ridondante col baseline — lavora tanto — ma lavora **male** OOS.
|
||||||
|
- mom thr=5pt: blocca 24-32%; mom thr=0: blocca 60-64% (quasi metà del book).
|
||||||
|
|
||||||
|
### Portafoglio 4-sleeve (VRP @15%)
|
||||||
|
|
||||||
|
| VRP nello slot | FULL Sh | FULL DD | HOLD Sh | HOLD DD |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **baseline** | **2.12** | 7.8% | **2.21** | 3.5% |
|
||||||
|
| IS-best (lin8+tp01-skip) | 2.11 | 7.8% | 2.12 | 3.5% |
|
||||||
|
|
||||||
|
Anche a livello portafoglio la variante **peggiora** l'hold-out (2.21→2.12) e non migliora nulla.
|
||||||
|
|
||||||
|
## Lettura (perché non migliora)
|
||||||
|
|
||||||
|
1. **L'alpha del VRP è già tutto nel gate IV-rank binario** ("vendi solo vol ricca"). Il sizing
|
||||||
|
proporzionale al gap IV−RV è ridondante *dato* il gate (Δ ≈ ±0.05, dentro il rumore); usato al
|
||||||
|
posto del gate, riporta dentro le settimane di vol povera e distrugge l'hold-out (−0.36/−0.38).
|
||||||
|
Il carry atteso (IV−RV) NON è un segnale di *intensità* utile oltre il suo segno+rank.
|
||||||
|
2. **Il gate TP01 è la trappola in-sample perfetta** (la lezione macro-gate al contrario): schiva il
|
||||||
|
2022 (−6%→0%) gonfiando l'IS a 2.05, ma nel 2025-26 TP01 è flat proprio nei regimi di vol ricca
|
||||||
|
in cui la vendita gated di put **guadagna** — il gate taglia le settimane migliori dell'hold-out
|
||||||
|
(0.59→−0.14). Short-vol gated e trend sono complementari, non condizionabili l'uno sull'altro.
|
||||||
|
3. **Il DVOL-momentum non aggiunge protezione**: il crash-skip IV-rank>0.90 + il long wing dello
|
||||||
|
spread coprono già il run-up di vol; thr=0 è troppo aggressivo (blocca ~60%), thr=5pt è neutro
|
||||||
|
(mai meglio del baseline su hold-out).
|
||||||
|
|
||||||
|
## Onestà / caveat (invariati)
|
||||||
|
|
||||||
|
Premio MODELLATO su DVOL ATM (no skew), book 1d, f di stress reale non catturato → tutto questo
|
||||||
|
resta dentro il modello; il verdetto massimo possibile era "sleeve modellato migliorato", e non è
|
||||||
|
stato raggiunto. Il DSR sulla griglia è un limite *superiore* (celle correlate + ~20 config storiche
|
||||||
|
di options_vrp_lab/_v2 non contate).
|
||||||
|
|
||||||
|
## Azione
|
||||||
|
|
||||||
|
**Nessuna.** VRP01 resta com'è (`bin + gate_vrp + ivr30 + crash-skip 0.90`, hold-to-expiry).
|
||||||
|
Terzo tentativo di affinamento fallito (dopo active-management e — per il portafoglio — DVOL
|
||||||
|
overlay): il filone "migliorare VRP01 dentro il modello" è da considerarsi **esaurito**; il prossimo
|
||||||
|
passo utile resta quello noto: f di stress reale quando cerbero-bite cattura un crash + skew reale
|
||||||
|
sul long wing.
|
||||||
|
|
||||||
|
Script: `scripts/research/r0701_vrp_refine.py` (`--skip-portfolio` per la sola griglia).
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 2026-07-01 — Residual (idiosyncratic) momentum cross-sectional sui 19 major HL — SCARTATO/REDUNDANT
|
||||||
|
|
||||||
|
**Tesi.** STATARB-RESID (thread 4, 2026-06-29) ha mostrato che il momentum del residuo ETH−β·BTC
|
||||||
|
(β OLS rolling, **sgn=+1**: le dislocazioni continuano a 1d) passa quasi tutti i gate su 2 gambe,
|
||||||
|
fallendo solo il deflated-Sharpe (0.929<0.95). Ipotesi qui: portare lo **stesso meccanismo
|
||||||
|
cross-sectional** sui 19 major di XS01 — per ogni alt, residuo vs β·BTC (β OLS rolling B), momentum
|
||||||
|
del residuo (blend z-score [30,90] come XS01 o singolo L), long top-k / short bottom-k, vol-target
|
||||||
|
20% — sperando che la **breadth** (18 stream invece di 1) alzasse il DSR dove il 2-gambe falliva.
|
||||||
|
Script `scripts/research/r0701_xs_residmom.py` (riusa l'harness collaudato `xsec_v2_nonmom`:
|
||||||
|
load_matrix, xs_engine — uguaglianza verificata maxdiff 0 —, deflated_sharpe, StrategyPortfolio).
|
||||||
|
|
||||||
|
Distinzione dal già-testato: IREV (idio-**reversal**, sgn=−1) era FALLITO nel filone C; IMOM di
|
||||||
|
xsec_v2 usava il mercato equal-weight come fattore, B=60 fisso, senza blend ed era solo riferimento.
|
||||||
|
Qui fattore = **BTC** (come STATARB-RESID, BTC escluso dal cross-section), B in griglia, blend
|
||||||
|
z-score, probe con gate di dispersione p30 (parità strutturale con XS01), **selezione in-sample-only**.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- Universo: 19 major HL certificati (`data/raw/hl_*_1d.parquet`), 913 giorni [2024-01-01 → 2026-07-01],
|
||||||
|
barre vol=0 escluse. Fee 0.10% RT/gamba + sweep fino a 0.30%.
|
||||||
|
- Griglia (57 trial): β-window {60,90,120} × lookback {blend[30,90], 30, 90} × k {3,5} × H {5,10,20}
|
||||||
|
+ 3 probe gated (blend, k5, H10, gate dispersione p30). Cella scelta col solo Sharpe **pre-2025**;
|
||||||
|
hold-out 2025-01-01 bloccato. Causalità: prefix-check ok (max_tail_diff 0).
|
||||||
|
- Baseline (stessa finestra, sleeve attivi): XS01 FULL Sh **1.38** / HOLD **1.51** / DD 11%;
|
||||||
|
book 4-sleeve FULL 2.12 / HOLD 2.21.
|
||||||
|
|
||||||
|
## Esito — collasso OOS della cella in-sample e nessuna cella competitiva
|
||||||
|
|
||||||
|
Cella scelta in-sample: **B60 L30 k3 H20** (IS Sh 2.00):
|
||||||
|
|
||||||
|
```
|
||||||
|
FULL +0.67 | HOLD −0.24 | DD 35% | per-anno 2024 +54% / 2025 −15% / 2026 +5%
|
||||||
|
corr→XS01 +0.54 | corr→TP01 −0.09 | DSR 0.510 (57 trial) | fee-sweep ok (F+0.59 a 0.30%)
|
||||||
|
small-cap $600/min$5: haircut ≈ 0 (ma book multi-gamba → STAT-MODE comunque, come XS01)
|
||||||
|
```
|
||||||
|
|
||||||
|
Portafoglio (il bar del mandato):
|
||||||
|
|
||||||
|
```
|
||||||
|
BASE 4-sleeve FULL 2.12 DD 7.8% | HOLD 2.21 DD 3.5%
|
||||||
|
SOSTITUZIONE XS01 → cand FULL 2.01 (−0.10) | HOLD 1.18 (−1.03) ← distrugge l'hold-out
|
||||||
|
AGGIUNTA 5° sleeve @10/15% HOLD −0.31 / −0.53 ← diluisce
|
||||||
|
MARGINAL vs BOOK: corr +0.23, uplift@10% full −0.04 / hold −0.31, multi-cut 2025 −0.31 / 2026 −0.14
|
||||||
|
```
|
||||||
|
|
||||||
|
**Il verdetto non dipende dalla selezione** — aggregato delle 57 celle:
|
||||||
|
- FULL max **+0.94**, HOLD max **+0.82** (mediane +0.52/+0.33): *nessuna* cella si avvicina a XS01
|
||||||
|
(1.38/1.51), nemmeno cherry-pickando sull'hold-out.
|
||||||
|
- corr→XS01 in [+0.47, +0.71], mediana **+0.62**: è momentum XS01 travestito (la residualizzazione
|
||||||
|
vs β·BTC sposta poco: lo z-scoring cross-sectional di XS01 già rimuove implicitamente il mercato).
|
||||||
|
- Solo 4/57 celle passerebbero un gate "diversificatore" (|corrXS|<0.6, FULL>0.5, HOLD>0), tutte con
|
||||||
|
FULL ≤0.68 e IS incoerente.
|
||||||
|
- **corr(Sharpe in-sample, Sharpe hold-out) attraverso le celle = −0.37**: l'in-sample è
|
||||||
|
anti-predittivo dell'OOS su questa griglia — la firma del rumore, non di un edge con plateau.
|
||||||
|
|
||||||
|
## Perché la tesi è falsa (3 righe)
|
||||||
|
|
||||||
|
1. La breadth non ha alzato il DSR (0.51 ≪ 0.95) perché non c'è edge da deflazionare: il residuo
|
||||||
|
vs β·BTC sul cross-section degli alt NON eredita l'edge del 2-gambe ETH−β·BTC — quel lead vive
|
||||||
|
nella coppia specifica ETH/BTC (i due asset profondi/cointegrati), non nel long-tail degli alt.
|
||||||
|
2. Cross-sectional, la residualizzazione è quasi un no-op: demeaning z-score (XS01) ≈ togliere il
|
||||||
|
mercato → il candidato è una variante rumorosa di XS01 (corrXS mediana 0.62) con meno segnale.
|
||||||
|
3. In-sample = solo 2024 (~1 anno utile dopo il warmup): la selezione onesta atterra su una cella
|
||||||
|
2024-lucky che perde nel 2025-26 — l'ennesima conferma del gate selection-on-holdout.
|
||||||
|
|
||||||
|
## Verdetto: **SCARTATO/REDUNDANT.** Nessuno sleeve, nessun forward-monitor.
|
||||||
|
|
||||||
|
Coerente con il filone momentum-structure (2026-06-29): l'edge di XS01 sta nella sua struttura
|
||||||
|
specifica (blend + gate dispersione), e le sue varianti — incluso l'idio-momentum vs BTC — sono
|
||||||
|
REDUNDANT o peggio. Chiude anche il cerchio su STATARB-RESID: il meccanismo residuo-momentum resta
|
||||||
|
valido SOLO come coppia ETH/BTC (forward-monitor `paper_statarb`), non generalizza al cross-section.
|
||||||
|
Nessun impatto sul book live; config canonica invariata.
|
||||||
|
|
||||||
|
Script: `scripts/research/r0701_xs_residmom.py` (non committato, prefisso r0701_xs).
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 2026-07-01 — Stagionalità CROSS-SECTIONAL sull'universo Hyperliquid → SCARTATO
|
||||||
|
|
||||||
|
## Ipotesi
|
||||||
|
|
||||||
|
Effetti calendario **relativi tra i 19 alt major HL** (weekday tilt, turn-of-month,
|
||||||
|
pattern weekend→lunedì nel cross-section), long/short market-neutral. L'angolo è diverso
|
||||||
|
dal prior art già morto (trackF seasonality BTC/ETH = buy&hold travestito;
|
||||||
|
crypto_weekend_signal/overnight = look-ahead; open_drive = artefatto calendario ucciso da
|
||||||
|
`day_boundary_robust`): essendo il segnale **demeaned cross-section**, il buy&hold
|
||||||
|
travestito è strutturalmente escluso… *in prima battuta* (vedi diagnostica beta sotto).
|
||||||
|
|
||||||
|
## Metodo (test statistico PRIMA della strategia)
|
||||||
|
|
||||||
|
Script `scripts/research/r0701_xs_seasonal.py`. Dati `data/raw/hl_*_1d.parquet`, 19 major
|
||||||
|
XS01 (lista `src/portfolio/sleeves.XS_UNIVERSE`), **912 giorni** (2024-01-02 → 2026-07-01,
|
||||||
|
~2.5 anni), 0 barre vol=0. Ritorno relativo x[t,a] = r[t,a] − media cross-section del
|
||||||
|
giorno. Per ogni bucket calendario: **tilt bucket-specifico** per asset = media di x nel
|
||||||
|
bucket − tilt incondizionato dell'asset (isola il calendario dal drift relativo generico).
|
||||||
|
**Persistenza split-half**: Spearman rank-corr del vettore tilt (19 asset) H1 vs H2, per
|
||||||
|
bucket. **Null**: 2000 permutazioni delle etichette entro ciascuna metà → distribuzione del
|
||||||
|
**max-statistic** (controlla il multiple-testing sui bucket e il confound "alpha persistente
|
||||||
|
dell'asset"). Soglia severa p<0.05 sul max. Regola del mandato: senza persistenza →
|
||||||
|
**nessun backtest**.
|
||||||
|
|
||||||
|
## Numeri
|
||||||
|
|
||||||
|
| Test | Statistica | Null 95° pctl | p | Esito |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| [A] Weekday (7 bucket) | max rank-corr **+0.511** (Wed); Mon +0.38, Sun +0.37, Fri +0.04 | +0.604 | **0.159** | FAIL |
|
||||||
|
| [B] Weekend vs feriali | +0.268 | +0.428 | **0.178** | FAIL |
|
||||||
|
| [C] Turn-of-month (±2gg) | +0.221 | +0.451 | **0.226** | FAIL |
|
||||||
|
| [D] IC weekend→lunedì | FULL −0.053 (t=−1.94); H1 −0.006 (t=−0.17) vs H2 −0.100 (t=−2.4) | — | non persiste | FAIL |
|
||||||
|
|
||||||
|
Con ~65 osservazioni per weekday per metà, lo std di una rank-corr su 19 asset è ~0.24 → il
|
||||||
|
max-su-7 sotto puro rumore arriva a ~0.60: il +0.51 reale è *dentro* il rumore.
|
||||||
|
|
||||||
|
## Diagnostica: perché tutte le rank-corr weekday sono positive?
|
||||||
|
|
||||||
|
Non è tilt idiosincratico, è il **canale-beta**: il mercato EW ha un pattern weekday di
|
||||||
|
*livello* che si ripete nelle due metà (Sun −42/−30 bps, Sat +26/+25, Mon +35/+32) e la
|
||||||
|
dispersione di beta (0.53→1.26) lo proietta nel cross-section — rank-corr(tilt, beta):
|
||||||
|
**Sun −0.642**, Sat +0.325, feriali ~0. Cioè la poca "persistenza" visibile è l'effetto
|
||||||
|
weekday di MERCATO (dominio trackF, già scartato come buy&hold travestito) che rientra
|
||||||
|
dalla finestra via beta. Un eventuale L/S "weekday tilt" sarebbe stato in gran parte un
|
||||||
|
book short-beta-la-domenica — non un edge calendario cross-sectional.
|
||||||
|
|
||||||
|
## Note sui gate non raggiunti
|
||||||
|
|
||||||
|
- **day_boundary_robust** (obbligatorio per ogni effetto calendario): NON eseguibile sui
|
||||||
|
19 alt — i dati HL locali sono **solo 1d**, il confine giorno non è ri-tagliabile. Anche
|
||||||
|
con persistenza, il verdetto massimo sarebbe stato LEAD-forward *condizionato* a barre
|
||||||
|
orarie HL, mai sleeve. Con lo SCARTATO allo step 1 il punto è moot, ma resta il vincolo
|
||||||
|
per chiunque riapra il filone.
|
||||||
|
- **Fee**: mai arrivati al backtest; nota strutturale: un weekday-tilt L/S ribilancia ogni
|
||||||
|
giorno (weekday di domani ≠ oggi) → turnover ~2×gross/die, morte-per-fee quasi certa a
|
||||||
|
0.10% RT anche con edge lordo.
|
||||||
|
- **DSR**: non calcolato (nessuna cella backtestata — il branch strategia esiste nello
|
||||||
|
script ma non si attiva senza persistenza).
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
|
||||||
|
**SCARTATO** allo step statistico, senza backtest. Tre motivi: (1) nessuna persistenza
|
||||||
|
split-half sopra il null permutato (p 0.16–0.23 su tutti i test, IC weekend→lunedì non
|
||||||
|
persiste tra le metà); (2) la sola struttura visibile è il canale-beta di un pattern
|
||||||
|
weekday di mercato — la stessa famiglia già scartata in trackF, non un effetto
|
||||||
|
cross-sectional; (3) ~2.5 anni × effetto calendario × turnover giornaliero = rumore +
|
||||||
|
morte-per-fee anche nello scenario migliore.
|
||||||
|
|
||||||
|
File: `scripts/research/r0701_xs_seasonal.py` (deterministico, seed 20260701, ~2 min).
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# 2026-07-02 — Audit anchor timing-luck su XS01 e SKH01 (chiusura del pendente dell'ondata TIMING)
|
||||||
|
|
||||||
|
**Contesto.** L'ondata TIMING+CRT dello stesso giorno (`2026-07-02-timing-crt-wave.md`) ha stabilito
|
||||||
|
che l'hold-out di TP01 (0.31) è la **migliore delle 24 ancore daily possibili** (mediana 0.04,
|
||||||
|
P~0.86 di uno spike così per caso). Lo scettico ha flaggato l'audit come **pendente** sugli altri
|
||||||
|
due sleeve a ribilanciamento/griglia ancorata: **XS01** (fase del ciclo di ribilanciamento H=10
|
||||||
|
giorni) e **SKH01** (offset della griglia dual-TF 230m/690m, `origin='epoch'`). Questo diario
|
||||||
|
chiude entrambi. Due agenti indipendenti, metodologia identica allo scettico TP01
|
||||||
|
(`r0702_skeptic_offset.py`): ancore dichiarate **a priori**, parametri **identici** in ogni
|
||||||
|
fase/offset (zero tuning per-ancora), **sanity replica bit-exact** della sleeve canonica prima di
|
||||||
|
qualsiasi numero, block-bootstrap congiunto per P(spike).
|
||||||
|
|
||||||
|
**Script:** `scripts/research/r0702_anchor_xs01.py`, `scripts/research/r0702_anchor_skh01.py`.
|
||||||
|
Nessun file di produzione toccato dagli agenti (solo docstring/narrativa aggiornate a valle, qui).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verdetto in una riga
|
||||||
|
|
||||||
|
**Il finding TP01 si replica su TUTTI e tre gli sleeve ancorati — su SKH01 più forte che su TP01 —
|
||||||
|
e i numeri headline del book (HOLD 2.46) sono in parte best-of-anchors.** Ciò che sopravvive
|
||||||
|
de-luckato: XS01 resta un diversificatore ammissibile (ensemble di fase FULL 1.25 / HOLD 1.31 /
|
||||||
|
DD 11%); SKH01 resta ADDS (uplift del blend positivo a **tutte** le 23 fasi, corr ~0.08) ma i suoi
|
||||||
|
numeri headline e — cruciale — **il gate DD<30% che l'ha selezionata** non reggono alla fase
|
||||||
|
mediana. Stima onesta de-luckata del book 5-sleeve: **HOLD ~1.9–2.1, FULL ~2.0–2.2** (vs 2.46/2.24
|
||||||
|
dichiarati).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## XS01 — fase del ciclo di ribilanciamento H=10 (10 fasi)
|
||||||
|
|
||||||
|
Sanity: replica di `_xsec_returns` con parametro `phase` → **max|Δ|=0.0 bit-exact** vs la canonica.
|
||||||
|
NB: la canonica sui dati di oggi fa FULL 1.367/HOLD 1.479/DD 10.8% (i dichiarati 1.50/1.71/11%
|
||||||
|
erano del 2026-06-19; deriva dati, confronto interno coerente).
|
||||||
|
|
||||||
|
| metrica | min | mediana | max | canonica (f0) | pctl f0 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Sh FULL | 0.41 | 1.21 | 1.64 | 1.37 | 85° |
|
||||||
|
| Sh HOLD | 0.15 | 1.17 | 1.71 | 1.48 | 65° |
|
||||||
|
| maxDD | 10.0% | 15.5% | 29.1% | 10.8% | **15°** |
|
||||||
|
|
||||||
|
- **Firma diversa da TP01: la fortuna NON è nell'hold-out** (65° pctl, non estremo) **ma nel
|
||||||
|
DD** (15° pctl: il "DD 11%" dichiarato è uno dei più bassi delle 10 fasi; la fase tipica fa
|
||||||
|
~15.5%, la peggiore 29%) e nel FULL (85°).
|
||||||
|
- **Bootstrap**: spike osservato +0.38 su hold-out; **P(una fase qualsiasi ≥ +0.38) = 0.91–0.94**
|
||||||
|
(block 10/20/40, B=4000) → la canonica è il massimo atteso di 10 stime correlate (corr mediana
|
||||||
|
fra fasi 0.71).
|
||||||
|
- **Ensemble 10 fasi** (lens di reporting onesta): **FULL 1.25 / HOLD 1.31 / DD 10.9%** — la
|
||||||
|
diversificazione di fase riporta il DD al livello canonico con Sharpe ≈ mediana.
|
||||||
|
- **Book 5-sleeve**: HOLD 2.44 (canonica) → 2.26 (fase mediana) → 1.75 (peggiore, marginalmente
|
||||||
|
PEGGIO del book senza XS01 = 1.77) → 2.35 (ensemble). **Eredità di fortuna ~+0.17 HOLD.**
|
||||||
|
FULL quasi insensibile (2.10–2.28).
|
||||||
|
- **Gate di ammissione a fase mediana**: FULL 1.08 / HOLD 1.10 / DD 20.7% — **i numeri dichiarati
|
||||||
|
non reggono** (Sharpe −0.3/−0.6, DD raddoppia). **La decisione però regge**: nessuna fase
|
||||||
|
negativa, 8/10 con FULL≥1.0, book migliore a ogni fase tranne la peggiore (dove è neutro) →
|
||||||
|
slot da diversificatore @15% difendibile; cambia la NARRATIVA (banda/ensemble, non 1.50/1.71/11%).
|
||||||
|
- **Caveat**: storia ~2.5 anni (CI95 Sh HOLD canonica **[−0.04, +2.72]** — nessuna stima puntuale
|
||||||
|
affidabile); dimensione **ora-del-giorno non testabile** (solo barre 1d HL: resta luck residua
|
||||||
|
10 fasi × 24 ore non certificabile); XS01 è STAT-MODE → impatto su reporting/gate, non sul book
|
||||||
|
live Deribit.
|
||||||
|
|
||||||
|
## SKH01-V2-DD — offset della griglia dual-TF 230m/690m (23 offset × 30m)
|
||||||
|
|
||||||
|
Sanity: offset 0 riproduce `_skyhook_returns()` **bit-exact** (max|Δ|=0.0 su 2666 giorni); maxDD
|
||||||
|
harness BTC 21.4%/ETH 27.4% = identici al diario di ammissione; riuso diretto di
|
||||||
|
`skyhook_entries`/`htf_features`/`merge_htf_to_ltf` con `resample(origin='epoch', offset=...)`,
|
||||||
|
confini 690 ⊂ 230 verificati a ogni offset.
|
||||||
|
|
||||||
|
| metrica (book 50/50) | min | mediana | max | off0 (canonico) | pctl off0 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Sh FULL | 0.78 | 1.05 | 1.48 | 1.46 | 93° |
|
||||||
|
| Sh HOLD | 0.44 | 0.93 | 1.64 | 1.64 | **98° (max)** |
|
||||||
|
| minFull (2 asset) | 0.35 | 0.67 | 1.01 | 0.99 | 93° |
|
||||||
|
| minHold | −0.10 | 0.39 | 1.25 | 1.25 | **98° (max)** |
|
||||||
|
| blend 0.75·TP01+0.25·SKH, HOLD | 0.49 | 0.72 | 1.16 | 1.16 | **98° (max)** |
|
||||||
|
|
||||||
|
- **È uno spike, non un plateau**: gli offset adiacenti ±30m crollano (book HOLD 1.64 → 0.88 a
|
||||||
|
off30). Firma identica all'ancora TP01, ma **più forte**: il canonico è al 93–98° pctl di OGNI
|
||||||
|
metrica; minHold +1.26 e blend "0.31→1.17" sono il **massimo dei 23**.
|
||||||
|
- **Gate DD<30% — il criterio per cui V2-DD fu selezionata — FALLISCE in 15/23 offset**
|
||||||
|
(mediana DD: BTC 23.5%, ETH **29.2%** — pass per 0.8pt; peggiori 35.9%/42.1%). Il margine ETH
|
||||||
|
"già sottile" (27.4%) è in realtà **sotto la mediana di fase**: la proprietà DD<30% è in larga
|
||||||
|
parte fortuna di fase.
|
||||||
|
- **Ciò che regge de-luckato**: **uplift del blend positivo a TUTTE le 23 fasi** (min +0.18,
|
||||||
|
mediana +0.42, canonico +0.86) e corr→TP01 in [0.05, 0.11] a ogni fase → il verdetto
|
||||||
|
ADDS/diversificatore sopravvive, ridimensionato. Alla fase peggiore invece non regge
|
||||||
|
(minHold −0.10, DD fail).
|
||||||
|
- **Bootstrap**: spike blend +0.44; **P(un offset qualsiasi ≥ +0.44) ≈ 0.70** (block 10/20/40);
|
||||||
|
P(uplift≤0) = 0.03–0.04 → l'uplift è reale, l'ampiezza è best-of-23.
|
||||||
|
- **Book 5-sleeve**: HOLD 2.44 (off0 = max dei 23) → mediana **1.96** → ensemble 2.12; FULL 2.24 →
|
||||||
|
mediana 2.01. **Eredità di fortuna ~+0.5 HOLD** — molto più dei ~+0.10 dell'ancora TP01.
|
||||||
|
- **Rilevanza LIVE (SKH = 25% del book Deribit reale)**: il cron è orario, i confini 230m no →
|
||||||
|
ritardo uniforme {0..50} min (media 25). Re-sim del path reale (fill al close 5m del prossimo
|
||||||
|
run orario, exit SOFTWARE come da `book.py`): book 50/50 **FULL 1.46→1.19, HOLD 1.64→1.15,
|
||||||
|
DD 18.1%→24.6%**. La maggior parte del gap è l'assunzione fill-al-livello del backtest, non il
|
||||||
|
ritardo orario in sé: nei crash le barre 230m **gappano attraverso lo SL software** (short
|
||||||
|
sl2% modellato −2% → realizzato **−11/−23%**: ETH 2021-05-19, 2021-01-04, COVID 2020-03-13).
|
||||||
|
Il disaster-SL on-book −30% copre solo la coda estrema della posizione netta.
|
||||||
|
- **Caveat**: equity daily-step (lens Sharpe, come il canonico); l'ensemble di offset NON è
|
||||||
|
eseguibile live (gira una sola griglia) → serve solo come stima de-luckata; sim hourly senza
|
||||||
|
slippage/parziali.
|
||||||
|
|
||||||
|
## Book 5-sleeve — stima de-luckata congiunta (approssimata)
|
||||||
|
|
||||||
|
Ogni audit varia UN solo sleeve (gli altri restano canonici), quindi gli effetti non si sommano
|
||||||
|
esattamente; ordine di grandezza dell'eredità di fortuna sull'HOLD 2.46: TP01-ancora ~+0.10,
|
||||||
|
XS01-fase ~+0.17, SKH01-offset ~+0.5. **Stima onesta: HOLD ~1.9–2.1, FULL ~2.0–2.2, DD ~6%
|
||||||
|
(invariato)** — il book resta largamente positivo e diversificato a ogni combinazione testata,
|
||||||
|
ma il 2.46 è un massimo di ~24×10×23 configurazioni di ancora, non la stima centrale.
|
||||||
|
|
||||||
|
## Regole codificate in narrativa (CLAUDE.md aggiornato)
|
||||||
|
|
||||||
|
1. **I numeri headline di OGNI sleeve ancorato si citano con la banda d'ancora** (o come ensemble
|
||||||
|
di fase): vale già per TP01, ora anche per XS01 e SKH01 e per l'HOLD del book.
|
||||||
|
2. **Un gate di selezione (es. DD<30%) va verificato sulla banda di fase, non sull'ancora
|
||||||
|
canonica** — SKH01-V2-DD fu selezionata per una proprietà che tiene in 8/23 fasi.
|
||||||
|
3. Il candidato gate `anchor_luck_band()` in altlib sale di priorità: tre sleeve su tre ancorati
|
||||||
|
mostrano la stessa firma, e il deflated-Sharpe non conta questo multiple-testing.
|
||||||
|
|
||||||
|
## Follow-up (non eseguiti, da valutare)
|
||||||
|
|
||||||
|
- **Rivalutare il peso 25% di SKH01 nel book live Deribit**: l'aspettativa onesta della gamba SKH
|
||||||
|
è ≈ fase-mediana + path orario (blend HOLD ~0.72, non 1.17) con gap-through-stop reale nei
|
||||||
|
crash. L'uplift resta positivo a ogni fase → non c'è motivo di rimuoverla, ma il sizing fu
|
||||||
|
deciso su numeri best-of-23.
|
||||||
|
- Cadenza cron a 230m (o trigger sui confini di barra) per chiudere il gap di path orario.
|
||||||
|
- `anchor_luck_band()` in `altlib.py` come gate standard per candidati ancorati.
|
||||||
|
- Audit ora-del-giorno di XS01 quando esisterà un feed HL intraday certificato.
|
||||||
|
|
||||||
|
**Stato:** XS01/SKH01/pesi/book live INVARIATI (nessun cambio operativo da questo audit — è un
|
||||||
|
audit di reporting e di gate, e ogni cambio pesi dovrà passare `weights_tilt_null` +
|
||||||
|
selection-on-holdout). Test suite 168 verdi. Script audit committati.
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# 2026-07-02 — Ondata "video claims": Elliott (3 filoni), Albimarini (2), capital scaling 2-5k
|
||||||
|
|
||||||
|
**Contesto.** Su richiesta: meccanizzare e testare le claim falsificabili di 4 video didattici
|
||||||
|
italiani (3 sulle onde di Elliott, 1 ricostruzione della "strategia Albimarini" — double diagonal
|
||||||
|
a credito su SPX), più l'audit dei muri di scala visto l'annuncio che il conto live verrà portato
|
||||||
|
a **2.000-5.000 $**. Sei agenti indipendenti, harness altlib, fee 0.10% RT, hold-out 2025-26 mai
|
||||||
|
usato per selezionare, banda d'ancora obbligatoria (regola dello stesso giorno). Nessun PASS →
|
||||||
|
nessuno scettico necessario. Nessun file di produzione toccato.
|
||||||
|
|
||||||
|
**Scripts:** `scripts/research/r0702_ell_{rangecycle,fibconfluence,channel}.py`,
|
||||||
|
`r0702_alb_{structure,claims}.py`, `r0702_capital_scaling.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verdetto in una riga
|
||||||
|
|
||||||
|
**Elliott 3/3 SCARTATO** (range-cycle = rumore 0/24 Bonferroni; canale = Donchian travestito che
|
||||||
|
non batte il Donchian; Fibonacci = effetto posizione, non numeri magici). **Albimarini 2/2
|
||||||
|
SCARTATO** (la diagonale non domina nemmeno nel modello ed è fee-negativa su Deribit; il track
|
||||||
|
record dichiarato è consistente con zero skill al 20-45%, e va in rovina in ogni finestra storica
|
||||||
|
con una coda). **Capital scaling: l'unico vincolo vero a 2-5k è il cap $300/asset** — da alzare a
|
||||||
|
equity/2 al momento del deposito; tutto il resto si scioglie da solo o resta fuori comunque.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ELL-A — "range-cycle" (onda 1 compressa → onda 3 ampia): RUMORE
|
||||||
|
|
||||||
|
Claim di Frost&Prechter/Larry Williams meccanizzato senza conteggio soggettivo: 2 definizioni di
|
||||||
|
segmento (flip TSMOM, zigzag k·ATR causale), impulso iniziale (N∈{3,5,10} barre, range/ATR
|
||||||
|
pre-segmento) vs ampiezza del seguito (claim A) e troncatura dell'ultimo terzo (claim B).
|
||||||
|
BTC/ETH 1d+1w, Spearman con null permutato (2000), Bonferroni m=24.
|
||||||
|
|
||||||
|
- **0/24 celle significative** (miglior p raw: A 0.061, B 0.058; Bonferroni 1.00 ovunque, FULL e
|
||||||
|
pre-2025). 7-9/12 celle col segno "giusto" = moneta su celle correlate.
|
||||||
|
- **Nessuna cella weekly regge a tutte e 7 le ancore** (p 0.02-0.05 di singole ancore = il
|
||||||
|
timing-luck d'ancora che da oggi non crediamo più; alcune celle flippano segno tra ancore).
|
||||||
|
- **Artefatto previsto e smascherato**: normalizzando col range POST-impulso la correlazione
|
||||||
|
diventa negativa per pura meccanica del denominatore — chi "conferma" il claim così misura
|
||||||
|
l'artefatto.
|
||||||
|
- Coerente con le famiglie di compressione già fallite nello sweep 104 (BRK08 NR7, BRK10
|
||||||
|
BB-squeeze, CMB05, VOL12). Campione strutturalmente piccolo (mediana 78 segmenti daily, ~30
|
||||||
|
weekly). Strategizzazione correttamente non eseguita (statistica prima della strategia).
|
||||||
|
|
||||||
|
⚠️ **LEZIONE PANDAS (gemella di `view("int64")` del 2026-07-01):** `resample("7D",
|
||||||
|
origin=...)` in pandas 2.x **ignora silenziosamente `origin`** ("D" non è Tick-like, solo un
|
||||||
|
RuntimeWarning) → tutte le ancore weekly uscivano identiche e la banda d'ancora sarebbe stata una
|
||||||
|
banda finta di larghezza zero. Serve **`"168h"`**. Da ricordare per ogni futura banda d'ancora
|
||||||
|
settimanale.
|
||||||
|
|
||||||
|
## ELL-B — Confluenza di Fibonacci: EFFETTO POSIZIONE, non numeri magici
|
||||||
|
|
||||||
|
Zigzag causale non-repainting (livelli usabili solo dalla conferma del pivot, causality_ok
|
||||||
|
max_tail_diff=0.0), tocco fresh intrabar, reazione ATR-normalizzata a 5/20 barre, ~24k/14k tocchi
|
||||||
|
IS a 1h. **Tre null in scala di durezza**: (A) rapporti uniformi casuali, (B) ri-sorteggiati per
|
||||||
|
swing, **(C) location-matched** (Fib ± jitter 2-8%: "0.618 è speciale vs 0.58/0.66?").
|
||||||
|
|
||||||
|
- Vs null A/B i Fib sembrano buoni (pctl 0.82-1.00) — **vs null C crollano a 0.39-0.68 = puro
|
||||||
|
caso**. L'apparente vantaggio è interamente l'effetto di *posizione* (i rapporti Fib cadono in
|
||||||
|
zone mediamente decenti del range), non dei numeri di Fibonacci.
|
||||||
|
- **Confluenza** (2 griglie entro 0.25·ATR): FAIL 4/4 celle — non reagisce più delle confluenze
|
||||||
|
di rapporti casuali. Break-rate al tocco ~46-50% (moneta); su 1h i Fib veri vengono bucati più
|
||||||
|
spesso del placebo.
|
||||||
|
- In assoluto comunque nulla da tradare: +2.6/+5.6 bps a 5 barre (fee = 10 bps), negativo a 20
|
||||||
|
barre e su 1d. Strategizzazione correttamente saltata da gate a priori.
|
||||||
|
- **Pattern di test riusabile**: il null location-matched è IL test giusto per ogni claim su
|
||||||
|
"livelli speciali" (Fib, pivot, round numbers) — separa la qualità della posizione dal numero.
|
||||||
|
|
||||||
|
## ELL-C — Tecnica del canale (Ftaonline): DONCHIAN TRAVESTITO
|
||||||
|
|
||||||
|
Implementazione onesta completa (zigzag causale, vincolo onda-2>origine, canale 0→min-2 con
|
||||||
|
parallela dal max-1, entry al close della barra fuori, target 1.618×, stop=invalidazione,
|
||||||
|
speculare short, 27 celle TF×k×varianti).
|
||||||
|
|
||||||
|
- **family_honest boccia**: cella scelta in-sample (1h k4 long, IS 1.40) → hold-out **−0.87**;
|
||||||
|
DSR 0.685 < 0.95; earns_slot=False. Marginale vs TP01: uplift positivo solo 2020-21 =
|
||||||
|
beta-trend del toro (corr 0.35).
|
||||||
|
- **Il Donchian banale a pari geometria/frequenza eguaglia o batte il canale a ogni TF**
|
||||||
|
(corr 0.43-0.53): vincoli d'onda + 1.618 non aggiungono nulla. Il segnale-2 ("onda 5") è
|
||||||
|
quasi inerte (+0.00-0.07).
|
||||||
|
- **Target 1.618 = geometria, non magia**: toccato con la frequenza di distanze identiche
|
||||||
|
piazzate a caso (5/6 celle p>0.18). Claim discriminante "in canale = correttivo":
|
||||||
|
direzionalmente sensata, mai significativa (p 0.08-0.45); l'uscita dal canale è momentum da
|
||||||
|
breakout, già in casa.
|
||||||
|
- **Anchor-luck di nuovo**: la miglior cella 4h fa hold 1.54 all'ancora 00:00, la migliore delle
|
||||||
|
4 (banda [0.35, 1.54]) — quarta conferma della firma nello stesso giorno.
|
||||||
|
|
||||||
|
## ALB-A — Double diagonal "Albimarini" su BTC/ETH: NON DOMINA, niente LEAD
|
||||||
|
|
||||||
|
Portata su Deribit (che ha le scadenze giornaliere per la long T+1) col motore DVOL di VRP01
|
||||||
|
(riprodotto esatto: 1.09/0.59/11.8%), strike scalati in vol (2σ/5g ≈ il "9% su SPY"), gate
|
||||||
|
IV-rank canonico non riottimizzato, 2021-26 con LUNA/FTX/2022, banda skew f∈{0.6,0.8,1.0,1.3}.
|
||||||
|
|
||||||
|
- **Il claim strutturale del corso è falsificato**: gated, l'iron condor stessa-scadenza batte la
|
||||||
|
diagonale a ogni f (1.05 vs 0.95 a f=1.0), Δ negativo ogni anno. La long T+1 è **assicurazione
|
||||||
|
di coda quantificata** (+12/+33 bps nei 5% peggiori vs ~1 bps di costo medio), non dominanza.
|
||||||
|
- **Senza gate tutte le strutture perdono** a f≤1.0 → terza conferma indipendente: nel VRP
|
||||||
|
l'alpha è il filtro di regime, non la vendita di premio. VRP01 resta superiore su tutta la
|
||||||
|
banda f (CAGR 8.3% vs 1.4%).
|
||||||
|
- **Il meccanismo dell'illusione riprodotto in vitro**: celle z≥2.5 = 0 perdite su 142 trade,
|
||||||
|
Sharpe 5.9-∞ (il punto cieco CC01 in forma opzioni); la best in-sample (7.88) crolla a −0.51
|
||||||
|
in hold-out. Seconda occorrenza del pattern "0-perdite = Sharpe implausibile" in una settimana
|
||||||
|
→ **si rafforza il caso per il gate `implausible_sharpe` in altlib** (raccomandato dal filone
|
||||||
|
CC01, ora con 2 casi d'uso).
|
||||||
|
|
||||||
|
## ALB-B — Audit dei numeri dichiarati (28 trade, 82%, PF 5.16, "420% annuo"): ZERO SKILL
|
||||||
|
|
||||||
|
SPY 1d 1996-2026 (parquet già in repo dal filone GTAA) + BTC/ETH certificati; MC 200k; replay
|
||||||
|
claim-anchored calibrato per riprodurre esattamente il video.
|
||||||
|
|
||||||
|
- **L'82% è incoerente col "deep OTM" in entrambe le letture**: strike a 9% → win strutturale
|
||||||
|
99.5% (P(|move 4td|>9%) = 0.53%, fat-tail 88× la normale); 82% letterale → strike a 2.63% ≈
|
||||||
|
1.2σ, non deep. In entrambi i casi win-rate = delta venduto.
|
||||||
|
- **P(track record del video senza edge) = 20-45%**; il **78.6%** di tutte le finestre 6-mesi
|
||||||
|
1996-2026 lo avrebbe prodotto (lettura deep). Il PF ~5.1 esce per costruzione quando la coda
|
||||||
|
non è campionata.
|
||||||
|
- **Replay attraverso le code reali**: rovina nel 1998/2002/2020 col sizing 1→4 dichiarato;
|
||||||
|
perfino regalando +20% di premio, 1 contratto fisso fa CAGR +4.1% con maxDD −95%. La stessa
|
||||||
|
semestrale del video annualizza a +73-77%, non 420%. Su BTC/ETH a EV=0: +12%/DD−68% e
|
||||||
|
+0%/DD−88%.
|
||||||
|
- **La diagonale lascia passare il 12-40%** della perdita naked in un −10% overnight (dipende dal
|
||||||
|
vol-spike). Su Deribit la double diagonal è **fee-negativa a qualsiasi size** (fee 8 gambe =
|
||||||
|
194-221% del theta raccolto a questi strike): il capitale sposta solo il muro di margine.
|
||||||
|
- Che cosa distingue VRP01 (sopravvissuto allo stesso tipo di audit): defined-risk per
|
||||||
|
costruzione, gate IV-rank che ribalta l'hold-out, sizing fisso — l'opposto esatto su tutti e
|
||||||
|
tre gli assi.
|
||||||
|
|
||||||
|
## Capital scaling 600 → 2000/3500/5000 (r0702_capital_scaling.py)
|
||||||
|
|
||||||
|
| capitale | TP01 haircut Sh | book investibile % (cap $300) | K=2 exec-turn % | statarb haircut | spread ETH conc. | XS01 exec-turn % |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 600 | +0.010 | 100.0% | 90.5 | −0.004 | 0 | 56.9 |
|
||||||
|
| 2000 | −0.002 | 85.9% | 95.7 | 0.000 | 3 | 87.4 |
|
||||||
|
| 3500 | −0.002 | 63.7% | 97.5 | 0.000 | 5 | 89.3 |
|
||||||
|
| 5000 | −0.002 | **48.9%** | 98.2 | 0.000 | 7 | 90.8 |
|
||||||
|
|
||||||
|
- **Il vincolo dominante è UNO: `max_notional_per_asset_usd`=300.** A 5k il book girerebbe a
|
||||||
|
metà del target (cap binding nel 39-46% delle barre). **Proposta config (da applicare AL
|
||||||
|
deposito): cap = equity/2** → $1000 a 2k, $1750 a 3.5k, $2500 a 5k. `min_order_usd`=5 va
|
||||||
|
LASCIATO (il fee drag risale solo al tetto noto ~0.4%/anno; l'isteresi artificiale costerebbe
|
||||||
|
più in lag — lezione ondata timing).
|
||||||
|
- Min-order/haircut TP01 ≈ 0 a ogni capitale (conferma indipendente del "−74% ordini" a 600 →
|
||||||
|
39-43% saltati a 5k, tutti micro). Statarb: haircut 0 da 2k (il muro era l'edge, non la scala).
|
||||||
|
- Tranching K=2: matematicamente regge a 5k (98.2% turnover-tranche), ma il blocco vero resta il
|
||||||
|
feed intraday fuori path certificato + beneficio solo di varianza → **non cablare**.
|
||||||
|
- Opzioni: il muro d'ingresso ETH cade a ~2.6k (3-7 spread concorrenti a 2-5k, fee solo 5-6% del
|
||||||
|
credito grazie al cap 12.5%; rettifica vs ALB-A: margine spread ETH oggi $66-76). BTC granulare
|
||||||
|
da ~$4.7k. **La regola "niente short-vol da modello" non decade col capitale.**
|
||||||
|
- XS01: soglia ~20k confermata (a 5k il 9%+ del turnover non esegue). CC01: il capitale non è
|
||||||
|
mai stato il muro (fuori per ragioni strutturali).
|
||||||
|
- **Aspettativa onesta**: col CAGR de-luckato del book live (10-15%, audit SKH01): **2k ≈
|
||||||
|
€0.55-0.82/giorno; 5k ≈ €1.37-2.05/giorno**. Il salto di capitale cambia cosa è eseguibile,
|
||||||
|
non l'ordine di grandezza del ritorno (€50/g ≈ 130k, invariato).
|
||||||
|
|
||||||
|
## Lezioni
|
||||||
|
|
||||||
|
1. **Il null giusto smonta il livello "speciale"**: location-matched (ELL-B) per i livelli,
|
||||||
|
Donchian-equivalente (ELL-C) per i pattern, EV=0-calibrato (ALB-B) per i track record. In
|
||||||
|
tutti e tre i casi il claim sopravviveva ai null ingenui e moriva su quello giusto.
|
||||||
|
2. **`resample("7D", origin=...)` ignora origin in pandas 2.x** → usare `"168h"` per le bande
|
||||||
|
d'ancora settimanali.
|
||||||
|
3. Secondo caso "0-perdite = Sharpe implausibile" (celle deep-OTM di ALB-A, dopo CC01) → il gate
|
||||||
|
`implausible_sharpe` in altlib sale in coda ai lavori insieme ad `anchor_luck_band`.
|
||||||
|
4. L'anchor-luck è comparso spontaneamente anche in questa ondata (celle weekly ELL-A, 4h ELL-C)
|
||||||
|
— la regola della banda d'ancora sta già pagando.
|
||||||
|
|
||||||
|
**Stato:** nessun nuovo sleeve, nessun forward-monitor aggiunto, book live INVARIATO. Un'azione
|
||||||
|
operativa pende sul FUTURO deposito: alzare `max_notional_per_asset_usd` a equity/2 (decisione
|
||||||
|
utente al momento del funding). Test suite verde. 6 script committati.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# 2026-07-02 — Ondata timing + CRT (8 filoni multi-agente + scettico): 0 nuovi sleeve, 1 finding strutturale (anchor timing-luck di TP01)
|
||||||
|
|
||||||
|
**Obiettivo (goal utente):** "cerca su Deribit altre strategie profittevoli magari con timing
|
||||||
|
differenti oltre a quelle già valutate". Prima ondata interamente dedicata al TIMING: dopo che le
|
||||||
|
ondate precedenti hanno coperto segnali (104 famiglie alt-sweep), universi (HL cross-sectional),
|
||||||
|
opzioni (VRP/gamma) e pesi (EW-STR/weights_tilt_null), qui si attacca la dimensione rimasta: *quando*
|
||||||
|
si campiona, si decide e si esegue. Su richiesta utente, seconda tranche dedicata alla **CRT (Candle
|
||||||
|
Range Theory)**, il pattern sweep-and-reclaim a 3 candele di scuola ICT/Wyckoff.
|
||||||
|
|
||||||
|
Inventario preventivo per non rifare lavoro: SEA01-09 coprivano già hour-of-day, day-of-week,
|
||||||
|
weekend, turn-of-month, sessioni; l'onda intraday 2026-06-21 il sub-daily (con il lead prevday in
|
||||||
|
forward-monitor); `tp01_lowfreq` i TF 4h/12h/1d. Angoli NON coperti individuati e testati: clock a
|
||||||
|
eventi, calendario scadenze Deribit, ancora di ribilanciamento, clock più lenti del daily, velocità
|
||||||
|
del segnale regime-condizionata — più i 3 tagli CRT (base, multi-TF, contesto).
|
||||||
|
|
||||||
|
**Esito: 7 filoni FAIL + 1 finding strutturale confermato dallo scettico (timing-luck dell'ancora
|
||||||
|
di TP01). Nessun nuovo sleeve. Nessuna modifica al book live. Il soffitto ~1.3 direzionale
|
||||||
|
BTC/ETH resta intatto anche cambiando il clock.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filone 1 — Event-clock bars (tempo-informazione): FAIL
|
||||||
|
|
||||||
|
`scripts/research/r0702_eventclock.py`. Volume/vol/range bars da 5m certificato (soglie causali
|
||||||
|
EWMA-90g shift(1), barre medie 4h/12h/24h), 5 strategie (TSMOM multi-orizzonte, Donchian 10/30d,
|
||||||
|
EWMA-cross), 45 celle event + 15 controlli wall-clock = 60 trial.
|
||||||
|
|
||||||
|
- Null decisivo (stesso segnale, stessa frequenza, wall-clock): event batte wall su ENTRAMBE le
|
||||||
|
finestre solo in **4/45 coppie (9%)** — rumore. Pattern incoerenti (EWMA event vince IS 9/9 ma
|
||||||
|
perde HOLD 9/9; Donchian-30d l'esatto opposto = selection-on-holdout in agguato).
|
||||||
|
- Cella scelta in-sample (volume-bars 24h, DONCH-10d): IS 1.45 → **HOLD −0.46** (2025 −7.4%,
|
||||||
|
2026 −18.1%). Marginal NEUTRAL (corr 0.74 a TP01 = trend travestito), earns_slot_honest=False.
|
||||||
|
- Executability comunque assente: in alta attività le barre "24h" chiudono in 3.6-4.6h (p5) → il
|
||||||
|
cron orario le eseguirebbe in ritardo; i clock 4h richiedono monitoraggio sub-orario.
|
||||||
|
|
||||||
|
**Lezione: campionare a tempo-informazione non normalizza nulla di monetizzabile sul feed
|
||||||
|
certificato; il clock non è la dimensione dove vive l'edge.**
|
||||||
|
|
||||||
|
## Filone 2 — Calendario scadenze Deribit: FAIL (0/24 celle)
|
||||||
|
|
||||||
|
`scripts/research/r0702_expiry_calendar.py`. Griglia dichiarata a priori: 4 finestre × 3 tipi expiry
|
||||||
|
(weekly ven 08:00 UTC / monthly ultimo ven / quarterly) × 2 asset, Bonferroni |t|≥3.08; 3 null
|
||||||
|
(placebo weekday, anchor-shift ±2/4h, permutation 500 calendari).
|
||||||
|
|
||||||
|
- Nessuna cella passa Bonferroni (max |t| 2.69). Il post-expiry drift monthly (+0.61/+0.77%) ha il
|
||||||
|
segno della teoria ma: non batte il placebo giovedì, permutation pctl 84-95 (non estremo), si
|
||||||
|
INVERTE sul quarterly (dove l'OI massimo dovrebbe amplificarlo) ed è guidato dal 2019 → rumore.
|
||||||
|
- Unica cella robusta ai null: weekly [-24,0) negativa (gio→ven), ma è indistinguibile dal
|
||||||
|
day-of-week (famiglia SEA già morta) e netta fee fa Sharpe ~0.08 (52 eventi/anno × 0.10% RT).
|
||||||
|
- Best-in-sample tradabile: IS +0.90 → **HOLD −0.52**; DSR 0.230 su 24 trial.
|
||||||
|
- Nota tecnica: colpito ESATTAMENTE il pitfall pandas 2.x documentato (epoca in secondi da
|
||||||
|
`date_range` su tz-aware → matching vuoto); fix con epoca ms esplicita. La lezione 2026-07-01
|
||||||
|
ha pagato.
|
||||||
|
|
||||||
|
## Filone 3 — Anchor timing-luck di TP01 + tranching: FINDING (verificato dallo scettico)
|
||||||
|
|
||||||
|
`scripts/research/r0702_tp01_offset.py` + scettico indipendente `r0702_skeptic_offset.py`
|
||||||
|
(ricostruzione bit-exact con codice diverso, zero riuso; h=0 riproduce `tp01_baseline_daily`
|
||||||
|
esattamente; vol-target ricalcolata per ancora; nessun look-ahead — verificato con troncamenti).
|
||||||
|
|
||||||
|
**Claim 1 CONFERMATO: l'hold-out 2025-26 "Sharpe 0.31" di TP01 è in larga parte fortuna
|
||||||
|
dell'ancora 00:00 UTC.** La stessa strategia CANONICAL alle altre 23 ancore orarie: HOLD mediano
|
||||||
|
**0.04**, banda **[−0.13, +0.30]** — e il massimo è proprio h=0 (98° pctl; in-sample era al 10°:
|
||||||
|
sfortunato lì, fortunato qui). Spike, non plateau (h=23/h=1 fanno 0.13/0.04). Bootstrap dello
|
||||||
|
scettico: P(una qualsiasi ancora mostri uno spike ≥ così) = **0.86** — è il massimo atteso di 24
|
||||||
|
stime correlate, non un'anomalia; best-anchor per finestra annuale ruota a caso (16→23→21→12→5→0,
|
||||||
|
Spearman ≈ 0, nessuna seasonality d'ancora). Formulazione corretta (NON "il vero hold-out è 0.07",
|
||||||
|
che ha CI95 [−1.2,+1.4] su 547 giorni): **l'hold-out 2025-26 non risolve l'edge di RITORNO di TP01**
|
||||||
|
(ritorno totale mediano ancore ≈ 0%); ciò che regge A OGNI ancora è il claim DIFENSIVO
|
||||||
|
(DD 6.7-10.1% vs ~60% B&H). Che è da sempre il vero valore dichiarato di TP01.
|
||||||
|
|
||||||
|
**Claim 2 (tranching multi-ancora) RIDIMENSIONATO:** K=4 ≡ esattamente EW di 4 book ancorati
|
||||||
|
(diff 1.4e-17), turnover identico (~8.3/y), haircut $600 ≈ 0. MA il "miglioramento" IS 1.49→1.56 è
|
||||||
|
per ~90% *tornare alla media delle ancore* (h=0 era sfortunato in-sample): vs ancora tipica
|
||||||
|
+0.005 mediano, P(K4≤h0)=0.18 n.s.; il taglio DD strutturale è ~0.5pt (non 2.8: la mediana delle
|
||||||
|
singole è già 12.6%). Ciò che regge: la compressione della VARIANZA DELLA STIMA (HOLD
|
||||||
|
[−0.12,+0.30]→[−0.01,+0.13] a K=4) → **lens di reporting, non alpha**.
|
||||||
|
|
||||||
|
**Impatto a valle (quantificato dallo scettico):**
|
||||||
|
- Blend SKH: HOLD 1.16 (h=0) → 0.97 mediana ancore, ma l'UPLIFT a mediana è +0.93 > +0.86
|
||||||
|
dichiarato → **il verdetto ADDS di SKH01 era conservativo, regge**.
|
||||||
|
- Book 5-sleeve: HOLD 2.44 → 2.34 mediana (min 2.22), FULL 2.24→2.22 → eredita **~+0.10** di
|
||||||
|
fortuna d'ancora (non +0.25: il peso di TP01 diluisce). Nessuna decisione (GTAA/SKH/pesi) cambia.
|
||||||
|
- NON toccati: tutti i FULL (h=0 al 31° pctl = normale), plateau, deflated-Sharpe, fee-sweep,
|
||||||
|
causalità, validazione GTAA (equity OOS), gate weights_tilt_null (relativo).
|
||||||
|
|
||||||
|
**Raccomandazione operativa (sottoscritta dallo scettico): cambio di narrativa e di standard di
|
||||||
|
reporting, NESSUN cambio del book live oggi.** (1) I numeri hold-out di TP01 si citano d'ora in poi
|
||||||
|
come banda d'ancora (mediana ~0.04, [−0.13,+0.30]) con valore = taglio DD; (2) valutazione
|
||||||
|
anchor-agnostic (media 24 ancore) come lens di reporting per i futuri hold-out; (3) NO deploy
|
||||||
|
K=2/K=4 a $600: guadagno ≈ 0, e a $225/asset di quota TP01 i delta per-ancora (~$1-2) sono sotto il
|
||||||
|
min-order $5 → degenererebbe comunque in K=1; inoltre le ancore intraday richiederebbero un feed
|
||||||
|
fresco fuori dal path certificato (staleness ×4 finestre di guasto). **Rivalutare K=2 (0,12) a
|
||||||
|
capitale ≥ ~5-10k.** (4) ⚠️ Audit analogo RACCOMANDATO su XS01 (rebalance 10g ancorato = spazio di
|
||||||
|
luck 10g×24h, potenzialmente peggiore) e SKH01 (fase griglia 230m/690m) — TP01 è oggi l'unico
|
||||||
|
sleeve de-luckato.
|
||||||
|
|
||||||
|
## Filone 4 — Clock lenti + banded rebalancing: FAIL (negativo utile)
|
||||||
|
|
||||||
|
`scripts/research/r0702_slow_clock.py`. Fatto a monte che chiude il filone: il fee drag di TP01 a
|
||||||
|
1d è **~0.40%/anno ≈ 0.03 Sharpe** (8 turnover/y × 0.10% RT) — il tetto di QUALSIASI risparmio.
|
||||||
|
- Clock N∈{2,3,5,7}g: degrado monotono dell'ensemble di fase (HOLD 0.34→0.11 da N=2 a N=7); lo
|
||||||
|
spread TRA fasi esplode a N≥5 (HOLD −0.19…+0.37 a N=7) = timing luck pura, coerente col filone 3.
|
||||||
|
- Bande di isteresi {2.5,5,10,20}%: tagliano il 77-94% degli ORDINI ma quasi zero TURNOVER (gli
|
||||||
|
ordini di TP01 sono micro-aggiustamenti del vol-target). Cella best-IS (band 20%): hold-out
|
||||||
|
0.13 vs 0.30 baseline, e il "guadagno" IS è effetto-segnale (posizioni stantie fortunate), non
|
||||||
|
effetto-costo → fitting. Non-monotonia sulla griglia = firma di rumore.
|
||||||
|
- **Finding utile per il live: a $600 il vincolo min-order $5 È GIÀ la banda ottimale** (ordini
|
||||||
|
427→111/y a costo Sharpe ~0, banda implicita 1.67%): cattura ~100% del risparmio catturabile.
|
||||||
|
Nessun cambio al book, a nessun capitale testato (600/2k/10k).
|
||||||
|
|
||||||
|
## Filone 5 — Velocità del trend regime-condizionata: FAIL
|
||||||
|
|
||||||
|
`scripts/research/r0702_regime_speed.py`. Pesi tra gli orizzonti TSMOM 30/90/180g condizionati al
|
||||||
|
percentile espandente di vol (realized e DVOL), 16 celle, sanity = riproduzione esatta del canonico.
|
||||||
|
- Cella best-IS (alta-vol→lento, linear, rv): FULL +0.06 ma **HOLD BTC −0.31**, 50/50 +0.005 →
|
||||||
|
dominanza fallita; multi-cut a segno instabile (+0.18/−0.08/+0.00).
|
||||||
|
- Null decisivo (300 pesi statici Dirichlet): la cella sta al pctl 0.71-0.72, sotto il p90 —
|
||||||
|
un peso statico casuale la batte spesso. Meccanismo smascherato: corr(peso-30d, Sharpe) = +0.93 →
|
||||||
|
le celle "regime" vincono in-sample perché tengono il tilt-30d la maggior parte dei giorni =
|
||||||
|
**tilt statico travestito da regime**, la stessa trappola di EW-STR. E il tilt statico verso il
|
||||||
|
30d NON regge su BTC hold-out (−0.15). RV e DVOL indistinguibili come misura di regime (coerente
|
||||||
|
con l'esito overlay DVOL 2026-06-26). Pesi canonici 1/3-1/3-1/3 confermati.
|
||||||
|
|
||||||
|
## Filoni 6-8 — CRT "Candle Range Theory" (base / multi-TF / contesto): FAIL 3/3
|
||||||
|
|
||||||
|
Il pattern sweep-and-reclaim a 3 candele (C1 range forte; C2 rompe un estremo ma chiude dentro =
|
||||||
|
"manipolazione"; C3 ingresso contro il breakout, stop dietro lo sweep, target all'estremo opposto),
|
||||||
|
meccanizzato onestamente in tre tagli. Overlap dichiarato: MRV01-11 e MIC07 non coprivano questa
|
||||||
|
meccanica; ora è coperta.
|
||||||
|
|
||||||
|
**6. Base single-TF** (`r0702_crt_base.py`, 864 trial su 1h/4h/12h/1d, motore trade-level
|
||||||
|
conservativo SL-first cross-checkato con l'harness): **DSR 0.000** (il null best-of-grid atteso è
|
||||||
|
Sharpe 2.27, ottenuto 0.74); cella best-IS (4h long color-rule) IS 0.90 → HOLD 0.07 con 2026 a
|
||||||
|
WR 0%; anchor-shift +1/+2h flippa l'hold-out a −1.0 (artifact-risk); lo **short su sweep dell'alto
|
||||||
|
(la narrativa smart-money canonica) perde perfino in-sample (−0.59)**. Autopsia: l'expectancy IS
|
||||||
|
veniva dal time-exit in trend (beta del toro ETH 2021-23), non dal target strutturale. Dettaglio
|
||||||
|
informativo: CRT batte nettamente sia il fade incondizionato (IS −0.49) sia il breakout-confermato
|
||||||
|
(IS −0.31) → il close-back-inside FILTRA davvero tossicità, ma "meno tossico del fade morto" non è
|
||||||
|
un edge.
|
||||||
|
|
||||||
|
**7. Multi-timeframe** (`r0702_crt_mtf.py`, 4h→15m e 1h→5m, ~10k trade): expectancy netta negativa
|
||||||
|
OVUNQUE (FULL e HOLD, entrambi gli asset); il MTF alza il R:R medio da ~3 a ~10-20 ma il WR collassa
|
||||||
|
da ~36% a 9-16% → "migliora" solo perdendo meno. **Refutazione strutturale: il ritest è informazione
|
||||||
|
negativa** — pattern CON ritest −40 bps, SENZA +52 bps (non tradabile: condiziona sul futuro):
|
||||||
|
aspettare il ritest per entrare seleziona sistematicamente i pattern peggiori; i buoni scappano
|
||||||
|
subito e il metodo non li prende mai. A fee ZERO l'edge lordo è ~0 (non è morte per fee: non c'è).
|
||||||
|
Stop 0.1-0.35% ineseguibili col cron orario (26-75% dei segnali già invalidati all'esecuzione).
|
||||||
|
|
||||||
|
**8. Contesto** (`r0702_crt_context.py`, 22 trial: livelli prevday/Donchian/prevweek × equal-H/L ×
|
||||||
|
FVG × sessioni): baseline incondizionata morta (8/8 celle HOLD<0); FVG semmai peggiora; sessione
|
||||||
|
EU consistentemente negativa; unica cella IS-positiva (sweep in Asia su livello prevday) uccisa 4
|
||||||
|
volte (DSR 0.001, anchor-flip a −2/−4h con la firma di open_drive — la sessione "Asia" inizia dove
|
||||||
|
il livello prevday viene ricreato —, solo-ETH, morta a 0.20% fee). **Sottoprodotto prezioso: sugli
|
||||||
|
STESSI livelli prior-day, FOLLOW batte FADE ogni singolo anno 2019-2026** (follow IS/HOLD
|
||||||
|
+1.22/+1.25 vs fade −0.66/−1.46, corr −0.19/−0.27) → conferma indipendente e rafforzativa del lead
|
||||||
|
prevday-breakout in forward-monitor (paper_prevday).
|
||||||
|
|
||||||
|
**Verdetto CRT: concetto Wyckoff riverniciato che, spogliato della discrezionalità, non contiene
|
||||||
|
edge su BTC/ETH Deribit certificati. La direzione giusta sui livelli affollati resta il FOLLOW
|
||||||
|
(breakout), non il fade — coerente con SKH01 (breakout) vivo e mean-reversion morta.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lezioni codificabili
|
||||||
|
|
||||||
|
1. **Il timing-luck dell'ancora è una dimensione di multiple-testing NON coperta dai gate
|
||||||
|
esistenti** (deflated-Sharpe conta i trial, non le ancore implicite). Nuova regola di reporting:
|
||||||
|
ogni metrica hold-out di una strategia a ribilanciamento ancorato si cita con la banda d'ancora
|
||||||
|
(o la media anchor-agnostic). Candidato gate futuro: `anchor_luck_band()` in altlib. Audit
|
||||||
|
pendente: XS01 (10g), SKH01 (fase 230m/690m).
|
||||||
|
2. **La famiglia day_boundary_robust / anchor-shift continua a uccidere** (CRT base, CRT-Asia,
|
||||||
|
6/24 celle expiry): ogni effetto legato a etichettatura di barre VA spostato d'ancora prima di
|
||||||
|
crederci. Confermata la regola 2026-06-21.
|
||||||
|
3. **Il fee drag di TP01 (~0.4%/anno) non è un problema da risolvere** — chiude a priori il filone
|
||||||
|
"esecuzione più furba" a questo turnover; il min-order small-cap è già la banda ottimale.
|
||||||
|
4. Il pitfall pandas tz-aware/epoca (2026-07-01) è stato evitato/gestito 2 volte su 9 agenti grazie
|
||||||
|
alla documentazione in CLAUDE.md — il costo di documentare i bug paga.
|
||||||
|
|
||||||
|
## Stato finale
|
||||||
|
|
||||||
|
- 0 nuovi sleeve; portafoglio e book live INVARIATI (5-sleeve 33/15/12/20/20; book Deribit
|
||||||
|
TP01+SKH01 75/25, flat da armamento — il segnale resta risk-off).
|
||||||
|
- Narrativa TP01 aggiornata (CLAUDE.md + docstring): hold-out = banda d'ancora, valore = difesa DD.
|
||||||
|
- Script: `scripts/research/r0702_{eventclock,expiry_calendar,tp01_offset,slow_clock,regime_speed,
|
||||||
|
crt_base,crt_mtf,crt_context,skeptic_offset}.py`. Test suite: invariata, verde.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# 2026-07-03 — Frontiera rischio/rendimento del book: quanto caldo correre a 2k-5k
|
||||||
|
|
||||||
|
**Contesto.** Dopo la diagnosi strategica ("€50/g è un problema di capitale e di frontiera, non di
|
||||||
|
alpha; hai costruito una macchina difensiva a DD 6% ma hai capitale piccolo e obiettivo di
|
||||||
|
crescita"), goal: **misurare** (non cercare segnali) la frontiera CAGR/DD/P-rovina in funzione del
|
||||||
|
target-vol, pesata per confidenza per-sleeve, a €2k e €5k — per decidere quanto caldo girare il book.
|
||||||
|
Workflow: 3 filoni (curva / onestà-della-coda / eseguibile+Kelly), refuter fat-tail su ciascuno,
|
||||||
|
scettico sintetizzatore. 7 agenti, 0 errori. Sanity bit-exact di entrambi i book prima di ogni numero.
|
||||||
|
|
||||||
|
**Scripts:** `scripts/research/r0703_frontier_{curve,tail,exec}.py`. Nessun file di produzione
|
||||||
|
toccato, nessun commit dagli agenti, `config/live.json` NON modificato (cap solo proposto).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verdetto in una riga
|
||||||
|
|
||||||
|
**La tesi "corri più caldo" era in gran parte un MIRAGGIO DI REGIME.** Il "~6% attuale" non è un
|
||||||
|
target scelto: è la vol *realizzata* in regime risk-off (TP01 spesso flat 0.0x → il conto è flat
|
||||||
|
*by design*). A lambda=1 il book LIVE a 2 sleeve gira **già a ~11% vol / 9.4% DD**. Quindi "saltare
|
||||||
|
da 6% a 10%" NON è aggiungere leva — è solo assumere che il book non sia risk-off. **Il numero
|
||||||
|
onesto è: gira alla vol NATIVA (~10%, banda 8-11%), non levare.** Guadagno reale vs il 6% risk-off:
|
||||||
|
**+€0.2/g @2k, +€0.5/g @5k** — reale ma modesto, comprato con un DD plausibile di ~€500 su €2k. €50/g
|
||||||
|
resta ~130k, invariato.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## I numeri (frontiera fidata, non full-Sharpe)
|
||||||
|
|
||||||
|
Sanity (deriva nulla): 5-sleeve FULL Sh **2.238** / HOLD 2.452 / DD 6.17% / vol nativa 7.84%;
|
||||||
|
2-sleeve live FULL Sh **1.776** / HOLD 1.162 / DD 9.42% / vol nativa 10.96%.
|
||||||
|
|
||||||
|
**Scale-invarianza confermata da tutti e 3 i refuter:** il capitale NON cambia la matematica del
|
||||||
|
target-vol (le tabelle sono in %), cambia solo l'ESEGUIBILITÀ → **stesso % a 2k e 5k.**
|
||||||
|
|
||||||
|
### Book LIVE 2-sleeve (TP01+SKH01) — l'unico eseguibile <20k
|
||||||
|
- **Target-vol raccomandato: ~10%, banda [8%, 11%], lambda ≈ 0.9.** Sopra la vol nativa non si va.
|
||||||
|
- €/giorno al punto fidato (haircut): CAGR full-sample 12.9% → €0.71@2k / €1.77@5k; ma **HOLD-onesto**
|
||||||
|
(de-luck) CAGR ~4.5% → **€0.25@2k / €0.62@5k**. Il lato-reward headline è ~3× l'onesto (HOLD≪FULL).
|
||||||
|
- **Stima centrale onesta: €0.3-0.7/g @2k, €0.8-1.8/g @5k.**
|
||||||
|
|
||||||
|
### Book PIENO 5-sleeve — ASPIRAZIONALE, non gira <20k
|
||||||
|
Frontiera fidata regge ~12-15% sotto tail estremo, ~20% sotto tail mite — ma XS01 serve ~20k e VRP01
|
||||||
|
è modellato → rilevante solo per il tracking di `paper_combo`, non per una decisione di deploy a 2-5k.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## I quattro insegnamenti strutturali
|
||||||
|
|
||||||
|
### 1. La rovina-50% è la metrica SBAGLIATA (trappola esposta dal curve-refuter)
|
||||||
|
P(rovina-50%|5y) resta <1% fino a ~20-22% di target-vol su ogni book — i book sono troppo lisci/
|
||||||
|
diversificati per bruciare metà capitale entro la distribuzione campionaria. **La risposta letterale
|
||||||
|
"max CAGR a P(rovina)<1%" direbbe "gira al 22%" — ed è un suicidio**: lì P(DD>30%|5y) ≈ 20-25%. Il
|
||||||
|
vincolo che MORDE è **P(DD>30%), super-lineare** (raddoppia ~ogni 2.5 punti di vol). E la rovina-50%
|
||||||
|
non è nemmeno robusta ad alta vol (block-15 vs block-20 danno 7.1% vs 0.9% a tv30%) → altro motivo
|
||||||
|
per stare bassi.
|
||||||
|
|
||||||
|
### 2. Il muro fat-tail ONESTO è ~12%, più basso della carta (~15%)
|
||||||
|
Tre ragioni che il modello sottostima: **(a) gap-through-stop SKH già osservato reale** (sl2%
|
||||||
|
modellato → −11/−23% realizzato — la coda vera NON è nei rendimenti modellati; l'iniezione dei
|
||||||
|
finder usava lo *clean-stop* → ~2× troppo mite); **(b) il 2-sleeve è 4× più crash-sensibile del
|
||||||
|
5-sleeve** (+14.5pt vs +3.5pt di P(DD>30) all'iniezione, diversificazione sottile); **(c)
|
||||||
|
vol-targeting a lambda costante lagga nello spike** → sei più levato *entrando* nel crash.
|
||||||
|
**LIVE 2-sleeve: non superare ~12%; mai 15-20%** (triplica il DD-in-euro senza margine di coda).
|
||||||
|
|
||||||
|
### 3. Aggressività differenziata-per-confidenza (TP01 più caldo di VRP) — REFUTATA
|
||||||
|
La mia proposta era intuitiva ma **sbagliata, e i numeri lo dicono netto**: differenziare la vol per
|
||||||
|
confidenza PEGGIORA la frontiera (5s Sharpe 2.24→2.11, 2s 1.78→1.69) perché **taglia la
|
||||||
|
diversificazione** di VRP/XS (corr uniforme-vs-differenziato 0.98-0.99). **Regola corretta: la bassa
|
||||||
|
fiducia sull'EDGE si esprime DE-MEANando lo sleeve in aspettativa (= la frontiera haircut), NON
|
||||||
|
riducendone l'esposizione/vol.** La confidenza vive nell'haircut della media, non nella leva. Tutti
|
||||||
|
gli sleeve alla stessa leva.
|
||||||
|
|
||||||
|
### 4. La diversificazione LIMA la coda sinistra in-sample (sorpresa onesta)
|
||||||
|
Per il 5-sleeve ben diversificato il block-bootstrap dà P(DD>30) SOTTO il Gaussiano iso-vol (30%:
|
||||||
|
10.6% reale vs 21.1% gauss) — la coda in-sample è benigna. Il segnale fat-tail vive in due posti:
|
||||||
|
l'**iniezione di crash sintetico** (il campione non contiene il crash peggiore di ogni sleeve) e
|
||||||
|
il **book live sottile** (2 gambe, drift haircut) dove il bootstrap SUPERA il Gaussiano. Cioè: la
|
||||||
|
frontiera è affidabile finché il futuro somiglia al passato campionato, e fragile esattamente dove
|
||||||
|
le storie sono corte (VRP mai stressato reale, XS 2.5y, nessun 2018/pre-2018).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Azione concreta (proposta, NON applicata)
|
||||||
|
|
||||||
|
1. **Nessun cambio di leva sul codice.** Il book gira già ≈ al punto fidato: LASCIARE `target_vol=20%`
|
||||||
|
di TP01 (canonico, la vol nativa del book emerge a ~8-11%). Non c'è una leva da girare su.
|
||||||
|
2. **⚠️ L'UNICO cambio che conta: `config/live.json` `max_notional_per_asset_usd` da $300 a
|
||||||
|
equity/2** ($1000@2k / $1750@3.5k / $2500@5k). Senza, il cap $300 tappa la vol raggiungibile a
|
||||||
|
13%@2k / 8%@3.5k / **6%@5k** → **throttling paradossale: più capitale = book più freddo**, forzato
|
||||||
|
sotto il punto fidato. Da applicare AL deposito. (config NON toccato ora.)
|
||||||
|
3. `min_order_usd=5`: LASCIARE (banda d'isteresi gratuita). Tranching K=2: NON cablare. Vol
|
||||||
|
differenziata: NON implementare (Q3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Brutalità sui limiti
|
||||||
|
|
||||||
|
- **Storie corte = il difetto strutturale.** TP01/SKH dal 2019 (no 2018), VRP dal 2021, XS **2.5
|
||||||
|
anni** (no 2022). Il block-bootstrap non può campionare un crash mai visto; l'iniezione sintetica
|
||||||
|
è l'unico proxy, e quella dei finder era ~2× troppo mite.
|
||||||
|
- **VRP01 è MODELLATO** (f-di-stress mai catturato su un crash reale) → l'haircut −50% è stima, non
|
||||||
|
misura; resta STAT-MODE anche a 5k (regola: niente short-vol da modello in deploy).
|
||||||
|
- **HOLD ≪ FULL sul book live** (1.78 vs 1.16) → il lato-reward headline è in gran parte artefatto
|
||||||
|
full-sample; citare i €/g su base HOLD-onesta.
|
||||||
|
- **€50/g NON è risolto e non lo sarà da questo.** Il warm-up compra ~€0.2/g@2k. La via resta
|
||||||
|
**capitale + tempo**; la regola "niente leva alta come base" è rispettata (la raccomandazione è
|
||||||
|
"gira alla vol nativa", non "corri caldo").
|
||||||
|
|
||||||
|
**Numero unico da portare a casa:** book LIVE a **~10% target-vol (lambda ≈ 0.9, banda 8-11%)**,
|
||||||
|
uguale a 2k e 5k, con **cap → equity/2** come unica azione config; muro invalicabile a **~12%**;
|
||||||
|
guadagno reale vs il 6% risk-off ~**+€0.2/g@2k / +€0.5/g@5k** — reale ma modesto.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 2026-07-03 — Cap notional per-asset DINAMICO (= equity/2): operazionalizza la decisione frontier
|
||||||
|
|
||||||
|
**Contesto.** La frontiera del 2026-07-03 (`docs/diary/2026-07-03-book-frontier.md`) aveva identificato
|
||||||
|
**una sola** azione config con impatto reale: portare `max_notional_per_asset_usd` da $300 fisso a
|
||||||
|
**equity/2**, perché il cap fisso strozza il book man mano che si deposita (paradosso: più capitale =
|
||||||
|
book più freddo — a $5k il cap $300 fa girare il libro al ~49% del target). Il diario la lasciava
|
||||||
|
"da applicare AL deposito". Qui la **operazionalizzo in codice** rendendo il cap dinamico, così è
|
||||||
|
sempre corretta e non richiede un edit manuale al deposito.
|
||||||
|
|
||||||
|
## Cosa è cambiato
|
||||||
|
|
||||||
|
- **`src/live/book.py` — `_cap()`**: se in config c'è `max_notional_per_asset_frac` (opt-in), il cap
|
||||||
|
per-asset diventa **`equity × frac`** (0.5 → equity/2). Senza la chiave → comportamento vecchio
|
||||||
|
(cap fisso `max_notional_per_asset_usd`). Il call site in `book_report()` passa
|
||||||
|
`equity`, `real_equity`, `eq_fallback` a `_cap()`.
|
||||||
|
- **`config/live.json`**: aggiunto `max_notional_per_asset_frac: 0.5` (+ nota). `max_notional_per_asset_usd`
|
||||||
|
resta come **cap fisso di fallback**.
|
||||||
|
- **`tests/test_book_live.py`**: +4 test (scaling con equity fidata, fallback su eq_fallback/real=None/
|
||||||
|
equity 0, no-frac, integrazione online). Suite: **172 passed**.
|
||||||
|
|
||||||
|
## Il guardrail di sicurezza (il punto delicato)
|
||||||
|
|
||||||
|
Il cap dinamico si usa **SOLO con equity reale fidata** (letta dal conto). Se l'equity reale non è
|
||||||
|
leggibile — `eq_fallback` attivo, `real_equity=None`, offline, o equity ≤ 0 — si **torna al cap fisso
|
||||||
|
$300**. Motivo: in fallback `equity` è il paper-cap ($2000 ipotetico); calcolare `equity/2 = $1000` su
|
||||||
|
un conto di importo reale **ignoto** sizerebbe troppo. Preserva esattamente la protezione downside che
|
||||||
|
il test `test_book_execute_eq_fallback_warns_but_proceeds` verificava (l'hard-cap $/asset protegge
|
||||||
|
quando E è ignota). Regola: **equity/2 solo quando ci fidiamo di E; altrimenti $300 fisso.**
|
||||||
|
|
||||||
|
## Verifica
|
||||||
|
|
||||||
|
- **Live dry-run reale** (nessun `--execute`, nessun ordine): `cap/asset **$299**` = $598.06/2 →
|
||||||
|
**INERTE** oggi (≡ vecchio $300). Book flat, HOLD.
|
||||||
|
- Offline / `equity_override`: cap resta **$300 fisso** (nessuna equity reale da fidarsi) → il test
|
||||||
|
di parità pre-esistente regge invariato.
|
||||||
|
- A $5.000 di equity il cap diventerebbe automaticamente **$2.500** (equity/2), rimuovendo il
|
||||||
|
throttling che la frontiera aveva quantificato.
|
||||||
|
|
||||||
|
## Impatto e limiti
|
||||||
|
|
||||||
|
- **Già attivo**: il cron orario (`cron_book.sh`) esegue dal working tree → il prossimo run legge già
|
||||||
|
`frac=0.5`. Nessun deploy separato. **Inerte a $600**, si "accende" da sé quando l'equity cresce.
|
||||||
|
- **NON avvicina €50/g**: la frontiera lo aveva già detto — è una miglioria marginale (+€0.2/g @2k,
|
||||||
|
+€0.5/g @5k), il cui unico scopo è non lasciare capitale futuro strozzato. €50/g resta un problema
|
||||||
|
di **capitale + tempo** (~130k @ CAGR onesto), non di questo cap.
|
||||||
|
- Nessun cambio di **leva** (TP01 `target_vol=20%` invariato, la vol nativa ~10% emerge da sola —
|
||||||
|
coerente col verdetto frontier "gira alla vol nativa, non levare").
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# 2026-07-03 — STATO COMPLETO DEL PROGETTO (snapshot)
|
||||||
|
|
||||||
|
Foto d'insieme dopo le ondate di ricerca del 1-3 luglio 2026 (timing/CRT, anchor-audit,
|
||||||
|
video-claims Elliott/Albimarini, capital-scaling, migliora-e-proteggi VRP01). Serve come punto di
|
||||||
|
riferimento unico: *dove siamo, cosa gira, cosa è provato, cosa resta aperto.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. In una frase
|
||||||
|
|
||||||
|
Progetto post-reset v2.0.0: su dati Deribit mainnet certificati (solo BTC/ETH) abbiamo costruito un
|
||||||
|
**portafoglio di 5 strategie diversificate** (research Sharpe ~2.2, DD ~6%), di cui **2 sono
|
||||||
|
eseguite live su Deribit** in un book nettato in software. Il conto reale è **~$598, flat** perché
|
||||||
|
il segnale trend è risk-off. Il valore delle strategie è **difensivo** (taglio del drawdown), non
|
||||||
|
generazione di ritorno: il target €50/giorno resta lontano (serve ~130k di capitale). L'onestà
|
||||||
|
metodologica è il vero asset accumulato: ~10 gate anti-illusione codificati, decine di edge
|
||||||
|
falsificati.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Il book LIVE (soldi reali)
|
||||||
|
|
||||||
|
- **Conto:** Deribit mainnet, USDC linear, **equity reale $598.06** (NON i €2000 nominali del paper).
|
||||||
|
- **Cosa gira:** `deribit_book_sleeves` = **TP01 (0.75) + SKH01 (0.25)** nettati in software su un
|
||||||
|
solo conto; UN ordine con segno per asset (BTC, ETH). `config/live.json`
|
||||||
|
**`execution_enabled=true`** → esecuzione ARMATA e reale.
|
||||||
|
- **Cadenza:** cron ORARIO `scripts/cron_book.sh` → `scripts/live/book_execute.py` (SKH01 decide su
|
||||||
|
griglia 230m; exit software, latenza fino a fine barra).
|
||||||
|
- **Guardrail:** cap **$300 notional/asset**, min order $5, **disaster-SL on-book −30%** sulla
|
||||||
|
posizione netta, alert Telegram su esecuzione/errori, gate fail-safe su posizione/equity non
|
||||||
|
leggibili (non opera a cieco).
|
||||||
|
- **Stato corrente (ultimo cron 2026-07-03 07:00Z):** BTC e ETH **flat, HOLD a target** — il target
|
||||||
|
TSMOM è risk-off (trend 0.0x) e SKH01 è flat (nessun breakout) → **zero ordini reali mai eseguiti**
|
||||||
|
da quando è armato (2026-06-23): il conto è flat *by design*, non per un errore.
|
||||||
|
- **NON nel book live:** XS01, VRP01, GTAA01 (research/paper/STAT-MODE).
|
||||||
|
|
||||||
|
## 3. Il portafoglio RESEARCH (5 sleeve) — `src/portfolio/sleeves.active_sleeves`
|
||||||
|
|
||||||
|
| Sleeve | Peso | Meccanismo | Dati | Stato |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **TP01** | 33% | TSMOM difensivo trend BTC/ETH 1d, long-flat, vol-target 20% | 2019→ | **Deploy pieno** (nel book live) |
|
||||||
|
| **SKH01** | 20% | Dual-TF regime+breakout BTC/ETH, L/S, exit %-asimmetrici | 2019→ | Research → nel book live |
|
||||||
|
| **GTAA01** | 20% | Trend difensivo equity 6-ETF (IB), ~30 anni storia | 1996→ | Paper (paper_combo) |
|
||||||
|
| **VRP01** | 12% | Options short-vol (put credit spread + gate IV-rank) | 2021→ | LEAD modellato (STAT-MODE) |
|
||||||
|
| **XS01** | 15% | Cross-sectional momentum 19 alt Hyperliquid | 2024→ | STAT-MODE (serve ~20k) |
|
||||||
|
|
||||||
|
- **Combinato (research, ancore canoniche):** FULL Sharpe **2.24**, HOLD-OUT **2.46**, DD full **6.2%**.
|
||||||
|
- **⚠️ Stima DE-LUCKATA onesta** (dopo gli anchor-audit, vedi §5): **HOLD ~1.9-2.1, FULL ~2.0-2.2,
|
||||||
|
DD ~6%**. Il 2.46 è un massimo di configurazioni d'ancora, non la stima centrale.
|
||||||
|
- Sleeve a date d'inizio diverse → outer-join con pesi rinormalizzati (`combine_outer`).
|
||||||
|
- Report: `scripts/portfolio/run_portfolio.py`. Il book live (TP01+SKH01 75/25) è un sottoinsieme.
|
||||||
|
|
||||||
|
## 4. Paper trader / forward-monitor attivi (nessun soldo reale)
|
||||||
|
|
||||||
|
- **paper_trend** — TP01 1d, capitale nominale 2000, forward-only.
|
||||||
|
- **paper_combo** — book 5-sleeve completo (incl. GTAA/VRP/XS) dal 2026-06-22.
|
||||||
|
- **paper_statarb** — STATARB-RESID (ETH−β·BTC residual momentum, W=45/sgn=+1 congelati), doppio
|
||||||
|
libro MODELED/REAL-$600. LEAD eseguibile a $600 sotto soglia solo sull'edge (DSR 0.929).
|
||||||
|
- **paper_prevday** — FOLLOW prior-day levels (3 trade registrati); confermato indipendentemente da
|
||||||
|
più ondate (FOLLOW > FADE ogni anno 2019-26).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Finding strutturale del blocco 1-3 luglio: ANCHOR TIMING-LUCK (audit COMPLETO 4/4)
|
||||||
|
|
||||||
|
Le strategie a ribilanciamento/griglia ancorata hanno un'ancora "canonica" che è spesso il
|
||||||
|
**migliore** di molte configurazioni equivalenti (giorno/ora/offset) — un multiple-testing che il
|
||||||
|
deflated-Sharpe NON conta. Audit eseguito su tutti e 4 gli sleeve ancorati:
|
||||||
|
|
||||||
|
| Sleeve | Spazio d'ancora | Firma | Conseguenza |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **TP01** | 24 ore (barra daily) | canonica = migliore (P=0.86) | hold-out 0.31 non risolve l'edge di ritorno; regge solo il taglio DD |
|
||||||
|
| **XS01** | 10 fasi (ciclo 10g) | canonica al 15° pctl di DD | numeri headline conservativi sul DD, non sull'hold-out |
|
||||||
|
| **SKH01** | 23 offset (griglia 230/690m) | canonica = 93-98° pctl (peggiore firma) | gate DD<30% fallisce in 15/23 offset; ~+0.5 HOLD di fortuna sul book |
|
||||||
|
| **VRP01** | 7 giorni (ciclo settimanale) | **canonica = PEGGIORE** (7° pctl) | **numeri CONSERVATIVI**, non gonfiati — unico sleeve senza luck |
|
||||||
|
|
||||||
|
**Regola codificata:** i numeri hold-out di strategie ancorate si citano **con banda d'ancora**.
|
||||||
|
Candidato gate futuro: `anchor_luck_band()` in altlib. Diari: `2026-07-02-timing-crt-wave.md`,
|
||||||
|
`2026-07-02-anchor-audit-xs01-skh01.md`, `2026-07-03-vrp-improve-dd.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Cosa è PROVATO e MORTO (per non ri-testarlo)
|
||||||
|
|
||||||
|
- **Soffitto BTC/ETH-direzionale ~1.3** confermato da ogni ondata; superato solo dal cross-sectional
|
||||||
|
(XS01) e dalla diversificazione multi-sleeve.
|
||||||
|
- **Sweep 104 famiglie** (2026-06-20): 1 sola sopravvissuta, comunque non deployabile.
|
||||||
|
- **Opzioni long-vol** (gamma scalping): specchio del VRP, perde ogni anno/variante/frequenza.
|
||||||
|
- **Cash-and-carry (CC01):** premio reale ma Sharpe artefatto (manca il 2022, procyclico) → STAT-MODE.
|
||||||
|
- **Timing/clock alternativi** (event-clock, expiry calendar, clock lenti, regime-speed): 0 edge.
|
||||||
|
- **CRT "Candle Range Theory"** (3/3 FAIL): DSR 0.000, expectancy negativa anche a fee zero, il
|
||||||
|
ritest è informazione negativa; sui livelli prior-day FOLLOW > FADE.
|
||||||
|
- **Elliott (3/3 FAIL):** range-cycle = rumore; Fibonacci = effetto posizione non numeri magici;
|
||||||
|
canale = Donchian travestito.
|
||||||
|
- **Albimarini double-diagonal (2/2 FAIL):** non domina il condor, fee-negativa su Deribit, track
|
||||||
|
record consistente con zero skill (P=20-45%), rovina nei replay con code reali.
|
||||||
|
- **VRP01 miglioramenti (0/7 filoni):** struttura, overlay-DD, gate nuovi (4° fallimento), sizing,
|
||||||
|
gate SPX (confound di modello) — **VRP01 non migliorabile; la protezione DD si compra con la size.**
|
||||||
|
|
||||||
|
## 7. Regole/gate anti-illusione codificati (l'asset metodologico)
|
||||||
|
|
||||||
|
- **Certificazione dati** obbligatoria (solo Deribit mainnet, BTC/ETH; alt esclusi).
|
||||||
|
- **`marginal_vs_tp01` / `study_marginal`** (earns_slot) — Sharpe MARGINALE, non assoluto.
|
||||||
|
- **`study_family_honest` / `select_cell_insample` / `deflated_sharpe≥0.95`** — anti selection-on-holdout.
|
||||||
|
- **`day_boundary_robust`** — effetti calendario che si invertono spostando il confine = artefatto.
|
||||||
|
- **`eval_weights_smallcap`** — Sharpe haircut reale a capitale piccolo (sub-min-order = finzione).
|
||||||
|
- **`weights_tilt_null`** — ogni cambio pesi vs null dei tilt casuali cap-respecting.
|
||||||
|
- **Banda d'ancora** (nuova, luglio) — per ogni costrutto ancorato.
|
||||||
|
- **Candidati in coda:** `anchor_luck_band` (multiple-testing d'ancora); `implausible_sharpe`
|
||||||
|
(0-perdite / Sharpe implausibile — 3 occorrenze: CC01, ALB-A, griglia struttura VRP).
|
||||||
|
- **Lezioni pandas:** mai `DatetimeIndex.view("int64")` su tz-aware; `resample("7D", origin=)`
|
||||||
|
ignora origin (usare `"168h"`); un gate che si correla con un errore di modello è indistinguibile
|
||||||
|
da alpha finché non correggi il modello.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Onestà sui numeri e sul target
|
||||||
|
|
||||||
|
- **€50/giorno da €1.000-2.000 NON è raggiungibile** in 1-2 anni: serve ~130k di capitale o un DD da
|
||||||
|
rovina. La leva non è la scorciatoia (5x raddoppia il DD). La via è target-vol + capitale + tempo.
|
||||||
|
- Aspettativa onesta col CAGR de-luckato del book (10-15%): **~€0.6-0.8/giorno a 2k, ~€1.4-2/giorno
|
||||||
|
a 5k**. Il salto di capitale cambia *cosa è eseguibile*, non l'ordine di grandezza del ritorno.
|
||||||
|
- Il valore reale di TP01/del book è il **taglio del drawdown ~6× vs buy&hold**, non l'alpha.
|
||||||
|
|
||||||
|
## 9. Capitale in arrivo 2-5k — cosa cambia (audit r0702_capital_scaling)
|
||||||
|
|
||||||
|
- **Unico vincolo binding: il cap `max_notional_per_asset_usd=300`.** A 5k il book live girerebbe al
|
||||||
|
~49% del target. **Azione pendente sul deposito: alzare il cap a equity/2** ($1000/$1750/$2500 a
|
||||||
|
2k/3.5k/5k). `min_order_usd=5` da lasciare.
|
||||||
|
- Tranching K=2: matematicamente regge a 5k ma serve feed intraday fuori path certificato → non cablare.
|
||||||
|
- Opzioni ETH eseguibili da ~2.6k (spread), ma **la regola "niente short-vol da modello in deploy"
|
||||||
|
non decade col capitale**. XS01 resta fuori fino a ~20k. CC01 fuori per struttura.
|
||||||
|
- ⚠️ Disambiguazione unità: il "12%" come peso book (~0.014 Kelly) ≠ "12%" come margine/equity
|
||||||
|
(~0.27 Kelly) — **fattore 19x**.
|
||||||
|
|
||||||
|
## 10. Aperto / prossimi passi
|
||||||
|
|
||||||
|
1. **Al deposito 2-5k:** alzare il cap $/asset (proposta pronta, `config/live.json` non ancora toccato).
|
||||||
|
2. **Forward-monitor da maturare:** paper_statarb (edge sotto soglia DSR), paper_prevday (FOLLOW),
|
||||||
|
paper_combo (book completo). Se la finestra forward conferma STATARB-RESID → deployabile a 2 gambe.
|
||||||
|
3. **Gate da codificare:** `anchor_luck_band`, `implausible_sharpe`.
|
||||||
|
4. **Rivisite temporizzate:** term-structure DVOL (logger dal 2026-06-26, rivalutare a 6-12 mesi);
|
||||||
|
breadth/internals HL (1-2 anni di storia nativa); tranching a ≥5-10k; VRP stress-f su un crash reale.
|
||||||
|
5. **Ricerca strategie nuove:** il soffitto direzionale ~1.3 è saturo; i margini restano in meccanismi
|
||||||
|
ORTOGONALI (cross-sectional, relative-value, options gated) e nella diversificazione, non in nuovi
|
||||||
|
segnali direzionali BTC/ETH.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Stato tecnico
|
||||||
|
|
||||||
|
- **Versione:** 2.0.0 (semver in `VERSION`). **Test:** 168 verdi (28 file). **Diari:** 76.
|
||||||
|
- **Ultimi commit:** `26f8d27` (migliora+proteggi VRP01), `76120b5` (video-claims + capital-scaling),
|
||||||
|
`a74cc69` (anchor-audit XS01/SKH01), `e6657fc` (GTAA01 5° sleeve).
|
||||||
|
- **Working tree pulito, tutto pushato.** Book live sano e invariato da tutta la ricerca (research
|
||||||
|
isolata: nessun agente tocca src/config/live).
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# 2026-07-03 — Ondata "migliora e proteggi VRP01 dai DD" (7 filoni + verifica a 2 lenti + scettico incrociato)
|
||||||
|
|
||||||
|
**Contesto.** Dopo la chiusura del dossier Albimarini (3 backtest indipendenti convergenti: premio
|
||||||
|
piccolo e reale, code non campionate, numeri del corso spiegabili al 100% dalla struttura del
|
||||||
|
payoff), goal esplicito: **migliorare la strategia short-vol** — la famiglia Albimarini → il nostro
|
||||||
|
**VRP01**, l'unica forma sopravvissuta agli audit — **e proteggerla dai drawdown**, con
|
||||||
|
orchestrazione a decine di agenti. Workflow deterministico: 7 filoni di ricerca in parallelo, 2
|
||||||
|
refuter avversariali (lente selection-bias + lente de-levering) su ogni miglioramento proposto,
|
||||||
|
scettico incrociato finale sui sopravvissuti. **26 agenti, 0 errori.**
|
||||||
|
|
||||||
|
**Scripts:** `scripts/research/r0703_vrpimp_{structgrid,ddoverlay,sizing,newgates,anchor,spxgate,stresslab}.py`.
|
||||||
|
Nessun file di produzione toccato, nessun commit dagli agenti. Motore riusato: `r0702_alb_structure.py`
|
||||||
|
(DVOL, riproduce VRP01 bit-exact: baseline bridge max|diff|=0.00e+00 su 266 settimane).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verdetto in una riga
|
||||||
|
|
||||||
|
**VRP01 non è migliorabile oltre lo status quo, e la protezione DD si compra SOLO con la size.**
|
||||||
|
Zero nuovi sleeve, zero overlay che reggono, zero gate nuovi, zero cambio di sizing. I due unici
|
||||||
|
"sopravvissuti" alla verifica **non propongono alcun cambiamento** (una conferma dello status quo +
|
||||||
|
una lente di misura). Il candidato più luccicante dell'ondata — un gate term-structure VIX su SPX
|
||||||
|
con ΔSharpe +0.90 e DSR 0.992 — è stato smontato dallo scettico come **confound di modello al
|
||||||
|
100%**. Deliverable reale = **audit anchor-luck di VRP01 CHIUSO** (ultimo sleeve ancorato): la fase
|
||||||
|
canonica è all'estremo **conservativo** della banda, quindi i numeri di ammissione reggono e da ora
|
||||||
|
si citano con banda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## I 7 filoni
|
||||||
|
|
||||||
|
### 1. Griglia di struttura (288 celle) — NESSUNA batte VRP01
|
||||||
|
6 strutture (vertical put/call, condor, 3 diagonali) × distanza {1,1.5,2,2.5}σ × ali {0.5,1,2}σ ×
|
||||||
|
tenor {3,5,7,10}g, gate IV-rank canonico sempre attivo. La cella scelta col **solo in-sample**
|
||||||
|
(DIAG-PUT z=1.0 3g, ShIS 2.28) crolla in hold-out a **+0.19** vs 0.86 del bridge stesso-motore;
|
||||||
|
DSR 0.000/0.004 su 288 trial; multi-cut negativo a 3-4 cut su 4; banda d'ancora che non tiene
|
||||||
|
nemmeno il segno (ShH [−0.21,+0.64]). **Metà griglia (150/288) è il pattern "0-perdite = Sharpe
|
||||||
|
implausibile"** (celle z≥2 lunghe, 0 perdite su ~97 trade, ShH fasulli 4-6.5) — **terza occorrenza
|
||||||
|
dopo CC01 e ALB-A**. Quarta conferma indipendente: nel VRP l'alpha sta nel gate di regime + nella
|
||||||
|
zona ~δ-0.28 dove la coda è campionata, non nella geometria.
|
||||||
|
|
||||||
|
### 2. Overlay di protezione DD (14 celle) — 4/4 REFUTED (2 lenti ciascuno)
|
||||||
|
(a) exit intra-settimana su spike DVOL, (b) stop-loss MTM, (c) ala di coda comprata (broken-wing),
|
||||||
|
(d) cooldown post-perdita. **Nessuno batte il null del de-levering** a pari maxDD su tutta la banda
|
||||||
|
f; nessuno passa multi-cut (max 1/4) né DSR (0.70-0.88, tutti <0.95). Esempio decisivo: SL MTM 3x fa
|
||||||
|
DD 7.9%/worst −5.7%, ma VRP01 × 0.66 (semplice riduzione di size) fa lo **stesso DD a Sharpe
|
||||||
|
invariato e worst-week migliore (−4.9%)**. Meccanismo confermato: i trigger di spike scattano solo
|
||||||
|
2-3 volte in 5 anni **perché il gate IV-rank/crash-skip evita già gli ingressi vicino alle
|
||||||
|
esplosioni di vol** — la protezione crash vive nel gate d'ingresso, non serve un overlay a valle.
|
||||||
|
Coerente con TP01×DVOL (2026-06-26): "per meno DD la leva è la size, non un overlay".
|
||||||
|
|
||||||
|
### 3. Sizing anti-rovina — CONFERMA (12% ≈ quarter-Kelly), 1 candidato REFUTED
|
||||||
|
Il sizing non crea alpha (Sharpe del flusso invariante ~0.95 a ogni frazione). Frontiera onesta
|
||||||
|
CAGR-DD-P(rovina) mappata su 92 trade gated ETH 2021-26 (pooled banda-f + coda sintetica full-loss):
|
||||||
|
Kelly onesto q*=44% → il **12% del book = 0.27 Kelly, già in zona quarter-Kelly anti-rovina**
|
||||||
|
(P(rovina-50%|5y) 0.0-0.1%, P(DD>30%) 9-18%, CAGR med 13.5-17% a 2k/5k). Salire al 25% → P(DD>30%)
|
||||||
|
> 92%; Albimarini 1→4 → P(rovina) 53-55%. **Nessun cambio raccomandato.** Il candidato anti-streak
|
||||||
|
(N=2, size 12%→3% dopo 2 vittorie, l'opposto di Albimarini) era il più luccicante mai visto sullo
|
||||||
|
sleeve (FULL CAGR +19.4%/DD 6.3%/Sh 2.08, random-null 0.996, DSR 0.999) ma **auto-refutato come
|
||||||
|
artefatto di selezione**: plateau assente (solo N=2 vince, N=3/5 collassano), P(loss|streak) non
|
||||||
|
monotona, delever-null perde in 2/7 ancore — tutto l'effetto sono ~6 perdite su 15 trade a
|
||||||
|
streak==2 (binomtest p=0.033, CI contiene la base). Istruttivo: mostra come un DSR 0.999 possa
|
||||||
|
convivere con l'assenza di meccanismo.
|
||||||
|
|
||||||
|
### 4. Gate nuovi — 4° FALLIMENTO su 4 (barra alta)
|
||||||
|
(a) term-structure DVOL **non testabile oggi** (storia vol_term = 7 giorni, logger forward dal
|
||||||
|
2026-06-26 → rivalutare a 6-12 mesi); (b) vol-of-vol, (c) RV-acceleration, (d) combinazioni-AND. Il
|
||||||
|
candidato IS-best (vol-of-vol pctl>0.80) passa DSR 0.960 e il de-levering all'ancora di default ma
|
||||||
|
la **banda d'ancora lo smaschera** (mediana uplift FULL −0.07 su 7 ancore, uplift concentrato in UNA
|
||||||
|
finestra 2021-22 di 5 settimane, hold-out uplift 0.00). Bonus strutturale: i veti RV-acceleration
|
||||||
|
bloccano settimane **profittevoli** (+0.93/+1.25% vs +0.6% tenute) — dopo il gate IV-rank, la vol
|
||||||
|
che accelera è dove il premio venduto è più ricco. Conferma il verdetto 2026-07-01: l'alpha è tutto
|
||||||
|
nel binario IV-rank>0.30.
|
||||||
|
|
||||||
|
### 5. Audit anchor-luck VRP01 — CHIUSO: primo sleeve SENZA firma di luck
|
||||||
|
VRP01 è l'ultimo sleeve ancorato dopo TP01/XS01/SKH01. Ciclo settimanale → 7 ancore (giorno di
|
||||||
|
apertura). Replica bit-exact della sleeve (max|diff|=0.0). Le 7 fasi con parametri identici:
|
||||||
|
|
||||||
|
| metrica | canonica (fase-0) | mediana 7 fasi | banda | pctl canonica |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Sh FULL | 1.09 | 1.32 | [1.09, 1.83] | **7° (peggiore)** |
|
||||||
|
| Sh HOLD | 0.59 | 0.59 | [−0.03, 1.11] | 50° (mediana) |
|
||||||
|
| maxDD | 11.8% | 7.2% | [5.7, 11.8%] | **93° (peggiore)** |
|
||||||
|
|
||||||
|
**Firma OPPOSTA a TP01/XS01/SKH01**: lo spike bootstrap è NEGATIVO (g0 HOLD −0.06, FULL −0.21,
|
||||||
|
P(g0≤0) 0.54/0.74) → niente da de-luckare. La fase canonica ha beccato le settimane crash 2022
|
||||||
|
**peggio** di ogni altra (unico anno-fase negativo, −5.8%). **I numeri di ammissione FULL 1.10 /
|
||||||
|
HOLD 0.60 / DD 12% sono quindi CONSERVATIVI, non gonfiati** — il timore di "protezione fittizia" è
|
||||||
|
refutato: il DD 12% è il caso peggiore della banda, non quello fortunato. Impatto della fase sul
|
||||||
|
book 5-sleeve ≤ 0.04 Sh (banda ShFULL 2.23-2.27, ShHOLD 2.35-2.48, DD 6.2-6.3%). **Con questo
|
||||||
|
l'audit anchor-luck è completo su tutti gli sleeve ancorati (4/4).**
|
||||||
|
|
||||||
|
### 6. Gate di regime sulla diagonale SPX (l'esperimento mancante) — REFUTED, con confound istruttivo
|
||||||
|
L'esperimento che mancava ai 3 backtest Albimarini: il gate di regime sulla diagonale SPX (VIX +
|
||||||
|
VXV da FRED, motore BS sintetico con skew log-lineare). **Domanda secca: il gate IV-rank ribalta gli
|
||||||
|
anni di coda su SPX come su crypto? NO.** Il gate VIX-rank canonico *abbassa* lo Sharpe (1.36→1.19),
|
||||||
|
non ribalta nessun anno di coda, peggiora il 2018, muore sul de-levering (ΔSh −0.17). Meccanismo:
|
||||||
|
su SPX il VRP è già positivo l'**84% dei giorni** (vs ~58% delle settimane crypto) → il filtro di
|
||||||
|
rank non ha lavoro discriminante; su equity IV-rank alto = crisi, non vol ricca.
|
||||||
|
Il candidato **term-structure VIX/VXV<1 (contango)** sembrava reale — Sh 1.18→2.08, DD 3.5→1.3%,
|
||||||
|
ΔSh **+0.90** a pari DD, multicut 3/3, flippa il 2020, DSR 0.992, plateau su c∈[0.93,1.05], jackknife
|
||||||
|
robusto — **e passa la lente selection-bias pulito**. Ma la lente de-levering lo affonda:
|
||||||
|
**l'intero uplift è un confound di modello, quantificato al 100%.** La variabile del gate (VIX/VXV>1)
|
||||||
|
coincide 1:1 con la variabile dell'errore di pricing (tenor 6g prezzato a VIX-30g). Il refuter ha
|
||||||
|
riprezzato con σ(6g)=VIX·(VIX/VXV)^α term-structure-consistent (α=1.42 = estrapolazione log-lineare
|
||||||
|
zero-parametri della pendenza osservata 30g→93g) → l'edge sparisce. **Lezione: un gate che si
|
||||||
|
correla con un errore di modello è indistinguibile da alpha finché non correggi il modello** — vale
|
||||||
|
per ogni futuro test su strutture prezzate BS-flat.
|
||||||
|
|
||||||
|
### 7. Stress lab di coda — fisica dello strumento, nessun cambio
|
||||||
|
La protezione di coda del VRP01 viene per ~metà dall'**ala far-OTM** (nel replay COVID taglia ~90%
|
||||||
|
della perdita naked) e ~metà dal **gate canonico** (sulle 10 peggiori finestre 14g taglia la perdita
|
||||||
|
cumulata del 44-71% saltando i re-entry a IV-rank>0.90). L'ala T+1 "Albimarini" è assicurazione solo
|
||||||
|
contro i gap **medi** (−15/−20%, dove atterra ATM con un giorno di time value: +150bps) e **NON**
|
||||||
|
contro i gap profondi (k(−30%)=0.98 → il de-levering la batte), con valore interamente dipendente
|
||||||
|
dal vol-spike (banda f/mult da −324 a +263bps). Worst-case onesto a sizing 12%: piccolo in
|
||||||
|
convenzione book (−€12/sett a 2k, −€31 a 5k alla cella −30%) ma **è il margine intero** se il 12%
|
||||||
|
fosse deployato fisicamente come margine (−€164..−227).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scettico incrociato — verdetto e disambiguazione critica
|
||||||
|
|
||||||
|
I 2 sopravvissuti (sizing-conferma, anchor-ensemble) usano leve diverse (posizione sulla frontiera
|
||||||
|
vs varianza della stima), ma lo scettico ha trovato **tre sovrapposizioni che i refuter singoli non
|
||||||
|
potevano vedere**:
|
||||||
|
|
||||||
|
1. **Stessa dipendenza dal modello f** — entrambi flippano segno allo stesso punto (f=0.6-0.8 →
|
||||||
|
negativo). Due sopravvissuti = **un solo grado di libertà di rischio-modello** (il caveat "premio
|
||||||
|
MODELLATO su DVOL ATM" resta il collo di bottiglia dell'intera famiglia).
|
||||||
|
2. **⚠️ CONFLITTO DI UNITÀ sul "12%"** (il punto più pericoloso per la narrativa): il **12% di PESO
|
||||||
|
del book** (convenzione pnl/Ks di `sleeves.py`) = ~**0.6% margine/equity ≈ 0.014 Kelly**, mentre
|
||||||
|
il "FISSO 12%" del filone sizing = **12% margine/equity ≈ 0.27 Kelly** — **fattore 19x**. Entrambe
|
||||||
|
corrette internamente, ma non vanno confuse: worst-week −12.2% del conto (convenzione margine) vs
|
||||||
|
−0.64% (convenzione peso book).
|
||||||
|
3. **L'ensemble-7-fasi è ridondante con il tranching TP01** (matematica di diversificazione: corr fra
|
||||||
|
fasi ρ=0.355, boost teorico ×1.50 vs osservato ×1.53, residuo alpha ≈ 0) → **non promuovere a LEAD
|
||||||
|
autonomo**; la coda strutturale è invariata (worst = 7 tranche a full-loss simultaneo), il DD 4.7%
|
||||||
|
è campionario, non un cap.
|
||||||
|
|
||||||
|
**Effetto congiunto sul book 5-sleeve: ZERO** (entrambi propongono nessun cambio; controfattuale
|
||||||
|
|ΔSh| ≤ 0.04). Il book live Deribit (TP01+SKH01) non contiene VRP01 → impatto operativo nullo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cosa entra nella narrativa (una sola voce, status = misura research)
|
||||||
|
|
||||||
|
- **Audit anchor-luck VRP01 CHIUSO** → i numeri di ammissione reggono e si citano con banda
|
||||||
|
(ShFULL [1.09,1.83], ShHOLD [−0.03,1.11], DD [5.7,11.8%]); l'edge OOS resta f-dipendente.
|
||||||
|
Con questo l'audit anchor è completo su 4/4 sleeve ancorati.
|
||||||
|
- **Sizing (conferma, con disambiguazione unità obbligatoria):** 12% deploy ≈ 0.27 Kelly onesto,
|
||||||
|
anti-rovina; NON confondere col 12% di peso book (0.014 Kelly, 19x).
|
||||||
|
- **VRP01 canonico resta lo sleeve giusto, INVARIATO** — non migliorabile per struttura, gate,
|
||||||
|
overlay o sizing; la protezione DD si compra con la size.
|
||||||
|
|
||||||
|
## Regole/candidati rafforzati
|
||||||
|
|
||||||
|
- **`implausible_sharpe` gate in altlib**: 3ª occorrenza del pattern "0-perdite = Sharpe
|
||||||
|
implausibile" (griglia struttura, dopo CC01 e ALB-A) → priorità alzata.
|
||||||
|
- **Nuova regola metodologica**: un gate che si correla con un errore di modello (term-structure vs
|
||||||
|
BS-flat) è indistinguibile da alpha finché non si corregge il modello — riprezzare
|
||||||
|
term-structure-consistent prima di credere a qualsiasi gate vol su strutture BS-flat.
|
||||||
|
- **Conferma (4ª): l'alpha del VRP è il gate IV-rank binario**, non struttura/sizing/overlay/gate
|
||||||
|
aggiuntivi.
|
||||||
|
|
||||||
|
**Stato:** book live INVARIATO, nessun nuovo sleeve, nessun forward-monitor. Test suite verde. 7
|
||||||
|
script committati.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 2026-07-07 — Video-claim "CRT top-down multi-TF, 74% win rate": SCARTATO (il 74% è un knob, non un edge)
|
||||||
|
|
||||||
|
**Richiesta utente:** analizzare e testare una strategia da video, metodo ICT/SMC top-down
|
||||||
|
multi-timeframe con claim **74% win rate**:
|
||||||
|
1. **H1** — setup CRT: candela di displacement forte, poi candela che prende liquidità (spike oltre
|
||||||
|
il range di C1) ma richiude dentro → falso breakout → fade/reversal.
|
||||||
|
2. **M15** — struttura: swing (U a 3 candele) + imbalance che diventa inverse-imbalance (FVG mitigato).
|
||||||
|
3. **M5** — conferma: displacement direzionale + zona di protezione (order block / imbalance).
|
||||||
|
4. **M1** — entry: attende la correzione, entra con SL dietro la zona di protezione.
|
||||||
|
Uscita: **TP1 a fine zona CRT (RR ~1.5–2), chiude 70–80% lì → resto a break-even → runner 20–30%**
|
||||||
|
verso la "liquidità successiva" (previous daily high/low). Due esempi mostrati (gold, nasdaq), entrambi
|
||||||
|
vincenti. Metà finale del video = vendita academy → lead magnet.
|
||||||
|
|
||||||
|
La valutazione critica dell'utente ("occhio Milito") aveva già isolato le red flag giuste (74% non
|
||||||
|
dimostrato, cherry-picking di 2 esempi, concetti discrezionali non falsificabili, RR basso + WR alto =
|
||||||
|
fragile, funnel commerciale). Questo diario **meccanizza e falsifica** la parte testabile.
|
||||||
|
|
||||||
|
## Inventario preventivo (non rifare lavoro)
|
||||||
|
|
||||||
|
Il setup H1 CRT è **già stato meccanizzato e scartato 3 volte** nell'onda 2026-07-02:
|
||||||
|
- `r0702_crt_base` (864 trial, 1h/4h/12h/1d): **DSR 0.000**; short "smart-money" negativo perfino IS.
|
||||||
|
- `r0702_crt_mtf` (4h→15m, 1h→5m, ~10k trade): **expectancy netta negativa OVUNQUE**; refutazione
|
||||||
|
strutturale = **"il ritest è informazione negativa"** (pattern CON ritest −40bps, SENZA +52bps).
|
||||||
|
- `r0702_crt_context` (FVG/sessioni/livelli): FVG semmai peggiora; **FADE < FOLLOW ogni anno 2019-26**.
|
||||||
|
|
||||||
|
**Angolo NUOVO non coperto** = la **gestione d'uscita (parziale+BE+runner)** e il **claim 74% WR**.
|
||||||
|
È lì che ho concentrato il test: `scripts/research/r0707_crt_topdown.py` (non tocca src/config/live;
|
||||||
|
nessun file scritto). Riusa l'harness certificato (dati/atr/sharpe/DSR di `altlib`).
|
||||||
|
|
||||||
|
## Disegno
|
||||||
|
|
||||||
|
- Detection CRT C1-C2 su **H1** (causale, nota alla chiusura di C2). Direzione = fade dello sweep.
|
||||||
|
- Entry sul TF basso (**5m/15m**, proxy di M5/M1 — ⚠️ **M1 non è nel feed certificato**): ritest della
|
||||||
|
zona violata + close-back attraverso il livello ("displacement di conferma"), **SL = estremo dello
|
||||||
|
swing basso = zona di protezione**.
|
||||||
|
- Tre schemi d'uscita sugli **stessi ingressi**: `fix1.5` (full, TP 1.5R), `fix2.0` (full, TP 2.0R),
|
||||||
|
`managed` (75% a 1.5R → resto 25% a BE, runner target = prev-daily high/low o estremo opposto C1).
|
||||||
|
- Gate progetto: hold-out 2025-01-01, griglia (k, s, d, tf, scheme) con **selezione cella solo
|
||||||
|
in-sample** + **DSR su tutti i trial**, **fee sweep** 0/0.10/0.20% RT, executability $600.
|
||||||
|
- Metrica invariante = **expectancy in R netto fee** (non il win-rate).
|
||||||
|
|
||||||
|
## Esito — il 74% è un artefatto della distanza del target, non un edge
|
||||||
|
|
||||||
|
**1) Win-rate reale (fade, tutti gli schemi, pooled BTC+ETH):**
|
||||||
|
|
||||||
|
| schema | WR mediana | expectancy R (netto 10bps) |
|
||||||
|
|---|---|---|
|
||||||
|
| fix1.5 | **35.8%** | −2.38 |
|
||||||
|
| fix2.0 | 31.3% | −2.34 |
|
||||||
|
| managed | **35.8%** | −2.17 |
|
||||||
|
|
||||||
|
Il WR a RR 1.5 è **~35%**, cioè **SOTTO** il null gambler's-ruin di un random walk senza edge
|
||||||
|
(P(+1.5R prima di −1R) = 1/(1+1.5) = **40%**). Il setup tocca 1.5R **meno spesso di una moneta** →
|
||||||
|
conferma indipendente che *il ritest è informazione negativa*. Lo schema `managed` **non gonfia** il
|
||||||
|
WR sopra `fix1.5` (per costruzione: "vinci" solo se raggiungi il parziale a 1.5R, che è la stessa
|
||||||
|
condizione di `fix1.5`). **In nessuna cella si avvicina al 74%.**
|
||||||
|
|
||||||
|
**2) Il win-rate È un knob — sweep del target parziale `rr1` (managed, 5m, cella tipica):**
|
||||||
|
|
||||||
|
| rr1 | WR reale | null 1/(1+rr1) | expR (fee 10bps) | expR (fee 0) |
|
||||||
|
|------|----------|----------------|------------------|--------------|
|
||||||
|
| 0.50 | 51.4% | 66.7% | **−2.54** | +0.11 |
|
||||||
|
| 0.75 | 46.9% | 57.1% | −2.51 | +0.14 |
|
||||||
|
| 1.00 | 43.2% | 50.0% | −2.47 | +0.17 |
|
||||||
|
| 1.50 | 36.3% | 40.0% | −2.45 | +0.19 |
|
||||||
|
| 2.00 | 31.8% | 33.3% | −2.42 | +0.23 |
|
||||||
|
|
||||||
|
Avvicinando il target il WR sale (esattamente come previsto dalla formula gambler's-ruin): **un "74%"
|
||||||
|
si fabbrica scegliendo un parziale vicino** (~0.35R), NON perché il metodo indovina la direzione. Ma
|
||||||
|
l'**expectancy R è costante e negativa a ogni rr1**: il WR alto non porta soldi. NB anche a WR massimo
|
||||||
|
il setup resta *sotto* il null teorico ad ogni rr1 → è pure peggio di un random walk nel raggiungere
|
||||||
|
target vicini.
|
||||||
|
|
||||||
|
**3) Gate statistici (cella scelta in-sample = 15m, k1.5, s0.1, d0.15, fix2.0):**
|
||||||
|
- **DSR = 0.000** su 48 trial (null max-Sharpe atteso 4.35). FAIL.
|
||||||
|
- **Fee sweep:** a **0 bps** expR = −0.10 e Sharpe book −0.63 → **non è morte-per-fee: l'edge lordo
|
||||||
|
non esiste** (il residuo lordo marginalmente positivo su 5m è la beta di trend dei time-exit, la
|
||||||
|
stessa autopsia del CRT base). A 10bps expR −1.26; a 20bps −2.43. Stop stretti (0.13–0.24%) →
|
||||||
|
la fee 0.10% pesa 0.75–2.6 R per trade → qualunque micro-edge lordo è polverizzato.
|
||||||
|
- **Hold-out 2025-26:** WR 32%, expR −1.56 (peggio dell'IS). Nessuna reggenza fuori campione.
|
||||||
|
|
||||||
|
**4) Executability $600:** SL sul TF basso = 0.13% (5m) / 0.24% (15m). Lo schema parziale+BE+runner
|
||||||
|
richiede **3–4 ordini per trade**, alcuni **sotto il min-order $5** a nozionale $1200 → non eseguibile
|
||||||
|
pulito al capitale reale.
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
|
||||||
|
**SCARTATO.** Il claim "74% win rate" è **coerente con ZERO edge**: è la scelta di un parziale vicino
|
||||||
|
(gambler's ruin), non capacità direzionale. Smontato lo schema d'uscita, resta il setup H1 CRT che
|
||||||
|
questo progetto ha già triplo-refutato (DSR 0.000, ritest = informazione negativa, fade < follow).
|
||||||
|
Sul feed certificato BTC/ETH il metodo ha **win-rate reale ~30–37% e expectancy R negativa a ogni
|
||||||
|
schema, fee, finestra e asset**; lordo di fee non c'è edge; a $600 non è nemmeno eseguibile pulito.
|
||||||
|
|
||||||
|
La parte *utile* del video è il framework di lettura (CRT + imbalance + parziali), non il numero — come
|
||||||
|
aveva già concluso la valutazione dell'utente. Da trattare come lettura del mercato, non come sistema.
|
||||||
|
|
||||||
|
## Lezione codificabile (candidata)
|
||||||
|
|
||||||
|
**Il win-rate di uno schema parziale+BE non è un parametro di merito: è ~P(tocca il primo target prima
|
||||||
|
dello stop) = 1/(1+rr1) per un processo senza drift.** Ogni claim "WR X%" da schema SMC va convertito
|
||||||
|
in **expectancy R netto fee** prima di crederci; il WR alto si compra con un target vicino a costo di
|
||||||
|
un'expectancy identica (o peggiore). Prossimo giro di gate: un helper `winrate_is_a_knob()` che, dato
|
||||||
|
un book a schema parziale, riporta expectancy-R vs WR al variare del target (rende esplicito l'artefatto).
|
||||||
|
|
||||||
|
## File
|
||||||
|
|
||||||
|
- `scripts/research/r0707_crt_topdown.py` — il test (griglia + 74%-check + knob-sweep + DSR + fee + exec).
|
||||||
|
- Prior: `r0702_crt_{base,mtf,context}.py`, diario `2026-07-02-timing-crt-wave.md`.
|
||||||
|
- Nessuna modifica a src/, config/, scripts/live/, tests/. Book live e pesi INVARIATI.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# 2026-07-09 — Incident infra: "BOOK LIVE conto offline" = cert Traefik di default (non un problema del conto)
|
||||||
|
|
||||||
|
**Tipo:** incident operativo (infra VPS), non ricerca. Nessun edge, nessun cambio a strategie/pesi/config
|
||||||
|
di trading. Documentato qui perché ha **bloccato la lettura del conto del book live** e ha prodotto
|
||||||
|
l'alert Telegram `⚠️ BOOK LIVE — conto offline`.
|
||||||
|
|
||||||
|
## Sintomo
|
||||||
|
|
||||||
|
Alert dal `cron_book` orario (`scripts/live/book_execute.py --execute`):
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ BOOK LIVE — conto offline
|
||||||
|
nota: salto l'esecuzione, non opero a cieco
|
||||||
|
```
|
||||||
|
|
||||||
|
Nel log (`logs/cron_book.log`) le run recenti mostravano `conto non leggibile (offline) -> stop, non
|
||||||
|
eseguo a cieco`. 10 run offline su 382 totali → **regressione recente**, non uno stato storico.
|
||||||
|
|
||||||
|
## Diagnosi (catena completa)
|
||||||
|
|
||||||
|
Il book legge il conto Deribit mainnet in sola lettura via **Cerbero MCP** (`DeribitRead`,
|
||||||
|
`src/live/deribit.py` → `https://cerbero-mcp.tielogic.xyz`). `book_report` marca `online=False` quando
|
||||||
|
`DeribitRead.mark_price(BTC)` lancia. Riproduzione diretta:
|
||||||
|
|
||||||
|
```
|
||||||
|
mark_price BTC: FAIL -> SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate
|
||||||
|
```
|
||||||
|
|
||||||
|
Il proxy presentava il **cert self-signed di default di Traefik** (`CN = TRAEFIK DEFAULT CERT`,
|
||||||
|
`notBefore Jul 9 19:55 2026`) al posto del vero Let's Encrypt. `curl -k` → HTTP 401 (backend
|
||||||
|
`cerbero-mcp` healthy, routing OK): **solo la presentazione del cert TLS era rotta**. `requests`
|
||||||
|
verifica il cert → fallisce → `online=False` → il book salta l'esecuzione (comportamento di sicurezza
|
||||||
|
**corretto**: non opera a cieco).
|
||||||
|
|
||||||
|
**Causa a monte** (log di avvio Traefik, 11:37:16Z, container `traefik-traefik-1`):
|
||||||
|
|
||||||
|
```
|
||||||
|
ERR The ACME resolve is skipped from the resolvers list
|
||||||
|
error="unable to get ACME account: permissions 660 for /acme.json are too open, please use 600"
|
||||||
|
resolver=mytlschallenge
|
||||||
|
→ (a cascata) Router uses a nonexistent certificate resolver certificateResolver=mytlschallenge
|
||||||
|
routerName=cerbero-mcp@docker [+ ~14 altri router: git, vault, portfolio, budget, cerbero-bite, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
Sequenza:
|
||||||
|
1. Immagine `traefik:latest` + **Watchtower** (`com.centurylinklabs.watchtower.enable=true`) →
|
||||||
|
auto-upgrade a **Traefik 3.7.7** con restart alle 11:37 di oggi.
|
||||||
|
2. Traefik 3.x **rifiuta** `acme.json` con permessi più larghi di `600`; il file era `660`
|
||||||
|
(`-rw-rw---- root:adriano`) → **resolver `mytlschallenge` scartato**.
|
||||||
|
3. Tutti i ~15 router che lo referenziano → "nonexistent certificate resolver" → Traefik serve il
|
||||||
|
**cert di default self-signed** a ogni host HTTPS del VPS (non solo cerbero-mcp).
|
||||||
|
4. I cert veri erano **già dentro `acme.json`** (14 domini, incl. `cerbero-mcp.tielogic.xyz`) → nessuna
|
||||||
|
ri-emissione necessaria, **zero rischio rate-limit** Let's Encrypt.
|
||||||
|
|
||||||
|
## Impatto
|
||||||
|
|
||||||
|
- **VPS-wide:** ogni servizio HTTPS dietro Traefik serviva un cert invalido (warning browser, fallimento
|
||||||
|
di ogni client che verifica il TLS). Non solo il trading.
|
||||||
|
- **Trading:** **nessun trade perso.** Target del book **flat** (TP01/SKH01 risk-off) + conto reale
|
||||||
|
~$598 (non finanziato al livello nominale): anche online il book avrebbe fatto HOLD. Il doppio gate
|
||||||
|
di sicurezza ha lavorato come previsto.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
**Immediato** (eseguito dall'utente):
|
||||||
|
```bash
|
||||||
|
sudo chmod 600 /opt/docker/traefik/acme.json
|
||||||
|
docker restart traefik-traefik-1
|
||||||
|
```
|
||||||
|
Il resolver ricarica i cert già presenti in `acme.json` e li ripresenta subito.
|
||||||
|
|
||||||
|
**Durevole** (pin dell'immagine, `/opt/docker/traefik/docker-compose.yml`):
|
||||||
|
```yaml
|
||||||
|
- image: "traefik" # = traefik:latest → Watchtower salta ai major (causa dell'incident)
|
||||||
|
+ image: "traefik:3.7" # Watchtower resta su 3.7.x: patch sì, salto a 3.8+ breaking no
|
||||||
|
```
|
||||||
|
Applicato con `docker compose up -d` (container ricreato su `traefik:3.7`, 20:05:05Z).
|
||||||
|
|
||||||
|
## Verifica (end-to-end, post-fix)
|
||||||
|
|
||||||
|
| Check | Risultato |
|
||||||
|
|---|---|
|
||||||
|
| `acme.json` perms | `600` |
|
||||||
|
| Cert `cerbero-mcp.tielogic.xyz` | issuer **Let's Encrypt** (fino 2026-09-27) |
|
||||||
|
| Traefik log | nessun `ACME resolve is skipped` / `too open` |
|
||||||
|
| Container image | `traefik:3.7` (running) |
|
||||||
|
| `DeribitRead.mark_price` | BTC 63.193 / ETH 1.746 |
|
||||||
|
| `book_report.online` | **True** · `eq_basis=mainnet USDC` · `real_equity=$598.06` · `pos_error=None` |
|
||||||
|
|
||||||
|
## Lezioni
|
||||||
|
|
||||||
|
- **`traefik:latest` + Watchtower = auto-upgrade non deterministico.** Un bump di major (qui il giro
|
||||||
|
di vite sui permessi di `acme.json`) diventa un'interruzione TLS di tutto il VPS senza intervento
|
||||||
|
umano. Pinnare la minor (`3.7`) rende i restart prevedibili pur ricevendo le patch.
|
||||||
|
- **Traefik 3.x pretende `acme.json` a `600`** (non solo warning: **scarta il resolver**). Se il file
|
||||||
|
è group/other-readable, tutti i router cadono sul cert di default.
|
||||||
|
- **Il gate `online` del book ha fatto il suo lavoro:** un problema *infra di lettura* non ha prodotto
|
||||||
|
ordini a cieco. L'alert "conto offline" va letto prima come possibile problema di **connettività/cert
|
||||||
|
al Cerbero MCP**, non del conto Deribit.
|
||||||
|
- **Runbook rapido** per "BOOK LIVE conto offline":
|
||||||
|
1. `python -c "from src.live.deribit import DeribitRead; DeribitRead().mark_price('BTC-PERPETUAL')"`
|
||||||
|
→ se `SSLError`/`CERTIFICATE_VERIFY_FAILED` è il cert del proxy, non il conto.
|
||||||
|
2. `openssl s_client -connect cerbero-mcp.tielogic.xyz:443 -servername cerbero-mcp.tielogic.xyz | openssl x509 -noout -issuer`
|
||||||
|
→ se `TRAEFIK DEFAULT CERT`, Traefik non serve il cert reale.
|
||||||
|
3. `docker logs traefik-traefik-1 | grep -iE "ACME resolve is skipped|too open|nonexistent"`
|
||||||
|
→ conferma resolver scartato; controlla `stat -c %a /opt/docker/traefik/acme.json` (deve essere `600`).
|
||||||
|
|
||||||
|
Nessun file di trading toccato; `config/live.json`, sleeve e pesi **invariati**.
|
||||||
@@ -74,12 +74,28 @@ def _run():
|
|||||||
print(f" sizing base : ${equity:,.2f} | cap/asset ${r['cap_per_asset']:.0f} | min ${min_order:.0f} | disaster-SL -{sl_pct*100:.0f}%")
|
print(f" sizing base : ${equity:,.2f} | cap/asset ${r['cap_per_asset']:.0f} | min ${min_order:.0f} | disaster-SL -{sl_pct*100:.0f}%")
|
||||||
print(f" ultima barra : {r['last_data']}\n")
|
print(f" ultima barra : {r['last_data']}\n")
|
||||||
|
|
||||||
|
if r.get("skh_error"): # SKH feed fallito -> book.py ha forzato flat IN SILENZIO
|
||||||
|
print(f" ⚠️ SKH FEED ERRORE (SKH forzato flat!): {r['skh_error']}")
|
||||||
|
notify("⚠️ BOOK — SKH feed fallito (sleeve forzato flat)", {"error": r["skh_error"]})
|
||||||
|
|
||||||
if not r["online"]:
|
if not r["online"]:
|
||||||
print(" conto non leggibile (offline) -> stop, non eseguo a cieco.")
|
print(" conto non leggibile (offline) -> stop, non eseguo a cieco.")
|
||||||
if do_execute:
|
if do_execute:
|
||||||
notify("⚠️ BOOK LIVE — conto offline", {"nota": "salto l'esecuzione, non opero a cieco"})
|
notify("⚠️ BOOK LIVE — conto offline", {"nota": "salto l'esecuzione, non opero a cieco"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if r.get("pos_error"): # ONLINE ma posizione IGNOTA (read fallita -> assunta flat)
|
||||||
|
print(f" 🛑 POSIZIONE NON LEGGIBILE -> NON eseguo a cieco: {r['pos_error']}")
|
||||||
|
if do_execute:
|
||||||
|
notify("🛑 BOOK LIVE — posizione non leggibile", {"error": r["pos_error"],
|
||||||
|
"nota": "salto l'esecuzione, non opero a cieco"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if r.get("eq_fallback"): # equity reale non leggibile -> sizing su paper_cap
|
||||||
|
print(f" ⚠️ EQUITY FALLBACK (sizing su paper_cap, NON blocco): {r['eq_fallback']}")
|
||||||
|
if do_execute: # solo diagnostica: l'hard-cap $/asset limita il downside
|
||||||
|
notify("⚠️ BOOK LIVE — equity fallback (sizing su paper_cap)", {"nota": r["eq_fallback"]})
|
||||||
|
|
||||||
trader = DeribitTrader() if do_execute else None
|
trader = DeribitTrader() if do_execute else None
|
||||||
actions = []
|
actions = []
|
||||||
for a in r["assets"]:
|
for a in r["assets"]:
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""r0701_breadth_internals.py — BREADTH / MARKET-INTERNALS del mercato ALT come segnale su BTC/ETH.
|
||||||
|
|
||||||
|
TESI (filone 2026-07-01)
|
||||||
|
------------------------
|
||||||
|
Gli "internals" del mercato crypto — la partecipazione degli alt — come segnale direzionale o gate
|
||||||
|
di de-risk su BTC/ETH perp (2 gambe, eseguibile a ~$600):
|
||||||
|
FAM-MA : % di alt sopra la propria SMA(N) (breadth classica)
|
||||||
|
FAM-AD : advance/decline — frazione di advancers, SMA(N) (partecipazione giornaliera)
|
||||||
|
FAM-RS : % di alt che BATTONO BTC sul ritorno a N giorni (risk-appetite relativo, ~mkt-neutral)
|
||||||
|
FAM-TH : breadth-THRUST — delta della breadth MA20 su N giorni (thrust/collapse)
|
||||||
|
Forme: LS (long/short), LF (long/flat), GATE (TP01 * gate binario). Tutte vol-target 20% cap 2x
|
||||||
|
(LS/LF) o ereditano il sizing TP01 (GATE).
|
||||||
|
|
||||||
|
RISCHI NOTI IN PARTENZA (CLAUDE.md, prior art)
|
||||||
|
----------------------------------------------
|
||||||
|
1. MACRO regime-gate (2026-06-29) = SCARTATO: corr->TP01 0.989, il gate lavorava nel 2-3% dei
|
||||||
|
giorni (TP01 e' gia' flat nei crash). Un breadth-gate rischia di essere LO STESSO artefatto:
|
||||||
|
TP01 travestito. Qui DOBBIAMO riportare corr->TP01 + verdetto marginale + "giorni in cui il
|
||||||
|
gate lavora" (gate off E TP01 non gia' flat).
|
||||||
|
2. trend-multiasset = ridondante (corr 0.74): la breadth degli alt e' correlata alla direzione
|
||||||
|
del mercato -> il rischio che breadth>soglia == "BTC sopra trend" e' concreto.
|
||||||
|
3. is_hedge: un segnale che paga solo quando TP01 soffre e' un hedge, non alpha.
|
||||||
|
4. STORIA: l'universo HL parte dal 2024-01 -> ~2.2 anni utili post-warmup. In-sample (pre-HOLDOUT
|
||||||
|
2025-01-01) = SOLO ~8 mesi del 2024. Limite strutturale DICHIARATO: qualunque esito e' al
|
||||||
|
massimo un LEAD, la selezione in-sample poggia su una finestra corta.
|
||||||
|
|
||||||
|
METODO (obbligatorio, CLAUDE.md)
|
||||||
|
--------------------------------
|
||||||
|
- Dati: 51 parquet certificati data/raw/hl_*_1d.parquet; PANEL = 49 alt (esclusi hl_btc/hl_eth
|
||||||
|
dalla breadth; hl_btc usato solo come riferimento per FAM-RS). Barre a volume<=0 = sintetiche
|
||||||
|
-> close mascherato NaN. Breadth definita solo con >= MIN_VALID(20) asset validi alla data.
|
||||||
|
- Causalita': barre HL 1d e barre BTC/ETH 1d (altlib.get, resample Deribit) sono entrambe
|
||||||
|
open-labeled 00:00 UTC -> il close del giorno D e' noto allo stesso istante su entrambe.
|
||||||
|
Allineamento merge_asof backward (allow_exact) sul timestamp; eval_weights shifta la posizione
|
||||||
|
(decisa a close[i], tenuta in i+1). Verifica al.causality_ok sul target end-to-end.
|
||||||
|
- Selezione ONESTA (lezione SELECTION-ON-HOLDOUT 2026-06-29): la cella si sceglie con il SOLO
|
||||||
|
Sharpe in-sample (pre-2025) sul candidato 50/50, MAI sull'hold-out; deflated Sharpe (Bailey &
|
||||||
|
Lopez de Prado) su TUTTE le celle cercate; poi al.marginal_vs_tp01 (multi-cut, has_insample_edge,
|
||||||
|
is_hedge) sulla cella scelta. NB: non si usa al.study_family_honest stock perche' la breadth non
|
||||||
|
esiste pre-2024 e il padding (LS/LF=flat, GATE=TP01 pieno) contaminerebbe il ranking in-sample
|
||||||
|
full-history (le celle GATE erediterebbero lo Sharpe 2019-2024 di TP01); la procedura qui sotto
|
||||||
|
e' il MIRROR esatto di select_cell_insample + deflated_sharpe + study_marginal sulla FINESTRA
|
||||||
|
COMUNE 2024-05+ (stessa libreria, stessi gate).
|
||||||
|
- Fee 0.10% RT + sweep 0-0.30% RT; eval_weights_smallcap a $600.
|
||||||
|
|
||||||
|
USO: uv run python scripts/research/r0701_breadth_internals.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import sys
|
||||||
|
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" / "alt"))
|
||||||
|
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
|
||||||
|
|
||||||
|
RAW = _ROOT / "data" / "raw"
|
||||||
|
HOLDOUT = al.HOLDOUT
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
MIN_VALID = 20 # asset validi minimi perche' la breadth esista
|
||||||
|
START = pd.Timestamp("2026-01-01", tz="UTC") # placeholder, ridefinito sotto
|
||||||
|
# finestra comune: HL parte 2024-01-01; warmup max = FAM-TH N=100 (20g MA + 100g delta) ~ 120g
|
||||||
|
START = pd.Timestamp("2024-05-05", tz="UTC")
|
||||||
|
|
||||||
|
MA_GRID = (20, 50, 100)
|
||||||
|
THR_GRID = (0.3, 0.5, 0.7)
|
||||||
|
FORMS = ("ls", "lf", "gate")
|
||||||
|
FAMS = ("ma", "ad", "rs", "th")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# PANEL ALT (49 asset, vol=0 mascherato) + riferimento BTC (hl_btc)
|
||||||
|
# ===========================================================================
|
||||||
|
def load_panel():
|
||||||
|
px, vol = {}, {}
|
||||||
|
for f in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))):
|
||||||
|
sym = Path(f).stem.replace("hl_", "").replace("_1d", "").upper()
|
||||||
|
d = pd.read_parquet(f)
|
||||||
|
idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||||
|
px[sym] = pd.Series(d["close"].values.astype(float), index=idx)
|
||||||
|
vol[sym] = pd.Series(d["volume"].values.astype(float), index=idx)
|
||||||
|
PX = pd.concat(px, axis=1).sort_index()
|
||||||
|
VOL = pd.concat(vol, axis=1).sort_index().reindex_like(PX)
|
||||||
|
PX = PX.mask(VOL <= 0) # barre sintetiche (vol=0) -> NaN (lezione 2026-06-20)
|
||||||
|
btc_ref = PX["BTC"].copy() # riferimento FAM-RS (stessa venue/stesso close time)
|
||||||
|
ALTS = PX.drop(columns=["BTC", "ETH"]) # breadth = SOLO alt
|
||||||
|
return ALTS, btc_ref
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_min_valid(score: pd.Series, n_valid: pd.Series) -> pd.Series:
|
||||||
|
s = score.copy()
|
||||||
|
s[n_valid < MIN_VALID] = np.nan
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def breadth_ma(ALTS: pd.DataFrame, N: int) -> pd.Series:
|
||||||
|
"""% di alt validi con close > SMA(N). Causale (SMA su dati <= i)."""
|
||||||
|
sma = ALTS.rolling(N, min_periods=N).mean()
|
||||||
|
valid = ALTS.notna() & sma.notna()
|
||||||
|
above = (ALTS > sma) & valid
|
||||||
|
n_valid = valid.sum(axis=1)
|
||||||
|
return _mask_min_valid(above.sum(axis=1) / n_valid.replace(0, np.nan), n_valid)
|
||||||
|
|
||||||
|
|
||||||
|
def breadth_ad(ALTS: pd.DataFrame, N: int) -> pd.Series:
|
||||||
|
"""Advance/decline: frazione di advancers (ret 1g > 0) tra i validi, SMA(N)."""
|
||||||
|
dr = ALTS.pct_change(fill_method=None)
|
||||||
|
valid = dr.notna()
|
||||||
|
adv = ((dr > 0) & valid).sum(axis=1)
|
||||||
|
n_valid = valid.sum(axis=1)
|
||||||
|
frac = adv / n_valid.replace(0, np.nan)
|
||||||
|
frac[n_valid < MIN_VALID] = np.nan
|
||||||
|
return frac.rolling(N, min_periods=N).mean()
|
||||||
|
|
||||||
|
|
||||||
|
def breadth_rs(ALTS: pd.DataFrame, btc_ref: pd.Series, N: int) -> pd.Series:
|
||||||
|
"""% di alt che battono BTC sul ritorno a N giorni (risk-appetite relativo)."""
|
||||||
|
altret = ALTS / ALTS.shift(N) - 1.0
|
||||||
|
btcret = (btc_ref / btc_ref.shift(N) - 1.0)
|
||||||
|
valid = altret.notna() & btcret.notna().values[:, None]
|
||||||
|
beat = altret.gt(btcret, axis=0) & valid
|
||||||
|
n_valid = valid.sum(axis=1)
|
||||||
|
return _mask_min_valid(beat.sum(axis=1) / n_valid.replace(0, np.nan), n_valid)
|
||||||
|
|
||||||
|
|
||||||
|
def breadth_th(ALTS: pd.DataFrame, N: int) -> pd.Series:
|
||||||
|
"""Breadth-THRUST: 0.5 + delta a N giorni della breadth MA20 (thrust>0.5, collapse<0.5)."""
|
||||||
|
b20 = breadth_ma(ALTS, 20)
|
||||||
|
return 0.5 + (b20 - b20.shift(N))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# FACTORY — target_fn(df, asset) per una cella (fam, N, thr, form)
|
||||||
|
# ===========================================================================
|
||||||
|
_ALTS, _BTC_REF = load_panel()
|
||||||
|
_BREADTH: dict[tuple, pd.Series] = {}
|
||||||
|
for _N in MA_GRID:
|
||||||
|
_BREADTH[("ma", _N)] = breadth_ma(_ALTS, _N)
|
||||||
|
_BREADTH[("ad", _N)] = breadth_ad(_ALTS, _N)
|
||||||
|
_BREADTH[("rs", _N)] = breadth_rs(_ALTS, _BTC_REF, _N)
|
||||||
|
_BREADTH[("th", _N)] = breadth_th(_ALTS, _N)
|
||||||
|
|
||||||
|
_TP01_POS: dict[str, np.ndarray] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def tp01_pos(df: pd.DataFrame, asset: str) -> np.ndarray:
|
||||||
|
if asset not in _TP01_POS:
|
||||||
|
_TP01_POS[asset] = TrendPortfolio(**CANONICAL).target_series(df)
|
||||||
|
return _TP01_POS[asset]
|
||||||
|
|
||||||
|
|
||||||
|
_EPOCH = pd.Timestamp("1970-01-01", tz="UTC")
|
||||||
|
|
||||||
|
|
||||||
|
def _align(b: pd.Series, df: pd.DataFrame) -> np.ndarray:
|
||||||
|
"""Breadth (calendario HL) -> barre BTC/ETH. merge_asof backward, exact ok (stesso
|
||||||
|
istante di close 00:00 UTC). NaN dove la breadth non esiste.
|
||||||
|
NB timestamp via epoca esplicita: .view('int64') su DatetimeIndex tz-aware a risoluzione
|
||||||
|
non-ns (pandas 2.x) da' la SCALA SBAGLIATA -> merge_asof matchava tutto all'ULTIMO valore
|
||||||
|
(broadcast del futuro su tutta la storia = look-ahead che causality_ok non vede, perche'
|
||||||
|
la serie breadth e' un input esterno fisso). Bug trovato e corretto in questa ricerca."""
|
||||||
|
ts_ms = ((b.index - _EPOCH) // pd.Timedelta(milliseconds=1)).astype("int64")
|
||||||
|
g = pd.DataFrame({"timestamp": ts_ms, "b": b.values})
|
||||||
|
g = g.dropna(subset=["b"]).sort_values("timestamp")
|
||||||
|
left = pd.DataFrame({"timestamp": df["timestamp"].astype("int64").values})
|
||||||
|
m = pd.merge_asof(left, g, on="timestamp", direction="backward")
|
||||||
|
return m["b"].values.astype(float)
|
||||||
|
|
||||||
|
|
||||||
|
def factory(tf: str = "1d", fam: str = "ma", N: int = 50, thr: float = 0.5, form: str = "ls"):
|
||||||
|
b_series = _BREADTH[(fam, N)]
|
||||||
|
|
||||||
|
def target_fn(df: pd.DataFrame, asset: str = "") -> np.ndarray:
|
||||||
|
b = _align(b_series, df)
|
||||||
|
if form == "gate":
|
||||||
|
g = np.where(np.isfinite(b), np.where(b >= thr, 1.0, 0.0), 1.0) # no info -> no de-risk
|
||||||
|
return tp01_pos(df, asset) * g
|
||||||
|
if form == "ls":
|
||||||
|
d = np.where(np.isfinite(b), np.where(b >= thr, 1.0, -1.0), 0.0)
|
||||||
|
else: # lf
|
||||||
|
d = np.where(np.isfinite(b), np.where(b >= thr, 1.0, 0.0), 0.0)
|
||||||
|
return al.vol_target(d, df, 0.20, 30, 2.0)
|
||||||
|
return target_fn
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [dict(fam=f, N=n, thr=t, form=fo)
|
||||||
|
for f in FAMS for n in MA_GRID for t in THR_GRID for fo in FORMS]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# DRIVER ONESTO sulla finestra comune (mirror di study_family_honest, stessi gate altlib)
|
||||||
|
# ===========================================================================
|
||||||
|
def cand_trim(fn) -> pd.Series:
|
||||||
|
return al.candidate_daily(fn, tf="1d")[lambda s: s.index >= START]
|
||||||
|
|
||||||
|
|
||||||
|
def abs_verdict_trimmed(fn) -> dict:
|
||||||
|
"""study_weights-equivalente sulla finestra comune 2024-05+ (fee sweep incluso)."""
|
||||||
|
per_asset = {}
|
||||||
|
fee_ok_all = True
|
||||||
|
for a in ASSETS:
|
||||||
|
df = al.get(a, "1d")
|
||||||
|
tgt = np.asarray(fn(df, a), float)
|
||||||
|
mask = (pd.to_datetime(df["datetime"], utc=True) >= START).values
|
||||||
|
dft = df[mask].reset_index(drop=True)
|
||||||
|
tgtt = tgt[mask]
|
||||||
|
base = al.eval_weights(dft, tgtt, fee_side=al.FEE_SIDE)
|
||||||
|
sweep = {f"{2*f*100:.2f}%RT": al.eval_weights(dft, tgtt, fee_side=f)["full"]["sharpe"]
|
||||||
|
for f in al.FEE_SWEEP}
|
||||||
|
fee_ok_all = fee_ok_all and sweep.get("0.20%RT", -9) > 0
|
||||||
|
per_asset[a] = dict(full=base["full"], holdout=base["holdout"],
|
||||||
|
tim=base["time_in_market"], turnover=base["turnover_per_year"],
|
||||||
|
fee_sweep=sweep, yearly=base["yearly"])
|
||||||
|
cell = dict(tf="1d", per_asset=per_asset,
|
||||||
|
min_asset_full_sharpe=round(min(per_asset[a]["full"]["sharpe"] for a in ASSETS), 3),
|
||||||
|
min_asset_holdout_sharpe=round(min(per_asset[a]["holdout"].get("sharpe", 0.0) for a in ASSETS), 3),
|
||||||
|
full_sharpe=round(float(np.mean([per_asset[a]["full"]["sharpe"] for a in ASSETS])), 3),
|
||||||
|
fee_survives=fee_ok_all)
|
||||||
|
return dict(cells=[cell], verdict=al._verdict([cell]))
|
||||||
|
|
||||||
|
|
||||||
|
def gate_work_diag(fn, thr_flat: float = 1e-6) -> dict:
|
||||||
|
"""Deep-dive ridondanza (lezione macro-gate): nei giorni in cui il segnale vorrebbe stare
|
||||||
|
fuori/short, TP01 e' gia' flat da solo? 'lavora' = segnale off/short E TP01 non flat."""
|
||||||
|
out = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
df = al.get(a, "1d")
|
||||||
|
mask = (pd.to_datetime(df["datetime"], utc=True) >= START).values
|
||||||
|
tgt = np.asarray(fn(df, a), float)[mask]
|
||||||
|
tp = tp01_pos(df, a)[mask]
|
||||||
|
off = tgt <= thr_flat # segnale fuori (o short per LS: qui solo "non long")
|
||||||
|
works = off & (tp > thr_flat)
|
||||||
|
out[a] = dict(days_off=round(float(off.mean()), 3),
|
||||||
|
days_gate_works=round(float(works.mean()), 3),
|
||||||
|
tp01_flat_days=round(float((tp <= thr_flat).mean()), 3),
|
||||||
|
corr_pos=round(float(np.corrcoef(tgt, tp)[0, 1]), 3)
|
||||||
|
if np.std(tgt) > 0 and np.std(tp) > 0 else None)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cell_activity(p: dict) -> float:
|
||||||
|
"""Frazione di giorni nello STATO DI MINORANZA del segnale (criterio STRUTTURALE, non di
|
||||||
|
performance: una cella sempre-on e' buy&hold/TP01 travestito, non un segnale di breadth).
|
||||||
|
ls: min(on, off); lf: frazione on... comunque = minoranza; gate: frazione off."""
|
||||||
|
b = _BREADTH[(p["fam"], p["N"])]
|
||||||
|
bb = b[(b.index >= START)].dropna()
|
||||||
|
on = float((bb >= p["thr"]).mean())
|
||||||
|
return round(min(on, 1.0 - on) if p["form"] == "ls" else
|
||||||
|
(on if p["form"] == "lf" else 1.0 - on), 3)
|
||||||
|
|
||||||
|
|
||||||
|
def full_report(label: str, chosen: dict, all_full: list, n_trials: int) -> dict:
|
||||||
|
p = chosen["params"]
|
||||||
|
fn = factory(**p)
|
||||||
|
daily = cand_trim(fn)
|
||||||
|
dsr, sr0 = al.deflated_sharpe(al._sh(daily), all_full, daily)
|
||||||
|
print(f"\n==== {label}: {p} (attivita' {chosen['act']}) ====")
|
||||||
|
print(f" standalone (finestra comune): IS {chosen['insample_sharpe']:+.2f} "
|
||||||
|
f"FULL {chosen['full_sharpe']:+.2f} HOLD {chosen['hold_sharpe']:+.2f}")
|
||||||
|
print(f" deflated Sharpe (su TUTTI i {n_trials} trial cercati): DSR={dsr:.3f} "
|
||||||
|
f"(null-max atteso {sr0:.2f}) PASS>=0.95: {dsr >= 0.95}")
|
||||||
|
|
||||||
|
marg = al.marginal_vs_tp01(daily)
|
||||||
|
absr = abs_verdict_trimmed(fn)
|
||||||
|
abs_grade = absr["verdict"]["grade"]
|
||||||
|
earns_slot = (abs_grade != "FAIL" and marg.get("marginal_verdict") == "ADDS"
|
||||||
|
and marg.get("robust_oos", False) and marg.get("beats_noise_null", False)
|
||||||
|
and not marg.get("is_hedge", False))
|
||||||
|
rep = dict(name=f"BREADTH {p}", marginal=marg, abs_grade=abs_grade,
|
||||||
|
marginal_verdict=marg.get("marginal_verdict"), earns_slot=earns_slot)
|
||||||
|
print("\n" + al.fmt_marginal(rep))
|
||||||
|
honest = earns_slot and dsr >= 0.95
|
||||||
|
print(f" earns_slot_honest = earns_slot({earns_slot}) AND DSR>=0.95({dsr >= 0.95}) "
|
||||||
|
f"=> {honest}")
|
||||||
|
|
||||||
|
# assoluto trimmed + fee sweep
|
||||||
|
c = absr["cells"][0]
|
||||||
|
print(f"\n---- ASSOLUTO (finestra comune, verdetto {abs_grade}): "
|
||||||
|
f"minFull {c['min_asset_full_sharpe']:+.2f} minHold {c['min_asset_holdout_sharpe']:+.2f} "
|
||||||
|
f"feeOK={c['fee_survives']}")
|
||||||
|
for a in ASSETS:
|
||||||
|
pa = c["per_asset"][a]
|
||||||
|
yr = " ".join(f"{y}:{d['ret']*100:+.0f}%" for y, d in pa["yearly"].items())
|
||||||
|
print(f" {a}: full Sh {pa['full']['sharpe']:+.2f} DD {pa['full']['maxdd']*100:.0f}% "
|
||||||
|
f"hold Sh {pa['holdout'].get('sharpe', 0):+.2f} tim {pa['tim']} "
|
||||||
|
f"turn/y {pa['turnover']} | {yr}")
|
||||||
|
print(f" fee sweep: {pa['fee_sweep']}")
|
||||||
|
|
||||||
|
# causalita' + smallcap $600
|
||||||
|
ca = al.causality_ok(fn, tf="1d")
|
||||||
|
print(f"\n---- causality_ok: {ca['ok']} (max_tail_diff {ca['max_tail_diff']}, checked {ca['checked']})")
|
||||||
|
for a in ASSETS:
|
||||||
|
df = al.get(a, "1d")
|
||||||
|
mask = (pd.to_datetime(df["datetime"], utc=True) >= START).values
|
||||||
|
dft = df[mask].reset_index(drop=True)
|
||||||
|
tgtt = np.asarray(fn(df, a), float)[mask]
|
||||||
|
sc = al.eval_weights_smallcap(dft, tgtt, capital=600.0, min_order=5.0)
|
||||||
|
print(f" smallcap $600 {a}: modeled Sh {sc['modeled']['sharpe']:+.2f} -> "
|
||||||
|
f"real {sc['realistic']['sharpe']:+.2f} (haircut {sc['sharpe_haircut']:+.2f}, "
|
||||||
|
f"{sc['n_executed_trades']} trade eseguiti)")
|
||||||
|
|
||||||
|
# deep-dive ridondanza col trend (lezione macro-gate)
|
||||||
|
print("---- RIDONDANZA COL TREND (il rischio n.1):")
|
||||||
|
for a, d in gate_work_diag(fn).items():
|
||||||
|
print(f" {a}: corr(pos, TP01pos) {d['corr_pos']} giorni segnale-off {d['days_off']} "
|
||||||
|
f"TP01-gia'-flat {d['tp01_flat_days']} GIORNI IN CUI LAVORA {d['days_gate_works']}")
|
||||||
|
return dict(params=p, dsr=round(float(dsr), 3), earns_slot=earns_slot,
|
||||||
|
earns_slot_honest=honest, marginal_verdict=marg.get("marginal_verdict"),
|
||||||
|
abs_grade=abs_grade, corr_tp01=marg.get("corr_full"),
|
||||||
|
is_hedge=marg.get("is_hedge"))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"=== r0701 BREADTH/INTERNALS — panel: {_ALTS.shape[1]} alt, "
|
||||||
|
f"{_ALTS.index[0].date()} -> {_ALTS.index[-1].date()} | finestra analisi {START.date()}+ "
|
||||||
|
f"(in-sample {START.date()} -> {HOLDOUT.date()} = ~8 mesi; storia ~2.2y: LIMITE DICHIARATO)")
|
||||||
|
nv = _ALTS.notna().sum(axis=1)
|
||||||
|
print(f" asset validi/D: min {int(nv.min())} med {int(nv.median())} max {int(nv.max())} "
|
||||||
|
f"(MIN_VALID={MIN_VALID})")
|
||||||
|
|
||||||
|
# ---- 1. tutte le celle: Sharpe in-sample (selezione) + full (DSR) sulla finestra comune
|
||||||
|
rows = []
|
||||||
|
for p in GRID:
|
||||||
|
fn = factory(**p)
|
||||||
|
daily = cand_trim(fn)
|
||||||
|
ins = daily[daily.index < HOLDOUT]
|
||||||
|
if len(ins) < 60 or daily.std() == 0:
|
||||||
|
continue
|
||||||
|
rows.append(dict(params=p, act=cell_activity(p),
|
||||||
|
insample_sharpe=round(al._sh(ins), 3),
|
||||||
|
full_sharpe=round(al._sh(daily), 3),
|
||||||
|
hold_sharpe=round(al._sh(daily[daily.index >= HOLDOUT]), 3)))
|
||||||
|
rows.sort(key=lambda r: r["insample_sharpe"], reverse=True)
|
||||||
|
all_full = [r["full_sharpe"] for r in rows]
|
||||||
|
print(f"\n---- GRIGLIA: {len(GRID)} celle, {len(rows)} valutabili; "
|
||||||
|
f"full>0: {sum(1 for s in all_full if s > 0)}/{len(all_full)}")
|
||||||
|
print("---- TOP-15 per Sharpe IN-SAMPLE (selezione onesta: MAI sull'hold-out) "
|
||||||
|
"[hold mostrato solo per trasparenza; act = frazione stato di minoranza]")
|
||||||
|
for r in rows[:15]:
|
||||||
|
p = r["params"]
|
||||||
|
print(f" {p['fam']:>2s} N={p['N']:>3d} thr={p['thr']} {p['form']:>4s} act={r['act']:.2f} | "
|
||||||
|
f"IS {r['insample_sharpe']:+.2f} full {r['full_sharpe']:+.2f} hold {r['hold_sharpe']:+.2f}")
|
||||||
|
|
||||||
|
# ---- 2. PRIMARIO: cella scelta in-sample su TUTTA la griglia (procedura onesta pura)
|
||||||
|
out1 = full_report("CELLA SCELTA IN-SAMPLE (tutta la griglia)", rows[0], all_full, len(rows))
|
||||||
|
|
||||||
|
# ---- 3. SECONDARIO (dichiarato): sole celle ATTIVE (minoranza >=10% — criterio strutturale
|
||||||
|
# deciso a priori, NON di performance; DSR sempre deflazionato su TUTTI i trial).
|
||||||
|
active = [r for r in rows if r["act"] >= 0.10]
|
||||||
|
print(f"\n---- CELLE ATTIVE (act>=0.10): {len(active)}/{len(rows)}")
|
||||||
|
out2 = None
|
||||||
|
if active and active[0]["params"] != rows[0]["params"]:
|
||||||
|
out2 = full_report("SECONDARIO: best cella ATTIVA in-sample", active[0], all_full, len(rows))
|
||||||
|
|
||||||
|
# ---- 4. marginal sui best per-forma tra le ATTIVE (trasparenza: il verdetto per forma)
|
||||||
|
print("\n---- VERDETTO MARGINALE dei best-IN-SAMPLE ATTIVI per forma (contesto):")
|
||||||
|
for fo in FORMS:
|
||||||
|
sub = [r for r in active if r["params"]["form"] == fo]
|
||||||
|
if not sub:
|
||||||
|
continue
|
||||||
|
rp = sub[0]["params"]
|
||||||
|
m = al.marginal_vs_tp01(cand_trim(factory(**rp)))
|
||||||
|
uh = m["blends"]["w25"]["uplift_hold"]
|
||||||
|
print(f" {fo:>4s} {rp} act={sub[0]['act']:.2f}: {m.get('marginal_verdict')} "
|
||||||
|
f"corr {m.get('corr_full')} IS-edge {m.get('cand_insample_sharpe')} "
|
||||||
|
f"is_hedge {m.get('is_hedge')} uplift w25 full {m['blends']['w25']['uplift_full']:+.3f} "
|
||||||
|
f"hold {uh if uh is None else format(uh, '+.3f')} multicut {m.get('multicut_uplift')}")
|
||||||
|
|
||||||
|
print("\n==== SINTESI ====")
|
||||||
|
print(f" primario: {out1}")
|
||||||
|
print(f" secondario (attive): {out2}")
|
||||||
|
print("==== FINE r0701 ====")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""r0701_funding_ts — FUNDING RATE come segnale TIME-SERIES direzionale su BTC/ETH (NON carry).
|
||||||
|
|
||||||
|
2026-07-01. Ipotesi: il funding orario Hyperliquid (proxy di POSIZIONAMENTO/sentiment dei perp)
|
||||||
|
contiene informazione direzionale a orizzonte giornaliero su BTC/ETH. Famiglia (griglia modesta):
|
||||||
|
- FADE : z-score del funding estremo-positivo = affollamento long -> SHORT (e viceversa)
|
||||||
|
- FOLLOW : funding in espansione = domanda long persistente -> LONG (sentiment momentum)
|
||||||
|
- GATE : trend TP01-like long-flat, FLAT quando il funding e' affollato (z>=thr, de-risk)
|
||||||
|
- DIVERGE : momentum prezzo 20d con funding NON affollato -> follow; affollato -> fade
|
||||||
|
Griglia: 4 forme x lookback z {7,14,30,60}g x soglia {0.5,1.0,1.5} = 48 celle, solo 1d.
|
||||||
|
|
||||||
|
PRIOR ART (non ripetuto): FC01 carry cross-sectional delta-neutral -> SCARTATO
|
||||||
|
(docs/diary/2026-06-22-funding-carry-hl.md); funding price-clock intraday -> FAIL (onda intraday).
|
||||||
|
Qui il funding e' un SEGNALE time-series direzionale su BTC/ETH perp (2 gambe, eseguibile a ~$600),
|
||||||
|
non un cashflow da incassare.
|
||||||
|
|
||||||
|
DATI: data/raw/hlfund_{btc,eth}_1h.parquet (funding orario HL: 2023-05-12 -> 2026-06-22; primi
|
||||||
|
~27 giorni a cadenza 8h, poi oraria, 0 gap; certificato nel diario 2026-06-22). Prezzi certificati
|
||||||
|
Deribit via altlib.get (1d resampled leak-free).
|
||||||
|
|
||||||
|
CAUSALITA' (il punto delicato): le barre 1d sono OPEN-LABELED (datetime = 00:00 UTC del giorno D;
|
||||||
|
il close della barra D e' noto alle 00:00 di D+1). Il feature-day D aggrega i SOLI stamp funding
|
||||||
|
in [D 00:00, D+24h) — l'ultimo alle 23:00 — quindi tutto e' noto PRIMA della decisione al close
|
||||||
|
della barra D. eval_weights poi shifta: target[D] e' tenuto durante la barra D+1. Nessun leak
|
||||||
|
strutturale; in piu' prefix-check esplicito.
|
||||||
|
|
||||||
|
VALUTAZIONE su finestra TRONCATA alla copertura funding (2023-05-12..2026-06-21), NON sul frame
|
||||||
|
prezzi 2018+: fuori copertura il target sarebbe zero per costruzione e i giorni-zero (a) GONFIANO
|
||||||
|
il T del deflated-Sharpe (anti-conservativo) e (b) DILUISCONO cand_insample_sharpe (gate
|
||||||
|
has_insample_edge scatterebbe a vuoto). La logica di study_family_honest e' replicata ESATTAMENTE
|
||||||
|
sui frame troncati coi primitivi altlib: selezione cella IN-SAMPLE-ONLY (mai sul hold-out) ->
|
||||||
|
study_marginal gates (ADDS + robust_oos + has_insample_edge + not is_hedge) -> deflated-Sharpe
|
||||||
|
>= 0.95 sull'INTERA griglia. Cross-check con study_marginal non-troncato riportato in coda.
|
||||||
|
|
||||||
|
CAVEAT STORIA: funding solo dal 2023-05 (~3.1 anni). In-sample pre-HOLDOUT ~1.6 anni (meno il
|
||||||
|
warmup z), hold-out 2025-01+ ~1.5 anni. Finestra corta: qualunque PASS andrebbe comunque in
|
||||||
|
forward-monitor, e un FAIL su questa finestra non e' appellabile a "regime sfortunato".
|
||||||
|
|
||||||
|
Run: cd /opt/docker/PythagorasGoal && uv run python scripts/research/r0701_funding_ts.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent / "alt"))
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
FORMS = ("fade", "follow", "gate", "diverge")
|
||||||
|
LOOKBACKS = (7, 14, 30, 60)
|
||||||
|
THRESHOLDS = (0.5, 1.0, 1.5)
|
||||||
|
EARLY_8H_END = pd.Timestamp("2023-06-11", tz="UTC") # fino a qui cadenza 8h (3 stamp/giorno)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# DATI FUNDING — aggregazione giornaliera causale
|
||||||
|
# ===========================================================================
|
||||||
|
@lru_cache(maxsize=16)
|
||||||
|
def daily_funding(asset: str, back_h: int = 0) -> pd.DataFrame:
|
||||||
|
"""Funding giornaliero = SOMMA degli stamp orari nella finestra [D-back_h, D+24h-back_h).
|
||||||
|
back_h=0 (default) = giorno UTC pieno [D, D+24h): tutti gli stamp (ultimo 23:00) sono noti
|
||||||
|
al close della barra open-labeled D (= 00:00 di D+1) -> causale. back_h>0 sposta la finestra
|
||||||
|
INDIETRO (sempre causale) — usato solo dal boundary-shift check.
|
||||||
|
'valid' = giorno con copertura piena (>=20 stamp orari; >=3 nell'era 8h iniziale)."""
|
||||||
|
p = al.DATA_DIR / f"hlfund_{asset.lower()}_1h.parquet"
|
||||||
|
d = pd.read_parquet(p)
|
||||||
|
idx = pd.DatetimeIndex(pd.to_datetime(d.index, utc=True)) + pd.Timedelta(hours=back_h)
|
||||||
|
day = idx.floor("1D")
|
||||||
|
g = pd.Series(d["funding"].values.astype(float), index=day).groupby(level=0)
|
||||||
|
out = pd.DataFrame({"fday": g.sum(), "n": g.count()})
|
||||||
|
early = out.index <= EARLY_8H_END
|
||||||
|
out["valid"] = np.where(early, out["n"] >= 3, out["n"] >= 20)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def fund_window() -> tuple:
|
||||||
|
"""Intersezione BTC/ETH dei giorni funding validi (a back_h=0)."""
|
||||||
|
los, his = [], []
|
||||||
|
for a in ASSETS:
|
||||||
|
v = daily_funding(a)
|
||||||
|
vd = v.index[v["valid"].values]
|
||||||
|
los.append(vd.min()); his.append(vd.max())
|
||||||
|
return max(los), min(his)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8)
|
||||||
|
def get_trunc(asset: str, tf: str = "1d") -> pd.DataFrame:
|
||||||
|
"""Prezzi certificati troncati alla copertura funding (vedi docstring modulo)."""
|
||||||
|
lo, hi = fund_window()
|
||||||
|
df = al.get(asset, tf)
|
||||||
|
day = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)).floor("1D")
|
||||||
|
m = (day >= lo) & (day <= hi)
|
||||||
|
return df.loc[m].reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
def aligned_fday(df: pd.DataFrame, asset: str, back_h: int = 0) -> np.ndarray:
|
||||||
|
"""Funding giornaliero allineato alle barre di df (NaN dove manca/incompleto)."""
|
||||||
|
fd = daily_funding(asset, back_h)
|
||||||
|
day = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)).floor("1D")
|
||||||
|
return fd["fday"].where(fd["valid"]).reindex(day).values.astype(float)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# FAMIGLIA DI SEGNALI — factory(tf, form, lb, thr) -> target_fn(df, asset)
|
||||||
|
# ===========================================================================
|
||||||
|
def make_target(tf: str = "1d", form: str = "fade", lb: int = 30, thr: float = 1.0,
|
||||||
|
back_h: int = 0):
|
||||||
|
def target(df: pd.DataFrame, asset: str) -> np.ndarray:
|
||||||
|
f = aligned_fday(df, asset, back_h)
|
||||||
|
z = al.zscore(f, lb) # causale: rolling fino a i incluso
|
||||||
|
c = pd.Series(df["close"].values.astype(float))
|
||||||
|
if form == "fade": # affollamento long -> short (e viceversa)
|
||||||
|
d = np.where(z >= thr, -1.0, np.where(z <= -thr, 1.0, 0.0))
|
||||||
|
elif form == "follow": # funding come sentiment momentum
|
||||||
|
d = np.where(z >= thr, 1.0, np.where(z <= -thr, -1.0, 0.0))
|
||||||
|
elif form == "gate": # trend long-flat, flat se affollato
|
||||||
|
m30 = np.nan_to_num(np.sign(c.pct_change(30).values))
|
||||||
|
m90 = np.nan_to_num(np.sign(c.pct_change(90).values))
|
||||||
|
trend = ((m30 + m90) > 0).astype(float)
|
||||||
|
zz = np.where(np.isfinite(z), z, np.inf) # z ignoto -> conservativo: flat
|
||||||
|
d = trend * (zz < thr).astype(float)
|
||||||
|
elif form == "diverge": # mossa non affollata -> follow; affollata -> fade
|
||||||
|
mom = np.nan_to_num(np.sign(c.pct_change(20).values))
|
||||||
|
d = np.where(z >= thr, -mom, np.where(z <= -thr, mom, 0.0))
|
||||||
|
else:
|
||||||
|
raise ValueError(form)
|
||||||
|
return al.vol_target(np.nan_to_num(d), df, 0.20, 30, 2.0)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# VALUTAZIONE (replica study_family_honest su frame troncati)
|
||||||
|
# ===========================================================================
|
||||||
|
def cell_daily(target_fn, fee_side: float = al.FEE_SIDE) -> pd.Series:
|
||||||
|
"""Serie daily netta 50/50 BTC+ETH del candidato (convenzione candidate_daily)."""
|
||||||
|
series = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
df = get_trunc(a)
|
||||||
|
ev = al.eval_weights(df, target_fn(df, a), fee_side=fee_side)
|
||||||
|
series[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||||
|
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||||
|
return al._to_daily(0.5 * J["BTC"] + 0.5 * J["ETH"])
|
||||||
|
|
||||||
|
|
||||||
|
def scan_family() -> list[dict]:
|
||||||
|
rows = []
|
||||||
|
for form in FORMS:
|
||||||
|
for lb in LOOKBACKS:
|
||||||
|
for thr in THRESHOLDS:
|
||||||
|
daily = cell_daily(make_target(form=form, lb=lb, thr=thr))
|
||||||
|
ins = daily[daily.index < al.HOLDOUT]
|
||||||
|
hold = daily[daily.index >= al.HOLDOUT]
|
||||||
|
rows.append(dict(
|
||||||
|
form=form, lb=lb, thr=thr,
|
||||||
|
insample_sharpe=round(al._sh(ins), 3) if len(ins) > 60 else float("nan"),
|
||||||
|
full_sharpe=round(al._sh(daily), 3),
|
||||||
|
hold_sharpe=round(al._sh(hold), 3) if len(hold) > 60 else float("nan")))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def absolute_study(target_fn, name: str) -> dict:
|
||||||
|
"""study_weights-equivalente sui frame troncati (fee sweep 0.00-0.30% RT incluso)."""
|
||||||
|
per_asset = {}
|
||||||
|
fee_ok_all = True
|
||||||
|
for a in ASSETS:
|
||||||
|
df = get_trunc(a)
|
||||||
|
tgt = target_fn(df, a)
|
||||||
|
base = al.eval_weights(df, tgt, fee_side=al.FEE_SIDE)
|
||||||
|
sweep = {f"{2 * f * 100:.2f}%RT": al.eval_weights(df, tgt, fee_side=f)["full"]["sharpe"]
|
||||||
|
for f in al.FEE_SWEEP}
|
||||||
|
fee_ok_all = fee_ok_all and sweep.get("0.20%RT", -9) > 0
|
||||||
|
per_asset[a] = dict(full=base["full"], holdout=base["holdout"],
|
||||||
|
tim=base["time_in_market"], turnover=base["turnover_per_year"],
|
||||||
|
fee_sweep=sweep, yearly=base["yearly"])
|
||||||
|
cells = [dict(tf="1d", per_asset=per_asset,
|
||||||
|
min_asset_full_sharpe=round(min(per_asset[a]["full"]["sharpe"] for a in ASSETS), 3),
|
||||||
|
min_asset_holdout_sharpe=round(min(per_asset[a]["holdout"].get("sharpe", 0.0) for a in ASSETS), 3),
|
||||||
|
full_sharpe=round(float(np.mean([per_asset[a]["full"]["sharpe"] for a in ASSETS])), 3),
|
||||||
|
fee_survives=fee_ok_all)]
|
||||||
|
return dict(name=name, kind="weights", cells=cells, verdict=al._verdict(cells))
|
||||||
|
|
||||||
|
|
||||||
|
def prefix_check(target_fn, tail: int = 60) -> float:
|
||||||
|
"""Consistenza online (guardia look-ahead): il target ricalcolato su un prefisso troncato
|
||||||
|
deve coincidere col target(full) sugli stessi indici. Ritorna il max scostamento."""
|
||||||
|
worst = 0.0
|
||||||
|
for a in ASSETS:
|
||||||
|
df = get_trunc(a)
|
||||||
|
full = np.nan_to_num(np.asarray(target_fn(df, a), float))
|
||||||
|
n = len(df)
|
||||||
|
for cut in (int(n * 0.80), int(n * 0.92)):
|
||||||
|
sub = df.iloc[:cut].reset_index(drop=True)
|
||||||
|
s = np.nan_to_num(np.asarray(target_fn(sub, a), float))
|
||||||
|
worst = max(worst, float(np.max(np.abs(s[cut - tail:cut] - full[cut - tail:cut]))))
|
||||||
|
return worst
|
||||||
|
|
||||||
|
|
||||||
|
def boundary_check(form: str, lb: int, thr: float, offsets=(0, 3, 6, 9, 12)) -> dict:
|
||||||
|
"""Lezione day_boundary: sposto INDIETRO di back_h ore la finestra di aggregazione del
|
||||||
|
funding (sempre causale). Un effetto di posizionamento reale non cambia segno."""
|
||||||
|
B = al.tp01_baseline_daily()
|
||||||
|
out = {}
|
||||||
|
for off in offsets:
|
||||||
|
daily = cell_daily(make_target(form=form, lb=lb, thr=thr, back_h=off))
|
||||||
|
J = pd.concat({"B": B, "C": daily}, axis=1, join="inner").dropna()
|
||||||
|
up = al._sh(0.75 * J["B"] + 0.25 * J["C"]) - al._sh(J["B"]) if len(J) > 30 else float("nan")
|
||||||
|
out[off] = dict(full_sharpe=round(al._sh(daily), 3), uplift_w25=round(up, 3))
|
||||||
|
ups = [v["uplift_w25"] for v in out.values() if np.isfinite(v["uplift_w25"])]
|
||||||
|
shs = [v["full_sharpe"] for v in out.values()]
|
||||||
|
return dict(per_offset=out,
|
||||||
|
sharpe_sign_stable=bool(min(shs) * max(shs) >= 0 or max(map(abs, shs)) < 0.1),
|
||||||
|
uplift_spread=round(max(ups) - min(ups), 3) if ups else None)
|
||||||
|
|
||||||
|
|
||||||
|
def trend_only_target(df: pd.DataFrame, asset: str = "") -> np.ndarray:
|
||||||
|
"""CONTROLLO DECISIVO (lezione TP01-DVOL-overlay): lo STESSO trend long-flat della forma
|
||||||
|
'gate' ma SENZA il gate funding. Se fa uguale/meglio, il funding non aggiunge nulla."""
|
||||||
|
c = pd.Series(df["close"].values.astype(float))
|
||||||
|
m30 = np.nan_to_num(np.sign(c.pct_change(30).values))
|
||||||
|
m90 = np.nan_to_num(np.sign(c.pct_change(90).values))
|
||||||
|
trend = ((m30 + m90) > 0).astype(float)
|
||||||
|
return al.vol_target(trend, df, 0.20, 30, 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def smallcap_check(target_fn) -> dict:
|
||||||
|
out = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
df = get_trunc(a)
|
||||||
|
sc = al.eval_weights_smallcap(df, target_fn(df, a), capital=600.0, min_order=5.0)
|
||||||
|
out[a] = dict(modeled_sh=sc["modeled"]["sharpe"], realistic_sh=sc["realistic"]["sharpe"],
|
||||||
|
haircut=sc["sharpe_haircut"], n_trades=sc["n_executed_trades"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
def main():
|
||||||
|
print("=" * 88)
|
||||||
|
print("r0701_funding_ts — funding HL come segnale TS direzionale su BTC/ETH (non carry)")
|
||||||
|
print("=" * 88)
|
||||||
|
|
||||||
|
# --- 1. data-first: qualita'/copertura --------------------------------------------
|
||||||
|
lo, hi = fund_window()
|
||||||
|
print("\n[1] DATI FUNDING")
|
||||||
|
for a in ASSETS:
|
||||||
|
fd = daily_funding(a)
|
||||||
|
v = fd["valid"]
|
||||||
|
ann = fd.loc[v, "fday"].mean() * 365.25 * 100
|
||||||
|
print(f" {a}: giorni validi {int(v.sum())}/{len(fd)} "
|
||||||
|
f"finestra {fd.index[0].date()} -> {fd.index[-1].date()} "
|
||||||
|
f"funding medio {ann:+.1f}%/anno "
|
||||||
|
f"std daily {fd.loc[v, 'fday'].std() * 1e4:.2f} bps")
|
||||||
|
print(f" finestra comune valida: {lo.date()} -> {hi.date()} "
|
||||||
|
f"({(hi - lo).days} giorni, ~{(hi - lo).days / 365.25:.1f} anni)")
|
||||||
|
n_ins = (al.HOLDOUT - lo).days
|
||||||
|
n_hold = (hi - al.HOLDOUT).days
|
||||||
|
print(f" in-sample pre-HOLDOUT ~{n_ins}g ({n_ins / 365.25:.1f}a), "
|
||||||
|
f"hold-out ~{n_hold}g ({n_hold / 365.25:.1f}a) <-- STORIA CORTA, caveat")
|
||||||
|
|
||||||
|
# --- 2. scan famiglia (48 celle, selezione IN-SAMPLE-ONLY) -------------------------
|
||||||
|
print("\n[2] SCAN FAMIGLIA (4 forme x lb{7,14,30,60} x thr{0.5,1.0,1.5} = 48 celle, 1d)")
|
||||||
|
rows = scan_family()
|
||||||
|
valid = [r for r in rows if np.isfinite(r["insample_sharpe"])]
|
||||||
|
valid.sort(key=lambda r: r["insample_sharpe"], reverse=True)
|
||||||
|
print(f" celle valide {len(valid)}/{len(rows)}; top-8 per Sharpe IN-SAMPLE "
|
||||||
|
f"(hold-out mostrato SOLO per trasparenza, mai per selezione):")
|
||||||
|
print(f" {'form':8s} {'lb':>3s} {'thr':>4s} {'IS':>7s} {'FULL':>7s} {'HOLD':>7s}")
|
||||||
|
for r in valid[:8]:
|
||||||
|
print(f" {r['form']:8s} {r['lb']:3d} {r['thr']:4.1f} {r['insample_sharpe']:7.2f} "
|
||||||
|
f"{r['full_sharpe']:7.2f} {r['hold_sharpe']:7.2f}")
|
||||||
|
per_form = {f: max((r["insample_sharpe"] for r in valid if r["form"] == f), default=float("nan"))
|
||||||
|
for f in FORMS}
|
||||||
|
print(f" best IS per forma: {per_form}")
|
||||||
|
|
||||||
|
chosen = valid[0]
|
||||||
|
print(f"\n CELLA SCELTA (in-sample-only): {chosen['form']} lb={chosen['lb']} thr={chosen['thr']} "
|
||||||
|
f"(IS {chosen['insample_sharpe']}, FULL {chosen['full_sharpe']}, HOLD {chosen['hold_sharpe']})")
|
||||||
|
fn = make_target(form=chosen["form"], lb=chosen["lb"], thr=chosen["thr"])
|
||||||
|
daily = cell_daily(fn)
|
||||||
|
|
||||||
|
# --- 3. deflated Sharpe sull'INTERA griglia ----------------------------------------
|
||||||
|
all_full = [r["full_sharpe"] for r in rows]
|
||||||
|
dsr, sr0 = al.deflated_sharpe(al._sh(daily), all_full, daily)
|
||||||
|
dsr_pass = bool(np.isfinite(dsr) and dsr >= 0.95)
|
||||||
|
print(f"\n[3] DEFLATED SHARPE (griglia {len(rows)} celle): "
|
||||||
|
f"DSR={dsr:.3f} (null-max atteso {sr0:.2f}) PASS(>=0.95)={dsr_pass}")
|
||||||
|
|
||||||
|
# --- 4. assoluto + marginale (gates study_marginal) --------------------------------
|
||||||
|
print("\n[4] ASSOLUTO (frame troncati, fee sweep 0.00-0.30% RT)")
|
||||||
|
absolute = absolute_study(fn, f"R0701-FUND-{chosen['form'].upper()}")
|
||||||
|
print(al.fmt(absolute))
|
||||||
|
c0 = absolute["cells"][0]
|
||||||
|
for a in ASSETS:
|
||||||
|
pa = c0["per_asset"][a]
|
||||||
|
print(f" {a}: TIM={pa['tim']} turnover/anno={pa['turnover']} fee_sweep={pa['fee_sweep']}")
|
||||||
|
|
||||||
|
print("\n[5] MARGINALE vs TP01 (finestra comune col baseline)")
|
||||||
|
marg = al.marginal_vs_tp01(daily)
|
||||||
|
abs_grade = absolute["verdict"]["grade"]
|
||||||
|
earns_slot = (abs_grade != "FAIL" and marg.get("marginal_verdict") == "ADDS"
|
||||||
|
and marg.get("robust_oos", False) and marg.get("beats_noise_null", False)
|
||||||
|
and not marg.get("is_hedge", False))
|
||||||
|
rep = dict(name=f"R0701-FUND {chosen['form']} lb{chosen['lb']} thr{chosen['thr']}",
|
||||||
|
absolute=absolute, marginal=marg, abs_grade=abs_grade,
|
||||||
|
marginal_verdict=marg.get("marginal_verdict"), earns_slot=earns_slot)
|
||||||
|
print(al.fmt_marginal(rep))
|
||||||
|
earns_honest = bool(earns_slot and dsr_pass)
|
||||||
|
print(f"\n EARNS_SLOT (marginal) = {earns_slot} EARNS_SLOT_HONEST (con DSR) = {earns_honest}")
|
||||||
|
|
||||||
|
# --- 5bis. controllo di attribuzione: il funding aggiunge qualcosa al trend nudo? ----
|
||||||
|
print("\n[5bis] CONTROLLO DECISIVO — trend long-flat IDENTICO ma SENZA gate funding")
|
||||||
|
d_tr = cell_daily(trend_only_target)
|
||||||
|
tr_ins, tr_hold = d_tr[d_tr.index < al.HOLDOUT], d_tr[d_tr.index >= al.HOLDOUT]
|
||||||
|
JJ = pd.concat({"G": daily, "T": d_tr}, axis=1, join="inner").dropna()
|
||||||
|
print(f" trend NUDO: IS {al._sh(tr_ins):.2f} FULL {al._sh(d_tr):.2f} HOLD {al._sh(tr_hold):.2f}")
|
||||||
|
print(f" trend+GATE: IS {chosen['insample_sharpe']:.2f} FULL {chosen['full_sharpe']:.2f} "
|
||||||
|
f"HOLD {chosen['hold_sharpe']:.2f}")
|
||||||
|
print(f" corr(gated, nudo) = {JJ['G'].corr(JJ['T']):.3f} "
|
||||||
|
f"delta FULL = {al._sh(JJ['G']) - al._sh(JJ['T']):+.3f} "
|
||||||
|
f"delta HOLD = {al._sh(JJ['G'][JJ.index >= al.HOLDOUT]) - al._sh(JJ['T'][JJ.index >= al.HOLDOUT]):+.3f}")
|
||||||
|
attribution = dict(trend_nudo=dict(IS=round(al._sh(tr_ins), 3), FULL=round(al._sh(d_tr), 3),
|
||||||
|
HOLD=round(al._sh(tr_hold), 3)),
|
||||||
|
corr_gated_nudo=round(float(JJ["G"].corr(JJ["T"])), 3),
|
||||||
|
delta_full=round(al._sh(JJ["G"]) - al._sh(JJ["T"]), 3),
|
||||||
|
delta_hold=round(al._sh(JJ["G"][JJ.index >= al.HOLDOUT])
|
||||||
|
- al._sh(JJ["T"][JJ.index >= al.HOLDOUT]), 3))
|
||||||
|
|
||||||
|
# --- 5ter. la migliore cella PURO-funding (fade/follow/diverge, senza trend) ---------
|
||||||
|
pure = [r for r in valid if r["form"] != "gate"]
|
||||||
|
bp = pure[0] if pure else None
|
||||||
|
if bp:
|
||||||
|
print(f"\n[5ter] MIGLIOR CELLA PURO-FUNDING (no trend): {bp['form']} lb={bp['lb']} "
|
||||||
|
f"thr={bp['thr']} IS {bp['insample_sharpe']} FULL {bp['full_sharpe']} "
|
||||||
|
f"HOLD {bp['hold_sharpe']}")
|
||||||
|
d_bp = cell_daily(make_target(form=bp["form"], lb=bp["lb"], thr=bp["thr"]))
|
||||||
|
m_bp = al.marginal_vs_tp01(d_bp)
|
||||||
|
print(f" marginale vs TP01: {m_bp.get('marginal_verdict')} corr {m_bp.get('corr_full')} "
|
||||||
|
f"IS-edge {m_bp.get('cand_insample_sharpe')} "
|
||||||
|
f"uplift w25 full {m_bp['blends']['w25']['uplift_full']:+.3f} / "
|
||||||
|
f"hold {m_bp['blends']['w25']['uplift_hold']:+.3f}")
|
||||||
|
|
||||||
|
# --- 6. realism: prefix / boundary / smallcap ---------------------------------------
|
||||||
|
print("\n[6] REALISM CHECKS (cella scelta)")
|
||||||
|
worst = prefix_check(fn)
|
||||||
|
print(f" prefix-consistency (guardia look-ahead): max diff = {worst:.2e} "
|
||||||
|
f"({'OK' if worst < 1e-9 else 'ATTENZIONE'})")
|
||||||
|
bnd = boundary_check(chosen["form"], chosen["lb"], chosen["thr"])
|
||||||
|
print(f" boundary-shift (finestra funding -0/3/6/9/12h): {bnd['per_offset']}")
|
||||||
|
print(f" sharpe_sign_stable={bnd['sharpe_sign_stable']} uplift_spread={bnd['uplift_spread']}")
|
||||||
|
sc = smallcap_check(fn)
|
||||||
|
print(f" smallcap $600 (min order $5): {sc}")
|
||||||
|
|
||||||
|
# --- 7. cross-check non troncato (footnote) -----------------------------------------
|
||||||
|
print("\n[7] CROSS-CHECK study_marginal NON troncato (frame 2018+, diluito dagli zeri "
|
||||||
|
"pre-copertura: footnote, non il giudizio primario)")
|
||||||
|
sm_full = al.study_marginal(f"R0701-FUND-XCHK {chosen['form']}", fn, tf="1d")
|
||||||
|
print(f" abs={sm_full['abs_grade']} marginal={sm_full['marginal_verdict']} "
|
||||||
|
f"earns_slot={sm_full['earns_slot']}")
|
||||||
|
|
||||||
|
# --- 8. verdetto --------------------------------------------------------------------
|
||||||
|
summary = dict(
|
||||||
|
chosen=dict(form=chosen["form"], lb=chosen["lb"], thr=chosen["thr"]),
|
||||||
|
insample_sharpe=chosen["insample_sharpe"], full_sharpe=chosen["full_sharpe"],
|
||||||
|
hold_sharpe=chosen["hold_sharpe"], dsr=round(float(dsr), 3), dsr_pass=dsr_pass,
|
||||||
|
abs_grade=abs_grade, marginal_verdict=marg.get("marginal_verdict"),
|
||||||
|
corr_tp01_full=marg.get("corr_full"), cand_insample_sharpe=marg.get("cand_insample_sharpe"),
|
||||||
|
has_insample_edge=marg.get("has_insample_edge"), is_hedge=marg.get("is_hedge"),
|
||||||
|
robust_oos=marg.get("robust_oos"), multicut=marg.get("multicut_uplift"),
|
||||||
|
earns_slot=earns_slot, earns_slot_honest=earns_honest,
|
||||||
|
smallcap=sc, boundary_uplift_spread=bnd["uplift_spread"],
|
||||||
|
attribution_vs_trend_nudo=attribution,
|
||||||
|
best_pure_funding=(dict(form=bp["form"], lb=bp["lb"], thr=bp["thr"],
|
||||||
|
IS=bp["insample_sharpe"], FULL=bp["full_sharpe"],
|
||||||
|
HOLD=bp["hold_sharpe"]) if bp else None),
|
||||||
|
n_cells=len(rows), history_years=round((hi - lo).days / 365.25, 1))
|
||||||
|
print("\n[8] SUMMARY JSON")
|
||||||
|
print(json.dumps(summary, default=str))
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""r0701_gtaa_5th_sleeve — EQ-GTAA01 come 5° sleeve del portafoglio attivo? Valutazione ONESTA.
|
||||||
|
|
||||||
|
Contesto (diari 2026-06-22/23): GTAA (trend difensivo equity, 6 ETF, vt12%, ~30y di storia, netto
|
||||||
|
fee IB 2bps/lato) fu validato come diversificatore strutturale del book crypto (corr ~0.17-0.21,
|
||||||
|
combo Sharpe 1.60->1.81) ma lasciato in paper cross-venue (paper_combo) e MAI valutato come 5°
|
||||||
|
sleeve del portafoglio attivo (il giorno dopo arrivo' SKH01). Qui: gate onesti per lo slot.
|
||||||
|
|
||||||
|
Metodo:
|
||||||
|
* pannello = 4 sleeve attivi + GTAA (to_daily), outer-join rinormalizzato (combine_outer);
|
||||||
|
* finestra di valutazione ancorata all'inizio del book crypto (TP01 start) — NO gonfiaggio
|
||||||
|
da 23 anni di solo-GTAA pre-2019;
|
||||||
|
* peso GTAA selezionato IN-SAMPLE (Sharpe pre-2025) su griglia {10..30%}, gli altri scalati
|
||||||
|
(1-w) — nessuna scelta sull'hold-out;
|
||||||
|
* verifica: hold-out 2025+, multi-cut (2023-01/2024-01/2024-07/2025-01), finestre DISGIUNTE,
|
||||||
|
per-anno, corr, is_hedge (corr tra Sharpe-anno del book e uplift-anno), plateau in w.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from src.portfolio.portfolio import HOLDOUT, combine_outer, metrics, to_daily
|
||||||
|
from src.portfolio.sleeves import active_sleeves
|
||||||
|
from src.portfolio.gtaa import gtaa_returns
|
||||||
|
|
||||||
|
GRID_W = [0.10, 0.15, 0.20, 0.25, 0.30]
|
||||||
|
CUTS = [pd.Timestamp(c, tz="UTC") for c in ("2023-01-01", "2024-01-01", "2024-07-01", "2025-01-01")]
|
||||||
|
DISJOINT = [("2019-03-01", "2023-01-01"), ("2023-01-01", "2025-01-01"), ("2025-01-01", "2027-01-01")]
|
||||||
|
|
||||||
|
|
||||||
|
def sh(s: pd.Series, lo=None, hi=None) -> float:
|
||||||
|
v = s
|
||||||
|
if lo is not None:
|
||||||
|
v = v[v.index >= lo]
|
||||||
|
if hi is not None:
|
||||||
|
v = v[v.index < hi]
|
||||||
|
r = np.asarray(v.dropna().values, float)
|
||||||
|
return float(r.mean() / r.std() * np.sqrt(365.25)) if len(r) > 1 and r.std() > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
sl = active_sleeves()
|
||||||
|
cols = {s.name: s.daily() for s in sl}
|
||||||
|
w_cur = {s.name: s.weight for s in sl}
|
||||||
|
g = to_daily(gtaa_returns())
|
||||||
|
lo_book = min(c.index.min() for c in cols.values()) # inizio era-crypto del book
|
||||||
|
cols5 = dict(cols, GTAA01=g)
|
||||||
|
|
||||||
|
base = combine_outer(cols, w_cur, lo=lo_book)
|
||||||
|
print(f"book start {lo_book.date()} | GTAA storia {g.index.min().date()}->{g.index.max().date()}"
|
||||||
|
f" | GTAA standalone: FULL {sh(g):.2f}, pre-2025 {sh(g, hi=HOLDOUT):.2f}, "
|
||||||
|
f"era-crypto {sh(g[g.index >= lo_book]):.2f}, HOLD {sh(g, lo=HOLDOUT):.2f}")
|
||||||
|
print(f"corr(GTAA, book) era-crypto: {g.reindex(base.index).corr(base):+.3f} | "
|
||||||
|
f"hold-out: {g.reindex(base.index).loc[HOLDOUT:].corr(base.loc[HOLDOUT:]):+.3f}")
|
||||||
|
print(f"\nBASE 4-sleeve : FULL {sh(base):.3f} IS {sh(base, hi=HOLDOUT):.3f} "
|
||||||
|
f"HOLD {sh(base, lo=HOLDOUT):.3f} DD {metrics(base)['maxdd']:.1%} "
|
||||||
|
f"CAGR {metrics(base)['cagr']:+.1%}")
|
||||||
|
|
||||||
|
# griglia peso GTAA — selezione IN-SAMPLE
|
||||||
|
rows = []
|
||||||
|
for w in GRID_W:
|
||||||
|
w5 = {k: v * (1 - w) for k, v in w_cur.items()} | {"GTAA01": w}
|
||||||
|
c5 = combine_outer(cols5, w5, lo=lo_book)
|
||||||
|
rows.append(dict(w=w, IS=sh(c5, hi=HOLDOUT), FULL=sh(c5), HOLD=sh(c5, lo=HOLDOUT),
|
||||||
|
DD=metrics(c5)["maxdd"], CAGR=metrics(c5)["cagr"]))
|
||||||
|
print(f" +GTAA {w:.0%}: IS {rows[-1]['IS']:.3f} FULL {rows[-1]['FULL']:.3f} "
|
||||||
|
f"HOLD {rows[-1]['HOLD']:.3f} DD {rows[-1]['DD']:.1%} CAGR {rows[-1]['CAGR']:+.1%}")
|
||||||
|
best = max(rows, key=lambda r: r["IS"])
|
||||||
|
w = best["w"]
|
||||||
|
print(f"\ncella IN-SAMPLE: w={w:.0%} (IS {best['IS']:.3f}); plateau IS: "
|
||||||
|
+ ", ".join(f"{r['w']:.0%}:{r['IS']:.2f}" for r in rows))
|
||||||
|
|
||||||
|
w5 = {k: v * (1 - w) for k, v in w_cur.items()} | {"GTAA01": w}
|
||||||
|
c5 = combine_outer(cols5, w5, lo=lo_book)
|
||||||
|
|
||||||
|
print("\nmulti-cut (Sharpe OOS dal taglio, book+GTAA vs book):")
|
||||||
|
for cut in CUTS:
|
||||||
|
print(f" {cut.date()}: {sh(c5, lo=cut):.3f} vs {sh(base, lo=cut):.3f} "
|
||||||
|
f"delta {sh(c5, lo=cut) - sh(base, lo=cut):+.3f}")
|
||||||
|
print("finestre DISGIUNTE:")
|
||||||
|
for a, b in DISJOINT:
|
||||||
|
la, lb = pd.Timestamp(a, tz="UTC"), pd.Timestamp(b, tz="UTC")
|
||||||
|
print(f" {a[:7]}..{b[:7]}: delta {sh(c5, lo=la, hi=lb) - sh(base, lo=la, hi=lb):+.3f}")
|
||||||
|
|
||||||
|
print("per-anno (ret book -> ret book+GTAA):")
|
||||||
|
ups, shs = [], []
|
||||||
|
for y in sorted(set(base.index.year)):
|
||||||
|
by, cy = base[base.index.year == y], c5[c5.index.year == y]
|
||||||
|
rb, rc = float((1 + by).prod() - 1), float((1 + cy).prod() - 1)
|
||||||
|
ups.append(sh(cy) - sh(by)); shs.append(sh(by))
|
||||||
|
print(f" {y}: {rb:+7.1%} -> {rc:+7.1%} dSh {sh(cy) - sh(by):+.2f}")
|
||||||
|
ih = float(np.corrcoef(shs, ups)[0, 1]) if len(ups) > 2 else float("nan")
|
||||||
|
print(f"is_hedge check — corr(Sharpe-anno book, uplift-anno): {ih:+.2f} "
|
||||||
|
f"(molto negativa = hedge, non alpha)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
"""r0701_portfolio_opt — MIGLIORARE IL PORTAFOGLIO ATTIVO SENZA NUOVE STRATEGIE (2026-07-01).
|
||||||
|
|
||||||
|
(A) RI-OTTIMIZZAZIONE DEI PESI STATICI dei 4 sleeve (TP01/XS01/VRP01/SKH01).
|
||||||
|
I pesi correnti (41.25/18.75/15/25) sono ad-hoc. Test onesto:
|
||||||
|
- ottimizza SOLO in-sample (pre-cut), valuta OOS (post-cut), multi-cut {2024-01, 2024-07, 2025-01};
|
||||||
|
- criteri: MAXSH (max-Sharpe simulato), RP (inverse-vol), ERC (equal-risk-contribution),
|
||||||
|
MINVAR-R (min-varianza con vincolo di ritorno >= ritorno dei pesi correnti in-sample);
|
||||||
|
- null di riferimento: EW (25x4) e CURRENT (pesi correnti);
|
||||||
|
- vincoli: 0.05 <= w_i <= 0.60, sum(w)=1;
|
||||||
|
- sleeve con < MIN_IS_DAYS giorni in-sample al cut (XS01 al 2024-01) -> PINNED al peso corrente
|
||||||
|
(l'ottimizzatore non ha dati per giudicarli), gli altri ottimizzati nel budget residuo;
|
||||||
|
- il combine replica ESATTAMENTE src/portfolio/portfolio.combined_daily (outer-join,
|
||||||
|
pesi rinormalizzati per-riga sugli sleeve attivi).
|
||||||
|
Verdetto: MIGLIORA solo se l'uplift OOS vs CURRENT e' PERSISTENTE (positivo a ogni cut),
|
||||||
|
non su una finestra sola.
|
||||||
|
|
||||||
|
(B) GUARDIA-DRAWDOWN a livello portafoglio (replica del soft-guard vincente del tail-hedge lab
|
||||||
|
2026-06-23, portato sul 4-sleeve). CAUSALE: l'esposizione del giorno D usa l'equity fino a D-1.
|
||||||
|
Trigger/re-entry sul NAV OMBRA (equity non-guardata: lezione stops_lab — sull'equity congelata
|
||||||
|
a expo=0 il DD non rientra mai). Isteresi: de-risk quando DD>X, re-risk quando DD<0.4*X.
|
||||||
|
Griglia modesta X in {3,4,5,6}%, derisk in {0, 0.5}. Selezione cella IN-SAMPLE (pre-cut,
|
||||||
|
criterio Calmar = CAGR/MaxDD), verifica OOS multi-cut.
|
||||||
|
NULL DECISIVO (lezione tp01-dvol-overlay): esposizione COSTANTE c calibrata in-sample per
|
||||||
|
pareggiare il MaxDD della guardia — lo Sharpe di c*r e' INVARIANTE, quindi la guardia "vale"
|
||||||
|
solo se a pari MaxDD trattiene piu' CAGR/Sharpe del semplice de-levering.
|
||||||
|
|
||||||
|
(C) Combinazione A+B se entrambe reggono.
|
||||||
|
|
||||||
|
NB (contesto): il thread meta-allocazione (2026-06-29) ha gia' mostrato che l'allocazione DINAMICA
|
||||||
|
fra sleeve perde vs pesi fissi — qui NON la rifacciamo: (A) e' statico, (B) e' un overlay di
|
||||||
|
esposizione aggregata, non riallocazione fra sleeve.
|
||||||
|
|
||||||
|
Solo ricerca: NON tocca src/ ne' scripts/live/. Propone pesi, non li applica.
|
||||||
|
|
||||||
|
uv run python scripts/research/r0701_portfolio_opt.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from scipy.optimize import minimize
|
||||||
|
|
||||||
|
from src.portfolio.sleeves import active_sleeves
|
||||||
|
from src.portfolio.portfolio import HOLDOUT, DAYS_PER_YEAR
|
||||||
|
|
||||||
|
CUTS = [pd.Timestamp(c, tz="UTC") for c in ("2024-01-01", "2024-07-01", "2025-01-01")]
|
||||||
|
LO_W, HI_W = 0.05, 0.60 # vincoli pesi
|
||||||
|
MIN_IS_DAYS = 120 # sotto -> sleeve PINNED al peso corrente (niente dati per giudicarlo)
|
||||||
|
GUARD_TRIGGERS = (0.03, 0.04, 0.05, 0.06)
|
||||||
|
GUARD_DERISK = (0.0, 0.5)
|
||||||
|
GUARD_REC_FRAC = 0.4 # re-risk quando DD < 0.4*trigger (isteresi, come tail-hedge lab)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- dati
|
||||||
|
def load_matrix() -> tuple[pd.DataFrame, list[str], np.ndarray]:
|
||||||
|
"""Matrice daily OUTER-join dei 4 sleeve (NaN dove lo sleeve non e' ancora partito)."""
|
||||||
|
sl = active_sleeves()
|
||||||
|
names = [s.name for s in sl]
|
||||||
|
w_cur = np.array([s.weight for s in sl], float)
|
||||||
|
w_cur = w_cur / w_cur.sum()
|
||||||
|
J = pd.concat({s.name: s.daily() for s in sl}, axis=1, join="outer").sort_index()
|
||||||
|
J = J[J.notna().any(axis=1)]
|
||||||
|
return J, names, w_cur
|
||||||
|
|
||||||
|
|
||||||
|
def combo(J: pd.DataFrame, w: np.ndarray) -> pd.Series:
|
||||||
|
"""Replica ESATTA di portfolio.combined_daily: pesi rinormalizzati per-riga sugli attivi."""
|
||||||
|
active = J.notna().values * w
|
||||||
|
rowsum = active.sum(axis=1, keepdims=True)
|
||||||
|
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
|
||||||
|
return pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
|
||||||
|
|
||||||
|
|
||||||
|
def met(r: pd.Series | np.ndarray) -> dict:
|
||||||
|
v = np.asarray(pd.Series(r).dropna().values, float)
|
||||||
|
if len(v) < 20 or v.std() == 0:
|
||||||
|
return dict(sh=0.0, cagr=0.0, dd=0.0, calmar=0.0, n=len(v))
|
||||||
|
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
|
||||||
|
yrs = len(v) / DAYS_PER_YEAR
|
||||||
|
cagr = float(eq[-1] ** (1 / yrs) - 1) if eq[-1] > 0 else -1.0
|
||||||
|
dd = float(np.max((pk - eq) / pk))
|
||||||
|
return dict(sh=float(v.mean() / v.std() * np.sqrt(DAYS_PER_YEAR)), cagr=cagr, dd=dd,
|
||||||
|
calmar=(cagr / dd if dd > 0 else 0.0), n=len(v))
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_w(names, w):
|
||||||
|
return " ".join(f"{n.split('_')[0]} {x*100:.1f}%" for n, x in zip(names, w))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- (A) ottimizzatori
|
||||||
|
def _pinned_mask(J_is: pd.DataFrame, w_cur: np.ndarray) -> np.ndarray:
|
||||||
|
"""True = sleeve senza abbastanza storia in-sample -> peso fissato al corrente."""
|
||||||
|
return np.array([J_is[c].notna().sum() < MIN_IS_DAYS for c in J_is.columns])
|
||||||
|
|
||||||
|
|
||||||
|
def _solve(J_is, w_cur, objective, extra_cons=None, hi_bounds=None) -> np.ndarray:
|
||||||
|
"""SLSQP su pesi liberi (i pinned restano al corrente), bounds+somma, multi-start.
|
||||||
|
hi_bounds: array di upper-bound per-sleeve (default HI_W) — per i cap STRUTTURALI."""
|
||||||
|
k = len(w_cur)
|
||||||
|
hi = np.full(k, HI_W) if hi_bounds is None else np.asarray(hi_bounds, float)
|
||||||
|
pin = _pinned_mask(J_is, w_cur)
|
||||||
|
free = ~pin
|
||||||
|
budget = 1.0 - w_cur[pin].sum()
|
||||||
|
nf = int(free.sum())
|
||||||
|
|
||||||
|
def full_w(wf):
|
||||||
|
w = w_cur.copy(); w[free] = wf
|
||||||
|
return w
|
||||||
|
|
||||||
|
cons = [dict(type="eq", fun=lambda wf: wf.sum() - budget)]
|
||||||
|
if extra_cons:
|
||||||
|
cons += [dict(type="ineq", fun=lambda wf, f=f: f(full_w(wf))) for f in extra_cons]
|
||||||
|
bounds = [(LO_W, float(h)) for h in hi[free]]
|
||||||
|
hif = hi[free]
|
||||||
|
starts = [w_cur[free], np.full(nf, budget / nf)]
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
for _ in range(4):
|
||||||
|
x = rng.uniform(LO_W, hif); starts.append(x / x.sum() * budget)
|
||||||
|
best, bval = w_cur.copy(), np.inf
|
||||||
|
for x0 in starts:
|
||||||
|
x0 = np.clip(x0, LO_W, hif); x0 = x0 / x0.sum() * budget
|
||||||
|
res = minimize(lambda wf: objective(full_w(wf)), x0, method="SLSQP",
|
||||||
|
bounds=bounds, constraints=cons,
|
||||||
|
options=dict(maxiter=300, ftol=1e-10))
|
||||||
|
if res.success and res.fun < bval:
|
||||||
|
bval, best = res.fun, full_w(res.x)
|
||||||
|
best = np.clip(best, LO_W, hi)
|
||||||
|
return best / best.sum()
|
||||||
|
|
||||||
|
|
||||||
|
def opt_maxsh(J_is, w_cur):
|
||||||
|
return _solve(J_is, w_cur, lambda w: -met(combo(J_is, w))["sh"])
|
||||||
|
|
||||||
|
|
||||||
|
def opt_minvar_ret(J_is, w_cur):
|
||||||
|
"""Min-varianza con vincolo: media in-sample >= media dei pesi correnti in-sample."""
|
||||||
|
mu_cur = float(combo(J_is, w_cur).mean())
|
||||||
|
return _solve(J_is, w_cur, lambda w: float(combo(J_is, w).var()),
|
||||||
|
extra_cons=[lambda w: float(combo(J_is, w).mean()) - mu_cur])
|
||||||
|
|
||||||
|
|
||||||
|
def opt_rp(J_is, w_cur):
|
||||||
|
"""Inverse-vol (risk-parity naive) sulla vol in-sample di ogni sleeve (giorni attivi propri)."""
|
||||||
|
pin = _pinned_mask(J_is, w_cur); free = ~pin
|
||||||
|
vol = np.array([J_is[c].dropna().std() for c in J_is.columns])
|
||||||
|
w = w_cur.copy()
|
||||||
|
iv = 1.0 / np.where(vol[free] > 0, vol[free], np.inf)
|
||||||
|
w[free] = iv / iv.sum() * (1.0 - w_cur[pin].sum())
|
||||||
|
for _ in range(50): # clip ai bounds + rinormalizza
|
||||||
|
w = np.clip(w, LO_W, HI_W)
|
||||||
|
if abs(w.sum() - 1.0) < 1e-9:
|
||||||
|
break
|
||||||
|
adj = ~np.isclose(w, LO_W) & ~np.isclose(w, HI_W)
|
||||||
|
if not adj.any():
|
||||||
|
break
|
||||||
|
w[adj] += (1.0 - w.sum()) * (w[adj] / w[adj].sum())
|
||||||
|
return w / w.sum()
|
||||||
|
|
||||||
|
|
||||||
|
def opt_erc(J_is, w_cur):
|
||||||
|
"""Equal-risk-contribution sulla cov in-sample pairwise-complete (submatrice dei non-pinned)."""
|
||||||
|
pin = _pinned_mask(J_is, w_cur)
|
||||||
|
cov = J_is.cov().values # pairwise-complete
|
||||||
|
idx = np.where(~pin)[0]
|
||||||
|
S = cov[np.ix_(idx, idx)]
|
||||||
|
S = np.nan_to_num(S, nan=0.0)
|
||||||
|
|
||||||
|
def obj(w):
|
||||||
|
wf = w[idx]
|
||||||
|
port = wf @ S @ wf
|
||||||
|
if port <= 0:
|
||||||
|
return 1e6
|
||||||
|
rc = wf * (S @ wf) / port
|
||||||
|
return float(np.sum((rc - 1.0 / len(idx)) ** 2))
|
||||||
|
return _solve(J_is, w_cur, obj)
|
||||||
|
|
||||||
|
|
||||||
|
def _struct_caps(J_is) -> np.ndarray:
|
||||||
|
"""Cap STRUTTURALI (prudenza, non statistica): VRP01 <= 15% (sleeve MODELLATO su DVOL ATM,
|
||||||
|
stress-f non catturato — 'niente short-vol da modello in deploy'), XS01 <= 25% (STAT-MODE,
|
||||||
|
storia ~2.5 anni). Gli ottimizzatori naive caricano VRP01 (vol 7.7% ma e' la vol del MODELLO)."""
|
||||||
|
return np.array([0.15 if c.startswith("VRP") else (0.25 if c.startswith("XS") else HI_W)
|
||||||
|
for c in J_is.columns])
|
||||||
|
|
||||||
|
|
||||||
|
def opt_maxsh_struct(J_is, w_cur):
|
||||||
|
return _solve(J_is, w_cur, lambda w: -met(combo(J_is, w))["sh"], hi_bounds=_struct_caps(J_is))
|
||||||
|
|
||||||
|
|
||||||
|
CRITERIA = [("MAXSH", opt_maxsh), ("RP-invvol", opt_rp), ("ERC", opt_erc), ("MINVAR-R", opt_minvar_ret),
|
||||||
|
("MAXSH-STR", opt_maxsh_struct)]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- (B) guardia-DD
|
||||||
|
def dd_guard(r: pd.Series, trigger: float, derisk: float, rec_frac: float = GUARD_REC_FRAC):
|
||||||
|
"""Soft-guard causale: expo[t] decisa dal DD del NAV OMBRA (non-guardato) fino a t-1.
|
||||||
|
DD>trigger -> expo=derisk; DD<rec_frac*trigger -> expo=1 (isteresi). Ritorna (serie, expo)."""
|
||||||
|
v = r.values
|
||||||
|
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
|
||||||
|
n = len(v); expo = np.ones(n); on = True
|
||||||
|
for i in range(1, n):
|
||||||
|
ddi = (pk[i - 1] - eq[i - 1]) / pk[i - 1]
|
||||||
|
if ddi > trigger:
|
||||||
|
on = False
|
||||||
|
elif ddi < trigger * rec_frac:
|
||||||
|
on = True
|
||||||
|
expo[i] = 1.0 if on else derisk
|
||||||
|
return pd.Series(expo * v, index=r.index), expo
|
||||||
|
|
||||||
|
|
||||||
|
def const_expo_match_dd(r: pd.Series, target_dd: float) -> float:
|
||||||
|
"""Esposizione costante c (in-sample) che pareggia il MaxDD target — il null de-levering."""
|
||||||
|
lo, hi = 0.05, 1.0
|
||||||
|
if met(r)["dd"] <= target_dd:
|
||||||
|
return 1.0
|
||||||
|
for _ in range(40):
|
||||||
|
mid = 0.5 * (lo + hi)
|
||||||
|
if met(pd.Series(mid * r.values, index=r.index))["dd"] > target_dd:
|
||||||
|
hi = mid
|
||||||
|
else:
|
||||||
|
lo = mid
|
||||||
|
return lo
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- report
|
||||||
|
def section_A(J, names, w_cur):
|
||||||
|
print("\n" + "=" * 108)
|
||||||
|
print(" (A) RI-OTTIMIZZAZIONE PESI STATICI — ottimizza IN-SAMPLE (pre-cut), valuta OOS (post-cut), multi-cut")
|
||||||
|
print("=" * 108)
|
||||||
|
|
||||||
|
# correlazioni e vol per contesto (full, pairwise)
|
||||||
|
C = J.corr()
|
||||||
|
print("\n corr pairwise (full):")
|
||||||
|
for i, a in enumerate(names):
|
||||||
|
print(" " + a.split("_")[0].ljust(6) + " ".join(f"{C.iloc[i, j]:+.2f}" for j in range(len(names))))
|
||||||
|
print(" vol annua per-sleeve (full): " +
|
||||||
|
" ".join(f"{n.split('_')[0]} {J[n].dropna().std()*np.sqrt(DAYS_PER_YEAR)*100:.1f}%" for n in names))
|
||||||
|
|
||||||
|
candidates = {} # nome -> pesi ottimizzati su pre-HOLDOUT (headline)
|
||||||
|
J_is_main = J[J.index < HOLDOUT]
|
||||||
|
for cname, fn in CRITERIA:
|
||||||
|
candidates[cname] = fn(J_is_main, w_cur)
|
||||||
|
candidates["EW"] = np.full(len(names), 1.0 / len(names))
|
||||||
|
caps = _struct_caps(J)
|
||||||
|
w_ewstr = np.full(len(names), 1.0 / len(names)) # EW sotto i cap strutturali (null senza fit)
|
||||||
|
for _ in range(20):
|
||||||
|
over = w_ewstr > caps + 1e-12
|
||||||
|
if not over.any():
|
||||||
|
break
|
||||||
|
excess = float((w_ewstr[over] - caps[over]).sum())
|
||||||
|
w_ewstr[over] = caps[over]
|
||||||
|
room = w_ewstr < caps - 1e-12 # redistribuisci SOLO a chi ha spazio
|
||||||
|
w_ewstr[room] += excess / max(int(room.sum()), 1)
|
||||||
|
candidates["EW-STR"] = w_ewstr
|
||||||
|
candidates["CURRENT"] = w_cur
|
||||||
|
|
||||||
|
print(f"\n PESI (ottimizzati su in-sample pre-{HOLDOUT.date()}):")
|
||||||
|
for cname, w in candidates.items():
|
||||||
|
print(f" {cname:<10s} {fmt_w(names, w)}")
|
||||||
|
|
||||||
|
# headline: FULL + HOLD-OUT
|
||||||
|
print(f"\n {'criterio':<10s} {'FULL Sh':>8s} {'FULL DD':>8s} {'FULL CAGR':>9s} | {'HOLD Sh':>8s} {'HOLD DD':>8s} {'HOLD CAGR':>9s}")
|
||||||
|
rows = {}
|
||||||
|
for cname, w in candidates.items():
|
||||||
|
f = met(combo(J, w)); h = met(combo(J[J.index >= HOLDOUT], w))
|
||||||
|
rows[cname] = (f, h)
|
||||||
|
print(f" {cname:<10s} {f['sh']:>8.2f} {f['dd']*100:>7.1f}% {f['cagr']*100:>+8.1f}% |"
|
||||||
|
f" {h['sh']:>8.2f} {h['dd']*100:>7.1f}% {h['cagr']*100:>+8.1f}%")
|
||||||
|
|
||||||
|
# multi-cut: ottimizza su <cut, valuta su >=cut, uplift vs CURRENT sulla stessa finestra OOS
|
||||||
|
print("\n MULTI-CUT (pesi ri-ottimizzati a ogni cut sul solo pre-cut; ΔSh OOS vs CURRENT):")
|
||||||
|
header = f" {'criterio':<10s}" + "".join(f" | {c.date()} Sh_oos ΔSh " for c in CUTS)
|
||||||
|
print(header)
|
||||||
|
persist = {}
|
||||||
|
fixed = {"EW": candidates["EW"], "EW-STR": candidates["EW-STR"]}
|
||||||
|
for cname, fn in CRITERIA + [("EW", None), ("EW-STR", None)]:
|
||||||
|
cells = []
|
||||||
|
ok = True
|
||||||
|
for cut in CUTS:
|
||||||
|
J_is, J_oos = J[J.index < cut], J[J.index >= cut]
|
||||||
|
w = fixed[cname] if cname in fixed else fn(J_is, w_cur)
|
||||||
|
sh_o = met(combo(J_oos, w))["sh"]
|
||||||
|
sh_c = met(combo(J_oos, w_cur))["sh"]
|
||||||
|
d = sh_o - sh_c
|
||||||
|
ok &= d > 0
|
||||||
|
cells.append(f" | {sh_o:>10.2f} {d:>+5.2f}")
|
||||||
|
persist[cname] = ok
|
||||||
|
print(f" {cname:<10s}" + "".join(cells) + (" <-- PERSISTENTE" if ok else ""))
|
||||||
|
|
||||||
|
print(" ⚠ caveat: le 3 finestre OOS sono ANNIDATE (tutte contengono il 2025-26) — non 3 conferme")
|
||||||
|
print(" indipendenti. Il test disgiunto vero e' il per-anno qui sotto.")
|
||||||
|
|
||||||
|
# per-anno (finestre DISGIUNTE): ritorno annuo del candidato vs CURRENT, pesi fissi pre-2025
|
||||||
|
print("\n PER-ANNO (pesi headline fissi; Δret vs CURRENT, finestre disgiunte):")
|
||||||
|
sel = ["MAXSH", "MINVAR-R", "MAXSH-STR", "EW", "EW-STR"]
|
||||||
|
years = sorted(set(J.index.year))
|
||||||
|
print(" " + "anno".ljust(6) + "".join(f"{c:>12s}" for c in ["CURRENT"] + sel))
|
||||||
|
wins = {c: 0 for c in sel}
|
||||||
|
for y in years:
|
||||||
|
Jy = J[J.index.year == y]
|
||||||
|
base_ret = float(np.prod(1 + combo(Jy, w_cur).values) - 1)
|
||||||
|
row = f" {y} {base_ret*100:>+10.1f}%"
|
||||||
|
for c in sel:
|
||||||
|
ry = float(np.prod(1 + combo(Jy, candidates[c]).values) - 1)
|
||||||
|
wins[c] += ry > base_ret
|
||||||
|
row += f"{(ry-base_ret)*100:>+11.1f}p"
|
||||||
|
print(row)
|
||||||
|
print(" anni vinti vs CURRENT: " + " ".join(f"{c} {wins[c]}/{len(years)}" for c in sel))
|
||||||
|
|
||||||
|
# verdetto: la persistenza statistica NON basta — i pesi devono rispettare i cap STRUTTURALI
|
||||||
|
# (VRP01<=15% perche' MODELLATO, XS01<=25% perche' STAT-MODE/storia corta), altrimenti l'uplift
|
||||||
|
# sta comprando rischio-modello che il backtest non vede (punto cieco tipo CC01).
|
||||||
|
winners = [c for c, ok in persist.items() if ok]
|
||||||
|
respects = {c: bool(np.all(candidates[c] <= caps + 1e-3)) for c in candidates} # tol 0.1pp (jitter clip/renorm)
|
||||||
|
struct_winners = [c for c in winners if respects[c]]
|
||||||
|
print("\n cap strutturali: " + fmt_w(names, caps))
|
||||||
|
if winners:
|
||||||
|
print(" persistenti: " + ", ".join(f"{c} ({'dentro i cap' if respects[c] else 'VIOLA i cap'})"
|
||||||
|
for c in winners))
|
||||||
|
if struct_winners:
|
||||||
|
wbest = candidates[struct_winners[0]]
|
||||||
|
fb, fc = met(combo(J, wbest)), met(combo(J, w_cur))
|
||||||
|
hb, hc = met(combo(J[J.index >= HOLDOUT], wbest)), met(combo(J[J.index >= HOLDOUT], w_cur))
|
||||||
|
verdict = (f"MIGLIORA (OOS) — {struct_winners} persistente E dentro i cap strutturali. "
|
||||||
|
f"Pesi candidati ({struct_winners[0]}): {fmt_w(names, wbest)}.\n"
|
||||||
|
f" TRADE-OFF onesto: HOLD Sh {hc['sh']:.2f}->{hb['sh']:.2f}, HOLD CAGR "
|
||||||
|
f"{hc['cagr']*100:+.1f}%->{hb['cagr']*100:+.1f}%, MA FULL Sh {fc['sh']:.2f}->{fb['sh']:.2f} "
|
||||||
|
f"e FULL DD {fc['dd']*100:.1f}%->{fb['dd']*100:.1f}% — l'uplift e' un TILT di ritorno verso "
|
||||||
|
f"gli sleeve research-grade (piu' SKH01/XS01, meno TP01), non un free lunch risk-adjusted. "
|
||||||
|
f"NB: nessun OTTIMIZZATORE aggiunge nulla oltre questo null parameter-free "
|
||||||
|
f"(MAXSH in-sample vince 1/8 anni: l'ottimizzazione qui overfitta, la de-concentrazione no).")
|
||||||
|
elif winners:
|
||||||
|
verdict = ("NON ADOTTABILE — l'uplift persistente esiste SOLO violando i cap strutturali "
|
||||||
|
"(tutti i vincitori caricano VRP01>15%, sleeve MODELLATO il cui rischio di coda "
|
||||||
|
"il backtest non cattura). Dentro i cap l'ottimizzatore converge ~ai pesi correnti "
|
||||||
|
"e non e' persistente -> per il book reale I PESI CORRENTI REGGONO.")
|
||||||
|
else:
|
||||||
|
verdict = "NON MIGLIORA — nessun criterio batte i pesi CORRENTI in modo persistente. I pesi correnti reggono."
|
||||||
|
print("\n VERDETTO (A): " + verdict)
|
||||||
|
return candidates, persist, struct_winners
|
||||||
|
|
||||||
|
|
||||||
|
def section_B(J, names, w_cur, label="CURRENT"):
|
||||||
|
print("\n" + "=" * 108)
|
||||||
|
print(f" (B) GUARDIA-DRAWDOWN a livello portafoglio (pesi {label}) — trigger X, de-risk d, isteresi 0.4X")
|
||||||
|
print("=" * 108)
|
||||||
|
r_full = combo(J, w_cur)
|
||||||
|
|
||||||
|
# griglia completa su FULL/IS/HOLD per trasparenza (selezione = solo in-sample)
|
||||||
|
r_is = r_full[r_full.index < HOLDOUT]
|
||||||
|
r_ho = r_full[r_full.index >= HOLDOUT]
|
||||||
|
b_is, b_ho, b_f = met(r_is), met(r_ho), met(r_full)
|
||||||
|
print(f"\n baseline IS Sh {b_is['sh']:.2f} DD {b_is['dd']*100:.1f}% CAGR {b_is['cagr']*100:+.1f}%"
|
||||||
|
f" | HOLD Sh {b_ho['sh']:.2f} DD {b_ho['dd']*100:.1f}% CAGR {b_ho['cagr']*100:+.1f}%"
|
||||||
|
f" | FULL Sh {b_f['sh']:.2f} DD {b_f['dd']*100:.1f}%")
|
||||||
|
print(f"\n {'cella':<16s} {'IS Sh':>6s} {'IS DD':>6s} {'IS CAGR':>8s} {'IS Calmar':>9s} {'%off':>5s} |"
|
||||||
|
f" {'HOLD Sh':>7s} {'HOLD DD':>7s} {'HOLD CAGR':>9s}")
|
||||||
|
grid = {}
|
||||||
|
for trig in GUARD_TRIGGERS:
|
||||||
|
for dr in GUARD_DERISK:
|
||||||
|
g_full, expo = dd_guard(r_full, trig, dr)
|
||||||
|
g_is = g_full[g_full.index < HOLDOUT]; g_ho = g_full[g_full.index >= HOLDOUT]
|
||||||
|
mi, mh = met(g_is), met(g_ho)
|
||||||
|
off = float((expo[: len(r_is)] < 1.0).mean())
|
||||||
|
grid[(trig, dr)] = dict(is_=mi, ho=mh, full=met(g_full))
|
||||||
|
print(f" X={trig*100:.0f}% d={dr:<4.1f} {mi['sh']:>6.2f} {mi['dd']*100:>5.1f}% {mi['cagr']*100:>+7.1f}%"
|
||||||
|
f" {mi['calmar']:>9.2f} {off*100:>4.0f}% | {mh['sh']:>7.2f} {mh['dd']*100:>6.1f}% {mh['cagr']*100:>+8.1f}%")
|
||||||
|
|
||||||
|
# selezione IN-SAMPLE (Calmar), poi null de-levering a pari MaxDD in-sample
|
||||||
|
best_cell = max(grid, key=lambda k: grid[k]["is_"]["calmar"])
|
||||||
|
trig, dr = best_cell
|
||||||
|
print(f"\n cella selezionata IN-SAMPLE (max Calmar): X={trig*100:.0f}% derisk={dr}")
|
||||||
|
tgt_dd = grid[best_cell]["is_"]["dd"]
|
||||||
|
c = const_expo_match_dd(r_is, tgt_dd)
|
||||||
|
cnull_full = pd.Series(c * r_full.values, index=r_full.index)
|
||||||
|
n_is, n_ho = met(cnull_full[cnull_full.index < HOLDOUT]), met(cnull_full[cnull_full.index >= HOLDOUT])
|
||||||
|
g = grid[best_cell]
|
||||||
|
print(f" NULL de-levering: expo costante c={c:.2f} pareggia il MaxDD IS della guardia ({tgt_dd*100:.1f}%)")
|
||||||
|
print(f" {'':<14s} {'IS Sh':>6s} {'IS CAGR':>8s} | {'HOLD Sh':>7s} {'HOLD DD':>7s} {'HOLD CAGR':>9s}")
|
||||||
|
print(f" guardia {g['is_']['sh']:>6.2f} {g['is_']['cagr']*100:>+7.1f}% | {g['ho']['sh']:>7.2f}"
|
||||||
|
f" {g['ho']['dd']*100:>6.1f}% {g['ho']['cagr']*100:>+8.1f}%")
|
||||||
|
print(f" const c={c:.2f} {n_is['sh']:>6.2f} {n_is['cagr']*100:>+7.1f}% | {n_ho['sh']:>7.2f}"
|
||||||
|
f" {n_ho['dd']*100:>6.1f}% {n_ho['cagr']*100:>+8.1f}%")
|
||||||
|
|
||||||
|
# multi-cut: selezione sul pre-cut, valutazione OOS post-cut (guardia vs baseline vs null)
|
||||||
|
inert = True
|
||||||
|
print("\n MULTI-CUT (cella riscelta a ogni cut sul solo pre-cut; OOS post-cut):")
|
||||||
|
print(f" {'cut':<12s} {'cella':<14s} {'OOS Sh(g)':>9s} {'OOS Sh(b)':>9s} {'ΔSh':>6s} {'OOS DD(g)':>9s}"
|
||||||
|
f" {'OOS DD(b)':>9s} {'ΔCAGR':>7s} {'null c: ΔSh':>11s}")
|
||||||
|
persist_sh, persist_dd = True, True
|
||||||
|
for cut in CUTS:
|
||||||
|
r_pre = r_full[r_full.index < cut]
|
||||||
|
cells = {}
|
||||||
|
for t in GUARD_TRIGGERS:
|
||||||
|
for d in GUARD_DERISK:
|
||||||
|
gpre, _ = dd_guard(r_pre, t, d)
|
||||||
|
cells[(t, d)] = met(gpre)["calmar"]
|
||||||
|
(t_, d_) = max(cells, key=cells.get)
|
||||||
|
g_full, _ = dd_guard(r_full, t_, d_) # guard sul path completo (stato causale)
|
||||||
|
g_oos = g_full[g_full.index >= cut]
|
||||||
|
b_oos = r_full[r_full.index >= cut]
|
||||||
|
gm, bm = met(g_oos), met(b_oos)
|
||||||
|
cc = const_expo_match_dd(r_pre, met(dd_guard(r_pre, t_, d_)[0])["dd"])
|
||||||
|
nm = met(pd.Series(cc * b_oos.values, index=b_oos.index))
|
||||||
|
dsh = gm["sh"] - bm["sh"]; ddd = gm["dd"] - bm["dd"]; dcagr = gm["cagr"] - bm["cagr"]
|
||||||
|
persist_sh &= dsh >= 0; persist_dd &= ddd < 0
|
||||||
|
inert &= abs(dsh) < 0.005 and abs(ddd) < 1e-4
|
||||||
|
print(f" {str(cut.date()):<12s} X={t_*100:.0f}% d={d_:<6.1f} {gm['sh']:>9.2f} {bm['sh']:>9.2f} {dsh:>+6.2f}"
|
||||||
|
f" {gm['dd']*100:>8.1f}% {bm['dd']*100:>8.1f}% {dcagr*100:>+6.1f}pp {gm['sh']-nm['sh']:>+11.2f}")
|
||||||
|
|
||||||
|
if inert:
|
||||||
|
verdict = ("NON MIGLIORA — INERTE OOS: la cella scelta in-sample non scatta MAI post-cut "
|
||||||
|
"(il DD OOS del 4-sleeve resta sotto il trigger: la diversificazione fa gia' il lavoro "
|
||||||
|
"della guardia). Trigger piu' stretti (X=3-4%) scattano ma COSTANO Sharpe su HOLD. "
|
||||||
|
"Coerente col thread meta-allocazione 2026-06-29 (drawdown-control ridondante).")
|
||||||
|
elif persist_sh and persist_dd:
|
||||||
|
verdict = f"MIGLIORA — la guardia X={trig*100:.0f}%/d={dr} taglia il DD OOS senza costo Sharpe persistente"
|
||||||
|
elif persist_dd:
|
||||||
|
verdict = ("TAGLIA IL DD MA COSTA — DD ridotto a ogni cut ma Sharpe/CAGR inferiori: "
|
||||||
|
"trade-off assicurativo, confrontare col null de-levering prima di adottarla")
|
||||||
|
else:
|
||||||
|
verdict = "NON MIGLIORA — la guardia non riduce il DD OOS in modo persistente (o e' solo de-levering)"
|
||||||
|
print("\n VERDETTO (B): " + verdict)
|
||||||
|
return grid, best_cell
|
||||||
|
|
||||||
|
|
||||||
|
def section_C(J, names, w_cur, candidates, struct_winners, grid, best_cell):
|
||||||
|
print("\n" + "=" * 108)
|
||||||
|
print(" (C) COMBINAZIONE A+B")
|
||||||
|
print("=" * 108)
|
||||||
|
wA = candidates[struct_winners[0]] if struct_winners else w_cur
|
||||||
|
labA = struct_winners[0] if struct_winners else "CURRENT (A non adottabile)"
|
||||||
|
trig, dr = best_cell
|
||||||
|
r = combo(J, wA)
|
||||||
|
g, _ = dd_guard(r, trig, dr)
|
||||||
|
for lbl, s in [("baseline CURRENT", combo(J, w_cur)),
|
||||||
|
(f"pesi {labA}", r),
|
||||||
|
(f"pesi {labA} + guard X={trig*100:.0f}%/d={dr}", g)]:
|
||||||
|
f = met(s); h = met(s[s.index >= HOLDOUT])
|
||||||
|
print(f" {lbl:<52s} FULL Sh {f['sh']:>5.2f} DD {f['dd']*100:>4.1f}% CAGR {f['cagr']*100:>+5.1f}%"
|
||||||
|
f" | HOLD Sh {h['sh']:>5.2f} DD {h['dd']*100:>4.1f}% CAGR {h['cagr']*100:>+5.1f}%")
|
||||||
|
if not struct_winners:
|
||||||
|
print(" -> (A) non adottabile e (B) inerte OOS: la combinazione non ha una cella da proporre.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 108)
|
||||||
|
print(" r0701_portfolio_opt — pesi statici + guardia-DD sul portafoglio attivo a 4 sleeve")
|
||||||
|
print(f" hold-out bloccato {HOLDOUT.date()}+ | vincoli pesi [{LO_W},{HI_W}] | cuts {[str(c.date()) for c in CUTS]}")
|
||||||
|
print("=" * 108)
|
||||||
|
J, names, w_cur = load_matrix()
|
||||||
|
print(f"\n sleeve: {names}")
|
||||||
|
print(f" pesi CORRENTI: {fmt_w(names, w_cur)}")
|
||||||
|
print(f" finestra: {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)")
|
||||||
|
for n in names:
|
||||||
|
s = J[n].dropna()
|
||||||
|
print(f" {n:<16s} dal {s.index[0].date()} ({len(s)} giorni)")
|
||||||
|
|
||||||
|
b_f = met(combo(J, w_cur)); b_h = met(combo(J[J.index >= HOLDOUT], w_cur))
|
||||||
|
print(f"\n BASELINE (pesi correnti): FULL Sh {b_f['sh']:.2f} DD {b_f['dd']*100:.1f}% CAGR {b_f['cagr']*100:+.1f}%"
|
||||||
|
f" | HOLD Sh {b_h['sh']:.2f} DD {b_h['dd']*100:.1f}% CAGR {b_h['cagr']*100:+.1f}%")
|
||||||
|
|
||||||
|
candidates, persistA, struct_winners = section_A(J, names, w_cur)
|
||||||
|
grid, best_cell = section_B(J, names, w_cur)
|
||||||
|
section_C(J, names, w_cur, candidates, struct_winners, grid, best_cell)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"""r0701_portfolio_skeptic — VERIFICA AVVERSARIALE del tilt EW-STR (TP30/XS25/VRP15/SKH30).
|
||||||
|
|
||||||
|
Oggetto: la proposta di r0701_portfolio_opt (diario 2026-07-01-portfolio-weights-ddguard.md) di
|
||||||
|
spostare i pesi del portafoglio a 4 sleeve da CURRENT (41.25/18.75/15/25) a EW-STR (30/25/15/30).
|
||||||
|
Claim: HOLD Sh 2.21->2.35, HOLD CAGR +16.0->+19.7%, multi-cut +0.06/+0.13/+0.14, 7/8 anni vinti.
|
||||||
|
|
||||||
|
Linee di attacco (agente scettico):
|
||||||
|
1. RIPRODUZIONE indipendente via src.portfolio.StrategyPortfolio (path di PRODUZIONE, non lo
|
||||||
|
script dell'agente).
|
||||||
|
2. SELEZIONE-SULL'HOLD-OUT DI 2° ORDINE via SKH01/XS01: SKH01 fu ammesso (2026-06-23) perche'
|
||||||
|
alzava l'hold-out 2025-26; XS01 fu affinato (blend+gate) conoscendo l'hold-out. Un tilt verso
|
||||||
|
di loro eredita quella selezione? Test: (a) per-anno SOLO 2019-2024; (b) tilt scomposti
|
||||||
|
(solo-XS con SKH pinned 25, solo-SKH con XS pinned); (c) finestre OOS DISGIUNTE (i 3 cut del
|
||||||
|
diario sono ANNIDATI); (d) contributo per-sleeve all'uplift hold-out.
|
||||||
|
3. "7/8 ANNI VINTI": quale metrica (ritorno, non Sharpe), margini per anno, Sharpe per-anno.
|
||||||
|
4. PLATEAU: griglia 2.5pp fra CURRENT e EW-STR (TP x SKH, VRP=15, XS=residuo cap 25).
|
||||||
|
5. REALISMO: pesi RINORMALIZZATI per era (outer-join: nel 2019-20 attivi solo TP+SKH!), haircut
|
||||||
|
d'esecuzione su SKH01/XS01 come DRAG (r' = r - h*mean(r), modello-costi, non de-levering).
|
||||||
|
6. FORKING PATHS: quante config viste sull'hold-out; null di tilt CASUALI cap-respecting — se
|
||||||
|
quasi ogni tilt anti-TP01 batte CURRENT sull'hold-out, il claim e' generico, non informativo.
|
||||||
|
|
||||||
|
Solo lettura/analisi: NON tocca src/ ne' scripts/live/.
|
||||||
|
uv run python scripts/research/r0701_portfolio_skeptic.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.portfolio.sleeves import active_sleeves
|
||||||
|
from src.portfolio.portfolio import StrategyPortfolio, HOLDOUT, DAYS_PER_YEAR, metrics
|
||||||
|
|
||||||
|
CUTS = [pd.Timestamp(c, tz="UTC") for c in ("2024-01-01", "2024-07-01", "2025-01-01")]
|
||||||
|
LO_W = 0.05
|
||||||
|
|
||||||
|
# pesi per PREFISSO sleeve (ordine risolto sui nomi del registry)
|
||||||
|
W_CURRENT = {"TP01": 0.4125, "XS01": 0.1875, "VRP01": 0.15, "SKH01": 0.25}
|
||||||
|
W_EWSTR = {"TP01": 0.30, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.30}
|
||||||
|
W_ONLY_XS = {"TP01": 0.35, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.25} # SKH pinned al corrente
|
||||||
|
W_ONLY_SKH = {"TP01": 0.3625, "XS01": 0.1875, "VRP01": 0.15, "SKH01": 0.30} # XS pinned al corrente
|
||||||
|
CAPS = {"TP01": 0.60, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.60}
|
||||||
|
|
||||||
|
|
||||||
|
def prefix(name: str) -> str:
|
||||||
|
return name.split("_")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def wvec(names: list[str], wd: dict) -> np.ndarray:
|
||||||
|
v = np.array([wd[prefix(n)] for n in names], float)
|
||||||
|
return v / v.sum()
|
||||||
|
|
||||||
|
|
||||||
|
def combo(J: pd.DataFrame, w: np.ndarray) -> pd.Series:
|
||||||
|
"""Combine outer-join con pesi rinormalizzati per-riga (stessa semantica di combined_daily;
|
||||||
|
verificata sotto contro il path di produzione)."""
|
||||||
|
act = J.notna().values * w
|
||||||
|
rs = act.sum(axis=1, keepdims=True)
|
||||||
|
wn = np.divide(act, rs, out=np.zeros_like(act), where=rs > 0)
|
||||||
|
return pd.Series(np.nansum(np.nan_to_num(J.values) * wn, axis=1), index=J.index)
|
||||||
|
|
||||||
|
|
||||||
|
def met(s) -> dict:
|
||||||
|
return metrics(pd.Series(s).dropna()) # metriche di PRODUZIONE (src.portfolio)
|
||||||
|
|
||||||
|
|
||||||
|
def sh(s) -> float:
|
||||||
|
return met(s)["sharpe"]
|
||||||
|
|
||||||
|
|
||||||
|
def yr_ret(s) -> float:
|
||||||
|
v = pd.Series(s).dropna().values
|
||||||
|
return float(np.prod(1 + v) - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 110)
|
||||||
|
print(" r0701_portfolio_skeptic — verifica avversariale EW-STR (30/25/15/30) vs CURRENT (41.25/18.75/15/25)")
|
||||||
|
print("=" * 110)
|
||||||
|
|
||||||
|
sl = active_sleeves()
|
||||||
|
names = [s.name for s in sl]
|
||||||
|
J = pd.concat({s.name: s.daily() for s in sl}, axis=1, join="outer").sort_index()
|
||||||
|
J = J[J.notna().any(axis=1)]
|
||||||
|
w_cur = wvec(names, W_CURRENT)
|
||||||
|
w_ew = wvec(names, W_EWSTR)
|
||||||
|
print(f"\n finestra {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)")
|
||||||
|
for n in names:
|
||||||
|
s = J[n].dropna()
|
||||||
|
print(f" {n:<16s} dal {s.index[0].date()} ({len(s)} giorni)")
|
||||||
|
|
||||||
|
# ------------------------------------------------ 1) RIPRODUZIONE (path di produzione)
|
||||||
|
print("\n" + "-" * 110)
|
||||||
|
print(" [1] RIPRODUZIONE INDIPENDENTE — StrategyPortfolio.combined_daily (produzione), non lo script agente")
|
||||||
|
print("-" * 110)
|
||||||
|
for lbl, wd in [("CURRENT", W_CURRENT), ("EW-STR", W_EWSTR)]:
|
||||||
|
for s, sleeve_w in zip(sl, [wd[prefix(n)] for n in names]):
|
||||||
|
s.weight = sleeve_w
|
||||||
|
port = StrategyPortfolio(sl)
|
||||||
|
full = port.combined_daily()
|
||||||
|
hold = port.combined_daily(lo=HOLDOUT)
|
||||||
|
# check che il mio combine coincida col path di produzione
|
||||||
|
mine = combo(J, wvec(names, wd))
|
||||||
|
dmax = float(np.max(np.abs(mine.values - full.values)))
|
||||||
|
f, h = met(full), met(hold)
|
||||||
|
print(f" {lbl:<8s} FULL Sh {f['sharpe']:.2f} DD {f['maxdd']*100:.1f}% CAGR {f['cagr']*100:+.1f}%"
|
||||||
|
f" | HOLD Sh {h['sharpe']:.2f} DD {h['maxdd']*100:.1f}% CAGR {h['cagr']*100:+.1f}%"
|
||||||
|
f" (|combine mio - produzione|max = {dmax:.2e})")
|
||||||
|
print(" multi-cut ΔSh OOS EW-STR vs CURRENT (finestre ANNIDATE, come nel diario):")
|
||||||
|
for cut in CUTS:
|
||||||
|
Jo = J[J.index >= cut]
|
||||||
|
d = sh(combo(Jo, w_ew)) - sh(combo(Jo, w_cur))
|
||||||
|
print(f" cut {cut.date()} ΔSh {d:+.2f}")
|
||||||
|
|
||||||
|
# ------------------------------------------------ 2) SELEZIONE-SULL'HOLD-OUT DI 2° ORDINE
|
||||||
|
print("\n" + "-" * 110)
|
||||||
|
print(" [2] SELEZIONE HOLD-OUT DI 2° ORDINE — SKH01 ammesso PERCHE' alzava l'hold-out; XS01 affinato idem")
|
||||||
|
print("-" * 110)
|
||||||
|
# (d prima: contesto) Sharpe standalone per-sleeve, pre-2025 vs hold-out
|
||||||
|
print(" Sharpe standalone per sleeve (pre-2025 | hold-out 2025+):")
|
||||||
|
for n in names:
|
||||||
|
s = J[n].dropna()
|
||||||
|
print(f" {n:<16s} IS {sh(s[s.index < HOLDOUT]):>5.2f} | HOLD {sh(s[s.index >= HOLDOUT]):>5.2f}")
|
||||||
|
|
||||||
|
# (a) per-anno SOLO 2019-2024 (pre-selezione di SKH01/holdout)
|
||||||
|
print("\n (a) per-anno SOLO 2019-2024 (finestra NON toccata dalla selezione hold-out):")
|
||||||
|
print(f" {'anno':<6s} {'ret CUR':>9s} {'ret EW-STR':>10s} {'Δret':>8s} | {'Sh CUR':>7s} {'Sh EW-STR':>9s} {'ΔSh':>6s}")
|
||||||
|
wins_ret_pre = wins_sh_pre = 0
|
||||||
|
years_pre = [y for y in sorted(set(J.index.year)) if y < 2025]
|
||||||
|
for y in years_pre:
|
||||||
|
Jy = J[J.index.year == y]
|
||||||
|
rc, re = combo(Jy, w_cur), combo(Jy, w_ew)
|
||||||
|
dr = yr_ret(re) - yr_ret(rc); dsh = sh(re) - sh(rc)
|
||||||
|
wins_ret_pre += dr > 0; wins_sh_pre += dsh > 0
|
||||||
|
print(f" {y:<6d} {yr_ret(rc)*100:>+8.1f}% {yr_ret(re)*100:>+9.1f}% {dr*100:>+7.1f}p |"
|
||||||
|
f" {sh(rc):>7.2f} {sh(re):>9.2f} {dsh:>+6.2f}")
|
||||||
|
print(f" -> vittorie EW-STR 2019-2024: ritorno {wins_ret_pre}/{len(years_pre)}, Sharpe {wins_sh_pre}/{len(years_pre)}")
|
||||||
|
Jpre = J[J.index < HOLDOUT]
|
||||||
|
print(f" pre-2025 aggregato: Sh CUR {sh(combo(Jpre, w_cur)):.2f} vs EW-STR {sh(combo(Jpre, w_ew)):.2f}"
|
||||||
|
f" (ΔSh {sh(combo(Jpre, w_ew)) - sh(combo(Jpre, w_cur)):+.2f})")
|
||||||
|
|
||||||
|
# (b) tilt scomposti
|
||||||
|
print("\n (b) tilt SCOMPOSTI (chi porta l'uplift?): HOLD Sh + multi-cut ΔSh vs CURRENT")
|
||||||
|
for lbl, wd in [("solo-XS (SKH pinned 25): TP35 /XS25/VRP15/SKH25", W_ONLY_XS),
|
||||||
|
("solo-SKH (XS pinned 18.75): TP36.25/XS18.75/VRP15/SKH30", W_ONLY_SKH),
|
||||||
|
("EW-STR: TP30 /XS25/VRP15/SKH30", W_EWSTR)]:
|
||||||
|
w = wvec(names, wd)
|
||||||
|
hold_sh = sh(combo(J[J.index >= HOLDOUT], w))
|
||||||
|
cuts_d = [sh(combo(J[J.index >= c], w)) - sh(combo(J[J.index >= c], w_cur)) for c in CUTS]
|
||||||
|
print(f" {lbl:<58s} HOLD Sh {hold_sh:.2f} multi-cut " +
|
||||||
|
" ".join(f"{d:+.2f}" for d in cuts_d))
|
||||||
|
|
||||||
|
# (c) finestre OOS DISGIUNTE (i cut del diario sono annidati)
|
||||||
|
print("\n (c) finestre OOS DISGIUNTE (ΔSh e Δret EW-STR vs CURRENT):")
|
||||||
|
windows = [("2024-01 -> 2024-07", "2024-01-01", "2024-07-01"),
|
||||||
|
("2024-07 -> 2025-01", "2024-07-01", "2025-01-01"),
|
||||||
|
("2025-01 -> fine ", "2025-01-01", None)]
|
||||||
|
for lbl, lo, hi in windows:
|
||||||
|
m = (J.index >= pd.Timestamp(lo, tz="UTC"))
|
||||||
|
if hi:
|
||||||
|
m &= (J.index < pd.Timestamp(hi, tz="UTC"))
|
||||||
|
Jw = J[m]
|
||||||
|
rc, re = combo(Jw, w_cur), combo(Jw, w_ew)
|
||||||
|
print(f" {lbl} ΔSh {sh(re) - sh(rc):+.2f} Δret {(yr_ret(re) - yr_ret(rc))*100:+.1f}pp")
|
||||||
|
|
||||||
|
# (d) contributo per-sleeve all'uplift hold-out (pesi statici: sul hold-out tutti attivi)
|
||||||
|
print("\n (d) contributo per-sleeve al Δritorno annualizzato sull'hold-out (Δw_i x mean_i x 365):")
|
||||||
|
Jh = J[J.index >= HOLDOUT]
|
||||||
|
for n in names:
|
||||||
|
dw = W_EWSTR[prefix(n)] - W_CURRENT[prefix(n)]
|
||||||
|
mu = float(Jh[n].dropna().mean()) * DAYS_PER_YEAR
|
||||||
|
print(f" {n:<16s} Δw {dw*100:>+6.2f}pp x ret_ann {mu*100:>+6.1f}% = {dw*mu*100:>+5.2f}pp/anno")
|
||||||
|
|
||||||
|
# ------------------------------------------------ 3) "7/8 ANNI VINTI"
|
||||||
|
print("\n" + "-" * 110)
|
||||||
|
print(" [3] '7/8 ANNI VINTI' — metrica = RITORNO composto per anno (non Sharpe). Tabella completa + margini")
|
||||||
|
print("-" * 110)
|
||||||
|
print(f" {'anno':<6s} {'ret CUR':>9s} {'ret EW-STR':>10s} {'Δret':>8s} | {'Sh CUR':>7s} {'Sh EW-STR':>9s} {'ΔSh':>6s} |"
|
||||||
|
f" {'DD CUR':>7s} {'DD EW':>7s}")
|
||||||
|
wins_ret = wins_sh = 0
|
||||||
|
years = sorted(set(J.index.year))
|
||||||
|
for y in years:
|
||||||
|
Jy = J[J.index.year == y]
|
||||||
|
rc, re = combo(Jy, w_cur), combo(Jy, w_ew)
|
||||||
|
mc, me = met(rc), met(re)
|
||||||
|
dr = yr_ret(re) - yr_ret(rc); dsh = sh(re) - sh(rc)
|
||||||
|
wins_ret += dr > 0; wins_sh += dsh > 0
|
||||||
|
print(f" {y:<6d} {yr_ret(rc)*100:>+8.1f}% {yr_ret(re)*100:>+9.1f}% {dr*100:>+7.1f}p |"
|
||||||
|
f" {sh(rc):>7.2f} {sh(re):>9.2f} {dsh:>+6.2f} | {mc['maxdd']*100:>6.1f}% {me['maxdd']*100:>6.1f}%")
|
||||||
|
print(f" -> vittorie EW-STR: RITORNO {wins_ret}/{len(years)} | SHARPE {wins_sh}/{len(years)}"
|
||||||
|
f" (2026 = anno parziale)")
|
||||||
|
|
||||||
|
# ------------------------------------------------ 4) PLATEAU
|
||||||
|
print("\n" + "-" * 110)
|
||||||
|
print(" [4] PLATEAU — griglia 2.5pp: TP x SKH, VRP=15 fisso, XS = residuo (cap 25). HOLD Sh | min multi-cut ΔSh")
|
||||||
|
print("-" * 110)
|
||||||
|
tps = [0.30, 0.325, 0.35, 0.375, 0.40, 0.4125]
|
||||||
|
skhs = [0.25, 0.275, 0.30]
|
||||||
|
Jh = J[J.index >= HOLDOUT]
|
||||||
|
hold_cur = sh(combo(Jh, w_cur))
|
||||||
|
print(f" (HOLD Sh CURRENT = {hold_cur:.2f}; celle con XS>25% = viola cap, marcate *)")
|
||||||
|
hdr = " TP\\SKH " + "".join(f"{s*100:>18.1f}%" for s in skhs)
|
||||||
|
print(hdr)
|
||||||
|
for tp in tps:
|
||||||
|
row = f" {tp*100:>6.2f}%"
|
||||||
|
for skh in skhs:
|
||||||
|
xs = 1.0 - 0.15 - tp - skh
|
||||||
|
tag = "*" if xs > CAPS["XS01"] + 1e-9 else " "
|
||||||
|
if xs < LO_W - 1e-9:
|
||||||
|
row += f"{'—':>19s}"
|
||||||
|
continue
|
||||||
|
w = np.array([{"TP01": tp, "XS01": xs, "VRP01": 0.15, "SKH01": skh}[prefix(n)] for n in names])
|
||||||
|
hs = sh(combo(Jh, w))
|
||||||
|
dmin = min(sh(combo(J[J.index >= c], w)) - sh(combo(J[J.index >= c], w_cur)) for c in CUTS)
|
||||||
|
row += f" {hs:>5.2f}|{dmin:+.2f}{tag}"
|
||||||
|
print(row)
|
||||||
|
|
||||||
|
# ------------------------------------------------ 5) REALISMO
|
||||||
|
print("\n" + "-" * 110)
|
||||||
|
print(" [5] REALISMO — pesi EFFETTIVI per era (outer-join rinormalizza!) + haircut d'esecuzione")
|
||||||
|
print("-" * 110)
|
||||||
|
# pesi rinormalizzati per era
|
||||||
|
eras = [("2019-2020 (TP+SKH)", "2019-06-01"), ("2021-2023 (TP+VRP+SKH)", "2022-06-01"),
|
||||||
|
("2024+ (tutti)", "2025-06-01")]
|
||||||
|
print(" pesi EFFETTIVI (rinormalizzati sugli sleeve attivi) per era:")
|
||||||
|
for lbl, probe in eras:
|
||||||
|
t = pd.Timestamp(probe, tz="UTC")
|
||||||
|
i = J.index.get_indexer([t], method="nearest")[0]
|
||||||
|
act = J.iloc[i].notna().values
|
||||||
|
for wl, w in [("CURRENT", w_cur), ("EW-STR ", w_ew)]:
|
||||||
|
wn = w * act; wn = wn / wn.sum()
|
||||||
|
s = " ".join(f"{prefix(n)} {x*100:.0f}%" for n, x in zip(names, wn) if act[list(names).index(n)])
|
||||||
|
print(f" {lbl:<24s} {wl}: {s}")
|
||||||
|
print(" -> nel 2019-23 EW-STR tiene SKH01 al 40-50% effettivo (research-grade, book 230m).")
|
||||||
|
|
||||||
|
# haircut d'esecuzione come DRAG (r' = r - h*mean_full(r)): modello-costi, non de-levering
|
||||||
|
print("\n haircut esecuzione (drag costante = h x mean ritorno full dello sleeve):")
|
||||||
|
print(f" {'scenario':<34s} {'CUR HOLD Sh':>11s} {'EW HOLD Sh':>11s} {'ΔSh':>6s} | {'CUR HOLD CAGR':>13s} {'EW HOLD CAGR':>13s}")
|
||||||
|
scen = [("nessun haircut", {}, 0.0),
|
||||||
|
("SKH01 -20%", {"SKH01"}, 0.20), ("SKH01 -30%", {"SKH01"}, 0.30),
|
||||||
|
("SKH01+XS01 -20%", {"SKH01", "XS01"}, 0.20), ("SKH01+XS01 -30%", {"SKH01", "XS01"}, 0.30)]
|
||||||
|
for lbl, targets, h in scen:
|
||||||
|
Jx = J.copy()
|
||||||
|
for n in names:
|
||||||
|
if prefix(n) in targets:
|
||||||
|
mu = float(J[n].dropna().mean())
|
||||||
|
Jx[n] = J[n] - h * mu # drag solo dove lo sleeve e' attivo (NaN restano NaN)
|
||||||
|
Jxh = Jx[Jx.index >= HOLDOUT]
|
||||||
|
rc, re = combo(Jxh, w_cur), combo(Jxh, w_ew)
|
||||||
|
mc, me = met(rc), met(re)
|
||||||
|
print(f" {lbl:<34s} {mc['sharpe']:>11.2f} {me['sharpe']:>11.2f} {me['sharpe']-mc['sharpe']:>+6.2f} |"
|
||||||
|
f" {mc['cagr']*100:>+12.1f}% {me['cagr']*100:>+12.1f}%")
|
||||||
|
|
||||||
|
# ------------------------------------------------ 6) FORKING PATHS
|
||||||
|
print("\n" + "-" * 110)
|
||||||
|
print(" [6] FORKING PATHS — config viste sull'hold-out + null di tilt casuali cap-respecting")
|
||||||
|
print("-" * 110)
|
||||||
|
n_configs = 7 + 8 # 7 vettori pesi (MAXSH,RP,ERC,MINVAR-R,MAXSH-STR,EW,EW-STR) + 8 celle guardia
|
||||||
|
print(f" config valutate sull'hold-out nello script agente: >= {n_configs} (7 pesi + 8 celle guardia),")
|
||||||
|
print(" EW-STR costruito DOPO aver visto EW vincere (ammesso nel diario).")
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
caps = np.array([CAPS[prefix(n)] for n in names])
|
||||||
|
N = 500
|
||||||
|
dsh_hold, dsh_full, all_cuts_pos = [], [], 0
|
||||||
|
Jh = J[J.index >= HOLDOUT]
|
||||||
|
hold_c = sh(combo(Jh, w_cur)); full_c = sh(combo(J, w_cur))
|
||||||
|
cuts_J = {c: J[J.index >= c] for c in CUTS}
|
||||||
|
cuts_base = {c: sh(combo(cuts_J[c], w_cur)) for c in CUTS}
|
||||||
|
for _ in range(N):
|
||||||
|
w = rng.dirichlet(np.ones(len(names)))
|
||||||
|
for _ in range(60): # proiezione su [LO_W, caps], somma 1
|
||||||
|
w = np.clip(w, LO_W, caps)
|
||||||
|
d = 1.0 - w.sum()
|
||||||
|
if abs(d) < 1e-9:
|
||||||
|
break
|
||||||
|
if d > 0:
|
||||||
|
room = caps - w
|
||||||
|
w += d * room / max(room.sum(), 1e-12)
|
||||||
|
else:
|
||||||
|
room = w - LO_W
|
||||||
|
w += d * room / max(room.sum(), 1e-12)
|
||||||
|
dh = sh(combo(Jh, w)) - hold_c
|
||||||
|
dsh_hold.append(dh)
|
||||||
|
dsh_full.append(sh(combo(J, w)) - full_c)
|
||||||
|
all_cuts_pos += all(sh(combo(cuts_J[c], w)) - cuts_base[c] > 0 for c in CUTS)
|
||||||
|
dsh_hold = np.array(dsh_hold); dsh_full = np.array(dsh_full)
|
||||||
|
dew = sh(combo(Jh, w_ew)) - hold_c
|
||||||
|
print(f" {N} tilt casuali dentro i cap (VRP<=15, XS<=25, w>=5): quota che batte CURRENT")
|
||||||
|
print(f" su HOLD-OUT: {float((dsh_hold > 0).mean())*100:.0f}% (mediana ΔSh {np.median(dsh_hold):+.2f})")
|
||||||
|
print(f" su FULL: {float((dsh_full > 0).mean())*100:.0f}% (mediana ΔSh {np.median(dsh_full):+.2f})")
|
||||||
|
print(f" su TUTTI e 3 cut: {all_cuts_pos/N*100:.0f}%")
|
||||||
|
print(f" percentile di EW-STR (ΔSh HOLD {dew:+.2f}) fra i tilt casuali: {float((dsh_hold < dew).mean())*100:.0f}°")
|
||||||
|
print(" lettura: se quasi ogni tilt cap-respecting batte CURRENT sull'hold-out, il 'vince OOS'")
|
||||||
|
print(" e' una proprieta' del PERIODO (hold-out pro-SKH/XS per costruzione), non del vettore EW-STR.")
|
||||||
|
|
||||||
|
print("\n" + "=" * 110)
|
||||||
|
print(" fine verifica — vedi addendum nel diario 2026-07-01-portfolio-weights-ddguard.md")
|
||||||
|
print("=" * 110)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""r0701_vrp_refine — AFFINAMENTO VRP01 (gate/sizing) dentro i limiti del modello (2026-07-01).
|
||||||
|
|
||||||
|
Baseline = VRP01 combo (sleeves._vrp_combo_returns): put credit spread settimanale -0.28/-0.10,
|
||||||
|
f=1.0, tenor 7d, gate VRP>0 (DVOL>RV30 causale) AND IV-rank>0.30 AND crash-skip IV-rank>0.90,
|
||||||
|
fee 12.5% del premio. FULL Sh ~1.10 / HOLD ~0.60 / DD ~12%.
|
||||||
|
|
||||||
|
Celle NUOVE (mai provate — verificato nei diari; l'active management intra-trade e' gia'
|
||||||
|
SCARTATO in 2026-06-20-vrp-active-management.md e NON si ripete):
|
||||||
|
1. SIZING sul gap IV-RV (il carry atteso): size lineare clip(vrp/scale,0,1) o percentile
|
||||||
|
espandente causale del VRP, invece del (o in aggiunta al) gate binario IV-rank.
|
||||||
|
NB: il gate composito "IV-rank>0.30 AND IV-RV>0" e' GIA' il baseline (gate_vrp=True).
|
||||||
|
2. Filtro DVOL-MOMENTUM: non vendere vol mentre DVOL sta salendo (dv[i]-dv[i-k] > thr).
|
||||||
|
(Diverso da dvol_directional 2026-06-29: la' il DVOL-mom era segnale DIREZIONALE sul perp.)
|
||||||
|
3. Gate di REGIME da TP01: de-risk (skip o half-size) quando TP01 e' flat su BTC e ETH
|
||||||
|
(risk-off). Rischio ridondanza col trend -> riporto la frequenza d'intervento REALE.
|
||||||
|
4. Croce completa delle manopole (griglia contenuta, 105 celle, TUTTE contate nel DSR).
|
||||||
|
|
||||||
|
Metodo: stessa pipeline di options_vrp_v2 (pricing BS su DVOL reale, payoff sul path
|
||||||
|
certificato, stesse fee) — cambiano SOLO gate/sizing. Selezione cella IN-SAMPLE (pre-2025),
|
||||||
|
hold-out 2025-26, multi-cut (5 tagli), deflated-Sharpe su tutti i trial, effetto a livello
|
||||||
|
portafoglio 4-sleeve (TP01 41.25 / XS01 18.75 / VRP 15 / SKH01 25).
|
||||||
|
|
||||||
|
ONESTA': il premio resta MODELLATO su DVOL ATM (no skew), book 1d, f di stress non catturato.
|
||||||
|
Il verdetto massimo possibile e' "sleeve modellato migliorato", MAI deploy pieno.
|
||||||
|
|
||||||
|
uv run python scripts/research/r0701_vrp_refine.py [--skip-portfolio]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt"))
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from functools import lru_cache
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from scripts.research.options_vrp_lab import bs_put, strike_from_delta, load_series, m_weekly, per_year
|
||||||
|
from altlib import deflated_sharpe
|
||||||
|
|
||||||
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||||
|
WK_PER_YEAR = 365.25 / 7.0
|
||||||
|
CUTS = [pd.Timestamp(c, tz="UTC") for c in
|
||||||
|
("2023-01-01", "2023-07-01", "2024-01-01", "2024-07-01", "2025-01-01")]
|
||||||
|
MIN_IS_ACTIVE = 0.20 # attivita' minima in-sample per candidarsi (baseline ~41%)
|
||||||
|
|
||||||
|
# --- parametri FISSI del baseline VRP01 (NON toccati: cambia solo gate/sizing) ---
|
||||||
|
SHORT_DELTA, LONG_DELTA, F, TENOR_D = -0.28, -0.10, 1.0, 7
|
||||||
|
CRASH_SKIP, FEE_FRAC = 0.90, 0.125
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- pre-compute per asset (causale) -----------------------------
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def prep(asset: str):
|
||||||
|
"""px/dvol allineati + VRP causale (DVOL - RV30) e IV-rank espandente per OGNI giorno.
|
||||||
|
vrp[i] usa i 30 log-ret che finiscono a close[i]; ivr[i] = percentile di dv[i] in dv[:i]."""
|
||||||
|
J = load_series(asset)
|
||||||
|
px = J["px"].values.astype(float)
|
||||||
|
dv = J["dvol"].values.astype(float) / 100.0
|
||||||
|
idx = J.index
|
||||||
|
n = len(px)
|
||||||
|
lr = np.diff(np.log(px)) # lr[k] = log(px[k+1]/px[k])
|
||||||
|
vrp = np.full(n, np.nan)
|
||||||
|
for i in range(31, n):
|
||||||
|
vrp[i] = dv[i] - float(np.std(lr[i - 30:i]) * np.sqrt(365.25)) # come baseline (ddof=0)
|
||||||
|
ivr = np.full(n, np.nan)
|
||||||
|
for i in range(60, n):
|
||||||
|
ivr[i] = float((dv[:i] < dv[i]).mean())
|
||||||
|
return px, dv, idx, vrp, ivr
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def tp01_avg_target():
|
||||||
|
"""Serie giornaliera del target medio TP01 (BTC+ETH)/2. target[i] usa solo dati <= close[i]
|
||||||
|
-> noto alla sell-date del VRP (stessa close). Long-flat: 0.0 = risk-off pieno."""
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_1d
|
||||||
|
tp = TrendPortfolio(**CANONICAL)
|
||||||
|
cols = {}
|
||||||
|
for a in ("BTC", "ETH"):
|
||||||
|
df = resample_1d(load_data(a, "1h"))
|
||||||
|
t = pd.Series(np.nan_to_num(tp.target_series(df), nan=0.0),
|
||||||
|
index=pd.to_datetime(df["datetime"]))
|
||||||
|
if t.index.tz is None:
|
||||||
|
t.index = t.index.tz_localize("UTC")
|
||||||
|
cols[a] = t
|
||||||
|
J = pd.concat(cols, axis=1, join="inner")
|
||||||
|
return J.mean(axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- motore settimanale (unica differenza: gate/sizing) -----------------------------
|
||||||
|
def vrp_weekly(asset: str, sizing="bin", prop_scale=0.10, ivr_gate=0.30,
|
||||||
|
mom_k=0, mom_thr=0.0, tp_mode="off") -> tuple[pd.Series, Counter]:
|
||||||
|
"""Put credit spread settimanale come VRP01, con gate/sizing parametrici. CAUSALE:
|
||||||
|
strike/premio/gate/size usano solo dati <= sell-date; payoff a scadenza sul path certificato.
|
||||||
|
Ordine gate: prima i gate BASELINE (vrp/crash/ivr), poi i NUOVI (mom, tp) -> i counter dei
|
||||||
|
nuovi gate contano l'intervento MARGINALE (settimane altrimenti tradabili)."""
|
||||||
|
px, dv, idx, vrp_a, ivr_a = prep(asset)
|
||||||
|
n = len(px); T = TENOR_D / 365.25
|
||||||
|
tpv = None
|
||||||
|
if tp_mode != "off":
|
||||||
|
tpv = tp01_avg_target().reindex(idx, method="ffill").values
|
||||||
|
rets = {}; st = Counter()
|
||||||
|
i = 60
|
||||||
|
while i + TENOR_D < n:
|
||||||
|
st["weeks"] += 1
|
||||||
|
S0 = px[i]; sig = dv[i]; vrp = vrp_a[i]; ivr = ivr_a[i]
|
||||||
|
blocked = None
|
||||||
|
# --- gate BASELINE (identici a VRP01) ---
|
||||||
|
if np.isnan(vrp) or vrp <= 0:
|
||||||
|
blocked = "vrp"
|
||||||
|
elif not np.isnan(ivr) and ivr > CRASH_SKIP:
|
||||||
|
blocked = "crash"
|
||||||
|
elif ivr_gate > 0 and not np.isnan(ivr) and ivr < ivr_gate:
|
||||||
|
blocked = "ivr"
|
||||||
|
# --- gate NUOVI (contati sul residuo tradabile) ---
|
||||||
|
if blocked is None and mom_k > 0 and i >= mom_k:
|
||||||
|
if (dv[i] - dv[i - mom_k]) > mom_thr:
|
||||||
|
blocked = "mom"
|
||||||
|
size = 1.0
|
||||||
|
if blocked is None and tp_mode != "off" and tpv is not None and tpv[i] <= 1e-12:
|
||||||
|
if tp_mode == "skip":
|
||||||
|
blocked = "tp"
|
||||||
|
else: # half-size in risk-off
|
||||||
|
size *= 0.5; st["tp_half"] += 1
|
||||||
|
if blocked is None and sizing != "bin":
|
||||||
|
if sizing == "lin": # size ∝ gap IV-RV (carry atteso)
|
||||||
|
size *= float(np.clip(vrp / prop_scale, 0.0, 1.0))
|
||||||
|
elif sizing == "rank": # percentile espandente causale del VRP
|
||||||
|
hist = vrp_a[31:i]; hist = hist[~np.isnan(hist)]
|
||||||
|
size *= float((hist < vrp).mean()) if len(hist) >= 30 else 0.5
|
||||||
|
if blocked is not None:
|
||||||
|
st[f"blk_{blocked}"] += 1
|
||||||
|
rets[idx[i + TENOR_D]] = 0.0
|
||||||
|
i += TENOR_D
|
||||||
|
continue
|
||||||
|
st["traded"] += 1; st["size_sum"] += size
|
||||||
|
Ks = strike_from_delta(S0, T, sig, SHORT_DELTA)
|
||||||
|
Kl = strike_from_delta(S0, T, sig, LONG_DELTA)
|
||||||
|
net_prem = (bs_put(S0, Ks, T, sig) - bs_put(S0, Kl, T, sig)) * F
|
||||||
|
S1 = px[i + TENOR_D]
|
||||||
|
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1)
|
||||||
|
pnl = net_prem - payoff - FEE_FRAC * abs(net_prem)
|
||||||
|
rets[idx[i + TENOR_D]] = size * pnl / Ks # cash-secured su strike corto
|
||||||
|
i += TENOR_D
|
||||||
|
return pd.Series(rets), st
|
||||||
|
|
||||||
|
|
||||||
|
def book(**kw) -> tuple[pd.Series, Counter]:
|
||||||
|
rB, sB = vrp_weekly("BTC", **kw)
|
||||||
|
rE, sE = vrp_weekly("ETH", **kw)
|
||||||
|
b = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1).sort_index()
|
||||||
|
return b, sB + sE
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- metriche -----------------------------
|
||||||
|
def sh_wk(r: pd.Series) -> float:
|
||||||
|
r = r.dropna()
|
||||||
|
if len(r) < 8 or r.std() == 0:
|
||||||
|
return float("nan")
|
||||||
|
return float(r.mean() / r.std() * np.sqrt(WK_PER_YEAR))
|
||||||
|
|
||||||
|
|
||||||
|
def cell_metrics(b: pd.Series) -> dict:
|
||||||
|
is_ = b[b.index < HOLDOUT]; ho = b[b.index >= HOLDOUT]
|
||||||
|
full = m_weekly(b)
|
||||||
|
return dict(full_sh=full["sh"], full_dd=full["dd"], full_cagr=full["cagr"],
|
||||||
|
is_sh=sh_wk(is_), hold_sh=sh_wk(ho), worst=float(b.min()),
|
||||||
|
active=float((b != 0).mean()), is_active=float((is_ != 0).mean()))
|
||||||
|
|
||||||
|
|
||||||
|
def multicut(cand: pd.Series, base: pd.Series) -> list[tuple[str, float, float, float]]:
|
||||||
|
out = []
|
||||||
|
for c in CUTS:
|
||||||
|
sc, sb = sh_wk(cand[cand.index >= c]), sh_wk(base[base.index >= c])
|
||||||
|
out.append((str(c.date()), sc, sb, sc - sb))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- griglia -----------------------------
|
||||||
|
def grid_cells():
|
||||||
|
sizings = [("bin", 0.0, 0.30), ("lin", 0.08, 0.30), ("lin", 0.08, 0.0),
|
||||||
|
("lin", 0.12, 0.30), ("lin", 0.12, 0.0), ("rank", 0.0, 0.30), ("rank", 0.0, 0.0)]
|
||||||
|
moms = [(0, 0.0), (5, 0.0), (5, 0.05), (10, 0.0), (10, 0.05)]
|
||||||
|
tps = ["off", "skip", "half"]
|
||||||
|
cells = []
|
||||||
|
for sz, scale, ivr in sizings:
|
||||||
|
for mk, mth in moms:
|
||||||
|
for tp in tps:
|
||||||
|
name = (f"{sz}{f'{scale:g}' if sz == 'lin' else ''}"
|
||||||
|
f"|ivr{ivr:g}|mom{mk}k{mth:g}|tp-{tp}")
|
||||||
|
cells.append(dict(name=name, sizing=sz, prop_scale=scale, ivr_gate=ivr,
|
||||||
|
mom_k=mk, mom_thr=mth, tp_mode=tp))
|
||||||
|
return cells
|
||||||
|
|
||||||
|
|
||||||
|
BASELINE_NAME = "bin|ivr0.3|mom0k0|tp-off"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- portafoglio 4-sleeve -----------------------------
|
||||||
|
def weekly_to_daily_lump(wk: pd.Series) -> pd.Series:
|
||||||
|
"""Come sleeves._vrp_combo_returns: rendimento settimanale sul giorno di scadenza, 0 altrove."""
|
||||||
|
days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC")
|
||||||
|
daily = pd.Series(0.0, index=days)
|
||||||
|
daily.loc[wk.index.normalize()] = wk.values
|
||||||
|
return daily
|
||||||
|
|
||||||
|
|
||||||
|
def portfolio_compare(base_wk: pd.Series, cand_wk: pd.Series, cand_name: str):
|
||||||
|
"""4-sleeve con VRP baseline vs VRP variante (stessi TP01/XS01/SKH01, cache condivisa)."""
|
||||||
|
from src.portfolio.sleeves import tp01_sleeve, xsec_sleeve, skyhook_sleeve
|
||||||
|
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, metrics
|
||||||
|
tp, xs, sk = tp01_sleeve(weight=0.4125), xsec_sleeve(weight=0.1875), skyhook_sleeve(weight=0.25)
|
||||||
|
rows = []
|
||||||
|
for tag, wk in (("VRP01 baseline", base_wk), (f"VRP variante [{cand_name}]", cand_wk)):
|
||||||
|
daily = weekly_to_daily_lump(wk)
|
||||||
|
vrp = Sleeve("VRP01_shortvol", 0.15, lambda d=daily: d)
|
||||||
|
port = StrategyPortfolio([tp, xs, vrp, sk])
|
||||||
|
full = metrics(port.combined_daily())
|
||||||
|
hold = metrics(port.combined_daily(lo=HOLDOUT))
|
||||||
|
rows.append((tag, full, hold))
|
||||||
|
print(f" {tag:<38} FULL Sh {full['sharpe']:>5.2f} DD {full['maxdd']*100:>4.1f}% "
|
||||||
|
f"CAGR {full['cagr']*100:>+5.1f}% | HOLD Sh {hold['sharpe']:>5.2f} DD {hold['maxdd']*100:>4.1f}%")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- main -----------------------------
|
||||||
|
def main():
|
||||||
|
skip_port = "--skip-portfolio" in sys.argv
|
||||||
|
print("=" * 110)
|
||||||
|
print(" r0701 VRP REFINE — sizing IV-RV / filtro DVOL-momentum / gate TP01 (griglia onesta, sel. in-sample)")
|
||||||
|
print("=" * 110)
|
||||||
|
|
||||||
|
cells = grid_cells()
|
||||||
|
print(f" griglia: {len(cells)} celle (TUTTE contate nel deflated-Sharpe). "
|
||||||
|
f"IS = pre-2025, HOLD = 2025-01-01+.\n")
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for c in cells:
|
||||||
|
b, st = book(sizing=c["sizing"], prop_scale=c["prop_scale"], ivr_gate=c["ivr_gate"],
|
||||||
|
mom_k=c["mom_k"], mom_thr=c["mom_thr"], tp_mode=c["tp_mode"])
|
||||||
|
results[c["name"]] = dict(cfg=c, b=b, st=st, **cell_metrics(b))
|
||||||
|
|
||||||
|
base = results[BASELINE_NAME]
|
||||||
|
print(f" (0) BASELINE riprodotto [{BASELINE_NAME}]:")
|
||||||
|
print(f" FULL Sh {base['full_sh']:.2f} DD {base['full_dd']*100:.0f}% CAGR {base['full_cagr']*100:+.0f}% "
|
||||||
|
f"worst {base['worst']*100:+.1f}% IS Sh {base['is_sh']:.2f} HOLD Sh {base['hold_sh']:.2f} "
|
||||||
|
f"attivo {base['active']*100:.0f}% (atteso ~ FULL 1.10 / HOLD 0.60 / DD 12%)")
|
||||||
|
|
||||||
|
# ---- frequenza d'intervento dei gate NUOVI (sul baseline + singola manopola) ----
|
||||||
|
print("\n (1) FREQUENZA D'INTERVENTO dei gate nuovi (settimane altrimenti tradabili, book BTC+ETH):")
|
||||||
|
probes = [("mom k=5 thr=0", dict(mom_k=5, mom_thr=0.0)),
|
||||||
|
("mom k=5 thr=5pt", dict(mom_k=5, mom_thr=0.05)),
|
||||||
|
("mom k=10 thr=0", dict(mom_k=10, mom_thr=0.0)),
|
||||||
|
("mom k=10 thr=5pt", dict(mom_k=10, mom_thr=0.05)),
|
||||||
|
("tp01-skip", dict(tp_mode="skip")),
|
||||||
|
("tp01-half", dict(tp_mode="half"))]
|
||||||
|
base_traded = base["st"]["traded"]
|
||||||
|
for label, kw in probes:
|
||||||
|
_, st = book(**kw)
|
||||||
|
blk = st.get("blk_mom", 0) + st.get("blk_tp", 0)
|
||||||
|
half = st.get("tp_half", 0)
|
||||||
|
extra = f" (+{half} sett. a mezza size)" if half else ""
|
||||||
|
print(f" {label:<18} blocca {blk:>3} / {base_traded} settimane-trade del baseline "
|
||||||
|
f"({100*blk/max(base_traded,1):>4.1f}%){extra}")
|
||||||
|
tgt = tp01_avg_target()
|
||||||
|
pxB, _, idxB, _, _ = prep("BTC")
|
||||||
|
tp_on_grid = tgt.reindex(idxB, method="ffill")
|
||||||
|
print(f" [contesto] TP01 flat (BTC+ETH entrambi 0): {100*float((tp_on_grid <= 1e-12).mean()):.0f}% dei giorni della finestra DVOL")
|
||||||
|
|
||||||
|
# ---- classifica IN-SAMPLE (selezione onesta: nessuno sguardo all'hold-out) ----
|
||||||
|
ranked = sorted((r for r in results.values() if r["is_active"] >= MIN_IS_ACTIVE),
|
||||||
|
key=lambda r: r["is_sh"], reverse=True)
|
||||||
|
print(f"\n (2) TOP-10 per Sharpe IN-SAMPLE (pre-2025; filtro attivita' IS >= {MIN_IS_ACTIVE:.0%}):")
|
||||||
|
print(f" {'cella':<34}{'IS Sh':>7}{'FULL':>7}{'HOLD':>7}{'DD':>6}{'worst':>8}{'att.':>6}")
|
||||||
|
for r in ranked[:10]:
|
||||||
|
print(f" {r['cfg']['name']:<34}{r['is_sh']:>7.2f}{r['full_sh']:>7.2f}{r['hold_sh']:>7.2f}"
|
||||||
|
f"{r['full_dd']*100:>5.0f}%{r['worst']*100:>+7.1f}%{r['active']*100:>5.0f}%")
|
||||||
|
|
||||||
|
# ---- varianti a SINGOLA manopola vs baseline (tabella diario) ----
|
||||||
|
print("\n (2b) VARIANTI A SINGOLA MANOPOLA vs baseline (stessa tabella, nessuna selezione):")
|
||||||
|
singles = ["lin0.08|ivr0.3|mom0k0|tp-off", "lin0.12|ivr0.3|mom0k0|tp-off",
|
||||||
|
"rank|ivr0.3|mom0k0|tp-off", "lin0.08|ivr0|mom0k0|tp-off",
|
||||||
|
"rank|ivr0|mom0k0|tp-off",
|
||||||
|
"bin|ivr0.3|mom5k0.05|tp-off", "bin|ivr0.3|mom10k0.05|tp-off",
|
||||||
|
"bin|ivr0.3|mom5k0|tp-off", "bin|ivr0.3|mom10k0|tp-off",
|
||||||
|
"bin|ivr0.3|mom0k0|tp-skip", "bin|ivr0.3|mom0k0|tp-half"]
|
||||||
|
print(f" {'cella':<34}{'IS Sh':>7}{'FULL':>7}{'HOLD':>7}{'DD':>6}{'worst':>8}{'att.':>6}")
|
||||||
|
r = base
|
||||||
|
print(f" {'BASELINE ' + BASELINE_NAME:<34}{r['is_sh']:>7.2f}{r['full_sh']:>7.2f}{r['hold_sh']:>7.2f}"
|
||||||
|
f"{r['full_dd']*100:>5.0f}%{r['worst']*100:>+7.1f}%{r['active']*100:>5.0f}%")
|
||||||
|
for nm in singles:
|
||||||
|
r = results[nm]
|
||||||
|
print(f" {nm:<34}{r['is_sh']:>7.2f}{r['full_sh']:>7.2f}{r['hold_sh']:>7.2f}"
|
||||||
|
f"{r['full_dd']*100:>5.0f}%{r['worst']*100:>+7.1f}%{r['active']*100:>5.0f}%")
|
||||||
|
n_beat_hold = sum(1 for r in results.values() if r["hold_sh"] > base["hold_sh"])
|
||||||
|
print(f" [onesta'] celle che battono l'HOLD-OUT del baseline: {n_beat_hold}/{len(results)} — "
|
||||||
|
f"NON selezionabili (sarebbe selection-on-holdout, gate 2026-06-29).")
|
||||||
|
|
||||||
|
cand = ranked[0]
|
||||||
|
is_baseline_best = cand["cfg"]["name"] == BASELINE_NAME
|
||||||
|
print(f"\n -> cella scelta IN-SAMPLE: [{cand['cfg']['name']}] IS Sh {cand['is_sh']:.2f} "
|
||||||
|
f"(baseline IS {base['is_sh']:.2f}, Δ {cand['is_sh']-base['is_sh']:+.2f})")
|
||||||
|
|
||||||
|
# ---- hold-out multi-cut vs baseline ----
|
||||||
|
print("\n (3) MULTI-CUT hold-out (Sharpe da ogni taglio a fine storia; uplift = cand - baseline):")
|
||||||
|
mc = multicut(cand["b"], base["b"])
|
||||||
|
pos = sum(1 for _, _, _, u in mc if u > 0)
|
||||||
|
for cut, sc, sb, u in mc:
|
||||||
|
print(f" cut {cut}: cand {sc:>5.2f} base {sb:>5.2f} uplift {u:>+5.2f}")
|
||||||
|
print(f" uplift positivo in {pos}/{len(mc)} tagli (richiesti >= 4/5)")
|
||||||
|
|
||||||
|
# ---- deflated Sharpe (tutti i trial della griglia) ----
|
||||||
|
all_sh = [r["full_sh"] for r in results.values()]
|
||||||
|
dsr_c, null_max = deflated_sharpe(cand["full_sh"], all_sh, cand["b"].values, dpy=WK_PER_YEAR)
|
||||||
|
dsr_b, _ = deflated_sharpe(base["full_sh"], all_sh, base["b"].values, dpy=WK_PER_YEAR)
|
||||||
|
print(f"\n (4) DEFLATED SHARPE (N={len(all_sh)} trial di questa griglia; PASS >= 0.95):")
|
||||||
|
print(f" cand DSR {dsr_c:.3f} (null-max Sh {null_max:.2f}) | baseline DSR {dsr_b:.3f}")
|
||||||
|
print(" NB: le celle della griglia sono fortemente correlate fra loro (stesso trade sottostante)")
|
||||||
|
print(" -> il DSR qui e' anti-conservativo sul multiple-testing; in piu' VRP01 stesso viene da")
|
||||||
|
print(" ~20 config precedenti (options_vrp_lab/_v2). Leggere il DSR come limite SUPERIORE.")
|
||||||
|
|
||||||
|
# ---- per-anno cand vs base ----
|
||||||
|
print("\n (5) PER-ANNO (ritorno composto):")
|
||||||
|
pyc, pyb = per_year(cand["b"]), per_year(base["b"])
|
||||||
|
print(" base: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in pyb.items()))
|
||||||
|
print(" cand: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in pyc.items()))
|
||||||
|
|
||||||
|
# ---- portafoglio 4-sleeve ----
|
||||||
|
if not skip_port:
|
||||||
|
print("\n (6) PORTAFOGLIO 4-SLEEVE (TP01 41.25 / XS01 18.75 / VRP 15 / SKH01 25), VRP base vs variante:")
|
||||||
|
try:
|
||||||
|
portfolio_compare(base["b"], cand["b"], cand["cfg"]["name"])
|
||||||
|
except Exception as e: # dati HL/5m mancanti in qualche ambiente
|
||||||
|
print(f" [saltato: {type(e).__name__}: {e}]")
|
||||||
|
else:
|
||||||
|
print("\n (6) portafoglio: saltato (--skip-portfolio)")
|
||||||
|
|
||||||
|
# ---- verdetto ----
|
||||||
|
print("\n" + "=" * 110)
|
||||||
|
improves = (not is_baseline_best
|
||||||
|
and cand["is_sh"] > base["is_sh"]
|
||||||
|
and pos >= 4
|
||||||
|
and (cand["hold_sh"] > base["hold_sh"])
|
||||||
|
and dsr_c >= 0.95)
|
||||||
|
if is_baseline_best:
|
||||||
|
print(" VERDETTO: NON MIGLIORA — il baseline VRP01 vince gia' la selezione in-sample.")
|
||||||
|
elif improves:
|
||||||
|
print(f" VERDETTO: MIGLIORA (variante {cand['cfg']['name']}) — batte il baseline in-sample,")
|
||||||
|
print(f" su hold-out multi-cut ({pos}/{len(mc)}) e DSR {dsr_c:.2f}>=0.95. Resta SLEEVE MODELLATO")
|
||||||
|
print(" (premio DVOL ATM, book 1d, f di stress non catturato): NON deploy pieno.")
|
||||||
|
else:
|
||||||
|
why = []
|
||||||
|
if cand["is_sh"] <= base["is_sh"]:
|
||||||
|
why.append("non batte il baseline in-sample")
|
||||||
|
if pos < 4:
|
||||||
|
why.append(f"multi-cut {pos}/{len(mc)} (<4)")
|
||||||
|
if cand["hold_sh"] <= base["hold_sh"]:
|
||||||
|
why.append("hold-out non migliore")
|
||||||
|
if dsr_c < 0.95:
|
||||||
|
why.append(f"DSR {dsr_c:.2f}<0.95")
|
||||||
|
print(f" VERDETTO: NON MIGLIORA — cella IS-best [{cand['cfg']['name']}] bocciata: " + "; ".join(why) + ".")
|
||||||
|
print("=" * 110)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
"""r0701_xs — RESIDUAL (IDIOSYNCRATIC) MOMENTUM cross-sectional sui 19 major Hyperliquid.
|
||||||
|
|
||||||
|
TESI (2026-07-01). STATARB-RESID (thread 4, 2026-06-29) ha mostrato che il MOMENTUM del residuo
|
||||||
|
ETH−β·BTC (β OLS rolling, sgn=+1: le dislocazioni CONTINUANO a 1d) passa quasi tutti i gate su
|
||||||
|
2 gambe, fallendo SOLO il deflated-Sharpe (0.929<0.95). Angolo nuovo: lo stesso meccanismo
|
||||||
|
CROSS-SECTIONAL sui 19 major di XS01 — per ogni alt, residuo vs β·BTC (β OLS rolling B giorni),
|
||||||
|
momentum del residuo su lookback (blend z-score [30,90] come XS01, o singolo), rank cross-section,
|
||||||
|
long top-k / short bottom-k, vol-target 20%. Ipotesi: la breadth (18 stream invece di 1) alza il
|
||||||
|
DSR dove il 2-gambe falliva.
|
||||||
|
|
||||||
|
DISTINZIONE da quanto gia' testato:
|
||||||
|
* IREV (xsec_v2_nonmom, idio-REVERSAL, sgn=-1): FALLITO. Qui sgn=+1 (idio-MOMENTUM).
|
||||||
|
* IMOM (xsec_v2_nonmom): residuo vs mercato EQUAL-WEIGHT, B=60 fisso, no blend, era solo
|
||||||
|
"riferimento momentum". Qui: fattore = BTC (come STATARB-RESID), B in griglia, blend z-score
|
||||||
|
[30,90] + probe con gate di dispersione (parita' strutturale con XS01), selezione IN-SAMPLE.
|
||||||
|
|
||||||
|
IL BAR (fondamentale): una variante di XS01 e' utile SOLO se (a) SOSTITUISCE XS01 (meglio
|
||||||
|
standalone E nel portafoglio 4-sleeve) oppure (b) AGGIUNGE come 5o sleeve (corr bassa a XS01 E
|
||||||
|
TP01, uplift del PORTAFOGLIO). corr>0.6 a XS01 senza batterlo -> REDUNDANT/SCARTATO.
|
||||||
|
Baseline XS01: standalone FULL Sh ~1.50 / HOLD ~1.71 / DD ~11%.
|
||||||
|
|
||||||
|
GATE (CLAUDE.md, metodologia obbligatoria):
|
||||||
|
1. CAUSALE: score a close[i], peso tenuto in i+1 (engine shifta W[i-1]*dret[i]); barre vol=0
|
||||||
|
escluse; prefix-check di causalita' sulla cella scelta.
|
||||||
|
2. NETTO fee 0.10% RT per gamba per ribilancio + sweep {0.05, 0.10, 0.20, 0.30}% RT/gamba.
|
||||||
|
3. Selezione cella IN-SAMPLE-ONLY (pre-2025, anti selection-on-holdout), poi hold-out bloccato.
|
||||||
|
4. DEFLATED Sharpe su TUTTI i trial della griglia (serve >=0.95).
|
||||||
|
5. Confronto PORTAFOGLIO: sostituzione di XS01 a parita' di peso + aggiunta 5o sleeve @10/15%
|
||||||
|
(riusa StrategyPortfolio/active_sleeves senza modificarli) + marginal vs il BOOK a 4 sleeve.
|
||||||
|
6. CAVEAT IMMUTABILI: storia HL ~2.5 anni; book L/S a ~2k gambe -> STAT-MODE a $600 (dichiaro
|
||||||
|
comunque l'haircut small-cap $600/min$5).
|
||||||
|
|
||||||
|
uv run python scripts/research/r0701_xs_residmom.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
import xsec_v2_nonmom as xv # harness collaudato (load_matrix, deflated_sharpe, portfolio, ...)
|
||||||
|
from src.portfolio.sleeves import XS_UNIVERSE
|
||||||
|
|
||||||
|
DPY, TV, FEE, HOLDOUT = xv.DPY, xv.TV, xv.FEE, xv.HOLDOUT
|
||||||
|
FACTOR = "BTC" # il fattore del residuo (come STATARB-RESID), NON tradato
|
||||||
|
BLEND = (30, 90) # blend z-score come XS01
|
||||||
|
|
||||||
|
# griglia (modesta, da mandato): beta-window x lookback x k x H (gate=off)
|
||||||
|
BETAS = (60, 90, 120)
|
||||||
|
LOOKS = ("blend", 30, 90)
|
||||||
|
KS = (3, 5)
|
||||||
|
HS = (5, 10, 20)
|
||||||
|
# probe a parita' STRUTTURALE con XS01 (blend + gate dispersione p30, H10 k5) — contano nei trial
|
||||||
|
GATED_PROBES = [dict(B=B, L="blend", k=5, H=10, gate=30) for B in BETAS]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# SCORE BUILDER — residual momentum vs beta*BTC. CAUSALE (dati <= i).
|
||||||
|
# Ritorna score_at(i) -> (score_blend_z[A], valid[A], disp_raw_i) + warmup.
|
||||||
|
# disp_raw_i = dispersione cross-section del momentum RESIDUO grezzo (per il gate: lo z-score
|
||||||
|
# blended ha std ~1 per costruzione, quindi la dispersione va misurata sul grezzo).
|
||||||
|
# ===========================================================================
|
||||||
|
def make_residmom(PX: pd.DataFrame, B: int, L):
|
||||||
|
lookbacks = BLEND if L == "blend" else (int(L),)
|
||||||
|
px = PX.values
|
||||||
|
n, A = px.shape
|
||||||
|
fi = list(PX.columns).index(FACTOR)
|
||||||
|
DR = PX.pct_change()
|
||||||
|
m = DR[FACTOR]
|
||||||
|
beta, _ = xv._rolling_beta(DR, m, B) # beta_j vs BTC su finestra B (<= i)
|
||||||
|
SDR = {Lk: DR.rolling(Lk, min_periods=int(0.8 * Lk)).sum().values for Lk in lookbacks}
|
||||||
|
SM = {Lk: m.rolling(Lk, min_periods=int(0.8 * Lk)).sum().values for Lk in lookbacks}
|
||||||
|
CNT = {Lk: DR.rolling(Lk, min_periods=1).count().values for Lk in lookbacks}
|
||||||
|
|
||||||
|
def score_at(i):
|
||||||
|
b = beta[i]
|
||||||
|
valid = np.isfinite(px[i]) & np.isfinite(b)
|
||||||
|
valid[fi] = False # BTC = fattore, fuori dal cross-section
|
||||||
|
resids = []
|
||||||
|
for Lk in lookbacks:
|
||||||
|
resid = SDR[Lk][i] - b * SM[Lk][i] # momentum del residuo r_j - beta_j*r_btc
|
||||||
|
valid = valid & np.isfinite(resid) & (CNT[Lk][i] >= 0.8 * Lk)
|
||||||
|
resids.append(resid)
|
||||||
|
score = np.full(A, np.nan)
|
||||||
|
disp = np.nan
|
||||||
|
nv = int(valid.sum())
|
||||||
|
if nv >= 2:
|
||||||
|
acc = np.zeros(nv)
|
||||||
|
cnt = 0
|
||||||
|
stds = []
|
||||||
|
for resid in resids:
|
||||||
|
r = resid[valid]
|
||||||
|
sd = float(r.std())
|
||||||
|
stds.append(sd)
|
||||||
|
if sd > 0:
|
||||||
|
acc += (r - r.mean()) / sd
|
||||||
|
cnt += 1
|
||||||
|
if cnt:
|
||||||
|
score[valid] = acc / cnt # blend: media z-score cross-sectional
|
||||||
|
disp = float(np.mean(stds)) # dispersione del momentum residuo GREZZO
|
||||||
|
return score, valid, disp
|
||||||
|
|
||||||
|
return score_at, max(max(lookbacks), B) + 1
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# ENGINE locale (= xv.xs_engine + ritorno di W/scale + gate di dispersione opzionale).
|
||||||
|
# L'uguaglianza con xv.xs_engine sulle celle non-gated e' VERIFICATA in main().
|
||||||
|
# ===========================================================================
|
||||||
|
def xs_engine_w(PX, VOL, score_at, H, k, target_vol=TV, fee=FEE, min_assets=10, warmup=0,
|
||||||
|
disp_pct=0, disp_minhist=20):
|
||||||
|
px = PX.values
|
||||||
|
vol = VOL.values
|
||||||
|
n, A = px.shape
|
||||||
|
dret = np.full((n, A), np.nan)
|
||||||
|
dret[1:] = px[1:] / px[:-1] - 1.0
|
||||||
|
W = np.zeros((n, A))
|
||||||
|
w = np.zeros(A)
|
||||||
|
disp_hist = []
|
||||||
|
for i in range(n):
|
||||||
|
if i >= warmup and i % H == 0:
|
||||||
|
score, valid, disp = score_at(i)
|
||||||
|
valid = valid & np.isfinite(score) & (vol[i] > 0)
|
||||||
|
idxv = np.where(valid)[0]
|
||||||
|
if len(idxv) >= min_assets:
|
||||||
|
thr = (np.percentile(disp_hist, disp_pct)
|
||||||
|
if (disp_pct > 0 and len(disp_hist) >= disp_minhist) else -np.inf)
|
||||||
|
if not (disp_pct > 0) or (np.isfinite(disp) and disp >= thr):
|
||||||
|
kk = min(k, len(idxv) // 2)
|
||||||
|
order = idxv[np.argsort(score[idxv])]
|
||||||
|
lo, hi = order[:kk], order[-kk:]
|
||||||
|
w = np.zeros(A)
|
||||||
|
w[hi] = 0.5 / kk # long alto residual-momentum (sgn=+1)
|
||||||
|
w[lo] = -0.5 / kk # short basso
|
||||||
|
else:
|
||||||
|
w = np.zeros(A) # regime compatto -> flat (gate)
|
||||||
|
if disp_pct > 0 and np.isfinite(disp):
|
||||||
|
disp_hist.append(disp)
|
||||||
|
else:
|
||||||
|
w = np.zeros(A)
|
||||||
|
W[i] = w
|
||||||
|
gross = np.zeros(n)
|
||||||
|
gross[1:] = np.nansum(W[:-1] * np.nan_to_num(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 * (fee / 2.0)
|
||||||
|
s = pd.Series(net, index=PX.index)
|
||||||
|
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(DPY)
|
||||||
|
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||||
|
turn_py = float(turn.sum() / (n / DPY)) if n else 0.0
|
||||||
|
return pd.Series(s.values * scale, index=PX.index), turn_py, W, scale
|
||||||
|
|
||||||
|
|
||||||
|
def run_cell(PX, VOL, cfg, fee=FEE):
|
||||||
|
score_at, warm = make_residmom(PX, cfg["B"], cfg["L"])
|
||||||
|
daily, turn, W, scale = xs_engine_w(PX, VOL, score_at, cfg["H"], cfg["k"], fee=fee,
|
||||||
|
warmup=warm, disp_pct=cfg.get("gate", 0))
|
||||||
|
return xv.to_daily(daily), turn, W, scale
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# CAUSALITA' (prefix-check, pattern di xv.causality_prefix_check sul nostro engine)
|
||||||
|
# ===========================================================================
|
||||||
|
def causality_prefix_check(PX, VOL, cfg, frac=0.85, tail=60, tol=1e-9):
|
||||||
|
score_full, warm = make_residmom(PX, cfg["B"], cfg["L"])
|
||||||
|
full, *_ = xs_engine_w(PX, VOL, score_full, cfg["H"], cfg["k"], warmup=warm,
|
||||||
|
disp_pct=cfg.get("gate", 0))
|
||||||
|
cut = int(len(PX) * frac)
|
||||||
|
PXc, VOLc = PX.iloc[:cut], VOL.iloc[:cut]
|
||||||
|
score_pre, warm2 = make_residmom(PXc, cfg["B"], cfg["L"])
|
||||||
|
pre, *_ = xs_engine_w(PXc, VOLc, score_pre, cfg["H"], cfg["k"], warmup=warm2,
|
||||||
|
disp_pct=cfg.get("gate", 0))
|
||||||
|
lo = max(0, cut - tail)
|
||||||
|
a, b = full.values[lo:cut], pre.values[lo:cut]
|
||||||
|
worst = float(np.max(np.abs(a - b))) if len(a) else float("nan")
|
||||||
|
return dict(ok=bool(worst <= tol), max_tail_diff=worst, cut=cut, tail=len(a))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# SMALL-CAP $600 (dichiarativo: il book resta STAT-MODE comunque, come XS01).
|
||||||
|
# Pesi EFFETTIVI = W[i] * scale[i+1] (scale[i+1] usa net fino a i via shift(1) -> noto a close i).
|
||||||
|
# Un cambio-gamba con |dw|*capital < min_order NON si esegue. Confronto realistico vs modeled
|
||||||
|
# sulla STESSA simulazione a pesi (coerente internamente).
|
||||||
|
# ===========================================================================
|
||||||
|
def smallcap_check(PX, W, scale, fee=FEE, capital=600.0, min_order=5.0):
|
||||||
|
px = PX.values
|
||||||
|
n, A = px.shape
|
||||||
|
dret = np.nan_to_num(np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]))
|
||||||
|
sc = np.roll(scale, -1)
|
||||||
|
sc[-1] = scale[-1]
|
||||||
|
T = W * sc[:, None] # target effettivo deciso a close i
|
||||||
|
|
||||||
|
def sim(min_ord):
|
||||||
|
held = np.zeros((n, A))
|
||||||
|
cur = np.zeros(A)
|
||||||
|
n_tr = 0
|
||||||
|
for i in range(n):
|
||||||
|
d = np.abs(T[i] - cur) * capital
|
||||||
|
ex = d >= min_ord
|
||||||
|
n_tr += int(ex.sum())
|
||||||
|
cur = np.where(ex, T[i], cur)
|
||||||
|
held[i] = cur
|
||||||
|
pos = np.zeros((n, A))
|
||||||
|
pos[1:] = held[:-1]
|
||||||
|
turn = np.abs(np.diff(held, axis=0, prepend=np.zeros((1, A)))).sum(axis=1)
|
||||||
|
net = (pos * dret).sum(axis=1) - turn * (fee / 2.0)
|
||||||
|
r = net[np.isfinite(net)]
|
||||||
|
sh = float(r.mean() / r.std() * np.sqrt(DPY)) if r.std() > 0 else 0.0
|
||||||
|
return sh, n_tr, float(turn.sum() / (n / DPY))
|
||||||
|
|
||||||
|
sh_real, ntr_real, turn_real = sim(min_order)
|
||||||
|
sh_mod, ntr_mod, turn_mod = sim(0.0)
|
||||||
|
return dict(sharpe_modeled=round(sh_mod, 3), sharpe_realistic=round(sh_real, 3),
|
||||||
|
haircut=round(sh_mod - sh_real, 3), n_executed=ntr_real, n_modeled=ntr_mod,
|
||||||
|
turnover_real=round(turn_real, 1), turnover_modeled=round(turn_mod, 1))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# PORTAFOGLIO — sostituzione XS01 + aggiunta 5o sleeve (pattern di xsec_v3_momstruct)
|
||||||
|
# ===========================================================================
|
||||||
|
_BASE = None
|
||||||
|
_BASE_M = None
|
||||||
|
|
||||||
|
|
||||||
|
def _base():
|
||||||
|
global _BASE, _BASE_M
|
||||||
|
if _BASE is None:
|
||||||
|
_BASE = xv.active_sleeves()
|
||||||
|
pf = xv.StrategyPortfolio(_BASE)
|
||||||
|
pf.backtest()
|
||||||
|
_BASE_M = (xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT)))
|
||||||
|
return _BASE, _BASE_M
|
||||||
|
|
||||||
|
|
||||||
|
def add_uplift(daily, fr):
|
||||||
|
base, _ = _base()
|
||||||
|
wraw = fr / (1.0 - fr)
|
||||||
|
cand = xv.Sleeve("R0701_cand", wraw, lambda d=daily: d)
|
||||||
|
pf = xv.StrategyPortfolio(base + [cand])
|
||||||
|
return (xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT)),
|
||||||
|
pf.weights().get("R0701_cand", 0.0))
|
||||||
|
|
||||||
|
|
||||||
|
def substitute_xs01(daily):
|
||||||
|
base, _ = _base()
|
||||||
|
sub = [xv.Sleeve("R0701_sub", s.weight, lambda d=daily: d) if s.name == "XS01_xsec_hl" else s
|
||||||
|
for s in base]
|
||||||
|
pf = xv.StrategyPortfolio(sub)
|
||||||
|
return xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT))
|
||||||
|
|
||||||
|
|
||||||
|
def marginal_vs_book(daily):
|
||||||
|
"""Corr + uplift del blend 0.9*BOOK+0.1*cand vs il BOOK a 4 sleeve (full/hold + multi-cut)."""
|
||||||
|
base, _ = _base()
|
||||||
|
book = xv.StrategyPortfolio(base).combined_daily()
|
||||||
|
J = pd.concat({"B": book, "C": daily}, axis=1, join="inner").dropna()
|
||||||
|
|
||||||
|
def _sh(s):
|
||||||
|
r = np.asarray(s.dropna().values, float)
|
||||||
|
return float(r.mean() / r.std() * np.sqrt(DPY)) if len(r) > 2 and r.std() > 0 else 0.0
|
||||||
|
|
||||||
|
def _up(sub):
|
||||||
|
return _sh(0.9 * sub["B"] + 0.1 * sub["C"]) - _sh(sub["B"])
|
||||||
|
|
||||||
|
JH = J[J.index >= HOLDOUT]
|
||||||
|
cuts = {}
|
||||||
|
for y in sorted(set(J.index.year))[1:]:
|
||||||
|
sub = J[J.index >= pd.Timestamp(f"{y}-01-01", tz="UTC")]
|
||||||
|
if len(sub) >= 120:
|
||||||
|
cuts[int(y)] = round(_up(sub), 3)
|
||||||
|
return dict(corr_book=round(float(J["B"].corr(J["C"])), 3),
|
||||||
|
uplift_full=round(_up(J), 3),
|
||||||
|
uplift_hold=round(_up(JH), 3) if len(JH) > 30 else None,
|
||||||
|
multicut=cuts)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
def per_year(daily):
|
||||||
|
return [(int(y), round(float((1 + g).prod() - 1), 3)) for y, g in daily.groupby(daily.index.year)]
|
||||||
|
|
||||||
|
|
||||||
|
def tag(cfg):
|
||||||
|
g = f" gate{cfg['gate']}" if cfg.get("gate") else ""
|
||||||
|
return f"B{cfg['B']} L{cfg['L']} k{cfg['k']} H{cfg['H']}{g}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 104)
|
||||||
|
print(" r0701_xs — RESIDUAL MOMENTUM cross-sectional (residuo vs beta*BTC) sui 19 major HL — STAT-MODE")
|
||||||
|
print("=" * 104)
|
||||||
|
|
||||||
|
PX, VOL = xv.load_matrix(XS_UNIVERSE)
|
||||||
|
print(f" universo 19-major: {PX.shape[1]} asset, {PX.shape[0]} giorni "
|
||||||
|
f"[{PX.index[0].date()} -> {PX.index[-1].date()}] fattore={FACTOR} (escluso dal cross-section)")
|
||||||
|
|
||||||
|
tp_daily = xv.tp01_sleeve().daily()
|
||||||
|
xs_daily = xv.xsec_sleeve().daily()
|
||||||
|
xs_f = xv.metrics(xs_daily)
|
||||||
|
xs_h = xv.metrics(xs_daily[xs_daily.index >= HOLDOUT])
|
||||||
|
print(f" baseline XS01 (sleeve attivo): FULL Sh {xs_f['sharpe']:.2f} DD {xs_f['maxdd']*100:.0f}%"
|
||||||
|
f" | HOLD Sh {xs_h['sharpe']:.2f}")
|
||||||
|
|
||||||
|
# --- sanity: engine locale == xv.xs_engine sulle celle non-gated -----------------
|
||||||
|
chk_cfg = dict(B=90, L=30, k=5, H=10)
|
||||||
|
score_at, warm = make_residmom(PX, chk_cfg["B"], chk_cfg["L"])
|
||||||
|
mine, *_ = xs_engine_w(PX, VOL, score_at, chk_cfg["H"], chk_cfg["k"], warmup=warm)
|
||||||
|
ref, _ = xv.xs_engine(PX, VOL, lambda i: score_at(i)[:2], chk_cfg["H"], chk_cfg["k"], warmup=warm)
|
||||||
|
dmax = float(np.nanmax(np.abs(mine.values - ref.values)))
|
||||||
|
assert dmax < 1e-12, f"engine locale diverge da xv.xs_engine: {dmax}"
|
||||||
|
print(f" [sanity] engine locale == xv.xs_engine (maxdiff {dmax:.1e})")
|
||||||
|
|
||||||
|
# --- griglia -----------------------------------------------------------------------
|
||||||
|
grid = [dict(B=B, L=L, k=k, H=H) for B in BETAS for L in LOOKS for k in KS for H in HS]
|
||||||
|
grid += GATED_PROBES
|
||||||
|
rows = []
|
||||||
|
for cfg in grid:
|
||||||
|
daily, turn, W, scale = run_cell(PX, VOL, cfg)
|
||||||
|
if daily.std() == 0 or len(daily) < 60:
|
||||||
|
continue
|
||||||
|
f, h, pct = xv.evalcfg(daily)
|
||||||
|
ins = daily[daily.index < HOLDOUT]
|
||||||
|
is_sh = xv.metrics(ins)["sharpe"] if len(ins) > 60 else float("nan")
|
||||||
|
rows.append(dict(cfg=cfg, daily=daily, W=W, scale=scale, turn=turn,
|
||||||
|
full=f["sharpe"], hold=h["sharpe"], dd=f["maxdd"], ret=f["ret"],
|
||||||
|
pct=pct, is_sh=is_sh,
|
||||||
|
corrXS=xv._corr(daily, xs_daily), corrTP=xv._corr(daily, tp_daily)))
|
||||||
|
all_sr = [r["full"] for r in rows]
|
||||||
|
print(f"\n griglia: {len(rows)} celle valide su {len(grid)} "
|
||||||
|
f"(trial per deflated-Sharpe = {len(all_sr)}; il conteggio VERO del programma e' >>)")
|
||||||
|
|
||||||
|
hdr = f" {'cfg':<26}{'IS':>7}{'FULL':>7}{'HOLD':>7}{'DD%':>6}{'anni+':>7}{'corrXS':>8}{'corrTP':>8}{'turn/y':>8}"
|
||||||
|
valid = [r for r in rows if np.isfinite(r["is_sh"])]
|
||||||
|
print("\n TOP-5 per Sharpe IN-SAMPLE (pre-2025) — la selezione ONESTA:")
|
||||||
|
print(hdr)
|
||||||
|
for r in sorted(valid, key=lambda r: -r["is_sh"])[:5]:
|
||||||
|
print(f" {tag(r['cfg']):<26}{r['is_sh']:>7.2f}{r['full']:>7.2f}{r['hold']:>7.2f}"
|
||||||
|
f"{r['dd']*100:>6.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}")
|
||||||
|
print("\n TOP-5 per HOLD (solo trasparenza — selezionare qui = selection-on-holdout):")
|
||||||
|
print(hdr)
|
||||||
|
for r in sorted(rows, key=lambda r: -r["hold"])[:5]:
|
||||||
|
iss = f"{r['is_sh']:.2f}" if np.isfinite(r["is_sh"]) else "n/a"
|
||||||
|
print(f" {tag(r['cfg']):<26}{iss:>7}{r['full']:>7.2f}{r['hold']:>7.2f}"
|
||||||
|
f"{r['dd']*100:>6.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}")
|
||||||
|
|
||||||
|
if not valid:
|
||||||
|
print("\n >>> nessuna cella con in-sample valutabile. SCARTATO.")
|
||||||
|
return
|
||||||
|
|
||||||
|
pick = max(valid, key=lambda r: r["is_sh"])
|
||||||
|
daily = pick["daily"]
|
||||||
|
print("\n" + "=" * 104)
|
||||||
|
print(f" CELLA SCELTA (in-sample-only): {tag(pick['cfg'])}")
|
||||||
|
print("=" * 104)
|
||||||
|
print(f" IS Sh {pick['is_sh']:.2f} | FULL {pick['full']:.2f} | HOLD {pick['hold']:.2f}"
|
||||||
|
f" | DD {pick['dd']*100:.0f}% | ret {pick['ret']*100:+.0f}% | anni+ {pick['pct']*100:.0f}%"
|
||||||
|
f" | turnover/y {pick['turn']:.0f}")
|
||||||
|
print(f" corr vs XS01 {pick['corrXS']:+.2f} | corr vs TP01 {pick['corrTP']:+.2f}"
|
||||||
|
f" | per-anno {per_year(daily)}")
|
||||||
|
|
||||||
|
caus = causality_prefix_check(PX, VOL, pick["cfg"])
|
||||||
|
print(f" CAUSALITA' (prefix-check): ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}")
|
||||||
|
|
||||||
|
dsr, sr0 = xv.deflated_sharpe(pick["full"], all_sr, daily)
|
||||||
|
print(f" DEFLATED Sharpe (N={len(all_sr)} trial di QUESTA griglia): {dsr:.3f}"
|
||||||
|
f" | soglia null-max annualizz. {sr0:.2f} (serve >=0.95)")
|
||||||
|
|
||||||
|
print(" fee sweep (RT per gamba):", end=" ")
|
||||||
|
for fee in (0.0005, 0.001, 0.002, 0.003):
|
||||||
|
d_f, *_ = run_cell(PX, VOL, pick["cfg"], fee=fee)
|
||||||
|
ff = xv.metrics(d_f)
|
||||||
|
hh = xv.metrics(d_f[d_f.index >= HOLDOUT])
|
||||||
|
print(f"{fee*100:.2f}%: F{ff['sharpe']:+.2f}/H{hh['sharpe']:+.2f}", end=" ")
|
||||||
|
print()
|
||||||
|
|
||||||
|
sc = smallcap_check(PX, pick["W"], pick["scale"])
|
||||||
|
print(f" SMALL-CAP $600/min$5 (dichiarativo, resta STAT-MODE): modeled Sh {sc['sharpe_modeled']:.2f}"
|
||||||
|
f" -> realistic {sc['sharpe_realistic']:.2f} (haircut {sc['haircut']:+.2f});"
|
||||||
|
f" fill eseguiti {sc['n_executed']}/{sc['n_modeled']}"
|
||||||
|
f" turn/y {sc['turnover_real']:.0f} vs {sc['turnover_modeled']:.0f}")
|
||||||
|
|
||||||
|
# --- confronto PORTAFOGLIO -----------------------------------------------------------
|
||||||
|
print("\n PORTAFOGLIO (TP01+XS01+VRP01+SKH01, pesi canonici):")
|
||||||
|
_, (bf, bh) = _base()
|
||||||
|
print(f" BASE 4-sleeve FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.1f}%"
|
||||||
|
f" | HOLD Sh {bh['sharpe']:.2f} DD {bh['maxdd']*100:.1f}%")
|
||||||
|
sf, sh_ = substitute_xs01(daily)
|
||||||
|
sub_full_d, sub_hold_d = sf["sharpe"] - bf["sharpe"], sh_["sharpe"] - bh["sharpe"]
|
||||||
|
print(f" SOSTITUZIONE XS01 -> cand FULL Sh {sf['sharpe']:.2f} ({sub_full_d:+.2f}) DD {sf['maxdd']*100:.1f}%"
|
||||||
|
f" | HOLD Sh {sh_['sharpe']:.2f} ({sub_hold_d:+.2f}) DD {sh_['maxdd']*100:.1f}%")
|
||||||
|
up_best = (-9.0, -9.0)
|
||||||
|
for fr in (0.10, 0.15):
|
||||||
|
cf, ch, wgt = add_uplift(daily, fr)
|
||||||
|
d_f, d_h = cf["sharpe"] - bf["sharpe"], ch["sharpe"] - bh["sharpe"]
|
||||||
|
if d_h > up_best[1]:
|
||||||
|
up_best = (d_f, d_h)
|
||||||
|
print(f" AGGIUNTA 5o sleeve @{wgt*100:>4.1f}% FULL Sh {cf['sharpe']:.2f} ({d_f:+.2f}) DD {cf['maxdd']*100:.1f}%"
|
||||||
|
f" | HOLD Sh {ch['sharpe']:.2f} ({d_h:+.2f}) DD {ch['maxdd']*100:.1f}%")
|
||||||
|
mb = marginal_vs_book(daily)
|
||||||
|
print(f" MARGINAL vs BOOK: corr {mb['corr_book']:+.2f} | uplift@10% full {mb['uplift_full']:+.3f}"
|
||||||
|
f" hold {mb['uplift_hold']:+.3f} | multi-cut {mb['multicut']}")
|
||||||
|
|
||||||
|
# --- VERDETTO -------------------------------------------------------------------------
|
||||||
|
print("\n" + "=" * 104)
|
||||||
|
beats_xs_standalone = (pick["full"] > xs_f["sharpe"] and pick["hold"] > xs_h["sharpe"])
|
||||||
|
dominates = sub_full_d > 0.02 and sub_hold_d > 0.05 and beats_xs_standalone
|
||||||
|
diversifies = (abs(pick["corrXS"]) < 0.6 and abs(pick["corrTP"]) < 0.5
|
||||||
|
and up_best[1] > 0.05 and mb["uplift_hold"] is not None and mb["uplift_hold"] > 0
|
||||||
|
and all(u > 0 for u in mb["multicut"].values()))
|
||||||
|
dsr_ok = np.isfinite(dsr) and dsr >= 0.95
|
||||||
|
if not caus["ok"]:
|
||||||
|
verdict, why = "SCARTATO", "prefix-check di causalita' fallito"
|
||||||
|
elif pick["full"] <= 0.3 or pick["hold"] <= 0:
|
||||||
|
verdict, why = "SCARTATO", f"standalone debole (FULL {pick['full']:+.2f}, HOLD {pick['hold']:+.2f})"
|
||||||
|
elif abs(pick["corrXS"]) > 0.6 and not dominates:
|
||||||
|
verdict, why = "REDUNDANT", f"corrXS {pick['corrXS']:+.2f}>0.6 e non batte XS01 (sub HOLD {sub_hold_d:+.2f})"
|
||||||
|
elif dominates and dsr_ok:
|
||||||
|
verdict, why = "CANDIDATO-SLEEVE (sostituto)", "batte XS01 standalone E nel book, DSR>=0.95"
|
||||||
|
elif diversifies and dsr_ok:
|
||||||
|
verdict, why = "CANDIDATO-SLEEVE (5o)", "scorrelato, uplift book persistente, DSR>=0.95"
|
||||||
|
elif dominates or diversifies:
|
||||||
|
verdict, why = "LEAD-forward", f"profilo utile ma DSR {dsr:.2f}<0.95 (storia ~2.5a, multiple-testing)"
|
||||||
|
else:
|
||||||
|
verdict, why = "SCARTATO", (f"ne' sostituto (sub HOLD {sub_hold_d:+.2f}) ne' additivo"
|
||||||
|
f" (uplift HOLD {up_best[1]:+.2f}, corrXS {pick['corrXS']:+.2f})")
|
||||||
|
print(f" VERDETTO: {verdict} — {why}")
|
||||||
|
print(" CAVEAT immutabili: storia HL ~2.5 anni; in-sample = solo 2024 (selezione su finestra corta);")
|
||||||
|
print(" book L/S multi-gamba -> STAT-MODE a $600 (come XS01), mai deploy a questo capitale.")
|
||||||
|
print("=" * 104)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""r0701_xs_seasonal — STAGIONALITÀ CROSS-SECTIONAL sull'universo Hyperliquid (2026-07-01).
|
||||||
|
|
||||||
|
DOMANDA: esistono effetti calendario RELATIVI tra i 19 alt major HL (weekday tilt,
|
||||||
|
turn-of-month, pattern weekend->lunedì nel cross-section)? Essendo long/short
|
||||||
|
market-neutral (demeaned cross-section), il "buy&hold travestito" — che ha ucciso la
|
||||||
|
seasonality trackF su BTC/ETH — è strutturalmente escluso.
|
||||||
|
|
||||||
|
METODO (ordine obbligatorio, dal mandato):
|
||||||
|
1. TEST STATISTICO PRIMA DELLA STRATEGIA — persistenza split-half: per ogni giorno
|
||||||
|
della settimana, il tilt cross-sectional per-asset (media del ritorno relativo
|
||||||
|
demeaned in quel giorno, al netto del tilt incondizionato dell'asset) della PRIMA
|
||||||
|
metà del campione correla (Spearman rank) con quello della SECONDA metà?
|
||||||
|
Null: permutazione delle etichette-giorno (2000 draw) entro ciascuna metà →
|
||||||
|
distribuzione del max-su-7 rank-corr. Se il max reale non batte il 95° pctl del
|
||||||
|
null → SCARTATO senza backtest. Idem per weekend-bucket e turn-of-month.
|
||||||
|
La permutazione controlla automaticamente il confound "alpha persistente
|
||||||
|
dell'asset su entrambe le metà" (momentum), perché anche le etichette permutate
|
||||||
|
lo mostrerebbero.
|
||||||
|
2. (solo se persiste) strategia L/S market-neutral vol-target, fee 0.10% RT,
|
||||||
|
breakeven fee, selezione IN-SAMPLE (pre-2025), hold-out, deflated_sharpe.
|
||||||
|
3. day_boundary_robust OBBLIGATORIO per ogni effetto calendario — NB: i dati HL
|
||||||
|
locali sono SOLO 1d → il confine giorno NON è ri-tagliabile localmente sui 19
|
||||||
|
alt. Qualunque lead weekday su HL 1d resta NON-VERIFICABILE al boundary-shift
|
||||||
|
finché non esistono barre orarie HL: il verdetto massimo possibile qui è
|
||||||
|
LEAD-forward *condizionato*, mai sleeve. (Su BTC/ETH 1h il test esiste in
|
||||||
|
altlib, ma il cross-section a 2 asset non riproduce l'effetto a 19.)
|
||||||
|
|
||||||
|
DATI: data/raw/hl_*_1d.parquet — 19 major XS01, 913 giorni (2024-01-01 → 2026-07-01),
|
||||||
|
0 barre vol=0 (verificato). LIMITE DICHIARATO: ~2.5 anni → ~130 osservazioni per
|
||||||
|
weekday, ~65 per metà → alto rischio rumore; soglie severe (p<0.05 sul max-statistic
|
||||||
|
permutato, non per-weekday).
|
||||||
|
|
||||||
|
Esecuzione: cd /opt/docker/PythagorasGoal && uv run python scripts/research/r0701_xs_seasonal.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
|
||||||
|
sys.path.insert(0, str(_ROOT))
|
||||||
|
|
||||||
|
import altlib as al # noqa: E402 (HOLDOUT, deflated_sharpe — riuso convenzioni)
|
||||||
|
|
||||||
|
RNG = np.random.default_rng(20260701)
|
||||||
|
N_PERM = 2000
|
||||||
|
UNIVERSE = ["BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "AVAX", "LINK", "LTC", "ADA",
|
||||||
|
"ARB", "OP", "SUI", "APT", "INJ", "TIA", "SEI", "NEAR", "AAVE"]
|
||||||
|
FEE_SIDE = 0.0005 # 0.10% RT
|
||||||
|
HOLDOUT = al.HOLDOUT # 2025-01-01 UTC (convenzione di progetto)
|
||||||
|
WD_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- dati
|
||||||
|
def load_relative_returns():
|
||||||
|
"""Matrice (date × asset) dei ritorni GIORNALIERI RELATIVI (demeaned cross-section).
|
||||||
|
Esclude barre vol=0 (→ NaN). x[t,a] = r[t,a] − mean_a r[t,a] → market-neutral."""
|
||||||
|
cols = {}
|
||||||
|
for s in UNIVERSE:
|
||||||
|
d = pd.read_parquet(_ROOT / "data" / "raw" / f"hl_{s.lower()}_1d.parquet")
|
||||||
|
idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||||
|
c = pd.Series(d["close"].values.astype(float), index=idx)
|
||||||
|
c[d["volume"].values <= 0] = np.nan # guardrail backfill sintetico
|
||||||
|
cols[s] = c
|
||||||
|
C = pd.concat(cols, axis=1).sort_index()
|
||||||
|
R = C.pct_change()
|
||||||
|
R = R.iloc[1:] # prima riga NaN
|
||||||
|
X = R.sub(R.mean(axis=1), axis=0) # demean cross-section per data
|
||||||
|
return R, X
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- statistica
|
||||||
|
def _rank(v):
|
||||||
|
return pd.Series(v).rank().values
|
||||||
|
|
||||||
|
|
||||||
|
def spearman(a, b):
|
||||||
|
ra, rb = _rank(a), _rank(b)
|
||||||
|
if np.std(ra) == 0 or np.std(rb) == 0:
|
||||||
|
return 0.0
|
||||||
|
return float(np.corrcoef(ra, rb)[0, 1])
|
||||||
|
|
||||||
|
|
||||||
|
def bucket_tilts(X: pd.DataFrame, labels: np.ndarray, n_buckets: int) -> np.ndarray:
|
||||||
|
"""tilt[b, a] = media di x nei giorni con label==b, MENO il tilt incondizionato
|
||||||
|
dell'asset (isola l'effetto calendario dal drift relativo generico)."""
|
||||||
|
base = np.nanmean(X.values, axis=0)
|
||||||
|
out = np.full((n_buckets, X.shape[1]), np.nan)
|
||||||
|
for b in range(n_buckets):
|
||||||
|
m = labels == b
|
||||||
|
if m.sum() >= 10:
|
||||||
|
out[b] = np.nanmean(X.values[m], axis=0) - base
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def split_half_persistence(X: pd.DataFrame, labels: np.ndarray, n_buckets: int,
|
||||||
|
n_perm: int = N_PERM):
|
||||||
|
"""Rank-corr H1 vs H2 dei tilt per bucket + null permutando le etichette entro
|
||||||
|
ciascuna metà. Ritorna (rho per bucket, max reale, p-value del max, null 95° pctl)."""
|
||||||
|
half = len(X) // 2
|
||||||
|
X1, X2 = X.iloc[:half], X.iloc[half:]
|
||||||
|
l1, l2 = labels[:half], labels[half:]
|
||||||
|
|
||||||
|
def rhos(la, lb):
|
||||||
|
t1, t2 = bucket_tilts(X1, la, n_buckets), bucket_tilts(X2, lb, n_buckets)
|
||||||
|
return np.array([spearman(t1[b], t2[b])
|
||||||
|
if np.isfinite(t1[b]).all() and np.isfinite(t2[b]).all() else np.nan
|
||||||
|
for b in range(n_buckets)])
|
||||||
|
|
||||||
|
real = rhos(l1, l2)
|
||||||
|
real_max = float(np.nanmax(real))
|
||||||
|
null_max = np.empty(n_perm)
|
||||||
|
for i in range(n_perm):
|
||||||
|
null_max[i] = np.nanmax(rhos(RNG.permutation(l1), RNG.permutation(l2)))
|
||||||
|
pval = float(np.mean(null_max >= real_max))
|
||||||
|
return real, real_max, pval, float(np.percentile(null_max, 95))
|
||||||
|
|
||||||
|
|
||||||
|
def weekend_monday_ic(X: pd.DataFrame):
|
||||||
|
"""Pattern pre/post weekend: il ritorno relativo cumulato Sab+Dom predice
|
||||||
|
(cross-sectionalmente) il ritorno relativo del lunedì? IC = Spearman giornaliero;
|
||||||
|
riporta mean IC e t-stat su ciascuna metà (persistenza del segno)."""
|
||||||
|
wd = X.index.dayofweek.values
|
||||||
|
ics, dates = [], []
|
||||||
|
for i in np.where(wd == 0)[0]: # lunedì
|
||||||
|
if i < 2 or wd[i - 1] != 6 or wd[i - 2] != 5:
|
||||||
|
continue
|
||||||
|
wkend = np.nansum(X.values[i - 2:i], axis=0) # Sab+Dom relativo
|
||||||
|
mon = X.values[i]
|
||||||
|
ok = np.isfinite(wkend) & np.isfinite(mon)
|
||||||
|
if ok.sum() >= 10:
|
||||||
|
ics.append(spearman(wkend[ok], mon[ok])); dates.append(X.index[i])
|
||||||
|
s = pd.Series(ics, index=pd.DatetimeIndex(dates))
|
||||||
|
half = len(s) // 2
|
||||||
|
out = {}
|
||||||
|
for name, seg in (("H1", s.iloc[:half]), ("H2", s.iloc[half:]), ("FULL", s)):
|
||||||
|
t = float(seg.mean() / seg.std() * np.sqrt(len(seg))) if len(seg) > 3 and seg.std() > 0 else 0.0
|
||||||
|
out[name] = dict(mean_ic=round(float(seg.mean()), 4), t=round(t, 2), n=len(seg))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- strategia (solo se persiste)
|
||||||
|
def sharpe(r):
|
||||||
|
r = np.asarray(pd.Series(r).dropna().values, float)
|
||||||
|
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def weekday_ls(R: pd.DataFrame, X: pd.DataFrame, k: int = 3, est_win: int = 0,
|
||||||
|
min_obs: int = 20, target_vol: float = 0.20):
|
||||||
|
"""L/S market-neutral: al close di t (dati ≤ t) stima il tilt per-asset del weekday
|
||||||
|
di DOMANI (expanding se est_win=0, altrimenti rolling est_win gg) e va long top-k /
|
||||||
|
short bottom-k per il giorno t+1. Fee su |Δw|. Ritorna (net Series, gross Series,
|
||||||
|
turnover medio/anno, breakeven fee %RT)."""
|
||||||
|
wd = X.index.dayofweek.values
|
||||||
|
xv = X.values
|
||||||
|
n, A = xv.shape
|
||||||
|
W = np.zeros((n, A))
|
||||||
|
# tilt causale: per ogni weekday d, media (expanding o rolling) dei soli giorni con wd==d fino a t
|
||||||
|
sums = np.zeros((7, A)); cnts = np.zeros((7, A))
|
||||||
|
hist: list[list[np.ndarray]] = [[] for _ in range(7)] # per rolling
|
||||||
|
for t in range(n - 1):
|
||||||
|
row = np.nan_to_num(xv[t], nan=0.0)
|
||||||
|
fin = np.isfinite(xv[t]).astype(float)
|
||||||
|
d = wd[t]
|
||||||
|
sums[d] += row; cnts[d] += fin
|
||||||
|
if est_win > 0:
|
||||||
|
hist[d].append(np.where(fin > 0, row, np.nan))
|
||||||
|
if len(hist[d]) > est_win:
|
||||||
|
hist[d].pop(0)
|
||||||
|
dn = wd[t + 1] # weekday di domani: noto (calendario)
|
||||||
|
if est_win > 0:
|
||||||
|
hh = np.array(hist[dn]) if hist[dn] else np.empty((0, A))
|
||||||
|
cnt = np.isfinite(hh).sum(axis=0) if len(hh) else np.zeros(A)
|
||||||
|
tilt = np.where(cnt >= min_obs, np.nanmean(hh, axis=0) if len(hh) else 0.0, np.nan)
|
||||||
|
else:
|
||||||
|
tilt = np.where(cnts[dn] >= min_obs, sums[dn] / np.maximum(cnts[dn], 1), np.nan)
|
||||||
|
ok = np.isfinite(tilt)
|
||||||
|
if ok.sum() >= 2 * k:
|
||||||
|
order = np.argsort(np.where(ok, tilt, -np.inf))
|
||||||
|
w = np.zeros(A)
|
||||||
|
w[order[-k:]] = 0.5 / k # long tilt positivi
|
||||||
|
lo = np.argsort(np.where(ok, tilt, np.inf))[:k]
|
||||||
|
w[lo] = -0.5 / k # short tilt negativi
|
||||||
|
W[t] = w
|
||||||
|
dret = np.nan_to_num(R.values, nan=0.0)
|
||||||
|
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
|
||||||
|
turn = np.abs(np.diff(W, axis=0, prepend=np.zeros((1, A)))).sum(axis=1)
|
||||||
|
net = gross - FEE_SIDE * turn
|
||||||
|
g, tn = pd.Series(gross, index=X.index), pd.Series(net, index=X.index)
|
||||||
|
rv = tn.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
|
||||||
|
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||||
|
net_vt = pd.Series(tn.values * scale, index=X.index)
|
||||||
|
mean_turn = float(turn.mean())
|
||||||
|
be_rt = 2 * float(gross.mean() / mean_turn) * 100 if mean_turn > 0 else np.nan # %RT a Sharpe 0
|
||||||
|
turn_yr = round(mean_turn * 365.25, 1)
|
||||||
|
return net_vt, g, turn_yr, be_rt
|
||||||
|
|
||||||
|
|
||||||
|
def run_strategy_branch(R, X):
|
||||||
|
"""Selezione IN-SAMPLE (pre-2025) su griglia piccola, hold-out, DSR su tutti i trial."""
|
||||||
|
grid = [dict(k=k, est_win=w) for k in (3, 5) for w in (0, 180)]
|
||||||
|
rows, all_full = [], []
|
||||||
|
for g in grid:
|
||||||
|
net, _, turn_yr, be = weekday_ls(R, X, **g)
|
||||||
|
ins, hold = net[net.index < HOLDOUT], net[net.index >= HOLDOUT]
|
||||||
|
rows.append(dict(params=g, ins=round(sharpe(ins), 2), hold=round(sharpe(hold), 2),
|
||||||
|
full=round(sharpe(net), 2), turn_yr=turn_yr,
|
||||||
|
be_rt=round(be, 3) if np.isfinite(be) else None, net=net))
|
||||||
|
all_full.append(sharpe(net))
|
||||||
|
chosen = max(rows, key=lambda r: r["ins"]) # selezione SOLO in-sample
|
||||||
|
dsr, sr0 = al.deflated_sharpe(chosen["full"], all_full, chosen["net"].dropna().values)
|
||||||
|
return rows, chosen, dsr, sr0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- main
|
||||||
|
def main():
|
||||||
|
R, X = load_relative_returns()
|
||||||
|
print(f"Universo: {len(UNIVERSE)} major HL | {X.index[0].date()} -> {X.index[-1].date()} "
|
||||||
|
f"({len(X)} giorni, ~{len(X) / 365.25:.1f} anni) | barre vol=0 escluse: "
|
||||||
|
f"{int((~np.isfinite(X.values)).sum())} celle NaN")
|
||||||
|
print(f"LIMITE: ~{len(X) // 7 // 2} osservazioni per weekday per metà campione — "
|
||||||
|
f"soglia severa: max-statistic permutato, p<0.05\n")
|
||||||
|
|
||||||
|
wd = X.index.dayofweek.values
|
||||||
|
print("=" * 78)
|
||||||
|
print("STEP 1 — PERSISTENZA SPLIT-HALF (test statistico PRIMA della strategia)")
|
||||||
|
print("=" * 78)
|
||||||
|
|
||||||
|
# --- (a) weekday (7 bucket)
|
||||||
|
rho, mx, p, null95 = split_half_persistence(X, wd, 7)
|
||||||
|
print("\n[A] WEEKDAY TILT (tilt weekday-specifico, al netto del tilt incondizionato)")
|
||||||
|
for d in range(7):
|
||||||
|
print(f" {WD_NAMES[d]}: rank-corr H1 vs H2 = {rho[d]:+.3f}")
|
||||||
|
print(f" max reale = {mx:+.3f} | null 95° pctl (perm max-su-7) = {null95:+.3f} | p = {p:.3f}")
|
||||||
|
pass_wd = p < 0.05
|
||||||
|
|
||||||
|
# --- (b) weekend vs feriali (2 bucket)
|
||||||
|
wk_lab = (wd >= 5).astype(int)
|
||||||
|
rho_w, mx_w, p_w, null95_w = split_half_persistence(X, wk_lab, 2)
|
||||||
|
print(f"\n[B] WEEKEND-vs-FERIALI: rho weekday={rho_w[0]:+.3f} weekend={rho_w[1]:+.3f} "
|
||||||
|
f"| max={mx_w:+.3f} null95={null95_w:+.3f} p={p_w:.3f}")
|
||||||
|
pass_we = p_w < 0.05
|
||||||
|
|
||||||
|
# --- (c) turn-of-month (ultimi 2 + primi 2 gg del mese vs resto)
|
||||||
|
day = X.index.day.values
|
||||||
|
dim = X.index.days_in_month.values
|
||||||
|
tom_lab = ((day <= 2) | (day >= dim - 1)).astype(int)
|
||||||
|
rho_t, mx_t, p_t, null95_t = split_half_persistence(X, tom_lab, 2)
|
||||||
|
print(f"[C] TURN-OF-MONTH (±2gg): rho non-TOM={rho_t[0]:+.3f} TOM={rho_t[1]:+.3f} "
|
||||||
|
f"| max={mx_t:+.3f} null95={null95_t:+.3f} p={p_t:.3f}")
|
||||||
|
pass_tom = p_t < 0.05
|
||||||
|
|
||||||
|
# --- (d) pattern weekend->lunedì (IC cross-serial)
|
||||||
|
ic = weekend_monday_ic(X)
|
||||||
|
print(f"[D] WEEKEND->LUNEDÌ IC (Spearman x_weekend vs x_lunedì): "
|
||||||
|
f"H1 {ic['H1']['mean_ic']:+.3f} (t={ic['H1']['t']}) | "
|
||||||
|
f"H2 {ic['H2']['mean_ic']:+.3f} (t={ic['H2']['t']}) | "
|
||||||
|
f"FULL {ic['FULL']['mean_ic']:+.3f} (t={ic['FULL']['t']}, n={ic['FULL']['n']})")
|
||||||
|
pass_ic = (abs(ic["FULL"]["t"]) > 2.5
|
||||||
|
and np.sign(ic["H1"]["mean_ic"]) == np.sign(ic["H2"]["mean_ic"])
|
||||||
|
and abs(ic["H1"]["t"]) > 1.5 and abs(ic["H2"]["t"]) > 1.5)
|
||||||
|
print(f" persistenza segno + |t|>2.5 FULL + |t|>1.5 su entrambe le metà: {pass_ic}")
|
||||||
|
|
||||||
|
any_pass = pass_wd or pass_we or pass_tom or pass_ic
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
if not any_pass:
|
||||||
|
print("VERDETTO: SCARTATO — nessuna persistenza cross-sectional calendario.")
|
||||||
|
print("Nessun backtest eseguito (regola: test statistico prima della strategia).")
|
||||||
|
print("NB: il day_boundary_robust resta comunque NON eseguibile sui 19 alt (dati")
|
||||||
|
print("HL solo 1d) → anche un pass qui sarebbe stato al massimo LEAD condizionato.")
|
||||||
|
print("=" * 78)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ---------------- branch strategia (si arriva qui solo con persistenza reale)
|
||||||
|
print("STEP 2 — persistenza rilevata: strategia L/S weekday-tilt (selezione IN-SAMPLE)")
|
||||||
|
print("=" * 78)
|
||||||
|
rows, chosen, dsr, sr0 = run_strategy_branch(R, X)
|
||||||
|
for r in rows:
|
||||||
|
print(f" k={r['params']['k']} est={'exp' if r['params']['est_win'] == 0 else r['params']['est_win']}: "
|
||||||
|
f"IS {r['ins']:+.2f} | HOLD {r['hold']:+.2f} | FULL {r['full']:+.2f} | "
|
||||||
|
f"turn/yr {r['turn_yr']} | breakeven fee {r['be_rt']}%RT")
|
||||||
|
print(f"\n CELLA IN-SAMPLE: {chosen['params']} -> FULL {chosen['full']:+.2f} "
|
||||||
|
f"HOLD {chosen['hold']:+.2f} | DSR={dsr:.3f} (null max {sr0:.2f}) "
|
||||||
|
f"{'PASS' if dsr >= 0.95 else 'FAIL'}")
|
||||||
|
print("\n ⚠ day_boundary_robust NON eseguibile su HL 1d (serve orario) → qualunque")
|
||||||
|
print(" esito qui è al massimo LEAD-forward CONDIZIONATO, mai sleeve.")
|
||||||
|
print("=" * 78)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
"""r0702_alb_claims — Audit STATISTICO delle claim "strategia Albimarini" (video didattici).
|
||||||
|
|
||||||
|
CLAIM DICHIARATE (input, dal video):
|
||||||
|
- Struttura: double diagonal DEEP OTM su SPX/SPY (short strangle ~6DTE + long strangle
|
||||||
|
a scadenza successiva, strike piu' lontani), durata trade 3-6 giorni di calendario.
|
||||||
|
- Track record: n=28 trade, win-rate 82% (23W/5L), P&L medio +$113/trade,
|
||||||
|
perdite totali $765 (loss medio $153), profit factor 5.16, capitale ~$10k,
|
||||||
|
estrapolazione "420% annuo", sizing a compounding 1->2->3->4 contratti.
|
||||||
|
Derivati: totale +$3.164; gross win $3.929 (avg win $171); PF = 3929/765 = 5.14 ok.
|
||||||
|
|
||||||
|
OBIETTIVO: QUANTIFICARE la critica (win-rate strutturale, coda non campionata, PF non
|
||||||
|
significativo su n=28), non riformularla. 6 test, ognuno con numeri.
|
||||||
|
|
||||||
|
DATI: SOLO locali gia' certificati/presenti nel progetto:
|
||||||
|
- data/raw/eq_spy_1d.parquet (SPY daily 1996-2026, fetch IB del filone GTAA)
|
||||||
|
- BTC/ETH 1d via altlib (Deribit mainnet certificato)
|
||||||
|
Nessuna rete. Nessun file scritto fuori dallo scratchpad. Nessun DatetimeIndex.view.
|
||||||
|
|
||||||
|
Convenzioni oneste:
|
||||||
|
- 6 giorni di calendario ~= 4 trading day (h=4 primario; h=2..5 come robustezza).
|
||||||
|
- Le probabilita' empiriche usano finestre rolling OVERLAPPING (stima della prob.
|
||||||
|
per-finestra); i test di significativita' usano le 28 finestre NON sovrapposte
|
||||||
|
del track record e cicli non sovrapposti nel replay.
|
||||||
|
- Il replay del Test 3 e' CLAIM-ANCHORED: usa l'economia per-trade dichiarata dal
|
||||||
|
video (massimo beneficio al claim); l'unico parametro nostro e' la severita'
|
||||||
|
della coda, calibrata in due modi (cap BS della diagonale / EV=0).
|
||||||
|
- BS senza skew nei Test 4-5: sottostima il premio degli OTM (a favore del claim).
|
||||||
|
|
||||||
|
Uso: uv run python scripts/research/r0702_alb_claims.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from scipy.stats import norm, t as student_t
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "research" / "alt"))
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
|
||||||
|
RNG = np.random.default_rng(20260702)
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
# ---- claim dichiarate ------------------------------------------------------
|
||||||
|
N_TRADES = 28
|
||||||
|
WINRATE_CLAIM = 23 / 28 # 0.821
|
||||||
|
AVG_PNL = 113.0 # $/trade
|
||||||
|
TOT_LOSS = 765.0 # $ totali (5 loss -> avg $153)
|
||||||
|
AVG_WIN = (N_TRADES * AVG_PNL + TOT_LOSS) / 23 # $171
|
||||||
|
AVG_LOSS = TOT_LOSS / 5 # $153
|
||||||
|
PF_CLAIM = (23 * AVG_WIN) / TOT_LOSS # 5.14
|
||||||
|
CAPITAL = 10_000.0
|
||||||
|
H_TD = 4 # 6 giorni calendario ~ 4 trading day
|
||||||
|
|
||||||
|
OUT: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def p(msg=""):
|
||||||
|
print(msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# BS helpers (no dividendi/tassi — orizzonte 6-13 giorni)
|
||||||
|
# ===========================================================================
|
||||||
|
def bs_put(S, K, T, sigma):
|
||||||
|
T = max(T, 1e-9)
|
||||||
|
d1 = (np.log(S / K) + 0.5 * sigma**2 * T) / (sigma * np.sqrt(T))
|
||||||
|
d2 = d1 - sigma * np.sqrt(T)
|
||||||
|
return K * norm.cdf(-d2) - S * norm.cdf(-d1)
|
||||||
|
|
||||||
|
|
||||||
|
def bs_call(S, K, T, sigma):
|
||||||
|
T = max(T, 1e-9)
|
||||||
|
d1 = (np.log(S / K) + 0.5 * sigma**2 * T) / (sigma * np.sqrt(T))
|
||||||
|
d2 = d1 - sigma * np.sqrt(T)
|
||||||
|
return S * norm.cdf(d1) - K * norm.cdf(d2)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# DATA
|
||||||
|
# ===========================================================================
|
||||||
|
def load_spy() -> pd.DataFrame:
|
||||||
|
df = pd.read_parquet(ROOT / "data" / "raw" / "eq_spy_1d.parquet")
|
||||||
|
df = df.sort_values("timestamp").reset_index(drop=True)
|
||||||
|
df["datetime"] = pd.to_datetime(df["timestamp"].astype("int64"), unit="ms", utc=True)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def hmoves(close: np.ndarray, h: int) -> np.ndarray:
|
||||||
|
return close[h:] / close[:-h] - 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def cal_moves(df: pd.DataFrame, days: int = 6) -> np.ndarray:
|
||||||
|
"""Move su ESATTAMENTE `days` giorni di calendario (ultimo close <= t+days).
|
||||||
|
Epoca ms esplicita (mai .view su DatetimeIndex)."""
|
||||||
|
ts = df["timestamp"].to_numpy(dtype="int64") # ms
|
||||||
|
c = df["close"].to_numpy(dtype=float)
|
||||||
|
tgt = ts + days * 86_400_000
|
||||||
|
j = np.searchsorted(ts, tgt, side="right") - 1 # ultimo close <= t+days
|
||||||
|
ok = j > np.arange(len(ts))
|
||||||
|
return c[j[ok]] / c[ok] - 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# TEST 1 — WIN-RATE STRUTTURALE
|
||||||
|
# ===========================================================================
|
||||||
|
def test1():
|
||||||
|
p("=" * 88)
|
||||||
|
p("TEST 1 — WIN-RATE STRUTTURALE: P(move >9% in ~6 giorni) e strike impliciti dall'82%")
|
||||||
|
p("=" * 88)
|
||||||
|
spy = load_spy()
|
||||||
|
c, lo, hi = spy["close"].values, spy["low"].values, spy["high"].values
|
||||||
|
r1 = np.diff(np.log(c))
|
||||||
|
vol_ann = r1.std() * np.sqrt(252)
|
||||||
|
|
||||||
|
res = {}
|
||||||
|
for h in (2, 3, 4, 5):
|
||||||
|
m = hmoves(c, h)
|
||||||
|
res[h] = dict(p_abs9=float(np.mean(np.abs(m) > 0.09)),
|
||||||
|
p_dn9=float(np.mean(m < -0.09)),
|
||||||
|
sigma=float(m.std()),
|
||||||
|
p_2sig=float(np.mean(np.abs(m) > 2 * m.std())))
|
||||||
|
m6c = cal_moves(spy, 6)
|
||||||
|
m4 = hmoves(c, H_TD)
|
||||||
|
|
||||||
|
# touch (management intra-finestra): min low / max high nei prossimi h giorni
|
||||||
|
n = len(c) - H_TD
|
||||||
|
lo_w = np.array([lo[i + 1:i + 1 + H_TD].min() for i in range(n)])
|
||||||
|
hi_w = np.array([hi[i + 1:i + 1 + H_TD].max() for i in range(n)])
|
||||||
|
p_touch9 = float(np.mean((lo_w < c[:n] * 0.91) | (hi_w > c[:n] * 1.09)))
|
||||||
|
|
||||||
|
# analitico: normale a vol 16% e alla vol campione; t-Student fittata sui move 4td
|
||||||
|
sig6_16 = 0.16 * np.sqrt(6 / 365)
|
||||||
|
p_norm16 = 2 * (1 - norm.cdf(0.09 / sig6_16))
|
||||||
|
sig4 = res[4]["sigma"]
|
||||||
|
p_norm_emp = 2 * (1 - norm.cdf(0.09 / sig4))
|
||||||
|
tdf, tloc, tscale = student_t.fit(m4)
|
||||||
|
p_t = float(2 * student_t.sf((0.09 - tloc) / tscale, tdf))
|
||||||
|
|
||||||
|
# strike implicito dall'82% di win (quantile 82% di |move|) e win-rate a 9% OTM
|
||||||
|
q82_4 = float(np.quantile(np.abs(m4), WINRATE_CLAIM))
|
||||||
|
q82_6c = float(np.quantile(np.abs(m6c), WINRATE_CLAIM))
|
||||||
|
win_at_9 = 1 - res[4]["p_abs9"]
|
||||||
|
|
||||||
|
p(f"SPY 1996-2026 ({len(spy)} barre, vol ann {vol_ann:.1%})")
|
||||||
|
for h in (2, 3, 4, 5):
|
||||||
|
r = res[h]
|
||||||
|
p(f" h={h}td: P(|m|>9%)={r['p_abs9']:.4%} P(m<-9%)={r['p_dn9']:.4%} "
|
||||||
|
f"sigma={r['sigma']:.2%} P(|m|>2sig)={r['p_2sig']:.2%}")
|
||||||
|
p(f" 6 giorni CALENDARIO esatti: P(|m|>9%)={np.mean(np.abs(m6c) > 0.09):.4%} "
|
||||||
|
f"sigma={m6c.std():.2%}")
|
||||||
|
p(f" TOUCH intra-finestra (h=4td, strike a +-9%): P={p_touch9:.4%}")
|
||||||
|
p(f" Analitico: normale vol16% -> {p_norm16:.2e} | normale vol camp. -> {p_norm_emp:.2e} | "
|
||||||
|
f"t-Student fit (df={tdf:.2f}) -> {p_t:.4%} | empirico {res[4]['p_abs9']:.4%}")
|
||||||
|
p(f" -> fat-tail factor empirico/normale = {res[4]['p_abs9'] / p_norm_emp:,.0f}x")
|
||||||
|
p()
|
||||||
|
p(f" INCONSISTENZA QUANTIFICATA delle claim:")
|
||||||
|
p(f" (a) se gli strike sono DAVVERO ~9% OTM: win-rate strutturale a scadenza = "
|
||||||
|
f"{win_at_9:.2%} (>=99%), non 82% -> le 5 perdite non sono breach, sono gestione;")
|
||||||
|
p(f" (b) se il win-rate 82% e' letterale (breach a scadenza): gli strike distano "
|
||||||
|
f"q82(|m4|) = {q82_4:.2%} (6gg cal: {q82_6c:.2%}) ~= 1.2 sigma, NON 'deep OTM'.")
|
||||||
|
p(f" In entrambi i casi il win-rate e' il quantile della distribuzione (il delta "
|
||||||
|
f"venduto), non skill: qualunque venditore agli stessi strike ottiene lo stesso numero.")
|
||||||
|
|
||||||
|
# BTC/ETH dai NOSTRI dati certificati
|
||||||
|
crypto = {}
|
||||||
|
for a in ("BTC", "ETH"):
|
||||||
|
d = al.get(a, "1d")
|
||||||
|
cc = d["close"].values
|
||||||
|
m6 = hmoves(cc, 6)
|
||||||
|
crypto[a] = dict(p_abs9=float(np.mean(np.abs(m6) > 0.09)),
|
||||||
|
sigma=float(m6.std()),
|
||||||
|
p_2sig=float(np.mean(np.abs(m6) > 2 * m6.std())),
|
||||||
|
q82=float(np.quantile(np.abs(m6), WINRATE_CLAIM)),
|
||||||
|
span=f"{d['datetime'].iloc[0].date()}->{d['datetime'].iloc[-1].date()}",
|
||||||
|
last=float(cc[-1]))
|
||||||
|
p(f" {a} (certificato, {crypto[a]['span']}): P(|m6d|>9%)={crypto[a]['p_abs9']:.1%} "
|
||||||
|
f"sigma6d={crypto[a]['sigma']:.1%} P(>2sig)={crypto[a]['p_2sig']:.1%} "
|
||||||
|
f"strike per win82% = {crypto[a]['q82']:.1%} OTM")
|
||||||
|
p(f" -> la stessa struttura trasposta su crypto: strike a 9% OTM = win-rate "
|
||||||
|
f"{1 - crypto['BTC']['p_abs9']:.0%} (BTC) / {1 - crypto['ETH']['p_abs9']:.0%} (ETH), "
|
||||||
|
f"cioe' il 9% che su SPY e' 'deep' su crypto e' ~1 sigma.")
|
||||||
|
|
||||||
|
OUT["test1"] = dict(spy=res, spy_6cal_p9=float(np.mean(np.abs(m6c) > 0.09)),
|
||||||
|
p_touch9=p_touch9, p_norm16=float(p_norm16),
|
||||||
|
p_norm_emp=float(p_norm_emp), p_t=p_t, t_df=float(tdf),
|
||||||
|
q82_4td=q82_4, q82_6cal=q82_6c, win_at_9otm=float(win_at_9),
|
||||||
|
crypto=crypto)
|
||||||
|
return spy, m4, q82_4
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# TEST 2 — SIGNIFICATIVITA' DEL PF 5.16 SU n=28 (null EV=0)
|
||||||
|
# ===========================================================================
|
||||||
|
def test2(spy: pd.DataFrame, q82: float):
|
||||||
|
p("\n" + "=" * 88)
|
||||||
|
p("TEST 2 — PF 5.16 su n=28: distribuzione sotto il null 'vendita premio a EV=0 netto'")
|
||||||
|
p("=" * 88)
|
||||||
|
c = spy["close"].values
|
||||||
|
m4 = hmoves(c, H_TD)
|
||||||
|
NSIM = 200_000
|
||||||
|
W = 126 # 6 mesi di trading day
|
||||||
|
|
||||||
|
def branch(name: str, thr: float) -> dict:
|
||||||
|
"""Null EV=0: win +$171 (p=0.821), small loss -$153, tail loss -L con
|
||||||
|
p_tail = P(|m4|>thr) empirica; L calibrata perche' EV=0 netto."""
|
||||||
|
p_loss = 1 - WINRATE_CLAIM
|
||||||
|
p_tail = float(np.mean(np.abs(m4) > thr))
|
||||||
|
p_small = max(p_loss - p_tail, 0.0)
|
||||||
|
L_tail = (WINRATE_CLAIM * AVG_WIN - p_small * AVG_LOSS) / p_tail
|
||||||
|
wins = RNG.normal(AVG_WIN, 60, (NSIM, N_TRADES)).clip(20, None)
|
||||||
|
smalls = RNG.normal(AVG_LOSS, 60, (NSIM, N_TRADES)).clip(30, None)
|
||||||
|
u = RNG.random((NSIM, N_TRADES))
|
||||||
|
is_win = u < min(WINRATE_CLAIM, 1 - p_tail)
|
||||||
|
is_tail = u > 1 - p_tail
|
||||||
|
pnl = np.where(is_win, wins, np.where(is_tail, -L_tail, -smalls))
|
||||||
|
gp = np.where(pnl > 0, pnl, 0).sum(1)
|
||||||
|
gl = -np.where(pnl < 0, pnl, 0).sum(1)
|
||||||
|
pf = gp / np.maximum(gl, 1e-9)
|
||||||
|
wr = is_win.mean(1)
|
||||||
|
p_joint = float(np.mean((pf >= PF_CLAIM) & (wr >= WINRATE_CLAIM - 1e-9)))
|
||||||
|
p_notail = float(np.mean(~is_tail.any(1)))
|
||||||
|
tail_day = np.abs(m4) > thr
|
||||||
|
frac_clean = float(np.mean([not tail_day[i:i + W].any()
|
||||||
|
for i in range(0, len(tail_day) - W)]))
|
||||||
|
r = dict(name=name, thr=thr, p_tail=p_tail, p_small=p_small,
|
||||||
|
L_tail=float(L_tail), ev_check=float(pnl.mean()),
|
||||||
|
pf_median=float(np.median(pf)),
|
||||||
|
p_pf_ge=float(np.mean(pf >= PF_CLAIM)), p_joint=p_joint,
|
||||||
|
p_no_tail_28=p_notail,
|
||||||
|
p_no_tail_analytic=float((1 - p_tail) ** N_TRADES),
|
||||||
|
frac_6m_clean=frac_clean)
|
||||||
|
p(f" Branch {name}:")
|
||||||
|
p(f" tail = |m4| > {thr:.2%}: P(tail/trade)={p_tail:.2%} -> L_tail per EV=0 = "
|
||||||
|
f"${L_tail:,.0f} ({L_tail / CAPITAL:.0%} del capitale per unita' di size); "
|
||||||
|
f"EV MC {pnl.mean():+.1f}$/trade")
|
||||||
|
p(f" PF su 28 trade: mediana {r['pf_median']:.2f} | P(PF>=5.16)={r['p_pf_ge']:.1%} | "
|
||||||
|
f"P(PF>=5.16 E win>=82%)={p_joint:.1%}")
|
||||||
|
p(f" P(zero tail in 28 trade)={p_notail:.1%} (analitico {r['p_no_tail_analytic']:.1%}); "
|
||||||
|
f"quota storica finestre 6 mesi SENZA tail = {frac_clean:.1%}")
|
||||||
|
return r
|
||||||
|
|
||||||
|
p(f"Null 'venditore di premio a EV=0 netto' (tanti +$171, -$153 gestite, code rare):")
|
||||||
|
b_obs = branch(f"OBS (strike {q82:.2%} OTM, breach oltre il long +2%)", q82 + 0.02)
|
||||||
|
b_deep = branch("DEEP (strike 9% OTM come dichiarato)", 0.09)
|
||||||
|
p(f" -> Il track record dichiarato (PF 5.16, win 82%, zero code) e' un esito da "
|
||||||
|
f"P={b_obs['p_joint']:.0%} (lettura OBS) a P={b_deep['p_joint']:.0%} (lettura DEEP) "
|
||||||
|
f"sotto ZERO skill: tra 'comune' e 'esito mediano'.")
|
||||||
|
p(f" Il PF senza coda campionata misura solo 23/5 * avg_win/avg_smallloss = "
|
||||||
|
f"{(23 * AVG_WIN) / (5 * AVG_LOSS):.2f}: e' un rapporto STRUTTURALE, non una statistica "
|
||||||
|
f"di edge (n=28 non tocca mai la coda che paga tutto).")
|
||||||
|
|
||||||
|
OUT["test2"] = dict(obs=b_obs, deep=b_deep)
|
||||||
|
return b_obs["L_tail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# TEST 3 — IL "420% ANNUO" ATTRAVERSO UNA CODA REALE (replay claim-anchored)
|
||||||
|
# ===========================================================================
|
||||||
|
def _replay(dates, moves, q82: float, width: float, L_tail: float,
|
||||||
|
win_amt: float, small_amt: float,
|
||||||
|
sizing: str = "video", start: float = CAPITAL, label: str = ""):
|
||||||
|
"""Replay CLAIM-ANCHORED sulla sequenza REALE di move (cicli non sovrapposti).
|
||||||
|
PER CONTRATTO:
|
||||||
|
|m| <= q82 -> +win_amt (win, calibrato sui numeri del video)
|
||||||
|
q82 < |m| <= q82+w -> -small_amt (perdita gestita, come le 5 del video)
|
||||||
|
|m| > q82+w -> -L_tail (breach oltre il long: coda)
|
||||||
|
sizing: 'video' = +1 contratto ogni +$1000 di profitto (1->2->3->4 come
|
||||||
|
dichiarato), cap margine equity/$2500; 'prop' = leva costante iniziale
|
||||||
|
(n = equity//10k); 'fixed' = 1 contratto. Ruin se equity <= 0."""
|
||||||
|
eq = start
|
||||||
|
path, pnl_hist, npos = [], [], []
|
||||||
|
ruined = None
|
||||||
|
for i, m in enumerate(moves):
|
||||||
|
if abs(m) <= q82:
|
||||||
|
out = win_amt
|
||||||
|
elif abs(m) <= q82 + width:
|
||||||
|
out = -small_amt
|
||||||
|
else:
|
||||||
|
out = -L_tail
|
||||||
|
if sizing == "video":
|
||||||
|
n = int(np.clip(1 + max(eq - start, 0) // 1000, 1, max(1, eq // 2500)))
|
||||||
|
elif sizing == "prop":
|
||||||
|
n = int(max(1, eq // 10_000))
|
||||||
|
else:
|
||||||
|
n = 1
|
||||||
|
pnl = n * out
|
||||||
|
eq += pnl
|
||||||
|
pnl_hist.append(pnl)
|
||||||
|
npos.append(n)
|
||||||
|
path.append(eq)
|
||||||
|
if eq <= 0:
|
||||||
|
ruined = dates[i]
|
||||||
|
break
|
||||||
|
path = np.asarray(path, dtype=float)
|
||||||
|
peak = np.maximum.accumulate(np.maximum(path, 1e-9))
|
||||||
|
maxdd = float((path / peak - 1.0).min()) if len(path) else 0.0
|
||||||
|
last_i = len(path) - 1
|
||||||
|
yrs = max(1e-9, (pd.Timestamp(dates[last_i]) - pd.Timestamp(dates[0])).days / 365.25)
|
||||||
|
cagr = float((max(path[-1], 1e-9) / start) ** (1 / yrs) - 1) if len(path) else 0.0
|
||||||
|
wi = int(np.argmin(pnl_hist)) if pnl_hist else 0
|
||||||
|
return dict(label=label, final=float(path[-1]) if len(path) else start,
|
||||||
|
cagr=cagr, maxdd=maxdd,
|
||||||
|
ruined=str(pd.Timestamp(ruined).date()) if ruined is not None else None,
|
||||||
|
worst_cycle=float(min(pnl_hist)) if pnl_hist else 0.0,
|
||||||
|
worst_date=str(pd.Timestamp(dates[wi]).date()) if pnl_hist else None,
|
||||||
|
n_at_worst=int(npos[wi]) if npos else 0,
|
||||||
|
n_cycles=len(pnl_hist),
|
||||||
|
n_tails=int(np.sum(np.abs(np.asarray(moves[:len(pnl_hist)])) > q82 + width)),
|
||||||
|
avg_pnl_cycle=float(np.mean(pnl_hist)) if pnl_hist else 0.0,
|
||||||
|
winrate=float(np.mean(np.asarray(pnl_hist) > 0)) if pnl_hist else 0.0,
|
||||||
|
years=float(yrs))
|
||||||
|
|
||||||
|
|
||||||
|
def _cycles(df: pd.DataFrame, h: int):
|
||||||
|
c = df["close"].values
|
||||||
|
idx = np.arange(0, len(c) - h, h)
|
||||||
|
moves = c[idx + h] / c[idx] - 1.0
|
||||||
|
dates = df["datetime"].values[idx + h]
|
||||||
|
return dates, moves
|
||||||
|
|
||||||
|
|
||||||
|
def _calib_win_per_contract() -> float:
|
||||||
|
"""Calibra il win PER CONTRATTO cosi' che 28 cicli TUTTI win con il sizing
|
||||||
|
video (+1 contratto/$1000, 1->2->3->4) riproducano il totale dichiarato
|
||||||
|
(+$3.164): i +$171/+$113 del video sono medie a livello CONTO su 1-4
|
||||||
|
contratti, non per contratto."""
|
||||||
|
lo, hi = 10.0, 171.0
|
||||||
|
for _ in range(60):
|
||||||
|
w = 0.5 * (lo + hi)
|
||||||
|
eq, tot = CAPITAL, 0.0
|
||||||
|
for _k in range(N_TRADES):
|
||||||
|
n = int(np.clip(1 + max(eq - CAPITAL, 0) // 1000, 1, max(1, eq // 2500)))
|
||||||
|
eq += n * w
|
||||||
|
tot += n * w
|
||||||
|
if tot > N_TRADES * AVG_PNL:
|
||||||
|
hi = w
|
||||||
|
else:
|
||||||
|
lo = w
|
||||||
|
return round(0.5 * (lo + hi), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test3(spy: pd.DataFrame, q82: float):
|
||||||
|
p("\n" + "=" * 88)
|
||||||
|
p("TEST 3 — '420% ANNUO': la stessa strategia attraverso le code reali, con compounding")
|
||||||
|
p("=" * 88)
|
||||||
|
# Replay CLAIM-ANCHORED: economia PER CONTRATTO calibrata sui numeri del video,
|
||||||
|
# guidata dalla sequenza REALE dei move ~6gg. Unico parametro nostro: la
|
||||||
|
# severita' della coda per contratto, boxata da due letture convergenti:
|
||||||
|
# cap fisico = width 2% x notional $60k = $1.200 (cap della diagonale, Test 4)
|
||||||
|
# EV=0 = la severita' che rende il gioco fair alle p empiriche
|
||||||
|
w_c = _calib_win_per_contract()
|
||||||
|
s_c = round(w_c * AVG_LOSS / AVG_WIN, 1)
|
||||||
|
m4 = hmoves(spy["close"].values, H_TD)
|
||||||
|
p_tail = float(np.mean(np.abs(m4) > q82 + 0.02))
|
||||||
|
p_small = max(1 - WINRATE_CLAIM - p_tail, 0.0)
|
||||||
|
L_ev0 = round((WINRATE_CLAIM * w_c - p_small * s_c) / p_tail, 0)
|
||||||
|
L_bs = 1200.0
|
||||||
|
p(f"Replay claim-anchored (cicli 4td non sovrapposti, strike q82={q82:.2%}, long +2%):")
|
||||||
|
p(f"PER CONTRATTO: win +${w_c} (|m|<=q82), gestita -${s_c} (<=q82+2%), coda -$L")
|
||||||
|
p(f"(|m|>q82+2%). Calibrazione: 28 cicli senza coda col sizing video 1->4 = +$3.164")
|
||||||
|
p(f"(riproduce il video PER COSTRUZIONE: win 82%, PF 5.1). Severita' coda boxata:")
|
||||||
|
p(f"cap fisico width2%x$60k = ${L_bs:,.0f} | EV=0 = ${L_ev0:,.0f} (convergenti).")
|
||||||
|
p(f"Sizing 'video' = +1 contratto/$1000 profitto (cap eq/$2500); 'prop' = eq//10k.")
|
||||||
|
windows = [("FULL 1996-2026", None, None),
|
||||||
|
("2019-2026 (era book)", "2019-01-01", None),
|
||||||
|
("H2-2023 (finestra tipo video)", "2023-07-01", "2023-12-31"),
|
||||||
|
("2020 (COVID)", "2020-01-01", "2020-12-31"),
|
||||||
|
("2022 (bear)", "2022-01-01", "2022-12-31"),
|
||||||
|
("2008 (GFC)", "2008-01-01", "2008-12-31")]
|
||||||
|
rows = []
|
||||||
|
for sizing, L, tag in (("video", L_bs, "video, cap fisico $1.200"),
|
||||||
|
("video", L_ev0, f"video, EV=0 ${L_ev0:,.0f}"),
|
||||||
|
("prop", L_bs, "leva costante, cap fisico")):
|
||||||
|
p(f"\n -- sizing {tag} --")
|
||||||
|
for label, a, b in windows:
|
||||||
|
d = spy
|
||||||
|
if a:
|
||||||
|
d = d[d["datetime"] >= pd.Timestamp(a, tz="UTC")]
|
||||||
|
if b:
|
||||||
|
d = d[d["datetime"] <= pd.Timestamp(b, tz="UTC")]
|
||||||
|
dates, moves = _cycles(d.reset_index(drop=True), H_TD)
|
||||||
|
out = _replay(dates, moves, q82, 0.02, L, w_c, s_c, sizing=sizing,
|
||||||
|
label=f"{label} [{tag}]")
|
||||||
|
rows.append(out)
|
||||||
|
ann = f"{out['cagr']:+.0%}" if not out["ruined"] else "RUIN"
|
||||||
|
p(f" {label:30s} finale ${out['final']:>8,.0f} CAGR {ann:>7s} "
|
||||||
|
f"maxDD {out['maxdd']:>5.0%} win {out['winrate']:.0%} "
|
||||||
|
f"code {out['n_tails']:>2d} worst ${out['worst_cycle']:>7,.0f} "
|
||||||
|
f"({out['worst_date']}, {out['n_at_worst']}c)"
|
||||||
|
+ (f" ** ROVINA {out['ruined']} **" if out["ruined"] else ""))
|
||||||
|
# controllo: size FISSA 1 contratto, e scenario 'premio generoso' (+20% sui win)
|
||||||
|
dates, moves = _cycles(spy, H_TD)
|
||||||
|
fx = _replay(dates, moves, q82, 0.02, L_ev0, w_c, s_c, sizing="fixed",
|
||||||
|
label="FULL fixed-1c EV=0")
|
||||||
|
gen_fx = _replay(dates, moves, q82, 0.02, L_ev0, w_c * 1.2, s_c, sizing="fixed",
|
||||||
|
label="FULL fixed-1c premio+20%")
|
||||||
|
gen = _replay(dates, moves, q82, 0.02, L_ev0, w_c * 1.2, s_c, sizing="video",
|
||||||
|
label="FULL video premio+20%")
|
||||||
|
p(f"\n Controlli (FULL 1996-2026, coda EV=0):")
|
||||||
|
p(f" - size FISSA 1 contratto: finale ${fx['final']:>8,.0f} CAGR {fx['cagr']:+.1%} "
|
||||||
|
f"maxDD {fx['maxdd']:.0%}" + (f" ROVINA {fx['ruined']}" if fx['ruined'] else ""))
|
||||||
|
p(f" - fixed-1c, premio +20%: finale ${gen_fx['final']:>8,.0f} CAGR "
|
||||||
|
f"{gen_fx['cagr']:+.1%} maxDD {gen_fx['maxdd']:.0%}"
|
||||||
|
+ (f" ROVINA {gen_fx['ruined']}" if gen_fx['ruined'] else ""))
|
||||||
|
p(f" - sizing video, premio +20%: finale ${gen['final']:>8,.0f} CAGR "
|
||||||
|
f"{gen['cagr']:+.1%} maxDD {gen['maxdd']:.0%}"
|
||||||
|
+ (f" ROVINA {gen['ruined']}" if gen['ruined'] else ""))
|
||||||
|
p(f" -> anche regalando un VRP persistente del +20%, il CAGR onesto multi-anno e'")
|
||||||
|
p(f" a una cifra, NON 420%; e il sizing del video lo converte comunque in rovina")
|
||||||
|
p(f" (scala dopo i win -> la coda colpisce sempre vicino alla size massima).")
|
||||||
|
rows += [fx, gen_fx, gen]
|
||||||
|
|
||||||
|
# BTC/ETH dai NOSTRI dati: stesso 82% strutturale (q82 proprio), width vol-scalata,
|
||||||
|
# severita' EV=0 ricalibrata sulla p_tail dell'asset.
|
||||||
|
p()
|
||||||
|
for aname in ("BTC", "ETH"):
|
||||||
|
d = al.get(aname, "1d")
|
||||||
|
m6 = hmoves(d["close"].values, 6)
|
||||||
|
q82a = float(np.quantile(np.abs(m6), WINRATE_CLAIM))
|
||||||
|
wa = float(0.9 * m6.std())
|
||||||
|
p_tail_a = float(np.mean(np.abs(m6) > q82a + wa))
|
||||||
|
La = round((WINRATE_CLAIM * w_c
|
||||||
|
- max(1 - WINRATE_CLAIM - p_tail_a, 0) * s_c) / p_tail_a, 0)
|
||||||
|
dates_a, moves_a = _cycles(d, 6)
|
||||||
|
out = _replay(dates_a, moves_a, q82a, wa, La, w_c, s_c, sizing="video",
|
||||||
|
label=f"{aname} strike {q82a:.0%} width {wa:.0%} EV=0")
|
||||||
|
rows.append(out)
|
||||||
|
ann = f"{out['cagr']:+.0%}" if not out["ruined"] else "RUIN"
|
||||||
|
p(f" {out['label']:38s} P(coda)={p_tail_a:.1%} L=${La:,.0f} -> finale "
|
||||||
|
f"${out['final']:>8,.0f} CAGR {ann:>6s} maxDD {out['maxdd']:>5.0%} "
|
||||||
|
f"code {out['n_tails']}"
|
||||||
|
+ (f" ** ROVINA {out['ruined']} **" if out["ruined"] else ""))
|
||||||
|
p()
|
||||||
|
p(" -> Il '420%' e' l'annualizzazione della finestra senza coda con il sizing gia'")
|
||||||
|
p(" scalato. Sulla storia intera, con la STESSA economia dichiarata dal video e")
|
||||||
|
p(" una coda fair, il compounding del video incontra la coda alla size massima.")
|
||||||
|
OUT["test3"] = dict(win_per_contract=w_c, small_per_contract=s_c,
|
||||||
|
L_bs=L_bs, L_ev0=L_ev0, p_tail=p_tail, rows=rows)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# TEST 4 — QUANTO COPRE LA DIAGONALE IL GIORNO DEL CRASH
|
||||||
|
# ===========================================================================
|
||||||
|
def test4(q82: float):
|
||||||
|
p("\n" + "=" * 88)
|
||||||
|
p("TEST 4 — DIAGONALE COME PROTEZIONE: loss_con_diagonale / loss_naked (BS, gap+vol spike)")
|
||||||
|
p("=" * 88)
|
||||||
|
S0 = 100.0
|
||||||
|
iv0 = 0.16
|
||||||
|
|
||||||
|
def scenario(d_short, width, gap, iv1):
|
||||||
|
"""Ritorna (loss_naked, loss_diag, ratio) in frazione di S0 (positivi=perdita)."""
|
||||||
|
Kp_s = S0 * (1 - d_short)
|
||||||
|
Kp_l = S0 * (1 - d_short - width)
|
||||||
|
v_s0 = bs_put(S0, Kp_s, 6 / 365, iv0)
|
||||||
|
v_l0 = bs_put(S0, Kp_l, 13 / 365, iv0)
|
||||||
|
S1 = S0 * (1 + gap)
|
||||||
|
v_s1 = bs_put(S1, Kp_s, 5 / 365, iv1)
|
||||||
|
v_l1 = bs_put(S1, Kp_l, 12 / 365, iv1)
|
||||||
|
loss_naked = float(v_s1 - v_s0) # mark contro lo short
|
||||||
|
loss_diag = float((v_s1 - v_s0) - (v_l1 - v_l0)) # long compensa
|
||||||
|
ratio = loss_diag / loss_naked if loss_naked > 1e-9 else np.nan
|
||||||
|
return loss_naked, loss_diag, ratio
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
p(f"Struttura put-side branch OBS: short K={100 * (1 - q82):.1f} T=6g, long T=13g piu' OTM.")
|
||||||
|
p(f"Scenario: gap overnight (resta 1g di vita in meno), IV 16% -> IV_crash. Perdite % notional.")
|
||||||
|
p(f" {'gap':>5s} {'IV->':>5s} {'width':>6s} | {'naked':>7s} {'diag':>7s} {'ratio':>6s} "
|
||||||
|
f"{'equity hit @6x':>14s}")
|
||||||
|
for width in (0.02, 0.03):
|
||||||
|
for gap in (-0.05, -0.10, -0.15):
|
||||||
|
for iv1 in (0.16, 0.40, 0.50, 0.70):
|
||||||
|
ln, ld, ratio = scenario(q82, width, gap, iv1)
|
||||||
|
rows.append(dict(d=q82, width=width, gap=gap, iv1=iv1,
|
||||||
|
naked=ln, diag=ld, ratio=ratio))
|
||||||
|
p(f" {gap:>5.0%} {iv1:>5.0%} {width:>6.0%} | {ln / S0:>7.2%} "
|
||||||
|
f"{ld / S0:>7.2%} {ratio:>6.0%} {6 * ld / S0:>13.1%}")
|
||||||
|
# branch DEEP (9% OTM come dichiarato), width 3%
|
||||||
|
deep = []
|
||||||
|
for gap in (-0.10, -0.15):
|
||||||
|
for iv1 in (0.40, 0.50, 0.70):
|
||||||
|
ln, ld, ratio = scenario(0.09, 0.03, gap, iv1)
|
||||||
|
deep.append(dict(d=0.09, width=0.03, gap=gap, iv1=iv1,
|
||||||
|
naked=ln, diag=ld, ratio=ratio))
|
||||||
|
p()
|
||||||
|
p(" Branch DEEP (short 9% OTM, long 12% OTM, width 3%):")
|
||||||
|
for r in deep:
|
||||||
|
p(f" {r['gap']:>5.0%} IV->{r['iv1']:.0%} | naked {r['naked'] / S0:>6.2%} "
|
||||||
|
f"diag {r['diag'] / S0:>6.2%} ratio {r['ratio']:>4.0%} "
|
||||||
|
f"equity hit @6x {6 * r['diag'] / S0:>6.1%}")
|
||||||
|
r10 = [r for r in rows if r["gap"] == -0.10 and r["iv1"] == 0.50]
|
||||||
|
r10_novol = [r for r in rows if r["gap"] == -0.10 and r["iv1"] == 0.16
|
||||||
|
and r["width"] == 0.02][0]
|
||||||
|
p()
|
||||||
|
p(f" -> La diagonale NON e' un hedge pieno e NON e' gratis: a gap -10%/IV 50 lascia")
|
||||||
|
p(f" passare il {r10[0]['ratio']:.0%} (width 2%) / {r10[1]['ratio']:.0%} (width 3%) "
|
||||||
|
f"della perdita naked; SENZA vol spike (IV ferma) il {r10_novol['ratio']:.0%}.")
|
||||||
|
p(f" A 6x notional un -10% costa {6 * r10[0]['diag'] / S0:.0%}-"
|
||||||
|
f"{6 * r10_novol['diag'] / S0:.0%} di equity PER UNITA' (x4 unita' post-scaling = "
|
||||||
|
f"{24 * r10[0]['diag'] / S0:.0%}-{24 * r10_novol['diag'] / S0:.0%}).")
|
||||||
|
p(f" La protezione dipende dal vol-spike che gonfia il long (vega del T+7g): e'")
|
||||||
|
p(f" un cap alla ROVINA, non alla perdita — e il costo del cap (long ricomprato")
|
||||||
|
p(f" ogni ciclo) e' il motivo per cui l'EV netto resta ~0 (Test 2/3).")
|
||||||
|
OUT["test4"] = dict(obs=rows, deep=deep)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# TEST 5 — ESEGUIBILITA' PER NOI (Deribit BTC/ETH, $600)
|
||||||
|
# ===========================================================================
|
||||||
|
def test5():
|
||||||
|
p("\n" + "=" * 88)
|
||||||
|
p("TEST 5 — ESEGUIBILITA' su Deribit BTC/ETH al NOSTRO capitale ($600)")
|
||||||
|
p("=" * 88)
|
||||||
|
res = {}
|
||||||
|
for a, minsz in (("BTC", 0.1), ("ETH", 0.1)):
|
||||||
|
d = al.get(a, "1d")
|
||||||
|
S = float(d["close"].values[-1])
|
||||||
|
m6 = hmoves(d["close"].values, 6)
|
||||||
|
q82a = float(np.quantile(np.abs(m6), WINRATE_CLAIM))
|
||||||
|
leg_notional = minsz * S
|
||||||
|
sig = 0.55 if a == "BTC" else 0.70
|
||||||
|
w = float(0.9 * m6.std()) # width vol-scalata (come il 2% su SPY)
|
||||||
|
short0 = bs_put(S, S * (1 - q82a), 6 / 365, sig) + bs_call(S, S * (1 + q82a), 6 / 365, sig)
|
||||||
|
long0 = (bs_put(S, S * (1 - q82a - w), 13 / 365, sig)
|
||||||
|
+ bs_call(S, S * (1 + q82a + w), 13 / 365, sig))
|
||||||
|
long7 = (bs_put(S, S * (1 - q82a - w), 7 / 365, sig)
|
||||||
|
+ bs_call(S, S * (1 + q82a + w), 7 / 365, sig))
|
||||||
|
# raccolto theta atteso per ciclo a mercato FERMO (spread 5%/gamba)
|
||||||
|
harvest = (0.95 * short0 - 1.05 * long0 + 0.95 * long7) / S * leg_notional
|
||||||
|
# fee Deribit opzioni: min(0.0003*S, 12.5% premio) per gamba; 8 esecuzioni/ciclo
|
||||||
|
avg_leg = (short0 / 2 + long0 / 2) / 2 / S * leg_notional
|
||||||
|
fee = 8 * min(0.0003 * S * minsz, 0.125 * max(avg_leg, 1e-9))
|
||||||
|
# margine short (standard margin, senza netting sulle diagonali):
|
||||||
|
# ~max(0.15-OTM, 0.10)*S*size + mark, x2 gambe short
|
||||||
|
margin = 2 * (max(0.15 - q82a, 0.10) * S * minsz + short0 / 2 * minsz)
|
||||||
|
# tail netta: full-breach del cap (gap = -(q82+w), IV x1.5), lato put
|
||||||
|
gap_full = -(q82a + w)
|
||||||
|
Sc = S * (1 + gap_full)
|
||||||
|
vs = bs_put(Sc, S * (1 - q82a), 1 / 365, 1.5 * sig)
|
||||||
|
vl = bs_put(Sc, S * (1 - q82a - w), 8 / 365, 1.5 * sig)
|
||||||
|
vs0 = bs_put(S, S * (1 - q82a), 6 / 365, sig)
|
||||||
|
vl0 = bs_put(S, S * (1 - q82a - w), 13 / 365, sig)
|
||||||
|
tail = float(((vs - vs0) - (vl - vl0)) / S * leg_notional)
|
||||||
|
net_cycle = harvest - fee
|
||||||
|
fee_pct = f"{fee / harvest:.0%} del raccolto" if harvest > 0 else "raccolto <=0!"
|
||||||
|
res[a] = dict(S=S, q82=q82a, width=w, leg_notional=leg_notional,
|
||||||
|
harvest_cycle=float(harvest), fee_rt=float(fee),
|
||||||
|
net_cycle=float(net_cycle), margin=float(margin), tail_net=tail,
|
||||||
|
gap_full=float(gap_full))
|
||||||
|
p(f" {a} (close {S:,.0f}, strike {q82a:.0%} OTM per win 82%, width {w:.0%}): "
|
||||||
|
f"min {minsz}/gamba -> {leg_notional:,.0f}$/gamba, 4 gambe = "
|
||||||
|
f"{4 * leg_notional:,.0f}$ notional")
|
||||||
|
p(f" raccolto theta/ciclo (mercato fermo, netto spread 5%) ~${harvest:.2f}, "
|
||||||
|
f"fee Deribit 8 gambe ~${fee:.2f} ({fee_pct}) -> NETTO ${net_cycle:+.2f}/ciclo;")
|
||||||
|
p(f" margine short ~${margin:,.0f}; tail netta (full-breach {gap_full:.0%} 6d, "
|
||||||
|
f"IV x1.5) ~${tail:,.0f}")
|
||||||
|
p()
|
||||||
|
b, e = res["BTC"], res["ETH"]
|
||||||
|
p(f" Fee strutturale: le fee Deribit sono PROPORZIONALI alla size -> il rapporto")
|
||||||
|
p(f" fee/raccolto ({b['fee_rt'] / max(b['harvest_cycle'], 1e-9):.0%} BTC, "
|
||||||
|
f"{e['fee_rt'] / max(e['harvest_cycle'], 1e-9):.0%} ETH) NON migliora scalando:")
|
||||||
|
p(f" a questi strike la DOUBLE DIAGONAL e' fee-negativa su Deribit A QUALSIASI size")
|
||||||
|
p(f" (netto {b['net_cycle']:+.2f}$/ciclo BTC, {e['net_cycle']:+.2f}$/ciclo ETH per "
|
||||||
|
f"unita' minima). Il capitale sposta il muro di margine, non questo.")
|
||||||
|
p()
|
||||||
|
# ---- soglie di capitale: $600 oggi, 2k/3.5k/5k prospettici --------------
|
||||||
|
# Granularita' sensata = >=3 posizioni concorrenti + scalabile 1->2 contratti
|
||||||
|
# (>=6 slot di margine), budget margine 25% equity, rischio/posizione <=5%.
|
||||||
|
# ETH CREDIT SPREAD: numeri del filone gemello ALB-A (margine ~$107/spread =
|
||||||
|
# max-loss defined-risk, credito ~$2/spread) + fee 2 gambe.
|
||||||
|
sp_margin, sp_credit = 107.0, 2.0
|
||||||
|
sp_fee = 2 * min(0.0003 * e["S"] * 0.1, 0.125 * sp_credit) # entry 2 gambe, 0.1 ETH
|
||||||
|
p(f" SOGLIE DI CAPITALE (budget margine 25%, rischio/pos <=5%, granularita' sensata")
|
||||||
|
p(f" = 3 posizioni concorrenti scalabili 1->2 = 6 slot):")
|
||||||
|
p(f" {'conto':>7s} | {'DD BTC (m.$' + format(b['margin'], ',.0f') + ')':>22s} | "
|
||||||
|
f"{'DD ETH min (m.$' + format(e['margin'], ',.0f') + ')':>22s} | "
|
||||||
|
f"{'spread ETH (m.$107)':>20s}")
|
||||||
|
tiers = {}
|
||||||
|
for cap in (600.0, 2000.0, 3500.0, 5000.0):
|
||||||
|
mbud = 0.25 * cap
|
||||||
|
n_btc = int(mbud // b["margin"])
|
||||||
|
n_eth = int(mbud // e["margin"])
|
||||||
|
n_sp = int(mbud // sp_margin)
|
||||||
|
risk_ok_sp = sp_margin <= 0.05 * cap # max-loss spread <= 5% equity
|
||||||
|
risk_ok_eth = e["tail_net"] <= 0.05 * cap
|
||||||
|
tiers[int(cap)] = dict(margin_budget=mbud, n_btc=n_btc, n_eth_dd=n_eth,
|
||||||
|
n_spread=n_sp, spread_risk_ok=bool(risk_ok_sp),
|
||||||
|
eth_dd_risk_ok=bool(risk_ok_eth),
|
||||||
|
spread_pct_per_pos=sp_margin / cap,
|
||||||
|
spread_income_yr=n_sp * (sp_credit - sp_fee) * 52)
|
||||||
|
p(f" {cap:>7,.0f} | {n_btc:>4d} unita' ({b['margin'] / cap:>4.0%}/u) | "
|
||||||
|
f"{n_eth:>4d} unita' ({e['margin'] / cap:>4.0%}/u) | "
|
||||||
|
f"{n_sp:>3d} spread ({sp_margin / cap:>4.0%}/u, risk5% "
|
||||||
|
f"{'OK' if risk_ok_sp else 'NO'})")
|
||||||
|
p()
|
||||||
|
p(f" Lettura (con upgrade conto 2-5k):")
|
||||||
|
p(f" - DOUBLE DIAGONAL BTC: margine ${b['margin']:,.0f}/unita' -> 1a unita' dentro il")
|
||||||
|
p(f" budget 25% solo da ~${4 * b['margin']:,.0f}; 'granularita' sensata' (6 slot) da "
|
||||||
|
f"~${24 * b['margin'] / 1000:,.0f}k. Nemmeno 5k basta: resta il muro gamma-scalp")
|
||||||
|
p(f" (diario 2026-06-26: 'opzione BTC min $5.968 >> $600'), che si sposta a ~10-30k.")
|
||||||
|
p(f" - DOUBLE DIAGONAL ETH min 0.1: gia' a 2k il margine non e' il vincolo "
|
||||||
|
f"({int(0.25 * 2000 / e['margin'])} unita' nel budget), ma resta FEE-NEGATIVA "
|
||||||
|
f"({e['net_cycle']:+.2f}$/ciclo): eseguibile != conveniente, a ogni soglia.")
|
||||||
|
p(f" - CREDIT SPREAD ETH (ALB-A): il PRIMO oggetto con soglia reale. A $600: 1 spread")
|
||||||
|
p(f" = {sp_margin / 600:.0%} del conto (max-loss {sp_margin / 600:.0%} > 5% -> NO). "
|
||||||
|
f"A 2k: {int(0.25 * 2000 // sp_margin)} spread nel budget, max-loss {sp_margin / 2000:.1%}")
|
||||||
|
p(f" ~ok ma niente scalata 1->2 su 3 posizioni; a 3.5k: {int(0.25 * 3500 // sp_margin)} slot "
|
||||||
|
f"(3 pos x2 non ancora); a 5k: {int(0.25 * 5000 // sp_margin)} slot -> granularita' OK")
|
||||||
|
p(f" (3 concorrenti scalabili 1->2, {6 * sp_margin / 5000:.0%} del conto impegnato).")
|
||||||
|
p(f" SOGLIA ~${int(np.ceil(6 * sp_margin / 0.25 / 100) * 100):,} per il set completo; "
|
||||||
|
f"~$2.1k per 5 slot senza scalata. Income lordo a 5k: "
|
||||||
|
f"{tiers[5000]['spread_income_yr']:.0f}$/anno ({tiers[5000]['spread_income_yr'] / 5000:.1%}) "
|
||||||
|
f"PRIMA delle code (Test 2-3: EV~0).")
|
||||||
|
p(f" - Confronto scala video: 10k su SPX = 1 contratto SPY (~$60k notional, 6x leva).")
|
||||||
|
p(f" L'equivalente Deribit a parita' di prudenza (tail 5%) e' ~${20 * e['tail_net']:,.0f}+")
|
||||||
|
p(f" su ETH e ~${20 * b['tail_net']:,.0f}+ su BTC: anche a 5k il conto e' SOTTO la")
|
||||||
|
p(f" scala minima del gioco del video su BTC, e ci sta su ETH solo in versione")
|
||||||
|
p(f" credit-spread (che e' VRP01, non 'Albimarini').")
|
||||||
|
OUT["test5"] = dict(assets=res, tiers=tiers,
|
||||||
|
spread=dict(margin=sp_margin, credit=sp_credit, fee=float(sp_fee)))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# TEST 6 — CONFRONTO CON VRP01 (perche' un theta-harvest sopravvive)
|
||||||
|
# ===========================================================================
|
||||||
|
def test6():
|
||||||
|
p("\n" + "=" * 88)
|
||||||
|
p("TEST 6 — Che cosa distingue un theta-harvest che sopravvive (VRP01) da uno che no")
|
||||||
|
p("=" * 88)
|
||||||
|
p(" VRP01 (audit superato, diario 2026-06-20): (1) DEFINED-RISK come struttura, non")
|
||||||
|
p(" come accessorio: il long wing cappa worst-week -16.6% -> -7.4% e DD 33% -> 21%;")
|
||||||
|
p(" (2) GATE di regime IV-rank>0.30 (vende solo vol RICCA): ribalta l'HOLD-OUT da")
|
||||||
|
p(" -0.25 a +0.28 — l'alpha e' il filtro, non il theta; (3) SIZING: sleeve al 12%")
|
||||||
|
p(" del book, niente compounding del numero di contratti; positivo/flat anche 2022.")
|
||||||
|
p(" La 'Albimarini' e' l'opposto quantificato: vende SEMPRE (nessun gate: incassa il")
|
||||||
|
p(" premio anche quando IV<RV), il rischio e' cappato solo dalla width della diagonale")
|
||||||
|
p(" (a 6x notional un gap -10% = ~-5/-12% equity PER UNITA', x4 post-scaling, Test 4),")
|
||||||
|
p(" e il sizing SCALA sui profitti proprio mentre la coda si avvicina (Test 3).")
|
||||||
|
p(" Win-rate 82% e PF 5.16 su 28 trade sono la FIRMA STRUTTURALE del venditore di")
|
||||||
|
p(" quantile a EV=0 in una finestra senza code (P 20-45%, Test 2), non un edge.")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
def main():
|
||||||
|
spy, m4, q82 = test1()
|
||||||
|
test2(spy, q82)
|
||||||
|
test3(spy, q82)
|
||||||
|
test4(q82)
|
||||||
|
test5()
|
||||||
|
test6()
|
||||||
|
p("\n" + "=" * 88)
|
||||||
|
p("JSON")
|
||||||
|
p("=" * 88)
|
||||||
|
p(json.dumps(OUT, indent=1, default=float))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"""R0702 ALB-STRUCTURE — double diagonal deep-OTM "Albimarini" vs struttura VRP01.
|
||||||
|
|
||||||
|
FILONE: video didattici claimano su SPY una vendita sistematica di DOUBLE DIAGONAL a credito
|
||||||
|
(2 short scadenza T a ~9% di distanza su ~6 giorni + 2 long scadenza T+1g, entrambi i lati) con
|
||||||
|
82% win, PF 5.16, "420% annuo". Qui la STRUTTURA (non il gate — lezione 2026-07-01: il gate
|
||||||
|
IV-rank>0.30 canonico NON si riottimizza) viene portata su BTC/ETH Deribit (che ha scadenze
|
||||||
|
giornaliere) e modellata onestamente sul nostro stack DVOL, contro il VRP01 canonico
|
||||||
|
(put credit spread settimanale -0.28/-0.10, gate vrp>0 + ivr>=0.30 + crash-skip 0.90).
|
||||||
|
|
||||||
|
⚠️ CAVEAT SKEW — IN TESTA, NON IN FONDO: il pricing e' Black-Scholes FLAT sulla DVOL (IV ATM 30g).
|
||||||
|
Il deep-OTM (qui 1.5-3.0 sigma ~ 8-25% di distanza) e' ESATTAMENTE dove il flat-vol sbaglia di piu':
|
||||||
|
su crypto lo smile e' ripido su ENTRAMBE le ali (su equity solo put). Il premio reale delle ali e'
|
||||||
|
probabilmente > modello (f>1) per le put e variabile per le call; la calibrazione reale che abbiamo
|
||||||
|
(f~1.0) e' ATM-ish delta -0.28 su finestra calma, NON copre il deep-OTM. Quindi OGNI numero va letto
|
||||||
|
come BANDA sul fattore premio f in {0.6, 0.8, 1.0, 1.3} + uno scenario skew asimmetrico
|
||||||
|
(f_put=1.3 / f_call=0.7), MAI come stima puntuale. In piu' la DVOL e' IV a 30g usata per tenor 3-6g
|
||||||
|
(term structure ignorata; snapshot vol_term 2026-06: iv_7d vs iv_30d entro ~±3pt in calma, ma in
|
||||||
|
stress il front-end esplode -> il modello SOTTOSTIMA il mark-to-market avverso in crash).
|
||||||
|
|
||||||
|
Distanza in unita' di VOL, non il 9% fisso di SPY: 9%/6g su SPY (IV~16%) = ~3.5-4 sigma; BTC si
|
||||||
|
muove del 9% in 6 giorni spesso. Cella centrale dichiarata A PRIORI: z=2.0 sigma, ali dz=+1.0 sigma,
|
||||||
|
tenor 5g (centro del range 3-6g del video). z=3.0 in griglia = cella "fedele al video".
|
||||||
|
|
||||||
|
Riusa: options_vrp_lab (bs_put, load_series, per_year), options_vrp_v2 (vrp_spread_weekly = VRP01,
|
||||||
|
_ivrank, _rv30), altlib.marginal_vs_tp01. Fee Deribit opzioni per gamba: 0.03% del notional cap
|
||||||
|
12.5% del premio (+ delivery 0.015% cap 12.5% sull'ITM a scadenza; il diagonale paga anche la fee
|
||||||
|
di USCITA per vendere le long residue a T). NON deploy: regola standing "niente short-vol da
|
||||||
|
modello in deploy" — l'esito massimo e' conoscenza sulla struttura.
|
||||||
|
|
||||||
|
uv run python scripts/research/r0702_alb_structure.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt"))
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from scipy.stats import norm
|
||||||
|
from scripts.research.options_vrp_lab import bs_put, load_series, per_year
|
||||||
|
from scripts.research.options_vrp_v2 import vrp_spread_weekly, _ivrank, _rv30
|
||||||
|
|
||||||
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||||
|
DAY = 1.0 / 365.25
|
||||||
|
# cella centrale DICHIARATA A PRIORI (prima di guardare qualsiasi risultato)
|
||||||
|
CENTRAL = dict(z=2.0, dz=1.0, tenor_d=5)
|
||||||
|
F_SWEEP = (0.6, 0.8, 1.0, 1.3) # banda skew simmetrica
|
||||||
|
F_SKEW = dict(f_put=1.3, f_call=0.7) # scenario skew crypto-shaped (put ricche, call povere)
|
||||||
|
|
||||||
|
|
||||||
|
def bs_call(S, K, T, sig):
|
||||||
|
if T <= 0 or sig <= 0:
|
||||||
|
return max(S - K, 0.0)
|
||||||
|
d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T))
|
||||||
|
return S * norm.cdf(d1) - K * norm.cdf(d1 - sig * np.sqrt(T)) # r=0
|
||||||
|
|
||||||
|
|
||||||
|
def _fee_frac(prem_frac, notional_ratio=1.0, rate=0.0003):
|
||||||
|
"""Fee Deribit per gamba come frazione di S0: rate*notional cap 12.5% del premio."""
|
||||||
|
return min(rate * notional_ratio, 0.125 * max(prem_frac, 0.0))
|
||||||
|
|
||||||
|
|
||||||
|
def run_structure(asset, kind, z=2.0, dz=1.0, tenor_d=5, f_put=1.0, f_call=1.0,
|
||||||
|
gated=False, collect=None):
|
||||||
|
"""Vendita sistematica non-overlapping della struttura, cadenza = tenor_d.
|
||||||
|
|
||||||
|
kind: 'diag' = double diagonal Albimarini (short T entrambi i lati, long T+1g piu' OTM)
|
||||||
|
'condor' = iron condor STESSA scadenza (controllo del claim '+1 giorno')
|
||||||
|
'vert' = vertical put credit spread deep-OTM (solo lato put, stessa scadenza)
|
||||||
|
Strike: K = S0*exp(±z·σ√T) (z in sigma dell'orizzonte SHORT); ali a z+dz.
|
||||||
|
gated=True -> gate CANONICO VRP01 (vrp>0 AND ivr>=0.30 AND ivr<=0.90), NON riottimizzato.
|
||||||
|
Ritorna Series di rendimenti per-periodo su capitale = S0 (spot a entry), indice = scadenza.
|
||||||
|
collect (dict) accumula diagnostica per la decomposizione diag-vs-condor."""
|
||||||
|
J = load_series(asset)
|
||||||
|
px = J["px"].values; dv = J["dvol"].values / 100.0; idx = J.index
|
||||||
|
n = len(px); T = tenor_d / 365.25
|
||||||
|
T_long = T + DAY if kind == "diag" else T
|
||||||
|
has_call = kind in ("diag", "condor")
|
||||||
|
rets = {}
|
||||||
|
i = 60
|
||||||
|
while i + tenor_d < n:
|
||||||
|
S0 = px[i]; sig = dv[i]
|
||||||
|
if gated:
|
||||||
|
rv = _rv30(px, i); ivr = _ivrank(dv, i)
|
||||||
|
skip = ((not np.isnan(rv) and (sig - rv) <= 0)
|
||||||
|
or (not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90)))
|
||||||
|
if skip:
|
||||||
|
rets[idx[i + tenor_d]] = 0.0
|
||||||
|
i += tenor_d
|
||||||
|
continue
|
||||||
|
m = sig * np.sqrt(T)
|
||||||
|
Kp_s = S0 * np.exp(-z * m); Kp_l = S0 * np.exp(-(z + dz) * m)
|
||||||
|
Kc_s = S0 * np.exp(+z * m); Kc_l = S0 * np.exp(+(z + dz) * m)
|
||||||
|
# premi a entry (frazione di S0), f per lato
|
||||||
|
ps = bs_put(S0, Kp_s, T, sig) / S0 * f_put # short put
|
||||||
|
pl = bs_put(S0, Kp_l, T_long, sig) / S0 * f_put # long put (T o T+1g)
|
||||||
|
legs = [ps, pl]
|
||||||
|
cs = cl = 0.0
|
||||||
|
if has_call:
|
||||||
|
cs = bs_call(S0, Kc_s, T, sig) / S0 * f_call
|
||||||
|
cl = bs_call(S0, Kc_l, T_long, sig) / S0 * f_call
|
||||||
|
legs += [cs, cl]
|
||||||
|
credit = (ps + cs) - (pl + cl)
|
||||||
|
# exit a scadenza degli short
|
||||||
|
j = i + tenor_d
|
||||||
|
S1 = px[j]; sig1 = dv[j]
|
||||||
|
short_pay = max(0.0, Kp_s - S1) / S0 + (max(0.0, S1 - Kc_s) / S0 if has_call else 0.0)
|
||||||
|
if kind == "diag": # long con 1 giorno residuo: mark BS alla DVOL di uscita (vega!)
|
||||||
|
lp = bs_put(S1, Kp_l, DAY, sig1) / S0 * f_put
|
||||||
|
lc = bs_call(S1, Kc_l, DAY, sig1) / S0 * f_call
|
||||||
|
long_val = lp + lc
|
||||||
|
exit_fee = sum(_fee_frac(v, notional_ratio=S1 / S0) for v in (lp, lc))
|
||||||
|
else: # stessa scadenza: valore = solo intrinseco
|
||||||
|
long_val = max(0.0, Kp_l - S1) / S0 + (max(0.0, S1 - Kc_l) / S0 if has_call else 0.0)
|
||||||
|
exit_fee = 0.0
|
||||||
|
entry_fee = sum(_fee_frac(v) for v in legs)
|
||||||
|
# delivery fee su gambe short ITM a scadenza (0.015% cap 12.5%)
|
||||||
|
deliv = _fee_frac(max(0.0, Kp_s - S1) / S0, rate=0.00015)
|
||||||
|
if has_call:
|
||||||
|
deliv += _fee_frac(max(0.0, S1 - Kc_s) / S0, rate=0.00015)
|
||||||
|
pnl = credit - short_pay + long_val - entry_fee - exit_fee - deliv
|
||||||
|
rets[idx[j]] = pnl
|
||||||
|
if collect is not None:
|
||||||
|
# costo extra del T+1 a entry e valore residuo recuperato a exit (vs intrinseco)
|
||||||
|
pl_T = bs_put(S0, Kp_l, T, sig) / S0 * f_put
|
||||||
|
cl_T = (bs_call(S0, Kc_l, T, sig) / S0 * f_call) if has_call else 0.0
|
||||||
|
intr = max(0.0, Kp_l - S1) / S0 + (max(0.0, S1 - Kc_l) / S0 if has_call else 0.0)
|
||||||
|
collect.setdefault("extra_cost", []).append((pl + cl) - (pl_T + cl_T))
|
||||||
|
collect.setdefault("recovered", []).append((long_val - intr) if kind == "diag" else 0.0)
|
||||||
|
collect.setdefault("short_pay", []).append(short_pay)
|
||||||
|
collect.setdefault("credit", []).append(credit)
|
||||||
|
i += tenor_d
|
||||||
|
return pd.Series(rets)
|
||||||
|
|
||||||
|
|
||||||
|
def book(kind, **kw):
|
||||||
|
rB = run_structure("BTC", kind, **kw); rE = run_structure("ETH", kind, **kw)
|
||||||
|
return pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def metrics(r, tenor_d):
|
||||||
|
"""Metriche su rendimenti per-periodo (cadenza tenor_d). win/PF solo sui periodi ATTIVI."""
|
||||||
|
r = r.dropna()
|
||||||
|
ppy = 365.25 / tenor_d
|
||||||
|
if len(r) < 3 or r.std() == 0:
|
||||||
|
return dict(sh=0.0, sh_h=0.0, cagr=0.0, dd=0.0, worst=0.0, win=0.0, pf=0.0, act=0.0)
|
||||||
|
def _sh(x):
|
||||||
|
return float(x.mean() / x.std() * np.sqrt(ppy)) if len(x) > 2 and x.std() > 0 else 0.0
|
||||||
|
eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq)
|
||||||
|
yrs = len(r) / ppy
|
||||||
|
act = r[r != 0.0]
|
||||||
|
pos = act[act > 0].sum(); neg = -act[act < 0].sum()
|
||||||
|
return dict(
|
||||||
|
sh=_sh(r), sh_h=_sh(r[r.index >= HOLDOUT]),
|
||||||
|
cagr=float(eq[-1] ** (1 / yrs) - 1) if yrs > 0 and eq[-1] > 0 else -1.0,
|
||||||
|
dd=float(np.max((pk - eq) / pk)), worst=float(r.min()),
|
||||||
|
win=float((act > 0).mean()) if len(act) else 0.0,
|
||||||
|
pf=float(pos / neg) if neg > 0 else float("inf"),
|
||||||
|
act=float((r != 0.0).mean()), n_act=int(len(act)))
|
||||||
|
|
||||||
|
|
||||||
|
def row(label, r, tenor_d):
|
||||||
|
mm = metrics(r, tenor_d)
|
||||||
|
pf = f"{mm['pf']:5.2f}" if np.isfinite(mm["pf"]) else " inf"
|
||||||
|
print(f" {label:<40} {mm['sh']:>6.2f} {mm['sh_h']:>6.2f} {mm['cagr']*100:>+6.1f}% "
|
||||||
|
f"{mm['dd']*100:>5.1f}% {mm['worst']*100:>+6.2f}% {pf} {mm['win']*100:>4.0f}% {mm['act']*100:>4.0f}%")
|
||||||
|
return mm
|
||||||
|
|
||||||
|
|
||||||
|
def to_daily_lumped(wk):
|
||||||
|
"""Rendimenti per-periodo -> griglia giornaliera con lump alla scadenza (convenzione
|
||||||
|
_vrp_combo_returns: preserva lo Sharpe annualizzato, niente smoothing)."""
|
||||||
|
wk = wk.sort_index()
|
||||||
|
days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC")
|
||||||
|
daily = pd.Series(0.0, index=days)
|
||||||
|
daily.loc[wk.index.normalize()] = wk.values
|
||||||
|
return daily
|
||||||
|
|
||||||
|
|
||||||
|
HDR = f" {'struttura':<40} {'ShF':>6} {'ShH':>6} {'CAGR':>7} {'maxDD':>6} {'worst':>7} {'PF':>5} {'win%':>5} {'att%':>5}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 110)
|
||||||
|
print(" R0702 ALB-STRUCTURE — double diagonal deep-OTM (Albimarini) vs vertical vs condor vs VRP01")
|
||||||
|
print(" Capitale = SPOT a entry (S0) per le strutture nuove; VRP01 canonico = strike corto (sua convenzione).")
|
||||||
|
print(" ⚠️ SKEW: pricing BS FLAT su DVOL-30g; il deep-OTM e' banda-f, non stima puntuale. Term structure ignorata.")
|
||||||
|
print("=" * 110)
|
||||||
|
|
||||||
|
z, dz, tn = CENTRAL["z"], CENTRAL["dz"], CENTRAL["tenor_d"]
|
||||||
|
for a in ("BTC", "ETH"):
|
||||||
|
J = load_series(a)
|
||||||
|
sig = J["dvol"].mean() / 100.0
|
||||||
|
d5 = sig * np.sqrt(tn / 365.25)
|
||||||
|
print(f" {a}: DVOL media {sig*100:.0f}% -> 1σ su {tn}g = {d5*100:.1f}% | z=2.0 = {2*d5*100:.1f}% "
|
||||||
|
f"| z=3.0 = {3*d5*100:.1f}% (il '9% su SPY/6g' ≈ 3.5-4σ equity)")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (1) VRP01 canonico
|
||||||
|
print(f"\n (1) VRP01 CANONICO (riproduzione options_vrp_v2 COMBO: spread -0.28/-0.10 7g, vrp>0+ivr30+cs90)")
|
||||||
|
print(HDR)
|
||||||
|
vrp = {}
|
||||||
|
for f in F_SWEEP:
|
||||||
|
vrp[f] = pd.concat({"B": vrp_spread_weekly("BTC", defined_risk=True, f=f, gate_vrp=True,
|
||||||
|
gate_ivr=0.30, crash_skip=0.90),
|
||||||
|
"E": vrp_spread_weekly("ETH", defined_risk=True, f=f, gate_vrp=True,
|
||||||
|
gate_ivr=0.30, crash_skip=0.90)},
|
||||||
|
axis=1, join="inner").mean(axis=1)
|
||||||
|
row(f"VRP01 gated f={f}", vrp[f], 7)
|
||||||
|
vrp_nog = pd.concat({"B": vrp_spread_weekly("BTC", defined_risk=True, f=1.0),
|
||||||
|
"E": vrp_spread_weekly("ETH", defined_risk=True, f=1.0)},
|
||||||
|
axis=1, join="inner").mean(axis=1)
|
||||||
|
row("VRP01 NO-gate f=1.0", vrp_nog, 7)
|
||||||
|
|
||||||
|
# bridge di validazione del motore: vertical -0.28-equivalente (z~0.58, dz~0.70, 7g) ~ VRP01
|
||||||
|
zb = float(-norm.ppf(0.28)); dzb = float(-norm.ppf(0.10)) - zb
|
||||||
|
br = book("vert", z=zb, dz=dzb, tenor_d=7, f_put=1.0, f_call=1.0, gated=True)
|
||||||
|
row(f"[bridge] vert z={zb:.2f} dz={dzb:.2f} 7g gated", br, 7)
|
||||||
|
print(" (bridge ~ VRP01 a meno di convenzione strike/capitale: valida il motore nuovo)")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (2) tabella principale
|
||||||
|
print(f"\n (2) STRUTTURE ALLA CELLA CENTRALE A PRIORI (z={z}σ, ali +{dz}σ, tenor {tn}g) — banda f")
|
||||||
|
scen = [(f, f, f"f={f}") for f in F_SWEEP] + [(F_SKEW["f_put"], F_SKEW["f_call"], "SKEW fp=1.3/fc=0.7")]
|
||||||
|
streams = {}
|
||||||
|
for gated, gtag in ((False, "NO-GATE"), (True, "GATE canonico (vrp>0+ivr30+cs90)")):
|
||||||
|
print(f"\n --- {gtag} ---")
|
||||||
|
print(HDR)
|
||||||
|
for kind, ktag in (("diag", "DIAG double-diagonal T+1g"),
|
||||||
|
("condor", "CONDOR iron condor stessa T"),
|
||||||
|
("vert", "VERT put spread stessa T")):
|
||||||
|
for fp, fc, ftag in scen:
|
||||||
|
r = book(kind, z=z, dz=dz, tenor_d=tn, f_put=fp, f_call=fc, gated=gated)
|
||||||
|
row(f"{ktag} {ftag}", r, tn)
|
||||||
|
streams[(kind, gated, ftag)] = r
|
||||||
|
|
||||||
|
# distanze alternative (sweep trasparente, selezione SOLO in-sample; f=1.0 gated)
|
||||||
|
print(f"\n (3) SWEEP DISTANZA/TENOR (gated, f=1.0) — selezione cella SOLO su Sharpe PRE-holdout")
|
||||||
|
print(f" {'cella':<40} {'ShF-IS':>7} {'ShH':>6} {'DD':>6} {'worst':>7} {'PF':>5} {'win%':>5}")
|
||||||
|
best = None
|
||||||
|
for kind in ("diag", "condor", "vert"):
|
||||||
|
for zz in (1.5, 2.0, 2.5, 3.0):
|
||||||
|
for tt in (3, 5):
|
||||||
|
r = book(kind, z=zz, dz=dz, tenor_d=tt, f_put=1.0, f_call=1.0, gated=True)
|
||||||
|
ris = r[r.index < HOLDOUT]
|
||||||
|
mm = metrics(r, tt); mi = metrics(ris, tt)
|
||||||
|
pf = f"{mm['pf']:5.2f}" if np.isfinite(mm["pf"]) else " inf"
|
||||||
|
act = r[r != 0.0]
|
||||||
|
nloss = int((act < 0).sum())
|
||||||
|
tag = " <- video ~3.5σ" if zz == 3.0 and tt == 5 and kind == "diag" else ""
|
||||||
|
if nloss == 0:
|
||||||
|
tag += " ⚠️ 0 perdite su tutta la storia = coda MAI campionata (lezione CC01: Sharpe implausibile -> rischio nascosto)"
|
||||||
|
print(f" {kind:<7} z={zz} dz={dz} tenor={tt}g{'':<14} {mi['sh']:>7.2f} {mm['sh_h']:>6.2f} "
|
||||||
|
f"{mm['dd']*100:>5.1f}% {mm['worst']*100:>+6.2f}% {pf} {mm['win']*100:>4.0f}% n={len(act):>3}{tag}")
|
||||||
|
if best is None or mi["sh"] > best[1]:
|
||||||
|
best = ((kind, zz, tt), mi["sh"], mm["sh_h"])
|
||||||
|
print(f" -> cella best IN-SAMPLE: {best[0]} (ShF-IS {best[1]:.2f}) | suo hold-out ShH {best[2]:.2f}")
|
||||||
|
print(" Le celle z>=2.5/5g vendono un evento ~1% mai occorso nel subsample gated (~140 trade):")
|
||||||
|
print(" Sharpe 'inf/5.9' = premio senza coda osservata, NON edge. E' il punto cieco CC01 in forma opzioni.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (4) claim del video
|
||||||
|
print(f"\n (4) TEST CLAIM VIDEO: la long a T+1g domina la long a STESSA T? (z={z}, dz={dz}, {tn}g, f=1.0)")
|
||||||
|
for gated in (False, True):
|
||||||
|
colD, colC = {}, {}
|
||||||
|
rD = pd.concat({a[0]: run_structure(a, "diag", z=z, dz=dz, tenor_d=tn, gated=gated, collect=colD)
|
||||||
|
for a in ("BTC", "ETH")}, axis=1, join="inner").mean(axis=1)
|
||||||
|
rC = pd.concat({a[0]: run_structure(a, "condor", z=z, dz=dz, tenor_d=tn, gated=gated, collect=colC)
|
||||||
|
for a in ("BTC", "ETH")}, axis=1, join="inner").mean(axis=1)
|
||||||
|
mD, mC = metrics(rD, tn), metrics(rC, tn)
|
||||||
|
ec = np.array(colD["extra_cost"]); rec = np.array(colD["recovered"])
|
||||||
|
sp = np.array(colD["short_pay"]); crd = np.array(colD["credit"])
|
||||||
|
crash = sp > np.quantile(sp, 0.95)
|
||||||
|
gt = "GATE" if gated else "NO-GATE"
|
||||||
|
print(f" [{gt}] DIAG ShF {mD['sh']:+.2f}/ShH {mD['sh_h']:+.2f} worst {mD['worst']*100:+.2f}% | "
|
||||||
|
f"CONDOR ShF {mC['sh']:+.2f}/ShH {mC['sh_h']:+.2f} worst {mC['worst']*100:+.2f}%")
|
||||||
|
print(f" costo extra T+1 a entry: {ec.mean()*1e4:+.1f} bps/trade | residuo recuperato a exit: "
|
||||||
|
f"{rec.mean()*1e4:+.1f} bps (nei 5% peggiori: {rec[crash].mean()*1e4:+.1f} bps vs extra {ec[crash].mean()*1e4:+.1f})")
|
||||||
|
print(f" trade a CREDITO netto: {(crd>0).mean()*100:.0f}% (credito medio {crd.mean()*1e4:+.1f} bps di S0)")
|
||||||
|
dY = per_year(rD); cY = per_year(rC)
|
||||||
|
print(" Δ(diag-condor) per anno: " + " ".join(f"{y}:{(dY[y]-cY.get(y,0))*100:+.2f}%" for y in sorted(dY)))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (5) per-anno
|
||||||
|
print(f"\n (5) PER-ANNO (gated, f=1.0) — 2022 = LUNA+FTX e' il banco di prova")
|
||||||
|
for tag, r, tt in (("DIAG", streams[("diag", True, "f=1.0")], tn),
|
||||||
|
("CONDOR", streams[("condor", True, "f=1.0")], tn),
|
||||||
|
("VERT", streams[("vert", True, "f=1.0")], tn),
|
||||||
|
("VRP01", vrp[1.0], 7)):
|
||||||
|
py = per_year(r)
|
||||||
|
print(f" {tag:<7} " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(py.items())))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (6) marginale
|
||||||
|
print(f"\n (6) MARGINALE vs TP01 e vs VRP01 (daily-lumped; corr su griglia settimanale)")
|
||||||
|
import altlib as al
|
||||||
|
tp = al.tp01_baseline_daily()
|
||||||
|
dv_daily = to_daily_lumped(streams[("diag", True, "f=1.0")])
|
||||||
|
vr_daily = to_daily_lumped(vrp[1.0])
|
||||||
|
tp_w = (1 + tp).resample("W").prod() - 1
|
||||||
|
di_w = (1 + dv_daily).resample("W").prod() - 1
|
||||||
|
vr_w = (1 + vr_daily).resample("W").prod() - 1
|
||||||
|
Jw = pd.concat({"tp": tp_w, "di": di_w, "vr": vr_w}, axis=1, join="inner").dropna()
|
||||||
|
print(f" corr settimanale: DIAG~TP01 {Jw['tp'].corr(Jw['di']):+.2f} | DIAG~VRP01 {Jw['di'].corr(Jw['vr']):+.2f} "
|
||||||
|
f"| VRP01~TP01 {Jw['tp'].corr(Jw['vr']):+.2f}")
|
||||||
|
for name, dd in (("DIAG gated f=1.0", dv_daily), ("VRP01 gated f=1.0 (riferimento)", vr_daily)):
|
||||||
|
mv = al.marginal_vs_tp01(dd)
|
||||||
|
print(f" marginal_vs_tp01[{name}]: verdict={mv.get('marginal_verdict')} corr={mv.get('corr_full')} "
|
||||||
|
f"uplift w25 full/hold={mv['blends']['w25']['uplift_full']}/{mv['blends']['w25']['uplift_hold']} "
|
||||||
|
f"IS-Sh={mv.get('cand_insample_sharpe')} insample_edge={mv.get('has_insample_edge')} "
|
||||||
|
f"hedge={mv.get('is_hedge')} robust_oos={mv.get('robust_oos')} multicut={mv.get('multicut_uplift')}")
|
||||||
|
print(" ⚠️ Un verdetto ADDS qui NON promuove: lo stream vende coda che nel subsample gated non ha mai")
|
||||||
|
print(" colpito (hold-out Sh 3+ = assenza di eventi, non alpha) — vale la lezione CC01, e vale la regola")
|
||||||
|
print(" standing 'niente short-vol da modello in deploy'.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (7) eseguibilita'
|
||||||
|
print(f"\n (7) ESEGUIBILITA' DERIBIT (min 0.1 BTC / 1 ETH per gamba; diag = 4 gambe/asset, book = 8)")
|
||||||
|
for a, minc in (("BTC", 0.1), ("ETH", 1.0)):
|
||||||
|
J = load_series(a); S = float(J["px"].iloc[-1]); sig = float(J["dvol"].iloc[-1]) / 100.0
|
||||||
|
w = dz * sig * np.sqrt(tn / 365.25) # larghezza ala in frazione di S
|
||||||
|
notional = minc * S
|
||||||
|
maxloss = w * notional # margine ~ max loss defined-risk (per lato)
|
||||||
|
col = {}
|
||||||
|
run_structure(a, "diag", z=z, dz=dz, tenor_d=tn, gated=True, collect=col)
|
||||||
|
cbps = np.mean(col["credit"]) * 1e4 if col.get("credit") else float("nan")
|
||||||
|
print(f" {a}: spot ~${S:,.0f} -> notional min {minc} = ${notional:,.0f}/gamba | ala {w*100:.1f}% "
|
||||||
|
f"-> margine/max-loss min ~${maxloss:,.0f} | credito tipico {cbps:+.0f} bps = ${notional*cbps/1e4:,.0f}/trade")
|
||||||
|
print(" -> a $600: UN diagonale BTC min-size impegna >50% del capitale su un trade 5g = NON eseguibile.")
|
||||||
|
print(" Scala minima: sleeve opzioni al ~12% con margine <= peso richiede >~$3-5k per il solo BTC")
|
||||||
|
print(" min-size; book 50/50 con granularita' (>=3-5 step di size) ~= $15-25k. STAT-MODE, come VRP01.")
|
||||||
|
|
||||||
|
print("\n NB ONESTO: win-rate alto e' STRUTTURALE nel deep-OTM (vendi eventi rari), non e' edge. Il verdetto")
|
||||||
|
print(" sta in Sharpe/PF/coda attraverso la banda f e il 2022. Regola standing: niente short-vol da modello")
|
||||||
|
print(" in deploy — esito massimo = aggiornamento di conoscenza sulla STRUTTURA.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""r0702_anchor_skh01.py — AUDIT timing-luck della FASE della griglia dual-TF di SKH01-V2-DD.
|
||||||
|
|
||||||
|
CONTESTO (diario 2026-07-02-timing-crt-wave.md): l'hold-out di TP01 (Sharpe 0.31) si e'
|
||||||
|
rivelato la MIGLIORE delle 24 ancore orarie possibili (mediana 0.04) — timing-luck
|
||||||
|
dell'ancora, dimensione di multiple-testing non contata dal deflated-Sharpe. SKH01 e' nel
|
||||||
|
BOOK LIVE Deribit (TP01+SKH01 75/25) -> audit analogo, con rilevanza operativa diretta.
|
||||||
|
|
||||||
|
SPAZIO DI LUCK: l'origine della griglia. SKH01 resampla il 5m certificato a 230m (exec) e
|
||||||
|
690m (segnale) con origin='epoch'. Shiftando l'origin di k x 5m si spostano COERENTEMENTE
|
||||||
|
entrambe le griglie (690 = 3 x 230 -> i confini HTF restano sottoinsieme dei LTF); la
|
||||||
|
struttura congiunta si ripete con periodo 690m = 138 step da 5m. Campioniamo 23 OFFSET
|
||||||
|
UNIFORMI (ogni 30m) su [0, 690), dichiarati A PRIORI — offset 0 = canonico. Parametri
|
||||||
|
IDENTICI (SKH01_V2_DD) su tutti gli offset; nessuna selezione.
|
||||||
|
|
||||||
|
NB strutturale: 230m e 690m NON dividono 24h -> la griglia MIGRA attraverso la giornata
|
||||||
|
(nessun offset "possiede" un'ora del giorno) — l'ancora e' meno "speciale" a priori di
|
||||||
|
quella daily di TP01, ma va misurato.
|
||||||
|
|
||||||
|
COSA FA:
|
||||||
|
1. SANITY: a offset 0 riproduce ESATTAMENTE la serie daily di _skyhook_returns()
|
||||||
|
(max|dif| ~0) e i confini HTF c LTF per ogni offset.
|
||||||
|
2. Per 23 offset x {BTC,ETH}: Sharpe FULL/IS/HOLD (equity daily-step, convenzione
|
||||||
|
canonica), maxDD (equity harness), n trade -> tabella + min/med/max + pctl di off 0.
|
||||||
|
3. GATE ammissione ri-valutati alla mediana/peggiore: (a) maxDD<30% entrambi gli asset,
|
||||||
|
(b) minFull ~0.99 / minHold ~1.26, (c) blend 0.75*TP01+0.25*SKH hold-out (baseline
|
||||||
|
al.tp01_baseline_daily; claim 0.31->1.17), (d) corr a TP01 (~0.09).
|
||||||
|
4. BOOK 5-sleeve 33/15/12/20/20 (combine_outer, outer-join rinormalizzato, era crypto):
|
||||||
|
HOLD/FULL con SKH a ogni offset + ensemble degli offset (lens de-luckato, NON eseguibile).
|
||||||
|
5. BOOTSTRAP (block ~20g, maniera scettico r0702_skeptic_offset): P(un offset qualsiasi
|
||||||
|
mostri uno spike >= quello del canonico sull'hold-out del blend).
|
||||||
|
6. RILEVANZA LIVE: il cron del book e' ORARIO (crontab 0 * * * * -> scripts/cron_book.sh)
|
||||||
|
ma i confini 230m NON sono allineati all'ora (230 mod 60 = 50 -> ciclo di 6 barre,
|
||||||
|
ritardo confine->prossima-ora in {0,10,20,30,40,50} min). Quantifica la distribuzione
|
||||||
|
e l'impatto: re-sim con fill al close 5m del PROSSIMO multiplo orario dopo la chiusura
|
||||||
|
della barra di segnale/exit (entry E exit software ritardati; detection dei trade
|
||||||
|
INVARIATA -> isola il puro effetto prezzo-esecuzione). Sanity: la modalita' canonica
|
||||||
|
della re-sim riproduce backtest_signals bit-exact.
|
||||||
|
|
||||||
|
CAVEAT DICHIARATI:
|
||||||
|
- equity daily-step (lens Sharpe) come il canonico — non e' mark-to-market intrabar;
|
||||||
|
- costi di ribilanciamento del book 230m gia' flaggati a deploy (diario skyhook);
|
||||||
|
- l'ensemble di offset NON e' eseguibile live (una sola griglia gira): serve SOLO come
|
||||||
|
stima de-luckata;
|
||||||
|
- la sim 'hourly' modella fill a close 5m del multiplo orario (cron a minuto 0, runtime
|
||||||
|
del job trascurato) e NON modella slippage/parziali.
|
||||||
|
|
||||||
|
TECNICA: mai .view('int64') su tz-aware (epoca esplicita in ms); htf_features/merge_htf_to_ltf
|
||||||
|
RIUSATE via skyhook_entries (importate, non riscritte). Vincoli: nessun file toccato fuori da
|
||||||
|
questo script; niente commit. Runtime ~3-6 min (46 run skyhook + book + bootstrap).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path("/opt/docker/PythagorasGoal")
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
from src.backtest.harness import backtest_signals # noqa: E402
|
||||||
|
from src.data.downloader import load_data # noqa: E402
|
||||||
|
from src.portfolio.portfolio import combine_outer, to_daily # noqa: E402
|
||||||
|
from src.strategies.skyhook import ( # noqa: E402
|
||||||
|
HTF_MIN, LTF_MIN, SKH01_V2_DD, skyhook_entries)
|
||||||
|
|
||||||
|
HOLDOUT = al.HOLDOUT
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
OFFSETS = tuple(range(0, 690, 30)) # 23 offset a priori (ogni 30m su [0,690)), 0 = canonico
|
||||||
|
MS5 = 300_000
|
||||||
|
MSH = 3_600_000
|
||||||
|
MS_LTF = LTF_MIN * 60_000
|
||||||
|
BLEND_W = {"TP": 0.75, "SKH": 0.25}
|
||||||
|
BOOK_W = {"TP": 0.33, "XS": 0.15, "VRP": 0.12, "SKH": 0.20, "GTAA": 0.20}
|
||||||
|
B_BOOT = 4000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dati + resample con fase spostata (identico a skyhook.resample_5m + offset)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@lru_cache(maxsize=4)
|
||||||
|
def get5m(asset: str) -> pd.DataFrame:
|
||||||
|
df = load_data(asset, "5m").reset_index(drop=True)
|
||||||
|
if "datetime" not in df.columns:
|
||||||
|
df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def resample_off(df5: pd.DataFrame, minutes: int, off: int) -> pd.DataFrame:
|
||||||
|
"""Identico a skyhook.resample_5m ma con griglia spostata di `off` minuti:
|
||||||
|
origin='epoch' + offset -> confini a epoch + off + n*minutes. Con `off` comune a
|
||||||
|
230m e 690m (690=3x230) i confini HTF restano sottoinsieme dei confini LTF."""
|
||||||
|
g = df5[["timestamp", "open", "high", "low", "close", "volume"]].copy()
|
||||||
|
g.index = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
|
||||||
|
out = (g.resample(f"{minutes}min", label="left", closed="left", origin="epoch",
|
||||||
|
offset=pd.Timedelta(minutes=off))
|
||||||
|
.agg({"open": "first", "high": "max", "low": "min",
|
||||||
|
"close": "last", "volume": "sum"})
|
||||||
|
.dropna(subset=["open"]))
|
||||||
|
out["datetime"] = out.index
|
||||||
|
epoch = pd.Timestamp("1970-01-01", tz="UTC")
|
||||||
|
out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
|
||||||
|
return out.reset_index(drop=True)[
|
||||||
|
["timestamp", "open", "high", "low", "close", "volume", "datetime"]]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Run per (asset, offset) — pipeline IDENTICA a sleeves._skyhook_returns
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_CACHE: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def run_asset(asset: str, off: int):
|
||||||
|
"""(daily equity-step series, Metrics, ltf, entries) per un asset a fase `off`."""
|
||||||
|
key = (asset, off)
|
||||||
|
if key in _CACHE:
|
||||||
|
return _CACHE[key]
|
||||||
|
df5 = get5m(asset)
|
||||||
|
ltf = resample_off(df5, LTF_MIN, off)
|
||||||
|
htf = resample_off(df5, HTF_MIN, off)
|
||||||
|
# confini HTF c confini LTF: vale per TUTTE le barre tranne al piu' la PRIMA (parziale:
|
||||||
|
# se il feed parte a meta' di un bin 690m il sub-bin 230m con la stessa label puo' essere
|
||||||
|
# vuoto). Identico al comportamento del canonico build_frames (origin='epoch').
|
||||||
|
assert np.isin(htf["timestamp"].values[1:], ltf["timestamp"].values).all(), \
|
||||||
|
f"confini HTF NON sottoinsieme dei LTF (asset={asset}, off={off})"
|
||||||
|
ent = skyhook_entries(ltf, htf, SKH01_V2_DD) # riusa htf_features/merge_htf_to_ltf
|
||||||
|
m = backtest_signals(ltf, ent, fee_rt=0.001, leverage=1.0, asset=asset, tf="230m")
|
||||||
|
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
|
||||||
|
daily = s.resample("1D").last().ffill().pct_change().dropna()
|
||||||
|
_CACHE[key] = (daily, m, ltf, ent)
|
||||||
|
return _CACHE[key]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32)
|
||||||
|
def skh_port(off: int) -> pd.Series:
|
||||||
|
"""Book 50/50 BTC+ETH daily-step alla fase `off` (convenzione di _skyhook_returns)."""
|
||||||
|
series = {a: run_asset(a, off)[0] for a in ASSETS}
|
||||||
|
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||||
|
return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index)
|
||||||
|
|
||||||
|
|
||||||
|
def sh3(s: pd.Series) -> tuple[float, float, float]:
|
||||||
|
return (al._sh(s), al._sh(s[s.index < HOLDOUT]), al._sh(s[s.index >= HOLDOUT]))
|
||||||
|
|
||||||
|
|
||||||
|
def pctl_of_first(v: np.ndarray) -> float:
|
||||||
|
return float((v < v[0]).mean() + 0.5 * (v == v[0]).mean()) * 100
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (1) SANITY
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def sanity() -> None:
|
||||||
|
from src.portfolio.sleeves import _skyhook_returns
|
||||||
|
mine = skh_port(0)
|
||||||
|
ref = _skyhook_returns()
|
||||||
|
assert len(mine) == len(ref), f"sanity len: {len(mine)} vs {len(ref)}"
|
||||||
|
dmax = float(np.max(np.abs(mine.values - ref.values)))
|
||||||
|
assert dmax < 1e-12, f"sanity offset 0: max|dif| = {dmax:.2e}"
|
||||||
|
f, i, h = sh3(mine)
|
||||||
|
print(f"[SANITY] offset 0 == _skyhook_returns(): max|dif| = {dmax:.2e} "
|
||||||
|
f"su {len(mine)} giorni (FULL {f:.3f} / IS {i:.3f} / HOLD {h:.3f})")
|
||||||
|
for a in ASSETS:
|
||||||
|
_, m, _, _ = run_asset(a, 0)
|
||||||
|
print(f"[SANITY] {a} off=0: maxDD harness {m.max_dd:.1%}, trade {m.n_trades} "
|
||||||
|
f"(diario: BTC 21.4% / ETH 27.4%)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (6) LIVE — re-sim con fill al prossimo multiplo orario (path del cron)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def sim_equity(ltf: pd.DataFrame, ent: list, mode: str,
|
||||||
|
ts5_close: np.ndarray | None = None,
|
||||||
|
c5: np.ndarray | None = None) -> np.ndarray:
|
||||||
|
"""Replica del loop di backtest_signals con prezzi d'esecuzione iniettabili.
|
||||||
|
mode='canonical': entry a close[i], exit AL LIVELLO sl/tp (SL prioritario) o close
|
||||||
|
-> DEVE riprodurre backtest_signals bit-exact (sanity della re-sim).
|
||||||
|
mode='barclose': stessi trade (stessa detection), fill entry/exit al CLOSE della barra
|
||||||
|
230m di segnale/trigger -> quota dell'ottimismo 'fill al livello' senza cron.
|
||||||
|
mode='hourly': fill entry/exit al close 5m del PROSSIMO multiplo orario dopo la
|
||||||
|
chiusura della barra di segnale/trigger (path reale del cron 0 * * * *).
|
||||||
|
In tutti i modi le TRADE BOUNDARIES (barra entry, barra trigger, non-overlap) sono
|
||||||
|
identiche al canonico: cambia solo il prezzo d'esecuzione."""
|
||||||
|
c = ltf["close"].values.astype(float)
|
||||||
|
h = ltf["high"].values.astype(float)
|
||||||
|
l = ltf["low"].values.astype(float)
|
||||||
|
n = len(c)
|
||||||
|
close_ts = ltf["timestamp"].values.astype(np.int64) + MS_LTF
|
||||||
|
|
||||||
|
def px_hour(t: int) -> float:
|
||||||
|
hb = ((t + MSH - 1) // MSH) * MSH # prossimo multiplo orario >= t
|
||||||
|
j = np.searchsorted(ts5_close, hb, side="left")
|
||||||
|
return c5[min(j, len(c5) - 1)]
|
||||||
|
|
||||||
|
initial = 1000.0
|
||||||
|
capital = initial
|
||||||
|
equity = np.full(n, capital, dtype=float)
|
||||||
|
busy_until = -1
|
||||||
|
for i in range(n):
|
||||||
|
e = ent[i] if i < len(ent) else None
|
||||||
|
if e is None or e.get("dir", 0) == 0:
|
||||||
|
equity[i] = capital
|
||||||
|
continue
|
||||||
|
if i <= busy_until:
|
||||||
|
equity[i] = capital
|
||||||
|
continue
|
||||||
|
direction = int(e["dir"])
|
||||||
|
tp = e.get("tp"); sl = e.get("sl")
|
||||||
|
max_bars = int(e.get("max_bars") or 24)
|
||||||
|
|
||||||
|
# entry price per modalita'
|
||||||
|
if mode == "canonical":
|
||||||
|
entry = c[i]
|
||||||
|
elif mode == "barclose":
|
||||||
|
entry = c[i]
|
||||||
|
else: # hourly
|
||||||
|
entry = px_hour(close_ts[i])
|
||||||
|
|
||||||
|
exit_idx = min(i + max_bars, n - 1)
|
||||||
|
exit_lvl = c[exit_idx] # default: time exit a close
|
||||||
|
hit_kind = "time"
|
||||||
|
for j in range(i + 1, min(i + max_bars + 1, n)):
|
||||||
|
hit_sl = sl is not None and (
|
||||||
|
(direction == 1 and l[j] <= sl) or (direction == -1 and h[j] >= sl))
|
||||||
|
hit_tp = tp is not None and (
|
||||||
|
(direction == 1 and h[j] >= tp) or (direction == -1 and l[j] <= tp))
|
||||||
|
if hit_sl:
|
||||||
|
exit_lvl, exit_idx, hit_kind = sl, j, "sl"
|
||||||
|
break
|
||||||
|
if hit_tp:
|
||||||
|
exit_lvl, exit_idx, hit_kind = tp, j, "tp"
|
||||||
|
break
|
||||||
|
exit_lvl, exit_idx = c[j], j
|
||||||
|
if mode == "canonical":
|
||||||
|
exit_price = exit_lvl # fill al livello (come harness)
|
||||||
|
elif mode == "barclose":
|
||||||
|
exit_price = c[exit_idx] # fill al close della barra trigger
|
||||||
|
else:
|
||||||
|
exit_price = px_hour(close_ts[exit_idx]) # fill al prossimo multiplo orario
|
||||||
|
|
||||||
|
gross = (exit_price - entry) / entry * direction
|
||||||
|
net = gross - 0.001 # fee_rt 0.10%, leverage 1
|
||||||
|
capital += capital * net
|
||||||
|
capital = max(capital, 1.0)
|
||||||
|
equity[i:exit_idx + 1] = capital
|
||||||
|
busy_until = exit_idx
|
||||||
|
_ = hit_kind
|
||||||
|
|
||||||
|
# stessa forward-fill robusta del harness
|
||||||
|
last = initial
|
||||||
|
for k in range(n):
|
||||||
|
if equity[k] != last and equity[k] != initial:
|
||||||
|
last = equity[k]
|
||||||
|
else:
|
||||||
|
equity[k] = last
|
||||||
|
return equity
|
||||||
|
|
||||||
|
|
||||||
|
def live_delay_section() -> None:
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("(6) RILEVANZA LIVE — cron ORARIO (0 * * * *) vs confini 230m non allineati all'ora")
|
||||||
|
print("=" * 100)
|
||||||
|
# distribuzione del ritardo confine-230m -> prossima ora (struttura: 230 mod 60 = 50)
|
||||||
|
_, _, ltf0, _ = run_asset("BTC", 0)
|
||||||
|
close_ts = ltf0["timestamp"].values.astype(np.int64) + MS_LTF
|
||||||
|
delay_min = ((MSH - (close_ts % MSH)) % MSH) // 60_000
|
||||||
|
vals, cnts = np.unique(delay_min, return_counts=True)
|
||||||
|
tot = cnts.sum()
|
||||||
|
print("ritardo chiusura-barra-230m -> prossimo run orario del cron:")
|
||||||
|
for v, cn in zip(vals, cnts):
|
||||||
|
print(f" {int(v):>3} min : {cn / tot:>6.1%}")
|
||||||
|
print(f" media {float(delay_min.mean()):.1f} min, max {int(delay_min.max())} min "
|
||||||
|
f"(ciclo di 6 barre = 23h: la griglia migra attraverso la giornata)")
|
||||||
|
|
||||||
|
# impatto: re-sim canonico (sanity bit-exact) / barclose / hourly
|
||||||
|
print(f"\nimpatto sull'equity daily-step (offset 0, canonico; fill hourly = close 5m del "
|
||||||
|
f"prossimo multiplo orario dopo la chiusura della barra di segnale/exit):")
|
||||||
|
print(f"{'asset':<5} {'modo':<10} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'maxDD':>7}")
|
||||||
|
dailies: dict[str, dict[str, pd.Series]] = {m: {} for m in ("canonical", "barclose", "hourly")}
|
||||||
|
for a in ASSETS:
|
||||||
|
_, m0, ltf, ent = run_asset(a, 0)
|
||||||
|
df5 = get5m(a)
|
||||||
|
ts5_close = df5["timestamp"].values.astype(np.int64) + MS5
|
||||||
|
c5 = df5["close"].values.astype(float)
|
||||||
|
for mode in ("canonical", "barclose", "hourly"):
|
||||||
|
eq = sim_equity(ltf, ent, mode, ts5_close=ts5_close, c5=c5)
|
||||||
|
if mode == "canonical":
|
||||||
|
dmax = float(np.max(np.abs(eq - m0.equity)))
|
||||||
|
assert dmax < 1e-6, f"re-sim canonica != harness ({a}): max|dif|={dmax:.2e}"
|
||||||
|
print(f" [sanity] {a}: re-sim canonica == backtest_signals "
|
||||||
|
f"(max|dif equity| = {dmax:.2e})")
|
||||||
|
s = pd.Series(eq, index=pd.DatetimeIndex(pd.to_datetime(ltf["datetime"], utc=True)))
|
||||||
|
d = s.resample("1D").last().ffill().pct_change().dropna()
|
||||||
|
dailies[mode][a] = d
|
||||||
|
f, i_, h = sh3(d)
|
||||||
|
dd = al._dd_ret(d)
|
||||||
|
print(f"{a:<5} {mode:<10} {f:>7.3f} {i_:>7.3f} {h:>7.3f} {dd:>6.1%}")
|
||||||
|
print("\nbook 50/50 BTC+ETH per modo:")
|
||||||
|
print(f"{'modo':<10} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'maxDD':>7}")
|
||||||
|
port_mode = {}
|
||||||
|
for mode in ("canonical", "barclose", "hourly"):
|
||||||
|
J = pd.concat(dailies[mode], axis=1, join="inner").fillna(0.0)
|
||||||
|
p = pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index)
|
||||||
|
port_mode[mode] = p
|
||||||
|
f, i_, h = sh3(p)
|
||||||
|
print(f"{mode:<10} {f:>7.3f} {i_:>7.3f} {h:>7.3f} {al._dd_ret(p):>6.1%}")
|
||||||
|
dfull = al._sh(port_mode['hourly']) - al._sh(port_mode['canonical'])
|
||||||
|
dhold = (al._sh(port_mode['hourly'][port_mode['hourly'].index >= HOLDOUT])
|
||||||
|
- al._sh(port_mode['canonical'][port_mode['canonical'].index >= HOLDOUT]))
|
||||||
|
print(f"\n-> delta hourly vs canonico: FULL {dfull:+.3f}, HOLD {dhold:+.3f} "
|
||||||
|
f"(barclose isola il fill-al-livello; hourly aggiunge il ritardo 0-50 min)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def main() -> None:
|
||||||
|
t0 = time.time()
|
||||||
|
print("=" * 100)
|
||||||
|
print("r0702 — SKH01-V2-DD: timing-luck della FASE della griglia dual-TF 230m/690m")
|
||||||
|
print(f"23 offset a priori (ogni 30m su [0,690)), parametri IDENTICI, fee 0.10% RT, "
|
||||||
|
f"HOLD-OUT >= {HOLDOUT.date()}")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
sanity()
|
||||||
|
|
||||||
|
# ---- (2) per-offset x asset -------------------------------------------
|
||||||
|
rows = []
|
||||||
|
for off in OFFSETS:
|
||||||
|
rec = {"off": off}
|
||||||
|
for a in ASSETS:
|
||||||
|
d, m, _, _ = run_asset(a, off)
|
||||||
|
f, i_, h = sh3(d)
|
||||||
|
rec[f"{a}_full"] = f; rec[f"{a}_is"] = i_; rec[f"{a}_hold"] = h
|
||||||
|
rec[f"{a}_dd"] = m.max_dd; rec[f"{a}_ntr"] = m.n_trades
|
||||||
|
p = skh_port(off)
|
||||||
|
f, i_, h = sh3(p)
|
||||||
|
rec["P_full"] = f; rec["P_is"] = i_; rec["P_hold"] = h
|
||||||
|
rec["P_dd"] = al._dd_ret(p)
|
||||||
|
rec["minFull"] = min(rec["BTC_full"], rec["ETH_full"])
|
||||||
|
rec["minHold"] = min(rec["BTC_hold"], rec["ETH_hold"])
|
||||||
|
rows.append(rec)
|
||||||
|
print(f" [{time.time()-t0:5.0f}s] offset {off:>3}m fatto "
|
||||||
|
f"(minFull {rec['minFull']:+.2f}, minHold {rec['minHold']:+.2f}, "
|
||||||
|
f"DD {rec['BTC_dd']:.0%}/{rec['ETH_dd']:.0%})")
|
||||||
|
T = pd.DataFrame(rows).set_index("off")
|
||||||
|
|
||||||
|
print("\n--- (2) PER-OFFSET (equity daily-step, come il canonico) ---")
|
||||||
|
for a in ASSETS:
|
||||||
|
print(f"\n{a}:")
|
||||||
|
print(f"{'off':>4} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'maxDD':>7} {'trade':>6}")
|
||||||
|
for off, r in T.iterrows():
|
||||||
|
tag = " <- canonico" if off == 0 else ""
|
||||||
|
print(f"{off:>4} {r[f'{a}_full']:>7.3f} {r[f'{a}_is']:>7.3f} "
|
||||||
|
f"{r[f'{a}_hold']:>7.3f} {r[f'{a}_dd']:>6.1%} {int(r[f'{a}_ntr']):>6}{tag}")
|
||||||
|
print(" min/med/max [pctl canonico]:")
|
||||||
|
for col, lbl in ((f"{a}_full", "ShFULL"), (f"{a}_is", "ShIS"),
|
||||||
|
(f"{a}_hold", "ShHOLD"), (f"{a}_dd", "maxDD")):
|
||||||
|
v = T[col].values
|
||||||
|
print(f" {lbl:<7} {v.min():>7.3f} / {np.median(v):>7.3f} / {v.max():>7.3f} "
|
||||||
|
f" [off0 = {v[0]:.3f}, {pctl_of_first(v):.0f} pctl]")
|
||||||
|
|
||||||
|
print(f"\nBOOK 50/50 (portafoglio SKH01 standalone):")
|
||||||
|
print(f"{'off':>4} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'maxDD':>7}")
|
||||||
|
for off, r in T.iterrows():
|
||||||
|
tag = " <- canonico" if off == 0 else ""
|
||||||
|
print(f"{off:>4} {r.P_full:>7.3f} {r.P_is:>7.3f} {r.P_hold:>7.3f} {r.P_dd:>6.1%}{tag}")
|
||||||
|
for col, lbl in (("P_full", "ShFULL"), ("P_is", "ShIS"), ("P_hold", "ShHOLD"),
|
||||||
|
("P_dd", "maxDD"), ("minFull", "minFull"), ("minHold", "minHold")):
|
||||||
|
v = T[col].values
|
||||||
|
print(f" {lbl:<8} min {v.min():>7.3f} / med {np.median(v):>7.3f} / max {v.max():>7.3f} "
|
||||||
|
f" [off0 = {v[0]:.3f}, {pctl_of_first(v):.0f} pctl]")
|
||||||
|
|
||||||
|
# ---- (3) gate di ammissione ------------------------------------------
|
||||||
|
print("\n--- (3) GATE DI AMMISSIONE ri-valutati sui 23 offset ---")
|
||||||
|
viol_b = T["BTC_dd"] >= 0.30
|
||||||
|
viol_e = T["ETH_dd"] >= 0.30
|
||||||
|
viol = viol_b | viol_e
|
||||||
|
print(f"(a) maxDD<30%: violazioni su 23 offset: BTC {int(viol_b.sum())}, "
|
||||||
|
f"ETH {int(viol_e.sum())}, almeno-un-asset {int(viol.sum())} "
|
||||||
|
f"({[int(o) for o in T.index[viol]]})")
|
||||||
|
print(f" mediana DD: BTC {T['BTC_dd'].median():.1%}, ETH {T['ETH_dd'].median():.1%}; "
|
||||||
|
f"peggiore: BTC {T['BTC_dd'].max():.1%} (off {int(T['BTC_dd'].idxmax())}), "
|
||||||
|
f"ETH {T['ETH_dd'].max():.1%} (off {int(T['ETH_dd'].idxmax())})")
|
||||||
|
print(f"(b) minFull (canonico +0.99): mediana {T['minFull'].median():+.2f}, "
|
||||||
|
f"peggiore {T['minFull'].min():+.2f} (off {int(T['minFull'].idxmin())})")
|
||||||
|
print(f" minHold (canonico +1.26): mediana {T['minHold'].median():+.2f}, "
|
||||||
|
f"peggiore {T['minHold'].min():+.2f} (off {int(T['minHold'].idxmin())})")
|
||||||
|
|
||||||
|
# (c) blend 0.75 TP01 + 0.25 SKH — TP01 baseline canonico FISSO
|
||||||
|
B = al.tp01_baseline_daily()
|
||||||
|
b_hold = al._sh(B[B.index >= HOLDOUT])
|
||||||
|
blends_hold, blends_full, corrs = [], [], []
|
||||||
|
blend_series_hold = {}
|
||||||
|
for off in OFFSETS:
|
||||||
|
s = skh_port(off)
|
||||||
|
bl = combine_outer({"TP": B, "SKH": s}, BLEND_W)
|
||||||
|
bl = bl[bl.index >= B.index.min()]
|
||||||
|
blends_hold.append(al._sh(bl[bl.index >= HOLDOUT]))
|
||||||
|
blends_full.append(al._sh(bl))
|
||||||
|
blend_series_hold[off] = bl[bl.index >= HOLDOUT]
|
||||||
|
J = pd.concat({"TP": B, "SKH": s}, axis=1, join="inner").dropna()
|
||||||
|
corrs.append(float(J["TP"].corr(J["SKH"])))
|
||||||
|
bh = np.array(blends_hold); bf = np.array(blends_full); co = np.array(corrs)
|
||||||
|
print(f"(c) blend 0.75*TP01+0.25*SKH (TP01 h=0 canonico; suo HOLD da solo = {b_hold:.2f}):")
|
||||||
|
print(f" Sharpe HOLD blend: off0 {bh[0]:.2f} | min {bh.min():.2f} / med "
|
||||||
|
f"{np.median(bh):.2f} / max {bh.max():.2f} [off0 al {pctl_of_first(bh):.0f} pctl]"
|
||||||
|
f" (claim: 0.31 -> 1.17)")
|
||||||
|
print(f" uplift HOLD vs TP01 solo: off0 {bh[0]-b_hold:+.2f} | min {bh.min()-b_hold:+.2f} "
|
||||||
|
f"/ med {np.median(bh)-b_hold:+.2f} / max {bh.max()-b_hold:+.2f}")
|
||||||
|
print(f" Sharpe FULL blend: off0 {bf[0]:.2f} | min {bf.min():.2f} / med "
|
||||||
|
f"{np.median(bf):.2f} / max {bf.max():.2f}")
|
||||||
|
print(f"(d) corr(SKH, TP01) full: off0 {co[0]:+.3f} | min {co.min():+.3f} / med "
|
||||||
|
f"{np.median(co):+.3f} / max {co.max():+.3f} (claim ~0.09)")
|
||||||
|
|
||||||
|
med_minfull = T["minFull"].median(); med_minhold = T["minHold"].median()
|
||||||
|
med_uplift = float(np.median(bh) - b_hold)
|
||||||
|
med_dd_ok = T["BTC_dd"].median() < 0.30 and T["ETH_dd"].median() < 0.30
|
||||||
|
worst_ok = (not viol.any()) and T["minFull"].min() > 0 and (bh.min() - b_hold) > 0
|
||||||
|
print("\nVERDETTO ammissione alla fase MEDIANA: "
|
||||||
|
f"DD<30% {'PASS' if med_dd_ok else 'FAIL'} alla mediana"
|
||||||
|
f" (violazioni puntuali {int(viol.sum())}/23), minFull {med_minfull:+.2f}, "
|
||||||
|
f"minHold {med_minhold:+.2f}, uplift blend HOLD {med_uplift:+.2f}, "
|
||||||
|
f"corr {np.median(co):+.2f}")
|
||||||
|
print(f" al PEGGIORE dei 23: DD {'PASS tutti' if not viol.any() else 'FAIL su ' + str(int(viol.sum())) + ' offset'}, "
|
||||||
|
f"minFull {T['minFull'].min():+.2f}, minHold {T['minHold'].min():+.2f}, "
|
||||||
|
f"uplift blend HOLD {bh.min()-b_hold:+.2f} "
|
||||||
|
f"-> {'regge anche al peggiore' if worst_ok else 'NON regge al peggiore'}")
|
||||||
|
|
||||||
|
# ---- (4) book 5-sleeve -------------------------------------------------
|
||||||
|
print("\n--- (4) BOOK 5-SLEEVE (TP 33 / XS 15 / VRP 12 / SKH 20 / GTAA 20) ---")
|
||||||
|
from src.portfolio.sleeves import (_gtaa_daily_returns, _tp01_returns,
|
||||||
|
_vrp_combo_returns, _xsec_returns)
|
||||||
|
tp_d = to_daily(_tp01_returns())
|
||||||
|
cols_fixed = dict(TP=tp_d, XS=to_daily(_xsec_returns()),
|
||||||
|
VRP=to_daily(_vrp_combo_returns()), GTAA=to_daily(_gtaa_daily_returns()))
|
||||||
|
lo = tp_d.index.min()
|
||||||
|
bkh, bkf = [], []
|
||||||
|
for off in OFFSETS:
|
||||||
|
s = combine_outer(dict(SKH=skh_port(off), **cols_fixed), BOOK_W, lo=lo)
|
||||||
|
bkh.append(al._sh(s[s.index >= HOLDOUT])); bkf.append(al._sh(s))
|
||||||
|
bkh = np.array(bkh); bkf = np.array(bkf)
|
||||||
|
ens = pd.concat({o: skh_port(o) for o in OFFSETS}, axis=1).mean(axis=1)
|
||||||
|
s_ens = combine_outer(dict(SKH=ens, **cols_fixed), BOOK_W, lo=lo)
|
||||||
|
i_med_h = int(np.argsort(bkh)[len(bkh) // 2]); i_worst_h = int(np.argmin(bkh))
|
||||||
|
print(f"HOLD: off0 {bkh[0]:.2f} | min {bkh.min():.2f} (off {OFFSETS[i_worst_h]}) / med "
|
||||||
|
f"{np.median(bkh):.2f} (off {OFFSETS[i_med_h]}) / max {bkh.max():.2f} "
|
||||||
|
f"[off0 al {pctl_of_first(bkh):.0f} pctl] | ENSEMBLE "
|
||||||
|
f"{al._sh(s_ens[s_ens.index >= HOLDOUT]):.2f}")
|
||||||
|
print(f"FULL: off0 {bkf[0]:.2f} | min {bkf.min():.2f} / med {np.median(bkf):.2f} / max "
|
||||||
|
f"{bkf.max():.2f} [off0 al {pctl_of_first(bkf):.0f} pctl] | ENSEMBLE {al._sh(s_ens):.2f}")
|
||||||
|
print("(l'ensemble di offset NON e' eseguibile live — una sola griglia gira: e' solo la "
|
||||||
|
"stima de-luckata)")
|
||||||
|
|
||||||
|
# ---- (5) bootstrap alla maniera dello scettico -------------------------
|
||||||
|
print("\n--- (5) BOOTSTRAP (block) — lo spike del canonico sull'hold-out del blend ---")
|
||||||
|
Mdf = pd.concat(blend_series_hold, axis=1, join="inner").dropna()
|
||||||
|
M = Mdf.values
|
||||||
|
def _sh_cols(R: np.ndarray) -> np.ndarray:
|
||||||
|
mu = R.mean(axis=1); sd = R.std(axis=1)
|
||||||
|
return np.where(sd > 0, mu / sd, 0.0) * np.sqrt(365.25)
|
||||||
|
sh_obs = np.array([al._sh(Mdf[c]) for c in Mdf.columns])
|
||||||
|
g0_obs = float(sh_obs[0] - np.median(sh_obs[1:]))
|
||||||
|
corrM = np.corrcoef(M.T); iu = np.triu_indices(M.shape[1], 1)
|
||||||
|
print(f"hold-out: {M.shape[0]} giorni x {M.shape[1]} offset; Sh blend off0 {sh_obs[0]:.3f}, "
|
||||||
|
f"mediana altri {np.median(sh_obs[1:]):.3f}, spike osservato g0 = {g0_obs:+.3f}")
|
||||||
|
print(f"corr daily fra i 23 blend (hold-out): mediana {np.median(corrM[iu]):.3f}, "
|
||||||
|
f"min {corrM[iu].min():.3f}")
|
||||||
|
n, K = M.shape
|
||||||
|
for blk in (10, 20, 40):
|
||||||
|
rng = np.random.default_rng(42 + blk)
|
||||||
|
nblocks = int(np.ceil(n / blk))
|
||||||
|
gmaxs, g0s = [], []
|
||||||
|
done = 0
|
||||||
|
while done < B_BOOT:
|
||||||
|
b = min(500, B_BOOT - done)
|
||||||
|
starts = rng.integers(0, n, size=(b, nblocks))
|
||||||
|
idx = (starts[:, :, None] + np.arange(blk)[None, None, :]) % n
|
||||||
|
idx = idx.reshape(b, -1)[:, :n]
|
||||||
|
R = M[idx] # (b, n, K)
|
||||||
|
Sh = np.stack([_sh_cols(R[:, :, k]) for k in range(K)], axis=1)
|
||||||
|
med_others = np.empty_like(Sh)
|
||||||
|
for k in range(K):
|
||||||
|
med_others[:, k] = np.median(np.delete(Sh, k, axis=1), axis=1)
|
||||||
|
g = Sh - med_others
|
||||||
|
gmaxs.append(g.max(axis=1)); g0s.append(g[:, 0])
|
||||||
|
done += b
|
||||||
|
gmax = np.concatenate(gmaxs); g0 = np.concatenate(g0s)
|
||||||
|
print(f" block={blk:>2}: P(spike di UN offset qualsiasi >= {g0_obs:+.2f}) = "
|
||||||
|
f"{float(np.mean(gmax >= g0_obs)):.3f} | P(g0<=0) = {float(np.mean(g0 <= 0)):.3f} "
|
||||||
|
f"| CI95 g0 [{np.percentile(g0, 2.5):+.2f},{np.percentile(g0, 97.5):+.2f}]")
|
||||||
|
|
||||||
|
# ---- (6) live ----------------------------------------------------------
|
||||||
|
live_delay_section()
|
||||||
|
|
||||||
|
# ---- (7) caveat --------------------------------------------------------
|
||||||
|
print("\n--- (7) CAVEAT ---")
|
||||||
|
print("- equity daily-step (lens Sharpe), stessa convenzione del canonico;")
|
||||||
|
print("- costi di ribilanciamento del book 230m gia' flaggati a deploy (diario skyhook);")
|
||||||
|
print("- ensemble di offset NON eseguibile live (una sola griglia gira) -> solo stima de-luckata;")
|
||||||
|
print("- sim 'hourly': fill a close 5m del multiplo orario (cron minuto 0), niente slippage/parziali;")
|
||||||
|
print("- nessuna selezione: 23 offset uniformi dichiarati a priori, parametri identici ovunque.")
|
||||||
|
print(f"\nFatto in {time.time()-t0:.0f}s.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""r0702_anchor_xs01.py — TIMING-LUCK della FASE del ciclo di ribilanciamento di XS01.
|
||||||
|
|
||||||
|
XS01 (`src/portfolio/sleeves._xsec_returns`, XS_CFG H=10) ribilancia ogni 10 giorni quando
|
||||||
|
`i % H == 0` con `i` = indice di riga della matrice prezzi inner-joined (start 2024-01-01).
|
||||||
|
La FASE del ciclo (quale dei 10 giorni possibili) e' quindi un artefatto della prima riga
|
||||||
|
dei dati — esattamente come l'ancora oraria di TP01 (vedi r0702_tp01_offset.py +
|
||||||
|
r0702_skeptic_offset.py, diario 2026-07-02-timing-crt-wave.md). Spazio di luck: 10 fasi.
|
||||||
|
|
||||||
|
Questo script (metodologia identica all'audit TP01, ZERO tuning per-fase):
|
||||||
|
1. REPLICA `_xsec_returns` con parametro `phase` (i % H == phase). SANITY OBBLIGATORIO:
|
||||||
|
phase=0 deve riprodurre ESATTAMENTE la serie di `_xsec_returns()` (max|dif| ~ 0).
|
||||||
|
Il gate di dispersione (percentile ESPANDENTE causale) e' ricalcolato PER FASE:
|
||||||
|
disp_hist accumula solo nei giorni di ribilanciamento di QUELLA fase (replica fedele).
|
||||||
|
2. Tabella delle 10 fasi: Sharpe/CAGR/maxDD FULL (serie dal 2024) e HOLD-OUT 2025-26;
|
||||||
|
min/mediana/max + percentile della fase canonica (phase=0).
|
||||||
|
3. ENSEMBLE delle 10 fasi (media dei ritorni = 1/10 del capitale per fase) vs canonica.
|
||||||
|
4. IMPATTO SUL BOOK 5-sleeve (TP 33 / XS 15 / VRP 12 / SKH 20 / GTAA 20, combine_outer
|
||||||
|
con pesi rinormalizzati, attivazione era crypto): XS canonica vs fase mediana vs fase
|
||||||
|
peggiore vs ensemble vs senza-XS -> HOLD e FULL del book.
|
||||||
|
5. GATE DI AMMISSIONE: plateau (lookback singoli/blend, disp_pct 15-35) ricalcolato alla
|
||||||
|
fase MEDIANA e alla canonica: i numeri di ammissione (FULL 1.50/HOLD 1.71/DD 11%) reggono?
|
||||||
|
6. BOOTSTRAP alla maniera dello scettico: block-bootstrap congiunto delle 10 fasi
|
||||||
|
sull'hold-out -> P(una fase qualsiasi mostri uno spike >= quello della canonica)
|
||||||
|
+ CI95 (storia ~2.5y -> quantifica l'ampiezza).
|
||||||
|
|
||||||
|
Tecnica: mai .view su tz-aware (epoca via pd.to_datetime unit='ms', come l'originale);
|
||||||
|
posizioni shiftate come nell'originale (gross[1:] = W[:-1]*dret[1:]); vol-target rolling
|
||||||
|
ricalcolato per fase sulla PROPRIA serie netta. Nessun file toccato fuori da questo script.
|
||||||
|
Runtime ~2-4 min (il grosso e' SKH01 dal 5m per il book).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path("/opt/docker/PythagorasGoal")
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from src.portfolio.portfolio import HOLDOUT, combine_outer, to_daily # noqa: E402
|
||||||
|
from src.portfolio.sleeves import (_HL_DIR, XS_CFG, XS_UNIVERSE, # noqa: E402
|
||||||
|
_xsec_returns)
|
||||||
|
|
||||||
|
RNG_SEED = 42
|
||||||
|
B_BOOT = 4000
|
||||||
|
H = XS_CFG["H"] # 10 -> 10 fasi possibili
|
||||||
|
FULL_START = pd.Timestamp("2024-01-01", tz="UTC")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Metriche (convenzioni identiche ad altlib._sh/_dd_ret e a r0702_tp01_offset.dmetrics)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _sh(s) -> float:
|
||||||
|
r = np.asarray(pd.Series(s).dropna().values, float)
|
||||||
|
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _dd(s) -> float:
|
||||||
|
eq = np.cumprod(1.0 + np.asarray(pd.Series(s).dropna().values, float))
|
||||||
|
pk = np.maximum.accumulate(eq)
|
||||||
|
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _cagr(s) -> float:
|
||||||
|
r = np.asarray(pd.Series(s).dropna().values, float)
|
||||||
|
eq = float(np.prod(1.0 + r))
|
||||||
|
yrs = len(r) / 365.25
|
||||||
|
return eq ** (1 / yrs) - 1 if eq > 0 and yrs > 0 else -1.0
|
||||||
|
|
||||||
|
|
||||||
|
def dmetrics(s: pd.Series) -> dict:
|
||||||
|
s = s.dropna()
|
||||||
|
ho = s[s.index >= HOLDOUT]
|
||||||
|
return dict(sh_full=_sh(s), cagr_full=_cagr(s), dd_full=_dd(s),
|
||||||
|
sh_hold=_sh(ho), cagr_hold=_cagr(ho), dd_hold=_dd(ho), n=len(s))
|
||||||
|
|
||||||
|
|
||||||
|
def pctl_of(v: np.ndarray, x: float) -> float:
|
||||||
|
return float((v < x).mean() + 0.5 * (v == x).mean()) * 100.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Prezzi: caricamento IDENTICO a _xsec_returns (stesso ordine di operazioni)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_PX_CACHE: pd.DataFrame | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_prices() -> pd.DataFrame:
|
||||||
|
global _PX_CACHE
|
||||||
|
if _PX_CACHE is not None:
|
||||||
|
return _PX_CACHE
|
||||||
|
cols = {}
|
||||||
|
for sym in XS_UNIVERSE:
|
||||||
|
p = _HL_DIR / f"hl_{sym.lower()}_1d.parquet"
|
||||||
|
if not p.exists():
|
||||||
|
continue
|
||||||
|
d = pd.read_parquet(p)
|
||||||
|
cols[sym] = pd.Series(d["close"].values.astype(float),
|
||||||
|
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
|
||||||
|
if len(cols) < 10:
|
||||||
|
raise FileNotFoundError("universo Hyperliquid XS01 incompleto")
|
||||||
|
_PX_CACHE = pd.concat(cols, axis=1, join="inner").sort_index().dropna()
|
||||||
|
return _PX_CACHE
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# REPLICA di _xsec_returns con parametro `phase` (+ override cfg per il plateau).
|
||||||
|
# Codice copiato 1:1 da src/portfolio/sleeves._xsec_returns; UNICA differenza:
|
||||||
|
# `i % H == 0` -> `i % H == phase`. Il disp_hist (percentile espandente causale)
|
||||||
|
# accumula SOLO nei giorni di ribilanciamento della fase -> ricalcolato per fase.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def xs_phase(phase: int, lookbacks=None, disp_pct=None) -> pd.Series:
|
||||||
|
cfg = dict(XS_CFG)
|
||||||
|
if lookbacks is not None:
|
||||||
|
cfg["lookbacks"] = lookbacks
|
||||||
|
if disp_pct is not None:
|
||||||
|
cfg["disp_pct"] = disp_pct
|
||||||
|
C = load_prices()
|
||||||
|
px = C.values
|
||||||
|
n, A = px.shape
|
||||||
|
lookbacks_, Hc, k, mode, tv = (cfg["lookbacks"], cfg["H"], cfg["k"],
|
||||||
|
cfg["mode"], cfg["target_vol"])
|
||||||
|
disp_pct_ = cfg.get("disp_pct", 0)
|
||||||
|
minhist = 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 % Hc == phase:
|
||||||
|
rLs = [px[i] / px[i - L] - 1.0 for L 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)
|
||||||
|
lo, hi = order[:k], order[-k:]
|
||||||
|
if mode == "mom":
|
||||||
|
w[hi] = 0.5 / k
|
||||||
|
w[lo] = -0.5 / k
|
||||||
|
else:
|
||||||
|
w[lo] = 0.5 / k
|
||||||
|
w[hi] = -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) # posizioni SHIFTATE (held t+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)
|
||||||
|
return pd.Series(s.values * scale, index=C.index)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. SANITY: replica a fase canonica == _xsec_returns() (bit-exact)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def sanity() -> None:
|
||||||
|
ref = _xsec_returns()
|
||||||
|
mine = xs_phase(0)
|
||||||
|
assert len(mine) == len(ref) and mine.index.equals(ref.index), "SANITY index mismatch"
|
||||||
|
dmax = float(np.max(np.abs(mine.values - ref.values)))
|
||||||
|
assert dmax == 0.0, f"SANITY FAIL: replica phase=0 != _xsec_returns (max|dif|={dmax:.3e})"
|
||||||
|
m = dmetrics(ref)
|
||||||
|
print(f"[SANITY] replica phase=0 == _xsec_returns(): OK (max|dif| = {dmax:.1e}, "
|
||||||
|
f"n={len(ref)}, start {ref.index.min().date()})")
|
||||||
|
print(f"[SANITY] canonica oggi: FULL Sh {m['sh_full']:.3f} / HOLD {m['sh_hold']:.3f} / "
|
||||||
|
f"DD {m['dd_full']:.1%} (dichiarati all'ammissione: 1.50 / 1.71 / 11% — dati mossi da allora)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. BOOTSTRAP alla maniera dello scettico (r0702_skeptic_offset.block_boot_stats)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _sh_mat(R: np.ndarray) -> np.ndarray:
|
||||||
|
mu = R.mean(axis=1)
|
||||||
|
sd = R.std(axis=1)
|
||||||
|
return np.where(sd > 0, mu / sd, 0.0) * np.sqrt(365.25)
|
||||||
|
|
||||||
|
|
||||||
|
def block_boot(M: np.ndarray, B: int, block: int, rng) -> dict:
|
||||||
|
n, K = M.shape
|
||||||
|
nblocks = int(np.ceil(n / block))
|
||||||
|
g0s, gmaxs, meds, sh0s = [], [], [], []
|
||||||
|
done = 0
|
||||||
|
while done < B:
|
||||||
|
b = min(500, B - done)
|
||||||
|
starts = rng.integers(0, n, size=(b, nblocks))
|
||||||
|
idx = (starts[:, :, None] + np.arange(block)[None, None, :]) % n
|
||||||
|
idx = idx.reshape(b, -1)[:, :n]
|
||||||
|
R = M[idx] # (b, n, K)
|
||||||
|
Sh = np.stack([_sh_mat(R[:, :, kk]) for kk in range(K)], axis=1)
|
||||||
|
gg = np.empty_like(Sh)
|
||||||
|
for h in range(K):
|
||||||
|
gg[:, h] = Sh[:, h] - np.median(np.delete(Sh, h, axis=1), axis=1)
|
||||||
|
g0s.append(gg[:, 0])
|
||||||
|
gmaxs.append(gg.max(axis=1))
|
||||||
|
meds.append(np.median(Sh, axis=1))
|
||||||
|
sh0s.append(Sh[:, 0])
|
||||||
|
done += b
|
||||||
|
return dict(g0=np.concatenate(g0s), gmax=np.concatenate(gmaxs),
|
||||||
|
med=np.concatenate(meds), sh0=np.concatenate(sh0s))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def main() -> None:
|
||||||
|
print("=" * 100)
|
||||||
|
print("r0702 — XS01 rebalance PHASE timing-luck: 10 fasi del ciclo H=10 "
|
||||||
|
"(metodologia = audit ancore TP01)")
|
||||||
|
print(f"XS_CFG={XS_CFG} universo={len(load_prices().columns)} major HL "
|
||||||
|
f"HOLD-OUT >= {HOLDOUT.date()}")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
sanity()
|
||||||
|
|
||||||
|
# ---- (2) tabella delle 10 fasi ----------------------------------------
|
||||||
|
phases = {p: xs_phase(p) for p in range(H)}
|
||||||
|
T = pd.DataFrame({p: dmetrics(s) for p, s in phases.items()}).T
|
||||||
|
print("\n--- (2) PER-FASE (parametri IDENTICI, zero tuning; fase = i % 10 del ciclo) ---")
|
||||||
|
print(f"{'fase':>4} {'ShFULL':>7} {'CAGRf':>7} {'DDfull':>7} {'ShHOLD':>7} "
|
||||||
|
f"{'CAGRh':>7} {'DDhold':>7}")
|
||||||
|
for p, r in T.iterrows():
|
||||||
|
tag = " <- canonica" if p == 0 else ""
|
||||||
|
print(f"{p:>4} {r.sh_full:>7.3f} {r.cagr_full:>6.1%} {r.dd_full:>6.1%} "
|
||||||
|
f"{r.sh_hold:>7.3f} {r.cagr_hold:>6.1%} {r.dd_hold:>6.1%}{tag}")
|
||||||
|
print("\nDistribuzione fra le 10 fasi (min / mediana / max / std) [percentile della canonica]:")
|
||||||
|
for col, lbl in (("sh_full", "Sharpe FULL"), ("sh_hold", "Sharpe HOLD"),
|
||||||
|
("cagr_full", "CAGR FULL"), ("dd_full", "maxDD FULL"),
|
||||||
|
("dd_hold", "maxDD HOLD")):
|
||||||
|
v = T[col].values.astype(float)
|
||||||
|
print(f" {lbl:<14} {v.min():>7.3f} / {np.median(v):>7.3f} / {v.max():>7.3f} "
|
||||||
|
f"/ std {v.std():.3f} canonica al {pctl_of(v, v[0]):.0f}° pctl (val {v[0]:.3f})")
|
||||||
|
|
||||||
|
hold_v = T["sh_hold"].values.astype(float)
|
||||||
|
full_v = T["sh_full"].values.astype(float)
|
||||||
|
med_hold = float(np.median(hold_v))
|
||||||
|
p_med = int(min(range(H), key=lambda p: (abs(hold_v[p] - med_hold), p)))
|
||||||
|
p_worst = int(np.argmin(hold_v))
|
||||||
|
p_best = int(np.argmax(hold_v))
|
||||||
|
print(f"\n fase MEDIANA (per ShHOLD) = {p_med} | PEGGIORE = {p_worst} | MIGLIORE = {p_best}")
|
||||||
|
|
||||||
|
# correlazione fra fasi (contestualizza il bootstrap)
|
||||||
|
Jf = pd.concat(phases, axis=1, join="inner").dropna()
|
||||||
|
Mh = Jf[Jf.index >= HOLDOUT].values
|
||||||
|
cor = np.corrcoef(Mh.T)
|
||||||
|
iu = np.triu_indices(H, 1)
|
||||||
|
print(f" correlazione daily fra fasi (hold-out): mediana {np.median(cor[iu]):.3f}, "
|
||||||
|
f"min {cor[iu].min():.3f}")
|
||||||
|
|
||||||
|
# ---- (3) ensemble delle 10 fasi ----------------------------------------
|
||||||
|
ens = pd.Series(Jf.values.mean(axis=1), index=Jf.index)
|
||||||
|
me, m0 = dmetrics(ens), dmetrics(phases[0])
|
||||||
|
print("\n--- (3) ENSEMBLE (media dei ritorni delle 10 fasi = 1/10 capitale per fase) ---")
|
||||||
|
print(f"{'config':<18} {'ShFULL':>7} {'CAGRf':>7} {'DDfull':>7} {'ShHOLD':>7} {'DDhold':>7}")
|
||||||
|
for name, m in (("canonica (f0)", m0), ("ensemble 10 fasi", me)):
|
||||||
|
print(f"{name:<18} {m['sh_full']:>7.3f} {m['cagr_full']:>6.1%} {m['dd_full']:>6.1%} "
|
||||||
|
f"{m['sh_hold']:>7.3f} {m['dd_hold']:>6.1%}")
|
||||||
|
|
||||||
|
# ---- (4) impatto sul book 5-sleeve -------------------------------------
|
||||||
|
print("\n--- (4) BOOK 5-sleeve (TP 33 / XS 15 / VRP 12 / SKH 20 / GTAA 20, combine_outer, "
|
||||||
|
"attivazione era crypto) ---")
|
||||||
|
from src.portfolio.sleeves import (_gtaa_daily_returns, _skyhook_returns,
|
||||||
|
_tp01_returns, _vrp_combo_returns)
|
||||||
|
TPd = to_daily(_tp01_returns())
|
||||||
|
fixed = dict(TP=TPd, VRP=to_daily(_vrp_combo_returns()),
|
||||||
|
SKH=to_daily(_skyhook_returns()), GTAA=to_daily(_gtaa_daily_returns()))
|
||||||
|
Wt = dict(TP=0.33, XS=0.15, VRP=0.12, SKH=0.20, GTAA=0.20)
|
||||||
|
lo = TPd.index.min()
|
||||||
|
|
||||||
|
def book(xs: pd.Series | None) -> pd.Series:
|
||||||
|
cols = dict(fixed)
|
||||||
|
if xs is not None:
|
||||||
|
cols["XS"] = to_daily(xs)
|
||||||
|
wt = {k: v for k, v in Wt.items() if k in cols}
|
||||||
|
return combine_outer(cols, wt, lo=lo)
|
||||||
|
|
||||||
|
rows = [("XS canonica (f0)", phases[0]), (f"XS fase mediana ({p_med})", phases[p_med]),
|
||||||
|
(f"XS fase peggiore ({p_worst})", phases[p_worst]),
|
||||||
|
(f"XS fase migliore ({p_best})", phases[p_best]),
|
||||||
|
("XS ensemble 10f", ens), ("senza XS01 (rinorm.)", None)]
|
||||||
|
print(f"{'book con':<24} {'ShHOLD':>7} {'ShFULL':>7} {'DDfull':>7} {'DDhold':>7}")
|
||||||
|
book_stats = {}
|
||||||
|
for name, xs in rows:
|
||||||
|
b = book(xs)
|
||||||
|
bh = b[b.index >= HOLDOUT]
|
||||||
|
book_stats[name] = (_sh(bh), _sh(b))
|
||||||
|
print(f"{name:<24} {_sh(bh):>7.3f} {_sh(b):>7.3f} {_dd(b):>6.1%} {_dd(bh):>6.1%}")
|
||||||
|
bh0 = book_stats["XS canonica (f0)"][0]
|
||||||
|
print(f"\n fortuna di fase ereditata dal book HOLD: canonica {bh0:.3f} vs mediana "
|
||||||
|
f"{book_stats[f'XS fase mediana ({p_med})'][0]:.3f} "
|
||||||
|
f"(delta {bh0 - book_stats[f'XS fase mediana ({p_med})'][0]:+.3f}) | vs peggiore "
|
||||||
|
f"{book_stats[f'XS fase peggiore ({p_worst})'][0]:.3f} "
|
||||||
|
f"(delta {bh0 - book_stats[f'XS fase peggiore ({p_worst})'][0]:+.3f}) | vs senza-XS "
|
||||||
|
f"{book_stats['senza XS01 (rinorm.)'][0]:.3f}")
|
||||||
|
|
||||||
|
# ---- (5) gate di ammissione alla fase mediana: plateau ------------------
|
||||||
|
print("\n--- (5) GATE DI AMMISSIONE — plateau ricalcolato alla fase MEDIANA "
|
||||||
|
f"({p_med}) vs canonica (0) ---")
|
||||||
|
print("(ammissione XS01: FULL 1.50 / HOLD 1.71 / DD 11%, plateau lookback + disp_pct 15-35)")
|
||||||
|
print(f"{'variante':<28} {'f0 FULL':>8} {'f0 HOLD':>8} {'f0 DD':>7} "
|
||||||
|
f"{'fMED FULL':>9} {'fMED HOLD':>9} {'fMED DD':>8}")
|
||||||
|
grid = ([(f"disp_pct={dp} (lb 30,90)", dict(disp_pct=dp)) for dp in (15, 20, 25, 30, 35)]
|
||||||
|
+ [(f"lookbacks={lb} (p30)", dict(lookbacks=lb))
|
||||||
|
for lb in ((30,), (60,), (90,), (30, 60, 90))])
|
||||||
|
for name, kw in grid:
|
||||||
|
a = dmetrics(xs_phase(0, **kw))
|
||||||
|
b = dmetrics(xs_phase(p_med, **kw))
|
||||||
|
star = " *" if kw == dict(disp_pct=30) or kw.get("lookbacks") == (30, 90) else ""
|
||||||
|
print(f"{name:<28} {a['sh_full']:>8.3f} {a['sh_hold']:>8.3f} {a['dd_full']:>6.1%} "
|
||||||
|
f"{b['sh_full']:>9.3f} {b['sh_hold']:>9.3f} {b['dd_full']:>7.1%}{star}")
|
||||||
|
mmed = dmetrics(phases[p_med])
|
||||||
|
print(f"\n cella canonica alla fase mediana: FULL {mmed['sh_full']:.3f} "
|
||||||
|
f"(ammesso con 1.50) / HOLD {mmed['sh_hold']:.3f} (ammesso con 1.71) / "
|
||||||
|
f"DD {mmed['dd_full']:.1%} (ammesso con 11%)")
|
||||||
|
n_full_pos = int((full_v >= 1.0).sum())
|
||||||
|
n_hold_pos = int((hold_v >= 1.0).sum())
|
||||||
|
print(f" fasi con FULL>=1.0: {n_full_pos}/10 | fasi con HOLD>=1.0: {n_hold_pos}/10 | "
|
||||||
|
f"fasi con HOLD<=0: {int((hold_v <= 0).sum())}/10")
|
||||||
|
|
||||||
|
# ---- (6) bootstrap: la canonica e' speciale? ----------------------------
|
||||||
|
print("\n--- (6) BOOTSTRAP (block, congiunto sulle 10 fasi, hold-out) ---")
|
||||||
|
sh_hold_obs = _sh_mat(Mh.T)
|
||||||
|
g0_obs = float(sh_hold_obs[0] - np.median(sh_hold_obs[1:]))
|
||||||
|
print(f"hold-out: {Mh.shape[0]} giorni, 10 fasi; Sh canonica {sh_hold_obs[0]:.3f}, "
|
||||||
|
f"mediana altre {np.median(sh_hold_obs[1:]):.3f}, spike osservato g0 = {g0_obs:+.3f}")
|
||||||
|
for blk in (10, 20, 40):
|
||||||
|
bs = block_boot(Mh, B_BOOT, blk, np.random.default_rng(RNG_SEED + blk))
|
||||||
|
p_any = float(np.mean(bs["gmax"] >= g0_obs))
|
||||||
|
ci_g0 = np.percentile(bs["g0"], [2.5, 97.5])
|
||||||
|
ci_med = np.percentile(bs["med"], [2.5, 97.5])
|
||||||
|
ci_sh0 = np.percentile(bs["sh0"], [2.5, 97.5])
|
||||||
|
print(f" block={blk:>2}: P(spike di UNA QUALSIASI fase >= {g0_obs:+.2f}) = {p_any:.3f} | "
|
||||||
|
f"CI95 g0 [{ci_g0[0]:+.2f},{ci_g0[1]:+.2f}] | CI95 Sh mediana-fasi "
|
||||||
|
f"[{ci_med[0]:+.2f},{ci_med[1]:+.2f}] | CI95 Sh canonica [{ci_sh0[0]:+.2f},{ci_sh0[1]:+.2f}]")
|
||||||
|
|
||||||
|
# CI sulla FULL della canonica (storia corta -> quanto sono larghi?)
|
||||||
|
Mf = Jf.values
|
||||||
|
bsf = block_boot(Mf, B_BOOT, 20, np.random.default_rng(RNG_SEED))
|
||||||
|
ci_f0 = np.percentile(bsf["sh0"], [2.5, 97.5])
|
||||||
|
ci_fmed = np.percentile(bsf["med"], [2.5, 97.5])
|
||||||
|
print(f" FULL (block=20): CI95 Sh canonica [{ci_f0[0]:+.2f},{ci_f0[1]:+.2f}] | "
|
||||||
|
f"CI95 Sh mediana-fasi [{ci_fmed[0]:+.2f},{ci_fmed[1]:+.2f}] "
|
||||||
|
f"(~2.5 anni di storia: intervalli larghi)")
|
||||||
|
|
||||||
|
# ---- (7) caveat ---------------------------------------------------------
|
||||||
|
print("\n--- (7) CAVEAT DICHIARATI ---")
|
||||||
|
print(" (a) storia corta ~2.5y (914g, hold-out ~548g): i CI95 qui sopra quantificano "
|
||||||
|
"l'incertezza — nessuna stima puntuale di Sharpe e' affidabile a +/-1.")
|
||||||
|
print(" (b) dimensione ORA-DEL-GIORNO non auditata: le barre HL in data/raw sono solo 1d "
|
||||||
|
"(ancora daily fissa del feed) -> luck residua non testabile con i dati certificati.")
|
||||||
|
print(" (c) XS01 e' STAT-MODE (19 gambe, non eseguito live): l'impatto e' sulla STIMA del "
|
||||||
|
"book (reporting/ammissione), non sull'operativita' del book live Deribit.")
|
||||||
|
print("\nFatto. Nessuna selezione per-fase: parametri identici, giudizio su distribuzione "
|
||||||
|
"delle fasi + ensemble; l'hold-out e' solo riportato.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""r0702_capital_scaling.py — RI-PRICING dei MURI DI SCALA a capitale {600, 2000, 3500, 5000}.
|
||||||
|
|
||||||
|
NON e' ricerca di strategie nuove: e' la ri-quantificazione ONESTA dei vincoli di scala gia'
|
||||||
|
documentati (tutti quantificati a $600) in vista del funding del conto live a 2-5k $:
|
||||||
|
|
||||||
|
(1) TP01 smallcap haircut — al.eval_weights_smallcap ai 4 capitali (budget per-asset = C/2,
|
||||||
|
book 50/50): haircut Sharpe modellato→eseguibile, ordini
|
||||||
|
eseguiti/saltati, turnover eseguito, fee drag (tetto noto
|
||||||
|
~0.4%/anno: a $600 il min-order fa da banda d'isteresi gratuita
|
||||||
|
— ondata timing 2026-07-02 — a 2-5k l'effetto si riduce).
|
||||||
|
(2) Book live TP01+SKH01 75/25 — replica CONCETTUALE dei target di src/live/book.py (formula
|
||||||
|
net = clamp(0.5*E*(0.75*tp_frac + 0.25*skh_sign), ±cap)) sulla
|
||||||
|
griglia 230m storica, SENZA importare il modulo live: notional
|
||||||
|
tipici, % ordini sub-min, e quanto book resta NON investito se
|
||||||
|
il cap $300/asset non viene alzato.
|
||||||
|
(3) Tranching TP01 K=2/K=4 — (diario 2026-07-02: "NO deploy a $600, rivalutare a >=5-10k"):
|
||||||
|
% di ribilanciamenti-tranche >= min-order $5 a ogni capitale.
|
||||||
|
NB il blocco feed-intraday-fuori-path-certificato e' SEPARATO
|
||||||
|
e resta (dichiarato in output).
|
||||||
|
(4) STATARB-RESID — stesso smallcap a 2 gambe (eval_spread_smallcap di
|
||||||
|
orthogonal_signals, W=45/sgn=+1 CONGELATI come in
|
||||||
|
scripts/live/paper_statarb.py — qui solo LETTI, mai toccati).
|
||||||
|
(5) Opzioni Deribit — matematica STATICA (nessun backtest): min 0.1 BTC / 1 ETH per
|
||||||
|
gamba, margine defined-risk dello spread VRP01, fee Deribit
|
||||||
|
0.03% notional cap 12.5% premio. Solo pricing del muro: regola
|
||||||
|
standing "niente short-vol da modello in deploy" INVARIATA.
|
||||||
|
(6) XS01 (19 gambe HL, "~20k") — replica della matrice pesi di sleeves._xsec_returns (copia
|
||||||
|
locale, sola lettura del modulo) + min order HL ~$10/gamba:
|
||||||
|
conferma/rettifica della soglia. CC01: conti statici gambe.
|
||||||
|
(7) SINTESI — tabella capitale × vincoli + raccomandazioni CONFIG (solo
|
||||||
|
proposte: config/live.json NON viene toccato) + aspettativa
|
||||||
|
onesta EUR/giorno col CAGR de-luckato del book (10-15%, audit
|
||||||
|
SKH01 2026-07-02 path orario — NON i numeri canonici).
|
||||||
|
|
||||||
|
Causalita'/pandas: niente DatetimeIndex.view("int64") — epoca ms esplicita ovunque (timestamp gia'
|
||||||
|
int64 nei frame certificati). Fee 0.10% RT (0.05%/lato). Nessun file di produzione toccato.
|
||||||
|
|
||||||
|
uv run python scripts/research/r0702_capital_scaling.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from scipy.stats import norm
|
||||||
|
|
||||||
|
ROOT = Path("/opt/docker/PythagorasGoal")
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
from src.data.downloader import load_data # noqa: E402
|
||||||
|
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
|
||||||
|
from src.strategies.skyhook import LTF_MIN, SKH01_V2_DD, build_frames, skyhook_entries # noqa: E402
|
||||||
|
from orthogonal_signals import build_joint, eval_spread, eval_spread_smallcap, f_statarb_resid # noqa: E402
|
||||||
|
|
||||||
|
CAPITALS = (600.0, 2000.0, 3500.0, 5000.0)
|
||||||
|
MIN_ORDER = 5.0 # Deribit min order USD (config/live.json)
|
||||||
|
MIN_ORDER_HL = 10.0 # Hyperliquid min order USD (~$10)
|
||||||
|
CAP_NOW = 300.0 # max_notional_per_asset_usd corrente
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
MS_D = 86_400_000
|
||||||
|
MS_LTF = LTF_MIN * 60_000
|
||||||
|
FEE_SIDE = al.FEE_SIDE # 0.0005
|
||||||
|
|
||||||
|
TP = TrendPortfolio(**CANONICAL)
|
||||||
|
|
||||||
|
|
||||||
|
def _years(ts_ms: np.ndarray) -> float:
|
||||||
|
return max((int(ts_ms[-1]) - int(ts_ms[0])) / (MS_D * 365.25), 1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# (1) TP01 smallcap haircut ai 4 capitali (budget per-asset = C/2, book 50/50)
|
||||||
|
# ===========================================================================
|
||||||
|
def smallcap_counts(tgt: np.ndarray, capital: float, min_order: float = MIN_ORDER) -> dict:
|
||||||
|
"""Replica il path di skip di al.eval_weights_smallcap per CONTARE ordini eseguiti vs
|
||||||
|
desiderati (la funzione ufficiale riporta metriche, non i saltati)."""
|
||||||
|
tgt = np.clip(np.nan_to_num(np.asarray(tgt, float)), -10, 10)
|
||||||
|
cur = 0.0
|
||||||
|
n_exec = 0
|
||||||
|
n_want = 0
|
||||||
|
for x in tgt:
|
||||||
|
d = abs(x - cur)
|
||||||
|
if d * capital >= min_order:
|
||||||
|
cur = x
|
||||||
|
n_exec += 1
|
||||||
|
n_want += 1
|
||||||
|
elif d * capital >= 0.01: # un cambio era desiderato ma sub-min-order
|
||||||
|
n_want += 1
|
||||||
|
return dict(n_exec=n_exec, n_want=n_want)
|
||||||
|
|
||||||
|
|
||||||
|
def section1():
|
||||||
|
print("=" * 100)
|
||||||
|
print("(1) TP01 CANONICO 1d — smallcap haircut ai 4 capitali (budget per-asset = C/2, book 50/50)")
|
||||||
|
print(" min order $5 Deribit; fee 0.05%/lato; fee-drag = fee_side * turnover-eseguito/anno")
|
||||||
|
print("=" * 100)
|
||||||
|
out = {}
|
||||||
|
d1 = {a: al.get(a, "1d") for a in ASSETS}
|
||||||
|
tgt = {a: np.nan_to_num(TP.target_series(d1[a])) for a in ASSETS}
|
||||||
|
modeled = {a: al.eval_weights(d1[a], tgt[a]) for a in ASSETS}
|
||||||
|
for a in ASSETS:
|
||||||
|
m = modeled[a]
|
||||||
|
print(f" [{a}] modellato (fiction ribilanciamento continuo): Sh FULL {m['full']['sharpe']:.2f} "
|
||||||
|
f"turnover {m['turnover_per_year']:.1f}x/anno -> fee drag modellato "
|
||||||
|
f"{m['turnover_per_year'] * FEE_SIDE * 100:.2f}%/anno")
|
||||||
|
hdr = (f" {'capitale':>8} {'asset':>5} {'Sh mod':>7} {'Sh real':>8} {'haircut':>8} "
|
||||||
|
f"{'ordini/anno':>12} {'saltati%':>9} {'turn exec':>10} {'feedrag%':>9}")
|
||||||
|
print(hdr)
|
||||||
|
for C in CAPITALS:
|
||||||
|
rows = []
|
||||||
|
for a in ASSETS:
|
||||||
|
r = al.eval_weights_smallcap(d1[a], tgt[a], capital=C / 2.0, min_order=MIN_ORDER)
|
||||||
|
cnt = smallcap_counts(tgt[a], C / 2.0)
|
||||||
|
yrs = _years(d1[a]["timestamp"].values.astype("int64"))
|
||||||
|
skipped = 1.0 - cnt["n_exec"] / max(cnt["n_want"], 1)
|
||||||
|
drag = r["executed_turnover_per_year"] * FEE_SIDE * 100
|
||||||
|
rows.append(dict(asset=a, mod=r["modeled"]["sharpe"], real=r["realistic"]["sharpe"],
|
||||||
|
hc=r["sharpe_haircut"], opy=cnt["n_exec"] / yrs, skip=skipped,
|
||||||
|
turn=r["executed_turnover_per_year"], drag=drag))
|
||||||
|
print(f" {C:>8.0f} {a:>5} {r['modeled']['sharpe']:>7.2f} {r['realistic']['sharpe']:>8.2f} "
|
||||||
|
f"{r['sharpe_haircut']:>8.3f} {cnt['n_exec'] / yrs:>12.0f} {skipped * 100:>8.1f}% "
|
||||||
|
f"{r['executed_turnover_per_year']:>10.1f} {drag:>9.2f}")
|
||||||
|
out[C] = dict(haircut=float(np.mean([x["hc"] for x in rows])),
|
||||||
|
drag=float(np.mean([x["drag"] for x in rows])),
|
||||||
|
skipped=float(np.mean([x["skip"] for x in rows])))
|
||||||
|
print(" NB contesto (ondata timing 2026-07-02): a $600 il min-order E' la banda d'isteresi ottimale")
|
||||||
|
print(" (ordini -74% a costo ~0). Ai capitali alti la banda implicita si stringe (5$/2500$ = 0.2% del")
|
||||||
|
print(" budget-asset) -> si eseguono quasi tutti i micro-ribilanci e il fee drag risale verso il")
|
||||||
|
print(" modellato (tetto noto ~0.4%/anno) — e' il costo, atteso e piccolo, della fedelta' al modello.")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# (2) BOOK LIVE TP01+SKH01 75/25 — replica concettuale dei target (griglia 230m)
|
||||||
|
# ===========================================================================
|
||||||
|
def skh_sign_series(asset: str) -> tuple[pd.DataFrame, np.ndarray]:
|
||||||
|
"""Segno SKH01 (+1/-1/0) alla DECISIONE di ogni barra 230m chiusa, replicando la logica
|
||||||
|
non-overlap entry+exit (TP/SL/max_bars) di sleeves._skyhook_positions su tutta la storia."""
|
||||||
|
ltf, htf = build_frames(load_data(asset, "5m"))
|
||||||
|
ent = skyhook_entries(ltf, htf, SKH01_V2_DD)
|
||||||
|
H = ltf["high"].values
|
||||||
|
L = ltf["low"].values
|
||||||
|
n = len(ltf)
|
||||||
|
sgn = np.zeros(n)
|
||||||
|
i = 0
|
||||||
|
while i < n:
|
||||||
|
e = ent[i]
|
||||||
|
if e is None:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
d, sl, tp, mb = e["dir"], e["sl"], e["tp"], e["max_bars"]
|
||||||
|
exit_idx = None
|
||||||
|
for s in range(1, mb + 1):
|
||||||
|
j = i + s
|
||||||
|
if j >= n:
|
||||||
|
break
|
||||||
|
hit = (L[j] <= sl or H[j] >= tp) if d == 1 else (H[j] >= sl or L[j] <= tp)
|
||||||
|
if hit or s == mb:
|
||||||
|
exit_idx = j
|
||||||
|
break
|
||||||
|
if exit_idx is None: # trade ancora aperto a fine storia
|
||||||
|
sgn[i:] = d
|
||||||
|
break
|
||||||
|
sgn[i:exit_idx] = d # alla decisione di exit_idx il trade e' gia' chiuso
|
||||||
|
i = exit_idx + 1
|
||||||
|
return ltf, sgn
|
||||||
|
|
||||||
|
|
||||||
|
def tp_frac_on_ltf(asset: str, ltf: pd.DataFrame) -> np.ndarray:
|
||||||
|
"""tp_frac (target TP01 daily, causale) mappato sulla griglia 230m: per ogni chiusura 230m
|
||||||
|
l'ultimo target daily la cui CHIUSURA nominale (open-label + 24h, epoca ms) e' <= chiusura 230m."""
|
||||||
|
d1 = al.get(asset, "1d")
|
||||||
|
tgt = np.nan_to_num(TP.target_series(d1))
|
||||||
|
close_d = d1["timestamp"].values.astype("int64") + MS_D
|
||||||
|
close_l = ltf["timestamp"].values.astype("int64") + MS_LTF
|
||||||
|
idx = np.searchsorted(close_d, close_l, side="right") - 1
|
||||||
|
return np.where(idx >= 0, tgt[np.maximum(idx, 0)], 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def book_sim(tpf: np.ndarray, sgn: np.ndarray, ts_ms: np.ndarray,
|
||||||
|
C: float, cap: float, min_order: float = MIN_ORDER) -> dict:
|
||||||
|
"""Replica PURA della formula di book.book_net_target + build_book_order (senza import live):
|
||||||
|
equity fissa = C (isola l'effetto scala; il live usa l'equity reale), ordini market al delta."""
|
||||||
|
raw = 0.5 * C * (0.75 * np.maximum(np.nan_to_num(tpf), 0.0) + 0.25 * np.nan_to_num(sgn))
|
||||||
|
net = np.clip(raw, -cap, cap)
|
||||||
|
pos = 0.0
|
||||||
|
executed = []
|
||||||
|
n_skip = 0
|
||||||
|
for x in net:
|
||||||
|
d = x - pos
|
||||||
|
if abs(d) >= min_order:
|
||||||
|
executed.append(abs(d))
|
||||||
|
pos = x
|
||||||
|
elif abs(d) >= 0.01:
|
||||||
|
n_skip += 1
|
||||||
|
yrs = _years(ts_ms)
|
||||||
|
ex = np.asarray(executed) if executed else np.asarray([0.0])
|
||||||
|
nz = np.abs(raw) > 1e-9
|
||||||
|
mean_raw = float(np.mean(np.abs(raw[nz]))) if nz.any() else 0.0
|
||||||
|
mean_net = float(np.mean(np.minimum(np.abs(raw[nz]), cap))) if nz.any() else 0.0
|
||||||
|
return dict(orders_py=len(executed) / yrs, med_order=float(np.median(ex)),
|
||||||
|
p90_order=float(np.percentile(ex, 90)),
|
||||||
|
sub_min=n_skip / max(n_skip + len(executed), 1),
|
||||||
|
cap_bind=float(np.mean(np.abs(raw) > cap)),
|
||||||
|
invested=(mean_net / mean_raw) if mean_raw > 0 else 1.0,
|
||||||
|
mean_raw=mean_raw)
|
||||||
|
|
||||||
|
|
||||||
|
def section2():
|
||||||
|
print()
|
||||||
|
print("=" * 100)
|
||||||
|
print("(2) BOOK LIVE TP01+SKH01 75/25 — replica formula book.py su griglia 230m storica")
|
||||||
|
print(" net = clamp(0.5*C*(0.75*tp_frac + 0.25*skh_sign), +/-cap); equity fissa = C; min order $5")
|
||||||
|
print("=" * 100)
|
||||||
|
data = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
ltf, sgn = skh_sign_series(a)
|
||||||
|
tpf = tp_frac_on_ltf(a, ltf)
|
||||||
|
data[a] = (ltf["timestamp"].values.astype("int64"), tpf, sgn)
|
||||||
|
print(f" [{a}] barre 230m: {len(sgn)} tempo con SKH aperto: {np.mean(sgn != 0) * 100:.1f}% "
|
||||||
|
f"tp_frac medio: {np.mean(np.maximum(tpf, 0)):.2f}")
|
||||||
|
out = {}
|
||||||
|
hdr = (f" {'capitale':>8} {'cap/asset':>10} {'asset':>5} {'ordini/anno':>12} {'medONot$':>9} "
|
||||||
|
f"{'p90$':>7} {'submin%':>8} {'capbind%':>9} {'investito%':>11}")
|
||||||
|
print(hdr)
|
||||||
|
for C in CAPITALS:
|
||||||
|
for cap, lbl in ((CAP_NOW, "300 (oggi)"), (C / 2.0, "C/2 (prop)")):
|
||||||
|
if C == 600.0 and cap != CAP_NOW:
|
||||||
|
continue # a 600 i due scenari coincidono
|
||||||
|
inv = []
|
||||||
|
for a in ASSETS:
|
||||||
|
ts, tpf, sgn = data[a]
|
||||||
|
r = book_sim(tpf, sgn, ts, C, cap)
|
||||||
|
inv.append(r["invested"])
|
||||||
|
print(f" {C:>8.0f} {lbl:>10} {a:>5} {r['orders_py']:>12.0f} {r['med_order']:>9.0f} "
|
||||||
|
f"{r['p90_order']:>7.0f} {r['sub_min'] * 100:>7.1f}% {r['cap_bind'] * 100:>8.1f}% "
|
||||||
|
f"{r['invested'] * 100:>10.1f}%")
|
||||||
|
out[(C, lbl)] = float(np.mean(inv))
|
||||||
|
print(" Lettura: 'investito%' = quota del target-notional desiderato che il cap/asset lascia")
|
||||||
|
print(" effettivamente a mercato (media sulle barre con target != 0). Col cap fermo a $300 il book")
|
||||||
|
print(" a 2-5k gira sotto-investito in modo strutturale; col cap = C/2 il rapporto attuale (300/600)")
|
||||||
|
print(" e' preservato e il vincolo torna a mordere solo sulle leve alte (tp_frac -> 2x).")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# (3) TRANCHING TP01 K=2/K=4 — eseguibilita' delle tranche a 2-5k
|
||||||
|
# ===========================================================================
|
||||||
|
def section3():
|
||||||
|
print()
|
||||||
|
print("=" * 100)
|
||||||
|
print("(3) TRANCHING TP01 (K ancore daily sfasate, 1/K del capitale per tranche)")
|
||||||
|
print(" ordine-tranche per asset = |Delta tgt| * C/(2K); eseguibile se >= $5")
|
||||||
|
print("=" * 100)
|
||||||
|
out = {}
|
||||||
|
dd = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
d1 = al.get(a, "1d")
|
||||||
|
tgt = np.nan_to_num(TP.target_series(d1))
|
||||||
|
d = np.abs(np.diff(tgt, prepend=0.0))
|
||||||
|
dd[a] = d[d > 1e-12] # solo i giorni in cui un ribilancio e' desiderato
|
||||||
|
print(f" [{a}] |Delta tgt| giornaliero (giorni con cambio): mediana {np.median(dd[a]):.4f} "
|
||||||
|
f"p25 {np.percentile(dd[a], 25):.4f} p75 {np.percentile(dd[a], 75):.4f}")
|
||||||
|
alld = np.concatenate([dd[a] for a in ASSETS])
|
||||||
|
print(f" {'capitale':>8} {'K':>3} {'$/tranche-asset':>16} {'ordine mediano $':>17} "
|
||||||
|
f"{'exec-eventi%':>13} {'exec-turnover%':>15}")
|
||||||
|
for C in CAPITALS:
|
||||||
|
for K in (1, 2, 4):
|
||||||
|
pt = C / (2.0 * K)
|
||||||
|
mask = alld * pt >= MIN_ORDER
|
||||||
|
ex_ev = float(np.mean(mask)) # % degli EVENTI di ribilancio
|
||||||
|
ex_tw = float(alld[mask].sum() / alld.sum()) # % del TURNOVER (massa) eseguibile
|
||||||
|
med = float(np.median(alld)) * pt
|
||||||
|
out[(C, K)] = ex_tw
|
||||||
|
print(f" {C:>8.0f} {K:>3} {pt:>16.0f} {med:>17.2f} {ex_ev * 100:>12.1f}% {ex_tw * 100:>14.1f}%")
|
||||||
|
print(" Lettura: 'exec-eventi%' basso e' in parte FISIOLOGICO (i micro-ribilanci vol-target saltati")
|
||||||
|
print(" = la banda d'isteresi gratuita); il degrado vero e' 'exec-turnover%': la quota della MASSA")
|
||||||
|
print(" di ribilanciamento che ogni tranche riesce a eseguire (i cambi grossi = entrate/uscite).")
|
||||||
|
print(" Il 'degenera in K=1 a $600' del diario e' la granularita' EVENTO: l'ordine-tranche mediano")
|
||||||
|
print(" K=2 resta sotto $5 perfino a 5k ($3.7) -> le tranche non fanno il fine-tuning giornaliero,")
|
||||||
|
print(" ma da ~2k in su eseguono >95% della massa (entrate/uscite) ciascuna alla propria ancora.")
|
||||||
|
print(" NB: distribuzione |Delta| presa dall'ancora canonica (proxy: le altre ancore hanno")
|
||||||
|
print(" distribuzioni simili — r0702_tp01_offset). BLOCCO SEPARATO E INVARIATO: il tranching")
|
||||||
|
print(" richiede decisioni intraday (ancore != 00:00) => feed intraday FUORI dal path certificato")
|
||||||
|
print(" daily del cron attuale. Anche dove il min-order non degenera piu' K=2 in K=1, il deploy")
|
||||||
|
print(" resta condizionato a quel lavoro di feed/infra (diario 2026-07-02-timing-crt-wave).")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# (4) STATARB-RESID — haircut REAL ai 4 capitali (2 gambe, W=45/sgn=+1 congelati)
|
||||||
|
# ===========================================================================
|
||||||
|
def section4():
|
||||||
|
print()
|
||||||
|
print("=" * 100)
|
||||||
|
print("(4) STATARB-RESID (paper_statarb, config CONGELATA W=45 sgn=+1) — haircut min-order a scala")
|
||||||
|
print("=" * 100)
|
||||||
|
j = build_joint("1d")
|
||||||
|
pos = f_statarb_resid(W=45, sgn=+1)(j) # identica a paper_statarb (solo lettura parametri)
|
||||||
|
mod = eval_spread(j, pos)
|
||||||
|
print(f" modellato (2 gambe, fee 0.05%/lato x2): Sh FULL {mod['full']['sharpe']:.2f} "
|
||||||
|
f"turnover {mod['turnover']:.1f}x/anno (per gamba)")
|
||||||
|
out = {}
|
||||||
|
print(f" {'capitale':>8} {'Sh mod':>7} {'Sh real':>8} {'haircut':>8} {'trade eseguiti':>15}")
|
||||||
|
for C in CAPITALS:
|
||||||
|
r = eval_spread_smallcap(j, pos, capital=C, min_order=MIN_ORDER)
|
||||||
|
out[C] = r["sharpe_haircut"]
|
||||||
|
print(f" {C:>8.0f} {r['modeled']['sharpe']:>7.2f} {r['realistic']['sharpe']:>8.2f} "
|
||||||
|
f"{r['sharpe_haircut']:>8.3f} {r['n_executed_trades']:>15d}")
|
||||||
|
print(" (il vincolo binding e' il nozionale PER-GAMBA |Delta pos|*C >= $5; a 1d il turnover e'")
|
||||||
|
print(" bassissimo -> haircut gia' ~0 a $600, a 2-5k e' rumore. Il muro di statarb NON e' la scala:")
|
||||||
|
print(" e' l'EDGE — DSR 0.929 < 0.95, forward-monitor in corso.)")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# (5) OPZIONI DERIBIT — matematica statica del muro (VRP01 spread / min size)
|
||||||
|
# ===========================================================================
|
||||||
|
def _bs_put(S, K, T, sig):
|
||||||
|
if T <= 0 or sig <= 0:
|
||||||
|
return max(K - S, 0.0)
|
||||||
|
d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T))
|
||||||
|
return K * norm.cdf(-(d1 - sig * np.sqrt(T))) - S * norm.cdf(-d1)
|
||||||
|
|
||||||
|
|
||||||
|
def _strike_from_delta(S, T, sig, target_delta):
|
||||||
|
return S * np.exp(0.5 * sig ** 2 * T - (-norm.ppf(-target_delta)) * sig * np.sqrt(T))
|
||||||
|
|
||||||
|
|
||||||
|
def section5():
|
||||||
|
print()
|
||||||
|
print("=" * 100)
|
||||||
|
print("(5) OPZIONI DERIBIT — pricing STATICO del muro (nessun backtest, nessuna proposta di deploy)")
|
||||||
|
print(" struttura VRP01: put credit spread 7g, short delta -0.28 / long -0.10, defined-risk")
|
||||||
|
print(" min size: 0.1 BTC/gamba, 1 ETH/gamba; fee 0.03% notional cap 12.5% premio (per gamba)")
|
||||||
|
print("=" * 100)
|
||||||
|
T = 7.0 / 365.25
|
||||||
|
res = {}
|
||||||
|
for a, minc in (("ETH", 1.0), ("BTC", 0.1)):
|
||||||
|
d1 = al.get(a, "1d")
|
||||||
|
S = float(d1["close"].iloc[-1])
|
||||||
|
dv = pd.read_parquet(ROOT / "data" / "raw" / f"dvol_{a.lower()}.parquet")
|
||||||
|
iv_last = float(dv["close"].iloc[-1]) / 100.0
|
||||||
|
iv_med = float(dv["close"].iloc[-365:].median()) / 100.0
|
||||||
|
rows = {}
|
||||||
|
for lbl, sig in (("DVOL oggi", iv_last), ("DVOL mediana 1y", iv_med)):
|
||||||
|
Ks = _strike_from_delta(S, T, sig, -0.28)
|
||||||
|
Kl = _strike_from_delta(S, T, sig, -0.10)
|
||||||
|
prem_s = _bs_put(S, Ks, T, sig) * minc
|
||||||
|
prem_l = _bs_put(S, Kl, T, sig) * minc
|
||||||
|
credit = prem_s - prem_l
|
||||||
|
width = (Ks - Kl) * minc
|
||||||
|
maxloss = width - credit # margine defined-risk ~ max loss
|
||||||
|
fee = (min(0.0003 * S * minc, 0.125 * prem_s)
|
||||||
|
+ min(0.0003 * S * minc, 0.125 * prem_l))
|
||||||
|
rows[lbl] = dict(S=S, sig=sig, credit=credit, width=width, maxloss=maxloss,
|
||||||
|
fee=fee, net=credit - fee)
|
||||||
|
print(f" [{a} x{minc}] {lbl}: spot ${S:,.0f} IV {sig * 100:.0f}% strike {Ks:,.0f}/{Kl:,.0f}"
|
||||||
|
f" credito ${credit:,.2f} width ${width:,.2f} margine/max-loss ${maxloss:,.2f}"
|
||||||
|
f" fee 2 gambe ${fee:,.2f} credito NETTO ${credit - fee:,.2f}"
|
||||||
|
f" ({(credit - fee) / max(credit, 1e-9) * 100:.0f}% del lordo)")
|
||||||
|
res[a] = rows["DVOL mediana 1y"]
|
||||||
|
print(f" + fee delivery a scadenza se ITM: 0.015% notional cap 12.5% (non inclusa sopra).")
|
||||||
|
m_eth = res["ETH"]["maxloss"]
|
||||||
|
m_btc = res["BTC"]["maxloss"]
|
||||||
|
print(f"\n {'capitale':>8} {'spread ETH conc. @12% peso':>27} {'@100% conto':>12} "
|
||||||
|
f"{'% conto/spread':>15}")
|
||||||
|
out = {}
|
||||||
|
for C in CAPITALS:
|
||||||
|
n12 = int((0.12 * C) // m_eth)
|
||||||
|
nfull = int(C // m_eth)
|
||||||
|
out[C] = n12
|
||||||
|
print(f" {C:>8.0f} {n12:>27d} {nfull:>12d} {m_eth / C * 100:>14.1f}%")
|
||||||
|
c_btc_1 = m_btc / 0.12
|
||||||
|
c_btc_3 = 3 * m_btc / 0.12
|
||||||
|
print(f"\n BTC options (0.1 BTC min): margine/max-loss ~${m_btc:,.0f}/spread -> a peso 12% servono"
|
||||||
|
f" ~${c_btc_1:,.0f} per 1 spread, ~${c_btc_3:,.0f} per granularita' minima (3 step di size).")
|
||||||
|
print(" Muro fee: il credito ETH sopravvive alle fee (cap 12.5% del premio ~ perdi al massimo un")
|
||||||
|
print(" quarto del credito con entry+delivery; il numero esatto sopra). Il muro VERO resta la regola")
|
||||||
|
print(" standing: NIENTE short-vol da modello in deploy (f di stress reale mai osservato).")
|
||||||
|
return out, m_eth, m_btc
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# (6) XS01 (19 gambe Hyperliquid) e CC01 — soglie di scala con conti espliciti
|
||||||
|
# ===========================================================================
|
||||||
|
def xs01_positions() -> tuple[pd.DatetimeIndex, np.ndarray]:
|
||||||
|
"""COPIA di sola-lettura della matrice pesi di src/portfolio/sleeves._xsec_returns
|
||||||
|
(stessi parametri XS_CFG / XS_UNIVERSE), che ritorna le POSIZIONI finali per gamba
|
||||||
|
P = W * scale(vol-target) invece dei rendimenti. Nessun modulo di produzione modificato."""
|
||||||
|
from src.portfolio.sleeves import XS_CFG, XS_UNIVERSE, _HL_DIR
|
||||||
|
cols = {}
|
||||||
|
for sym in XS_UNIVERSE:
|
||||||
|
p = _HL_DIR / f"hl_{sym.lower()}_1d.parquet"
|
||||||
|
if p.exists():
|
||||||
|
d = pd.read_parquet(p)
|
||||||
|
cols[sym] = pd.Series(d["close"].values.astype(float),
|
||||||
|
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
|
||||||
|
C = pd.concat(cols, axis=1, join="inner").sort_index().dropna()
|
||||||
|
px = C.values
|
||||||
|
n, A = px.shape
|
||||||
|
lookbacks, H, k = XS_CFG["lookbacks"], XS_CFG["H"], XS_CFG["k"]
|
||||||
|
mode, tv = XS_CFG["mode"], XS_CFG["target_vol"]
|
||||||
|
disp_pct = XS_CFG.get("disp_pct", 0)
|
||||||
|
minhist = 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 % H == 0:
|
||||||
|
rLs = [px[i] / px[i - L] - 1.0 for L 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)
|
||||||
|
lo, hi = order[:k], order[-k:]
|
||||||
|
if mode == "mom":
|
||||||
|
w[hi] = 0.5 / k
|
||||||
|
w[lo] = -0.5 / k
|
||||||
|
else:
|
||||||
|
w[lo] = 0.5 / k
|
||||||
|
w[hi] = -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)
|
||||||
|
return C.index, W * scale[:, None]
|
||||||
|
|
||||||
|
|
||||||
|
def xs01_exec(P: np.ndarray, idx: pd.DatetimeIndex, sleeve_cap: float,
|
||||||
|
min_order: float = MIN_ORDER_HL) -> dict:
|
||||||
|
"""Skip sequenziale per gamba: un Delta di nozionale < min_order NON si esegue."""
|
||||||
|
n, A = P.shape
|
||||||
|
yrs = max((idx[-1] - idx[0]).days / 365.25, 1e-9)
|
||||||
|
tot_mod = 0.0
|
||||||
|
n_orders = 0
|
||||||
|
orders = []
|
||||||
|
for a in range(A):
|
||||||
|
tgt = P[:, a]
|
||||||
|
tot_mod += float(np.abs(np.diff(tgt, prepend=0.0)).sum())
|
||||||
|
cur = 0.0
|
||||||
|
for x in tgt:
|
||||||
|
d = abs(x - cur)
|
||||||
|
if d * sleeve_cap >= min_order:
|
||||||
|
orders.append(d * sleeve_cap)
|
||||||
|
cur = x
|
||||||
|
n_orders += 1
|
||||||
|
exec_turn = float(np.sum(orders) / sleeve_cap) if orders else 0.0
|
||||||
|
return dict(exec_share=exec_turn / tot_mod if tot_mod > 0 else 0.0,
|
||||||
|
orders_py=n_orders / yrs,
|
||||||
|
med_order=float(np.median(orders)) if orders else 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def section6():
|
||||||
|
print()
|
||||||
|
print("=" * 100)
|
||||||
|
print("(6) XS01 (19 gambe HL, min order ~$10) e CC01 — le soglie '~20k' ricontate")
|
||||||
|
print("=" * 100)
|
||||||
|
idx, P = xs01_positions()
|
||||||
|
gross = np.abs(P).sum(axis=1)
|
||||||
|
print(f" XS01: gross tipico {np.median(gross[gross > 0]):.2f}x del capitale sleeve; "
|
||||||
|
f"10 gambe attive (5 long + 5 short da 19), ribilancio ogni 10g + vol-target giornaliero")
|
||||||
|
print(f" {'capitale':>8} {'sleeve@15%':>11} {'exec-turnover%':>15} {'ordini/anno':>12} {'medONot$':>9}")
|
||||||
|
out = {}
|
||||||
|
for Ctot in list(CAPITALS) + [10000.0, 20000.0, 50000.0]:
|
||||||
|
sc = 0.15 * Ctot
|
||||||
|
r = xs01_exec(P, idx, sc)
|
||||||
|
out[Ctot] = r["exec_share"]
|
||||||
|
tag = " <- dichiarato" if Ctot == 20000 else ""
|
||||||
|
print(f" {Ctot:>8.0f} {sc:>11.0f} {r['exec_share'] * 100:>14.1f}% {r['orders_py']:>12.0f} "
|
||||||
|
f"{r['med_order']:>9.0f}{tag}")
|
||||||
|
print(" Lettura: exec-turnover% = quota del turnover modellato che supera il min-order $10 per gamba.")
|
||||||
|
print(" Sotto ~80-90% il libro reale diverge dal backtest (tracking error non modellato).")
|
||||||
|
|
||||||
|
print("\n CC01 (cash-and-carry HL, spot+perp stesso asset — Sharpe modellato = ARTEFATTO, v. diario):")
|
||||||
|
for Ctot in CAPITALS:
|
||||||
|
for N in (2, 4, 19):
|
||||||
|
D = 0.75 * Ctot # deploy carry: spot cash-funded + margine perp ~D/3
|
||||||
|
per_leg = D / (2 * N)
|
||||||
|
yld = (0.08 * D, 0.14 * D)
|
||||||
|
if N == 4:
|
||||||
|
print(f" C={Ctot:>5.0f} N={N:>2} asset ({2 * N} gambe): ${per_leg:,.0f}/gamba; "
|
||||||
|
f"funding 8-14% su ${D:,.0f} = ${yld[0]:,.0f}-{yld[1]:,.0f}/anno")
|
||||||
|
print(" -> il MIN-ORDER non e' il muro di CC01 gia' a 2k (gambe > $90); i muri sono strutturali:")
|
||||||
|
print(" (a) Sharpe 11-13 artefatto (manca il 2022: deleveraging/funding-negativo/basis blowout),")
|
||||||
|
print(" (b) funding HL non eseguibile da Deribit (secondo venue + travaso capitale),")
|
||||||
|
print(" (c) liquidazione short e slippage non modellati. A 5k il carry atteso ($300-525/anno")
|
||||||
|
print(" lordi, prociclico) NON paga il rischio operativo: resta LEAD da rivedere a ~20k+.")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# (7) SINTESI OPERATIVA
|
||||||
|
# ===========================================================================
|
||||||
|
def book_cagr_canonical() -> float:
|
||||||
|
"""CAGR canonico del book Deribit (0.75*TP01 + 0.25*SKH01, daily) — SOLO per confronto col
|
||||||
|
band de-luckato 10-15% (audit SKH01 2026-07-02: hourly-path + fase mediana)."""
|
||||||
|
from src.portfolio.sleeves import _skyhook_returns, _tp01_returns # sola lettura
|
||||||
|
tp = _tp01_returns()
|
||||||
|
sk = _skyhook_returns()
|
||||||
|
if tp.index.tz is None:
|
||||||
|
tp.index = tp.index.tz_localize("UTC")
|
||||||
|
J = pd.concat({"tp": tp, "sk": sk}, axis=1, join="inner").fillna(0.0)
|
||||||
|
r = 0.75 * J["tp"] + 0.25 * J["sk"]
|
||||||
|
eq = float(np.prod(1.0 + r.values))
|
||||||
|
yrs = len(r) / 365.25
|
||||||
|
return eq ** (1.0 / yrs) - 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def section7(s1, s2, s3, s4, s5, s6):
|
||||||
|
n_eth_12, m_eth, m_btc = s5
|
||||||
|
print()
|
||||||
|
print("=" * 100)
|
||||||
|
print("(7) SINTESI OPERATIVA — capitale x vincoli (numeri dalle sezioni sopra)")
|
||||||
|
print("=" * 100)
|
||||||
|
hdr = (f" {'capitale':>8} | {'TP01 haircut':>12} | {'book inv.% cap300':>17} | {'K=2 turn%':>9} | "
|
||||||
|
f"{'statarb hc':>10} | {'spreadETH@12%':>13} | {'XS01 exec%':>10}")
|
||||||
|
print(hdr)
|
||||||
|
print(" " + "-" * (len(hdr) - 2))
|
||||||
|
for C in CAPITALS:
|
||||||
|
inv300 = s2.get((C, "300 (oggi)"), float("nan"))
|
||||||
|
print(f" {C:>8.0f} | {s1[C]['haircut']:>12.3f} | {inv300 * 100:>16.1f}% | "
|
||||||
|
f"{s3[(C, 2)] * 100:>8.1f}% | {s4[C]:>10.3f} | {n_eth_12[C]:>13d} | "
|
||||||
|
f"{s6[C] * 100:>9.1f}%")
|
||||||
|
try:
|
||||||
|
cagr = book_cagr_canonical()
|
||||||
|
print(f"\n CAGR canonico book Deribit (0.75 TP01 + 0.25 SKH01): {cagr * 100:.1f}%/anno "
|
||||||
|
f"(lens research, ancora canonica)")
|
||||||
|
except Exception as e: # pragma: no cover — il resto del report resta valido
|
||||||
|
print(f"\n (CAGR canonico book non calcolabile in questo run: {e})")
|
||||||
|
print(" ASPETTATIVA ONESTA (CAGR de-luckato 10-15%/anno — audit SKH01 2026-07-02, path orario +")
|
||||||
|
print(" fase mediana, NON i numeri canonici):")
|
||||||
|
for C in (2000.0, 5000.0):
|
||||||
|
lo, hi = C * 0.10 / 365.25, C * 0.15 / 365.25
|
||||||
|
print(f" a ${C:,.0f}: ~EUR {lo:.2f}-{hi:.2f}/giorno (=${C * 0.10:,.0f}-{C * 0.15:,.0f}/anno)")
|
||||||
|
print(" Regola di onesta' del progetto INVARIATA: EUR 50/giorno richiede ~130k di capitale a questo")
|
||||||
|
print(" CAGR — il funding a 2-5k NON cambia l'ordine di grandezza, cambia solo cosa e' ESEGUIBILE.")
|
||||||
|
print()
|
||||||
|
print(" RACCOMANDAZIONI CONFIG (SOLO PROPOSTE — config/live.json non viene toccato da questo script):")
|
||||||
|
print(" 1. max_notional_per_asset_usd: alzarlo INSIEME al funding mantenendo il rapporto attuale")
|
||||||
|
print(" cap = equity/2 -> $1000 a 2k, $1750 a 3.5k, $2500 a 5k. Col cap fermo a $300 il book")
|
||||||
|
print(" resta strutturalmente sotto-investito (vedi colonna 'book inv.% cap300' sopra).")
|
||||||
|
print(" 2. min_order_usd $5: lasciarlo (floor Deribit). Nessuna banda artificiale in piu': il fee")
|
||||||
|
print(" drag risale verso il modellato (~0.4%/anno max, sez.1) ed e' il costo corretto della")
|
||||||
|
print(" fedelta' al target (l'ondata timing ha mostrato che il lag costa piu' del risparmio).")
|
||||||
|
print(" 3. Tranching K=2: la matematica degli ordini regge da ~2k (exec-turnover >95%/tranche,")
|
||||||
|
print(" sez.3) e a 5k e' matura (98%), MA (a) il beneficio atteso e' solo riduzione di varianza")
|
||||||
|
print(" della STIMA (Delta-Sharpe n.s., diario timing) e (b) il blocco feed-intraday-fuori-path-")
|
||||||
|
print(" certificato resta intero -> NON cablarlo ora; rivalutare solo con feed intraday validato.")
|
||||||
|
print(" 4. Opzioni: a 3.5-5k il singolo spread ETH min-size e' sostenibile a peso ~12% (sez.5),")
|
||||||
|
print(" ma la regola standing 'niente short-vol da modello' NON decade col capitale.")
|
||||||
|
print(" 5. XS01/CC01: restano STAT-MODE/LEAD anche a 5k (sez.6). Soglia ~20k confermata per XS01")
|
||||||
|
print(" come ordine di grandezza; per CC01 il capitale non e' comunque il muro binding.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
s1 = section1()
|
||||||
|
s2 = section2()
|
||||||
|
s3 = section3()
|
||||||
|
s4 = section4()
|
||||||
|
s5 = section5()
|
||||||
|
s6 = section6()
|
||||||
|
section7(s1, s2, s3, s4, s5, s6)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
"""r0702_crt_base — CRT "Candle Range Theory", versione BASE single-TF (pattern meccanizzato).
|
||||||
|
|
||||||
|
FILONE 2026-07-02. Falso breakout codificato in 3 candele (turtle soup / spring-upthrust):
|
||||||
|
C1 (range): candela direzionale forte -> body/range >= b AND range >= k*ATR14 (griglia b,k)
|
||||||
|
C2 (manipolazione): rompe un estremo di C1 di almeno s*ATR14 (griglia s) ma CHIUDE DENTRO
|
||||||
|
il range di C1. Flag colore opzionale (short: C2 rossa che apre sopra il close di C1;
|
||||||
|
long: C2 verde che apre sotto il close di C1).
|
||||||
|
C3 (ingresso): entry a open C3 = close C2 (deciso con dati <= close C2 -> causale).
|
||||||
|
SL = estremo di C2 (punto dello sweep). TP = estremo OPPOSTO del range di C1.
|
||||||
|
Filtro R:R >= 1.3 a entry. Direzioni: short su sweep dell'alto, long su sweep del basso.
|
||||||
|
|
||||||
|
OVERLAP DICHIARATO con la ricerca esistente (grep dei docstring runs/MRV*.py + MIC07):
|
||||||
|
- MRV01-11 = mean-reversion su INDICATORI (RSI2, BB, z-score, IBS, W%R, consec-down,
|
||||||
|
gap-fill, CCI, stochastic, VWAP-dev, %b) — nessuna testa il pattern 3-candele
|
||||||
|
sweep+close-back-inside con SL/TP strutturali. La famiglia MR generica e' MORTA sul
|
||||||
|
feed certificato: CRT e' una MR *condizionata da un evento di liquidita'*, quindi il
|
||||||
|
prior e' fortemente negativo — serve battere il null del fade incondizionato.
|
||||||
|
- MIC07 (pin-bar rejection al supporto) e' il parente piu' vicino: rejection candle
|
||||||
|
single-bar a un N-bar low. CRT differisce: riferimento = range di C1 forte (1 barra),
|
||||||
|
sweep quantificato in ATR, close-back-inside esplicito, TP strutturale (estremo opposto
|
||||||
|
di C1) e non R-multiple. Overlap concettuale parziale, meccanica diversa.
|
||||||
|
|
||||||
|
GATES: selezione cella SOLO in-sample pre-2025; deflated Sharpe su TUTTI i trial
|
||||||
|
(cella x tf x direzione); ANCHOR-SHIFT (+1/+2/+4h) sul resample 4h/12h/1d; fee sweep
|
||||||
|
0.00-0.20% RT; marginal_vs_tp01 se Sharpe standalone >= 0.5.
|
||||||
|
NULL decisivi: (i) fade INCONDIZIONATO dello stesso estremo (senza close-back-inside);
|
||||||
|
(ii) condizione INVERTITA (C2 chiude FUORI = breakout confermato, trade col breakout).
|
||||||
|
|
||||||
|
Motore trade-level CONSERVATIVO (specchia src/backtest/harness.backtest_signals):
|
||||||
|
entry a close[i]; exit scan da i+1; SL/TP fillati AL LIVELLO su high/low; se nella stessa
|
||||||
|
barra sono toccati entrambi scatta PRIMA lo STOP (worst-case); time-exit a close dopo
|
||||||
|
max_hold barre; nessun overlap (una posizione alla volta per asset). Fee 0.10% RT.
|
||||||
|
Equity mark-to-market per barra (lente Sharpe daily-compounded, convenzione progetto).
|
||||||
|
|
||||||
|
Run: uv run python scripts/research/r0702_crt_base.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from itertools import product
|
||||||
|
|
||||||
|
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
|
||||||
|
import numpy as np # noqa: E402
|
||||||
|
import pandas as pd # noqa: E402
|
||||||
|
|
||||||
|
HOLDOUT = al.HOLDOUT # 2025-01-01 UTC
|
||||||
|
FEE_RT = 2 * al.FEE_SIDE # 0.10% round-trip
|
||||||
|
RR_MIN = 1.3
|
||||||
|
TFS = ("1h", "4h", "12h", "1d")
|
||||||
|
RULES = {"4h": "4h", "12h": "12h", "1d": "1D"}
|
||||||
|
ASSETS = al.CERTIFIED # BTC, ETH
|
||||||
|
MIN_IS_TRADES = 25 # trade combinati minimi in-sample per cella eleggibile
|
||||||
|
|
||||||
|
GRID = [dict(b=b, k=k, s=s, color=col, mh=mh)
|
||||||
|
for b, k, s, col, mh in product((0.5, 0.7), (1.0, 1.5),
|
||||||
|
(0.0, 0.1, 0.25), (False, True), (5, 10, 20))]
|
||||||
|
DIRS = ("long", "short", "both")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# DATI (anchor 00:00 UTC di default; anchor spostabile per il gate anchor-shift)
|
||||||
|
# ===========================================================================
|
||||||
|
def resample_anchor(df_1h: pd.DataFrame, rule: str, offset_hours: int) -> pd.DataFrame:
|
||||||
|
"""Come trend_portfolio.resample_tf (label/closed='left') ma con ancora spostata di
|
||||||
|
+offset_hours. Niente .view('int64'): epoca esplicita via // Timedelta (tz-aware safe)."""
|
||||||
|
g = df_1h.copy()
|
||||||
|
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
|
||||||
|
idx.name = "dt"
|
||||||
|
g.index = idx
|
||||||
|
out = g.resample(rule, label="left", closed="left",
|
||||||
|
offset=pd.Timedelta(hours=offset_hours)).agg(
|
||||||
|
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
|
||||||
|
out = out.dropna(subset=["open"])
|
||||||
|
out["datetime"] = out.index
|
||||||
|
epoch = pd.Timestamp("1970-01-01", tz="UTC")
|
||||||
|
out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
|
||||||
|
return out.reset_index(drop=True)[["timestamp", "open", "high", "low", "close",
|
||||||
|
"volume", "datetime"]]
|
||||||
|
|
||||||
|
|
||||||
|
_PREP_CACHE: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def prep(asset: str, tf: str, anchor: int = 0) -> dict:
|
||||||
|
key = (asset, tf, anchor)
|
||||||
|
if key in _PREP_CACHE:
|
||||||
|
return _PREP_CACHE[key]
|
||||||
|
if anchor == 0 or tf == "1h":
|
||||||
|
df = al.get(asset, tf)
|
||||||
|
else:
|
||||||
|
df = resample_anchor(al.get(asset, "1h"), RULES[tf], anchor)
|
||||||
|
d = dict(
|
||||||
|
df=df,
|
||||||
|
o=df["open"].values.astype(float), h=df["high"].values.astype(float),
|
||||||
|
l=df["low"].values.astype(float), c=df["close"].values.astype(float),
|
||||||
|
atr=al.atr(df, 14),
|
||||||
|
idx=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)),
|
||||||
|
)
|
||||||
|
_PREP_CACHE[key] = d
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# DETECTION (vettoriale, causale: tutto deciso con OHLC fino alla barra i = C2;
|
||||||
|
# l'ATR usato e' quello di C1 (i-1) -> ancora piu' conservativo)
|
||||||
|
# ===========================================================================
|
||||||
|
def _shift1(x: np.ndarray) -> np.ndarray:
|
||||||
|
out = np.empty_like(x); out[0] = np.nan; out[1:] = x[:-1]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def detect(d: dict, b: float, k: float, s: float, color: bool, variant: str) -> dict:
|
||||||
|
"""Ritorna {dir: (indici C2, sl, tp)} per variant in {'crt','fade','breakout'}.
|
||||||
|
Indice i = barra C2; C1 = i-1. Entry (gestita dal motore) = close[i]."""
|
||||||
|
o, h, l, c = d["o"], d["h"], d["l"], d["c"]
|
||||||
|
h1, l1, o1, c1 = _shift1(h), _shift1(l), _shift1(o), _shift1(c)
|
||||||
|
atr1 = _shift1(d["atr"])
|
||||||
|
rng1 = h1 - l1
|
||||||
|
body1 = np.abs(c1 - o1)
|
||||||
|
with np.errstate(invalid="ignore", divide="ignore"):
|
||||||
|
strong = (np.isfinite(atr1) & (atr1 > 0) & (rng1 > 0)
|
||||||
|
& (body1 / rng1 >= b) & (rng1 >= k * atr1))
|
||||||
|
sweep_up = strong & (h > h1 + s * atr1) # C2 rompe l'alto di C1
|
||||||
|
sweep_dn = strong & (l < l1 - s * atr1) # C2 rompe il basso di C1
|
||||||
|
out = {}
|
||||||
|
if variant == "crt":
|
||||||
|
sh = sweep_up & (c < h1) & (c > l1) # chiude DENTRO il range di C1
|
||||||
|
lg = sweep_dn & (c > l1) & (c < h1)
|
||||||
|
if color:
|
||||||
|
sh &= (c < o) & (o > c1) # rossa che apre sopra il close di C1
|
||||||
|
lg &= (c > o) & (o < c1) # verde che apre sotto il close di C1
|
||||||
|
sh &= (h > c) & ((c - l1) / np.where(h - c > 0, h - c, np.nan) >= RR_MIN)
|
||||||
|
lg &= (c > l) & ((h1 - c) / np.where(c - l > 0, c - l, np.nan) >= RR_MIN)
|
||||||
|
out["short"] = (np.where(sh)[0], h, l1) # SL=high C2, TP=low C1
|
||||||
|
out["long"] = (np.where(lg)[0], l, h1) # SL=low C2, TP=high C1
|
||||||
|
elif variant == "fade":
|
||||||
|
# NULL (i): stesso sweep, NESSUNA richiesta di close-back-inside (no colore).
|
||||||
|
# Solo validita' geometrica (TP dal lato giusto dell'entry).
|
||||||
|
sh = sweep_up & (c > l1)
|
||||||
|
lg = sweep_dn & (c < h1)
|
||||||
|
sh &= (h > c) & ((c - l1) / np.where(h - c > 0, h - c, np.nan) >= RR_MIN)
|
||||||
|
lg &= (c > l) & ((h1 - c) / np.where(c - l > 0, c - l, np.nan) >= RR_MIN)
|
||||||
|
out["short"] = (np.where(sh)[0], h, l1)
|
||||||
|
out["long"] = (np.where(lg)[0], l, h1)
|
||||||
|
elif variant == "breakout":
|
||||||
|
# NULL (ii): condizione INVERTITA — C2 chiude FUORI dal range di C1 =
|
||||||
|
# breakout confermato, trade IN DIREZIONE del breakout.
|
||||||
|
# SL = livello rotto (rientro nel range = fallimento), TP = measured move
|
||||||
|
# (range di C1 proiettato oltre il livello). Stesso filtro R:R.
|
||||||
|
lg = sweep_up & (c > h1) # rompe l'alto e chiude sopra -> LONG
|
||||||
|
sh = sweep_dn & (c < l1) # rompe il basso e chiude sotto -> SHORT
|
||||||
|
tp_lg = h1 + rng1
|
||||||
|
tp_sh = l1 - rng1
|
||||||
|
lg &= (tp_lg > c) & ((tp_lg - c) / np.where(c - h1 > 0, c - h1, np.nan) >= RR_MIN)
|
||||||
|
sh &= (c > tp_sh) & ((c - tp_sh) / np.where(l1 - c > 0, l1 - c, np.nan) >= RR_MIN)
|
||||||
|
out["long"] = (np.where(lg)[0], h1, tp_lg) # SL=high C1, TP=measured move
|
||||||
|
out["short"] = (np.where(sh)[0], l1, tp_sh) # SL=low C1
|
||||||
|
else:
|
||||||
|
raise ValueError(variant)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def merge_dirs(sig: dict, which: str):
|
||||||
|
"""Lista ordinata di (i, dir, sl, tp) per direzione 'long'/'short'/'both'."""
|
||||||
|
rows = []
|
||||||
|
if which in ("long", "both"):
|
||||||
|
ii, sl, tp = sig["long"]
|
||||||
|
rows += [(int(i), 1, float(sl[i]), float(tp[i])) for i in ii]
|
||||||
|
if which in ("short", "both"):
|
||||||
|
ii, sl, tp = sig["short"]
|
||||||
|
rows += [(int(i), -1, float(sl[i]), float(tp[i])) for i in ii]
|
||||||
|
rows.sort(key=lambda r: r[0])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# MOTORE TRADE-LEVEL (conservativo; specchia backtest_signals: SL prioritario)
|
||||||
|
# ===========================================================================
|
||||||
|
def run_trades(d: dict, rows: list, mh: int, fee_rt: float = FEE_RT):
|
||||||
|
"""Ritorna (trades, barnet). trades: (i_entry, i_exit, dir, net, R, gross).
|
||||||
|
barnet: rendimento netto per-barra mark-to-market (fee 50/50 su entry/exit bar)."""
|
||||||
|
c, h, l = d["c"], d["h"], d["l"]
|
||||||
|
n = len(c)
|
||||||
|
barnet = np.zeros(n)
|
||||||
|
trades = []
|
||||||
|
busy_until = -1
|
||||||
|
for i, dr, sl, tp in rows:
|
||||||
|
if i <= busy_until or i >= n - 1:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
exit_idx = min(i + mh, n - 1)
|
||||||
|
exit_price = c[exit_idx]
|
||||||
|
for j in range(i + 1, min(i + mh, n - 1) + 1):
|
||||||
|
if dr == 1:
|
||||||
|
if l[j] <= sl: # STOP prima (worst-case)
|
||||||
|
exit_price, exit_idx = sl, j; break
|
||||||
|
if h[j] >= tp:
|
||||||
|
exit_price, exit_idx = tp, j; break
|
||||||
|
else:
|
||||||
|
if h[j] >= sl:
|
||||||
|
exit_price, exit_idx = sl, j; break
|
||||||
|
if l[j] <= tp:
|
||||||
|
exit_price, exit_idx = tp, j; break
|
||||||
|
exit_price, exit_idx = c[j], j
|
||||||
|
gross = dr * (exit_price / entry - 1.0)
|
||||||
|
net = gross - fee_rt
|
||||||
|
risk = abs(sl - entry) / entry
|
||||||
|
R = net / risk if risk > 0 else np.nan
|
||||||
|
trades.append((i, exit_idx, dr, net, R, gross))
|
||||||
|
pp = entry
|
||||||
|
for j in range(i + 1, exit_idx + 1):
|
||||||
|
pj = exit_price if j == exit_idx else c[j]
|
||||||
|
barnet[j] += dr * (pj / pp - 1.0)
|
||||||
|
pp = pj
|
||||||
|
barnet[i] -= fee_rt / 2
|
||||||
|
barnet[exit_idx] -= fee_rt / 2
|
||||||
|
busy_until = exit_idx
|
||||||
|
return trades, barnet
|
||||||
|
|
||||||
|
|
||||||
|
def daily_series(d: dict, barnet: np.ndarray) -> pd.Series:
|
||||||
|
return al._to_daily(pd.Series(barnet, index=d["idx"]))
|
||||||
|
|
||||||
|
|
||||||
|
def combo_daily(dailies: dict) -> pd.Series:
|
||||||
|
J = pd.concat(dailies, axis=1, join="inner").fillna(0.0)
|
||||||
|
return 0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]
|
||||||
|
|
||||||
|
|
||||||
|
def series_metrics(daily: pd.Series) -> dict:
|
||||||
|
def _dd(s):
|
||||||
|
eq = np.cumprod(1.0 + s.values); pk = np.maximum.accumulate(eq)
|
||||||
|
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||||
|
ins, hold = daily[daily.index < HOLDOUT], daily[daily.index >= HOLDOUT]
|
||||||
|
yearly = {int(y): round(float(np.prod(1 + g.values) - 1), 4)
|
||||||
|
for y, g in daily.groupby(daily.index.year)}
|
||||||
|
return dict(full_sh=round(al._sh(daily), 3), is_sh=round(al._sh(ins), 3),
|
||||||
|
hold_sh=round(al._sh(hold), 3), full_dd=round(_dd(daily), 4),
|
||||||
|
hold_dd=round(_dd(hold), 4), full_ret=round(float(np.prod(1 + daily.values) - 1), 4),
|
||||||
|
hold_ret=round(float(np.prod(1 + hold.values) - 1), 4), yearly=yearly)
|
||||||
|
|
||||||
|
|
||||||
|
def trade_stats(trades: list, idx: pd.DatetimeIndex) -> dict:
|
||||||
|
if not trades:
|
||||||
|
return dict(n=0, n_is=0, n_hold=0, wr=None, avg_R=None, exp_net=None)
|
||||||
|
t_entry = idx[[t[0] for t in trades]]
|
||||||
|
net = np.array([t[3] for t in trades]); R = np.array([t[4] for t in trades])
|
||||||
|
is_m = np.asarray(t_entry < HOLDOUT)
|
||||||
|
|
||||||
|
def _blk(m):
|
||||||
|
if m.sum() == 0:
|
||||||
|
return dict(n=0, wr=None, avg_R=None, exp_net=None)
|
||||||
|
return dict(n=int(m.sum()), wr=round(float(np.mean(net[m] > 0) * 100), 1),
|
||||||
|
avg_R=round(float(np.nanmean(R[m])), 3),
|
||||||
|
exp_net=round(float(np.mean(net[m]) * 100), 3))
|
||||||
|
full = _blk(np.ones(len(net), bool))
|
||||||
|
per_year = {}
|
||||||
|
for y in sorted(set(t_entry.year)):
|
||||||
|
per_year[int(y)] = _blk(np.asarray(t_entry.year == y))
|
||||||
|
return dict(n=full["n"], n_is=int(is_m.sum()), n_hold=int((~is_m).sum()),
|
||||||
|
wr=full["wr"], avg_R=full["avg_R"], exp_net=full["exp_net"],
|
||||||
|
is_blk=_blk(is_m), hold_blk=_blk(~is_m), per_year=per_year)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# RUNNER di un trial (cella x tf x direzione) su entrambi gli asset
|
||||||
|
# ===========================================================================
|
||||||
|
def run_trial(tf: str, p: dict, which: str, variant: str = "crt",
|
||||||
|
fee_rt: float = FEE_RT, anchor: int = 0):
|
||||||
|
dailies, all_trades, all_stats = {}, {}, {}
|
||||||
|
for a in ASSETS:
|
||||||
|
d = prep(a, tf, anchor)
|
||||||
|
sig = detect(d, p["b"], p["k"], p["s"], p["color"], variant)
|
||||||
|
rows = merge_dirs(sig, which)
|
||||||
|
trades, barnet = run_trades(d, rows, p["mh"], fee_rt)
|
||||||
|
dailies[a] = daily_series(d, barnet)
|
||||||
|
all_trades[a] = trades
|
||||||
|
all_stats[a] = trade_stats(trades, d["idx"])
|
||||||
|
daily = combo_daily(dailies)
|
||||||
|
sm = series_metrics(daily)
|
||||||
|
n_is = sum(st["n_is"] for st in all_stats.values())
|
||||||
|
n_full = sum(st["n"] for st in all_stats.values())
|
||||||
|
return dict(tf=tf, params=p, dir=which, variant=variant, daily=daily,
|
||||||
|
metrics=sm, per_asset_stats=all_stats, n_is=n_is, n_full=n_full)
|
||||||
|
|
||||||
|
|
||||||
|
def pooled_trade_stats(trial: dict) -> dict:
|
||||||
|
"""Statistiche trade POOLED sui due asset (per il report della cella scelta)."""
|
||||||
|
trades, idxs = [], []
|
||||||
|
for a in ASSETS:
|
||||||
|
d = prep(a, trial["tf"])
|
||||||
|
for t in trial["_raw_trades"][a]:
|
||||||
|
trades.append(t); idxs.append(d["idx"][t[0]])
|
||||||
|
if not trades:
|
||||||
|
return dict(n=0)
|
||||||
|
order = np.argsort(np.array([i.value for i in idxs]))
|
||||||
|
trades = [trades[k] for k in order]
|
||||||
|
idx = pd.DatetimeIndex([idxs[k] for k in order])
|
||||||
|
return _pooled(trades, idx)
|
||||||
|
|
||||||
|
|
||||||
|
def _pooled(trades, idx):
|
||||||
|
net = np.array([t[3] for t in trades]); R = np.array([t[4] for t in trades])
|
||||||
|
is_m = np.asarray(idx < HOLDOUT)
|
||||||
|
|
||||||
|
def _blk(m):
|
||||||
|
if m.sum() == 0:
|
||||||
|
return dict(n=0, wr=None, avg_R=None, exp_net=None)
|
||||||
|
return dict(n=int(m.sum()), wr=round(float(np.mean(net[m] > 0) * 100), 1),
|
||||||
|
avg_R=round(float(np.nanmean(R[m])), 3),
|
||||||
|
exp_net=round(float(np.mean(net[m]) * 100), 3))
|
||||||
|
out = dict(full=_blk(np.ones(len(net), bool)), is_blk=_blk(is_m), hold_blk=_blk(~is_m),
|
||||||
|
per_year={int(y): _blk(np.asarray(idx.year == y)) for y in sorted(set(idx.year))})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# MAIN
|
||||||
|
# ===========================================================================
|
||||||
|
def main():
|
||||||
|
t0 = time.time()
|
||||||
|
print("=" * 96)
|
||||||
|
print("r0702 CRT — Candle Range Theory BASE single-TF | fee 0.10% RT | hold-out 2025-01-01+")
|
||||||
|
print("Griglia: b(0.5,0.7) x k(1.0,1.5) x s(0.0,0.1,0.25) x color(off,on) x max_hold(5,10,20)")
|
||||||
|
print(f"= {len(GRID)} celle x {len(TFS)} TF x {len(DIRS)} direzioni = "
|
||||||
|
f"{len(GRID) * len(TFS) * len(DIRS)} trial (tutti contati nel DSR)")
|
||||||
|
print("=" * 96)
|
||||||
|
|
||||||
|
for a in ASSETS:
|
||||||
|
d = prep(a, "1d")
|
||||||
|
print(f" dati {a} 1d: {d['idx'][0].date()} -> {d['idx'][-1].date()} ({len(d['c'])} barre)")
|
||||||
|
|
||||||
|
# ---- 1) griglia completa (righe leggere; il daily si ricalcola per la scelta) ----
|
||||||
|
rows = []
|
||||||
|
freq_by_tf = {tf: [] for tf in TFS}
|
||||||
|
for tf in TFS:
|
||||||
|
years = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
d = prep(a, tf)
|
||||||
|
years[a] = (d["idx"][-1] - d["idx"][0]).total_seconds() / 86400 / 365.25
|
||||||
|
span_y = float(np.mean(list(years.values())))
|
||||||
|
for p in GRID:
|
||||||
|
# detection condivisa fra direzioni e mh (mh influenza solo il motore)
|
||||||
|
for which in DIRS:
|
||||||
|
tr = run_trial(tf, p, which)
|
||||||
|
m = tr["metrics"]
|
||||||
|
rows.append(dict(tf=tf, **p, dir=which, is_sh=m["is_sh"], full_sh=m["full_sh"],
|
||||||
|
hold_sh=m["hold_sh"], n_is=tr["n_is"], n_full=tr["n_full"]))
|
||||||
|
if which == "both":
|
||||||
|
freq_by_tf[tf].append(tr["n_full"] / (2 * span_y)) # trade/anno per asset
|
||||||
|
print(f" [grid] tf={tf} fatto ({time.time() - t0:.0f}s)")
|
||||||
|
|
||||||
|
R = pd.DataFrame(rows)
|
||||||
|
print("\n--- FREQUENZA PATTERN (CRT, entrambe le direzioni, trade/anno PER ASSET) ---")
|
||||||
|
for tf in TFS:
|
||||||
|
f = np.array(freq_by_tf[tf])
|
||||||
|
print(f" {tf:>4s}: mediana {np.median(f):6.1f} min {f.min():6.1f} max {f.max():6.1f} "
|
||||||
|
f"(su {len(f)} celle)")
|
||||||
|
|
||||||
|
# ---- 2) selezione cella SOLO in-sample (pre-2025) ----
|
||||||
|
elig = R[(R.n_is >= MIN_IS_TRADES) & np.isfinite(R.is_sh)].copy()
|
||||||
|
print(f"\n--- SELEZIONE IN-SAMPLE: {len(elig)}/{len(R)} trial eleggibili "
|
||||||
|
f"(>= {MIN_IS_TRADES} trade IS combinati) ---")
|
||||||
|
top = elig.sort_values("is_sh", ascending=False).head(12)
|
||||||
|
cols = ["tf", "b", "k", "s", "color", "mh", "dir", "is_sh", "hold_sh", "full_sh", "n_is", "n_full"]
|
||||||
|
print(top[cols].to_string(index=False))
|
||||||
|
|
||||||
|
if len(elig) == 0:
|
||||||
|
print("\nVERDETTO: FAIL — nessuna cella con abbastanza trade in-sample.")
|
||||||
|
return
|
||||||
|
|
||||||
|
best = elig.sort_values("is_sh", ascending=False).iloc[0]
|
||||||
|
p = dict(b=float(best.b), k=float(best.k), s=float(best.s),
|
||||||
|
color=bool(best.color), mh=int(best.mh))
|
||||||
|
tf, which = str(best.tf), str(best.dir)
|
||||||
|
print(f"\n=== CELLA SCELTA (max Sharpe IN-SAMPLE, hold-out solo riportato) ===")
|
||||||
|
print(f" tf={tf} dir={which} {p}")
|
||||||
|
|
||||||
|
# ricalcolo completo della cella scelta (con trade grezzi per il pooled report)
|
||||||
|
chosen = run_trial(tf, p, which)
|
||||||
|
chosen["_raw_trades"] = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
d = prep(a, tf)
|
||||||
|
sig = detect(d, p["b"], p["k"], p["s"], p["color"], "crt")
|
||||||
|
trades, _ = run_trades(d, merge_dirs(sig, which), p["mh"])
|
||||||
|
chosen["_raw_trades"][a] = trades
|
||||||
|
m = chosen["metrics"]
|
||||||
|
print(f" COMBINED 50/50: FULL Sh {m['full_sh']} IS Sh {m['is_sh']} HOLD Sh {m['hold_sh']} "
|
||||||
|
f"| FULL ret {m['full_ret'] * 100:+.1f}% DD {m['full_dd'] * 100:.1f}% "
|
||||||
|
f"| HOLD ret {m['hold_ret'] * 100:+.1f}% DD {m['hold_dd'] * 100:.1f}%")
|
||||||
|
print(f" per-anno (ret combo): " + " ".join(f"{y}:{v * 100:+.1f}%" for y, v in m["yearly"].items()))
|
||||||
|
ps = pooled_trade_stats(chosen)
|
||||||
|
if ps.get("full", {}).get("n", 0) > 0:
|
||||||
|
f_, i_, h_ = ps["full"], ps["is_blk"], ps["hold_blk"]
|
||||||
|
print(f" trade POOLED: n={f_['n']} WR={f_['wr']}% avgR={f_['avg_R']} exp={f_['exp_net']}%"
|
||||||
|
f" | IS n={i_['n']} WR={i_['wr']}% avgR={i_['avg_R']} exp={i_['exp_net']}%"
|
||||||
|
f" | HOLD n={h_['n']} WR={h_['wr']}% avgR={h_['avg_R']} exp={h_['exp_net']}%")
|
||||||
|
print(" trade per anno: " + " ".join(
|
||||||
|
f"{y}:n{b['n']}/wr{b['wr']}/exp{b['exp_net']}%" for y, b in ps["per_year"].items()))
|
||||||
|
for a in ASSETS:
|
||||||
|
st = chosen["per_asset_stats"][a]
|
||||||
|
print(f" {a}: n={st['n']} (IS {st['n_is']}/HOLD {st['n_hold']}) WR={st['wr']}% "
|
||||||
|
f"avgR={st['avg_R']} exp={st['exp_net']}%")
|
||||||
|
|
||||||
|
# per-direzione della cella scelta (stessi parametri)
|
||||||
|
print("\n--- CELLA SCELTA per DIREZIONE (stessi parametri) ---")
|
||||||
|
for wdir in DIRS:
|
||||||
|
tr = run_trial(tf, p, wdir)
|
||||||
|
mm = tr["metrics"]
|
||||||
|
print(f" {wdir:>5s}: IS {mm['is_sh']:+.2f} HOLD {mm['hold_sh']:+.2f} FULL {mm['full_sh']:+.2f} "
|
||||||
|
f"n={tr['n_full']} (IS {tr['n_is']})")
|
||||||
|
|
||||||
|
# ---- sanity cross-check vs harness ufficiale (al.eval_signals) ----
|
||||||
|
print("\n--- CROSS-CHECK vs al.eval_signals (harness ufficiale, stessa convenzione) ---")
|
||||||
|
for a in ASSETS:
|
||||||
|
d = prep(a, tf)
|
||||||
|
sig = detect(d, p["b"], p["k"], p["s"], p["color"], "crt")
|
||||||
|
entries = [None] * len(d["c"])
|
||||||
|
for i, dr, sl, tp in merge_dirs(sig, which):
|
||||||
|
entries[i] = dict(dir=dr, sl=sl, tp=tp, max_bars=p["mh"])
|
||||||
|
ev = al.eval_signals(d["df"], entries, fee_rt=FEE_RT, asset=a, tf=tf)
|
||||||
|
mine = chosen["_raw_trades"][a]
|
||||||
|
my_ret = float(np.prod([1 + t[3] for t in mine]) - 1)
|
||||||
|
print(f" {a}: harness n={ev['n_trades']} ret={ev['full']['ret'] * 100:+.1f}% "
|
||||||
|
f"| mio n={len(mine)} ret={my_ret * 100:+.1f}% "
|
||||||
|
f"{'OK' if ev['n_trades'] == len(mine) else 'MISMATCH!'}")
|
||||||
|
|
||||||
|
# ---- 3) DSR su TUTTI i trial ----
|
||||||
|
all_sr = [r["full_sh"] for r in rows if np.isfinite(r["full_sh"]) and r["n_full"] >= 1]
|
||||||
|
dsr, sr0 = al.deflated_sharpe(m["full_sh"], all_sr, chosen["daily"])
|
||||||
|
print(f"\n--- DEFLATED SHARPE: DSR={dsr:.3f} (PASS>=0.95) expected-null-max Sh={sr0:.2f} "
|
||||||
|
f"trial contati={len(all_sr)} (su {len(rows)} totali; esclusi 0-trade) ---")
|
||||||
|
|
||||||
|
# ---- 4) ANCHOR-SHIFT (+1/+2/+4h) ----
|
||||||
|
print("\n--- ANCHOR-SHIFT (ancora resample spostata; pattern vero ~invariante) ---")
|
||||||
|
anchor_rows = {}
|
||||||
|
if tf == "1h":
|
||||||
|
print(" tf=1h nativo: nessuna dipendenza dall'ancora del resample (N/A).")
|
||||||
|
alt = elig[elig.tf != "1h"].sort_values("is_sh", ascending=False)
|
||||||
|
if len(alt):
|
||||||
|
b2 = alt.iloc[0]
|
||||||
|
p2 = dict(b=float(b2.b), k=float(b2.k), s=float(b2.s), color=bool(b2.color), mh=int(b2.mh))
|
||||||
|
print(f" (test eseguito sulla miglior cella IS a tf>=4h: tf={b2.tf} dir={b2.dir} {p2})")
|
||||||
|
for off in (0, 1, 2, 4):
|
||||||
|
tr = run_trial(str(b2.tf), p2, str(b2.dir), anchor=off)
|
||||||
|
mm = tr["metrics"]
|
||||||
|
anchor_rows[off] = mm
|
||||||
|
print(f" anchor +{off}h: IS {mm['is_sh']:+.2f} HOLD {mm['hold_sh']:+.2f} "
|
||||||
|
f"FULL {mm['full_sh']:+.2f} n={tr['n_full']}")
|
||||||
|
else:
|
||||||
|
for off in (0, 1, 2, 4):
|
||||||
|
tr = run_trial(tf, p, which, anchor=off)
|
||||||
|
mm = tr["metrics"]
|
||||||
|
anchor_rows[off] = mm
|
||||||
|
print(f" anchor +{off}h: IS {mm['is_sh']:+.2f} HOLD {mm['hold_sh']:+.2f} "
|
||||||
|
f"FULL {mm['full_sh']:+.2f} n={tr['n_full']}")
|
||||||
|
if anchor_rows:
|
||||||
|
fulls = [v["full_sh"] for v in anchor_rows.values()]
|
||||||
|
flip = (max(fulls) > 0) and (min(fulls) < 0)
|
||||||
|
print(f" spread FULL Sh = {max(fulls) - min(fulls):+.2f} "
|
||||||
|
f"{'SIGN-FLIP -> ARTIFACT-RISK' if flip else 'nessun sign-flip'}")
|
||||||
|
|
||||||
|
# ---- 5) FEE SWEEP 0.00-0.20% RT ----
|
||||||
|
print("\n--- FEE SWEEP (cella scelta) ---")
|
||||||
|
for fr in (0.0, 0.0005, 0.001, 0.0015, 0.002):
|
||||||
|
tr = run_trial(tf, p, which, fee_rt=fr)
|
||||||
|
mm = tr["metrics"]
|
||||||
|
print(f" fee {fr * 100:.2f}%RT: IS {mm['is_sh']:+.2f} HOLD {mm['hold_sh']:+.2f} "
|
||||||
|
f"FULL {mm['full_sh']:+.2f}")
|
||||||
|
|
||||||
|
# ---- 6) NULL DECISIVI ----
|
||||||
|
print("\n--- NULL (i): FADE INCONDIZIONATO dello stesso estremo (no close-back-inside) ---")
|
||||||
|
p_null = dict(p, color=False)
|
||||||
|
for var, lbl in (("fade", "fade-incond"), ("breakout", "breakout-conf")):
|
||||||
|
tr = run_trial(tf, p_null, which, variant=var)
|
||||||
|
mm = tr["metrics"]
|
||||||
|
print(f" {lbl:>14s}: IS {mm['is_sh']:+.2f} HOLD {mm['hold_sh']:+.2f} FULL {mm['full_sh']:+.2f} "
|
||||||
|
f"n={tr['n_full']} (IS {tr['n_is']}) per-anno " +
|
||||||
|
" ".join(f"{y}:{v * 100:+.0f}%" for y, v in mm["yearly"].items()))
|
||||||
|
tr = run_trial(tf, p, which, variant="crt")
|
||||||
|
mm = tr["metrics"]
|
||||||
|
print(f" {'CRT (rif.)':>14s}: IS {mm['is_sh']:+.2f} HOLD {mm['hold_sh']:+.2f} FULL {mm['full_sh']:+.2f} "
|
||||||
|
f"n={tr['n_full']} (IS {tr['n_is']})")
|
||||||
|
|
||||||
|
# ---- 7) MARGINAL vs TP01 (solo se standalone >= 0.5) ----
|
||||||
|
if max(m["full_sh"], m["is_sh"]) >= 0.5:
|
||||||
|
print("\n--- MARGINAL vs TP01 (standalone >= 0.5) ---")
|
||||||
|
marg = al.marginal_vs_tp01(chosen["daily"])
|
||||||
|
keys = ("marginal_verdict", "corr_full", "corr_hold", "cand_insample_sharpe",
|
||||||
|
"has_insample_edge", "is_hedge", "multicut_uplift", "multicut_persistent",
|
||||||
|
"robust_oos", "beta_to_tp01", "resid_sharpe_full")
|
||||||
|
for kk in keys:
|
||||||
|
print(f" {kk}: {marg.get(kk)}")
|
||||||
|
for w, dd in marg.get("blends", {}).items():
|
||||||
|
print(f" blend {w}: full {dd['full']} (uplift {dd['uplift_full']:+.3f}) "
|
||||||
|
f"hold {dd['hold']} (uplift {dd['uplift_hold']})")
|
||||||
|
else:
|
||||||
|
print(f"\n--- MARGINAL vs TP01: SALTATO (standalone FULL {m['full_sh']} / IS {m['is_sh']} < 0.5) ---")
|
||||||
|
|
||||||
|
print(f"\n[done in {time.time() - t0:.0f}s]")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
"""r0702_crt_context.py — CRT CON CONTESTO (2026-07-02).
|
||||||
|
|
||||||
|
FILONE: la scuola "Candle Range Theory" dice che lo sweep-and-reclaim vale SOLO su zone
|
||||||
|
importanti (liquidita' sopra massimi/minimi rilevanti, FVG, sessione giusta). Qui testiamo se
|
||||||
|
i FILTRI DI CONTESTO trasformano un fade (gia' morto in versione generica sul feed certificato)
|
||||||
|
in un edge. ONESTA' PRIMA DI TUTTO.
|
||||||
|
|
||||||
|
SETUP BASE FISSO (identico per tutte le celle, deciso A PRIORI prima di guardare i numeri):
|
||||||
|
* TF in {1h, 4h} (4h = resample leak-free da 1h via altlib.get).
|
||||||
|
* C2 = barra che SUPERA un livello di riferimento e CHIUDE dal lato opposto:
|
||||||
|
SHORT: high[i] > lvl_hi AND close[i] < lvl_hi (sweep del massimo + reclaim)
|
||||||
|
LONG : low[i] < lvl_lo AND close[i] > lvl_lo (sweep del minimo + reclaim)
|
||||||
|
(barra che sweppa ENTRAMBI i livelli e chiude in mezzo = ambigua -> scartata, dichiarato)
|
||||||
|
* Entry a close[i] (decisione con dati <= close[i], eseguibile).
|
||||||
|
* Stop DIETRO l'estremo di C2: estremo +/- 0.10*ATR14 (causale).
|
||||||
|
* Target = R FISSO 1.5:1 (scelto a priori; NON centro-range: il centro di un Donchian in
|
||||||
|
trend e' asimmetrico/ambiguo, R fisso e' uniforme su tutti i level-type).
|
||||||
|
* max_hold 20 barre; fill conservativi (SL prioritario se TP e SL nella stessa barra,
|
||||||
|
identico all'harness src/backtest/harness.backtest_signals); fee 0.10% RT.
|
||||||
|
|
||||||
|
LIVELLI (tutti causali, shift(1) su aggregati di periodi COMPLETI precedenti):
|
||||||
|
prevday = high/low del giorno UTC precedente (barre open-labeled, groupby giorno -> shift)
|
||||||
|
don20/55 = max/min delle N barre STRETTAMENTE precedenti (al.donchian, shift(1) built-in)
|
||||||
|
prevweek = high/low della settimana ISO precedente (lunedi' 00 UTC)
|
||||||
|
|
||||||
|
⚠️ OVERLAP DICHIARATO col lead PREVDAY-BREAKOUT in forward-monitor
|
||||||
|
(src/strategies/prevday_breakout.py + scripts/live/paper_prevday.py): STESSI livelli prior-day,
|
||||||
|
condizionamento OPPOSTO — il lead SEGUE il break decisivo (close > lvl + 0.30*range), qui si
|
||||||
|
FADEA il reclaim (close torna dentro). Se entrambi avessero edge sugli stessi livelli, uno dei
|
||||||
|
due e' rumore -> confronto esplicito fade-vs-follow (corr daily + chi vince dove) in fondo.
|
||||||
|
|
||||||
|
FILTRI DI CONTESTO (la parte "discrezionale" della CRT, meccanizzata):
|
||||||
|
EQ = equal highs/lows: il livello e' stato toccato >=2 volte entro 0.10*ATR14 nelle ultime
|
||||||
|
N barre (N = lookback del livello stesso; prevday=2 giorni, prevweek=7 giorni).
|
||||||
|
FVG = esiste un fair-value-gap a 3 candele NON ancora riempito nelle ultime 20 barre, nella
|
||||||
|
direzione del trade (short: FVG bullish sotto il prezzo non riempito = magnete giu';
|
||||||
|
long: FVG bearish sopra non riempito). Meccanizzazione SEMPLICE di un concetto fuzzy
|
||||||
|
discrezionale — limiti dichiarati: k=20 fisso, zona "non riempita" = mai traversata
|
||||||
|
interamente, nessuna nozione di "displacement" o "premium/discount".
|
||||||
|
SES = sessione dello sweep (ora UTC di apertura barra): Asia 00-08 / Europa 08-14 / US 14-22.
|
||||||
|
Solo a 1h (a 4h la label di sessione e' troppo grossolana). ⚠️ OGNI cella sessione
|
||||||
|
passa un anchor-shift +/-2/4h (analogo di al.day_boundary_robust a livello trade)
|
||||||
|
prima di essere creduta.
|
||||||
|
|
||||||
|
GATES: selezione SOLO in-sample pre-2025 (HOLDOUT altlib = 2025-01-01); hold-out riportato mai
|
||||||
|
usato per scegliere; al.deflated_sharpe su TUTTI i 22 trial; fee sweep 0.00-0.20% RT; se il
|
||||||
|
best-IS regge (Sharpe >= 0.5) -> al.marginal_vs_tp01. Causalita': livelli ricalcolati su
|
||||||
|
prefisso troncato e confrontati (check esplicito in fondo). Niente .view("int64"), niente
|
||||||
|
ffill mixed-TF.
|
||||||
|
|
||||||
|
Uso: uv run python scripts/research/r0702_crt_context.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
|
||||||
|
import numpy as np # noqa: E402
|
||||||
|
import pandas as pd # noqa: E402
|
||||||
|
|
||||||
|
ROOT = Path("/opt/docker/PythagorasGoal")
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
from src.backtest.harness import backtest_signals # noqa: E402
|
||||||
|
from src.strategies.prevday_breakout import target as prevday_follow_target # noqa: E402
|
||||||
|
|
||||||
|
HOLDOUT = al.HOLDOUT
|
||||||
|
FEE_RT = 0.001 # 0.10% round-trip (Deribit taker)
|
||||||
|
MAX_HOLD = 20 # barre
|
||||||
|
R_MULT = 1.5 # target R fisso 1.5:1 (a priori)
|
||||||
|
SL_ATR_BUF = 0.10 # stop = estremo C2 +/- 0.10*ATR14 (a priori)
|
||||||
|
EQ_TOL_ATR = 0.10 # tolleranza equal highs/lows
|
||||||
|
EQ_MIN_TOUCH = 2
|
||||||
|
FVG_K = 20 # lookback barre per FVG non riempito
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
SESSIONS = {"asia": (0, 8), "eu": (8, 14), "us": (14, 22)}
|
||||||
|
LEVELS = ("prevday", "don20", "don55", "prevweek")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# LIVELLI (causali)
|
||||||
|
# ===========================================================================
|
||||||
|
def prior_day_levels(df: pd.DataFrame, shift_h: int = 0):
|
||||||
|
"""High/low del giorno UTC PRECEDENTE (shift(1) sul groupby giorno -> strettamente
|
||||||
|
prima di oggi). shift_h sposta il confine del giorno (per l'anchor-shift test)."""
|
||||||
|
dt = pd.to_datetime(df["datetime"], utc=True) + pd.Timedelta(hours=shift_h)
|
||||||
|
day = dt.dt.floor("1D")
|
||||||
|
g = pd.DataFrame({"day": day.values,
|
||||||
|
"high": df["high"].values.astype(float),
|
||||||
|
"low": df["low"].values.astype(float)})
|
||||||
|
per = g.groupby("day").agg(dh=("high", "max"), dl=("low", "min"))
|
||||||
|
m = pd.DataFrame({"dh": per["dh"].shift(1), "dl": per["dl"].shift(1)}).reindex(g["day"].values)
|
||||||
|
return m["dh"].values, m["dl"].values
|
||||||
|
|
||||||
|
|
||||||
|
def prior_week_levels(df: pd.DataFrame):
|
||||||
|
"""High/low della settimana ISO PRECEDENTE (lunedi' 00 UTC, shift(1))."""
|
||||||
|
dt = pd.to_datetime(df["datetime"], utc=True)
|
||||||
|
day = dt.dt.floor("1D")
|
||||||
|
week = (day - pd.to_timedelta(dt.dt.dayofweek, unit="D"))
|
||||||
|
g = pd.DataFrame({"wk": week.values,
|
||||||
|
"high": df["high"].values.astype(float),
|
||||||
|
"low": df["low"].values.astype(float)})
|
||||||
|
per = g.groupby("wk").agg(wh=("high", "max"), wl=("low", "min"))
|
||||||
|
m = pd.DataFrame({"wh": per["wh"].shift(1), "wl": per["wl"].shift(1)}).reindex(g["wk"].values)
|
||||||
|
return m["wh"].values, m["wl"].values
|
||||||
|
|
||||||
|
|
||||||
|
def get_levels(df: pd.DataFrame, level: str, shift_h: int = 0):
|
||||||
|
if level == "prevday":
|
||||||
|
return prior_day_levels(df, shift_h)
|
||||||
|
if level == "prevweek":
|
||||||
|
return prior_week_levels(df)
|
||||||
|
if level == "don20":
|
||||||
|
return al.donchian(df, 20)
|
||||||
|
if level == "don55":
|
||||||
|
return al.donchian(df, 55)
|
||||||
|
raise ValueError(level)
|
||||||
|
|
||||||
|
|
||||||
|
def level_lookback_bars(level: str, bpd: int) -> int:
|
||||||
|
"""Lookback per il conteggio equal-touch = finestra del livello stesso."""
|
||||||
|
return {"prevday": 2 * bpd, "prevweek": 7 * bpd, "don20": 20, "don55": 55}[level]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# EVENTI (sweep-and-reclaim) + outcome trade-level (overlap PERMESSO -> paired analysis)
|
||||||
|
# ===========================================================================
|
||||||
|
def _unfilled_fvg(h: np.ndarray, l: np.ndarray, i: int, d: int, price: float) -> bool:
|
||||||
|
"""SHORT (d=-1): esiste FVG BULLISH (low[j] > high[j-2]) nelle ultime FVG_K barre con zona
|
||||||
|
(high[j-2], low[j]) interamente SOTTO il prezzo e mai riempita (nessuna barra dopo j e'
|
||||||
|
scesa fino al bordo inferiore). LONG (d=+1): simmetrico con FVG bearish sopra."""
|
||||||
|
j0 = max(2, i - FVG_K)
|
||||||
|
for j in range(i - 1, j0 - 1, -1):
|
||||||
|
if d == -1 and l[j] > h[j - 2]:
|
||||||
|
zone_lo, zone_hi = h[j - 2], l[j]
|
||||||
|
if zone_hi < price and np.min(l[j + 1:i + 1]) > zone_lo:
|
||||||
|
return True
|
||||||
|
if d == 1 and h[j] < l[j - 2]:
|
||||||
|
zone_lo, zone_hi = h[j], l[j - 2]
|
||||||
|
if zone_lo > price and np.max(h[j + 1:i + 1]) < zone_hi:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def build_events(df: pd.DataFrame, level: str, shift_h: int = 0,
|
||||||
|
with_context: bool = True) -> pd.DataFrame:
|
||||||
|
"""Tabella eventi sweep-and-reclaim con outcome trade-level (entry close[i], exit da i+1,
|
||||||
|
SL prioritario, fee 0.10% RT) + feature di contesto (eq/fvg/session). Overlap permesso:
|
||||||
|
ogni evento valutato indipendentemente -> confronto PAIRED filtro-vs-tutti pulito."""
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
n = len(c)
|
||||||
|
lvl_hi, lvl_lo = get_levels(df, level, shift_h)
|
||||||
|
a14 = al.atr(df, 14)
|
||||||
|
dt = pd.to_datetime(df["datetime"], utc=True)
|
||||||
|
hours = dt.dt.hour.values
|
||||||
|
bpd = al.bars_per_day(df)
|
||||||
|
lb = level_lookback_bars(level, bpd)
|
||||||
|
|
||||||
|
sw_hi = np.isfinite(lvl_hi) & (h > lvl_hi) & (c < lvl_hi)
|
||||||
|
sw_lo = np.isfinite(lvl_lo) & (l < lvl_lo) & (c > lvl_lo)
|
||||||
|
both = sw_hi & sw_lo
|
||||||
|
sw_hi &= ~both
|
||||||
|
sw_lo &= ~both
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for i in np.where(sw_hi | sw_lo)[0]:
|
||||||
|
if i >= n - 1 or i < 60:
|
||||||
|
continue
|
||||||
|
d = -1 if sw_hi[i] else 1
|
||||||
|
entry = c[i]
|
||||||
|
atr_i = a14[i]
|
||||||
|
if not np.isfinite(atr_i) or atr_i <= 0:
|
||||||
|
continue
|
||||||
|
if d == -1:
|
||||||
|
sl = h[i] + SL_ATR_BUF * atr_i
|
||||||
|
risk = sl - entry
|
||||||
|
tp = entry - R_MULT * risk
|
||||||
|
else:
|
||||||
|
sl = l[i] - SL_ATR_BUF * atr_i
|
||||||
|
risk = entry - sl
|
||||||
|
tp = entry + R_MULT * risk
|
||||||
|
if risk <= 0 or tp <= 0:
|
||||||
|
continue
|
||||||
|
jend = min(i + MAX_HOLD, n - 1)
|
||||||
|
exit_price = c[jend]
|
||||||
|
for j in range(i + 1, jend + 1):
|
||||||
|
if d == 1:
|
||||||
|
if l[j] <= sl:
|
||||||
|
exit_price = sl
|
||||||
|
break
|
||||||
|
if h[j] >= tp:
|
||||||
|
exit_price = tp
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if h[j] >= sl:
|
||||||
|
exit_price = sl
|
||||||
|
break
|
||||||
|
if l[j] <= tp:
|
||||||
|
exit_price = tp
|
||||||
|
break
|
||||||
|
exit_price = c[j]
|
||||||
|
gross = d * (exit_price - entry) / entry
|
||||||
|
L = lvl_hi[i] if d == -1 else lvl_lo[i]
|
||||||
|
row = dict(i=int(i), dir=int(d), entry=entry, sl=sl, tp=tp, level_px=float(L),
|
||||||
|
atr=float(atr_i), gross=gross, net=gross - FEE_RT)
|
||||||
|
if with_context:
|
||||||
|
j0 = max(0, i - lb)
|
||||||
|
tol = EQ_TOL_ATR * atr_i
|
||||||
|
touches = int(np.sum(np.abs((h if d == -1 else l)[j0:i] - L) <= tol))
|
||||||
|
row["eq"] = touches >= EQ_MIN_TOUCH
|
||||||
|
row["fvg"] = _unfilled_fvg(h, l, int(i), int(d), float(entry))
|
||||||
|
hr = int(hours[i])
|
||||||
|
row["ses"] = next((s for s, (a, b) in SESSIONS.items() if a <= hr < b), "none")
|
||||||
|
rows.append(row)
|
||||||
|
ev = pd.DataFrame(rows)
|
||||||
|
if len(ev):
|
||||||
|
ev["dt"] = dt.values[ev["i"].values]
|
||||||
|
ev["hold"] = pd.to_datetime(ev["dt"], utc=True) >= HOLDOUT
|
||||||
|
ev["year"] = pd.to_datetime(ev["dt"], utc=True).dt.year
|
||||||
|
return ev
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# STRATEGIA (non-overlap, harness ufficiale) — metriche daily-step
|
||||||
|
# ===========================================================================
|
||||||
|
def entries_from(df: pd.DataFrame, sub: pd.DataFrame) -> list:
|
||||||
|
ent: list = [None] * len(df)
|
||||||
|
for row in sub.itertuples():
|
||||||
|
ent[row.i] = dict(dir=int(row.dir), tp=float(row.tp), sl=float(row.sl),
|
||||||
|
max_bars=MAX_HOLD)
|
||||||
|
return ent
|
||||||
|
|
||||||
|
|
||||||
|
def strat_eval(df: pd.DataFrame, entries: list, fee_rt: float = FEE_RT) -> dict:
|
||||||
|
m = backtest_signals(df, entries, fee_rt=fee_rt, leverage=1.0)
|
||||||
|
idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
|
||||||
|
eq = pd.Series(m.equity, index=idx)
|
||||||
|
d = eq.resample("1D").last().dropna().pct_change().dropna()
|
||||||
|
di, dh = d[d.index < HOLDOUT], d[d.index >= HOLDOUT]
|
||||||
|
return dict(n_trades=int(m.n_trades), wr=round(m.win_rate, 1), dd=round(m.max_dd, 4),
|
||||||
|
sh_full=round(al._sh(d), 3), sh_is=round(al._sh(di), 3),
|
||||||
|
sh_hold=round(al._sh(dh), 3), daily=d)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_filter(ev: pd.DataFrame, filt: str | None) -> pd.DataFrame:
|
||||||
|
if not len(ev) or filt is None:
|
||||||
|
return ev
|
||||||
|
if filt == "eq":
|
||||||
|
return ev[ev["eq"]]
|
||||||
|
if filt == "fvg":
|
||||||
|
return ev[ev["fvg"]]
|
||||||
|
if filt.startswith("ses_"):
|
||||||
|
return ev[ev["ses"] == filt[4:]]
|
||||||
|
raise ValueError(filt)
|
||||||
|
|
||||||
|
|
||||||
|
def eval_trial(events: dict, tf: str, level: str, filt: str | None) -> dict:
|
||||||
|
per_asset, dailies = {}, {}
|
||||||
|
for a in ASSETS:
|
||||||
|
df = al.get(a, tf)
|
||||||
|
ev = events[(a, tf, level)]
|
||||||
|
sub = apply_filter(ev, filt)
|
||||||
|
r = strat_eval(df, entries_from(df, sub))
|
||||||
|
yrs = (pd.to_datetime(df["datetime"].iloc[-1], utc=True)
|
||||||
|
- pd.to_datetime(df["datetime"].iloc[0], utc=True)).days / 365.25
|
||||||
|
per_asset[a] = dict(r, n_ev=len(sub), ev_per_yr=round(len(sub) / yrs, 1),
|
||||||
|
exp_is=_exp(sub, False), exp_hold=_exp(sub, True))
|
||||||
|
dailies[a] = r["daily"]
|
||||||
|
J = pd.concat(dailies, axis=1, join="inner").fillna(0.0)
|
||||||
|
comb = J.mean(axis=1)
|
||||||
|
ci, ch = comb[comb.index < HOLDOUT], comb[comb.index >= HOLDOUT]
|
||||||
|
return dict(tf=tf, level=level, filt=filt or "-", per_asset=per_asset, comb_daily=comb,
|
||||||
|
sh_is=round(al._sh(ci), 3), sh_hold=round(al._sh(ch), 3),
|
||||||
|
sh_full=round(al._sh(comb), 3),
|
||||||
|
min_sh_is=round(min(per_asset[a]["sh_is"] for a in ASSETS), 3),
|
||||||
|
min_sh_hold=round(min(per_asset[a]["sh_hold"] for a in ASSETS), 3))
|
||||||
|
|
||||||
|
|
||||||
|
def _exp(sub: pd.DataFrame, hold: bool):
|
||||||
|
"""Expectancy netta per trade (%) sullo slice IS/HOLD."""
|
||||||
|
if not len(sub):
|
||||||
|
return None
|
||||||
|
s = sub[sub["hold"] == hold]["net"]
|
||||||
|
return round(float(s.mean()) * 100, 3) if len(s) else None
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# MAIN
|
||||||
|
# ===========================================================================
|
||||||
|
def main():
|
||||||
|
print("=" * 100)
|
||||||
|
print("r0702 CRT CON CONTESTO — sweep-and-reclaim su livelli + filtri (EQ/FVG/sessione)")
|
||||||
|
print(f"setup fisso: entry close C2, SL estremo+/-{SL_ATR_BUF}*ATR14, TP R={R_MULT}:1, "
|
||||||
|
f"max_hold {MAX_HOLD} barre, fee {FEE_RT*100:.2f}% RT, SL prioritario same-bar")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
# ---------- 0. CAUSALITY CHECK sui livelli (prefisso troncato) ----------
|
||||||
|
print("\n[0] CAUSALITY CHECK livelli (ricalcolo su prefisso troncato, tail 200 barre)")
|
||||||
|
for level in LEVELS:
|
||||||
|
worst = 0.0
|
||||||
|
for a in ASSETS:
|
||||||
|
df = al.get(a, "1h")
|
||||||
|
cut = int(len(df) * 0.9)
|
||||||
|
hi_f, lo_f = get_levels(df, level)
|
||||||
|
sub = df.iloc[:cut].reset_index(drop=True)
|
||||||
|
hi_s, lo_s = get_levels(sub, level)
|
||||||
|
for full, part in ((hi_f, hi_s), (lo_f, lo_s)):
|
||||||
|
x, y = np.nan_to_num(full[cut - 200:cut]), np.nan_to_num(part[cut - 200:cut])
|
||||||
|
worst = max(worst, float(np.max(np.abs(x - y))))
|
||||||
|
print(f" {level:<9s} max_tail_diff={worst:.10f} {'OK' if worst < 1e-9 else 'FAIL'}")
|
||||||
|
|
||||||
|
# ---------- 1. EVENTI (cache) ----------
|
||||||
|
events = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
for tf in ("1h", "4h"):
|
||||||
|
df = al.get(a, tf)
|
||||||
|
for level in LEVELS:
|
||||||
|
events[(a, tf, level)] = build_events(df, level)
|
||||||
|
|
||||||
|
# ---------- 2. TRIALS (22, definiti a priori) ----------
|
||||||
|
trials = []
|
||||||
|
for tf in ("1h", "4h"):
|
||||||
|
for level in LEVELS:
|
||||||
|
trials.append((tf, level, None))
|
||||||
|
for tf in ("1h", "4h"):
|
||||||
|
for level in ("don20", "prevday"):
|
||||||
|
trials.append((tf, level, "eq"))
|
||||||
|
trials.append((tf, level, "fvg"))
|
||||||
|
for level in ("don20", "prevday"):
|
||||||
|
for ses in SESSIONS:
|
||||||
|
trials.append(("1h", level, f"ses_{ses}"))
|
||||||
|
assert len(trials) == 22
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for tf, level, filt in trials:
|
||||||
|
results[(tf, level, filt or "-")] = eval_trial(events, tf, level, filt)
|
||||||
|
|
||||||
|
# ---------- 3. BASELINE INCONDIZIONATA (don20, nessun filtro) ----------
|
||||||
|
print("\n[1] BASELINE INCONDIZIONATA — sweep-and-reclaim Donchian20, nessun contesto")
|
||||||
|
for tf in ("1h", "4h"):
|
||||||
|
r = results[(tf, "don20", "-")]
|
||||||
|
print(f" TF {tf}: comb Sharpe IS={r['sh_is']:+.2f} HOLD={r['sh_hold']:+.2f} "
|
||||||
|
f"FULL={r['sh_full']:+.2f}")
|
||||||
|
for a in ASSETS:
|
||||||
|
p = r["per_asset"][a]
|
||||||
|
print(f" {a}: ev/yr={p['ev_per_yr']:>6.1f} trades(no-overlap)={p['n_trades']:>5d} "
|
||||||
|
f"wr={p['wr']:>4.1f}% expIS={p['exp_is']}% expHOLD={p['exp_hold']}% "
|
||||||
|
f"Sh IS={p['sh_is']:+.2f} HOLD={p['sh_hold']:+.2f} DD={p['dd']*100:.1f}%")
|
||||||
|
|
||||||
|
# ---------- 4. TUTTE LE CELLE (tabella) ----------
|
||||||
|
print("\n[2] TUTTE LE 22 CELLE (comb 50/50, daily-step Sharpe; exp = %/trade netto)")
|
||||||
|
print(f" {'tf':<3s} {'level':<9s} {'filt':<9s} {'ShIS':>6s} {'ShHOLD':>7s} {'ShFULL':>7s} "
|
||||||
|
f"{'minShIS':>8s} {'minShHOLD':>9s} {'BTCexpIS':>9s} {'ETHexpIS':>9s} "
|
||||||
|
f"{'BTCexpH':>8s} {'ETHexpH':>8s} {'nBTC':>5s} {'nETH':>5s}")
|
||||||
|
for (tf, level, filt), r in sorted(results.items(), key=lambda kv: -kv[1]["sh_is"]):
|
||||||
|
pb, pe = r["per_asset"]["BTC"], r["per_asset"]["ETH"]
|
||||||
|
print(f" {tf:<3s} {level:<9s} {filt:<9s} {r['sh_is']:>+6.2f} {r['sh_hold']:>+7.2f} "
|
||||||
|
f"{r['sh_full']:>+7.2f} {r['min_sh_is']:>+8.2f} {r['min_sh_hold']:>+9.2f} "
|
||||||
|
f"{str(pb['exp_is']):>9s} {str(pe['exp_is']):>9s} "
|
||||||
|
f"{str(pb['exp_hold']):>8s} {str(pe['exp_hold']):>8s} "
|
||||||
|
f"{pb['n_ev']:>5d} {pe['n_ev']:>5d}")
|
||||||
|
|
||||||
|
# ---------- 5. UPLIFT PAIRED dei filtri (stessi eventi, sottoinsieme vs tutti) ----------
|
||||||
|
print("\n[3] UPLIFT PAIRED per filtro (expectancy %/trade: filtrato - tutti; stessi eventi)")
|
||||||
|
print(f" {'base':<16s} {'filtro':<9s} {'asset':<4s} {'slice':<5s} {'n_all':>6s} {'n_f':>5s} "
|
||||||
|
f"{'exp_all':>8s} {'exp_f':>8s} {'uplift':>8s}")
|
||||||
|
filt_names = ["eq", "fvg"] + [f"ses_{s}" for s in SESSIONS]
|
||||||
|
uplift_summary = {}
|
||||||
|
for tf in ("1h", "4h"):
|
||||||
|
for level in ("don20", "prevday"):
|
||||||
|
for filt in filt_names:
|
||||||
|
if filt.startswith("ses_") and tf != "1h":
|
||||||
|
continue
|
||||||
|
key = (tf, level, filt)
|
||||||
|
for a in ASSETS:
|
||||||
|
ev = events[(a, tf, level)]
|
||||||
|
sub = apply_filter(ev, filt)
|
||||||
|
for hold, lab in ((False, "IS"), (True, "HOLD")):
|
||||||
|
ea, ef = _exp(ev, hold), _exp(sub, hold)
|
||||||
|
na = int((ev["hold"] == hold).sum()) if len(ev) else 0
|
||||||
|
nf = int((sub["hold"] == hold).sum()) if len(sub) else 0
|
||||||
|
up = round(ef - ea, 3) if (ea is not None and ef is not None) else None
|
||||||
|
uplift_summary.setdefault(key, []).append((a, lab, up))
|
||||||
|
print(f" {tf+'/'+level:<16s} {filt:<9s} {a:<4s} {lab:<5s} {na:>6d} "
|
||||||
|
f"{nf:>5d} {str(ea):>8s} {str(ef):>8s} {str(up):>8s}")
|
||||||
|
print("\n Consistenza uplift per filtro (positivo su TUTTE le 4 slice asset x IS/HOLD?):")
|
||||||
|
for key, ups in uplift_summary.items():
|
||||||
|
vals = [u for (_, _, u) in ups if u is not None]
|
||||||
|
n_pos = sum(1 for u in vals if u > 0)
|
||||||
|
print(f" {key[0]}/{key[1]}+{key[2]:<9s}: {n_pos}/{len(vals)} slice positive "
|
||||||
|
f"{'<-- consistente' if vals and n_pos == len(vals) else ''}")
|
||||||
|
|
||||||
|
# ---------- 6. SELEZIONE IN-SAMPLE + DSR ----------
|
||||||
|
all_sr = [r["sh_full"] for r in results.values()]
|
||||||
|
chosen_key = max(results, key=lambda k: results[k]["sh_is"])
|
||||||
|
ch = results[chosen_key]
|
||||||
|
dsr, sr0 = al.deflated_sharpe(ch["sh_full"], all_sr, ch["comb_daily"])
|
||||||
|
print(f"\n[4] SELEZIONE IN-SAMPLE-ONLY (pre-2025) su {len(trials)} trial")
|
||||||
|
print(f" best-IS: {chosen_key} ShIS={ch['sh_is']:+.2f} ShHOLD={ch['sh_hold']:+.2f} "
|
||||||
|
f"ShFULL={ch['sh_full']:+.2f}")
|
||||||
|
print(f" deflated Sharpe (n_trials={len(all_sr)}): DSR={dsr:.3f} "
|
||||||
|
f"(PASS>=0.95) expected-null-max Sharpe={sr0:.2f}")
|
||||||
|
|
||||||
|
# fee sweep sul best-IS e sulla baseline don20/1h
|
||||||
|
print("\n[5] FEE SWEEP (Sharpe FULL comb per fee RT)")
|
||||||
|
for key in {chosen_key, ("1h", "don20", "-")}:
|
||||||
|
tf, level, filt = key
|
||||||
|
row = []
|
||||||
|
for fee in (0.0, 0.0005, 0.001, 0.0015, 0.002):
|
||||||
|
dailies = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
df = al.get(a, tf)
|
||||||
|
sub = apply_filter(events[(a, tf, level)], None if filt == "-" else filt)
|
||||||
|
# ricalcola net eventi con fee diversa e' lineare; per la strategia rifacciamo il bt
|
||||||
|
dailies[a] = strat_eval(df, entries_from(df, sub), fee_rt=fee)["daily"]
|
||||||
|
J = pd.concat(dailies, axis=1, join="inner").fillna(0.0)
|
||||||
|
row.append(f"{fee*100:.2f}%RT:{al._sh(J.mean(axis=1)):+.2f}")
|
||||||
|
print(f" {key}: " + " ".join(row))
|
||||||
|
|
||||||
|
# ---------- 7. ANCHOR-SHIFT sulle celle sessione (+/-2/4h) ----------
|
||||||
|
print("\n[6] ANCHOR-SHIFT celle sessione (label ora spostata; uplift expectancy IS per shift)")
|
||||||
|
for level in ("don20", "prevday"):
|
||||||
|
for ses in SESSIONS:
|
||||||
|
per_shift = {}
|
||||||
|
for sh in (-4, -2, 0, 2, 4):
|
||||||
|
ups = []
|
||||||
|
for a in ASSETS:
|
||||||
|
ev = events[(a, "1h", level)]
|
||||||
|
if not len(ev):
|
||||||
|
continue
|
||||||
|
hrs = (pd.to_datetime(ev["dt"], utc=True).dt.hour + sh) % 24
|
||||||
|
a_, b_ = SESSIONS[ses]
|
||||||
|
sub = ev[(hrs >= a_) & (hrs < b_)]
|
||||||
|
ea, ef = _exp(ev, False), _exp(sub, False)
|
||||||
|
if ea is not None and ef is not None:
|
||||||
|
ups.append(ef - ea)
|
||||||
|
per_shift[sh] = round(float(np.mean(ups)), 3) if ups else None
|
||||||
|
vals = [v for v in per_shift.values() if v is not None]
|
||||||
|
flip = vals and (max(vals) > 0 > min(vals)) and (max(vals) - min(vals)) > 0.05
|
||||||
|
verd = "ARTIFACT-RISK(flip)" if flip else \
|
||||||
|
("stabile-pos" if vals and min(vals) > 0 else
|
||||||
|
"stabile-neg/nullo" if vals and max(vals) <= 0 else "misto-debole")
|
||||||
|
print(f" {level}+{ses:<5s}: " +
|
||||||
|
" ".join(f"{k:+d}h:{v}" for k, v in per_shift.items()) + f" -> {verd}")
|
||||||
|
|
||||||
|
# ---------- 8. DAY-BOUNDARY SHIFT sul fade prevday base (1h) ----------
|
||||||
|
print("\n[7] DAY-BOUNDARY SHIFT su fade prevday base 1h (livelli ricostruiti col giorno spostato)")
|
||||||
|
for sh in (0, 2, 4, 8, 12):
|
||||||
|
dailies = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
df = al.get(a, "1h")
|
||||||
|
ev = build_events(df, "prevday", shift_h=sh, with_context=False)
|
||||||
|
dailies[a] = strat_eval(df, entries_from(df, ev))["daily"]
|
||||||
|
J = pd.concat(dailies, axis=1, join="inner").fillna(0.0)
|
||||||
|
comb = J.mean(axis=1)
|
||||||
|
ci = comb[comb.index < HOLDOUT]
|
||||||
|
print(f" shift +{sh:>2d}h: Sh IS={al._sh(ci):+.2f} FULL={al._sh(comb):+.2f}")
|
||||||
|
|
||||||
|
# ---------- 9. FADE vs FOLLOW sui livelli prior-day (lead esistente) ----------
|
||||||
|
print("\n[8] FADE (questo filone, prevday base 1h) vs FOLLOW (lead prevday_breakout congelato)")
|
||||||
|
fol = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
df = al.get(a, "1h")
|
||||||
|
evw = al.eval_weights(df, prevday_follow_target(df))
|
||||||
|
fol[a] = pd.Series(evw["net"], index=evw["idx"])
|
||||||
|
Jf = pd.concat(fol, axis=1, join="inner").fillna(0.0)
|
||||||
|
follow_d = al._to_daily(0.5 * Jf["BTC"] + 0.5 * Jf["ETH"])
|
||||||
|
fade_d = results[("1h", "prevday", "-")]["comb_daily"]
|
||||||
|
JJ = pd.concat({"fade": fade_d, "follow": follow_d}, axis=1, join="inner").dropna()
|
||||||
|
JH = JJ[JJ.index >= HOLDOUT]
|
||||||
|
JI = JJ[JJ.index < HOLDOUT]
|
||||||
|
print(f" corr daily fade-follow: FULL={JJ['fade'].corr(JJ['follow']):+.3f} "
|
||||||
|
f"HOLD={JH['fade'].corr(JH['follow']):+.3f}")
|
||||||
|
print(f" Sharpe IS : fade={al._sh(JI['fade']):+.2f} follow={al._sh(JI['follow']):+.2f}")
|
||||||
|
print(f" Sharpe HOLD: fade={al._sh(JH['fade']):+.2f} follow={al._sh(JH['follow']):+.2f}")
|
||||||
|
print(" per anno (Sharpe fade | follow):")
|
||||||
|
for y in sorted(set(JJ.index.year)):
|
||||||
|
sub = JJ[JJ.index.year == y]
|
||||||
|
if len(sub) > 40:
|
||||||
|
print(f" {y}: {al._sh(sub['fade']):+.2f} | {al._sh(sub['follow']):+.2f}")
|
||||||
|
|
||||||
|
# ---------- 10. MARGINAL vs TP01 (solo se il best-IS regge) ----------
|
||||||
|
if ch["sh_full"] >= 0.5 and ch["sh_is"] >= 0.5:
|
||||||
|
print("\n[9] MARGINAL vs TP01 (best-IS regge >=0.5 -> gate)")
|
||||||
|
m = al.marginal_vs_tp01(ch["comb_daily"])
|
||||||
|
print(f" verdict={m.get('marginal_verdict')} corr_full={m.get('corr_full')} "
|
||||||
|
f"uplift w25 full={m['blends']['w25']['uplift_full']:+.3f} "
|
||||||
|
f"hold={m['blends']['w25']['uplift_hold']}")
|
||||||
|
print(f" has_insample_edge={m.get('has_insample_edge')} is_hedge={m.get('is_hedge')} "
|
||||||
|
f"robust_oos={m.get('robust_oos')} multicut={m.get('multicut_uplift')}")
|
||||||
|
else:
|
||||||
|
print(f"\n[9] MARGINAL vs TP01: SALTATO — best-IS Sharpe FULL={ch['sh_full']:+.2f} / "
|
||||||
|
f"IS={ch['sh_is']:+.2f} sotto la soglia 0.5 standalone")
|
||||||
|
|
||||||
|
print("\nFine. Nessun file scritto fuori da questo script; selezione solo in-sample.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""r0702_crt_mtf.py — CRT (Candle Range Theory) MULTI-TIMEFRAME — filone 2026-07-02.
|
||||||
|
|
||||||
|
TESI DA TESTARE (scuola CRT): il pattern a 3 candele
|
||||||
|
C1 = candela-range forte; C2 = sweep di UN estremo di C1 con close back-inside (presa di
|
||||||
|
liquidita'); C3 = ingresso CONTRO il breakout
|
||||||
|
renderebbe molto di piu' eseguito MULTI-TF: struttura su TF alto (4h/1h), ingresso sul TF basso
|
||||||
|
(15m/5m) al RITEST della zona violata -> stop dietro lo swing del TF basso (piu' stretto del
|
||||||
|
"dietro l'estremo di C2" single-TF) -> R:R da ~1.3 a ~3+.
|
||||||
|
|
||||||
|
DISEGNO SPERIMENTALE: confronto CONTROLLATO/PAIRED sugli STESSI pattern C1-C2, tre esecuzioni:
|
||||||
|
(i) BASE single-TF: entry a open di C3, stop dietro l'estremo di C2, target estremo opposto C1
|
||||||
|
(ii) MTF ritest della zona + trigger di conferma sul TF basso, stop dietro lo swing basso
|
||||||
|
(iii) NOTRIG ritest puro (entry al primo tocco della zona), senza conferma bassa
|
||||||
|
Tutte e tre simulate sulla STESSA griglia di barre del TF basso (fill intrabar identici,
|
||||||
|
conservativi: SL prima di TP nella stessa barra bassa). Fee 0.10% RT + sweep 0/0.10/0.20.
|
||||||
|
|
||||||
|
DEFINIZIONI FISSATE A PRIORI (dichiarate prima di guardare i risultati, nessuna sensibilita' qui;
|
||||||
|
la sensibilita' della detection e' del filone base single-TF):
|
||||||
|
- C1 forte: range >= 1.2 * ATR14 del TF alto (UNA definizione; body/range NON usato).
|
||||||
|
- C2: rompe UN SOLO estremo di C1 (doppio sweep = skip) e chiude DENTRO il range di C1.
|
||||||
|
- Finestra: 1 barra del TF alto dopo la chiusura di C2 (la "C3").
|
||||||
|
- Max hold: 20 barre high-TF dall'apertura della finestra, poi exit a market al close
|
||||||
|
(identico per tutte le varianti -> confronto pulito).
|
||||||
|
- Invalidation (solo MTF/NOTRIG): se PRIMA del trigger il prezzo supera l'estremo di C2
|
||||||
|
(>=, conservativo), setup invalidato -> no trade (la BASE nello stesso caso
|
||||||
|
viene semplicemente stoppata: e' la differenza strutturale fra le esecuzioni).
|
||||||
|
- R:R >= 1.3 all'entry per MTF/NOTRIG (parte della tesi CRT-MTF). La BASE non e' filtrata
|
||||||
|
(e' l'esecuzione classica single-TF).
|
||||||
|
- Sizing: 1.0x nozionale per trade; book SEQUENZIALE per asset (1 trade aperto alla volta)
|
||||||
|
per la serie daily (Sharpe/DD); expectancy per-trade su TUTTI i pattern (indip.).
|
||||||
|
|
||||||
|
GRIGLIA (unica, chiusa a priori): d in {0.10, 0.25} x trigger in {closeback, sweeprec} per MTF;
|
||||||
|
d in {0.10, 0.25} per NOTRIG. Selezione cella SOLO in-sample (<2025-01-01). Trials per DSR =
|
||||||
|
(1 base + 4 MTF + 2 NOTRIG) x 2 coppie TF = 14.
|
||||||
|
|
||||||
|
Trigger meccanici sul TF basso (per short, simmetrico per long); L = estremo C1 violato,
|
||||||
|
zona = [L - d*ATR14_alto, L]:
|
||||||
|
- closeback: dopo che una barra bassa ha TOCCATO la zona (high >= L - d*ATR), la prima barra
|
||||||
|
bassa che CHIUDE sotto L -> entry al suo close.
|
||||||
|
- sweeprec: barra bassa j che tocca la zona E sweep del massimo della barra bassa precedente
|
||||||
|
(high[j] > high[j-1]) E chiude sotto high[j-1] E sotto L -> entry al close di j.
|
||||||
|
Stop MTF/NOTRIG = estremo dello swing basso (max high dall'apertura della finestra alla barra
|
||||||
|
del trigger inclusa). Target (tutte): estremo opposto di C1.
|
||||||
|
|
||||||
|
Esecuzione: uv run python scripts/research/r0702_crt_mtf.py
|
||||||
|
NON tocca src/, config/, scripts/live/. Nessun file scritto.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
import altlib as al
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- config (a priori)
|
||||||
|
PAIRS = (("4h", "15m"), ("1h", "5m"))
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
D_GRID = (0.10, 0.25)
|
||||||
|
TRIGGERS = ("closeback", "sweeprec")
|
||||||
|
MAXHOLD_HTF = 20 # barre high-TF di holding max, dalla apertura della finestra C3
|
||||||
|
RR_MIN = 1.3 # filtro R:R all'entry (solo MTF/NOTRIG)
|
||||||
|
ATR_MULT = 1.2 # C1 forte: range >= 1.2*ATR14 (definizione unica)
|
||||||
|
FEE_RT = 0.001 # 0.10% round-trip
|
||||||
|
FEE_SWEEP_RT = (0.0, 0.001, 0.002)
|
||||||
|
TF_MS = {"5m": 300_000, "15m": 900_000, "1h": 3_600_000, "4h": 14_400_000}
|
||||||
|
HOLDOUT = al.HOLDOUT
|
||||||
|
HOLDOUT_MS = int(HOLDOUT.value // 10**6)
|
||||||
|
CAPITAL = 600.0
|
||||||
|
LEV_CAP = 2.0
|
||||||
|
MIN_ORDER = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- detection (vettoriale)
|
||||||
|
def detect_patterns(dfh: pd.DataFrame, tf_hi: str) -> list[dict]:
|
||||||
|
"""CRT C1-C2 sul TF alto. Pattern noto alla CHIUSURA di C2 (causale: usa solo barre <= C2)."""
|
||||||
|
ts = dfh["timestamp"].astype("int64").values
|
||||||
|
o = dfh["open"].values.astype(float)
|
||||||
|
h = dfh["high"].values.astype(float)
|
||||||
|
l = dfh["low"].values.astype(float)
|
||||||
|
c = dfh["close"].values.astype(float)
|
||||||
|
a = al.atr(dfh, 14)
|
||||||
|
rng = h - l
|
||||||
|
strong = rng >= ATR_MULT * a
|
||||||
|
|
||||||
|
h1 = np.roll(h, 1); l1 = np.roll(l, 1); s1 = np.roll(strong, 1)
|
||||||
|
up = s1 & (h > h1) & ~(l < l1) & (c <= h1) & (c >= l1) # sweep del massimo di C1 -> SHORT
|
||||||
|
dn = s1 & (l < l1) & ~(h > h1) & (c >= l1) & (c <= h1) # sweep del minimo di C1 -> LONG
|
||||||
|
idx = np.where(up | dn)[0]
|
||||||
|
tf_ms = TF_MS[tf_hi]
|
||||||
|
pats = []
|
||||||
|
for i in idx:
|
||||||
|
if i < 20: # warm-up ATR
|
||||||
|
continue
|
||||||
|
if up[i]:
|
||||||
|
d, level, target, c2ext = -1, h1[i], l1[i], h[i]
|
||||||
|
else:
|
||||||
|
d, level, target, c2ext = +1, l1[i], h1[i], l[i]
|
||||||
|
pats.append(dict(i=int(i), dir=d, level=float(level), target=float(target),
|
||||||
|
c2ext=float(c2ext), atr=float(a[i]),
|
||||||
|
win_open=int(ts[i] + tf_ms),
|
||||||
|
win_close=int(ts[i] + 2 * tf_ms),
|
||||||
|
hold_end=int(ts[i] + (1 + MAXHOLD_HTF) * tf_ms)))
|
||||||
|
return pats
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- low-TF arrays
|
||||||
|
class Low:
|
||||||
|
__slots__ = ("ts", "o", "h", "l", "c", "tsclose", "n")
|
||||||
|
|
||||||
|
def __init__(self, df: pd.DataFrame, tf_lo: str):
|
||||||
|
self.ts = df["timestamp"].astype("int64").values
|
||||||
|
self.o = df["open"].values.astype(float)
|
||||||
|
self.h = df["high"].values.astype(float)
|
||||||
|
self.l = df["low"].values.astype(float)
|
||||||
|
self.c = df["close"].values.astype(float)
|
||||||
|
self.tsclose = self.ts + TF_MS[tf_lo]
|
||||||
|
self.n = len(self.ts)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_exit(L: Low, j0: int, j1: int, dr: int, entry_ts: int,
|
||||||
|
stop: float, target: float):
|
||||||
|
"""Barre j0..j1-1; conservativo: SL prima di TP nella stessa barra. Ritorna (px, ts, kind)."""
|
||||||
|
Lh, Ll, Lc, Ltsc = L.h, L.l, L.c, L.tsclose
|
||||||
|
for j in range(j0, j1):
|
||||||
|
if dr < 0:
|
||||||
|
if Lh[j] >= stop:
|
||||||
|
return stop, int(Ltsc[j]), "SL"
|
||||||
|
if Ll[j] <= target:
|
||||||
|
return target, int(Ltsc[j]), "TP"
|
||||||
|
else:
|
||||||
|
if Ll[j] <= stop:
|
||||||
|
return stop, int(Ltsc[j]), "SL"
|
||||||
|
if Lh[j] >= target:
|
||||||
|
return target, int(Ltsc[j]), "TP"
|
||||||
|
if j1 - 1 < j0:
|
||||||
|
return None, entry_ts, "NOBARS"
|
||||||
|
return float(Lc[j1 - 1]), int(Ltsc[j1 - 1]), "TIME"
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_trade(p, entry, entry_ts, stop, exitp, exit_ts, kind, jt=None, j1=None):
|
||||||
|
dr = p["dir"]
|
||||||
|
risk = abs(stop - entry) / entry
|
||||||
|
gross = dr * (exitp / entry - 1.0)
|
||||||
|
rr = (abs(entry - p["target"]) / abs(stop - entry)) if stop != entry else np.inf
|
||||||
|
return dict(ok=True, dir=dr, entry=entry, stop=stop, target=p["target"],
|
||||||
|
risk=risk, rr=rr, gross=gross, entry_ts=int(entry_ts),
|
||||||
|
exit_ts=int(exit_ts), kind=kind, jt=jt, j1=j1)
|
||||||
|
|
||||||
|
|
||||||
|
def trade_base(p: dict, L: Low):
|
||||||
|
"""(i) BASE: entry a open C3 (= prima barra bassa della finestra), stop dietro estremo C2."""
|
||||||
|
j0 = int(np.searchsorted(L.ts, p["win_open"]))
|
||||||
|
if j0 >= L.n or L.ts[j0] >= p["win_close"]:
|
||||||
|
return dict(ok=False, reason="nodata")
|
||||||
|
entry = float(L.o[j0])
|
||||||
|
dr, stop, target = p["dir"], p["c2ext"], p["target"]
|
||||||
|
if (dr < 0 and not (target < entry < stop)) or (dr > 0 and not (stop < entry < target)):
|
||||||
|
return dict(ok=False, reason="degenerate")
|
||||||
|
j1 = int(np.searchsorted(L.ts, p["hold_end"]))
|
||||||
|
exitp, exit_ts, kind = scan_exit(L, j0, j1, dr, int(L.ts[j0]), stop, target)
|
||||||
|
if exitp is None:
|
||||||
|
return dict(ok=False, reason="nodata")
|
||||||
|
return _mk_trade(p, entry, L.ts[j0], stop, exitp, exit_ts, kind, jt=j0, j1=j1)
|
||||||
|
|
||||||
|
|
||||||
|
def trade_mtf(p: dict, L: Low, d_mult: float, trigger: str | None):
|
||||||
|
"""(ii) MTF con trigger / (iii) NOTRIG (trigger=None): ritest della zona nella finestra C3."""
|
||||||
|
j0 = int(np.searchsorted(L.ts, p["win_open"]))
|
||||||
|
if j0 >= L.n or L.ts[j0] >= p["win_close"]:
|
||||||
|
return dict(ok=False, reason="nodata")
|
||||||
|
jw = int(np.searchsorted(L.ts, p["win_close"]))
|
||||||
|
dr, level, c2ext, target = p["dir"], p["level"], p["c2ext"], p["target"]
|
||||||
|
zone = d_mult * p["atr"]
|
||||||
|
Lh, Ll, Lc = L.h, L.l, L.c
|
||||||
|
touched = False
|
||||||
|
jt = -1
|
||||||
|
if dr < 0:
|
||||||
|
swing = -np.inf
|
||||||
|
for j in range(j0, jw):
|
||||||
|
if Lh[j] > swing:
|
||||||
|
swing = Lh[j]
|
||||||
|
if Lh[j] >= c2ext: # struttura violata prima del trigger
|
||||||
|
return dict(ok=False, reason="invalidated")
|
||||||
|
if Lh[j] >= level - zone:
|
||||||
|
touched = True
|
||||||
|
if touched:
|
||||||
|
if trigger is None:
|
||||||
|
jt = j; break
|
||||||
|
if trigger == "closeback" and Lc[j] < level:
|
||||||
|
jt = j; break
|
||||||
|
if (trigger == "sweeprec" and j >= 1 and Lh[j] >= level - zone
|
||||||
|
and Lh[j] > Lh[j - 1] and Lc[j] < Lh[j - 1] and Lc[j] < level):
|
||||||
|
jt = j; break
|
||||||
|
if jt < 0:
|
||||||
|
return dict(ok=False, reason=("notrigger" if touched else "noretest"))
|
||||||
|
stop = float(max(swing, Lh[jt]))
|
||||||
|
entry = float(Lc[jt])
|
||||||
|
if not (target < entry < stop):
|
||||||
|
return dict(ok=False, reason="degenerate")
|
||||||
|
else:
|
||||||
|
swing = np.inf
|
||||||
|
for j in range(j0, jw):
|
||||||
|
if Ll[j] < swing:
|
||||||
|
swing = Ll[j]
|
||||||
|
if Ll[j] <= c2ext:
|
||||||
|
return dict(ok=False, reason="invalidated")
|
||||||
|
if Ll[j] <= level + zone:
|
||||||
|
touched = True
|
||||||
|
if touched:
|
||||||
|
if trigger is None:
|
||||||
|
jt = j; break
|
||||||
|
if trigger == "closeback" and Lc[j] > level:
|
||||||
|
jt = j; break
|
||||||
|
if (trigger == "sweeprec" and j >= 1 and Ll[j] <= level + zone
|
||||||
|
and Ll[j] < Ll[j - 1] and Lc[j] > Ll[j - 1] and Lc[j] > level):
|
||||||
|
jt = j; break
|
||||||
|
if jt < 0:
|
||||||
|
return dict(ok=False, reason=("notrigger" if touched else "noretest"))
|
||||||
|
stop = float(min(swing, Ll[jt]))
|
||||||
|
entry = float(Lc[jt])
|
||||||
|
if not (stop < entry < target):
|
||||||
|
return dict(ok=False, reason="degenerate")
|
||||||
|
rr = abs(entry - target) / abs(stop - entry)
|
||||||
|
if rr < RR_MIN:
|
||||||
|
return dict(ok=False, reason="rrfail")
|
||||||
|
j1 = int(np.searchsorted(L.ts, p["hold_end"]))
|
||||||
|
exitp, exit_ts, kind = scan_exit(L, jt + 1, j1, dr, int(L.tsclose[jt]), stop, target)
|
||||||
|
if exitp is None: # trigger sull'ultima barra: flat, -fee
|
||||||
|
exitp, exit_ts, kind = entry, int(L.tsclose[jt]), "NOBARS"
|
||||||
|
return _mk_trade(p, entry, L.tsclose[jt], stop, exitp, exit_ts, kind, jt=jt, j1=j1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- stats & book
|
||||||
|
def trade_stats(trades: list[dict], fee_rt: float = FEE_RT) -> dict:
|
||||||
|
tr = [t for t in trades if t and t.get("ok")]
|
||||||
|
if not tr:
|
||||||
|
return dict(n=0, exp_bps=np.nan, wr=np.nan, avgR=np.nan, med_risk=np.nan, avg_rr=np.nan)
|
||||||
|
nets = np.array([t["gross"] - fee_rt for t in tr])
|
||||||
|
Rs = np.array([(t["gross"] - fee_rt) / t["risk"] for t in tr if t["risk"] > 0])
|
||||||
|
return dict(n=len(tr), exp_bps=float(nets.mean() * 1e4), wr=float((nets > 0).mean() * 100),
|
||||||
|
avgR=float(Rs.mean()) if len(Rs) else np.nan,
|
||||||
|
med_risk=float(np.median([t["risk"] for t in tr]) * 100),
|
||||||
|
avg_rr=float(np.mean([min(t["rr"], 50.0) for t in tr])))
|
||||||
|
|
||||||
|
|
||||||
|
def seq_filter(trades: list[dict]) -> list[dict]:
|
||||||
|
out, last = [], -1
|
||||||
|
for t in sorted((t for t in trades if t and t.get("ok")), key=lambda x: x["entry_ts"]):
|
||||||
|
if t["entry_ts"] >= last:
|
||||||
|
out.append(t)
|
||||||
|
last = t["exit_ts"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def daily_series(seq_trades: list[dict], span: tuple[int, int], fee_rt: float = FEE_RT) -> pd.Series:
|
||||||
|
idx = pd.date_range(pd.Timestamp(span[0], unit="ms", tz="UTC").normalize(),
|
||||||
|
pd.Timestamp(span[1], unit="ms", tz="UTC").normalize(), freq="D")
|
||||||
|
s = pd.Series(0.0, index=idx)
|
||||||
|
for t in seq_trades:
|
||||||
|
d = pd.Timestamp(t["exit_ts"], unit="ms", tz="UTC").normalize()
|
||||||
|
if d in s.index:
|
||||||
|
s[d] += t["gross"] - fee_rt
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def sh_dd(s: pd.Series) -> tuple[float, float]:
|
||||||
|
sharpe = al._sh(s)
|
||||||
|
eq = np.cumprod(1.0 + s.values)
|
||||||
|
pk = np.maximum.accumulate(eq)
|
||||||
|
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||||
|
return sharpe, dd
|
||||||
|
|
||||||
|
|
||||||
|
def portfolio_daily(res_pair: dict, key, spans: dict, fee_rt: float = FEE_RT) -> pd.Series:
|
||||||
|
per = []
|
||||||
|
for a in ASSETS:
|
||||||
|
seq = seq_filter(res_pair[a][key])
|
||||||
|
per.append(daily_series(seq, spans[a], fee_rt))
|
||||||
|
J = pd.concat(per, axis=1).fillna(0.0)
|
||||||
|
return 0.5 * J.iloc[:, 0] + 0.5 * J.iloc[:, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def split_hold(trades: list[dict]) -> tuple[list, list]:
|
||||||
|
ins = [t for t in trades if t and t.get("ok") and t["entry_ts"] < HOLDOUT_MS]
|
||||||
|
hold = [t for t in trades if t and t.get("ok") and t["entry_ts"] >= HOLDOUT_MS]
|
||||||
|
return ins, hold
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- delayed execution (cron orario)
|
||||||
|
def delayed_eval(trades: list[dict], L: Low, fee_rt: float = FEE_RT) -> dict:
|
||||||
|
"""Il book live gira ogni ora: il segnale (close barra bassa) viene eseguito alla PRIMA
|
||||||
|
chiusura di barra bassa sulla griglia oraria successiva. Se nel frattempo SL/TP e' gia'
|
||||||
|
stato attraversato -> nessun ingresso (skip). Ritorna expectancy originale vs ritardata."""
|
||||||
|
orig, dela, delays = [], [], []
|
||||||
|
n_skip_sl = n_skip_tp = n_missed_window = 0
|
||||||
|
for t in trades:
|
||||||
|
if not (t and t.get("ok")):
|
||||||
|
continue
|
||||||
|
ts_e = t["entry_ts"]
|
||||||
|
boundary = ((ts_e + 3_599_999) // 3_600_000) * 3_600_000
|
||||||
|
delays.append((boundary - ts_e) / 60_000.0)
|
||||||
|
if boundary == ts_e:
|
||||||
|
orig.append(t["gross"] - fee_rt)
|
||||||
|
dela.append(t["gross"] - fee_rt)
|
||||||
|
continue
|
||||||
|
jb = int(np.searchsorted(L.tsclose, boundary))
|
||||||
|
j1 = t["j1"]
|
||||||
|
if jb >= L.n or jb >= j1:
|
||||||
|
n_missed_window += 1
|
||||||
|
orig.append(t["gross"] - fee_rt)
|
||||||
|
continue
|
||||||
|
dr, stop, target = t["dir"], t["stop"], t["target"]
|
||||||
|
crossed = None
|
||||||
|
for j in range(t["jt"] + 1, jb + 1):
|
||||||
|
if dr < 0:
|
||||||
|
if L.h[j] >= stop:
|
||||||
|
crossed = "SL"; break
|
||||||
|
if L.l[j] <= target:
|
||||||
|
crossed = "TP"; break
|
||||||
|
else:
|
||||||
|
if L.l[j] <= stop:
|
||||||
|
crossed = "SL"; break
|
||||||
|
if L.h[j] >= target:
|
||||||
|
crossed = "TP"; break
|
||||||
|
orig.append(t["gross"] - fee_rt)
|
||||||
|
if crossed == "SL":
|
||||||
|
n_skip_sl += 1
|
||||||
|
continue
|
||||||
|
if crossed == "TP":
|
||||||
|
n_skip_tp += 1
|
||||||
|
continue
|
||||||
|
entry2 = float(L.c[jb])
|
||||||
|
if (dr < 0 and not (target < entry2 < stop)) or (dr > 0 and not (stop < entry2 < target)):
|
||||||
|
n_skip_sl += 1
|
||||||
|
continue
|
||||||
|
exitp, _, _ = scan_exit(L, jb + 1, j1, dr, int(L.tsclose[jb]), stop, target)
|
||||||
|
if exitp is None:
|
||||||
|
exitp = entry2
|
||||||
|
dela.append(dr * (exitp / entry2 - 1.0) - fee_rt)
|
||||||
|
n_sig = len(orig)
|
||||||
|
return dict(n_signals=n_sig, mean_delay_min=float(np.mean(delays)) if delays else np.nan,
|
||||||
|
n_entered=len(dela), n_skip_sl=n_skip_sl, n_skip_tp=n_skip_tp,
|
||||||
|
n_missed_window=n_missed_window,
|
||||||
|
exp_orig_bps=float(np.mean(orig) * 1e4) if orig else np.nan,
|
||||||
|
exp_delayed_entered_bps=float(np.mean(dela) * 1e4) if dela else np.nan,
|
||||||
|
exp_delayed_per_signal_bps=float(np.sum(dela) / n_sig * 1e4) if n_sig else np.nan)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- main
|
||||||
|
def key_label(key) -> str:
|
||||||
|
if key == ("base",):
|
||||||
|
return "BASE single-TF "
|
||||||
|
if key[0] == "mtf":
|
||||||
|
return f"MTF d={key[1]:.2f} {key[2]:<9s}"
|
||||||
|
return f"NOTRIG d={key[1]:.2f} "
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
t0 = time.time()
|
||||||
|
print("=" * 100)
|
||||||
|
print("r0702 CRT MULTI-TIMEFRAME — struttura su TF alto, ingresso su TF basso (paired vs base)")
|
||||||
|
print(f"C1 forte: range>={ATR_MULT}*ATR14 | maxhold {MAXHOLD_HTF} barre HTF | RR>={RR_MIN} (MTF) "
|
||||||
|
f"| fee {FEE_RT*1e4:.0f}bps RT | hold-out >= {HOLDOUT.date()}")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
all_trial_sharpes = [] # per DSR: full Sharpe di OGNI (pair, variant-cell)
|
||||||
|
chosen_summaries = [] # per selezione finale cross-pair
|
||||||
|
|
||||||
|
for tf_hi, tf_lo in PAIRS:
|
||||||
|
print(f"\n{'#'*100}\n### COPPIA {tf_hi} -> {tf_lo}\n{'#'*100}")
|
||||||
|
res: dict[str, dict] = {}
|
||||||
|
spans: dict[str, tuple[int, int]] = {}
|
||||||
|
reasons: dict[str, dict] = {}
|
||||||
|
n_pats: dict[str, int] = {}
|
||||||
|
|
||||||
|
variant_keys = [("base",)] + [("mtf", d, tr) for d in D_GRID for tr in TRIGGERS] \
|
||||||
|
+ [("notrig", d) for d in D_GRID]
|
||||||
|
|
||||||
|
for a in ASSETS:
|
||||||
|
dfh = al.get(a, tf_hi)
|
||||||
|
L = Low(al.get(a, tf_lo), tf_lo)
|
||||||
|
spans[a] = (int(L.ts[0]), int(L.tsclose[-1]))
|
||||||
|
pats = detect_patterns(dfh, tf_hi)
|
||||||
|
n_pats[a] = len(pats)
|
||||||
|
res[a] = {}
|
||||||
|
reasons[a] = {}
|
||||||
|
for key in variant_keys:
|
||||||
|
outs = []
|
||||||
|
for p in pats:
|
||||||
|
if key[0] == "base":
|
||||||
|
outs.append(trade_base(p, L))
|
||||||
|
elif key[0] == "mtf":
|
||||||
|
outs.append(trade_mtf(p, L, key[1], key[2]))
|
||||||
|
else:
|
||||||
|
outs.append(trade_mtf(p, L, key[1], None))
|
||||||
|
res[a][key] = outs
|
||||||
|
reasons[a][key] = Counter(t.get("reason") for t in outs if not t.get("ok"))
|
||||||
|
print(f" {a}: {len(pats)} pattern C1-C2 su {tf_hi} "
|
||||||
|
f"(short={sum(1 for p in pats if p['dir'] < 0)}, long={sum(1 for p in pats if p['dir'] > 0)})")
|
||||||
|
|
||||||
|
# ------- tabella varianti: per-trade (tutti i pattern, indip.) + book sequenziale 50/50
|
||||||
|
print(f"\n --- VARIANTI (pooled BTC+ETH; per-trade su tutti i pattern; Sharpe/DD su book "
|
||||||
|
f"sequenziale 50/50, daily) ---")
|
||||||
|
hdr = (f" {'variante':<24s} | {'n_FULL':>6s} {'exp(bps)':>8s} {'WR%':>5s} {'avgR':>6s} "
|
||||||
|
f"{'RRm':>5s} {'Sh_F':>6s} {'DD_F%':>6s} | {'n_H':>5s} {'expH':>8s} {'WRH':>5s} "
|
||||||
|
f"{'Sh_H':>6s} | {'riskMed%':>8s}")
|
||||||
|
print(hdr)
|
||||||
|
table = {}
|
||||||
|
for key in variant_keys:
|
||||||
|
pooled = res["BTC"][key] + res["ETH"][key]
|
||||||
|
ins, hold = split_hold(pooled)
|
||||||
|
st_f = trade_stats(ins + hold)
|
||||||
|
st_h = trade_stats(hold)
|
||||||
|
port = portfolio_daily(res, key, spans)
|
||||||
|
sh_f, dd_f = sh_dd(port)
|
||||||
|
ph = port[port.index >= HOLDOUT]
|
||||||
|
sh_h, _ = sh_dd(ph) if len(ph) > 30 else (np.nan, np.nan)
|
||||||
|
pi = port[port.index < HOLDOUT]
|
||||||
|
sh_is, _ = sh_dd(pi) if len(pi) > 30 else (np.nan, np.nan)
|
||||||
|
st_is = trade_stats(ins)
|
||||||
|
table[key] = dict(st_f=st_f, st_h=st_h, st_is=st_is, sh_f=sh_f, dd_f=dd_f,
|
||||||
|
sh_h=sh_h, sh_is=sh_is, port=port)
|
||||||
|
all_trial_sharpes.append(sh_f)
|
||||||
|
print(f" {key_label(key)} | {st_f['n']:>6d} {st_f['exp_bps']:>8.1f} {st_f['wr']:>5.1f} "
|
||||||
|
f"{st_f['avgR']:>6.2f} {st_f['avg_rr']:>5.1f} {sh_f:>6.2f} {dd_f*100:>6.1f} | "
|
||||||
|
f"{st_h['n']:>5d} {st_h['exp_bps']:>8.1f} {st_h['wr']:>5.1f} {sh_h:>6.2f} | "
|
||||||
|
f"{st_f['med_risk']:>8.3f}")
|
||||||
|
|
||||||
|
# ------- quota pattern senza ritest / invalidati / rr-fail (per cella MTF)
|
||||||
|
print("\n --- FUNNEL pattern -> trade (pooled, % dei pattern) ---")
|
||||||
|
for key in variant_keys[1:]:
|
||||||
|
cnt = reasons["BTC"][key] + reasons["ETH"][key]
|
||||||
|
tot = n_pats["BTC"] + n_pats["ETH"]
|
||||||
|
n_tr = table[key]["st_f"]["n"]
|
||||||
|
print(f" {key_label(key)} | trade {n_tr:>5d} ({n_tr/tot*100:4.1f}%) | "
|
||||||
|
f"no-ritest {cnt.get('noretest', 0)/tot*100:4.1f}% | "
|
||||||
|
f"no-trigger {cnt.get('notrigger', 0)/tot*100:4.1f}% | "
|
||||||
|
f"invalidato {cnt.get('invalidated', 0)/tot*100:4.1f}% | "
|
||||||
|
f"RR<{RR_MIN} {cnt.get('rrfail', 0)/tot*100:4.1f}% | "
|
||||||
|
f"altro {sum(v for k, v in cnt.items() if k in ('nodata', 'degenerate'))/tot*100:4.1f}%")
|
||||||
|
|
||||||
|
# ------- selezione cella SOLO in-sample (<2025)
|
||||||
|
mtf_keys = [k for k in variant_keys if k[0] == "mtf"]
|
||||||
|
ntg_keys = [k for k in variant_keys if k[0] == "notrig"]
|
||||||
|
|
||||||
|
def is_score(k):
|
||||||
|
v = table[k]["sh_is"]
|
||||||
|
return v if np.isfinite(v) else -9
|
||||||
|
|
||||||
|
best_mtf = max(mtf_keys, key=is_score)
|
||||||
|
best_ntg = max(ntg_keys, key=is_score)
|
||||||
|
print(f"\n --- SELEZIONE IN-SAMPLE (<2025, Sharpe book 50/50) ---")
|
||||||
|
for k in mtf_keys + ntg_keys:
|
||||||
|
mark = " <== scelta" if k in (best_mtf, best_ntg) else ""
|
||||||
|
print(f" {key_label(k)} | Sh_IS={table[k]['sh_is']:>6.2f} exp_IS={table[k]['st_is']['exp_bps']:>7.1f}bps "
|
||||||
|
f"(n_IS={table[k]['st_is']['n']}){mark}")
|
||||||
|
print(f" BASE | Sh_IS={table[('base',)]['sh_is']:>6.2f} "
|
||||||
|
f"exp_IS={table[('base',)]['st_is']['exp_bps']:>7.1f}bps (n_IS={table[('base',)]['st_is']['n']})")
|
||||||
|
|
||||||
|
# ------- confronto PAIRED sugli stessi pattern (subset dove TUTTE e 3 hanno tradato)
|
||||||
|
print(f"\n --- PAIRED sugli stessi pattern (BASE vs MTF{best_mtf[1:]} vs NOTRIG d={best_ntg[1]}) ---")
|
||||||
|
for label, mask_hold in (("FULL", None), ("HOLD", True)):
|
||||||
|
diffs_mb, diffs_nb = [], []
|
||||||
|
rows = {k: [] for k in (("base",), best_mtf, best_ntg)}
|
||||||
|
for a in ASSETS:
|
||||||
|
for tb, tm, tn in zip(res[a][("base",)], res[a][best_mtf], res[a][best_ntg]):
|
||||||
|
if not (tb.get("ok") and tm.get("ok") and tn.get("ok")):
|
||||||
|
continue
|
||||||
|
if mask_hold and tm["entry_ts"] < HOLDOUT_MS:
|
||||||
|
continue
|
||||||
|
if mask_hold is None and False:
|
||||||
|
continue
|
||||||
|
rows[("base",)].append(tb)
|
||||||
|
rows[best_mtf].append(tm)
|
||||||
|
rows[best_ntg].append(tn)
|
||||||
|
diffs_mb.append((tm["gross"] - FEE_RT) - (tb["gross"] - FEE_RT))
|
||||||
|
diffs_nb.append((tn["gross"] - FEE_RT) - (tb["gross"] - FEE_RT))
|
||||||
|
n = len(diffs_mb)
|
||||||
|
if n < 5:
|
||||||
|
print(f" [{label}] n={n} — potenza statistica insufficiente per il paired")
|
||||||
|
continue
|
||||||
|
d_mb = np.array(diffs_mb); d_nb = np.array(diffs_nb)
|
||||||
|
t_mb = d_mb.mean() / (d_mb.std(ddof=1) / np.sqrt(n)) if d_mb.std() > 0 else np.nan
|
||||||
|
t_nb = d_nb.mean() / (d_nb.std(ddof=1) / np.sqrt(n)) if d_nb.std() > 0 else np.nan
|
||||||
|
print(f" [{label}] n_paired={n}")
|
||||||
|
for k in (("base",), best_mtf, best_ntg):
|
||||||
|
st = trade_stats(rows[k])
|
||||||
|
print(f" {key_label(k)} | exp={st['exp_bps']:>7.1f}bps WR={st['wr']:>5.1f}% "
|
||||||
|
f"avgR={st['avgR']:>6.2f} RRmedio={st['avg_rr']:>4.1f} riskMed={st['med_risk']:.3f}%")
|
||||||
|
print(f" Δ(MTF-BASE) = {d_mb.mean()*1e4:>+7.1f}bps/trade t={t_mb:+.2f}")
|
||||||
|
print(f" Δ(NOTRIG-BASE)= {d_nb.mean()*1e4:>+7.1f}bps/trade t={t_nb:+.2f}")
|
||||||
|
|
||||||
|
# ------- fee sweep (celle scelte + base)
|
||||||
|
print(f"\n --- FEE SWEEP (exp bps/trade FULL | Sharpe book) ---")
|
||||||
|
for k in (("base",), best_mtf, best_ntg):
|
||||||
|
parts = []
|
||||||
|
for f in FEE_SWEEP_RT:
|
||||||
|
pooled = [t for t in res["BTC"][k] + res["ETH"][k] if t.get("ok")]
|
||||||
|
e = np.mean([t["gross"] - f for t in pooled]) * 1e4 if pooled else np.nan
|
||||||
|
shf, _ = sh_dd(portfolio_daily(res, k, spans, fee_rt=f))
|
||||||
|
parts.append(f"{f*1e4:3.0f}bps: {e:+7.1f}bps/Sh {shf:+5.2f}")
|
||||||
|
print(f" {key_label(k)} | " + " | ".join(parts))
|
||||||
|
|
||||||
|
# ------- esecuzione ritardata alla griglia oraria (celle MTF scelte)
|
||||||
|
print(f"\n --- ESECUZIONE RITARDATA (cron orario) ---")
|
||||||
|
for k in (best_mtf, best_ntg):
|
||||||
|
agg = dict(n_signals=0, n_entered=0, n_skip_sl=0, n_skip_tp=0, n_missed_window=0)
|
||||||
|
wsum_o = wsum_d = wsum_ps = 0.0
|
||||||
|
dsum = 0.0
|
||||||
|
for a in ASSETS:
|
||||||
|
L = Low(al.get(a, tf_lo), tf_lo)
|
||||||
|
d = delayed_eval(res[a][k], L)
|
||||||
|
for kk in agg:
|
||||||
|
agg[kk] += d[kk]
|
||||||
|
wsum_o += d["exp_orig_bps"] * d["n_signals"] if d["n_signals"] else 0
|
||||||
|
wsum_d += d["exp_delayed_entered_bps"] * d["n_entered"] if d["n_entered"] else 0
|
||||||
|
wsum_ps += d["exp_delayed_per_signal_bps"] * d["n_signals"] if d["n_signals"] else 0
|
||||||
|
dsum += d["mean_delay_min"] * d["n_signals"] if d["n_signals"] else 0
|
||||||
|
ns, ne = agg["n_signals"], agg["n_entered"]
|
||||||
|
print(f" {key_label(k)} | segnali {ns} | gap medio {dsum/ns if ns else np.nan:.1f}min | "
|
||||||
|
f"entrati {ne} ({ne/ns*100 if ns else 0:.0f}%) skipSL {agg['n_skip_sl']} "
|
||||||
|
f"skipTP {agg['n_skip_tp']} persi-finestra {agg['n_missed_window']}")
|
||||||
|
print(f" exp originale {wsum_o/ns if ns else np.nan:+.1f}bps/trade -> ritardata "
|
||||||
|
f"{wsum_d/ne if ne else np.nan:+.1f}bps/trade (entrati) | per-SEGNALE "
|
||||||
|
f"{wsum_ps/ns if ns else np.nan:+.1f}bps")
|
||||||
|
|
||||||
|
# ------- executability a $600
|
||||||
|
print(f"\n --- EXECUTABILITY $600 (cap leva {LEV_CAP}x, min order ${MIN_ORDER}) ---")
|
||||||
|
for k in (("base",), best_mtf, best_ntg):
|
||||||
|
tr = [t for t in res["BTC"][k] + res["ETH"][k] if t.get("ok")]
|
||||||
|
if not tr:
|
||||||
|
continue
|
||||||
|
risks = np.array([t["risk"] for t in tr]) * 100
|
||||||
|
med = float(np.median(risks))
|
||||||
|
lev_1pct = 1.0 / med if med > 0 else np.inf
|
||||||
|
yrs = (spans["BTC"][1] - spans["BTC"][0]) / (365.25 * 86400e3)
|
||||||
|
tpy = len(seq_filter(res["BTC"][k])) / yrs + len(seq_filter(res["ETH"][k])) / yrs
|
||||||
|
print(f" {key_label(k)} | stopMed {med:.3f}% (p25 {np.percentile(risks, 25):.3f} / "
|
||||||
|
f"p75 {np.percentile(risks, 75):.3f}) | leva per rischio-1% = {lev_1pct:.1f}x "
|
||||||
|
f"-> CAP {LEV_CAP}x: rischio/trade {LEV_CAP*med:.2f}% (${CAPITAL*LEV_CAP*med/100:.1f}) "
|
||||||
|
f"| nozionale ${CAPITAL*LEV_CAP:.0f} > min ${MIN_ORDER} OK | ~{tpy:.0f} trade/anno (seq)")
|
||||||
|
|
||||||
|
chosen_summaries.append(dict(pair=f"{tf_hi}->{tf_lo}", key=best_mtf, table=table,
|
||||||
|
res=res, spans=spans, tf_lo=tf_lo))
|
||||||
|
|
||||||
|
# ---------------- DSR sul candidato scelto in-sample fra TUTTI i trial
|
||||||
|
print(f"\n{'='*100}\n### GATE STATISTICI GLOBALI\n{'='*100}")
|
||||||
|
best = max(chosen_summaries, key=lambda cs: cs["table"][cs["key"]]["sh_is"]
|
||||||
|
if np.isfinite(cs["table"][cs["key"]]["sh_is"]) else -9)
|
||||||
|
bt = best["table"][best["key"]]
|
||||||
|
print(f"Candidato scelto (best in-sample fra le celle MTF): {best['pair']} {key_label(best['key'])} "
|
||||||
|
f"| Sh_IS={bt['sh_is']:.2f} Sh_FULL={bt['sh_f']:.2f} Sh_HOLD={bt['sh_h']:.2f}")
|
||||||
|
valid_trials = [s for s in all_trial_sharpes if np.isfinite(s)]
|
||||||
|
dsr, sr0 = al.deflated_sharpe(bt["sh_f"], valid_trials, bt["port"].values)
|
||||||
|
print(f"Deflated Sharpe (n_trials={len(valid_trials)}): DSR={dsr:.3f} "
|
||||||
|
f"(expected null max Sharpe={sr0:.2f}) -> {'PASS' if dsr >= 0.95 else 'FAIL'} (soglia 0.95)")
|
||||||
|
|
||||||
|
if np.isfinite(bt["sh_f"]) and bt["sh_f"] >= 0.5:
|
||||||
|
print("\nSharpe >= 0.5 -> marginal_vs_tp01:")
|
||||||
|
m = al.marginal_vs_tp01(bt["port"])
|
||||||
|
print(f" verdict={m.get('marginal_verdict')} corr_full={m.get('corr_full')} "
|
||||||
|
f"uplift w25 full={m['blends']['w25']['uplift_full']} hold={m['blends']['w25']['uplift_hold']} "
|
||||||
|
f"has_insample_edge={m.get('has_insample_edge')} is_hedge={m.get('is_hedge')} "
|
||||||
|
f"robust_oos={m.get('robust_oos')}")
|
||||||
|
else:
|
||||||
|
print(f"\nSharpe full {bt['sh_f']:.2f} < 0.5 -> marginal_vs_tp01 NON eseguito (sotto soglia).")
|
||||||
|
|
||||||
|
print(f"\n[runtime {time.time()-t0:.0f}s]")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
"""r0702_ell_channel — "tecnica del canale" Elliott (Ftaonline): falsificazione onesta.
|
||||||
|
|
||||||
|
FILONE (2026-07-02): l'unica parte pienamente meccanica/falsificabile del metodo Elliott
|
||||||
|
dell'analista Ftaonline:
|
||||||
|
- Swing meccanici (zigzag causale k*ATR): pivot 0 (origine), massimo "onda 1", minimo
|
||||||
|
"onda 2" (vincolo: sopra l'origine, altrimenti conteggio NULLO).
|
||||||
|
- Canale: retta 0 -> minimo onda 2; parallela dal massimo di onda 1.
|
||||||
|
- SEGNALE 1: close FUORI dal lato alto del canale = "onda 3" -> long al close della barra
|
||||||
|
di rottura; target = min(onda2) + 1.618 * ampiezza(onda 1); stop = min(onda 2).
|
||||||
|
- SEGNALE 2 (variante): rottura del massimo di onda 3 dopo un pivot di onda 4 che NON
|
||||||
|
sovrappone il territorio di onda 1 -> target = min(onda4) + 1.0 * ampiezza(onda 1).
|
||||||
|
- REGOLA DISCRIMINANTE: movimento che NON esce mai dal canale = correttivo (nessun trade;
|
||||||
|
segnale opposto alla violazione della base). Testata separatamente con null permutato.
|
||||||
|
- Speculare per lo short.
|
||||||
|
|
||||||
|
COVERAGE (scripts/research/alt/runs, sweep 104 famiglie 2026-06-20): BRK01 (Donchian LS/LF),
|
||||||
|
BRK02 (Donchian+chandelier), BRK03 (Keltner), BRK04 (Bollinger), BRK05 (ATR-range), BRK08
|
||||||
|
(NR7), BRK09 (inside-bar), BRK10 (squeeze) + SKH01 coprono la famiglia breakout-canale, ma
|
||||||
|
NESSUNO costruisce canali da pivot zigzag con vincoli d'onda e target 1.618 -> non identico,
|
||||||
|
pero' stessa famiglia: per giudizio si confronta ANCHE contro un Donchian a pari geometria
|
||||||
|
(stop = base canale, target = base + 1.618*larghezza) e pari frequenza di trade.
|
||||||
|
|
||||||
|
ONESTA':
|
||||||
|
- Pivot noti solo alla CONFERMA (reversal k*ATR dal running extreme); il canale usa solo
|
||||||
|
pivot confermati al tempo t. Guard: al.causality_ok (prefix-recompute).
|
||||||
|
- Entry a close[i] della barra che CHIUDE fuori dal canale (mai fill sull'estremo).
|
||||||
|
- Exit a target/stop/timeout ESEGUITE AL CLOSE della barra che li tocca (gap-through-stop
|
||||||
|
reale, lezione SKH01). Il fill-al-livello e' riportato SOLO come lens ottimista dichiarata.
|
||||||
|
- Fee 0.10% RT + sweep 0.00-0.20% (al.study_weights).
|
||||||
|
- Selezione cella IN-SAMPLE-ONLY + deflated Sharpe su TUTTA la griglia
|
||||||
|
(al.study_family_honest); marginale vs TP01 (al.study_marginal).
|
||||||
|
- 4h: banda d'ancora su offset 0/1/2/3h (regola anchor-luck 2026-07-02). Epoca ms
|
||||||
|
ESPLICITA nel resample (MAI DatetimeIndex.view("int64")).
|
||||||
|
- Hold-out 2025+ mai usato per selezionare. Timeout 150 barre FISSO (non cercato).
|
||||||
|
|
||||||
|
Output temporanei: scratchpad ell_c_*. Diario: da scrivere a valle (a cura del chiamante).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
|
||||||
|
SCRATCH = ("/tmp/claude-1001/-opt-docker-PythagorasGoal/"
|
||||||
|
"e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad")
|
||||||
|
TFS = ("1d", "4h", "1h")
|
||||||
|
KS = (2.0, 3.0, 4.0) # zigzag: reversal = k * ATR(14)
|
||||||
|
VARIANTS = ("s1_long", "s1_ls", "s12_ls")
|
||||||
|
TIMEOUT = 150 # barre, fisso e dichiarato (non cercato)
|
||||||
|
ATR_WIN = 14
|
||||||
|
TGT1, TGT2 = 1.618, 1.0
|
||||||
|
SEED = 20260702
|
||||||
|
|
||||||
|
_ZZ_CACHE: dict = {}
|
||||||
|
_SIM_CACHE: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _dfkey(df: pd.DataFrame, asset: str):
|
||||||
|
return (asset, int(df["timestamp"].iloc[0]), int(df["timestamp"].iloc[-1]), len(df))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 1) ZIGZAG CAUSALE — pivot confermato SOLO quando il close ritraccia k*ATR
|
||||||
|
# dal running extreme. Ogni valore usa dati <= i (prefix-stable).
|
||||||
|
# ===========================================================================
|
||||||
|
def zigzag(df: pd.DataFrame, k: float, asset: str = "?"):
|
||||||
|
"""Ritorna lista di pivot confermati (conf_i, piv_i, piv_price, kind); kind +1=high,
|
||||||
|
-1=low. Un pivot e' utilizzabile solo da conf_i in poi."""
|
||||||
|
key = _dfkey(df, asset) + (k,)
|
||||||
|
if key in _ZZ_CACHE:
|
||||||
|
return _ZZ_CACHE[key]
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
a = al.atr(df, ATR_WIN)
|
||||||
|
n = len(c)
|
||||||
|
piv = []
|
||||||
|
dir_ = 0 # 0=unknown, +1=gamba su (caccio un high), -1=gamba giu
|
||||||
|
hi_v, hi_i = h[0], 0
|
||||||
|
lo_v, lo_i = l[0], 0
|
||||||
|
for i in range(1, n):
|
||||||
|
thr = k * a[i]
|
||||||
|
if dir_ >= 0 and h[i] > hi_v:
|
||||||
|
hi_v, hi_i = h[i], i
|
||||||
|
if dir_ <= 0 and l[i] < lo_v:
|
||||||
|
lo_v, lo_i = l[i], i
|
||||||
|
if dir_ >= 0 and c[i] < hi_v - thr:
|
||||||
|
piv.append((i, hi_i, float(hi_v), +1))
|
||||||
|
dir_ = -1
|
||||||
|
j0 = hi_i + 1
|
||||||
|
if j0 <= i:
|
||||||
|
seg = l[j0:i + 1]
|
||||||
|
off = int(np.argmin(seg))
|
||||||
|
lo_v, lo_i = float(seg[off]), j0 + off
|
||||||
|
else:
|
||||||
|
lo_v, lo_i = l[i], i
|
||||||
|
elif dir_ <= 0 and c[i] > lo_v + thr:
|
||||||
|
piv.append((i, lo_i, float(lo_v), -1))
|
||||||
|
dir_ = +1
|
||||||
|
j0 = lo_i + 1
|
||||||
|
if j0 <= i:
|
||||||
|
seg = h[j0:i + 1]
|
||||||
|
off = int(np.argmax(seg))
|
||||||
|
hi_v, hi_i = float(seg[off]), j0 + off
|
||||||
|
else:
|
||||||
|
hi_v, hi_i = h[i], i
|
||||||
|
_ZZ_CACHE[key] = piv
|
||||||
|
return piv
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 2) STATE MACHINE canale Elliott — forward-only, un trade alla volta.
|
||||||
|
# ===========================================================================
|
||||||
|
def simulate(df: pd.DataFrame, k: float, variant: str, asset: str = "?") -> dict:
|
||||||
|
key = _dfkey(df, asset) + (k, variant)
|
||||||
|
if key in _SIM_CACHE:
|
||||||
|
return _SIM_CACHE[key]
|
||||||
|
piv = zigzag(df, k, asset)
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
n = len(c)
|
||||||
|
pos = np.zeros(n)
|
||||||
|
trades: list = []
|
||||||
|
events: list = []
|
||||||
|
cnt = dict(setups_long=0, setups_short=0, null_count=0, superseded=0)
|
||||||
|
allow_short = variant in ("s1_ls", "s12_ls")
|
||||||
|
allow_s2 = variant == "s12_ls"
|
||||||
|
LS = SS = None # setup long / short attivo
|
||||||
|
S2L = S2S = None # stato onda-5 (dopo S1 chiuso a target)
|
||||||
|
tr = None # trade aperto
|
||||||
|
pi = 0
|
||||||
|
for i in range(1, n):
|
||||||
|
# --- (a) gestione trade aperto: exit AL CLOSE della barra che tocca ------------
|
||||||
|
if tr is not None and i > tr["e"]:
|
||||||
|
reason = None
|
||||||
|
if tr["dir"] == +1:
|
||||||
|
if l[i] <= tr["stp"]:
|
||||||
|
reason = "stop" # stop prioritario se tocca entrambi
|
||||||
|
elif h[i] >= tr["tgt"]:
|
||||||
|
reason = "target"
|
||||||
|
else:
|
||||||
|
if h[i] >= tr["stp"]:
|
||||||
|
reason = "stop"
|
||||||
|
elif l[i] <= tr["tgt"]:
|
||||||
|
reason = "target"
|
||||||
|
if reason is None and i - tr["e"] >= TIMEOUT:
|
||||||
|
reason = "timeout"
|
||||||
|
if reason:
|
||||||
|
tr["x"], tr["exit_px"], tr["reason"] = i, float(c[i]), reason
|
||||||
|
trades.append(tr)
|
||||||
|
if allow_s2 and tr["sig"] == "S1" and reason == "target":
|
||||||
|
if tr["dir"] == +1:
|
||||||
|
S2L = dict(stage="w3h", P1p=tr["P1p"], amp=tr["amp"], after=tr["e"])
|
||||||
|
else:
|
||||||
|
S2S = dict(stage="w3l", P1p=tr["P1p"], amp=tr["amp"], after=tr["e"])
|
||||||
|
tr = None
|
||||||
|
pos[i] = 0.0
|
||||||
|
else:
|
||||||
|
pos[i] = tr["dir"]
|
||||||
|
# --- (b) pivot confermati a questa barra ----------------------------------------
|
||||||
|
while pi < len(piv) and piv[pi][0] == i:
|
||||||
|
_, p_i, p_px, kind = piv[pi]
|
||||||
|
pi += 1
|
||||||
|
if kind == -1: # nuovo pivot LOW
|
||||||
|
if S2L is not None and S2L.get("stage") == "w4l" and p_i > S2L["w3h_i"]:
|
||||||
|
if p_px > S2L["P1p"]: # onda 4 NON sovrappone onda 1
|
||||||
|
S2L["w4l_px"], S2L["stage"] = p_px, "brk"
|
||||||
|
else:
|
||||||
|
S2L = None
|
||||||
|
if S2S is not None and S2S.get("stage") == "w3l" and p_i > S2S["after"]:
|
||||||
|
S2S["w3l_px"], S2S["w3l_i"], S2S["stage"] = p_px, p_i, "w4h"
|
||||||
|
if pi >= 3:
|
||||||
|
t0, t1, t2 = piv[pi - 3], piv[pi - 2], piv[pi - 1]
|
||||||
|
if t0[3] == -1 and t1[3] == +1 and t2[3] == -1:
|
||||||
|
if t2[2] > t0[2]: # vincolo: onda 2 sopra l'origine
|
||||||
|
if LS is not None and not LS["done"]:
|
||||||
|
cnt["superseded"] += 1
|
||||||
|
m = (t2[2] - t0[2]) / (t2[1] - t0[1])
|
||||||
|
LS = dict(P0i=t0[1], P0p=t0[2], P1i=t1[1], P1p=t1[2],
|
||||||
|
P2i=t2[1], P2p=t2[2], m=m, conf=i, done=False)
|
||||||
|
cnt["setups_long"] += 1
|
||||||
|
else:
|
||||||
|
cnt["null_count"] += 1
|
||||||
|
LS = None
|
||||||
|
else: # nuovo pivot HIGH
|
||||||
|
if S2L is not None and S2L.get("stage") == "w3h" and p_i > S2L["after"]:
|
||||||
|
S2L["w3h_px"], S2L["w3h_i"], S2L["stage"] = p_px, p_i, "w4l"
|
||||||
|
if S2S is not None and S2S.get("stage") == "w4h" and p_i > S2S["w3l_i"]:
|
||||||
|
if p_px < S2S["P1p"]:
|
||||||
|
S2S["w4h_px"], S2S["stage"] = p_px, "brk"
|
||||||
|
else:
|
||||||
|
S2S = None
|
||||||
|
if pi >= 3:
|
||||||
|
t0, t1, t2 = piv[pi - 3], piv[pi - 2], piv[pi - 1]
|
||||||
|
if t0[3] == +1 and t1[3] == -1 and t2[3] == +1:
|
||||||
|
if t2[2] < t0[2]: # speculare: onda 2 sotto l'origine
|
||||||
|
if SS is not None and not SS["done"]:
|
||||||
|
cnt["superseded"] += 1
|
||||||
|
m = (t2[2] - t0[2]) / (t2[1] - t0[1])
|
||||||
|
SS = dict(P0i=t0[1], P0p=t0[2], P1i=t1[1], P1p=t1[2],
|
||||||
|
P2i=t2[1], P2p=t2[2], m=m, conf=i, done=False)
|
||||||
|
cnt["setups_short"] += 1
|
||||||
|
else:
|
||||||
|
cnt["null_count"] += 1
|
||||||
|
SS = None
|
||||||
|
# --- (c) monitoraggio setup + entry a close[i] ----------------------------------
|
||||||
|
if LS is not None and not LS["done"]:
|
||||||
|
up = LS["P1p"] + LS["m"] * (i - LS["P1i"])
|
||||||
|
base = LS["P0p"] + LS["m"] * (i - LS["P0i"])
|
||||||
|
if c[i] > up:
|
||||||
|
LS["done"] = True
|
||||||
|
events.append(dict(kind="impulse", side=+1, bar=i))
|
||||||
|
if tr is None:
|
||||||
|
amp = LS["P1p"] - LS["P0p"]
|
||||||
|
tr = dict(dir=+1, sig="S1", e=i, entry_px=float(c[i]),
|
||||||
|
tgt=LS["P2p"] + TGT1 * amp, stp=LS["P2p"],
|
||||||
|
P1p=LS["P1p"], amp=amp)
|
||||||
|
pos[i] = 1.0
|
||||||
|
elif c[i] < base:
|
||||||
|
LS["done"] = True
|
||||||
|
events.append(dict(kind="corrective", side=+1, bar=i))
|
||||||
|
if SS is not None and not SS["done"]:
|
||||||
|
dn = SS["P1p"] + SS["m"] * (i - SS["P1i"])
|
||||||
|
base = SS["P0p"] + SS["m"] * (i - SS["P0i"])
|
||||||
|
if c[i] < dn:
|
||||||
|
SS["done"] = True
|
||||||
|
events.append(dict(kind="impulse", side=-1, bar=i))
|
||||||
|
if tr is None and allow_short:
|
||||||
|
amp = SS["P0p"] - SS["P1p"]
|
||||||
|
tr = dict(dir=-1, sig="S1", e=i, entry_px=float(c[i]),
|
||||||
|
tgt=SS["P2p"] - TGT1 * amp, stp=SS["P2p"],
|
||||||
|
P1p=SS["P1p"], amp=amp)
|
||||||
|
pos[i] = -1.0
|
||||||
|
elif c[i] > base:
|
||||||
|
SS["done"] = True
|
||||||
|
events.append(dict(kind="corrective", side=-1, bar=i))
|
||||||
|
# --- (d) SEGNALE 2 (onda 5) -----------------------------------------------------
|
||||||
|
if allow_s2 and S2L is not None and S2L.get("stage") == "brk":
|
||||||
|
if c[i] < S2L["w4l_px"]:
|
||||||
|
S2L = None
|
||||||
|
elif c[i] > S2L["w3h_px"] and tr is None:
|
||||||
|
tr = dict(dir=+1, sig="S2", e=i, entry_px=float(c[i]),
|
||||||
|
tgt=S2L["w4l_px"] + TGT2 * S2L["amp"], stp=S2L["w4l_px"],
|
||||||
|
P1p=S2L["P1p"], amp=S2L["amp"])
|
||||||
|
pos[i] = 1.0
|
||||||
|
S2L = None
|
||||||
|
if allow_s2 and S2S is not None and S2S.get("stage") == "brk":
|
||||||
|
if c[i] > S2S["w4h_px"]:
|
||||||
|
S2S = None
|
||||||
|
elif c[i] < S2S["w3l_px"] and tr is None:
|
||||||
|
tr = dict(dir=-1, sig="S2", e=i, entry_px=float(c[i]),
|
||||||
|
tgt=S2S["w4h_px"] - TGT2 * S2S["amp"], stp=S2S["w4h_px"],
|
||||||
|
P1p=S2S["P1p"], amp=S2S["amp"])
|
||||||
|
pos[i] = -1.0
|
||||||
|
S2S = None
|
||||||
|
out = dict(pos=pos, trades=trades, events=events, counters=cnt)
|
||||||
|
_SIM_CACHE[key] = out
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def make_target(k: float, variant: str):
|
||||||
|
def target_fn(df, asset):
|
||||||
|
return simulate(df, k, variant, asset)["pos"]
|
||||||
|
return target_fn
|
||||||
|
|
||||||
|
|
||||||
|
def factory(tf=None, k=3.0, variant="s1_ls"):
|
||||||
|
# tf consumata da study_family_honest/candidate_daily (carica il df giusto)
|
||||||
|
return make_target(k, variant)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 3) COMPARATORE DONCHIAN "banale" a pari geometria (stop=base canale,
|
||||||
|
# target = base + 1.618*larghezza) — stesso engine close-exec.
|
||||||
|
# ===========================================================================
|
||||||
|
def donch_sim(df: pd.DataFrame, N: int, allow_short: bool, asset: str = "?") -> dict:
|
||||||
|
key = _dfkey(df, asset) + ("donch", N, allow_short)
|
||||||
|
if key in _SIM_CACHE:
|
||||||
|
return _SIM_CACHE[key]
|
||||||
|
hi, lo = al.donchian(df, N) # shiftati -> causali
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
n = len(c)
|
||||||
|
pos = np.zeros(n)
|
||||||
|
trades = []
|
||||||
|
tr = None
|
||||||
|
for i in range(1, n):
|
||||||
|
if tr is not None and i > tr["e"]:
|
||||||
|
reason = None
|
||||||
|
if tr["dir"] == +1:
|
||||||
|
if l[i] <= tr["stp"]:
|
||||||
|
reason = "stop"
|
||||||
|
elif h[i] >= tr["tgt"]:
|
||||||
|
reason = "target"
|
||||||
|
else:
|
||||||
|
if h[i] >= tr["stp"]:
|
||||||
|
reason = "stop"
|
||||||
|
elif l[i] <= tr["tgt"]:
|
||||||
|
reason = "target"
|
||||||
|
if reason is None and i - tr["e"] >= TIMEOUT:
|
||||||
|
reason = "timeout"
|
||||||
|
if reason:
|
||||||
|
tr["x"], tr["exit_px"], tr["reason"] = i, float(c[i]), reason
|
||||||
|
trades.append(tr)
|
||||||
|
tr = None
|
||||||
|
pos[i] = 0.0
|
||||||
|
else:
|
||||||
|
pos[i] = tr["dir"]
|
||||||
|
if tr is None and np.isfinite(hi[i]) and np.isfinite(lo[i]):
|
||||||
|
W = hi[i] - lo[i]
|
||||||
|
if c[i] > hi[i] and W > 0:
|
||||||
|
tr = dict(dir=+1, sig="D", e=i, entry_px=float(c[i]),
|
||||||
|
tgt=lo[i] + TGT1 * W, stp=lo[i])
|
||||||
|
pos[i] = 1.0
|
||||||
|
elif allow_short and c[i] < lo[i] and W > 0:
|
||||||
|
tr = dict(dir=-1, sig="D", e=i, entry_px=float(c[i]),
|
||||||
|
tgt=hi[i] - TGT1 * W, stp=hi[i])
|
||||||
|
pos[i] = -1.0
|
||||||
|
out = dict(pos=pos, trades=trades)
|
||||||
|
_SIM_CACHE[key] = out
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 4) 4h con ancora spostata (offset 0/1/2/3h) — epoca ms ESPLICITA.
|
||||||
|
# ===========================================================================
|
||||||
|
def get_4h_anchor(asset: str, off: int) -> pd.DataFrame:
|
||||||
|
g = al.get(asset, "1h").copy()
|
||||||
|
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
|
||||||
|
idx.name = "dt"
|
||||||
|
g.index = idx
|
||||||
|
out = g.resample("4h", label="left", closed="left",
|
||||||
|
offset=pd.Timedelta(hours=off)).agg(
|
||||||
|
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
|
||||||
|
out = out.dropna(subset=["open"])
|
||||||
|
out["datetime"] = out.index
|
||||||
|
epoch = pd.Timestamp("1970-01-01", tz="UTC")
|
||||||
|
out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
|
||||||
|
return out.reset_index(drop=True)[
|
||||||
|
["timestamp", "open", "high", "low", "close", "volume", "datetime"]]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 5) STATISTICHE: claim discriminante + target 1.618 vs null vol-matched.
|
||||||
|
# ===========================================================================
|
||||||
|
def discriminant_test(tf: str, k: float, H: int = 10, B: int = 2000) -> dict:
|
||||||
|
"""Claim: un movimento che NON esce dal canale (violazione base senza uscita alta) e'
|
||||||
|
correttivo -> follow-through nella direzione del conteggio NEGATIVO (reversal).
|
||||||
|
Statistica: media del forward-return H-barre ALLINEATO al conteggio, normalizzato ATR,
|
||||||
|
vs null di barre casuali (stessi segni). p_low piccolo => claim supportata."""
|
||||||
|
rng = np.random.default_rng(SEED)
|
||||||
|
out = {}
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
df = al.get(a, tf)
|
||||||
|
sim = simulate(df, k, "s1_ls", a)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
atr_ = al.atr(df, ATR_WIN)
|
||||||
|
n = len(c)
|
||||||
|
f = np.full(n, np.nan)
|
||||||
|
f[:n - H] = (c[H:] / c[:n - H] - 1.0) / np.maximum(atr_[:n - H] / c[:n - H], 1e-9) / np.sqrt(H)
|
||||||
|
res = {}
|
||||||
|
for kind in ("corrective", "impulse"):
|
||||||
|
ev = [(e["bar"], e["side"]) for e in sim["events"]
|
||||||
|
if e["kind"] == kind and e["bar"] + H < n and e["bar"] >= 50]
|
||||||
|
if len(ev) < 5:
|
||||||
|
res[kind] = dict(n=len(ev), mean=None, p_low=None, p_high=None)
|
||||||
|
continue
|
||||||
|
bars = np.array([b for b, _ in ev])
|
||||||
|
sides = np.array([s for _, s in ev], float)
|
||||||
|
obs = float(np.mean(f[bars] * sides))
|
||||||
|
valid = np.arange(50, n - H - 1)
|
||||||
|
draws = rng.choice(valid, size=(B, len(ev)))
|
||||||
|
null = (f[draws] * sides[None, :]).mean(axis=1)
|
||||||
|
res[kind] = dict(n=len(ev), mean=round(obs, 4),
|
||||||
|
null_mean=round(float(np.mean(null)), 4),
|
||||||
|
p_low=round(float(np.mean(null <= obs)), 4),
|
||||||
|
p_high=round(float(np.mean(null >= obs)), 4))
|
||||||
|
out[a] = res
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _first_touch(h, l, s, dirn, tgt, stp):
|
||||||
|
wh = h[s + 1:s + 1 + TIMEOUT]
|
||||||
|
wl = l[s + 1:s + 1 + TIMEOUT]
|
||||||
|
if dirn == +1:
|
||||||
|
mT, mS = wh >= tgt, wl <= stp
|
||||||
|
else:
|
||||||
|
mT, mS = wl <= tgt, wh >= stp
|
||||||
|
iT = int(np.argmax(mT)) if mT.any() else 10 ** 9
|
||||||
|
iS = int(np.argmax(mS)) if mS.any() else 10 ** 9
|
||||||
|
return 1 if iT < iS else 0 # pari barra -> stop prioritario (come l'engine)
|
||||||
|
|
||||||
|
|
||||||
|
def target_hit_vs_null(tf: str, k: float, variant: str, B: int = 300) -> dict:
|
||||||
|
"""Freq. con cui il target 1.618 viene toccato prima dello stop nei trade REALI vs
|
||||||
|
null: stessa geometria (distanze % target/stop, direzione, timeout) da barre CASUALI.
|
||||||
|
p_high piccolo => la struttura d'onda tempa meglio del caso a parita' di geometria."""
|
||||||
|
rng = np.random.default_rng(SEED + 1)
|
||||||
|
out = {}
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
df = al.get(a, tf)
|
||||||
|
sim = simulate(df, k, variant, a)
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
n = len(c)
|
||||||
|
trs = [t for t in sim["trades"] if t["sig"] == "S1"]
|
||||||
|
if len(trs) < 5:
|
||||||
|
out[a] = dict(n=len(trs), real=None)
|
||||||
|
continue
|
||||||
|
real = float(np.mean([1 if t["reason"] == "target" else 0 for t in trs]))
|
||||||
|
geo = [(t["dir"], abs(t["tgt"] / t["entry_px"] - 1.0),
|
||||||
|
abs(1.0 - t["stp"] / t["entry_px"])) for t in trs]
|
||||||
|
if len(geo) > 300: # cap dichiarato per il bootstrap (compute)
|
||||||
|
sel = rng.choice(len(geo), size=300, replace=False)
|
||||||
|
geo = [geo[int(s)] for s in sel]
|
||||||
|
nulls = np.empty(B)
|
||||||
|
lo_s, hi_s = 50, n - TIMEOUT - 2
|
||||||
|
for b in range(B):
|
||||||
|
starts = rng.integers(lo_s, hi_s, size=len(geo))
|
||||||
|
hits = 0
|
||||||
|
for (dirn, dT, dS), s in zip(geo, starts):
|
||||||
|
e = c[s]
|
||||||
|
if dirn == +1:
|
||||||
|
hits += _first_touch(h, l, s, +1, e * (1 + dT), e * (1 - dS))
|
||||||
|
else:
|
||||||
|
hits += _first_touch(h, l, s, -1, e * (1 - dT), e * (1 + dS))
|
||||||
|
nulls[b] = hits / len(geo)
|
||||||
|
out[a] = dict(n=len(trs), real=round(real, 3),
|
||||||
|
null_mean=round(float(np.mean(nulls)), 3),
|
||||||
|
p_high=round(float(np.mean(nulls >= real)), 4),
|
||||||
|
p_low=round(float(np.mean(nulls <= real)), 4))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 6) LENS OTTIMISTA (fill al livello) — dichiarata, solo per confronto.
|
||||||
|
# ===========================================================================
|
||||||
|
def lens_compare(tf: str, k: float, variant: str, fee_rt: float = 0.001) -> dict:
|
||||||
|
out = {}
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
df = al.get(a, tf)
|
||||||
|
trs = simulate(df, k, variant, a)["trades"]
|
||||||
|
if not trs:
|
||||||
|
out[a] = dict(n=0)
|
||||||
|
continue
|
||||||
|
close_r, level_r = [], []
|
||||||
|
for t in trs:
|
||||||
|
rc = t["dir"] * (t["exit_px"] / t["entry_px"] - 1.0) - fee_rt
|
||||||
|
if t["reason"] == "target":
|
||||||
|
px = t["tgt"]
|
||||||
|
elif t["reason"] == "stop":
|
||||||
|
px = t["stp"]
|
||||||
|
else:
|
||||||
|
px = t["exit_px"]
|
||||||
|
rl = t["dir"] * (px / t["entry_px"] - 1.0) - fee_rt
|
||||||
|
close_r.append(rc)
|
||||||
|
level_r.append(rl)
|
||||||
|
out[a] = dict(n=len(trs),
|
||||||
|
exp_close=round(float(np.mean(close_r)), 5),
|
||||||
|
exp_level=round(float(np.mean(level_r)), 5),
|
||||||
|
tot_close=round(float(np.prod(1 + np.array(close_r)) - 1), 4),
|
||||||
|
tot_level=round(float(np.prod(1 + np.array(level_r)) - 1), 4))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# MAIN
|
||||||
|
# ===========================================================================
|
||||||
|
def main():
|
||||||
|
t0 = time.time()
|
||||||
|
report = []
|
||||||
|
|
||||||
|
def say(s=""):
|
||||||
|
print(s, flush=True)
|
||||||
|
report.append(s)
|
||||||
|
|
||||||
|
say("=" * 88)
|
||||||
|
say("r0702_ell_channel — canale Elliott (Ftaonline), falsificazione onesta")
|
||||||
|
say("=" * 88)
|
||||||
|
|
||||||
|
# ---------- TABELLA CELLE ----------------------------------------------------------
|
||||||
|
rows = []
|
||||||
|
say("\n[1] TABELLA CELLE (TF x k x variante) — net 0.10% RT, exit al close (onesto)")
|
||||||
|
hdr = (f"{'tf':>4} {'k':>3} {'variante':>8} | "
|
||||||
|
f"{'BTC f/h':>12} {'nT':>4} {'DD%':>5} | {'ETH f/h':>12} {'nT':>4} {'DD%':>5} | "
|
||||||
|
f"{'COMB full':>9} {'hold':>6} {'inS':>6} {'DD%':>5}")
|
||||||
|
say(hdr)
|
||||||
|
say("-" * len(hdr))
|
||||||
|
for tf in TFS:
|
||||||
|
for k in KS:
|
||||||
|
for v in VARIANTS:
|
||||||
|
per, parts = {}, {}
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
df = al.get(a, tf)
|
||||||
|
sim = simulate(df, k, v, a)
|
||||||
|
ev = al.eval_weights(df, sim["pos"])
|
||||||
|
per[a] = dict(full=ev["full"], hold=ev["holdout"],
|
||||||
|
ntr=len(sim["trades"]), cnt=sim["counters"])
|
||||||
|
parts[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||||
|
J = pd.concat(parts, axis=1, join="inner").fillna(0.0)
|
||||||
|
comb = al._to_daily(0.5 * J["BTC"] + 0.5 * J["ETH"])
|
||||||
|
ins = comb[comb.index < al.HOLDOUT]
|
||||||
|
hold = comb[comb.index >= al.HOLDOUT]
|
||||||
|
r = dict(tf=tf, k=k, var=v,
|
||||||
|
btc_full=per["BTC"]["full"]["sharpe"],
|
||||||
|
btc_hold=per["BTC"]["hold"].get("sharpe", 0.0),
|
||||||
|
btc_dd=per["BTC"]["full"]["maxdd"], btc_n=per["BTC"]["ntr"],
|
||||||
|
eth_full=per["ETH"]["full"]["sharpe"],
|
||||||
|
eth_hold=per["ETH"]["hold"].get("sharpe", 0.0),
|
||||||
|
eth_dd=per["ETH"]["full"]["maxdd"], eth_n=per["ETH"]["ntr"],
|
||||||
|
comb_full=round(al._sh(comb), 3), comb_hold=round(al._sh(hold), 3),
|
||||||
|
comb_ins=round(al._sh(ins), 3), comb_dd=round(al._dd_ret(comb), 4),
|
||||||
|
counters=dict(BTC=per["BTC"]["cnt"], ETH=per["ETH"]["cnt"]))
|
||||||
|
rows.append(r)
|
||||||
|
flag = " (<30 trade!)" if min(r["btc_n"], r["eth_n"]) < 30 else ""
|
||||||
|
say(f"{tf:>4} {k:>3.0f} {v:>8} | "
|
||||||
|
f"{r['btc_full']:>+5.2f}/{r['btc_hold']:>+5.2f} {r['btc_n']:>4d} "
|
||||||
|
f"{r['btc_dd']*100:>5.1f} | "
|
||||||
|
f"{r['eth_full']:>+5.2f}/{r['eth_hold']:>+5.2f} {r['eth_n']:>4d} "
|
||||||
|
f"{r['eth_dd']*100:>5.1f} | "
|
||||||
|
f"{r['comb_full']:>+9.2f} {r['comb_hold']:>+6.2f} {r['comb_ins']:>+6.2f} "
|
||||||
|
f"{r['comb_dd']*100:>5.1f}{flag}")
|
||||||
|
say(f"\n (tempo tabella: {time.time()-t0:.0f}s)")
|
||||||
|
|
||||||
|
# ---------- FAMILY HONEST (selezione in-sample + deflated Sharpe) ------------------
|
||||||
|
say("\n[2] study_family_honest — cella scelta IN-SAMPLE-ONLY + DSR su tutta la griglia")
|
||||||
|
grid = [dict(k=k, variant=v) for k in KS for v in VARIANTS]
|
||||||
|
fam = al.study_family_honest("ELLCH", factory, grid, TFS)
|
||||||
|
ch = fam["chosen"]
|
||||||
|
say(f" celle valutate: {fam['n_cells']} cella in-sample: tf={ch['tf']} "
|
||||||
|
f"params={ch['params']} (inS Sharpe {ch['insample_sharpe']}, full {ch['full_sharpe']})")
|
||||||
|
say(f" deflated Sharpe = {fam['deflated_sharpe']} (null max atteso "
|
||||||
|
f"{fam['expected_null_max']}) dsr_pass={fam['dsr_pass']}")
|
||||||
|
say(f" earns_slot_marginal={fam['earns_slot_marginal']} "
|
||||||
|
f"EARNS_SLOT_HONEST={fam['earns_slot_honest']}")
|
||||||
|
say(al.fmt_marginal(fam["marginal"]))
|
||||||
|
ck, cv, ctf = ch["params"]["k"], ch["params"]["variant"], ch["tf"]
|
||||||
|
|
||||||
|
# ---------- CAUSALITA' -------------------------------------------------------------
|
||||||
|
say("\n[3] causality_ok (prefix-recompute) sulla cella scelta")
|
||||||
|
for tf_chk in {ctf, "1h"}:
|
||||||
|
cz = al.causality_ok(make_target(ck, cv), tf=tf_chk)
|
||||||
|
say(f" tf={tf_chk}: ok={cz['ok']} max_tail_diff={cz['max_tail_diff']} "
|
||||||
|
f"checked={cz['checked']}")
|
||||||
|
|
||||||
|
# ---------- FEE SWEEP + SMALLCAP sulla cella scelta ---------------------------------
|
||||||
|
say("\n[4] fee sweep 0.00-0.20% RT + haircut small-cap ($600, min order $5) — cella scelta")
|
||||||
|
sw = al.study_weights(f"ELLCH k={ck} {cv}", make_target(ck, cv), tfs=(ctf,))
|
||||||
|
cell = sw["cells"][0]
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
say(f" {a}: fee_sweep={cell['per_asset'][a]['fee_sweep']}")
|
||||||
|
df = al.get(a, ctf)
|
||||||
|
sc = al.eval_weights_smallcap(df, simulate(df, ck, cv, a)["pos"])
|
||||||
|
say(f" smallcap: modeled Sh {sc['modeled']['sharpe']} -> real "
|
||||||
|
f"{sc['realistic']['sharpe']} (haircut {sc['sharpe_haircut']}, "
|
||||||
|
f"{sc['n_executed_trades']} ordini)")
|
||||||
|
|
||||||
|
# ---------- COMPARATORE DONCHIAN ----------------------------------------------------
|
||||||
|
say("\n[5] Donchian 'banale' a pari geometria (stop=base, target=base+1.618*W), "
|
||||||
|
"close-exec, stesso timeout")
|
||||||
|
donch_rows = []
|
||||||
|
allow_short = cv in ("s1_ls", "s12_ls")
|
||||||
|
for tf in TFS:
|
||||||
|
ell_r = next(r for r in rows if r["tf"] == tf and r["k"] == ck and r["var"] == cv)
|
||||||
|
ell_n = (ell_r["btc_n"] + ell_r["eth_n"]) / 2
|
||||||
|
for N in (20, 55, 100):
|
||||||
|
per, parts = {}, {}
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
df = al.get(a, tf)
|
||||||
|
sim = donch_sim(df, N, allow_short, a)
|
||||||
|
ev = al.eval_weights(df, sim["pos"])
|
||||||
|
per[a] = dict(full=ev["full"]["sharpe"], hold=ev["holdout"].get("sharpe", 0.0),
|
||||||
|
ntr=len(sim["trades"]))
|
||||||
|
parts[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||||
|
J = pd.concat(parts, axis=1, join="inner").fillna(0.0)
|
||||||
|
comb = al._to_daily(0.5 * J["BTC"] + 0.5 * J["ETH"])
|
||||||
|
dn = (per["BTC"]["ntr"] + per["ETH"]["ntr"]) / 2
|
||||||
|
donch_rows.append(dict(tf=tf, N=N, per=per, ntr=dn,
|
||||||
|
comb_full=round(al._sh(comb), 3),
|
||||||
|
comb_hold=round(al._sh(comb[comb.index >= al.HOLDOUT]), 3),
|
||||||
|
comb=comb, freq_gap=abs(dn - ell_n)))
|
||||||
|
best = min([d for d in donch_rows if d["tf"] == tf], key=lambda d: d["freq_gap"])
|
||||||
|
# corr Elliott(cella scelta k,cv su questo tf) vs Donchian matched
|
||||||
|
parts_e = {}
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
df = al.get(a, tf)
|
||||||
|
ev = al.eval_weights(df, simulate(df, ck, cv, a)["pos"])
|
||||||
|
parts_e[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||||
|
JE = pd.concat(parts_e, axis=1, join="inner").fillna(0.0)
|
||||||
|
comb_e = al._to_daily(0.5 * JE["BTC"] + 0.5 * JE["ETH"])
|
||||||
|
JJ = pd.concat({"E": comb_e, "D": best["comb"]}, axis=1, join="inner").dropna()
|
||||||
|
corr = round(float(JJ["E"].corr(JJ["D"])), 3) if len(JJ) > 30 else None
|
||||||
|
for d in [x for x in donch_rows if x["tf"] == tf]:
|
||||||
|
mark = " <-- freq-matched" if d is best else ""
|
||||||
|
say(f" {tf:>4} N={d['N']:>3d}: comb full {d['comb_full']:>+5.2f} hold "
|
||||||
|
f"{d['comb_hold']:>+5.2f} nT/asset~{d['ntr']:.0f}{mark}")
|
||||||
|
say(f" {tf:>4} ELLIOTT (k={ck:.0f},{cv}): comb full {ell_r['comb_full']:>+5.2f} "
|
||||||
|
f"hold {ell_r['comb_hold']:>+5.2f} nT/asset~{ell_n:.0f} "
|
||||||
|
f"corr(Elliott,Donch-matched)={corr}")
|
||||||
|
|
||||||
|
# ---------- BANDA D'ANCORA 4h -------------------------------------------------------
|
||||||
|
say("\n[6] Banda d'ancora 4h (offset 0/1/2/3h) — cella 4h migliore IN-SAMPLE")
|
||||||
|
r4 = [r for r in rows if r["tf"] == "4h"]
|
||||||
|
best4 = max(r4, key=lambda r: r["comb_ins"])
|
||||||
|
say(f" cella 4h in-sample: k={best4['k']:.0f} var={best4['var']} "
|
||||||
|
f"(inS {best4['comb_ins']}, full {best4['comb_full']}, hold {best4['comb_hold']})")
|
||||||
|
anchor = {}
|
||||||
|
for off in (0, 1, 2, 3):
|
||||||
|
parts = {}
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
df = get_4h_anchor(a, off)
|
||||||
|
sim = simulate(df, best4["k"], best4["var"], f"{a}@+{off}h")
|
||||||
|
ev = al.eval_weights(df, sim["pos"])
|
||||||
|
parts[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||||
|
J = pd.concat(parts, axis=1, join="inner").fillna(0.0)
|
||||||
|
comb = al._to_daily(0.5 * J["BTC"] + 0.5 * J["ETH"])
|
||||||
|
anchor[off] = dict(full=round(al._sh(comb), 3),
|
||||||
|
hold=round(al._sh(comb[comb.index >= al.HOLDOUT]), 3))
|
||||||
|
say(f" offset +{off}h: comb full {anchor[off]['full']:>+5.2f} "
|
||||||
|
f"hold {anchor[off]['hold']:>+5.2f}")
|
||||||
|
fulls = [v["full"] for v in anchor.values()]
|
||||||
|
holds = [v["hold"] for v in anchor.values()]
|
||||||
|
say(f" banda full [{min(fulls):+.2f},{max(fulls):+.2f}] "
|
||||||
|
f"hold [{min(holds):+.2f},{max(holds):+.2f}]")
|
||||||
|
|
||||||
|
# ---------- CLAIM DISCRIMINANTE -----------------------------------------------------
|
||||||
|
say("\n[7] Claim discriminante: 'mai fuori dal canale = correttivo' (fwd 10 barre "
|
||||||
|
"allineato al conteggio, ATR-norm, null permutato B=2000)")
|
||||||
|
disc = {}
|
||||||
|
for tf in TFS:
|
||||||
|
disc[tf] = discriminant_test(tf, ck)
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
d = disc[tf][a]
|
||||||
|
co, im = d["corrective"], d["impulse"]
|
||||||
|
say(f" {tf:>4} {a}: corrective n={co['n']} mean={co.get('mean')} "
|
||||||
|
f"(null {co.get('null_mean')}) p_low={co.get('p_low')} | "
|
||||||
|
f"impulse n={im['n']} mean={im.get('mean')} "
|
||||||
|
f"(null {im.get('null_mean')}) p_high={im.get('p_high')}")
|
||||||
|
|
||||||
|
# ---------- TARGET 1.618 vs NULL ----------------------------------------------------
|
||||||
|
say("\n[8] Target 1.618 toccato prima dello stop: freq reale vs null vol/geometry-"
|
||||||
|
"matched (B=300 bootstrap, stessa distanza %/direzione/timeout da barre casuali)")
|
||||||
|
thit = {}
|
||||||
|
for tf in TFS:
|
||||||
|
thit[tf] = target_hit_vs_null(tf, ck, cv)
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
t = thit[tf][a]
|
||||||
|
if t.get("real") is None:
|
||||||
|
say(f" {tf:>4} {a}: n={t['n']} (troppo pochi trade)")
|
||||||
|
else:
|
||||||
|
say(f" {tf:>4} {a}: n={t['n']} real={t['real']} null={t['null_mean']} "
|
||||||
|
f"p_high={t['p_high']} p_low={t['p_low']}")
|
||||||
|
|
||||||
|
# ---------- LENS OTTIMISTA ----------------------------------------------------------
|
||||||
|
say("\n[9] Lens fill-al-livello (OTTIMISTA, dichiarata) vs close-exec — cella scelta, "
|
||||||
|
f"tf={ctf}")
|
||||||
|
lc = lens_compare(ctf, ck, cv)
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
d = lc[a]
|
||||||
|
if d.get("n", 0) == 0:
|
||||||
|
say(f" {a}: 0 trade")
|
||||||
|
else:
|
||||||
|
say(f" {a}: n={d['n']} expectancy/trade close={d['exp_close']:+.4f} "
|
||||||
|
f"level={d['exp_level']:+.4f} tot close={d['tot_close']:+.2%} "
|
||||||
|
f"level={d['tot_level']:+.2%}")
|
||||||
|
|
||||||
|
say(f"\n(tempo totale {time.time()-t0:.0f}s)")
|
||||||
|
|
||||||
|
# ---------- SALVATAGGI ---------------------------------------------------------------
|
||||||
|
donch_save = [{kk: vv for kk, vv in d.items() if kk not in ("comb", "freq_gap")}
|
||||||
|
for d in donch_rows]
|
||||||
|
payload = dict(rows=rows, family=al._clean(fam), donchian=al._clean(donch_save),
|
||||||
|
anchor_4h=anchor, discriminant=disc, target_hit=thit, lens=lc,
|
||||||
|
chosen=dict(tf=ctf, k=ck, variant=cv))
|
||||||
|
with open(f"{SCRATCH}/ell_c_results.json", "w") as f:
|
||||||
|
json.dump(al._clean(payload), f, default=str, indent=1)
|
||||||
|
with open(f"{SCRATCH}/ell_c_report.txt", "w") as f:
|
||||||
|
f.write("\n".join(report))
|
||||||
|
say(f"salvato: {SCRATCH}/ell_c_results.json + ell_c_report.txt")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
"""r0702_ell_fibconfluence.py — FIBONACCI CONFLUENCE: il prezzo reagisce ai livelli Fib
|
||||||
|
piu' che a livelli QUALSIASI? (2026-07-02, filone ELL-B)
|
||||||
|
|
||||||
|
CLAIM (video didattico, Casario): proiettando su swing di prezzo griglie di ritracciamento
|
||||||
|
(0.382/0.5/0.618) ed estensione (1.272/1.414/1.618/2.618) di Fibonacci, i livelli — e in
|
||||||
|
particolare le CONFLUENZE dove due griglie convergono — sarebbero zone di reazione del prezzo.
|
||||||
|
Claim testabile: la reazione ai livelli Fib batte quella a livelli con rapporti CASUALI
|
||||||
|
proiettati sugli STESSI swing? IL NULL E' TUTTO: senza placebo, "il prezzo ha reagito al 61.8%"
|
||||||
|
e' solo apofenia (qualunque livello dentro il range viene "toccato e rispettato" a volte).
|
||||||
|
|
||||||
|
METODO (tutto a priori, dichiarato PRIMA di guardare i numeri):
|
||||||
|
* Swing MECCANICI e causali: zigzag a soglia k*ATR14 (k in {2,4}), NON-REPAINTING — un pivot
|
||||||
|
e' confermato solo quando close si e' mosso k*ATR dall'estremo; i livelli dello swing
|
||||||
|
(A->B) sono utilizzabili solo da confirm_idx+1 (timestamp di conferma esplicito).
|
||||||
|
* Griglia attiva per finestra [confirm(B)+1, confirm(pivot successivo)]: ritracciamenti
|
||||||
|
L = pB - r*M e estensioni L = pA + e*M (M = pB-pA). Finestre disgiunte per costruzione.
|
||||||
|
* TOCCO = low<=L<=high con la barra precedente che NON conteneva L (fresh touch); direzione
|
||||||
|
attesa dal lato di approccio (close[i-1]>L -> supporto -> atteso rimbalzo SU; <L -> giu').
|
||||||
|
Decisione a close[i] (high/low noti a fine barra), reazione misurata da close[i] a
|
||||||
|
close[i+H], H in {5,20} barre — ESEGUIBILE (niente entry sugli estremi). La variante
|
||||||
|
"fill al livello" (da L invece che da close) e' riportata SOLO come lens ottimista.
|
||||||
|
* Statistica primaria: media della reazione segnata NORMALIZZATA per ATR (react/(ATR/close))
|
||||||
|
sui tocchi IN-SAMPLE (pre-2025); raw bps riportati. Il drift da buy-the-dip e' identico
|
||||||
|
per Fib e placebo -> il percentile vs placebo lo neutralizza.
|
||||||
|
* NULL (a): 100 set di rapporti PLACEBO fissi — 3 ritracciamenti uniformi in [0.2,0.9] +
|
||||||
|
4 estensioni uniformi in [1.05,3.0], esclusa banda max(0.02, 2%rel) attorno ai Fib veri —
|
||||||
|
proiettati sugli STESSI swing. NULL (b): 100 repliche con rapporti casuali RI-SORTEGGIATI
|
||||||
|
per ogni swing (stessa densita', nessuna coerenza cross-swing). NULL (c) — AGGIUNTO dopo
|
||||||
|
il run 1 come indurimento dichiarato: 100 set LOCATION-MATCHED (ogni rapporto = Fib vero
|
||||||
|
+/- jitter appena fuori la banda esclusa, entro ~6-8%) — il null uniforme (a) non e'
|
||||||
|
density-matched (la reazione media dipende da DOVE sta il rapporto, non solo da quale
|
||||||
|
numero e'), quindi (c) e' il test affilato di "0.618 e' speciale vs 0.58/0.66".
|
||||||
|
Verdetto = percentile del Fib vero nelle tre distribuzioni placebo.
|
||||||
|
* CONFLUENZA: ritracciamenti dello swing corrente x estensioni dello swing s-2 (stesso
|
||||||
|
verso, il precedente ha verso opposto e proietta sempre FUORI dal range corrente — bug
|
||||||
|
geometrico del run 1, corretto e dichiarato) entro eps=0.25*ATR(w0); reazione in zona
|
||||||
|
confluente vs livelli singoli delle stesse griglie, stesso placebo.
|
||||||
|
* MULTIPLE TESTING: 16 celle (2 asset x 2 TF x 2 k x 2 H). Soglia single-cell Bonferroni
|
||||||
|
~0.997; verdetto famiglia (a priori): esiste (k,H) con percentile POOLED IS >= 0.99 su
|
||||||
|
ENTRAMBI i null E tutte le 4 celle asset x TF a pctl >= 0.90. Solo allora si strategizza
|
||||||
|
(fade ai livelli, fee 0.10% RT) con al.study_family_honest + al.study_marginal.
|
||||||
|
* Hold-out 2025-26 MAI usato per selezionare (qui non si seleziona nulla: rapporti fissati
|
||||||
|
dal claim; k/H riportati per cella, il verdetto e' sul pooled IS).
|
||||||
|
|
||||||
|
CAUSALITA': pivot ricalcolati su prefisso troncato == pivot full con confirm<cut (check [0]);
|
||||||
|
niente DatetimeIndex.view("int64") (mai usato; date via pandas tz-aware).
|
||||||
|
|
||||||
|
Uso: uv run python scripts/research/r0702_ell_fibconfluence.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
|
||||||
|
import numpy as np # noqa: E402
|
||||||
|
import pandas as pd # noqa: E402
|
||||||
|
|
||||||
|
# ----------------------------- costanti (a priori) --------------------------
|
||||||
|
SEED = 20260702
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
TFS = ("1h", "1d")
|
||||||
|
K_GRID = (2.0, 4.0) # soglia zigzag = k * ATR14
|
||||||
|
ATR_WIN = 14
|
||||||
|
RET_TRUE = (0.382, 0.5, 0.618)
|
||||||
|
EXT_TRUE = (1.272, 1.414, 1.618, 2.618)
|
||||||
|
HORIZONS = (5, 20) # barre di reazione
|
||||||
|
N_PA = 100 # placebo (a): set di rapporti fissi
|
||||||
|
N_PB = 100 # placebo (b): rapporti ri-sorteggiati per swing
|
||||||
|
N_PC = 100 # placebo (c): set fissi LOCATION-MATCHED (jitter attorno ai Fib)
|
||||||
|
CONF_EPS_ATR = 0.25 # tolleranza confluenza = 0.25 * ATR14(w0)
|
||||||
|
MIN_CELL_TOUCH = 50 # tocchi IS minimi perche' una cella conti nel verdetto
|
||||||
|
MIN_CONF_TOUCH = 30
|
||||||
|
RET_RANGE = (0.20, 0.90)
|
||||||
|
EXT_RANGE = (1.05, 3.00)
|
||||||
|
NL = len(RET_TRUE) + len(EXT_TRUE) # 7 livelli per griglia
|
||||||
|
N_SETS = 1 + N_PA + N_PB + N_PC # riga 0 = Fib vero
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# ZIGZAG causale non-repainting + segmenti
|
||||||
|
# ===========================================================================
|
||||||
|
def zigzag_pivots(df: pd.DataFrame, k_atr: float, atr_win: int = ATR_WIN) -> list:
|
||||||
|
"""Pivot alternati H/L: (pivot_idx, price, type +1=high/-1=low, confirm_idx).
|
||||||
|
Un pivot e' confermato quando close si muove k*ATR14[i] oltre l'estremo corrente.
|
||||||
|
Causale: usa solo dati <= i; il pivot NON viene mai spostato dopo la conferma."""
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
a = al.atr(df, atr_win)
|
||||||
|
n = len(c)
|
||||||
|
piv: list = []
|
||||||
|
start = atr_win
|
||||||
|
if n <= start + 2:
|
||||||
|
return piv
|
||||||
|
ehi, ehi_i = h[start], start
|
||||||
|
elo, elo_i = l[start], start
|
||||||
|
dir_ = 0
|
||||||
|
i = start + 1
|
||||||
|
while i < n and dir_ == 0: # bootstrap: direzione ignota
|
||||||
|
if h[i] > ehi:
|
||||||
|
ehi, ehi_i = h[i], i
|
||||||
|
if l[i] < elo:
|
||||||
|
elo, elo_i = l[i], i
|
||||||
|
if c[i] <= ehi - k_atr * a[i]:
|
||||||
|
piv.append((ehi_i, float(ehi), +1, i))
|
||||||
|
dir_ = -1
|
||||||
|
seg = l[ehi_i:i + 1]
|
||||||
|
elo_i = ehi_i + int(np.argmin(seg)); elo = float(seg.min())
|
||||||
|
elif c[i] >= elo + k_atr * a[i]:
|
||||||
|
piv.append((elo_i, float(elo), -1, i))
|
||||||
|
dir_ = +1
|
||||||
|
seg = h[elo_i:i + 1]
|
||||||
|
ehi_i = elo_i + int(np.argmax(seg)); ehi = float(seg.max())
|
||||||
|
i += 1
|
||||||
|
while i < n:
|
||||||
|
if dir_ == -1: # in discesa: cerco swing low
|
||||||
|
if l[i] < elo:
|
||||||
|
elo, elo_i = l[i], i
|
||||||
|
if c[i] >= elo + k_atr * a[i]:
|
||||||
|
piv.append((elo_i, float(elo), -1, i))
|
||||||
|
dir_ = +1
|
||||||
|
seg = h[elo_i:i + 1]
|
||||||
|
ehi_i = elo_i + int(np.argmax(seg)); ehi = float(seg.max())
|
||||||
|
else: # in salita: cerco swing high
|
||||||
|
if h[i] > ehi:
|
||||||
|
ehi, ehi_i = h[i], i
|
||||||
|
if c[i] <= ehi - k_atr * a[i]:
|
||||||
|
piv.append((ehi_i, float(ehi), +1, i))
|
||||||
|
dir_ = -1
|
||||||
|
seg = l[ehi_i:i + 1]
|
||||||
|
elo_i = ehi_i + int(np.argmin(seg)); elo = float(seg.min())
|
||||||
|
i += 1
|
||||||
|
return piv
|
||||||
|
|
||||||
|
|
||||||
|
def build_segments(piv: list, n: int) -> list:
|
||||||
|
"""Segmenti: swing A->B (pivot s-1 -> s), griglia attiva in [confirm(B)+1, confirm(next)].
|
||||||
|
prev2 = (qA, qB) dello swing s-2 (STESSO verso del corrente, per la confluenza: lo swing
|
||||||
|
s-1 ha verso opposto e le sue estensioni proiettano sempre fuori dal range corrente)."""
|
||||||
|
segs = []
|
||||||
|
for s in range(1, len(piv)):
|
||||||
|
A, B = piv[s - 1], piv[s]
|
||||||
|
w0 = B[3] + 1
|
||||||
|
w1 = piv[s + 1][3] if s + 1 < len(piv) else n - 1
|
||||||
|
prev2 = (piv[s - 3][1], piv[s - 2][1]) if s >= 3 else None
|
||||||
|
segs.append(dict(w0=w0, w1=w1, pA=A[1], pB=B[1], prev2=prev2))
|
||||||
|
return segs
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# RATIO SET (vero + placebo)
|
||||||
|
# ===========================================================================
|
||||||
|
def _draw_valid(rng, lo: float, hi: float, size, forbid) -> np.ndarray:
|
||||||
|
"""Uniformi in [lo,hi] con banda esclusa max(0.02, 2% relativo) attorno ai Fib veri."""
|
||||||
|
out = rng.uniform(lo, hi, size=size)
|
||||||
|
for _ in range(200):
|
||||||
|
bad = np.zeros(out.shape, bool)
|
||||||
|
for f in forbid:
|
||||||
|
bad |= np.abs(out - f) < max(0.02, 0.02 * f)
|
||||||
|
if not bad.any():
|
||||||
|
break
|
||||||
|
out[bad] = rng.uniform(lo, hi, size=int(bad.sum()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def make_placebo_a(rng):
|
||||||
|
"""(N_PA,3) ritracciamenti + (N_PA,4) estensioni: set FISSI cross-swing."""
|
||||||
|
r = _draw_valid(rng, *RET_RANGE, (N_PA, len(RET_TRUE)), RET_TRUE)
|
||||||
|
e = _draw_valid(rng, *EXT_RANGE, (N_PA, len(EXT_TRUE)), EXT_TRUE)
|
||||||
|
return r, e
|
||||||
|
|
||||||
|
|
||||||
|
def make_placebo_b(rng, n_seg: int):
|
||||||
|
"""(n_seg,N_PB,3) + (n_seg,N_PB,4): rapporti RI-SORTEGGIATI per ogni swing."""
|
||||||
|
r = _draw_valid(rng, *RET_RANGE, (n_seg, N_PB, len(RET_TRUE)), RET_TRUE)
|
||||||
|
e = _draw_valid(rng, *EXT_RANGE, (n_seg, N_PB, len(EXT_TRUE)), EXT_TRUE)
|
||||||
|
return r, e
|
||||||
|
|
||||||
|
|
||||||
|
def make_placebo_c(rng):
|
||||||
|
"""Set fissi LOCATION-MATCHED: ogni rapporto = Fib vero +/- jitter appena FUORI la banda
|
||||||
|
esclusa (max(0.02,2%rel)) ed entro ~6-8% -> vicini di casa dei Fib. Se i Fib sono davvero
|
||||||
|
speciali (il mercato ancora ESATTAMENTE a 0.618), devono battere anche questi."""
|
||||||
|
def jit(true_vals, lo, hi):
|
||||||
|
f = np.array(true_vals, float)[None, :]
|
||||||
|
band = np.maximum(0.02, 0.02 * f)
|
||||||
|
delta = band + rng.uniform(0.0, 0.06 * np.maximum(1.0, f), size=(N_PC, f.shape[1]))
|
||||||
|
sign = rng.choice([-1.0, 1.0], size=(N_PC, f.shape[1]))
|
||||||
|
return np.clip(f + sign * delta, lo, hi)
|
||||||
|
return jit(RET_TRUE, *RET_RANGE), jit(EXT_TRUE, *EXT_RANGE)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# TOUCH SCAN vettoriale + accumulo
|
||||||
|
# ===========================================================================
|
||||||
|
def _touch_scan(h, l, c, w0: int, w1: int, L: np.ndarray, ids: np.ndarray):
|
||||||
|
"""Fresh touch dei livelli L nella finestra [w0,w1]: ritorna (bar globali, direzione
|
||||||
|
attesa dal lato di approccio, livello, id-set). Direzione: sign(close[i-1] - L)."""
|
||||||
|
lo = l[w0:w1 + 1]; hi = h[w0:w1 + 1]
|
||||||
|
contains = (lo[:, None] <= L[None, :]) & (hi[:, None] >= L[None, :])
|
||||||
|
fresh = contains.copy()
|
||||||
|
fresh[1:] &= ~contains[:-1]
|
||||||
|
fresh[0] &= ~((l[w0 - 1] <= L) & (h[w0 - 1] >= L))
|
||||||
|
tt, kk = np.nonzero(fresh)
|
||||||
|
if not len(tt):
|
||||||
|
z = np.zeros(0)
|
||||||
|
return z.astype(int), z, z, z.astype(int)
|
||||||
|
gi = w0 + tt
|
||||||
|
Lk = L[kk]
|
||||||
|
dirs = np.sign(c[gi - 1] - Lk)
|
||||||
|
m = dirs != 0
|
||||||
|
return gi[m], dirs[m], Lk[m], ids[kk[m]]
|
||||||
|
|
||||||
|
|
||||||
|
def _new_store():
|
||||||
|
return dict(
|
||||||
|
acc={(H, s): dict(sn=np.zeros(N_SETS), sr=np.zeros(N_SETS),
|
||||||
|
sl=np.zeros(N_SETS), c=np.zeros(N_SETS))
|
||||||
|
for H in HORIZONS for s in ("IS", "FULL")},
|
||||||
|
brk={s: dict(b=np.zeros(N_SETS), t=np.zeros(N_SETS)) for s in ("IS", "FULL")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _acc_touches(store, gi, dirs, Lk, sid, c, a14, is_pre, n):
|
||||||
|
if not len(gi):
|
||||||
|
return
|
||||||
|
relatr = a14[gi] / c[gi]
|
||||||
|
ok = np.isfinite(relatr) & (relatr > 0)
|
||||||
|
gi, dirs, Lk, sid, relatr = gi[ok], dirs[ok], Lk[ok], sid[ok], relatr[ok]
|
||||||
|
if not len(gi):
|
||||||
|
return
|
||||||
|
broke = ((dirs > 0) & (c[gi] < Lk)) | ((dirs < 0) & (c[gi] > Lk))
|
||||||
|
for scope, sm in (("FULL", np.ones(len(gi), bool)), ("IS", is_pre[gi])):
|
||||||
|
b = store["brk"][scope]
|
||||||
|
np.add.at(b["t"], sid[sm], 1.0)
|
||||||
|
np.add.at(b["b"], sid[sm & broke], 1.0)
|
||||||
|
for H in HORIZONS:
|
||||||
|
m = sm & ((gi + H) < n)
|
||||||
|
if not m.any():
|
||||||
|
continue
|
||||||
|
g2, d2 = gi[m], dirs[m]
|
||||||
|
react = d2 * (c[g2 + H] / c[g2] - 1.0)
|
||||||
|
a = store["acc"][(H, scope)]
|
||||||
|
np.add.at(a["sn"], sid[m], react / relatr[m])
|
||||||
|
np.add.at(a["sr"], sid[m], react)
|
||||||
|
np.add.at(a["sl"], sid[m], d2 * (c[g2 + H] / Lk[m] - 1.0))
|
||||||
|
np.add.at(a["c"], sid[m], 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_config(df: pd.DataFrame, segs: list, PA_r, PA_e, PB_r, PB_e, PC_r, PC_e) -> dict:
|
||||||
|
"""Scansione completa di un config (asset,tf,k): test principale (griglia dello swing
|
||||||
|
corrente: ret+ext) e confluenza (ret corrente x ext dello swing s-2, stesso verso)."""
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
a14 = al.atr(df, ATR_WIN)
|
||||||
|
n = len(c)
|
||||||
|
is_pre = (pd.to_datetime(df["datetime"], utc=True) < al.HOLDOUT).values
|
||||||
|
R_true_r = np.array(RET_TRUE)[None, :]
|
||||||
|
R_true_e = np.array(EXT_TRUE)[None, :]
|
||||||
|
main, conf, sing = _new_store(), _new_store(), _new_store()
|
||||||
|
czc = np.zeros(N_SETS) # numero di zone confluenti per set
|
||||||
|
set_rep = np.repeat(np.arange(N_SETS), NL)
|
||||||
|
for si, seg in enumerate(segs):
|
||||||
|
w0, w1 = seg["w0"], seg["w1"]
|
||||||
|
if w0 < 1 or w1 < w0 or w0 >= n:
|
||||||
|
continue
|
||||||
|
w1 = min(w1, n - 1)
|
||||||
|
pA, pB = seg["pA"], seg["pB"]
|
||||||
|
M = pB - pA
|
||||||
|
if abs(M) < 1e-12:
|
||||||
|
continue
|
||||||
|
Rr = np.vstack([R_true_r, PA_r, PB_r[si], PC_r]) # (N_SETS,3)
|
||||||
|
Re = np.vstack([R_true_e, PA_e, PB_e[si], PC_e]) # (N_SETS,4)
|
||||||
|
L = np.concatenate([pB - Rr * M, pA + Re * M], axis=1).ravel()
|
||||||
|
_acc_touches(main, *_touch_scan(h, l, c, w0, w1, L, set_rep), c, a14, is_pre, n)
|
||||||
|
# ------- confluenza: ret(cur) x ext(swing s-2, stesso verso) entro eps -------
|
||||||
|
if seg["prev2"] is None:
|
||||||
|
continue
|
||||||
|
qA, qB = seg["prev2"]
|
||||||
|
Mp = qB - qA
|
||||||
|
if abs(Mp) < 1e-12:
|
||||||
|
continue
|
||||||
|
eps = CONF_EPS_ATR * a14[w0]
|
||||||
|
if not np.isfinite(eps) or eps <= 0:
|
||||||
|
continue
|
||||||
|
Lr = pB - Rr * M # (N_SETS,3)
|
||||||
|
Le = qA + Re * Mp # (N_SETS,4)
|
||||||
|
dmat = np.abs(Lr[:, :, None] - Le[:, None, :]) # (N_SETS,3,4)
|
||||||
|
cs, cr, ce = np.nonzero(dmat <= eps)
|
||||||
|
if len(cs):
|
||||||
|
Lc = 0.5 * (Lr[cs, cr] + Le[cs, ce])
|
||||||
|
np.add.at(czc, cs, 1.0)
|
||||||
|
_acc_touches(conf, *_touch_scan(h, l, c, w0, w1, Lc, cs), c, a14, is_pre, n)
|
||||||
|
used_r = np.zeros((N_SETS, Lr.shape[1]), bool); used_r[cs, cr] = True
|
||||||
|
used_e = np.zeros((N_SETS, Le.shape[1]), bool); used_e[cs, ce] = True
|
||||||
|
sr_, rr_ = np.nonzero(~used_r)
|
||||||
|
se_, ee_ = np.nonzero(~used_e)
|
||||||
|
Ls = np.concatenate([Lr[sr_, rr_], Le[se_, ee_]])
|
||||||
|
ids_s = np.concatenate([sr_, se_])
|
||||||
|
_acc_touches(sing, *_touch_scan(h, l, c, w0, w1, Ls, ids_s), c, a14, is_pre, n)
|
||||||
|
return dict(main=main, conf=conf, sing=sing, czc=czc, n_seg=len(segs))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# STATISTICHE dai contatori
|
||||||
|
# ===========================================================================
|
||||||
|
def _means(store, H: int, scope: str, field: str = "sn"):
|
||||||
|
a = store["acc"][(H, scope)]
|
||||||
|
with np.errstate(invalid="ignore", divide="ignore"):
|
||||||
|
return np.where(a["c"] > 0, a[field] / a["c"], np.nan)
|
||||||
|
|
||||||
|
|
||||||
|
def _pctl(true_val: float, plc: np.ndarray):
|
||||||
|
v = plc[np.isfinite(plc)]
|
||||||
|
if len(v) < 20 or not np.isfinite(true_val):
|
||||||
|
return None
|
||||||
|
return float(np.mean(v <= true_val))
|
||||||
|
|
||||||
|
|
||||||
|
IDX_A = slice(1, 1 + N_PA)
|
||||||
|
IDX_B = slice(1 + N_PA, 1 + N_PA + N_PB)
|
||||||
|
IDX_C = slice(1 + N_PA + N_PB, N_SETS)
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_stats(store, H: int):
|
||||||
|
out = {}
|
||||||
|
for scope in ("IS", "FULL"):
|
||||||
|
mn = _means(store, H, scope)
|
||||||
|
mr = _means(store, H, scope, "sr")
|
||||||
|
ml = _means(store, H, scope, "sl")
|
||||||
|
cnt = store["acc"][(H, scope)]["c"]
|
||||||
|
out[scope] = dict(
|
||||||
|
n=int(cnt[0]),
|
||||||
|
raw_bps=float(mr[0] * 1e4) if np.isfinite(mr[0]) else None,
|
||||||
|
lvl_bps=float(ml[0] * 1e4) if np.isfinite(ml[0]) else None,
|
||||||
|
norm=float(mn[0]) if np.isfinite(mn[0]) else None,
|
||||||
|
plcA_med=float(np.nanmedian(mn[IDX_A])),
|
||||||
|
plcC_med=float(np.nanmedian(mn[IDX_C])),
|
||||||
|
pctlA=_pctl(mn[0], mn[IDX_A]),
|
||||||
|
pctlB=_pctl(mn[0], mn[IDX_B]),
|
||||||
|
pctlC=_pctl(mn[0], mn[IDX_C]),
|
||||||
|
)
|
||||||
|
b = store["brk"]["IS"]
|
||||||
|
with np.errstate(invalid="ignore", divide="ignore"):
|
||||||
|
rate = np.where(b["t"] > 0, b["b"] / b["t"], np.nan)
|
||||||
|
out["brk_rate"] = float(rate[0]) if np.isfinite(rate[0]) else None
|
||||||
|
# "il livello tiene" = break-rate BASSO -> pctl_hold alto se true < placebo
|
||||||
|
v = rate[IDX_A][np.isfinite(rate[IDX_A])]
|
||||||
|
out["brk_pctl_hold"] = (float(np.mean(v >= rate[0]))
|
||||||
|
if len(v) >= 20 and np.isfinite(rate[0]) else None)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _pool_stores(stores: list):
|
||||||
|
"""Somma i contatori di piu' config (stesso significato dei set -> pooling coerente)."""
|
||||||
|
pooled = _new_store()
|
||||||
|
for st in stores:
|
||||||
|
for key, a in st["acc"].items():
|
||||||
|
for f in ("sn", "sr", "sl", "c"):
|
||||||
|
pooled["acc"][key][f] += a[f]
|
||||||
|
for s, b in st["brk"].items():
|
||||||
|
pooled["brk"][s]["b"] += b["b"]
|
||||||
|
pooled["brk"][s]["t"] += b["t"]
|
||||||
|
return pooled
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# STRATEGIA (solo se il claim batte il placebo) — fade ai livelli Fib
|
||||||
|
# ===========================================================================
|
||||||
|
def fib_fade_positions(df: pd.DataFrame, k_atr: float, H: int) -> np.ndarray:
|
||||||
|
"""Posizione long/short: al fresh-touch di un livello Fib vero prendi la direzione
|
||||||
|
del rimbalzo atteso e tienila H barre (tocchi sovrapposti sommati, clip a +/-1).
|
||||||
|
Decisione a close[i] -> eval_weights la applica dalla barra i+1 (nessun leak)."""
|
||||||
|
piv = zigzag_pivots(df, k_atr)
|
||||||
|
n = len(df)
|
||||||
|
segs = build_segments(piv, n)
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
sig = np.zeros(n)
|
||||||
|
ids0 = np.zeros(NL, dtype=int)
|
||||||
|
for seg in segs:
|
||||||
|
w0, w1 = seg["w0"], min(seg["w1"], n - 1)
|
||||||
|
if w0 < 1 or w1 < w0:
|
||||||
|
continue
|
||||||
|
pA, pB = seg["pA"], seg["pB"]
|
||||||
|
M = pB - pA
|
||||||
|
if abs(M) < 1e-12:
|
||||||
|
continue
|
||||||
|
L = np.concatenate([pB - np.array(RET_TRUE) * M, pA + np.array(EXT_TRUE) * M])
|
||||||
|
gi, dirs, _, _ = _touch_scan(h, l, c, w0, w1, L, ids0)
|
||||||
|
np.add.at(sig, gi, dirs)
|
||||||
|
return np.clip(pd.Series(sig).rolling(H, min_periods=1).sum().values, -1.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def make_fib_target(tf: str = "1d", k_atr: float = 2.0, H: int = 20):
|
||||||
|
def target(df):
|
||||||
|
return fib_fade_positions(df, k_atr, H)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# MAIN
|
||||||
|
# ===========================================================================
|
||||||
|
def main():
|
||||||
|
print("=" * 104)
|
||||||
|
print("r0702 ELL-B FIBONACCI CONFLUENCE — reazione ai livelli Fib vs rapporti placebo "
|
||||||
|
"(il null e' tutto)")
|
||||||
|
print(f"zigzag k*ATR14 k={K_GRID}, ratios ret={RET_TRUE} ext={EXT_TRUE}, H={HORIZONS} barre,"
|
||||||
|
f" placebo A={N_PA} set fissi / B={N_PB} per-swing / C={N_PC} location-matched,"
|
||||||
|
f" seed={SEED}")
|
||||||
|
print("statistica = media reazione segnata/ATR sui tocchi IS (pre-2025); "
|
||||||
|
"pctl alto = Fib meglio del placebo")
|
||||||
|
print("=" * 104)
|
||||||
|
|
||||||
|
# ---------- [0] CAUSALITY: pivot su prefisso troncato + target strategy ----------
|
||||||
|
print("\n[0] CAUSALITY CHECK zigzag (pivot confermati < cut identici su prefisso 90%)")
|
||||||
|
for a in ASSETS:
|
||||||
|
for tf in TFS:
|
||||||
|
df = al.get(a, tf)
|
||||||
|
cut = int(len(df) * 0.9)
|
||||||
|
pf = [p for p in zigzag_pivots(df, K_GRID[0]) if p[3] < cut]
|
||||||
|
pp = [p for p in zigzag_pivots(df.iloc[:cut].reset_index(drop=True), K_GRID[0])
|
||||||
|
if p[3] < cut]
|
||||||
|
ok = pf == pp
|
||||||
|
print(f" {a} {tf}: pivots full<cut={len(pf)} prefix={len(pp)} "
|
||||||
|
f"identici={'OK' if ok else 'FAIL'}")
|
||||||
|
cz = al.causality_ok(make_fib_target("1d", K_GRID[0], HORIZONS[1]), tf="1d")
|
||||||
|
print(f" target fade 1d (al.causality_ok): ok={cz['ok']} "
|
||||||
|
f"max_tail_diff={cz['max_tail_diff']}")
|
||||||
|
|
||||||
|
# ---------- [1] SCANSIONE tutti i config ----------
|
||||||
|
rng_a = np.random.default_rng(SEED)
|
||||||
|
PA_r, PA_e = make_placebo_a(rng_a)
|
||||||
|
PC_r, PC_e = make_placebo_c(np.random.default_rng(SEED + 7))
|
||||||
|
results = {}
|
||||||
|
print("\n[1] SWING E TOCCHI per config")
|
||||||
|
for ia, a in enumerate(ASSETS):
|
||||||
|
for itf, tf in enumerate(TFS):
|
||||||
|
df = al.get(a, tf)
|
||||||
|
n = len(df)
|
||||||
|
for ik, k in enumerate(K_GRID):
|
||||||
|
piv = zigzag_pivots(df, k)
|
||||||
|
segs = build_segments(piv, n)
|
||||||
|
rng_b = np.random.default_rng([SEED, ia, itf, ik])
|
||||||
|
PB_r, PB_e = make_placebo_b(rng_b, len(segs))
|
||||||
|
res = scan_config(df, segs, PA_r, PA_e, PB_r, PB_e, PC_r, PC_e)
|
||||||
|
results[(a, tf, k)] = res
|
||||||
|
nIS = int(res["main"]["acc"][(HORIZONS[0], "IS")]["c"][0])
|
||||||
|
nF = int(res["main"]["acc"][(HORIZONS[0], "FULL")]["c"][0])
|
||||||
|
print(f" {a} {tf} k={k:.0f}: bars={n} pivots={len(piv)} swings={len(segs)} "
|
||||||
|
f"tocchi_true IS={nIS} FULL={nF} zone_confluenti_true={int(res['czc'][0])}")
|
||||||
|
|
||||||
|
# ---------- [2] TABELLA PRINCIPALE per cella ----------
|
||||||
|
print("\n[2] REAZIONE AL LIVELLO vs PLACEBO — per cella (statistica: media react/ATR, IS)")
|
||||||
|
print(f" pctlA = vs {N_PA} set fissi uniformi; pctlB = vs {N_PB} per-swing; "
|
||||||
|
f"pctlC = vs {N_PC} location-matched (jitter attorno ai Fib);"
|
||||||
|
f" Bonferroni 16 celle -> single-cell sig ~>=0.997")
|
||||||
|
hdr = (f" {'asset':<5s} {'tf':<3s} {'k':>2s} {'H':>3s} {'nIS':>6s} {'nFULL':>6s} "
|
||||||
|
f"{'rawIS(bps)':>10s} {'lvlIS(bps)':>10s} {'normIS':>8s} {'plcAmed':>8s} "
|
||||||
|
f"{'plcCmed':>8s} {'pctlA_IS':>8s} {'pctlB_IS':>8s} {'pctlC_IS':>8s} "
|
||||||
|
f"{'pctlA_F':>8s} {'brkIS':>6s} {'brkPctl':>7s}")
|
||||||
|
print(hdr)
|
||||||
|
cells = {}
|
||||||
|
for (a, tf, k), res in results.items():
|
||||||
|
for H in HORIZONS:
|
||||||
|
st = _cell_stats(res["main"], H)
|
||||||
|
cells[(a, tf, k, H)] = st
|
||||||
|
i_, f_ = st["IS"], st["FULL"]
|
||||||
|
print(f" {a:<5s} {tf:<3s} {k:>2.0f} {H:>3d} {i_['n']:>6d} {f_['n']:>6d} "
|
||||||
|
f"{i_['raw_bps']:>10.1f} {i_['lvl_bps']:>10.1f} {i_['norm']:>8.3f} "
|
||||||
|
f"{i_['plcA_med']:>8.3f} {i_['plcC_med']:>8.3f} "
|
||||||
|
f"{i_['pctlA']:>8.2f} {i_['pctlB']:>8.2f} {i_['pctlC']:>8.2f} "
|
||||||
|
f"{f_['pctlA']:>8.2f} {st['brk_rate']:>6.2f} "
|
||||||
|
f"{st['brk_pctl_hold'] if st['brk_pctl_hold'] is not None else float('nan'):>7.2f}")
|
||||||
|
|
||||||
|
# ---------- [3] POOLED per (k,H) su asset x TF ----------
|
||||||
|
print("\n[3] POOLED (somma tocchi su 2 asset x 2 TF) — il test famiglia")
|
||||||
|
pooled_pass = {}
|
||||||
|
for k in K_GRID:
|
||||||
|
pooled = _pool_stores([results[(a, tf, k)]["main"] for a in ASSETS for tf in TFS])
|
||||||
|
for H in HORIZONS:
|
||||||
|
st = _cell_stats(pooled, H)
|
||||||
|
i_ = st["IS"]
|
||||||
|
cell_ps = [cells[(a, tf, k, H)]["IS"]["pctlA"] for a in ASSETS for tf in TFS
|
||||||
|
if cells[(a, tf, k, H)]["IS"]["n"] >= MIN_CELL_TOUCH]
|
||||||
|
min_cell = min(cell_ps) if cell_ps else None
|
||||||
|
ok = (i_["pctlA"] is not None and i_["pctlA"] >= 0.99
|
||||||
|
and i_["pctlB"] is not None and i_["pctlB"] >= 0.99
|
||||||
|
and i_["pctlC"] is not None and i_["pctlC"] >= 0.95
|
||||||
|
and min_cell is not None and min_cell >= 0.90)
|
||||||
|
pooled_pass[(k, H)] = ok
|
||||||
|
print(f" k={k:.0f} H={H:>2d}: nIS={i_['n']:>6d} normIS={i_['norm']:+.3f} "
|
||||||
|
f"(plcA med {i_['plcA_med']:+.3f} / plcC med {i_['plcC_med']:+.3f}) "
|
||||||
|
f"pctlA_IS={i_['pctlA']:.2f} pctlB_IS={i_['pctlB']:.2f} "
|
||||||
|
f"pctlC_IS={i_['pctlC']:.2f} pctlA_FULL={st['FULL']['pctlA']:.2f} "
|
||||||
|
f"min_cell_pctlA={min_cell if min_cell is not None else 'n/a'} "
|
||||||
|
f"PASS(A,B>=0.99 & C>=0.95 & cells>=0.90)={ok}")
|
||||||
|
|
||||||
|
# ---------- [4] CONFLUENZA vs livello singolo ----------
|
||||||
|
print("\n[4] CONFLUENZA (ret swing corrente x ext swing s-2 stesso verso, eps=0.25*ATR)")
|
||||||
|
print(" diff = norm(conf) - norm(single); pctl del diff vero vs placebo A")
|
||||||
|
conf_ok_cells = {}
|
||||||
|
for (a, tf, k), res in results.items():
|
||||||
|
for H in HORIZONS:
|
||||||
|
mc = _means(res["conf"], H, "IS")
|
||||||
|
ms = _means(res["sing"], H, "IS")
|
||||||
|
nc = int(res["conf"]["acc"][(H, "IS")]["c"][0])
|
||||||
|
ns = int(res["sing"]["acc"][(H, "IS")]["c"][0])
|
||||||
|
diff = mc - ms
|
||||||
|
p = _pctl(diff[0], diff[IDX_A])
|
||||||
|
czc_true = int(res["czc"][0])
|
||||||
|
czc_plc = float(np.mean(res["czc"][IDX_A]))
|
||||||
|
conf_ok_cells[(a, tf, k, H)] = (p is not None and p >= 0.95
|
||||||
|
and nc >= MIN_CONF_TOUCH)
|
||||||
|
d0 = f"{diff[0]:+.3f}" if np.isfinite(diff[0]) else " n/a"
|
||||||
|
print(f" {a} {tf} k={k:.0f} H={H:>2d}: zone true={czc_true} (plcA med~{czc_plc:.0f}) "
|
||||||
|
f"tocchiIS conf={nc} sing={ns} "
|
||||||
|
f"norm conf={mc[0] if np.isfinite(mc[0]) else float('nan'):+.3f} "
|
||||||
|
f"sing={ms[0] if np.isfinite(ms[0]) else float('nan'):+.3f} diff={d0} "
|
||||||
|
f"pctl_diff={p if p is not None else 'n/a'}")
|
||||||
|
conf_real = {}
|
||||||
|
for k in K_GRID:
|
||||||
|
for H in HORIZONS:
|
||||||
|
conf_real[(k, H)] = all(conf_ok_cells[(a, tf, k, H)] for a in ASSETS for tf in TFS)
|
||||||
|
print(" verdetto confluenza per (k,H) [tutte le 4 celle pctl>=0.95 e n>=30]: "
|
||||||
|
+ " ".join(f"k={k:.0f},H={H}:{'PASS' if v else 'FAIL'}"
|
||||||
|
for (k, H), v in conf_real.items()))
|
||||||
|
|
||||||
|
# ---------- [5] VERDETTO + eventuale strategizzazione ----------
|
||||||
|
fib_real = any(pooled_pass.values())
|
||||||
|
any_conf = any(conf_real.values())
|
||||||
|
print("\n[5] VERDETTO")
|
||||||
|
print(f" fib_real (pooled IS: A,B>=0.99, C>=0.95, tutte le celle >=0.90, "
|
||||||
|
f"per qualche k,H): {fib_real} -> {pooled_pass}")
|
||||||
|
print(f" confluenza > singolo livello (robusto 4/4 celle): {any_conf}")
|
||||||
|
if fib_real:
|
||||||
|
print("\n[6] STRATEGIA (il claim regge il placebo) — fade ai livelli, "
|
||||||
|
"study_family_honest + marginal")
|
||||||
|
grid = [dict(k_atr=k, H=H) for k in K_GRID for H in HORIZONS]
|
||||||
|
fam = al.study_family_honest("FIB-FADE", make_fib_target, grid, tfs=TFS)
|
||||||
|
ch = fam.get("chosen")
|
||||||
|
print(f" chosen(IS)={ch} n_cells={fam.get('n_cells')} "
|
||||||
|
f"DSR={fam.get('deflated_sharpe')} (null-max {fam.get('expected_null_max')}) "
|
||||||
|
f"dsr_pass={fam.get('dsr_pass')}")
|
||||||
|
print(f" earns_slot_marginal={fam.get('earns_slot_marginal')} "
|
||||||
|
f"EARNS_SLOT_HONEST={fam.get('earns_slot_honest')}")
|
||||||
|
if fam.get("marginal"):
|
||||||
|
print(al.fmt_marginal(fam["marginal"]))
|
||||||
|
else:
|
||||||
|
print("\n[6] STRATEGIA: SALTATA — la reazione ai livelli Fib NON batte i rapporti "
|
||||||
|
"casuali in modo robusto (regola a priori). Nessun edge da strategizzare.")
|
||||||
|
|
||||||
|
print("\nFine. Selezione: nessuna (rapporti dal claim, verdetto su pooled IS); "
|
||||||
|
"hold-out mai usato per scegliere.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"""r0702_ell_rangecycle — Test ONESTO del claim "Elliott/Larry Williams range-cycle".
|
||||||
|
|
||||||
|
CLAIM (da video didattico, operazionalizzato SENZA conteggio soggettivo di onde):
|
||||||
|
(A) un segmento di trend che inizia con un impulso a RANGE COMPRESSO tende a produrre
|
||||||
|
un'estensione successiva AMPIA ("onda 3 larga");
|
||||||
|
(B) un segmento che inizia con impulso a RANGE AMPIO produce una fase finale TRONCATA
|
||||||
|
(ultimo terzo debole -> uscire prima).
|
||||||
|
|
||||||
|
Entrambi i claim, come operazionalizzati, predicono rho(impulso, seguito) NEGATIVA:
|
||||||
|
A: rho(range_impulso_norm, ritorno_normalizzato del RESTO del segmento) < 0
|
||||||
|
B: rho(range_impulso_norm, ritorno_normalizzato dell'ULTIMO TERZO) < 0
|
||||||
|
|
||||||
|
PRIOR ART (sweep 104-famiglie 2026-06-20): la compressione come *setup di breakout* è
|
||||||
|
già stata testata e NON regge — BRK08 (NR7), BRK10 (BB-squeeze long), CMB05 (squeeze->
|
||||||
|
breakout), VOL12 (low-vol timing). Qui la domanda è DIVERSA e a livello di SEGMENTO:
|
||||||
|
il range dell'impulso iniziale predice l'ampiezza/troncatura del seguito? Statistica
|
||||||
|
PRIMA della strategia: si strategizza SOLO se il segnale regge Bonferroni sul pre-2025.
|
||||||
|
|
||||||
|
METODO:
|
||||||
|
- Dati certificati BTC/ETH 1d (al.get) + settimanali costruiti dal 1d con resample
|
||||||
|
"7D", label='left', closed='left', origin ESPLICITA (7 ancore, banda riportata).
|
||||||
|
Niente DatetimeIndex.view("int64") (lezione pandas 2026-07-01).
|
||||||
|
- 2 definizioni MECCANICHE di segmento:
|
||||||
|
(i) TSMOM-flip: run massimali di sign(close[t]-close[t-L]) (L=30 bar 1d, 4 bar 1w).
|
||||||
|
Causale sia in start sia in end.
|
||||||
|
(ii) zigzag causale k*ATR: pivot = estremo confermato solo dopo retracement k*ATR
|
||||||
|
(k=3.0/ATR14 su 1d, k=2.0/ATR8 su 1w — FISSATI a priori, non cercati).
|
||||||
|
NB: il pivot è noto solo DOPO (lag di conferma) -> le statistiche sono
|
||||||
|
event-aligned ex-post (test di ASSOCIAZIONE); un overlay tradabile dovrebbe
|
||||||
|
scontare il lag. L'impulso (prime N barre) precede comunque il seguito ->
|
||||||
|
nessuna sovrapposizione meccanica impulso/seguito.
|
||||||
|
- Impulso = mean(TrueRange prime N barre) / ATR[pre-segmento], N in {3,5,10}.
|
||||||
|
- Seguito A = dir*(close_end - close_impulso) / (ATR_norm * sqrt(n_resto))
|
||||||
|
Seguito B = dir*(close_end - close_2/3) / (ATR_norm * sqrt(n_ultimo_terzo))
|
||||||
|
Normalizzazione PRIMARIA con ATR pre-segmento (stesso denominatore di impulso ->
|
||||||
|
bias comune-divisore POSITIVO su rho = CONSERVATIVO vs claim che predicono rho<0).
|
||||||
|
Sensibilità: anche ATR post-impulso (ANTI-conservativa: un impulso ampio gonfia
|
||||||
|
l'ATR post -> sgonfia il seguito -> fabbrica rho<0). Riportate entrambe, si testa
|
||||||
|
la primaria.
|
||||||
|
- Test: Spearman pooled BTC+ETH con NULL PERMUTATO (shuffle seguito<->impulso ENTRO
|
||||||
|
asset, 2000 permutazioni, two-sided). Secondario: differenza terzili (compresso -
|
||||||
|
ampio, terzili entro asset). Bonferroni su 24 celle primarie (2 def x 2 TF x 3 N x
|
||||||
|
2 claim; weekly = ancora lunedì, le altre 6 ancore = banda robustezza).
|
||||||
|
- Campione PICCOLO per costruzione (decine di segmenti, weekly ~390 barre) -> n
|
||||||
|
riportato per cella; p marginali NON vanno creduti.
|
||||||
|
|
||||||
|
Output: tabella celle + CSV nello scratchpad (ell_a_cells.csv). Strategizzazione
|
||||||
|
(study_marginal / study_family_honest) SOLO se una cella passa Bonferroni con il segno
|
||||||
|
giusto sul campione pre-2025.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
import altlib as al
|
||||||
|
|
||||||
|
SCRATCH = ("/tmp/claude-1001/-opt-docker-PythagorasGoal/"
|
||||||
|
"e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad")
|
||||||
|
SEED = 20260702
|
||||||
|
N_PERM = 2000
|
||||||
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||||
|
N_LIST = (3, 5, 10)
|
||||||
|
# Parametri di segmentazione FISSATI A PRIORI (nessuna ricerca su di essi):
|
||||||
|
TSMOM_L = {"1d": 30, "1w": 4} # 30 giorni / 4 settimane (~28g)
|
||||||
|
ZZ_K = {"1d": 3.0, "1w": 2.0} # soglia retracement in multipli di ATR
|
||||||
|
ZZ_ATRWIN = {"1d": 14, "1w": 8}
|
||||||
|
BONF_M = 24 # 2 def x 2 TF x 3 N x 2 claim (weekly=ancora lun)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dati
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def daily(asset: str) -> pd.DataFrame:
|
||||||
|
df = al.get(asset, "1d")
|
||||||
|
return df[["datetime", "open", "high", "low", "close", "volume"]].copy()
|
||||||
|
|
||||||
|
|
||||||
|
def weekly_from_daily(dfd: pd.DataFrame, anchor_day: int) -> pd.DataFrame:
|
||||||
|
"""Barre settimanali dal 1d. anchor_day=0 -> settimane che iniziano lunedì
|
||||||
|
(origin=2018-01-01, un lunedì); anchor_day=k sposta l'origine di k giorni.
|
||||||
|
resample('168h', label='left', closed='left', origin=esplicita) — '168h' e non
|
||||||
|
'7D': in pandas 2.x 'D' non è Tick-like e IGNOREREBBE origin (warning silenzioso
|
||||||
|
-> tutte le ancore identiche). Bins parziali (<7 giorni) scartati."""
|
||||||
|
origin = pd.Timestamp("2018-01-01", tz="UTC") + pd.Timedelta(days=anchor_day)
|
||||||
|
x = dfd.set_index(pd.DatetimeIndex(pd.to_datetime(dfd["datetime"], utc=True)))
|
||||||
|
g = x.resample("168h", label="left", closed="left", origin=origin)
|
||||||
|
w = g.agg(open=("open", "first"), high=("high", "max"), low=("low", "min"),
|
||||||
|
close=("close", "last"), volume=("volume", "sum"))
|
||||||
|
cnt = g["close"].count()
|
||||||
|
w = w[cnt >= 7].dropna().reset_index(names="datetime")
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def true_range(df: pd.DataFrame) -> np.ndarray:
|
||||||
|
h, l, c = (df[k].values.astype(float) for k in ("high", "low", "close"))
|
||||||
|
pc = np.roll(c, 1)
|
||||||
|
pc[0] = c[0]
|
||||||
|
return np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Definizioni di segmento (meccaniche)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def tsmom_segments(close: np.ndarray, L: int) -> list[tuple[int, int, int]]:
|
||||||
|
"""Run massimali di sign(close[i]-close[i-L]). Ritorna (start, end, dir) inclusivi.
|
||||||
|
Causale: il segno a i usa solo close<=i; start noto allo start, end noto all'end."""
|
||||||
|
n = len(close)
|
||||||
|
s = np.zeros(n)
|
||||||
|
s[L:] = np.sign(close[L:] - close[:-L])
|
||||||
|
for i in range(1, n): # 0 -> segno precedente (nessun flip)
|
||||||
|
if s[i] == 0:
|
||||||
|
s[i] = s[i - 1]
|
||||||
|
segs, start = [], L
|
||||||
|
for i in range(L + 1, n + 1):
|
||||||
|
if i == n or s[i] != s[start]:
|
||||||
|
if s[start] != 0:
|
||||||
|
segs.append((start, i - 1, int(s[start])))
|
||||||
|
start = i
|
||||||
|
return segs
|
||||||
|
|
||||||
|
|
||||||
|
def zigzag_segments(df: pd.DataFrame, k: float, atr_win: int) -> list[tuple[int, int, int]]:
|
||||||
|
"""Zigzag causale a soglia k*ATR: pivot (estremo) confermato solo quando il prezzo
|
||||||
|
ritraccia k*ATR[i] dall'estremo corrente. Segmento = (pivot_prec+1 .. pivot), dir =
|
||||||
|
segno del leg. ⚠️ Il pivot è noto solo alla CONFERMA (lag): qui usato solo per
|
||||||
|
statistiche di associazione ex-post, non come segnale tradabile."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
a = al.atr(df, atr_win)
|
||||||
|
n = len(c)
|
||||||
|
piv: list[int] = []
|
||||||
|
hi, hi_i, lo, lo_i, d = c[0], 0, c[0], 0, 0
|
||||||
|
for i in range(1, n):
|
||||||
|
if d >= 0:
|
||||||
|
if c[i] > hi:
|
||||||
|
hi, hi_i = c[i], i
|
||||||
|
if d == 0 and c[i] < lo:
|
||||||
|
lo, lo_i = c[i], i
|
||||||
|
if hi - c[i] > k * a[i]:
|
||||||
|
piv.append(hi_i)
|
||||||
|
d = -1
|
||||||
|
lo, lo_i = c[i], i
|
||||||
|
continue
|
||||||
|
if d == 0 and c[i] - lo > k * a[i]:
|
||||||
|
piv.append(lo_i)
|
||||||
|
d = 1
|
||||||
|
hi, hi_i = c[i], i
|
||||||
|
else:
|
||||||
|
if c[i] < lo:
|
||||||
|
lo, lo_i = c[i], i
|
||||||
|
if c[i] - lo > k * a[i]:
|
||||||
|
piv.append(lo_i)
|
||||||
|
d = 1
|
||||||
|
hi, hi_i = c[i], i
|
||||||
|
segs = []
|
||||||
|
for j in range(1, len(piv)):
|
||||||
|
p, q = piv[j - 1], piv[j]
|
||||||
|
if q > p + 1:
|
||||||
|
segs.append((p + 1, q, int(np.sign(c[q] - c[p]))))
|
||||||
|
return [s for s in segs if s[2] != 0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Misure per segmento
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def measure(df: pd.DataFrame, segs, N: int, atr_win: int):
|
||||||
|
"""Per ogni segmento: impulso (range prime N barre / ATR pre-segmento) e seguito
|
||||||
|
per claim A (resto) e B (ultimo terzo). Normalizzazione primaria = ATR pre-segmento
|
||||||
|
(conservativa); sensibilità = ATR post-impulso. Ritorna DataFrame righe-segmento."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
a = al.atr(df, atr_win)
|
||||||
|
tr = true_range(df)
|
||||||
|
ts = pd.to_datetime(df["datetime"], utc=True).values
|
||||||
|
rows = []
|
||||||
|
for (s, e, d) in segs:
|
||||||
|
seg_len = e - s + 1
|
||||||
|
if s - 1 < atr_win or seg_len < N + 3:
|
||||||
|
continue
|
||||||
|
a_pre = a[s - 1]
|
||||||
|
if not np.isfinite(a_pre) or a_pre <= 0:
|
||||||
|
continue
|
||||||
|
imp = float(np.mean(tr[s:s + N]) / a_pre)
|
||||||
|
i_end = s + N - 1 # ultima barra dell'impulso
|
||||||
|
a_post = a[i_end] if a[i_end] > 0 else np.nan
|
||||||
|
# Claim A: resto del segmento (da fine impulso a fine segmento), in dir. trend
|
||||||
|
n_rest = e - i_end
|
||||||
|
move_a = d * (c[e] - c[i_end])
|
||||||
|
seqA_pre = float(move_a / (a_pre * np.sqrt(n_rest)))
|
||||||
|
seqA_post = float(move_a / (a_post * np.sqrt(n_rest))) if np.isfinite(a_post) else np.nan
|
||||||
|
# ampiezza massima favorevole del resto (descrittiva)
|
||||||
|
if d > 0:
|
||||||
|
mfe = np.max(df["high"].values[i_end + 1:e + 1]) - c[i_end]
|
||||||
|
else:
|
||||||
|
mfe = c[i_end] - np.min(df["low"].values[i_end + 1:e + 1])
|
||||||
|
mfe_pre = float(mfe / (a_pre * np.sqrt(n_rest)))
|
||||||
|
# Claim B: ultimo terzo del segmento (deve iniziare dopo l'impulso)
|
||||||
|
l0 = s + int(np.ceil(seg_len * 2.0 / 3.0)) # prima barra dell'ultimo terzo
|
||||||
|
seqB_pre = seqB_post = np.nan
|
||||||
|
if l0 - 1 >= i_end and e - (l0 - 1) >= 2:
|
||||||
|
n_last = e - (l0 - 1)
|
||||||
|
move_b = d * (c[e] - c[l0 - 1])
|
||||||
|
a_b = a[l0 - 1]
|
||||||
|
seqB_pre = float(move_b / (a_pre * np.sqrt(n_last)))
|
||||||
|
seqB_post = float(move_b / (a_b * np.sqrt(n_last))) if a_b > 0 else np.nan
|
||||||
|
rows.append(dict(t0=ts[s], seg_len=seg_len, dir=d, imp=imp,
|
||||||
|
seqA=seqA_pre, seqA_post=seqA_post, mfe=mfe_pre,
|
||||||
|
seqB=seqB_pre, seqB_post=seqB_post))
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Statistica: Spearman pooled + null permutato entro-asset; terzili
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _spearman(x: np.ndarray, y: np.ndarray) -> float:
|
||||||
|
rx = pd.Series(x).rank().values
|
||||||
|
ry = pd.Series(y).rank().values
|
||||||
|
sx, sy = rx.std(), ry.std()
|
||||||
|
if sx == 0 or sy == 0:
|
||||||
|
return 0.0
|
||||||
|
return float(np.corrcoef(rx, ry)[0, 1])
|
||||||
|
|
||||||
|
|
||||||
|
def _tercile_diff(imp, seq, aid):
|
||||||
|
"""mean(seguito | terzile compresso) - mean(seguito | terzile ampio), terzili
|
||||||
|
per-asset (>=9 segmenti per asset, altrimenti asset escluso). Claim -> diff>0."""
|
||||||
|
lo_v, hi_v = [], []
|
||||||
|
for a in np.unique(aid):
|
||||||
|
m = aid == a
|
||||||
|
if m.sum() < 9:
|
||||||
|
continue
|
||||||
|
q1, q2 = np.quantile(imp[m], [1 / 3, 2 / 3])
|
||||||
|
lo_v.append(seq[m & (imp <= q1)])
|
||||||
|
hi_v.append(seq[m & (imp >= q2)])
|
||||||
|
if not lo_v:
|
||||||
|
return np.nan
|
||||||
|
return float(np.concatenate(lo_v).mean() - np.concatenate(hi_v).mean())
|
||||||
|
|
||||||
|
|
||||||
|
def perm_test(imp, seq, aid, n_perm=N_PERM, seed=SEED):
|
||||||
|
"""Shuffle del seguito rispetto all'impulso ENTRO asset. p two-sided su Spearman
|
||||||
|
(primario) e su tercile diff (secondario)."""
|
||||||
|
ok = np.isfinite(imp) & np.isfinite(seq)
|
||||||
|
imp, seq, aid = imp[ok], seq[ok], aid[ok]
|
||||||
|
n = len(imp)
|
||||||
|
if n < 12:
|
||||||
|
return dict(rho=np.nan, p=np.nan, ter=np.nan, p_ter=np.nan, n=n)
|
||||||
|
rho = _spearman(imp, seq)
|
||||||
|
ter = _tercile_diff(imp, seq, aid)
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
masks = [aid == a for a in np.unique(aid)]
|
||||||
|
cnt_r = cnt_t = 0
|
||||||
|
n_t = 0
|
||||||
|
for _ in range(n_perm):
|
||||||
|
sp = seq.copy()
|
||||||
|
for m in masks:
|
||||||
|
sp[m] = rng.permutation(sp[m])
|
||||||
|
if abs(_spearman(imp, sp)) >= abs(rho):
|
||||||
|
cnt_r += 1
|
||||||
|
if np.isfinite(ter):
|
||||||
|
tp = _tercile_diff(imp, sp, aid)
|
||||||
|
if np.isfinite(tp):
|
||||||
|
n_t += 1
|
||||||
|
if abs(tp) >= abs(ter):
|
||||||
|
cnt_t += 1
|
||||||
|
p = (1 + cnt_r) / (n_perm + 1)
|
||||||
|
p_ter = (1 + cnt_t) / (n_t + 1) if (np.isfinite(ter) and n_t > 0) else np.nan
|
||||||
|
return dict(rho=round(rho, 3), p=p, ter=(round(ter, 3) if np.isfinite(ter) else np.nan),
|
||||||
|
p_ter=p_ter, n=n)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Celle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def build_frames(tf: str, anchor: int):
|
||||||
|
out = {}
|
||||||
|
for a in ("BTC", "ETH"):
|
||||||
|
d = daily(a)
|
||||||
|
out[a] = d if tf == "1d" else weekly_from_daily(d, anchor)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def segments_for(df, sdef: str, tf: str):
|
||||||
|
if sdef == "tsmom":
|
||||||
|
return tsmom_segments(df["close"].values.astype(float), TSMOM_L[tf])
|
||||||
|
return zigzag_segments(df, ZZ_K[tf], ZZ_ATRWIN[tf])
|
||||||
|
|
||||||
|
|
||||||
|
def cell_rows(frames, sdef: str, tf: str, N: int) -> pd.DataFrame:
|
||||||
|
parts = []
|
||||||
|
for i, (a, df) in enumerate(frames.items()):
|
||||||
|
m = measure(df, segments_for(df, sdef, tf), N, ZZ_ATRWIN[tf])
|
||||||
|
if len(m):
|
||||||
|
m["aid"] = i
|
||||||
|
m["asset"] = a
|
||||||
|
parts.append(m)
|
||||||
|
return pd.concat(parts, ignore_index=True) if parts else pd.DataFrame()
|
||||||
|
|
||||||
|
|
||||||
|
def run_cell(rows: pd.DataFrame, claim: str, pre2025: bool, norm: str = "pre"):
|
||||||
|
if rows.empty:
|
||||||
|
return dict(rho=np.nan, p=np.nan, ter=np.nan, p_ter=np.nan, n=0)
|
||||||
|
r = rows
|
||||||
|
if pre2025:
|
||||||
|
r = r[pd.to_datetime(r["t0"], utc=True) < HOLDOUT]
|
||||||
|
col = {"A": {"pre": "seqA", "post": "seqA_post"},
|
||||||
|
"B": {"pre": "seqB", "post": "seqB_post"}}[claim][norm]
|
||||||
|
return perm_test(r["imp"].values.astype(float), r[col].values.astype(float),
|
||||||
|
r["aid"].values)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(__doc__.split("\n")[0])
|
||||||
|
print(f"seed={SEED} n_perm={N_PERM} Bonferroni m={BONF_M} (celle primarie)")
|
||||||
|
results = []
|
||||||
|
frames_1d = build_frames("1d", 0)
|
||||||
|
frames_1w = {k: build_frames("1w", k) for k in range(7)}
|
||||||
|
for a, df in frames_1d.items():
|
||||||
|
print(f" {a} 1d: {len(df)} barre {df['datetime'].iloc[0]} -> {df['datetime'].iloc[-1]}"
|
||||||
|
f" | 1w(lun): {len(frames_1w[0][a])} barre")
|
||||||
|
|
||||||
|
for sdef in ("tsmom", "zigzag"):
|
||||||
|
for tf in ("1d", "1w"):
|
||||||
|
fr = frames_1d if tf == "1d" else frames_1w[0]
|
||||||
|
# conteggio segmenti grezzi (informativo)
|
||||||
|
nseg = {a: len(segments_for(df, sdef, tf)) for a, df in fr.items()}
|
||||||
|
print(f"\n[{sdef} {tf}] segmenti grezzi: {nseg}")
|
||||||
|
for N in N_LIST:
|
||||||
|
rows = cell_rows(fr, sdef, tf, N)
|
||||||
|
for claim in ("A", "B"):
|
||||||
|
full = run_cell(rows, claim, pre2025=False)
|
||||||
|
ins = run_cell(rows, claim, pre2025=True)
|
||||||
|
post = run_cell(rows, claim, pre2025=False, norm="post")
|
||||||
|
rec = dict(sdef=sdef, tf=tf, N=N, claim=claim,
|
||||||
|
n=full["n"], rho=full["rho"], p=full["p"],
|
||||||
|
p_bonf=min(1.0, full["p"] * BONF_M) if np.isfinite(full["p"]) else np.nan,
|
||||||
|
ter=full["ter"], p_ter=full["p_ter"],
|
||||||
|
rho_is=ins["rho"], p_is=ins["p"], n_is=ins["n"],
|
||||||
|
rho_postnorm=post["rho"])
|
||||||
|
# banda ancore weekly (tutte e 7, stessa cella)
|
||||||
|
if tf == "1w":
|
||||||
|
rhos, ps = [], []
|
||||||
|
for k in range(7):
|
||||||
|
rr = run_cell(cell_rows(frames_1w[k], sdef, tf, N), claim, False)
|
||||||
|
rhos.append(float(rr["rho"]) if np.isfinite(rr["rho"]) else np.nan)
|
||||||
|
ps.append(float(rr["p"]) if np.isfinite(rr["p"]) else np.nan)
|
||||||
|
if np.any(np.isfinite(rhos)):
|
||||||
|
med = float(np.nanmedian(rhos))
|
||||||
|
rec["anchor_rho_band"] = (round(float(np.nanmin(rhos)), 3),
|
||||||
|
round(med, 3),
|
||||||
|
round(float(np.nanmax(rhos)), 3))
|
||||||
|
rec["anchor_p_band"] = (round(float(np.nanmin(ps)), 4),
|
||||||
|
round(float(np.nanmax(ps)), 4))
|
||||||
|
rec["anchor_sign_flips"] = int(np.nansum(
|
||||||
|
np.sign(rhos) != np.sign(med)))
|
||||||
|
else:
|
||||||
|
rec["anchor_rho_band"] = rec["anchor_p_band"] = None
|
||||||
|
rec["anchor_sign_flips"] = None
|
||||||
|
results.append(rec)
|
||||||
|
|
||||||
|
R = pd.DataFrame(results)
|
||||||
|
R.to_csv(f"{SCRATCH}/ell_a_cells.csv", index=False)
|
||||||
|
|
||||||
|
print("\n" + "=" * 118)
|
||||||
|
print("TABELLA CELLE (norm primaria = ATR pre-segmento; claim A/B predicono rho<0; p permutato two-sided)")
|
||||||
|
print("=" * 118)
|
||||||
|
hdr = (f"{'def':7}{'tf':4}{'N':3}{'cl':3}{'n':5}{'rho':>7}{'p':>8}{'p_bonf':>8}"
|
||||||
|
f"{'ter(c-a)':>9}{'p_ter':>8}{'rho_IS':>8}{'p_IS':>8}{'n_IS':>5}{'rho_post':>9} anchors(1w)")
|
||||||
|
print(hdr)
|
||||||
|
for _, r in R.iterrows():
|
||||||
|
anch = ""
|
||||||
|
if r["tf"] == "1w" and r["anchor_rho_band"] is not None:
|
||||||
|
anch = (f"rho[min/med/max]={r['anchor_rho_band']} "
|
||||||
|
f"p[{r['anchor_p_band'][0]}..{r['anchor_p_band'][1]}] "
|
||||||
|
f"sign_flips={r['anchor_sign_flips']}")
|
||||||
|
print(f"{r['sdef']:7}{r['tf']:4}{r['N']:<3}{r['claim']:3}{r['n']:<5}"
|
||||||
|
f"{r['rho']:>7}{r['p']:>8.4f}{r['p_bonf']:>8.3f}"
|
||||||
|
f"{str(r['ter']):>9}{r['p_ter']:>8.4f}{str(r['rho_is']):>8}{r['p_is']:>8.4f}{r['n_is']:>5}"
|
||||||
|
f"{str(r['rho_postnorm']):>9} {anch}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Verdetti
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
print("\n" + "=" * 118)
|
||||||
|
for claim in ("A", "B"):
|
||||||
|
sub = R[R["claim"] == claim]
|
||||||
|
sig = sub[(sub["p_bonf"] < 0.05) & (sub["rho"] < 0)]
|
||||||
|
sig_is = sub[(sub["p_is"] * BONF_M < 0.05) & (sub["rho_is"] < 0)]
|
||||||
|
neg = int((sub["rho"] < 0).sum())
|
||||||
|
print(f"CLAIM {claim}: celle={len(sub)} rho<0 (segno del claim)={neg}/{len(sub)} "
|
||||||
|
f"pass Bonferroni FULL={len(sig)} pass Bonferroni pre-2025={len(sig_is)}")
|
||||||
|
if len(sig) == 0 and len(sig_is) == 0:
|
||||||
|
print(f" -> NESSUNA cella regge Bonferroni. Claim {claim} NON supportato: "
|
||||||
|
f"si strategizza solo con evidenza statistica -> STOP qui per questo claim.")
|
||||||
|
else:
|
||||||
|
print(f" -> celle sopravvissute:\n{sig.to_string()}")
|
||||||
|
print(" -> procedere a strategizzazione SOLO su selezione pre-2025 "
|
||||||
|
"(study_family_honest).")
|
||||||
|
|
||||||
|
n_med = int(R["n"].median())
|
||||||
|
print(f"\nCAVEAT CAMPIONE: n mediano per cella = {n_med} segmenti "
|
||||||
|
f"(weekly ~{int(R[R['tf'] == '1w']['n'].median())}). Con decine di osservazioni "
|
||||||
|
"un p marginale (0.01-0.05 raw) è indistinguibile dal multiple-testing: "
|
||||||
|
"24 celle primarie + 2 claim correlati + 7 ancore weekly.")
|
||||||
|
print("NOTE: (1) zigzag = associazione ex-post (pivot noto solo a conferma); "
|
||||||
|
"(2) norm 'post' (ATR post-impulso) è ANTI-conservativa (divisore contaminato "
|
||||||
|
"dall'impulso) -> se rho<0 appare solo lì, è artefatto di normalizzazione; "
|
||||||
|
"(3) daily = ancora 00:00 UTC fissa (feed certificato).")
|
||||||
|
print(f"CSV: {SCRATCH}/ell_a_cells.csv")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
"""r0702_eventclock.py — EVENT-CLOCK BARS (campionamento a tempo-informazione), 2026-07-02.
|
||||||
|
|
||||||
|
IPOTESI: campionare il tempo per INFORMAZIONE (volume bars, vol bars = cum|logret|, range
|
||||||
|
bars) normalizza i regimi e migliora trend/breakout A PARITA' di strategia e frequenza media
|
||||||
|
rispetto alle barre wall-clock. Mai testato nel progetto (tutte le 104 famiglie girano su
|
||||||
|
barre wall-clock).
|
||||||
|
|
||||||
|
DISEGNO ONESTO:
|
||||||
|
* Barre-evento costruite dal 5m certificato Deribit (al.get). Soglia CAUSALE: EWMA-90g
|
||||||
|
dell'incremento per barra 5m, SHIFTATA di 1 (solo passato), x N_target barre 5m per la
|
||||||
|
durata nominale (4h/12h/24h). Nessuna calibrazione full-sample. Parametri fissati a
|
||||||
|
priori (span 90g, warm-up 14g, durate 4/12/24h).
|
||||||
|
* Decisione a close della barra-evento k -> posizione tenuta DALLA prima barra 5m dopo la
|
||||||
|
chiusura (shift +1 barra-evento). Mark-to-market sul 5m, compounding a griglia daily
|
||||||
|
UTC (stessa convenzione di al.candidate_daily). Fee 0.0005/lato su |Δpos|.
|
||||||
|
* Selezione cella SOLO IN-SAMPLE (pre-2025) sul Sharpe 50/50; hold-out riportato per
|
||||||
|
QUELLA cella. deflated_sharpe su TUTTI i trial (event + wall).
|
||||||
|
* CONTROLLO DECISIVO: stessa strategia, stessi parametri (in unita' di barre, convertiti
|
||||||
|
per durata nominale) su barre WALL-CLOCK 4h/12h/1d (al.get, path resample leak-free).
|
||||||
|
* Guardia causalita': ricostruzione barre+target su prefisso (80%/92%) -> i confini e i
|
||||||
|
target devono coincidere con la run full troncata.
|
||||||
|
* NIENTE ffill mixed-timeframe; niente DatetimeIndex.view('int64') (uso la colonna
|
||||||
|
timestamp in ms).
|
||||||
|
|
||||||
|
Run: uv run python scripts/research/r0702_eventclock.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
|
||||||
|
FEE = al.FEE_SIDE
|
||||||
|
HOLDOUT = al.HOLDOUT
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
|
||||||
|
# ---- parametri FISSATI A PRIORI (nessun tuning) -----------------------------------------
|
||||||
|
DUR_HOURS = (4.0, 12.0, 24.0) # durata media nominale delle barre-evento
|
||||||
|
BAR_TYPES = ("volume", "volbar", "range")
|
||||||
|
WALL_TF = {4.0: "4h", 12.0: "12h", 24.0: "1d"}
|
||||||
|
EWM_SPAN_5M = 90 * 288 # soglia adattiva: EWMA 90 giorni di barre 5m
|
||||||
|
WARMUP_5M = 14 * 288 # min_periods 14 giorni prima della prima barra
|
||||||
|
BARS_5M_PER_H = 12
|
||||||
|
|
||||||
|
# strategie (parametri in GIORNI-equivalenti, convertiti in barre per durata nominale)
|
||||||
|
STRATS = [
|
||||||
|
("TSMOM-30/90/180", "tsmom", dict(days=(30, 90, 180))),
|
||||||
|
("DONCH-10d", "donch", dict(days=10)),
|
||||||
|
("DONCH-30d", "donch", dict(days=30)),
|
||||||
|
("EWMA-5/30", "ewma", dict(days=(5, 30))),
|
||||||
|
("EWMA-15/75", "ewma", dict(days=(15, 75))),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def bars_for_days(days: float, dur_h: float) -> int:
|
||||||
|
return max(2, int(round(days * 24.0 / dur_h)))
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================================
|
||||||
|
# COSTRUZIONE BARRE-EVENTO (causale)
|
||||||
|
# ==========================================================================================
|
||||||
|
def _increments(df5: pd.DataFrame, kind: str) -> np.ndarray:
|
||||||
|
c = df5["close"].values.astype(float)
|
||||||
|
if kind == "volume":
|
||||||
|
return df5["volume"].values.astype(float)
|
||||||
|
if kind == "volbar": # cum |logret|
|
||||||
|
r = np.zeros(len(c)); r[1:] = np.abs(np.log(c[1:] / c[:-1]))
|
||||||
|
return r
|
||||||
|
if kind == "range": # cum range relativo (high-low)/close
|
||||||
|
h = df5["high"].values.astype(float); l = df5["low"].values.astype(float)
|
||||||
|
return (h - l) / np.where(c > 0, c, np.nan)
|
||||||
|
raise ValueError(kind)
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_close_indices(x: np.ndarray, thr: np.ndarray) -> np.ndarray:
|
||||||
|
"""Loop di formazione barre: chiude una barra quando il cum degli incrementi dal
|
||||||
|
close della barra precedente raggiunge la soglia CAUSALE thr[i] (gia' shiftata)."""
|
||||||
|
e = []
|
||||||
|
cum = 0.0
|
||||||
|
ap = e.append
|
||||||
|
for i in range(len(x)):
|
||||||
|
t = thr[i]
|
||||||
|
if not (t > 0.0): # NaN o <=0 (warm-up): non accumulare
|
||||||
|
cum = 0.0
|
||||||
|
continue
|
||||||
|
cum += x[i]
|
||||||
|
if cum >= t:
|
||||||
|
ap(i)
|
||||||
|
cum = 0.0
|
||||||
|
return np.asarray(e, dtype=np.int64)
|
||||||
|
|
||||||
|
|
||||||
|
class EventBars:
|
||||||
|
"""Barre-evento per (asset, tipo, durata): OHLC + indici 5m di chiusura."""
|
||||||
|
|
||||||
|
def __init__(self, df5: pd.DataFrame, kind: str, dur_h: float):
|
||||||
|
x = np.nan_to_num(_increments(df5, kind), nan=0.0)
|
||||||
|
# soglia causale: EWMA(span 90g) dell'incremento per 5m, shift(1), x N barre target
|
||||||
|
m = pd.Series(x).ewm(span=EWM_SPAN_5M, min_periods=WARMUP_5M,
|
||||||
|
adjust=False).mean().shift(1).values
|
||||||
|
n_target = dur_h * BARS_5M_PER_H
|
||||||
|
thr = m * n_target
|
||||||
|
self.e = _bar_close_indices(x, thr) # indici 5m dei close di barra
|
||||||
|
if len(self.e) < 300:
|
||||||
|
raise RuntimeError(f"troppo poche barre-evento ({len(self.e)}) per {kind}/{dur_h}h")
|
||||||
|
c5 = df5["close"].values.astype(float)
|
||||||
|
h5 = df5["high"].values.astype(float)
|
||||||
|
l5 = df5["low"].values.astype(float)
|
||||||
|
i0 = int(np.argmax(thr > 0)) # primo indice utilizzabile
|
||||||
|
starts = np.concatenate([[i0], self.e[:-1] + 1])
|
||||||
|
sl = slice(0, self.e[-1] + 1)
|
||||||
|
self.close = c5[self.e]
|
||||||
|
self.high = np.maximum.reduceat(h5[sl], starts)
|
||||||
|
self.low = np.minimum.reduceat(l5[sl], starts)
|
||||||
|
# close-time in ms (fine barra 5m = open label + 5m); NIENTE .view su tz-aware
|
||||||
|
ts5 = df5["timestamp"].values.astype(np.int64)
|
||||||
|
self.ts_close_ms = ts5[self.e] + 300_000
|
||||||
|
self.n5 = len(df5)
|
||||||
|
# statistiche durata
|
||||||
|
d_h = np.diff(self.ts_close_ms) / 3.6e6
|
||||||
|
self.dur_median_h = float(np.median(d_h))
|
||||||
|
self.dur_p5_h = float(np.percentile(d_h, 5))
|
||||||
|
span_days = (self.ts_close_ms[-1] - self.ts_close_ms[0]) / 86.4e6
|
||||||
|
self.bars_per_day = len(self.e) / max(span_days, 1.0)
|
||||||
|
ho_ms = int(HOLDOUT.value // 1_000_000)
|
||||||
|
mask_h = self.ts_close_ms >= ho_ms
|
||||||
|
span_h = (self.ts_close_ms[-1] - ho_ms) / 86.4e6
|
||||||
|
self.bars_per_day_holdout = float(mask_h.sum() / max(span_h, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================================
|
||||||
|
# STRATEGIE (target causale su barre-evento O wall-clock: array close/high/low)
|
||||||
|
# ==========================================================================================
|
||||||
|
def strat_target(close: np.ndarray, high: np.ndarray, low: np.ndarray,
|
||||||
|
fn: str, params: dict, dur_h: float) -> np.ndarray:
|
||||||
|
n = len(close)
|
||||||
|
if fn == "tsmom":
|
||||||
|
hs = [bars_for_days(d, dur_h) for d in params["days"]]
|
||||||
|
d = np.zeros(n)
|
||||||
|
for k in hs:
|
||||||
|
s = np.zeros(n); s[k:] = np.sign(close[k:] - close[:-k])
|
||||||
|
d += s
|
||||||
|
t = (d > 0).astype(float)
|
||||||
|
t[:max(hs)] = 0.0 # tutte le finestre disponibili
|
||||||
|
return t
|
||||||
|
if fn == "donch":
|
||||||
|
N = bars_for_days(params["days"], dur_h)
|
||||||
|
hi = pd.Series(high).rolling(N, min_periods=N).max().shift(1).values
|
||||||
|
lo = pd.Series(low).rolling(N, min_periods=N).min().shift(1).values
|
||||||
|
pos = np.where(close > hi, 1.0, np.nan)
|
||||||
|
pos = np.where(close < lo, 0.0, pos)
|
||||||
|
return pd.Series(pos).ffill().fillna(0.0).values
|
||||||
|
if fn == "ewma":
|
||||||
|
f_d, s_d = params["days"]
|
||||||
|
fs, ss = bars_for_days(f_d, dur_h), bars_for_days(s_d, dur_h)
|
||||||
|
f = pd.Series(close).ewm(span=fs, adjust=False).mean().values
|
||||||
|
s = pd.Series(close).ewm(span=ss, adjust=False).mean().values
|
||||||
|
t = (f > s).astype(float)
|
||||||
|
t[:ss] = 0.0
|
||||||
|
return t
|
||||||
|
raise ValueError(fn)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================================
|
||||||
|
# VALUTAZIONE — barre-evento marked-to-market sul 5m, compounding daily
|
||||||
|
# ==========================================================================================
|
||||||
|
def pos5_from_event(n5: int, e: np.ndarray, tgt: np.ndarray) -> np.ndarray:
|
||||||
|
"""Espande i target di barra-evento a posizione per-barra-5m. Il target deciso al
|
||||||
|
close della barra-evento k (indice 5m e[k]) e' tenuto DURANTE le barre 5m
|
||||||
|
(e[k], e[k+1]] -> shift +1 barra-evento by construction."""
|
||||||
|
tgt = np.nan_to_num(np.asarray(tgt, float), nan=0.0)
|
||||||
|
pos = np.zeros(n5)
|
||||||
|
if len(e) >= 2:
|
||||||
|
pos[e[0] + 1:e[-1] + 1] = np.repeat(tgt[:-1], np.diff(e))
|
||||||
|
if len(e) >= 1:
|
||||||
|
pos[e[-1] + 1:] = tgt[-1]
|
||||||
|
return pos
|
||||||
|
|
||||||
|
|
||||||
|
def daily_from_pos5(df5: pd.DataFrame, pos5: np.ndarray, fee_side: float = FEE) -> pd.Series:
|
||||||
|
c = df5["close"].values.astype(float)
|
||||||
|
r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0
|
||||||
|
turn = np.abs(np.diff(pos5, prepend=0.0))
|
||||||
|
net = pos5 * r - fee_side * turn
|
||||||
|
net[0] = 0.0
|
||||||
|
idx = pd.DatetimeIndex(pd.to_datetime(df5["datetime"], utc=True))
|
||||||
|
return al._to_daily(pd.Series(net, index=idx))
|
||||||
|
|
||||||
|
|
||||||
|
def daily_wall(asset: str, tf: str, fn: str, params: dict, dur_h: float,
|
||||||
|
fee_side: float = FEE) -> pd.Series:
|
||||||
|
df = al.get(asset, tf)
|
||||||
|
tgt = strat_target(df["close"].values.astype(float), df["high"].values.astype(float),
|
||||||
|
df["low"].values.astype(float), fn, params, dur_h)
|
||||||
|
ev = al.eval_weights(df, tgt, fee_side=fee_side) # shift +1 fatto dall'harness
|
||||||
|
return al._to_daily(pd.Series(ev["net"], index=ev["idx"]))
|
||||||
|
|
||||||
|
|
||||||
|
def combo5050(dA: pd.Series, dB: pd.Series) -> pd.Series:
|
||||||
|
J = pd.concat({"A": dA, "B": dB}, axis=1, join="inner").fillna(0.0)
|
||||||
|
return 0.5 * J["A"] + 0.5 * J["B"]
|
||||||
|
|
||||||
|
|
||||||
|
def met(d: pd.Series) -> dict:
|
||||||
|
"""Sharpe/CAGR/maxDD full + hold + in-sample da una serie daily."""
|
||||||
|
di = d[d.index < HOLDOUT]; dh = d[d.index >= HOLDOUT]
|
||||||
|
|
||||||
|
def _cagr(s):
|
||||||
|
if len(s) < 10:
|
||||||
|
return float("nan")
|
||||||
|
tot = float(np.prod(1.0 + s.values))
|
||||||
|
return tot ** (365.25 / len(s)) - 1.0 if tot > 0 else -1.0
|
||||||
|
|
||||||
|
return dict(is_sh=round(al._sh(di), 3), full_sh=round(al._sh(d), 3),
|
||||||
|
hold_sh=round(al._sh(dh), 3), full_dd=round(al._dd_ret(d), 4),
|
||||||
|
hold_dd=round(al._dd_ret(dh), 4), full_cagr=round(_cagr(d), 4),
|
||||||
|
hold_cagr=round(_cagr(dh), 4))
|
||||||
|
|
||||||
|
|
||||||
|
def yearly(d: pd.Series) -> dict:
|
||||||
|
out = {}
|
||||||
|
for y, g in d.groupby(d.index.year):
|
||||||
|
eq = np.cumprod(1 + g.values); pk = np.maximum.accumulate(eq)
|
||||||
|
out[int(y)] = (round(float(eq[-1] - 1), 4), round(float(np.max((pk - eq) / pk)), 4))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================================
|
||||||
|
# GUARDIA CAUSALITA' — ricostruzione su prefisso
|
||||||
|
# ==========================================================================================
|
||||||
|
def causality_prefix_check(asset: str, kind: str, dur_h: float, fn: str, params: dict) -> dict:
|
||||||
|
"""Ricostruisce barre+target sul prefisso 80%/92% del 5m: i confini di barra devono
|
||||||
|
essere un prefisso esatto di quelli full (tranne l'ultima barra incompleta) e i target
|
||||||
|
delle barre condivise identici. Qualunque dipendenza dal futuro diverge."""
|
||||||
|
df5 = al.get(asset, "5m")
|
||||||
|
full = EventBars(df5, kind, dur_h)
|
||||||
|
t_full = strat_target(full.close, full.high, full.low, fn, params, dur_h)
|
||||||
|
worst = 0.0; ok = True; checked = 0
|
||||||
|
for frac in (0.80, 0.92):
|
||||||
|
cut = int(len(df5) * frac)
|
||||||
|
sub = df5.iloc[:cut].reset_index(drop=True)
|
||||||
|
eb = EventBars(sub, kind, dur_h)
|
||||||
|
m = len(eb.e)
|
||||||
|
if not np.array_equal(eb.e, full.e[:m]):
|
||||||
|
ok = False
|
||||||
|
continue
|
||||||
|
t_sub = strat_target(eb.close, eb.high, eb.low, fn, params, dur_h)
|
||||||
|
d = float(np.max(np.abs(t_sub - t_full[:m]))) if m else 0.0
|
||||||
|
worst = max(worst, d)
|
||||||
|
checked += 1
|
||||||
|
return dict(ok=bool(ok and worst <= 1e-9), max_diff=worst, checked=checked)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================================
|
||||||
|
# SMALL-CAP a $600 sulle transizioni della cella scelta
|
||||||
|
# ==========================================================================================
|
||||||
|
def smallcap_event(df5: pd.DataFrame, pos5: np.ndarray, capital=600.0, min_order=5.0) -> dict:
|
||||||
|
tgt = np.nan_to_num(pos5, nan=0.0)
|
||||||
|
held = np.empty(len(tgt)); cur = 0.0; n_tr = 0
|
||||||
|
for i in range(len(tgt)):
|
||||||
|
if abs(tgt[i] - cur) * capital >= min_order:
|
||||||
|
cur = tgt[i]; n_tr += 1
|
||||||
|
held[i] = cur
|
||||||
|
d_real = daily_from_pos5(df5, held)
|
||||||
|
d_mod = daily_from_pos5(df5, tgt)
|
||||||
|
return dict(realistic_sh=round(al._sh(d_real), 3), modeled_sh=round(al._sh(d_mod), 3),
|
||||||
|
haircut=round(al._sh(d_mod) - al._sh(d_real), 3), n_executed=n_tr)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================================
|
||||||
|
# MAIN
|
||||||
|
# ==========================================================================================
|
||||||
|
def main():
|
||||||
|
t0 = time.time()
|
||||||
|
print("=" * 100)
|
||||||
|
print("R0702 EVENT-CLOCK BARS — volume/volbar/range vs wall-clock, selezione in-sample")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
df5 = {a: al.get(a, "5m") for a in ASSETS}
|
||||||
|
for a in ASSETS:
|
||||||
|
print(f"{a} 5m: {len(df5[a])} barre, {df5[a]['datetime'].iloc[0]} -> "
|
||||||
|
f"{df5[a]['datetime'].iloc[-1]}")
|
||||||
|
|
||||||
|
# ---- 1. costruzione barre-evento (cache) --------------------------------------------
|
||||||
|
print("\n--- CALIBRAZIONE CLOCK (barre/giorno; target 4h=6, 12h=2, 24h=1) ---")
|
||||||
|
bars = {}
|
||||||
|
print(f"{'asset':5s} {'tipo':7s} {'dur':>5s} {'n_bars':>7s} {'bars/g':>7s} "
|
||||||
|
f"{'bars/g HOLD':>11s} {'med(h)':>7s} {'p5(h)':>6s}")
|
||||||
|
for a in ASSETS:
|
||||||
|
for k in BAR_TYPES:
|
||||||
|
for dh in DUR_HOURS:
|
||||||
|
eb = EventBars(df5[a], k, dh)
|
||||||
|
bars[(a, k, dh)] = eb
|
||||||
|
print(f"{a:5s} {k:7s} {dh:4.0f}h {len(eb.e):7d} {eb.bars_per_day:7.2f} "
|
||||||
|
f"{eb.bars_per_day_holdout:11.2f} {eb.dur_median_h:7.2f} {eb.dur_p5_h:6.2f}")
|
||||||
|
|
||||||
|
# ---- 2. tutte le celle: event (3 tipi x 3 durate x 5 strategie) + wall (3 tf x 5) ---
|
||||||
|
cells = [] # dict(kind, bar_type, dur_h, strat, daily {asset}, daily5050, met)
|
||||||
|
for sname, fn, params in STRATS:
|
||||||
|
for dh in DUR_HOURS:
|
||||||
|
# wall-clock control
|
||||||
|
dw = {a: daily_wall(a, WALL_TF[dh], fn, params, dh) for a in ASSETS}
|
||||||
|
c5050 = combo5050(dw["BTC"], dw["ETH"])
|
||||||
|
cells.append(dict(kind="wall", bar_type=WALL_TF[dh], dur_h=dh, strat=sname,
|
||||||
|
fn=fn, params=params, daily=dw, d5050=c5050, met=met(c5050)))
|
||||||
|
# event-clock
|
||||||
|
for k in BAR_TYPES:
|
||||||
|
de = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
eb = bars[(a, k, dh)]
|
||||||
|
tgt = strat_target(eb.close, eb.high, eb.low, fn, params, dh)
|
||||||
|
de[a] = daily_from_pos5(df5[a], pos5_from_event(eb.n5, eb.e, tgt))
|
||||||
|
c5050 = combo5050(de["BTC"], de["ETH"])
|
||||||
|
cells.append(dict(kind="event", bar_type=k, dur_h=dh, strat=sname,
|
||||||
|
fn=fn, params=params, daily=de, d5050=c5050, met=met(c5050)))
|
||||||
|
|
||||||
|
print(f"\n--- TUTTE LE CELLE (Sharpe 50/50: IN-SAMPLE pre-2025 | FULL | HOLD 2025-26) ---")
|
||||||
|
print(f"{'clock':6s} {'barre':7s} {'dur':>4s} {'strategia':16s} {'IS':>6s} {'FULL':>6s} {'HOLD':>6s}")
|
||||||
|
for c in sorted(cells, key=lambda x: (x["strat"], x["dur_h"], x["kind"], x["bar_type"])):
|
||||||
|
m = c["met"]
|
||||||
|
print(f"{c['kind']:6s} {c['bar_type']:7s} {c['dur_h']:3.0f}h {c['strat']:16s} "
|
||||||
|
f"{m['is_sh']:6.2f} {m['full_sh']:6.2f} {m['hold_sh']:6.2f}")
|
||||||
|
|
||||||
|
# ---- 3. CONTROLLO DECISIVO: paired event vs wall a parita' di strategia+durata ------
|
||||||
|
print("\n--- PAIRED: event vs wall (Δ Sharpe = event − wall, per cella accoppiata) ---")
|
||||||
|
print(f"{'strategia':16s} {'dur':>4s} {'tipo':7s} {'ΔIS':>7s} {'ΔHOLD':>7s}")
|
||||||
|
n_pairs = n_is_win = n_hold_win = n_both_win = 0
|
||||||
|
for sname, fn, params in STRATS:
|
||||||
|
for dh in DUR_HOURS:
|
||||||
|
w = next(c for c in cells if c["kind"] == "wall" and c["strat"] == sname
|
||||||
|
and c["dur_h"] == dh)
|
||||||
|
for k in BAR_TYPES:
|
||||||
|
e = next(c for c in cells if c["kind"] == "event" and c["strat"] == sname
|
||||||
|
and c["dur_h"] == dh and c["bar_type"] == k)
|
||||||
|
d_is = e["met"]["is_sh"] - w["met"]["is_sh"]
|
||||||
|
d_h = e["met"]["hold_sh"] - w["met"]["hold_sh"]
|
||||||
|
n_pairs += 1
|
||||||
|
n_is_win += d_is > 0
|
||||||
|
n_hold_win += d_h > 0
|
||||||
|
n_both_win += (d_is > 0 and d_h > 0)
|
||||||
|
print(f"{sname:16s} {dh:3.0f}h {k:7s} {d_is:+7.2f} {d_h:+7.2f}")
|
||||||
|
print(f"\nevent batte wall: IS {n_is_win}/{n_pairs}, HOLD {n_hold_win}/{n_pairs}, "
|
||||||
|
f"ENTRAMBI {n_both_win}/{n_pairs}")
|
||||||
|
|
||||||
|
# ---- 4. selezione IN-SAMPLE della cella event migliore ------------------------------
|
||||||
|
ev_cells = [c for c in cells if c["kind"] == "event"]
|
||||||
|
wall_cells = [c for c in cells if c["kind"] == "wall"]
|
||||||
|
chosen = max(ev_cells, key=lambda c: c["met"]["is_sh"])
|
||||||
|
paired = next(c for c in wall_cells if c["strat"] == chosen["strat"]
|
||||||
|
and c["dur_h"] == chosen["dur_h"])
|
||||||
|
best_wall_is = max(wall_cells, key=lambda c: c["met"]["is_sh"])
|
||||||
|
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print(f"CELLA SCELTA (max Sharpe IN-SAMPLE 50/50 tra le {len(ev_cells)} event): "
|
||||||
|
f"{chosen['bar_type']} {chosen['dur_h']:.0f}h {chosen['strat']}")
|
||||||
|
print("=" * 100)
|
||||||
|
for label, d in (("BTC", chosen["daily"]["BTC"]), ("ETH", chosen["daily"]["ETH"]),
|
||||||
|
("50/50", chosen["d5050"])):
|
||||||
|
m = met(d)
|
||||||
|
print(f" {label:6s} FULL Sh {m['full_sh']:+.2f} DD {m['full_dd']*100:5.1f}% "
|
||||||
|
f"CAGR {m['full_cagr']*100:+6.1f}% | HOLD Sh {m['hold_sh']:+.2f} "
|
||||||
|
f"DD {m['hold_dd']*100:5.1f}% CAGR {m['hold_cagr']*100:+6.1f}% | IS {m['is_sh']:+.2f}")
|
||||||
|
print(f"\n paired wall ({paired['bar_type']}, stessa strategia):")
|
||||||
|
for label, d in (("BTC", paired["daily"]["BTC"]), ("ETH", paired["daily"]["ETH"]),
|
||||||
|
("50/50", paired["d5050"])):
|
||||||
|
m = met(d)
|
||||||
|
print(f" {label:6s} FULL Sh {m['full_sh']:+.2f} DD {m['full_dd']*100:5.1f}% "
|
||||||
|
f"CAGR {m['full_cagr']*100:+6.1f}% | HOLD Sh {m['hold_sh']:+.2f} "
|
||||||
|
f"DD {m['hold_dd']*100:5.1f}% CAGR {m['hold_cagr']*100:+6.1f}% | IS {m['is_sh']:+.2f}")
|
||||||
|
bw = best_wall_is["met"]
|
||||||
|
print(f"\n best WALL in-sample: {best_wall_is['bar_type']} {best_wall_is['strat']} "
|
||||||
|
f"IS {bw['is_sh']:+.2f} FULL {bw['full_sh']:+.2f} HOLD {bw['hold_sh']:+.2f}")
|
||||||
|
|
||||||
|
print("\n per-anno 50/50 cella scelta (ret, maxDD):")
|
||||||
|
for y, (r, dd) in yearly(chosen["d5050"]).items():
|
||||||
|
print(f" {y}: {r*100:+6.1f}% dd {dd*100:5.1f}%")
|
||||||
|
|
||||||
|
# decisive control per-asset
|
||||||
|
print("\n CONTROLLO DECISIVO per-asset (event − wall):")
|
||||||
|
dec_ok = True
|
||||||
|
for a in ASSETS:
|
||||||
|
me, mw = met(chosen["daily"][a]), met(paired["daily"][a])
|
||||||
|
d_is, d_h = me["is_sh"] - mw["is_sh"], me["hold_sh"] - mw["hold_sh"]
|
||||||
|
dec_ok = dec_ok and (d_is > 0 and d_h > 0)
|
||||||
|
print(f" {a}: ΔIS {d_is:+.2f} ΔHOLD {d_h:+.2f}")
|
||||||
|
print(f" event batte wall IS E HOLD su entrambi gli asset: {dec_ok}")
|
||||||
|
|
||||||
|
# ---- 5. fee sweep sulla cella scelta -------------------------------------------------
|
||||||
|
print("\n FEE SWEEP (Sharpe FULL 50/50):")
|
||||||
|
fee_sh = {}
|
||||||
|
for f in al.FEE_SWEEP:
|
||||||
|
dd_ = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
eb = bars[(a, chosen["bar_type"], chosen["dur_h"])]
|
||||||
|
tgt = strat_target(eb.close, eb.high, eb.low, chosen["fn"], chosen["params"],
|
||||||
|
chosen["dur_h"])
|
||||||
|
dd_[a] = daily_from_pos5(df5[a], pos5_from_event(eb.n5, eb.e, tgt), fee_side=f)
|
||||||
|
fee_sh[f] = round(al._sh(combo5050(dd_["BTC"], dd_["ETH"])), 3)
|
||||||
|
print(f" {2*f*100:.2f}%RT: {fee_sh[f]:+.2f}")
|
||||||
|
fee_ok = fee_sh[0.0015] > 0
|
||||||
|
|
||||||
|
# ---- 6. deflated Sharpe su TUTTI i trial ---------------------------------------------
|
||||||
|
all_sr = [c["met"]["full_sh"] for c in cells]
|
||||||
|
dsr, sr0 = al.deflated_sharpe(al._sh(chosen["d5050"]), all_sr, chosen["d5050"].values)
|
||||||
|
print(f"\n DEFLATED SHARPE: DSR={dsr:.3f} (soglia 0.95) | expected null max Sh {sr0:.2f} "
|
||||||
|
f"| trial totali {len(all_sr)} (event {len(ev_cells)} + wall {len(wall_cells)})")
|
||||||
|
|
||||||
|
# ---- 7. marginal vs TP01 -------------------------------------------------------------
|
||||||
|
print("\n MARGINAL vs TP01 (cella scelta in-sample):")
|
||||||
|
marg = al.marginal_vs_tp01(chosen["d5050"])
|
||||||
|
for kk in ("marginal_verdict", "corr_full", "corr_hold", "cand_insample_sharpe",
|
||||||
|
"has_insample_edge", "is_hedge", "robust_oos", "multicut_uplift",
|
||||||
|
"multicut_persistent", "clean_year_uplift", "jackknife_min_uplift",
|
||||||
|
"beta_to_tp01", "resid_sharpe_full", "hedge_yearly_corr",
|
||||||
|
"uplift_tp01_up", "uplift_tp01_down"):
|
||||||
|
print(f" {kk}: {marg.get(kk)}")
|
||||||
|
for w, b in marg.get("blends", {}).items():
|
||||||
|
print(f" blend {w}: full {b['full']} (uplift {b['uplift_full']:+.3f}) "
|
||||||
|
f"hold {b['hold']} (uplift {b['uplift_hold']:+.3f})")
|
||||||
|
earns = (marg.get("marginal_verdict") == "ADDS" and marg.get("robust_oos", False)
|
||||||
|
and marg.get("has_insample_edge", False) and not marg.get("is_hedge", False))
|
||||||
|
dsr_pass = np.isfinite(dsr) and dsr >= 0.95
|
||||||
|
print(f" earns_slot(marginale)={earns} dsr_pass={dsr_pass} "
|
||||||
|
f"earns_slot_honest={earns and dsr_pass and fee_ok}")
|
||||||
|
|
||||||
|
# ---- 8. causalita' + executability ----------------------------------------------------
|
||||||
|
print("\n GUARDIA CAUSALITA' (prefisso 80%/92%, entrambi gli asset):")
|
||||||
|
for a in ASSETS:
|
||||||
|
cz = causality_prefix_check(a, chosen["bar_type"], chosen["dur_h"],
|
||||||
|
chosen["fn"], chosen["params"])
|
||||||
|
print(f" {a}: ok={cz['ok']} max_diff={cz['max_diff']:.2e} checked={cz['checked']}")
|
||||||
|
|
||||||
|
print("\n EXECUTABILITY:")
|
||||||
|
for a in ASSETS:
|
||||||
|
eb = bars[(a, chosen["bar_type"], chosen["dur_h"])]
|
||||||
|
tgt = strat_target(eb.close, eb.high, eb.low, chosen["fn"], chosen["params"],
|
||||||
|
chosen["dur_h"])
|
||||||
|
sc = smallcap_event(df5[a], pos5_from_event(eb.n5, eb.e, tgt))
|
||||||
|
print(f" {a}: {eb.bars_per_day:.2f} barre/g (hold-out {eb.bars_per_day_holdout:.2f}), "
|
||||||
|
f"durata mediana {eb.dur_median_h:.1f}h p5 {eb.dur_p5_h:.1f}h | "
|
||||||
|
f"smallcap $600: modeled {sc['modeled_sh']:+.2f} realistic {sc['realistic_sh']:+.2f} "
|
||||||
|
f"haircut {sc['haircut']:+.3f} ({sc['n_executed']} trade)")
|
||||||
|
|
||||||
|
print(f"\n[done in {time.time()-t0:.0f}s]")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""r0702_expiry_calendar.py — FILONE: effetti del calendario SCADENZE Deribit (2026-07-02).
|
||||||
|
|
||||||
|
Deribit: opzioni settimanali scadono ogni VENERDI' 08:00 UTC; mensili l'ultimo venerdi'
|
||||||
|
del mese 08:00 UTC; trimestrali l'ultimo venerdi' di mar/giu/set/dic. Ipotesi: pinning /
|
||||||
|
compressione pre-expiry, drift post-expiry (rimozione hedging dealer), pattern di vol.
|
||||||
|
|
||||||
|
=== GRIGLIA DICHIARATA PRIMA DI GUARDARE I DATI (nessun cherry-picking a posteriori) ===
|
||||||
|
Finestre evento (24h, allineate alla griglia giornaliera ancorata alle 08:00 UTC):
|
||||||
|
W-2 = [-48h,-24h) W-1 = [-24h,0) W0 = [0,+24h) W+1 = [+24h,+48h)
|
||||||
|
Tipi expiry: WEEKLY (ogni venerdi' 08:00), MONTHLY (ultimo venerdi' del mese),
|
||||||
|
QUARTERLY (ultimo venerdi' di mar/giu/set/dic). NB: MONTHLY subset di WEEKLY,
|
||||||
|
QUARTERLY subset di MONTHLY (nesting dichiarato).
|
||||||
|
Asset: BTC, ETH. => 24 celle base (3 tipi x 4 finestre x 2 asset).
|
||||||
|
Multiple testing: Bonferroni a 24 celle, alpha 5% due code -> |t| >= 3.09.
|
||||||
|
|
||||||
|
CONFOUND STRUTTURALE dichiarato: il WEEKLY e' osservazionalmente IDENTICO al
|
||||||
|
day-of-week venerdi' (ogni venerdi' e' un expiry: non esistono venerdi' di controllo)
|
||||||
|
e SEA02 (day-of-week) e' gia' morto. Quindi:
|
||||||
|
- CONTRASTI CHIAVE separabili: MONTHLY vs ALTRI venerdi' (controlla il day-of-week),
|
||||||
|
QUARTERLY vs ALTRE monthly. Sono questi i test che possono dare un PASS.
|
||||||
|
NULL (tutti e tre, un effetto vero li passa tutti):
|
||||||
|
(a) PLACEBO WEEKDAY: ancora lun/mar/mer/gio 08:00 (weekly) e last-lun..last-gio
|
||||||
|
del mese (monthly): il venerdi'/ultimo-venerdi' deve essere speciale.
|
||||||
|
(b) ANCHOR-SHIFT: ancora a 08:00 +/-2h/+/-4h (04/06/08/10/12): un evento reale
|
||||||
|
degrada gradualmente, un artefatto di etichettatura si inverte.
|
||||||
|
(c) PERMUTATION: 500 calendari con stesso n eventi/anno.
|
||||||
|
perm-A = giorni casuali (qualsiasi weekday); perm-B (piu' affilato) =
|
||||||
|
venerdi' casuali (monthly) / ultimi-venerdi'-del-mese casuali (quarterly).
|
||||||
|
Statistica prima della strategia. Regola tradabile costruita SOLO se una cella passa:
|
||||||
|
|t2|>=3.09 (Bonferroni) AND placebo AND anchor-shift senza inversione AND perm pctl
|
||||||
|
estremo (<=1% o >=99%). La famiglia strategica (3 tipi x 4 finestre x 2 direzioni =
|
||||||
|
24 trial) viene comunque valutata per riportare deflated_sharpe con n trial onesto.
|
||||||
|
|
||||||
|
Vincoli rispettati: nessun .view("int64") su datetimes tz-aware (epoca esplicita in ms
|
||||||
|
via colonna `timestamp` gia' in ms); posizioni causali (target[i] deciso a close[i],
|
||||||
|
tenuto nella barra i+1 — eval_weights shifta); fee 0.10% RT.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from scipy import stats as sps
|
||||||
|
|
||||||
|
import altlib as al
|
||||||
|
|
||||||
|
RNG_SEED = 20260702
|
||||||
|
N_PERM = 500
|
||||||
|
ANCHOR_HOUR = 8
|
||||||
|
OFFSETS = {"[-48,-24)": -2, "[-24,0)": -1, "[0,+24)": 0, "[+24,+48)": +1}
|
||||||
|
N_CELLS = 24 # 3 tipi x 4 finestre x 2 asset — dichiarato
|
||||||
|
BONF_T = float(sps.norm.ppf(1 - 0.025 / N_CELLS)) # ~3.09
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
ETYPES = ("WEEKLY", "MONTHLY", "QUARTERLY")
|
||||||
|
MS_H = 3_600_000
|
||||||
|
MS_D = 24 * MS_H
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# CALENDARIO SCADENZE (funzione pura, nessun dato di mercato)
|
||||||
|
# ===========================================================================
|
||||||
|
def _utc_index(values) -> pd.DatetimeIndex:
|
||||||
|
idx = pd.DatetimeIndex(values)
|
||||||
|
return idx.tz_localize("UTC") if idx.tz is None else idx.tz_convert("UTC")
|
||||||
|
|
||||||
|
|
||||||
|
def expiry_calendar(start: str, end: str, anchor_hour: int = ANCHOR_HOUR) -> dict:
|
||||||
|
"""Ancore expiry Deribit (tz UTC, ore = anchor_hour). Ritorna dict tipo->DatetimeIndex."""
|
||||||
|
days = pd.date_range(start, end, freq="D", tz="UTC")
|
||||||
|
fri = days[days.weekday == 4]
|
||||||
|
weekly = pd.DatetimeIndex(fri) + pd.Timedelta(hours=anchor_hour)
|
||||||
|
ym = np.asarray(fri.year * 100 + fri.month)
|
||||||
|
per = pd.Series(fri).groupby(ym).max()
|
||||||
|
monthly = _utc_index(per.to_numpy()) + pd.Timedelta(hours=anchor_hour)
|
||||||
|
quarterly = monthly[monthly.month.isin([3, 6, 9, 12])]
|
||||||
|
return {"WEEKLY": weekly, "MONTHLY": monthly, "QUARTERLY": quarterly}
|
||||||
|
|
||||||
|
|
||||||
|
def placebo_calendar(start: str, end: str, weekday: int, anchor_hour: int = ANCHOR_HOUR) -> dict:
|
||||||
|
"""Placebo: stesso costrutto ancorato a un ALTRO giorno della settimana."""
|
||||||
|
days = pd.date_range(start, end, freq="D", tz="UTC")
|
||||||
|
wd = days[days.weekday == weekday]
|
||||||
|
weekly = pd.DatetimeIndex(wd) + pd.Timedelta(hours=anchor_hour)
|
||||||
|
ym = np.asarray(wd.year * 100 + wd.month)
|
||||||
|
per = pd.Series(wd).groupby(ym).max()
|
||||||
|
monthly = _utc_index(per.to_numpy()) + pd.Timedelta(hours=anchor_hour)
|
||||||
|
return {"WEEKLY": weekly, "MONTHLY": monthly}
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# GRIGLIA GIORNALIERA ancorata (ritorno log 24h + RV) — tutta epoca ms esplicita
|
||||||
|
# ===========================================================================
|
||||||
|
def day_table(asset: str, anchor_hour: int = ANCHOR_HOUR) -> pd.DataFrame:
|
||||||
|
"""Partiziona le barre 1h in 'giorni' [anchor, anchor+24h). Ritorna per giorno:
|
||||||
|
ret (somma log-ret orari = log-ret close->close della finestra), rv (std oraria
|
||||||
|
annualizzata), n barre. r[i] copre ~[open_i, open_i+1h) => giorno = open in finestra."""
|
||||||
|
df = al.get(asset, "1h")
|
||||||
|
ts = df["timestamp"].to_numpy(dtype=np.int64) # epoca ms esplicita
|
||||||
|
c = df["close"].to_numpy(dtype=float)
|
||||||
|
lr = np.zeros(len(c))
|
||||||
|
lr[1:] = np.log(c[1:] / c[:-1])
|
||||||
|
day_ms = ((ts - anchor_hour * MS_H) // MS_D) * MS_D + anchor_hour * MS_H
|
||||||
|
g = pd.DataFrame({"day_ms": day_ms, "lr": lr})
|
||||||
|
agg = g.groupby("day_ms")["lr"].agg(ret="sum", rv="std", n="size")
|
||||||
|
agg = agg[agg["n"] >= 20] # solo giorni ~completi
|
||||||
|
agg["rv"] = agg["rv"] * np.sqrt(24 * 365.25) # RV annualizzata
|
||||||
|
agg.index = pd.to_datetime(agg.index, unit="ms", utc=True)
|
||||||
|
return agg
|
||||||
|
|
||||||
|
|
||||||
|
_EPOCH = pd.Timestamp("1970-01-01", tz="UTC")
|
||||||
|
|
||||||
|
|
||||||
|
def anchors_ms(anchors: pd.DatetimeIndex) -> np.ndarray:
|
||||||
|
"""Epoca ms ESPLICITA e unit-safe: in pandas 2.x un DatetimeIndex tz-aware puo'
|
||||||
|
essere in unita' s/ms/ns (.asi8 cambia scala!) — la delta da EPOCH no."""
|
||||||
|
delta = pd.DatetimeIndex(anchors) - _EPOCH
|
||||||
|
return np.asarray(delta // pd.Timedelta(milliseconds=1), dtype=np.int64)
|
||||||
|
|
||||||
|
|
||||||
|
def window_days(agg: pd.DataFrame, anchors: pd.DatetimeIndex, offset: int) -> pd.DataFrame:
|
||||||
|
"""I giorni-griglia che iniziano a (anchor + offset*24h) e cadono nel campione."""
|
||||||
|
starts = pd.DatetimeIndex(pd.to_datetime(
|
||||||
|
anchors_ms(anchors) + offset * MS_D, unit="ms", utc=True))
|
||||||
|
return agg.loc[agg.index.isin(starts)]
|
||||||
|
|
||||||
|
|
||||||
|
def cell_stats(agg: pd.DataFrame, anchors: pd.DatetimeIndex, offset: int) -> dict:
|
||||||
|
ev = window_days(agg, anchors, offset)
|
||||||
|
base = agg.loc[~agg.index.isin(ev.index)]
|
||||||
|
r = ev["ret"].to_numpy()
|
||||||
|
if len(r) < 8:
|
||||||
|
return dict(n=len(r))
|
||||||
|
mean, med = float(r.mean()), float(np.median(r))
|
||||||
|
sem = float(r.std(ddof=1) / np.sqrt(len(r)))
|
||||||
|
t1 = mean / sem if sem > 0 else 0.0 # vs zero
|
||||||
|
t2, p2 = sps.ttest_ind(r, base["ret"].to_numpy(), equal_var=False) # vs tutte le altre finestre
|
||||||
|
rv_ev, rv_b = float(ev["rv"].mean()), float(base["rv"].mean())
|
||||||
|
trv, prv = sps.ttest_ind(ev["rv"].dropna(), base["rv"].dropna(), equal_var=False)
|
||||||
|
ci = 1.96 * sem
|
||||||
|
return dict(n=len(r), mean=mean, median=med, ci95=ci, t1=float(t1),
|
||||||
|
t2=float(t2), p2=float(p2), base_mean=float(base["ret"].mean()),
|
||||||
|
rv_ev=rv_ev, rv_base=rv_b, rv_ratio=rv_ev / rv_b if rv_b > 0 else np.nan,
|
||||||
|
t_rv=float(trv))
|
||||||
|
|
||||||
|
|
||||||
|
def contrast_stats(agg: pd.DataFrame, a1: pd.DatetimeIndex, a2: pd.DatetimeIndex,
|
||||||
|
offset: int) -> dict:
|
||||||
|
"""Welch t fra finestre-evento di due calendari (es. MONTHLY vs altri venerdi')."""
|
||||||
|
e1 = window_days(agg, a1, offset)["ret"].to_numpy()
|
||||||
|
e2 = window_days(agg, a2, offset)["ret"].to_numpy()
|
||||||
|
if len(e1) < 8 or len(e2) < 8:
|
||||||
|
return dict(n1=len(e1), n2=len(e2))
|
||||||
|
t, p = sps.ttest_ind(e1, e2, equal_var=False)
|
||||||
|
return dict(n1=len(e1), n2=len(e2), m1=float(e1.mean()), m2=float(e2.mean()),
|
||||||
|
diff=float(e1.mean() - e2.mean()), t=float(t), p=float(p))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# PERMUTATION NULL — 500 calendari, stesso n eventi/anno
|
||||||
|
# ===========================================================================
|
||||||
|
def permutation_null(agg: pd.DataFrame, anchors: pd.DatetimeIndex, offsets: dict,
|
||||||
|
pool: pd.DatetimeIndex, n_perm: int = N_PERM,
|
||||||
|
seed: int = RNG_SEED) -> dict:
|
||||||
|
"""Percentile della media reale per finestra vs n_perm calendari casuali estratti da
|
||||||
|
`pool` (stesso numero di ancore per anno del calendario reale, senza rimpiazzo)."""
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
full_idx = pd.date_range(agg.index.min(), agg.index.max(), freq="24h")
|
||||||
|
ret_full = agg["ret"].reindex(full_idx).to_numpy() # NaN dove giorno mancante
|
||||||
|
t0 = int(anchors_ms(full_idx)[0])
|
||||||
|
|
||||||
|
def pos_of(idx: pd.DatetimeIndex) -> np.ndarray:
|
||||||
|
return ((anchors_ms(idx) - t0) // MS_D).astype(np.int64)
|
||||||
|
|
||||||
|
n_days = len(full_idx)
|
||||||
|
a_pos = pos_of(anchors)
|
||||||
|
a_pos = a_pos[(a_pos >= 2) & (a_pos < n_days - 2)]
|
||||||
|
pool_pos = pos_of(pool)
|
||||||
|
pool_pos = pool_pos[(pool_pos >= 2) & (pool_pos < n_days - 2)]
|
||||||
|
years_a = pd.to_datetime((a_pos * MS_D + t0), unit="ms", utc=True).year
|
||||||
|
years_p = pd.to_datetime((pool_pos * MS_D + t0), unit="ms", utc=True).year
|
||||||
|
per_year = pd.Series(a_pos).groupby(np.asarray(years_a)).size()
|
||||||
|
pool_by_year = {y: pool_pos[years_p == y] for y in per_year.index}
|
||||||
|
|
||||||
|
def win_means(pos: np.ndarray) -> dict:
|
||||||
|
out = {}
|
||||||
|
for wname, off in offsets.items():
|
||||||
|
v = ret_full[pos + off]
|
||||||
|
out[wname] = float(np.nanmean(v))
|
||||||
|
return out
|
||||||
|
|
||||||
|
real = win_means(a_pos)
|
||||||
|
null = {w: np.empty(n_perm) for w in offsets}
|
||||||
|
for k in range(n_perm):
|
||||||
|
draw = []
|
||||||
|
for y, cnt in per_year.items():
|
||||||
|
p = pool_by_year.get(y, np.array([], dtype=np.int64))
|
||||||
|
if len(p) == 0:
|
||||||
|
continue
|
||||||
|
take = min(cnt, len(p))
|
||||||
|
draw.append(rng.choice(p, size=take, replace=False))
|
||||||
|
pos = np.concatenate(draw) if draw else np.array([], dtype=np.int64)
|
||||||
|
wm = win_means(pos)
|
||||||
|
for w in offsets:
|
||||||
|
null[w][k] = wm[w]
|
||||||
|
return {w: dict(real=real[w],
|
||||||
|
pctl=float(np.mean(null[w] <= real[w])),
|
||||||
|
null_mean=float(np.nanmean(null[w])),
|
||||||
|
null_sd=float(np.nanstd(null[w])))
|
||||||
|
for w in offsets}
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# STRATEGIA (famiglia dichiarata: 3 tipi x 4 finestre x 2 direzioni = 24 trial)
|
||||||
|
# ===========================================================================
|
||||||
|
def make_expiry_target(anchors: pd.DatetimeIndex, offset: int, direction: float):
|
||||||
|
"""target[i] = direction se la PROSSIMA barra (open+1h) cade nella finestra
|
||||||
|
[anchor+offset*24h, anchor+(offset+1)*24h). Calendario noto ex-ante => causale;
|
||||||
|
eval_weights shifta comunque di +1 barra (decidi a close[i], agisci in i+1)."""
|
||||||
|
ws = np.sort(anchors_ms(anchors) + offset * MS_D) # start finestre, ms
|
||||||
|
|
||||||
|
def target_fn(df: pd.DataFrame) -> np.ndarray:
|
||||||
|
ts = df["timestamp"].to_numpy(dtype=np.int64) + MS_H # open prossima barra
|
||||||
|
j = np.searchsorted(ws, ts, side="right") - 1
|
||||||
|
ok = (j >= 0) & ((ts - ws[np.clip(j, 0, len(ws) - 1)]) < MS_D)
|
||||||
|
return np.where(ok, direction, 0.0)
|
||||||
|
|
||||||
|
return target_fn
|
||||||
|
|
||||||
|
|
||||||
|
def strategy_family(cal: dict) -> list[dict]:
|
||||||
|
fam = []
|
||||||
|
for et in ETYPES:
|
||||||
|
for wname, off in OFFSETS.items():
|
||||||
|
for d in (+1.0, -1.0):
|
||||||
|
fam.append(dict(etype=et, window=wname, offset=off, direction=d,
|
||||||
|
fn=make_expiry_target(cal[et], off, d)))
|
||||||
|
return fam
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# MAIN
|
||||||
|
# ===========================================================================
|
||||||
|
def main() -> None:
|
||||||
|
aggs = {a: day_table(a) for a in ASSETS}
|
||||||
|
spans = {a: (aggs[a].index.min(), aggs[a].index.max()) for a in ASSETS}
|
||||||
|
cal_start = min(s[0] for s in spans.values()) - pd.Timedelta(days=3)
|
||||||
|
cal_end = max(s[1] for s in spans.values()) + pd.Timedelta(days=3)
|
||||||
|
cal = expiry_calendar(str(cal_start.date()), str(cal_end.date()))
|
||||||
|
print(f"Span dati (griglia 08:00): " +
|
||||||
|
"; ".join(f"{a} {spans[a][0].date()}->{spans[a][1].date()} ({len(aggs[a])}g)"
|
||||||
|
for a in ASSETS))
|
||||||
|
print(f"Eventi calendario: " + ", ".join(f"{k}={len(v)}" for k, v in cal.items()))
|
||||||
|
print(f"Soglia Bonferroni (24 celle, 5% due code): |t2| >= {BONF_T:.2f}\n")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (3) statistica
|
||||||
|
print("=" * 100)
|
||||||
|
print("(1) EFFETTI PER FINESTRA x TIPO-EXPIRY x ASSET — ret log 24h; t1 vs 0, t2 vs TUTTE le altre finestre")
|
||||||
|
print("=" * 100)
|
||||||
|
cells = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
for et in ETYPES:
|
||||||
|
for wname, off in OFFSETS.items():
|
||||||
|
st = cell_stats(aggs[a], cal[et], off)
|
||||||
|
cells[(a, et, wname)] = st
|
||||||
|
if st.get("n", 0) >= 8:
|
||||||
|
print(f"{a} {et:9s} {wname:10s} n={st['n']:4d} "
|
||||||
|
f"mean={st['mean']*100:+.3f}%±{st['ci95']*100:.3f} "
|
||||||
|
f"med={st['median']*100:+.3f}% t1={st['t1']:+.2f} t2={st['t2']:+.2f} "
|
||||||
|
f"| RV ev/base={st['rv_ev']:.3f}/{st['rv_base']:.3f} "
|
||||||
|
f"ratio={st['rv_ratio']:.2f} tRV={st['t_rv']:+.2f}")
|
||||||
|
print("-" * 100)
|
||||||
|
|
||||||
|
print("\nPer-anno (mean ret % della finestra; n eventi):")
|
||||||
|
for a in ASSETS:
|
||||||
|
for et in ETYPES:
|
||||||
|
years = sorted(set(aggs[a].index.year))
|
||||||
|
for wname, off in OFFSETS.items():
|
||||||
|
ev = window_days(aggs[a], cal[et], off)
|
||||||
|
parts = []
|
||||||
|
for y in years:
|
||||||
|
r = ev[ev.index.year == y]["ret"]
|
||||||
|
parts.append(f"{y}:{r.mean()*100:+.2f}({len(r)})" if len(r) else f"{y}:--")
|
||||||
|
print(f"{a} {et:9s} {wname:10s} " + " ".join(parts))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# -------------------------------------------------- contrasti chiave (separabili)
|
||||||
|
print("=" * 100)
|
||||||
|
print("CONTRASTI CHIAVE (separano l'expiry dal day-of-week): MONTHLY vs ALTRI venerdi'; QUARTERLY vs ALTRE monthly")
|
||||||
|
print("=" * 100)
|
||||||
|
other_fri = cal["WEEKLY"][~cal["WEEKLY"].isin(cal["MONTHLY"])]
|
||||||
|
other_mon = cal["MONTHLY"][~cal["MONTHLY"].isin(cal["QUARTERLY"])]
|
||||||
|
contrasts = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
for label, (a1, a2) in {"MONTHLY-vs-otherFRI": (cal["MONTHLY"], other_fri),
|
||||||
|
"QUARTERLY-vs-otherMON": (cal["QUARTERLY"], other_mon)}.items():
|
||||||
|
for wname, off in OFFSETS.items():
|
||||||
|
cs = contrast_stats(aggs[a], a1, a2, off)
|
||||||
|
contrasts[(a, label, wname)] = cs
|
||||||
|
if cs.get("n1", 0) >= 8:
|
||||||
|
print(f"{a} {label:22s} {wname:10s} n={cs['n1']}/{cs['n2']} "
|
||||||
|
f"m_ev={cs['m1']*100:+.3f}% m_ctrl={cs['m2']*100:+.3f}% "
|
||||||
|
f"diff={cs['diff']*100:+.3f}% t={cs['t']:+.2f} p={cs['p']:.3f}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (4a) placebo
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("(2a) PLACEBO WEEKDAY — stesso costrutto ancorato a lun/mar/mer/gio (t2 vs base; venerdi' deve spiccare)")
|
||||||
|
print("=" * 100)
|
||||||
|
wd_names = {0: "MON", 1: "TUE", 2: "WED", 3: "THU", 4: "FRI(reale)"}
|
||||||
|
placebo_t2 = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
for et in ("WEEKLY", "MONTHLY"):
|
||||||
|
for wname, off in OFFSETS.items():
|
||||||
|
row = {}
|
||||||
|
for wd in (0, 1, 2, 3):
|
||||||
|
pc = placebo_calendar(str(cal_start.date()), str(cal_end.date()), wd)
|
||||||
|
st = cell_stats(aggs[a], pc[et], off)
|
||||||
|
row[wd_names[wd]] = st.get("t2", np.nan)
|
||||||
|
row[wd_names[4]] = cells[(a, et, wname)].get("t2", np.nan)
|
||||||
|
placebo_t2[(a, et, wname)] = row
|
||||||
|
print(f"{a} {et:8s} {wname:10s} " +
|
||||||
|
" ".join(f"{k}:{v:+.2f}" for k, v in row.items()))
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- (4b) anchor-shift
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("(2b) ANCHOR-SHIFT — media evento (%) con ancora a 04/06/08/10/12 UTC (reale=08). Inversione => artefatto")
|
||||||
|
print("=" * 100)
|
||||||
|
shift_means = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
tabs = {h: day_table(a, anchor_hour=h) for h in (4, 6, 8, 10, 12)}
|
||||||
|
for et in ETYPES:
|
||||||
|
for wname, off in OFFSETS.items():
|
||||||
|
row = {}
|
||||||
|
for h in (4, 6, 8, 10, 12):
|
||||||
|
calh = expiry_calendar(str(cal_start.date()), str(cal_end.date()),
|
||||||
|
anchor_hour=h)
|
||||||
|
st = cell_stats(tabs[h], calh[et], off)
|
||||||
|
row[h] = st.get("mean", np.nan)
|
||||||
|
shift_means[(a, et, wname)] = row
|
||||||
|
base = row[8]
|
||||||
|
vals = [row[h] for h in (4, 6, 10, 12) if np.isfinite(row.get(h, np.nan))]
|
||||||
|
inverts = (np.isfinite(base) and abs(base) > 0 and
|
||||||
|
any(np.sign(v) == -np.sign(base) and abs(v) > 0.5 * abs(base)
|
||||||
|
for v in vals))
|
||||||
|
print(f"{a} {et:9s} {wname:10s} " +
|
||||||
|
" ".join(f"{h:02d}h:{row[h]*100:+.3f}%" for h in (4, 6, 8, 10, 12)) +
|
||||||
|
f" inverts={inverts}")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- (4c) permutation
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print(f"(2c) PERMUTATION NULL — {N_PERM} calendari, stesso n eventi/anno. pctl = quota null <= reale")
|
||||||
|
print(" perm-A: giorni casuali. perm-B (affilato): venerdi' casuali (MONTHLY) / monthly casuali (QUARTERLY)")
|
||||||
|
print("=" * 100)
|
||||||
|
perm = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
idx_all = aggs[a].index
|
||||||
|
pool_any = idx_all
|
||||||
|
pool_fri = idx_all[idx_all.weekday == 4]
|
||||||
|
pool_mon_expiry = idx_all[idx_all.isin(cal["MONTHLY"])]
|
||||||
|
specs = {("WEEKLY", "A"): (cal["WEEKLY"], pool_any),
|
||||||
|
("MONTHLY", "A"): (cal["MONTHLY"], pool_any),
|
||||||
|
("MONTHLY", "B"): (cal["MONTHLY"], pool_fri),
|
||||||
|
("QUARTERLY", "A"): (cal["QUARTERLY"], pool_any),
|
||||||
|
("QUARTERLY", "B"): (cal["QUARTERLY"], pool_mon_expiry)}
|
||||||
|
for (et, mode), (anch, pool) in specs.items():
|
||||||
|
res = permutation_null(aggs[a], anch, OFFSETS, pool)
|
||||||
|
for wname, r in res.items():
|
||||||
|
perm[(a, et, mode, wname)] = r
|
||||||
|
print(f"{a} {et:9s} perm-{mode} " +
|
||||||
|
" ".join(f"{w}:{r['pctl']*100:.1f}%" for w, r in res.items()))
|
||||||
|
|
||||||
|
# ------------------------------------------------------- gate statistico dichiarato
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("GATE (dichiarato in testa): |t2|>=Bonferroni AND placebo AND no-inversione AND perm pctl <=1% o >=99%")
|
||||||
|
print("=" * 100)
|
||||||
|
survivors = []
|
||||||
|
for a in ASSETS:
|
||||||
|
for et in ETYPES:
|
||||||
|
for wname in OFFSETS:
|
||||||
|
st = cells[(a, et, wname)]
|
||||||
|
if st.get("n", 0) < 8:
|
||||||
|
continue
|
||||||
|
t2 = st["t2"]
|
||||||
|
bonf_ok = abs(t2) >= BONF_T
|
||||||
|
pt = placebo_t2.get((a, et, wname))
|
||||||
|
placebo_ok = (pt is None or
|
||||||
|
abs(pt["FRI(reale)"]) > max(abs(pt[k]) for k in
|
||||||
|
("MON", "TUE", "WED", "THU")))
|
||||||
|
row = shift_means[(a, et, wname)]
|
||||||
|
base = row[8]
|
||||||
|
shift_ok = not (np.isfinite(base) and any(
|
||||||
|
np.isfinite(row[h]) and np.sign(row[h]) == -np.sign(base)
|
||||||
|
and abs(row[h]) > 0.5 * abs(base) for h in (4, 6, 10, 12)))
|
||||||
|
pA = perm.get((a, et, "A", wname), {}).get("pctl", 0.5)
|
||||||
|
pB = perm.get((a, et, "B", wname), {}).get("pctl", pA)
|
||||||
|
perm_ok = all(p <= 0.01 or p >= 0.99 for p in (pA, pB))
|
||||||
|
ok = bonf_ok and placebo_ok and shift_ok and perm_ok
|
||||||
|
flag = " <== SURVIVES" if ok else ""
|
||||||
|
print(f"{a} {et:9s} {wname:10s} t2={t2:+.2f} bonf={bonf_ok} "
|
||||||
|
f"placebo={placebo_ok} shift_ok={shift_ok} "
|
||||||
|
f"permA={pA:.3f} permB={pB:.3f} perm_ok={perm_ok}{flag}")
|
||||||
|
if ok:
|
||||||
|
survivors.append((a, et, wname, st))
|
||||||
|
|
||||||
|
# ---------------------------------------------- (5) famiglia strategica + DSR onesto
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("(3) FAMIGLIA STRATEGICA (24 trial dichiarati: 3 tipi x 4 finestre x 2 dir) — Sharpe 50/50 netto fee")
|
||||||
|
print(" Riportata SEMPRE per il conteggio trial/DSR; study_marginal SOLO se la statistica sopravvive.")
|
||||||
|
print("=" * 100)
|
||||||
|
fam = strategy_family(cal)
|
||||||
|
rows = []
|
||||||
|
for f in fam:
|
||||||
|
daily = al.candidate_daily(f["fn"], tf="1h")
|
||||||
|
ins = daily[daily.index < al.HOLDOUT]
|
||||||
|
rows.append(dict(etype=f["etype"], window=f["window"], direction=f["direction"],
|
||||||
|
fn=f["fn"], daily=daily,
|
||||||
|
full_sh=al._sh(daily), ins_sh=al._sh(ins),
|
||||||
|
hold_sh=al._sh(daily[daily.index >= al.HOLDOUT])))
|
||||||
|
rows.sort(key=lambda r: r["ins_sh"], reverse=True)
|
||||||
|
for r in rows:
|
||||||
|
print(f"{r['etype']:9s} {r['window']:10s} dir={r['direction']:+.0f} "
|
||||||
|
f"insample={r['ins_sh']:+.2f} full={r['full_sh']:+.2f} hold={r['hold_sh']:+.2f}")
|
||||||
|
all_full = [r["full_sh"] for r in rows]
|
||||||
|
best = rows[0] # scelto IN-SAMPLE-ONLY (no hold-out)
|
||||||
|
dsr, sr0 = al.deflated_sharpe(best["full_sh"], all_full, best["daily"])
|
||||||
|
print(f"\nCella best IN-SAMPLE: {best['etype']} {best['window']} dir={best['direction']:+.0f} "
|
||||||
|
f"(ins {best['ins_sh']:+.2f}, full {best['full_sh']:+.2f}, hold {best['hold_sh']:+.2f})")
|
||||||
|
print(f"deflated_sharpe (N={len(all_full)} trial): DSR={dsr:.3f} "
|
||||||
|
f"(null max atteso ~{sr0:.2f} ann.) PASS>=0.95: {dsr >= 0.95}")
|
||||||
|
|
||||||
|
if survivors:
|
||||||
|
print("\nStatistica SOPRAVVISSUTA -> study_marginal sulla regola piu' semplice della cella best in-sample:")
|
||||||
|
rep = al.study_marginal(
|
||||||
|
f"EXPIRY-{best['etype']}-{best['window']}-d{best['direction']:+.0f}",
|
||||||
|
best["fn"], tf="1h")
|
||||||
|
print(al.fmt_marginal(rep))
|
||||||
|
yr = al.eval_weights(al.get("BTC", "1h"),
|
||||||
|
best["fn"](al.get("BTC", "1h")))["yearly"]
|
||||||
|
print("Per-anno BTC:", {y: v["ret"] for y, v in yr.items()})
|
||||||
|
else:
|
||||||
|
print("\nNESSUNA cella sopravvive al gate statistico -> NESSUNA regola tradabile costruita (per protocollo).")
|
||||||
|
|
||||||
|
print(f"\nSopravvissuti al gate statistico: {len(survivors)}/24 celle")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""r0702_regime_speed — VELOCITÀ DEL TREND CONDIZIONATA DAL REGIME DI VOL (2026-07-02).
|
||||||
|
|
||||||
|
DOMANDA: TP01 media TRE orizzonti TSMOM (30/90/180g) a PESI FISSI. Condizionare i PESI
|
||||||
|
TRA GLI ORIZZONTI (la velocità del segnale, NON la leva — l'overlay DVOL sul vol-target è
|
||||||
|
già stato scartato il 2026-06-26) al regime di volatilità migliora il fixed-blend canonico?
|
||||||
|
Ipotesi A: alta vol → trend più veloci → più peso all'orizzonte corto (hv_fast).
|
||||||
|
Ipotesi B: il contrario (hv_slow).
|
||||||
|
|
||||||
|
METODO (onesto):
|
||||||
|
* TSMOM per orizzonte separato, long-flat, vol-target 20% / cap 2x come il canonico.
|
||||||
|
Sanity: pesi fissi 1/3-1/3-1/3 deve riprodurre il baseline TP01 (stesso code-path).
|
||||||
|
* REGIME = percentile ESPANDENTE CAUSALE (rank del valore di oggi nella storia fino a
|
||||||
|
oggi inclusa, min 365 osservazioni) di DUE misure: realized vol 30g (storia 2019+) e
|
||||||
|
DVOL Deribit (dal 2021-03, allineato causale via al.dvol / merge_asof backward su
|
||||||
|
epoca ms esplicita). Dove il percentile non è ancora definito → pesi EQUAL (canonico).
|
||||||
|
* FAMIGLIA via al.study_family_honest (selezione IN-SAMPLE + deflated Sharpe automatici):
|
||||||
|
griglia = misura {rv, dvol} × soglia {0.60, 0.75} × mappa {hv_fast, hv_slow} ×
|
||||||
|
blend {hard, linear} = 16 celle (UNA famiglia sola: il DSR conta TUTTI i trial).
|
||||||
|
* ASTICELLA: il candidato è quasi-TP01 (corr ~1) → il criterio NON è earns_slot ma la
|
||||||
|
DOMINANZA del fixed-blend canonico: Sharpe FULL e HOLD >= canonico su BTC, ETH e 50/50,
|
||||||
|
uplift positivo a più date di taglio (2023/2024/2025), DSR >= 0.95.
|
||||||
|
* CONTROLLO NULL: 300 draw di PESI FISSI casuali (Dirichlet) sui 3 orizzonti — il
|
||||||
|
regime-conditioning deve battere il ~p90 del null, altrimenti è rumore di pesatura.
|
||||||
|
* Causalità: percentili espandenti (mai full-sample), eval_weights shifta la posizione,
|
||||||
|
al.causality_ok sulla cella scelta; niente .view("int64") su indici tz-aware.
|
||||||
|
|
||||||
|
Run: uv run python scripts/research/r0702_regime_speed.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import bisect
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
|
||||||
|
HORIZONS_D = (30, 90, 180)
|
||||||
|
FAST_W = np.array([0.65, 0.25, 0.10]) # tilt forte sull'orizzonte corto
|
||||||
|
SLOW_W = np.array([0.10, 0.25, 0.65]) # tilt forte sull'orizzonte lungo
|
||||||
|
EQ_W = np.array([1 / 3, 1 / 3, 1 / 3]) # canonico TP01
|
||||||
|
MIN_REGIME_OBS = 365 # storia minima prima di fidarsi del percentile
|
||||||
|
RAMP = 0.25 # semi-larghezza del blend lineare attorno alla soglia
|
||||||
|
CUTS = ("2023-01-01", "2024-01-01", "2025-01-01")
|
||||||
|
NULL_DRAWS = 300
|
||||||
|
SEED = 20260702
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Blocchi causali
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def horizon_signs(c: np.ndarray, bpd: int) -> np.ndarray:
|
||||||
|
"""S[i, j] = sign(close[i]/close[i-h_j] - 1), NaN dove la storia non basta."""
|
||||||
|
n = len(c)
|
||||||
|
S = np.full((n, len(HORIZONS_D)), np.nan)
|
||||||
|
for j, hd in enumerate(HORIZONS_D):
|
||||||
|
h = hd * bpd
|
||||||
|
if h < n:
|
||||||
|
S[h:, j] = np.sign(c[h:] / c[:-h] - 1.0)
|
||||||
|
return S
|
||||||
|
|
||||||
|
|
||||||
|
def direction_from_weights(S: np.ndarray, W: np.ndarray) -> np.ndarray:
|
||||||
|
"""Direzione long-flat = media pesata dei sign sugli orizzonti VALIDI (pesi
|
||||||
|
rinormalizzati sui validi, come tsmom_blend rinormalizza sul conteggio)."""
|
||||||
|
V = np.isfinite(S)
|
||||||
|
Wv = np.where(V, W, 0.0)
|
||||||
|
norm = Wv.sum(axis=1)
|
||||||
|
num = (np.where(V, S, 0.0) * Wv).sum(axis=1)
|
||||||
|
d = np.where(norm > 0, num / np.where(norm > 0, norm, 1.0), 0.0)
|
||||||
|
return np.clip(d, 0.0, None) # LONG-FLAT come TP01 canonico
|
||||||
|
|
||||||
|
|
||||||
|
def expanding_pctl(v: np.ndarray, min_n: int = MIN_REGIME_OBS) -> np.ndarray:
|
||||||
|
"""Percentile espandente CAUSALE: mid-rank di v[i] nella storia v[<=i] (NaN esclusi).
|
||||||
|
Nessuna statistica full-sample; identico ricomputato su qualunque prefisso."""
|
||||||
|
v = np.asarray(v, float)
|
||||||
|
out = np.full(len(v), np.nan)
|
||||||
|
hist: list[float] = []
|
||||||
|
for i in range(len(v)):
|
||||||
|
x = v[i]
|
||||||
|
if not np.isfinite(x):
|
||||||
|
continue
|
||||||
|
bisect.insort(hist, x)
|
||||||
|
if len(hist) >= min_n:
|
||||||
|
lo = bisect.bisect_left(hist, x)
|
||||||
|
hi = bisect.bisect_right(hist, x)
|
||||||
|
out[i] = (lo + hi) / 2.0 / len(hist)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def regime_pctl(df: pd.DataFrame, asset: str, measure: str) -> np.ndarray:
|
||||||
|
bpd = al.bars_per_day(df)
|
||||||
|
if measure == "rv":
|
||||||
|
r = al.simple_returns(df["close"].values.astype(float))
|
||||||
|
v = al.realized_vol(r, 30 * bpd, bpd * 365.25)
|
||||||
|
elif measure == "dvol":
|
||||||
|
v = al.dvol(df, asset) # merge_asof backward, epoca ms esplicita
|
||||||
|
else:
|
||||||
|
raise ValueError(measure)
|
||||||
|
return expanding_pctl(v)
|
||||||
|
|
||||||
|
|
||||||
|
def weight_matrix(pct: np.ndarray, thr: float, mapping: str, blend: str) -> np.ndarray:
|
||||||
|
"""Pesi per barra sui 3 orizzonti. lam=1 → peso di regime ALTO, lam=0 → BASSO.
|
||||||
|
hv_fast: alto → FAST_W; hv_slow: alto → SLOW_W. hard = switch alla soglia;
|
||||||
|
linear = rampa lineare col percentile centrata sulla soglia (larghezza 2*RAMP).
|
||||||
|
Dove il percentile non è definito → EQ_W (canonico) — causale e conservativo."""
|
||||||
|
n = len(pct)
|
||||||
|
hi_w, lo_w = (FAST_W, SLOW_W) if mapping == "hv_fast" else (SLOW_W, FAST_W)
|
||||||
|
if blend == "hard":
|
||||||
|
lam = (pct > thr).astype(float)
|
||||||
|
else:
|
||||||
|
lam = np.clip(0.5 + (pct - thr) / (2.0 * RAMP), 0.0, 1.0)
|
||||||
|
W = lam[:, None] * hi_w[None, :] + (1.0 - lam[:, None]) * lo_w[None, :]
|
||||||
|
bad = ~np.isfinite(pct)
|
||||||
|
W[bad] = EQ_W
|
||||||
|
return W
|
||||||
|
|
||||||
|
|
||||||
|
def make_target(thr: float, mapping: str, blend: str, measure: str):
|
||||||
|
def target(df: pd.DataFrame, asset: str) -> np.ndarray:
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
bpd = al.bars_per_day(df)
|
||||||
|
S = horizon_signs(c, bpd)
|
||||||
|
W = weight_matrix(regime_pctl(df, asset, measure), thr, mapping, blend)
|
||||||
|
d = direction_from_weights(S, W)
|
||||||
|
return al.vol_target(d, df, 0.20, 30, 2.0)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def fixed_target(weights: np.ndarray):
|
||||||
|
def target(df: pd.DataFrame, asset: str = "") -> np.ndarray:
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
S = horizon_signs(c, al.bars_per_day(df))
|
||||||
|
d = direction_from_weights(S, np.tile(weights, (len(c), 1)))
|
||||||
|
return al.vol_target(d, df, 0.20, 30, 2.0)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def factory(tf: str = "1d", thr: float = 0.6, mapping: str = "hv_fast",
|
||||||
|
blend: str = "hard", measure: str = "rv"):
|
||||||
|
return make_target(thr, mapping, blend, measure)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Valutazione: per-asset + 50/50 (stessa convenzione di candidate_daily)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def per_asset_series(target_fn) -> dict[str, pd.Series]:
|
||||||
|
out = {}
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
df = al.get(a, "1d")
|
||||||
|
ev = al.eval_weights(df, al._call_target(target_fn, df, a))
|
||||||
|
out[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def combo_5050(series: dict[str, pd.Series]) -> pd.Series:
|
||||||
|
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||||
|
return al._to_daily(0.5 * J[al.CERTIFIED[0]] + 0.5 * J[al.CERTIFIED[1]])
|
||||||
|
|
||||||
|
|
||||||
|
def sh_full_hold(s: pd.Series) -> tuple[float, float]:
|
||||||
|
return al._sh(s), al._sh(s[s.index >= al.HOLDOUT])
|
||||||
|
|
||||||
|
|
||||||
|
def dominance_table(cand: dict[str, pd.Series], ctrl: dict[str, pd.Series]) -> dict:
|
||||||
|
"""Sharpe FULL/HOLD per BTC, ETH, 50/50: candidato vs controllo fixed-blend."""
|
||||||
|
rows = {}
|
||||||
|
for k in ["BTC", "ETH", "5050"]:
|
||||||
|
cs = combo_5050(cand) if k == "5050" else al._to_daily(cand[k])
|
||||||
|
bs = combo_5050(ctrl) if k == "5050" else al._to_daily(ctrl[k])
|
||||||
|
cf, chd = sh_full_hold(cs)
|
||||||
|
bf, bh = sh_full_hold(bs)
|
||||||
|
rows[k] = dict(cand_full=round(cf, 3), ctrl_full=round(bf, 3), d_full=round(cf - bf, 3),
|
||||||
|
cand_hold=round(chd, 3), ctrl_hold=round(bh, 3), d_hold=round(chd - bh, 3))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def multicut(cand_5050: pd.Series, ctrl_5050: pd.Series) -> dict:
|
||||||
|
out = {}
|
||||||
|
for cut in CUTS:
|
||||||
|
t = pd.Timestamp(cut, tz="UTC")
|
||||||
|
c, b = cand_5050[cand_5050.index >= t], ctrl_5050[ctrl_5050.index >= t]
|
||||||
|
out[cut] = round(al._sh(c) - al._sh(b), 3)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def dd_of(s: pd.Series) -> float:
|
||||||
|
return round(al._dd_ret(s), 4)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# NULL: 300 pesi fissi casuali sui 3 orizzonti (fast path vettoriale)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def null_fixed_weights(n_draws: int = NULL_DRAWS, seed: int = SEED):
|
||||||
|
pre = {}
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
df = al.get(a, "1d")
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
bpd = al.bars_per_day(df)
|
||||||
|
r = al.simple_returns(c)
|
||||||
|
vol = al.realized_vol(r, 30 * bpd, bpd * 365.25)
|
||||||
|
scal = np.where((vol > 0) & np.isfinite(vol), 0.20 / vol, 0.0)
|
||||||
|
idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
|
||||||
|
pre[a] = dict(S=horizon_signs(c, bpd), scal=scal, r=r, idx=idx)
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
draws = rng.dirichlet(np.ones(3), size=n_draws)
|
||||||
|
fulls, holds = [], []
|
||||||
|
for W in draws:
|
||||||
|
nets = {}
|
||||||
|
for a, p in pre.items():
|
||||||
|
d = direction_from_weights(p["S"], np.tile(W, (len(p["r"]), 1)))
|
||||||
|
tgt = np.clip(d * p["scal"], 0.0, 2.0)
|
||||||
|
tgt[~np.isfinite(tgt)] = 0.0
|
||||||
|
pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1]
|
||||||
|
turn = np.abs(np.diff(pos, prepend=0.0))
|
||||||
|
net = pos * p["r"] - al.FEE_SIDE * turn
|
||||||
|
net[0] = 0.0
|
||||||
|
nets[a] = pd.Series(net, index=p["idx"])
|
||||||
|
s = combo_5050(nets)
|
||||||
|
f, h = sh_full_hold(s)
|
||||||
|
fulls.append(f); holds.append(h)
|
||||||
|
return np.array(fulls), np.array(holds), draws
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def main():
|
||||||
|
print("=" * 88)
|
||||||
|
print("r0702 REGIME-SPEED: pesi tra orizzonti TSMOM condizionati al regime di vol")
|
||||||
|
print("=" * 88)
|
||||||
|
|
||||||
|
# ---- 1) SANITY: pesi fissi EQUAL devono riprodurre il baseline TP01 ------------
|
||||||
|
ctrl = per_asset_series(fixed_target(EQ_W))
|
||||||
|
ctrl_5050 = combo_5050(ctrl)
|
||||||
|
base = al.tp01_baseline_daily()
|
||||||
|
J = pd.concat({"mine": ctrl_5050, "tp01": base}, axis=1, join="inner").dropna()
|
||||||
|
mf, mh = sh_full_hold(J["mine"]); bf, bh = sh_full_hold(J["tp01"])
|
||||||
|
max_diff = float(np.max(np.abs(J["mine"].values - J["tp01"].values)))
|
||||||
|
print(f"\n[SANITY] EQ-weight per-orizzonte vs TP01 canonico (50/50 daily):")
|
||||||
|
print(f" mine full {mf:+.3f} hold {mh:+.3f} tp01 full {bf:+.3f} hold {bh:+.3f}"
|
||||||
|
f" max|Δdaily-ret| = {max_diff:.2e}")
|
||||||
|
sanity_ok = abs(mf - bf) < 0.02 and max_diff < 1e-9
|
||||||
|
print(f" sanity_ok = {sanity_ok}")
|
||||||
|
|
||||||
|
# ---- 2) FAMIGLIA ONESTA: 16 celle, selezione in-sample + DSR automatici --------
|
||||||
|
grid = [dict(thr=thr, mapping=m, blend=b, measure=meas)
|
||||||
|
for meas in ("rv", "dvol")
|
||||||
|
for thr in (0.60, 0.75)
|
||||||
|
for m in ("hv_fast", "hv_slow")
|
||||||
|
for b in ("hard", "linear")]
|
||||||
|
print(f"\n[FAMIGLIA] study_family_honest su {len(grid)} celle (1d)...")
|
||||||
|
fam = al.study_family_honest("R0702-REGIME-SPEED", factory, grid, tfs=("1d",))
|
||||||
|
ch = fam["chosen"]
|
||||||
|
print(f" cella scelta IN-SAMPLE: {ch['params']} (IS Sharpe {ch['insample_sharpe']},"
|
||||||
|
f" full {ch['full_sharpe']})")
|
||||||
|
print(f" n_cells={fam['n_cells']} deflated_sharpe={fam['deflated_sharpe']}"
|
||||||
|
f" expected_null_max={fam['expected_null_max']} dsr_pass={fam['dsr_pass']}")
|
||||||
|
print(f" earns_slot_marginal={fam['earns_slot_marginal']} (atteso False: quasi-TP01)"
|
||||||
|
f" verdict marginale={fam['marginal']['marginal_verdict']}")
|
||||||
|
print(" tutte le celle (ordinate per IS Sharpe):")
|
||||||
|
for r in fam["rows"]:
|
||||||
|
print(f" IS {r['insample_sharpe']:+.3f} full {r['full_sharpe']:+.3f} {r['params']}")
|
||||||
|
|
||||||
|
# ---- 3) DOMINANZA della cella scelta vs fixed-blend canonico -------------------
|
||||||
|
chosen_fn = factory(**{"tf": ch["tf"], **ch["params"]})
|
||||||
|
cand = per_asset_series(chosen_fn)
|
||||||
|
cand_5050 = combo_5050(cand)
|
||||||
|
dom = dominance_table(cand, ctrl)
|
||||||
|
print("\n[DOMINANZA] cella scelta vs fixed-blend canonico (Sharpe, netto 0.10% RT):")
|
||||||
|
for k, d in dom.items():
|
||||||
|
print(f" {k:>4s}: FULL {d['cand_full']:+.3f} vs {d['ctrl_full']:+.3f} (Δ{d['d_full']:+.3f})"
|
||||||
|
f" HOLD {d['cand_hold']:+.3f} vs {d['ctrl_hold']:+.3f} (Δ{d['d_hold']:+.3f})")
|
||||||
|
dominates = all(d["d_full"] >= 0 and d["d_hold"] >= 0 for d in dom.values())
|
||||||
|
print(f" DD 50/50: cand {dd_of(cand_5050)*100:.1f}% ctrl {dd_of(ctrl_5050)*100:.1f}%")
|
||||||
|
print(f" dominates_all_6 = {dominates}")
|
||||||
|
|
||||||
|
mc = multicut(cand_5050, ctrl_5050)
|
||||||
|
mc_ok = all(v > 0 for v in mc.values())
|
||||||
|
print(f" multi-cut ΔSharpe (50/50, dal taglio a fine): {mc} all_positive={mc_ok}")
|
||||||
|
|
||||||
|
corr = float(pd.concat({"c": cand_5050, "b": ctrl_5050}, axis=1, join="inner")
|
||||||
|
.dropna().corr().iloc[0, 1])
|
||||||
|
print(f" corr(cand, ctrl) daily = {corr:.4f} (attesa ~1: è un tilt di TP01)")
|
||||||
|
|
||||||
|
# ---- 4) CAUSALITÀ ---------------------------------------------------------------
|
||||||
|
caus = al.causality_ok(chosen_fn, tf="1d")
|
||||||
|
print(f"\n[CAUSALITÀ] causality_ok = {caus['ok']} max_tail_diff={caus['max_tail_diff']}"
|
||||||
|
f" checked={caus['checked']}")
|
||||||
|
|
||||||
|
# ---- 5) NULL: pesi fissi casuali ------------------------------------------------
|
||||||
|
print(f"\n[NULL] {NULL_DRAWS} draw Dirichlet di pesi FISSI sui 3 orizzonti (50/50)...")
|
||||||
|
nf, nh, _ = null_fixed_weights()
|
||||||
|
cf, chd = sh_full_hold(cand_5050)
|
||||||
|
p_full = float(np.mean(nf <= cf)); p_hold = float(np.mean(nh <= chd))
|
||||||
|
print(f" null FULL: mean {nf.mean():+.3f} p90 {np.percentile(nf, 90):+.3f}"
|
||||||
|
f" max {nf.max():+.3f} cella {cf:+.3f} → pctl {p_full:.3f}")
|
||||||
|
print(f" null HOLD: mean {nh.mean():+.3f} p90 {np.percentile(nh, 90):+.3f}"
|
||||||
|
f" max {nh.max():+.3f} cella {chd:+.3f} → pctl {p_hold:.3f}")
|
||||||
|
beats_null = p_full >= 0.90 and p_hold >= 0.90
|
||||||
|
print(f" beats_null_p90 (FULL e HOLD) = {beats_null}")
|
||||||
|
|
||||||
|
# ---- 6) RV vs DVOL come regime ---------------------------------------------------
|
||||||
|
print("\n[RV vs DVOL] migliore cella per misura (full / IS Sharpe):")
|
||||||
|
for meas in ("rv", "dvol"):
|
||||||
|
rows = [r for r in fam["rows"] if r["params"]["measure"] == meas]
|
||||||
|
if rows:
|
||||||
|
b = max(rows, key=lambda r: r["insample_sharpe"])
|
||||||
|
print(f" {meas:>4s}: best-IS {b['insample_sharpe']:+.3f} (full {b['full_sharpe']:+.3f})"
|
||||||
|
f" {b['params']}")
|
||||||
|
|
||||||
|
# ---- 7) VERDETTO ------------------------------------------------------------------
|
||||||
|
crit = dict(sanity_ok=sanity_ok, dominates=dominates, multicut_ok=mc_ok,
|
||||||
|
dsr_pass=bool(fam["dsr_pass"]), beats_null_p90=beats_null,
|
||||||
|
causal=bool(caus["ok"]))
|
||||||
|
n_pass = sum(crit.values())
|
||||||
|
if all(crit.values()):
|
||||||
|
verdict = "PASS"
|
||||||
|
elif crit["sanity_ok"] and crit["causal"] and crit["dominates"] and crit["multicut_ok"]:
|
||||||
|
verdict = "LEAD"
|
||||||
|
else:
|
||||||
|
verdict = "FAIL"
|
||||||
|
print(f"\n[VERDETTO] {verdict} criteri={crit} ({n_pass}/{len(crit)})")
|
||||||
|
return verdict
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""r0702_skeptic_offset.py — VERIFICA AVVERSARIALE INDIPENDENTE di r0702_tp01_offset.py.
|
||||||
|
|
||||||
|
Linee d'attacco (tutte con codice INDIPENDENTE dal finding, cross-check contro le sue funzioni):
|
||||||
|
A. COSTRUZIONE: daily-offset ricostruito via floor-division su epoca ms (niente pandas.resample);
|
||||||
|
h=0 deve == al.get('1d') e == tp01_baseline_daily; mapping daily->1h via searchsorted (niente
|
||||||
|
merge_asof); guardia troncamento del feed 1h (nessun look-ahead a h!=0).
|
||||||
|
B. STATISTICA: block-bootstrap congiunto delle 24 ancore sull'hold-out — lo spike di h=0
|
||||||
|
(Sh(h0) - mediana(altri)) e' speciale o e' il massimo atteso di 24 stime correlate?
|
||||||
|
+ hold-out finti (2020..2024): l'ancora migliore e' stabile o gira a caso?
|
||||||
|
C. TRANCHING: identita' K=4 == EW dei 4 book ancorati (netting non nasconde nulla)?
|
||||||
|
turnover verificato; DD del K=4 vs DD della ROTAZIONE TIPICA (non vs h=0 sfortunato);
|
||||||
|
bootstrap appaiato della differenza IS.
|
||||||
|
D. IMPATTO: blend TP+SKH 75/25 e book 5-sleeve ricalcolati con TP01 alle 24 ancore.
|
||||||
|
|
||||||
|
Nessun file toccato fuori da questo script. Runtime ~3-6 min (SKH/XS/VRP/GTAA inclusi).
|
||||||
|
"""
|
||||||
|
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("/opt/docker/PythagorasGoal")
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
import r0702_tp01_offset as RF # il finding, SOLO per cross-check # noqa: E402
|
||||||
|
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
|
||||||
|
|
||||||
|
TP = TrendPortfolio(**CANONICAL)
|
||||||
|
HOLDOUT = al.HOLDOUT
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
MS_H = 3_600_000
|
||||||
|
MS_D = 86_400_000
|
||||||
|
FEE = al.FEE_SIDE
|
||||||
|
RNG = np.random.default_rng(42)
|
||||||
|
B_BOOT = 4000
|
||||||
|
BLOCK = 20
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# A. COSTRUZIONE INDIPENDENTE
|
||||||
|
# ===========================================================================
|
||||||
|
@lru_cache(maxsize=8)
|
||||||
|
def get1h(asset: str) -> pd.DataFrame:
|
||||||
|
return al.get(asset, "1h")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=64)
|
||||||
|
def sk_daily(asset: str, h: int) -> pd.DataFrame:
|
||||||
|
"""Daily-offset costruito a mano: day_id = (ts - h*1h) // 24h su epoca ms (open-labeled)."""
|
||||||
|
df = get1h(asset)
|
||||||
|
ts = df["timestamp"].values.astype(np.int64)
|
||||||
|
day = (ts - h * MS_H) // MS_D
|
||||||
|
uday, first = np.unique(day, return_index=True)
|
||||||
|
o = df["open"].values.astype(float)
|
||||||
|
hi = df["high"].values.astype(float)
|
||||||
|
lo = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
v = df["volume"].values.astype(float)
|
||||||
|
last = np.r_[first[1:], len(ts)] - 1
|
||||||
|
out = pd.DataFrame(dict(
|
||||||
|
timestamp=uday * MS_D + h * MS_H,
|
||||||
|
open=o[first],
|
||||||
|
high=np.maximum.reduceat(hi, first),
|
||||||
|
low=np.minimum.reduceat(lo, first),
|
||||||
|
close=c[last],
|
||||||
|
volume=np.add.reduceat(v, first),
|
||||||
|
))
|
||||||
|
out["datetime"] = pd.to_datetime(out["timestamp"], unit="ms", utc=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def sk_net_daily(asset: str, h: int) -> pd.Series:
|
||||||
|
"""Rendimenti netti TP01 sul grid daily-offset (pipeline mia: shift+fee espliciti)."""
|
||||||
|
d = sk_daily(asset, h)
|
||||||
|
c = d["close"].values.astype(float)
|
||||||
|
r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0
|
||||||
|
tgt = TP.target_series(d)
|
||||||
|
pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1]
|
||||||
|
net = pos * r - FEE * np.abs(np.diff(pos, prepend=0.0))
|
||||||
|
net[0] = 0.0
|
||||||
|
return pd.Series(net, index=pd.DatetimeIndex(d["datetime"]))
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32)
|
||||||
|
def sk_port_daily(h: int) -> pd.Series:
|
||||||
|
J = pd.concat({a: sk_net_daily(a, h) for a in ASSETS}, axis=1, join="inner").fillna(0.0)
|
||||||
|
return al._to_daily(0.5 * J["BTC"] + 0.5 * J["ETH"])
|
||||||
|
|
||||||
|
|
||||||
|
def sk_pos_hourly(asset: str, hs: tuple, df1h: pd.DataFrame | None = None) -> np.ndarray:
|
||||||
|
"""Posizione TENUTA durante ogni barra 1h (ensemble media delle ancore hs), via searchsorted:
|
||||||
|
pos durante barra i = target dell'ultima barra daily-offset con close nominale <= open(barra i)."""
|
||||||
|
df = get1h(asset) if df1h is None else df1h
|
||||||
|
open_ms = df["timestamp"].values.astype(np.int64)
|
||||||
|
pos = np.zeros(len(open_ms))
|
||||||
|
for h in hs:
|
||||||
|
d = sk_daily(asset, h) if df1h is None else sk_daily_from(df, h)
|
||||||
|
tgt = np.nan_to_num(TP.target_series(d), nan=0.0)
|
||||||
|
close_ms = d["timestamp"].values.astype(np.int64) + MS_D
|
||||||
|
j = np.searchsorted(close_ms, open_ms, side="right") - 1
|
||||||
|
p = np.where(j >= 0, tgt[np.clip(j, 0, None)], 0.0)
|
||||||
|
pos += p / len(hs)
|
||||||
|
return pos
|
||||||
|
|
||||||
|
|
||||||
|
def sk_daily_from(df1h: pd.DataFrame, h: int) -> pd.DataFrame:
|
||||||
|
"""sk_daily ma da un frame 1h arbitrario (per il test di troncamento)."""
|
||||||
|
ts = df1h["timestamp"].values.astype(np.int64)
|
||||||
|
day = (ts - h * MS_H) // MS_D
|
||||||
|
uday, first = np.unique(day, return_index=True)
|
||||||
|
c = df1h["close"].values.astype(float)
|
||||||
|
last = np.r_[first[1:], len(ts)] - 1
|
||||||
|
out = pd.DataFrame(dict(timestamp=uday * MS_D + h * MS_H, close=c[last]))
|
||||||
|
out["datetime"] = pd.to_datetime(out["timestamp"], unit="ms", utc=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def sk_book_hourly(hs: tuple) -> tuple[pd.Series, float, dict]:
|
||||||
|
"""Book 0.5/0.5 sul grid 1h con posizioni ensemble; ritorna (daily, turnover/y, per-asset net)."""
|
||||||
|
nets, turns = {}, 0.0
|
||||||
|
for a in ASSETS:
|
||||||
|
df = get1h(a)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0
|
||||||
|
pos = sk_pos_hourly(a, hs)
|
||||||
|
turn = np.abs(np.diff(pos, prepend=0.0))
|
||||||
|
net = pos * r - FEE * turn
|
||||||
|
net[0] = 0.0
|
||||||
|
idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
|
||||||
|
nets[a] = pd.Series(net, index=idx)
|
||||||
|
yrs = len(net) / (24 * 365.25)
|
||||||
|
turns += 0.5 * turn.sum() / yrs
|
||||||
|
J = pd.concat(nets, axis=1, join="inner").fillna(0.0)
|
||||||
|
return al._to_daily(0.5 * J["BTC"] + 0.5 * J["ETH"]), turns, nets
|
||||||
|
|
||||||
|
|
||||||
|
def sh3(s: pd.Series) -> tuple[float, float, float]:
|
||||||
|
return (al._sh(s), al._sh(s[s.index < HOLDOUT]), al._sh(s[s.index >= HOLDOUT]))
|
||||||
|
|
||||||
|
|
||||||
|
def part_A() -> None:
|
||||||
|
print("=" * 100)
|
||||||
|
print("A. COSTRUZIONE — ricostruzione indipendente (floor-division ms / searchsorted)")
|
||||||
|
print("=" * 100)
|
||||||
|
# A1: daily-offset mio vs al.get('1d') (h=0) e vs il loro resample_offset (h campionati)
|
||||||
|
for a in ASSETS:
|
||||||
|
ref = al.get(a, "1d")
|
||||||
|
mine0 = sk_daily(a, 0)
|
||||||
|
assert len(mine0) == len(ref), f"A1 len mismatch {a}"
|
||||||
|
for col in ("timestamp", "open", "high", "low", "close", "volume"):
|
||||||
|
rtol = 1e-9 if col == "volume" else 0.0 # volume: solo ordine di sommatoria float
|
||||||
|
assert np.allclose(mine0[col].values.astype(float), ref[col].values.astype(float),
|
||||||
|
atol=0, rtol=rtol), f"A1 h=0 mismatch {a}:{col}"
|
||||||
|
for h in (1, 5, 11, 13, 21, 23):
|
||||||
|
theirs = RF.daily_off(a, h)
|
||||||
|
m = sk_daily(a, h)
|
||||||
|
assert len(m) == len(theirs), f"A1 len mismatch {a} h={h}"
|
||||||
|
for col in ("timestamp", "open", "high", "low", "close", "volume"):
|
||||||
|
rtol = 1e-9 if col == "volume" else 0.0
|
||||||
|
assert np.allclose(m[col].values.astype(float),
|
||||||
|
theirs[col].values.astype(float), atol=0, rtol=rtol), \
|
||||||
|
f"A1 h={h} mismatch {a}:{col}"
|
||||||
|
print("[A1] daily-offset: costruzione mia == al.get('1d') (h=0) == loro resample_offset "
|
||||||
|
"(h=1,5,11,13,21,23, tutte le colonne, bit-exact): OK")
|
||||||
|
|
||||||
|
# A2: pipeline completa h=0 vs baseline del progetto
|
||||||
|
mine = sk_port_daily(0)
|
||||||
|
base = al.tp01_baseline_daily()
|
||||||
|
assert len(mine) == len(base) and np.allclose(mine.values, base.values, atol=1e-12), "A2 FAIL"
|
||||||
|
f, i, ho = sh3(mine)
|
||||||
|
print(f"[A2] portafoglio h=0 (pipeline mia) == tp01_baseline_daily: OK "
|
||||||
|
f"(FULL {f:.4f} / IS {i:.4f} / HOLD {ho:.4f})")
|
||||||
|
|
||||||
|
# A3: troncamento del feed 1h -> posizioni orarie IDENTICHE su tutto il range troncato
|
||||||
|
for a in ASSETS:
|
||||||
|
df = get1h(a)
|
||||||
|
for cut in (len(df) - 3000, len(df) - 777):
|
||||||
|
dtr = df.iloc[:cut].reset_index(drop=True)
|
||||||
|
for h in (0, 5, 13, 21):
|
||||||
|
p_full = sk_pos_hourly(a, (h,))
|
||||||
|
p_tr = sk_pos_hourly(a, (h,), df1h=dtr)
|
||||||
|
assert np.allclose(p_full[:cut], p_tr, atol=1e-12), \
|
||||||
|
f"A3 look-ahead {a} h={h} cut={cut}"
|
||||||
|
print("[A3] troncamento 1h (2 cut x 4 ancore x 2 asset): posizioni orarie invariate "
|
||||||
|
"sul prefisso -> nessun look-ahead nel mapping daily->1h: OK")
|
||||||
|
|
||||||
|
# A4: vol-target ricalcolata per-offset? (fatto strutturale + evidenza numerica)
|
||||||
|
for a in ASSETS:
|
||||||
|
for h in (5, 13):
|
||||||
|
assert TP._bpd(sk_daily(a, h)) == 1, "A4 bpd"
|
||||||
|
t0 = TP.target_series(sk_daily(a, 0))
|
||||||
|
t13 = TP.target_series(sk_daily(a, 13))
|
||||||
|
m = min(len(t0), len(t13))
|
||||||
|
d = np.abs(t0[300:m] - t13[300:m])
|
||||||
|
print(f"[A4] {a}: target h=0 vs h=13 stesso giorno-calendario, |diff| media "
|
||||||
|
f"{np.nanmean(d):.4f} (max {np.nanmax(d):.3f}) -> vol e segnale RICALCOLATI "
|
||||||
|
f"sul grid dell'ancora (target_series riceve il grid offset)")
|
||||||
|
|
||||||
|
# A5: cross-check book orario mio vs loro (K=1 h0 e K=4)
|
||||||
|
for name, hs in (("K=1 h0", (0,)), ("K=4", (0, 6, 12, 18))):
|
||||||
|
mine_s, mine_t, _ = sk_book_hourly(hs)
|
||||||
|
theirs_s, theirs_t = RF.port_hourly(hs)
|
||||||
|
common = mine_s.index.intersection(theirs_s.index)
|
||||||
|
dmax = float(np.max(np.abs(mine_s.loc[common].values - theirs_s.loc[common].values)))
|
||||||
|
print(f"[A5] {name}: book 1h mio vs loro — max|diff ret giornaliero| {dmax:.2e}, "
|
||||||
|
f"turn/y {mine_t:.2f} vs {theirs_t:.2f}")
|
||||||
|
assert dmax < 1e-10, f"A5 mismatch {name}"
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# B. STATISTICA — lo spike h=0 e' speciale?
|
||||||
|
# ===========================================================================
|
||||||
|
@lru_cache(maxsize=2)
|
||||||
|
def anchor_matrix() -> pd.DataFrame:
|
||||||
|
cols = {f"h{h:02d}": sk_port_daily(h) for h in range(24)}
|
||||||
|
return pd.concat(cols, axis=1, join="inner").dropna()
|
||||||
|
|
||||||
|
|
||||||
|
def _sh_mat(R: np.ndarray) -> np.ndarray:
|
||||||
|
mu = R.mean(axis=1)
|
||||||
|
sd = R.std(axis=1)
|
||||||
|
return np.where(sd > 0, mu / sd, 0.0) * np.sqrt(365.25)
|
||||||
|
|
||||||
|
|
||||||
|
def block_boot_stats(M: np.ndarray, B: int, block: int, rng) -> dict:
|
||||||
|
n, K = M.shape
|
||||||
|
nblocks = int(np.ceil(n / block))
|
||||||
|
g0s, gmaxs, med_all, sh0s = [], [], [], []
|
||||||
|
done = 0
|
||||||
|
while done < B:
|
||||||
|
b = min(500, B - done)
|
||||||
|
starts = rng.integers(0, n, size=(b, nblocks))
|
||||||
|
idx = (starts[:, :, None] + np.arange(block)[None, None, :]) % n
|
||||||
|
idx = idx.reshape(b, -1)[:, :n]
|
||||||
|
R = M[idx] # (b, n, K)
|
||||||
|
Sh = np.stack([_sh_mat(R[:, :, k]) for k in range(K)], axis=1)
|
||||||
|
med_others = np.empty_like(Sh)
|
||||||
|
for h in range(K):
|
||||||
|
others = np.delete(Sh, h, axis=1)
|
||||||
|
med_others[:, h] = np.median(others, axis=1)
|
||||||
|
g = Sh - med_others
|
||||||
|
g0s.append(g[:, 0])
|
||||||
|
gmaxs.append(g.max(axis=1))
|
||||||
|
med_all.append(np.median(Sh, axis=1))
|
||||||
|
sh0s.append(Sh[:, 0])
|
||||||
|
done += b
|
||||||
|
return dict(g0=np.concatenate(g0s), gmax=np.concatenate(gmaxs),
|
||||||
|
med=np.concatenate(med_all), sh0=np.concatenate(sh0s))
|
||||||
|
|
||||||
|
|
||||||
|
def part_B() -> None:
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("B. STATISTICA — spike h=0 sull'hold-out: speciale o massimo atteso di 24 stime correlate?")
|
||||||
|
print("=" * 100)
|
||||||
|
Mdf = anchor_matrix()
|
||||||
|
Mh = Mdf[Mdf.index >= HOLDOUT].values
|
||||||
|
sh_hold = _sh_mat(Mh.T)
|
||||||
|
med_others_obs = np.median(sh_hold[1:])
|
||||||
|
g0_obs = sh_hold[0] - med_others_obs
|
||||||
|
corr = np.corrcoef(Mh.T)
|
||||||
|
iu = np.triu_indices(24, 1)
|
||||||
|
print(f"hold-out: {Mh.shape[0]} giorni, 24 ancore; Sh h=0 {sh_hold[0]:.3f}, "
|
||||||
|
f"mediana altri {med_others_obs:.3f}, spike osservato g0 = {g0_obs:.3f}")
|
||||||
|
print(f"correlazione daily fra ancore (hold-out): mediana {np.median(corr[iu]):.3f}, "
|
||||||
|
f"min {corr[iu].min():.3f}")
|
||||||
|
|
||||||
|
for blk in (10, 20, 40):
|
||||||
|
bs = block_boot_stats(Mh, B_BOOT, blk, np.random.default_rng(42 + blk))
|
||||||
|
p_any = float(np.mean(bs["gmax"] >= g0_obs))
|
||||||
|
p_g0 = float(np.mean(bs["g0"] <= 0.0))
|
||||||
|
ci_g0 = np.percentile(bs["g0"], [2.5, 97.5])
|
||||||
|
ci_med = np.percentile(bs["med"], [2.5, 97.5])
|
||||||
|
ci_sh0 = np.percentile(bs["sh0"], [2.5, 97.5])
|
||||||
|
print(f" block={blk:>2}: P(max-spike di UNA QUALSIASI ancora >= {g0_obs:.2f}) = "
|
||||||
|
f"{p_any:.3f} | P(g0<=0) = {p_g0:.3f} | CI95 g0 [{ci_g0[0]:+.2f},{ci_g0[1]:+.2f}] "
|
||||||
|
f"| CI95 Sh mediana-ancore [{ci_med[0]:+.2f},{ci_med[1]:+.2f}] "
|
||||||
|
f"| CI95 Sh h=0 [{ci_sh0[0]:+.2f},{ci_sh0[1]:+.2f}]")
|
||||||
|
|
||||||
|
# hold-out finti: l'ancora migliore per finestra e' stabile?
|
||||||
|
print("\n finestre annuali (hold-out finti) — best/worst anchor, h=0, spread:")
|
||||||
|
print(f" {'finestra':<9} {'best':>5} {'ShBest':>7} {'worst':>6} {'ShWorst':>8} "
|
||||||
|
f"{'mediana':>8} {'h=0':>6} {'pctl h0':>8} {'max-med':>8}")
|
||||||
|
years = [2020, 2021, 2022, 2023, 2024]
|
||||||
|
windows: list[tuple[str, pd.DataFrame]] = [
|
||||||
|
(str(y), Mdf[Mdf.index.year == y]) for y in years] + [("2025+", Mdf[Mdf.index >= HOLDOUT])]
|
||||||
|
sh_by_win = {}
|
||||||
|
from scipy.stats import spearmanr
|
||||||
|
for name, W in windows:
|
||||||
|
sh = _sh_mat(W.values.T)
|
||||||
|
sh_by_win[name] = sh
|
||||||
|
pctl0 = float((sh < sh[0]).mean() + 0.5 * (sh == sh[0]).mean()) * 100
|
||||||
|
print(f" {name:<9} {int(np.argmax(sh)):>5} {sh.max():>7.3f} {int(np.argmin(sh)):>6} "
|
||||||
|
f"{sh.min():>8.3f} {np.median(sh):>8.3f} {sh[0]:>6.3f} {pctl0:>7.0f}° "
|
||||||
|
f"{sh.max() - np.median(sh):>8.3f}")
|
||||||
|
names = [n for n, _ in windows]
|
||||||
|
print("\n stabilita' del ranking ancore (Spearman fra finestre consecutive):")
|
||||||
|
for a, b in zip(names[:-1], names[1:]):
|
||||||
|
rho, p = spearmanr(sh_by_win[a], sh_by_win[b])
|
||||||
|
print(f" {a} vs {b}: rho={rho:+.2f} (p={p:.2f})")
|
||||||
|
# l'ancora migliore di ogni finestra, quanto rende NELLE ALTRE finestre? (pctl medio)
|
||||||
|
print(" best-anchor di ogni finestra valutata nelle ALTRE finestre (pctl medio su 24):")
|
||||||
|
for name in names:
|
||||||
|
h_star = int(np.argmax(sh_by_win[name]))
|
||||||
|
pct = [float((sh_by_win[o] < sh_by_win[o][h_star]).mean()) * 100
|
||||||
|
for o in names if o != name]
|
||||||
|
print(f" best({name}) = h={h_star:>2} -> pctl medio altrove {np.mean(pct):.0f}° "
|
||||||
|
f"(per finestra: {', '.join(f'{p:.0f}' for p in pct)})")
|
||||||
|
|
||||||
|
# ritorno totale hold-out per ancora (per la narrativa '+3.5%')
|
||||||
|
tot = np.prod(1 + Mh, axis=0) - 1
|
||||||
|
print(f"\n ritorno TOTALE hold-out per ancora: min {tot.min():+.1%} / mediana "
|
||||||
|
f"{np.median(tot):+.1%} / max {tot.max():+.1%} (h=0: {tot[0]:+.1%})")
|
||||||
|
dd = [al._dd_ret(pd.Series(Mh[:, k])) for k in range(24)]
|
||||||
|
print(f" maxDD hold-out per ancora: min {min(dd):.1%} / mediana {np.median(dd):.1%} / "
|
||||||
|
f"max {max(dd):.1%} (h=0: {dd[0]:.1%}) [B&H 50/50 2025-26: DD ~60%]")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# C. TRANCHING — gratis davvero?
|
||||||
|
# ===========================================================================
|
||||||
|
def part_C() -> None:
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("C. TRANCHING — identita' EW, turnover, DD vs rotazione tipica, significativita' IS")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
# C1: K=4 book == EW dei 4 book ancorati? (identita' esatta, incluse fee)
|
||||||
|
for a in ASSETS:
|
||||||
|
df = get1h(a)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0
|
||||||
|
hs = (0, 6, 12, 18)
|
||||||
|
pos_e = sk_pos_hourly(a, hs)
|
||||||
|
net_e = pos_e * r - FEE * np.abs(np.diff(pos_e, prepend=0.0)); net_e[0] = 0.0
|
||||||
|
nets_1 = []
|
||||||
|
turns_1 = []
|
||||||
|
for h in hs:
|
||||||
|
p = sk_pos_hourly(a, (h,))
|
||||||
|
t = np.abs(np.diff(p, prepend=0.0))
|
||||||
|
n1 = p * r - FEE * t; n1[0] = 0.0
|
||||||
|
nets_1.append(n1)
|
||||||
|
turns_1.append(t.sum())
|
||||||
|
ew = np.mean(nets_1, axis=0)
|
||||||
|
turn_e = np.abs(np.diff(pos_e, prepend=0.0)).sum()
|
||||||
|
print(f"[C1] {a}: max|net K4 - EW(4 book singoli)| = {np.max(np.abs(net_e - ew)):.2e} ; "
|
||||||
|
f"turnover K4 {turn_e:.1f} vs media singoli {np.mean(turns_1):.1f} "
|
||||||
|
f"(rapporto {turn_e / np.mean(turns_1):.4f})")
|
||||||
|
|
||||||
|
# C2: tutte le rotazioni (mie): livelli e dispersione, DD compreso
|
||||||
|
fams = {"singole(24)": [(h,) for h in range(24)],
|
||||||
|
"K=2(12)": [(h, h + 12) for h in range(12)],
|
||||||
|
"K=4(6)": [tuple(h + 6 * j for j in range(4)) for h in range(6)]}
|
||||||
|
stats = {}
|
||||||
|
for fam, rots in fams.items():
|
||||||
|
rec = []
|
||||||
|
for hs in rots:
|
||||||
|
s, t, _ = sk_book_hourly(hs)
|
||||||
|
f, i, ho = sh3(s)
|
||||||
|
rec.append(dict(hs=hs, full=f, is_=i, hold=ho, dd=al._dd_ret(s),
|
||||||
|
dd_h=al._dd_ret(s[s.index >= HOLDOUT]), turn=t))
|
||||||
|
stats[fam] = pd.DataFrame(rec)
|
||||||
|
print("\n[C2] rotazioni complete (book 1h, misura identica per tutte):")
|
||||||
|
print(f" {'famiglia':<12} {'IS med[min,max]':>24} {'HOLD med[min,max]':>26} "
|
||||||
|
f"{'maxDD med[min,max]':>24} {'turn/y med':>10}")
|
||||||
|
for fam, T in stats.items():
|
||||||
|
print(f" {fam:<12} {T.is_.median():>8.3f} [{T.is_.min():.3f},{T.is_.max():.3f}]"
|
||||||
|
f" {T.hold.median():>9.3f} [{T.hold.min():+.3f},{T.hold.max():+.3f}]"
|
||||||
|
f" {T.dd.median():>8.1%} [{T.dd.min():.1%},{T.dd.max():.1%}]"
|
||||||
|
f" {T.turn.median():>8.2f}")
|
||||||
|
s24, _, _ = sk_book_hourly(tuple(range(24)))
|
||||||
|
f24, i24, h24 = sh3(s24)
|
||||||
|
print(f" K=24 IS {i24:.3f} HOLD {h24:+.3f} maxDD {al._dd_ret(s24):.1%}")
|
||||||
|
T1 = stats["singole(24)"]
|
||||||
|
T4 = stats["K=4(6)"]
|
||||||
|
print(f"\n -> claim 'maxDD 14.7->11.9': h=0 singolo DD {T1.dd.iloc[0]:.1%} ma la MEDIANA "
|
||||||
|
f"delle 24 singole e' {T1.dd.median():.1%}; K=4 mediano {T4.dd.median():.1%} "
|
||||||
|
f"=> beneficio del tranching vs ancora TIPICA = {T1.dd.median() - T4.dd.median():+.1%}pt, "
|
||||||
|
f"vs h=0 = {T1.dd.iloc[0] - T4.dd.median():+.1%}pt (in gran parte 'h=0 era sfortunato sul DD')")
|
||||||
|
print(f" -> claim 'IS 1.49->1.54/1.56': mediana IS delle 24 singole = {T1.is_.median():.3f} "
|
||||||
|
f"(K=4 mediano {T4.is_.median():.3f}) => il 'miglioramento' e' tornare alla MEDIA delle "
|
||||||
|
f"ancore, h=0 era al {(T1.is_ < T1.is_.iloc[0]).mean() * 100:.0f}° pctl IS")
|
||||||
|
|
||||||
|
# C3: significativita' IS del K=4 vs h=0 (bootstrap appaiato a blocchi)
|
||||||
|
s0 = sk_book_hourly((0,))[0]
|
||||||
|
s4 = sk_book_hourly((0, 6, 12, 18))[0]
|
||||||
|
common = s0.index.intersection(s4.index)
|
||||||
|
A = s4.loc[common]; Bser = s0.loc[common]
|
||||||
|
mask = common < HOLDOUT
|
||||||
|
Ai, Bi = A[mask].values, Bser[mask].values
|
||||||
|
n = len(Ai)
|
||||||
|
nblocks = int(np.ceil(n / BLOCK))
|
||||||
|
d_obs = al._sh(A[mask]) - al._sh(Bser[mask])
|
||||||
|
ds = []
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
for _ in range(B_BOOT // 500):
|
||||||
|
starts = rng.integers(0, n, size=(500, nblocks))
|
||||||
|
idx = (starts[:, :, None] + np.arange(BLOCK)[None, None, :]) % n
|
||||||
|
idx = idx.reshape(500, -1)[:, :n]
|
||||||
|
Ra, Rb = Ai[idx], Bi[idx]
|
||||||
|
sa = Ra.mean(1) / Ra.std(1) * np.sqrt(365.25)
|
||||||
|
sb = Rb.mean(1) / Rb.std(1) * np.sqrt(365.25)
|
||||||
|
ds.append(sa - sb)
|
||||||
|
ds = np.concatenate(ds)
|
||||||
|
print(f"\n[C3] IS: Sh(K4) - Sh(h0) = {d_obs:+.3f}; bootstrap appaiato (block {BLOCK}, "
|
||||||
|
f"B={len(ds)}): CI95 [{np.percentile(ds, 2.5):+.3f}, {np.percentile(ds, 97.5):+.3f}], "
|
||||||
|
f"P(diff<=0) = {np.mean(ds <= 0):.3f}")
|
||||||
|
# e vs l'ancora mediana (piu' onesto): K4 confrontato con OGNI singola
|
||||||
|
dvs = [d for h in range(24)
|
||||||
|
for d in [al._sh(A[mask]) - al._sh(sk_book_hourly((h,))[0].loc[common][mask])]]
|
||||||
|
print(f" Sh_IS(K4) - Sh_IS(singola h) sulle 24 ancore: min {min(dvs):+.3f} / "
|
||||||
|
f"mediana {np.median(dvs):+.3f} / max {max(dvs):+.3f} "
|
||||||
|
f"-> vs ancora tipica il guadagno IS e' ~{np.median(dvs):+.2f}, non +0.05/+0.07")
|
||||||
|
|
||||||
|
# C4: small-cap $600 (mia implementazione min-order)
|
||||||
|
print("\n[C4] small-cap $600 (min order $5, quota 0.5/asset):")
|
||||||
|
for name, hs in (("K=1 h0", (0,)), ("K=2", (0, 12)), ("K=4", (0, 6, 12, 18)),
|
||||||
|
("K=24", tuple(range(24)))):
|
||||||
|
nets_r, nets_m, ntr = {}, {}, 0
|
||||||
|
for a in ASSETS:
|
||||||
|
df = get1h(a)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0
|
||||||
|
t = 0.5 * sk_pos_hourly(a, hs)
|
||||||
|
held = np.empty(len(t)); cur = 0.0
|
||||||
|
for i in range(len(t)):
|
||||||
|
if abs(t[i] - cur) * 600.0 >= 5.0:
|
||||||
|
cur = t[i]; ntr += 1
|
||||||
|
held[i] = cur
|
||||||
|
pos = np.zeros(len(held)); pos[1:] = held[:-1]
|
||||||
|
nr = pos * r - FEE * np.abs(np.diff(pos, prepend=0.0)); nr[0] = 0.0
|
||||||
|
posm = np.zeros(len(t)); posm[1:] = t[:-1]
|
||||||
|
nm = posm * r - FEE * np.abs(np.diff(posm, prepend=0.0)); nm[0] = 0.0
|
||||||
|
idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
|
||||||
|
nets_r[a] = pd.Series(nr, index=idx); nets_m[a] = pd.Series(nm, index=idx)
|
||||||
|
Jr = pd.concat(nets_r, axis=1, join="inner").fillna(0.0)
|
||||||
|
Jm = pd.concat(nets_m, axis=1, join="inner").fillna(0.0)
|
||||||
|
dr = al._to_daily(Jr["BTC"] + Jr["ETH"]); dm = al._to_daily(Jm["BTC"] + Jm["ETH"])
|
||||||
|
yrs = len(dr) / 365.25
|
||||||
|
print(f" {name:<8} Sh real {al._sh(dr):.3f} (model {al._sh(dm):.3f}, haircut "
|
||||||
|
f"{al._sh(dm) - al._sh(dr):+.3f}) trade/y {ntr / yrs:.0f}")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# D. IMPATTO sui numeri del progetto (blend SKH e book 5-sleeve, TP01 per ancora)
|
||||||
|
# ===========================================================================
|
||||||
|
def part_D() -> None:
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("D. IMPATTO — blend TP+SKH 75/25 e book 5-sleeve con TP01 alle 24 ancore")
|
||||||
|
print("=" * 100)
|
||||||
|
from src.portfolio.portfolio import combine_outer, to_daily
|
||||||
|
try:
|
||||||
|
from src.portfolio.sleeves import (_gtaa_daily_returns, _skyhook_returns,
|
||||||
|
_vrp_combo_returns, _xsec_returns)
|
||||||
|
skh = to_daily(_skyhook_returns())
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [SKIP] sleeve non calcolabili: {type(e).__name__}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
def hold_sh(s: pd.Series) -> float:
|
||||||
|
return al._sh(s[s.index >= HOLDOUT])
|
||||||
|
|
||||||
|
# blend deribit book 75/25
|
||||||
|
blends = []
|
||||||
|
for h in range(24):
|
||||||
|
tp = sk_port_daily(h)
|
||||||
|
b = combine_outer({"TP": tp, "SKH": skh}, {"TP": 0.75, "SKH": 0.25})
|
||||||
|
b = b[b.index >= tp.index.min()]
|
||||||
|
blends.append(hold_sh(b))
|
||||||
|
b24 = combine_outer({"TP": sk_book_hourly(tuple(range(24)))[0], "SKH": skh},
|
||||||
|
{"TP": 0.75, "SKH": 0.25})
|
||||||
|
print(f"[D1] blend 0.75*TP01(h)+0.25*SKH — Sharpe HOLD: h=0 {blends[0]:.2f} | "
|
||||||
|
f"min {min(blends):.2f} / mediana {np.median(blends):.2f} / max {max(blends):.2f} | "
|
||||||
|
f"TP=K24 {hold_sh(b24[b24.index >= sk_port_daily(0).index.min()]):.2f} "
|
||||||
|
f"(claim del progetto: 0.31 -> 1.17)")
|
||||||
|
|
||||||
|
# book 5-sleeve (pesi CLAUDE.md), attivazione era crypto
|
||||||
|
try:
|
||||||
|
cols_fixed = dict(XS=to_daily(_xsec_returns()), VRP=to_daily(_vrp_combo_returns()),
|
||||||
|
SKH=skh, GTAA=to_daily(_gtaa_daily_returns()))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [SKIP 5-sleeve] {type(e).__name__}: {e}")
|
||||||
|
return
|
||||||
|
W = dict(TP=0.33, XS=0.15, VRP=0.12, SKH=0.20, GTAA=0.20)
|
||||||
|
lo = sk_port_daily(0).index.min()
|
||||||
|
books = []
|
||||||
|
for h in range(24):
|
||||||
|
cols = dict(TP=sk_port_daily(h), **cols_fixed)
|
||||||
|
s = combine_outer(cols, W, lo=lo)
|
||||||
|
books.append((hold_sh(s), al._sh(s)))
|
||||||
|
bh = [b[0] for b in books]; bf = [b[1] for b in books]
|
||||||
|
s24b = combine_outer(dict(TP=sk_book_hourly(tuple(range(24)))[0], **cols_fixed), W, lo=lo)
|
||||||
|
print(f"[D2] book 5-sleeve (TP 33/XS 15/VRP 12/SKH 20/GTAA 20) — Sharpe HOLD: "
|
||||||
|
f"h=0 {bh[0]:.2f} | min {min(bh):.2f} / mediana {np.median(bh):.2f} / max {max(bh):.2f} "
|
||||||
|
f"| TP=K24 {hold_sh(s24b):.2f}")
|
||||||
|
print(f" Sharpe FULL: h=0 {bf[0]:.2f} | min {min(bf):.2f} / mediana {np.median(bf):.2f} "
|
||||||
|
f"/ max {max(bf):.2f} | TP=K24 {al._sh(s24b):.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
part_A()
|
||||||
|
part_B()
|
||||||
|
part_C()
|
||||||
|
part_D()
|
||||||
|
print("\nFatto (scettico).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"""r0702_slow_clock.py — FILONE: clock più lenti del daily + banded rebalancing per TP01.
|
||||||
|
|
||||||
|
Due idee di TIMING DI ESECUZIONE (non di segnale) sul TP01 CANONICAL (PORT LF1d):
|
||||||
|
(A) CLOCK LENTI — segnale calcolato daily, posizione aggiornata solo ogni N giorni
|
||||||
|
(N in {2,3,5,7}). ⚠ timing luck: si riportano TUTTE le N fasi (min/med/max) e
|
||||||
|
l'ENSEMBLE delle fasi (media dei libri sfasati), MAI la fase migliore da sola.
|
||||||
|
(B) BANDE DI ISTERESI — decisione daily, si esegue solo se |target − posizione| > banda
|
||||||
|
(banda in frazione di equity PER ASSET, in {0, .025, .05, .10, .20}); quando si
|
||||||
|
esegue si va al target pieno.
|
||||||
|
|
||||||
|
Onestà:
|
||||||
|
- selezione cella SOLO in-sample pre-2025 (pattern al.select_cell_insample); l'hold-out
|
||||||
|
della cella scelta si RIPORTA, non si sceglie.
|
||||||
|
- deflated Sharpe (al.deflated_sharpe) su TUTTI i trial esplorati (fasi incluse).
|
||||||
|
- Sharpe LORDO (fee=0) accanto al netto: una variante di esecuzione onesta ha lordo
|
||||||
|
~uguale al canonico e netto >= (il guadagno è meccanico-di-costo, non fitting).
|
||||||
|
- executability: replica di eval_weights_smallcap a capitale 600/2000/10000 (min order
|
||||||
|
$5, capitale per-asset = C/2) per baseline vs variante scelta — a $600 la banda
|
||||||
|
implicita min-order è 5/(600/2) ≈ 0.0167 di peso per asset.
|
||||||
|
- causalità: target TP01 causale (verificato altrove); i filtri di esecuzione usano solo
|
||||||
|
stato passato; eval_weights fa lo shift +1; check prefix-consistency inline sulla
|
||||||
|
cella scelta. Nessun ffill mixed-TF, nessun .view("int64") su tz-aware.
|
||||||
|
|
||||||
|
Convenzione (stessa di eval_weights/TrendPortfolio): il peso resta costante tra i
|
||||||
|
ribilanciamenti (fee solo su |Δpeso|); il drift del peso intra-periodo non è modellato
|
||||||
|
(secondo ordine a N<=7 giorni) — dichiarato nei caveat.
|
||||||
|
|
||||||
|
Run: uv run python scripts/research/r0702_slow_clock.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
|
||||||
|
import numpy as np # noqa: E402
|
||||||
|
import pandas as pd # noqa: E402
|
||||||
|
|
||||||
|
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
|
||||||
|
|
||||||
|
HOLDOUT = al.HOLDOUT
|
||||||
|
FEE = al.FEE_SIDE
|
||||||
|
EPOCH = pd.Timestamp("1970-01-01", tz="UTC")
|
||||||
|
|
||||||
|
CLOCK_NS = (2, 3, 5, 7)
|
||||||
|
BANDS = (0.0, 0.025, 0.05, 0.10, 0.20)
|
||||||
|
CAPITALS = (600.0, 2000.0, 10000.0)
|
||||||
|
MIN_ORDER = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# targets & execution filters (tutti causali: stato = solo passato)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def daily_targets() -> dict[str, tuple[pd.DataFrame, np.ndarray]]:
|
||||||
|
tp = TrendPortfolio(**CANONICAL)
|
||||||
|
out = {}
|
||||||
|
for a in al.CERTIFIED:
|
||||||
|
df = al.get(a, "1d")
|
||||||
|
out[a] = (df, tp.target_series(df))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def epoch_days(df: pd.DataFrame) -> np.ndarray:
|
||||||
|
dt = pd.to_datetime(df["datetime"], utc=True)
|
||||||
|
return ((dt.dt.floor("D") - EPOCH) // pd.Timedelta(days=1)).values.astype(int)
|
||||||
|
|
||||||
|
|
||||||
|
def slow_clock_exec(df: pd.DataFrame, tgt: np.ndarray, N: int, phase: int) -> np.ndarray:
|
||||||
|
"""Aggiorna la posizione solo nei giorni con epoch_day % N == phase (ancoraggio a
|
||||||
|
calendario -> prefix-consistent, entrambe le gambe ribilanciano lo stesso giorno)."""
|
||||||
|
days = epoch_days(df)
|
||||||
|
out = np.empty(len(tgt))
|
||||||
|
cur = 0.0
|
||||||
|
for i in range(len(tgt)):
|
||||||
|
if days[i] % N == phase:
|
||||||
|
cur = tgt[i]
|
||||||
|
out[i] = cur
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def band_exec(tgt: np.ndarray, band: float) -> np.ndarray:
|
||||||
|
"""Esegue (va al target pieno) solo se |target − posizione corrente| > band."""
|
||||||
|
out = np.empty(len(tgt))
|
||||||
|
cur = 0.0
|
||||||
|
for i in range(len(tgt)):
|
||||||
|
if abs(tgt[i] - cur) > band:
|
||||||
|
cur = tgt[i]
|
||||||
|
out[i] = cur
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def smallcap_exec(tgt: np.ndarray, capital_per_asset: float,
|
||||||
|
min_order: float = MIN_ORDER) -> np.ndarray:
|
||||||
|
"""Replica della logica di al.eval_weights_smallcap (serve la SERIE, non solo le
|
||||||
|
metriche): un Δpeso il cui nozionale < min_order NON si esegue."""
|
||||||
|
out = np.empty(len(tgt))
|
||||||
|
cur = 0.0
|
||||||
|
for i in range(len(tgt)):
|
||||||
|
if abs(tgt[i] - cur) * capital_per_asset >= min_order:
|
||||||
|
cur = tgt[i]
|
||||||
|
out[i] = cur
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# valutazione book 50/50 (netto + lordo)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _series(df: pd.DataFrame, et: np.ndarray, fee_side: float) -> pd.Series:
|
||||||
|
ev = al.eval_weights(df, et, fee_side=fee_side)
|
||||||
|
return pd.Series(ev["net"], index=ev["idx"])
|
||||||
|
|
||||||
|
|
||||||
|
def book_eval(pairs: dict[str, tuple[pd.DataFrame, np.ndarray]]) -> dict:
|
||||||
|
"""pairs: {asset: (df, exec_target)} -> metriche book 50/50 nette e lorde."""
|
||||||
|
net_s, gro_s = {}, {}
|
||||||
|
turn_book = 0.0
|
||||||
|
orders_y = 0.0
|
||||||
|
for a, (df, et) in pairs.items():
|
||||||
|
net_s[a] = _series(df, et, FEE)
|
||||||
|
gro_s[a] = _series(df, et, 0.0)
|
||||||
|
years = (df["datetime"].iloc[-1] - df["datetime"].iloc[0]).total_seconds() / 86400 / 365.25
|
||||||
|
pos = np.zeros(len(et)); pos[1:] = et[:-1]
|
||||||
|
turn = np.abs(np.diff(pos, prepend=0.0))
|
||||||
|
turn_book += 0.5 * turn.sum() / years # in unità di equity del book
|
||||||
|
orders_y += float(np.sum(turn > 1e-12) / years) # ordini reali (entrambe le gambe)
|
||||||
|
NET = pd.concat(net_s, axis=1, join="inner").fillna(0.0)
|
||||||
|
GRO = pd.concat(gro_s, axis=1, join="inner").fillna(0.0)
|
||||||
|
assets = list(pairs)
|
||||||
|
net = 0.5 * NET[assets[0]] + 0.5 * NET[assets[1]]
|
||||||
|
gro = 0.5 * GRO[assets[0]] + 0.5 * GRO[assets[1]]
|
||||||
|
|
||||||
|
def _hold(s): return s[s.index >= HOLDOUT]
|
||||||
|
def _ins(s): return s[s.index < HOLDOUT]
|
||||||
|
|
||||||
|
eqn = np.cumprod(1.0 + np.clip(net.values, -0.99, None))
|
||||||
|
span_y = (net.index[-1] - net.index[0]).total_seconds() / 86400 / 365.25
|
||||||
|
cagr = eqn[-1] ** (1 / span_y) - 1 if span_y > 0 else 0.0
|
||||||
|
return dict(
|
||||||
|
sh_full_net=al._sh(net), sh_hold_net=al._sh(_hold(net)), sh_ins_net=al._sh(_ins(net)),
|
||||||
|
sh_full_gro=al._sh(gro), sh_hold_gro=al._sh(_hold(gro)),
|
||||||
|
maxdd=al._dd_ret(net), cagr=cagr,
|
||||||
|
turnover_y=turn_book, orders_y=orders_y, net=net, gross=gro,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def row(tag: str, m: dict) -> str:
|
||||||
|
return (f"{tag:<26} | net F {m['sh_full_net']:5.2f} H {m['sh_hold_net']:5.2f} "
|
||||||
|
f"(IS {m['sh_ins_net']:5.2f}) | gross F {m['sh_full_gro']:5.2f} "
|
||||||
|
f"H {m['sh_hold_gro']:5.2f} | DD {m['maxdd']*100:4.1f}% | CAGR {m['cagr']*100:5.1f}% "
|
||||||
|
f"| turn/y {m['turnover_y']:5.1f} | ord/y {m['orders_y']:6.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def main() -> None:
|
||||||
|
pairs = daily_targets()
|
||||||
|
|
||||||
|
# ---- sanity check: riproduci al.tp01_baseline_daily() -------------------
|
||||||
|
base = book_eval(pairs)
|
||||||
|
ref = al.tp01_baseline_daily()
|
||||||
|
common = base["net"].index.intersection(ref.index)
|
||||||
|
diff = float(np.max(np.abs(base["net"].reindex(common).values - ref.reindex(common).values)))
|
||||||
|
print("=" * 118)
|
||||||
|
print("SANITY — baseline daily vs al.tp01_baseline_daily():",
|
||||||
|
f"max|Δdaily ret| = {diff:.2e}",
|
||||||
|
f"(Sharpe qui {base['sh_full_net']:.3f} / ref {al._sh(ref):.3f})")
|
||||||
|
assert diff < 1e-9, "baseline non riprodotta!"
|
||||||
|
print(row("BASELINE daily band=0", base))
|
||||||
|
|
||||||
|
all_trial_sharpes: list[float] = [base["sh_full_net"]]
|
||||||
|
candidates: dict[str, dict] = {"baseline_daily": base}
|
||||||
|
|
||||||
|
# ---- (A) CLOCK LENTI: tutte le fasi + ensemble ---------------------------
|
||||||
|
print("\n" + "=" * 118)
|
||||||
|
print("(A) CLOCK LENTI — TP01 daily-signal, ribilanciamento ogni N giorni "
|
||||||
|
"(tutte le fasi: min/med/max; ensemble = media dei libri sfasati)")
|
||||||
|
print("=" * 118)
|
||||||
|
clock_tbl = {}
|
||||||
|
for N in CLOCK_NS:
|
||||||
|
phase_ms, phase_nets, phase_gros = [], [], []
|
||||||
|
for p in range(N):
|
||||||
|
pp = {a: (df, slow_clock_exec(df, tgt, N, p)) for a, (df, tgt) in pairs.items()}
|
||||||
|
m = book_eval(pp)
|
||||||
|
phase_ms.append(m)
|
||||||
|
phase_nets.append(m["net"]); phase_gros.append(m["gross"])
|
||||||
|
all_trial_sharpes.append(m["sh_full_net"])
|
||||||
|
ens_net = pd.concat(phase_nets, axis=1, join="inner").mean(axis=1)
|
||||||
|
ens_gro = pd.concat(phase_gros, axis=1, join="inner").mean(axis=1)
|
||||||
|
eqn = np.cumprod(1.0 + np.clip(ens_net.values, -0.99, None))
|
||||||
|
span_y = (ens_net.index[-1] - ens_net.index[0]).total_seconds() / 86400 / 365.25
|
||||||
|
ens = dict(
|
||||||
|
sh_full_net=al._sh(ens_net),
|
||||||
|
sh_hold_net=al._sh(ens_net[ens_net.index >= HOLDOUT]),
|
||||||
|
sh_ins_net=al._sh(ens_net[ens_net.index < HOLDOUT]),
|
||||||
|
sh_full_gro=al._sh(ens_gro), sh_hold_gro=al._sh(ens_gro[ens_gro.index >= HOLDOUT]),
|
||||||
|
maxdd=al._dd_ret(ens_net), cagr=eqn[-1] ** (1 / span_y) - 1,
|
||||||
|
turnover_y=float(np.mean([m["turnover_y"] for m in phase_ms])),
|
||||||
|
orders_y=float(np.mean([m["orders_y"] for m in phase_ms])),
|
||||||
|
net=ens_net, gross=ens_gro,
|
||||||
|
)
|
||||||
|
all_trial_sharpes.append(ens["sh_full_net"])
|
||||||
|
candidates[f"clock_N{N}_ensemble"] = ens
|
||||||
|
clock_tbl[N] = (phase_ms, ens)
|
||||||
|
|
||||||
|
fn = [m["sh_full_net"] for m in phase_ms]
|
||||||
|
hn = [m["sh_hold_net"] for m in phase_ms]
|
||||||
|
fg = [m["sh_full_gro"] for m in phase_ms]
|
||||||
|
hg = [m["sh_hold_gro"] for m in phase_ms]
|
||||||
|
dd = [m["maxdd"] for m in phase_ms]
|
||||||
|
oy = [m["orders_y"] for m in phase_ms]
|
||||||
|
print(f"N={N} fasi ({N}): net FULL min/med/max {min(fn):.2f}/{np.median(fn):.2f}/{max(fn):.2f}"
|
||||||
|
f" HOLD {min(hn):.2f}/{np.median(hn):.2f}/{max(hn):.2f}"
|
||||||
|
f" | gross FULL {min(fg):.2f}/{np.median(fg):.2f}/{max(fg):.2f}"
|
||||||
|
f" HOLD {min(hg):.2f}/{np.median(hg):.2f}/{max(hg):.2f}"
|
||||||
|
f" | DD {min(dd)*100:.1f}-{max(dd)*100:.1f}% | ord/y {min(oy):.0f}-{max(oy):.0f}")
|
||||||
|
print(row(f" N={N} ENSEMBLE", ens))
|
||||||
|
|
||||||
|
# ---- (B) BANDE DI ISTERESI ----------------------------------------------
|
||||||
|
print("\n" + "=" * 118)
|
||||||
|
print("(B) BANDE DI ISTERESI — decisione daily, esecuzione solo se |target−pos| > banda "
|
||||||
|
"(frazione di equity per asset); si va al target pieno")
|
||||||
|
print("=" * 118)
|
||||||
|
band_tbl = {}
|
||||||
|
for b in BANDS:
|
||||||
|
pp = {a: (df, band_exec(tgt, b)) for a, (df, tgt) in pairs.items()}
|
||||||
|
m = book_eval(pp)
|
||||||
|
band_tbl[b] = m
|
||||||
|
all_trial_sharpes.append(m["sh_full_net"])
|
||||||
|
if b > 0:
|
||||||
|
candidates[f"band_{b:g}"] = m
|
||||||
|
saved = base["turnover_y"] - m["turnover_y"]
|
||||||
|
print(row(f"banda {b:5.3f}", m) +
|
||||||
|
f" | turn risparmiato {saved:5.1f}/y (fee ~{saved*FEE*100*2:.2f}%/y su RT)")
|
||||||
|
|
||||||
|
# ---- selezione IN-SAMPLE (pre-2025) e hold-out riportato -----------------
|
||||||
|
print("\n" + "=" * 118)
|
||||||
|
print("SELEZIONE CELLA — solo in-sample pre-2025 (l'hold-out si riporta, non si sceglie)")
|
||||||
|
print("=" * 118)
|
||||||
|
ranked = sorted(candidates.items(), key=lambda kv: kv[1]["sh_ins_net"], reverse=True)
|
||||||
|
for name, m in ranked:
|
||||||
|
print(f" IS {m['sh_ins_net']:5.3f} | HOLD {m['sh_hold_net']:5.3f} | FULL {m['sh_full_net']:5.3f} {name}")
|
||||||
|
chosen_name, chosen = ranked[0]
|
||||||
|
n_trials = len(all_trial_sharpes)
|
||||||
|
dsr, sr0 = al.deflated_sharpe(chosen["sh_full_net"], all_trial_sharpes, chosen["net"])
|
||||||
|
print(f"\nCELLA SCELTA IN-SAMPLE: {chosen_name}")
|
||||||
|
print(row(" scelta", chosen))
|
||||||
|
print(f" trials totali esplorati: {n_trials} (fasi singole incluse)")
|
||||||
|
print(f" deflated Sharpe (vs {n_trials} trial): DSR={dsr:.3f}, null-max atteso={sr0:.3f} "
|
||||||
|
f"(NB: candidato = variante di TP01, correlatissima al baseline — l'asticella "
|
||||||
|
f"giusta è lordo~uguale/netto-migliore, non earns_slot)")
|
||||||
|
dgro = chosen["sh_full_gro"] - base["sh_full_gro"]
|
||||||
|
dnet = chosen["sh_full_net"] - base["sh_full_net"]
|
||||||
|
dgro_h = chosen["sh_hold_gro"] - base["sh_hold_gro"]
|
||||||
|
dnet_h = chosen["sh_hold_net"] - base["sh_hold_net"]
|
||||||
|
print(f" Δ vs baseline — FULL: gross {dgro:+.3f} / net {dnet:+.3f} "
|
||||||
|
f"HOLD: gross {dgro_h:+.3f} / net {dnet_h:+.3f}")
|
||||||
|
print(f" fee drag baseline: turn {base['turnover_y']:.1f}/y × {2*FEE*100:.2f}%RT "
|
||||||
|
f"≈ {base['turnover_y']*FEE*100:.2f}%/y di equity — questo è il TETTO del guadagno meccanico")
|
||||||
|
|
||||||
|
# ---- prefix-consistency (causalità dell'exec filter) ---------------------
|
||||||
|
ok = True
|
||||||
|
for a, (df, tgt) in pairs.items():
|
||||||
|
if chosen_name.startswith("band"):
|
||||||
|
b = float(chosen_name.split("_")[1])
|
||||||
|
full_e = band_exec(tgt, b)
|
||||||
|
cut = int(len(df) * 0.8)
|
||||||
|
sub = df.iloc[:cut].reset_index(drop=True)
|
||||||
|
sub_t = TrendPortfolio(**CANONICAL).target_series(sub)
|
||||||
|
sub_e = band_exec(sub_t, b)
|
||||||
|
elif chosen_name.startswith("clock"):
|
||||||
|
N = int(chosen_name.split("_")[1][1:])
|
||||||
|
full_e = slow_clock_exec(df, tgt, N, 0)
|
||||||
|
cut = int(len(df) * 0.8)
|
||||||
|
sub = df.iloc[:cut].reset_index(drop=True)
|
||||||
|
sub_t = TrendPortfolio(**CANONICAL).target_series(sub)
|
||||||
|
sub_e = slow_clock_exec(sub, sub_t, N, 0)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
d = float(np.max(np.abs(sub_e[-60:] - full_e[cut - 60:cut])))
|
||||||
|
ok &= d < 1e-9
|
||||||
|
print(f" prefix-consistency exec-filter (fase 0 per i clock): {'OK' if ok else 'FAIL'}")
|
||||||
|
|
||||||
|
# ---- (6) EXECUTABILITY small-cap a 600 / 2000 / 10000 --------------------
|
||||||
|
print("\n" + "=" * 118)
|
||||||
|
print("(6) EXECUTABILITY — min order $5, capitale per-asset = C/2 "
|
||||||
|
"(banda implicita = 5/(C/2) in peso per asset)")
|
||||||
|
print("=" * 118)
|
||||||
|
|
||||||
|
def chosen_exec(a, df, tgt):
|
||||||
|
if chosen_name.startswith("band"):
|
||||||
|
return band_exec(tgt, float(chosen_name.split("_")[1]))
|
||||||
|
if chosen_name.startswith("clock"):
|
||||||
|
N = int(chosen_name.split("_")[1][1:])
|
||||||
|
# deploy reale = UNA fase; usiamo fase 0 e dichiariamo la timing luck
|
||||||
|
return slow_clock_exec(df, tgt, N, 0)
|
||||||
|
return tgt.copy()
|
||||||
|
|
||||||
|
for C in CAPITALS:
|
||||||
|
cpa = C / 2.0
|
||||||
|
implicit = MIN_ORDER / cpa
|
||||||
|
print(f"\ncapitale ${C:.0f} (banda implicita min-order = {implicit:.4f} peso/asset)")
|
||||||
|
for label, mk in (("baseline daily", lambda a, df, t: t.copy()),
|
||||||
|
(f"variante [{chosen_name}]", chosen_exec)):
|
||||||
|
pp = {a: (df, smallcap_exec(mk(a, df, tgt), cpa))
|
||||||
|
for a, (df, tgt) in pairs.items()}
|
||||||
|
m = book_eval(pp)
|
||||||
|
# cross-check con l'utility ufficiale (per-asset, solo full)
|
||||||
|
hc = {a: al.eval_weights_smallcap(df, mk(a, df, tgt), capital=cpa)["sharpe_haircut"]
|
||||||
|
for a, (df, tgt) in pairs.items()}
|
||||||
|
print(row(f" {label}", m) +
|
||||||
|
f" | haircut/asset vs modellato: " +
|
||||||
|
", ".join(f"{a} {h:+.3f}" for a, h in hc.items()))
|
||||||
|
|
||||||
|
print("\nNOTA: se la banda ottimale ≈ banda implicita a $600 (0.0167), il vincolo "
|
||||||
|
"small-cap del libro live sta GIÀ facendo il lavoro della banda.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""r0702_tp01_offset.py — TIMING-LUCK del ribilanciamento giornaliero di TP01.
|
||||||
|
|
||||||
|
TP01 CANONICAL (PORT LF1d) decide sulla barra daily chiusa alle 00:00 UTC. L'ancora e'
|
||||||
|
arbitraria (Hoffstein, "rebalance timing luck"): la STESSA strategia con parametri IDENTICI
|
||||||
|
ancorata alle h:00 (h=0..23) puo' dare Sharpe diversi. Questo script:
|
||||||
|
|
||||||
|
1. costruisce 24 serie daily (resample 24h del 1h certificato, offset h, label/closed left,
|
||||||
|
stessa convenzione di trend_portfolio.resample_tf) — SANITY OBBLIGATORIO: h=0 riproduce
|
||||||
|
ESATTAMENTE al.tp01_baseline_daily() (stesso Sharpe FULL/HOLD);
|
||||||
|
2. misura Sharpe/CAGR/maxDD FULL, IS (pre-2025) e HOLD-OUT per offset -> percentile di h=0;
|
||||||
|
3. ENSEMBLE (tranching 1/K del capitale per ancora): 24-way + K=2 (0,12), K=3 (0,8,16),
|
||||||
|
K=4 (0,6,12,18) — scelte A PRIORI simmetriche, zero tuning per-offset, zero selezione
|
||||||
|
sull'hold-out. L'ensemble e' valutato sul BOOK AGGREGATO su griglia 1h (posizione =
|
||||||
|
media delle tranche, fee sul turnover netto reale) — non media di equity separate;
|
||||||
|
4. dispersione: std/range dello Sharpe fra le 24 ancore vs fra TUTTE le rotazioni possibili
|
||||||
|
di K=2 (12), K=3 (8), K=4 (6) — la riduzione di varianza e' il criterio strutturale;
|
||||||
|
5. small-cap: haircut min-order $5 a capitale 600/2k/10k per K=1 vs K=2/4/24
|
||||||
|
(il tranching divide gli ordini per K -> piu' rebalance sotto min-order).
|
||||||
|
|
||||||
|
Causalita': targets TP01 causali per costruzione; guardia ricalcolo-su-prefisso sia sul
|
||||||
|
daily resampled sia sul troncamento del 1h; mappatura daily->1h via merge_asof backward su
|
||||||
|
EPOCA MS ESPLICITA (mai .view su tz-aware); eval_weights fa lo shift (held durante t+1).
|
||||||
|
|
||||||
|
Vincoli: nessun file toccato fuori da questo script. Runtime ~1-2 min.
|
||||||
|
"""
|
||||||
|
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("/opt/docker/PythagorasGoal")
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
|
||||||
|
|
||||||
|
TP = TrendPortfolio(**CANONICAL)
|
||||||
|
HOLDOUT = al.HOLDOUT
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
MS_H = 3_600_000
|
||||||
|
MS_D = 86_400_000
|
||||||
|
|
||||||
|
# ancore a priori, simmetriche, NON ottimizzate
|
||||||
|
HEADLINE = {
|
||||||
|
"K=1 (canonico h=0)": (0,),
|
||||||
|
"K=2 (0,12)": (0, 12),
|
||||||
|
"K=3 (0,8,16)": (0, 8, 16),
|
||||||
|
"K=4 (0,6,12,18)": (0, 6, 12, 18),
|
||||||
|
"K=24 (tutte)": tuple(range(24)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Resample con ancora h — stessa convenzione di trend_portfolio.resample_tf
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def resample_offset(df_1h: pd.DataFrame, h: int) -> pd.DataFrame:
|
||||||
|
g = df_1h.copy()
|
||||||
|
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
|
||||||
|
idx.name = "dt"
|
||||||
|
g.index = idx
|
||||||
|
out = g.resample("24h", offset=pd.Timedelta(hours=h), label="left", closed="left").agg(
|
||||||
|
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
|
||||||
|
out = out.dropna(subset=["open"])
|
||||||
|
out["datetime"] = out.index
|
||||||
|
epoch = pd.Timestamp("1970-01-01", tz="UTC")
|
||||||
|
out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
|
||||||
|
return out.reset_index(drop=True)[
|
||||||
|
["timestamp", "open", "high", "low", "close", "volume", "datetime"]]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8)
|
||||||
|
def get1h(asset: str) -> pd.DataFrame:
|
||||||
|
return al.get(asset, "1h")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=64)
|
||||||
|
def daily_off(asset: str, h: int) -> pd.DataFrame:
|
||||||
|
return resample_offset(get1h(asset), h)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Metriche su serie daily (convenzione identica al baseline: _to_daily + _sh)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def dmetrics(s: pd.Series) -> dict:
|
||||||
|
s = s.dropna()
|
||||||
|
is_ = s[s.index < HOLDOUT]
|
||||||
|
ho = s[s.index >= HOLDOUT]
|
||||||
|
eq = float(np.prod(1.0 + s.values))
|
||||||
|
yrs = len(s) / 365.25
|
||||||
|
cagr = eq ** (1 / yrs) - 1 if eq > 0 and yrs > 0 else -1.0
|
||||||
|
return dict(sh_full=al._sh(s), sh_is=al._sh(is_), sh_hold=al._sh(ho),
|
||||||
|
cagr=cagr, dd=al._dd_ret(s), dd_hold=al._dd_ret(ho), n=len(s))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Path DAILY-NATIVO per offset (counterfactual "e se l'ancora fosse h")
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def port_daily_native(h: int) -> pd.Series:
|
||||||
|
series = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
df = daily_off(a, h)
|
||||||
|
net, ts = TP.net_returns(df)
|
||||||
|
series[a] = pd.Series(net, index=pd.DatetimeIndex(pd.to_datetime(ts.values, utc=True)))
|
||||||
|
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||||
|
return al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Path 1h AGGREGATO (book unico): target daily-offset mappato causale sul 1h
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@lru_cache(maxsize=64)
|
||||||
|
def hourly_target(asset: str, h: int) -> tuple:
|
||||||
|
"""Target TP01 (ancora h) sul grid 1h: per ogni barra 1h prendi il target dell'ultima
|
||||||
|
barra daily-offset il cui CLOSE nominale (label+24h, epoca ms) e' <= close della barra 1h
|
||||||
|
(ts+1h). merge_asof backward su int ms espliciti. eval_weights poi SHIFTA (held t+1)."""
|
||||||
|
d = daily_off(asset, h)
|
||||||
|
tgt = TP.target_series(d)
|
||||||
|
right = pd.DataFrame({"cms": d["timestamp"].values.astype("int64") + MS_D,
|
||||||
|
"tgt": tgt})
|
||||||
|
left = pd.DataFrame({"cms": get1h(asset)["timestamp"].values.astype("int64") + MS_H})
|
||||||
|
m = pd.merge_asof(left, right, on="cms", direction="backward")
|
||||||
|
return tuple(np.nan_to_num(m["tgt"].values, nan=0.0))
|
||||||
|
|
||||||
|
|
||||||
|
def ens_target(asset: str, hs: tuple) -> np.ndarray:
|
||||||
|
return np.mean([np.asarray(hourly_target(asset, h)) for h in hs], axis=0)
|
||||||
|
|
||||||
|
|
||||||
|
def port_hourly(hs: tuple) -> tuple[pd.Series, float]:
|
||||||
|
"""Serie daily del book aggregato (0.5 BTC + 0.5 ETH su grid 1h) + turnover/anno."""
|
||||||
|
nets, turns = {}, 0.0
|
||||||
|
for a in ASSETS:
|
||||||
|
df = get1h(a)
|
||||||
|
ev = al.eval_weights(df, ens_target(a, hs))
|
||||||
|
nets[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||||
|
turns += 0.5 * ev["turnover_per_year"]
|
||||||
|
J = pd.concat(nets, axis=1, join="inner").fillna(0.0)
|
||||||
|
return al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]), turns
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Small-cap: min-order $5, capitale condiviso 50/50 fra i 2 asset
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def smallcap_net(df: pd.DataFrame, tgt: np.ndarray, capital: float,
|
||||||
|
min_order: float = 5.0) -> tuple[pd.Series, int]:
|
||||||
|
"""Copia locale della logica di al.eval_weights_smallcap che restituisce la serie net
|
||||||
|
(serve per combinare il book 2-asset). Cambi di nozionale < min_order NON eseguiti."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
tgt = np.clip(np.nan_to_num(np.asarray(tgt, float)), -10, 10)
|
||||||
|
held = np.empty(len(tgt))
|
||||||
|
cur, n_tr = 0.0, 0
|
||||||
|
for i in range(len(tgt)):
|
||||||
|
if abs(tgt[i] - cur) * capital >= min_order:
|
||||||
|
cur = tgt[i]
|
||||||
|
n_tr += 1
|
||||||
|
held[i] = cur
|
||||||
|
r = al.simple_returns(c)
|
||||||
|
pos = np.zeros(len(held))
|
||||||
|
pos[1:] = held[:-1]
|
||||||
|
turn = np.abs(np.diff(pos, prepend=0.0))
|
||||||
|
net = pos * r - al.FEE_SIDE * turn
|
||||||
|
net[0] = 0.0
|
||||||
|
idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
|
||||||
|
return pd.Series(net, index=idx), n_tr
|
||||||
|
|
||||||
|
|
||||||
|
def smallcap_port(hs: tuple, capital: float) -> dict:
|
||||||
|
"""Book realistico a `capital`: target per-asset = 0.5*ensemble (quota 50/50).
|
||||||
|
modeled = stesso book senza vincolo min-order (fee identiche proporzionali)."""
|
||||||
|
nets_r, nets_m, ntr = {}, {}, 0
|
||||||
|
for a in ASSETS:
|
||||||
|
df = get1h(a)
|
||||||
|
t = 0.5 * ens_target(a, hs)
|
||||||
|
nr, n = smallcap_net(df, t, capital)
|
||||||
|
nets_r[a] = nr
|
||||||
|
ntr += n
|
||||||
|
ev = al.eval_weights(df, t)
|
||||||
|
nets_m[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||||
|
Jr = pd.concat(nets_r, axis=1, join="inner").fillna(0.0)
|
||||||
|
Jm = pd.concat(nets_m, axis=1, join="inner").fillna(0.0)
|
||||||
|
dr = al._to_daily(Jr[ASSETS[0]] + Jr[ASSETS[1]])
|
||||||
|
dm = al._to_daily(Jm[ASSETS[0]] + Jm[ASSETS[1]])
|
||||||
|
yrs = len(dr) / 365.25
|
||||||
|
return dict(sh_real=al._sh(dr), sh_model=al._sh(dm),
|
||||||
|
haircut=al._sh(dm) - al._sh(dr),
|
||||||
|
dd_real=al._dd_ret(dr), trades_per_year=ntr / yrs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Guardie
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def sanity_h0() -> None:
|
||||||
|
"""h=0 deve riprodurre ESATTAMENTE il baseline (dati + strategia + metriche)."""
|
||||||
|
for a in ASSETS:
|
||||||
|
d0 = daily_off(a, 0)
|
||||||
|
ref = al.get(a, "1d")
|
||||||
|
for col in ("timestamp", "open", "high", "low", "close", "volume"):
|
||||||
|
assert np.allclose(d0[col].values.astype(float),
|
||||||
|
ref[col].values.astype(float), atol=0, rtol=0), \
|
||||||
|
f"resample_offset(h=0) != al.get('{a}','1d') su {col}"
|
||||||
|
mine = port_daily_native(0)
|
||||||
|
base = al.tp01_baseline_daily()
|
||||||
|
assert len(mine) == len(base) and np.allclose(mine.values, base.values, atol=1e-12), \
|
||||||
|
"portafoglio h=0 != tp01_baseline_daily"
|
||||||
|
mm, bb = dmetrics(mine), dmetrics(base)
|
||||||
|
print(f"[SANITY] h=0 == baseline: OK (Sharpe FULL {mm['sh_full']:.4f} == "
|
||||||
|
f"{bb['sh_full']:.4f}, HOLD {mm['sh_hold']:.4f} == {bb['sh_hold']:.4f})")
|
||||||
|
|
||||||
|
|
||||||
|
def causality_guards() -> None:
|
||||||
|
"""(a) prefix-recompute sul daily resampled: target[i] non cambia aggiungendo futuro.
|
||||||
|
(b) troncando il 1h, le barre daily complete restano identiche (solo l'ultima e' parziale)."""
|
||||||
|
for a in ASSETS:
|
||||||
|
for h in (0, 7, 13, 21):
|
||||||
|
d = daily_off(a, h)
|
||||||
|
t_full = TP.target_series(d)
|
||||||
|
cut = len(d) - 250
|
||||||
|
t_pref = TP.target_series(d.iloc[:cut].reset_index(drop=True))
|
||||||
|
assert np.allclose(t_full[:cut], t_pref, atol=1e-12), \
|
||||||
|
f"prefix-recompute FAIL {a} h={h}"
|
||||||
|
df1h = get1h(a)
|
||||||
|
d_tr = resample_offset(df1h.iloc[:-500].reset_index(drop=True), h)
|
||||||
|
k = len(d_tr) - 1 # l'ultima barra del troncato e' parziale per costruzione
|
||||||
|
for col in ("timestamp", "close"):
|
||||||
|
assert np.allclose(d_tr[col].values[:k].astype(float),
|
||||||
|
d[col].values[:k].astype(float)), \
|
||||||
|
f"1h-truncation FAIL {a} h={h} {col}"
|
||||||
|
print("[SANITY] guardie causalita' (prefix-recompute daily + troncamento 1h): OK")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def main() -> None:
|
||||||
|
print("=" * 96)
|
||||||
|
print("r0702 — TP01 rebalance timing-luck: 24 ancore daily + ensemble tranching")
|
||||||
|
print(f"CANONICAL={CANONICAL} fee 0.10% RT HOLD-OUT >= {HOLDOUT.date()}")
|
||||||
|
print("=" * 96)
|
||||||
|
|
||||||
|
sanity_h0()
|
||||||
|
causality_guards()
|
||||||
|
|
||||||
|
# ---- (1) per-offset, path daily-nativo --------------------------------
|
||||||
|
rows = []
|
||||||
|
for h in range(24):
|
||||||
|
m = dmetrics(port_daily_native(h))
|
||||||
|
rows.append(dict(h=h, **m))
|
||||||
|
T = pd.DataFrame(rows).set_index("h")
|
||||||
|
|
||||||
|
print("\n--- (1) PER-OFFSET (daily nativo, ancora h:00 UTC) ---")
|
||||||
|
print(f"{'h':>3} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'CAGR':>7} {'maxDD':>7}")
|
||||||
|
for h, r in T.iterrows():
|
||||||
|
tag = " <- canonico" if h == 0 else ""
|
||||||
|
print(f"{h:>3} {r.sh_full:>7.3f} {r.sh_is:>7.3f} {r.sh_hold:>7.3f} "
|
||||||
|
f"{r.cagr:>6.1%} {r.dd:>6.1%}{tag}")
|
||||||
|
|
||||||
|
def pctl(col: str) -> float:
|
||||||
|
v = T[col].values
|
||||||
|
return float((v < v[0]).mean() + 0.5 * (v == v[0]).mean()) * 100
|
||||||
|
|
||||||
|
print("\nDistribuzione fra le 24 ancore (min / mediana / max / std) [percentile di h=0]:")
|
||||||
|
for col, lbl in (("sh_full", "Sharpe FULL"), ("sh_is", "Sharpe IS(pre-2025)"),
|
||||||
|
("sh_hold", "Sharpe HOLD"), ("dd", "maxDD"), ("cagr", "CAGR")):
|
||||||
|
v = T[col]
|
||||||
|
print(f" {lbl:<20} {v.min():>7.3f} / {v.median():>7.3f} / {v.max():>7.3f} "
|
||||||
|
f"/ std {v.std():.3f} h=0 al {pctl(col):.0f}° pctl (val {v.iloc[0]:.3f})")
|
||||||
|
|
||||||
|
# ---- (2) ensemble headline, book aggregato su 1h ----------------------
|
||||||
|
print("\n--- (2) ENSEMBLE (book aggregato su grid 1h, fee su turnover netto) ---")
|
||||||
|
print(f"{'config':<22} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'CAGR':>7} "
|
||||||
|
f"{'maxDD':>7} {'DDhold':>7} {'turn/y':>7}")
|
||||||
|
head = {}
|
||||||
|
for name, hs in HEADLINE.items():
|
||||||
|
s, tpy = port_hourly(hs)
|
||||||
|
m = dmetrics(s)
|
||||||
|
head[name] = m
|
||||||
|
print(f"{name:<22} {m['sh_full']:>7.3f} {m['sh_is']:>7.3f} {m['sh_hold']:>7.3f} "
|
||||||
|
f"{m['cagr']:>6.1%} {m['dd']:>6.1%} {m['dd_hold']:>6.1%} {tpy:>7.1f}")
|
||||||
|
print("(nota: 'K=1 (canonico h=0)' qui e' lo stesso book valutato sul grid 1h — "
|
||||||
|
"differenze vs riga h=0 sopra = sola granularita' di compounding, non strategia)")
|
||||||
|
|
||||||
|
# ---- (3) varianza della stima: rotazioni complete per famiglia --------
|
||||||
|
print("\n--- (3) DISPERSIONE fra rotazioni (nessuna selezione: TUTTE le rotazioni) ---")
|
||||||
|
fams = {
|
||||||
|
"singole (24)": [(h,) for h in range(24)],
|
||||||
|
"K=2 h,h+12 (12)": [(h, h + 12) for h in range(12)],
|
||||||
|
"K=3 h,h+8,h+16 (8)": [(h, h + 8, h + 16) for h in range(8)],
|
||||||
|
"K=4 h,h+6,.. (6)": [tuple(h + 6 * j for j in range(4)) for h in range(6)],
|
||||||
|
}
|
||||||
|
print(f"{'famiglia':<20} {'ShFULL μ':>9} {'σ':>6} {'range':>13} "
|
||||||
|
f"{'ShHOLD μ':>9} {'σ':>6} {'range':>13}")
|
||||||
|
for name, rot in fams.items():
|
||||||
|
mf = [dmetrics(port_hourly(hs)[0]) for hs in rot]
|
||||||
|
f = np.array([m["sh_full"] for m in mf])
|
||||||
|
ho = np.array([m["sh_hold"] for m in mf])
|
||||||
|
print(f"{name:<20} {f.mean():>9.3f} {f.std():>6.3f} "
|
||||||
|
f"[{f.min():>5.3f},{f.max():>5.3f}] "
|
||||||
|
f"{ho.mean():>9.3f} {ho.std():>6.3f} [{ho.min():>5.3f},{ho.max():>5.3f}]")
|
||||||
|
|
||||||
|
# ---- (4) small-cap: haircut min-order per capitale --------------------
|
||||||
|
print("\n--- (4) SMALL-CAP (min order $5, capitale 50/50 sui 2 asset) ---")
|
||||||
|
print(f"{'config':<22} {'cap':>7} {'Sh model':>9} {'Sh real':>8} {'haircut':>8} "
|
||||||
|
f"{'DD real':>8} {'trade/y':>8}")
|
||||||
|
for name, hs in HEADLINE.items():
|
||||||
|
for cap in (600, 2000, 10000):
|
||||||
|
r = smallcap_port(hs, cap)
|
||||||
|
print(f"{name:<22} {cap:>7} {r['sh_model']:>9.3f} {r['sh_real']:>8.3f} "
|
||||||
|
f"{r['haircut']:>8.3f} {r['dd_real']:>7.1%} {r['trades_per_year']:>8.1f}")
|
||||||
|
|
||||||
|
print("\nFatto. Nessuna selezione sull'hold-out: ensemble a priori, giudizio su "
|
||||||
|
"struttura (varianza) + IS pre-2025; l'hold-out e' solo riportato.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""FILONE 1 — LA CURVA FRONTIERA (r0703_frontier_curve.py)
|
||||||
|
|
||||||
|
MISURA (non ricerca segnali) della frontiera rischio/rendimento dei DUE book che GIA' esistono:
|
||||||
|
- book RESEARCH 5-sleeve: TP01 33 / XS01 15 / VRP01 12 / SKH01 20 / GTAA01 20 (combine_outer)
|
||||||
|
- book LIVE 2-sleeve: TP01 75 / SKH01 25 (cio' che gira su soldi reali su Deribit)
|
||||||
|
|
||||||
|
Per decidere a quale TARGET-VOL conviene girare il book a capitale piccolo (2k / 5k).
|
||||||
|
|
||||||
|
LEVA STUDIATA = moltiplicatore lambda sui rendimenti giornalieri del book combinato:
|
||||||
|
r_scaled(t) = clip(lambda * r_book(t), -1, None) (clip: non si perde >100% in un giorno)
|
||||||
|
Sweep di lambda per coprire target-vol annuo ~5% -> ~30%. Sharpe INVARIANTE per costruzione
|
||||||
|
(lo verifichiamo: cambia solo con l'attivazione del clip a leva alta).
|
||||||
|
|
||||||
|
CONFIDENZA PER-SLEEVE (anti-alllucinazione #2): 3 livelli. La de-luck e l'haircut sono un
|
||||||
|
HAIRCUT SULLA MEDIA (edge), applicato DE-MEANando lo sleeve: s' = s - h*mean(s).
|
||||||
|
=> std invariata (stessa vol/coda), drift ridotto. Quindi tra i 3 livelli cambia SOLO il drift
|
||||||
|
(CAGR / P-rovina via minor deriva), NON la forma della coda -> il messaggio e' la BANDA.
|
||||||
|
(a) FULL : book as-is.
|
||||||
|
(b) DE-LUCK : rimuove l'anchor timing-luck (audit 4/4):
|
||||||
|
SKH01 mean x0.60 (canonica = 93-98 pctl di 23 offset, ~+0.5 HOLD di fortuna),
|
||||||
|
TP01 mean x0.85 (hold-out 0.31 = migliore di 24 ancore, mediana 0.04).
|
||||||
|
VRP01/XS01 NON haircuttati qui: le loro ancore canoniche sono CONSERVATIVE
|
||||||
|
(VRP 7 pctl = peggiore; XS 15 pctl di DD). GTAA: nessuna ancora-luck.
|
||||||
|
(c) HAIRCUT : de-luck + taglio -50% della MEDIA degli sleeve a FIDUCIA BASSA sull'edge
|
||||||
|
(VRP01 premio MODELLATO, f di stress mai catturato; XS01 STAT-MODE, 2.5y).
|
||||||
|
= la "frontiera fidata". Il GAP full<->haircut e' il margine di onesta'.
|
||||||
|
Variante extra: EXCL = escludi del tutto VRP01+XS01 (ultra-conservativo, riferimento).
|
||||||
|
Per il book LIVE 2-sleeve (niente VRP/XS): haircut = de-luck + SKH01 mean a x0.50 dall'originale
|
||||||
|
(SKH e' la gamba a fiducia MEDIA: research, ETH DD margine sottile vs 30%).
|
||||||
|
|
||||||
|
TAIL (anti-allucinazione #1): la coda NON scala lineare-Gaussiano. Quantificato con:
|
||||||
|
- BLOCK BOOTSTRAP (blocchi 10 e 20g, B=5000) sui rendimenti REALI del book (fat-tail+autocorr):
|
||||||
|
P(rovina-50%|5y) = P(equity tocca <=50% del capitale iniziale in un path di 5y)
|
||||||
|
P(DD>30%|5y) = P(max drawdown peak-to-trough > 30% in 5y)
|
||||||
|
- BENCHMARK GAUSSIANO iid (stessa media/vol) -> il GAP misura il contributo fat-tail/autocorr.
|
||||||
|
- INIEZIONE DI CODA SINTETICA: un giorno-crash = 1.5x il peggior giorno storico, ~1 volta per
|
||||||
|
path di 5y -> sensibilita' al "il campione puo' non contenere il crash peggiore".
|
||||||
|
|
||||||
|
Nessuna selezione su hold-out: FULL e HOLD affiancati solo per PROVARE che la frontiera non e'
|
||||||
|
un artefatto in-sample. NON tocca src/config/live/tests: importa solo (sola lettura) i builder.
|
||||||
|
|
||||||
|
Output CSV+report testuale in scratchpad. Girare: uv run python scripts/research/r0703_frontier_curve.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys, os, time
|
||||||
|
sys.path.insert(0, '/opt/docker/PythagorasGoal')
|
||||||
|
sys.path.insert(0, '/opt/docker/PythagorasGoal/scripts/research/alt')
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.portfolio.sleeves import (_tp01_returns, _skyhook_returns, _xsec_returns,
|
||||||
|
_vrp_combo_returns, _gtaa_daily_returns)
|
||||||
|
from src.portfolio.portfolio import combine_outer, to_daily, HOLDOUT
|
||||||
|
|
||||||
|
DPY = 365.25
|
||||||
|
OUT = '/tmp/claude-1001/-opt-docker-PythagorasGoal/e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad'
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
RNG = np.random.default_rng(20260703)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- sleeves
|
||||||
|
def build_sleeves() -> dict[str, pd.Series]:
|
||||||
|
d = {}
|
||||||
|
for nm, fn in [("TP01", _tp01_returns), ("XS01", _xsec_returns), ("VRP01", _vrp_combo_returns),
|
||||||
|
("SKH01", _skyhook_returns), ("GTAA01", _gtaa_daily_returns)]:
|
||||||
|
d[nm] = to_daily(fn())
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def demean_haircut(s: pd.Series, h: float) -> pd.Series:
|
||||||
|
"""s' = s - h*mean(s): riduce il drift del fattore (1-h), std INVARIATA."""
|
||||||
|
if h == 0.0:
|
||||||
|
return s
|
||||||
|
return s - h * float(s.mean())
|
||||||
|
|
||||||
|
|
||||||
|
# per-livello: fattore di haircut sulla MEDIA per sleeve (h)
|
||||||
|
LEVELS_5 = {
|
||||||
|
"FULL": dict(TP01=0.0, XS01=0.0, VRP01=0.0, SKH01=0.0, GTAA01=0.0),
|
||||||
|
"DELUCK": dict(TP01=0.15, XS01=0.0, VRP01=0.0, SKH01=0.40, GTAA01=0.0),
|
||||||
|
"HAIRCUT": dict(TP01=0.15, XS01=0.5, VRP01=0.5, SKH01=0.40, GTAA01=0.0),
|
||||||
|
"EXCL": dict(TP01=0.15, XS01=1.0, VRP01=1.0, SKH01=0.40, GTAA01=0.0), # escludi VRP+XS (mean->0)
|
||||||
|
}
|
||||||
|
W5 = {"TP01": 0.33, "XS01": 0.15, "VRP01": 0.12, "SKH01": 0.20, "GTAA01": 0.20}
|
||||||
|
|
||||||
|
LEVELS_2 = {
|
||||||
|
"FULL": dict(TP01=0.0, SKH01=0.0),
|
||||||
|
"DELUCK": dict(TP01=0.15, SKH01=0.40),
|
||||||
|
"HAIRCUT": dict(TP01=0.15, SKH01=0.50), # SKH = gamba fiducia MEDIA -> taglio piu' profondo
|
||||||
|
}
|
||||||
|
W2 = {"TP01": 0.75, "SKH01": 0.25}
|
||||||
|
|
||||||
|
|
||||||
|
def make_book(sleeves: dict, weights: dict, hair: dict) -> pd.Series:
|
||||||
|
cols = {nm: demean_haircut(sleeves[nm], hair.get(nm, 0.0)) for nm in weights}
|
||||||
|
return combine_outer(cols, weights)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- metrics (full sample, scaled)
|
||||||
|
def full_metrics(book: pd.Series, lam: float) -> dict:
|
||||||
|
r = np.clip(lam * book.values.astype(float), -1.0, None)
|
||||||
|
s = pd.Series(r, index=book.index)
|
||||||
|
eq = np.cumprod(1.0 + r)
|
||||||
|
pk = np.maximum.accumulate(eq)
|
||||||
|
yrs = len(r) / DPY
|
||||||
|
dd = float(np.max((pk - eq) / pk))
|
||||||
|
cagr = float(eq[-1] ** (1 / yrs) - 1) if eq[-1] > 0 else -1.0
|
||||||
|
shp = float(r.mean() / r.std() * np.sqrt(DPY)) if r.std() > 0 else 0.0
|
||||||
|
ww = float(((1.0 + s).resample('168h').prod() - 1.0).min()) # worst-week (168h, non '7D')
|
||||||
|
vol = float(r.std() * np.sqrt(DPY))
|
||||||
|
return dict(sharpe=shp, cagr=cagr, maxdd=dd, worstweek=ww, vol=vol)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- block bootstrap engine
|
||||||
|
def block_index(n: int, L: int, npath: int, B: int) -> np.ndarray:
|
||||||
|
"""Matrice (B, npath) di indici in [0,n) via block-bootstrap circolare-troncato (blocchi L)."""
|
||||||
|
nblk = int(np.ceil(npath / L))
|
||||||
|
starts = RNG.integers(0, n - L + 1, size=(B, nblk)) # start di ogni blocco
|
||||||
|
offs = np.arange(L)
|
||||||
|
idx = (starts[:, :, None] + offs[None, None, :]).reshape(B, nblk * L)[:, :npath]
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
def ruin_dd(Rmat: np.ndarray, lam: float) -> tuple[float, float]:
|
||||||
|
"""P(rovina-50%|5y), P(DD>30%|5y) da una matrice (B,npath) di rendimenti NATIVI, scalata per lam.
|
||||||
|
Versione veloce: la matrice bootstrap (o la coda iniettata, o il gauss iid) e' PRE-costruita una
|
||||||
|
volta per (book,level); qui si applica solo lo scaling di leva + cumprod. Numerica identica a
|
||||||
|
boot_probs/gauss_probs, senza ri-gather/ri-generazione per ogni target-vol."""
|
||||||
|
R = np.clip(lam * Rmat, -1.0, None)
|
||||||
|
eq = np.cumprod(1.0 + R, axis=1)
|
||||||
|
ruin = float((eq.min(axis=1) <= 0.5).mean())
|
||||||
|
pk = np.maximum.accumulate(eq, axis=1)
|
||||||
|
dd = ((pk - eq) / pk).max(axis=1)
|
||||||
|
return ruin, float((dd > 0.30).mean())
|
||||||
|
|
||||||
|
|
||||||
|
def boot_probs(r_native: np.ndarray, lam: float, idx: np.ndarray,
|
||||||
|
inject_crash: float | None = None) -> tuple[float, float]:
|
||||||
|
"""P(rovina-50%|5y), P(DD>30%|5y) su path bootstrap.
|
||||||
|
inject_crash: se non None, in OGNI path sostituisce 1 giorno casuale con questo rendimento
|
||||||
|
NATIVO (pre-scala) -> stress "una coda sintetica per 5 anni"."""
|
||||||
|
R = r_native[idx].copy() # (B, npath) nativi
|
||||||
|
if inject_crash is not None:
|
||||||
|
B, npath = R.shape
|
||||||
|
pos = RNG.integers(0, npath, size=B)
|
||||||
|
R[np.arange(B), pos] = inject_crash
|
||||||
|
R = np.clip(lam * R, -1.0, None)
|
||||||
|
eq = np.cumprod(1.0 + R, axis=1)
|
||||||
|
ruin = float((eq.min(axis=1) <= 0.5).mean()) # tocca 50% del capitale iniziale
|
||||||
|
pk = np.maximum.accumulate(eq, axis=1)
|
||||||
|
dd = ((pk - eq) / pk).max(axis=1)
|
||||||
|
dd30 = float((dd > 0.30).mean())
|
||||||
|
return ruin, dd30
|
||||||
|
|
||||||
|
|
||||||
|
def gauss_probs(mu: float, sd: float, npath: int, B: int) -> tuple[float, float]:
|
||||||
|
"""Benchmark iid-Gaussiano con stessa media/std giornaliera (post-scala)."""
|
||||||
|
R = RNG.normal(mu, sd, size=(B, npath))
|
||||||
|
R = np.clip(R, -1.0, None)
|
||||||
|
eq = np.cumprod(1.0 + R, axis=1)
|
||||||
|
ruin = float((eq.min(axis=1) <= 0.5).mean())
|
||||||
|
pk = np.maximum.accumulate(eq, axis=1)
|
||||||
|
dd = ((pk - eq) / pk).max(axis=1)
|
||||||
|
return ruin, float((dd > 0.30).mean())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- main
|
||||||
|
def run():
|
||||||
|
t0 = time.time()
|
||||||
|
print("building sleeves...", flush=True)
|
||||||
|
sl = build_sleeves()
|
||||||
|
|
||||||
|
NPATH = int(round(5 * DPY)) # 5 anni ~ 1826 giorni-calendario
|
||||||
|
B = 5000
|
||||||
|
TV_GRID = np.round(np.arange(0.05, 0.305, 0.01), 3) # 5%..30% target-vol (fine)
|
||||||
|
TV_FINE = np.round(np.arange(0.04, 0.305, 0.005), 4) # per la ricerca del punto-decisione
|
||||||
|
|
||||||
|
books = {
|
||||||
|
"5SLEEVE": dict(weights=W5, levels=LEVELS_5),
|
||||||
|
"2LIVE": dict(weights=W2, levels=LEVELS_2),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- costruzione book per livello + sanity + banda di confidenza
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("SANITY + BANDA DI CONFIDENZA (FULL vs HOLD affiancati)")
|
||||||
|
print("=" * 78)
|
||||||
|
built = {} # (book,level) -> pd.Series (native, lam=1)
|
||||||
|
for bk, cfg in books.items():
|
||||||
|
for lv, hair in cfg["levels"].items():
|
||||||
|
b = make_book(sl, cfg["weights"], hair)
|
||||||
|
built[(bk, lv)] = b
|
||||||
|
# print sanity for this book, all levels
|
||||||
|
print(f"\n[{bk}] weights={cfg['weights']}")
|
||||||
|
print(f" {'level':9s} {'nat.vol':>8s} {'FULL Sh':>8s} {'HOLD Sh':>8s} "
|
||||||
|
f"{'FULL CAGR':>10s} {'HOLD CAGR':>10s} {'FULL DD':>8s}")
|
||||||
|
for lv in cfg["levels"]:
|
||||||
|
b = built[(bk, lv)]
|
||||||
|
mF = full_metrics(b, 1.0)
|
||||||
|
bh = b[b.index >= HOLDOUT]
|
||||||
|
mH = full_metrics(bh, 1.0)
|
||||||
|
print(f" {lv:9s} {mF['vol']*100:7.2f}% {mF['sharpe']:8.3f} {mH['sharpe']:8.3f} "
|
||||||
|
f"{mF['cagr']*100:9.2f}% {mH['cagr']*100:9.2f}% {mF['maxdd']*100:7.2f}%")
|
||||||
|
|
||||||
|
# ---- anchor band (citata dagli audit; non ricalcolata qui)
|
||||||
|
print("\n" + "-" * 78)
|
||||||
|
print("BANDA D'ANCORA (dove il DE-LUCK e' ancorato — audit 4/4, diari 2026-07-02/03):")
|
||||||
|
print(" TP01 : hold-out 0.31 = MIGLIORE di 24 ancore orarie (mediana 0.04, banda [-0.13,+0.30],")
|
||||||
|
print(" P~0.86 che un'ancora mostri tale spike per caso) -> de-luck mean x0.85")
|
||||||
|
print(" SKH01: canonica = 93-98 pctl di 23 offset (griglia 230/690m), ~+0.5 HOLD di fortuna sul")
|
||||||
|
print(" book; gate DD<30% fallisce in 15/23 offset -> de-luck mean x0.60")
|
||||||
|
print(" XS01 : canonica = 15 pctl di DD (10 fasi) -> CONSERVATIVA sul DD, nessun de-luck edge")
|
||||||
|
print(" VRP01: canonica = PEGGIORE (7 pctl, 7 giorni) -> CONSERVATIVA, nessun de-luck edge")
|
||||||
|
print(" GTAA : validato OOS 30y indipendente, nessuna ancora-luck")
|
||||||
|
print(" => il DE-LUCK haircutta SOLO gli sleeve con ancora-FORTUNATA (TP01/SKH01).")
|
||||||
|
|
||||||
|
# ---- frontiera + bootstrap
|
||||||
|
rows = [] # per CSV
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("FRONTIERA + BLOCK-BOOTSTRAP (B=%d, 5y=%d gg, blocchi 10 & 20)" % (B, NPATH))
|
||||||
|
print("=" * 78)
|
||||||
|
|
||||||
|
# crash sintetico per book: 1.5x il peggior giorno storico del book FULL (nativo)
|
||||||
|
for bk, cfg in books.items():
|
||||||
|
native_min = float(built[(bk, "FULL")].values.min())
|
||||||
|
crash = 1.5 * native_min
|
||||||
|
n = len(built[(bk, "FULL")])
|
||||||
|
idx10 = block_index(n, 10, NPATH, B)
|
||||||
|
idx20 = block_index(n, 20, NPATH, B)
|
||||||
|
print(f"\n########## BOOK {bk} (n={n} gg, worst-day nativo {native_min*100:.2f}%, "
|
||||||
|
f"crash sintetico {crash*100:.2f}%) ##########")
|
||||||
|
for lv in cfg["levels"]:
|
||||||
|
b = built[(bk, lv)]
|
||||||
|
r_nat = b.values.astype(float)
|
||||||
|
bh = b[b.index >= HOLDOUT]
|
||||||
|
r_hold = bh.values.astype(float)
|
||||||
|
nat_vol = float(r_nat.std() * np.sqrt(DPY))
|
||||||
|
# PRE-costruzione (una volta per level) delle matrici bootstrap NATIVE + coda + gauss:
|
||||||
|
R20 = r_nat[idx20] # (B, npath) block-20
|
||||||
|
R10 = r_nat[idx10] # (B, npath) block-10
|
||||||
|
R20c = R20.copy() # coda sintetica iniettata 1x/path
|
||||||
|
_pos = RNG.integers(0, R20c.shape[1], size=R20c.shape[0])
|
||||||
|
R20c[np.arange(R20c.shape[0]), _pos] = crash
|
||||||
|
Z = RNG.standard_normal(R20.shape) # normali std fisse (gauss iid)
|
||||||
|
mu0 = float(r_nat.mean()); sd0 = float(r_nat.std())
|
||||||
|
print(f"\n--- {bk} / {lv} (native vol {nat_vol*100:.2f}%, "
|
||||||
|
f"FULL Sh {full_metrics(b,1.0)['sharpe']:.3f}, "
|
||||||
|
f"HOLD Sh {full_metrics(bh,1.0)['sharpe']:.3f}) ---")
|
||||||
|
hdr = (f" {'tvol':>5s} {'lam':>5s} {'Sh':>6s} {'CAGRf':>7s} {'CAGRh':>7s} "
|
||||||
|
f"{'maxDDf':>7s} {'wWeek':>7s} | {'Pruin20':>8s} {'Pdd30_20':>9s} "
|
||||||
|
f"{'Pruin10':>8s} {'Pdd30_10':>9s} | {'Gruin':>7s} {'Gdd30':>7s} | "
|
||||||
|
f"{'Pruin+cr':>9s} {'Pdd30+cr':>9s}")
|
||||||
|
print(hdr)
|
||||||
|
for tv in TV_GRID:
|
||||||
|
lam = tv / nat_vol
|
||||||
|
mF = full_metrics(b, lam)
|
||||||
|
mH = full_metrics(bh, lam)
|
||||||
|
r20, d20 = ruin_dd(R20, lam)
|
||||||
|
r10, d10 = ruin_dd(R10, lam)
|
||||||
|
# gaussiano iid con media/std della serie SCALATA (Rg = lam*mu0 + lam*sd0*Z)
|
||||||
|
gr, gd = ruin_dd((lam * mu0) + (lam * sd0) * Z, 1.0)
|
||||||
|
# tail injection (blocco 20)
|
||||||
|
rc, dc = ruin_dd(R20c, lam)
|
||||||
|
print(f" {tv*100:4.0f}% {lam:5.2f} {mF['sharpe']:6.2f} "
|
||||||
|
f"{mF['cagr']*100:6.1f}% {mH['cagr']*100:6.1f}% "
|
||||||
|
f"{mF['maxdd']*100:6.1f}% {mF['worstweek']*100:6.1f}% | "
|
||||||
|
f"{r20*100:7.2f}% {d20*100:8.2f}% {r10*100:7.2f}% {d10*100:8.2f}% | "
|
||||||
|
f"{gr*100:6.2f}% {gd*100:6.2f}% | {rc*100:8.2f}% {dc*100:8.2f}%")
|
||||||
|
rows.append(dict(book=bk, level=lv, target_vol=tv, lam=round(lam, 3),
|
||||||
|
sharpe=round(mF['sharpe'], 3), cagr_full=round(mF['cagr'], 4),
|
||||||
|
cagr_hold=round(mH['cagr'], 4), maxdd_full=round(mF['maxdd'], 4),
|
||||||
|
worstweek=round(mF['worstweek'], 4),
|
||||||
|
p_ruin50_b20=round(r20, 4), p_dd30_b20=round(d20, 4),
|
||||||
|
p_ruin50_b10=round(r10, 4), p_dd30_b10=round(d10, 4),
|
||||||
|
p_ruin50_gauss=round(gr, 4), p_dd30_gauss=round(gd, 4),
|
||||||
|
p_ruin50_crash=round(rc, 4), p_dd30_crash=round(dc, 4)))
|
||||||
|
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
csv = os.path.join(OUT, 'frontier_curve.csv')
|
||||||
|
df.to_csv(csv, index=False)
|
||||||
|
print(f"\nCSV completo -> {csv}")
|
||||||
|
|
||||||
|
# ---- punto OGGI (lam=1) marcato
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("PUNTO OPERATIVO OGGI (lambda=1, book as-run):")
|
||||||
|
for bk, cfg in books.items():
|
||||||
|
b = built[(bk, "FULL")]
|
||||||
|
m = full_metrics(b, 1.0)
|
||||||
|
print(f" {bk}: native vol {m['vol']*100:.2f}% => target-vol as-run ~{m['vol']*100:.1f}%, "
|
||||||
|
f"lambda=1, FULL maxDD {m['maxdd']*100:.2f}%")
|
||||||
|
|
||||||
|
# ---- NUMERO-DECISIONE: max CAGR con P(rovina-50%|5y) <= 1%
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("NUMERO-DECISIONE: max target-vol/CAGR con P(rovina-50%|5y) <= 1.0%")
|
||||||
|
print(" (headline = block-20 baseline; verifica block-10 e coda-iniettata)")
|
||||||
|
print("=" * 78)
|
||||||
|
dec = []
|
||||||
|
for bk, cfg in books.items():
|
||||||
|
n = len(built[(bk, "FULL")])
|
||||||
|
idx20 = block_index(n, 20, NPATH, B)
|
||||||
|
idx10 = block_index(n, 10, NPATH, B)
|
||||||
|
native_min = float(built[(bk, "FULL")].values.min())
|
||||||
|
crash = 1.5 * native_min
|
||||||
|
for lv in cfg["levels"]:
|
||||||
|
b = built[(bk, lv)]
|
||||||
|
r_nat = b.values.astype(float)
|
||||||
|
bh = b[b.index >= HOLDOUT]
|
||||||
|
nat_vol = float(r_nat.std() * np.sqrt(DPY))
|
||||||
|
R20 = r_nat[idx20]; R10 = r_nat[idx10]
|
||||||
|
R20c = R20.copy()
|
||||||
|
_pos = RNG.integers(0, R20c.shape[1], size=R20c.shape[0])
|
||||||
|
R20c[np.arange(R20c.shape[0]), _pos] = crash
|
||||||
|
best = None
|
||||||
|
for tv in TV_FINE:
|
||||||
|
lam = tv / nat_vol
|
||||||
|
r20, _ = ruin_dd(R20, lam)
|
||||||
|
if r20 <= 0.01:
|
||||||
|
best = tv
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if best is None:
|
||||||
|
dec.append(dict(book=bk, level=lv, tvmax=np.nan))
|
||||||
|
print(f" {bk:8s}/{lv:8s}: nessun punto con P(ruin)<=1% (troppo grasso)")
|
||||||
|
continue
|
||||||
|
lam = best / nat_vol
|
||||||
|
mF = full_metrics(b, lam); mH = full_metrics(bh, lam)
|
||||||
|
r20, d20 = ruin_dd(R20, lam)
|
||||||
|
r10, d10 = ruin_dd(R10, lam)
|
||||||
|
rc, dc = ruin_dd(R20c, lam)
|
||||||
|
dec.append(dict(book=bk, level=lv, tvmax=best, lam=round(lam, 3),
|
||||||
|
cagr_full=mF['cagr'], cagr_hold=mH['cagr'], maxdd=mF['maxdd'],
|
||||||
|
pruin20=r20, pruin10=r10, pruin_crash=rc, pdd30=d20))
|
||||||
|
print(f" {bk:8s}/{lv:8s}: tvol_max {best*100:4.1f}% (lam {lam:.2f}) -> "
|
||||||
|
f"CAGR_full {mF['cagr']*100:5.1f}% / CAGR_hold {mH['cagr']*100:5.1f}% ; "
|
||||||
|
f"maxDDfull {mF['maxdd']*100:4.1f}% ; P(ruin) b20 {r20*100:.2f}% / "
|
||||||
|
f"b10 {r10*100:.2f}% / +crash {rc*100:.2f}% ; P(DD>30) {d20*100:.2f}%")
|
||||||
|
pd.DataFrame(dec).to_csv(os.path.join(OUT, 'decision_pruin1pct.csv'), index=False)
|
||||||
|
|
||||||
|
# ---- €/giorno a 2k e 5k dal CAGR (haircut = frontiera fidata)
|
||||||
|
print("\n" + "-" * 78)
|
||||||
|
print("TRADUZIONE €/giorno (CAGR haircut = frontiera fidata):")
|
||||||
|
for bk in books:
|
||||||
|
row = next((x for x in dec if x['book'] == bk and x['level'] == 'HAIRCUT'), None)
|
||||||
|
if row and not np.isnan(row.get('tvmax', np.nan)):
|
||||||
|
for cap in (2000, 5000):
|
||||||
|
eur = cap * row['cagr_full'] / 365.25
|
||||||
|
print(f" {bk}/HAIRCUT @ tvol {row['tvmax']*100:.1f}%: {cap}EUR -> "
|
||||||
|
f"~{eur:.2f} EUR/giorno (CAGR {row['cagr_full']*100:.1f}%)")
|
||||||
|
|
||||||
|
print(f"\ndone in {time.time()-t0:.0f}s")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
"""FILONE 3 (2026-07-03) — FRONTIERA ESEGUIBILE a 2k/5k + STAGING KELLY.
|
||||||
|
|
||||||
|
NON e' ricerca segnali (il soffitto direzionale ~1.3 e' saturo): rende OPERATIVA la frontiera
|
||||||
|
rischio/rendimento dei DUE book che GIA' esistono, per decidere a quale TARGET-VOL girarli a
|
||||||
|
capitale piccolo. Riusa r0702_capital_scaling.py (granularita' reale) e la macchina bootstrap dei
|
||||||
|
filoni 1/2. NON tocca src/config/live/tests: importa solo (sola lettura) i builder.
|
||||||
|
|
||||||
|
(A) FRONTIERA ESEGUIBILE del BOOK LIVE (TP01+SKH01 75/25) a capitale {2000,3500,5000}:
|
||||||
|
il target notional per-asset del book e' net = clamp(0.5*E*(0.75*max(tp,0)+0.25*skh), +-cap)
|
||||||
|
(formula ESATTA di src/live/book.book_net_target). La LEVA studiata = moltiplicatore lambda
|
||||||
|
su quel target (target-vol = lambda*vol_nativa). A ogni (capitale, lambda):
|
||||||
|
- il CAP degenera dall'ALTO: quanto della vol richiesta il cap $300 lascia esprimere, e
|
||||||
|
quanta ne sblocca il cap = equity/2 -> "cap ceiling" sul target-vol raggiungibile;
|
||||||
|
- il MIN-ORDER degenera dal BASSO: granularita' reale (step BTC 0.0001~$7, ETH 0.001~$2,
|
||||||
|
floor config $5) -> il target-vol sotto cui la posizione/ribilancio non si esegue;
|
||||||
|
- il DD in EURO (non solo %): -20% su 2k = -EUR 400 -> tabella DD-in-euro a ogni lambda.
|
||||||
|
(B) STAGING KELLY: target-vol growth-optimal (full / half / quarter-Kelly) del book FIDATO,
|
||||||
|
con le CODE ONESTE del filone 2 (block-bootstrap fat-tail, NON Gaussiano). Confronto col
|
||||||
|
posizionamento attuale (~6% vol) -> di quanti punti di vol e' sotto l'ottimo di crescita?
|
||||||
|
Tesi: 'a capitale piccolo con obiettivo di crescita i 6% sono iper-prudenti; l'ottimo
|
||||||
|
Kelly-frazionario e' piu' caldo' -> confermata o refutata coi numeri (E col vincolo di rovina).
|
||||||
|
(C) AGGRESSIVITA' PER-SLEEVE ~ CONFIDENZA: correre TP01 (validato) piu' caldo di VRP (modellato)?
|
||||||
|
Book a target-vol DIFFERENZIATO per sleeve (moltiplicatore di esposizione ~ fiducia
|
||||||
|
sull'EDGE) vs uniforme, a book-vol PARI -> il differenziato domina l'uniforme sulla
|
||||||
|
frontiera fidata? (Sharpe/DD/P-rovina.)
|
||||||
|
|
||||||
|
Confidenza per-sleeve (edge, NON vol): TP01 piena, GTAA alta, SKH media, VRP/XS bassa.
|
||||||
|
Tre livelli di frontiera (la BANDA e' il messaggio): FULL / DE-LUCK (anchor-audit 4/4) /
|
||||||
|
HAIRCUT (de-luck + taglio -50% della MEDIA di VRP/XS = frontiera FIDATA).
|
||||||
|
|
||||||
|
uv run python scripts/research/r0703_frontier_exec.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys, os, math, time
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = "/opt/docker/PythagorasGoal"
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
sys.path.insert(0, ROOT + "/scripts/research/alt")
|
||||||
|
sys.path.insert(0, ROOT + "/scripts/research")
|
||||||
|
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
from src.portfolio.sleeves import (_tp01_returns, _skyhook_returns, _xsec_returns, # noqa: E402
|
||||||
|
_vrp_combo_returns, _gtaa_daily_returns)
|
||||||
|
from src.portfolio.portfolio import combine_outer, to_daily, HOLDOUT # noqa: E402
|
||||||
|
# riuso della macchina di scala (granularita' reale) del filone capital-scaling
|
||||||
|
from r0702_capital_scaling import skh_sign_series, tp_frac_on_ltf, book_sim, TP # noqa: E402
|
||||||
|
|
||||||
|
DPY = 365.25
|
||||||
|
FEE_SIDE = al.FEE_SIDE
|
||||||
|
WEIGHT, W_TP01, W_SKH = 0.5, 0.75, 0.25 # src/live/book.py + shadow.WEIGHT
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
CAPS = (2000.0, 3500.0, 5000.0)
|
||||||
|
CAP_NOW = 300.0
|
||||||
|
MIN_ORDER = 5.0
|
||||||
|
TVOLS = [0.05, 0.06, 0.08, 0.10, 0.125, 0.15, 0.20, 0.25, 0.30]
|
||||||
|
OUT = "/tmp/claude-1001/-opt-docker-PythagorasGoal/e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad"
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
SEED = 20260703
|
||||||
|
RNG = np.random.default_rng(SEED)
|
||||||
|
|
||||||
|
REP = []
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a); print(s, flush=True); REP.append(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ metriche base
|
||||||
|
def _r(s): return np.asarray(pd.Series(s).dropna().values, float)
|
||||||
|
def ann_vol(s): r = _r(s); return float(r.std() * math.sqrt(DPY)) if len(r) else 0.0
|
||||||
|
def ann_mean(s): r = _r(s); return float(r.mean() * DPY) if len(r) else 0.0
|
||||||
|
def sharpe(s):
|
||||||
|
r = _r(s); return float(r.mean() / r.std() * math.sqrt(DPY)) if len(r) > 1 and r.std() > 0 else 0.0
|
||||||
|
def cagr(s):
|
||||||
|
r = _r(s); eq = np.cumprod(1 + np.clip(r, -1, None)); y = len(r) / DPY
|
||||||
|
return float(eq[-1] ** (1 / y) - 1) if y > 0 and eq[-1] > 0 else -1.0
|
||||||
|
def maxdd(s):
|
||||||
|
r = _r(s); eq = np.cumprod(1 + np.clip(r, -1, None)); pk = np.maximum.accumulate(eq)
|
||||||
|
return float(np.max((pk - eq) / pk)) if len(r) else 0.0
|
||||||
|
def worst_week(s):
|
||||||
|
ss = pd.Series(s).dropna()
|
||||||
|
if ss.index.tz is None:
|
||||||
|
try: ss.index = ss.index.tz_localize("UTC")
|
||||||
|
except Exception: pass
|
||||||
|
return float(((1 + ss).resample("168h").prod() - 1).min())
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ sleeves + book variants (per la parte RISCHIO)
|
||||||
|
def build_sleeves():
|
||||||
|
d = {}
|
||||||
|
for nm, fn in [("TP01", _tp01_returns), ("XS01", _xsec_returns), ("VRP01", _vrp_combo_returns),
|
||||||
|
("SKH01", _skyhook_returns), ("GTAA01", _gtaa_daily_returns)]:
|
||||||
|
d[nm] = to_daily(fn())
|
||||||
|
return d
|
||||||
|
|
||||||
|
def demean(s, h):
|
||||||
|
return s if h == 0.0 else s - h * float(s.mean())
|
||||||
|
|
||||||
|
W5 = {"TP01": 0.33, "XS01": 0.15, "VRP01": 0.12, "SKH01": 0.20, "GTAA01": 0.20}
|
||||||
|
W2 = {"TP01": 0.75, "SKH01": 0.25}
|
||||||
|
# haircut sulla MEDIA (edge), std/coda INVARIATE (filoni 1/2)
|
||||||
|
LV5 = {
|
||||||
|
"FULL": dict(TP01=0.0, XS01=0.0, VRP01=0.0, SKH01=0.0, GTAA01=0.0),
|
||||||
|
"DELUCK": dict(TP01=0.15, XS01=0.0, VRP01=0.0, SKH01=0.40, GTAA01=0.0),
|
||||||
|
"HAIRCUT": dict(TP01=0.15, XS01=0.5, VRP01=0.5, SKH01=0.40, GTAA01=0.0),
|
||||||
|
}
|
||||||
|
LV2 = {
|
||||||
|
"FULL": dict(TP01=0.0, SKH01=0.0),
|
||||||
|
"DELUCK": dict(TP01=0.15, SKH01=0.40),
|
||||||
|
"HAIRCUT": dict(TP01=0.15, SKH01=0.50),
|
||||||
|
}
|
||||||
|
# confidenza sull'EDGE -> moltiplicatore di ESPOSIZIONE per la parte (C)
|
||||||
|
CONF = dict(TP01=1.00, GTAA01=0.90, SKH01=0.70, VRP01=0.45, XS01=0.45)
|
||||||
|
|
||||||
|
|
||||||
|
def make_book(sl, weights, hair):
|
||||||
|
cols = {nm: demean(sl[nm], hair.get(nm, 0.0)) for nm in weights}
|
||||||
|
return combine_outer(cols, weights)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ block-bootstrap (fat-tail, filoni 1/2)
|
||||||
|
def make_paths(r, n_paths, horizon, block, rng):
|
||||||
|
r = _r(r); N = len(r); nb = int(math.ceil(horizon / block))
|
||||||
|
starts = rng.integers(0, N - block + 1, size=(n_paths, nb))
|
||||||
|
off = np.arange(block)
|
||||||
|
idx = (starts[:, :, None] + off[None, None, :]).reshape(n_paths, nb * block)[:, :horizon]
|
||||||
|
return r[idx].astype(np.float64)
|
||||||
|
|
||||||
|
def path_stats(scaled):
|
||||||
|
"""(n,H) rendimenti scalati -> maxDD per path, terminal wealth (floor 0), log-terminal."""
|
||||||
|
sc = np.clip(scaled, -1.0, None)
|
||||||
|
eq = np.cumprod(1.0 + sc, axis=1)
|
||||||
|
pk = np.maximum.accumulate(eq, axis=1)
|
||||||
|
dd = (pk - eq) / pk
|
||||||
|
term = eq[:, -1]
|
||||||
|
return dd.max(axis=1), term
|
||||||
|
|
||||||
|
NPATH = 4000
|
||||||
|
HOR = int(round(5 * 365))
|
||||||
|
BLOCK = 15
|
||||||
|
RUIN = 0.50 # rovina = maxDD >= 50% (equity <= 50% del capitale iniziale)
|
||||||
|
DDLIM = 0.30
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ (SANITY)
|
||||||
|
def sanity(sl):
|
||||||
|
P("=" * 110)
|
||||||
|
P("SANITY — ricostruzione book (tolleranza per deriva dati)")
|
||||||
|
P("=" * 110)
|
||||||
|
b5 = make_book(sl, W5, LV5["FULL"]); b2 = make_book(sl, W2, LV2["FULL"])
|
||||||
|
for nm, b, tgt in [("5-SLEEVE", b5, "[target 2.24 / 2.46 / 6.2%]"),
|
||||||
|
("2-SLEEVE LIVE", b2, "[target ~1.78 / 1.17 / 9%]")]:
|
||||||
|
ho = b[b.index >= HOLDOUT]
|
||||||
|
P(f" {nm:14s} n={len(b):5d} FULL Sh={sharpe(b):.3f} DD={maxdd(b)*100:5.2f}% vol={ann_vol(b)*100:5.2f}% "
|
||||||
|
f"CAGR={cagr(b)*100:5.2f}% | HOLD Sh={sharpe(ho):.3f} DD={maxdd(ho)*100:5.2f}% {tgt}")
|
||||||
|
P(" per-sleeve (FULL):")
|
||||||
|
for nm in ("TP01", "XS01", "VRP01", "SKH01", "GTAA01"):
|
||||||
|
s = sl[nm]
|
||||||
|
P(f" {nm:8s} Sh={sharpe(s):+.2f} vol={ann_vol(s)*100:5.2f}% CAGR={cagr(s)*100:+6.2f}% "
|
||||||
|
f"DD={maxdd(s)*100:5.2f}% worstDay={float(pd.Series(s).min())*100:+.2f}% n={len(s)}")
|
||||||
|
return b5, b2
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ FRONTIER TABLE consolidata (deliverable)
|
||||||
|
def frontier_table(sl):
|
||||||
|
"""Tabella-frontiera del deliverable: target-vol/lambda x {Sharpe, CAGR, maxDD, worst-week,
|
||||||
|
P(rovina50%|5y), P(DD>30%|5y)} per BOOK 5-sleeve E 2-sleeve, ai 3 livelli di confidenza
|
||||||
|
(FULL / DE-LUCK / HAIRCUT). Sharpe e' INVARIANTE per leva (native); CAGR/DD scalano; le prob.
|
||||||
|
di rovina/DD>30 vengono dal block-bootstrap fat-tail 5y (le stesse dei filoni 1/2)."""
|
||||||
|
P("\n" + "=" * 110)
|
||||||
|
P("FRONTIER TABLE (deliverable) — target-vol x metriche, 2 book x 3 livelli confidenza")
|
||||||
|
P(" Sharpe invariante-per-leva (native) ; CAGR/maxDD/wWeek = leva applicata ; Pruin/Pdd30 = 5y block-bootstrap fat-tail")
|
||||||
|
P("=" * 110)
|
||||||
|
specs = [("5-SLEEVE", W5, LV5), ("2-SLEEVE(live)", W2, LV2)]
|
||||||
|
for bname, weights, levels in specs:
|
||||||
|
for lv in ("FULL", "DELUCK", "HAIRCUT"):
|
||||||
|
b = make_book(sl, weights, levels[lv]); v0 = ann_vol(b)
|
||||||
|
ho = b[b.index >= HOLDOUT]
|
||||||
|
bp = make_paths(b, NPATH, HOR, BLOCK, np.random.default_rng(SEED + 51 + hash(bname + lv) % 9973))
|
||||||
|
P(f"\n --- {bname} / {lv} (native vol {v0*100:.2f}%, Sh_full {sharpe(b):.3f}, Sh_hold {sharpe(ho):.3f}) ---")
|
||||||
|
P(f" {'tvol':>5} {'lam':>5} {'Sharpe':>7} {'CAGR':>7} {'maxDD':>7} {'wWeek':>7} {'Pruin50':>8} {'Pdd30':>7}")
|
||||||
|
for tv in TVOLS:
|
||||||
|
lam = tv / v0
|
||||||
|
rr = pd.Series(_r(b) * lam, index=pd.Series(b).dropna().index)
|
||||||
|
mdd, _ = path_stats(bp * lam)
|
||||||
|
P(f" {tv*100:4.0f}% {lam:5.2f} {sharpe(rr):7.3f} {cagr(rr)*100:6.1f}% "
|
||||||
|
f"{maxdd(rr)*100:6.2f}% {worst_week(rr)*100:6.2f}% {float((mdd>=RUIN).mean())*100:7.2f}% "
|
||||||
|
f"{float((mdd>=DDLIM).mean())*100:6.2f}%")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ (A) FRONTIERA ESEGUIBILE (book live)
|
||||||
|
def daily_frames():
|
||||||
|
"""Frame giornaliero per asset con tp_frac (>=0), skh_sign (+-1) e ret. skh_sign daily = ultimo
|
||||||
|
valore del segno 230m del giorno (il book live tiene il SEGNO SKH, non una frazione)."""
|
||||||
|
frames = {}; skh_cache = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
d1 = al.get(a, "1d")
|
||||||
|
ts = d1["timestamp"].values.astype("int64")
|
||||||
|
close = d1["close"].values.astype(float)
|
||||||
|
tp = np.nan_to_num(np.asarray(TP.target_series(d1), float))
|
||||||
|
ret = np.zeros(len(close)); ret[1:] = close[1:] / close[:-1] - 1.0
|
||||||
|
idx = pd.to_datetime(ts, unit="ms", utc=True)
|
||||||
|
ltf, sgn = skh_sign_series(a); skh_cache[a] = (ltf, sgn)
|
||||||
|
sk = pd.Series(sgn, index=pd.to_datetime(ltf["timestamp"].values.astype("int64"), unit="ms", utc=True))
|
||||||
|
sk_d = sk.resample("1D").last().reindex(idx, method="ffill").fillna(0.0).values
|
||||||
|
frames[a] = pd.DataFrame({"ret": ret, "tp": np.maximum(tp, 0.0), "skh": sk_d}, index=idx)
|
||||||
|
common = frames["BTC"].index.intersection(frames["ETH"].index)
|
||||||
|
for a in ASSETS:
|
||||||
|
frames[a] = frames[a].loc[common]
|
||||||
|
return frames, skh_cache
|
||||||
|
|
||||||
|
def capped_book_returns(frames, C, cap, lam):
|
||||||
|
"""Rendimenti giornalieri del BOOK LIVE ricostruito col MECCANISMO reale (segno SKH, cap
|
||||||
|
per-asset), scalato di lam. posfrac_a = clip(lam*0.5*(0.75*tp+0.25*skh), +-cap/C)."""
|
||||||
|
capfrac = cap / C
|
||||||
|
tot = np.zeros(len(frames["BTC"])); fee = np.zeros_like(tot); prev = {a: 0.0 for a in ASSETS}
|
||||||
|
posf = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
f = frames[a]
|
||||||
|
raw = lam * WEIGHT * (W_TP01 * f["tp"].values + W_SKH * f["skh"].values)
|
||||||
|
posf[a] = np.clip(raw, -capfrac, capfrac)
|
||||||
|
for a in ASSETS:
|
||||||
|
pf = posf[a]; r = frames[a]["ret"].values
|
||||||
|
held = np.zeros_like(pf); held[1:] = pf[:-1]
|
||||||
|
tot += held * r
|
||||||
|
d = np.abs(np.diff(pf, prepend=0.0))
|
||||||
|
fee += FEE_SIDE * d
|
||||||
|
net = tot - fee
|
||||||
|
return pd.Series(net, index=frames["BTC"].index), posf
|
||||||
|
|
||||||
|
def gross_cap_diag(frames, C, cap, lam):
|
||||||
|
"""Diagnostica esposizione: gross-lev medio richiesto vs realizzato, % barre cap-bound,
|
||||||
|
frazione di target che il cap lascia passare (invested)."""
|
||||||
|
capfrac = cap / C
|
||||||
|
raw_g = np.zeros(len(frames["BTC"])); cap_g = np.zeros_like(raw_g); bind = np.zeros_like(raw_g)
|
||||||
|
for a in ASSETS:
|
||||||
|
f = frames[a]
|
||||||
|
raw = lam * WEIGHT * (W_TP01 * f["tp"].values + W_SKH * f["skh"].values)
|
||||||
|
cp = np.clip(raw, -capfrac, capfrac)
|
||||||
|
raw_g += np.abs(raw); cap_g += np.abs(cp)
|
||||||
|
bind += (np.abs(raw) > capfrac + 1e-12).astype(float)
|
||||||
|
active = raw_g > 1e-9
|
||||||
|
invested = float(cap_g[active].sum() / raw_g[active].sum()) if active.any() else 1.0
|
||||||
|
return dict(gross_req=float(np.mean(raw_g)), gross_real=float(np.mean(cap_g)),
|
||||||
|
cap_bind=float(np.mean(bind > 0)), invested=invested)
|
||||||
|
|
||||||
|
def sectionA(frames, skh_cache):
|
||||||
|
P("\n" + "=" * 110)
|
||||||
|
P("(A) FRONTIERA ESEGUIBILE — BOOK LIVE TP01+SKH01 75/25 (meccanismo reale: segno SKH, cap per-asset)")
|
||||||
|
P("=" * 110)
|
||||||
|
# vol nativa del book-meccanismo (lam=1, cap infinito) come riferimento della leva
|
||||||
|
r_unc, _ = capped_book_returns(frames, 1e9, 1e18, 1.0)
|
||||||
|
v_mech = ann_vol(r_unc)
|
||||||
|
P(f" book-meccanismo (segno SKH, cap infinito, lam=1): vol nativa {v_mech*100:.2f}% "
|
||||||
|
f"Sh {sharpe(r_unc):.2f} DD {maxdd(r_unc)*100:.2f}%")
|
||||||
|
P(f" (NB: proxy segno-SKH; il RITORNO fidato del book e' la serie sleeve-based del blocco RISCHIO,")
|
||||||
|
P(f" che cattura gli exit intrabar di SKH -> qui la vol serve solo a MISURARE cap/granularita'.)")
|
||||||
|
|
||||||
|
# ---- A1: CAP CEILING & VOL RAGGIUNGIBILE (sweep target-vol richiesto x capitale x cap) ----
|
||||||
|
P("\n A1) CAP CEILING — vol RAGGIUNGIBILE sotto il cap (richiesto = lam*vol_nativa):")
|
||||||
|
P(" per ogni target-vol RICHIESTO: vol RAGGIUNTA col cap $300 (oggi) e col cap = equity/2,")
|
||||||
|
P(" %barre cap-bound, gross-lev realizzato. Il cap degenera quando 'raggiunta' smette di salire.")
|
||||||
|
a1 = []
|
||||||
|
for C in CAPS:
|
||||||
|
P(f"\n --- capitale ${C:,.0f} (cap oggi $300 = {300/C*100:.0f}% eq ; cap prop = equity/2 = ${C/2:,.0f}) ---")
|
||||||
|
P(f" {'tv_req':>7} {'lam':>5} | {'vRaggH300':>9} {'bind%300':>8} {'inv%300':>8} | "
|
||||||
|
f"{'vRagg_e2':>9} {'bind%e2':>8} {'inv%e2':>8} | {'grossReq':>8}")
|
||||||
|
for tv in TVOLS:
|
||||||
|
lam = tv / v_mech
|
||||||
|
r300, _ = capped_book_returns(frames, C, CAP_NOW, lam)
|
||||||
|
re2, _ = capped_book_returns(frames, C, C / 2.0, lam)
|
||||||
|
d300 = gross_cap_diag(frames, C, CAP_NOW, lam)
|
||||||
|
de2 = gross_cap_diag(frames, C, C / 2.0, lam)
|
||||||
|
a1.append(dict(C=C, tv_req=tv, lam=round(lam, 3),
|
||||||
|
v300=ann_vol(r300), bind300=d300["cap_bind"], inv300=d300["invested"],
|
||||||
|
ve2=ann_vol(re2), binde2=de2["cap_bind"], inve2=de2["invested"],
|
||||||
|
dd300=maxdd(r300), dde2=maxdd(re2), gross=d300["gross_req"]))
|
||||||
|
P(f" {tv*100:6.1f}% {lam:5.2f} | {ann_vol(r300)*100:8.2f}% {d300['cap_bind']*100:7.1f}% "
|
||||||
|
f"{d300['invested']*100:7.1f}% | {ann_vol(re2)*100:8.2f}% {de2['cap_bind']*100:7.1f}% "
|
||||||
|
f"{de2['invested']*100:7.1f}% | {d300['gross_req']:8.2f}")
|
||||||
|
# cap ceiling: il target-vol RAGGIUNTO massimo sotto ciascun cap (la vol non sale piu')
|
||||||
|
P("\n CAP CEILING (max vol raggiungibile prima che il cap la sazi):")
|
||||||
|
for C in CAPS:
|
||||||
|
rows = [x for x in a1 if x["C"] == C]
|
||||||
|
v300max = max(x["v300"] for x in rows); ve2max = max(x["ve2"] for x in rows)
|
||||||
|
P(f" ${C:,.0f}: cap $300 -> tetto vol ~{v300max*100:.1f}% | cap equity/2 -> tetto vol ~{ve2max*100:.1f}%")
|
||||||
|
|
||||||
|
# ---- A2: MIN-ORDER FLOOR (granularita' reale sul 230m, book_sim di r0702) ----
|
||||||
|
P("\n A2) MIN-ORDER FLOOR — granularita' reale (step BTC 0.0001~$7 / ETH 0.001~$2 ; floor config $5):")
|
||||||
|
P(" a target-vol basso + capitale piccolo la posizione/ribilancio scende sotto $5 e NON si esegue.")
|
||||||
|
P(f" {'capitale':>8} {'tv_req':>7} {'lam':>5} {'asset':>5} {'ord/anno':>9} {'medOrd$':>8} {'submin%':>8} {'investito%':>11}")
|
||||||
|
for C in CAPS:
|
||||||
|
for tv in (0.05, 0.10, 0.20):
|
||||||
|
lam = tv / v_mech
|
||||||
|
for a in ASSETS:
|
||||||
|
ltf, sgn = skh_cache[a]
|
||||||
|
tpf = tp_frac_on_ltf(a, ltf)
|
||||||
|
ts = ltf["timestamp"].values.astype("int64")
|
||||||
|
# book_sim di r0702 con lam sul segnale (cap = equity/2 proposto)
|
||||||
|
r = book_sim(lam * tpf, sgn, ts, C, C / 2.0, MIN_ORDER)
|
||||||
|
P(f" {C:>8.0f} {tv*100:6.1f}% {lam:5.2f} {a:>5} {r['orders_py']:>9.0f} "
|
||||||
|
f"{r['med_order']:>8.0f} {r['sub_min']*100:>7.1f}% {r['invested']*100:>10.1f}%")
|
||||||
|
P(" Lettura: il muro dal basso e' fisiologico e piccolo (i micro-ribilanci saltati = banda")
|
||||||
|
P(" d'isteresi gratuita); la POSIZIONE piena resta >> $5 gia' a target-vol 5% -> il min-order")
|
||||||
|
P(" NON e' il vincolo binding a 2-5k. Il vincolo binding e' il CAP (A1).")
|
||||||
|
return a1, v_mech
|
||||||
|
|
||||||
|
|
||||||
|
def sectionA_dd_euro(sl):
|
||||||
|
"""DD-in-EURO: per ogni capitale x target-vol, maxDD% del book FIDATO (haircut) -> EURO.
|
||||||
|
Sia 2-sleeve (live) sia 5-sleeve (research/paper)."""
|
||||||
|
P("\n A3) DD-IN-EURO (book FIDATO = HAIRCUT) — maxDD full & bootstrap-p95, worst-week, in EURO:")
|
||||||
|
out = {}
|
||||||
|
for tag, weights, lv in [("2-SLEEVE (live)", W2, LV2["HAIRCUT"]), ("5-SLEEVE (research)", W5, LV5["HAIRCUT"])]:
|
||||||
|
b = make_book(sl, weights, lv); v0 = ann_vol(b)
|
||||||
|
bp = make_paths(b, NPATH, HOR, BLOCK, np.random.default_rng(SEED + len(tag)))
|
||||||
|
P(f"\n --- {tag} (vol nativa {v0*100:.2f}%, Sh {sharpe(b):.2f}) ---")
|
||||||
|
P(f" {'tv':>5} {'lam':>5} {'maxDD%':>7} {'p95DD%':>7} {'wWeek%':>7} | "
|
||||||
|
f"{'DD€@2k':>8} {'DD€@3.5k':>9} {'DD€@5k':>8} | {'p95€@2k':>8} {'p95€@5k':>8}")
|
||||||
|
for tv in TVOLS:
|
||||||
|
lam = tv / v0
|
||||||
|
rr = _r(b) * lam
|
||||||
|
dd = maxdd(rr); ww = worst_week(pd.Series(rr, index=b.index))
|
||||||
|
mdd, _ = path_stats(bp * lam); p95 = float(np.percentile(mdd, 95))
|
||||||
|
out[(tag, tv)] = dict(dd=dd, p95=p95, ww=ww)
|
||||||
|
P(f" {tv*100:4.0f}% {lam:5.2f} {dd*100:6.2f}% {p95*100:6.2f}% {ww*100:6.2f}% | "
|
||||||
|
f"{2000*dd:8.0f} {3500*dd:9.0f} {5000*dd:8.0f} | {2000*p95:8.0f} {5000*p95:8.0f}")
|
||||||
|
P(" (EURO = maxDD% * capitale ; p95€ = 95° pctl del maxDD su 5y bootstrap fat-tail * capitale.)")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ (B) STAGING KELLY
|
||||||
|
def kelly_growth(book, label, v_curr_note):
|
||||||
|
P("\n" + "-" * 110)
|
||||||
|
P(f" KELLY — {label} (vol nativa {ann_vol(book)*100:.2f}%, Sh {sharpe(book):.3f}, "
|
||||||
|
f"mean_ann {ann_mean(book)*100:.2f}%)")
|
||||||
|
v0 = ann_vol(book); m0 = ann_mean(book)
|
||||||
|
# Gaussiano-log: g(lam)=lam*m0 - 0.5*(lam*v0)^2 -> lam*=m0/v0^2 -> tvol* = m0/v0 = Sharpe
|
||||||
|
lam_g = m0 / (v0 ** 2)
|
||||||
|
tv_g = lam_g * v0
|
||||||
|
P(f" KELLY GAUSSIANO (log-approx): full-Kelly target-vol = Sharpe = {tv_g*100:.1f}% "
|
||||||
|
f"(lam*={lam_g:.2f}) -> half {tv_g*50:.1f}% quarter {tv_g*25:.1f}%")
|
||||||
|
# Fat-tail: massimizza E[log W terminale] su 5y block-bootstrap
|
||||||
|
bp = make_paths(book, NPATH, HOR, BLOCK, np.random.default_rng(SEED + 11))
|
||||||
|
lam_grid = np.arange(0.25, 8.01, 0.25)
|
||||||
|
elog = []; pruin = []; medterm = []
|
||||||
|
for lam in lam_grid:
|
||||||
|
_, term = path_stats(bp * lam)
|
||||||
|
term = np.maximum(term, 1e-9)
|
||||||
|
elog.append(float(np.mean(np.log(term))))
|
||||||
|
mdd, _ = path_stats(bp * lam)
|
||||||
|
pruin.append(float((mdd >= RUIN).mean()))
|
||||||
|
medterm.append(float(np.median(term)))
|
||||||
|
elog = np.array(elog); lam_star = float(lam_grid[int(np.argmax(elog))])
|
||||||
|
tv_fat = lam_star * v0
|
||||||
|
P(f" KELLY FAT-TAIL (argmax E[log W] su 5y block-bootstrap): full-Kelly target-vol "
|
||||||
|
f"~{tv_fat*100:.1f}% (lam*={lam_star:.2f})")
|
||||||
|
P(f" -> half-Kelly ~{tv_fat*50:.1f}% quarter-Kelly ~{tv_fat*25:.1f}% (le code abbassano l'ottimo vs Gauss)")
|
||||||
|
# curva crescita/rovina attorno all'ottimo
|
||||||
|
P(f" {'tvol':>6} {'lam':>5} {'E[logW]5y':>10} {'medW5y':>8} {'Pruin50%':>9} {'CAGR_emp':>9} {'maxDD%':>7}")
|
||||||
|
show = [0.06, 0.08, 0.10, 0.125, 0.15, 0.20, 0.25, 0.30, round(tv_fat, 3)]
|
||||||
|
for tv in sorted(set(show)):
|
||||||
|
lam = tv / v0
|
||||||
|
_, term = path_stats(bp * lam); term = np.maximum(term, 1e-9)
|
||||||
|
mdd, _ = path_stats(bp * lam)
|
||||||
|
rr = _r(book) * lam
|
||||||
|
P(f" {tv*100:5.1f}% {lam:5.2f} {float(np.mean(np.log(term))):10.4f} "
|
||||||
|
f"{float(np.median(term)):8.3f} {float((mdd>=RUIN).mean())*100:8.2f}% "
|
||||||
|
f"{cagr(pd.Series(rr))*100:8.2f}% {maxdd(pd.Series(rr))*100:6.2f}%")
|
||||||
|
# dove P(rovina) supera 1% e 5%
|
||||||
|
def wall(thr):
|
||||||
|
w = None
|
||||||
|
for lam, pr in zip(lam_grid, pruin):
|
||||||
|
if pr >= thr: w = lam * v0; break
|
||||||
|
return w
|
||||||
|
w1, w5 = wall(0.01), wall(0.05)
|
||||||
|
P(f" MURO rovina: P(rovina50%)>=1% a tvol~{None if w1 is None else round(w1*100,1)}% ; "
|
||||||
|
f">=5% a tvol~{None if w5 is None else round(w5*100,1)}%")
|
||||||
|
P(f" posizione ATTUALE ~{v_curr_note}% vol -> distanza dall'ottimo fat-tail full-Kelly: "
|
||||||
|
f"{(tv_fat- v_curr_note/100)*100:+.1f} pt ; da quarter-Kelly: {(tv_fat*0.25 - v_curr_note/100)*100:+.1f} pt")
|
||||||
|
return dict(v0=v0, tv_gauss=tv_g, tv_fat=tv_fat, lam_star=lam_star,
|
||||||
|
wall1=w1, wall5=w5)
|
||||||
|
|
||||||
|
|
||||||
|
def sectionB(sl):
|
||||||
|
P("\n" + "=" * 110)
|
||||||
|
P("(B) STAGING KELLY — target-vol growth-optimal del book FIDATO (code oneste, NON Gaussiano)")
|
||||||
|
P("=" * 110)
|
||||||
|
res = {}
|
||||||
|
# book FIDATO = HAIRCUT (edge de-luckato + VRP/XS media tagliata)
|
||||||
|
b5h = make_book(sl, W5, LV5["HAIRCUT"])
|
||||||
|
b2h = make_book(sl, W2, LV2["HAIRCUT"])
|
||||||
|
b5f = make_book(sl, W5, LV5["FULL"])
|
||||||
|
b2f = make_book(sl, W2, LV2["FULL"])
|
||||||
|
v5 = ann_vol(b5f) * 100; v2 = ann_vol(b2f) * 100
|
||||||
|
P(f" vol nativa attuale (lam=1, as-run): 5-sleeve {v5:.2f}% | 2-sleeve live {v2:.2f}%")
|
||||||
|
P(f" ('i 6% attuali' del brief = ~vol nativa del book 5-sleeve diversificato; il 2-sleeve live e' piu' caldo)")
|
||||||
|
res["5s_FULL"] = kelly_growth(b5f, "5-SLEEVE FULL (canonico)", v5)
|
||||||
|
res["5s_HC"] = kelly_growth(b5h, "5-SLEEVE HAIRCUT (fidato)", v5)
|
||||||
|
res["2s_FULL"] = kelly_growth(b2f, "2-SLEEVE FULL (canonico, live)", v2)
|
||||||
|
res["2s_HC"] = kelly_growth(b2h, "2-SLEEVE HAIRCUT (fidato, live)", v2)
|
||||||
|
P("\n TESI 'a capitale piccolo i 6% sono iper-prudenti; l'ottimo Kelly-frazionario e' piu' caldo':")
|
||||||
|
hc = res["5s_HC"]
|
||||||
|
P(f" - Kelly-frazionario FIDATO (5s haircut): quarter-Kelly ~{hc['tv_fat']*25:.0f}% vol, "
|
||||||
|
f"half ~{hc['tv_fat']*50:.0f}% -> il 6% attuale e' sotto perfino il QUARTER-Kelly fat-tail.")
|
||||||
|
P(f" - MA il vincolo di ROVINA a capitale piccolo morde molto prima: P(rovina50%)>=1% gia' a "
|
||||||
|
f"tvol~{None if hc['wall1'] is None else round(hc['wall1']*100)}% -> l'ottimo di CRESCITA (Kelly) e' OLTRE la")
|
||||||
|
P(f" soglia di rovina tollerabile -> la TESI e' CONFERMATA in senso Kelly (6% << ottimo di crescita)")
|
||||||
|
P(f" ma la RACCOMANDAZIONE non e' Kelly ne' il muro di rovina: e' un warm-up MODESTO (sez. D),")
|
||||||
|
P(f" ancorato alla vol NATIVA del book (~8-11%) = ancora deep sub-Kelly, tail-safe sotto iniezione.")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ (C) AGGRESSIVITA' PER-SLEEVE
|
||||||
|
def scale_series(sl, mult):
|
||||||
|
return {nm: sl[nm] * mult.get(nm, 1.0) for nm in sl}
|
||||||
|
|
||||||
|
def book_at_vol(book, target_v):
|
||||||
|
"""serie book scalata al target-vol dato."""
|
||||||
|
v0 = ann_vol(book)
|
||||||
|
lam = target_v / v0 if v0 > 0 else 0.0
|
||||||
|
return pd.Series(_r(book) * lam, index=pd.Series(book).dropna().index), lam
|
||||||
|
|
||||||
|
def sectionC(sl):
|
||||||
|
P("\n" + "=" * 110)
|
||||||
|
P("(C) AGGRESSIVITA' PER-SLEEVE ~ CONFIDENZA vs UNIFORME (a book-vol PARI, sulla frontiera fidata)")
|
||||||
|
P("=" * 110)
|
||||||
|
P(f" moltiplicatori di esposizione ~ fiducia sull'EDGE: {CONF}")
|
||||||
|
P(" UNIFORME = tutti gli sleeve alla stessa leva; DIFF = ogni sleeve scalato per la sua confidenza,")
|
||||||
|
P(" poi leva globale per PAREGGIARE la book-vol. Domanda: il DIFF domina l'UNIFORME (Sh su, DD/rovina giu')?")
|
||||||
|
out = {}
|
||||||
|
for tag, weights in [("5-SLEEVE", W5), ("2-SLEEVE", W2)]:
|
||||||
|
conf = {nm: CONF[nm] for nm in weights}
|
||||||
|
b_uni = combine_outer({nm: sl[nm] for nm in weights}, weights)
|
||||||
|
b_dif = combine_outer({nm: sl[nm] * conf[nm] for nm in weights}, weights)
|
||||||
|
P(f"\n --- {tag}: uniforme vol {ann_vol(b_uni)*100:.2f}% Sh {sharpe(b_uni):.3f} | "
|
||||||
|
f"diff-conf vol {ann_vol(b_dif)*100:.2f}% Sh {sharpe(b_dif):.3f} "
|
||||||
|
f"corr(uni,dif)={np.corrcoef(_align(b_uni,b_dif))[0,1]:.3f} ---")
|
||||||
|
P(f" {'book-vol':>9} {'variante':>10} {'Sh_full':>8} {'Sh_hold':>8} {'CAGR':>7} {'maxDD':>7} {'Pruin50':>8} {'Pdd30':>7}")
|
||||||
|
for tv in (0.08, 0.10, 0.125):
|
||||||
|
for name, bk in [("uniforme", b_uni), ("diff-conf", b_dif)]:
|
||||||
|
bs, lam = book_at_vol(bk, tv)
|
||||||
|
bs.index = pd.Series(bk).dropna().index
|
||||||
|
ho = bs[bs.index >= HOLDOUT]
|
||||||
|
bp = make_paths(bk, NPATH, HOR, BLOCK, np.random.default_rng(SEED + 21 + int(tv*1000)))
|
||||||
|
mdd, _ = path_stats(bp * lam)
|
||||||
|
out[(tag, tv, name)] = dict(shf=sharpe(bs), shh=sharpe(ho), dd=maxdd(bs),
|
||||||
|
pr=float((mdd>=RUIN).mean()), pd30=float((mdd>=DDLIM).mean()))
|
||||||
|
P(f" {tv*100:8.1f}% {name:>10} {sharpe(bs):8.3f} {sharpe(ho):8.3f} {cagr(bs)*100:6.1f}% "
|
||||||
|
f"{maxdd(bs)*100:6.2f}% {float((mdd>=RUIN).mean())*100:7.2f}% {float((mdd>=DDLIM).mean())*100:6.2f}%")
|
||||||
|
# verdetto
|
||||||
|
dom = all(out[(tag, tv, "diff-conf")]["shf"] >= out[(tag, tv, "uniforme")]["shf"] - 1e-9 and
|
||||||
|
out[(tag, tv, "diff-conf")]["dd"] <= out[(tag, tv, "uniforme")]["dd"] + 1e-9
|
||||||
|
for tv in (0.08, 0.10, 0.125))
|
||||||
|
P(f" -> DIFF domina UNIFORME (Sh>= E DD<= a ogni book-vol)? {'SI' if dom else 'NO'}")
|
||||||
|
P("\n VERDETTO (C): il differenziato-per-confidenza NON domina l'uniforme -> anzi PEGGIORA la")
|
||||||
|
P(" frontiera. Tagliare l'esposizione degli sleeve a bassa fiducia (VRP/XS/SKH) RIMUOVE la loro")
|
||||||
|
P(" DIVERSIFICAZIONE (corr uni/diff 0.98-0.99): Sharpe scende (5s 2.24->2.11, 2s 1.78->1.69), DD")
|
||||||
|
P(" sale a book-vol pari. La bassa fiducia sull'EDGE va espressa DE-MEANando VRP/XS in aspettativa")
|
||||||
|
P(" (la banda fidata dei filoni 1/2), NON tagliandone la vol: correre TP01 piu' caldo di VRP e'")
|
||||||
|
P(" intuitivo ma sub-ottimo, perche' il valore di VRP/XS e' la varianza-riduzione, non il ritorno.")
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _align(a, b):
|
||||||
|
J = pd.concat({"a": pd.Series(a), "b": pd.Series(b)}, axis=1, join="inner").dropna()
|
||||||
|
return J["a"].values, J["b"].values
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ (D) SINTESI + RACCOMANDAZIONE
|
||||||
|
def inject_tail(paths, x_native, seed_off):
|
||||||
|
"""Sostituisce UN giorno casuale per path con x_native (rendimento NATIVO, pre-leva): stress
|
||||||
|
'il campione 2019-26 puo' NON contenere il crash peggiore' (anti-allucinazione #1)."""
|
||||||
|
P2 = paths.copy()
|
||||||
|
col = np.random.default_rng(SEED + seed_off).integers(0, P2.shape[1], size=P2.shape[0])
|
||||||
|
P2[np.arange(P2.shape[0]), col] = x_native
|
||||||
|
return P2
|
||||||
|
|
||||||
|
def sectionD(sl, a1, v_mech, kelly, ddeuro):
|
||||||
|
P("\n" + "=" * 110)
|
||||||
|
P("(D) SINTESI OPERATIVA — RACCOMANDAZIONE target-vol a 2k e 5k + azione config")
|
||||||
|
P("=" * 110)
|
||||||
|
P(" Riconciliazione delle 3 forze (la BANDA e' il messaggio):")
|
||||||
|
P(" [crescita] Kelly fat-tail full ~60-90% vol, quarter ~16-22% -> spinge CALDO")
|
||||||
|
P(" [rovina] muro P(rovina50%|5y)>=1% (book fidato) ~25-29% vol -> tetto duro")
|
||||||
|
P(" [coda] iniezione di un crash 1.5x il peggior giorno storico (il campione puo' non averlo)")
|
||||||
|
P(" [ethos] il valore del book e' il TAGLIO del DD, non l'alpha; a capitale piccolo la rovina e' definitiva")
|
||||||
|
P(" => raccomandazione = warm-up MODESTO ancorato alla vol NATIVA del book, verificato sotto iniezione,")
|
||||||
|
P(" NON il muro di rovina (17-25% sarebbe reckless) ne' Kelly (assurdo). Criterio operativo:")
|
||||||
|
P(" p95(maxDD|5y, coda iniettata) <= 25% E P(rovina50%|5y, coda iniettata) <= 2%.")
|
||||||
|
rec_out = {}
|
||||||
|
for tag, key, weights, lv, v_nat in [("2-SLEEVE (live)", "2s_HC", W2, LV2["HAIRCUT"], None),
|
||||||
|
("5-SLEEVE (research)", "5s_HC", W5, LV5["HAIRCUT"], None)]:
|
||||||
|
k = kelly[key]
|
||||||
|
b = make_book(sl, weights, lv)
|
||||||
|
bp = make_paths(b, NPATH, HOR, BLOCK, np.random.default_rng(SEED + 31))
|
||||||
|
v0 = ann_vol(b)
|
||||||
|
crash = 1.5 * float(pd.Series(b).min()) # 1.5x il peggior giorno storico del book (nativo)
|
||||||
|
bpi = inject_tail(bp, crash, 41)
|
||||||
|
P(f"\n {tag}: vol nativa {v0*100:.2f}% ; crash iniettato = {crash*100:.2f}%/giorno (1.5x worst-day storico {float(pd.Series(b).min())*100:.2f}%)")
|
||||||
|
P(f" {'tvol':>5} {'lam':>5} {'maxDD%':>7} {'p95DD':>7} {'p95DD+cr':>9} {'Prov+cr':>8} {'CAGR':>6} | "
|
||||||
|
f"{'DD€2k':>6} {'DD€5k':>6} {'p95€2k+cr':>10} {'p95€5k+cr':>10}")
|
||||||
|
rec = None
|
||||||
|
for tv in (0.06, 0.08, 0.10, 0.12, 0.15, 0.20):
|
||||||
|
lam = tv / v0
|
||||||
|
rr = pd.Series(_r(b) * lam, index=pd.Series(b).dropna().index)
|
||||||
|
dd = maxdd(rr)
|
||||||
|
mdd, _ = path_stats(bp * lam); p95 = float(np.percentile(mdd, 95))
|
||||||
|
mddi, _ = path_stats(bpi * lam); p95i = float(np.percentile(mddi, 95)); provi = float((mddi >= RUIN).mean())
|
||||||
|
if p95i <= 0.25 and provi <= 0.02:
|
||||||
|
rec = tv
|
||||||
|
P(f" {tv*100:4.0f}% {lam:5.2f} {dd*100:6.2f}% {p95*100:6.2f}% {p95i*100:8.2f}% "
|
||||||
|
f"{provi*100:7.2f}% {cagr(rr)*100:5.1f}% | {2000*dd:6.0f} {5000*dd:6.0f} "
|
||||||
|
f"{2000*p95i:9.0f} {5000*p95i:9.0f}")
|
||||||
|
rec_out[tag] = rec
|
||||||
|
if rec is not None:
|
||||||
|
lam = rec / v0
|
||||||
|
rr = pd.Series(_r(b) * lam, index=pd.Series(b).dropna().index)
|
||||||
|
P(f" -> WARM-UP raccomandato ~{rec*100:.0f}% vol (sotto coda-iniettata): DD atteso {maxdd(rr)*100:.1f}% "
|
||||||
|
f"= EUR {2000*maxdd(rr):.0f}@2k / EUR {5000*maxdd(rr):.0f}@5k ; CAGR_full {cagr(rr)*100:.1f}% "
|
||||||
|
f"-> ~EUR {2000*cagr(rr)/365.25:.2f}/g@2k, ~EUR {5000*cagr(rr)/365.25:.2f}/g@5k")
|
||||||
|
P(f" riferimenti: Kelly-frazionario quarter ~{k['tv_fat']*25:.0f}% ; muro rovina1% ~{None if k['wall1'] is None else round(k['wall1']*100)}% ; vol nativa {v0*100:.1f}%")
|
||||||
|
P("\n CAP CEILING (sez.A1): col cap $300 la vol RAGGIUNGIBILE crolla al crescere del capitale")
|
||||||
|
P(" (il cap fisso diventa una frazione minore dell'equity -> throttling PARADOSSALE):")
|
||||||
|
for C in CAPS:
|
||||||
|
rows = [x for x in a1 if x["C"] == C]
|
||||||
|
v300 = max(x["v300"] for x in rows); ve2 = max(x["ve2"] for x in rows)
|
||||||
|
P(f" ${C:,.0f}: cap $300 -> tetto vol ~{v300*100:.1f}% | cap equity/2 -> tetto vol ~{ve2*100:.1f}%")
|
||||||
|
P("\n RACCOMANDAZIONE FINALE (book LIVE 2-sleeve; vol capital-indipendente, cambia solo DD-in-EURO e cap):")
|
||||||
|
r2 = rec_out.get("2-SLEEVE (live)")
|
||||||
|
P(f" - target-vol ~10-11% (= la vol NATIVA del book a lam~1). E' un warm-up di ~+4-5 pt dai '6% attuali'")
|
||||||
|
P(f" del regime diversificato, MA e' ancora meta' del quarter-Kelly fidato (~22%) e ben dentro il muro")
|
||||||
|
P(f" di rovina (~25%): tail-safe anche con un crash 1.5x-worse iniettato (P(rovina)~0). Criterio-coda -> ~{None if r2 is None else round(r2*100)}%.")
|
||||||
|
P(f" - a 2k: DD atteso ~9-12% = EUR ~200-290 ; a 5k: stesso % = EUR ~500-720. La % di rischio NON cambia")
|
||||||
|
P(f" col capitale; cambia l'EURO in gioco e cosa e' ESEGUIBILE.")
|
||||||
|
P(f" - NON alzare oltre: 15-20% (verso Kelly/muro) triplica il DD-in-euro senza margine di coda a capitale piccolo.")
|
||||||
|
P("\n AZIONE CONFIG (SOLO PROPOSTA, config/live.json NON toccato):")
|
||||||
|
P(" max_notional_per_asset_usd: PORTARLO a equity/2 -> $1000 (2k) / $1750 (3.5k) / $2500 (5k).")
|
||||||
|
P(" Motivo: col cap fermo a $300 il tetto vol raggiungibile scende a ~13%@2k / ~8%@3.5k / ~6%@5k -> a 3.5-5k")
|
||||||
|
P(" il book verrebbe FORZATO sotto il target ~10-11% (throttling), cioe' il capitale in piu' RAFFREDDA il book.")
|
||||||
|
P(" Il cap=equity/2 ripristina il rapporto attuale (300/600) e riporta il tetto a ~27% (headroom ampio).")
|
||||||
|
P(" min_order_usd $5: LASCIARE (granularita' reale non binding a 2-5k, sez.A2). Tranching K=2: NON cablare")
|
||||||
|
P(" (blocco feed-intraday). Nessun altro cambio.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
t0 = time.time()
|
||||||
|
P("costruzione sleeve...")
|
||||||
|
sl = build_sleeves()
|
||||||
|
b5, b2 = sanity(sl)
|
||||||
|
frontier_table(sl)
|
||||||
|
P("\ncostruzione frame giornalieri book live (segno SKH 230m)...")
|
||||||
|
frames, skh_cache = daily_frames()
|
||||||
|
a1, v_mech = sectionA(frames, skh_cache)
|
||||||
|
ddeuro = sectionA_dd_euro(sl)
|
||||||
|
kelly = sectionB(sl)
|
||||||
|
_ = sectionC(sl)
|
||||||
|
sectionD(sl, a1, v_mech, kelly, ddeuro)
|
||||||
|
with open(os.path.join(OUT, "r0703_frontier_exec_report.txt"), "w") as f:
|
||||||
|
f.write("\n".join(REP))
|
||||||
|
P(f"\n[report -> {OUT}/r0703_frontier_exec_report.txt] done in {time.time()-t0:.0f}s")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""FILONE 2 (2026-07-03) — ONESTA' DELLA CODA + FRONTIERA FIDATA.
|
||||||
|
|
||||||
|
Misura (NON cerca segnali) la frontiera rischio/rendimento del book che GIA' esiste, per
|
||||||
|
decidere a quale target-vol conviene girarlo a capitale piccolo. Tre blocchi:
|
||||||
|
|
||||||
|
A) SUPER-LINEARITA': DD e P(rovina) sotto (i) Gaussiana-lineare vs (ii) block-bootstrap sui
|
||||||
|
rendimenti REALI fat-tail. Di quanto la coda reale peggiora vs il lineare, e a che target-vol
|
||||||
|
il divario diventa dominante (il "muro").
|
||||||
|
B) INIEZIONE DI CODA SINTETICA: un giorno-crash e una settimana-crash a livello book (calibrati
|
||||||
|
su un worst-correlato dei singoli sleeve), per testare se la frontiera regge o crolla quando
|
||||||
|
il campione 2019-26 NON contiene il crash peggiore di ogni sleeve.
|
||||||
|
C) CONFIDENCE-HAIRCUT: taglia la MEDIA (non la vol) di VRP01 e XS01 del 30% e 50%, e in una
|
||||||
|
variante ESCLUDILI, per costruire la frontiera "fidata". Il GAP full-vs-fidata = margine
|
||||||
|
d'onestita'.
|
||||||
|
|
||||||
|
Deliverable: il target-vol massimo che sopravvive SIA all'iniezione di coda SIA all'haircut ->
|
||||||
|
"quanto caldo puoi correre con fiducia".
|
||||||
|
|
||||||
|
SOLA LETTURA su src/ (importa i builder degli sleeve). Output in scratchpad. NON committare.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys, os, math, json, zlib, gc
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, '/opt/docker/PythagorasGoal')
|
||||||
|
from src.portfolio.sleeves import (_tp01_returns, _skyhook_returns, _xsec_returns,
|
||||||
|
_vrp_combo_returns, _gtaa_daily_returns)
|
||||||
|
from src.portfolio.portfolio import combine_outer, to_daily, HOLDOUT, DAYS_PER_YEAR
|
||||||
|
|
||||||
|
OUT = '/tmp/claude-1001/-opt-docker-PythagorasGoal/e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad'
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
AY = DAYS_PER_YEAR # 365.25
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- helpers
|
||||||
|
def ann_vol(r): r = np.asarray(pd.Series(r).dropna().values, float); return r.std() * math.sqrt(AY)
|
||||||
|
def ann_mean(r): r = np.asarray(pd.Series(r).dropna().values, float); return r.mean() * AY
|
||||||
|
def sharpe(r):
|
||||||
|
r = np.asarray(pd.Series(r).dropna().values, float)
|
||||||
|
return r.mean() / r.std() * math.sqrt(AY) if r.std() > 0 else 0.0
|
||||||
|
def emp_cagr(r):
|
||||||
|
r = np.asarray(pd.Series(r).dropna().values, float)
|
||||||
|
eq = np.cumprod(1 + r); y = len(r) / AY
|
||||||
|
return eq[-1] ** (1 / y) - 1 if (y > 0 and eq[-1] > 0) else -1.0
|
||||||
|
def emp_maxdd(r):
|
||||||
|
r = np.asarray(pd.Series(r).dropna().values, float)
|
||||||
|
eq = np.cumprod(1 + r); pk = np.maximum.accumulate(eq)
|
||||||
|
return float(np.max((pk - eq) / pk))
|
||||||
|
def emp_worst_week(r):
|
||||||
|
"""Peggior rendimento composto su finestra rolling di 7 giorni della serie GIORNALIERA."""
|
||||||
|
s = pd.Series(r).dropna()
|
||||||
|
w = (1 + s).rolling(7).apply(np.prod, raw=True) - 1.0
|
||||||
|
return float(w.min())
|
||||||
|
|
||||||
|
def haircut_mean(s: pd.Series, frac: float) -> pd.Series:
|
||||||
|
"""Taglia la MEDIA di una serie del `frac` (0.5 = -50%) preservando vol e autocorr:
|
||||||
|
r' = r - frac*mean(r) -> mean' = (1-frac)*mean, std invariata."""
|
||||||
|
s = s.dropna()
|
||||||
|
return s - frac * s.mean()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- build sleeves (daily grid)
|
||||||
|
print("costruzione sleeve (griglia giornaliera)...", flush=True)
|
||||||
|
SL = {
|
||||||
|
'TP01_trend_1d': to_daily(_tp01_returns()),
|
||||||
|
'SKH01_skyhook': to_daily(_skyhook_returns()),
|
||||||
|
'XS01_xsec_hl': to_daily(_xsec_returns()),
|
||||||
|
'VRP01_shortvol': to_daily(_vrp_combo_returns()),
|
||||||
|
'GTAA01_eq_trend':to_daily(_gtaa_daily_returns()),
|
||||||
|
}
|
||||||
|
W5 = {'TP01_trend_1d':0.33,'XS01_xsec_hl':0.15,'VRP01_shortvol':0.12,'SKH01_skyhook':0.20,'GTAA01_eq_trend':0.20}
|
||||||
|
W2 = {'TP01_trend_1d':0.75,'SKH01_skyhook':0.25}
|
||||||
|
|
||||||
|
# ---- confidence variants: each -> a daily book return series ----
|
||||||
|
def book5_full(): return combine_outer(SL, W5)
|
||||||
|
def book5_deluck(f=0.15):
|
||||||
|
# de-luck: haircut della MEDIA dell'intero book (post anchor-audit sul RITORNO)
|
||||||
|
return haircut_mean(combine_outer(SL, W5), f)
|
||||||
|
def book5_haircut(frac):
|
||||||
|
# taglia la media dei soli sleeve a BASSA confidenza (VRP01, XS01), ricombina
|
||||||
|
sl = dict(SL); sl['VRP01_shortvol'] = haircut_mean(SL['VRP01_shortvol'], frac)
|
||||||
|
sl['XS01_xsec_hl'] = haircut_mean(SL['XS01_xsec_hl'], frac)
|
||||||
|
return combine_outer(sl, W5)
|
||||||
|
def book5_exclude():
|
||||||
|
# escludi VRP01 e XS01, rinormalizza su TP01/SKH01/GTAA (33/20/20)
|
||||||
|
w = {'TP01_trend_1d':0.33,'SKH01_skyhook':0.20,'GTAA01_eq_trend':0.20}
|
||||||
|
return combine_outer({k:SL[k] for k in w}, w)
|
||||||
|
|
||||||
|
def book2_full(): return combine_outer({k:SL[k] for k in W2}, W2)
|
||||||
|
def book2_deluck(f=0.15): return haircut_mean(combine_outer({k:SL[k] for k in W2}, W2), f)
|
||||||
|
def book2_haircut(frac):
|
||||||
|
# nel book live la confidenza media e' SKH01 (research, ETH DD sottile) -> taglia la sua media
|
||||||
|
sl = {'TP01_trend_1d':SL['TP01_trend_1d'], 'SKH01_skyhook':haircut_mean(SL['SKH01_skyhook'], frac)}
|
||||||
|
return combine_outer(sl, W2)
|
||||||
|
def book2_exclude():
|
||||||
|
# escludi SKH -> TP01 puro (l'unico deployato pieno)
|
||||||
|
return SL['TP01_trend_1d'].dropna()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- SANITY
|
||||||
|
def line(r, tag):
|
||||||
|
ho = r[r.index >= HOLDOUT]
|
||||||
|
return (f" {tag:14s} n={len(r):5d} FULL sh={sharpe(r):.3f} cagr={emp_cagr(r):+.4f} "
|
||||||
|
f"dd={emp_maxdd(r):.4f} vol={ann_vol(r):.4f} | HOLD sh={sharpe(ho):.3f} "
|
||||||
|
f"cagr={emp_cagr(ho):+.4f} dd={emp_maxdd(ho):.4f} ww={emp_worst_week(r):+.4f}")
|
||||||
|
|
||||||
|
rep = []
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a); print(s, flush=True); rep.append(s)
|
||||||
|
|
||||||
|
P("="*118)
|
||||||
|
P("SANITY — ricostruzione book (tolleranza per deriva dati)")
|
||||||
|
P("="*118)
|
||||||
|
b5, b2 = book5_full(), book2_full()
|
||||||
|
P(line(b5, "5-SLEEVE"), " [target 2.24 / 2.46 / 6.2%]")
|
||||||
|
P(line(b2, "2-SLEEVE"), " [target 1.78 / 1.17 / 9.0%]")
|
||||||
|
for nm, s in SL.items():
|
||||||
|
P(f" sleeve {nm:16s} sh_full={sharpe(s):+.2f} vol={ann_vol(s):.3f} cagr={emp_cagr(s):+.3f} "
|
||||||
|
f"worst_day={float(pd.Series(s).min()):+.4f} worst_wk={emp_worst_week(s):+.4f} n={len(s)}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- bootstrap engine
|
||||||
|
def make_paths(r, n_paths, horizon, block, rng):
|
||||||
|
"""Block-bootstrap: matrice (n_paths, horizon) di rendimenti UNSCALED campionati da r."""
|
||||||
|
r = np.asarray(pd.Series(r).dropna().values, float)
|
||||||
|
N = len(r); nb = int(math.ceil(horizon / block))
|
||||||
|
starts = rng.integers(0, N - block + 1, size=(n_paths, nb))
|
||||||
|
off = np.arange(block)
|
||||||
|
idx = (starts[:, :, None] + off[None, None, :]).reshape(n_paths, nb * block)[:, :horizon]
|
||||||
|
return r[idx].astype(np.float32) # float32: dimezza la memoria dei path (nessun impatto su P a 4 cifre)
|
||||||
|
|
||||||
|
def path_maxdd(scaled):
|
||||||
|
"""scaled: (n_paths, horizon). Ritorna maxDD per path (clip equity floor a 0 = liquidazione)."""
|
||||||
|
sc = np.clip(scaled, -1.0, None)
|
||||||
|
eq = np.cumprod(1.0 + sc, axis=1)
|
||||||
|
pk = np.maximum.accumulate(eq, axis=1)
|
||||||
|
dd = (pk - eq) / pk
|
||||||
|
return dd.max(axis=1), eq[:, -1]
|
||||||
|
|
||||||
|
def gauss_maxdd(mu_d, sd_d, n_paths, horizon, rng):
|
||||||
|
x = rng.normal(mu_d, sd_d, size=(n_paths, horizon)).astype(np.float32)
|
||||||
|
return path_maxdd(x)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- config
|
||||||
|
NPATH = 4000
|
||||||
|
HYR = 5
|
||||||
|
HOR = int(round(HYR * 365)) # 5 anni ~ 1825 giorni
|
||||||
|
BLOCK = 15
|
||||||
|
SEED = 20260703
|
||||||
|
TVOLS = [0.05, 0.06, 0.08, 0.10, 0.125, 0.15, 0.20, 0.25, 0.30]
|
||||||
|
RUIN = 0.50 # rovina = maxDD >= 50%
|
||||||
|
DDLIM = 0.30 # soglia DD severo
|
||||||
|
|
||||||
|
def frontier_row(r_book, tv, base_paths, base_vol):
|
||||||
|
"""Una riga di frontiera per un book-variant a un target-vol tv, riusando base_paths (unscaled)."""
|
||||||
|
lam = tv / base_vol
|
||||||
|
scaled_emp = np.asarray(pd.Series(r_book).dropna().values, float) * lam
|
||||||
|
mdd, fin = path_maxdd(base_paths * lam)
|
||||||
|
return dict(
|
||||||
|
tvol=tv, lam=round(lam, 3),
|
||||||
|
cagr=emp_cagr(scaled_emp), maxdd=emp_maxdd(scaled_emp),
|
||||||
|
worst_wk=emp_worst_week(scaled_emp), sharpe=sharpe(scaled_emp),
|
||||||
|
p_ruin=float((mdd >= RUIN).mean()), p_dd30=float((mdd >= DDLIM).mean()),
|
||||||
|
med_dd=float(np.median(mdd)), p95_dd=float(np.percentile(mdd, 95)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- A) SUPER-LINEARITY (full 5-sleeve)
|
||||||
|
P("\n" + "="*118)
|
||||||
|
P("A) SUPER-LINEARITA' — coda reale (block-bootstrap) vs Gaussiana-lineare [book 5-sleeve FULL, orizzonte 5y]")
|
||||||
|
P("="*118)
|
||||||
|
rng = np.random.default_rng(SEED)
|
||||||
|
r5 = np.asarray(book5_full().dropna().values, float)
|
||||||
|
v5 = r5.std() * math.sqrt(AY); m5 = r5.mean() * AY
|
||||||
|
paths5 = make_paths(r5, NPATH, HOR, BLOCK, rng)
|
||||||
|
base_dd5 = emp_maxdd(r5) # maxDD in-sample unlevered (per la retta "DD~lambda")
|
||||||
|
P(f" book vol nativa={v5:.4f} mean_ann={m5:.4f} maxDD in-sample(unlev)={base_dd5:.4f}")
|
||||||
|
P(f" {'tvol':>6} {'lam':>5} | {'DD_lin':>7} {'DDg_p95':>8} {'DDr_p95':>8} {'DDr/DDg':>7} | "
|
||||||
|
f"{'ruin_g':>7} {'ruin_r':>7} {'r/g':>6} | {'dd30_g':>7} {'dd30_r':>7}")
|
||||||
|
superlin = []
|
||||||
|
for tv in TVOLS:
|
||||||
|
lam = tv / v5
|
||||||
|
mu_d = m5 / AY * lam; sd_d = tv / math.sqrt(AY)
|
||||||
|
mdd_r, _ = path_maxdd(paths5 * lam)
|
||||||
|
mdd_g, _ = gauss_maxdd(mu_d, sd_d, NPATH, HOR, rng)
|
||||||
|
dd_lin = base_dd5 * lam
|
||||||
|
ddg95, ddr95 = np.percentile(mdd_g, 95), np.percentile(mdd_r, 95)
|
||||||
|
ruin_g, ruin_r = float((mdd_g >= RUIN).mean()), float((mdd_r >= RUIN).mean())
|
||||||
|
dd30_g, dd30_r = float((mdd_g >= DDLIM).mean()), float((mdd_r >= DDLIM).mean())
|
||||||
|
rg = ruin_r / ruin_g if ruin_g > 1e-6 else float('inf')
|
||||||
|
superlin.append(dict(tv=tv, lam=lam, dd_lin=dd_lin, ddg95=ddg95, ddr95=ddr95,
|
||||||
|
ruin_g=ruin_g, ruin_r=ruin_r, dd30_g=dd30_g, dd30_r=dd30_r))
|
||||||
|
rgs = f"{rg:6.1f}" if np.isfinite(rg) else " inf"
|
||||||
|
P(f" {tv:6.3f} {lam:5.2f} | {dd_lin:7.3f} {ddg95:8.3f} {ddr95:8.3f} {ddr95/ddg95:7.2f} | "
|
||||||
|
f"{ruin_g:7.4f} {ruin_r:7.4f} {rgs} | {dd30_g:7.4f} {dd30_r:7.4f}")
|
||||||
|
|
||||||
|
# individua il "muro": primo tvol dove P(rovina reale) supera 5% e 10%
|
||||||
|
def wall(seq, key, thr):
|
||||||
|
for d in seq:
|
||||||
|
if d[key] >= thr:
|
||||||
|
return d['tv']
|
||||||
|
return None
|
||||||
|
P(f" MURO ruin_reale>=5%: tvol={wall(superlin,'ruin_r',0.05)} >=10%: tvol={wall(superlin,'ruin_r',0.10)} "
|
||||||
|
f">=1%: tvol={wall(superlin,'ruin_r',0.01)}")
|
||||||
|
P(f" MURO DD30_reale>=25%: tvol={wall(superlin,'dd30_r',0.25)} >=50%: tvol={wall(superlin,'dd30_r',0.50)}")
|
||||||
|
|
||||||
|
# sensibilita' block-length sul muro (10/15/20)
|
||||||
|
P("\n sensibilita' block-length (P_rovina reale a tvol=0.15 / 0.20 / 0.30):")
|
||||||
|
for bl in (10, 15, 20):
|
||||||
|
rr = np.random.default_rng(SEED + bl)
|
||||||
|
pp = make_paths(r5, NPATH, HOR, bl, rr)
|
||||||
|
row = []
|
||||||
|
for tv in (0.15, 0.20, 0.30):
|
||||||
|
mdd, _ = path_maxdd(pp * (tv / v5))
|
||||||
|
row.append(f"tvol{tv:.2f}={float((mdd>=RUIN).mean()):.4f}")
|
||||||
|
P(f" block={bl:2d}: " + " ".join(row))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- C) FRONTIER TABLES (3 livelli confidenza)
|
||||||
|
def frontier_table(name, variant_series, base_vol_override=None):
|
||||||
|
"""Costruisce e stampa la tabella di frontiera per un book-variant."""
|
||||||
|
r = np.asarray(pd.Series(variant_series).dropna().values, float)
|
||||||
|
bvol = base_vol_override if base_vol_override else r.std() * math.sqrt(AY)
|
||||||
|
rr = np.random.default_rng(SEED + zlib.crc32(name.encode()) % 99991) # seed DETERMINISTICO (no hash randomizzato)
|
||||||
|
bp = make_paths(r, NPATH, HOR, BLOCK, rr)
|
||||||
|
P(f"\n --- {name} (vol_nativa={bvol:.4f} sharpe={sharpe(r):+.3f} cagr_unlev={emp_cagr(r):+.4f}) ---")
|
||||||
|
P(f" {'tvol':>6} {'lam':>5} {'CAGR':>8} {'maxDD':>7} {'worstWk':>8} {'Sharpe':>7} "
|
||||||
|
f"{'Pruin50':>8} {'Pdd30':>7} {'medDD':>6} {'p95DD':>6}")
|
||||||
|
rows = []
|
||||||
|
for tv in TVOLS:
|
||||||
|
d = frontier_row(variant_series, tv, bp, bvol)
|
||||||
|
rows.append(d)
|
||||||
|
P(f" {d['tvol']:6.3f} {d['lam']:5.2f} {d['cagr']:+8.4f} {d['maxdd']:7.4f} {d['worst_wk']:+8.4f} "
|
||||||
|
f"{d['sharpe']:7.3f} {d['p_ruin']:8.4f} {d['p_dd30']:7.4f} {d['med_dd']:6.3f} {d['p95_dd']:6.3f}")
|
||||||
|
return rows, bp, bvol
|
||||||
|
|
||||||
|
P("\n" + "="*118)
|
||||||
|
P("C) FRONTIERA a 3 LIVELLI DI CONFIDENZA — BOOK 5-SLEEVE (research/paper aspiration)")
|
||||||
|
P("="*118)
|
||||||
|
rows5_full, bp5_full, bv5_full = frontier_table("5s FULL (canonico)", book5_full())
|
||||||
|
rows5_deluck, _, _ = frontier_table("5s DE-LUCK (mean x0.85)", book5_deluck(0.15))
|
||||||
|
rows5_hc50, bp5_hc, bv5_hc = frontier_table("5s HAIRCUT VRP&XS -50%", book5_haircut(0.50))
|
||||||
|
rows5_hc30, _, _ = frontier_table("5s HAIRCUT VRP&XS -30%", book5_haircut(0.30))
|
||||||
|
rows5_excl, bp5_excl, bv5_excl = frontier_table("5s ESCLUDI VRP&XS (renorm)", book5_exclude())
|
||||||
|
|
||||||
|
P("\n" + "="*118)
|
||||||
|
P("C) FRONTIERA a 3 LIVELLI — BOOK LIVE 2-SLEEVE (TP01+SKH 75/25, l'unico deployabile a 2-5k)")
|
||||||
|
P("="*118)
|
||||||
|
rows2_full, bp2_full, bv2_full = frontier_table("2s FULL (canonico)", book2_full())
|
||||||
|
rows2_deluck, _, _ = frontier_table("2s DE-LUCK (mean x0.85)", book2_deluck(0.15))
|
||||||
|
rows2_hc50, bp2_hc, bv2_hc = frontier_table("2s HAIRCUT SKH -50%", book2_haircut(0.50))
|
||||||
|
rows2_excl, _, _ = frontier_table("2s ESCLUDI SKH -> TP01 puro", book2_exclude())
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- B) SYNTHETIC TAIL INJECTION
|
||||||
|
P("\n" + "="*118)
|
||||||
|
P("B) INIEZIONE DI CODA SINTETICA — calibrazione su worst-correlato dei singoli sleeve")
|
||||||
|
P("="*118)
|
||||||
|
# giorno-crash a livello book = tutti gli sleeve al loro peggior giorno IN CONTEMPORANEA (corr->1 in crisi)
|
||||||
|
def worst_correlated_day(weights):
|
||||||
|
tot = sum(weights.values())
|
||||||
|
return sum((w / tot) * float(pd.Series(SL[k]).min()) for k, w in weights.items())
|
||||||
|
def worst_correlated_week(weights):
|
||||||
|
tot = sum(weights.values())
|
||||||
|
return sum((w / tot) * emp_worst_week(SL[k]) for k, w in weights.items())
|
||||||
|
|
||||||
|
wc_day5, wc_wk5 = worst_correlated_day(W5), worst_correlated_week(W5)
|
||||||
|
wc_day2, wc_wk2 = worst_correlated_day(W2), worst_correlated_week(W2)
|
||||||
|
P(f" book 5-sleeve: worst_day in-sample={float(pd.Series(book5_full()).min()):+.4f} "
|
||||||
|
f"worst_correlato(iniettato)={wc_day5:+.4f} | worst_wk in-sample={emp_worst_week(book5_full()):+.4f} "
|
||||||
|
f"worst_wk_correlato={wc_wk5:+.4f}")
|
||||||
|
P(f" book 2-sleeve: worst_day in-sample={float(pd.Series(book2_full()).min()):+.4f} "
|
||||||
|
f"worst_correlato(iniettato)={wc_day2:+.4f} | worst_wk in-sample={emp_worst_week(book2_full()):+.4f} "
|
||||||
|
f"worst_wk_correlato={wc_wk2:+.4f}")
|
||||||
|
P(" Nota: il worst-correlato assume che in una LUNA/COVID TUTTI gli sleeve colpiscano insieme")
|
||||||
|
P(" (VRP mai stressato reale, SKH ETH DD sottile, XS 2.5y) -> stress deliberatamente severo.")
|
||||||
|
|
||||||
|
def inject_day(paths, xday):
|
||||||
|
"""Setta UN giorno casuale per path al valore xday (UNSCALED: la leva lo moltiplica)."""
|
||||||
|
P2 = paths.copy()
|
||||||
|
col = np.random.default_rng(SEED + 7).integers(0, P2.shape[1], size=P2.shape[0])
|
||||||
|
P2[np.arange(P2.shape[0]), col] = xday
|
||||||
|
return P2
|
||||||
|
def inject_week(paths, xday_wk, ndays=5):
|
||||||
|
"""Setta 5 giorni consecutivi per path a xday_wk (giornaliero equivalente della settimana)."""
|
||||||
|
P2 = paths.copy()
|
||||||
|
st = np.random.default_rng(SEED + 8).integers(0, P2.shape[1] - ndays, size=P2.shape[0])
|
||||||
|
for k in range(ndays):
|
||||||
|
P2[np.arange(P2.shape[0]), st + k] = xday_wk
|
||||||
|
return P2
|
||||||
|
|
||||||
|
def inject_report(name, base_paths, base_vol, xday, xwk_daily):
|
||||||
|
P(f"\n --- INIEZIONE su {name} (day={xday:+.3f}, week={xwk_daily:+.3f}/g x5) ---")
|
||||||
|
P(f" {'tvol':>6} {'lam':>5} | {'ruin_base':>9} {'ruin+day':>9} {'ruin+wk':>8} | "
|
||||||
|
f"{'dd30_base':>9} {'dd30+day':>9} {'dd30+wk':>8}")
|
||||||
|
pd_day = inject_day(base_paths, xday)
|
||||||
|
pd_wk = inject_week(base_paths, xwk_daily)
|
||||||
|
out = []
|
||||||
|
for tv in TVOLS:
|
||||||
|
lam = tv / base_vol
|
||||||
|
mb, _ = path_maxdd(base_paths * lam)
|
||||||
|
md, _ = path_maxdd(pd_day * lam)
|
||||||
|
mw, _ = path_maxdd(pd_wk * lam)
|
||||||
|
rb, rd, rw = (mb >= RUIN).mean(), (md >= RUIN).mean(), (mw >= RUIN).mean()
|
||||||
|
db, dd_, dw = (mb >= DDLIM).mean(), (md >= DDLIM).mean(), (mw >= DDLIM).mean()
|
||||||
|
out.append(dict(tv=tv, ruin_base=float(rb), ruin_day=float(rd), ruin_wk=float(rw),
|
||||||
|
dd30_base=float(db), dd30_day=float(dd_), dd30_wk=float(dw)))
|
||||||
|
P(f" {tv:6.3f} {lam:5.2f} | {rb:9.4f} {rd:9.4f} {rw:8.4f} | {db:9.4f} {dd_:9.4f} {dw:8.4f}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
# week daily-equivalent: distribuisci la settimana-crash su 5 giorni uguali composti
|
||||||
|
def wk_daily(x_wk):
|
||||||
|
return (1 + x_wk) ** (1 / 5.0) - 1.0
|
||||||
|
|
||||||
|
inj5_full = inject_report("5s FULL", bp5_full, bv5_full, wc_day5, wk_daily(wc_wk5))
|
||||||
|
inj5_hc = inject_report("5s HAIRCUT", bp5_hc, bv5_hc, wc_day5, wk_daily(wc_wk5))
|
||||||
|
inj2_full = inject_report("2s FULL", bp2_full, bv2_full, wc_day2, wk_daily(wc_wk2))
|
||||||
|
inj2_hc = inject_report("2s HAIRCUT", bp2_hc, bv2_hc, wc_day2, wk_daily(wc_wk2))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- DELIVERABLE
|
||||||
|
P("\n" + "="*118)
|
||||||
|
P("DELIVERABLE — target-vol MASSIMO che sopravvive SIA all'iniezione di coda SIA all'haircut")
|
||||||
|
P("="*118)
|
||||||
|
# criterio di confidenza (sul book HAIRCUT, CON iniezione peggiore day/week, orizzonte 5y):
|
||||||
|
# P(rovina>50%) < 5% AND P(DD>30%) < 20%
|
||||||
|
RUIN_TOL = 0.05
|
||||||
|
DD30_TOL = 0.20
|
||||||
|
def max_safe_tvol(inj_rows):
|
||||||
|
ok = None
|
||||||
|
for d in inj_rows:
|
||||||
|
worst_ruin = max(d['ruin_day'], d['ruin_wk'])
|
||||||
|
worst_dd30 = max(d['dd30_day'], d['dd30_wk'])
|
||||||
|
if worst_ruin < RUIN_TOL and worst_dd30 < DD30_TOL:
|
||||||
|
ok = d['tv']
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return ok
|
||||||
|
P(f" criterio: sotto l'iniezione PEGGIORE (day|week), P(rovina>50%)<{RUIN_TOL:.0%} AND P(DD>30%)<{DD30_TOL:.0%}")
|
||||||
|
P(f" BOOK 5-SLEEVE haircut+iniezione -> target-vol max fidato = {max_safe_tvol(inj5_hc)}")
|
||||||
|
P(f" BOOK 2-SLEEVE haircut+iniezione -> target-vol max fidato = {max_safe_tvol(inj2_hc)}")
|
||||||
|
# variante piu' tollerante (rovina<10%)
|
||||||
|
RUIN_TOL2 = 0.10
|
||||||
|
def max_safe_tvol2(inj_rows, rt):
|
||||||
|
ok = None
|
||||||
|
for d in inj_rows:
|
||||||
|
if max(d['ruin_day'], d['ruin_wk']) < rt:
|
||||||
|
ok = d['tv']
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return ok
|
||||||
|
P(f" [tolleranza P(rovina)<10%] 5-sleeve={max_safe_tvol2(inj5_hc,0.10)} 2-sleeve={max_safe_tvol2(inj2_hc,0.10)}")
|
||||||
|
|
||||||
|
# GAP full-vs-fidata a pari DD: a quale CAGR arrivi a maxDD 6.2%/9.4% con full vs haircut/exclude
|
||||||
|
def cagr_at_tvol(rows, tv):
|
||||||
|
for d in rows:
|
||||||
|
if abs(d['tvol'] - tv) < 1e-9:
|
||||||
|
return d['cagr'], d['maxdd']
|
||||||
|
return None, None
|
||||||
|
P("\n GAP full-vs-fidata (CAGR a pari target-vol):")
|
||||||
|
for tv in (0.06, 0.08, 0.10, 0.15):
|
||||||
|
cf, _ = cagr_at_tvol(rows5_full, tv); ch, _ = cagr_at_tvol(rows5_hc50, tv); ce, _ = cagr_at_tvol(rows5_excl, tv)
|
||||||
|
P(f" 5s tvol={tv:.3f}: full CAGR={cf:+.4f} haircut50={ch:+.4f} escludi={ce:+.4f} "
|
||||||
|
f"gap(full-escludi)={cf-ce:+.4f}")
|
||||||
|
for tv in (0.08, 0.10, 0.15):
|
||||||
|
cf, _ = cagr_at_tvol(rows2_full, tv); ch, _ = cagr_at_tvol(rows2_hc50, tv); ce, _ = cagr_at_tvol(rows2_excl, tv)
|
||||||
|
P(f" 2s tvol={tv:.3f}: full CAGR={cf:+.4f} haircutSKH50={ch:+.4f} escludi(TP01)={ce:+.4f} "
|
||||||
|
f"gap(full-escludi)={cf-ce:+.4f}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- €/giorno a 2k e 5k
|
||||||
|
P("\n" + "="*118)
|
||||||
|
P("TRADUZIONE €/GIORNO (CAGR de-luck & haircut, capitale 2k e 5k)")
|
||||||
|
P("="*118)
|
||||||
|
def eur_day(cagr, cap): return cap * cagr / 365.0
|
||||||
|
for label, rows in [("5s FULL", rows5_full), ("5s DE-LUCK", rows5_deluck), ("5s HAIRCUT50", rows5_hc50),
|
||||||
|
("2s FULL", rows2_full), ("2s HAIRCUT50", rows2_hc50), ("2s ESCLUDI(TP01)", rows2_excl)]:
|
||||||
|
for tv in (0.06, 0.10):
|
||||||
|
c, _ = cagr_at_tvol(rows, tv)
|
||||||
|
if c is None: continue
|
||||||
|
P(f" {label:18s} tvol={tv:.3f}: CAGR={c:+.4f} -> 2k={eur_day(c,2000):+.2f}€/g 5k={eur_day(c,5000):+.2f}€/g")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- dump
|
||||||
|
with open(os.path.join(OUT, 'r0703_frontier_tail_report.txt'), 'w') as f:
|
||||||
|
f.write("\n".join(rep))
|
||||||
|
def rows_to_df(rows): return pd.DataFrame(rows)
|
||||||
|
with pd.ExcelWriter(os.path.join(OUT, 'r0703_frontier.xlsx')) if False else open(os.devnull,'w'):
|
||||||
|
pass
|
||||||
|
for nm, rows in [('5s_full',rows5_full),('5s_deluck',rows5_deluck),('5s_hc50',rows5_hc50),
|
||||||
|
('5s_hc30',rows5_hc30),('5s_excl',rows5_excl),('2s_full',rows2_full),
|
||||||
|
('2s_deluck',rows2_deluck),('2s_hc50',rows2_hc50),('2s_excl',rows2_excl)]:
|
||||||
|
rows_to_df(rows).to_csv(os.path.join(OUT, f'r0703_front_{nm}.csv'), index=False)
|
||||||
|
P("\n[report salvato in scratchpad: r0703_frontier_tail_report.txt + CSV per book/variante]")
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""r0703_vrpimp_anchor.py — FILONE 5: AUDIT ANCHOR-LUCK di VRP01 (le 7 fasi settimanali).
|
||||||
|
|
||||||
|
VRP01 apre un put credit spread ogni 7 GIORNI-INDICE a partire da i=60 del join px/DVOL
|
||||||
|
(gap-free dal 2021-03-24) e lo tiene a scadenza. L'ancora e' quindi il GIORNO DELLA SETTIMANA
|
||||||
|
di apertura: 7 fasi a priori (i0 = 60+phase, phase in 0..6). E' l'unico sleeve ancorato non
|
||||||
|
ancora auditato dopo TP01 (24 ancore, P=0.86), XS01 (10 fasi) e SKH01 (23 offset).
|
||||||
|
|
||||||
|
Domande (ricerca di PROTEZIONE, non di alpha):
|
||||||
|
0. REPLICA bit-exact della sleeve canonica (_vrp_combo_returns) prima di tutto.
|
||||||
|
1. Banda delle 7 fasi a parametri IDENTICI (gate IV-rank/VRP ricalcolati causali per fase,
|
||||||
|
automatico: i gate si valutano all'indice d'ingresso della fase): Sh FULL/HOLD, maxDD,
|
||||||
|
worst-week, per-anno. Percentile della fase canonica.
|
||||||
|
2. Banda f skew-caveat {0.6, 0.8, 1.0, 1.3} (pricing BS flat su DVOL ATM).
|
||||||
|
3. ENSEMBLE delle 7 fasi (1/7 ciascuna) + null del DE-LEVERING (lezione TP01xDVOL:
|
||||||
|
una riduzione DD replicabile scalando la size non vale nulla).
|
||||||
|
4. Block-bootstrap P(spike) alla maniera dello scettico TP01 (r0702_skeptic_offset.py):
|
||||||
|
lo spike della fase canonica e' speciale o e' il massimo atteso di 7 stime correlate?
|
||||||
|
5. Impatto sul book 5-sleeve (TP 33/XS 15/VRP 12/SKH 20/GTAA 20, combine_outer) alla fase
|
||||||
|
canonica / mediana / peggiore / ensemble.
|
||||||
|
6. Ri-verdetto dei numeri di ammissione (FULL 1.10 / HOLD 0.60 / DD 12%) alla fase mediana.
|
||||||
|
|
||||||
|
Macchineria RIUSATA, non riscritta: helper del pricing e config presi in sola lettura da
|
||||||
|
src/portfolio/sleeves (_bs_put, _strike_from_delta, VRP_CFG) — il motore replica quindi la
|
||||||
|
sleeve canonica per costruzione, e la replica e' VERIFICATA bit-exact in PART 0. (Il motore
|
||||||
|
r0702_alb_structure usa load_tf di research_lab: qui serve il path della sleeve, resample_1d
|
||||||
|
su load_data, per la bit-exactness.) Nessun file di produzione toccato. Nessuna selezione
|
||||||
|
sull'hold-out (nessuna grid: 7 fasi = famiglia a priori, parametri congelati).
|
||||||
|
|
||||||
|
Regole standing rispettate: niente short-vol da modello in deploy (esito = conoscenza);
|
||||||
|
niente '7D' con origin (qui il ciclo e' index-based come il canonico, niente resample
|
||||||
|
settimanale); pandas 2.x niente .view('int64').
|
||||||
|
|
||||||
|
uv run python scripts/research/r0703_vrpimp_anchor.py
|
||||||
|
"""
|
||||||
|
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("/opt/docker/PythagorasGoal")
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
import altlib as al # noqa: E402 (harness di progetto: _sh/_dd_ret/HOLDOUT)
|
||||||
|
from src.data.downloader import load_data # noqa: E402
|
||||||
|
from src.strategies.trend_portfolio import resample_1d # noqa: E402
|
||||||
|
from src.portfolio.sleeves import ( # noqa: E402 (SOLA LETTURA)
|
||||||
|
VRP_CFG, _bs_put, _strike_from_delta, _vrp_weekly_asset, _vrp_combo_returns, _HL_DIR)
|
||||||
|
from src.portfolio.portfolio import combine_outer, to_daily # noqa: E402
|
||||||
|
|
||||||
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||||
|
WKY = 365.25 / 7.0
|
||||||
|
F_BAND = (0.6, 0.8, 1.0, 1.3)
|
||||||
|
PHASES = tuple(range(7))
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
B_BOOT = 4000
|
||||||
|
DAYNAME = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
|
ADMISSION = dict(full=1.10, hold=0.60, dd=0.12) # numeri dichiarati in CLAUDE.md
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# motore per-fase (specchio 1:1 di sleeves._vrp_weekly_asset, param i0 e f)
|
||||||
|
# ===========================================================================
|
||||||
|
@lru_cache(maxsize=4)
|
||||||
|
def joined(asset: str) -> pd.DataFrame:
|
||||||
|
"""Stesso join px/DVOL della sleeve canonica (resample_1d su load_data 1h)."""
|
||||||
|
df = resample_1d(load_data(asset, "1h"))
|
||||||
|
s = pd.Series(df["close"].values.astype(float), index=pd.to_datetime(df["datetime"]))
|
||||||
|
if s.index.tz is None:
|
||||||
|
s.index = s.index.tz_localize("UTC")
|
||||||
|
dv = pd.read_parquet(_HL_DIR / f"dvol_{asset.lower()}.parquet")
|
||||||
|
d = pd.Series(dv["close"].values.astype(float),
|
||||||
|
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True))
|
||||||
|
return pd.concat({"px": s, "dvol": d}, axis=1, join="inner").sort_index().dropna()
|
||||||
|
|
||||||
|
|
||||||
|
def vrp_weekly_phase(asset: str, phase: int = 0, f: float = 1.0) -> pd.Series:
|
||||||
|
"""Replica ESATTA di _vrp_weekly_asset con i0 = 60+phase e fattore premio f.
|
||||||
|
I gate (VRP>0, IV-rank espandente, crash-skip) sono ricalcolati CAUSALI all'indice
|
||||||
|
d'ingresso di ciascuna fase (dvf[:i]) — nessun riuso della sequenza gate di fase 0."""
|
||||||
|
J = joined(asset)
|
||||||
|
px = J["px"].values; dvf = J["dvol"].values / 100.0; idx = J.index
|
||||||
|
n = len(px); cfg = VRP_CFG; tn = cfg["tenor_d"]; T = tn / 365.25
|
||||||
|
rets = {}
|
||||||
|
i = 60 + phase
|
||||||
|
while i + tn < n:
|
||||||
|
S0 = px[i]; sig = dvf[i]
|
||||||
|
skip = False
|
||||||
|
if cfg["gate_vrp"] and i >= 31: # VRP>0 causale
|
||||||
|
rv = np.std(np.diff(np.log(px[i - 30:i + 1]))) * np.sqrt(365.25)
|
||||||
|
if (sig - rv) <= 0:
|
||||||
|
skip = True
|
||||||
|
if not skip and (cfg["gate_ivr"] > 0 or cfg["crash_skip"] < 1.0) and i >= 60:
|
||||||
|
ivr = float((dvf[:i] < dvf[i]).mean()) # IV-rank espandente
|
||||||
|
if cfg["gate_ivr"] > 0 and ivr < cfg["gate_ivr"]:
|
||||||
|
skip = True
|
||||||
|
if cfg["crash_skip"] < 1.0 and ivr > cfg["crash_skip"]:
|
||||||
|
skip = True
|
||||||
|
if skip:
|
||||||
|
rets[idx[i + tn]] = 0.0; i += tn; continue
|
||||||
|
Ks = _strike_from_delta(S0, T, sig, cfg["short_delta"])
|
||||||
|
Kl = _strike_from_delta(S0, T, sig, cfg["long_delta"])
|
||||||
|
net_prem = (_bs_put(S0, Ks, T, sig) - _bs_put(S0, Kl, T, sig)) * f
|
||||||
|
S1 = px[i + tn]
|
||||||
|
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1)
|
||||||
|
pnl = net_prem - payoff - cfg["fee_frac"] * abs(net_prem)
|
||||||
|
rets[idx[i + tn]] = pnl / Ks
|
||||||
|
i += tn
|
||||||
|
return pd.Series(rets)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=64)
|
||||||
|
def combo_weekly(phase: int, f: float = 1.0) -> pd.Series:
|
||||||
|
"""Book 50/50 BTC+ETH per fase (stessa inner-join su date di scadenza del canonico)."""
|
||||||
|
rB = vrp_weekly_phase("BTC", phase, f)
|
||||||
|
rE = vrp_weekly_phase("ETH", phase, f)
|
||||||
|
return pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1).sort_index()
|
||||||
|
|
||||||
|
|
||||||
|
def to_daily_lumped(wk: pd.Series) -> pd.Series:
|
||||||
|
"""Convenzione _vrp_combo_returns: lump del rendimento settimanale sul giorno di scadenza."""
|
||||||
|
days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(),
|
||||||
|
freq="1D", tz="UTC")
|
||||||
|
daily = pd.Series(0.0, index=days)
|
||||||
|
daily.loc[wk.index.normalize()] = wk.values
|
||||||
|
return daily
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# metriche
|
||||||
|
# ===========================================================================
|
||||||
|
def m_wk(r: pd.Series) -> dict:
|
||||||
|
"""Metriche sul ciclo settimanale naturale (convenzione dei numeri di ammissione)."""
|
||||||
|
r = r.dropna()
|
||||||
|
if len(r) < 3 or r.std() == 0:
|
||||||
|
return dict(sh=0.0, sh_h=0.0, cagr=0.0, dd=0.0, worst=0.0, act=0.0, n=len(r))
|
||||||
|
def _s(x):
|
||||||
|
return float(x.mean() / x.std() * np.sqrt(WKY)) if len(x) > 2 and x.std() > 0 else 0.0
|
||||||
|
eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq)
|
||||||
|
yrs = len(r) / WKY
|
||||||
|
return dict(sh=_s(r), sh_h=_s(r[r.index >= HOLDOUT]),
|
||||||
|
cagr=float(eq[-1] ** (1 / yrs) - 1) if yrs > 0 and eq[-1] > 0 else -1.0,
|
||||||
|
dd=float(np.max((pk - eq) / pk)), worst=float(r.min()),
|
||||||
|
act=float((r != 0.0).mean()), n=int(len(r)))
|
||||||
|
|
||||||
|
|
||||||
|
def sh_daily(s: pd.Series) -> float:
|
||||||
|
return al._sh(s)
|
||||||
|
|
||||||
|
|
||||||
|
def worst_7d(daily: pd.Series) -> float:
|
||||||
|
"""Peggior finestra di 7 giorni compounding, ancor-free (per l'ensemble)."""
|
||||||
|
lr = np.log1p(daily.values)
|
||||||
|
if len(lr) < 8:
|
||||||
|
return 0.0
|
||||||
|
cs = np.concatenate([[0.0], np.cumsum(lr)])
|
||||||
|
w = cs[7:] - cs[:-7]
|
||||||
|
return float(np.expm1(w.min()))
|
||||||
|
|
||||||
|
|
||||||
|
def pctl_of(vals: np.ndarray, v0: float) -> float:
|
||||||
|
return float(((vals < v0).sum() + 0.5 * (vals == v0).sum()) / len(vals) * 100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def per_year(r: pd.Series) -> dict:
|
||||||
|
return {int(y): float(np.prod(1 + g.values) - 1) for y, g in r.groupby(r.index.year)}
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# PART 0 — replica bit-exact
|
||||||
|
# ===========================================================================
|
||||||
|
def part0() -> None:
|
||||||
|
print("=" * 100)
|
||||||
|
print("PART 0 — REPLICA della sleeve canonica (sanity bit-exact vs sleeves._vrp_combo_returns)")
|
||||||
|
print("=" * 100)
|
||||||
|
for a in ASSETS:
|
||||||
|
mine = vrp_weekly_phase(a, phase=0, f=VRP_CFG["f"])
|
||||||
|
ref = _vrp_weekly_asset(a)
|
||||||
|
assert len(mine) == len(ref), f"P0 len mismatch {a}: {len(mine)} vs {len(ref)}"
|
||||||
|
assert (mine.index == ref.index).all(), f"P0 index mismatch {a}"
|
||||||
|
dmax = float(np.max(np.abs(mine.values - ref.values)))
|
||||||
|
assert dmax == 0.0, f"P0 valori non bit-exact {a}: max|diff|={dmax:.2e}"
|
||||||
|
print(f" [{a}] settimanale fase-0 == _vrp_weekly_asset: bit-exact "
|
||||||
|
f"(n={len(mine)}, max|diff|={dmax:.1e})")
|
||||||
|
mine_d = to_daily_lumped(combo_weekly(0, VRP_CFG["f"]))
|
||||||
|
ref_d = _vrp_combo_returns()
|
||||||
|
assert len(mine_d) == len(ref_d) and (mine_d.index == ref_d.index).all(), "P0 grid daily"
|
||||||
|
dmax = float(np.max(np.abs(mine_d.values - ref_d.values)))
|
||||||
|
assert dmax == 0.0, f"P0 combo daily non bit-exact: {dmax:.2e}"
|
||||||
|
print(f" [COMBO] daily-lumped fase-0 == _vrp_combo_returns: bit-exact (n={len(mine_d)})")
|
||||||
|
for a in ASSETS:
|
||||||
|
J = joined(a)
|
||||||
|
gaps = J.index.to_series().diff().dt.total_seconds().dropna() / 86400.0
|
||||||
|
print(f" [{a}] join px/DVOL: {len(J)} giorni {J.index.min().date()} -> "
|
||||||
|
f"{J.index.max().date()}, gap>1g: {int((gaps > 1).sum())} "
|
||||||
|
f"(gap-free -> ogni fase = giorno-della-settimana FISSO)")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# PART 1 — le 7 fasi a parametri identici (f=1.0)
|
||||||
|
# ===========================================================================
|
||||||
|
def part1() -> dict:
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("PART 1 — LE 7 FASI (parametri canonici, f=1.0). Ancora canonica = fase 0.")
|
||||||
|
print("=" * 100)
|
||||||
|
rows = {}
|
||||||
|
print(f" {'fase':<6}{'apre':>5}{'n_wk':>6}{'att%':>6}{'ShFULL':>8}{'ShHOLD':>8}"
|
||||||
|
f"{'CAGR':>8}{'maxDD':>8}{'worst-wk':>9}")
|
||||||
|
for p in PHASES:
|
||||||
|
wk = combo_weekly(p, 1.0)
|
||||||
|
entry_wd = int(pd.DatetimeIndex(wk.index - pd.Timedelta(days=VRP_CFG["tenor_d"]))
|
||||||
|
.dayofweek.min())
|
||||||
|
wd_n = pd.DatetimeIndex(wk.index).dayofweek.nunique()
|
||||||
|
mm = m_wk(wk)
|
||||||
|
rows[p] = dict(mm, entry_wd=entry_wd, wd_n=wd_n, wk=wk)
|
||||||
|
tag = " <- CANONICA" if p == 0 else ""
|
||||||
|
print(f" {p:<6}{DAYNAME[entry_wd]:>5}{mm['n']:>6}{mm['act']*100:>5.0f}%"
|
||||||
|
f"{mm['sh']:>8.2f}{mm['sh_h']:>8.2f}{mm['cagr']*100:>+7.1f}%"
|
||||||
|
f"{mm['dd']*100:>7.1f}%{mm['worst']*100:>+8.2f}%{tag}")
|
||||||
|
assert wd_n == 1, f"fase {p}: weekday non unico ({wd_n})"
|
||||||
|
|
||||||
|
for key, label in (("sh", "Sh FULL"), ("sh_h", "Sh HOLD"), ("dd", "maxDD"),
|
||||||
|
("worst", "worst-week"), ("cagr", "CAGR")):
|
||||||
|
v = np.array([rows[p][key] for p in PHASES])
|
||||||
|
print(f" banda {label:<11}: min {v.min():+.3f} / mediana {np.median(v):+.3f} / "
|
||||||
|
f"max {v.max():+.3f} | canonica {v[0]:+.3f} "
|
||||||
|
f"(pctl {pctl_of(v, v[0]):.0f}° su 7 fasi)")
|
||||||
|
|
||||||
|
print("\n per-anno (f=1.0) — dispersione di fase attraverso il 2022 (LUNA/FTX):")
|
||||||
|
yrs = sorted({y for p in PHASES for y in per_year(rows[p]["wk"])})
|
||||||
|
print(" fase " + "".join(f"{y:>9}" for y in yrs))
|
||||||
|
for p in PHASES:
|
||||||
|
py = per_year(rows[p]["wk"])
|
||||||
|
print(f" {p:<6}" + "".join(f"{py.get(y, float('nan'))*100:>+8.1f}%" for y in yrs))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# PART 2 — banda f (caveat skew: pricing BS flat su DVOL ATM)
|
||||||
|
# ===========================================================================
|
||||||
|
def part2() -> None:
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("PART 2 — BANDA f {0.6, 0.8, 1.0, 1.3} x 7 fasi (skew non esplicito -> banda, non punto)")
|
||||||
|
print("=" * 100)
|
||||||
|
print(f" {'f':>5} | {'ShFULL min/med/max':>22} {'(canon)':>8} | {'ShHOLD min/med/max':>22} "
|
||||||
|
f"{'(canon)':>8} | {'maxDD min/med/max':>20} {'(canon)':>8} | {'worst min':>9}")
|
||||||
|
for f in F_BAND:
|
||||||
|
mm = [m_wk(combo_weekly(p, f)) for p in PHASES]
|
||||||
|
sf = np.array([m["sh"] for m in mm]); shh = np.array([m["sh_h"] for m in mm])
|
||||||
|
dd = np.array([m["dd"] for m in mm]); wo = np.array([m["worst"] for m in mm])
|
||||||
|
print(f" {f:>5.1f} | {sf.min():>6.2f} {np.median(sf):>6.2f} {sf.max():>6.2f} "
|
||||||
|
f"{sf[0]:>7.2f} | {shh.min():>6.2f} {np.median(shh):>6.2f} {shh.max():>6.2f} "
|
||||||
|
f"{shh[0]:>7.2f} | {dd.min()*100:>5.1f} {np.median(dd)*100:>5.1f} "
|
||||||
|
f"{dd.max()*100:>5.1f}% {dd[0]*100:>6.1f}% | {wo.min()*100:>+8.2f}%")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# PART 3 — ensemble delle 7 fasi + null del de-levering
|
||||||
|
# ===========================================================================
|
||||||
|
def phase_matrix(f: float = 1.0) -> pd.DataFrame:
|
||||||
|
cols = {f"p{p}": to_daily_lumped(combo_weekly(p, f)) for p in PHASES}
|
||||||
|
M = pd.concat(cols, axis=1, join="outer").sort_index()
|
||||||
|
lo = max(c.index.min() for c in cols.values())
|
||||||
|
hi = min(c.index.max() for c in cols.values())
|
||||||
|
return M.loc[(M.index >= lo) & (M.index <= hi)].fillna(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def part3(rows: dict) -> dict:
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("PART 3 — ENSEMBLE delle 7 fasi (1/7 ciascuna) + NULL DEL DE-LEVERING")
|
||||||
|
print("=" * 100)
|
||||||
|
M = phase_matrix(1.0)
|
||||||
|
ens = M.mean(axis=1)
|
||||||
|
can = M["p0"]
|
||||||
|
def _stats(s, name):
|
||||||
|
dd = al._dd_ret(s)
|
||||||
|
out = dict(sh=sh_daily(s), sh_h=sh_daily(s[s.index >= HOLDOUT]), dd=dd,
|
||||||
|
w7=worst_7d(s),
|
||||||
|
cagr=float(np.prod(1 + s.values) ** (365.25 / len(s)) - 1))
|
||||||
|
print(f" {name:<28} ShFULL {out['sh']:>5.2f} ShHOLD {out['sh_h']:>5.2f} "
|
||||||
|
f"CAGR {out['cagr']*100:>+5.1f}% maxDD {out['dd']*100:>5.1f}% "
|
||||||
|
f"worst-7g {out['w7']*100:>+6.2f}%")
|
||||||
|
return out
|
||||||
|
print(" (griglia daily comune alle 7 fasi; Sharpe daily ~= Sharpe weekly del lump)")
|
||||||
|
s_can = _stats(can, "CANONICA fase-0")
|
||||||
|
s_ens = _stats(ens, "ENSEMBLE 7 fasi (1/7)")
|
||||||
|
|
||||||
|
# NULL DEL DE-LEVERING: lo Sharpe e' scale-free -> scalare la canonica non lo cambia.
|
||||||
|
# Cerco lambda t.c. maxDD(lam*canonica) ~= maxDD(ensemble) e confronto CAGR a pari DD.
|
||||||
|
lams = np.linspace(0.30, 1.00, 141)
|
||||||
|
dds = np.array([al._dd_ret(can * lam) for lam in lams])
|
||||||
|
k = int(np.argmin(np.abs(dds - s_ens["dd"])))
|
||||||
|
lam = float(lams[k]); scaled = can * lam
|
||||||
|
cagr_sc = float(np.prod(1 + scaled.values) ** (365.25 / len(scaled)) - 1)
|
||||||
|
print(f"\n NULL de-levering: canonica x lambda={lam:.2f} -> maxDD {dds[k]*100:.1f}% "
|
||||||
|
f"(~ ensemble {s_ens['dd']*100:.1f}%), Sharpe INVARIATO {sh_daily(scaled):.2f}, "
|
||||||
|
f"CAGR {cagr_sc*100:+.1f}% (ensemble {s_ens['cagr']*100:+.1f}%)")
|
||||||
|
# LENTE ONESTA sul numero dell'ensemble: lo Sharpe daily (sopra) e' GONFIATO dalla
|
||||||
|
# convenzione del lump — le 7 fasi lumpano su giorni DISGIUNTI (corr daily ~0 per
|
||||||
|
# costruzione) ma i 7 spread concorrenti condividono ~6/7 dell'esposizione economica.
|
||||||
|
# Ricompound a blocchi di 7 giorni (tutti gli offset) = P&L settimanale del libro
|
||||||
|
# sfalsato con la correlazione cross-fase RIPRISTINATA.
|
||||||
|
def sh_7d_blocks(s: pd.Series) -> np.ndarray:
|
||||||
|
lr = np.log1p(s.values)
|
||||||
|
shs = []
|
||||||
|
for o in range(7):
|
||||||
|
m = (len(lr) - o) // 7
|
||||||
|
if m < 30:
|
||||||
|
continue
|
||||||
|
w = np.expm1(lr[o:o + 7 * m].reshape(m, 7).sum(axis=1))
|
||||||
|
shs.append(float(w.mean() / w.std() * np.sqrt(WKY)) if w.std() > 0 else 0.0)
|
||||||
|
return np.array(shs)
|
||||||
|
print("\n LENTE ONESTA (Sharpe su blocchi 7g non-overlap, banda sui 7 offset di blocco):")
|
||||||
|
for name, s in (("CANONICA fase-0", can), ("ENSEMBLE 7 fasi", ens)):
|
||||||
|
vf = sh_7d_blocks(s); vh = sh_7d_blocks(s[s.index >= HOLDOUT])
|
||||||
|
print(f" {name:<20} FULL min/med/max {vf.min():.2f}/{np.median(vf):.2f}/{vf.max():.2f}"
|
||||||
|
+ (f" | HOLD {vh.min():.2f}/{np.median(vh):.2f}/{vh.max():.2f}" if len(vh) else ""))
|
||||||
|
print(" -> il numero citabile dell'ensemble e' quello a blocchi 7g, NON lo Sh daily del lump.")
|
||||||
|
print(" NB: anche il maxDD (canonica E ensemble) e' su equity a date di REGOLAMENTO —")
|
||||||
|
print(" il mark-to-market intra-settimana degli spread aperti non e' catturato (caveat noto).")
|
||||||
|
beats = (s_ens["sh"] > s_can["sh"] + 1e-9) and (s_ens["cagr"] > cagr_sc + 1e-9)
|
||||||
|
print(f"\n -> l'ensemble batte il de-levering? {'SI' if beats else 'NO'} "
|
||||||
|
f"(serve Sh_ens > Sh_can E CAGR_ens > CAGR de-levered a pari DD; il CAGR e' "
|
||||||
|
f"convention-free, lo Sh daily dell'ensemble NO — vedi lente onesta)")
|
||||||
|
print(" NB deploy: a $600-2k un solo spread min-size satura il libro -> 7 tranche da 1/7")
|
||||||
|
print(" NON sono eseguibili (stesso muro del tranching TP01); l'ensemble e' una LENTE di")
|
||||||
|
print(" misura (de-lucking della stima), non una modifica proponibile del book.")
|
||||||
|
return dict(ens=s_ens, can=s_can, lam=lam, cagr_scaled=cagr_sc, beats=beats)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# PART 4 — block-bootstrap P(spike) (metodo r0702_skeptic_offset)
|
||||||
|
# ===========================================================================
|
||||||
|
def _sh_mat(R: np.ndarray) -> np.ndarray:
|
||||||
|
mu = R.mean(axis=1); sd = R.std(axis=1)
|
||||||
|
with np.errstate(divide="ignore", invalid="ignore"):
|
||||||
|
out = np.where(sd > 0, mu / sd, 0.0)
|
||||||
|
return np.nan_to_num(out) * np.sqrt(365.25)
|
||||||
|
|
||||||
|
|
||||||
|
def block_boot(M: np.ndarray, B: int, block: int, rng) -> dict:
|
||||||
|
n, K = M.shape
|
||||||
|
nblocks = int(np.ceil(n / block))
|
||||||
|
g0s, gmaxs = [], []
|
||||||
|
done = 0
|
||||||
|
while done < B:
|
||||||
|
b = min(500, B - done)
|
||||||
|
starts = rng.integers(0, n, size=(b, nblocks))
|
||||||
|
idx = (starts[:, :, None] + np.arange(block)[None, None, :]) % n
|
||||||
|
idx = idx.reshape(b, -1)[:, :n]
|
||||||
|
R = M[idx]
|
||||||
|
Sh = np.stack([_sh_mat(R[:, :, k]) for k in range(K)], axis=1)
|
||||||
|
med_others = np.empty_like(Sh)
|
||||||
|
for h in range(K):
|
||||||
|
others = np.delete(Sh, h, axis=1)
|
||||||
|
med_others[:, h] = np.median(others, axis=1)
|
||||||
|
g = Sh - med_others
|
||||||
|
g0s.append(g[:, 0]); gmaxs.append(g.max(axis=1))
|
||||||
|
done += b
|
||||||
|
return dict(g0=np.concatenate(g0s), gmax=np.concatenate(gmaxs))
|
||||||
|
|
||||||
|
|
||||||
|
def part4() -> dict:
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("PART 4 — BLOCK-BOOTSTRAP: lo spike della fase canonica e' speciale o massimo atteso di 7?")
|
||||||
|
print("=" * 100)
|
||||||
|
M = phase_matrix(1.0)
|
||||||
|
out = {}
|
||||||
|
for wname, W in (("HOLD-OUT 2025+", M[M.index >= HOLDOUT]),
|
||||||
|
("FULL 2021-26", M)):
|
||||||
|
Mv = W.values
|
||||||
|
sh = _sh_mat(Mv.T)
|
||||||
|
g0 = float(sh[0] - np.median(sh[1:]))
|
||||||
|
corr = np.corrcoef(Mv.T); iu = np.triu_indices(len(PHASES), 1)
|
||||||
|
print(f"\n [{wname}] {Mv.shape[0]} giorni | Sh fase-0 {sh[0]:+.3f}, mediana altre "
|
||||||
|
f"{np.median(sh[1:]):+.3f}, spike g0 = {g0:+.3f} | corr daily fra fasi: "
|
||||||
|
f"mediana {np.median(corr[iu]):+.3f} (lump su giorni diversi -> quasi-ortogonali "
|
||||||
|
f"per costruzione)")
|
||||||
|
for blk in (14, 28, 56):
|
||||||
|
bs = block_boot(Mv, B_BOOT, blk, np.random.default_rng(73 + blk))
|
||||||
|
p_any = float(np.mean(bs["gmax"] >= g0))
|
||||||
|
p_le0 = float(np.mean(bs["g0"] <= 0.0))
|
||||||
|
ci = np.percentile(bs["g0"], [2.5, 97.5])
|
||||||
|
print(f" block={blk:>2}: P(spike di UNA QUALSIASI fase >= {g0:+.2f}) = {p_any:.3f}"
|
||||||
|
f" | P(g0<=0) = {p_le0:.3f} | CI95 g0 [{ci[0]:+.2f},{ci[1]:+.2f}]")
|
||||||
|
out[(wname, blk)] = p_any
|
||||||
|
# finestre annuali: la fase migliore e' stabile o gira?
|
||||||
|
print("\n finestre annuali — Sh per fase, pctl della canonica, best-fase:")
|
||||||
|
print(f" {'finestra':<9}" + "".join(f"{'p'+str(p):>8}" for p in PHASES)
|
||||||
|
+ f"{'pctl p0':>9}{'best':>6}")
|
||||||
|
idx_years = sorted(set(M.index.year))
|
||||||
|
for y in idx_years:
|
||||||
|
W = M[M.index.year == y]
|
||||||
|
if len(W) < 60:
|
||||||
|
continue
|
||||||
|
sh = _sh_mat(W.values.T)
|
||||||
|
print(f" {y:<9}" + "".join(f"{v:>8.2f}" for v in sh)
|
||||||
|
+ f"{pctl_of(sh, sh[0]):>8.0f}°{int(np.argmax(sh)):>6}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# PART 5 — impatto sul book 5-sleeve
|
||||||
|
# ===========================================================================
|
||||||
|
def part5(rows: dict) -> dict:
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("PART 5 — BOOK 5-SLEEVE (TP 33/XS 15/VRP 12/SKH 20/GTAA 20) con VRP01 per fase")
|
||||||
|
print("=" * 100)
|
||||||
|
from src.portfolio.sleeves import (_gtaa_daily_returns, _skyhook_returns,
|
||||||
|
_tp01_returns, _xsec_returns)
|
||||||
|
tp = to_daily(_tp01_returns())
|
||||||
|
fixed = dict(TP=tp, XS=to_daily(_xsec_returns()), SKH=to_daily(_skyhook_returns()),
|
||||||
|
GTAA=to_daily(_gtaa_daily_returns()))
|
||||||
|
W = dict(TP=0.33, XS=0.15, VRP=0.12, SKH=0.20, GTAA=0.20)
|
||||||
|
lo = tp.index.min()
|
||||||
|
|
||||||
|
def book(vrp_daily):
|
||||||
|
s = combine_outer(dict(fixed, VRP=vrp_daily), W, lo=lo)
|
||||||
|
return dict(sh=al._sh(s), sh_h=al._sh(s[s.index >= HOLDOUT]), dd=al._dd_ret(s),
|
||||||
|
dd_h=al._dd_ret(s[s.index >= HOLDOUT]))
|
||||||
|
|
||||||
|
res = {}
|
||||||
|
print(f" {'variante VRP':<22}{'ShFULL':>8}{'ShHOLD':>8}{'maxDD':>8}{'DD hold':>9}")
|
||||||
|
for p in PHASES:
|
||||||
|
res[p] = book(to_daily_lumped(rows[p]["wk"]))
|
||||||
|
tag = " <- CANONICA (book corrente)" if p == 0 else ""
|
||||||
|
print(f" fase {p} ({DAYNAME[rows[p]['entry_wd']]}){'':<10}{res[p]['sh']:>8.2f}"
|
||||||
|
f"{res[p]['sh_h']:>8.2f}{res[p]['dd']*100:>7.1f}%{res[p]['dd_h']*100:>8.1f}%{tag}")
|
||||||
|
M = phase_matrix(1.0)
|
||||||
|
res["ens"] = book(M.mean(axis=1))
|
||||||
|
print(f" {'ENSEMBLE 7 fasi':<22}{res['ens']['sh']:>8.2f}{res['ens']['sh_h']:>8.2f}"
|
||||||
|
f"{res['ens']['dd']*100:>7.1f}%{res['ens']['dd_h']*100:>8.1f}%")
|
||||||
|
|
||||||
|
for key, label in (("sh", "ShFULL"), ("sh_h", "ShHOLD"), ("dd", "maxDD")):
|
||||||
|
v = np.array([res[p][key] for p in PHASES])
|
||||||
|
print(f" banda book {label:<7}: min {v.min():.3f} / mediana {np.median(v):.3f} / "
|
||||||
|
f"max {v.max():.3f} | canonica {v[0]:.3f} (pctl {pctl_of(v, v[0]):.0f}°)")
|
||||||
|
print(" (il peso VRP e' 12%: la banda di fase si diluisce ~0.12 nel book — atteso stretto)")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# PART 6 — ri-verdetto dei numeri di ammissione alla fase mediana
|
||||||
|
# ===========================================================================
|
||||||
|
def part6(rows: dict) -> None:
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("PART 6 — RI-VERDETTO dei numeri di ammissione VRP01 (FULL 1.10 / HOLD 0.60 / DD 12%)")
|
||||||
|
print("=" * 100)
|
||||||
|
sf = np.array([rows[p]["sh"] for p in PHASES])
|
||||||
|
shh = np.array([rows[p]["sh_h"] for p in PHASES])
|
||||||
|
dd = np.array([rows[p]["dd"] for p in PHASES])
|
||||||
|
wo = np.array([rows[p]["worst"] for p in PHASES])
|
||||||
|
print(f" Sh FULL : dichiarato {ADMISSION['full']:.2f} | canonica {sf[0]:.2f} "
|
||||||
|
f"(pctl {pctl_of(sf, sf[0]):.0f}°) | mediana fasi {np.median(sf):.2f} | "
|
||||||
|
f"banda [{sf.min():.2f}, {sf.max():.2f}]")
|
||||||
|
print(f" Sh HOLD : dichiarato {ADMISSION['hold']:.2f} | canonica {shh[0]:.2f} "
|
||||||
|
f"(pctl {pctl_of(shh, shh[0]):.0f}°) | mediana fasi {np.median(shh):.2f} | "
|
||||||
|
f"banda [{shh.min():.2f}, {shh.max():.2f}]")
|
||||||
|
print(f" maxDD : dichiarato {ADMISSION['dd']*100:.0f}% | canonica {dd[0]*100:.1f}% "
|
||||||
|
f"(pctl {pctl_of(dd, dd[0]):.0f}°) | mediana fasi {np.median(dd)*100:.1f}% | "
|
||||||
|
f"banda [{dd.min()*100:.1f}%, {dd.max()*100:.1f}%]")
|
||||||
|
print(f" worst-wk: canonica {wo[0]*100:+.2f}% | mediana {np.median(wo)*100:+.2f}% | "
|
||||||
|
f"peggiore {wo.min()*100:+.2f}%")
|
||||||
|
print("\n Regola 2026-07-02: i numeri di VRP01 si citano d'ora in poi CON la banda di fase.")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
part0()
|
||||||
|
rows = part1()
|
||||||
|
part2()
|
||||||
|
part3(rows)
|
||||||
|
part4()
|
||||||
|
part5(rows)
|
||||||
|
part6(rows)
|
||||||
|
print("\nFatto (r0703_vrpimp_anchor).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""R0703 VRPIMP-DDOVERLAY — overlay di protezione DD su VRP01 canonico vs NULL DEL DE-LEVERING.
|
||||||
|
|
||||||
|
FILONE 2 (ondata 2026-07-03): su VRP01 canonico (put credit spread settimanale -0.28/-0.10,
|
||||||
|
gate vrp>0 + ivr>=0.30 + crash-skip 0.90, riprodotto ESATTO dal motore path qui sotto e
|
||||||
|
bridge-validato vs options_vrp_v2.vrp_spread_weekly) si testano 4 famiglie di overlay:
|
||||||
|
|
||||||
|
(a) EXIT INTRA-SETTIMANA su spike: DVOL sale >X% dall'ingresso, oppure RV5 causale supera
|
||||||
|
m x DVOL d'ingresso -> chiudi lo spread al close del giorno del trigger (MTM BS).
|
||||||
|
(b) STOP-LOSS sul MTM: esci se il valore dello spread raggiunge mult x credito incassato.
|
||||||
|
(c) ALA DI CODA comprata: q put molto piu' OTM (delta -0.05/-0.03) finanziate col credito
|
||||||
|
(ratio/broken-wing-ish), tenute a scadenza.
|
||||||
|
(d) COOLDOWN: salta c settimane di calendario dopo una settimana attiva in perdita.
|
||||||
|
|
||||||
|
REGOLA VINCOLANTE (lezione TP01xDVOL 2026-06-26): ogni claim di riduzione DD si confronta col
|
||||||
|
NULL DEL DE-LEVERING — lo scaling s della size del baseline che produce lo STESSO maxDD. Lo
|
||||||
|
Sharpe e' scale-invariante => il de-levered ha SEMPRE lo Sharpe del baseline: un overlay "vale"
|
||||||
|
solo se il suo Sharpe batte quello del baseline A PARI maxDD (e regge banda f + multi-cut + DSR).
|
||||||
|
|
||||||
|
⚠️ LENS DICHIARATA (in testa, non in fondo):
|
||||||
|
- Granularita' 1d: i trigger (a)/(b) si valutano al CLOSE giornaliero e si esegue a QUEL close
|
||||||
|
-> il gap-through oltre il livello di stop E' DENTRO il fill (onesto), ma lo spike intraday
|
||||||
|
peggiore del close NON e' modellato (lezione SKH01: a 1d lo stop e' una lente, non un fill).
|
||||||
|
- MTM Black-Scholes FLAT sulla DVOL-30g usata per tenor <=7g: in stress il front-end esplode
|
||||||
|
piu' della 30g e lo skew irripidisce -> il MTM avverso e' SOTTOSTIMATO => gli stop scattano
|
||||||
|
piu' tardi e costano piu' del modellato. Percio' banda f {0.6,0.8,1.0,1.3} obbligatoria.
|
||||||
|
- Booking: il pnl di ogni trade (anche se chiuso prima) e' registrato alla data di SCADENZA
|
||||||
|
-> griglia settimanale identica al baseline, Sharpe confrontabile (lente, non timing reale).
|
||||||
|
- Equity settimanale compounding (convenzione VRP01): DD/worst-week su quella lente.
|
||||||
|
- Banda d'ancora (regola 2026-07-02): loop settimanale ancorato a i0=60 -> 7 offset riportati.
|
||||||
|
|
||||||
|
Regola standing: niente short-vol da modello in deploy — obiettivo = conoscenza/LEAD.
|
||||||
|
Selezione celle SOLO in-sample (pre-2025); hold-out 2025-26 mai usato per scegliere; DSR sul
|
||||||
|
numero totale di celle (14).
|
||||||
|
|
||||||
|
uv run python scripts/research/r0703_vrpimp_ddoverlay.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt"))
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from scripts.research.options_vrp_lab import bs_put, strike_from_delta, load_series, m_weekly, per_year
|
||||||
|
from scripts.research.options_vrp_v2 import vrp_spread_weekly, _ivrank, _rv30
|
||||||
|
|
||||||
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||||
|
WK = 365.25 / 7.0
|
||||||
|
F_SWEEP = (0.6, 0.8, 1.0, 1.3)
|
||||||
|
FEE_FRAC = 0.125 # convenzione VRP01: 12.5% del premio netto per il round-trip base
|
||||||
|
TENOR = 7
|
||||||
|
|
||||||
|
|
||||||
|
def _rv5(px: np.ndarray, j: int) -> float:
|
||||||
|
"""RV annualizzata causale dagli ultimi 5 rendimenti giornalieri (fino a j incluso)."""
|
||||||
|
if j < 6:
|
||||||
|
return np.nan
|
||||||
|
r = np.diff(np.log(px[j - 5:j + 1]))
|
||||||
|
return float(np.std(r) * np.sqrt(365.25))
|
||||||
|
|
||||||
|
|
||||||
|
def vrp_path_weekly(asset, f=1.0, overlay=None, i0=60, collect=None):
|
||||||
|
"""VRP01 canonico con path MTM giornaliero. overlay=None riproduce vrp_spread_weekly
|
||||||
|
(defined_risk, gate vrp>0 + ivr>=0.30 + crash_skip 0.90) ESATTO (bridge sotto).
|
||||||
|
|
||||||
|
overlay: ('spike_dvol', x) | ('spike_rv', m) | ('sl', mult) | ('tail', delta, q) | ('cool', c)
|
||||||
|
Ritorna Series settimanale di rendimenti su capitale = strike corto (convenzione VRP01).
|
||||||
|
"""
|
||||||
|
J = load_series(asset)
|
||||||
|
px = J["px"].values; dv = J["dvol"].values / 100.0; idx = J.index
|
||||||
|
n = len(px); T = TENOR / 365.25
|
||||||
|
kind = overlay[0] if overlay else None
|
||||||
|
rets = {}
|
||||||
|
cool_left = 0
|
||||||
|
i = i0
|
||||||
|
while i + TENOR < n:
|
||||||
|
S0 = px[i]; sig = dv[i]
|
||||||
|
# --- cooldown (d): decisione causale dal risultato del trade precedente gia' chiuso ---
|
||||||
|
if kind == "cool" and cool_left > 0:
|
||||||
|
cool_left -= 1
|
||||||
|
rets[idx[i + TENOR]] = 0.0
|
||||||
|
i += TENOR
|
||||||
|
continue
|
||||||
|
# --- gate canonici VRP01 (identici a vrp_spread_weekly, NON riottimizzati) ---
|
||||||
|
skip = False
|
||||||
|
rv = _rv30(px, i)
|
||||||
|
if not np.isnan(rv) and (sig - rv) <= 0:
|
||||||
|
skip = True
|
||||||
|
ivr = _ivrank(dv, i)
|
||||||
|
if not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90):
|
||||||
|
skip = True
|
||||||
|
if skip:
|
||||||
|
rets[idx[i + TENOR]] = 0.0
|
||||||
|
i += TENOR
|
||||||
|
continue
|
||||||
|
# --- struttura canonica ---
|
||||||
|
Ks = strike_from_delta(S0, T, sig, -0.28)
|
||||||
|
Kl = strike_from_delta(S0, T, sig, -0.10)
|
||||||
|
net_prem = (bs_put(S0, Ks, T, sig) - bs_put(S0, Kl, T, sig)) * f
|
||||||
|
base_fee = FEE_FRAC * abs(net_prem)
|
||||||
|
# --- ala di coda (c): q put extra-OTM a stessa scadenza, pagata col credito ---
|
||||||
|
tail_cost = tail_fee = 0.0; K_tail = None; q = 0
|
||||||
|
if kind == "tail":
|
||||||
|
_, dlt, q = overlay
|
||||||
|
K_tail = strike_from_delta(S0, T, sig, dlt)
|
||||||
|
prem_t = bs_put(S0, K_tail, T, sig) * f
|
||||||
|
tail_cost = q * prem_t
|
||||||
|
tail_fee = q * min(0.0003 * S0, 0.125 * max(prem_t, 1e-12))
|
||||||
|
if collect is not None:
|
||||||
|
collect.setdefault("tail_frac", []).append(tail_cost / max(net_prem, 1e-12))
|
||||||
|
# --- path MTM giornaliero: exit intra-settimana (a)/(b) al primo trigger, a QUEL close ---
|
||||||
|
exited = False
|
||||||
|
if kind in ("spike_dvol", "spike_rv", "sl"):
|
||||||
|
for j in range(i + 1, i + TENOR):
|
||||||
|
T_rem = (i + TENOR - j) / 365.25
|
||||||
|
trig = False
|
||||||
|
if kind == "spike_dvol":
|
||||||
|
trig = (dv[j] / sig - 1.0) > overlay[1]
|
||||||
|
elif kind == "spike_rv":
|
||||||
|
r5 = _rv5(px, j)
|
||||||
|
trig = (not np.isnan(r5)) and (r5 > overlay[1] * sig)
|
||||||
|
elif kind == "sl":
|
||||||
|
Vj = (bs_put(px[j], Ks, T_rem, dv[j]) - bs_put(px[j], Kl, T_rem, dv[j])) * f
|
||||||
|
trig = Vj >= overlay[1] * net_prem
|
||||||
|
if trig:
|
||||||
|
Vj = (bs_put(px[j], Ks, T_rem, dv[j]) - bs_put(px[j], Kl, T_rem, dv[j])) * f
|
||||||
|
exit_fee = min(0.0006 * px[j], 0.125 * max(Vj, 1e-12)) # 2 gambe buyback
|
||||||
|
pnl = net_prem - Vj - base_fee - exit_fee
|
||||||
|
rets[idx[i + TENOR]] = pnl / Ks # booking a scadenza (lens dichiarata)
|
||||||
|
if collect is not None:
|
||||||
|
collect.setdefault("exits", []).append((idx[j], (net_prem - Vj) / Ks))
|
||||||
|
exited = True
|
||||||
|
break
|
||||||
|
if not exited:
|
||||||
|
S1 = px[i + TENOR]
|
||||||
|
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1)
|
||||||
|
pnl = net_prem - payoff - base_fee
|
||||||
|
if kind == "tail":
|
||||||
|
pnl += q * max(0.0, K_tail - S1) - tail_cost - tail_fee
|
||||||
|
rets[idx[i + TENOR]] = pnl / Ks
|
||||||
|
if kind == "cool" and rets[idx[i + TENOR]] < 0:
|
||||||
|
cool_left = overlay[1]
|
||||||
|
i += TENOR
|
||||||
|
return pd.Series(rets)
|
||||||
|
|
||||||
|
|
||||||
|
def book(f=1.0, overlay=None, i0=60, collect=None):
|
||||||
|
rB = vrp_path_weekly("BTC", f=f, overlay=overlay, i0=i0, collect=collect)
|
||||||
|
rE = vrp_path_weekly("ETH", f=f, overlay=overlay, i0=i0, collect=collect)
|
||||||
|
return pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def maxdd(r):
|
||||||
|
eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq)
|
||||||
|
return float(np.max((pk - eq) / pk)) if len(r) else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def delever_scale(r_base, dd_target, hi=5.0):
|
||||||
|
"""Scaling s della size del baseline che produce maxDD == dd_target (bisezione)."""
|
||||||
|
lo = 0.0
|
||||||
|
for _ in range(80):
|
||||||
|
mid = (lo + hi) / 2
|
||||||
|
if maxdd(mid * r_base) > dd_target:
|
||||||
|
hi = mid
|
||||||
|
else:
|
||||||
|
lo = mid
|
||||||
|
return (lo + hi) / 2
|
||||||
|
|
||||||
|
|
||||||
|
def mstats(r):
|
||||||
|
full = m_weekly(r); ho = m_weekly(r[r.index >= HOLDOUT]); isam = m_weekly(r[r.index < HOLDOUT])
|
||||||
|
return dict(sh=full["sh"], sh_h=ho["sh"], sh_is=isam["sh"], cagr=full["cagr"],
|
||||||
|
dd=full["dd"], worst=float(r.min()) if len(r) else 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(name, m, extra=""):
|
||||||
|
print(f" {name:<34} ShF {m['sh']:>5.2f} ShH {m['sh_h']:>5.2f} Sh-IS {m['sh_is']:>5.2f} "
|
||||||
|
f"CAGR {m['cagr']*100:>+5.1f}% DD {m['dd']*100:>4.1f}% worst {m['worst']*100:>+5.1f}%{extra}")
|
||||||
|
|
||||||
|
|
||||||
|
OVERLAYS = {
|
||||||
|
"a-spike": [("spike_dvol", 0.15), ("spike_dvol", 0.25), ("spike_dvol", 0.40),
|
||||||
|
("spike_rv", 1.00), ("spike_rv", 1.25)],
|
||||||
|
"b-stoploss": [("sl", 1.5), ("sl", 2.0), ("sl", 3.0)],
|
||||||
|
"c-tailwing": [("tail", -0.05, 1), ("tail", -0.05, 2), ("tail", -0.03, 1), ("tail", -0.03, 2)],
|
||||||
|
"d-cooldown": [("cool", 1), ("cool", 2)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def olabel(ov):
|
||||||
|
k = ov[0]
|
||||||
|
if k == "spike_dvol":
|
||||||
|
return f"exit DVOL +{ov[1]*100:.0f}% da entry"
|
||||||
|
if k == "spike_rv":
|
||||||
|
return f"exit RV5 > {ov[1]:.2f}x DVOL entry"
|
||||||
|
if k == "sl":
|
||||||
|
return f"stop-loss MTM {ov[1]:.1f}x credito"
|
||||||
|
if k == "tail":
|
||||||
|
return f"tail put d={ov[1]:+.2f} q={ov[2]}"
|
||||||
|
return f"cooldown {ov[1]} settimana/e"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 112)
|
||||||
|
print(" R0703 VRPIMP-DDOVERLAY — overlay di protezione DD su VRP01 vs NULL DEL DE-LEVERING")
|
||||||
|
print(" Lens: MTM BS flat su DVOL-30g, trigger e fill al close 1d (gap-through nel fill, spike")
|
||||||
|
print(" intraday e term-structure di stress NON modellati -> stop piu' lenti/cari del reale).")
|
||||||
|
print(" Booking a scadenza su griglia settimanale. Selezione celle SOLO in-sample (pre-2025).")
|
||||||
|
print("=" * 112)
|
||||||
|
for a in ("BTC", "ETH"):
|
||||||
|
J = load_series(a)
|
||||||
|
print(f" {a}: dati {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)")
|
||||||
|
print(" Eventi chiave nel campione: 2021-05 (crash Cina), 2022-05 (LUNA), 2022-11 (FTX).")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- (0) bridge di validazione
|
||||||
|
print("\n (0) BRIDGE: motore path (overlay=None) vs options_vrp_v2.vrp_spread_weekly (VRP01)")
|
||||||
|
base = {}
|
||||||
|
for f in F_SWEEP:
|
||||||
|
canon = pd.concat({"B": vrp_spread_weekly("BTC", defined_risk=True, f=f, gate_vrp=True,
|
||||||
|
gate_ivr=0.30, crash_skip=0.90),
|
||||||
|
"E": vrp_spread_weekly("ETH", defined_risk=True, f=f, gate_vrp=True,
|
||||||
|
gate_ivr=0.30, crash_skip=0.90)},
|
||||||
|
axis=1, join="inner").mean(axis=1)
|
||||||
|
mine = book(f=f)
|
||||||
|
Jn = pd.concat({"c": canon, "m": mine}, axis=1, join="inner")
|
||||||
|
dmax = float((Jn["c"] - Jn["m"]).abs().max())
|
||||||
|
base[f] = mine
|
||||||
|
if f == 1.0:
|
||||||
|
print(f" f={f}: max|diff| = {dmax:.2e} su {len(Jn)} settimane "
|
||||||
|
f"{'OK (riproduzione esatta)' if dmax < 1e-12 else '⚠️ MISMATCH'}")
|
||||||
|
|
||||||
|
print("\n (1) BASELINE VRP01 canonico — banda f")
|
||||||
|
for f in F_SWEEP:
|
||||||
|
fmt(f"VRP01 f={f}", mstats(base[f]))
|
||||||
|
pyb = per_year(base[1.0])
|
||||||
|
print(" per-anno f=1.0: " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(pyb.items())))
|
||||||
|
anchor_sh = [m_weekly(book(f=1.0, i0=60 + o))["sh"] for o in range(7)]
|
||||||
|
print(f" banda d'ancora (7 offset del loop settimanale): ShF in "
|
||||||
|
f"[{min(anchor_sh):.2f}, {max(anchor_sh):.2f}] mediana {np.median(anchor_sh):.2f}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- (2) griglia overlay f=1.0
|
||||||
|
print(f"\n (2) GRIGLIA OVERLAY (f=1.0) — 14 celle, selezione per-famiglia su Sh-IS (pre-2025)")
|
||||||
|
m0 = mstats(base[1.0])
|
||||||
|
all_full_sh = [m0["sh"]]
|
||||||
|
cells = {}
|
||||||
|
best = {}
|
||||||
|
for fam, ovs in OVERLAYS.items():
|
||||||
|
for ov in ovs:
|
||||||
|
col = {}
|
||||||
|
r = book(f=1.0, overlay=ov, collect=col)
|
||||||
|
m = mstats(r)
|
||||||
|
cells[ov] = (r, m, col)
|
||||||
|
all_full_sh.append(m["sh"])
|
||||||
|
n_ex = len(col.get("exits", []))
|
||||||
|
extra = f" dSh {m['sh']-m0['sh']:+.2f}"
|
||||||
|
if n_ex:
|
||||||
|
extra += f" early-exit n={n_ex}"
|
||||||
|
if col.get("tail_frac"):
|
||||||
|
extra += f" costo ala = {np.mean(col['tail_frac'])*100:.0f}% del credito"
|
||||||
|
fmt(f"[{fam}] {olabel(ov)}", m, extra)
|
||||||
|
if fam not in best or m["sh_is"] > cells[best[fam]][1]["sh_is"]:
|
||||||
|
best[fam] = ov
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- (3) best-IS per famiglia: banda f + NULL de-levering
|
||||||
|
print(" (3) CELLA BEST-IN-SAMPLE PER FAMIGLIA — banda f + CONFRONTO OBBLIGATORIO col null de-levering")
|
||||||
|
print(" (de-lever = s x baseline con lo STESSO maxDD; lo Sharpe del de-levered = Sharpe baseline,")
|
||||||
|
print(" scale-invariante: l'overlay vale SOLO se ShF_overlay > ShF_base a pari DD)")
|
||||||
|
import altlib as al
|
||||||
|
verdicts = {}
|
||||||
|
for fam, ov in best.items():
|
||||||
|
print(f"\n --- {fam}: {olabel(ov)} ---")
|
||||||
|
print(f" {'f':>4} | {'overlay: ShF':>12} {'ShH':>6} {'DD':>6} {'worst':>7} | "
|
||||||
|
f"{'de-lever s':>10} {'ShF':>6} {'CAGR':>7} {'worst':>7} | {'dSh a pari DD':>13}")
|
||||||
|
wins = 0
|
||||||
|
for f in F_SWEEP:
|
||||||
|
r = book(f=f, overlay=ov) if f != 1.0 else cells[ov][0]
|
||||||
|
m = mstats(r); mb = mstats(base[f])
|
||||||
|
s = delever_scale(base[f], m["dd"])
|
||||||
|
rd = s * base[f]; md = mstats(rd)
|
||||||
|
dsh = m["sh"] - md["sh"]
|
||||||
|
wins += dsh > 0.05
|
||||||
|
print(f" {f:>4} | {m['sh']:>12.2f} {m['sh_h']:>6.2f} {m['dd']*100:>5.1f}% {m['worst']*100:>+6.1f}% | "
|
||||||
|
f"{s:>10.2f} {md['sh']:>6.2f} {md['cagr']*100:>+6.1f}% {md['worst']*100:>+6.1f}% | {dsh:>+13.2f}")
|
||||||
|
# multi-cut: dSh per finestra annuale disgiunta (f=1.0) — persistenza, non finestra fortunata
|
||||||
|
r1 = cells[ov][0]
|
||||||
|
dcut = {}
|
||||||
|
for y in (2021, 2022, 2023, 2024, 2025):
|
||||||
|
sel = r1.index.year == y if y < 2025 else r1.index >= HOLDOUT
|
||||||
|
selb = base[1.0].index.year == y if y < 2025 else base[1.0].index >= HOLDOUT
|
||||||
|
dcut[y] = m_weekly(r1[sel])["sh"] - m_weekly(base[1.0][selb])["sh"]
|
||||||
|
npos = sum(v > 0 for y, v in dcut.items() if y < 2025)
|
||||||
|
print(" multi-cut dSh: " + " ".join(f"{y}:{v:+.2f}" for y, v in dcut.items())
|
||||||
|
+ f" ({npos}/4 finestre IS positive)")
|
||||||
|
dsr, null_max = al.deflated_sharpe(cells[ov][1]["sh"], all_full_sh,
|
||||||
|
cells[ov][0].values, dpy=WK)
|
||||||
|
print(f" deflated-Sharpe (14 celle + base): DSR {dsr:.3f} (null-max {null_max:.2f}) "
|
||||||
|
f"{'PASS' if dsr >= 0.95 else 'FAIL'}")
|
||||||
|
aband = [m_weekly(book(f=1.0, overlay=ov, i0=60 + o))["sh"]
|
||||||
|
- m_weekly(book(f=1.0, i0=60 + o))["sh"] for o in range(7)]
|
||||||
|
print(f" banda d'ancora dSh (7 offset): [{min(aband):+.2f}, {max(aband):+.2f}] "
|
||||||
|
f"mediana {np.median(aband):+.2f}")
|
||||||
|
survives = (wins == len(F_SWEEP)) and npos >= 3 and dsr >= 0.95 and np.median(aband) > 0
|
||||||
|
verdicts[fam] = dict(ov=ov, survives=survives, wins_f=int(wins), multicut=dcut,
|
||||||
|
dsr=float(dsr), anchor=(min(aband), max(aband)))
|
||||||
|
print(f" => {'SOPRAVVIVE al null de-levering' if survives else 'NON batte il de-levering'} "
|
||||||
|
f"(pari-DD win {wins}/4 f, multicut {npos}/4, DSR {dsr:.2f})")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- (4) per-anno
|
||||||
|
print("\n (4) PER-ANNO (f=1.0) — 2021 (crash 5/2021) e 2022 (LUNA+FTX) sono il banco di prova")
|
||||||
|
rows = [("VRP01 base", base[1.0])] + [(f"{fam} best", cells[best[fam]][0]) for fam in OVERLAYS]
|
||||||
|
for tag, r in rows:
|
||||||
|
py = per_year(r)
|
||||||
|
print(f" {tag:<14} " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(py.items())))
|
||||||
|
|
||||||
|
print("\n VERDETTO: " + "; ".join(
|
||||||
|
f"{fam} {'PASS' if v['survives'] else 'FAIL'}" for fam, v in verdicts.items()))
|
||||||
|
print(" Se tutti FAIL: la protezione DD su VRP01 si compra meglio con la SIZE (de-levering),")
|
||||||
|
print(" coerente con TP01xDVOL 2026-06-26. Regola standing: niente short-vol da modello in deploy.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
"""R0703 VRPIMP-NEWGATES — gate d'ingresso NUOVI (veto additivi) su VRP01 canonico, barra alta.
|
||||||
|
|
||||||
|
FILONE 4 (ondata 2026-07-03). TRE raffinamenti del gate sono GIA' falliti (2026-07-01,
|
||||||
|
r0701_vrp_refine: sizing sul gap IV-RV, filtro DVOL-momentum, gate di regime TP01) e qui NON
|
||||||
|
si ritestano. Solo angoli mai provati, sempre come VETO ADDITIVO sopra il gate canonico
|
||||||
|
(vrp>0 AND ivr>=0.30 AND ivr<=0.90 — MAI riottimizzato, lezione 2026-07-01):
|
||||||
|
|
||||||
|
(a) TERM STRUCTURE del DVOL (contango=vendi / backwardation=fuori). PRIMA il dato: la storia
|
||||||
|
per-scadenza NON e' pubblica su Deribit (probe_vol_termstructure 2026-06-29); il logger
|
||||||
|
forward (log_vol_termstructure -> data/raw/vol_term_*.parquet) accumula da fine giugno.
|
||||||
|
Se la storia e' < ~180 giorni il gate NON e' testabile e si DICHIARA. L'unico stand-in
|
||||||
|
ricostruibile dai nostri dati e' TSPROXY = DVOL30 - RV7 (pseudo-slope implied-30g vs
|
||||||
|
realized-7g): dichiaratamente NON una term structure (e' il gate VRP canonico con
|
||||||
|
finestra RV corta) — incluso in griglia solo per completezza, etichettato PROXY.
|
||||||
|
(b) VOL-OF-VOL: std rolling delle variazioni giornaliere del DVOL (k=10/20 gg), messa in
|
||||||
|
percentile espandente CAUSALE; alto = regime vol instabile -> veto (stai fuori).
|
||||||
|
(Diverso dal DVOL-momentum fallito il 2026-07-01: la' era il LIVELLO in salita, qui la
|
||||||
|
VOLATILITA' del DVOL, direzione-agnostica.)
|
||||||
|
(c) ACCELERAZIONE della RV: RV5/RV20 causale > soglia (vol che accelera = precursore di
|
||||||
|
crash) -> veto. Anche in versione percentile espandente.
|
||||||
|
(d) AND pre-dichiarati: canonico + veto(b) + veto(c), 4 combo FISSE scritte nel codice
|
||||||
|
prima di guardare qualunque risultato.
|
||||||
|
|
||||||
|
METODO (vincolante, regole comuni ondata):
|
||||||
|
- selezione soglie SOLO in-sample (pre-2025); hold-out 2025-26 mai usato per scegliere;
|
||||||
|
- multi-cut: uplift vs baseline a 5 date di taglio (>=4/5 richiesti; >=3 = soglia minima
|
||||||
|
del filone) + 4 finestre DISGIUNTE;
|
||||||
|
- deflated-Sharpe sul TOTALE delle celle provate (18, baseline inclusa) — e comunque
|
||||||
|
anti-conservativo (celle correlate + VRP01 stesso viene da ~20 config storiche);
|
||||||
|
- NULL DEL DE-LEVERING esplicito (lezione TP01xDVOL 2026-06-26): lo Sharpe e' scale-invariante,
|
||||||
|
quindi un veto "vale" solo se batte lo Sharpe del baseline A PARI maxDD;
|
||||||
|
- banda f {0.6, 0.8, 1.0, 1.3} (pricing BS flat su DVOL ATM, skew non modellato);
|
||||||
|
- banda d'ancora obbligatoria (regola 2026-07-02): loop settimanale ancorato a i0=60 ->
|
||||||
|
7 offset i0 in {60..66} riportati per baseline e cella scelta.
|
||||||
|
|
||||||
|
Motore: riproduzione ESATTA di options_vrp_v2.vrp_spread_weekly (bridge-check a diff=0 nel
|
||||||
|
main) + veto parametrici. Fee: 12.5% del premio netto (convenzione VRP01). Capitale = strike
|
||||||
|
corto (cash-secured). Regola standing: niente short-vol da modello in deploy — esito massimo
|
||||||
|
= LEAD/conoscenza. Aspettativa dichiarata: FALLIRE (3 precedenti su 3); la mappa dei
|
||||||
|
fallimenti E' il deliverable.
|
||||||
|
|
||||||
|
uv run python scripts/research/r0703_vrpimp_newgates.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt"))
|
||||||
|
from collections import Counter
|
||||||
|
from functools import lru_cache
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from scripts.research.options_vrp_lab import bs_put, strike_from_delta, load_series, m_weekly, per_year
|
||||||
|
from scripts.research.options_vrp_v2 import vrp_spread_weekly
|
||||||
|
from altlib import deflated_sharpe
|
||||||
|
|
||||||
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||||
|
WK = 365.25 / 7.0
|
||||||
|
TENOR = 7
|
||||||
|
FEE_FRAC = 0.125 # convenzione VRP01: 12.5% del premio netto round-trip
|
||||||
|
F_SWEEP = (0.6, 0.8, 1.0, 1.3)
|
||||||
|
CUTS = [pd.Timestamp(c, tz="UTC") for c in
|
||||||
|
("2023-01-01", "2023-07-01", "2024-01-01", "2024-07-01", "2025-01-01")]
|
||||||
|
WINDOWS = [("2021-2022", None, "2023-01-01"), ("2023", "2023-01-01", "2024-01-01"),
|
||||||
|
("2024", "2024-01-01", "2025-01-01"), ("2025+", "2025-01-01", None)]
|
||||||
|
MIN_IS_ACTIVE = 0.20 # baseline ~41% attivo
|
||||||
|
SCRATCH = Path("/tmp/claude-1001/-opt-docker-PythagorasGoal/e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ features causali
|
||||||
|
def _expanding_pctl(x: np.ndarray, min_hist: int = 60) -> np.ndarray:
|
||||||
|
"""pctl[i] = quota della storia PASSATA valida x[:i] sotto x[i] (espandente, causale)."""
|
||||||
|
n = len(x); out = np.full(n, np.nan)
|
||||||
|
for i in range(n):
|
||||||
|
if np.isnan(x[i]):
|
||||||
|
continue
|
||||||
|
h = x[:i]; h = h[~np.isnan(h)]
|
||||||
|
if len(h) >= min_hist:
|
||||||
|
out[i] = float((h < x[i]).mean())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def prep(asset: str):
|
||||||
|
"""Tutte le feature usano SOLO dati <= close[i] (sell-date). lr[k]=log(px[k+1]/px[k]) =>
|
||||||
|
lr[i-w:i] sono i w rendimenti che finiscono a close[i] (stessa convenzione _rv30 di VRP01)."""
|
||||||
|
J = load_series(asset)
|
||||||
|
px = J["px"].values.astype(float)
|
||||||
|
dv = J["dvol"].values.astype(float) / 100.0
|
||||||
|
idx = J.index
|
||||||
|
n = len(px)
|
||||||
|
lr = np.diff(np.log(px))
|
||||||
|
|
||||||
|
def _rv(i, w):
|
||||||
|
if i < w + 1:
|
||||||
|
return np.nan
|
||||||
|
return float(np.std(lr[i - w:i]) * np.sqrt(365.25))
|
||||||
|
|
||||||
|
rv5 = np.array([_rv(i, 5) for i in range(n)])
|
||||||
|
rv7 = np.array([_rv(i, 7) for i in range(n)])
|
||||||
|
rv20 = np.array([_rv(i, 20) for i in range(n)])
|
||||||
|
rv30 = np.array([_rv(i, 30) for i in range(n)])
|
||||||
|
ivr = np.full(n, np.nan)
|
||||||
|
for i in range(60, n):
|
||||||
|
ivr[i] = float((dv[:i] < dv[i]).mean())
|
||||||
|
# (b) vol-of-vol: std delle ultime k variazioni giornaliere del DVOL (ultima = dv[i]-dv[i-1])
|
||||||
|
ddv = np.diff(dv)
|
||||||
|
def _vv(i, k):
|
||||||
|
if i < k + 1:
|
||||||
|
return np.nan
|
||||||
|
return float(np.std(ddv[i - k:i]))
|
||||||
|
vv = {k: np.array([_vv(i, k) for i in range(n)]) for k in (10, 20)}
|
||||||
|
vvp = {k: _expanding_pctl(vv[k]) for k in (10, 20)}
|
||||||
|
# (c) accelerazione RV
|
||||||
|
with np.errstate(invalid="ignore", divide="ignore"):
|
||||||
|
rvacc = rv5 / rv20
|
||||||
|
rap = _expanding_pctl(rvacc)
|
||||||
|
# (a-proxy) pseudo-slope: DVOL30 - RV7 (PROXY, non term structure)
|
||||||
|
tsproxy = dv - rv7
|
||||||
|
tsp = _expanding_pctl(tsproxy)
|
||||||
|
return dict(px=px, dv=dv, idx=idx, n=n, rv30=rv30, ivr=ivr,
|
||||||
|
vvp=vvp, rvacc=rvacc, rap=rap, tsproxy=tsproxy, tsp=tsp)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ motore settimanale
|
||||||
|
def _veto_hit(P, i, veto) -> bool:
|
||||||
|
"""True se il veto scatta al giorno i (NaN = veto non applicabile -> non scatta)."""
|
||||||
|
kind = veto[0]
|
||||||
|
if kind == "vv": # ("vv", k, pctl_thr)
|
||||||
|
v = P["vvp"][veto[1]][i]
|
||||||
|
return (not np.isnan(v)) and v > veto[2]
|
||||||
|
if kind == "ra": # ("ra", ratio_thr)
|
||||||
|
v = P["rvacc"][i]
|
||||||
|
return (not np.isnan(v)) and v > veto[1]
|
||||||
|
if kind == "rap": # ("rap", pctl_thr)
|
||||||
|
v = P["rap"][i]
|
||||||
|
return (not np.isnan(v)) and v > veto[1]
|
||||||
|
if kind == "ts_bin": # backwardation-proxy: RV7 sopra DVOL30
|
||||||
|
v = P["tsproxy"][i]
|
||||||
|
return (not np.isnan(v)) and v <= 0.0
|
||||||
|
if kind == "ts_pctl": # ("ts_pctl", thr): slope-proxy nel fondo percentile
|
||||||
|
v = P["tsp"][i]
|
||||||
|
return (not np.isnan(v)) and v < veto[1]
|
||||||
|
raise ValueError(kind)
|
||||||
|
|
||||||
|
|
||||||
|
def vrp_weekly(asset: str, vetoes=(), f: float = 1.0, i0: int = 60,
|
||||||
|
collect: dict | None = None) -> tuple[pd.Series, Counter]:
|
||||||
|
"""VRP01 canonico (riproduce vrp_spread_weekly gated ESATTO con vetoes=()) + veto additivi.
|
||||||
|
I veto sono valutati DOPO i gate canonici -> il counter conta l'intervento MARGINALE
|
||||||
|
(settimane altrimenti tradate). Causale: gate/strike/premio a sell-date, payoff a scadenza."""
|
||||||
|
P = prep(asset)
|
||||||
|
px, dv, idx, n = P["px"], P["dv"], P["idx"], P["n"]
|
||||||
|
T = TENOR / 365.25
|
||||||
|
rets = {}; st = Counter()
|
||||||
|
i = i0
|
||||||
|
while i + TENOR < n:
|
||||||
|
st["weeks"] += 1
|
||||||
|
S0 = px[i]; sig = dv[i]
|
||||||
|
# --- gate canonici (identici a options_vrp_v2, NON riottimizzati) ---
|
||||||
|
rv = P["rv30"][i]; ivr = P["ivr"][i]
|
||||||
|
skip = ((not np.isnan(rv) and (sig - rv) <= 0)
|
||||||
|
or (not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90)))
|
||||||
|
if skip:
|
||||||
|
st["blk_canon"] += 1
|
||||||
|
rets[idx[i + TENOR]] = 0.0
|
||||||
|
i += TENOR
|
||||||
|
continue
|
||||||
|
# --- veto NUOVI (marginali) ---
|
||||||
|
hit = None
|
||||||
|
for v in vetoes:
|
||||||
|
if _veto_hit(P, i, v):
|
||||||
|
hit = v[0]
|
||||||
|
break
|
||||||
|
if hit is not None:
|
||||||
|
st[f"blk_{hit}"] += 1
|
||||||
|
if collect is not None:
|
||||||
|
collect.setdefault("vetoed_i", []).append((asset, i))
|
||||||
|
rets[idx[i + TENOR]] = 0.0
|
||||||
|
i += TENOR
|
||||||
|
continue
|
||||||
|
st["traded"] += 1
|
||||||
|
# --- struttura canonica: put credit spread -0.28/-0.10, capitale = strike corto ---
|
||||||
|
Ks = strike_from_delta(S0, T, sig, -0.28)
|
||||||
|
Kl = strike_from_delta(S0, T, sig, -0.10)
|
||||||
|
net_prem = (bs_put(S0, Ks, T, sig) - bs_put(S0, Kl, T, sig)) * f
|
||||||
|
S1 = px[i + TENOR]
|
||||||
|
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1)
|
||||||
|
pnl = net_prem - payoff - FEE_FRAC * abs(net_prem)
|
||||||
|
rets[idx[i + TENOR]] = pnl / Ks
|
||||||
|
i += TENOR
|
||||||
|
return pd.Series(rets), st
|
||||||
|
|
||||||
|
|
||||||
|
def book(vetoes=(), f: float = 1.0, i0: int = 60) -> tuple[pd.Series, Counter]:
|
||||||
|
rB, sB = vrp_weekly("BTC", vetoes=vetoes, f=f, i0=i0)
|
||||||
|
rE, sE = vrp_weekly("ETH", vetoes=vetoes, f=f, i0=i0)
|
||||||
|
b = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1).sort_index()
|
||||||
|
return b, sB + sE
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ metriche / null
|
||||||
|
def sh_wk(r: pd.Series) -> float:
|
||||||
|
r = r.dropna()
|
||||||
|
if len(r) < 8 or r.std() == 0:
|
||||||
|
return float("nan")
|
||||||
|
return float(r.mean() / r.std() * np.sqrt(WK))
|
||||||
|
|
||||||
|
|
||||||
|
def mstats(r: pd.Series) -> dict:
|
||||||
|
full = m_weekly(r)
|
||||||
|
return dict(sh=full["sh"], dd=full["dd"], cagr=full["cagr"],
|
||||||
|
sh_is=sh_wk(r[r.index < HOLDOUT]), sh_h=sh_wk(r[r.index >= HOLDOUT]),
|
||||||
|
worst=float(r.min()) if len(r) else 0.0, active=float((r != 0).mean()))
|
||||||
|
|
||||||
|
|
||||||
|
def maxdd(r: pd.Series) -> float:
|
||||||
|
eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq)
|
||||||
|
return float(np.max((pk - eq) / pk)) if len(r) else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def delever_scale(r_base: pd.Series, dd_target: float, hi: float = 2.0) -> float:
|
||||||
|
"""Scaling s della size del baseline con maxDD == dd_target (bisezione, Sharpe invariante)."""
|
||||||
|
lo = 0.0
|
||||||
|
for _ in range(80):
|
||||||
|
mid = (lo + hi) / 2
|
||||||
|
if maxdd(mid * r_base) > dd_target:
|
||||||
|
hi = mid
|
||||||
|
else:
|
||||||
|
lo = mid
|
||||||
|
return (lo + hi) / 2
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ griglia (A PRIORI)
|
||||||
|
def build_grid():
|
||||||
|
cells = [("BASELINE canonico", ())]
|
||||||
|
for k in (10, 20):
|
||||||
|
for p in (0.70, 0.80, 0.90):
|
||||||
|
cells.append((f"VV k={k} p>{p:.2f}", (("vv", k, p),)))
|
||||||
|
for thr in (1.0, 1.25, 1.5):
|
||||||
|
cells.append((f"RA rv5/rv20>{thr:.2f}", (("ra", thr),)))
|
||||||
|
for p in (0.80, 0.90):
|
||||||
|
cells.append((f"RAP pctl>{p:.2f}", (("rap", p),)))
|
||||||
|
cells.append(("TSPROXY bin (rv7>dvol) [PROXY]", (("ts_bin",),)))
|
||||||
|
cells.append(("TSPROXY pctl<0.20 [PROXY]", (("ts_pctl", 0.20),)))
|
||||||
|
# combo AND pre-dichiarate (scritte prima di guardare i risultati)
|
||||||
|
cells.append(("AND VV(20,.80)+RA(1.25)", (("vv", 20, 0.80), ("ra", 1.25))))
|
||||||
|
cells.append(("AND VV(20,.80)+RAP(.80)", (("vv", 20, 0.80), ("rap", 0.80))))
|
||||||
|
cells.append(("AND VV(10,.80)+RA(1.25)", (("vv", 10, 0.80), ("ra", 1.25))))
|
||||||
|
cells.append(("AND VV(20,.90)+RA(1.50)", (("vv", 20, 0.90), ("ra", 1.50))))
|
||||||
|
return cells
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ main
|
||||||
|
def main():
|
||||||
|
print("=" * 112)
|
||||||
|
print(" R0703 VRPIMP-NEWGATES — veto d'ingresso nuovi su VRP01 (term-structure / vol-of-vol / RV-accel / AND)")
|
||||||
|
print(" Barra: selezione IS-only + multi-cut >=4/5 + DSR>=0.95 + null de-levering + banda f + banda d'ancora")
|
||||||
|
print("=" * 112)
|
||||||
|
|
||||||
|
# ---------------- (0) angolo (a): fattibilita' del dato term-structure ----------------
|
||||||
|
print("\n (0) TERM STRUCTURE DVOL — verifica del dato PRIMA del test")
|
||||||
|
n_days = {}
|
||||||
|
for a in ("btc", "eth"):
|
||||||
|
fp = PROJECT_ROOT / "data" / "raw" / f"vol_term_{a}.parquet"
|
||||||
|
if fp.exists():
|
||||||
|
df = pd.read_parquet(fp)
|
||||||
|
n_days[a] = len(df)
|
||||||
|
print(f" vol_term_{a}: {len(df)} righe [{df['date'].min().date()} -> {df['date'].max().date()}] "
|
||||||
|
f"slope7-180 medio {df['slope_7_180'].mean():+.1f}pp")
|
||||||
|
else:
|
||||||
|
n_days[a] = 0
|
||||||
|
print(f" vol_term_{a}: NESSUN FILE")
|
||||||
|
n_ts = min(n_days.values()) if n_days else 0
|
||||||
|
print(f" -> storia term-structure = {n_ts} GIORNI (logger forward dal 2026-06-26; serve >=~180g +")
|
||||||
|
print(" certificazione). La storia per-scadenza NON e' pubblica (probe 2026-06-29) e NON e'")
|
||||||
|
print(" ricostruibile dai fetch. VERDETTO (a): NON TESTABILE OGGI — rivalutare a ~6-12 mesi di log.")
|
||||||
|
print(" In griglia resta SOLO il proxy TSPROXY = DVOL30-RV7, dichiarato NON-term-structure")
|
||||||
|
print(" (e' il gate VRP canonico a finestra RV corta).")
|
||||||
|
|
||||||
|
# ---------------- (1) baseline + bridge ----------------
|
||||||
|
base, st_base = book()
|
||||||
|
ref = pd.concat({"B": vrp_spread_weekly("BTC", defined_risk=True, f=1.0, gate_vrp=True,
|
||||||
|
gate_ivr=0.30, crash_skip=0.90),
|
||||||
|
"E": vrp_spread_weekly("ETH", defined_risk=True, f=1.0, gate_vrp=True,
|
||||||
|
gate_ivr=0.30, crash_skip=0.90)},
|
||||||
|
axis=1, join="inner").mean(axis=1).sort_index()
|
||||||
|
al_ = base.align(ref, join="inner")
|
||||||
|
bridge = float((al_[0] - al_[1]).abs().max())
|
||||||
|
mb = mstats(base)
|
||||||
|
print(f"\n (1) BASELINE VRP01 riprodotto: ShF {mb['sh']:.2f} ShH {mb['sh_h']:.2f} Sh-IS {mb['sh_is']:.2f} "
|
||||||
|
f"CAGR {mb['cagr']*100:+.1f}% DD {mb['dd']*100:.1f}% worst {mb['worst']*100:+.1f}% attivo {mb['active']*100:.0f}%")
|
||||||
|
print(f" bridge vs options_vrp_v2.vrp_spread_weekly: max|diff| = {bridge:.2e} "
|
||||||
|
f"({'OK motore ESATTO' if bridge < 1e-12 else '⚠️ DIVERGE — non fidarsi dei numeri'})")
|
||||||
|
|
||||||
|
# ---------------- (2) diagnostica: i veto rimuovono settimane cattive? (IS only) ----------------
|
||||||
|
print("\n (2) DIAGNOSTICA IN-SAMPLE (pre-2025): pnl medio del BASELINE nelle settimane che il veto")
|
||||||
|
print(" avrebbe bloccato vs tenuto (per-asset, solo settimane canonicamente tradate):")
|
||||||
|
probes = [("VV k=10 p>.80", ("vv", 10, 0.80)), ("VV k=20 p>.80", ("vv", 20, 0.80)),
|
||||||
|
("RA >1.25", ("ra", 1.25)), ("RAP p>.80", ("rap", 0.80)),
|
||||||
|
("TSPROXY bin", ("ts_bin",)), ("TSPROXY p<.20", ("ts_pctl", 0.20))]
|
||||||
|
for label, veto in probes:
|
||||||
|
cut_v, cut_k = [], []
|
||||||
|
for a in ("BTC", "ETH"):
|
||||||
|
r0, _ = vrp_weekly(a) # baseline per-asset
|
||||||
|
P = prep(a)
|
||||||
|
pos = {ts: k for k, ts in enumerate(P["idx"])}
|
||||||
|
for ts, v in r0.items():
|
||||||
|
if v == 0.0:
|
||||||
|
continue
|
||||||
|
i_sell = pos[ts] - TENOR
|
||||||
|
if P["idx"][i_sell] >= HOLDOUT:
|
||||||
|
continue
|
||||||
|
(cut_v if _veto_hit(P, i_sell, veto) else cut_k).append(v)
|
||||||
|
mv = np.mean(cut_v) * 100 if cut_v else float("nan")
|
||||||
|
mk = np.mean(cut_k) * 100 if cut_k else float("nan")
|
||||||
|
print(f" {label:<16} bloccate n={len(cut_v):>3} pnl medio {mv:>+6.2f}% | "
|
||||||
|
f"tenute n={len(cut_k):>3} pnl medio {mk:>+6.2f}% "
|
||||||
|
f"({'veto sensato IS' if cut_v and mv < mk else 'veto NON discrimina IS'})")
|
||||||
|
|
||||||
|
# ---------------- (3) griglia 18 celle ----------------
|
||||||
|
cells = build_grid()
|
||||||
|
print(f"\n (3) GRIGLIA {len(cells)} celle (tutte nel DSR) — selezione SOLO su Sh-IS (pre-2025):")
|
||||||
|
print(f" {'cella':<32}{'Sh-IS':>7}{'ShF':>7}{'ShH':>7}{'DD':>7}{'worst':>8}{'att.':>6}{'blk-marg':>9}")
|
||||||
|
results = {}
|
||||||
|
for name, vetoes in cells:
|
||||||
|
b, st = book(vetoes=vetoes)
|
||||||
|
m = mstats(b)
|
||||||
|
blk = sum(v for k, v in st.items() if k.startswith("blk_") and k != "blk_canon")
|
||||||
|
results[name] = dict(name=name, vetoes=vetoes, b=b, st=st, blk=blk, **m)
|
||||||
|
print(f" {name:<32}{m['sh_is']:>7.2f}{m['sh']:>7.2f}{m['sh_h']:>7.2f}"
|
||||||
|
f"{m['dd']*100:>6.1f}%{m['worst']*100:>+7.1f}%{m['active']*100:>5.0f}%{blk:>9}")
|
||||||
|
pd.DataFrame([{k: v for k, v in r.items() if k not in ("b", "st", "vetoes")}
|
||||||
|
for r in results.values()]).to_csv(SCRATCH / "r0703_newgates_grid.csv", index=False)
|
||||||
|
|
||||||
|
bl = results["BASELINE canonico"]
|
||||||
|
ranked = sorted((r for r in results.values() if r["active"] >= MIN_IS_ACTIVE
|
||||||
|
and not np.isnan(r["sh_is"])), key=lambda r: r["sh_is"], reverse=True)
|
||||||
|
cand = ranked[0]
|
||||||
|
is_baseline_best = cand["name"] == bl["name"]
|
||||||
|
n_beat_hold = sum(1 for r in results.values() if r["sh_h"] > bl["sh_h"])
|
||||||
|
print(f"\n -> cella scelta IN-SAMPLE: [{cand['name']}] Sh-IS {cand['sh_is']:.2f} "
|
||||||
|
f"(baseline {bl['sh_is']:.2f}, Δ {cand['sh_is'] - bl['sh_is']:+.2f})")
|
||||||
|
print(f" [onesta'] celle che battono l'HOLD-OUT del baseline: {n_beat_hold}/{len(results)} — "
|
||||||
|
f"NON selezionabili (selection-on-holdout, gate 2026-06-29).")
|
||||||
|
|
||||||
|
# ---------------- (4) multi-cut + finestre disgiunte + DSR ----------------
|
||||||
|
print("\n (4) MULTI-CUT (Sharpe dal taglio a fine storia; uplift = cand - baseline):")
|
||||||
|
pos_cuts = 0
|
||||||
|
for c in CUTS:
|
||||||
|
sc = sh_wk(cand["b"][cand["b"].index >= c]); sb = sh_wk(bl["b"][bl["b"].index >= c])
|
||||||
|
u = sc - sb
|
||||||
|
pos_cuts += int(u > 0)
|
||||||
|
print(f" cut {c.date()}: cand {sc:>5.2f} base {sb:>5.2f} uplift {u:>+5.2f}")
|
||||||
|
print(f" -> uplift positivo in {pos_cuts}/{len(CUTS)} tagli (barra alta >=4/5; soglia minima filone >=3)")
|
||||||
|
print(" FINESTRE DISGIUNTE:")
|
||||||
|
pos_win = 0
|
||||||
|
for wname, lo, hi in WINDOWS:
|
||||||
|
m_ = pd.Series(True, index=cand["b"].index)
|
||||||
|
if lo:
|
||||||
|
m_ &= cand["b"].index >= pd.Timestamp(lo, tz="UTC")
|
||||||
|
if hi:
|
||||||
|
m_ &= cand["b"].index < pd.Timestamp(hi, tz="UTC")
|
||||||
|
sc, sb = sh_wk(cand["b"][m_]), sh_wk(bl["b"][m_])
|
||||||
|
u = sc - sb
|
||||||
|
pos_win += int(u > 0)
|
||||||
|
print(f" {wname:<10} cand {sc:>5.2f} base {sb:>5.2f} uplift {u:>+5.2f}")
|
||||||
|
print(f" -> uplift positivo in {pos_win}/{len(WINDOWS)} finestre disgiunte")
|
||||||
|
|
||||||
|
all_sh = [r["sh"] for r in results.values()]
|
||||||
|
dsr_c, null_max = deflated_sharpe(cand["sh"], all_sh, cand["b"].values, dpy=WK)
|
||||||
|
dsr_b, _ = deflated_sharpe(bl["sh"], all_sh, bl["b"].values, dpy=WK)
|
||||||
|
print(f"\n DEFLATED SHARPE (N={len(all_sh)} celle): cand DSR {dsr_c:.3f} (null-max Sh {null_max:.2f}) | "
|
||||||
|
f"baseline DSR {dsr_b:.3f} [PASS >= 0.95; anti-conservativo: celle correlate + ~20 config storiche VRP]")
|
||||||
|
|
||||||
|
# ---------------- (5) NULL DEL DE-LEVERING ----------------
|
||||||
|
print("\n (5) NULL DEL DE-LEVERING (lezione TP01xDVOL): baseline scalato allo STESSO maxDD del candidato")
|
||||||
|
s = delever_scale(bl["b"], cand["dd"])
|
||||||
|
scaled = s * bl["b"]
|
||||||
|
ms = mstats(scaled)
|
||||||
|
print(f" candidato [{cand['name']}]: Sh {cand['sh']:.2f} ShH {cand['sh_h']:.2f} CAGR {cand['cagr']*100:+.1f}% "
|
||||||
|
f"DD {cand['dd']*100:.1f}% worst {cand['worst']*100:+.1f}%")
|
||||||
|
print(f" baseline x{s:.2f} (pari DD): Sh {ms['sh']:.2f} ShH {ms['sh_h']:.2f} CAGR {ms['cagr']*100:+.1f}% "
|
||||||
|
f"DD {ms['dd']*100:.1f}% worst {ms['worst']*100:+.1f}%")
|
||||||
|
survives_delever = (cand["sh"] > ms["sh"]) and (cand["sh_h"] > ms["sh_h"])
|
||||||
|
print(f" -> il veto {'BATTE' if survives_delever else 'NON batte'} il de-levering "
|
||||||
|
f"(serve Sh full E hold > baseline a pari DD; lo Sharpe e' scale-invariante).")
|
||||||
|
|
||||||
|
# ---------------- (6) banda f + banda d'ancora ----------------
|
||||||
|
print("\n (6a) BANDA f (skew non modellato) — uplift cand-baseline per f:")
|
||||||
|
for f in F_SWEEP:
|
||||||
|
bf, _ = book(f=f); cf, _ = book(vetoes=cand["vetoes"], f=f)
|
||||||
|
mbf, mcf = mstats(bf), mstats(cf)
|
||||||
|
print(f" f={f}: base ShF {mbf['sh']:>5.2f}/ShH {mbf['sh_h']:>5.2f} cand ShF {mcf['sh']:>5.2f}/ShH {mcf['sh_h']:>5.2f} "
|
||||||
|
f"uplift full {mcf['sh']-mbf['sh']:>+5.2f} hold {mcf['sh_h']-mbf['sh_h']:>+5.2f}")
|
||||||
|
|
||||||
|
print("\n (6b) BANDA D'ANCORA (7 offset i0 del ciclo settimanale; regola 2026-07-02):")
|
||||||
|
ups_f, ups_h = [], []
|
||||||
|
for ph in range(7):
|
||||||
|
bA, _ = book(i0=60 + ph); cA, _ = book(vetoes=cand["vetoes"], i0=60 + ph)
|
||||||
|
mbA, mcA = mstats(bA), mstats(cA)
|
||||||
|
ups_f.append(mcA["sh"] - mbA["sh"]); ups_h.append(mcA["sh_h"] - mbA["sh_h"])
|
||||||
|
print(f" i0=+{ph}g: base {mbA['sh']:>5.2f}/{mbA['sh_h']:>5.2f} cand {mcA['sh']:>5.2f}/{mcA['sh_h']:>5.2f} "
|
||||||
|
f"uplift {mcA['sh']-mbA['sh']:>+5.2f}/{mcA['sh_h']-mbA['sh_h']:>+5.2f}")
|
||||||
|
print(f" banda uplift FULL [{min(ups_f):+.2f}, {max(ups_f):+.2f}] mediana {np.median(ups_f):+.2f} | "
|
||||||
|
f"HOLD [{min(ups_h):+.2f}, {max(ups_h):+.2f}] mediana {np.median(ups_h):+.2f}")
|
||||||
|
|
||||||
|
# ---------------- (7) per-anno + verdetto ----------------
|
||||||
|
print("\n (7) PER-ANNO (ritorno composto):")
|
||||||
|
pyb, pyc = per_year(bl["b"]), per_year(cand["b"])
|
||||||
|
print(" base: " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(pyb.items())))
|
||||||
|
print(" cand: " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(pyc.items())))
|
||||||
|
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
gates = dict(
|
||||||
|
non_e_baseline=not is_baseline_best,
|
||||||
|
batte_IS=cand["sh_is"] > bl["sh_is"],
|
||||||
|
multicut_4su5=pos_cuts >= 4,
|
||||||
|
multicut_min3=pos_cuts >= 3,
|
||||||
|
finestre_disgiunte=pos_win >= 3,
|
||||||
|
hold_migliore=cand["sh_h"] > bl["sh_h"],
|
||||||
|
dsr_pass=bool(dsr_c >= 0.95),
|
||||||
|
batte_delevering=bool(survives_delever),
|
||||||
|
anchor_mediana_pos=float(np.median(ups_h)) > 0,
|
||||||
|
)
|
||||||
|
improves = all(gates.values())
|
||||||
|
if is_baseline_best:
|
||||||
|
print(" VERDETTO: NON MIGLIORA — il baseline VRP01 vince gia' la selezione in-sample.")
|
||||||
|
print(" QUARTO fallimento del filone 'raffina il gate': l'alpha resta il binario IV-rank>0.30.")
|
||||||
|
elif improves:
|
||||||
|
print(f" VERDETTO: MIGLIORA — [{cand['name']}] passa TUTTI i gate. Resta sleeve MODELLATO")
|
||||||
|
print(" (DVOL ATM, no skew, f di stress non catturato): MAI deploy, solo aggiornamento del modello.")
|
||||||
|
else:
|
||||||
|
fails = [k for k, v in gates.items() if not v]
|
||||||
|
print(f" VERDETTO: NON MIGLIORA — cella IS-best [{cand['name']}] bocciata su: {', '.join(fails)}.")
|
||||||
|
print(" QUARTO fallimento del filone 'raffina il gate' (dopo sizing/momentum/TP01 del 2026-07-01):")
|
||||||
|
print(" l'alpha del VRP01 resta interamente nel gate binario IV-rank>0.30. Angolo (a) term-structure:")
|
||||||
|
print(f" non testabile ({n_ts}g di storia), il logger forward accumula — rivalutare a ~6-12 mesi.")
|
||||||
|
print(" gates: " + " ".join(f"{k}={'PASS' if v else 'FAIL'}" for k, v in gates.items()))
|
||||||
|
print("=" * 112)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""r0703_vrpimp_sizing.py — FILONE 3: SIZING ANTI-ROVINA per lo sleeve short-vol defined-risk.
|
||||||
|
|
||||||
|
DOMANDA: ALB-B ha mostrato che il sizing 1->4 di Albimarini porta alla rovina (1998/2002/2020).
|
||||||
|
Qual e' la politica di sizing OTTIMA per uno sleeve put-credit-spread ETH su Deribit a 2.000-5.000$?
|
||||||
|
|
||||||
|
MOTORE (riusato, non riscritto): la struttura VRP01 canonica (put credit spread 7g, short delta
|
||||||
|
-0.28 / long -0.10, gate CANONICO vrp>0 + ivr>=0.30 + crash-skip 0.90 — NON riottimizzato, lezione
|
||||||
|
2026-07-01) prezzata col motore ALB-A (r0702_alb_structure): BS flat su DVOL reale, fee Deribit
|
||||||
|
PER GAMBA 0.03% notional cap 12.5% premio + delivery 0.015% su ITM. ETH-only: a 2-5k solo ETH e'
|
||||||
|
granulare (BTC min 0.1 = margine ~$470+, diario r0702_capital_scaling). Rendimenti PER-TRADE su
|
||||||
|
MARGINE (= max loss defined-risk, width - credito): e' il capitale davvero a rischio, e rende la
|
||||||
|
rovina ben definita (perdita massima = 100% del margine impegnato + fee).
|
||||||
|
|
||||||
|
POLITICHE TESTATE (il sizing NON cambia l'edge: cambia CAGR/DD/P(rovina) — lo Sharpe del flusso
|
||||||
|
sottostante e' invariante di scala, quindi qui NON si caccia Sharpe, si mappa la frontiera):
|
||||||
|
(a) frazione fissa del capitale q in {5,10,12,15,20,25}% (margine impegnato/equity per settimana)
|
||||||
|
(b) vol-scaled: q ∝ 1/DVOL (riferimento = mediana ESPANDENTE causale della DVOL)
|
||||||
|
(c) anti-streak: q ridotta dopo N vittorie consecutive (l'OPPOSTO di Albimarini)
|
||||||
|
(d) Kelly frazionario con stima ONESTA delle code: distribuzione empirica per-trade IS (pre-2025)
|
||||||
|
POOLED sulla banda skew f in {0.6,0.8,1.0,1.3} + coda sintetica (full-loss -102% con probabilita'
|
||||||
|
extra = P(move settimanale <= strike long | storia ETH 2019-26 CERTIFICATA) - freq empirica)
|
||||||
|
(e) [riferimento negativo] Albimarini 1->4: size crescente con lo streak di vittorie
|
||||||
|
|
||||||
|
METRICHE: CAGR, maxDD, worst-week, P(rovina -50% / -80% | 5 anni, bootstrap a BLOCCHI L=13 settimane
|
||||||
|
che preserva il clustering di regime del gate), a C=2.000 e 5.000$ con granularita' REALE degli
|
||||||
|
ordini (size INTERE di spread min 1 ETH/gamba; margine/spread dal dato della settimana stessa —
|
||||||
|
la nota task "0.1 ETH" e' incoerente coi numeri citati $66-76 = spread da 1 ETH di r0702_capital_
|
||||||
|
scaling sez.5; la sensitivity 0.1 ETH e' riportata a parte). Confronto esplicito col 12% del book
|
||||||
|
(VRP01 @12% peso = margine settimanale ~12% del conto, stessa convenzione del conteggio spread del
|
||||||
|
diario capital-scaling).
|
||||||
|
|
||||||
|
REGOLE ONORATE: hold-out 2025-26 MAI usato per selezionare (selezione overlay su MAR in-sample);
|
||||||
|
NULL DEL DE-LEVERING esplicito (ogni claim di riduzione DD degli overlay b/c/d confrontato con la
|
||||||
|
frazione fissa che raggiunge lo stesso DD); banda f {0.6,0.8,1.0,1.3}; banda d'ancora sulle 7 fasi
|
||||||
|
della cadenza settimanale; niente '7D'-origin (qui la cadenza e' a passi di indice, non resample).
|
||||||
|
REGOLA STANDING INVARIATA: niente short-vol da modello in deploy — esito = conoscenza/frontiera.
|
||||||
|
|
||||||
|
uv run python scripts/research/r0703_vrpimp_sizing.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path("/opt/docker/PythagorasGoal")
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
import altlib as al # noqa: E402 (harness di progetto; usato per storia ETH certificata + DSR)
|
||||||
|
from scripts.research.options_vrp_lab import bs_put, strike_from_delta, load_series # noqa: E402
|
||||||
|
from scripts.research.options_vrp_v2 import _ivrank, _rv30 # noqa: E402
|
||||||
|
|
||||||
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||||
|
WK = 365.25 / 7.0
|
||||||
|
TENOR = 7
|
||||||
|
F_SWEEP = (0.6, 0.8, 1.0, 1.3)
|
||||||
|
CAPITALS = (2000.0, 5000.0)
|
||||||
|
MIN_ETH = 1.0 # min size Deribit ETH per gamba (r0702_capital_scaling sez.5)
|
||||||
|
Q_CAP = 0.95 # mai impegnare piu' del 95% dell'equity (loss max ~102% del margine)
|
||||||
|
FULL_LOSS = -1.02 # full-loss defined-risk incl. fee (empirico: worst -1.018)
|
||||||
|
RUIN_LVL = (0.50, 0.80) # rovina = equity sotto (1-lvl)*E0 ... definita come perdita >= lvl
|
||||||
|
SEED = 20260703
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# MOTORE PER-TRADE (struttura VRP01, fee ALB-A per gamba, ritorni su MARGINE)
|
||||||
|
# ===========================================================================
|
||||||
|
def trade_records(f: float = 1.0, phase: int = 0) -> pd.DataFrame:
|
||||||
|
"""Vendita settimanale ETH put credit spread -0.28/-0.10, gate canonico. Una riga per
|
||||||
|
settimana di cadenza (phase = fase d'ancora 0..6): active, r (ritorno su margine),
|
||||||
|
margin ($ per spread da 1 ETH), sig (DVOL a decisione), kl_dist (distanza strike long)."""
|
||||||
|
J = load_series("ETH")
|
||||||
|
px = J["px"].values
|
||||||
|
dv = J["dvol"].values / 100.0
|
||||||
|
idx = J.index
|
||||||
|
n = len(px)
|
||||||
|
T = TENOR / 365.25
|
||||||
|
rows = []
|
||||||
|
i = 60 + phase
|
||||||
|
while i + TENOR < n:
|
||||||
|
S0 = px[i]
|
||||||
|
sig = dv[i]
|
||||||
|
rv = _rv30(px, i)
|
||||||
|
ivr = _ivrank(dv, i)
|
||||||
|
skip = ((not np.isnan(rv) and (sig - rv) <= 0)
|
||||||
|
or (not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90)))
|
||||||
|
if skip:
|
||||||
|
rows.append(dict(date=idx[i + TENOR], active=0, r=0.0, margin=0.0,
|
||||||
|
sig=sig, win=0, kl_dist=np.nan))
|
||||||
|
i += TENOR
|
||||||
|
continue
|
||||||
|
Ks = strike_from_delta(S0, T, sig, -0.28)
|
||||||
|
Kl = strike_from_delta(S0, T, sig, -0.10)
|
||||||
|
ps = bs_put(S0, Ks, T, sig) * f
|
||||||
|
pl = bs_put(S0, Kl, T, sig) * f
|
||||||
|
credit = ps - pl
|
||||||
|
width = Ks - Kl
|
||||||
|
margin = width - credit # margine defined-risk Deribit ~ max loss
|
||||||
|
S1 = px[i + TENOR]
|
||||||
|
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1)
|
||||||
|
fee = min(0.0003 * S0, 0.125 * max(ps, 0.0)) + min(0.0003 * S0, 0.125 * max(pl, 0.0))
|
||||||
|
deliv = (min(0.00015 * S1, 0.125 * max(0.0, Ks - S1))
|
||||||
|
+ min(0.00015 * S1, 0.125 * max(0.0, Kl - S1)))
|
||||||
|
pnl = credit - payoff - fee - deliv # $ per spread da 1 ETH
|
||||||
|
rows.append(dict(date=idx[i + TENOR], active=1, r=pnl / margin, margin=margin,
|
||||||
|
sig=sig, win=int(pnl > 0), kl_dist=Kl / S0 - 1.0))
|
||||||
|
i += TENOR
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# POLITICHE DI SIZING — q = frazione di equity impegnata come margine questa settimana
|
||||||
|
# state: sig (DVOL decisione), ref (mediana espandente DVOL), streak (vittorie consecutive)
|
||||||
|
# ===========================================================================
|
||||||
|
def pol_fixed(frac):
|
||||||
|
return lambda st: frac
|
||||||
|
|
||||||
|
|
||||||
|
def pol_volscaled(frac, lo=0.5, hi=2.0):
|
||||||
|
"""q = frac * clip(DVOL_ref/DVOL, lo, hi): meno size quando la vol e' alta. Causale."""
|
||||||
|
return lambda st: frac * float(np.clip(st["ref"] / max(st["sig"], 1e-9), lo, hi))
|
||||||
|
|
||||||
|
|
||||||
|
def pol_antistreak(frac, N, mult):
|
||||||
|
"""Dopo >=N vittorie consecutive riduci a frac*mult (l'opposto di Albimarini)."""
|
||||||
|
return lambda st: frac * (mult if st["streak"] >= N else 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def pol_alb14(frac):
|
||||||
|
"""Riferimento NEGATIVO: Albimarini 1->4, size crescente con le vittorie consecutive."""
|
||||||
|
return lambda st: frac * min(1.0 + st["streak"], 4.0)
|
||||||
|
|
||||||
|
|
||||||
|
def simulate(rec: pd.DataFrame, qfun, E0: float, granular: bool = True,
|
||||||
|
min_eth: float = MIN_ETH, stress_p: float = 0.0, rng=None,
|
||||||
|
q_series=None) -> dict:
|
||||||
|
"""Applica la politica al flusso settimanale. granular=True -> size intere di spread
|
||||||
|
(floor(budget/margine_spread)); False -> frazione continua (lens frontiera).
|
||||||
|
stress_p>0: ogni settimana attiva ha prob extra di full-loss sintetico (coda stress).
|
||||||
|
q_series: array di q per riga (bypassa qfun — usato dal null a piazzamento casuale)."""
|
||||||
|
E = E0
|
||||||
|
eq, rets, dates = [], [], []
|
||||||
|
sig_hist: list[float] = []
|
||||||
|
streak = 0
|
||||||
|
halted = 0
|
||||||
|
for t, row in enumerate(rec.itertuples(index=False)):
|
||||||
|
ref = float(np.median(sig_hist)) if len(sig_hist) >= 20 else row.sig
|
||||||
|
st = dict(sig=row.sig, ref=ref, streak=streak)
|
||||||
|
sig_hist.append(row.sig)
|
||||||
|
r_wk = 0.0
|
||||||
|
if row.active and E > 0:
|
||||||
|
q_raw = q_series[t] if q_series is not None else qfun(st)
|
||||||
|
q = float(np.clip(q_raw, 0.0, Q_CAP))
|
||||||
|
unit = row.margin * min_eth
|
||||||
|
if granular:
|
||||||
|
n_spread = int((q * E) // unit) if unit > 0 else 0
|
||||||
|
committed = n_spread * unit
|
||||||
|
if q > 0 and n_spread == 0:
|
||||||
|
halted += 1
|
||||||
|
else:
|
||||||
|
committed = q * E
|
||||||
|
r_trade = row.r
|
||||||
|
if stress_p > 0.0 and rng is not None and rng.random() < stress_p:
|
||||||
|
r_trade = FULL_LOSS
|
||||||
|
r_wk = committed * r_trade / E if E > 0 else 0.0
|
||||||
|
E = E + committed * r_trade
|
||||||
|
if row.active:
|
||||||
|
streak = streak + 1 if (row.win and row.r == row.r) else 0
|
||||||
|
if not row.win:
|
||||||
|
streak = 0
|
||||||
|
rets.append(r_wk)
|
||||||
|
eq.append(max(E, 0.0))
|
||||||
|
dates.append(row.date)
|
||||||
|
return dict(dates=pd.DatetimeIndex(dates), eq=np.asarray(eq), rets=np.asarray(rets),
|
||||||
|
halted=halted, E_end=E)
|
||||||
|
|
||||||
|
|
||||||
|
def path_metrics(dates, rets) -> dict:
|
||||||
|
r = np.asarray(rets, float)
|
||||||
|
if len(r) < 3:
|
||||||
|
return dict(cagr=0.0, dd=0.0, worst=0.0, sh=0.0)
|
||||||
|
eq = np.cumprod(1.0 + r)
|
||||||
|
pk = np.maximum.accumulate(eq)
|
||||||
|
yrs = len(r) / WK
|
||||||
|
cagr = eq[-1] ** (1.0 / yrs) - 1.0 if eq[-1] > 0 else -1.0
|
||||||
|
sh = float(r.mean() / r.std() * np.sqrt(WK)) if r.std() > 0 else 0.0
|
||||||
|
return dict(cagr=float(cagr), dd=float(np.max((pk - eq) / pk)), worst=float(r.min()), sh=sh)
|
||||||
|
|
||||||
|
|
||||||
|
def full_hold(sim) -> tuple[dict, dict]:
|
||||||
|
m_full = path_metrics(sim["dates"], sim["rets"])
|
||||||
|
mask = sim["dates"] >= HOLDOUT
|
||||||
|
m_hold = path_metrics(sim["dates"][mask], sim["rets"][mask])
|
||||||
|
return m_full, m_hold
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# KELLY con code oneste + bootstrap a blocchi
|
||||||
|
# ===========================================================================
|
||||||
|
def kelly_star(rs: np.ndarray, p_extra: float, tail: float = FULL_LOSS) -> float:
|
||||||
|
"""argmax_q E[log(1+q r)] su mistura: con prob p_extra r=tail, altrimenti empirico."""
|
||||||
|
qs = np.linspace(0.0, Q_CAP, 191)
|
||||||
|
best_q, best_g = 0.0, -np.inf
|
||||||
|
for q in qs:
|
||||||
|
vals = np.log1p(q * rs)
|
||||||
|
g = (1.0 - p_extra) * vals.mean() + p_extra * np.log1p(q * tail)
|
||||||
|
if np.isfinite(g) and g > best_g:
|
||||||
|
best_g, best_q = g, q
|
||||||
|
return float(best_q)
|
||||||
|
|
||||||
|
|
||||||
|
def synthetic_tail_prob(rec: pd.DataFrame) -> tuple[float, float, float]:
|
||||||
|
"""p_extra = P(move 7g ETH <= distanza strike long | storia certificata 2019-26)
|
||||||
|
- freq empirica di full-loss nel flusso gated. Ritorna (p_extra, p_uncond, p_emp)."""
|
||||||
|
d1 = al.get("ETH", "1d")
|
||||||
|
px = d1["close"].values.astype(float)
|
||||||
|
wk_mv = px[TENOR:] / px[:-TENOR] - 1.0 # mosse 7g overlapping, 2019->oggi
|
||||||
|
act = rec[rec.active == 1]
|
||||||
|
kl_med = float(act["kl_dist"].median())
|
||||||
|
p_uncond = float(np.mean(wk_mv <= kl_med))
|
||||||
|
p_emp = float((act["r"] <= -0.9).mean())
|
||||||
|
return max(0.0, p_uncond - p_emp), p_uncond, p_emp
|
||||||
|
|
||||||
|
|
||||||
|
def block_bootstrap_ruin(rec: pd.DataFrame, qfun, C: float, npaths: int = 1000,
|
||||||
|
horizon_w: int = 261, L: int = 13, stress_p: float = 0.0,
|
||||||
|
seed: int = SEED, needs_ref: bool = False) -> dict:
|
||||||
|
"""P(rovina | 5 anni): bootstrap a blocchi circolari di L settimane dal flusso storico
|
||||||
|
(preserva clustering del gate/regime), politica applicata con granularita' REALE a C.
|
||||||
|
needs_ref: calcola la mediana espandente della DVOL solo per le politiche che la usano."""
|
||||||
|
rows = list(rec.itertuples(index=False))
|
||||||
|
n = len(rows)
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
ruin = np.zeros((npaths, len(RUIN_LVL)), bool)
|
||||||
|
dd30 = np.zeros(npaths, bool)
|
||||||
|
cagrs = np.zeros(npaths)
|
||||||
|
for p in range(npaths):
|
||||||
|
starts = rng.integers(0, n, size=horizon_w // L + 1)
|
||||||
|
seq = [rows[(s + k) % n] for s in starts for k in range(L)][:horizon_w]
|
||||||
|
E = C
|
||||||
|
peak = C
|
||||||
|
minE = C
|
||||||
|
sig_hist: list[float] = []
|
||||||
|
streak = 0
|
||||||
|
maxdd = 0.0
|
||||||
|
for row in seq:
|
||||||
|
if needs_ref:
|
||||||
|
ref = float(np.median(sig_hist)) if len(sig_hist) >= 20 else row.sig
|
||||||
|
sig_hist.append(row.sig)
|
||||||
|
else:
|
||||||
|
ref = row.sig
|
||||||
|
if row.active and E > 0:
|
||||||
|
q = float(np.clip(qfun(dict(sig=row.sig, ref=ref, streak=streak)), 0.0, Q_CAP))
|
||||||
|
unit = row.margin * MIN_ETH
|
||||||
|
n_spread = int((q * E) // unit) if unit > 0 else 0
|
||||||
|
r_trade = row.r
|
||||||
|
if stress_p > 0.0 and rng.random() < stress_p:
|
||||||
|
r_trade = FULL_LOSS
|
||||||
|
E = E + n_spread * unit * r_trade
|
||||||
|
streak = streak + 1 if (row.win and r_trade > 0) else 0
|
||||||
|
peak = max(peak, E)
|
||||||
|
minE = min(minE, E)
|
||||||
|
maxdd = max(maxdd, (peak - E) / peak if peak > 0 else 1.0)
|
||||||
|
for j, lvl in enumerate(RUIN_LVL):
|
||||||
|
ruin[p, j] = minE <= C * (1.0 - lvl)
|
||||||
|
dd30[p] = maxdd >= 0.30
|
||||||
|
cagrs[p] = (max(E, 0.0) / C) ** (1.0 / (horizon_w / WK)) - 1.0 if E > 0 else -1.0
|
||||||
|
return dict(p_ruin50=float(ruin[:, 0].mean()), p_ruin80=float(ruin[:, 1].mean()),
|
||||||
|
p_dd30=float(dd30.mean()), cagr_med=float(np.median(cagrs)),
|
||||||
|
cagr_p10=float(np.percentile(cagrs, 10)))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# REPORT
|
||||||
|
# ===========================================================================
|
||||||
|
def hist_row(label, rec, qfun, C, granular=True):
|
||||||
|
sim = simulate(rec, qfun, C, granular=granular)
|
||||||
|
mf, mh = full_hold(sim)
|
||||||
|
return sim, mf, mh, (f" {label:<34} {mf['cagr']*100:>+6.1f}% {mf['dd']*100:>5.1f}% "
|
||||||
|
f"{mf['worst']*100:>+6.1f}% {mf['sh']:>5.2f} | {mh['cagr']*100:>+6.1f}% "
|
||||||
|
f"{mh['dd']*100:>5.1f}% | halt {sim['halted']:>3}")
|
||||||
|
|
||||||
|
|
||||||
|
HDR = (f" {'politica':<34} {'CAGR-F':>7} {'DD-F':>5} {'worst':>6} {'Sh-F':>5} | "
|
||||||
|
f"{'CAGR-H':>7} {'DD-H':>5} | settimane-0-spread")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 112)
|
||||||
|
print(" R0703 VRPIMP-SIZING — sizing anti-rovina dello sleeve short-vol defined-risk ETH (2-5k, Deribit)")
|
||||||
|
print(" Flusso: put credit spread ETH 7g -0.28/-0.10, gate canonico, motore ALB-A (fee per gamba), 2021-2026.")
|
||||||
|
print(" Rendimenti per-trade su MARGINE (=max loss). Il sizing non cambia lo Sharpe del flusso: mappa la")
|
||||||
|
print(" frontiera CAGR-DD-P(rovina). REGOLA STANDING INVARIATA: niente short-vol da modello in deploy.")
|
||||||
|
print("=" * 112)
|
||||||
|
|
||||||
|
rec = trade_records(f=1.0, phase=0)
|
||||||
|
act = rec[rec.active == 1]
|
||||||
|
is_act = act[act.date < HOLDOUT]
|
||||||
|
print(f"\n flusso f=1.0 ancora-0: {len(rec)} settimane, {len(act)} attive ({len(act)/len(rec)*100:.0f}%), "
|
||||||
|
f"win {act['win'].mean()*100:.0f}%, r-margine medio {act['r'].mean()*100:+.1f}% "
|
||||||
|
f"(mediana {act['r'].median()*100:+.1f}%), full-loss r<=-0.9: {int((act['r']<=-0.9).sum())}")
|
||||||
|
print(f" margine $/spread(1 ETH): mediana ${act['margin'].median():.0f}, ultimo ${act['margin'].iloc[-1]:.0f} "
|
||||||
|
f"(la banda $66-76 del diario = spot/DVOL del run r0702)")
|
||||||
|
|
||||||
|
# streak diagnostics: gli streak di vittorie predicono la prossima perdita?
|
||||||
|
wins = act["win"].values
|
||||||
|
streaks_before = []
|
||||||
|
s = 0
|
||||||
|
for w in wins:
|
||||||
|
streaks_before.append(s)
|
||||||
|
s = s + 1 if w else 0
|
||||||
|
sb = np.asarray(streaks_before)
|
||||||
|
print("\n DIAGNOSTICA ANTI-STREAK — P(perdita | vittorie consecutive precedenti):")
|
||||||
|
base = 1.0 - act["win"].mean()
|
||||||
|
for N in (0, 2, 3, 5):
|
||||||
|
m = sb >= N
|
||||||
|
if m.sum() >= 8:
|
||||||
|
print(f" streak>={N}: P(loss)={1.0-wins[m].mean():.2f} su n={int(m.sum())} (base {base:.2f})")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (1) frontiera fisso
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print(" (1) FRONTIERA DD-CAGR — frazione fissa (IL NULL DEL DE-LEVERING), f=1.0, storico 2021-26")
|
||||||
|
print(" 'cont' = frazione continua (lens); '2k'/'5k' = granularita' REALE (spread interi da 1 ETH)")
|
||||||
|
print("=" * 112)
|
||||||
|
fracs = (0.02, 0.05, 0.08, 0.10, 0.12, 0.15, 0.20, 0.25, 0.35, 0.50)
|
||||||
|
frontier = {} # frac -> (dd_full, cagr_full) continuo
|
||||||
|
print(f" {'q':>5} | {'cont: CAGR-F':>12} {'DD-F':>6} {'Sh-F':>5} {'CAGR-H':>7} | "
|
||||||
|
f"{'2k: CAGR-F':>10} {'DD-F':>6} {'halt':>4} | {'5k: CAGR-F':>10} {'DD-F':>6} {'halt':>4}")
|
||||||
|
for q in fracs:
|
||||||
|
sc = simulate(rec, pol_fixed(q), 2000.0, granular=False)
|
||||||
|
mfc, mhc = full_hold(sc)
|
||||||
|
frontier[q] = (mfc["dd"], mfc["cagr"])
|
||||||
|
s2 = simulate(rec, pol_fixed(q), 2000.0, granular=True)
|
||||||
|
mf2, _ = full_hold(s2)
|
||||||
|
s5 = simulate(rec, pol_fixed(q), 5000.0, granular=True)
|
||||||
|
mf5, _ = full_hold(s5)
|
||||||
|
tag = " <- book 12%" if abs(q - 0.12) < 1e-9 else ""
|
||||||
|
print(f" {q*100:>4.0f}% | {mfc['cagr']*100:>+11.1f}% {mfc['dd']*100:>5.1f}% {mfc['sh']:>5.2f} "
|
||||||
|
f"{mhc['cagr']*100:>+6.1f}% | {mf2['cagr']*100:>+9.1f}% {mf2['dd']*100:>5.1f}% {s2['halted']:>4} | "
|
||||||
|
f"{mf5['cagr']*100:>+9.1f}% {mf5['dd']*100:>5.1f}% {s5['halted']:>4}{tag}")
|
||||||
|
print(" ('halt' = settimane attive in cui il budget q*E non compra NEMMENO 1 spread -> size 0:")
|
||||||
|
print(" a 2k le frazioni <=10% saltano trade nelle ere a margine alto — la granularita' distorce il basso)")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (2) overlay b/c — selezione IS
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print(" (2) OVERLAY (b) VOL-SCALED e (c) ANTI-STREAK — selezione SOLO in-sample (MAR pre-2025, continuo)")
|
||||||
|
print("=" * 112)
|
||||||
|
rec_is = rec[rec.date < HOLDOUT].reset_index(drop=True)
|
||||||
|
|
||||||
|
def is_mar(qfun):
|
||||||
|
sim = simulate(rec_is, qfun, 2000.0, granular=False)
|
||||||
|
m = path_metrics(sim["dates"], sim["rets"])
|
||||||
|
return m["cagr"] / max(m["dd"], 1e-9)
|
||||||
|
|
||||||
|
cells_b = [(fb, lo, hi) for fb in (0.08, 0.12, 0.16) for lo, hi in ((0.5, 1.5), (0.5, 2.0))]
|
||||||
|
best_b = max(cells_b, key=lambda c: is_mar(pol_volscaled(*c)))
|
||||||
|
cells_c = [(0.12, N, m) for N in (2, 3, 5) for m in (0.25, 0.5)]
|
||||||
|
best_c = max(cells_c, key=lambda c: is_mar(pol_antistreak(*c)))
|
||||||
|
print(f" celle esplorate: vol-scaled {len(cells_b)}, anti-streak {len(cells_c)} "
|
||||||
|
f"(+6 frazioni fisse dichiarate a priori, +3 Kelly = {len(cells_b)+len(cells_c)+9} totali)")
|
||||||
|
print(f" best IS vol-scaled: base={best_b[0]:.0%} clip[{best_b[1]},{best_b[2]}] | "
|
||||||
|
f"best IS anti-streak: q=12% N={best_c[1]} mult={best_c[2]}")
|
||||||
|
|
||||||
|
print("\n" + HDR)
|
||||||
|
named = {}
|
||||||
|
for label, qf in (
|
||||||
|
("(a) FISSO 12% (book)", pol_fixed(0.12)),
|
||||||
|
(f"(b) VOL-SCALED {best_b[0]:.0%} clip[{best_b[1]},{best_b[2]}]", pol_volscaled(*best_b)),
|
||||||
|
(f"(c) ANTI-STREAK 12% N={best_c[1]} m={best_c[2]}", pol_antistreak(0.12, best_c[1], best_c[2])),
|
||||||
|
("(e) ALBIMARINI 1->4 (rif. negativo)", pol_alb14(0.12))):
|
||||||
|
sim, mf, mh, line = hist_row(label, rec, qf, 2000.0, granular=False)
|
||||||
|
named[label] = (qf, mf, mh)
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (3) Kelly onesto
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print(" (3) (d) KELLY FRAZIONARIO con code ONESTE (distribuzione IS pooled banda-f + coda sintetica)")
|
||||||
|
print("=" * 112)
|
||||||
|
p_extra, p_unc, p_emp = synthetic_tail_prob(rec)
|
||||||
|
pooled = []
|
||||||
|
for f in F_SWEEP:
|
||||||
|
rf = trade_records(f=f, phase=0)
|
||||||
|
a = rf[(rf.active == 1) & (rf.date < HOLDOUT)]
|
||||||
|
pooled.append(a["r"].values)
|
||||||
|
rs_pool = np.concatenate(pooled)
|
||||||
|
q_star = kelly_star(rs_pool, p_extra)
|
||||||
|
q_naive = kelly_star(is_act["r"].values, 0.0)
|
||||||
|
print(f" coda: P(move7g<=Kl | ETH 2019-26 certificato)={p_unc:.3f}, freq empirica full-loss gated={p_emp:.3f}"
|
||||||
|
f" -> p_extra sintetica={p_extra:.3f} a r={FULL_LOSS}")
|
||||||
|
print(f" Kelly NAIVE (solo empirico IS f=1.0): q* = {q_naive:.1%}")
|
||||||
|
print(f" Kelly ONESTO (pooled f-band + coda): q* = {q_star:.1%}"
|
||||||
|
f" -> il 12% del book = {0.12/q_star:.2f} Kelly")
|
||||||
|
print("\n" + HDR)
|
||||||
|
for lam in (0.25, 0.5, 1.0):
|
||||||
|
q = lam * q_star
|
||||||
|
label = f"(d) KELLY {lam:.2f}x -> q={q:.1%}"
|
||||||
|
sim, mf, mh, line = hist_row(label, rec, pol_fixed(q), 2000.0, granular=False)
|
||||||
|
named[label] = (pol_fixed(q), mf, mh)
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (4) null del de-levering
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print(" (4) NULL DEL DE-LEVERING — ogni overlay vs la frazione FISSA che da' lo stesso maxDD (interp.)")
|
||||||
|
print("=" * 112)
|
||||||
|
fr = sorted(frontier.items())
|
||||||
|
dds = np.array([v[0] for _, v in fr])
|
||||||
|
cags = np.array([v[1] for _, v in fr])
|
||||||
|
qss = np.array([q for q, _ in fr])
|
||||||
|
for label, (qf, mf, mh) in named.items():
|
||||||
|
if label.startswith("(a)") or label.startswith("(e)"):
|
||||||
|
continue
|
||||||
|
q_eq = float(np.interp(mf["dd"], dds, qss))
|
||||||
|
c_eq = float(np.interp(mf["dd"], dds, cags))
|
||||||
|
verdict = "OVERLAY NON VALE (delever null vince/pareggia)" if c_eq >= mf["cagr"] - 0.002 \
|
||||||
|
else "overlay batte il null (verificare persistenza!)"
|
||||||
|
print(f" {label:<38} DD {mf['dd']*100:5.1f}% CAGR {mf['cagr']*100:+6.1f}% | fisso-equivalente "
|
||||||
|
f"q={q_eq*100:4.1f}% CAGR {c_eq*100:+6.1f}% -> {verdict}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (4b) stress-test ANTI-STREAK
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print(" (4b) STRESS-TEST ANTI-STREAK — regola standing: 3 raffinamenti-gate VRP gia' falliti; un 4°")
|
||||||
|
print(" candidato deve battere il NULL GIUSTO (piazzamento casuale), persistere multi-cut, DSR>=0.95")
|
||||||
|
print("=" * 112)
|
||||||
|
N_as, m_as = best_c[1], best_c[2]
|
||||||
|
# replay della maschera 'settimana ridotta' (streak>=N a decisione)
|
||||||
|
red = np.zeros(len(rec), bool)
|
||||||
|
s = 0
|
||||||
|
for t, row in enumerate(rec.itertuples(index=False)):
|
||||||
|
red[t] = bool(row.active and s >= N_as)
|
||||||
|
if row.active:
|
||||||
|
s = s + 1 if row.win else 0
|
||||||
|
act_mask = rec["active"].values == 1
|
||||||
|
act_idx = np.where(act_mask)[0]
|
||||||
|
k_red = int(red[act_mask].sum())
|
||||||
|
win_arr = rec["win"].values.astype(bool)
|
||||||
|
loss_red = int((~win_arr[act_mask]) [red[act_mask]].sum())
|
||||||
|
n_loss = int((~win_arr[act_mask]).sum())
|
||||||
|
print(f" settimane attive ridotte (streak>={N_as}): {k_red}/{len(act_idx)} ({k_red/len(act_idx)*100:.0f}%) | "
|
||||||
|
f"perdite intercettate a size ridotta: {loss_red}/{n_loss}")
|
||||||
|
|
||||||
|
sim_anti = simulate(rec, pol_antistreak(0.12, N_as, m_as), 2000.0, granular=False)
|
||||||
|
sim_fix = simulate(rec, pol_fixed(0.12), 2000.0, granular=False)
|
||||||
|
m_anti = path_metrics(sim_anti["dates"], sim_anti["rets"])
|
||||||
|
|
||||||
|
# NULL a PIAZZAMENTO CASUALE: stessa sizing a 2 livelli (12% / 12%*mult), stesse k settimane
|
||||||
|
# ridotte, ma scelte A CASO fra le attive (isola il TIMING dello streak dall'esposizione media)
|
||||||
|
rng = np.random.default_rng(SEED + 7)
|
||||||
|
null_cagr, null_dd, null_mar = [], [], []
|
||||||
|
for _ in range(500):
|
||||||
|
choose = rng.choice(act_idx, size=k_red, replace=False)
|
||||||
|
qs = np.full(len(rec), 0.12)
|
||||||
|
qs[choose] = 0.12 * m_as
|
||||||
|
sn = simulate(rec, None, 2000.0, granular=False, q_series=qs)
|
||||||
|
mn = path_metrics(sn["dates"], sn["rets"])
|
||||||
|
null_cagr.append(mn["cagr"])
|
||||||
|
null_dd.append(mn["dd"])
|
||||||
|
null_mar.append(mn["cagr"] / max(mn["dd"], 1e-9))
|
||||||
|
mar_anti = m_anti["cagr"] / max(m_anti["dd"], 1e-9)
|
||||||
|
p_cagr = float(np.mean(np.asarray(null_cagr) < m_anti["cagr"]))
|
||||||
|
p_dd = float(np.mean(np.asarray(null_dd) > m_anti["dd"])) # quota di null con DD PEGGIORE
|
||||||
|
p_mar = float(np.mean(np.asarray(null_mar) < mar_anti))
|
||||||
|
print(f" NULL piazzamento-casuale (500 draw, stessa esposizione media): anti-streak CAGR pctl {p_cagr:.3f}, "
|
||||||
|
f"DD-migliore-del-null {p_dd:.3f}, MAR pctl {p_mar:.3f}")
|
||||||
|
print(f" (null: CAGR med {np.median(null_cagr)*100:+.1f}%, DD med {np.median(null_dd)*100:.1f}% vs "
|
||||||
|
f"anti-streak {m_anti['cagr']*100:+.1f}%/{m_anti['dd']*100:.1f}%)")
|
||||||
|
|
||||||
|
# banda d'ancora dell'EFFETTO (7 fasi): direzione + IL DELEVER-NULL RIFATTO PER OGNI ANCORA
|
||||||
|
# (lezione anchor-luck 2026-07-02: il claim vale solo se regge a OGNI ancora, non alla migliore)
|
||||||
|
print(f"\n banda d'ancora dell'effetto streak (7 fasi, f=1.0) + delever-null PER ANCORA:")
|
||||||
|
n_dir = 0
|
||||||
|
n_null = 0
|
||||||
|
for ph in range(7):
|
||||||
|
rp = trade_records(f=1.0, phase=ph)
|
||||||
|
wa = rp[rp.active == 1]["win"].values.astype(bool)
|
||||||
|
sb2 = []
|
||||||
|
s = 0
|
||||||
|
for w in wa:
|
||||||
|
sb2.append(s)
|
||||||
|
s = s + 1 if w else 0
|
||||||
|
sb2 = np.asarray(sb2)
|
||||||
|
hi = sb2 >= N_as
|
||||||
|
p_hi = 1.0 - wa[hi].mean() if hi.sum() else np.nan
|
||||||
|
p_lo = 1.0 - wa[~hi].mean() if (~hi).sum() else np.nan
|
||||||
|
sa = simulate(rp, pol_antistreak(0.12, N_as, m_as), 2000.0, granular=False)
|
||||||
|
ma = path_metrics(sa["dates"], sa["rets"])
|
||||||
|
# frontiera fissa DI QUESTA ancora -> CAGR del fisso allo stesso DD
|
||||||
|
dds_p, cags_p = [], []
|
||||||
|
for q in fracs:
|
||||||
|
sf_ = simulate(rp, pol_fixed(q), 2000.0, granular=False)
|
||||||
|
mq = path_metrics(sf_["dates"], sf_["rets"])
|
||||||
|
dds_p.append(mq["dd"])
|
||||||
|
cags_p.append(mq["cagr"])
|
||||||
|
c_eq = float(np.interp(ma["dd"], dds_p, cags_p))
|
||||||
|
beats = ma["cagr"] > c_eq + 0.002
|
||||||
|
direz = p_hi > p_lo
|
||||||
|
n_dir += int(direz)
|
||||||
|
n_null += int(beats)
|
||||||
|
print(f" fase {ph}: P(loss|s>={N_as})={p_hi:.2f} vs s<{N_as}={p_lo:.2f} {'OK' if direz else 'NO'} | "
|
||||||
|
f"anti CAGR {ma['cagr']*100:+5.1f}% DD {ma['dd']*100:4.1f}% | fisso stesso-DD CAGR {c_eq*100:+5.1f}% "
|
||||||
|
f"-> {'batte null' if beats else 'NULL VINCE'}")
|
||||||
|
print(f" -> direzione presente in {n_dir}/7 ancore; batte il delever-null in {n_null}/7 ancore")
|
||||||
|
|
||||||
|
# plateau delle celle (ancora 0): l'effetto deve reggere OLTRE la cella selezionata
|
||||||
|
print("\n plateau celle anti-streak (ancora 0, FULL): MAR = CAGR/maxDD vs fisso 12% MAR "
|
||||||
|
f"{path_metrics(sim_fix['dates'], sim_fix['rets'])['cagr'] / max(path_metrics(sim_fix['dates'], sim_fix['rets'])['dd'], 1e-9):.2f}")
|
||||||
|
for (fq, N, m) in cells_c:
|
||||||
|
sa = simulate(rec, pol_antistreak(fq, N, m), 2000.0, granular=False)
|
||||||
|
mm = path_metrics(sa["dates"], sa["rets"])
|
||||||
|
sel = " <- selezionata IS" if (N, m) == (N_as, m_as) else ""
|
||||||
|
print(f" N={N} mult={m}: CAGR {mm['cagr']*100:+5.1f}% DD {mm['dd']*100:5.1f}% "
|
||||||
|
f"MAR {mm['cagr']/max(mm['dd'],1e-9):5.2f}{sel}")
|
||||||
|
|
||||||
|
# multi-cut: uplift Sharpe/MAR post-cut vs fisso 12%
|
||||||
|
print("\n persistenza multi-cut (uplift anti-streak vs fisso 12%, finestra POST-cut):")
|
||||||
|
for cut in ("2022-07-01", "2023-01-01", "2023-07-01", "2024-01-01", "2024-07-01", "2025-01-01"):
|
||||||
|
cts = pd.Timestamp(cut, tz="UTC")
|
||||||
|
msk = sim_anti["dates"] >= cts
|
||||||
|
ma = path_metrics(sim_anti["dates"][msk], sim_anti["rets"][msk])
|
||||||
|
mfx = path_metrics(sim_fix["dates"][msk], sim_fix["rets"][msk])
|
||||||
|
print(f" cut {cut}: dSh {ma['sh']-mfx['sh']:+.2f} dCAGR {(ma['cagr']-mfx['cagr'])*100:+6.1f}pp "
|
||||||
|
f"DD {ma['dd']*100:4.1f}% vs {mfx['dd']*100:4.1f}%")
|
||||||
|
|
||||||
|
# DSR della cella anti-streak (scelta IS) vs tutte le celle di sizing
|
||||||
|
all_sh_is = []
|
||||||
|
for c in cells_b:
|
||||||
|
s_ = simulate(rec_is, pol_volscaled(*c), 2000.0, granular=False)
|
||||||
|
all_sh_is.append(path_metrics(s_["dates"], s_["rets"])["sh"])
|
||||||
|
for c in cells_c:
|
||||||
|
s_ = simulate(rec_is, pol_antistreak(*c), 2000.0, granular=False)
|
||||||
|
all_sh_is.append(path_metrics(s_["dates"], s_["rets"])["sh"])
|
||||||
|
for q in fracs:
|
||||||
|
s_ = simulate(rec_is, pol_fixed(q), 2000.0, granular=False)
|
||||||
|
all_sh_is.append(path_metrics(s_["dates"], s_["rets"])["sh"])
|
||||||
|
sim_anti_is = simulate(rec_is, pol_antistreak(0.12, N_as, m_as), 2000.0, granular=False)
|
||||||
|
m_anti_is = path_metrics(sim_anti_is["dates"], sim_anti_is["rets"])
|
||||||
|
ret_is = pd.Series(sim_anti_is["rets"], index=sim_anti_is["dates"])
|
||||||
|
dsr_as, nm_as = al.deflated_sharpe(m_anti_is["sh"], all_sh_is, ret_is, dpy=WK)
|
||||||
|
print(f"\n DSR anti-streak (Sh-IS {m_anti_is['sh']:.2f} vs {len(all_sh_is)} celle): {dsr_as:.3f} "
|
||||||
|
f"(null-max {nm_as:.2f}) -> {'PASS' if dsr_as >= 0.95 else 'FAIL'} soglia 0.95")
|
||||||
|
|
||||||
|
# banda f dell'anti-streak
|
||||||
|
print(" banda f anti-streak (FULL/HOLD):")
|
||||||
|
for f in F_SWEEP:
|
||||||
|
rf = trade_records(f=f, phase=0)
|
||||||
|
sa = simulate(rf, pol_antistreak(0.12, N_as, m_as), 2000.0, granular=False)
|
||||||
|
mfa, mha = full_hold(sa)
|
||||||
|
print(f" f={f:<4} CAGR-F {mfa['cagr']*100:>+6.1f}% DD-F {mfa['dd']*100:>5.1f}% Sh-F {mfa['sh']:>5.2f} | "
|
||||||
|
f"CAGR-H {mha['cagr']*100:>+6.1f}% DD-H {mha['dd']*100:>5.1f}%")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (5) P(rovina) bootstrap
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print(" (5) P(ROVINA | 5 ANNI) — bootstrap a blocchi (L=13 sett., 1000 path), granularita' REALE")
|
||||||
|
print(" rovina-50 = equity <= 50% del capitale iniziale in un punto qualsiasi; stress = +coda sintetica")
|
||||||
|
print("=" * 112)
|
||||||
|
boot_pols = [
|
||||||
|
("FISSO 5%", pol_fixed(0.05), False), ("FISSO 12% (book)", pol_fixed(0.12), False),
|
||||||
|
("FISSO 25%", pol_fixed(0.25), False),
|
||||||
|
("VOL-SCALED best-IS", pol_volscaled(*best_b), True),
|
||||||
|
("ANTI-STREAK best-IS", pol_antistreak(0.12, best_c[1], best_c[2]), False),
|
||||||
|
(f"KELLY 0.25x ({0.25*q_star:.0%})", pol_fixed(0.25 * q_star), False),
|
||||||
|
(f"KELLY 0.50x ({0.5*q_star:.0%})", pol_fixed(0.5 * q_star), False),
|
||||||
|
(f"KELLY 1.00x ({q_star:.0%})", pol_fixed(q_star), False),
|
||||||
|
("ALBIMARINI 1->4", pol_alb14(0.12), False),
|
||||||
|
]
|
||||||
|
print(f" {'politica':<24} {'C':>5} | {'P(rov50)':>8} {'P(rov80)':>8} {'P(DD>30%)':>9} "
|
||||||
|
f"{'CAGRmed':>8} {'CAGRp10':>8} | {'stress: P(rov50)':>16} {'P(rov80)':>8}")
|
||||||
|
boot_out = {}
|
||||||
|
for label, qf, nref in boot_pols:
|
||||||
|
for C in CAPITALS:
|
||||||
|
b = block_bootstrap_ruin(rec, qf, C, stress_p=0.0, needs_ref=nref)
|
||||||
|
bs = block_bootstrap_ruin(rec, qf, C, stress_p=p_extra, seed=SEED + 1, needs_ref=nref)
|
||||||
|
boot_out[(label, C)] = (b, bs)
|
||||||
|
print(f" {label:<24} {C:>5.0f} | {b['p_ruin50']*100:>7.1f}% {b['p_ruin80']*100:>7.1f}% "
|
||||||
|
f"{b['p_dd30']*100:>8.1f}% {b['cagr_med']*100:>+7.1f}% {b['cagr_p10']*100:>+7.1f}% | "
|
||||||
|
f"{bs['p_ruin50']*100:>15.1f}% {bs['p_ruin80']*100:>7.1f}%")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (6) banda f + banda ancora
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print(" (6) BANDE OBBLIGATORIE — skew f e ancora settimanale (politica FISSO 12%, continuo)")
|
||||||
|
print("=" * 112)
|
||||||
|
print(" banda f (storico FULL/HOLD):")
|
||||||
|
for f in F_SWEEP:
|
||||||
|
rf = trade_records(f=f, phase=0)
|
||||||
|
sim = simulate(rf, pol_fixed(0.12), 2000.0, granular=False)
|
||||||
|
mf, mh = full_hold(sim)
|
||||||
|
print(f" f={f:<4} CAGR-F {mf['cagr']*100:>+6.1f}% DD-F {mf['dd']*100:>5.1f}% worst {mf['worst']*100:>+5.1f}%"
|
||||||
|
f" Sh-F {mf['sh']:>5.2f} | CAGR-H {mh['cagr']*100:>+6.1f}% DD-H {mh['dd']*100:>5.1f}%")
|
||||||
|
print(" banda d'ancora (7 fasi della cadenza settimanale, f=1.0):")
|
||||||
|
anchor = []
|
||||||
|
for ph in range(7):
|
||||||
|
rp = trade_records(f=1.0, phase=ph)
|
||||||
|
sim = simulate(rp, pol_fixed(0.12), 2000.0, granular=False)
|
||||||
|
mf, mh = full_hold(sim)
|
||||||
|
anchor.append((ph, mf["cagr"], mf["dd"], mf["sh"], mh["cagr"]))
|
||||||
|
a = np.array([(x[1], x[2], x[3], x[4]) for x in anchor])
|
||||||
|
print(f" CAGR-F: min {a[:,0].min()*100:+.1f}% med {np.median(a[:,0])*100:+.1f}% max {a[:,0].max()*100:+.1f}% | "
|
||||||
|
f"DD-F: {a[:,1].min()*100:.1f}/{np.median(a[:,1])*100:.1f}/{a[:,1].max()*100:.1f}% | "
|
||||||
|
f"Sh-F: {a[:,2].min():.2f}/{np.median(a[:,2]):.2f}/{a[:,2].max():.2f} | "
|
||||||
|
f"CAGR-H: {a[:,3].min()*100:+.1f}/{np.median(a[:,3])*100:+.1f}/{a[:,3].max()*100:+.1f}%")
|
||||||
|
|
||||||
|
# DSR pro-forma sulle celle (il sizing riscala il flusso: Sharpe quasi invariante per costruzione)
|
||||||
|
all_sh = []
|
||||||
|
for c in cells_b:
|
||||||
|
s = simulate(rec_is, pol_volscaled(*c), 2000.0, granular=False)
|
||||||
|
all_sh.append(path_metrics(s["dates"], s["rets"])["sh"])
|
||||||
|
for c in cells_c:
|
||||||
|
s = simulate(rec_is, pol_antistreak(*c), 2000.0, granular=False)
|
||||||
|
all_sh.append(path_metrics(s["dates"], s["rets"])["sh"])
|
||||||
|
for q in fracs:
|
||||||
|
s = simulate(rec_is, pol_fixed(q), 2000.0, granular=False)
|
||||||
|
all_sh.append(path_metrics(s["dates"], s["rets"])["sh"])
|
||||||
|
sim_b = simulate(rec, pol_volscaled(*best_b), 2000.0, granular=False)
|
||||||
|
daily = pd.Series(sim_b["rets"], index=sim_b["dates"])
|
||||||
|
dsr, null_max = al.deflated_sharpe(path_metrics(sim_b["dates"], sim_b["rets"])["sh"], all_sh, daily, dpy=WK)
|
||||||
|
print(f"\n DSR pro-forma (best overlay vs {len(all_sh)} celle di sizing): {dsr:.3f} (null-max {null_max:.2f})")
|
||||||
|
print(" NB: il sizing RISCALA lo stesso flusso -> gli Sharpe delle celle sono quasi identici per costruzione;")
|
||||||
|
print(" il DSR qui e' dovuto, ma la metrica decisiva del filone e' la frontiera CAGR-DD-P(rovina) vs il null.")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (7) sensitivity 0.1 ETH
|
||||||
|
print("\n (7) SENSITIVITY GRANULARITA' 0.1 ETH (se il min-size fosse 0.1 ETH/gamba, unit ~$7-15):")
|
||||||
|
for C in CAPITALS:
|
||||||
|
s10 = simulate(rec, pol_fixed(0.12), C, granular=True, min_eth=0.1)
|
||||||
|
s1 = simulate(rec, pol_fixed(0.12), C, granular=True, min_eth=1.0)
|
||||||
|
m10, _ = full_hold(s10)
|
||||||
|
m1, _ = full_hold(s1)
|
||||||
|
print(f" C={C:.0f}: 1.0 ETH CAGR {m1['cagr']*100:+.1f}% DD {m1['dd']*100:.1f}% halt {s1['halted']} | "
|
||||||
|
f"0.1 ETH CAGR {m10['cagr']*100:+.1f}% DD {m10['dd']*100:.1f}% halt {s10['halted']}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print(" SINTESI ONESTA")
|
||||||
|
print(" - Il sizing non crea alpha: sposta il punto sulla frontiera CAGR-DD-P(rovina) del flusso gated.")
|
||||||
|
print(" - VERDETTI: (b) vol-scaled batte il null di ~1pp a singola ancora = rumore (lezione TP01xDVOL)")
|
||||||
|
print(" -> SCARTATO. (c) anti-streak: passa random-null 0.996 / DSR 0.999 / multi-cut / 7-7 direzione,")
|
||||||
|
print(" MA plateau ASSENTE (solo la cella N=2 vince; N=3/5 collassano al MAR del fisso), P(loss|streak)")
|
||||||
|
print(" NON monotona (0.24/0.18/0.15) e delever-null vince in 2/7 ancore: l'intero effetto sono ~6")
|
||||||
|
print(" perdite su 15 trade a streak==2 -> ARTEFATTO DI SELEZIONE, non adottato. La batteria minima per")
|
||||||
|
print(" un sizing-gate e': random-placement null + plateau celle + delever-null PER ANCORA (i primi 3")
|
||||||
|
print(" test da soli l'avrebbero promosso). (d) Kelly frazionario = punti sulla frontiera fissa (il")
|
||||||
|
print(" null vince/pareggia per costruzione): il valore e' la CALIBRAZIONE, q*_onesto=44%.")
|
||||||
|
print(" - Il 12% del book = 0.27 Kelly-onesto ~= quarter-Kelly: gia' nella zona sana anti-rovina")
|
||||||
|
print(" (P(rovina50|5y)~0.0-0.1%, P(DD>30%) 9-18%). Alzarlo a 25% decuplica P(DD>30%) a >92%.")
|
||||||
|
print(" - Albimarini 1->4 e' il controesempio: stesso flusso, stessa edge, P(rovina50|5y)=53-55%.")
|
||||||
|
print(" - Granularita' REALE: a 2k il min-size 1 ETH rende inservibili le frazioni <=10% (halt 13-92")
|
||||||
|
print(" settimane) e costa -3.7pp di CAGR al 12%; a 5k il muro sparisce (coerente con r0702: ~2.6k).")
|
||||||
|
print(" - REGOLA STANDING: niente short-vol da modello in deploy. Output = conoscenza per QUANDO/SE")
|
||||||
|
print(" il f di stress reale arrivera' da cerbero-bite. Nessun file di produzione toccato.")
|
||||||
|
print("=" * 112)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
"""R0703 VRPIMP-SPXGATE — gate di regime (VIX-rank / term-structure) sulla double diagonal SPX.
|
||||||
|
|
||||||
|
FILONE 6 (ondata 2026-07-03). L'esperimento MANCANTE del backtest esterno dell'utente
|
||||||
|
(spx-diagonal-backtest, non su questa macchina): il gate di regime sulla diagonale SPX.
|
||||||
|
Domanda secca: il gate ribalta gli anni di coda come fa IV-rank su crypto (VRP01 hold-out
|
||||||
|
-0.25 -> +0.28), o su equity e' solo de-levering?
|
||||||
|
|
||||||
|
DATI (⚠️ PROVENIENZA):
|
||||||
|
- SPY 1d: data/raw/eq_spy_1d.parquet (1996-2026, gia' in repo dal filone GTAA; research-grade).
|
||||||
|
- VIX (VIXCLS) e VIX-3M (VXVCLS): scaricati da FRED via curl (pubblico, senza token) in
|
||||||
|
scratchpad/vixcls.csv e vxvcls.csv. ⚠️ DATO RESEARCH-GRADE, NON CERTIFICATO dal nostro
|
||||||
|
pipeline (nessun cross-venue, nessun audit per-barra; missing = '.', ffill<=5g). VXV parte
|
||||||
|
dal 2007-12 -> il gate term-structure e' testabile solo dal 2008.
|
||||||
|
|
||||||
|
MOTORE (⚠️ E' UN MODELLO, non una catena di quote):
|
||||||
|
- Double diagonal come il progetto esterno: SHORT STRANGLE a ~6 giorni di calendario
|
||||||
|
(4 trading day sul grid SPY; T di pricing = gap di calendario REALE del trade) + ALI LONG
|
||||||
|
a T+1 giorno, entrambe i lati. Distanze del corto in DELTA {0.01, 0.02, 0.05} ("1-2-5
|
||||||
|
delta" del progetto esterno); ali a meta' del delta corto (WING_FRAC=0.5, scelta a priori).
|
||||||
|
- Pricing Black-Scholes r=0 con SKEW LOG-LINEARE: sigma(K) = VIX + SLOPE*ln(S/K) (put piu'
|
||||||
|
ricche, call piu' povere — smile equity), SLOPE=1.0 dichiarato a priori (≈ +1 vol pt per
|
||||||
|
1% di moneyness down, tipico SPX short-dated). Strike-da-delta risolto a punto fisso con
|
||||||
|
la vol skewata. Il VIX e' IV a 30g usata per tenor 6g: term structure IGNORATA (in stress
|
||||||
|
il front-end esplode -> il modello SOTTOSTIMA il mark avverso, dichiarato).
|
||||||
|
- Ali marcate a exit con BS a 1 giorno residuo alla vol di uscita (vega catturato).
|
||||||
|
- Costi: 5% del premio di ogni gamba (entry 4 gambe + vendita ali residue a exit).
|
||||||
|
Modello commissioni+slippage SPX; gli spread reali deep-OTM sono spesso PEGGIORI (dich.).
|
||||||
|
- Banda f {0.6, 0.8, 1.0, 1.3} moltiplica il premio di ogni gamba (regola comune n.4).
|
||||||
|
- Capitale = S0 (cash-secured sul livello indice, convenzione r0702_alb_structure): CAGR/DD
|
||||||
|
sono su quel denominatore conservativo; Sharpe/PF/confronti gate-vs-no-gate invarianti.
|
||||||
|
|
||||||
|
TRADE SOVRAPPOSTI (fedele al progetto esterno, "~6 concorrenti" in calendario):
|
||||||
|
- Ingresso OGNI trading day, holding 4 td -> 4 trade concorrenti sul grid trading-day.
|
||||||
|
- Onesta' statistica: il libro si decompone in 4 STRAND non-sovrapposti (fase d'ingresso
|
||||||
|
mod 4). Lo Sharpe headline = media degli Sharpe di strand (ppy=63); la banda min-max degli
|
||||||
|
strand E' la banda d'ancora obbligatoria (regola 2026-07-02). N_eff = N_trade/4; PF/win
|
||||||
|
riportati con lo sconto esplicito.
|
||||||
|
- Equity/maxDD/CAGR/per-anno dalla serie giornaliera di portafoglio (1/4 del capitale per
|
||||||
|
trade, PnL lumpato a scadenza dello strand che scade quel giorno).
|
||||||
|
|
||||||
|
GATE (tutti causali a entry; rank VIX espandente con warm-start dal 1990):
|
||||||
|
(a) VIX-rank > soglia {0.20,0.30,0.40,0.50} x crash-skip {no, ivr<=0.90}; piu' il GATE
|
||||||
|
CANONICO crypto NON riottimizzato (vrp>0 AND 0.30<=ivr<=0.90, vrp = VIX-RV20). Selezione
|
||||||
|
della cella SOLO in-sample (pre-2025); deflated-Sharpe sul totale delle celle provate.
|
||||||
|
(b) Term-structure: VIX/VXV < 1 (contango) -> vendi; backwardation -> flat. Dal 2008.
|
||||||
|
(c) NULL DEL DE-LEVERING obbligatorio (lezione TP01xDVOL): lo Sharpe e' scale-invariante ->
|
||||||
|
il gate "vale" solo se batte lo Sharpe dell'always-in A PARI maxDD, con persistenza
|
||||||
|
multi-finestra, non su una finestra fortunata.
|
||||||
|
|
||||||
|
REGOLE STANDING: niente short-vol da modello in deploy — esito massimo = CONOSCENZA sul
|
||||||
|
meccanismo del gate (crypto vs equity). Hold-out 2025-26 mai usato per selezionare.
|
||||||
|
|
||||||
|
uv run python scripts/research/r0703_vrpimp_spxgate.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from bisect import bisect_left, insort
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt"))
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from scipy.stats import norm
|
||||||
|
from altlib import deflated_sharpe # noqa: E402 (harness condiviso)
|
||||||
|
|
||||||
|
SCRATCH = Path("/tmp/claude-1001/-opt-docker-PythagorasGoal/"
|
||||||
|
"e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad")
|
||||||
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||||
|
TS_START = pd.Timestamp("2008-01-01", tz="UTC") # finestra comune term-structure (VXV dal 2007-12)
|
||||||
|
TENOR_TD = 4 # trading days del corto (~6 giorni di calendario)
|
||||||
|
WING_FRAC = 0.5 # delta ali = meta' del delta corto (a priori)
|
||||||
|
SLOPE = 1.0 # skew log-lineare (vol per unita' di ln-moneyness) — MODELLO
|
||||||
|
SIG_FLOOR = 0.05
|
||||||
|
COST_FRAC = 0.05 # 5% del premio per gamba (commissioni+slippage, MODELLO)
|
||||||
|
F_SWEEP = (0.6, 0.8, 1.0, 1.3)
|
||||||
|
DELTAS = (0.01, 0.02, 0.05) # distanze corto del progetto esterno
|
||||||
|
CENTRAL_SD = 0.05 # cella centrale A PRIORI: 5-delta (la piu' campionata in coda;
|
||||||
|
# le 1-2 delta sono il territorio "0 perdite = Sharpe implausibile")
|
||||||
|
DPY_TRADE = 252.0 / TENOR_TD # ~63 trade indipendenti/anno per strand
|
||||||
|
WINDOWS = [("1996-2002", "1996", "2003"), ("2003-2009", "2003", "2010"),
|
||||||
|
("2010-2016", "2010", "2017"), ("2017-2023", "2017", "2024"),
|
||||||
|
("2024-2026", "2024", "2027")]
|
||||||
|
TAIL_YEARS = (1998, 2002, 2008, 2011, 2018, 2020, 2022)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- pricing (MODELLO)
|
||||||
|
def bs_put(S, K, T, sig):
|
||||||
|
if T <= 0 or sig <= 0:
|
||||||
|
return max(K - S, 0.0)
|
||||||
|
d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T))
|
||||||
|
d2 = d1 - sig * np.sqrt(T)
|
||||||
|
return K * norm.cdf(-d2) - S * norm.cdf(-d1)
|
||||||
|
|
||||||
|
|
||||||
|
def bs_call(S, K, T, sig):
|
||||||
|
if T <= 0 or sig <= 0:
|
||||||
|
return max(S - K, 0.0)
|
||||||
|
d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T))
|
||||||
|
return S * norm.cdf(d1) - K * norm.cdf(d1 - sig * np.sqrt(T))
|
||||||
|
|
||||||
|
|
||||||
|
def sigma_k(sig_atm, S, K):
|
||||||
|
"""Skew log-lineare equity: put OTM (K<S) piu' ricche, call OTM piu' povere."""
|
||||||
|
return max(sig_atm + SLOPE * np.log(S / K), SIG_FLOOR)
|
||||||
|
|
||||||
|
|
||||||
|
def strike_skew(S, T, sig_atm, delta, kind):
|
||||||
|
"""Strike dal delta CON la vol skewata (punto fisso). kind: 'put'/'call', delta>0."""
|
||||||
|
d1 = norm.ppf(1 - delta) if kind == "put" else norm.ppf(delta)
|
||||||
|
sig = sig_atm
|
||||||
|
K = S
|
||||||
|
for _ in range(8):
|
||||||
|
K = S * np.exp(0.5 * sig * sig * T - d1 * sig * np.sqrt(T))
|
||||||
|
sig = sigma_k(sig_atm, S, K)
|
||||||
|
return K, sig
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- dati
|
||||||
|
def load_master():
|
||||||
|
spy = pd.read_parquet(PROJECT_ROOT / "data" / "raw" / "eq_spy_1d.parquet")
|
||||||
|
px = pd.Series(spy["close"].astype(float).values,
|
||||||
|
index=pd.to_datetime(spy["timestamp"].astype("int64"), unit="ms", utc=True))
|
||||||
|
|
||||||
|
def _fred(fn, col):
|
||||||
|
f = SCRATCH / fn
|
||||||
|
if not f.exists():
|
||||||
|
return None
|
||||||
|
c = pd.read_csv(f, na_values=".")
|
||||||
|
return pd.Series(c[col].astype(float).values,
|
||||||
|
index=pd.to_datetime(c["observation_date"], utc=True))
|
||||||
|
|
||||||
|
vix = _fred("vixcls.csv", "VIXCLS")
|
||||||
|
vxv = _fred("vxvcls.csv", "VXVCLS")
|
||||||
|
if vix is None:
|
||||||
|
raise SystemExit("Manca scratchpad/vixcls.csv (curl FRED VIXCLS) — vedi docstring.")
|
||||||
|
# IV-rank espandente CAUSALE sulla serie VIX nativa (warm-start dal 1990, come _ivrank)
|
||||||
|
vals = vix.dropna()
|
||||||
|
hist: list[float] = []
|
||||||
|
rank = {}
|
||||||
|
for d, v in vals.items():
|
||||||
|
if len(hist) >= 250:
|
||||||
|
rank[d] = bisect_left(hist, v) / len(hist)
|
||||||
|
insort(hist, v)
|
||||||
|
ivr = pd.Series(rank)
|
||||||
|
|
||||||
|
df = pd.DataFrame({"px": px})
|
||||||
|
df["vix"] = vix.reindex(df.index).ffill(limit=5)
|
||||||
|
df["ivr"] = ivr.reindex(df.index).ffill(limit=5)
|
||||||
|
df["vxv"] = vxv.reindex(df.index).ffill(limit=5) if vxv is not None else np.nan
|
||||||
|
lr = np.log(df["px"]).diff()
|
||||||
|
df["rv20"] = lr.rolling(20).std() * np.sqrt(252) * 100.0 # causale: usa ritorni fino a i
|
||||||
|
return df.dropna(subset=["px", "vix", "ivr"])
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- motore
|
||||||
|
def run_base(df, sd=CENTRAL_SD, f=1.0):
|
||||||
|
"""Vendita della double diagonal OGNI trading day (always-in). Ritorna DataFrame trade
|
||||||
|
(i, entry, exp, pnl su capitale S0, credit). I gate si applicano DOPO come maschera:
|
||||||
|
il pricing e' gate-indipendente -> una sola passata per (sd, f)."""
|
||||||
|
px = df["px"].values
|
||||||
|
vix = df["vix"].values / 100.0
|
||||||
|
idx = df.index
|
||||||
|
n = len(px)
|
||||||
|
recs = []
|
||||||
|
for i in range(21, n - TENOR_TD):
|
||||||
|
j = i + TENOR_TD
|
||||||
|
S0 = px[i]; sig0 = vix[i]
|
||||||
|
if not (np.isfinite(S0) and np.isfinite(sig0)) or sig0 <= 0:
|
||||||
|
continue
|
||||||
|
T = max((idx[j] - idx[i]).days, 1) / 365.25 # gap di calendario REALE
|
||||||
|
Tl = T + 1.0 / 365.25 # ali T+1g
|
||||||
|
# gambe corte (strangle) a delta sd; ali long a delta sd*WING_FRAC, scadenza T+1
|
||||||
|
Kps, sps = strike_skew(S0, T, sig0, sd, "put")
|
||||||
|
Kcs, scs = strike_skew(S0, T, sig0, sd, "call")
|
||||||
|
Kpl, spl = strike_skew(S0, Tl, sig0, sd * WING_FRAC, "put")
|
||||||
|
Kcl, scl = strike_skew(S0, Tl, sig0, sd * WING_FRAC, "call")
|
||||||
|
p_ps = bs_put(S0, Kps, T, sps) / S0 * f
|
||||||
|
p_cs = bs_call(S0, Kcs, T, scs) / S0 * f
|
||||||
|
p_pl = bs_put(S0, Kpl, Tl, spl) / S0 * f
|
||||||
|
p_cl = bs_call(S0, Kcl, Tl, scl) / S0 * f
|
||||||
|
credit = (p_ps + p_cs) - (p_pl + p_cl)
|
||||||
|
# exit a scadenza del corto: corti a intrinseco, ali marcate BS 1g alla vol di uscita
|
||||||
|
S1 = px[j]; sig1 = vix[j]
|
||||||
|
short_pay = (max(0.0, Kps - S1) + max(0.0, S1 - Kcs)) / S0
|
||||||
|
lp = bs_put(S1, Kpl, 1.0 / 365.25, sigma_k(sig1, S1, Kpl)) / S0 * f
|
||||||
|
lc = bs_call(S1, Kcl, 1.0 / 365.25, sigma_k(sig1, S1, Kcl)) / S0 * f
|
||||||
|
cost = COST_FRAC * (p_ps + p_cs + p_pl + p_cl) + COST_FRAC * (lp + lc)
|
||||||
|
pnl = credit - short_pay + (lp + lc) - cost
|
||||||
|
recs.append((i, idx[i], idx[j], pnl, credit))
|
||||||
|
tr = pd.DataFrame(recs, columns=["i", "entry", "exp", "pnl", "credit"])
|
||||||
|
tr["active"] = True
|
||||||
|
return tr
|
||||||
|
|
||||||
|
|
||||||
|
def apply_gate(base, mask):
|
||||||
|
"""mask: Series booleana su df.index (causale a entry). Trade bloccato -> pnl 0, inattivo."""
|
||||||
|
tr = base.copy()
|
||||||
|
ok = mask.reindex(tr["entry"]).fillna(False).values.astype(bool)
|
||||||
|
tr["active"] = ok
|
||||||
|
tr.loc[~tr["active"], "pnl"] = 0.0
|
||||||
|
return tr
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- metriche
|
||||||
|
def _sh(x, ppy):
|
||||||
|
x = pd.Series(x).dropna()
|
||||||
|
return float(x.mean() / x.std() * np.sqrt(ppy)) if len(x) > 10 and x.std() > 0 else float("nan")
|
||||||
|
|
||||||
|
|
||||||
|
def daily_series(tr):
|
||||||
|
return tr.groupby("exp")["pnl"].sum() / TENOR_TD
|
||||||
|
|
||||||
|
|
||||||
|
def dd_of(daily):
|
||||||
|
eq = (1 + daily).cumprod()
|
||||||
|
pk = eq.cummax()
|
||||||
|
return float(((pk - eq) / pk).max())
|
||||||
|
|
||||||
|
|
||||||
|
def agg(tr):
|
||||||
|
"""Metriche oneste: Sharpe = media degli strand non-sovrapposti (banda = ancora);
|
||||||
|
equity/DD/CAGR dalla serie giornaliera; PF/win sui trade ATTIVI con N_eff = N/4."""
|
||||||
|
strands = [tr[tr["i"] % TENOR_TD == o]["pnl"] for o in range(TENOR_TD)]
|
||||||
|
shs = [_sh(s, DPY_TRADE) for s in strands]
|
||||||
|
hold = tr[tr["exp"] >= HOLDOUT]
|
||||||
|
isam = tr[tr["exp"] < HOLDOUT]
|
||||||
|
shs_h = [_sh(hold[hold["i"] % TENOR_TD == o]["pnl"], DPY_TRADE) for o in range(TENOR_TD)]
|
||||||
|
shs_i = [_sh(isam[isam["i"] % TENOR_TD == o]["pnl"], DPY_TRADE) for o in range(TENOR_TD)]
|
||||||
|
d = daily_series(tr)
|
||||||
|
eq = (1 + d).cumprod()
|
||||||
|
yrs = len(d) / 252.0
|
||||||
|
act = tr[tr["active"]]["pnl"]
|
||||||
|
pos = act[act > 0].sum(); neg = -act[act < 0].sum()
|
||||||
|
return dict(
|
||||||
|
sh=float(np.nanmean(shs)), sh_lo=float(np.nanmin(shs)), sh_hi=float(np.nanmax(shs)),
|
||||||
|
sh_is=float(np.nanmean(shs_i)), sh_h=float(np.nanmean(shs_h)),
|
||||||
|
sh_h_lo=float(np.nanmin(shs_h)) if np.isfinite(shs_h).any() else float("nan"),
|
||||||
|
sh_h_hi=float(np.nanmax(shs_h)) if np.isfinite(shs_h).any() else float("nan"),
|
||||||
|
cagr=float(eq.iloc[-1] ** (1 / yrs) - 1) if yrs > 0 and eq.iloc[-1] > 0 else -1.0,
|
||||||
|
dd=dd_of(d), worst5=float(d.rolling(5).sum().min()),
|
||||||
|
pf=float(pos / neg) if neg > 0 else float("inf"),
|
||||||
|
win=float((act > 0).mean()) if len(act) else float("nan"),
|
||||||
|
n_act=int(len(act)), n_eff=int(len(act) / TENOR_TD),
|
||||||
|
act_frac=float(tr["active"].mean()), nloss=int((act < 0).sum()))
|
||||||
|
|
||||||
|
|
||||||
|
def per_year(tr):
|
||||||
|
d = daily_series(tr)
|
||||||
|
return {int(y): float((1 + g).prod() - 1) for y, g in d.groupby(d.index.year)}
|
||||||
|
|
||||||
|
|
||||||
|
def win_sh(tr, a, b):
|
||||||
|
"""Sharpe strand-medio nella finestra [a, b) (su scadenza)."""
|
||||||
|
w = tr[(tr["exp"] >= pd.Timestamp(a, tz="UTC")) & (tr["exp"] < pd.Timestamp(b, tz="UTC"))]
|
||||||
|
return float(np.nanmean([_sh(w[w["i"] % TENOR_TD == o]["pnl"], DPY_TRADE)
|
||||||
|
for o in range(TENOR_TD)]))
|
||||||
|
|
||||||
|
|
||||||
|
def delever_k(daily_full, dd_target):
|
||||||
|
lo, hi = 0.0, 1.0
|
||||||
|
if dd_of(daily_full) <= dd_target:
|
||||||
|
return 1.0
|
||||||
|
for _ in range(40):
|
||||||
|
k = 0.5 * (lo + hi)
|
||||||
|
if dd_of(k * daily_full) > dd_target:
|
||||||
|
hi = k
|
||||||
|
else:
|
||||||
|
lo = k
|
||||||
|
return lo
|
||||||
|
|
||||||
|
|
||||||
|
def row(label, m):
|
||||||
|
pf = f"{m['pf']:5.2f}" if np.isfinite(m["pf"]) else " inf"
|
||||||
|
print(f" {label:<42} {m['sh']:>5.2f} [{m['sh_lo']:>5.2f},{m['sh_hi']:>5.2f}] "
|
||||||
|
f"{m['sh_is']:>6.2f} {m['sh_h']:>6.2f} {m['cagr']*100:>+6.2f}% {m['dd']*100:>5.1f}% "
|
||||||
|
f"{m['worst5']*100:>+6.2f}% {pf} {m['win']*100:>4.0f}% {m['act_frac']*100:>4.0f}% "
|
||||||
|
f"{m['n_eff']:>5}")
|
||||||
|
|
||||||
|
|
||||||
|
HDR = (f" {'config':<42} {'Sh':>5} {'[banda ancora]':>14} {'Sh-IS':>6} {'Sh-HO':>6} "
|
||||||
|
f"{'CAGR':>7} {'maxDD':>6} {'w5d':>7} {'PF':>5} {'win%':>5} {'att%':>5} {'Neff':>5}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 118)
|
||||||
|
print(" R0703 VRPIMP-SPXGATE — gate di regime (VIX-rank / term-structure) sulla double diagonal SPX")
|
||||||
|
print(" ⚠️ MODELLO: BS + skew log-lineare su VIX-30g @ tenor 6g; VIX/VXV da FRED research-grade NON certificati.")
|
||||||
|
print(" ⚠️ Trade sovrapposti (~4 concorrenti trading-day, ~6 calendario): Sharpe = media 4 strand indipendenti;")
|
||||||
|
print(" PF/win su trade sovrapposti -> campione effettivo = N/4. Capitale = S0 cash-secured (conv. ALB).")
|
||||||
|
print("=" * 118)
|
||||||
|
df = load_master()
|
||||||
|
print(f" SPY {df.index[0].date()} -> {df.index[-1].date()} ({len(df)} td) | VIX medio {df['vix'].mean():.1f} "
|
||||||
|
f"| RV20 media {df['rv20'].mean():.1f} | VRP(VIX-RV20) medio {(df['vix']-df['rv20']).mean():+.1f} pt, "
|
||||||
|
f">0 nel {((df['vix']-df['rv20'])>0).mean()*100:.0f}% dei giorni | VXV dal "
|
||||||
|
f"{df['vxv'].dropna().index[0].date() if df['vxv'].notna().any() else 'N/A'}")
|
||||||
|
|
||||||
|
trials = {} # (tag) -> Sharpe FULL strand-medio: conteggio per deflated-Sharpe
|
||||||
|
# ------------------------------------------------------------------ maschere gate (causali)
|
||||||
|
m_vrp = (df["vix"] - df["rv20"]) > 0
|
||||||
|
m_cs = df["ivr"] <= 0.90
|
||||||
|
m_canon = m_vrp & (df["ivr"] >= 0.30) & m_cs
|
||||||
|
m_contango = (df["vix"] / df["vxv"]) < 1.0
|
||||||
|
m_contango = m_contango.fillna(False)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (1) griglia struttura
|
||||||
|
print(f"\n (1) STRUTTURA always-in, f=1.0 — distanze corto {{1,2,5}}-delta (ali a delta/2, T+1g)")
|
||||||
|
print(HDR)
|
||||||
|
bases = {}
|
||||||
|
for sd in DELTAS:
|
||||||
|
bases[sd] = run_base(df, sd=sd, f=1.0)
|
||||||
|
m = agg(bases[sd])
|
||||||
|
cr = bases[sd]["credit"]
|
||||||
|
tag = " ⚠️ 0-loss=Sharpe implausibile (CC01)" if m["nloss"] == 0 else ""
|
||||||
|
row(f"diag {sd*100:.0f}Δ always-in (credito {cr.mean()*1e4:+.1f}bps)", m)
|
||||||
|
if tag:
|
||||||
|
print(f" {tag}")
|
||||||
|
trials[f"sd{sd}_always"] = m["sh"]
|
||||||
|
base = bases[CENTRAL_SD]
|
||||||
|
print(f" -> cella centrale A PRIORI: {CENTRAL_SD*100:.0f}Δ (dichiarata nel docstring prima di guardare i numeri)")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (2) gate VIX-rank
|
||||||
|
print(f"\n (2a) GATE VIX-RANK sulla cella centrale (f=1.0) — soglia scelta SOLO in-sample (pre-2025)")
|
||||||
|
print(HDR)
|
||||||
|
m_always = agg(base)
|
||||||
|
row("ALWAYS-IN (baseline)", m_always)
|
||||||
|
gates = {"vrp>0": m_vrp,
|
||||||
|
"CANONICO crypto (vrp>0 & 0.30<=ivr<=0.90)": m_canon}
|
||||||
|
for th in (0.20, 0.30, 0.40, 0.50):
|
||||||
|
gates[f"ivr>={th:.2f}"] = df["ivr"] >= th
|
||||||
|
gates[f"ivr>={th:.2f} & cs0.90"] = (df["ivr"] >= th) & m_cs
|
||||||
|
res = {}
|
||||||
|
for tag, msk in gates.items():
|
||||||
|
tr = apply_gate(base, msk)
|
||||||
|
res[tag] = (tr, agg(tr))
|
||||||
|
row(tag, res[tag][1])
|
||||||
|
trials[tag] = res[tag][1]["sh"]
|
||||||
|
sel_tag = max(res, key=lambda t: res[t][1]["sh_is"])
|
||||||
|
sel_tr, sel_m = res[sel_tag]
|
||||||
|
can_tr, can_m = res["CANONICO crypto (vrp>0 & 0.30<=ivr<=0.90)"]
|
||||||
|
print(f" -> cella scelta IN-SAMPLE: '{sel_tag}' (Sh-IS {sel_m['sh_is']:.2f}) | suo hold-out {sel_m['sh_h']:.2f} "
|
||||||
|
f"[{sel_m['sh_h_lo']:.2f},{sel_m['sh_h_hi']:.2f}]")
|
||||||
|
dsr, sr0 = deflated_sharpe(sel_m["sh"], list(trials.values()),
|
||||||
|
sel_tr[sel_tr["i"] % TENOR_TD == 0]["pnl"], dpy=DPY_TRADE)
|
||||||
|
print(f" -> deflated-Sharpe della cella scelta su {len(trials)} celle: DSR={dsr:.3f} "
|
||||||
|
f"(null max atteso {sr0:.2f}) {'PASS' if dsr >= 0.95 else 'FAIL'} (>=0.95)")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (3) per-anno completo
|
||||||
|
print(f"\n (3) PER-ANNO COMPLETO (f=1.0, ritorni su capitale S0) — * = anno di coda")
|
||||||
|
pa, pc, ps = per_year(base), per_year(can_tr), per_year(sel_tr)
|
||||||
|
print(f" {'anno':<6} {'ALWAYS':>8} {'CANONICO':>9} {'SELECTED':>9} {'anno':<6} {'ALWAYS':>8} {'CANONICO':>9} {'SELECTED':>9}")
|
||||||
|
ys = sorted(pa)
|
||||||
|
half = (len(ys) + 1) // 2
|
||||||
|
for k in range(half):
|
||||||
|
cells = []
|
||||||
|
for y in (ys[k], ys[k + half] if k + half < len(ys) else None):
|
||||||
|
if y is None:
|
||||||
|
cells.append(" " * 37)
|
||||||
|
continue
|
||||||
|
star = "*" if y in TAIL_YEARS else " "
|
||||||
|
cells.append(f"{y}{star:<1} {pa[y]*100:>+7.2f}% {pc.get(y,0)*100:>+8.2f}% {ps.get(y,0)*100:>+8.2f}%")
|
||||||
|
print(" " + " ".join(cells))
|
||||||
|
flips_c = [y for y in TAIL_YEARS if y in pa and pa[y] < 0 and pc.get(y, 0) >= 0]
|
||||||
|
flips_s = [y for y in TAIL_YEARS if y in pa and pa[y] < 0 and ps.get(y, 0) >= 0]
|
||||||
|
worse_c = [y for y in TAIL_YEARS if y in pa and pc.get(y, 0) < min(0.0, pa[y])]
|
||||||
|
print(f" anni di coda RIBALTATI (neg->non-neg): canonico {flips_c or 'NESSUNO'} | selected {flips_s or 'NESSUNO'}")
|
||||||
|
print(f" anni di coda PEGGIORATI dal canonico: {worse_c or 'nessuno'}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (4) term structure
|
||||||
|
print(f"\n (4) (2b) GATE TERM-STRUCTURE VIX/VXV — finestra comune dal 2008 (VXV research-grade)")
|
||||||
|
sub = lambda tr: tr[tr["entry"] >= TS_START] # noqa: E731
|
||||||
|
print(HDR)
|
||||||
|
ts_res = {}
|
||||||
|
cont_tr = apply_gate(base, m_contango)
|
||||||
|
for tag, tr in (("ALWAYS-IN (2008+)", sub(base)),
|
||||||
|
("contango VIX/VXV<1 (2008+)", sub(cont_tr)),
|
||||||
|
("CANONICO (2008+)", sub(can_tr)),
|
||||||
|
("CANONICO & contango (2008+)", sub(apply_gate(base, m_canon & m_contango)))):
|
||||||
|
ts_res[tag] = agg(tr)
|
||||||
|
row(tag, ts_res[tag])
|
||||||
|
trials[f"ts_{tag}"] = ts_res[tag]["sh"]
|
||||||
|
# la finestra 2008-2009 (esclusa dal multicut per costruzione) e' il banco di prova di coda
|
||||||
|
sa08 = win_sh(base, "2008", "2010"); st08 = win_sh(cont_tr, "2008", "2010")
|
||||||
|
print(f" finestra 2008-2009 (GFC): always {sa08:.2f} vs contango {st08:.2f} (Δ {st08-sa08:+.2f})")
|
||||||
|
# per-anno del contango (2008+) + code ribaltate
|
||||||
|
pt = per_year(sub(cont_tr)); pa8 = {y: v for y, v in per_year(sub(base)).items()}
|
||||||
|
print(f" per-anno 2008+ (ALWAYS vs CONTANGO): " + " ".join(
|
||||||
|
f"{y}{'*' if y in TAIL_YEARS else ''}:{pa8[y]*100:+.2f}/{pt.get(y,0)*100:+.2f}%" for y in sorted(pa8)))
|
||||||
|
flips_t = [y for y in TAIL_YEARS if y in pa8 and pa8[y] < 0 and pt.get(y, 0) >= 0]
|
||||||
|
print(f" anni di coda RIBALTATI dal contango: {flips_t or 'NESSUNO'}")
|
||||||
|
# sovrapposizione maschere (il contango e' un crash-skip travestito?) — giorni 2008+
|
||||||
|
w8 = df.index >= TS_START
|
||||||
|
for tag, mk in (("ivr<=0.90 (crash-skip)", m_cs), ("vrp>0", m_vrp), ("canonico", m_canon)):
|
||||||
|
a_ = m_contango[w8].values; b_ = mk[w8].values
|
||||||
|
agree = float((a_ == b_).mean())
|
||||||
|
print(f" accordo giorni contango vs {tag:<22}: {agree*100:.0f}% (contango attivo {a_.mean()*100:.0f}%, "
|
||||||
|
f"{tag.split()[0]} attivo {b_.mean()*100:.0f}%)")
|
||||||
|
dsr_t, sr0_t = deflated_sharpe(ts_res["contango VIX/VXV<1 (2008+)"]["sh"], list(trials.values()),
|
||||||
|
sub(cont_tr)[sub(cont_tr)["i"] % TENOR_TD == 0]["pnl"], dpy=DPY_TRADE)
|
||||||
|
print(f" deflated-Sharpe contango su {len(trials)} celle totali provate: DSR={dsr_t:.3f} "
|
||||||
|
f"{'PASS' if dsr_t >= 0.95 else 'FAIL'} (>=0.95)")
|
||||||
|
print(" ⚠️ CAVEAT MODELLO (decisivo): il pricing usa VIX-30g come IV del tenor 6g. In BACKWARDATION")
|
||||||
|
print(" la IV front-end reale e' >> VIX -> il credito reale nei giorni esclusi dal gate e' maggiore")
|
||||||
|
print(" del modellato: l'uplift del contango e' quindi in parte SOVRASTIMATO per costruzione.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (5) null de-levering
|
||||||
|
print(f"\n (5) NULL DEL DE-LEVERING (obbligatorio) — always-in scalato al maxDD del gated:")
|
||||||
|
d_full = daily_series(base)
|
||||||
|
for tag, m_g in (("CANONICO", can_m), (f"SELECTED '{sel_tag}'", sel_m),
|
||||||
|
("contango (2008+)", ts_res["contango VIX/VXV<1 (2008+)"])):
|
||||||
|
dref = d_full if "2008" not in tag else d_full[d_full.index >= TS_START]
|
||||||
|
sh_ref = m_always["sh"] if "2008" not in tag else ts_res["ALWAYS-IN (2008+)"]["sh"]
|
||||||
|
k = delever_k(dref, m_g["dd"])
|
||||||
|
eq = (1 + k * dref).cumprod(); yrs = len(dref) / 252.0
|
||||||
|
cagr_k = float(eq.iloc[-1] ** (1 / yrs) - 1)
|
||||||
|
beat = m_g["sh"] - sh_ref
|
||||||
|
print(f" {tag:<28} gated: Sh {m_g['sh']:.2f} DD {m_g['dd']*100:.1f}% CAGR {m_g['cagr']*100:+.2f}% | "
|
||||||
|
f"de-lever k={k:.2f}: Sh {sh_ref:.2f} (invariante) CAGR {cagr_k:+.2f}% -> "
|
||||||
|
f"ΔSh gate-vs-delever {beat:+.2f} {'(il gate AGGIUNGE)' if beat > 0.1 else '(de-levering basta)'}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (6) multi-cut
|
||||||
|
print(f"\n (6) PERSISTENZA MULTI-FINESTRA (uplift Sharpe strand-medio gated - always, finestre disgiunte)")
|
||||||
|
print(f" {'finestra':<12} {'ALWAYS':>7} {'CANON':>7} {'Δcanon':>8} {'SELECT':>7} {'Δsel':>8} {'contango':>9} {'Δcont':>8}")
|
||||||
|
pos_c = pos_s = pos_t = n_t = 0
|
||||||
|
for wtag, a, b in WINDOWS:
|
||||||
|
sa = win_sh(base, a, b); sc = win_sh(can_tr, a, b); ss = win_sh(sel_tr, a, b)
|
||||||
|
in_ts = pd.Timestamp(a, tz="UTC") >= TS_START
|
||||||
|
st = win_sh(apply_gate(base, m_contango), a, b) if in_ts else float("nan")
|
||||||
|
dc, dsl = sc - sa, ss - sa
|
||||||
|
dt = (st - sa) if in_ts else float("nan")
|
||||||
|
pos_c += dc > 0; pos_s += dsl > 0
|
||||||
|
if in_ts:
|
||||||
|
pos_t += dt > 0; n_t += 1
|
||||||
|
print(f" {wtag:<12} {sa:>7.2f} {sc:>7.2f} {dc:>+8.2f} {ss:>7.2f} {dsl:>+8.2f} "
|
||||||
|
f"{st:>9.2f} {dt:>+8.2f}")
|
||||||
|
print(f" -> multicut: canonico {pos_c}/5 | selected {pos_s}/5 | contango {pos_t}/{n_t} (2008+)")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (7) banda f
|
||||||
|
print(f"\n (7) BANDA f {F_SWEEP} (regola skew n.4) — cella centrale, always vs canonico vs selected")
|
||||||
|
print(HDR)
|
||||||
|
sel_mask = gates[sel_tag]
|
||||||
|
sub8 = lambda tr: tr[tr["entry"] >= TS_START] # noqa: E731
|
||||||
|
for f in F_SWEEP:
|
||||||
|
bf = bases[CENTRAL_SD] if f == 1.0 else run_base(df, sd=CENTRAL_SD, f=f)
|
||||||
|
row(f"always-in f={f}", agg(bf))
|
||||||
|
row(f"canonico f={f}", agg(apply_gate(bf, m_canon)))
|
||||||
|
row(f"selected f={f}", agg(apply_gate(bf, sel_mask)))
|
||||||
|
a8 = agg(sub8(bf)); c8 = agg(sub8(apply_gate(bf, m_contango)))
|
||||||
|
print(f" -> contango f={f} (2008+): Sh {c8['sh']:.2f} vs always {a8['sh']:.2f} "
|
||||||
|
f"(Δ {c8['sh']-a8['sh']:+.2f}) DD {c8['dd']*100:.1f}% vs {a8['dd']*100:.1f}%")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ verdetto
|
||||||
|
print("\n DOMANDA SECCA — il gate ribalta gli anni di coda come IV-rank su crypto (HOLD -0.25->+0.28)?")
|
||||||
|
print(f" crypto VRP01: gate ivr>=0.30 = vendi solo vol RICCA -> ribalta l'hold-out.")
|
||||||
|
print(f" SPX qui: always Sh {m_always['sh']:.2f} (HO {m_always['sh_h']:.2f}) vs canonico Sh {can_m['sh']:.2f} "
|
||||||
|
f"(HO {can_m['sh_h']:.2f}); code ribaltate: {flips_c or 'NESSUNA'}; multicut canonico {pos_c}/5; DSR {dsr:.2f}.")
|
||||||
|
print(" ⚠️ Tutto su MODELLO (BS+skew log-lineare, VIX 30g @ 6g, dati FRED non certificati). Regola standing:")
|
||||||
|
print(" niente short-vol da modello in deploy — esito = conoscenza sul meccanismo del gate, non un LEAD.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""r0703_vrpimp_stresslab.py — FILONE 7: STRESS LAB DI CODA per la famiglia short-vol crypto.
|
||||||
|
|
||||||
|
NON e' ricerca di alpha: e' FISICA DELLO STRUMENTO (niente hold-out, niente selezione — dichiarato).
|
||||||
|
Tre banchi:
|
||||||
|
|
||||||
|
(a) REPLAY STORICI — le 10 peggiori finestre 2-settimane di BTC/ETH 2019-2026 (prezzi CERTIFICATI,
|
||||||
|
load_tf 1d) applicate a VRP01 canonico (put credit spread Δ-0.28/-0.10, 7g) e alle strutture
|
||||||
|
ALB-A alla cella a-priori z=2σ/ali+1σ/5g (vertical / condor / diagonal T+1), gate canonico
|
||||||
|
ON/OFF. IV: REALE (DVOL) dove esiste (2021-03+); prima, salta secondo la relazione EMPIRICA
|
||||||
|
DVOL-vs-ritorno stimata dai dati (regressione piecewise Δdvol_pts ~ b−·ret− + b+·ret+ su
|
||||||
|
finestre NON sovrapposte; applicata in avanti dal punto d'ingresso = nessun uso di futuro
|
||||||
|
dentro la finestra). Entry di base = GIORNO DI PICCO della finestra (worst-case timing);
|
||||||
|
banda d'ancora = fase d'ingresso 0..tenor-1. Banda f obbligatoria.
|
||||||
|
|
||||||
|
(b) MATRICE SINTETICA — gap {-10,-15,-20,-30}% × IV-spike {×1.5,×2,×3} × timing {overnight,
|
||||||
|
intra-settimana}: perdita per struttura in unita' di CREDITO MEDIO (storico, gated) e in
|
||||||
|
% del capitale a sizing 12% (convenzione margine-deployed = max-loss defined-risk; per i
|
||||||
|
nudi il margine non e' definito → cash-secured, dichiarato). Il timing muove il MARK-TO-
|
||||||
|
MARKET al gap (stress di margine), non la perdita a scadenza (stessa S1): riportati entrambi.
|
||||||
|
L'asse IV-spike e' ancorato all'empirico (la regressione dice quale ×IV produce ogni gap).
|
||||||
|
|
||||||
|
(c) VALORE DELL'ALA — per ogni feature strutturale (ala far-OTM del VRP01 Δ-0.10; ala far-OTM
|
||||||
|
dello strangle z2; ala T+1 del diagonale; ala PIU' VICINA dz=0.5): drag annuo storico in bps
|
||||||
|
(decomposto calm-drag vs tail-benefit come ALB-A) vs protezione comprata in ogni cella della
|
||||||
|
matrice → tabella "protezione di coda per bps di drag" + NULL DEL DE-LEVERING esplicito
|
||||||
|
(lezione TP01×DVOL 2026-06-26): se ridurre la size della struttura SENZA ala raggiunge la
|
||||||
|
stessa perdita di cella a costo CAGR minore, l'ala NON vale.
|
||||||
|
|
||||||
|
Deliverable finale: worst-case ONESTO del VRP01 attuale a 2k/5k di capitale in EUR (convenzione
|
||||||
|
book cash-secured E convenzione margine-deployed, granulare in spread interi ETH), banda f.
|
||||||
|
|
||||||
|
MACCHINERIA RIUSATA (non riscritta): motore DVOL di r0702_alb_structure (bs_call, _fee_frac,
|
||||||
|
run_structure, book, metrics — riproduce VRP01 esatto), options_vrp_lab (bs_put, load_series,
|
||||||
|
strike_from_delta), options_vrp_v2 (vrp_spread_weekly canonico, _ivrank, _rv30). Il codice NUOVO
|
||||||
|
e' solo (i) pricing di scenario a (S0, σ0, S1, σ1) fissati, (ii) replay su path certificato con
|
||||||
|
IV sintetica pre-DVOL, (iii) contabilita' drag/protezione.
|
||||||
|
|
||||||
|
CAVEAT (in testa, non in fondo): pricing BS FLAT su DVOL-30g usato a tenor 5-7g e 1g (term
|
||||||
|
structure ignorata: in stress il front-end esplode → il MtM avverso e' SOTTOSTIMATO); skew non
|
||||||
|
esplicito → banda f {0.6,0.8,1.0,1.3} su ogni claim; DVOL pre-2021 NON esiste → le finestre
|
||||||
|
2019-2020 (COVID incluso) sono SCENARI regression-driven, non backtest. Regola standing INVARIATA:
|
||||||
|
niente short-vol da modello in deploy — l'esito massimo e' conoscenza.
|
||||||
|
|
||||||
|
pandas 2.x: nessun DatetimeIndex.view('int64'); nessun resample '7D' (cadenze a passi d'indice).
|
||||||
|
|
||||||
|
uv run python scripts/research/r0703_vrpimp_stresslab.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path("/opt/docker/PythagorasGoal")
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
import altlib as al # noqa: E402 (fee/holdout conventions; qui usato solo per coerenza costanti)
|
||||||
|
from scripts.research.options_vrp_lab import bs_put, load_series, strike_from_delta # noqa: E402
|
||||||
|
from scripts.research.options_vrp_v2 import vrp_spread_weekly # noqa: E402
|
||||||
|
from scripts.research.r0702_alb_structure import ( # noqa: E402
|
||||||
|
bs_call, _fee_frac, run_structure, book, metrics,
|
||||||
|
)
|
||||||
|
from scripts.analysis.research_lab import load_tf # noqa: E402
|
||||||
|
|
||||||
|
DAY = 1.0 / 365.25
|
||||||
|
F_SWEEP = (0.6, 0.8, 1.0, 1.3)
|
||||||
|
START = pd.Timestamp("2019-01-01", tz="UTC")
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
ALB = dict(z=2.0, dz=1.0, tenor_d=5) # cella a-priori ALB-A (non riottimizzata)
|
||||||
|
SIZING = 0.12 # peso VRP01 nel book
|
||||||
|
GAPS = (-0.10, -0.15, -0.20, -0.30)
|
||||||
|
IVMULT = (1.5, 2.0, 3.0)
|
||||||
|
IV_FLOOR, IV_CAP = 25.0, 250.0 # punti DVOL, clamp della IV sintetica
|
||||||
|
|
||||||
|
# strutture del banco: kind -> (engine-kind, z, dz, tenor, label)
|
||||||
|
STRUCTS = {
|
||||||
|
"NAKED28": dict(tenor=7, label="naked put Δ-0.28 7g (VRP01 senza ala)"),
|
||||||
|
"VRP01": dict(tenor=7, label="VRP01 spread Δ-0.28/-0.10 7g"),
|
||||||
|
"STRANGLE": dict(tenor=5, label="short strangle z2 5g (ALB senza ali)"),
|
||||||
|
"VERT": dict(tenor=5, label="vertical put z2/+1σ 5g"),
|
||||||
|
"CONDOR": dict(tenor=5, label="iron condor z2/+1σ 5g"),
|
||||||
|
"DIAG": dict(tenor=5, label="double diagonal z2/+1σ T+1 5g"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# PRICING DI SCENARIO — un trade a (S0, σ0) → (S1, σ1), stessa matematica del
|
||||||
|
# motore r0702_alb_structure (fee per gamba 0.03% cap 12.5%, delivery 0.015%)
|
||||||
|
# ===========================================================================
|
||||||
|
def _strikes(kind: str, S0: float, sig0: float, tenor_d: int, z: float, dz: float):
|
||||||
|
T = tenor_d / 365.25
|
||||||
|
if kind in ("VRP01", "NAKED28"):
|
||||||
|
Kp_s = strike_from_delta(S0, T, sig0, -0.28)
|
||||||
|
Kp_l = strike_from_delta(S0, T, sig0, -0.10) if kind == "VRP01" else None
|
||||||
|
return Kp_s, Kp_l, None, None
|
||||||
|
m = sig0 * np.sqrt(T)
|
||||||
|
Kp_s = S0 * np.exp(-z * m)
|
||||||
|
Kp_l = S0 * np.exp(-(z + dz) * m) if kind in ("VERT", "CONDOR", "DIAG") else None
|
||||||
|
Kc_s = S0 * np.exp(+z * m) if kind in ("STRANGLE", "CONDOR", "DIAG") else None
|
||||||
|
Kc_l = S0 * np.exp(+(z + dz) * m) if kind in ("CONDOR", "DIAG") else None
|
||||||
|
return Kp_s, Kp_l, Kc_s, Kc_l
|
||||||
|
|
||||||
|
|
||||||
|
def trade_pnl(kind: str, S0: float, sig0: float, S1: float, sig1: float,
|
||||||
|
tenor_d: int, f_put: float = 1.0, f_call: float = 1.0,
|
||||||
|
z: float = 2.0, dz: float = 1.0) -> dict:
|
||||||
|
"""PnL a scadenza degli short (frazione di S0). DIAG: ali T+1 marcate BS a σ1 (vega)."""
|
||||||
|
T = tenor_d / 365.25
|
||||||
|
T_l = T + DAY if kind == "DIAG" else T
|
||||||
|
Kp_s, Kp_l, Kc_s, Kc_l = _strikes(kind, S0, sig0, tenor_d, z, dz)
|
||||||
|
ps = bs_put(S0, Kp_s, T, sig0) / S0 * f_put
|
||||||
|
pl = bs_put(S0, Kp_l, T_l, sig0) / S0 * f_put if Kp_l else 0.0
|
||||||
|
cs = bs_call(S0, Kc_s, T, sig0) / S0 * f_call if Kc_s else 0.0
|
||||||
|
cl = bs_call(S0, Kc_l, T_l, sig0) / S0 * f_call if Kc_l else 0.0
|
||||||
|
credit = (ps + cs) - (pl + cl)
|
||||||
|
short_pay = max(0.0, Kp_s - S1) / S0 + (max(0.0, S1 - Kc_s) / S0 if Kc_s else 0.0)
|
||||||
|
if kind == "DIAG":
|
||||||
|
lp = bs_put(S1, Kp_l, DAY, sig1) / S0 * f_put
|
||||||
|
lc = bs_call(S1, Kc_l, DAY, sig1) / S0 * f_call
|
||||||
|
long_val = lp + lc
|
||||||
|
exit_fee = sum(_fee_frac(v, notional_ratio=S1 / S0) for v in (lp, lc))
|
||||||
|
else:
|
||||||
|
long_val = (max(0.0, Kp_l - S1) / S0 if Kp_l else 0.0) \
|
||||||
|
+ (max(0.0, S1 - Kc_l) / S0 if Kc_l else 0.0)
|
||||||
|
exit_fee = 0.0
|
||||||
|
legs = [v for v in (ps, pl, cs, cl) if v > 0]
|
||||||
|
entry_fee = sum(_fee_frac(v) for v in legs)
|
||||||
|
deliv = _fee_frac(max(0.0, Kp_s - S1) / S0, rate=0.00015)
|
||||||
|
if Kc_s:
|
||||||
|
deliv += _fee_frac(max(0.0, S1 - Kc_s) / S0, rate=0.00015)
|
||||||
|
pnl = credit - short_pay + long_val - entry_fee - exit_fee - deliv
|
||||||
|
widths = [w for w in ((Kp_s - Kp_l) / S0 if Kp_l else np.nan,
|
||||||
|
(Kc_l - Kc_s) / S0 if Kc_l else np.nan) if np.isfinite(w)]
|
||||||
|
margin = (max(widths) - credit) if widths else np.nan # defined-risk max loss
|
||||||
|
return dict(pnl=pnl, credit=credit, margin=margin, ks_frac=Kp_s / S0)
|
||||||
|
|
||||||
|
|
||||||
|
def trade_mtm(kind: str, sig0: float, gap: float, mult: float, t_gap: int,
|
||||||
|
tenor_d: int, f_put: float = 1.0, f_call: float = 1.0,
|
||||||
|
z: float = 2.0, dz: float = 1.0) -> float:
|
||||||
|
"""Mark-to-market al giorno del gap (chiusura ipotetica, senza fee di chiusura):
|
||||||
|
tutte le gambe riprezzate BS al tempo residuo e alla IV spikeata. Frazione di S0=1."""
|
||||||
|
S0 = 1.0
|
||||||
|
T = tenor_d / 365.25
|
||||||
|
Kp_s, Kp_l, Kc_s, Kc_l = _strikes(kind, S0, sig0, tenor_d, z, dz)
|
||||||
|
ps = bs_put(S0, Kp_s, T, sig0) * f_put
|
||||||
|
pl = bs_put(S0, Kp_l, (T + DAY if kind == "DIAG" else T), sig0) * f_put if Kp_l else 0.0
|
||||||
|
cs = bs_call(S0, Kc_s, T, sig0) * f_call if Kc_s else 0.0
|
||||||
|
cl = bs_call(S0, Kc_l, (T + DAY if kind == "DIAG" else T), sig0) * f_call if Kc_l else 0.0
|
||||||
|
credit = (ps + cs) - (pl + cl)
|
||||||
|
legs = [v for v in (ps, pl, cs, cl) if v > 0]
|
||||||
|
entry_fee = sum(_fee_frac(v) for v in legs)
|
||||||
|
S1 = 1.0 + gap
|
||||||
|
sig_sp = sig0 * mult
|
||||||
|
Tr = max(tenor_d - t_gap, 0) / 365.25
|
||||||
|
Tr_l = Tr + DAY if kind == "DIAG" else Tr
|
||||||
|
sv = bs_put(S1, Kp_s, Tr, sig_sp) * f_put + (bs_call(S1, Kc_s, Tr, sig_sp) * f_call if Kc_s else 0.0)
|
||||||
|
lv = (bs_put(S1, Kp_l, Tr_l, sig_sp) * f_put if Kp_l else 0.0) \
|
||||||
|
+ (bs_call(S1, Kc_l, Tr_l, sig_sp) * f_call if Kc_l else 0.0)
|
||||||
|
return credit - (sv - lv) - entry_fee
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# DATI FULL-HISTORY (2019+) + IV SINTETICA (proxy RV30+medVRP, regressione Δdvol~ret)
|
||||||
|
# ===========================================================================
|
||||||
|
def full_history(asset: str) -> dict:
|
||||||
|
d = load_tf(asset, "1d")
|
||||||
|
s = pd.Series(d["close"].values.astype(float),
|
||||||
|
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
|
||||||
|
s = s[s.index >= START]
|
||||||
|
px = s.values
|
||||||
|
n = len(px)
|
||||||
|
lr = np.diff(np.log(px))
|
||||||
|
rv30 = np.full(n, np.nan)
|
||||||
|
for i in range(30, n):
|
||||||
|
rv30[i] = float(np.std(lr[i - 30:i]) * np.sqrt(365.25)) # come _rv30 (ddof=0)
|
||||||
|
dv = pd.read_parquet(ROOT / "data" / "raw" / f"dvol_{asset.lower()}.parquet")
|
||||||
|
dvs = pd.Series(dv["close"].values.astype(float),
|
||||||
|
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True))
|
||||||
|
dvol = dvs.reindex(s.index).values # punti %, NaN pre-2021
|
||||||
|
ok = ~np.isnan(dvol) & ~np.isnan(rv30)
|
||||||
|
medvrp = float(np.median(dvol[ok] - rv30[ok] * 100.0)) # premio mediano IV-RV, punti
|
||||||
|
proxy = rv30 * 100.0 + medvrp # IV proxy pre-DVOL, punti
|
||||||
|
hist_iv = np.where(np.isnan(dvol), proxy, dvol) # serie spliced per il RANK
|
||||||
|
return dict(idx=s.index, px=px, n=n, rv30=rv30, dvol=dvol, proxy=proxy,
|
||||||
|
hist_iv=hist_iv, medvrp=medvrp)
|
||||||
|
|
||||||
|
|
||||||
|
def fit_dvol_reg(asset: str, horizon_d: int) -> dict:
|
||||||
|
"""Regressione piecewise Δdvol_pts = a + b−·min(ret,0) + b+·max(ret,0), finestre NON
|
||||||
|
sovrapposte di horizon_d giorni sull'era DVOL (2021-03+). E' una relazione CONTEMPORANEA
|
||||||
|
(fisica del salto di IV), applicata in avanti nel replay — nessun uso del futuro."""
|
||||||
|
J = load_series(asset)
|
||||||
|
px = J["px"].values
|
||||||
|
dv = J["dvol"].values
|
||||||
|
r, d = [], []
|
||||||
|
for i in range(0, len(px) - horizon_d, horizon_d):
|
||||||
|
r.append(px[i + horizon_d] / px[i] - 1.0)
|
||||||
|
d.append(dv[i + horizon_d] - dv[i])
|
||||||
|
r = np.asarray(r); d = np.asarray(d)
|
||||||
|
X = np.column_stack([np.ones_like(r), np.minimum(r, 0.0), np.maximum(r, 0.0)])
|
||||||
|
beta, *_ = np.linalg.lstsq(X, d, rcond=None)
|
||||||
|
pred = X @ beta
|
||||||
|
r2 = 1.0 - ((d - pred) ** 2).sum() / ((d - d.mean()) ** 2).sum()
|
||||||
|
return dict(a=float(beta[0]), b_neg=float(beta[1]), b_pos=float(beta[2]),
|
||||||
|
r2=float(r2), n=len(r))
|
||||||
|
|
||||||
|
|
||||||
|
def iv_jump(iv0_pts: float, cum_ret: float, reg: dict) -> float:
|
||||||
|
"""IV sintetica: livello d'ingresso + salto regressione (intercetta esclusa: e' drift
|
||||||
|
di regime, non fisica del salto — dichiarato)."""
|
||||||
|
v = iv0_pts + reg["b_neg"] * min(cum_ret, 0.0) + reg["b_pos"] * max(cum_ret, 0.0)
|
||||||
|
return float(np.clip(v, IV_FLOOR, IV_CAP))
|
||||||
|
|
||||||
|
|
||||||
|
def worst_windows(px: np.ndarray, n_win: int = 10, ndays: int = 14) -> list[int]:
|
||||||
|
"""Indici di partenza delle n_win peggiori finestre ndays NON sovrapposte."""
|
||||||
|
r = px[ndays:] / px[:-ndays] - 1.0
|
||||||
|
order = np.argsort(r)
|
||||||
|
picked: list[int] = []
|
||||||
|
for i in order:
|
||||||
|
if all(abs(int(i) - j) >= ndays for j in picked):
|
||||||
|
picked.append(int(i))
|
||||||
|
if len(picked) >= n_win:
|
||||||
|
break
|
||||||
|
return picked
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# REPLAY di una finestra: vendita sistematica della struttura dentro [i0, i0+14)
|
||||||
|
# ===========================================================================
|
||||||
|
def replay_window(H: dict, reg: dict, i0: int, kind: str, gated: bool,
|
||||||
|
f_put: float = 1.0, f_call: float = 1.0, phase: int = 0,
|
||||||
|
ndays: int = 14) -> dict:
|
||||||
|
px, n = H["px"], H["n"]
|
||||||
|
tenor = STRUCTS[kind]["tenor"]
|
||||||
|
t = i0 + phase
|
||||||
|
entry0 = t
|
||||||
|
tot = 0.0
|
||||||
|
n_tr = n_skip = 0
|
||||||
|
credits, margins = [], []
|
||||||
|
while t < i0 + phase + ndays and t + tenor < n:
|
||||||
|
S0 = px[t]
|
||||||
|
real = not np.isnan(H["dvol"][t])
|
||||||
|
if real:
|
||||||
|
sig0_pts = H["dvol"][t]
|
||||||
|
else:
|
||||||
|
base = H["proxy"][entry0] if np.isnan(H["dvol"][entry0]) else H["dvol"][entry0]
|
||||||
|
sig0_pts = iv_jump(base, px[t] / px[entry0] - 1.0, reg)
|
||||||
|
if gated:
|
||||||
|
rv = H["rv30"][t]
|
||||||
|
hist = H["hist_iv"][30:t]
|
||||||
|
ivr = float((hist < sig0_pts).mean()) if len(hist) >= 60 else np.nan
|
||||||
|
skip = ((not np.isnan(rv) and (sig0_pts / 100.0 - rv) <= 0)
|
||||||
|
or (not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90)))
|
||||||
|
if skip:
|
||||||
|
n_skip += 1
|
||||||
|
t += tenor
|
||||||
|
continue
|
||||||
|
j = t + tenor
|
||||||
|
S1 = px[j]
|
||||||
|
if not np.isnan(H["dvol"][j]):
|
||||||
|
sig1_pts = H["dvol"][j]
|
||||||
|
else:
|
||||||
|
base = H["proxy"][entry0] if np.isnan(H["dvol"][entry0]) else H["dvol"][entry0]
|
||||||
|
sig1_pts = iv_jump(base, px[j] / px[entry0] - 1.0, reg)
|
||||||
|
r = trade_pnl(kind, S0, sig0_pts / 100.0, S1, sig1_pts / 100.0, tenor,
|
||||||
|
f_put=f_put, f_call=f_call, z=ALB["z"], dz=ALB["dz"])
|
||||||
|
tot += r["pnl"]
|
||||||
|
credits.append(r["credit"]); margins.append(r["margin"])
|
||||||
|
n_tr += 1
|
||||||
|
t += tenor
|
||||||
|
fin_m = [m for m in margins if np.isfinite(m)]
|
||||||
|
return dict(pnl=tot, n_tr=n_tr, n_skip=n_skip,
|
||||||
|
credit=float(np.mean(credits)) if credits else np.nan,
|
||||||
|
margin=float(np.mean(fin_m)) if fin_m else np.nan)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# MAIN
|
||||||
|
# ===========================================================================
|
||||||
|
def main() -> None:
|
||||||
|
print("=" * 112)
|
||||||
|
print(" R0703 VRPIMP-STRESSLAB — banco di stress di coda per la famiglia short-vol (fisica, non selezione)")
|
||||||
|
print(" ⚠️ BS flat su DVOL-30g (term structure ignorata: MtM in stress SOTTOSTIMATO); banda f obbligatoria;")
|
||||||
|
print(" finestre pre-2021 = SCENARI regression-driven (DVOL non esiste); niente hold-out: nessuna selezione.")
|
||||||
|
print("=" * 112)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (0) setup + check motore
|
||||||
|
H = {a: full_history(a) for a in ASSETS}
|
||||||
|
vrp_canon = pd.concat(
|
||||||
|
{a[0]: vrp_spread_weekly(a, defined_risk=True, f=1.0, gate_vrp=True,
|
||||||
|
gate_ivr=0.30, crash_skip=0.90) for a in ASSETS},
|
||||||
|
axis=1, join="inner").mean(axis=1)
|
||||||
|
mm = metrics(vrp_canon, 7)
|
||||||
|
print(f"\n(0) CHECK MOTORE: VRP01 canonico riprodotto — ShF {mm['sh']:.2f} / ShH {mm['sh_h']:.2f} / "
|
||||||
|
f"DD {mm['dd']*100:.1f}% (atteso ~1.09/0.59/11.8%)")
|
||||||
|
for a in ASSETS:
|
||||||
|
h = H[a]
|
||||||
|
print(f" {a}: storia 1d {h['idx'][0].date()} → {h['idx'][-1].date()} ({h['n']} g) | "
|
||||||
|
f"DVOL reale da {h['idx'][np.argmax(~np.isnan(h['dvol']))].date()} | "
|
||||||
|
f"premio mediano IV-RV (proxy pre-DVOL) = {h['medvrp']:+.1f} pt")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (1) relazione empirica DVOL-vs-ritorno
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print("(1) RELAZIONE EMPIRICA Δdvol ~ ritorno (piecewise, finestre non sovrapposte, era DVOL 2021-2026)")
|
||||||
|
print("=" * 112)
|
||||||
|
reg = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
reg[a] = dict(w=fit_dvol_reg(a, 7), d=fit_dvol_reg(a, 1))
|
||||||
|
w, d = reg[a]["w"], reg[a]["d"]
|
||||||
|
print(f" {a} weekly (7g, n={w['n']}): Δdvol = {w['a']:+.1f} {w['b_neg']:+.1f}·ret⁻ {w['b_pos']:+.1f}·ret⁺ R²={w['r2']:.2f}")
|
||||||
|
print(f" {a} daily (1g, n={d['n']}): Δdvol = {d['a']:+.1f} {d['b_neg']:+.1f}·ret⁻ {d['b_pos']:+.1f}·ret⁺ R²={d['r2']:.2f}")
|
||||||
|
print("\n Grounding dell'asse IV-spike della matrice (b): moltiplicatore implicito partendo dalla DVOL mediana")
|
||||||
|
for a in ASSETS:
|
||||||
|
J = load_series(a)
|
||||||
|
med = float(np.median(J["dvol"].values))
|
||||||
|
b = reg[a]["d"]["b_neg"]
|
||||||
|
mults = {g: (med + b * g) / med for g in GAPS}
|
||||||
|
mx1 = float((J["dvol"] / J["dvol"].shift(1)).max())
|
||||||
|
mx7 = float((J["dvol"] / J["dvol"].shift(7)).max())
|
||||||
|
print(f" {a}: DVOL mediana {med:.0f} → gap overnight " +
|
||||||
|
" ".join(f"{g*100:+.0f}%→×{m:.2f}" for g, m in mults.items()) +
|
||||||
|
f" | max storico ×{mx1:.2f} (1g), ×{mx7:.2f} (7g)")
|
||||||
|
print(" → ×1.5 e' un crash empiricamente normale, ×2 ≈ il massimo storico settimanale, ×3 = oltre-campione")
|
||||||
|
print(" (stress bound; il lineare della regressione NON estrapola fin li' — dichiarato).")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (2) replay 10 peggiori finestre
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print("(2) REPLAY — 10 peggiori finestre 14g per asset (entry al PICCO, fase 0), f=1.0")
|
||||||
|
print(" perdita per struttura in % del NOTIONAL S0 (somma dei trade nella finestra); G=gate canonico")
|
||||||
|
print("=" * 112)
|
||||||
|
kinds = list(STRUCTS.keys())
|
||||||
|
replay_rows: dict[str, list] = {a: [] for a in ASSETS}
|
||||||
|
for a in ASSETS:
|
||||||
|
h = H[a]
|
||||||
|
wins = worst_windows(h["px"])
|
||||||
|
wins.sort()
|
||||||
|
print(f"\n [{a}] {'finestra':<23} {'ret14':>7} {'IV':>5}" +
|
||||||
|
"".join(f" {k:>9} {k[:5]+'·G':>9}" for k in kinds))
|
||||||
|
for i0 in wins:
|
||||||
|
d0, d1 = h["idx"][i0].date(), h["idx"][min(i0 + 14, h["n"] - 1)].date()
|
||||||
|
ret14 = h["px"][min(i0 + 14, h["n"] - 1)] / h["px"][i0] - 1.0
|
||||||
|
ivtag = "real" if not np.isnan(h["dvol"][i0]) else "synt"
|
||||||
|
cells = {}
|
||||||
|
for k in kinds:
|
||||||
|
off = replay_window(h, reg[a]["w"], i0, k, gated=False)
|
||||||
|
on = replay_window(h, reg[a]["w"], i0, k, gated=True)
|
||||||
|
cells[k] = (off, on)
|
||||||
|
replay_rows[a].append(dict(i0=i0, d0=d0, d1=d1, ret=ret14, iv=ivtag, cells=cells))
|
||||||
|
print(f" {str(d0)}→{str(d1)} {ret14*100:>+6.1f}% {ivtag:>5}" +
|
||||||
|
"".join(f" {cells[k][0]['pnl']*100:>+8.2f}% {cells[k][1]['pnl']*100:>+8.2f}%"
|
||||||
|
for k in kinds))
|
||||||
|
tot_off = {k: sum(r["cells"][k][0]["pnl"] for r in replay_rows[a]) for k in kinds}
|
||||||
|
tot_on = {k: sum(r["cells"][k][1]["pnl"] for r in replay_rows[a]) for k in kinds}
|
||||||
|
ntr = {k: sum(r["cells"][k][1]["n_tr"] for r in replay_rows[a]) for k in kinds}
|
||||||
|
nsk = {k: sum(r["cells"][k][1]["n_skip"] for r in replay_rows[a]) for k in kinds}
|
||||||
|
print(f" {'SOMMA 10 finestre':<38}" +
|
||||||
|
"".join(f" {tot_off[k]*100:>+8.2f}% {tot_on[k]*100:>+8.2f}%" for k in kinds))
|
||||||
|
print(f" {'gate: trade eseguiti / saltati':<38}" +
|
||||||
|
"".join(f" {'':>9} {f'{ntr[k]}/{nsk[k]}':>9}" for k in kinds))
|
||||||
|
|
||||||
|
# validazione IV sintetica sulle finestre DVOL-era
|
||||||
|
print("\n VALIDAZIONE IV SINTETICA (finestre DVOL-era: regressione vs DVOL reale ai punti di trade):")
|
||||||
|
for a in ASSETS:
|
||||||
|
h = H[a]
|
||||||
|
errs = []
|
||||||
|
for r in replay_rows[a]:
|
||||||
|
if r["iv"] != "real":
|
||||||
|
continue
|
||||||
|
i0 = r["i0"]
|
||||||
|
base = h["dvol"][i0]
|
||||||
|
for tt in range(i0, min(i0 + 14, h["n"] - 1)):
|
||||||
|
if np.isnan(h["dvol"][tt]):
|
||||||
|
continue
|
||||||
|
synth = iv_jump(base, h["px"][tt] / h["px"][i0] - 1.0, reg[a]["w"])
|
||||||
|
errs.append(synth - h["dvol"][tt])
|
||||||
|
if errs:
|
||||||
|
e = np.asarray(errs)
|
||||||
|
print(f" {a}: bias {e.mean():+.1f} pt, MAE {np.abs(e).mean():.1f} pt su {len(e)} giorni-crash "
|
||||||
|
f"(la regressione {'SOTTOSTIMA' if e.mean() < 0 else 'sovrastima'} lo spike reale)")
|
||||||
|
|
||||||
|
# banda d'ancora (fase d'ingresso) + banda f, sul totale delle 10 finestre gate-ON
|
||||||
|
print("\n BANDA D'ANCORA (fase d'ingresso 0..tenor-1) e BANDA f — somma 10 finestre, gate ON, per asset:")
|
||||||
|
for a in ASSETS:
|
||||||
|
h = H[a]
|
||||||
|
wins = [r["i0"] for r in replay_rows[a]]
|
||||||
|
for k in ("VRP01", "CONDOR", "DIAG"):
|
||||||
|
tenor = STRUCTS[k]["tenor"]
|
||||||
|
tots = []
|
||||||
|
for p in range(tenor):
|
||||||
|
tots.append(sum(replay_window(h, reg[a]["w"], i0, k, gated=True, phase=p)["pnl"]
|
||||||
|
for i0 in wins))
|
||||||
|
tots = np.asarray(tots) * 100
|
||||||
|
fband = {f: sum(replay_window(h, reg[a]["w"], i0, k, gated=True,
|
||||||
|
f_put=f, f_call=f)["pnl"] for i0 in wins) * 100
|
||||||
|
for f in F_SWEEP}
|
||||||
|
print(f" {a} {k:<7}: ancora med {np.median(tots):+.2f}% [{tots.min():+.2f}, {tots.max():+.2f}] "
|
||||||
|
f"(fase0 {tots[0]:+.2f}%) | f-band " +
|
||||||
|
" ".join(f"f{f}:{v:+.2f}%" for f, v in fband.items()))
|
||||||
|
print(" NB: fase 0 = venduto ESATTAMENTE al picco (worst-case timing). Le altre fasi entrano a crash")
|
||||||
|
print(" iniziato: il gate crash-skip (ivr>0.90) e la IV piu' alta (strike piu' larghi) attutiscono.")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (3) matrice sintetica
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print("(3) MATRICE SINTETICA — gap × IV-spike × timing; entry a DVOL mediana; f=1.0 (banda f in (5))")
|
||||||
|
print(" unita': ×credito-medio-storico-gated | %acct = % del conto a sizing 12% margine-deployed")
|
||||||
|
print(" (nudi: margine non definito → %acct in convenzione cash-secured sul collaterale, dichiarato)")
|
||||||
|
print("=" * 112)
|
||||||
|
# credito medio storico (gated, f=1.0) per normalizzare
|
||||||
|
print("\n CREDITO MEDIO storico (gated canonico, f=1.0, frazione di S0) e margine tipico:")
|
||||||
|
avg_credit: dict[str, dict[str, float]] = {a: {} for a in ASSETS}
|
||||||
|
avg_margin: dict[str, dict[str, float]] = {a: {} for a in ASSETS}
|
||||||
|
for a in ASSETS:
|
||||||
|
h = H[a]
|
||||||
|
for k in kinds:
|
||||||
|
tenor = STRUCTS[k]["tenor"]
|
||||||
|
creds, margs = [], []
|
||||||
|
t = 60
|
||||||
|
while t + tenor < h["n"]:
|
||||||
|
if np.isnan(h["dvol"][t]):
|
||||||
|
t += tenor
|
||||||
|
continue
|
||||||
|
sig0 = h["dvol"][t]
|
||||||
|
rv = h["rv30"][t]
|
||||||
|
hist = h["hist_iv"][30:t]
|
||||||
|
ivr = float((hist < sig0).mean()) if len(hist) >= 60 else np.nan
|
||||||
|
skip = ((not np.isnan(rv) and (sig0 / 100.0 - rv) <= 0)
|
||||||
|
or (not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90)))
|
||||||
|
if not skip:
|
||||||
|
r = trade_pnl(k, h["px"][t], sig0 / 100.0, h["px"][t], sig0 / 100.0, tenor,
|
||||||
|
z=ALB["z"], dz=ALB["dz"])
|
||||||
|
creds.append(r["credit"]); margs.append(r["margin"])
|
||||||
|
t += tenor
|
||||||
|
avg_credit[a][k] = float(np.mean(creds))
|
||||||
|
fin_m = [m for m in margs if np.isfinite(m)]
|
||||||
|
avg_margin[a][k] = float(np.mean(fin_m)) if fin_m else np.nan
|
||||||
|
print(f" {a}: " + " | ".join(
|
||||||
|
f"{k} cr {avg_credit[a][k]*1e4:.0f}bps"
|
||||||
|
+ (f" mg {avg_margin[a][k]*100:.1f}%" if np.isfinite(avg_margin[a][k]) else " mg n/a")
|
||||||
|
for k in kinds))
|
||||||
|
|
||||||
|
matrix: dict[tuple, dict] = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
J = load_series(a)
|
||||||
|
med_iv = float(np.median(J["dvol"].values)) / 100.0
|
||||||
|
primary = a == "ETH"
|
||||||
|
if primary:
|
||||||
|
print(f"\n [{a}] PERDITA A SCADENZA (S resta al livello del gap; IV-spike entra solo nel mark T+1 del DIAG)")
|
||||||
|
print(f" {'gap':>5} {'IVx':>4}" + "".join(f" | {k:>16}" for k in kinds))
|
||||||
|
for g in GAPS:
|
||||||
|
for m in IVMULT:
|
||||||
|
row = {}
|
||||||
|
for k in kinds:
|
||||||
|
tenor = STRUCTS[k]["tenor"]
|
||||||
|
r = trade_pnl(k, 1.0, med_iv, 1.0 + g, med_iv * m, tenor,
|
||||||
|
z=ALB["z"], dz=ALB["dz"])
|
||||||
|
mg = avg_margin[a][k]
|
||||||
|
acct = (r["pnl"] / mg if np.isfinite(mg) else r["pnl"] / r["ks_frac"]) * SIZING
|
||||||
|
row[k] = dict(pnl=r["pnl"], xc=r["pnl"] / avg_credit[a][k], acct=acct)
|
||||||
|
matrix[(a, g, m)] = row
|
||||||
|
if primary:
|
||||||
|
print(f" {g*100:>+4.0f}% {m:>4.1f}" + "".join(
|
||||||
|
f" | {row[k]['xc']:>+6.1f}c {row[k]['acct']*100:>+6.2f}%" for k in kinds))
|
||||||
|
if not primary:
|
||||||
|
print(f"\n [{a}] (compatto — solo gap -30%): " + " | ".join(
|
||||||
|
f"{k} {matrix[(a, -0.30, 2.0)][k]['xc']:+.1f}c/{matrix[(a, -0.30, 2.0)][k]['acct']*100:+.2f}%acct"
|
||||||
|
for k in kinds))
|
||||||
|
print("\n Lettura: 'c' = multipli del credito medio; %acct = perdita sul CONTO a sizing 12%.")
|
||||||
|
print(" A scadenza il timing non cambia S1: la dimensione timing vive nel MtM qui sotto.")
|
||||||
|
|
||||||
|
print("\n MARK-TO-MARKET AL GIORNO DEL GAP (stress di margine; ETH, IV mediana, f=1.0)")
|
||||||
|
print(f" {'gap':>5} {'IVx':>4} {'timing':>10}" + "".join(f" | {k:>14}" for k in kinds))
|
||||||
|
J = load_series("ETH")
|
||||||
|
med_iv_e = float(np.median(J["dvol"].values)) / 100.0
|
||||||
|
for g in GAPS:
|
||||||
|
for m in IVMULT:
|
||||||
|
for lbl, tg in (("overnight", 1), ("intra-week", None)):
|
||||||
|
cells = []
|
||||||
|
for k in kinds:
|
||||||
|
tenor = STRUCTS[k]["tenor"]
|
||||||
|
t_gap = tg if tg is not None else max(tenor - 2, 1)
|
||||||
|
v = trade_mtm(k, med_iv_e, g, m, t_gap, tenor, z=ALB["z"], dz=ALB["dz"])
|
||||||
|
cells.append(v / avg_credit["ETH"][k])
|
||||||
|
if m == 2.0 or g == -0.30: # stampa compatta: x2 sempre, -30% tutte
|
||||||
|
print(f" {g*100:>+4.0f}% {m:>4.1f} {lbl:>10}" +
|
||||||
|
"".join(f" | {c:>+12.1f}c" for c in cells))
|
||||||
|
print(" Lettura: MtM overnight ≪ MtM intra-week (piu' tempo residuo + vega). Per il defined-risk il")
|
||||||
|
print(" MtM NON e' la perdita realizzata (a scadenza vale la tabella sopra) ma e' il margine richiesto")
|
||||||
|
print(" per TENERE la posizione — e con BS-flat e' pure sottostimato (front-end IV esplode).")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (4) valore dell'ala
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print("(4) VALORE DELL'ALA — drag storico vs protezione di coda + NULL DEL DE-LEVERING")
|
||||||
|
print("=" * 112)
|
||||||
|
# coppie (base, feature): l'ala far-OTM di VRP01; l'ala far-OTM dello strangle; l'ala T+1; l'ala vicina
|
||||||
|
print("\n DRAG STORICO (gated canonico, f=1.0, book 50/50 BTC+ETH; rendimenti per-periodo):")
|
||||||
|
hist: dict[str, pd.Series] = {}
|
||||||
|
hist["NAKED28"] = pd.concat(
|
||||||
|
{a[0]: vrp_spread_weekly(a, defined_risk=False, f=1.0, gate_vrp=True,
|
||||||
|
gate_ivr=0.30, crash_skip=0.90) for a in ASSETS},
|
||||||
|
axis=1, join="inner").mean(axis=1)
|
||||||
|
hist["VRP01"] = vrp_canon
|
||||||
|
hist["STRANGLE"] = book("condor", z=ALB["z"], dz=8.0, tenor_d=ALB["tenor_d"], gated=True)
|
||||||
|
hist["VERT"] = book("vert", z=ALB["z"], dz=ALB["dz"], tenor_d=ALB["tenor_d"], gated=True)
|
||||||
|
hist["CONDOR"] = book("condor", z=ALB["z"], dz=ALB["dz"], tenor_d=ALB["tenor_d"], gated=True)
|
||||||
|
hist["DIAG"] = book("diag", z=ALB["z"], dz=ALB["dz"], tenor_d=ALB["tenor_d"], gated=True)
|
||||||
|
hist["CONDOR05"] = book("condor", z=ALB["z"], dz=0.5, tenor_d=ALB["tenor_d"], gated=True)
|
||||||
|
|
||||||
|
def annual_bps(s: pd.Series, tenor: int) -> float:
|
||||||
|
return float(s.mean() * (365.25 / tenor) * 1e4)
|
||||||
|
|
||||||
|
for k, tn in (("NAKED28", 7), ("VRP01", 7), ("STRANGLE", 5), ("VERT", 5),
|
||||||
|
("CONDOR", 5), ("DIAG", 5), ("CONDOR05", 5)):
|
||||||
|
m = metrics(hist[k], tn)
|
||||||
|
print(f" {k:<9} Sh {m['sh']:>5.2f} | media aritm {annual_bps(hist[k], tn):>+7.0f} bps/anno | "
|
||||||
|
f"worst {m['worst']*100:>+6.2f}% | DD {m['dd']*100:>5.1f}%")
|
||||||
|
|
||||||
|
features = [
|
||||||
|
("ala far-OTM VRP01 (Δ-0.10)", "NAKED28", "VRP01", 7),
|
||||||
|
("ala far-OTM strangle (z+1σ)", "STRANGLE", "CONDOR", 5),
|
||||||
|
("ala T+1 (diag vs condor)", "CONDOR", "DIAG", 5),
|
||||||
|
("ala VICINA (dz 0.5 vs 1.0)", "CONDOR", "CONDOR05", 5),
|
||||||
|
]
|
||||||
|
# kind/dz effettivi per il pricing di cella (CONDOR05 = condor con ala a +0.5σ)
|
||||||
|
featmap = {"NAKED28": ("NAKED28", ALB["dz"]), "VRP01": ("VRP01", ALB["dz"]),
|
||||||
|
"STRANGLE": ("STRANGLE", ALB["dz"]), "VERT": ("VERT", ALB["dz"]),
|
||||||
|
"CONDOR": ("CONDOR", ALB["dz"]), "DIAG": ("DIAG", ALB["dz"]),
|
||||||
|
"CONDOR05": ("CONDOR", 0.5)}
|
||||||
|
|
||||||
|
def eth_cell(tag: str, g: float, m: float = 2.0) -> float:
|
||||||
|
kind, dz = featmap[tag]
|
||||||
|
tenor = STRUCTS[kind]["tenor"]
|
||||||
|
return trade_pnl(kind, 1.0, med_iv_e, 1.0 + g, med_iv_e * m, tenor,
|
||||||
|
z=ALB["z"], dz=dz)["pnl"]
|
||||||
|
print("\n DECOMPOSIZIONE calm-drag vs tail-benefit (per-trade, date comuni, f=1.0):")
|
||||||
|
feat_stats = {}
|
||||||
|
for name, b, f_, tn in features:
|
||||||
|
JJ = pd.concat({"b": hist[b], "f": hist[f_]}, axis=1, join="inner").dropna()
|
||||||
|
act = JJ[(JJ["b"] != 0) | (JJ["f"] != 0)]
|
||||||
|
diff = act["f"] - act["b"]
|
||||||
|
q05 = act["b"].quantile(0.05)
|
||||||
|
tail = diff[act["b"] <= q05]
|
||||||
|
calm = diff[act["b"] > q05]
|
||||||
|
tpy = len(act) / (len(JJ) * tn / 365.25)
|
||||||
|
drag_yr = annual_bps(hist[f_], tn) - annual_bps(hist[b], tn)
|
||||||
|
feat_stats[name] = dict(drag_yr=drag_yr, calm=float(calm.mean() * 1e4),
|
||||||
|
tail=float(tail.mean() * 1e4), base=b, feat=f_, tn=tn)
|
||||||
|
print(f" {name:<28}: drag netto {drag_yr:>+6.0f} bps/anno | calm {calm.mean()*1e4:>+6.1f} bps/trade "
|
||||||
|
f"| tail(5% peggiori) {tail.mean()*1e4:>+7.1f} bps/trade | ~{tpy:.0f} trade attivi/anno")
|
||||||
|
|
||||||
|
print("\n PROTEZIONE COMPRATA NELLA MATRICE (ETH, a scadenza, bps di S0 risparmiati; celle -15/-20/-30, IVx2):")
|
||||||
|
print(f" {'feature':<28}" + "".join(f" {f'gap{g*100:+.0f}%':>10}" for g in (-0.15, -0.20, -0.30)) +
|
||||||
|
f" {'drag/anno':>10} {'protez/drag @-30':>17}")
|
||||||
|
for name, b, f_, tn in features:
|
||||||
|
prot = {}
|
||||||
|
for g in (-0.15, -0.20, -0.30):
|
||||||
|
lb = eth_cell(b, g)
|
||||||
|
lf = eth_cell(f_, g)
|
||||||
|
prot[g] = (lf - lb) * 1e4 # bps di S0 risparmiati (>0 = l'ala protegge)
|
||||||
|
drg = feat_stats[name]["drag_yr"]
|
||||||
|
ratio = prot[-0.30] / abs(drg) if drg < 0 else float("inf")
|
||||||
|
feat_stats[name]["prot"] = prot
|
||||||
|
feat_stats[name]["ratio"] = ratio
|
||||||
|
rtag = f"{ratio:>8.1f}x" if np.isfinite(ratio) else " GRATIS"
|
||||||
|
print(f" {name:<28}" + "".join(f" {prot[g]:>+9.0f}" for g in (-0.15, -0.20, -0.30)) +
|
||||||
|
f" {drg:>+9.0f} {rtag:>17}")
|
||||||
|
|
||||||
|
print("\n NULL DEL DE-LEVERING (regola standing #3), DUE LETTURE ONESTE:")
|
||||||
|
print(" (i) RITORNO a pari perdita-di-cella: de-lever della base a k = L_ala/L_base costa (1-k)·ritorno;")
|
||||||
|
print(" l'ala vale se il suo drag e' minore. (ii) LETTERA della regola (Sharpe): il de-levering")
|
||||||
|
print(" PRESERVA lo Sharpe della base — se Sh(base) >= Sh(ala), il de-lever vince nominalmente.")
|
||||||
|
print(f" {'feature':<28} {'k(-30%)':>8} {'k(-20%)':>8} {'delever':>9} {'ala':>7} "
|
||||||
|
f"{'Sh base':>8} {'Sh ala':>7} {'verdetto ritorno':>17} {'verdetto Sharpe':>16}")
|
||||||
|
for name, b, f_, tn in features:
|
||||||
|
k30 = eth_cell(f_, -0.30) / eth_cell(b, -0.30)
|
||||||
|
k20 = eth_cell(f_, -0.20) / eth_cell(b, -0.20)
|
||||||
|
k = max(k30, k20) # de-lever deve coprire la cella PEGGIO protetta
|
||||||
|
base_ret = annual_bps(hist[b], tn)
|
||||||
|
cost_del = (1.0 - min(k30, 1.0)) * base_ret
|
||||||
|
cost_f = -feat_stats[name]["drag_yr"] # >0 = l'ala costa
|
||||||
|
sh_b = metrics(hist[b], tn)["sh"]
|
||||||
|
sh_f = metrics(hist[f_], tn)["sh"]
|
||||||
|
if cost_f <= 0:
|
||||||
|
v_ret = "ALA GRATIS"
|
||||||
|
elif cost_f < cost_del:
|
||||||
|
v_ret = "ala vince"
|
||||||
|
else:
|
||||||
|
v_ret = "delever vince"
|
||||||
|
v_sh = "ala vince" if sh_f > sh_b else "delever vince"
|
||||||
|
feat_stats[name]["survives_ret"] = (cost_f <= 0) or (cost_f < cost_del)
|
||||||
|
feat_stats[name]["survives_sh"] = sh_f > sh_b
|
||||||
|
print(f" {name:<28} {k30:>8.2f} {k20:>8.2f} {cost_del:>+8.0f}b {cost_f:>+6.0f}b "
|
||||||
|
f"{sh_b:>8.2f} {sh_f:>7.2f} {v_ret:>17} {v_sh:>16}")
|
||||||
|
print(" ⚠️ CAVEAT sulla lettura Sharpe: lo Sharpe in-sample della struttura SENZA ala e' tail-uncapped")
|
||||||
|
print(" — e' alto proprio perche' la cella -30% overnight non e' mai occorsa piena nel campione 2021-26")
|
||||||
|
print(" (il punto cieco CC01 'Sharpe implausibile'). La lettura (i) a pari perdita-di-cella e' quella")
|
||||||
|
print(" che prezza le celle FUORI campione; la (ii) e' la lettera della regola. Riportate entrambe.")
|
||||||
|
print(" NB a favore dell'ala (oltre il numero): il defined-risk mette un LIMITE RIGIDO oltre la cella")
|
||||||
|
print(" (-50%, -70%...) che il de-levering non mette mai; e a parita' di margine Deribit l'ala LIBERA")
|
||||||
|
print(" capitale. NB contro: il drag e' misurato IN-SAMPLE su un'era (2021-26) che le code le ha viste")
|
||||||
|
print(" (LUNA/FTX) — in un'era senza code il drag sale e il tail-benefit non si incassa.")
|
||||||
|
|
||||||
|
# banda f sul verdetto ala (chiave: drag e protezione a f 0.6/1.3)
|
||||||
|
print("\n BANDA f sul valore dell'ala T+1 e dell'ala far-OTM (drag bps/anno | protezione bps @-30%×2):")
|
||||||
|
for f in F_SWEEP:
|
||||||
|
nk = pd.concat({a[0]: vrp_spread_weekly(a, defined_risk=False, f=f, gate_vrp=True,
|
||||||
|
gate_ivr=0.30, crash_skip=0.90) for a in ASSETS},
|
||||||
|
axis=1, join="inner").mean(axis=1)
|
||||||
|
sp = pd.concat({a[0]: vrp_spread_weekly(a, defined_risk=True, f=f, gate_vrp=True,
|
||||||
|
gate_ivr=0.30, crash_skip=0.90) for a in ASSETS},
|
||||||
|
axis=1, join="inner").mean(axis=1)
|
||||||
|
co = book("condor", z=ALB["z"], dz=ALB["dz"], tenor_d=ALB["tenor_d"], gated=True, f_put=f, f_call=f)
|
||||||
|
di = book("diag", z=ALB["z"], dz=ALB["dz"], tenor_d=ALB["tenor_d"], gated=True, f_put=f, f_call=f)
|
||||||
|
drag_v = annual_bps(sp, 7) - annual_bps(nk, 7)
|
||||||
|
drag_d = annual_bps(di, 5) - annual_bps(co, 5)
|
||||||
|
pv = (trade_pnl("VRP01", 1.0, med_iv_e, 0.70, med_iv_e * 2, 7, f_put=f)["pnl"]
|
||||||
|
- trade_pnl("NAKED28", 1.0, med_iv_e, 0.70, med_iv_e * 2, 7, f_put=f)["pnl"]) * 1e4
|
||||||
|
pdg = (trade_pnl("DIAG", 1.0, med_iv_e, 0.70, med_iv_e * 2, 5, f_put=f, f_call=f)["pnl"]
|
||||||
|
- trade_pnl("CONDOR", 1.0, med_iv_e, 0.70, med_iv_e * 2, 5, f_put=f, f_call=f)["pnl"]) * 1e4
|
||||||
|
print(f" f={f}: ala VRP01 drag {drag_v:+.0f} prot {pv:+.0f} | ala T+1 drag {drag_d:+.0f} prot {pdg:+.0f}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (5) worst-case EUR a 2k/5k
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print("(5) WORST-CASE ONESTO del VRP01 attuale a 2k / 5k (EUR; convenzione diario $≈€)")
|
||||||
|
print("=" * 112)
|
||||||
|
# margine spread ETH a IV mediana 1y (granularita' reale: spread interi da 1 ETH)
|
||||||
|
dfe = al.get("ETH", "1d")
|
||||||
|
S_eth = float(dfe["close"].iloc[-1])
|
||||||
|
dv_e = pd.read_parquet(ROOT / "data" / "raw" / "dvol_eth.parquet")
|
||||||
|
iv1y = float(dv_e["close"].iloc[-365:].median()) / 100.0
|
||||||
|
T7 = 7.0 / 365.25
|
||||||
|
Ks = strike_from_delta(S_eth, T7, iv1y, -0.28)
|
||||||
|
Kl = strike_from_delta(S_eth, T7, iv1y, -0.10)
|
||||||
|
fband_credit = {}
|
||||||
|
for f in F_SWEEP:
|
||||||
|
cr = (bs_put(S_eth, Ks, T7, iv1y) - bs_put(S_eth, Kl, T7, iv1y)) * f
|
||||||
|
fee2 = (min(0.0003 * S_eth, 0.125 * bs_put(S_eth, Ks, T7, iv1y) * f)
|
||||||
|
+ min(0.0003 * S_eth, 0.125 * bs_put(S_eth, Kl, T7, iv1y) * f))
|
||||||
|
fband_credit[f] = dict(credit=cr, margin=(Ks - Kl) - cr, fee=fee2)
|
||||||
|
print(f"\n Spread ETH 1x (spot ${S_eth:,.0f}, DVOL mediana 1y {iv1y*100:.0f}%): strike {Ks:,.0f}/{Kl:,.0f}")
|
||||||
|
for f in F_SWEEP:
|
||||||
|
d = fband_credit[f]
|
||||||
|
print(f" f={f}: credito ${d['credit']:.2f} | margine/max-loss ${d['margin']:.2f} | fee 2 gambe ${d['fee']:.2f}")
|
||||||
|
worst_repl = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
worst_repl[a] = min(r["cells"]["VRP01"][1]["pnl"] for r in replay_rows[a]) # gate ON
|
||||||
|
wr_book = float(np.mean([worst_repl[a] for a in ASSETS]))
|
||||||
|
print(f"\n {'capitale':>9} {'sleeve12%':>10} {'n spread ETH':>13} "
|
||||||
|
f"{'WC fisico (margine-deployed)':>36} {'WC cella -30% (book conv.)':>27} {'peggior 14g replay (gate)':>26}")
|
||||||
|
for C in (2000.0, 5000.0):
|
||||||
|
sleeve = SIZING * C
|
||||||
|
rows_f = []
|
||||||
|
for f in F_SWEEP:
|
||||||
|
d = fband_credit[f]
|
||||||
|
n_sp = int(sleeve // d["margin"])
|
||||||
|
wc_phys = n_sp * (d["margin"] + d["fee"])
|
||||||
|
rows_f.append((f, n_sp, wc_phys))
|
||||||
|
# convenzione book (cash-secured su Ks, come lo sleeve compone nel portafoglio)
|
||||||
|
r30 = trade_pnl("VRP01", 1.0, iv1y, 0.70, iv1y * 2, 7)
|
||||||
|
wc_book_eur = r30["pnl"] / r30["ks_frac"] * SIZING * C
|
||||||
|
wr_eur = wr_book / r30["ks_frac"] * SIZING * C # replay pnl frac S0 → su collaterale Ks
|
||||||
|
base = next(x for x in rows_f if x[0] == 1.0)
|
||||||
|
print(f" {C:>9.0f} {sleeve:>10.0f} {base[1]:>13d} "
|
||||||
|
f"{'-€%.0f' % base[2]:>13} [f-band -€{min(x[2] for x in rows_f):.0f}..-€{max(x[2] for x in rows_f):.0f}]"
|
||||||
|
f" {'-€%.1f' % abs(wc_book_eur):>20} {'-€%.1f' % abs(wr_eur):>20}")
|
||||||
|
print("\n Lettura (i tre numeri sono TRE domande diverse):")
|
||||||
|
print(" - WC FISICO margine-deployed: se il 12% del conto e' TUTTO margine di spread e il gap -30% li")
|
||||||
|
print(" manda full-ITM, si perde il margine intero + fee = ~il 12% del conto. E' il bound rigido del")
|
||||||
|
print(" defined-risk: a 2k ≈ -€240, a 5k ≈ -€600. Nessun modello puo' peggiorarlo (skew incluso).")
|
||||||
|
print(" - WC alla cella -30% in CONVENZIONE BOOK (cash-secured su Ks, come lo sleeve compone nel")
|
||||||
|
print(" portafoglio): la perdita che il book REGISTRAerebbe quella settimana a sizing 12%.")
|
||||||
|
print(" - Peggior 14g del replay storico (gate ON): il worst-case EMPIRICO osservato/simulato, che il")
|
||||||
|
print(" gate canonico attenua saltando i re-entry a IV-rank>0.90.")
|
||||||
|
|
||||||
|
print("\n" + "=" * 112)
|
||||||
|
print(" CONCLUSIONI (fisica, non selezione — nessun nuovo sleeve, regola standing invariata)")
|
||||||
|
print("=" * 112)
|
||||||
|
for name, st in feat_stats.items():
|
||||||
|
r = "ritorno:OK" if st.get("survives_ret") else "ritorno:NO"
|
||||||
|
s = "sharpe:OK" if st.get("survives_sh") else "sharpe:NO"
|
||||||
|
print(f" - {name:<28}: drag {st['drag_yr']:+.0f} bps/anno, protezione @-30% {st['prot'][-0.30]:+.0f} bps "
|
||||||
|
f"→ null de-levering [{r}, {s}]")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"""R0703 VRPIMP-STRUCTGRID — griglia ONESTA di struttura short-vol vs VRP01 canonico.
|
||||||
|
|
||||||
|
FILONE 1 (2026-07-03). L'audit ALB-A (r0702_alb_structure) ha testato UNA cella a priori
|
||||||
|
(z=2.0σ, ali +1σ, tenor 5g). Domanda: nella griglia onesta
|
||||||
|
distanza z ∈ {1.0, 1.5, 2.0, 2.5}σ × tenor ∈ {3, 5, 7, 10}g × ali dz ∈ {+0.5, +1.0, +2.0}σ
|
||||||
|
× lato {put-only, call-only, entrambi} × struttura {vertical, condor, diagonal T+1}
|
||||||
|
esiste una cella che batte VRP01 canonico (put credit spread settimanale Δ-0.28/-0.10, gate
|
||||||
|
IV-rank) in modo che SOPRAVVIVE a (a) selezione IN-SAMPLE-ONLY, (b) deflated-Sharpe sul numero
|
||||||
|
TOTALE di celle, (c) banda f, (d) plateau dei vicini, (e) null del de-levering?
|
||||||
|
|
||||||
|
MAPPATURA lato×struttura (9 combo nominali -> 6 DISTINTE, per non contare doppio):
|
||||||
|
vertical×put = VERT-PUT vertical×call = VERT-CALL vertical×both ≡ CONDOR
|
||||||
|
condor×put ≡ VERT-PUT condor×call ≡ VERT-CALL condor×both = CONDOR
|
||||||
|
diagonal×{put,call,both} = DIAG-PUT / DIAG-CALL / DIAG-2 (Albimarini)
|
||||||
|
Totale celle = 4z × 4tenor × 3dz × 6 strutture = 288 (tutte contate nel deflated-Sharpe).
|
||||||
|
|
||||||
|
REGOLE RISPETTATE:
|
||||||
|
- Gate IV-rank CANONICO sempre attivo, NON riottimizzato (vrp>0 AND 0.30<=ivr<=0.90,
|
||||||
|
identico a VRP01 combo e a r0702_alb_structure gated=True).
|
||||||
|
- Selezione cella SOLO su Sharpe PRE-holdout (2021-03 .. 2024-12); hold-out 2025-26 mai
|
||||||
|
guardato per scegliere. DSR (Bailey & LdP, altlib.deflated_sharpe) su tutte le 288 celle.
|
||||||
|
- Pattern "0-perdite = Sharpe implausibile" (CC01, ALB-A): le celle con < MIN_LOSS_IS
|
||||||
|
perdite attive in-sample sono ESCLUSE DALLA SELEZIONE E DICHIARATE (coda mai campionata
|
||||||
|
-> Sharpe non informativo). Il criterio usa SOLO dati in-sample (niente peek).
|
||||||
|
- Banda f {0.6, 0.8, 1.0, 1.3} + scenario skew (f_put=1.3 / f_call=0.7) su ogni claim
|
||||||
|
(pricing BS FLAT su DVOL-30g: il deep-OTM e' banda, non stima puntuale).
|
||||||
|
- Banda d'ancora (regola 2026-07-02): la cella scelta si riporta su TUTTE le fasi di
|
||||||
|
partenza 0..tenor-1 (il costrutto e' ancorato alla cadenza tenor_d).
|
||||||
|
- NULL DEL DE-LEVERING (lezione TP01×DVOL): ogni vantaggio di DD della cella si confronta
|
||||||
|
con VRP01 semplicemente scalato allo stesso DD (Sharpe invariante per scala).
|
||||||
|
- Confronto apples-to-apples: oltre a VRP01 canonico (motore options_vrp_v2, capitale=K_short,
|
||||||
|
fee=12.5% del premio) si riporta il BRIDGE (stessa struttura di VRP01 nel motore nuovo:
|
||||||
|
vert-put z=0.583 dz=0.699 7g, capitale=S0, fee per-gamba) — battere solo VRP01 canonico
|
||||||
|
ma non il bridge = artefatto di convenzione fee/capitale, non struttura.
|
||||||
|
|
||||||
|
MACCHINERIA RIUSATA (non riscritta): motore di r0702_alb_structure (bs_put/bs_call/_fee_frac/
|
||||||
|
metrics/to_daily_lumped, VALIDATO vs run_structure cella-per-cella in testa al run),
|
||||||
|
options_vrp_v2.vrp_spread_weekly (VRP01 canonico esatto), altlib.deflated_sharpe/marginal_vs_tp01.
|
||||||
|
|
||||||
|
Regola standing: NIENTE short-vol da modello in deploy — l'esito massimo e' conoscenza/LEAD.
|
||||||
|
|
||||||
|
uv run python scripts/research/r0703_vrpimp_structgrid.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt"))
|
||||||
|
import numpy as np, pandas as pd # noqa: E402
|
||||||
|
from scipy.stats import norm # noqa: E402
|
||||||
|
|
||||||
|
from scripts.research.options_vrp_lab import bs_put, load_series, per_year # noqa: E402
|
||||||
|
from scripts.research.options_vrp_v2 import vrp_spread_weekly, _ivrank, _rv30 # noqa: E402
|
||||||
|
from scripts.research.r0702_alb_structure import ( # noqa: E402
|
||||||
|
bs_call, _fee_frac, run_structure, metrics, to_daily_lumped, DAY, HOLDOUT)
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
|
||||||
|
SCRATCH = Path("/tmp/claude-1001/-opt-docker-PythagorasGoal/e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad")
|
||||||
|
|
||||||
|
F_SWEEP = (0.6, 0.8, 1.0, 1.3)
|
||||||
|
F_SKEW = dict(f_put=1.3, f_call=0.7) # skew crypto-shaped: put ricche, call povere
|
||||||
|
Z_GRID = (1.0, 1.5, 2.0, 2.5)
|
||||||
|
TENOR_GRID = (3, 5, 7, 10)
|
||||||
|
DZ_GRID = (0.5, 1.0, 2.0)
|
||||||
|
STRUCTS = (("same", "put", "VERT-PUT"), ("same", "call", "VERT-CALL"), ("same", "both", "CONDOR"),
|
||||||
|
("diag", "put", "DIAG-PUT"), ("diag", "call", "DIAG-CALL"), ("diag", "both", "DIAG-2"))
|
||||||
|
MIN_ACT_IS = 30 # trade attivi minimi in-sample per credere allo Sharpe IS
|
||||||
|
MIN_LOSS_IS = 3 # perdite attive minime IS: <3 = coda mai campionata (CC01/ALB-A) -> ESCLUSA
|
||||||
|
CUTS = ("2022-01-01", "2023-01-01", "2024-01-01", "2025-01-01") # persistenza multi-cut
|
||||||
|
|
||||||
|
# bridge = la struttura ESATTA di VRP01 dentro il motore nuovo (delta -0.28/-0.10, 7g)
|
||||||
|
Z_BRIDGE = float(-norm.ppf(0.28)) # ~0.583
|
||||||
|
DZ_BRIDGE = float(-norm.ppf(0.10)) - Z_BRIDGE # ~0.699
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- dati + gate cache
|
||||||
|
_S: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def S(asset):
|
||||||
|
"""(px, dv, idx, skip) con gate canonico VRP01 precomputato (identico a r0702/options_vrp_v2)."""
|
||||||
|
if asset not in _S:
|
||||||
|
J = load_series(asset)
|
||||||
|
px = J["px"].values.astype(float)
|
||||||
|
dv = (J["dvol"].values / 100.0).astype(float)
|
||||||
|
n = len(px)
|
||||||
|
skip = np.zeros(n, dtype=bool)
|
||||||
|
for i in range(n):
|
||||||
|
rv = _rv30(px, i)
|
||||||
|
ivr = _ivrank(dv, i)
|
||||||
|
skip[i] = ((not np.isnan(rv) and (dv[i] - rv) <= 0)
|
||||||
|
or (not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90)))
|
||||||
|
_S[asset] = (px, dv, J.index, skip)
|
||||||
|
return _S[asset]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- motore generalizzato
|
||||||
|
def run_cell(asset, expiry, side, z, dz, tenor_d, f_put=1.0, f_call=1.0, offset=0):
|
||||||
|
"""Vendita sistematica non-overlapping della struttura, cadenza tenor_d, GATE CANONICO SEMPRE.
|
||||||
|
|
||||||
|
expiry: 'same' (long stessa scadenza) | 'diag' (long T+1g, mark BS a exit — vega)
|
||||||
|
side: 'put' | 'call' | 'both'
|
||||||
|
Identica contabilita' di r0702_alb_structure.run_structure (validata sotto): capitale = S0,
|
||||||
|
fee Deribit per gamba 0.03% notional cap 12.5% premio, delivery 0.015% su short ITM, exit fee
|
||||||
|
sulle long residue del diag. offset = fase d'ancora (banda d'ancora)."""
|
||||||
|
px, dv, idx, skip = S(asset)
|
||||||
|
n = len(px)
|
||||||
|
T = tenor_d / 365.25
|
||||||
|
T_long = T + DAY if expiry == "diag" else T
|
||||||
|
rets = {}
|
||||||
|
i = 60 + offset
|
||||||
|
while i + tenor_d < n:
|
||||||
|
j = i + tenor_d
|
||||||
|
if skip[i]:
|
||||||
|
rets[idx[j]] = 0.0
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
S0 = px[i]; sig = dv[i]; m = sig * math.sqrt(T)
|
||||||
|
S1 = px[j]; sig1 = dv[j]
|
||||||
|
credit = short_pay = long_val = exit_fee = deliv = 0.0
|
||||||
|
legs = []
|
||||||
|
if side in ("put", "both"):
|
||||||
|
Ks = S0 * math.exp(-z * m); Kl = S0 * math.exp(-(z + dz) * m)
|
||||||
|
ps = bs_put(S0, Ks, T, sig) / S0 * f_put
|
||||||
|
pl = bs_put(S0, Kl, T_long, sig) / S0 * f_put
|
||||||
|
credit += ps - pl; legs += [ps, pl]
|
||||||
|
short_pay += max(0.0, Ks - S1) / S0
|
||||||
|
deliv += _fee_frac(max(0.0, Ks - S1) / S0, rate=0.00015)
|
||||||
|
if expiry == "diag":
|
||||||
|
lv = bs_put(S1, Kl, DAY, sig1) / S0 * f_put
|
||||||
|
long_val += lv
|
||||||
|
exit_fee += _fee_frac(lv, notional_ratio=S1 / S0)
|
||||||
|
else:
|
||||||
|
long_val += max(0.0, Kl - S1) / S0
|
||||||
|
if side in ("call", "both"):
|
||||||
|
Ks = S0 * math.exp(+z * m); Kl = S0 * math.exp(+(z + dz) * m)
|
||||||
|
cs = bs_call(S0, Ks, T, sig) / S0 * f_call
|
||||||
|
cl = bs_call(S0, Kl, T_long, sig) / S0 * f_call
|
||||||
|
credit += cs - cl; legs += [cs, cl]
|
||||||
|
short_pay += max(0.0, S1 - Ks) / S0
|
||||||
|
deliv += _fee_frac(max(0.0, S1 - Ks) / S0, rate=0.00015)
|
||||||
|
if expiry == "diag":
|
||||||
|
lv = bs_call(S1, Kl, DAY, sig1) / S0 * f_call
|
||||||
|
long_val += lv
|
||||||
|
exit_fee += _fee_frac(lv, notional_ratio=S1 / S0)
|
||||||
|
else:
|
||||||
|
long_val += max(0.0, S1 - Kl) / S0
|
||||||
|
entry_fee = sum(_fee_frac(v) for v in legs)
|
||||||
|
rets[idx[j]] = credit - short_pay + long_val - entry_fee - exit_fee - deliv
|
||||||
|
i = j
|
||||||
|
return pd.Series(rets)
|
||||||
|
|
||||||
|
|
||||||
|
def book_cell(expiry, side, z, dz, tenor_d, **kw):
|
||||||
|
rB = run_cell("BTC", expiry, side, z, dz, tenor_d, **kw)
|
||||||
|
rE = run_cell("ETH", expiry, side, z, dz, tenor_d, **kw)
|
||||||
|
return pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def vrp01_book(f):
|
||||||
|
return pd.concat({a[0]: vrp_spread_weekly(a, defined_risk=True, f=f, gate_vrp=True,
|
||||||
|
gate_ivr=0.30, crash_skip=0.90)
|
||||||
|
for a in ("BTC", "ETH")}, axis=1, join="inner").mean(axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- utilita' report
|
||||||
|
def _sh_window(r, tenor_d, start=None, end=None):
|
||||||
|
w = r
|
||||||
|
if start is not None:
|
||||||
|
w = w[w.index >= pd.Timestamp(start, tz="UTC")]
|
||||||
|
if end is not None:
|
||||||
|
w = w[w.index < pd.Timestamp(end, tz="UTC")]
|
||||||
|
ppy = 365.25 / tenor_d
|
||||||
|
return float(w.mean() / w.std() * np.sqrt(ppy)) if len(w) > 5 and w.std() > 0 else float("nan")
|
||||||
|
|
||||||
|
|
||||||
|
def dd_of(r, lam=1.0):
|
||||||
|
eq = np.cumprod(1 + lam * r.values)
|
||||||
|
pk = np.maximum.accumulate(eq)
|
||||||
|
return float(np.max((pk - eq) / pk))
|
||||||
|
|
||||||
|
|
||||||
|
def scale_to_dd(r, dd_target):
|
||||||
|
"""λ tale che il maxDD di λ·r == dd_target (null del de-levering)."""
|
||||||
|
lo, hi = 0.01, 3.0
|
||||||
|
for _ in range(60):
|
||||||
|
mid = 0.5 * (lo + hi)
|
||||||
|
if dd_of(r, mid) > dd_target:
|
||||||
|
hi = mid
|
||||||
|
else:
|
||||||
|
lo = mid
|
||||||
|
return 0.5 * (lo + hi)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_engine():
|
||||||
|
"""run_cell deve riprodurre ESATTAMENTE run_structure (motore ALB-A validato) su 3 kind."""
|
||||||
|
print(" [validazione motore] run_cell vs r0702_alb_structure.run_structure (z=2, dz=1, 5g, gated):")
|
||||||
|
ok = True
|
||||||
|
for kind, (expiry, side) in (("diag", ("diag", "both")), ("condor", ("same", "both")),
|
||||||
|
("vert", ("same", "put"))):
|
||||||
|
for a in ("BTC", "ETH"):
|
||||||
|
ref = run_structure(a, kind, z=2.0, dz=1.0, tenor_d=5, gated=True)
|
||||||
|
new = run_cell(a, expiry, side, 2.0, 1.0, 5)
|
||||||
|
d = float((ref - new).abs().max()) if len(ref) == len(new) else float("inf")
|
||||||
|
ok &= d < 1e-12
|
||||||
|
print(f" {kind:<7}{a}: n={len(new):>4} max|Δ|={d:.2e}")
|
||||||
|
if not ok:
|
||||||
|
raise SystemExit("ENGINE MISMATCH: run_cell non riproduce run_structure — stop.")
|
||||||
|
print(" -> motore identico (riuso validato, non riscrittura).")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 112)
|
||||||
|
print(" R0703 VRPIMP-STRUCTGRID — griglia onesta 288 celle di struttura short-vol vs VRP01 canonico")
|
||||||
|
print(" Gate IV-rank canonico SEMPRE attivo (mai riottimizzato). Selezione IN-SAMPLE-ONLY (pre-2025).")
|
||||||
|
print(" ⚠️ SKEW: pricing BS FLAT su DVOL-30g -> banda f {0.6,0.8,1.0,1.3} + scenario fp=1.3/fc=0.7.")
|
||||||
|
print(" Regola standing: NIENTE short-vol da modello in deploy — esito massimo = conoscenza/LEAD.")
|
||||||
|
print("=" * 112)
|
||||||
|
|
||||||
|
validate_engine()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- (1) VRP01 canonico + bridge
|
||||||
|
print("\n (1) BASELINE — VRP01 canonico (options_vrp_v2 COMBO) e bridge motore-nuovo, banda f")
|
||||||
|
hdr = (f" {'riga':<38} {'ShF':>6} {'ShIS':>6} {'ShH':>6} {'CAGR':>7} {'maxDD':>6} "
|
||||||
|
f"{'worst':>7} {'win%':>5} {'att%':>5}")
|
||||||
|
print(hdr)
|
||||||
|
vrp = {}
|
||||||
|
for f in F_SWEEP:
|
||||||
|
vrp[f] = vrp01_book(f)
|
||||||
|
mm = metrics(vrp[f], 7); mi = metrics(vrp[f][vrp[f].index < HOLDOUT], 7)
|
||||||
|
print(f" VRP01 canonico f={f:<4} {'':<16} {mm['sh']:>6.2f} {mi['sh']:>6.2f} {mm['sh_h']:>6.2f} "
|
||||||
|
f"{mm['cagr']*100:>+6.1f}% {mm['dd']*100:>5.1f}% {mm['worst']*100:>+6.2f}% "
|
||||||
|
f"{mm['win']*100:>4.0f}% {mm['act']*100:>4.0f}%")
|
||||||
|
bridge = {}
|
||||||
|
for f in F_SWEEP:
|
||||||
|
bridge[f] = book_cell("same", "put", Z_BRIDGE, DZ_BRIDGE, 7, f_put=f, f_call=f)
|
||||||
|
mm = metrics(bridge[f], 7); mi = metrics(bridge[f][bridge[f].index < HOLDOUT], 7)
|
||||||
|
print(f" bridge vert-put Δ-0.28/-0.10 7g f={f:<4} {mm['sh']:>6.2f} {mi['sh']:>6.2f} {mm['sh_h']:>6.2f} "
|
||||||
|
f"{mm['cagr']*100:>+6.1f}% {mm['dd']*100:>5.1f}% {mm['worst']*100:>+6.2f}% "
|
||||||
|
f"{mm['win']*100:>4.0f}% {mm['act']*100:>4.0f}%")
|
||||||
|
print(" (bridge = stessa struttura di VRP01 nel motore-griglia: e' il confronto apples-to-apples;")
|
||||||
|
print(" battere VRP01 ma non il bridge = artefatto di convenzione fee/capitale, non struttura)")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- (2) griglia 288 celle, f=1.0
|
||||||
|
print(f"\n (2) GRIGLIA {len(STRUCTS)}x{len(Z_GRID)}x{len(DZ_GRID)}x{len(TENOR_GRID)} = "
|
||||||
|
f"{len(STRUCTS)*len(Z_GRID)*len(DZ_GRID)*len(TENOR_GRID)} celle (f=1.0, gate canonico)")
|
||||||
|
cells = {}
|
||||||
|
for expiry, side, lab in STRUCTS:
|
||||||
|
for z in Z_GRID:
|
||||||
|
for dz in DZ_GRID:
|
||||||
|
for tn in TENOR_GRID:
|
||||||
|
r = book_cell(expiry, side, z, dz, tn)
|
||||||
|
r_is = r[r.index < HOLDOUT]
|
||||||
|
act_is = r_is[r_is != 0]
|
||||||
|
rec = dict(expiry=expiry, side=side, lab=lab, z=z, dz=dz, tn=tn, r=r,
|
||||||
|
mi=metrics(r_is, tn), mm=metrics(r, tn),
|
||||||
|
nact_is=int(len(act_is)), nloss_is=int((act_is < 0).sum()))
|
||||||
|
rec["eligible"] = (rec["nact_is"] >= MIN_ACT_IS and rec["nloss_is"] >= MIN_LOSS_IS)
|
||||||
|
cells[(lab, z, dz, tn)] = rec
|
||||||
|
all_full_sh = [c["mm"]["sh"] for c in cells.values()]
|
||||||
|
all_is_sh = [c["mi"]["sh"] for c in cells.values()]
|
||||||
|
n_cells = len(cells)
|
||||||
|
inel = [c for c in cells.values() if not c["eligible"]]
|
||||||
|
print(f" celle totali {n_cells} | ESCLUSE dalla selezione (implausibili: <{MIN_LOSS_IS} perdite IS "
|
||||||
|
f"o <{MIN_ACT_IS} trade IS): {len(inel)} — dichiarate sotto, contate comunque nel DSR")
|
||||||
|
|
||||||
|
# il pattern '0-perdite': cosa avremmo scelto SENZA il filtro
|
||||||
|
naive = max(cells.values(), key=lambda c: c["mi"]["sh"])
|
||||||
|
print(f"\n [pattern CC01/ALB-A] best IS SENZA filtro implausibilita': {naive['lab']} z={naive['z']} "
|
||||||
|
f"dz={naive['dz']} {naive['tn']}g -> ShIS {naive['mi']['sh']:.2f} (perdite IS: {naive['nloss_is']}, "
|
||||||
|
f"trade IS: {naive['nact_is']}) | suo hold-out ShH {naive['mm']['sh_h']:+.2f}")
|
||||||
|
top_inel = sorted(inel, key=lambda c: c["mi"]["sh"], reverse=True)[:8]
|
||||||
|
print(" top celle ESCLUSE (ShIS alto MA coda mai campionata in-sample -> Sharpe non informativo):")
|
||||||
|
for c in top_inel:
|
||||||
|
print(f" {c['lab']:<10} z={c['z']} dz={c['dz']} {c['tn']:>2}g ShIS {c['mi']['sh']:>6.2f} "
|
||||||
|
f"perdite-IS {c['nloss_is']:>2} su {c['nact_is']:>3} trade -> ShH {c['mm']['sh_h']:>+6.2f}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- (3) selezione in-sample-only
|
||||||
|
elig = [c for c in cells.values() if c["eligible"]]
|
||||||
|
ch = max(elig, key=lambda c: c["mi"]["sh"])
|
||||||
|
print(f"\n (3) SELEZIONE IN-SAMPLE-ONLY (fra le {len(elig)} eleggibili) + top-10")
|
||||||
|
print(f" {'cella':<34} {'ShIS':>6} {'ShF':>6} {'ShH':>6} {'DD':>6} {'worst':>7} {'lossIS':>7}")
|
||||||
|
for c in sorted(elig, key=lambda c: c["mi"]["sh"], reverse=True)[:10]:
|
||||||
|
mark = " <== SCELTA" if c is ch else ""
|
||||||
|
print(f" {c['lab']:<10} z={c['z']} dz={c['dz']} tenor={c['tn']:>2}g{'':<6} {c['mi']['sh']:>6.2f} "
|
||||||
|
f"{c['mm']['sh']:>6.2f} {c['mm']['sh_h']:>6.2f} {c['mm']['dd']*100:>5.1f}% "
|
||||||
|
f"{c['mm']['worst']*100:>+6.2f}% {c['nloss_is']:>4}/{c['nact_is']:<4}{mark}")
|
||||||
|
|
||||||
|
tn = ch["tn"]
|
||||||
|
dsr_full, sr0_full = al.deflated_sharpe(ch["mm"]["sh"], all_full_sh, ch["r"], dpy=365.25 / tn)
|
||||||
|
r_is = ch["r"][ch["r"].index < HOLDOUT]
|
||||||
|
dsr_is, sr0_is = al.deflated_sharpe(ch["mi"]["sh"], all_is_sh, r_is, dpy=365.25 / tn)
|
||||||
|
print(f"\n DSR cella scelta (N={n_cells} trial): FULL {dsr_full:.3f} (null-max ~{sr0_full:.2f}) | "
|
||||||
|
f"IS {dsr_is:.3f} (null-max ~{sr0_is:.2f}) | PASS richiede >= 0.95")
|
||||||
|
|
||||||
|
# nota anti-hindsight: la cella col miglior HOLD-OUT fra le eleggibili NON e' selezionabile
|
||||||
|
# (selection-on-holdout, gate 2026-06-29); la riporto solo per chiudere la domanda ovvia.
|
||||||
|
hb = max(elig, key=lambda c: c["mm"]["sh_h"])
|
||||||
|
dsr_hb, _ = al.deflated_sharpe(hb["mm"]["sh"], all_full_sh, hb["r"], dpy=365.25 / hb["tn"])
|
||||||
|
rank_is = 1 + sum(1 for c in elig if c["mi"]["sh"] > hb["mi"]["sh"])
|
||||||
|
print(f" [anti-hindsight] best HOLD-OUT fra le eleggibili: {hb['lab']} z={hb['z']} dz={hb['dz']} "
|
||||||
|
f"{hb['tn']}g (ShIS {hb['mi']['sh']:.2f}, rank-IS #{rank_is}, ShH {hb['mm']['sh_h']:.2f}, "
|
||||||
|
f"DSR {dsr_hb:.3f}) — sceglierla = selection-on-holdout, VIETATO; e comunque DSR<0.95.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- (4) cella scelta: banda f vs VRP01
|
||||||
|
print(f"\n (4) CELLA SCELTA {ch['lab']} z={ch['z']} dz={ch['dz']} {tn}g — banda f vs VRP01 e bridge")
|
||||||
|
print(f" {'f':<22} {'cella ShF/ShH':>16} {'VRP01 ShF/ShH':>16} {'bridge ShF/ShH':>16} "
|
||||||
|
f"{'cella DD/worst':>17} {'VRP01 DD/worst':>17}")
|
||||||
|
ch_f = {}
|
||||||
|
beats_vrp = beats_bridge = True
|
||||||
|
for f in F_SWEEP:
|
||||||
|
rr = book_cell(ch["expiry"], ch["side"], ch["z"], ch["dz"], tn, f_put=f, f_call=f)
|
||||||
|
ch_f[f] = rr
|
||||||
|
m = metrics(rr, tn); v = metrics(vrp[f], 7); b = metrics(bridge[f], 7)
|
||||||
|
beats_vrp &= (m["sh"] > v["sh"]) and (m["sh_h"] > v["sh_h"])
|
||||||
|
beats_bridge &= (m["sh"] > b["sh"]) and (m["sh_h"] > b["sh_h"])
|
||||||
|
print(f" f={f:<20} {m['sh']:>7.2f}/{m['sh_h']:>6.2f} {v['sh']:>8.2f}/{v['sh_h']:>6.2f} "
|
||||||
|
f"{b['sh']:>8.2f}/{b['sh_h']:>6.2f} {m['dd']*100:>7.1f}%/{m['worst']*100:>+6.2f}% "
|
||||||
|
f"{v['dd']*100:>7.1f}%/{v['worst']*100:>+6.2f}%")
|
||||||
|
rr = book_cell(ch["expiry"], ch["side"], ch["z"], ch["dz"], tn, **F_SKEW)
|
||||||
|
m = metrics(rr, tn)
|
||||||
|
print(f" SKEW fp=1.3/fc=0.7 {m['sh']:>7.2f}/{m['sh_h']:>6.2f} (VRP01/bridge non hanno lato call)")
|
||||||
|
py_c = per_year(ch_f[1.0]); py_v = per_year(vrp[1.0])
|
||||||
|
print(" per-anno cella f=1.0: " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(py_c.items())))
|
||||||
|
print(" per-anno VRP01 f=1.0: " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(py_v.items())))
|
||||||
|
|
||||||
|
# persistenza multi-cut del presunto vantaggio (ΔSh cella - bridge, finestre [cut, end))
|
||||||
|
print(" multi-cut ΔSh (cella - bridge | cella - VRP01) su [cut, fine):")
|
||||||
|
for cut in CUTS:
|
||||||
|
d_b = _sh_window(ch_f[1.0], tn, start=cut) - _sh_window(bridge[1.0], 7, start=cut)
|
||||||
|
d_v = _sh_window(ch_f[1.0], tn, start=cut) - _sh_window(vrp[1.0], 7, start=cut)
|
||||||
|
print(f" {cut}: {d_b:+.2f} | {d_v:+.2f}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- (5) banda d'ancora
|
||||||
|
print(f"\n (5) BANDA D'ANCORA — cella scelta su tutte le {tn} fasi di partenza (offset 0..{tn-1})")
|
||||||
|
anch = []
|
||||||
|
for o in range(tn):
|
||||||
|
ro = book_cell(ch["expiry"], ch["side"], ch["z"], ch["dz"], tn, offset=o)
|
||||||
|
mo = metrics(ro, tn)
|
||||||
|
anch.append((o, mo["sh"], mo["sh_h"]))
|
||||||
|
print(f" offset {o}: ShF {mo['sh']:>5.2f} ShH {mo['sh_h']:>+5.2f}")
|
||||||
|
shf = [a[1] for a in anch]; shh = [a[2] for a in anch]
|
||||||
|
print(f" banda ShF [{min(shf):.2f}, {max(shf):.2f}] mediana {np.median(shf):.2f} | "
|
||||||
|
f"ShH [{min(shh):+.2f}, {max(shh):+.2f}] mediana {np.median(shh):+.2f}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- (6) plateau dei vicini
|
||||||
|
print(f"\n (6) PLATEAU — slice {ch['lab']} dz={ch['dz']}: matrice z × tenor (ShIS / ShH)")
|
||||||
|
print(" " + "".join(f"{t:>14}g" for t in TENOR_GRID))
|
||||||
|
for z in Z_GRID:
|
||||||
|
rowtxt = f" z={z:<4}"
|
||||||
|
for t in TENOR_GRID:
|
||||||
|
c = cells[(ch["lab"], z, ch["dz"], t)]
|
||||||
|
star = "*" if (z == ch["z"] and t == tn) else " "
|
||||||
|
rowtxt += f" {c['mi']['sh']:>5.2f}/{c['mm']['sh_h']:>+5.2f}{star}"
|
||||||
|
print(rowtxt)
|
||||||
|
print(" vicini dz (a z/tenor scelti): " + " ".join(
|
||||||
|
f"dz={d}: ShIS {cells[(ch['lab'], ch['z'], d, tn)]['mi']['sh']:.2f}/"
|
||||||
|
f"ShH {cells[(ch['lab'], ch['z'], d, tn)]['mm']['sh_h']:+.2f}" for d in DZ_GRID))
|
||||||
|
neigh = []
|
||||||
|
zi, ti, di = Z_GRID.index(ch["z"]), TENOR_GRID.index(tn), DZ_GRID.index(ch["dz"])
|
||||||
|
for (gi, grid, mk) in ((zi, Z_GRID, "z"), (ti, TENOR_GRID, "tn"), (di, DZ_GRID, "dz")):
|
||||||
|
for step in (-1, +1):
|
||||||
|
k = gi + step
|
||||||
|
if 0 <= k < len(grid):
|
||||||
|
key = dict(z=ch["z"], tn=tn, dz=ch["dz"]); key[mk] = grid[k]
|
||||||
|
neigh.append(cells[(ch["lab"], key["z"], key["dz"], key["tn"])])
|
||||||
|
n_pos = sum(1 for c in neigh if c["mi"]["sh"] > 0 and c["mm"]["sh_h"] > 0)
|
||||||
|
print(f" vicini adiacenti (±1 passo per asse): {len(neigh)} | con ShIS>0 E ShH>0: {n_pos}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- (7) null del de-levering
|
||||||
|
print("\n (7) NULL DEL DE-LEVERING — il vantaggio DD della cella e' replicabile scalando VRP01?")
|
||||||
|
m_c = metrics(ch_f[1.0], tn); m_v = metrics(vrp[1.0], 7)
|
||||||
|
lam = scale_to_dd(vrp[1.0], m_c["dd"])
|
||||||
|
scaled = lam * vrp[1.0]
|
||||||
|
m_s = metrics(scaled, 7)
|
||||||
|
print(f" cella: Sh {m_c['sh']:.2f} / ShH {m_c['sh_h']:.2f} DD {m_c['dd']*100:.1f}% "
|
||||||
|
f"worst {m_c['worst']*100:+.2f}% CAGR {m_c['cagr']*100:+.1f}%")
|
||||||
|
print(f" VRP01 pieno: Sh {m_v['sh']:.2f} / ShH {m_v['sh_h']:.2f} DD {m_v['dd']*100:.1f}% "
|
||||||
|
f"worst {m_v['worst']*100:+.2f}% CAGR {m_v['cagr']*100:+.1f}%")
|
||||||
|
print(f" VRP01 × λ={lam:.2f}: Sh {m_s['sh']:.2f} / ShH {m_s['sh_h']:.2f} DD {m_s['dd']*100:.1f}% "
|
||||||
|
f"worst {m_s['worst']*100:+.2f}% CAGR {m_s['cagr']*100:+.1f}%")
|
||||||
|
delever_kills = (m_c["dd"] < m_v["dd"]) and (m_s["sh"] >= m_c["sh"]) and (m_s["sh_h"] >= m_c["sh_h"])
|
||||||
|
print(" -> lo scaling lascia lo Sharpe invariato: ogni vantaggio di DD e' replicabile con λ,"
|
||||||
|
f" la cella vale SOLO se Sh/ShH superiori. delever_null_kills={delever_kills}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- (8) marginale/corr
|
||||||
|
print("\n (8) CORRELAZIONE + MARGINALE (informativo, non promozione: regola standing short-vol)")
|
||||||
|
d_c = to_daily_lumped(ch_f[1.0]); d_v = to_daily_lumped(vrp[1.0])
|
||||||
|
w_c = (1 + d_c).resample("168h", origin="epoch").prod() - 1 # origin comune (lezione '7D')
|
||||||
|
w_v = (1 + d_v).resample("168h", origin="epoch").prod() - 1
|
||||||
|
Jw = pd.concat({"c": w_c, "v": w_v}, axis=1, join="inner").dropna()
|
||||||
|
print(f" corr settimanale cella~VRP01: {Jw['c'].corr(Jw['v']):+.2f} "
|
||||||
|
f"(alta = stesso trade in altri vestiti)")
|
||||||
|
mv = al.marginal_vs_tp01(d_c)
|
||||||
|
print(f" marginal_vs_tp01[cella]: verdict={mv.get('marginal_verdict')} corr={mv.get('corr_full')} "
|
||||||
|
f"IS-Sh={mv.get('cand_insample_sharpe')} insample_edge={mv.get('has_insample_edge')} "
|
||||||
|
f"hedge={mv.get('is_hedge')} robust_oos={mv.get('robust_oos')}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- (9) verdetto
|
||||||
|
print("\n (9) VERDETTO")
|
||||||
|
dsr_pass = bool(np.isfinite(dsr_full) and dsr_full >= 0.95 and np.isfinite(dsr_is) and dsr_is >= 0.95)
|
||||||
|
wins = beats_vrp and beats_bridge and dsr_pass and not delever_kills
|
||||||
|
print(f" batte VRP01 su tutta la banda f (ShF e ShH): {beats_vrp}")
|
||||||
|
print(f" batte il bridge (stesso motore) su tutta la banda f: {beats_bridge}")
|
||||||
|
print(f" DSR>=0.95 su {n_cells} celle (FULL {dsr_full:.3f} / IS {dsr_is:.3f}): {dsr_pass}")
|
||||||
|
print(f" sopravvive al null del de-levering: {not delever_kills}")
|
||||||
|
print(f" ==> {'ESISTE una cella che batte VRP01 onestamente' if wins else 'NESSUNA cella batte VRP01 onestamente'}")
|
||||||
|
print(" (esecuzione: comunque STAT-MODE — niente short-vol da modello in deploy;")
|
||||||
|
print(" muri di size Deribit invariati dal filone capital-scaling: spread ETH ~2.6k+, BTC ~4.7k+)")
|
||||||
|
|
||||||
|
# dump metriche per il report
|
||||||
|
SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||||
|
dump = {f"{k[0]}|z{k[1]}|dz{k[2]}|t{k[3]}": dict(
|
||||||
|
is_sh=c["mi"]["sh"], full_sh=c["mm"]["sh"], hold_sh=c["mm"]["sh_h"], dd=c["mm"]["dd"],
|
||||||
|
worst=c["mm"]["worst"], nact_is=c["nact_is"], nloss_is=c["nloss_is"], eligible=c["eligible"])
|
||||||
|
for k, c in cells.items()}
|
||||||
|
(SCRATCH / "r0703_structgrid_results.json").write_text(json.dumps(dump, indent=1))
|
||||||
|
print(f"\n [dump] {SCRATCH / 'r0703_structgrid_results.json'}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""r0707_crt_topdown.py — "CRT top-down multi-TF" (video-claim, 74% win rate) — 2026-07-07.
|
||||||
|
|
||||||
|
CLAIM DAL VIDEO (metodo ICT/SMC top-down, 74% WR "da quanto sto testando"):
|
||||||
|
1. H1 — setup CRT: candela di displacement forte, poi candela che PRENDE LIQUIDITA'
|
||||||
|
(spike oltre il range di C1) ma RICHIUDE dentro il range -> falso breakout -> fade.
|
||||||
|
2. M15 — struttura: swing (U a 3 candele) + imbalance che diventa inverse-imbalance (FVG mitigato).
|
||||||
|
3. M5 — conferma: displacement direzionale + zona di protezione (order block / imbalance).
|
||||||
|
4. M1 — entry: attende la correzione, entra con SL DIETRO la zona di protezione.
|
||||||
|
Uscita: TP1 "a fine zona CRT" a RR ~1.5-2, chiude 70-80% li' -> resto a BREAK-EVEN ->
|
||||||
|
runner 20-30% verso la "liquidita' successiva" (previous daily high/low).
|
||||||
|
|
||||||
|
COSA E' GIA' STATO TESTATO E SCARTATO (non lo rifaccio): 2026-07-02 CRT wave, 3 tagli —
|
||||||
|
base single-TF (r0702_crt_base, 864 trial, DSR 0.000), multi-TF (r0702_crt_mtf, ~10k trade,
|
||||||
|
expectancy netta NEGATIVA ovunque, "il ritest e' informazione negativa"), contesto/FVG
|
||||||
|
(r0702_crt_context, FVG semmai peggiora; FADE < FOLLOW ogni anno). Il setup H1 CRT NON ha
|
||||||
|
edge direzionale su BTC/ETH certificati.
|
||||||
|
|
||||||
|
ANGOLO NUOVO (non coperto dalle 3 onde precedenti) = LA GESTIONE D'USCITA e IL "74% WR":
|
||||||
|
la scuola SMC mostra win-rate alti perche' usa PARZIALE-a-1.5R + BREAK-EVEN + runner.
|
||||||
|
TESI DA FALSIFICARE: **il 74% e' un ARTEFATTO di misura dello schema parziale+BE, NON un edge**.
|
||||||
|
Con parziale+BE un trade e' "perdente" SOLO se lo stop scatta PRIMA di toccare 1.5R; tutto il
|
||||||
|
resto (anche un ritorno a BE dopo aver bankato il 75% a 1.5R) e' bookato come "vinto". Quindi
|
||||||
|
WR_managed = P(tocca +1.5R prima di -1R). Per un random walk senza edge P(+aR prima di -1R)=1/(1+a)
|
||||||
|
-> a=1.5 => 40%. Il numero che conta e' l'EXPECTANCY in R netto fee, invariante allo schema.
|
||||||
|
|
||||||
|
DISEGNO (onesto, riusa l'harness del progetto per dati/atr/sharpe/DSR):
|
||||||
|
- Detection CRT C1-C2 su H1 (1h nativo, causale: nota alla chiusura di C2).
|
||||||
|
- Entry sul TF basso (5m/15m, proxy di M5/M1 — NB: M1 non e' nel feed certificato):
|
||||||
|
ritest della zona violata + CLOSE-BACK attraverso il livello ("displacement di conferma"),
|
||||||
|
SL = estremo dello swing basso dalla apertura finestra (la "zona di protezione").
|
||||||
|
- Tre schemi d'uscita sugli STESSI ingressi:
|
||||||
|
FIXED-1.5 : full size, TP a 1.5R, SL protezione.
|
||||||
|
FIXED-2.0 : full size, TP a 2.0R.
|
||||||
|
MANAGED : 75% a 1.5R -> resto 25% a BE, runner target = prev-daily high/low (o estremo
|
||||||
|
opposto di C1 se piu' lontano), altrimenti chiude a BE/tempo. (schema del video)
|
||||||
|
- Null decisivo: FOLLOW (trada in direzione del breakout, non il fade) sugli stessi pattern.
|
||||||
|
- Gate progetto: hold-out 2025-01-01, fee sweep 0/0.10/0.20% RT, griglia (dir x RR x zona x forza
|
||||||
|
C1) con DSR su tutti i trial, executability $600. Nessun file scritto; non tocca src/live.
|
||||||
|
|
||||||
|
Run: uv run python scripts/research/r0707_crt_topdown.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||||
|
import altlib as al # noqa: E402
|
||||||
|
|
||||||
|
import numpy as np # noqa: E402
|
||||||
|
import pandas as pd # noqa: E402
|
||||||
|
|
||||||
|
HTF = "1h"
|
||||||
|
LTFS = ("5m", "15m") # proxy M5/M1 (M1 non certificato -> dichiarato)
|
||||||
|
ASSETS = al.CERTIFIED # BTC, ETH
|
||||||
|
FEE_RT = 2 * al.FEE_SIDE # 0.10% round-trip
|
||||||
|
FEE_SWEEP = (0.0, 0.001, 0.002)
|
||||||
|
HOLDOUT = al.HOLDOUT
|
||||||
|
HOLDOUT_MS = int(HOLDOUT.value // 10**6)
|
||||||
|
TF_MS = {"5m": 300_000, "15m": 900_000, "1h": 3_600_000}
|
||||||
|
CAPITAL = 600.0
|
||||||
|
MIN_ORDER = 5.0
|
||||||
|
LEV_CAP = 2.0
|
||||||
|
|
||||||
|
# --- griglia a priori (chiusa prima di guardare i risultati) ---
|
||||||
|
K_GRID = (1.0, 1.5) # C1 forte: range >= k*ATR14(H1)
|
||||||
|
S_GRID = (0.0, 0.1) # C2 sweep: rompe l'estremo di C1 di s*ATR
|
||||||
|
D_GRID = (0.15, 0.35) # zona di ritest = d*ATR intorno al livello
|
||||||
|
WIN_HTF = 4 # finestra ritest+entry: 4 barre H1 dopo la chiusura di C2
|
||||||
|
MAXHOLD_HTF = 24 # holding massimo del trade: 24 barre H1 (1 giorno) di barre basse
|
||||||
|
RR1 = 1.5 # parziale "fine zona CRT"
|
||||||
|
RR2 = 2.0
|
||||||
|
RUNNER_CAP_R = 3.0 # se prevday non oltre TP1, runner target = 3R (cap)
|
||||||
|
ATR_MULT_DEFAULT = 1.2
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# DETECTION CRT C1-C2 su H1 (vettoriale, causale). i = barra C2; C1 = i-1.
|
||||||
|
# ===========================================================================
|
||||||
|
def detect_patterns(dfh: pd.DataFrame, k: float, s: float) -> list[dict]:
|
||||||
|
ts = dfh["timestamp"].astype("int64").values
|
||||||
|
h = dfh["high"].values.astype(float)
|
||||||
|
l = dfh["low"].values.astype(float)
|
||||||
|
c = dfh["close"].values.astype(float)
|
||||||
|
a = al.atr(dfh, 14)
|
||||||
|
h1 = np.roll(h, 1); l1 = np.roll(l, 1); a1 = np.roll(a, 1)
|
||||||
|
rng1 = h1 - l1
|
||||||
|
strong = (a1 > 0) & (rng1 >= k * a1)
|
||||||
|
up = strong & (h > h1 + s * a1) & (c < h1) & (c > l1) # sweep alto, close dentro -> SHORT
|
||||||
|
dn = strong & (l < l1 - s * a1) & (c > l1) & (c < h1) # sweep basso, close dentro -> LONG
|
||||||
|
idx = np.where(up | dn)[0]
|
||||||
|
tf_ms = TF_MS[HTF]
|
||||||
|
pats = []
|
||||||
|
for i in idx:
|
||||||
|
if i < 20:
|
||||||
|
continue
|
||||||
|
if up[i]:
|
||||||
|
d, level, c1_opp, c2ext = -1, float(h1[i]), float(l1[i]), float(h[i])
|
||||||
|
else:
|
||||||
|
d, level, c1_opp, c2ext = +1, float(l1[i]), float(h1[i]), float(l[i])
|
||||||
|
pats.append(dict(i=int(i), dir=d, level=level, c1_opp=c1_opp, c2ext=c2ext,
|
||||||
|
atr=float(a1[i]),
|
||||||
|
win_open=int(ts[i] + tf_ms),
|
||||||
|
win_close=int(ts[i] + (1 + WIN_HTF) * tf_ms),
|
||||||
|
hold_end=int(ts[i] + (1 + MAXHOLD_HTF) * tf_ms)))
|
||||||
|
return pats
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# LOW-TF arrays + prev-daily levels (runner target = "previous daily high/low")
|
||||||
|
# ===========================================================================
|
||||||
|
class Low:
|
||||||
|
__slots__ = ("ts", "o", "h", "l", "c", "tsclose", "n", "pdh", "pdl")
|
||||||
|
|
||||||
|
def __init__(self, df: pd.DataFrame, tf_lo: str):
|
||||||
|
self.ts = df["timestamp"].astype("int64").values
|
||||||
|
self.o = df["open"].values.astype(float)
|
||||||
|
self.h = df["high"].values.astype(float)
|
||||||
|
self.l = df["low"].values.astype(float)
|
||||||
|
self.c = df["close"].values.astype(float)
|
||||||
|
self.tsclose = self.ts + TF_MS[tf_lo]
|
||||||
|
self.n = len(self.ts)
|
||||||
|
# prev-day high/low, causale: per ogni barra, estremi del giorno UTC PRECEDENTE completo
|
||||||
|
dti = pd.to_datetime(self.ts, unit="ms", utc=True)
|
||||||
|
day = dti.floor("D")
|
||||||
|
g = pd.DataFrame({"day": day, "h": self.h, "l": self.l})
|
||||||
|
dh = g.groupby("day")["h"].max()
|
||||||
|
dl = g.groupby("day")["l"].min()
|
||||||
|
pdh_map = dh.shift(1) # high del giorno prima
|
||||||
|
pdl_map = dl.shift(1)
|
||||||
|
self.pdh = pdh_map.reindex(day).values.astype(float)
|
||||||
|
self.pdl = pdl_map.reindex(day).values.astype(float)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# ENTRY (ritest + close-back sul TF basso) -> (entry, entry_ts, stop, jt, j1)
|
||||||
|
# ===========================================================================
|
||||||
|
def find_entry(p: dict, L: Low, d_mult: float):
|
||||||
|
j0 = int(np.searchsorted(L.ts, p["win_open"]))
|
||||||
|
jw = int(np.searchsorted(L.ts, p["win_close"]))
|
||||||
|
if j0 >= L.n or L.ts[j0] >= p["win_close"]:
|
||||||
|
return None
|
||||||
|
dr, level, c2ext = p["dir"], p["level"], p["c2ext"]
|
||||||
|
zone = d_mult * p["atr"]
|
||||||
|
Lh, Ll, Lc = L.h, L.l, L.c
|
||||||
|
touched = False
|
||||||
|
jt = -1
|
||||||
|
swing = -np.inf if dr < 0 else np.inf
|
||||||
|
for j in range(j0, jw):
|
||||||
|
if dr < 0:
|
||||||
|
swing = max(swing, Lh[j])
|
||||||
|
if Lh[j] >= c2ext: # struttura violata prima del trigger
|
||||||
|
return None
|
||||||
|
if Lh[j] >= level - zone:
|
||||||
|
touched = True
|
||||||
|
if touched and Lc[j] < level: # close-back sotto il livello -> conferma
|
||||||
|
jt = j; break
|
||||||
|
else:
|
||||||
|
swing = min(swing, Ll[j])
|
||||||
|
if Ll[j] <= c2ext:
|
||||||
|
return None
|
||||||
|
if Ll[j] <= level + zone:
|
||||||
|
touched = True
|
||||||
|
if touched and Lc[j] > level:
|
||||||
|
jt = j; break
|
||||||
|
if jt < 0:
|
||||||
|
return None
|
||||||
|
entry = float(Lc[jt])
|
||||||
|
stop = float(max(swing, Lh[jt])) if dr < 0 else float(min(swing, Ll[jt]))
|
||||||
|
if (dr < 0 and not (entry < stop)) or (dr > 0 and not (entry > stop)):
|
||||||
|
return None
|
||||||
|
j1 = int(np.searchsorted(L.ts, p["hold_end"]))
|
||||||
|
return dict(dir=dr, entry=entry, stop=stop, jt=jt, j1=min(j1, L.n),
|
||||||
|
entry_ts=int(L.tsclose[jt]))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# EXIT SCHEMES sugli stessi ingressi. Ritorna gross_R (in multipli di R, size-1 base),
|
||||||
|
# is_win (bool), kind. R (frazione prezzo) = |entry-stop|/entry.
|
||||||
|
# Convenzione conservativa: nella stessa barra lo STOP scatta prima del target.
|
||||||
|
# ===========================================================================
|
||||||
|
def _scan_fixed(en, L, rr, fee_rt):
|
||||||
|
dr, entry, stop = en["dir"], en["entry"], en["stop"]
|
||||||
|
risk = abs(entry - stop) / entry
|
||||||
|
tp = entry * (1 + dr * rr * risk)
|
||||||
|
Lh, Ll, Lc = L.h, L.l, L.c
|
||||||
|
for j in range(en["jt"] + 1, en["j1"]):
|
||||||
|
if dr < 0:
|
||||||
|
if Lh[j] >= stop:
|
||||||
|
return -1.0, False, "SL", risk
|
||||||
|
if Ll[j] <= tp:
|
||||||
|
return rr, True, "TP", risk
|
||||||
|
else:
|
||||||
|
if Ll[j] <= stop:
|
||||||
|
return -1.0, False, "SL", risk
|
||||||
|
if Lh[j] >= tp:
|
||||||
|
return rr, True, "TP", risk
|
||||||
|
# time exit
|
||||||
|
last = en["j1"] - 1
|
||||||
|
if last <= en["jt"]:
|
||||||
|
return 0.0, False, "NOBARS", risk
|
||||||
|
g = dr * (Lc[last] / entry - 1.0) / risk if risk > 0 else 0.0
|
||||||
|
return g, g > 0, "TIME", risk
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_managed(en, L, p, rr1=RR1, fee_rt=FEE_RT):
|
||||||
|
"""75% a rr1 -> resto 25% a BE, runner = prev-daily high/low (o estremo opposto C1 / cap)."""
|
||||||
|
dr, entry, stop = en["dir"], en["entry"], en["stop"]
|
||||||
|
risk = abs(entry - stop) / entry
|
||||||
|
tp1 = entry * (1 + dr * rr1 * risk)
|
||||||
|
Lh, Ll, Lc = L.h, L.l, L.c
|
||||||
|
jt, j1 = en["jt"], en["j1"]
|
||||||
|
# runner target in prezzo: prev-daily level nel verso del trade, oltre tp1; else estremo opp C1; else cap
|
||||||
|
if dr > 0:
|
||||||
|
cands = [x for x in (L.pdh[jt], p["c1_opp"]) if np.isfinite(x) and x > tp1]
|
||||||
|
runner = min(cands) if cands else entry * (1 + RUNNER_CAP_R * risk)
|
||||||
|
else:
|
||||||
|
cands = [x for x in (L.pdl[jt], p["c1_opp"]) if np.isfinite(x) and x < tp1]
|
||||||
|
runner = max(cands) if cands else entry * (1 - RUNNER_CAP_R * risk)
|
||||||
|
runner_R = abs(runner - entry) / entry / risk if risk > 0 else 0.0
|
||||||
|
# fase 1: fino a tp1 (o stop)
|
||||||
|
j_tp1 = -1
|
||||||
|
for j in range(jt + 1, j1):
|
||||||
|
if dr < 0:
|
||||||
|
if Lh[j] >= stop:
|
||||||
|
return -1.0, False, "SL", risk # perde: stop prima del parziale
|
||||||
|
if Ll[j] <= tp1:
|
||||||
|
j_tp1 = j; break
|
||||||
|
else:
|
||||||
|
if Ll[j] <= stop:
|
||||||
|
return -1.0, False, "SL", risk
|
||||||
|
if Lh[j] >= tp1:
|
||||||
|
j_tp1 = j; break
|
||||||
|
if j_tp1 < 0:
|
||||||
|
# non ha raggiunto tp1 ne' stop: chiude a tempo sul resto pieno (nessun parziale)
|
||||||
|
last = j1 - 1
|
||||||
|
if last <= jt:
|
||||||
|
return 0.0, False, "NOBARS", risk
|
||||||
|
g = dr * (Lc[last] / entry - 1.0) / risk if risk > 0 else 0.0
|
||||||
|
return g, g > 0, "TIME", risk
|
||||||
|
# bankato 75% a rr1; resto 25% con stop a BE (=entry), target=runner
|
||||||
|
for j in range(j_tp1 + 1, j1):
|
||||||
|
if dr < 0:
|
||||||
|
if Lh[j] >= entry: # ritorno a BE
|
||||||
|
return 0.75 * rr1 + 0.25 * 0.0, True, "BE-RUN", risk
|
||||||
|
if Ll[j] <= runner:
|
||||||
|
return 0.75 * rr1 + 0.25 * runner_R, True, "RUNNER", risk
|
||||||
|
else:
|
||||||
|
if Ll[j] <= entry:
|
||||||
|
return 0.75 * rr1 + 0.25 * 0.0, True, "BE-RUN", risk
|
||||||
|
if Lh[j] >= runner:
|
||||||
|
return 0.75 * rr1 + 0.25 * runner_R, True, "RUNNER", risk
|
||||||
|
# resto chiude a tempo
|
||||||
|
last = j1 - 1
|
||||||
|
grun = dr * (Lc[last] / entry - 1.0) / risk if risk > 0 else 0.0
|
||||||
|
return 0.75 * rr1 + 0.25 * grun, True, "TIME-RUN", risk
|
||||||
|
|
||||||
|
|
||||||
|
def net_frac(gross_R, risk, fee_rt):
|
||||||
|
"""Rendimento netto (frazione prezzo) del trade a 1x nozionale: gross_R*risk - fee_rt."""
|
||||||
|
return gross_R * risk - fee_rt
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# BOOK / STATS
|
||||||
|
# ===========================================================================
|
||||||
|
def build_trades(pats, L, d_mult, scheme, p_by_i, fee_rt=FEE_RT, rr1=RR1):
|
||||||
|
"""scheme in {'fix1.5','fix2.0','managed'}. Ritorna lista di trade dict (sequenziali)."""
|
||||||
|
raw = []
|
||||||
|
for p in pats:
|
||||||
|
en = find_entry(p, L, d_mult)
|
||||||
|
if en is None:
|
||||||
|
continue
|
||||||
|
if scheme == "managed":
|
||||||
|
gR, win, kind, risk = _scan_managed(en, L, p, rr1, fee_rt)
|
||||||
|
elif scheme == "fix2.0":
|
||||||
|
gR, win, kind, risk = _scan_fixed(en, L, RR2, fee_rt)
|
||||||
|
else:
|
||||||
|
gR, win, kind, risk = _scan_fixed(en, L, RR1, fee_rt)
|
||||||
|
nf = net_frac(gR, risk, fee_rt)
|
||||||
|
raw.append(dict(entry_ts=en["entry_ts"], exit_ts=en["entry_ts"], gR=gR, win=win,
|
||||||
|
kind=kind, risk=risk, net=nf, dir=en["dir"]))
|
||||||
|
# sequenziale (una posizione alla volta) sull'entry_ts
|
||||||
|
raw.sort(key=lambda t: t["entry_ts"])
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def seq_filter(trades):
|
||||||
|
out, last = [], -1
|
||||||
|
for t in trades:
|
||||||
|
if t["entry_ts"] >= last:
|
||||||
|
out.append(t); last = t["exit_ts"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def stats(trades, fee_rt=FEE_RT, mask=None):
|
||||||
|
tr = trades if mask is None else [t for t in trades if mask(t)]
|
||||||
|
if not tr:
|
||||||
|
return dict(n=0, wr=np.nan, exp_R=np.nan, exp_net_bps=np.nan, med_risk=np.nan)
|
||||||
|
# expectancy in R netto fee: (gross_R - fee_rt/risk)
|
||||||
|
R_net = np.array([t["gR"] - fee_rt / t["risk"] if t["risk"] > 0 else t["gR"] for t in tr])
|
||||||
|
wr = float(np.mean([t["win"] for t in tr]) * 100)
|
||||||
|
net = np.array([t["gR"] * t["risk"] - fee_rt for t in tr])
|
||||||
|
return dict(n=len(tr), wr=wr, exp_R=float(R_net.mean()),
|
||||||
|
exp_net_bps=float(net.mean() * 1e4),
|
||||||
|
med_risk=float(np.median([t["risk"] for t in tr]) * 100))
|
||||||
|
|
||||||
|
|
||||||
|
def daily_series(trades, span, fee_rt=FEE_RT):
|
||||||
|
idx = pd.date_range(pd.Timestamp(span[0], unit="ms", tz="UTC").normalize(),
|
||||||
|
pd.Timestamp(span[1], unit="ms", tz="UTC").normalize(), freq="D")
|
||||||
|
s = pd.Series(0.0, index=idx)
|
||||||
|
for t in trades:
|
||||||
|
d = pd.Timestamp(t["exit_ts"], unit="ms", tz="UTC").normalize()
|
||||||
|
if d in s.index:
|
||||||
|
s[d] += t["gR"] * t["risk"] - fee_rt
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def sh_dd(s):
|
||||||
|
sharpe = al._sh(s)
|
||||||
|
eq = np.cumprod(1.0 + s.values)
|
||||||
|
pk = np.maximum.accumulate(eq)
|
||||||
|
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||||
|
return sharpe, dd
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# MAIN
|
||||||
|
# ===========================================================================
|
||||||
|
def main():
|
||||||
|
t0 = time.time()
|
||||||
|
print("=" * 100)
|
||||||
|
print("r0707 CRT TOP-DOWN (video-claim, 74% WR) — H1 setup -> M15/M5 entry -> parziale+BE+runner")
|
||||||
|
print(f"fee {FEE_RT*1e4:.0f}bps RT | hold-out {HOLDOUT.date()} | M1 NON nel feed (proxy 5m/15m)")
|
||||||
|
print("TESI: il 74% WR e' artefatto del parziale+BE; il numero invariante e' l'expectancy in R.")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
# dati H1 span
|
||||||
|
for a in ASSETS:
|
||||||
|
d = al.get(a, HTF)
|
||||||
|
idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||||
|
print(f" {a} {HTF}: {idx.iloc[0].date()} -> {idx.iloc[-1].date()} ({len(d)} barre)")
|
||||||
|
|
||||||
|
all_trial_sh = []
|
||||||
|
grid_rows = []
|
||||||
|
# cache pattern per (asset, k, s) e low arrays per (asset, ltf)
|
||||||
|
lowcache = {}
|
||||||
|
for tf_lo in LTFS:
|
||||||
|
for a in ASSETS:
|
||||||
|
lowcache[(a, tf_lo)] = Low(al.get(a, tf_lo), tf_lo)
|
||||||
|
|
||||||
|
print(f"\n{'#'*100}\n### GRIGLIA — schema d'uscita x (k, s, d, tf_lo, dir) [selezione cella SOLO in-sample]\n{'#'*100}")
|
||||||
|
hdr = (f" {'tf':>3s} {'dir':>5s} k={'':1s}{'':1s} s d sch | "
|
||||||
|
f"{'n':>5s} {'WR%':>5s} {'expR':>6s} {'net_bps':>8s} {'ShF':>6s} {'DDF%':>5s} | "
|
||||||
|
f"{'nH':>4s} {'WRH':>5s} {'expRH':>6s}")
|
||||||
|
print(hdr)
|
||||||
|
|
||||||
|
detail = {} # per il candidato finale
|
||||||
|
for tf_lo in LTFS:
|
||||||
|
for k in K_GRID:
|
||||||
|
for s in S_GRID:
|
||||||
|
pats_by_asset = {a: detect_patterns(al.get(a, HTF), k, s) for a in ASSETS}
|
||||||
|
for d_mult in D_GRID:
|
||||||
|
which = "fade" # metodo del video; fade-vs-follow gia' settlato in r0702_crt_context
|
||||||
|
for scheme in ("fix1.5", "fix2.0", "managed"):
|
||||||
|
per_asset_trades = {}
|
||||||
|
spans = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
L = lowcache[(a, tf_lo)]
|
||||||
|
spans[a] = (int(L.ts[0]), int(L.tsclose[-1]))
|
||||||
|
per_asset_trades[a] = build_trades(pats_by_asset[a], L, d_mult, scheme, None)
|
||||||
|
pooled = per_asset_trades["BTC"] + per_asset_trades["ETH"]
|
||||||
|
pooled.sort(key=lambda t: t["entry_ts"])
|
||||||
|
st_f = stats(pooled)
|
||||||
|
st_h = stats(pooled, mask=lambda t: t["entry_ts"] >= HOLDOUT_MS)
|
||||||
|
st_i = stats(pooled, mask=lambda t: t["entry_ts"] < HOLDOUT_MS)
|
||||||
|
# book daily 50/50 sequenziale
|
||||||
|
per = []
|
||||||
|
for a in ASSETS:
|
||||||
|
per.append(daily_series(seq_filter(per_asset_trades[a]), spans[a]))
|
||||||
|
J = pd.concat(per, axis=1).fillna(0.0)
|
||||||
|
book = 0.5 * J.iloc[:, 0] + 0.5 * J.iloc[:, 1]
|
||||||
|
shf, ddf = sh_dd(book)
|
||||||
|
bh = book[book.index >= HOLDOUT]
|
||||||
|
shh = al._sh(bh) if len(bh) > 30 else np.nan
|
||||||
|
bi = book[book.index < HOLDOUT]
|
||||||
|
shi = al._sh(bi) if len(bi) > 30 else np.nan
|
||||||
|
all_trial_sh.append(shf)
|
||||||
|
grid_rows.append(dict(tf=tf_lo, k=k, s=s, d=d_mult, which=which,
|
||||||
|
scheme=scheme, n=st_f["n"], wr=st_f["wr"],
|
||||||
|
expR=st_f["exp_R"], shf=shf, shi=shi, shh=shh,
|
||||||
|
wrh=st_h["wr"], expRH=st_h["exp_R"]))
|
||||||
|
detail[(tf_lo, k, s, d_mult, which, scheme)] = dict(
|
||||||
|
book=book, spans=spans, per_asset=per_asset_trades,
|
||||||
|
st_f=st_f, st_h=st_h, st_i=st_i, shf=shf, shh=shh, ddf=ddf)
|
||||||
|
print(f" {tf_lo:>3s} {which:>5s} k={k:.1f} {s:.2f} {d_mult:.2f} "
|
||||||
|
f"{scheme:<8s} | {st_f['n']:>5d} {st_f['wr']:>5.1f} "
|
||||||
|
f"{st_f['exp_R']:>+6.2f} {st_f['exp_net_bps']:>+8.1f} "
|
||||||
|
f"{shf:>6.2f} {ddf*100:>5.1f} | {st_h['n']:>4d} {st_h['wr']:>5.1f} "
|
||||||
|
f"{st_h['exp_R']:>+6.2f}")
|
||||||
|
|
||||||
|
G = pd.DataFrame(grid_rows)
|
||||||
|
|
||||||
|
# ---------- 1) IL "74% WR": WR per schema (solo FADE, il metodo del video) ----------
|
||||||
|
print(f"\n{'='*100}\n### 1) IL CLAIM 74% WR — win-rate per schema d'uscita (FADE = metodo del video)")
|
||||||
|
print("Random-walk senza edge: P(tocca +1.5R prima di -1R) = 1/(1+1.5) = 40.0%")
|
||||||
|
print("=" * 100)
|
||||||
|
fade = G[G.which == "fade"]
|
||||||
|
for scheme in ("fix1.5", "fix2.0", "managed"):
|
||||||
|
sub = fade[fade.scheme == scheme]
|
||||||
|
print(f" {scheme:<8s} | WR range [{sub.wr.min():.1f}, {sub.wr.max():.1f}]% "
|
||||||
|
f"mediana {sub.wr.median():.1f}% | expectancy R range "
|
||||||
|
f"[{sub.expR.min():+.2f}, {sub.expR.max():+.2f}] mediana {sub.expR.median():+.2f}")
|
||||||
|
print(" -> a rr1=1.5 il WR reale e' ~35% (SOTTO il null 40%): il setup non tocca 1.5R nemmeno")
|
||||||
|
print(" come un random walk. Per fabbricare un WR alto serve avvicinare il target (sez.5).")
|
||||||
|
|
||||||
|
# ---------- 2) selezione cella SOLO in-sample fra le celle FADE ----------
|
||||||
|
print(f"\n{'='*100}\n### 2) SELEZIONE IN-SAMPLE (Sharpe book pre-2025) fra le celle FADE, poi hold-out")
|
||||||
|
print("=" * 100)
|
||||||
|
fade_elig = G[(G.which == "fade") & (G.n >= 30) & np.isfinite(G.shi)]
|
||||||
|
if len(fade_elig):
|
||||||
|
top = fade_elig.sort_values("shi", ascending=False).head(8)
|
||||||
|
print(top[["tf", "k", "s", "d", "scheme", "n", "wr", "expR", "shi", "shf", "shh",
|
||||||
|
"wrh", "expRH"]].to_string(index=False))
|
||||||
|
best = fade_elig.sort_values("shi", ascending=False).iloc[0]
|
||||||
|
bkey = (best.tf, best.k, best.s, best.d, "fade", best.scheme)
|
||||||
|
bd = detail[bkey]
|
||||||
|
print(f"\n CELLA SCELTA (max Sh IN-SAMPLE): tf={best.tf} k={best.k} s={best.s} d={best.d} "
|
||||||
|
f"scheme={best.scheme}")
|
||||||
|
print(f" FULL: n={bd['st_f']['n']} WR={bd['st_f']['wr']:.1f}% expR={bd['st_f']['exp_R']:+.2f} "
|
||||||
|
f"Sh={bd['shf']:+.2f} DD={bd['ddf']*100:.1f}%")
|
||||||
|
print(f" IS : n={bd['st_i']['n']} WR={bd['st_i']['wr']:.1f}% expR={bd['st_i']['exp_R']:+.2f}")
|
||||||
|
print(f" HOLD: n={bd['st_h']['n']} WR={bd['st_h']['wr']:.1f}% expR={bd['st_h']['exp_R']:+.2f} "
|
||||||
|
f"Sh={bd['shh']:+.2f}")
|
||||||
|
|
||||||
|
# ---------- 3) DSR su tutti i trial ----------
|
||||||
|
valid = [x for x in all_trial_sh if np.isfinite(x)]
|
||||||
|
dsr, sr0 = al.deflated_sharpe(bd["shf"], valid, bd["book"].values)
|
||||||
|
print(f"\n### 3) DEFLATED SHARPE (n_trials={len(valid)}): DSR={dsr:.3f} "
|
||||||
|
f"(null max Sh atteso {sr0:.2f}) -> {'PASS' if dsr >= 0.95 else 'FAIL'} (soglia 0.95)")
|
||||||
|
|
||||||
|
# ---------- 4) fee sweep sulla cella scelta ----------
|
||||||
|
print(f"\n### 4) FEE SWEEP (cella scelta, {best.scheme}) — expectancy R netto / Sharpe book")
|
||||||
|
pats_by_asset = {a: detect_patterns(al.get(a, HTF), best.k, best.s) for a in ASSETS}
|
||||||
|
for fr in FEE_SWEEP:
|
||||||
|
per, pooled = [], []
|
||||||
|
for a in ASSETS:
|
||||||
|
L = lowcache[(a, best.tf)]
|
||||||
|
tr = build_trades(pats_by_asset[a], L, best.d, best.scheme, None, fee_rt=fr)
|
||||||
|
pooled += tr
|
||||||
|
per.append(daily_series(seq_filter(tr),
|
||||||
|
(int(L.ts[0]), int(L.tsclose[-1])), fee_rt=fr))
|
||||||
|
J = pd.concat(per, axis=1).fillna(0.0)
|
||||||
|
bk = 0.5 * J.iloc[:, 0] + 0.5 * J.iloc[:, 1]
|
||||||
|
R_net = np.array([t["gR"] - fr / t["risk"] if t["risk"] > 0 else t["gR"] for t in pooled])
|
||||||
|
print(f" fee {fr*1e4:3.0f}bps: expR={R_net.mean():+.2f} ShFULL={al._sh(bk):+.2f}")
|
||||||
|
else:
|
||||||
|
print(" Nessuna cella FADE eleggibile (>=30 trade IS).")
|
||||||
|
|
||||||
|
# ---------- 5) IL WIN-RATE E' UN KNOB: sweep del target parziale (managed) ----------
|
||||||
|
print(f"\n{'='*100}\n### 5) IL WIN-RATE E' UN KNOB — managed, sweep del target parziale rr1")
|
||||||
|
print("Gambler's ruin senza edge: WR = P(+rr1 prima di -1R) = 1/(1+rr1). Avvicina il target -> WR sale.")
|
||||||
|
print("Se WR sale verso ~74% MA l'expectancy R resta <=0, il 74% e' la scelta del target, non un edge.")
|
||||||
|
print("=" * 100)
|
||||||
|
print(f" {'rr1':>4s} | {'WR%':>6s} {'null 1/(1+rr1)':>14s} | {'expR(fee10)':>11s} "
|
||||||
|
f"{'expR(fee0)':>10s} | {'n':>6s}")
|
||||||
|
pats_by_asset = {a: detect_patterns(al.get(a, HTF), 1.5, 0.1) for a in ASSETS}
|
||||||
|
for rr1 in (0.5, 0.75, 1.0, 1.5, 2.0):
|
||||||
|
pooled = []
|
||||||
|
for a in ASSETS:
|
||||||
|
L = lowcache[(a, "5m")]
|
||||||
|
pooled += build_trades(pats_by_asset[a], L, 0.15, "managed", None, rr1=rr1)
|
||||||
|
if not pooled:
|
||||||
|
continue
|
||||||
|
wr = np.mean([t["win"] for t in pooled]) * 100
|
||||||
|
R10 = np.mean([t["gR"] - FEE_RT / t["risk"] if t["risk"] > 0 else t["gR"] for t in pooled])
|
||||||
|
R0 = np.mean([t["gR"] for t in pooled])
|
||||||
|
print(f" {rr1:>4.2f} | {wr:>6.1f} {100/(1+rr1):>13.1f}% | {R10:>+11.2f} {R0:>+10.2f} | "
|
||||||
|
f"{len(pooled):>6d}")
|
||||||
|
print(" (fade-vs-follow direzionale gia' settlato: r0702_crt_context, FOLLOW batte FADE ogni anno)")
|
||||||
|
|
||||||
|
# ---------- 6) executability $600 ----------
|
||||||
|
print(f"\n{'='*100}\n### 6) EXECUTABILITY $600 (stop sul TF basso = zona di protezione stretta)")
|
||||||
|
print("=" * 100)
|
||||||
|
for tf_lo in LTFS:
|
||||||
|
risks = []
|
||||||
|
pats_by_asset = {a: detect_patterns(al.get(a, HTF), 1.2, 0.1) for a in ASSETS}
|
||||||
|
for a in ASSETS:
|
||||||
|
L = lowcache[(a, tf_lo)]
|
||||||
|
for t in build_trades(pats_by_asset[a], L, 0.25, "fix1.5", None):
|
||||||
|
risks.append(t["risk"] * 100)
|
||||||
|
if risks:
|
||||||
|
risks = np.array(risks)
|
||||||
|
med = float(np.median(risks))
|
||||||
|
print(f" {tf_lo}: stopMed {med:.3f}% (p25 {np.percentile(risks,25):.3f}/"
|
||||||
|
f"p75 {np.percentile(risks,75):.3f}) -> a CAP {LEV_CAP}x rischio/trade "
|
||||||
|
f"{LEV_CAP*med:.2f}% (${CAPITAL*LEV_CAP*med/100:.2f}); nozionale ${CAPITAL*LEV_CAP:.0f} "
|
||||||
|
f">min ${MIN_ORDER}. NB parziali/BE/runner = 3-4 ordini/trade, alcuni <${MIN_ORDER}.")
|
||||||
|
|
||||||
|
print(f"\n[done in {time.time()-t0:.0f}s]")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+24
-6
@@ -13,7 +13,11 @@ deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01):
|
|||||||
tp_frac = target TP01 (causale, >=0, long-flat) da TrendPortfolio.current_target
|
tp_frac = target TP01 (causale, >=0, long-flat) da TrendPortfolio.current_target
|
||||||
skh_sign = +1 long / -1 short / 0 flat da _skyhook_positions (book 230m)
|
skh_sign = +1 long / -1 short / 0 flat da _skyhook_positions (book 230m)
|
||||||
E = equity reale del conto (fallback paper se offline)
|
E = equity reale del conto (fallback paper se offline)
|
||||||
CAP = max_notional_per_asset_usd (config/live.json, $300)
|
CAP = cap notional per-asset. Fisso = max_notional_per_asset_usd ($300), OPPURE
|
||||||
|
DINAMICO = equity * max_notional_per_asset_frac (es. 0.5 -> equity/2) se la
|
||||||
|
frazione e' presente in config -> il cap cresce col capitale e non strozza il
|
||||||
|
book quando depositi (frontiera 2026-07-03). Dinamico SOLO con equity reale
|
||||||
|
fidata; su fallback/offline si torna al cap fisso (protezione downside).
|
||||||
|
|
||||||
GLI EXIT DI SKH01 SONO IMPLICITI: _skyhook_positions() replica il book (ingressi + SL/TP/max_bars +
|
GLI EXIT DI SKH01 SONO IMPLICITI: _skyhook_positions() replica il book (ingressi + SL/TP/max_bars +
|
||||||
non-overlap) sulla feed certificata fresca -> quando il trade va chiuso ritorna 'flat' -> skh_sign=0
|
non-overlap) sulla feed certificata fresca -> quando il trade va chiuso ritorna 'flat' -> skh_sign=0
|
||||||
@@ -42,9 +46,21 @@ W_SKH = 0.25
|
|||||||
FLAT_USD = 1.0
|
FLAT_USD = 1.0
|
||||||
|
|
||||||
|
|
||||||
def _cap() -> float:
|
def _cap(equity: float | None = None, real_equity: float | None = None,
|
||||||
|
eq_fallback: str | None = None) -> float:
|
||||||
|
"""Cap notional per-asset ($). Se in config c'e' `max_notional_per_asset_frac` (opt-in), il cap
|
||||||
|
diventa DINAMICO = equity * frac (es. equity/2) -> cresce col capitale, cosi' un deposito non
|
||||||
|
resta strozzato dal $300 fisso (vedi frontiera 2026-07-03). GUARDRAIL: il cap dinamico si usa
|
||||||
|
SOLO con equity reale fidata (letta dal conto); se l'equity reale non e' leggibile
|
||||||
|
(eq_fallback/offline, real_equity=None) si torna al cap FISSO `max_notional_per_asset_usd`, che
|
||||||
|
protegge il downside quando l'equity e' ignota. Senza `..._frac` in config -> sempre cap fisso."""
|
||||||
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
|
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
|
||||||
return float(cfg.get("max_notional_per_asset_usd", 300.0))
|
fixed = float(cfg.get("max_notional_per_asset_usd", 300.0))
|
||||||
|
frac = cfg.get("max_notional_per_asset_frac")
|
||||||
|
if frac is None:
|
||||||
|
return fixed
|
||||||
|
trusted = (real_equity is not None) and (not eq_fallback) and (equity is not None) and (equity > 0)
|
||||||
|
return float(equity) * float(frac) if trusted else fixed
|
||||||
|
|
||||||
|
|
||||||
def book_net_target(tp_frac: float, skh_sign: int, equity: float, cap: float,
|
def book_net_target(tp_frac: float, skh_sign: int, equity: float, cap: float,
|
||||||
@@ -99,17 +115,18 @@ def book_report(offline: bool = False, equity_override: float | None = None,
|
|||||||
segnale 230m all'ultima barra chiusa, per l'esecuzione reale. Default False (feed certificato,
|
segnale 230m all'ultima barra chiusa, per l'esecuzione reale. Default False (feed certificato,
|
||||||
deterministico: dashboard/test). TP01 resta sul certificato (è giornaliero)."""
|
deterministico: dashboard/test). TP01 resta sul certificato (è giornaliero)."""
|
||||||
sh = shadow_report(offline=offline, equity_override=equity_override)
|
sh = shadow_report(offline=offline, equity_override=equity_override)
|
||||||
cap = _cap()
|
|
||||||
equity = sh["equity"]
|
equity = sh["equity"]
|
||||||
|
cap = _cap(equity=equity, real_equity=sh.get("real_equity"), eq_fallback=sh.get("eq_fallback"))
|
||||||
load5m = None
|
load5m = None
|
||||||
if live_feed:
|
if live_feed:
|
||||||
from src.live.livefeed import fresh_5m
|
from src.live.livefeed import fresh_5m
|
||||||
load5m = fresh_5m
|
load5m = fresh_5m
|
||||||
|
skh_error = None
|
||||||
try:
|
try:
|
||||||
skh = _skyhook_positions(load5m=load5m)
|
skh = _skyhook_positions(load5m=load5m)
|
||||||
except Exception as e: # non bloccare il report se il feed SKH fallisce
|
except Exception as e: # non bloccare il report se il feed SKH fallisce
|
||||||
skh = {a: "flat" for a in ASSETS}
|
skh = {a: "flat" for a in ASSETS}
|
||||||
sh = {**sh, "skh_error": f"{type(e).__name__}: {e}"}
|
skh_error = f"{type(e).__name__}: {e}" # forzato flat -> ESPORRE (sennò flat silenzioso)
|
||||||
|
|
||||||
assets, orders = [], []
|
assets, orders = [], []
|
||||||
for a_rec in sh["assets"]:
|
for a_rec in sh["assets"]:
|
||||||
@@ -135,6 +152,7 @@ def book_report(offline: bool = False, equity_override: float | None = None,
|
|||||||
last_data=sh["last_data"], online=sh["online"],
|
last_data=sh["last_data"], online=sh["online"],
|
||||||
real_equity=sh["real_equity"], equity=equity, eq_basis=sh["eq_basis"],
|
real_equity=sh["real_equity"], equity=equity, eq_basis=sh["eq_basis"],
|
||||||
cap_per_asset=cap, weights=dict(TP01=W_TP01, SKH01=W_SKH),
|
cap_per_asset=cap, weights=dict(TP01=W_TP01, SKH01=W_SKH),
|
||||||
assets=assets, orders=orders,
|
assets=assets, orders=orders, skh_error=skh_error, pos_error=sh.get("pos_error"),
|
||||||
|
eq_fallback=sh.get("eq_fallback"),
|
||||||
flat=all(abs(x["net_target"]) < FLAT_USD for x in assets),
|
flat=all(abs(x["net_target"]) < FLAT_USD for x in assets),
|
||||||
)
|
)
|
||||||
|
|||||||
+37
-1
@@ -71,6 +71,20 @@ def build():
|
|||||||
freq = book_trade_frequency() # trade/anno contati (SKH01 round-trip + TP01 turnover)
|
freq = book_trade_frequency() # trade/anno contati (SKH01 round-trip + TP01 turnover)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
freq = {"error": f"{type(e).__name__}: {e}"}
|
freq = {"error": f"{type(e).__name__}: {e}"}
|
||||||
|
# ALERT operativi del book live (health): pos/equity dallo shadow gia' fetchato, feed SKH da book_report.
|
||||||
|
book_alerts = {}
|
||||||
|
if shadow and "error" not in shadow:
|
||||||
|
if shadow.get("pos_error"):
|
||||||
|
book_alerts["pos_error"] = shadow["pos_error"]
|
||||||
|
if shadow.get("eq_fallback"):
|
||||||
|
book_alerts["eq_fallback"] = shadow["eq_fallback"]
|
||||||
|
try:
|
||||||
|
from src.live.book import book_report
|
||||||
|
skh_err = book_report(offline=True).get("skh_error") # feed certificato (deterministico), no rete extra
|
||||||
|
if skh_err:
|
||||||
|
book_alerts["skh_error"] = skh_err
|
||||||
|
except Exception as e:
|
||||||
|
book_alerts["book_report"] = f"{type(e).__name__}: {e}"
|
||||||
data = dict(
|
data = dict(
|
||||||
version=APP_VERSION,
|
version=APP_VERSION,
|
||||||
last_data=str(idx[-1].date()),
|
last_data=str(idx[-1].date()),
|
||||||
@@ -78,7 +92,7 @@ def build():
|
|||||||
per_sleeve=bt["per_sleeve"], yearly=bt["yearly"],
|
per_sleeve=bt["per_sleeve"], yearly=bt["yearly"],
|
||||||
positions=pf.current_positions(), spark=spark, paper=paper, prevday=prevday,
|
positions=pf.current_positions(), spark=spark, paper=paper, prevday=prevday,
|
||||||
combo=combo, statarb=statarb, gtaa_weights=gtaa_w, deribit=deribit,
|
combo=combo, statarb=statarb, gtaa_weights=gtaa_w, deribit=deribit,
|
||||||
shadow=shadow, trades=trades, freq=freq, bh=None,
|
shadow=shadow, trades=trades, freq=freq, bh=None, book_alerts=book_alerts,
|
||||||
)
|
)
|
||||||
_CACHE.update(t=time.time(), data=data)
|
_CACHE.update(t=time.time(), data=data)
|
||||||
return data
|
return data
|
||||||
@@ -97,6 +111,27 @@ def svg_spark(spark, w=900, h=220):
|
|||||||
f'<polyline fill="none" stroke="#2ecc71" stroke-width="2" points="{" ".join(pts)}"/></svg>')
|
f'<polyline fill="none" stroke="#2ecc71" stroke-width="2" points="{" ".join(pts)}"/></svg>')
|
||||||
|
|
||||||
|
|
||||||
|
_ALERT_LABELS = {
|
||||||
|
"skh_error": "⚠️ Feed SKH01 fallito (sleeve forzato flat)",
|
||||||
|
"pos_error": "🛑 Posizione reale non leggibile (esecuzione BLOCCATA)",
|
||||||
|
"eq_fallback": "⚠️ Equity reale non leggibile (sizing su paper_cap)",
|
||||||
|
"book_report": "🛑 Book report non calcolabile",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _alerts_banner(book_alerts: dict) -> str:
|
||||||
|
"""Banner HTML degli alert operativi del book live (health). Verde se nessuno.
|
||||||
|
Rispecchia esattamente i tre segnali che book_execute manda su Telegram/cron."""
|
||||||
|
if book_alerts:
|
||||||
|
rows = "<br>".join(f"<b>{_ALERT_LABELS.get(k, k)}:</b> <span class=warn>{v}</span>"
|
||||||
|
for k, v in book_alerts.items())
|
||||||
|
return (f"<div class=box style='border-color:#e74c3c;background:#241416'>"
|
||||||
|
f"<b class=r>⚠ ALERT OPERATIVI BOOK LIVE</b><br>{rows}</div>")
|
||||||
|
return ("<div class=box style='border-color:#1f7a3f'>"
|
||||||
|
"<span class=g>✓ Book live: nessun alert</span> "
|
||||||
|
"<span style='color:#8a93a0;font-size:12px'>(feed SKH ok · posizione leggibile · equity reale ok)</span></div>")
|
||||||
|
|
||||||
|
|
||||||
def html():
|
def html():
|
||||||
d = build()
|
d = build()
|
||||||
f, ho = d["full"], d["holdout"]
|
f, ho = d["full"], d["holdout"]
|
||||||
@@ -269,6 +304,7 @@ th{{color:#8a93a0;font-weight:500}}.y{{display:inline-block;background:#161b22;b
|
|||||||
<div class=sub>monitor · v{d['version']} · ultimo dato {d['last_data']} · esecuzione REALE non attiva (solo micro-test) · ordine: LIVE → trades fatti → storico</div>
|
<div class=sub>monitor · v{d['version']} · ultimo dato {d['last_data']} · esecuzione REALE non attiva (solo micro-test) · ordine: LIVE → trades fatti → storico</div>
|
||||||
|
|
||||||
<div class="section live">① LIVE — Deribit mainnet (conto reale, sola lettura)</div>
|
<div class="section live">① LIVE — Deribit mainnet (conto reale, sola lettura)</div>
|
||||||
|
{_alerts_banner(d.get("book_alerts") or {})}
|
||||||
<div class=box><b>Shadow TP01</b> (cosa farebbe ORA sul conto reale, nessun ordine inviato):<br>{shadow_html}</div>
|
<div class=box><b>Shadow TP01</b> (cosa farebbe ORA sul conto reale, nessun ordine inviato):<br>{shadow_html}</div>
|
||||||
<p class=warn>Solo TP01 e' armato live (cap $300/asset · disaster-SL on-book −30% · capitale reale ≈ $600). SKH01/XS01/VRP01 = paper/stat-mode. Lo "Shadow" mostra il target reale ma NON invia ordini.</p>
|
<p class=warn>Solo TP01 e' armato live (cap $300/asset · disaster-SL on-book −30% · capitale reale ≈ $600). SKH01/XS01/VRP01 = paper/stat-mode. Lo "Shadow" mostra il target reale ma NON invia ordini.</p>
|
||||||
|
|
||||||
|
|||||||
+17
-5
@@ -72,14 +72,18 @@ def _marks(client, dfs):
|
|||||||
|
|
||||||
def _positions(client):
|
def _positions(client):
|
||||||
if client is None:
|
if client is None:
|
||||||
return {a: 0.0 for a in ASSETS}, "offline -> assunto flat"
|
return {a: 0.0 for a in ASSETS}, "offline -> assunto flat", None
|
||||||
pos, note = {}, "mainnet"
|
pos, note, failed = {}, "mainnet", []
|
||||||
for a in ASSETS:
|
for a in ASSETS:
|
||||||
try:
|
try:
|
||||||
pos[a] = client.position_usd(INSTRUMENT[a])
|
pos[a] = client.position_usd(INSTRUMENT[a])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pos[a], note = 0.0, f"read fallito ({type(e).__name__}) -> assunto flat"
|
pos[a], note = 0.0, f"read fallito ({type(e).__name__}) -> assunto flat"
|
||||||
return pos, note
|
failed.append(f"{a} ({type(e).__name__}: {e})")
|
||||||
|
# pos_error != None SOLO se ONLINE ma la read ha lanciato: la posizione e' IGNOTA (assunta flat) ->
|
||||||
|
# l'esecutore NON deve operare a cieco. L'offline (client=None) e' gestito a parte dal gate 'online'.
|
||||||
|
err = ("posizione non leggibile, assunta FLAT: " + ", ".join(failed)) if failed else None
|
||||||
|
return pos, note, err
|
||||||
|
|
||||||
|
|
||||||
def _equity(client, marks):
|
def _equity(client, marks):
|
||||||
@@ -101,6 +105,8 @@ def _equity(client, marks):
|
|||||||
pass
|
pass
|
||||||
if any_ok and tot > 1:
|
if any_ok and tot > 1:
|
||||||
return tot, "mainnet coin-margined"
|
return tot, "mainnet coin-margined"
|
||||||
|
# real_eq=None qui -> shadow_report ripiega su paper_cap e alza eq_fallback (diagnostica B):
|
||||||
|
# segnalato a book_execute (alert), non blocca (l'hard-cap $300/asset limita il downside).
|
||||||
return None, "conto flat / non finanziato"
|
return None, "conto flat / non finanziato"
|
||||||
|
|
||||||
|
|
||||||
@@ -113,7 +119,7 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
|
|||||||
|
|
||||||
client = None if offline else _safe_client()
|
client = None if offline else _safe_client()
|
||||||
marks, marks_src = _marks(client, dfs)
|
marks, marks_src = _marks(client, dfs)
|
||||||
positions, pos_src = _positions(client)
|
positions, pos_src, pos_error = _positions(client)
|
||||||
real_eq, eq_src = _equity(client, marks)
|
real_eq, eq_src = _equity(client, marks)
|
||||||
|
|
||||||
paper = json.loads(PAPER_STATE.read_text()) if PAPER_STATE.exists() else None
|
paper = json.loads(PAPER_STATE.read_text()) if PAPER_STATE.exists() else None
|
||||||
@@ -124,6 +130,11 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
|
|||||||
equity = equity_override if equity_override is not None else (real_eq if real_eq else paper_cap)
|
equity = equity_override if equity_override is not None else (real_eq if real_eq else paper_cap)
|
||||||
eq_basis = ("override" if equity_override is not None
|
eq_basis = ("override" if equity_override is not None
|
||||||
else eq_src if real_eq else "paper capital (ipotetico: conto non finanziato)")
|
else eq_src if real_eq else "paper capital (ipotetico: conto non finanziato)")
|
||||||
|
# Opzione B (diagnostica): ONLINE ma equity reale non leggibile -> sizing su paper_cap invece del
|
||||||
|
# conto reale. NON blocca (l'hard-cap $300/asset limita il downside), ma va SEGNALATO.
|
||||||
|
eq_fallback = (f"equity reale non leggibile ({eq_src}) -> sizing su paper_cap ${paper_cap:,.0f} "
|
||||||
|
f"invece del conto reale") if (equity_override is None and client is not None
|
||||||
|
and not real_eq) else None
|
||||||
|
|
||||||
assets, orders = [], []
|
assets, orders = [], []
|
||||||
for a in ASSETS:
|
for a in ASSETS:
|
||||||
@@ -172,7 +183,8 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
|
|||||||
last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()),
|
last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()),
|
||||||
online=(client is not None and marks_src.get("BTC") == "mainnet"),
|
online=(client is not None and marks_src.get("BTC") == "mainnet"),
|
||||||
real_equity=real_eq, equity=equity, eq_basis=eq_basis,
|
real_equity=real_eq, equity=equity, eq_basis=eq_basis,
|
||||||
pos_src=pos_src, assets=assets, orders=orders, live_trades=live_trades,
|
pos_src=pos_src, pos_error=pos_error, eq_fallback=eq_fallback,
|
||||||
|
assets=assets, orders=orders, live_trades=live_trades,
|
||||||
disaster_sls=disaster_sls,
|
disaster_sls=disaster_sls,
|
||||||
flat=all(abs(targets[a]) < 1e-9 for a in ASSETS),
|
flat=all(abs(targets[a]) < 1e-9 for a in ASSETS),
|
||||||
paper_aligned=(paper_ts == last_ts),
|
paper_aligned=(paper_ts == last_ts),
|
||||||
|
|||||||
+90
-14
@@ -106,6 +106,94 @@ def rebalance_sim(daily_cols: dict[str, pd.Series], weights: dict,
|
|||||||
n_rebalances=n_rebal, period_days=period_days, cost_rate=cost_rate)
|
n_rebalances=n_rebal, period_days=period_days, cost_rate=cost_rate)
|
||||||
|
|
||||||
|
|
||||||
|
def combine_outer(daily_cols: dict[str, pd.Series], weights: dict,
|
||||||
|
lo=None, hi=None) -> pd.Series:
|
||||||
|
"""Combina serie GIORNALIERE per peso con OUTER-join e rinormalizzazione per-giorno
|
||||||
|
(stessa semantica di StrategyPortfolio.combined_daily, ma su pesi arbitrari —
|
||||||
|
riusabile per studi di sensibilità/tilt senza istanziare il portafoglio)."""
|
||||||
|
J = pd.concat(daily_cols, axis=1, join="outer").sort_index()
|
||||||
|
wv = np.array([weights[c] for c in J.columns], float)
|
||||||
|
active = J.notna().values * wv # peso solo dove c'e' dato
|
||||||
|
rowsum = active.sum(axis=1, keepdims=True)
|
||||||
|
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
|
||||||
|
combo = pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
|
||||||
|
combo = combo[J.notna().any(axis=1).values] # togli i giorni senza alcun dato
|
||||||
|
if lo is not None:
|
||||||
|
combo = combo[combo.index >= lo]
|
||||||
|
if hi is not None:
|
||||||
|
combo = combo[combo.index < hi]
|
||||||
|
return combo
|
||||||
|
|
||||||
|
|
||||||
|
def weights_tilt_null(daily_cols: dict[str, pd.Series], w_current: dict, w_proposed: dict,
|
||||||
|
*, caps: dict | None = None, floor: float = 0.05, n: int = 500,
|
||||||
|
seed: int = 20260701, holdout: pd.Timestamp = HOLDOUT,
|
||||||
|
k_seen: int | None = None) -> dict:
|
||||||
|
"""GATE per ogni proposta di CAMBIO PESI del portafoglio (lezione 2026-07-01, EW-STR refutato).
|
||||||
|
|
||||||
|
Un tilt che "batte i pesi correnti sull'hold-out" non è informativo di per sé: se metà dei
|
||||||
|
tilt casuali dentro i vincoli batte CURRENT sull'hold-out, il claim è generico; e se il tilt
|
||||||
|
proposto siede al percentile ~k/(k+1) fra i tilt casuali (k = configurazioni viste
|
||||||
|
sull'hold-out durante la ricerca), l'uplift è indistinguibile dal *best-of-k scelto
|
||||||
|
sull'hold-out* (selezione di 2° ordine). Vedi diario 2026-07-01-portfolio-weights-ddguard.md.
|
||||||
|
|
||||||
|
daily_cols: serie di rendimenti GIORNALIERI per sleeve (es. {s.name: s.daily()}).
|
||||||
|
caps: peso massimo per sleeve (es. {"VRP01": 0.15, "XS01": 0.25}); floor: peso minimo comune.
|
||||||
|
k_seen: quante configurazioni di pesi sono state guardate sull'hold-out durante la ricerca.
|
||||||
|
|
||||||
|
REGOLA (gate_pass): un cambio pesi si applica solo se
|
||||||
|
(1) delta_insample >= 0 — non deve PERDERE risk-adjusted pre-holdout;
|
||||||
|
(2) pctl_hold < 100*k/(k+1) (o < 80 se k_seen ignoto) — sotto la firma best-of-k.
|
||||||
|
Il gate è NECESSARIO, non sufficiente: restano richiesti finestre OOS disgiunte e realismo
|
||||||
|
(pesi effettivi post-rinormalizzazione, eseguibilità degli sleeve a peso aumentato)."""
|
||||||
|
names = list(daily_cols)
|
||||||
|
caps_v = np.array([(caps or {}).get(nm, 1.0) for nm in names], float)
|
||||||
|
|
||||||
|
def _sh(s: pd.Series, lo=None, hi=None) -> float:
|
||||||
|
v = s
|
||||||
|
if lo is not None:
|
||||||
|
v = v[v.index >= lo]
|
||||||
|
if hi is not None:
|
||||||
|
v = v[v.index < hi]
|
||||||
|
r = np.asarray(v.dropna().values, float)
|
||||||
|
return float(r.mean() / r.std() * np.sqrt(DAYS_PER_YEAR)) if len(r) > 1 and r.std() > 0 else 0.0
|
||||||
|
|
||||||
|
def _wvec(wd: dict) -> np.ndarray:
|
||||||
|
w = np.array([wd[nm] for nm in names], float)
|
||||||
|
return w / w.sum()
|
||||||
|
|
||||||
|
# campiona n pesi casuali uniformi sul simplesso, dentro floor/caps (rejection sampling)
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
samples, tries = [], 0
|
||||||
|
while len(samples) < n:
|
||||||
|
batch = rng.dirichlet(np.ones(len(names)), size=max(4 * n, 256))
|
||||||
|
ok = (batch >= floor).all(axis=1) & (batch <= caps_v).all(axis=1)
|
||||||
|
samples.extend(batch[ok])
|
||||||
|
tries += 1
|
||||||
|
if tries > 200:
|
||||||
|
raise RuntimeError("weights_tilt_null: vincoli floor/caps troppo stretti (acceptance ~0)")
|
||||||
|
S = np.array(samples[:n])
|
||||||
|
|
||||||
|
sh_cur_hold = _sh(combine_outer(daily_cols, dict(zip(names, _wvec(w_current)))), lo=holdout)
|
||||||
|
d_hold_rand = np.array([
|
||||||
|
_sh(combine_outer(daily_cols, dict(zip(names, w))), lo=holdout) - sh_cur_hold for w in S])
|
||||||
|
|
||||||
|
wp = dict(zip(names, _wvec(w_proposed)))
|
||||||
|
wc = dict(zip(names, _wvec(w_current)))
|
||||||
|
d_hold = _sh(combine_outer(daily_cols, wp), lo=holdout) - sh_cur_hold
|
||||||
|
d_full = _sh(combine_outer(daily_cols, wp)) - _sh(combine_outer(daily_cols, wc))
|
||||||
|
d_is = _sh(combine_outer(daily_cols, wp, hi=holdout)) - _sh(combine_outer(daily_cols, wc, hi=holdout))
|
||||||
|
|
||||||
|
pctl_hold = float((d_hold_rand < d_hold).mean() * 100.0)
|
||||||
|
bestofk = float(100.0 * k_seen / (k_seen + 1)) if k_seen else None
|
||||||
|
gate_pass = bool(d_is >= 0.0 and pctl_hold < (bestofk if bestofk is not None else 80.0))
|
||||||
|
return dict(delta_hold=round(d_hold, 4), delta_full=round(d_full, 4),
|
||||||
|
delta_insample=round(d_is, 4),
|
||||||
|
frac_random_beat_hold=round(float((d_hold_rand > 0).mean()), 3),
|
||||||
|
pctl_hold=round(pctl_hold, 1), bestofk_pctl=bestofk,
|
||||||
|
gate_pass=gate_pass, n_samples=int(len(S)), samples=S)
|
||||||
|
|
||||||
|
|
||||||
class StrategyPortfolio:
|
class StrategyPortfolio:
|
||||||
def __init__(self, sleeves: list[Sleeve], capital: float = 2000.0):
|
def __init__(self, sleeves: list[Sleeve], capital: float = 2000.0):
|
||||||
if not sleeves:
|
if not sleeves:
|
||||||
@@ -124,20 +212,8 @@ class StrategyPortfolio:
|
|||||||
(es. TP01 dal 2019, uno nuovo dal 2024) -> ogni giorno i pesi sono RINORMALIZZATI
|
(es. TP01 dal 2019, uno nuovo dal 2024) -> ogni giorno i pesi sono RINORMALIZZATI
|
||||||
fra i soli sleeve con dato disponibile (uno sleeve "si attiva" quando parte la sua
|
fra i soli sleeve con dato disponibile (uno sleeve "si attiva" quando parte la sua
|
||||||
storia). Cosi' non si tronca il portafoglio alla finestra comune."""
|
storia). Cosi' non si tronca il portafoglio alla finestra comune."""
|
||||||
w = self.weights()
|
return combine_outer({s.name: s.daily() for s in self.sleeves}, self.weights(),
|
||||||
cols = {s.name: s.daily() for s in self.sleeves}
|
lo=lo, hi=hi)
|
||||||
J = pd.concat(cols, axis=1, join="outer").sort_index()
|
|
||||||
wv = np.array([w[c] for c in J.columns], float)
|
|
||||||
active = J.notna().values * wv # peso solo dove c'e' dato
|
|
||||||
rowsum = active.sum(axis=1, keepdims=True)
|
|
||||||
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
|
|
||||||
combo = pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
|
|
||||||
combo = combo[J.notna().any(axis=1).values] # togli i giorni senza alcun dato
|
|
||||||
if lo is not None:
|
|
||||||
combo = combo[combo.index >= lo]
|
|
||||||
if hi is not None:
|
|
||||||
combo = combo[combo.index < hi]
|
|
||||||
return combo
|
|
||||||
|
|
||||||
def backtest(self) -> dict:
|
def backtest(self) -> dict:
|
||||||
full = self.combined_daily()
|
full = self.combined_daily()
|
||||||
|
|||||||
@@ -277,6 +277,42 @@ def skyhook_sleeve(weight: float = 0.25) -> Sleeve:
|
|||||||
return Sleeve("SKH01_skyhook", weight, _skyhook_returns, pos_fn=_skyhook_positions)
|
return Sleeve("SKH01_skyhook", weight, _skyhook_returns, pos_fn=_skyhook_positions)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- GTAA01: trend difensivo equity multi-asset (IB) -----------------------------
|
||||||
|
# EQ-GTAA01 (src/portfolio/gtaa.py): TSMOM long-flat multi-orizzonte [21/63/126/252g] per-ETF,
|
||||||
|
# vol-target 12%, EW su SPY/QQQ/IWM/TLT/GLD/HYG, netto fee IB 2bps/lato. ~30 anni di storia
|
||||||
|
# (1996+), validato su OOS 2015+ PRIMA e INDIPENDENTEMENTE dall'hold-out crypto (diari
|
||||||
|
# 2026-06-22-equities-gtaa-and-crypto-combo / -deployable-combo-tp01-gtaa). Diversificatore
|
||||||
|
# STRUTTURALE del book crypto: corr ~+0.10 (hold-out ~0.00), is_hedge no (+0.30).
|
||||||
|
# Promosso a 5o sleeve il 2026-07-01 (diario 2026-07-01-strategy-wave-6threads.md, addendum GTAA):
|
||||||
|
# uplift positivo IN-SAMPLE (+0.13) e su TUTTE le finestre disgiunte (2019-23 +0.05, 23-24 +0.19,
|
||||||
|
# 25+ +0.25), multi-cut +0.21..+0.25, plateau monotono su w 10-30%. A differenza di EW-STR
|
||||||
|
# (refutato lo stesso giorno) NON e' selezione-sull'hold-out: l'edge di GTAA e' pre-esistente.
|
||||||
|
# CONVENZIONE CROSS-VENUE ONESTA: nei giorni senza barra equity (weekend/festivi US) il rendimento
|
||||||
|
# e' 0.0 (capitale IB fermo), NON rinormalizzato sugli altri sleeve: due conti reali non si
|
||||||
|
# travasano nel weekend. ESEGUIBILE a basso capitale (ETF frazionali, switch mensile, gia' in
|
||||||
|
# paper_combo dal 2026-06-23). CAVEAT: cross-venue = due conti (Deribit+IB); il book live Deribit
|
||||||
|
# (deribit_book_sleeves) NON lo include.
|
||||||
|
from src.portfolio.gtaa import gtaa_returns, gtaa_weights
|
||||||
|
|
||||||
|
# GTAA nel BOOK si attiva con l'era del book (inizio storia certificata BTC/ETH): senza troncatura
|
||||||
|
# l'outer-join farebbe partire il portafoglio nel 1996 con 23 anni di solo-GTAA rinormalizzato al
|
||||||
|
# 100%, distorcendo le metriche FULL. La storia 1996+ resta in gtaa.py per la ricerca.
|
||||||
|
GTAA_BOOK_ACTIVATION = pd.Timestamp("2019-03-14", tz="UTC")
|
||||||
|
|
||||||
|
|
||||||
|
def _gtaa_daily_returns() -> pd.Series:
|
||||||
|
r = gtaa_returns().dropna().sort_index()
|
||||||
|
if r.index.tz is None:
|
||||||
|
r.index = r.index.tz_localize("UTC")
|
||||||
|
days = pd.date_range(r.index.min().normalize(), r.index.max().normalize(), freq="1D", tz="UTC")
|
||||||
|
r = r.reindex(days).fillna(0.0) # weekend/festivi = capitale fermo (0), non riciclato
|
||||||
|
return r[r.index >= GTAA_BOOK_ACTIVATION]
|
||||||
|
|
||||||
|
|
||||||
|
def gtaa_sleeve(weight: float = 0.20) -> Sleeve:
|
||||||
|
return Sleeve("GTAA01_eq_trend", weight, _gtaa_daily_returns, pos_fn=gtaa_weights)
|
||||||
|
|
||||||
|
|
||||||
_TRADE_FREQ_CACHE = None
|
_TRADE_FREQ_CACHE = None
|
||||||
|
|
||||||
|
|
||||||
@@ -323,13 +359,17 @@ def active_sleeves() -> list[Sleeve]:
|
|||||||
"""Sleeve ATTIVI nel portafoglio (pesi rinormalizzati; sleeve a date diverse si attivano
|
"""Sleeve ATTIVI nel portafoglio (pesi rinormalizzati; sleeve a date diverse si attivano
|
||||||
quando parte la loro storia). Aggiungere qui SOLO strategie validate col gauntlet.
|
quando parte la loro storia). Aggiungere qui SOLO strategie validate col gauntlet.
|
||||||
|
|
||||||
SKH01 cablato @0.25 effettivo: i tre sleeve preesistenti scalati nel restante 0.75 mantenendo
|
SKH01 cablato @0.25 effettivo (2026-06-23): i tre preesistenti scalati nel restante 0.75
|
||||||
il loro rapporto 55:25:20 (-> 41.25/18.75/15), cosi' Skyhook pesa esattamente 25% del book."""
|
(rapporto 55:25:20 -> 41.25/18.75/15). GTAA01 cablato @0.20 effettivo (2026-07-01): i quattro
|
||||||
|
preesistenti scalati ancora x0.80 (-> 33/15/12/20). NB: la cella IS-best della griglia era
|
||||||
|
w=30% ma l'ingresso e' @20% per scelta STRUTTURALE dichiarata (cross-venue IB con paper_combo
|
||||||
|
giovane; precedente ingresso-sleeve <=25%; costo CAGR) — tutte le celle 10-30% passano i gate."""
|
||||||
return [
|
return [
|
||||||
tp01_sleeve(weight=0.4125), # trend difensivo, BTC/ETH, dal 2019 (l'unico deployable pieno)
|
tp01_sleeve(weight=0.33), # trend difensivo, BTC/ETH, dal 2019 (l'unico deployable pieno)
|
||||||
xsec_sleeve(weight=0.1875), # cross-sectional momentum Hyperliquid, dal 2024 (scorrelato, stat-mode)
|
xsec_sleeve(weight=0.15), # cross-sectional momentum Hyperliquid, dal 2024 (scorrelato, stat-mode)
|
||||||
vrp_sleeve(weight=0.15), # options short-vol (put credit spread + gate IV-rank), dal 2021 (lead modellato, scorrelato)
|
vrp_sleeve(weight=0.12), # options short-vol (put credit spread + gate IV-rank), dal 2021 (lead modellato, scorrelato)
|
||||||
skyhook_sleeve(weight=0.25), # dual-TF regime+breakout BTC/ETH, dal 2019 (quasi-ortogonale, exit %-asimmetrici, research)
|
skyhook_sleeve(weight=0.20), # dual-TF regime+breakout BTC/ETH, dal 2019 (quasi-ortogonale, exit %-asimmetrici, research)
|
||||||
|
gtaa_sleeve(weight=0.20), # trend difensivo equity 6-ETF (IB), dal 1996 (diversificatore strutturale, cross-venue)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ Architettura (dal brief):
|
|||||||
Entrambi resampled dal feed 5m certificato con origin='epoch' -> i confini 690 sono un
|
Entrambi resampled dal feed 5m certificato con origin='epoch' -> i confini 690 sono un
|
||||||
SOTTOINSIEME dei confini 230, quindi una barra HTF chiude esattamente su una chiusura LTF.
|
SOTTOINSIEME dei confini 230, quindi una barra HTF chiude esattamente su una chiusura LTF.
|
||||||
|
|
||||||
|
NB GRID TIMING-LUCK (2026-07-02): i numeri headline (minHold +1.26, blend 0.31->1.17, maxDD<30%)
|
||||||
|
sono sull'offset 0 della griglia epoch, il MIGLIORE dei 23 offset a priori (93-98mo pctl; mediane:
|
||||||
|
minHold +0.39, blend 0.72; il gate DD<30% fallisce in 15/23 offset). Regge de-luckato: uplift del
|
||||||
|
blend positivo a TUTTE le 23 fasi (min +0.18) + corr ~0.08 -> diversificatore ADDS ridimensionato.
|
||||||
|
Vedi docs/diary/2026-07-02-anchor-audit-xs01-skh01.md e scripts/research/r0702_anchor_skh01.py.
|
||||||
|
|
||||||
Pipeline per barra (evaluate_bar): barre -> indicatori -> fasce regime -> pattern -> composer
|
Pipeline per barra (evaluate_bar): barre -> indicatori -> fasce regime -> pattern -> composer
|
||||||
-> ingresso/uscita -> SkyhookDecision
|
-> ingresso/uscita -> SkyhookDecision
|
||||||
1. INDICATORI (sul HTF, tipo-Chande, normalizzati 0-100):
|
1. INDICATORI (sul HTF, tipo-Chande, normalizzati 0-100):
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ fee 0.10% RT, positiva ogni anno 2019-2026, robusta su griglia e su tutti i time
|
|||||||
Config canonica deployabile (PORT LF1d):
|
Config canonica deployabile (PORT LF1d):
|
||||||
timeframe >=12h (1d RACCOMANDATO), LONG-FLAT (niente short), vol-target 20%, leverage cap 2x.
|
timeframe >=12h (1d RACCOMANDATO), LONG-FLAT (niente short), vol-target 20%, leverage cap 2x.
|
||||||
-> FULL Sharpe ~1.30, maxDD ~14%, HOLD-OUT 2025-26 Sharpe ~0.31 (calcolo per-TF leak-free).
|
-> FULL Sharpe ~1.30, maxDD ~14%, HOLD-OUT 2025-26 Sharpe ~0.31 (calcolo per-TF leak-free).
|
||||||
|
NB ANCHOR TIMING-LUCK (2026-07-02): lo 0.31 e' sull'ancora daily 00:00 UTC, la MIGLIORE
|
||||||
|
delle 24 possibili (mediana ancore 0.04, banda [-0.13,+0.30], P~0.86 di uno spike cosi'
|
||||||
|
per caso) -> l'hold-out non risolve l'edge di RITORNO; cio' che regge a OGNI ancora e' il
|
||||||
|
taglio del DD (7-10% vs ~60% B&H). FULL/plateau/gate invariati (h=0 al 31mo pctl su FULL).
|
||||||
|
Vedi docs/diary/2026-07-02-timing-crt-wave.md e scripts/research/r0702_tp01_offset.py.
|
||||||
|
|
||||||
NB LOOK-AHEAD (2026-06-19): un ffill MIXED-TIMEFRAME su barre open-labeled (label="left")
|
NB LOOK-AHEAD (2026-06-19): un ffill MIXED-TIMEFRAME su barre open-labeled (label="left")
|
||||||
gonfiava il 4h (~1.60 -> reale ~1.1). Il calcolo per-SINGOLO-TF e' leak-free (guard
|
gonfiava il 4h (~1.60 -> reale ~1.1). Il calcolo per-SINGOLO-TF e' leak-free (guard
|
||||||
|
|||||||
@@ -36,6 +36,55 @@ def test_net_target_clamps_negative():
|
|||||||
assert book_net_target(-5.0, 1, 600.0, 300.0) == 75.0
|
assert book_net_target(-5.0, 1, 600.0, 300.0) == 75.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CAP DINAMICO (frontiera 2026-07-03): con max_notional_per_asset_frac in config il
|
||||||
|
# cap = equity*frac (cresce col capitale). GUARDRAIL: dinamico SOLO con equity reale
|
||||||
|
# fidata; su fallback/offline/no-frac -> cap FISSO (protezione downside).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cfg(monkeypatch, tmp_path, **keys):
|
||||||
|
import src.live.book as book
|
||||||
|
p = tmp_path / "live.json"
|
||||||
|
p.write_text(_json.dumps(keys))
|
||||||
|
monkeypatch.setattr(book, "CONFIG", p)
|
||||||
|
return book
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_dynamic_scales_with_trusted_equity(monkeypatch, tmp_path):
|
||||||
|
book = _write_cfg(monkeypatch, tmp_path, max_notional_per_asset_usd=300, max_notional_per_asset_frac=0.5)
|
||||||
|
# equity reale fidata -> cap = equity/2 (cresce col capitale)
|
||||||
|
assert book._cap(equity=5000.0, real_equity=5000.0, eq_fallback=None) == 2500.0
|
||||||
|
assert book._cap(equity=598.0, real_equity=598.0, eq_fallback=None) == 299.0 # ~inerte a $600
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_falls_back_to_fixed_on_eq_fallback(monkeypatch, tmp_path):
|
||||||
|
book = _write_cfg(monkeypatch, tmp_path, max_notional_per_asset_usd=300, max_notional_per_asset_frac=0.5)
|
||||||
|
# equity paper (real non leggibile) -> NON usare equity/2 ($1000 su conto ignoto = pericoloso)
|
||||||
|
assert book._cap(equity=2000.0, real_equity=None, eq_fallback="paper_cap") == 300.0
|
||||||
|
assert book._cap(equity=2000.0, real_equity=None, eq_fallback=None) == 300.0 # real None -> fisso
|
||||||
|
assert book._cap(equity=0.0, real_equity=0.0, eq_fallback=None) == 300.0 # equity 0 -> fisso
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_fixed_when_no_frac_in_config(monkeypatch, tmp_path):
|
||||||
|
book = _write_cfg(monkeypatch, tmp_path, max_notional_per_asset_usd=300) # niente frac
|
||||||
|
assert book._cap(equity=5000.0, real_equity=5000.0, eq_fallback=None) == 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_book_report_dynamic_cap_online(monkeypatch, tmp_path):
|
||||||
|
"""Integrazione: online con equity reale -> cap_per_asset = equity/2 e la formula lo rispetta."""
|
||||||
|
book = _write_cfg(monkeypatch, tmp_path, max_notional_per_asset_usd=300, max_notional_per_asset_frac=0.5)
|
||||||
|
base = book.shadow_report(offline=True, equity_override=5000.0)
|
||||||
|
monkeypatch.setattr(book, "shadow_report",
|
||||||
|
lambda **k: {**base, "real_equity": 5000.0, "equity": 5000.0, "eq_fallback": None})
|
||||||
|
r = book.book_report()
|
||||||
|
assert r["cap_per_asset"] == 2500.0
|
||||||
|
for a in r["assets"]:
|
||||||
|
expect = book_net_target(a["tp_frac"], a["skh_sign"], 5000.0, 2500.0)
|
||||||
|
assert abs(a["net_target"] - expect) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
def test_weights_match_backtest_sleeves():
|
def test_weights_match_backtest_sleeves():
|
||||||
"""I pesi del book live DEVONO coincidere con quelli del backtest (deribit_book_sleeves)."""
|
"""I pesi del book live DEVONO coincidere con quelli del backtest (deribit_book_sleeves)."""
|
||||||
from src.portfolio.sleeves import deribit_book_sleeves
|
from src.portfolio.sleeves import deribit_book_sleeves
|
||||||
@@ -228,3 +277,173 @@ def test_book_report_uses_pure_formula_offline():
|
|||||||
assert a["skh_sign"] in (-1, 0, 1)
|
assert a["skh_sign"] in (-1, 0, 1)
|
||||||
# offline -> conto assunto flat -> nessuna posizione reale, report deterministico
|
# offline -> conto assunto flat -> nessuna posizione reale, report deterministico
|
||||||
assert all(a["position_usd"] == 0.0 for a in r["assets"])
|
assert all(a["position_usd"] == 0.0 for a in r["assets"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SKH feed fallito: book_report NON crasha (forza flat + flagga skh_error) e
|
||||||
|
# book_execute DEVE farlo emergere (log + alert), non ingoiarlo silenziosamente.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_book_report_flags_skh_feed_error(monkeypatch):
|
||||||
|
import src.live.book as book
|
||||||
|
monkeypatch.setattr(book, "_skyhook_positions",
|
||||||
|
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("feed 5m giu")))
|
||||||
|
r = book.book_report(offline=True, equity_override=600.0)
|
||||||
|
assert "skh_error" in r and "feed 5m giu" in r["skh_error"] # errore catturato + esposto
|
||||||
|
assert all(a["skh_sign"] == 0 for a in r["assets"]) # SKH forzato flat (fail-safe)
|
||||||
|
|
||||||
|
|
||||||
|
def test_book_execute_surfaces_skh_error(monkeypatch, capsys):
|
||||||
|
"""Se il report porta skh_error, _run() lo stampa E chiama notify (niente flat silenzioso)."""
|
||||||
|
import importlib.util
|
||||||
|
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
|
||||||
|
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||||
|
|
||||||
|
canned = dict(
|
||||||
|
last_data="2026-07-01", online=True, real_equity=600.0, equity=600.0, eq_basis="test",
|
||||||
|
cap_per_asset=300.0, skh_error="RuntimeError: feed 5m giu",
|
||||||
|
assets=[dict(asset="BTC", instrument="BTC_USDC-PERPETUAL", tp_frac=0.0, skh_sign=0,
|
||||||
|
skh_state="flat", net_target=0.0, position_usd=0.0, mark=60000.0, order=None)],
|
||||||
|
orders=[],
|
||||||
|
)
|
||||||
|
alerts = []
|
||||||
|
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||||
|
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
|
||||||
|
monkeypatch.setattr(mod, "load_config",
|
||||||
|
lambda: dict(execution_enabled=False, min_order_usd=5.0, disaster_sl_pct=0.30))
|
||||||
|
monkeypatch.setattr(sys, "argv", ["book_execute.py"]) # niente --execute -> dry-run
|
||||||
|
mod._run()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "SKH FEED ERRORE" in out # stampato nel log
|
||||||
|
assert any("SKH feed fallito" in title for title, _ in alerts) # alert inviato
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GATE FAIL-SAFE posizione: se la read della posizione reale fallisce (ONLINE ma
|
||||||
|
# posizione IGNOTA -> assunta flat), l'esecutore NON deve operare a cieco.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_positions_flags_read_failure():
|
||||||
|
from src.live.shadow import ASSETS, _positions
|
||||||
|
|
||||||
|
class BadClient:
|
||||||
|
def position_usd(self, inst):
|
||||||
|
raise RuntimeError("api 500")
|
||||||
|
|
||||||
|
pos, note, err = _positions(BadClient())
|
||||||
|
assert all(pos[a] == 0.0 for a in ASSETS) # assunta flat (fail-safe)
|
||||||
|
assert err is not None and "non leggibile" in err # ma SEGNALATA (non silenziosa)
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_ok_and_offline_have_no_error():
|
||||||
|
from src.live.shadow import ASSETS, _positions
|
||||||
|
|
||||||
|
class GoodClient:
|
||||||
|
def position_usd(self, inst):
|
||||||
|
return 123.0
|
||||||
|
|
||||||
|
_, _, err_ok = _positions(GoodClient())
|
||||||
|
_, _, err_off = _positions(None) # offline -> gestito dal gate 'online'
|
||||||
|
assert err_ok is None and err_off is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_book_report_propagates_pos_error(monkeypatch):
|
||||||
|
import src.live.book as book
|
||||||
|
base = book.shadow_report(offline=True, equity_override=600.0)
|
||||||
|
monkeypatch.setattr(book, "shadow_report", lambda **k: {**base, "pos_error": "IGNOTA"})
|
||||||
|
r = book.book_report(offline=True, equity_override=600.0)
|
||||||
|
assert r.get("pos_error") == "IGNOTA" # propagato fino al book
|
||||||
|
|
||||||
|
|
||||||
|
def test_book_execute_halts_on_unreadable_position(monkeypatch, capsys):
|
||||||
|
"""ARMATO + --execute + ordine presente: il gate DEVE fermarsi PRIMA di costruire il trader."""
|
||||||
|
import importlib.util
|
||||||
|
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
|
||||||
|
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||||
|
|
||||||
|
canned = dict(
|
||||||
|
last_data="2026-07-01", online=True, real_equity=598.0, equity=598.0, eq_basis="mainnet USDC",
|
||||||
|
cap_per_asset=300.0, skh_error=None,
|
||||||
|
pos_error="posizione non leggibile, assunta FLAT: BTC (RuntimeError: api 500)",
|
||||||
|
assets=[dict(asset="BTC", instrument="BTC_USDC-PERPETUAL", tp_frac=1.0, skh_sign=1,
|
||||||
|
skh_state="flat", net_target=225.0, position_usd=0.0, mark=60000.0,
|
||||||
|
order=dict(side="buy"))], # ordine presente: senza gate proverebbe a eseguire
|
||||||
|
orders=[dict(side="buy")],
|
||||||
|
)
|
||||||
|
alerts = []
|
||||||
|
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||||
|
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
|
||||||
|
monkeypatch.setattr(mod, "load_config",
|
||||||
|
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
|
||||||
|
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"]) # ARMATO + execute
|
||||||
|
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise AssertionError("DeribitTrader NON deve essere costruito: il gate deve fermarsi prima")
|
||||||
|
monkeypatch.setattr(mod, "DeribitTrader", boom)
|
||||||
|
|
||||||
|
mod._run()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "POSIZIONE NON LEGGIBILE" in out # stampato
|
||||||
|
assert any("posizione non leggibile" in t for t, _ in alerts) # alert inviato
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DIAGNOSTICA equity (Opzione B): ONLINE ma equity reale non leggibile -> sizing su
|
||||||
|
# paper_cap. NON blocca (l'hard-cap $/asset protegge), ma DEVE essere segnalato.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_shadow_flags_eq_fallback_when_online_but_equity_unreadable(monkeypatch):
|
||||||
|
import src.live.shadow as sh
|
||||||
|
monkeypatch.setattr(sh, "_safe_client", lambda: object()) # online
|
||||||
|
monkeypatch.setattr(sh, "_marks", lambda c, d: ({a: 60000.0 for a in sh.ASSETS},
|
||||||
|
{a: "mainnet" for a in sh.ASSETS}))
|
||||||
|
monkeypatch.setattr(sh, "_positions", lambda c: ({a: 0.0 for a in sh.ASSETS}, "mainnet", None))
|
||||||
|
monkeypatch.setattr(sh, "_equity", lambda c, m: (None, "conto flat / non finanziato")) # equity IGNOTA
|
||||||
|
r = sh.shadow_report() # online, no override
|
||||||
|
assert r["online"] is True and r["real_equity"] is None
|
||||||
|
assert r.get("eq_fallback") and "paper_cap" in r["eq_fallback"] # fallback SEGNALATO
|
||||||
|
|
||||||
|
|
||||||
|
def test_shadow_no_eq_fallback_offline_or_override():
|
||||||
|
from src.live.shadow import shadow_report
|
||||||
|
assert shadow_report(offline=True, equity_override=600.0).get("eq_fallback") is None # override
|
||||||
|
assert shadow_report(offline=True).get("eq_fallback") is None # offline != fallback-online
|
||||||
|
|
||||||
|
|
||||||
|
def test_book_report_propagates_eq_fallback(monkeypatch):
|
||||||
|
import src.live.book as book
|
||||||
|
base = book.shadow_report(offline=True, equity_override=600.0)
|
||||||
|
monkeypatch.setattr(book, "shadow_report", lambda **k: {**base, "eq_fallback": "PAPER_CAP"})
|
||||||
|
assert book.book_report(offline=True, equity_override=600.0).get("eq_fallback") == "PAPER_CAP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys):
|
||||||
|
"""eq_fallback: avvisa + notify MA PROSEGUE (non e' un halt, a differenza di pos_error)."""
|
||||||
|
import importlib.util
|
||||||
|
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
|
||||||
|
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||||
|
|
||||||
|
canned = dict(
|
||||||
|
last_data="2026-07-01", online=True, real_equity=None, equity=2000.0,
|
||||||
|
eq_basis="paper capital (ipotetico)", cap_per_asset=300.0, skh_error=None, pos_error=None,
|
||||||
|
eq_fallback="equity reale non leggibile (conto flat) -> sizing su paper_cap $2,000",
|
||||||
|
assets=[dict(asset="BTC", instrument="BTC_USDC-PERPETUAL", tp_frac=0.0, skh_sign=0,
|
||||||
|
skh_state="flat", net_target=0.0, position_usd=0.0, mark=60000.0, order=None)],
|
||||||
|
orders=[],
|
||||||
|
)
|
||||||
|
alerts = []
|
||||||
|
|
||||||
|
class FakeDT:
|
||||||
|
def ensure_disaster_sl(self, inst, sl_pct): return {"state": "flat"}
|
||||||
|
def position_usd(self, inst): return 0.0
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||||
|
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
|
||||||
|
monkeypatch.setattr(mod, "DeribitTrader", lambda: FakeDT())
|
||||||
|
monkeypatch.setattr(mod, "load_config",
|
||||||
|
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
|
||||||
|
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||||
|
|
||||||
|
mod._run()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "EQUITY FALLBACK" in out # avvisato
|
||||||
|
assert any("equity fallback" in t for t, _ in alerts) # alert inviato
|
||||||
|
assert "Nessuna azione" in out # HA PROSEGUITO (non halt)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Test del dashboard (src/live/dashboard.py) — solo la logica pura, niente rete/HTTP.
|
||||||
|
|
||||||
|
Copre il banner degli ALERT operativi del book live: i tre segnali di health (feed SKH, posizione
|
||||||
|
non leggibile, equity fallback) che book_execute manda su Telegram devono comparire anche sul
|
||||||
|
dashboard. Verde quando pulito, rosso con le righe quando in errore.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.live.dashboard import _alerts_banner
|
||||||
|
|
||||||
|
|
||||||
|
def test_banner_green_when_no_alerts():
|
||||||
|
h = _alerts_banner({})
|
||||||
|
assert "nessun alert" in h
|
||||||
|
assert "class=g" in h # stile verde
|
||||||
|
assert "ALERT OPERATIVI" not in h # nessun banner rosso
|
||||||
|
|
||||||
|
|
||||||
|
def test_banner_shows_all_three_alerts():
|
||||||
|
h = _alerts_banner({
|
||||||
|
"skh_error": "RuntimeError: feed 5m giu",
|
||||||
|
"pos_error": "posizione non leggibile, assunta FLAT: BTC",
|
||||||
|
"eq_fallback": "sizing su paper_cap $2,000",
|
||||||
|
})
|
||||||
|
assert "ALERT OPERATIVI BOOK LIVE" in h and "class=r" in h # banner rosso
|
||||||
|
# ogni alert: label descrittiva + messaggio grezzo
|
||||||
|
assert "Feed SKH01 fallito" in h and "feed 5m giu" in h
|
||||||
|
assert "Posizione reale non leggibile" in h and "assunta FLAT: BTC" in h
|
||||||
|
assert "Equity reale non leggibile" in h and "paper_cap $2,000" in h
|
||||||
|
|
||||||
|
|
||||||
|
def test_banner_partial_alert_only_shows_present():
|
||||||
|
h = _alerts_banner({"pos_error": "BTC non leggibile"})
|
||||||
|
assert "Posizione reale non leggibile" in h and "BTC non leggibile" in h
|
||||||
|
assert "Feed SKH01" not in h and "Equity reale" not in h # solo quello presente
|
||||||
|
|
||||||
|
|
||||||
|
def test_banner_unknown_key_falls_back_to_key_name():
|
||||||
|
h = _alerts_banner({"book_report": "ImportError: boom"})
|
||||||
|
assert "Book report non calcolabile" in h and "boom" in h
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Lock del 5o sleeve GTAA01 (promosso 2026-07-01) e del registry a 5 sleeve.
|
||||||
|
|
||||||
|
Pin degli invarianti decisi nel diario 2026-07-01-strategy-wave-6threads.md (addendum GTAA):
|
||||||
|
* registry = 5 sleeve, pesi 33/15/12/20/20 (somma 1), GTAA01 presente @0.20;
|
||||||
|
* convenzione cross-venue: weekend/festivi = 0.0 (capitale IB fermo, NON rinormalizzato);
|
||||||
|
* attivazione nel book all'era crypto (2019-03-14) — niente 1996-2019 di solo-GTAA;
|
||||||
|
* il book live Deribit (deribit_book_sleeves) NON include GTAA (resta TP01+SKH01 75/25).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from src.portfolio.sleeves import GTAA_BOOK_ACTIVATION, _gtaa_daily_returns, active_sleeves, deribit_book_sleeves
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_5_sleeve_pesi():
|
||||||
|
sl = active_sleeves()
|
||||||
|
w = {s.name: s.weight for s in sl}
|
||||||
|
assert len(sl) == 5
|
||||||
|
assert w == {"TP01_trend_1d": 0.33, "XS01_xsec_hl": 0.15, "VRP01_shortvol": 0.12,
|
||||||
|
"SKH01_skyhook": 0.20, "GTAA01_eq_trend": 0.20}
|
||||||
|
assert sum(w.values()) == pytest.approx(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gtaa_weekend_zero_e_attivazione():
|
||||||
|
r = _gtaa_daily_returns()
|
||||||
|
assert r.index.min() == GTAA_BOOK_ACTIVATION # niente storia pre-book nel registry
|
||||||
|
assert r.index.freqstr in ("D", "1D") or (r.index[1:] - r.index[:-1] == pd.Timedelta("1D")).all()
|
||||||
|
wknd = r[r.index.dayofweek >= 5] # sab/dom: capitale equity fermo
|
||||||
|
assert len(wknd) > 100 and float(np.abs(wknd.values).max()) == 0.0
|
||||||
|
wk = r[r.index.dayofweek < 5]
|
||||||
|
assert float(np.abs(wk.values).sum()) > 0 # nei feriali la strategia vive
|
||||||
|
|
||||||
|
|
||||||
|
def test_book_live_deribit_non_include_gtaa():
|
||||||
|
names = [s.name for s in deribit_book_sleeves()]
|
||||||
|
assert names == ["TP01_trend_1d", "SKH01_skyhook"] # esecuzione live invariata
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Test del gate weights_tilt_null (lezione 2026-07-01: EW-STR refutato come best-of-k).
|
||||||
|
|
||||||
|
Dati SINTETICI deterministici: 3 sleeve a date d'inizio diverse, di cui uno ("C") con
|
||||||
|
Sharpe gonfiato SOLO nell'hold-out — il tilt verso C deve risultare sospetto (percentile
|
||||||
|
alto fra i tilt casuali, delta_insample ~<=0), mentre un tilt nullo deve essere innocuo.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from src.portfolio.portfolio import HOLDOUT, StrategyPortfolio, Sleeve, combine_outer, weights_tilt_null
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_daily(start: str, n: int, mu: float, sigma: float, seed: int,
|
||||||
|
mu_holdout: float | None = None) -> pd.Series:
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
idx = pd.date_range(start, periods=n, freq="1D", tz="UTC")
|
||||||
|
r = rng.normal(mu, sigma, n)
|
||||||
|
if mu_holdout is not None:
|
||||||
|
m = idx >= HOLDOUT
|
||||||
|
r[m] = rng.normal(mu_holdout, sigma, int(m.sum()))
|
||||||
|
return pd.Series(r, index=idx)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def cols() -> dict:
|
||||||
|
n = 2600 # ~2019-07 -> 2026-08: copre pre e post hold-out
|
||||||
|
return {
|
||||||
|
"A": _mk_daily("2019-07-01", n, 8e-4, 0.010, seed=1),
|
||||||
|
"B": _mk_daily("2021-01-01", n - 550, 6e-4, 0.012, seed=2),
|
||||||
|
# C: rumore pre-holdout, forte SOLO nell'hold-out (imita lo sleeve selezionato sull'OOS)
|
||||||
|
"C": _mk_daily("2019-07-01", n, 0.0, 0.011, seed=3, mu_holdout=18e-4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_combine_outer_equivale_a_combined_daily(cols):
|
||||||
|
sleeves = [Sleeve(nm, w, daily_fn=(lambda s=cols[nm]: s))
|
||||||
|
for nm, w in [("A", 0.5), ("B", 0.3), ("C", 0.2)]]
|
||||||
|
port = StrategyPortfolio(sleeves)
|
||||||
|
a = port.combined_daily()
|
||||||
|
b = combine_outer(cols, {"A": 0.5, "B": 0.3, "C": 0.2})
|
||||||
|
assert np.allclose(a.values, b.values) and a.index.equals(b.index)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tilt_identico_e_neutro(cols):
|
||||||
|
w = {"A": 0.5, "B": 0.3, "C": 0.2}
|
||||||
|
rep = weights_tilt_null(cols, w, w, n=100, seed=7)
|
||||||
|
assert rep["delta_hold"] == 0.0 and rep["delta_full"] == 0.0 and rep["delta_insample"] == 0.0
|
||||||
|
assert rep["n_samples"] == 100
|
||||||
|
assert 0.0 <= rep["frac_random_beat_hold"] <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_vincoli_floor_caps_rispettati(cols):
|
||||||
|
rep = weights_tilt_null(cols, {"A": 0.5, "B": 0.3, "C": 0.2}, {"A": 0.4, "B": 0.3, "C": 0.3},
|
||||||
|
caps={"B": 0.35}, floor=0.05, n=150, seed=11)
|
||||||
|
S = rep["samples"]
|
||||||
|
assert (S >= 0.05 - 1e-12).all() and (S[:, 1] <= 0.35 + 1e-12).all()
|
||||||
|
assert np.allclose(S.sum(axis=1), 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tilt_verso_sleeve_holdout_only_e_sospetto(cols):
|
||||||
|
"""Tilt verso C (edge solo hold-out): delta_hold>0 ma insample<=~0 -> gate_pass False."""
|
||||||
|
w_cur = {"A": 0.5, "B": 0.3, "C": 0.2}
|
||||||
|
w_pro = {"A": 0.30, "B": 0.25, "C": 0.45}
|
||||||
|
rep = weights_tilt_null(cols, w_cur, w_pro, n=300, seed=13, k_seen=15)
|
||||||
|
assert rep["delta_hold"] > 0 # sull'hold-out "vince" (per costruzione)
|
||||||
|
assert rep["delta_insample"] <= 0.05 # ma pre-holdout non c'e' edge
|
||||||
|
assert rep["bestofk_pctl"] == pytest.approx(100 * 15 / 16)
|
||||||
|
assert not rep["gate_pass"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinismo(cols):
|
||||||
|
w_cur = {"A": 0.5, "B": 0.3, "C": 0.2}
|
||||||
|
w_pro = {"A": 0.4, "B": 0.3, "C": 0.3}
|
||||||
|
r1 = weights_tilt_null(cols, w_cur, w_pro, n=80, seed=42)
|
||||||
|
r2 = weights_tilt_null(cols, w_cur, w_pro, n=80, seed=42)
|
||||||
|
assert r1["pctl_hold"] == r2["pctl_hold"] and np.allclose(r1["samples"], r2["samples"])
|
||||||
Reference in New Issue
Block a user