15 Commits

Author SHA1 Message Date
Adriano Dal Pastro b691f48f43 docs(diary): incident 2026-07-09 — "BOOK LIVE conto offline" = cert Traefik di default
Traefik auto-upgrade (latest+Watchtower -> 3.7.7) ha scartato il resolver ACME
per acme.json a 660 (3.x pretende 600) -> cert self-signed su tutto il VPS ->
DeribitRead SSLError -> book online=False. Zero trade persi (target flat, gate
di sicurezza OK). Fix: chmod 600 acme.json + restart; pin traefik:3.7 nel compose.
Diario con diagnosi, verifica end-to-end e runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:09:41 +00:00
Adriano Dal Pastro 822aa1307e research(crt): video-claim "CRT top-down 74% win rate" SCARTATO — il 74% e' un knob, non un edge
Meccanizzato e falsificato il claim ICT/SMC top-down (H1 setup CRT -> M15/M5
conferma -> entry, uscita parziale+BE+runner) su BTC/ETH certificati.

- Setup H1 CRT gia' triplo-refutato (onda 2026-07-02); qui testato l'angolo nuovo:
  la gestione d'uscita e il "74% WR".
- WR reale 30-37% a RR 1.5-2, SOTTO il null gambler's-ruin 40% -> il ritest tocca
  il target meno di una moneta (conferma "il ritest e' informazione negativa").
- Il 74% si fabbrica avvicinando il target: sweep rr1 0.5->2.0 = WR sale, expectancy
  R INVARIANTE e negativa (-1.2..-3.3R netto). DSR 0.000; a fee 0 ancora negativa
  -> non morte-per-fee, l'edge lordo non c'e'.
- Non eseguibile pulito a $600 (3-4 ordini/trade, alcuni sub-min-order).
- Regola candidata: convertire ogni claim "WR X%" da schema parziale+BE in
  expectancy R netto fee (WR ~= 1/(1+rr1), non un merito).

Nessuna modifica a src/config/live/tests. Book e pesi INVARIATI.
Diario: docs/diary/2026-07-07-crt-topdown-74winrate.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 06:02:38 +00:00
Adriano Dal Pastro cf7de40dc0 feat(live): cap/asset dinamico = equity/2 — operazionalizza la decisione frontier (inerte a $600)
Il cap notional per-asset non e' piu' fisso a $300: con max_notional_per_asset_frac
in config diventa equity/2, cosi' cresce col capitale e un deposito futuro non resta
strozzato (frontiera 2026-07-03). Guardrail: dinamico SOLO con equity reale fidata;
su fallback/offline si torna al cap fisso (protezione downside quando E e' ignota).
Live dry-run: cap $299 a equity $598 -> inerte oggi. Suite 172 passed (+4 test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 09:42:13 +00:00
Adriano Dal Pastro eef200cf14 research(frontier): frontiera onesta del book — il 6% e' un miraggio di regime, gira gia' alla vol nativa
Misura (non ricerca segnali) della frontiera CAGR/DD/P-rovina vs
target-vol, pesata per confidenza per-sleeve, a 2k/5k. 7 agenti (3
filoni + refuter fat-tail + scettico), sanity bit-exact dei due book.

Reframe decisivo: il "6% attuale" NON e' un target, e' la vol realizzata
in risk-off; a lambda=1 il book LIVE 2-sleeve gira gia' a ~11% vol/9.4%
DD -> "saltare a 10%" e' assumere non-risk-off, non aggiungere leva.

- Target-vol fidato book LIVE (TP01+SKH01, unico eseguibile <20k):
  ~10% (banda 8-11%, lambda ~0.9). Scale-invariante: stesso % a 2k/5k.
- P(rovina-50%) e' la metrica SBAGLIATA (slack a 22%); il vincolo che
  morde e' P(DD>30%), super-lineare. Muro onesto ~12% (gap-through SKH
  reale non nei rendimenti modellati; 2-sleeve 4x crash-sensibile).
- Vol differenziata-per-confidenza REFUTATA: taglia la diversificazione;
  la bassa fiducia va nel DE-MEAN (haircut media), non nella leva.
- Reward onesto (HOLD<<FULL): ~EUR 0.3-0.7/g@2k, 0.8-1.8/g@5k; warm-up
  vs 6% risk-off = ~+EUR 0.2/g@2k. EUR 50/g resta ~130k (capitale+tempo).
- UNICA azione config (proposta, NON applicata): cap $300 -> equity/2,
  altrimenti a 5k il cap raffredda paradossalmente il book sotto il punto
  fidato (6% vol ceiling).

config/live.json NON toccato. Book invariato. 168 test verdi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:43:59 +00:00
Adriano Dal Pastro 116abf5203 docs(diary): snapshot stato completo del progetto (2026-07-03)
Foto d'insieme dopo il blocco di ricerca 1-3 luglio: book live
(TP01+SKH01 flat, $598), portafoglio research 5-sleeve (de-luckato
HOLD ~2.0), anchor-audit completo 4/4, edge morti da non ri-testare,
~10 gate anti-illusione codificati, mappa capital-scaling 2-5k,
aperti/prossimi passi. Punto di riferimento unico dello stato.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:16:17 +00:00
Adriano Dal Pastro 26f8d27a61 research(wave-0703): migliora+proteggi VRP01 — 7 filoni, 0 miglioramenti, anchor-audit VRP01 chiuso (4/4 sleeve)
Goal: migliorare la strategia short-vol (famiglia Albimarini/VRP01) e
proteggerla dai DD. Workflow 26 agenti (7 filoni + 2 lenti avversariali
+ scettico incrociato). Esito: NON migliorabile; la protezione DD si
compra SOLO con la size.

- Griglia 288 strutture: nessuna batte VRP01 (DSR 0.000; meta' griglia
  = 3a occorrenza "0-perdite/Sharpe implausibile" dopo CC01/ALB-A).
- 4 overlay DD (exit-spike/SL-MTM/ala-coda/cooldown): 4/4 REFUTED dal
  null de-levering — la protezione crash vive gia' nel gate IV-rank.
- Gate nuovi: 4o fallimento su 4 (l'alpha e' il binario IV-rank>0.30).
- Sizing: 12% deploy ~ 0.27 Kelly onesto (anti-rovina); disambiguazione
  unita' obbligatoria vs 12% peso book (0.014 Kelly, fattore 19x).
- Gate term-structure VIX/VXV su SPX (dSh +0.90, DSR 0.992) = confound
  di modello al 100% -> nuova regola: riprezzare term-structure-consistent
  prima di credere a un gate vol su strutture BS-flat.
- ANCHOR-AUDIT VRP01 CHIUSO: primo sleeve SENZA luck (fase canonica =
  peggiore delle 7 -> numeri di ammissione conservativi). Audit anchor
  ora completo su 4/4 sleeve ancorati.

Book/pesi INVARIATI. Nessun nuovo sleeve. 168 test verdi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:05:19 +00:00
Adriano Dal Pastro 76120b59c2 research(wave-0702bis): ondata video-claims — Elliott 3/3 e Albimarini 2/2 scartati, mappa capital scaling 2-5k
Sei agenti, nessun sopravvissuto:
- ELL-A range-cycle: rumore (0/24 Bonferroni; nessuna cella weekly regge
  a tutte le 7 ancore). Lezione pandas: resample("7D", origin) IGNORA
  origin -> usare "168h" per le bande d'ancora weekly.
- ELL-B Fibonacci: l'edge apparente e' la POSIZIONE dei livelli, non i
  numeri (null location-matched: pctl 0.39-0.68); confluenza FAIL 4/4.
- ELL-C canale: Donchian travestito (non batte il Donchian equivalente,
  DSR 0.685, IS 1.40 -> HOLD -0.87; target 1.618 = caso; anchor-luck 4h).
- ALB-A diagonale: il condor stessa-scadenza la batte a ogni f; senza
  gate IV-rank tutte le strutture perdono (3a conferma: l'alpha del VRP
  e' il gate); fee-negativa su Deribit a qualsiasi size; 2o caso
  "0-perdite = Sharpe implausibile" dopo CC01.
- ALB-B claims: 82%/PF 5.16/"420%" consistente con zero skill (P=20-45%,
  78.6% delle finestre 6-mesi lo produce); replay con code reali =
  rovina 1998/2002/2020; la diagonale passa il 12-40% della perdita naked.
- Capital scaling 600->2-5k: unico vincolo binding = cap $300/asset
  (a 5k book al 49% del target) -> AL DEPOSITO alzare a equity/2;
  min_order $5 lasciare; XS01 ~20k confermata; aspettativa onesta
  de-luckata 2k ~EUR 0.6-0.8/g, 5k ~EUR 1.4-2/g.

Nessun nuovo sleeve, book live invariato. 168 test verdi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:31:38 +00:00
Adriano Dal Pastro a74cc69583 research(anchor-audit): timing-luck confermato su XS01 e SKH01 — 3/3 sleeve ancorati, book de-luckato HOLD ~2.0
Chiude il pendente dell'ondata timing 2026-07-02. Due audit indipendenti
(sanity replica bit-exact, ancore a priori, zero tuning per-fase, bootstrap):

- XS01 (10 fasi ciclo H=10): fortuna nel DD (15° pctl: 10.8% vs 15.5% tipico,
  29% peggiore) e FULL (85°), non nell'hold-out (65°); P(spike)~0.91-0.94.
  Lens onesta = ensemble di fase FULL 1.25 / HOLD 1.31 / DD 11%. Ammissione
  @15% regge, i numeri 1.50/1.71/11% no.
- SKH01 (23 offset griglia 230m/690m): canonico = 93-98° pctl di OGNI metrica,
  minHold/blend/book-HOLD = massimo dei 23; il gate DD<30% (criterio di
  selezione V2-DD) fallisce in 15/23 offset. Regge: uplift blend positivo a
  tutte le 23 fasi (min +0.18) + corr ~0.08 -> ADDS ridimensionato. Path live
  reale (cron orario + exit software): book FULL 1.46->1.19 / HOLD 1.64->1.15 /
  DD 18->25%, gap-through-stop nei crash (sl2% -> -11/-23%).
- Book 5-sleeve: HOLD 2.46 eredita ~+0.10/+0.17/+0.5 di fortuna d'ancora
  (TP01/XS01/SKH01) -> stima de-luckata HOLD ~1.9-2.1, FULL ~2.0-2.2, DD ~6%.

Nessun cambio operativo (pesi/book live invariati; ogni cambio passa
weights_tilt_null). Narrativa aggiornata (CLAUDE.md, docstring skyhook).
Follow-up: anchor_luck_band() in altlib, cadenza 230m, peso SKH live.
168 test verdi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:34:35 +00:00
Adriano Dal Pastro 73d74c5e53 research(wave-0702): ondata timing + CRT — 8 filoni, 0 nuovi sleeve, finding anchor timing-luck TP01
Goal: "altre strategie su Deribit con timing differenti". 8 filoni multi-agente + scettico:
- event-clock bars, expiry calendar Deribit, clock lenti/bande, regime-speed: SCARTATI
- CRT (Candle Range Theory) base/multi-TF/contesto: SCARTATA 3/3 (DSR~0, ritest =
  informazione negativa; sottoprodotto: FOLLOW>FADE sui livelli prior-day ogni anno,
  conferma il lead prevday)
- FINDING (confermato da scettico indipendente): hold-out 0.31 di TP01 = migliore delle
  24 ancore orarie (mediana 0.04, banda [-0.13,+0.30]) -> narrativa corretta in CLAUDE.md
  e docstring: l'hold-out non risolve l'edge di ritorno, regge il taglio DD a ogni ancora.
  Tranching K=2/4 = solo varianza della stima, no deploy a $600. Audit d'ancora pendente
  su XS01/SKH01. Book live e portafoglio INVARIATI. Test 168/168.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:12:22 +00:00
Adriano Dal Pastro e6657fcb16 feat(portfolio): GTAA01 promosso a 5° sleeve @20% — FULL 2.12→2.24, HOLD 2.21→2.46, DD 7.8→6.2%
Il diversificatore strutturale validato il 2026-06-22 (trend difensivo equity
6-ETF su IB, 30y storia, OOS 2015+ indipendente dall'hold-out crypto, corr al
book ~+0.10) era rimasto in paper_combo senza mai essere valutato come sleeve.
Valutazione onesta (r0701_gtaa_5th_sleeve): uplift positivo in-sample e su
TUTTE le finestre disgiunte (+0.05/+0.19/+0.25), multi-cut +0.21..+0.25,
plateau monotono w10-30% — passa dove EW-STR era morto. Ingresso @20% (IS-best
30%, scelta strutturale dichiarata). Convenzioni: weekend equity=0 (capitale
IB fermo, non riciclato), attivazione all'era book 2019-03. Il book live
Deribit (TP01+SKH01) NON cambia. Suite 168/168.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:31:38 +00:00
Adriano Dal Pastro 491411ac77 research(wave-0701): 6 filoni multi-agente — 0 nuovi sleeve, pesi confermati, gate weights_tilt_null
Ondata onesta su angoli non coperti: funding-TS (chiude il filone funding su 3
lati), breadth alt (non-ridondante ma DSR 0.43, rivisitabile con storia),
XS-residmom (REDUNDANT), pesi+guardia-DD (EW-STR refutato dallo scettico come
selezione-sull'hold-out di 2° ordine, firma best-of-15), VRP-refine (filone
esaurito), stagionalità-XS (morta allo step statistico).

Lezione codificata: weights_tilt_null + combine_outer in src/portfolio
(ogni cambio-pesi vs null di tilt casuali cap-respecting + delta in-sample>=0);
5 test nuovi, suite 165/165.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:21:59 +00:00
Adriano Dal Pastro ccf5e38101 feat(dashboard): banner alert operativi del book live (SKH/posizione/equity)
Il dashboard non mostrava nessuno dei tre segnali di health introdotti nei
commit 31369b3 (skh_error) e 5670469 (pos_error/eq_fallback): finivano solo su
Telegram + log cron, invisibili a chi guarda il monitor. pos_error/eq_fallback
erano gia' in shadow_report (usato dal dashboard) ma ignorati dal template;
skh_error non era nemmeno recuperato (il dashboard usa shadow_report, non
book_report).

Fix:
- _alerts_banner(): helper puro (verde "nessun alert" se pulito, rosso con una
  riga per errore) che rispecchia i tre alert di book_execute.
- build(): raccoglie pos_error/eq_fallback dallo shadow gia' fetchato + skh_error
  da book_report(offline=True) (feed certificato, nessuna rete extra).
- html(): renderizza il banner in cima alla sezione ① LIVE.
- tests/test_dashboard.py: +4 (verde, 3 alert, parziale, chiave-ignota).

Suite 160/160. Render end-to-end verde (conto sano $598.06 flat). Chiude la
copertura: i 3 pattern di errore silenzioso ora visibili su log + Telegram + dashboard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:47:35 +00:00
Adriano Dal Pastro 567046953d fix(book): gate fail-safe posizione + diagnostica equity — niente errori silenziosi nel path live
Terza+quarta chiusura del pattern "eccezione ingoiata -> stato safe silenzioso"
in src/live, dopo skh_error (31369b3). Emerse durante la verifica del gate SKH01.

Opzione A (gate fail-safe posizione):
- shadow._positions: se la read della posizione reale Deribit lancia, assume flat
  MA ora ritorna un pos_error esplicito (prima solo una note mai propagata).
- Propagato shadow_report -> book_report -> book_execute: se ONLINE ma posizione
  IGNOTA, l'esecutore NON opera a cieco (return + alert), come gia' per 'online'.

Opzione B (diagnostica equity, no halt):
- shadow._equity/shadow_report: se ONLINE ma equity reale non leggibile, il book
  ripiega su paper_cap (~$2000) invece del conto reale (~$598) -> sovradimensiona
  ~33%, ma l'hard-cap $300/asset limita il downside. Nuovo flag eq_fallback ->
  book_execute stampa warning + alert Telegram MA prosegue (niente gate: la scelta
  e' solo diagnostica, l'hard-cap gia' protegge).

Test: +8 (4 gate A, 4 diagnostica B). Suite 156/156. Path live pulito
(skh_error/pos_error/eq_fallback = None). Diario 2026-07-01-book-live-error-surfacing.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:39:59 +00:00
Adriano Dal Pastro 31369b358c fix(book): esponi skh_error nel book live — niente flat SKH silenzioso
book_report scriveva skh_error in un dict locale MAI incluso nel return ->
r.get("skh_error") era sempre None. E book_execute non lo leggeva comunque.
Risultato: se il feed SKH (fresh_5m) fallisse, il book forzava SKH a flat in
silenzio, indistinguibile da un flat legittimo -> entry SKH reale mancato,
nessun alert.

Fix:
- src/live/book.py: skh_error come variabile esplicita, esposta nel dict di
  ritorno (None normalmente). Il fail-safe (SKH->flat, TP01 indipendente sul
  suo feed certificato) resta invariato.
- scripts/live/book_execute.py: emette la riga di log + alert Telegram quando
  skh_error e' presente.
- tests: book_report cattura-e-flagga l'errore; book_execute lo fa emergere
  (log + notify). Suite 148/148.

Contesto: verifica del gate SKH01 dopo 193 run flat dal 06-23 (gate SANO —
335/341 entry storici, shorta i crash; flat legittimo: ultimo entry 06-06
chiuso ~06-08 pre-arming). Questo chiude il rischio latente scoperto durante
la verifica.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:26:02 +00:00
Adriano Dal Pastro 88f5f5a02a docs(memory): CLAUDE.md — completa il log dei filoni 2026-06-29 (1ª ondata A/C/D)
Aggiunge il bullet riassuntivo dei filoni della prima ondata del 29-06 (gia' nei diari
ma mancanti dal log curato in CLAUDE.md): A DVOL-direzionale=HEDGE, B intraday ERM=falso
positivo (gia' coperto dal gate), C xsec-v2 low-vol=debole/STAT-MODE, D macro-gate=ridondante.
Memoria curata ora completa per la sessione 2026-06-29.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:29:37 +00:00
67 changed files with 18964 additions and 42 deletions
+167 -9
View File
@@ -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 3037%** a RR 1.52 (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
View File
@@ -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) |
+114
View File
@@ -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 **D1**; 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 IVRV, 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.
+98
View File
@@ -0,0 +1,98 @@
# 2026-07-01 — Affinamento VRP01 (sizing IVRV / 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 IVRV** (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 IVRV>0" è **già il baseline** (`gate_vrp=True`).
2. **Filtro DVOL-momentum**: non vendere se `dvol[i]dvol[ik] > 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 IVRV è 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 (IVRV) 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).
+69
View File
@@ -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.160.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.92.1, FULL ~2.02.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.910.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.102.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 9398° 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.030.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.92.1, FULL ~2.02.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%/DD68% e
+0%/DD88%.
- **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.
+186
View File
@@ -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.
+111
View File
@@ -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.
+47
View File
@@ -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).
+167
View File
@@ -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.52), chiude 7080% lì → resto a break-even → runner 2030%**
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.130.24%) →
la fee 0.10% pesa 0.752.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 **34 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 ~3037% 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**.
+16
View File
@@ -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"]:
+383
View File
@@ -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()
+387
View File
@@ -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()
+103
View File
@@ -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()
+472
View File
@@ -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()
+304
View File
@@ -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()
+369
View File
@@ -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()
+439
View File
@@ -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()
+295
View File
@@ -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()
+662
View File
@@ -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()
+333
View File
@@ -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()
+513
View File
@@ -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()
+378
View File
@@ -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()
+594
View File
@@ -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()
+507
View File
@@ -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()
+505
View File
@@ -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()
+579
View File
@@ -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()
+692
View File
@@ -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()
+562
View File
@@ -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()
+414
View File
@@ -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()
+453
View File
@@ -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()
+450
View File
@@ -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()
+331
View File
@@ -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()
+515
View File
@@ -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()
+319
View File
@@ -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 |targetpos| > 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()
+321
View File
@@ -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()
+352
View File
@@ -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()
+564
View File
@@ -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()
+385
View File
@@ -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]")
+484
View File
@@ -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()
+323
View File
@@ -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()
+444
View File
@@ -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()
+655
View File
@@ -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()
+462
View File
@@ -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()
+691
View File
@@ -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()
+426
View File
@@ -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()
+506
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 : 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()
+46 -6
View File
@@ -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)
] ]
+6
View File
@@ -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):
+5
View File
@@ -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
+219
View File
@@ -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)
+44
View File
@@ -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
+42
View File
@@ -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
+82
View File
@@ -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"])