Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee82e0a056 | |||
| c52e0ab3f8 | |||
| cff5fa2bf5 | |||
| 02d875b07b | |||
| b4ec92734c | |||
| aad69f9790 | |||
| a158d0e2ae | |||
| e28176efef | |||
| 8d559c6f33 | |||
| 555977d987 | |||
| 8c3868cb31 | |||
| 9d586eeb58 | |||
| 69e3517f1b | |||
| cf72e395d3 | |||
| db738bce3b | |||
| 25a22fc7c1 | |||
| eeac97dde4 | |||
| c8a390d6b7 | |||
| 384b9cb0af | |||
| 160ad300be | |||
| 50e2adf837 |
@@ -66,3 +66,4 @@ scripts/research/blind/leaderboard.json
|
||||
# forward-monitor runtime state (regenerable, forward-only)
|
||||
data/paper_prevday/
|
||||
data/paper_combo/
|
||||
data/paper_statarb/
|
||||
|
||||
@@ -96,6 +96,59 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
||||
regime-luck), calendar/seasonality (buy&hold travestito), volume/vol e momentum-reversal (negativi).
|
||||
- **MORTO/confermato artefatto:** mean-reversion / fade (negativo anche a fee zero — la vecchia
|
||||
libreria +201%/+1238% era contaminazione); trend 5m/15m (fee).
|
||||
- **GAMMA SCALPING (long-vol) "scalping BTC/ETH con copertura in opzioni" — SCARTATO (2026-06-26)** —
|
||||
`scripts/research/options_gamma_scalp.py`, test `tests/test_gamma_scalp.py`. È lo **specchio
|
||||
esatto del VRP01** (long straddle ATM + delta-hedge: incassa **RV−IV**, dove VRP01 incassa IV−RV).
|
||||
Perde **ogni anno, ogni variante, ogni frequenza** (Sharpe −3 a −6; nudo/cheap-gated/rich-skip;
|
||||
rehedge 1d e 1h). Diagnostica strutturale: a 1d IV≈o>RV (BTC +4.9pp) → paghi il VRP; a 1h RV>IV
|
||||
gross ma (a) gonfiata da microstruttura, (b) il rehedge orario paga **24× la fee di hedge** →
|
||||
variante *peggiore* (−6). Marginale vs TP01 = **DILUTES**, non è nemmeno hedge (perde sia TP01-up
|
||||
sia TP01-down). Muro eseguibilità: opzione BTC min $5.968 ≫ $600. Schiacciato tra due muri:
|
||||
rehedge lento = premio, veloce = fee → **nessuna frequenza vince.** Regola gemella del VRP:
|
||||
*niente long-vol scalp da modello in deploy*. Il VRP01 (lato short, gated) resta l'unico edge
|
||||
opzioni — funziona perché sta sul lato *giusto* dello stesso premio. Diario `2026-06-26-gamma-scalp-options.md`.
|
||||
- **CASH-AND-CARRY (basis trade) "CC01" — premio REALE, Sharpe ARTEFATTO, NON deployabile (2026-06-26)** —
|
||||
`scripts/research/cash_carry_hl.py`, test `tests/test_cash_carry.py`. Diverso da FC01 (funding
|
||||
cross-sectional, già scartato): qui delta-neutral long-spot/short-perp sullo *stesso* asset →
|
||||
ritorno ≈ **+funding** (zero esposizione prezzo). Il premio di funding è **reale** (~+8-14%/anno
|
||||
aggregato, positivo ogni anno in-sample, ortogonale a TP01 corr ~0.05). MA lo **Sharpe modellato
|
||||
11-13 (DD 0.3%) è un ARTEFATTO**: il modello cattura solo il cashflow liscio del funding e i
|
||||
rischi di coda sono **strutturalmente fuori dal dataset** — (1) storico funding dal 2023-05 →
|
||||
**manca il 2022** (deleveraging, funding −, basis blow-out); (2) **procyclico** (carry +23% toro
|
||||
2024 → +1.7% bear 2026, si comprime quando servirebbe); (3) liquidazione short/slippage non
|
||||
modellati. Il mark-to-market della base (`premium` col → r=funding−Δpremium) sgonfia lo Sharpe
|
||||
solo 13→11 → il basis-from-data NON è il rischio vero. Sharpe reale di un basis-trade ~1-3 con
|
||||
code brusche. **NON eseguibile a $600** (spot+perp = 4-38 gambe, funding HL non Deribit) → STAT-MODE.
|
||||
**LEAD da rivedere a scala (~20k+ e venue con funding eseguibile), non uno sleeve.** Sottoprodotto:
|
||||
CC01 passa OGNI gate del marginal scorer → **punto cieco** (manca un gate "Sharpe implausibile →
|
||||
rischio nascosto"; prossima indurita raccomandata). Diario `2026-06-26-cash-carry-hl.md`.
|
||||
- **TP01 × DVOL vol-targeting — NON migliora (2026-06-26)** — `scripts/research/tp01_dvol_overlay.py`,
|
||||
test `tests/test_tp01_dvol_overlay.py`. Angolo ESEGUIBILE (tocca il book live, non STAT-MODE):
|
||||
usare il DVOL (vol implicita forward-looking) come denominatore del vol-target di TP01 invece
|
||||
della vol realizzata. Su finestra comune 2021-2026: le varianti DVOL abbassano il DD (12.3%→9.2%)
|
||||
ma anche Sharpe FULL (0.75→0.70) e CAGR (8%→6%). **Controllo decisivo:** il realized a target_vol
|
||||
RIDOTTO (15%) eguaglia quel DD (9.4%) a Sharpe **più alto** (0.75) → il taglio di DD del DVOL è
|
||||
solo **de-levering**, replicabile meglio con un semplice target_vol più basso. L'unico residuo
|
||||
(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
|
||||
DD sul live la leva è `target_vol`, non un overlay DVOL** (20% resta canonico). Diario `2026-06-26-tp01-dvol-overlay.md`.
|
||||
- **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
|
||||
`study_family_honest`): (1) low-risk cross-sectional, (2) momentum-structure vs XS01, (3) meta-allocazione
|
||||
dinamica, (4) segnali ortogonali ETH/BTC, (5) 1-gamba a segnale. Esito: soffitto ~1.3 riconfermato; ogni
|
||||
candidato ucciso dal gate giusto (deflated-Sharpe, is_hedge, selection-on-holdout, sostituzione-XS01,
|
||||
multi-cut). Niente batte/diversifica XS01 (varianti = REDUNDANT); meta-allocazione < pesi fissi (i 4
|
||||
sleeve già quasi-risk-parity); 1-gamba a segnale = TP01 travestito (trend) o hedge a DSR<0.95.
|
||||
**LEAD forward-monitor:** **STATARB-RESID** (relative-MOMENTUM del residuo ETH−β·BTC, β OLS rolling, 2 gambe,
|
||||
cella vincente **sgn=+1**: le dislocazioni ETH-vs-BTC CONTINUANO a 1d, la MR pura sgn=−1 perde) — primo
|
||||
stream **insieme ortogonale (corr→book 0.027, β-mkt 0.013) ED eseguibile a $600** (haircut ~0, NON
|
||||
STAT-MODE come XS01/opzioni): marginal ADDS, robust_oos, fee-survive 0.30%/gamba; resta sotto soglia solo
|
||||
sull'**edge** (Sharpe 0.84, DSR 0.929 same-sign <0.95). **CABLATO in forward-monitor PAPER:**
|
||||
`scripts/live/paper_statarb.py` (W=45/sgn=+1 congelati, doppio libro MODELED/REAL-$600), nel cron
|
||||
giornaliero accanto a `paper_prevday`; test `tests/test_paper_statarb.py`. Se la finestra forward conferma
|
||||
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
|
||||
`scripts/research/{xsec_v3_lowrisk,xsec_v3_momstruct,meta_allocation,orthogonal_signals,signal_inout_1leg}.py`.
|
||||
- **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso:
|
||||
cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55.
|
||||
- **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.**
|
||||
@@ -142,6 +195,17 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
||||
migliaia di micro-trade sub-dollaro (tipici di un overlay vol-target) è **finzione**. Salta i
|
||||
sub-min_order e riporta lo **Sharpe haircut** reale vs modellato. **Vale per OGNI sleeve a questo
|
||||
capitale, TP01 incluso** — lo Sharpe netto onesto a $600 è quello small-cap, non quello modellato.
|
||||
- **SELECTION-ON-HOLDOUT gate (codificato 2026-06-29, filone B intraday ERM)** — terzo gate in
|
||||
`altlib.py`, test `tests/test_harness_realism.py`. Il lead ERM faceva `earns_slot=True` MA lo script
|
||||
di scoperta sceglieva la cella per **`min_hold` massimo** su 60+ celle = **selezione-sull'hold-out**:
|
||||
scegliendola in-sample-only ne esce un'altra (trend-beta corr→TP01 0.53, NEUTRAL) e il deflated-Sharpe
|
||||
crolla (DSR 0.0-0.24 su 122 trial). `study_marginal` da solo non lo vede (giudica UNO stream, non *come*
|
||||
è scelto). Tre funzioni: **`deflated_sharpe()`** (Bailey & Lopez de Prado, PASS ≥0.95), **`select_cell_insample()`**
|
||||
(cella scelta col solo Sharpe pre-HOLDOUT), e il gate combinato **`study_family_honest(name, factory, grid, tfs)`**
|
||||
→ `earns_slot_honest = earns_slot[cella in-sample] AND deflated-Sharpe≥0.95`. **Regola: una strategia
|
||||
direzionale grid-searched si giudica con `study_family_honest`, non chiamando `study_marginal` sulla
|
||||
cella a max hold-out.** Chiude il punto cieco gemello di CC01 ("Sharpe implausibile"). Diario
|
||||
`2026-06-29-intraday-regime.md` (analisi `scripts/research/intraday_regime_analysis.py`).
|
||||
- **Onestà sul target €50/giorno:** NON raggiungibile su 2000 in 1-2 anni (servono ~130k di
|
||||
capitale o un DD da rovina). La leva non è la scorciatoia; la via è target-vol + capitale +
|
||||
tempo. La strategia che *guadagna* esiste, ma a ~+€1.5/giorno su 2000.
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_nota": "Config esecuzione LIVE di TP01. execution_enabled=true + --execute -> ordini REALI. ARMATO 2026-06-20.",
|
||||
"_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.",
|
||||
"execution_enabled": true,
|
||||
"max_notional_per_asset_usd": 300,
|
||||
"min_order_usd": 5,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 2026-06-26 — Cash-and-carry (basis trade) delta-neutral su HL (CC01): premio REALE, Sharpe ARTEFATTO, NON deployabile
|
||||
|
||||
**Goal utente:** dopo lo scarto del gamma scalping, "provare" gli altri due angoli proposti —
|
||||
funding-carry cross-sectional e basis/cash-and-carry. Il **funding cross-sectional (FC01) è già
|
||||
SCARTATO** (diario 2026-06-22: DILUTES, sign-flip su un nome). L'angolo non testato è il
|
||||
**cash-and-carry delta-neutral (CC01)**. Branch `research/gamma-scalp-options`,
|
||||
script `scripts/research/cash_carry_hl.py`.
|
||||
|
||||
## Meccanismo (diverso da FC01)
|
||||
|
||||
FC01 pickava cross-section (short alto-funding / long basso-funding) → scommessa relativa, fragile.
|
||||
CC01 **non picka**: harvesta il premio di funding AGGREGATO restando delta-neutral sullo *stesso*
|
||||
asset. Long spot + short perp (stessa size): le gambe lineari nel prezzo si cancellano → ritorno
|
||||
≈ **+funding** (lo short incassa il funding quando f>0), zero esposizione direzionale. Dati: funding
|
||||
orario reale HL dal 2023-05 (`hlfund_*_1h.parquet`), gli stessi 19 major di XS01.
|
||||
|
||||
## Risultato grezzo — e perché NON va creduto
|
||||
|
||||
| config | Sharpe | CAGR | maxDD | per-anno |
|
||||
|---|---|---|---|---|
|
||||
| BTC/ETH CC-static | **+12.9** | +13% | 0.8% | 2023:+10 2024:+23 2025:+8 2026:+1% |
|
||||
| BTC/ETH CC-gated | +13.9 | +14% | 0.3% | tutti positivi |
|
||||
| 19-major CC-gated | +11.6 | +16% | 0.4% | tutti positivi |
|
||||
|
||||
**Uno Sharpe di 13 con DD 0.3% è un ALLARME, non un edge.** È la firma di un modello che cattura
|
||||
solo un cashflow liscio (il funding) e azzera la volatilità reale. Il marginal scorer dà **ADDS /
|
||||
robust_oos / has_insample_edge=True / multicut_persistent** su OGNI config — ma questo **espone un
|
||||
punto cieco dello scorer**: si fida della vol riportata dal candidato e non ha un gate "Sharpe
|
||||
implausibile → rischio nascosto". Un Sharpe di 13 dovrebbe squalificare-e-indagare, non passare.
|
||||
|
||||
## Il rischio è NASCOSTO, non assente
|
||||
|
||||
- **Mark-to-market della base** (`premium` col → `r = funding − Δpremium`): sgonfia lo Sharpe solo
|
||||
13→11. La colonna premium di HL è già lisciata/clampata → a risoluzione daily il basis-vol è
|
||||
minimo. **Quindi il basis-from-data NON è il rischio vero.**
|
||||
- **I rischi che giustificherebbero uno Sharpe realistico (~1-3) sono ASSENTI dal dataset:**
|
||||
1. **2022 mancante.** Lo storico funding parte 2023-05 → niente deleveraging LUNA/3AC/FTX, il
|
||||
regime di funding profondamente negativo + basis blow-out che farebbe il vero drawdown.
|
||||
2. **Procyclicità.** Funding aggregato BTC/ETH: +23% nel toro 2024 → **+1.7% nel bear 2026**
|
||||
(e 19-major CC-static 2026 = **−1%**). Solo 10% di giorni a funding negativo *in-sample* — ma
|
||||
in un vero bear quella frazione esplode. Il carry **non è all-weather**: è un risk-on premium.
|
||||
3. **Liquidazione dello short** in uno squeeze e **slippage** su spot+perp: non modellati.
|
||||
- **Sharpe reale di un crypto basis-trade ≈ 1-3 con code brusche** (Mar-2020, Mag-2021, FTX), non 13.
|
||||
|
||||
## Eseguibilità a $600 — muro indipendente
|
||||
|
||||
Serve spot **+** perp per gamba: BTC/ETH = 4 posizioni, 19-major = 38. Su Deribit (dove operiamo) lo
|
||||
storico funding è **bloccato** (404) e non tradiamo spot HL. **STAT-MODE**, come XS01/VRP01 ma peggio
|
||||
(doppia gamba per asset). Non deployabile a questo capitale.
|
||||
|
||||
## Verdetto
|
||||
|
||||
**CC01 = premio di funding REALE (~+8-14%/anno aggregato, positivo ogni anno in-sample) e
|
||||
genuinamente ortogonale a TP01 (corr ~0.05, NON direzionale).** È la "fonte di ritorno diversa" più
|
||||
concreta trovata finora. MA:
|
||||
- lo **Sharpe headline è fiction** (rischi di coda strutturalmente fuori dal dataset);
|
||||
- è **procyclico** (si comprime/inverte nel bear — proprio quando servirebbe);
|
||||
- **non eseguibile** a $600 / sul nostro venue.
|
||||
|
||||
→ **LEAD da rivedere a scala** (capitale ~20k+ e un venue con funding eseguibile), **non uno sleeve**.
|
||||
Nessun impatto sul book live (branch separato).
|
||||
|
||||
## Sottoprodotto metodologico (raccomandazione)
|
||||
|
||||
CC01 ha passato ogni gate del marginal scorer pur essendo un'illusione di rischio. **Prossima
|
||||
indurita dello scorer:** un gate di *plausibilità dello Sharpe* — uno standalone Sharpe ≫ 4 deve
|
||||
declassare a "SUSPECT/INVESTIGATE" invece di alimentare ADDS, perché segnala vol sottostimata
|
||||
(mark-to-cashflow, dati che escludono il regime di stress, leva nascosta). Documentato qui, non
|
||||
implementato in questa passata per non toccare lo scorer condiviso su `main`.
|
||||
@@ -0,0 +1,58 @@
|
||||
# 2026-06-26 — "Short su fondamentali/notizie NEG ma prezzo SU": screener forward, edge non provato
|
||||
|
||||
**Goal utente (chiarito):** se i dati finanziari/notizie di un'azienda sono negativi ma la
|
||||
quotazione è positiva (sale), andare SHORT — scommessa che il prezzo scenda a riallinearsi ai
|
||||
fondamentali. Branch `research/equity-meanrev-ib`, script `scripts/research/eq_fundnews_short.py`.
|
||||
|
||||
## Gate dati (lezione v2.0.0) — NON backtestabile
|
||||
|
||||
I fondamentali e le notizie scaricabili dalla rete (Yahoo) sono **snapshot CORRENTI**, non
|
||||
point-in-time storici. Applicarli a prezzi passati = **look-ahead** (restatement + survivorship):
|
||||
esattamente la classe di errore che ha prodotto la libreria fasulla v2.0.0. Un backtest onesto
|
||||
richiede un DB point-in-time (Compustat PIT, news storiche con timestamp) — **assente**. Quindi,
|
||||
come per la vol term-structure: **niente backtest**, l'unica via onesta è uno **screener forward**.
|
||||
|
||||
## Cosa è scaricabile (feasibility, fatto)
|
||||
|
||||
- **Fondamentali strutturati** via Yahoo quoteSummary (flusso cookie+**crumb**, tokenless):
|
||||
`recommendationMean` (1=buy..5=sell), `revenueGrowth`, `earningsHistory` surprise%,
|
||||
`recommendationTrend` (skew buy/sell). ✅
|
||||
- **Notizie**: Yahoo news search (headline + publisher), sentiment lessicale crudo. ✅ (proxy debole)
|
||||
- **Momentum**: chart API (return 1m/3m). ✅
|
||||
|
||||
## Screener costruito + eseguito dal vivo
|
||||
|
||||
`fund_neg` ∈ [0,1] (media di: recMean→sell, surprise negative, ricavi in calo, analisti a sell) +
|
||||
`news_neg` lessicale + momentum. **SHORT candidate = (fond/news neg) AND prezzo in salita** (la
|
||||
divergenza). Idempotente, logga forward su `data/raw/fundnews_short_screen.parquet`.
|
||||
|
||||
**Run di oggi (2026-06-26): NESSUN candidato.** Quasi tutto l'universo ha momentum 1m **negativo**
|
||||
(mercato in flessione ampia, coerente col bear) → la gamba "prezzo che sale" non scatta. I peggiori
|
||||
per fondamentali (TSLA 0.48, BA 0.39, NKE 0.38) stanno comunque *scendendo*. Output sensato.
|
||||
|
||||
## L'intuizione analitica chiave (il valore vero di questa analisi)
|
||||
|
||||
La premessa ha una **tensione interna** che la rende la versione *rischiosa* dell'anomalia:
|
||||
- **Shortare un prezzo che SALE combatte il momentum** — una delle anomalie più robuste e persistenti
|
||||
(i winner continuano a vincere su 3-12 mesi). Si scommette contro di essa.
|
||||
- **Il PEAD** (post-earnings drift) dice che dopo cattive notizie il prezzo continua a **scendere**;
|
||||
ma qui il prezzo *sale malgrado* le cattive notizie → spesso significa che il mercato sta
|
||||
**prezzando informazione** che i fondamentali *trailing* non contengono (turnaround, M&A, squeeze).
|
||||
- Quindi: la versione *pulita* dell'edge "short su fondamentali deboli" funziona meglio quando il
|
||||
prezzo **conferma** (fondamentali deboli + prezzo che SCENDE = momentum e fondamentali allineati),
|
||||
**non** quando diverge. La richiesta "shorta il titolo che sale su brutte notizie" è il caso
|
||||
contrarian, più difficile e storicamente più punito (squeeze, perdita illimitata).
|
||||
|
||||
## Eseguibilità (muro)
|
||||
|
||||
Short = **borrow** (locate + fee; hard-to-borrow caro/assente), **perdita illimitata**, **squeeze**,
|
||||
**PDT $25k** per i day-trade, IB instabile qui, $600, universo single-stock (non i nostri ETF). NON
|
||||
deployabile.
|
||||
|
||||
## Verdetto
|
||||
|
||||
**Non un edge schierabile, né dimostrabile ora.** Deliverable onesto = lo **screener forward**
|
||||
(funzionante, da dati di rete) + il log che accumula i candidati per una validazione futura, con
|
||||
la chiara avvertenza che l'edge è **non provato** e che la *direzione contrarian* della scommessa
|
||||
(short sulla forza) è la parte fragile. Strumento utile (fond+news+momentum da rete in un colpo),
|
||||
tesi da maneggiare con scetticismo. Nessun impatto sul book live (branch separato).
|
||||
@@ -0,0 +1,57 @@
|
||||
# 2026-06-26 — Scalping azioni IB "quando sottoquotate" + check dati dalla rete
|
||||
|
||||
**Goal utente:** su IB, comprare azioni quando "sottoquotate" (oversold/sotto fair-value), con
|
||||
verifica incrociata dei dati dalla rete. Branch `research/equity-meanrev-ib`,
|
||||
script `scripts/research/eq_meanrev_ib.py`. Due pezzi.
|
||||
|
||||
## 1) CHECK DATI DALLA RETE — il pezzo richiesto (e perfettamente in-metodo)
|
||||
|
||||
Confronto i dati certificati IB (`data/raw/eq_*_1d.parquet`, ADJUSTED_LAST) con una **sorgente di
|
||||
rete indipendente** (Yahoo Finance chart API, tokenless). È la lezione fondante del progetto: non
|
||||
fidarti di un feed solo (il disastro v2.0.0 era un feed contaminato).
|
||||
|
||||
**Risultato (180g, BTC… no: 6 ETF):** dopo l'allineamento corretto **tutti CONCORDE**, deviazione
|
||||
sui rendimenti **≤1.2bps**, ultimo close identico (0.0bps). Il feed IB è validato.
|
||||
|
||||
**La lezione (errore che ho fatto e corretto):** un primo confronto naïf (nostro close *adjusted*
|
||||
vs `close` *grezzo* di Yahoo) ha **falso-allarmato 4/6 ticker** a 30-52bps (SPY 30, TLT 40, HYG 52).
|
||||
Le divergenze erano **tutte su singoli giorni = stacco dividendo**: confrontavo dividend-adjusted vs
|
||||
non-adjusted. Usando l'`adjclose` di Yahoo (apples-to-apples) → ~0bps. **Regola: ogni divergenza va
|
||||
SPIEGATA prima di gridare "feed sporco" — e prima di tradarci sopra.** Lo strumento resta come
|
||||
validatore di feed riutilizzabile / template del pre-trade price-check live.
|
||||
|
||||
## 2) "SCALPING quando sottoquotata" — non testabile/eseguibile; testato lo swing MR
|
||||
|
||||
Lo **scalping intraday non è valutabile**: (a) non abbiamo dati intraday (solo `eq_*_1d`), (b) non
|
||||
è eseguibile — **PDT rule**: il day-trading di azioni US sotto **$25.000** è limitato a 3
|
||||
day-trade/5gg → lo scalping è *regolatoriamente bloccato* al capitale del progetto (l'analogo equity
|
||||
del muro STAT-MODE a $600 sul crypto), (c) IB Gateway è instabile qui (timeout ordini diagnosticato).
|
||||
|
||||
Versione onesta e testabile = **swing mean-reversion** "compra quando sottoquotata": Connors RSI2<10
|
||||
+ filtro trend MA200, exit a MA5. Causale (segnale ≤ close[i], entry a close[i]), netto fee, hold-out 2015+:
|
||||
|
||||
| ETF | FULL Sh | DD | CAGR | expo | HOLD Sh | **B&H HOLD Sh** |
|
||||
|---|---|---|---|---|---|---|
|
||||
| SPY | +0.75 | 15% | +5% | 13% | +0.70 | **+0.81** |
|
||||
| QQQ | +0.50 | 16% | +4% | 14% | +0.56 | **+0.92** |
|
||||
| IWM | +0.32 | 22% | +2% | 13% | +0.15 | +0.53 |
|
||||
| DIA | +0.38 | 13% | +2% | 14% | +0.55 | +0.74 |
|
||||
| EEM | +0.41 | 20% | +3% | 13% | +0.36 | +0.46 |
|
||||
|
||||
Fee sweep SPY: Sh 0.82 (0bps) → 0.75 (3) → 0.70 (5) → 0.58 (10bps RT).
|
||||
|
||||
**Lettura onesta:** la mean-reversion "buy the dip" sugli indici equity ha un edge **reale ma
|
||||
modesto** (Sharpe ~0.5-0.75, investito solo ~13% dei giorni) **che NON batte il buy&hold
|
||||
risk-adjusted** (B&H hold-out 0.81 su SPY vs 0.70 della MR), con CAGR piccolo (2-5%) e sensibile ai
|
||||
costi. È capital-efficient in teoria (cash 87% del tempo) ma il valore aggiunto vs "tieni l'indice"
|
||||
è negativo o nullo, e la frequenza *scalping* che potrebbe aggiungere valore è bloccata dalla PDT.
|
||||
|
||||
## Verdetto
|
||||
|
||||
- **Check dati dalla rete: FATTO e utile** — feed IB validato (≤1.2bps vs Yahoo); strumento
|
||||
riutilizzabile. Unico vero keeper della sessione equity.
|
||||
- **Scalping "sottoquotate": NON deployabile** — non testabile (no intraday), non eseguibile (PDT
|
||||
$25k, IB instabile). Lo swing MR daily testabile **non batte il buy&hold** → niente edge schierabile.
|
||||
|
||||
Conferma il pattern del progetto: l'idea promettente sopravvive solo finché non incontra
|
||||
eseguibilità + benchmark onesto. Nessun impatto sul book live crypto (branch separato).
|
||||
@@ -0,0 +1,74 @@
|
||||
# 2026-06-26 — "Scalping BTC/ETH con copertura in opzioni" = gamma scalping → SCARTATO
|
||||
|
||||
**Domanda (goal utente):** analizzare lo scalping su BTC/ETH con copertura in opzioni come
|
||||
possibile nuovo sleeve. **Esito: negativo, strutturale, su tutte le frequenze. Non è uno sleeve,
|
||||
non è nemmeno un hedge.** Branch `research/gamma-scalp-options`, script
|
||||
`scripts/research/options_gamma_scalp.py`.
|
||||
|
||||
## Cos'è davvero "scalping con copertura in opzioni"
|
||||
|
||||
L'interpretazione rigorosa è il **gamma scalping**: compri un'opzione (la *copertura* = long
|
||||
gamma), delta-hedgi il sottostante a cadenza fissa (lo *scalp*), e il P&L netto è
|
||||
≈ dollar-gamma × (vol realizzata² − vol implicita²). È lo **specchio esatto del VRP01** (short-vol):
|
||||
VRP01 incassa IV−RV (positivo in media), il gamma scalping incassa **RV−IV** (negativo in media).
|
||||
|
||||
Modello (mirror della struttura VRP per comparabilità): long straddle ATM, tenor 7g, IV = DVOL
|
||||
Deribit (la stessa fonte del VRP), delta-hedge sui prezzi certificati, fee opzioni cap 12.5% del
|
||||
premio + fee perp 0.05%/lato sull'hedge turnover, return-on-notional vol-targeted 20%.
|
||||
|
||||
## La diagnostica strutturale (il cuore)
|
||||
|
||||
| asset | IV (DVOL) | RV 1d | IV−RV 1d | RV 1h | IV−RV 1h |
|
||||
|---|---|---|---|---|---|
|
||||
| BTC | 60.9% | 56.0% | **+4.9pp** | 64.5% | −3.6pp |
|
||||
| ETH | 74.9% | 76.0% | −1.0pp | 81.0% | −6.1pp |
|
||||
|
||||
- A **rehedge giornaliero** l'IV ≈ o > la RV → il long gamma **paga il VRP** (sei sul lato sbagliato
|
||||
del premio). Perde gross, prima di ogni fee.
|
||||
- A **rehedge orario** la RV *supera* l'IV (BTC +3.6pp, ETH +6.1pp gross) — sembrerebbe un edge…
|
||||
**ma è una trappola doppia:** (a) la RV_1h è gonfiata da microstruttura (bid-ask bounce), quindi
|
||||
il "gross edge" è in parte fittizio; (b) il rehedge orario paga **24× la fee di hedge**, che
|
||||
spazza via qualunque edge intraday reale.
|
||||
|
||||
## I numeri (ogni variante, ogni anno, ogni frequenza: PERDE)
|
||||
|
||||
| variante | rehedge | Sharpe | CAGR | per-anno |
|
||||
|---|---|---|---|---|
|
||||
| NAKED | 1d | **−3.99** | −56% | 2021..2026 tutti −25..−68% |
|
||||
| CHEAP-GATED (IVr<0.30) | 1d | −3.05 | −47% | tutti negativi |
|
||||
| RICH-SKIP (IVr>0.90) | 1d | −3.92 | −55% | tutti negativi |
|
||||
| NAKED | 1h | **−6.06** | −71% | tutti −38..−81% (peggio: le fee) |
|
||||
| CHEAP-GATED (IVr<0.30) | 1h | −4.96 | −64% | tutti negativi |
|
||||
|
||||
Il gate "compra vol solo quando è a sconto" (lo specchio del gate vincente del VRP, *vendi vol solo
|
||||
quando è cara*) **migliora ma non salva**: −3.05 invece di −3.99. Il segno resta negativo perché
|
||||
l'IV-rank basso non garantisce RV > IV su 7 giorni.
|
||||
|
||||
## Scoring marginale vs TP01
|
||||
|
||||
Tutte le varianti: **DILUTES**, `is_hedge=False`, `has_insample_edge=False` (IS Sharpe −3..−4.5),
|
||||
blend-25 uplift hold −1.2/−1.5. Non aiuta nemmeno come hedge: perde sia quando TP01 è su
|
||||
(uplift −1.2..−1.6) sia quando TP01 è giù (uplift −2.5..−3.2). Corr a TP01 ≈ 0 ma il drift negativo
|
||||
domina la matematica di diversificazione.
|
||||
|
||||
## Eseguibilità a ~$600 (muro indipendente)
|
||||
|
||||
- **BTC**: contratto 1 BTC, min 0.1 BTC → notional minimo **$5.968** ≫ $600 → **NON eseguibile**.
|
||||
- **ETH**: min 0.1 ETH → **$157** → marginalmente OK, ma un solo straddle = ~25% del conto in premio.
|
||||
|
||||
Anche se l'edge esistesse, BTC è fuori e ETH è troppo grosso per diversificare.
|
||||
|
||||
## Conclusione
|
||||
|
||||
Lo "scalping con copertura in opzioni" è **schiacciato tra due muri**: rehedge lento → paghi il
|
||||
premio (RV < IV); rehedge veloce → paghi le fee (24×/day). **Nessuna frequenza vince.** Conferma
|
||||
due lezioni di prim'ordine del progetto, ora quantificate sul long-vol:
|
||||
1. **Le fee uccidono lo scalping** (il rehedge orario, vera frequenza di scalping, è la variante
|
||||
*peggiore*: −6 Sharpe).
|
||||
2. **Vol-da-modello è fragile in entrambe le direzioni.** Regola gemella del VRP: *niente long-vol
|
||||
scalp da modello in deploy*.
|
||||
|
||||
**Il VRP01 (lato short, gated, rischio-definito) resta l'unico edge opzioni del progetto** —
|
||||
e funziona proprio perché sta sul lato *giusto* dello stesso premio che il gamma scalp paga.
|
||||
|
||||
SCARTATO. Script preservato come riferimento. Nessun impatto sul book live (branch separato).
|
||||
@@ -0,0 +1,66 @@
|
||||
# 2026-06-26 — TP01 × DVOL vol-targeting: NON migliora (il taglio di DD è solo de-levering)
|
||||
|
||||
**Goal utente:** "continua" ad analizzare strategie. Dopo i tre angoli di diversificazione tutti
|
||||
STAT-MODE (gamma scalp scartato, funding cross-sectional già morto, cash-and-carry lead non
|
||||
deployabile), ho puntato sull'angolo a **più alto valore perché ESEGUIBILE**: migliorare il
|
||||
risk-sizing del book **live** TP01 (BTC/ETH perp Deribit, già armato). Branch
|
||||
`research/gamma-scalp-options`, script `scripts/research/tp01_dvol_overlay.py`.
|
||||
|
||||
## Ipotesi
|
||||
|
||||
TP01 vol-targeta sulla vol **realizzata** 30g (backward-looking). Il **DVOL** (vol implicita 30g
|
||||
Deribit, forward-looking, che spesso anticipa i salti di vol) come denominatore → de-risking più
|
||||
tempestivo prima dei crash → DD più basso / hold-out migliore senza affondare il FULL.
|
||||
|
||||
Onestà: DVOL parte 2021-03 → confronto TUTTE le varianti sulla **finestra comune 2021-04 → 2026**
|
||||
(1891g, perdo il toro 2019-2020 pre-DVOL). Causale, fee 0.10% RT, config canonica TP01.
|
||||
|
||||
## Risultati (finestra comune, hold-out 2025+)
|
||||
|
||||
| variante (denominatore vol) | FULL Sh | FULL DD | CAGR | HOLD Sh |
|
||||
|---|---|---|---|---|
|
||||
| **realized** (baseline) | **+0.75** | 12.3% | +8% | +0.30 |
|
||||
| dvol (implicita) | +0.70 | 9.3% | +6% | +0.33 |
|
||||
| blend 50/50 | +0.72 | 10.4% | +7% | +0.32 |
|
||||
| max(realiz, dvol) | +0.70 | 9.2% | +6% | +0.36 |
|
||||
| derisk (gate DVOL>p90 ×0.5) | +0.75 | 12.3% | +8% | +0.30 |
|
||||
|
||||
Le varianti DVOL **abbassano il DD** (12.3% → ~9.2%) ma **anche** Sharpe FULL (−0.04/−0.05) e CAGR
|
||||
(8% → 6%). Lo Sharpe è scale-invariant → sospetto: il taglio di DD è solo *posizioni più piccole*
|
||||
(l'IV > RV per il VRP → denominatore più grande → leva minore), non vero timing.
|
||||
|
||||
## Il controllo decisivo
|
||||
|
||||
Confronto le varianti DVOL col **realized a target_vol RIDOTTO** (stesso de-levering, senza DVOL):
|
||||
|
||||
| | FULL Sh | DD | CAGR | HOLD Sh |
|
||||
|---|---|---|---|---|
|
||||
| **max-DVOL** | +0.70 | 9.2% | +6% | +0.36 |
|
||||
| **realized @ vol-tgt 15%** | **+0.75** | 9.4% | +6% | +0.30 |
|
||||
| **realized @ vol-tgt 13%** | +0.75 | **8.1%** | +5% | +0.30 |
|
||||
|
||||
**Conclusivo:** il realized a 15% eguaglia il DD del max-DVOL (9.4% ≈ 9.2%) a Sharpe **più alto**
|
||||
(0.75 > 0.70) e stesso CAGR; a 13% fa DD ancora più basso (8.1%), sempre a 0.75. **Il taglio di DD
|
||||
del DVOL è interamente "leva minore", replicabile MEGLIO con un semplice target_vol più basso.**
|
||||
|
||||
## Verdetto
|
||||
|
||||
**DVOL vol-targeting NON migliora TP01.** Risk-adjusted è marginalmente *peggiore* (FULL 0.70 vs
|
||||
0.75 a parità di DD). L'unico residuo a favore — hold-out +0.06 (max 0.36 vs 0.30) — è **single-window**
|
||||
su 5 anni scarsi di storia DVOL: sotto la soglia di **persistenza multi-cut** del progetto (la lezione
|
||||
che ha ucciso 13/14 falsi positivi nello sweep alt). Il gate **DVOL-spike de-risk è RIDONDANTE col
|
||||
trend**: quando il DVOL esplode (crash) TP01 è già flat (momentum negativo → posizione 0) → il gate
|
||||
non tocca nulla (Δ 0.00 su tutto). Niente da deployare.
|
||||
|
||||
## Lezioni
|
||||
|
||||
1. **Per meno DD sul book live, la leva giusta è `target_vol`, non un overlay DVOL.** Abbassare TP01
|
||||
da vol-tgt 20% → 15% taglia il DD 12.3% → ~9.4% allo **stesso Sharpe** (0.75). Dial pulito e noto,
|
||||
se mai si volesse un profilo più difensivo (oggi 20% è la scelta canonica — invariata).
|
||||
2. **La vol implicita non batte la realizzata come denominatore di sizing per un trend long-flat**:
|
||||
il segnale di trend gestisce già il de-risking direzionale; il denominatore fissa solo la
|
||||
magnitudine, e la realizzata lo fa marginalmente meglio per unità di rischio.
|
||||
3. Conferma indiretta del soffitto: anche un raffinamento "ovvio" e *eseguibile* su TP01 non produce
|
||||
alpha; al massimo sposta il punto sulla stessa frontiera rischio/rendimento.
|
||||
|
||||
SCARTATO come miglioria. Nessun impatto sul book live (branch separato, config canonica invariata).
|
||||
@@ -0,0 +1,47 @@
|
||||
# 2026-06-26 — Calendar-vol / term-structure DVOL: il DATO STORICO NON ESISTE (data-first gate)
|
||||
|
||||
**Goal utente:** "continua" — angolo scelto: scaricare e certificare la term-structure DVOL per
|
||||
testare un calendar-vol (front IV vs back IV). **Esito: bloccato a monte dal gate "il dato prima
|
||||
della strategia" (lezione v2.0.0).** Branch `research/gamma-scalp-options`.
|
||||
Script: `scripts/research/probe_vol_termstructure.py` (scan) + `log_vol_termstructure.py` (forward).
|
||||
|
||||
## Scan di fattibilità (prima di costruire qualunque backtest)
|
||||
|
||||
Cosa l'API pubblica Deribit espone davvero per la vol per-scadenza:
|
||||
|
||||
| dato | disponibile? |
|
||||
|---|---|
|
||||
| Snapshot CORRENTE della term-structure ATM (mark_iv per scadenza) | **SÌ** (book_summary_by_currency, tokenless) |
|
||||
| DVOL storico | solo **30g** fisso — nessun indice 7g/60g/90g pubblico |
|
||||
| Trade-history IV per strumento | solo per strumenti **VIVI** (gli scaduti spariscono) |
|
||||
| **Serie storica continua front-vs-back IV** | **NO** — il front-month rotola/espira → irricostruibile |
|
||||
|
||||
**Snapshot oggi (2026-06-26), per riferimento:**
|
||||
- BTC spot $59.9k: 7g 45.6% · 30g 43.2% · 60g 42.7% · 90g 42.9% · 180g 44.5% — **contango** lieve.
|
||||
- ETH spot $1.58k: 7g 57.6% · 30g 56.4% · 90g 57.5% · 180g 59.2% — contango (+1.6pp slope).
|
||||
- (I valori sono interpolati a tenor fissi: il front a 1-giorno grezzo, 38%/54%, è rumore escluso.)
|
||||
|
||||
## Verdetto
|
||||
|
||||
**Un calendar-vol NON è backtestabile ora su dati certificati.** La storia per-scadenza non è
|
||||
pubblica su Deribit (la nostra fonte di verità), e — coerentemente con la metodologia — **nessun
|
||||
edge va creduto senza backtest OOS su dati certificati**. Costruire un calendar-vol su uno snapshot
|
||||
singolo o su una storia ricostruita-a-pezzi sarebbe esattamente il tipo di scorciatoia che ha
|
||||
prodotto la libreria fasulla v2.0.0. Quindi: **stop, niente backtest.**
|
||||
|
||||
## L'unica via legittima: costruire il dato in avanti
|
||||
|
||||
`scripts/research/log_vol_termstructure.py` — logger forward idempotente: ogni run prende lo snapshot
|
||||
ATM, lo interpola ai tenor fissi {7,30,60,90,180}g e appende una riga/asset a
|
||||
`data/raw/vol_term_<asset>.parquet`. Seminate le prime righe BTC/ETH oggi. Test offline
|
||||
`tests/test_vol_termstructure.py` (interpolazione pura).
|
||||
|
||||
**Non auto-cablato in cron** (è ricerca forward, e non tocco il cron live senza ok). Per accumulare:
|
||||
aggiungere una riga giornaliera al crontab. Dopo **~6-12 mesi** → certificare (monotonia, spike,
|
||||
cross-venue) e SOLO ALLORA testare front-vs-back. Prima, nulla.
|
||||
|
||||
## Lezione
|
||||
|
||||
Il data-first gate ha funzionato: ha fermato un backtest su un dato che non esiste, invece di
|
||||
produrre un numero non falsificabile. La term-structure è in **contango** oggi (regime calmo) —
|
||||
osservazione da forward-monitor, non un edge. Nessun impatto sul book live (branch separato).
|
||||
@@ -0,0 +1,118 @@
|
||||
# 2026-06-29 — DVOL come segnale DIREZIONALE/REGIME su BTC/ETH: l'unico edge è un HEDGE, non alpha
|
||||
|
||||
**Filone A — "DVOL-direzionale".** Tesi: usare la vol IMPLICITA Deribit (DVOL, `data/raw/dvol_*.parquet`,
|
||||
2021-03→oggi) come **segnale direzionale o di regime** sul perp BTC/ETH (long-flat o L/S sul LIVELLO di
|
||||
mercato), non come denominatore del vol-target. Script `scripts/research/dvol_directional.py`, test
|
||||
`tests/test_dvol_directional.py`. Branch `research/equity-meanrev-ib` (separato, nessun impatto live).
|
||||
|
||||
## Cosa è diverso dal lavoro DVOL precedente
|
||||
|
||||
- `tp01_dvol_overlay.py` (2026-06-26): DVOL come **denominatore** del vol-target → solo de-levering, SCARTATO.
|
||||
- VOL03/04/10/11 (sweep alt 100-agent): DVOL che **gata/scala un TSMOM** → eredita lo Sharpe di trend di
|
||||
TP01 (il marginal scorer li boccia NEUTRAL/REDUNDANT).
|
||||
- `agent_14_dvol_spread` (onda ortho): IV **relativa BTC-vs-ETH**, market-NEUTRAL 2-leg (l'unico LEAD vivo).
|
||||
|
||||
Qui invece: DVOL/IV-RV come segnale **direzionale STANDALONE sul livello di mercato**, per vedere se porta
|
||||
alpha direzionale **ortogonale** a TP01 (non un overlay sul trend). Tutto causale (decisione ≤ `close[i]`,
|
||||
tenuta in `i+1` dallo shift di `eval_weights`), fee 0.10% RT, vol-target 20%, leva cap 2x, harness onesto
|
||||
condiviso `altlib`. Allineamento DVOL: `al.dvol` (merge_asof backward) → DVOL close del giorno *t* (≈
|
||||
contemporaneo a `close[i]`), noto a decisione; verificato leak-free (`causality_ok` max_tail_diff 0.0).
|
||||
|
||||
DVOL parte 2021-03 → pre-DVOL il segnale è flat. Valuto sia **FULL** (col flat pre-2021, deflaziona lo
|
||||
Sharpe) sia **ERA-only** (da 2021-10, equo per la tesi).
|
||||
|
||||
## 1) Probe direzionale (corr segnale[i] vs ritorno[i+1], pooled BTC+ETH, era DVOL)
|
||||
|
||||
| segnale | corr | long bps | flat bps | **edge bps** | frac_long |
|
||||
|---|---|---|---|---|---|
|
||||
| VRP-Z+ (long VRP ricco, IV-RV alto) | +0.010 | +7.8 | −0.9 | **+8.6** | 0.52 |
|
||||
| VRP-Z− (flip) | −0.017 | −0.9 | +7.8 | −8.6 | 0.48 |
|
||||
| **DVOL-LV fear (rank>0.5 → long)** | **+0.024** | +20.4 | +0.9 | **+19.5** | 0.14 |
|
||||
| DVOL-LV calm (rank<0.5 → long) | −0.013 | +0.8 | +21.2 | −20.4 | 0.86 |
|
||||
| DVOL-MOM (DVOL in calo → long) | −0.041 | −10.0 | +22.9 | **−32.9** | 0.59 |
|
||||
| VRP>0 (quasi buy&hold) | +0.003 | +5.3 | −0.7 | +5.9 | 0.72 |
|
||||
|
||||
Lettura: l'unico contenuto direzionale reale è **"buy-the-fear"** (long quando il DVOL è alto nel suo
|
||||
percentile espandente: edge +19.5 bps il giorno dopo). Il VRP-ricco→long è coerente ma debolissimo (+8.6).
|
||||
Il "DVOL in calo → long" è **negativo** (−33 bps): comprare quando la paura scende è perdente (regola
|
||||
falsificata). Nota: il percentile espandente è dominato dai picchi 2021-22 → "fear" scatta solo nel 14%
|
||||
delle barre (gli spike veri), non metà del tempo.
|
||||
|
||||
## 2) Robustezza assoluta (study_weights 1d, fee sweep 0.00-0.20% RT) — ERA-only il giudizio equo
|
||||
|
||||
| segnale | abs | minFull Sh | minHold Sh | feeOK | **ERA Sh** | ERA DD | ERA CAGR |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **DVOL-fear q0.4 LF** | WEAK | +0.39 | +0.14 | sì | **+0.59** | **8%** | +5% |
|
||||
| VRP-Z60 LF | FAIL | +0.42 | −0.39 | sì | +0.46 | 23% | +7% |
|
||||
| DVOL-fear q0.5 LF | FAIL | +0.31 | −0.17 | sì | +0.40 | 10% | +3% |
|
||||
| VRP-Z90 LF | FAIL | +0.32 | −0.26 | sì | +0.32 | 29% | +4% |
|
||||
| VRP-Z90 L/S | FAIL | +0.25 | +0.32 | sì | +0.31 | 28% | +3% |
|
||||
| DVOL-calm q0.5 LF | FAIL | +0.00 | −1.05 | no | +0.09 | 37% | −0% |
|
||||
| DVOL-MOM k10/k20 LF | FAIL | −0.6/−0.7 | <−1 | no | −0.6/−0.7 | ~40% | ~−10% |
|
||||
| DVOL-MOM k10 L/S | FAIL | −1.16 | −1.29 | no | −1.23 | 72% | −23% |
|
||||
|
||||
Nessun segnale supera lo Sharpe assoluto in **valore** (tutti < 0.6, soffitto direzionale BTC/ETH ~1.3
|
||||
confermato). Unico con profilo decente: **DVOL-fear q0.4 long-flat** (ERA Sh +0.59, DD **8%**) — il DD
|
||||
bassissimo perché è long solo nei picchi di paura (poche barre, mean-reversion). Le famiglie momentum e
|
||||
"buy-the-calm" sono nettamente negative e non sopravvivono nemmeno alle fee.
|
||||
|
||||
## 3) Marginal vs TP01 (il gate vero) — DVOL-fear q0.4 long-flat
|
||||
|
||||
| | corr→TP01 | in-sample Sh | uplift_full w25 | uplift_hold w25 | multi-cut persistente | is_hedge | verdetto |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **ERA-only** (2021-10+) | 0.37 | 0.54 (has_edge) | +0.06 | +0.17 | sì | **No** | **ADDS** (earns_slot=True) |
|
||||
| **FULL** (gate ufficiale) | 0.28 | 0.64 (has_edge) | +0.03 | +0.17 | sì | **Sì** | **HEDGE** (earns_slot=**False**) |
|
||||
|
||||
Il candidato ha tutto ciò che serve a un nuovo sleeve **tranne una cosa**: il gate canonico full-history
|
||||
lo classifica **HEDGE**, non alpha. `hedge_yearly_corr(TP01-Sh, uplift) = −0.76`, `uplift TP01-up −0.003`
|
||||
/ `uplift TP01-down +0.194` → **paga SOLO quando TP01 è debole**. Ha perfettamente senso: il DVOL esplode
|
||||
durante i crash, proprio quando il trend di TP01 è flat/in-drawdown; comprare la paura cattura il rimbalzo
|
||||
e smorza il DD del portafoglio, ma **non è un premio permanente**. Il blend 0.75·TP01 + 0.25·cand alza
|
||||
l'hold-out 0.30→**0.47** (w50 → 0.62) e taglia il DD a ~11%/8% — un effetto **drawdown-dampener** reale,
|
||||
ma classificato (giustamente) come hedge.
|
||||
|
||||
Perché ERA dice ADDS e FULL dice HEDGE: il rivelatore di hedge richiede ≥3 anni; sulla finestra ERA
|
||||
(2021-10+) la `corr(TP01-Sh annuale, uplift)` è meno netta e non scatta. **Il gate full-history è quello
|
||||
giusto** (più dati per il test di hedge) → mi attengo a quello: **HEDGE, non alpha.**
|
||||
|
||||
Gli altri tre candidati (VRP-Z60/90, DVOL-fear q0.5): **NEUTRAL o HEDGE**, mai ADDS. I VRP-Z hanno beta a
|
||||
TP01 alto (0.88-0.90) → sono trend-correlati travestiti (resid Sharpe ~0), uplift hold-out negativo.
|
||||
|
||||
## 4) Causalità, alignment, eseguibilità, sign-falsification
|
||||
|
||||
- **Causale**: `causality_ok` ok=True, max_tail_diff **0.0** su tutti i candidati (rank espandente +
|
||||
z-score ricalcolati su prefisso = identici → nessun future-peeking). Test dedicato `test_expanding_rank_is_causal`.
|
||||
- **Alignment DVOL**: laggando il segnale di +1 giorno (extra-conservativo, usa solo DVOL di ieri) l'ERA
|
||||
Sharpe del leader scende +0.59 → **+0.45**. L'edge sopravvive ma è **sensibile all'allineamento**
|
||||
(parte del vantaggio è nel reagire allo spike DVOL del giorno stesso) → cautela in esecuzione.
|
||||
- **Eseguibile a $600**: `eval_weights_smallcap` (min_order $5, cap $300/asset): haircut Sharpe **≈ 0.00**
|
||||
su BTC ed ETH (segnale long-flat a basso turnover, ~210-270 trade su tutta la storia) → lo Sharpe netto
|
||||
onesto a $600 = quello modellato. È l'unico aspetto pienamente promosso.
|
||||
- **Sign-falsification**: tesi buy-the-fear ERA Sh +0.40 vs flip buy-the-calm +0.09; VRP-Z+ +0.32 vs
|
||||
VRP-Z− (flip) −0.14. Il segno della tesi è quello giusto (l'edge è reale, non rumore simmetrico).
|
||||
|
||||
## Verdetto onesto: **LEAD (hedge / drawdown-dampener), NON sleeve.** SCARTATO come alpha direzionale.
|
||||
|
||||
- Nessun segnale DVOL-direzionale ha Sharpe assoluto sopra ~0.6 → **conferma il soffitto BTC/ETH
|
||||
direzionale ~1.3** (la via per superarlo resta il cross-sectional XS01, non un nuovo segnale a 2 asset).
|
||||
- L'unico con edge in-sample reale + eseguibile + leak-free è **DVOL-fear "buy-the-fear" long-flat**, ma il
|
||||
gate canonico lo bolla **HEDGE** (`is_hedge=True`, paga solo quando TP01 è in difficoltà). Per la regola
|
||||
del progetto un hedge **non si arma come slot di alpha** → **earns_slot=False**.
|
||||
- È comunque un **LEAD utile in forward-monitor** come **smorzatore di drawdown** del portafoglio: il blend
|
||||
porta l'hold-out 0.30→0.47/0.62 e abbassa il DD. Se in futuro si vorrà esplicitamente un tail/DD-hedge
|
||||
*eseguibile* (a differenza del tail-hedge in opzioni, non eseguibile a $600), questo è il candidato più
|
||||
pulito visto finora — ma da prezzare **come hedge**, non come ritorno atteso permanente.
|
||||
|
||||
## Caveat
|
||||
|
||||
1. **Storia DVOL <5 anni** (2021-03→oggi, ~4.7 anni; era valutata 2021-10+) → multiple-testing e
|
||||
finestra corta. Il multi-cut 2026 è un assurdo +1.944 (manciata di giorni, picco DVOL recente):
|
||||
l'uplift hold-out è trascinato dal regime 2025-26 ad alta-paura, non da un edge stazionario.
|
||||
2. **È un hedge, non alpha**: il valore è condizionato ai drawdown di TP01 (2022, ed eventuali futuri).
|
||||
In un toro lungo a bassa vol il segnale resta **flat l'86% del tempo** e non contribuisce nulla.
|
||||
3. **Sensibile all'allineamento** DVOL (haircut −0.14 Sharpe col lag +1g): l'esecuzione reale dovrebbe
|
||||
usare il DVOL già stampato a decisione, non assumere il close contemporaneo.
|
||||
4. Long-flat → cattura il rimbalzo post-paura ma **non protegge** durante la discesa (non è uno short):
|
||||
è "buy-the-dip gated by fear", con tutti i rischi del comprare coltelli che cadono se il regime peggiora.
|
||||
|
||||
Nessun impatto sul book live (branch separato, nessuno sleeve registrato, config canonica invariata).
|
||||
@@ -0,0 +1,145 @@
|
||||
# 2026-06-29 — Filone B: INTRADAY REGIME BTC/ETH — ERM (efficiency-ratio) è un LEAD reale, forward-monitor
|
||||
|
||||
**Tesi.** Cercare un meccanismo **sub-daily** sui dati certificati 1h→12h BTC/ETH che sia ortogonale
|
||||
**sia** a TP01 (TSMOM trend daily, long-flat) **sia** a SKH01 (regime BuzVola/BuzVolume + Donchian a
|
||||
230m). SKH01 prova che il sub-daily PUÒ funzionare ed essere quasi-ortogonale: qui un MECCANISMO DIVERSO,
|
||||
basato sulla **qualità del moto intraday** (efficiency-ratio / vol-expansion / thrust) come **regime** che
|
||||
condiziona una posizione direzionale tenuta ~1 giorno. Script `scripts/research/intraday_regime.py`, test
|
||||
`tests/test_intraday_regime.py`. Branch `research/equity-meanrev-ib` (separato, nessun impatto live).
|
||||
|
||||
Killer ricorrente del progetto sotto le 12h = **muro-fee 0.10% RT + overfitting**. Ricetta SKH01:
|
||||
decisione sub-daily ma **hold ~1 giorno** → pochi trade → la fee non uccide. Ogni meccanismo qui è
|
||||
costruito a basso turnover e giudicato col **fee-sweep alla sua frequenza reale**.
|
||||
|
||||
## Meccanismi provati (tutti come posizione CONTINUA decisa ≤ `close[i]`, causali)
|
||||
|
||||
| | meccanismo |
|
||||
|---|---|
|
||||
| **ERM** | **Efficiency-Ratio regime momentum** (Kaufman): ER = \|moto netto su L barre\| / \|percorso\|. Prendi la direzione del moto netto **solo quando ER ≥ soglia** (regime intraday "pulito"/trendy), altrimenti flat |
|
||||
| VEM | Vol-Expansion Momentum: direzione = segno del moto, attiva solo quando vol-corta > vol-lunga |
|
||||
| VBR | Volatility/thrust breakout (Larry-Williams ROLLING, no calendario): segui i moti > k·ATR |
|
||||
| TOD | Time-of-day seasonality — **CONTROLLO calendario**, incluso APPOSTA per `day_boundary_robust` |
|
||||
|
||||
## Selezione + fee-sweep a frequenza reale (vincitori per famiglia, min-asset)
|
||||
|
||||
```
|
||||
ERM: 8h L=2.0 thr=0.35 L/S minFull +0.88 minHold +0.99 f@.20 +0.55 feeOK=True turn/y 125
|
||||
VEM: 12h Lmom=3 Ls=2 Ll=10 minFull +0.72 minHold -0.29 f@.20 +0.51 feeOK=True (hold<0 → out)
|
||||
VBR: 12h k=1.0 atr=14 L/S minFull +0.33 minHold +1.20 f@.20 +0.25 feeOK=True
|
||||
TOD: 1h minFull -3.99 minHold -4.49 feeOK=False (controllo → FAIL atteso)
|
||||
```
|
||||
|
||||
Solo **ERM** e **VBR** sopravvivono al gate fee+OOS. VEM ha hold-out negativo. TOD esplode (vedi sotto).
|
||||
|
||||
## Il candidato: ERM 8h L=2.0 thr=0.35 (L/S) — `earns_slot=True`
|
||||
|
||||
```
|
||||
abs=PASS marginal=ADDS EARNS_SLOT=True
|
||||
standalone: cand FULL 0.919 / HOLD 1.105 (TP01 ref FULL 1.297 / HOLD 0.303)
|
||||
corr->TP01 full 0.146 hold 0.057 beta 0.197 resid Sharpe 0.738 alpha/yr 0.121
|
||||
robust_oos=True (clean-year +0.103, drop-best-month +0.283)
|
||||
multi-cut persistence: 2020 .166 / 2021 .167 / 2022 .298 / 2023 .337 / 2024 .418 / 2025 .497 / 2026 2.861 persistent=True
|
||||
has_insample_edge=True (standalone PRE-holdout Sharpe 0.874, > null-pctl 0.317)
|
||||
is_hedge=False (yearly corr(TP01-Sh, uplift) -0.762; paga sia TP01-up 0.139 sia TP01-down 0.384)
|
||||
blend w25: FULL 1.481 (+0.184) HOLD 0.80 (+0.497) DD 11.0%
|
||||
corr con SKH01: full 0.278 hold 0.291
|
||||
day_boundary=INVARIANT (spread 0.0) haircut $600 ≈ 0.00 su BTC/ETH
|
||||
```
|
||||
|
||||
È il **primo sub-daily da SKH01** a passare TUTTI i gate del marginal scorer indurito: edge in-sample
|
||||
reale (non diversification-math), persistenza multi-cut, robust_oos, NON hedge, scorrelato a TP01 (0.15).
|
||||
|
||||
### ERM aggiunge OLTRE SKH01? (gate #5 — il book esistente ne ha già uno sub-daily)
|
||||
|
||||
| blend | FULL Sh | FULL DD | HOLD Sh | HOLD DD |
|
||||
|---|---|---|---|---|
|
||||
| TP01 | +1.30 | 14.3% | +0.30 | 7.5% |
|
||||
| TP01+SKH 75/25 | +1.78 | 9.4% | +1.17 | 6.2% |
|
||||
| **TP01+SKH+ERM 60/25/15** | **+1.88** | 8.9% | **+1.46** | 5.3% |
|
||||
| TP01+SKH+ERM 55/20/25 | +1.84 | 8.8% | +1.48 | 5.1% |
|
||||
|
||||
Sì: corr con SKH01 solo 0.28 → ERM **aggiunge oltre SKH** (FULL +0.10, HOLD +0.29 a peso 15%, DD ancora
|
||||
giù). Non è SKH01 travestito.
|
||||
|
||||
## Il controllo TOD (calendario) — fa esattamente ciò che doveva
|
||||
|
||||
`TOD` (direzione per ora-del-giorno, media espandente causale) è incluso come **trappola**: è il tipo di
|
||||
effetto che uccise `open_drive` (artefatto di etichettatura UTC). Esito: **FAIL** (FULL −3.99, 31.811
|
||||
trade, fee-killed), marginal=DILUTES. `day_boundary_robust=INVARIANT` → l'effetto è **robustamente
|
||||
negativo** a ogni offset (non un artefatto di confine giorno: è proprio che la time-of-day-direzionale non
|
||||
ha edge e sanguina fee). Il controllo conferma che l'harness non si fa ingannare e che il segno
|
||||
dei segnali di prezzo (ERM/VBR) è reale, non rumore di calendario.
|
||||
|
||||
## Causalità / eseguibilità
|
||||
|
||||
- **Leak-free**: `causality_ok=True`, max_tail_diff **0.0** su tutti i candidati (ER, rank espandente,
|
||||
medie su prefisso = identiche → nessun future-peeking). Test dedicato in `test_intraday_regime.py`.
|
||||
- **day_boundary_robust=INVARIANT** (spread 0.0) per ERM/VBR/TOD: segnali di prezzo, non di calendario.
|
||||
- **Eseguibile a $600**: `eval_weights_smallcap` haircut **≈ 0.00** su BTC ed ETH. **MA** ERM a 8h fa
|
||||
~3.158 trade BTC / ~2.823 ETH su tutta la storia (turnover 125/anno): haircut nullo nel modello, ma
|
||||
l'esecuzione reale sub-daily sul book è operativamente più pesante di un segnale 1d (slippage/spread
|
||||
intraday non interamente catturati dalla fee proporzionale).
|
||||
|
||||
## Caveat (perché LEAD, non sleeve)
|
||||
|
||||
1. **Plateau hold-out a UNA SOLA RIGA.** Il FULL è robusto su tutta la griglia L∈[2.0,3.0] (+0.6..+1.0),
|
||||
ma l'**hold-out è positivo SOLO a L=2.0** (a L=2.5/3.0 crolla a −0.5..−0.8). Il plateau sul full è
|
||||
ampio, quello che conta — l'hold-out — è single-row. Da rinforzare prima di credere alla stazionarietà.
|
||||
2. **Standalone FULL 0.92 < soffitto ~1.3.** Coerente col soffitto direzionale BTC/ETH: il valore di ERM
|
||||
è **marginale/diversificante** (corr 0.15 a TP01, 0.28 a SKH01), non assoluto. Non rompe il soffitto.
|
||||
3. **Multiple-testing non deflazionato.** 102 celle testate (60 ERM + 16 VEM + 24 VBR + 2 TOD) senza
|
||||
deflated-Sharpe (a differenza del filone C). Il multi-cut 2026 = +2.861 è una manciata di giorni che
|
||||
gonfia. Storia sub-daily certificata utile ~quanto SKH01 → finestra non lunghissima.
|
||||
4. **Esecuzione 8h** = complessità operativa reale (vedi sopra), oltre il modello a haircut nullo.
|
||||
|
||||
## Analisi di robustezza / de-bias (`intraday_regime_analysis.py`) — il lead NON regge
|
||||
|
||||
I caveat #1 (plateau hold-out single-row) e #3 (multiple-testing) erano i sospetti giusti. Tre test di
|
||||
de-bias li trasformano da sospetto in **bocciatura** dello slot:
|
||||
|
||||
| test | esito |
|
||||
|---|---|
|
||||
| **A) Deflated-Sharpe** (Bailey & Lopez de Prado) su 122 trial cercati | **FAIL.** DSR 0.000 (tutti) / **0.163 (escludendo i trap TOD)** / 0.241 (solo-ERM) — tutti ≪ 0.95. Lo Sharpe winner (0.92) è sotto lo Sharpe-max-atteso-null (1.16–2.51): il search ha trovato celle a 1.6 full / 1.7 in-sample, il winner 0.92 **non è eccezionale**. |
|
||||
| **B) Selezione IN-SAMPLE-only** (scelgo la cella ERM col solo Sharpe < 2025) | **earns_slot=False.** La cella migliore pre-hold-out è un'**ALTRA** (8h L=2.0 thr=0.4 **long-flat**), con corr→TP01 **0.53** (è trend-beta travestito) → marginal=**NEUTRAL**. Il winner max-hold **non si seleziona senza guardare l'hold-out** → il suo `earns_slot=True` era **selezione-sull'hold-out**. |
|
||||
| **C) Ensemble del plateau** (media 20 celle L×thr, niente cherry-pick) | **earns_slot=False.** marginal=ADDS, in-sample Sh 1.01, corr→TP01 0.18 — ma **`robust_oos=False`** (clean-year + jackknife): l'uplift hold-out è trascinato dal **2026 (+2.09 multicut)**, manciata di giorni. |
|
||||
|
||||
**Dove vive l'(eventuale) edge** (per-anno, blend 3-way 60/25/15 vs 2-way 75/25): uplift FULL solo **+0.10**,
|
||||
**negativo nel 2021 (−0.23) e 2022 (−0.15)**, positivo altrove; l'uplift HOLD **+0.30 è concentrato nel
|
||||
2026 (+0.46)**. corr(ERM,SKH) 0.28 full (fino a 0.42 in alcuni anni) → **parziale sovrapposizione con SKH**,
|
||||
non ortogonalità piena.
|
||||
|
||||
**Lettura.** Il segnale efficiency-ratio non è rumore puro (l'ensemble ha in-sample Sh ~1.0, positivo nella
|
||||
maggior parte degli anni), ma come **slot** fallisce ogni de-bias: il `earns_slot=True` della scoperta era
|
||||
prodotto da **(1) selezione della cella sull'hold-out** + **(2) coda 2026** + **(3) multiple-testing non
|
||||
corretto**. È lo stesso falso-positivo che l'alt-sweep 100-agent imparò a uccidere — qui ucciso dai gate.
|
||||
|
||||
## Caveat originari (ora risolti dall'analisi sopra)
|
||||
|
||||
1. ~~Plateau hold-out single-row~~ → **confermato fatale**: l'edge hold-out a L=2.0 è cell-selection.
|
||||
2. ~~Multiple-testing non deflazionato~~ → **deflazionato: DSR FAIL** anche senza i trap.
|
||||
3. Esecuzione 8h: irrilevante ormai (lo slot non c'è).
|
||||
|
||||
## Verdetto onesto: **NON è uno slot. Falso positivo da selezione-hold-out + coda 2026.** SCARTATO come sleeve.
|
||||
|
||||
Lo "earns_slot=True" della scoperta **non sopravvive** alla de-selezione: deflated-Sharpe FAIL (anche
|
||||
escludendo i controlli), selezione in-sample-only → NEUTRAL su un'altra cella (trend-beta corr 0.53),
|
||||
ensemble del plateau → robust_oos FAIL. **Conferma ennesima del soffitto direzionale BTC/ETH ~1.3**: un
|
||||
segnale a 2 asset non lo supera; la via resta il cross-sectional (XS01). Resta al più una **curiosità in
|
||||
forward-monitor** (l'efficiency-ratio ha un debole edge in-sample reale), ma da non armare come alpha.
|
||||
Gli altri tre meccanismi: VBR=NOISE, VEM hold-out<0, TOD=controllo FAIL come atteso.
|
||||
|
||||
**Lezione harness (CODIFICATA).** Lo script di scoperta selezionava il vincitore per `min_hold` massimo
|
||||
sulla griglia = **selezione-sull'hold-out**, il punto cieco che ha generato il falso PASS. Il marginal
|
||||
scorer da solo non basta se la *cella* è scelta guardando l'hold-out: serve **(a)** scegliere la cella
|
||||
in-sample-only (o walk-forward) **prima** di valutare il marginal, e **(b)** deflazionare per il numero di
|
||||
celle/famiglie testate. Stesso buco segnalato per CC01 ("Sharpe implausibile") e per l'alt-sweep
|
||||
(hold-out-fitting): qui in forma "selection-on-holdout". Ora è **codice** in `altlib` (LESSON 4): tre
|
||||
funzioni nuove — `deflated_sharpe()` (Bailey & Lopez de Prado), `select_cell_insample()` (scelta cella
|
||||
col solo Sharpe pre-HOLDOUT), e il gate combinato **`study_family_honest()`** (`earns_slot_honest =
|
||||
earns_slot[cella in-sample] AND deflated-Sharpe≥0.95`). Verificato: su ERM il gate ritorna
|
||||
`earns_slot_honest=False` (cella in-sample = trend-beta NEUTRAL, DSR 0.74). Analisi completa in
|
||||
`scripts/research/intraday_regime_analysis.py`; test in `tests/test_harness_realism.py`.
|
||||
**Regola nuova: una strategia direzionale grid-searched si giudica con `study_family_honest`, non
|
||||
chiamando `study_marginal` sulla cella a max hold-out.**
|
||||
|
||||
Nessun impatto sul book live (branch separato, config canonica invariata).
|
||||
@@ -0,0 +1,71 @@
|
||||
# 2026-06-29 — Filone D: MACRO regime-gate sul book crypto — RIDONDANTE col trend, SCARTATO
|
||||
|
||||
**Tesi.** Usare segnali macro/cross-market — equity (SPY/QQQ/IWM), credito (HYG/LQD), oro (GLD/SLV),
|
||||
tassi (TLT/IEF) — come **gate risk-on/risk-off** sul book BTC/ETH, per migliorare il *timing* del
|
||||
drawdown di TP01: regime risk-off (credito che cede, equity sotto trend, fuga sui bond) → riduci/azzera
|
||||
l'esposizione crypto; risk-on → lascia agire TP01. **È eseguibile** (gata solo BTC/ETH perp, non aggiunge
|
||||
gambe). Script `scripts/research/macro_regime_gate.py`, test `tests/test_macro_regime_gate.py`. Branch
|
||||
`research/equity-meanrev-ib` (separato, nessun impatto live). Dati: `data/raw/eq_*_1d.parquet` (ETF daily,
|
||||
allineati causale `merge_asof backward` sul calendario SPY).
|
||||
|
||||
**NON è un lead-lag direzionale** (già morto: diari 2026-06-22/-23). L'angolo nuovo = un overlay
|
||||
binario/continuo di **de-risk** sul book esistente.
|
||||
|
||||
## Il rischio noto in partenza (CLAUDE.md)
|
||||
|
||||
La lezione DVOL-spike (2026-06-26): un gate di de-risk rischia di essere **ridondante col trend** — TP01
|
||||
è già long-flat e va a 0 nei crash (Δ 0.00). Questo script DOVEVA dimostrare che il gate aggiunge **oltre**
|
||||
quel che TP01 fa da solo. **Non ci riesce.**
|
||||
|
||||
## Gate provati (tutti causali: SMA/ratio/z-score rolling, merge_asof backward)
|
||||
|
||||
`SPY>MA{50,100,150,200,250}`, `HYG>MA`, ratio di credito `HYG/LQD`, `HYG/IEF`, combo a maggioranza
|
||||
(SPY+HYG+HYG/LQD, binario e continuo), flight-to-quality (`TLTup&SPYdn`, `GLDup&SPYdn`), regime di vol
|
||||
equity `SPYvol_z1` (proxy "VIX-spike" senza VIX). g_off ∈ {0.0, 0.5}.
|
||||
|
||||
## Esiti
|
||||
|
||||
I gate di trend equity alzano lo Sharpe FULL della combo (SPY>MA150/200/250 → FULL ~1.22-1.25 vs TP01-solo
|
||||
1.30) ma **non l'hold-out** (HOLD ~0.17-0.19) → è **de-levering**, non timing: tagliano DD spegnendo il
|
||||
book in mercati equity laterali, non anticipando i crash crypto. Il miglior profilo DD è `SPYvol_z1`.
|
||||
|
||||
### Deep-dive `SPYvol_z1` — il controllo decisivo (ridondanza col trend)
|
||||
|
||||
| | exposure TP01 risk-off | exposure risk-on | giorni risk-off | **giorni in cui il gate LAVORA** | corr(1-gate, 1-expo) |
|
||||
|---|---|---|---|---|---|
|
||||
| BTC | 0.023 | 0.093 | 18% | **3%** | 0.278 |
|
||||
| ETH | 0.013 | 0.068 | 16% | **2%** | 0.296 |
|
||||
|
||||
"Il gate lavora" = giorno risk-off **E** posizione TP01 non già flat. Accade nel **2-3% dei giorni**: nel
|
||||
resto, quando il gate vorrebbe de-riskare, TP01 è **già flat da solo**. Il gate spegne un motore già spento.
|
||||
|
||||
### Marginal scorer vs TP01 (gate come candidato-sleeve)
|
||||
|
||||
```
|
||||
abs=FAIL marginal=REDUNDANT EARNS_SLOT=False
|
||||
corr->TP01 full 0.989 hold 0.984 beta 0.978 resid Sharpe 0.739
|
||||
multi-cut persistent=True has_insample_edge=True (standalone 1.634) is_hedge=False
|
||||
blend w25: full 1.323 (+0.026) hold 0.257 (-0.046) blend w50: full 1.348 (+0.051) hold 0.21 (-0.093)
|
||||
```
|
||||
|
||||
corr→TP01 **0.989**, beta 0.98: lo stream gated **È** TP01. L'uplift hold-out è **negativo** (de-levering
|
||||
che toglie ritorno fuori dai crash). overlay-delta (gated−solo) come stream a sé: `verdict=NEUTRAL`.
|
||||
|
||||
## Causalità / eseguibilità (gli unici aspetti promossi)
|
||||
|
||||
- **Leak-free**: `causality_ok=True` (max_tail_diff 0.0); STRICT align (1 barra equity extra di lag) →
|
||||
FULL invariato; `day_boundary_robust=INVARIANT` (spread 0.0). Allineamento macro→crypto backward-only.
|
||||
- **Eseguibile a $600**: haircut `eval_weights_smallcap` ≈ **0.00** su BTC ed ETH. Fee-survivente
|
||||
(0.00→0.30% RT: 1.42→1.33). Tecnicamente impeccabile — semplicemente **non aggiunge alpha**.
|
||||
|
||||
## Verdetto: **SCARTATO (ridondante col trend).** earns_slot=False.
|
||||
|
||||
Conferma esatta della lezione in CLAUDE.md: un overlay di de-risk su un book che è **già** long-flat e
|
||||
già a 0 nei crash non ha quasi nulla da fare (lavora il 2-3% dei giorni, corr→TP01 0.99). Il taglio di DD
|
||||
che mostra è **de-levering**, replicabile meglio con un `target_vol` più basso (stessa lezione del
|
||||
TP01×DVOL overlay del 2026-06-26). Il macro-regime **non anticipa** i drawdown crypto meglio del prezzo
|
||||
crypto stesso. Nessuno sleeve registrato; nessun impatto sul book live.
|
||||
|
||||
**Sottoprodotto utile:** il gate `redundancy_diag` (exposure risk-off vs risk-on + "quota giorni in cui il
|
||||
gate lavora") è il test pulito da riusare ogni volta che si propone un overlay di de-risk — se "il gate
|
||||
lavora" è basso e corr→TP01 ~1, è ridondante, a prescindere dallo Sharpe FULL che mostra.
|
||||
@@ -0,0 +1,106 @@
|
||||
# 2026-06-29 — Ricerca strategie a 5 thread paralleli: 0 nuovi sleeve, 1 LEAD che rompe 2 muri su 3
|
||||
|
||||
**Mandato.** "Cerca altre strategie" (+ "senza correlazioni, a segnale" + "a 1 gamba in/out a segnale").
|
||||
Cinque ricerche parallele su aree genuinamente inesplorate, tutte sull'harness onesto condiviso
|
||||
(`altlib` + `xsec_v2_nonmom`) con TUTTI i gate: causalità (prefix-check), netto fee 0.10% RT + sweep,
|
||||
OOS hold-out 2025+, **deflated-Sharpe** (multiple-testing), **gate anti-selection-on-holdout**
|
||||
(`study_family_honest`: cella scelta IN-SAMPLE-only), marginal vs TP01/XS01, corr vs book, haircut $600.
|
||||
Branch `main`, **nessun impatto live** (solo file nuovi, nessuno sleeve registrato).
|
||||
|
||||
Premessa onesta (base-rate): il soffitto direzionale BTC/ETH ~1.3 è già confermato e lo sweep di 104
|
||||
ipotesi (2026-06-20) non produsse nulla di nuovo che reggesse. L'attesa era "quasi tutto SCARTATO".
|
||||
|
||||
## Esiti per thread
|
||||
|
||||
| # | Thread | File | Verdetto |
|
||||
|---|--------|------|----------|
|
||||
| 1 | **XSEC low-risk cousins** (MAX/lottery, idio-vol, Amihud) | `xsec_v3_lowrisk.py` | 1 LEAD (IVOL), 0 sleeve |
|
||||
| 2 | **XSEC momentum-structure** (risk-adj/accel/frog-in-pan/vol-managed vs XS01) | `xsec_v3_momstruct.py` | tutto REDUNDANT/SCARTATO |
|
||||
| 3 | **Meta-allocazione** (allocazione dinamica tra i 4 sleeve) | `meta_allocation.py` | pesi fissi vincono |
|
||||
| 4 | **Segnali ortogonali ETH/BTC** (relative-value dollar-neutral) | `orthogonal_signals.py` | 2 LEAD (STATARB, DVOLSPREAD) |
|
||||
| 5 | **1-gamba a segnale** (MACD/RSI/Supertrend/Donchian/BBands/EMA) | `signal_inout_1leg.py` | 0/12 earns_slot |
|
||||
|
||||
**Netto: 0 nuovi sleeve.** Il soffitto regge. Test 143/143.
|
||||
|
||||
### Thread 1 — Low-risk cross-sectional (51 alt HL)
|
||||
Tre fattori mai provati (il filone C aveva fatto total-vol e BAB, non questi). **IVOL** (idiosyncratic-vol
|
||||
basso, 19-major B30 H5 k8): FULL 1.06 / HOLD **1.22** / corr ~0 a XS01 e TP01 / positivo ogni anno /
|
||||
uplift portafoglio HOLD +0.42/+0.50 — l'unico LEAD di valore. **MAX** e **AMIHUD-liquido** sono lo stesso
|
||||
tema "evita speculativo/illiquido/volatile" in altre vesti (corr fra loro 0.33-0.59). **AMIHUD
|
||||
long-illiquido SCARTATO** (premio illiquidità invertito in crypto: vincono i major liquidi). Bocciatura
|
||||
del claim forte: **deflated-Sharpe 0.30-0.37 ≪ 0.95** (96 trial), storia ~2.5 anni, book 10-16 gambe →
|
||||
**STAT-MODE**. Nota di rigore: il naive best-HOLD atterrava su celle in-sample-negative (holdout-fitting) →
|
||||
l'agente ha auto-aggiunto il gate has_insample_edge. → **forward-monitor IVOL**.
|
||||
|
||||
### Thread 2 — Momentum-structure vs XS01
|
||||
4 varianti (risk-adjusted, acceleration, frog-in-pan, vol-managed). **Nessuna batte né diversifica XS01**
|
||||
(standalone 1.42). Sostituire XS01 con una variante **distrugge l'hold-out** del portafoglio (−0.58…−1.38)
|
||||
— prova diretta che l'edge di XS01 sta nella sua struttura SPECIFICA (blend z-score [30,90] + gate
|
||||
dispersione), non in varianti generiche. Deflated-Sharpe max 0.49. Tutte REDUNDANT/SCARTATO.
|
||||
|
||||
### Thread 3 — Meta-allocazione dinamica
|
||||
Vol-parity / momentum-of-sleeves / dispersion-regime / drawdown-control vs pesi fissi, con costo di
|
||||
ribilancio realistico. **Nessuno batte i pesi fissi OOS.** Vol-parity = trappola da manuale (+0.09…+0.44
|
||||
sui tagli in-sample 2022-24, **−0.11 sull'hold-out 2025** → intercettato dal multi-cut). Drawdown-control
|
||||
RIDONDANTE (TP01 va già flat nei crash, il gate non si attiva mai). I 4 sleeve sono già quasi-scorrelati
|
||||
(corr ~0.12 max) → i pesi fissi sono già vicini al risk-parity ottimo statico. **Mantenere i pesi fissi.**
|
||||
|
||||
### Thread 4 — Segnali ortogonali ETH/BTC (il risultato più notevole)
|
||||
Relative-value dollar-neutral sul ratio log(ETH/BTC): 6 segnali, evaluator a 2 gambe (fee × 2), cella
|
||||
scelta in-sample-only.
|
||||
|
||||
| segnale | FULL/HOLD | corr→book | β-mkt | marginal | DSR (grid/same-sign) | exec $600 | verdetto |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **STATARB-RESID** (residual relative-mom ETH−β·BTC, W45 **sgn+1**) | 0.84/0.56 | **0.027** | 0.013 | **ADDS** | 0.056 / 0.929 | haircut ~0 | **LEAD** |
|
||||
| DVOLSPREAD (IV relativa, W60) | 0.74/0.77 | 0.017 | 0.012 | ADDS | 0.082 / 0.907 | haircut ~0 | LEAD |
|
||||
| RATIO-MOM/REV/ACCEL | 0.25-0.68 / ≤0 | ~0 | ~0 | NEUTRAL | <0.23 | ok | NEUTRAL (diversification-math) |
|
||||
| VOLSPREAD | −0.24/−1.47 | ~0 | 0.013 | DILUTES | — | — | SCARTATO |
|
||||
|
||||
**STATARB-RESID rompe 2 dei 3 muri storici:** è **ortogonale per costruzione** (corr→book 0.027, beta di
|
||||
mercato 0.013 — meglio di SKH ~0.09) **ED eseguibile a $600** (book a 2 gambe BTC+ETH perp Deribit,
|
||||
haircut ≈ 0, fee-survive fino a 0.30%/gamba → **NON STAT-MODE**, a differenza di XS01 e delle opzioni). Il
|
||||
muro che **resta** è l'**edge**: Sharpe 0.84 / DSR 0.929 same-sign (ottimistico) ma comunque <0.95.
|
||||
Verificato indipendentemente dal coordinatore: residuo causale (β OLS rolling backward, decisione a
|
||||
close[i], return in i+1), fee a 2 gambe corretta. **⚠ Correzione di etichetta:** la cella vincente è
|
||||
**sgn=+1**, NON sgn=−1 → NON è mean-reversion ma **momentum relativo sul residuo** (long lo spread quando
|
||||
ETH è ricco vs il suo fit su BTC: le dislocazioni ETH-vs-BTC *continuano* a 1d; la MR pura sgn=−1 perde
|
||||
−1.4 in-sample). L'ortogonalità (β~0) viene dalla struttura dollar-neutral, non dal verso. → **LEAD
|
||||
forward-monitor** (monitorabile a costo reale ~0), NON deploy. **CABLATO:** `scripts/live/paper_statarb.py`
|
||||
(config CONGELATA W=45/sgn=+1, doppio libro MODELED/REAL-$600, riusa il segnale esatto di
|
||||
`orthogonal_signals.py`), aggiunto al cron giornaliero accanto a `paper_prevday`; test `tests/test_paper_statarb.py`.
|
||||
**DVOLSPREAD** ri-valida l'ex-lead `dvol_spread`: ADDS ma storia DVOL corta (2021+) → resta forward-monitor
|
||||
come già noto. Secondario crypto-vs-macro = "TSMOM travestito" (corr→book 0.17-0.20), non ortogonale.
|
||||
|
||||
### Thread 5 — 1-gamba a segnale (eseguibile)
|
||||
12 famiglie (MACD/RSI/Supertrend/Donchian/BBands/EMA, ±ADX) su 1d/12h/8h. **0/12 earns_slot_honest.**
|
||||
**Eseguibilità validata** (haircut $600 = 0 ovunque; RSI-MR ~2.5 trade/anno) — ma è l'unica cosa che
|
||||
regge. I trend-follower sono **TP01 travestito** (corr 0.44-0.79, full ~1.2-1.3 = il soffitto) e le celle
|
||||
sub-daily scelte in-sample **collassano OOS** (dimostrazione da manuale del gate selection-on-holdout). La
|
||||
mean-reversion è morta (BBands-MR has_insample_edge=False). I "low-corr interessanti" (RSI-MR, MACD-LS,
|
||||
Donchian-LS) sono **HEDGE non alpha** (`is_hedge=True`, pagano solo quando TP01 è debole), e con
|
||||
deflated-Sharpe <0.95 (RSI-MR 0.861; Donchian-LS passa DSR ma is_hedge). **A $600 un 1-gamba a segnale è
|
||||
eseguibile ma non aggiunge nulla a TP01.**
|
||||
|
||||
## Sintesi / cosa ho imparato
|
||||
|
||||
1. **0 nuovi sleeve, soffitto ~1.3 riconfermato** dal lato direzionale e dal lato struttura-momentum. La
|
||||
ricerca è onesta: ogni candidato è stato ucciso dal gate giusto (deflated-Sharpe, is_hedge,
|
||||
selection-on-holdout, sostituzione-XS01, multi-cut), non da un giudizio a occhio.
|
||||
2. **Il LEAD di valore è STATARB-RESID** (ETH/BTC residual mean-reversion). È il primo stream visto che è
|
||||
**insieme ortogonale (β~0) ED eseguibile a 2 gambe** — cadono i due muri che bloccano XS01 (STAT-MODE)
|
||||
e le opzioni. Manca solo l'edge sopra-soglia. È il candidato n.1 per il **forward-monitor**, e l'unico
|
||||
che — se la finestra forward confermasse l'edge — sarebbe *deployabile* a $600 (non statistico).
|
||||
3. **Forward-monitor (STAT-MODE / sub-soglia):** IVOL (idio-vol XS, 19-major), DVOLSPREAD (storia corta),
|
||||
STATARB-RESID (eseguibile — il più promettente). Nessuno armato come sleeve.
|
||||
4. **I gate nuovi funzionano:** `study_family_honest` (selection-on-holdout) ha intercettato i collassi
|
||||
OOS dei trend 1-gamba e dei low-risk XS; il deflated-Sharpe ha tenuto sotto la soglia ogni Sharpe ~1
|
||||
su storia corta. La lezione del filone B (de-bias prima di credere) è ora applicata di default.
|
||||
|
||||
## Caveat
|
||||
- Universi/finestre: HL ~2.5 anni, DVOL dal 2021 → multiple-testing reale; tutti i LEAD sono sotto la
|
||||
soglia deflazionata. Niente di questo va creduto come alpha finché una finestra forward non lo conferma.
|
||||
- Nessuno sleeve registrato, config canonica invariata (TP01+XS01+VRP01+SKH01 a pesi fissi). Book live
|
||||
intatto. Tutto il lavoro è statistico/forward, su `main` come ricerca.
|
||||
|
||||
Script: `scripts/research/{xsec_v3_lowrisk,xsec_v3_momstruct,meta_allocation,orthogonal_signals,signal_inout_1leg}.py`.
|
||||
Test: i rispettivi `tests/test_*.py` (143/143 verdi).
|
||||
@@ -0,0 +1,65 @@
|
||||
# 2026-06-29 — Filone C: cross-sectional NON-momentum su Hyperliquid — DEBOLE / forward-monitor
|
||||
|
||||
**Tesi.** XS01 (sleeve attivo) è momentum cross-sectional sui 19 major HL. Lezione del progetto
|
||||
(2026-06-19/20): **espandere il numero di asset NON aiuta il momentum** (gli small-cap diluiscono/
|
||||
invertono il segnale). Quindi qui NON si ri-prova l'espansione-universo: si cerca un **meccanismo
|
||||
DIVERSO dal momentum** che, market-neutral e scorrelato, possa diversificare il portafoglio. Script
|
||||
`scripts/research/xsec_v2_nonmom.py`, test `tests/test_xsec_v2.py`. Branch `research/equity-meanrev-ib`.
|
||||
|
||||
## Meccanismi provati (tutti L/S dollar-neutral, vol-target ~20%, ribilancio periodico, CAUSALI)
|
||||
|
||||
| | meccanismo |
|
||||
|---|---|
|
||||
| REV | short-term reversal grezzo (long loser / short winner di breve) |
|
||||
| IREV | reversal **idiosincratico** (sul residuo dopo aver tolto il mercato, beta-adjusted) |
|
||||
| LOWVOL | factor **low-vol** (long bassa vol realizzata / short alta vol) |
|
||||
| IMOM | momentum **idiosincratico** (sul residuo, ≠ raw mom) — *riferimento* |
|
||||
| BAB | betting-against-beta (long basso beta / short alto beta) |
|
||||
| MOM | momentum grezzo — *riferimento* |
|
||||
|
||||
Universi: **19 major** e **51 all** (i parquet certificati). Gate canonico di selezione: FULL>0.5,
|
||||
HOLD>0, |corr→XS01|<0.6 (escluse le famiglie momentum MOM/IMOM, che sono riferimento, non candidati).
|
||||
|
||||
## Esiti — un solo candidato non-momentum supera il gate standalone
|
||||
|
||||
```
|
||||
[OK] LOWVOL 19-major B30 H10 k5 FULL +1.07 HOLD +1.07 DD 19% corrXS +0.16 corrTP -0.28
|
||||
[--] BAB 19-major B60 H10 k8 FULL +0.47 HOLD +1.15 DD 28% corrXS +0.20 corrTP -0.27
|
||||
[--] REV/IREV (19 e 51) FULL ~0 o NEGATIVO (reversal cross-sectional non paga)
|
||||
[--] LOWVOL 51-all FULL +0.42 (il long-tail diluisce, come per il momentum)
|
||||
```
|
||||
|
||||
- **REV / IREV negativi** (FULL −0.10..−0.70): il reversal cross-sectional grezzo o idiosincratico **non
|
||||
è un edge** su questo universo/finestra — coerente con la morte storica del mean-reversion del progetto.
|
||||
- **BAB** ha un buon hold-out ma FULL solo +0.47 (sotto soglia) e DD 28%.
|
||||
- **LOWVOL 19-major** (betting-against-vol) è l'unico idoneo: FULL **1.07** / HOLD **1.07** / DD 19%,
|
||||
causale (max_tail_diff 0), corr→XS01 +0.16 (scorrelato), corr→TP01 −0.28.
|
||||
- Espandere a **51 asset peggiora** anche il low-vol (FULL 1.07→0.42): riconferma che i margini
|
||||
cross-sectional sono nella **struttura del segnale**, non nel numero di asset.
|
||||
|
||||
### Uplift portafoglio (4 → 5 sleeve, LOWVOL come 5° sleeve)
|
||||
|
||||
```
|
||||
BASE (TP01+XS01+VRP01+SKH01) FULL 2.12 DD 8% | HOLD 2.25 DD 3%
|
||||
+LOWVOL @10% FULL 2.17 (+0.05) | HOLD 2.57 (+0.32)
|
||||
+LOWVOL @15% FULL 2.19 (+0.06) | HOLD 2.62 (+0.37)
|
||||
```
|
||||
|
||||
## Il muro (perché NON è uno sleeve)
|
||||
|
||||
1. **Deflated Sharpe 0.130 ≪ 0.95** (N=216 trial globali; soglia Sharpe-max-null annualizz. 1.79). Con il
|
||||
numero di celle/famiglie/universi testati, lo Sharpe 1.07 **non è significativo** dopo la correzione
|
||||
per multiple-testing. Per-anno: 2024 +0.23 / 2025 +0.50 / **2026 −0.09** (il 2026 è già negativo).
|
||||
2. **Storia ~2.5 anni** (HL nativo 2024-2026): finestra troppo corta per un fattore cross-sectional.
|
||||
3. **Non eseguibile a $600**: book L/S a molte gambe (10+ per lato) → **STAT-MODE**, mai deploy. Stesso
|
||||
vincolo strutturale di XS01 (serve ~20k di capitale).
|
||||
|
||||
## Verdetto: **DEBOLE / forward-monitor (STAT-MODE).** Nessuno sleeve registrato.
|
||||
|
||||
Il **low-vol cross-sectional** (betting-against-vol sui 19 major) è l'unico meccanismo non-momentum con un
|
||||
profilo decente e scorrelato sia a XS01 sia a TP01, e migliorerebbe l'hold-out del portafoglio (+0.32/+0.37).
|
||||
**MA** non supera il deflated-Sharpe (multiple-testing), ha storia cortissima ed è non-eseguibile a questo
|
||||
capitale. → lavoro **puramente statistico**, da tenere in forward-monitor come *secondo* segnale
|
||||
cross-sectional a fianco di XS01, da rivalutare solo a scala (capitale ~20k+) e con più storia HL.
|
||||
|
||||
Reversal cross-sectional (REV/IREV) **confermato non-edge** su HL. Nessun impatto sul book live.
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# BOOK DERIBIT-ONLY (TP01+SKH01 nettati) — esecuzione LIVE a cadenza ORARIA. v2.0.0+.
|
||||
# SKH01 decide su griglia 230m -> serve girare piu' spesso del daily; orario IDEMPOTENTE:
|
||||
# riconcilia al target NETTO corrente (se non cambia nulla -> HOLD). Il feed 5m fresco per il
|
||||
# segnale SKH e' preso IN MEMORIA dentro book_execute (livefeed.fresh_5m): NON tocca i dati
|
||||
# certificati su disco. Esecuzione reale gated da config/live.json (execution_enabled) + --execute.
|
||||
export PATH="/home/adriano/.local/bin:$PATH"
|
||||
cd /opt/docker/PythagorasGoal || exit 1
|
||||
mkdir -p logs
|
||||
{
|
||||
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_book ====="
|
||||
uv run python scripts/live/book_execute.py --execute
|
||||
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||
} >> logs/cron_book.log 2>&1
|
||||
@@ -10,7 +10,10 @@ mkdir -p logs
|
||||
uv run python scripts/research/fetch_dvol.py # DVOL (per ricerca opzioni)
|
||||
uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01
|
||||
uv run python scripts/live/paper_prevday.py # forward-monitor lead prevday-breakout (PAPER, non deploy)
|
||||
uv run python scripts/live/live_execute.py --execute # TP01 LIVE su Deribit (gated da config/live.json)
|
||||
uv run python scripts/live/paper_statarb.py # forward-monitor lead STATARB-RESID ETH/BTC ortogonale (PAPER, non deploy)
|
||||
# NB: l'esecuzione Deribit e' passata al BOOK (TP01+SKH01 nettati) via scripts/cron_book.sh a
|
||||
# cadenza ORARIA (SKH01 e' a 230m: il daily mancherebbe gli ingressi). live_execute.py
|
||||
# (TP01-only) NON va piu' eseguito qui, sennò i due farebbero a pugni sullo stesso strumento.
|
||||
# --- COMBO cross-venue (PAPER): refresh ETF IB (GTAA) + avanza paper TP01+GTAA ---
|
||||
docker compose up -d ib-gateway >/dev/null 2>&1 # gateway IB paper (idempotente)
|
||||
for i in $(seq 1 25); do (echo > /dev/tcp/127.0.0.1/4002) >/dev/null 2>&1 && break; sleep 6; done
|
||||
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# LOGGER FORWARD della vol term-structure Deribit — cadenza GIORNALIERA. v2.0.0+.
|
||||
# Costruisce il dataset per un futuro calendar-vol (oggi NON backtestabile: storia per-scadenza non
|
||||
# pubblica — vedi docs/diary/2026-06-26-vol-termstructure-feasibility.md). Append idempotente per
|
||||
# giorno su data/raw/vol_term_<asset>.parquet. SOLO ricerca forward: NON tocca il book live ne' i
|
||||
# dati certificati BTC/ETH; legge l'API pubblica Deribit (tokenless) e scrive un parquet dedicato.
|
||||
export PATH="/home/adriano/.local/bin:$PATH"
|
||||
cd /opt/docker/PythagorasGoal || exit 1
|
||||
mkdir -p logs
|
||||
{
|
||||
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_vol_term ====="
|
||||
uv run python scripts/research/log_vol_termstructure.py
|
||||
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||
} >> logs/cron_vol_term.log 2>&1
|
||||
@@ -0,0 +1,146 @@
|
||||
"""BOOK DERIBIT-ONLY LIVE EXECUTE — TP01 + SKH01 NETTATI in software su un solo conto Deribit mainnet.
|
||||
|
||||
Porta il conto reale al target NETTO per asset (vedi src/live/book.py): per ogni asset combina la
|
||||
frazione long-flat di TP01 (peso 0.75) e il segno L/S di SKH01 (peso 0.25), e manda UN ordine con
|
||||
segno (long/short/flip) per raggiungerlo. Poi assicura un disaster-SL on-book sulla posizione NETTA.
|
||||
|
||||
DOPPIO GATE DI SICUREZZA (entrambi necessari per inviare ordini reali):
|
||||
1. config/live.json -> "execution_enabled": true (master switch, default false)
|
||||
2. flag CLI --execute
|
||||
Senza entrambi e' un DRY-RUN (stampa il piano, NON invia). Reconciliation dopo ogni ordine; log in
|
||||
data/live/book_executions.jsonl.
|
||||
|
||||
⚠️ CADENZA: SKH01 decide su griglia 230m -> questo script va lanciato ogni ~230 minuti con la feed
|
||||
fresca all'ultima barra chiusa (NON il cron giornaliero, che mancherebbe gli ingressi). Gli exit di
|
||||
SKH sono SOFTWARE (latenza fino a fine barra 230m); solo il disaster-SL (-30%) e' on-book.
|
||||
|
||||
uv run python scripts/live/book_execute.py # DRY-RUN (piano, nessun ordine)
|
||||
uv run python scripts/live/book_execute.py --execute # esegue SOLO se execution_enabled=true
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.live.book import book_report
|
||||
from src.live.execution import DeribitTrader
|
||||
from src.live.notifier import notify
|
||||
|
||||
CONFIG = PROJECT_ROOT / "config" / "live.json"
|
||||
LOG_DIR = PROJECT_ROOT / "data" / "live"
|
||||
LOG = LOG_DIR / "book_executions.jsonl"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
|
||||
cfg.setdefault("execution_enabled", False)
|
||||
cfg.setdefault("max_notional_per_asset_usd", 300.0)
|
||||
cfg.setdefault("min_order_usd", 5.0)
|
||||
cfg.setdefault("disaster_sl_pct", 0.30)
|
||||
return cfg
|
||||
|
||||
|
||||
def log_event(rec: dict):
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOG, "a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
|
||||
|
||||
def _run():
|
||||
cfg = load_config()
|
||||
want_execute = "--execute" in sys.argv[1:]
|
||||
enabled = bool(cfg["execution_enabled"])
|
||||
do_execute = want_execute and enabled
|
||||
min_order = float(cfg["min_order_usd"])
|
||||
sl_pct = float(cfg["disaster_sl_pct"])
|
||||
|
||||
r = book_report(live_feed=True) # target NETTO + conto/posizioni reali (feed SKH fresco)
|
||||
equity = r["equity"]
|
||||
|
||||
print("=" * 88)
|
||||
print(" BOOK DERIBIT LIVE EXECUTE — TP01(0.75)+SKH01(0.25) NETTATI — Deribit mainnet (USDC linear)")
|
||||
print("=" * 88)
|
||||
mode = ("ESECUZIONE REALE" if do_execute else
|
||||
("ARMATO ma manca --execute" if enabled else "DRY-RUN (execution_enabled=false)"))
|
||||
print(f" modo : {mode}")
|
||||
print(f" gate : execution_enabled={enabled} | --execute={want_execute}")
|
||||
print(f" conto reale : ${r['real_equity']:,.2f}" if r["real_equity"] else f" conto: {r['eq_basis']}")
|
||||
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")
|
||||
|
||||
if not r["online"]:
|
||||
print(" conto non leggibile (offline) -> stop, non eseguo a cieco.")
|
||||
if do_execute:
|
||||
notify("⚠️ BOOK LIVE — conto offline", {"nota": "salto l'esecuzione, non opero a cieco"})
|
||||
return
|
||||
|
||||
trader = DeribitTrader() if do_execute else None
|
||||
actions = []
|
||||
for a in r["assets"]:
|
||||
asset, inst = a["asset"], a["instrument"]
|
||||
net, cur, mark = a["net_target"], a["position_usd"], a["mark"]
|
||||
sk = a["skh_state"]
|
||||
sk_txt = "flat" if sk == "flat" else f"{sk['dir']}@{sk.get('entry')}"
|
||||
order = a["order"]
|
||||
if order is None:
|
||||
act = "HOLD (a target)"
|
||||
elif order.get("is_close"):
|
||||
act = f"CLOSE ${cur:,.0f}"
|
||||
elif order.get("needs_flip"):
|
||||
act = f"FLIP -> ${net:,.0f}"
|
||||
else:
|
||||
act = f"{order['side'].upper()} ${order['delta']:+,.0f}"
|
||||
print(f" {asset:<3} TP {a['tp_frac']:+.3f} · SKH {a['skh_sign']:+d}({sk_txt}) -> net ${net:+,.0f} "
|
||||
f"| pos ${cur:+,.0f} -> {act}")
|
||||
|
||||
if do_execute and order is not None:
|
||||
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
|
||||
newpos = trader.position_usd(inst)
|
||||
for f in fills:
|
||||
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
|
||||
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
|
||||
log_event(dict(ts_utc=str(pd.Timestamp(r['last_data'])), asset=asset, action=act,
|
||||
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
|
||||
verified=f.verified, notes=f.notes, net_target=net, pos_after=newpos,
|
||||
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"]))
|
||||
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
|
||||
fee=round(f.fee_usdc, 5), net=round(net, 0), pos_after=round(newpos, 0))
|
||||
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
|
||||
det if f.verified else {**det, "notes": f.notes})
|
||||
print(f" reconcile: pos ${newpos:,.0f}")
|
||||
if do_execute:
|
||||
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
|
||||
print(f" disaster-SL: {ds.get('state')}" + (f" @ ${ds['stop']:,.1f}" if ds.get("stop") else ""))
|
||||
if ds.get("state") == "placed":
|
||||
notify("🛡️ BOOK disaster-SL piazzato", {"asset": asset, "stop": round(ds.get("stop") or 0, 1),
|
||||
"amount": round(ds.get("amount") or 0, 4)})
|
||||
elif ds.get("state") == "place-failed":
|
||||
notify("⚠️ BOOK disaster-SL FALLITO", {"asset": asset, "notes": ds.get("notes")})
|
||||
actions.append(act)
|
||||
|
||||
print()
|
||||
if not do_execute:
|
||||
print(" => DRY-RUN: nessun ordine inviato." +
|
||||
("" if enabled else " Per armare: config/live.json execution_enabled=true + --execute."))
|
||||
elif all(x.startswith("HOLD") for x in actions):
|
||||
print(" => Nessuna azione: conto gia' al target netto del book.")
|
||||
else:
|
||||
print(" => Esecuzione completata (vedi data/live/book_executions.jsonl).")
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
_run()
|
||||
except Exception as e:
|
||||
notify("🛑 BOOK LIVE — ERRORE", {"error": f"{type(e).__name__}: {e}"})
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""FORWARD-MONITOR — STATARB-RESID (relative-value ETH/BTC, dollar-neutral 2 gambe), PAPER.
|
||||
|
||||
NON è esecuzione reale. È il monitoraggio forward-only del LEAD dello sweep 2026-06-29 (thread
|
||||
"segnali ortogonali"): il PRIMO stream visto che è INSIEME ortogonale al book (corr→book 0.027,
|
||||
beta-mkt 0.013) ED eseguibile a $600 (book a 2 gambe BTC+ETH perp, NON STAT-MODE come XS01). Resta
|
||||
sotto la soglia solo sull'EDGE (Sharpe ~0.84, deflated-Sharpe <0.95 su storia ~2.5 anni). Questo
|
||||
monitor accumula la finestra OUT-OF-SAMPLE VERA per decidere se l'edge regge — stesso trattamento di
|
||||
PREVDAY/STA05/XS01 STAT-MODE. Nessun edge creduto prima.
|
||||
|
||||
ⓘ NATURA DEL SEGNALE (onesto): la cella vincente è W=45 **sgn=+1**, NON sgn=-1. Cioè il residuo di
|
||||
ETH dopo β·BTC (β OLS rolling causale) viene seguito in MOMENTUM (long lo spread quando ETH è ricco
|
||||
vs il suo fit su BTC), non in mean-reversion: le dislocazioni relative ETH-vs-BTC CONTINUANO a 1d
|
||||
(la MR pura, sgn=-1, perde -1.4 in-sample). È relative-MOMENTUM, dollar-neutral → ortogonale al
|
||||
trend assoluto di TP01.
|
||||
|
||||
CONFIG CONGELATA (dallo sweep, selezione IN-SAMPLE-only, mai sul max hold-out):
|
||||
W=45, sgn=+1, tf=1d, vol-target 20%, leva cap 2x. Riusa il segnale ESATTO di
|
||||
scripts/research/orthogonal_signals.py (nessuna reimplementazione → niente drift).
|
||||
|
||||
DESIGN (onesto, come paper_prevday):
|
||||
- Legge BTC/ETH 1d certificati (build_joint), calcola il segnale CAUSALE su tutta la storia.
|
||||
- Alla prima esecuzione fissa l'INCEPTION all'ultima barra 1d chiusa (forward-only: lo storico
|
||||
NON entra nel PnL di paper).
|
||||
- Ogni run processa le NUOVE barre 1d chiuse: applica il rendimento dello spread tenuto,
|
||||
addebita le fee su 2 GAMBE (ogni Δpos muove ETH e BTC → 2×|Δpos|), registra i flip di segno.
|
||||
- DUE libri in parallelo (onestà sull'esecuzione a piccolo capitale):
|
||||
* MODELED : capitale nominale $2000, ribilanciamento continuo (fee su ogni |Δpos|).
|
||||
* REAL-$600: capitale reale $600, salta i ribilanci di nozionale-per-gamba < min_order ($5)
|
||||
→ il gap MODELED-REAL = l'haircut di fill reale (qui ~0: turnover bassissimo a 1d).
|
||||
- Per barre fresche aggiornare prima i dati: uv run python scripts/analysis/rebuild_history.py --asset BTC ETH
|
||||
|
||||
Stato: data/paper_statarb/{state.json, trades.jsonl, returns.jsonl} (append-only).
|
||||
|
||||
uv run python scripts/live/paper_statarb.py # avanza col dato disponibile
|
||||
uv run python scripts/live/paper_statarb.py --status # solo stato, non avanza
|
||||
uv run python scripts/live/paper_statarb.py --reset # azzera (riparte da ora)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
|
||||
|
||||
# Segnale ESATTO dello sweep (nessuna reimplementazione → identità garantita col backtest).
|
||||
from orthogonal_signals import build_joint, f_statarb_resid, spread_ret # noqa: E402
|
||||
|
||||
STATE_DIR = PROJECT_ROOT / "data" / "paper_statarb"
|
||||
STATE_FILE = STATE_DIR / "state.json"
|
||||
TRADES_FILE = STATE_DIR / "trades.jsonl"
|
||||
RETURNS_FILE = STATE_DIR / "returns.jsonl"
|
||||
|
||||
# --- CONFIG CONGELATA (frozen) -----------------------------------------------------------
|
||||
W_FROZEN = 45
|
||||
SGN_FROZEN = +1
|
||||
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% round-trip (Deribit taker)
|
||||
MODELED_CAPITAL = 2000.0 # nominale, ribilanciamento continuo
|
||||
REAL_CAPITAL = 600.0 # capitale mainnet reale
|
||||
MIN_ORDER = 5.0 # min order Deribit -> sotto, il conto vero NON ribilancia
|
||||
|
||||
|
||||
def _signal(j: pd.DataFrame):
|
||||
"""(ts, dt, pos, sr): posizione vol-targeted sullo spread (decisa <= close[i]) + ritorno spread."""
|
||||
pos = np.nan_to_num(f_statarb_resid(W=W_FROZEN, sgn=SGN_FROZEN)(j))
|
||||
return (j["timestamp"].values.astype("int64"),
|
||||
pd.to_datetime(j["datetime"]).values, pos, spread_ret(j))
|
||||
|
||||
|
||||
def _state_io(write: dict | None = None):
|
||||
if write is not None:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps(write, indent=2))
|
||||
return write
|
||||
return json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else None
|
||||
|
||||
|
||||
def _append(path: Path, rec: dict):
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
|
||||
|
||||
def init_state(j: pd.DataFrame) -> dict:
|
||||
ts, _, pos, _ = _signal(j)
|
||||
last = int(ts[-1])
|
||||
return dict(start_ts=last, last_ts=last, n_bars=0,
|
||||
pos_modeled=float(pos[-1]), pos_real=float(pos[-1]),
|
||||
cap_modeled=MODELED_CAPITAL, cap_real=REAL_CAPITAL,
|
||||
peak_modeled=MODELED_CAPITAL, peak_real=REAL_CAPITAL,
|
||||
dd_modeled=0.0, dd_real=0.0, n_trades=0)
|
||||
|
||||
|
||||
def advance(st: dict, j: pd.DataFrame) -> dict:
|
||||
ts, dt, pos, sr = _signal(j)
|
||||
new = [i for i in range(len(ts)) if ts[i] > st["last_ts"]]
|
||||
if not new:
|
||||
return st
|
||||
pm, pr = st["pos_modeled"], st["pos_real"] # posizioni TENUTE (decise alla barra precedente)
|
||||
cm, cr = st["cap_modeled"], st["cap_real"]
|
||||
pkm, pkr, ddm, ddr = st["peak_modeled"], st["peak_real"], st["dd_modeled"], st["dd_real"]
|
||||
ntr = st.get("n_trades", 0)
|
||||
for i in new:
|
||||
s = float(sr[i])
|
||||
tgt = float(pos[i]) # bersaglio deciso a close[i] (eseguito da i+1)
|
||||
# MODELED: rendimento dello spread tenuto durante la barra i, poi ribilancia al target
|
||||
net_m = pm * s - FEE_SIDE * 2.0 * abs(tgt - pm)
|
||||
# REAL-$600: salta i ribilanci sub-min_order (nozionale per-gamba |Δpos|*cap < $5)
|
||||
executed = abs(tgt - pr) * REAL_CAPITAL >= MIN_ORDER
|
||||
new_pr = tgt if executed else pr
|
||||
net_r = pr * s - FEE_SIDE * 2.0 * abs(new_pr - pr)
|
||||
if np.sign(new_pr) != np.sign(pr):
|
||||
_append(TRADES_FILE, dict(ts=int(ts[i]), dt=str(pd.Timestamp(dt[i])),
|
||||
action="FLIP", from_pos=round(pr, 4), to_pos=round(new_pr, 4)))
|
||||
ntr += 1
|
||||
cm *= (1.0 + max(net_m, -0.99)); cr *= (1.0 + max(net_r, -0.99))
|
||||
pkm = max(pkm, cm); pkr = max(pkr, cr)
|
||||
ddm = max(ddm, (pkm - cm) / pkm if pkm > 0 else 0.0)
|
||||
ddr = max(ddr, (pkr - cr) / pkr if pkr > 0 else 0.0)
|
||||
pm, pr = tgt, new_pr
|
||||
_append(RETURNS_FILE, dict(ts=int(ts[i]), dt=str(pd.Timestamp(dt[i])),
|
||||
net_modeled=round(net_m, 6), net_real=round(net_r, 6),
|
||||
pos=round(new_pr, 4), cap_modeled=round(cm, 2), cap_real=round(cr, 2)))
|
||||
st.update(last_ts=int(ts[new[-1]]), n_bars=st.get("n_bars", 0) + len(new),
|
||||
pos_modeled=pm, pos_real=pr, cap_modeled=cm, cap_real=cr,
|
||||
peak_modeled=pkm, peak_real=pkr, dd_modeled=ddm, dd_real=ddr, n_trades=ntr)
|
||||
return st
|
||||
|
||||
|
||||
def print_status(st: dict, j: pd.DataFrame):
|
||||
last_data = int(j["timestamp"].iloc[-1])
|
||||
days = (last_data - st["start_ts"]) / 86400_000
|
||||
rm = st["cap_modeled"] / MODELED_CAPITAL - 1
|
||||
rr = st["cap_real"] / REAL_CAPITAL - 1
|
||||
print("\n STATARB-RESID forward-monitor (PAPER — relative-value ETH/BTC, ortogonale, NON deploy)")
|
||||
print(f" config CONGELATA: W={W_FROZEN} sgn={SGN_FROZEN:+d} (residual relative-momentum, dollar-neutral)")
|
||||
print(f" forward da {pd.Timestamp(st['start_ts'], unit='ms', tz='UTC').date()} "
|
||||
f"({st['n_bars']} barre 1d ~{days:.0f}g) flip di segno: {st['n_trades']}")
|
||||
print(f" posizione spread corrente (long ETH / short BTC per unità): {st['pos_real']:+.3f}")
|
||||
print(f" MODELED ($2000 nominale): {rm*100:+6.2f}% eq ${st['cap_modeled']:.2f} maxDD {st['dd_modeled']*100:.1f}%")
|
||||
print(f" REAL-$600 (min-order $5) : {rr*100:+6.2f}% eq ${st['cap_real']:.2f} maxDD {st['dd_real']*100:.1f}%")
|
||||
print(f" -> fill-haircut MODELED-REAL: {(rm-rr)*100:+.2f} pp (atteso ~0: turnover basso a 1d)")
|
||||
print(f" log: {RETURNS_FILE}\n")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--status", action="store_true")
|
||||
ap.add_argument("--reset", action="store_true")
|
||||
args = ap.parse_args()
|
||||
j = build_joint("1d")
|
||||
if args.reset:
|
||||
for p in (STATE_FILE, TRADES_FILE, RETURNS_FILE):
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
st = init_state(j); _state_io(st)
|
||||
print("forward-monitor STATARB-RESID inizializzato (forward-only da ora).")
|
||||
print_status(st, j); return
|
||||
st = _state_io()
|
||||
if st is None:
|
||||
st = init_state(j); _state_io(st)
|
||||
print("forward-monitor STATARB-RESID inizializzato (forward-only da ora).")
|
||||
print_status(st, j); return
|
||||
if not args.status:
|
||||
st = advance(st, j); _state_io(st)
|
||||
print_status(st, j)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""PROIEZIONE ACCUMULO del book DERIBIT-ONLY (TP01 + SKH01) — compounding puro (reinvesti le
|
||||
vincite), allineamento MENSILE, NESSUN versamento esterno (non e' un PAC).
|
||||
|
||||
Base: rendimenti mensili del book Deribit-only (rebal mensile, netto costo turnover). Proietta in
|
||||
avanti l'equity da un capitale iniziale:
|
||||
- DETERMINISTICO @CAGR storico e @CAGR conservativo (= storico × cons_frac, default metà);
|
||||
- MONTE CARLO block-bootstrap dei rendimenti mensili storici (mediana + banda p10/p90);
|
||||
- €/giorno run-rate (cresce col capitale, perche' si rigiocano le vincite).
|
||||
|
||||
⚠️ ONESTA': lo storico e' un BULL crypto ~2019-26 -> il futuro sara' quasi certamente piu' magro.
|
||||
Pianificare sulla colonna conservativa; il MC non contiene un vero bear pluriennale (anche il p10
|
||||
e' ottimista). Nessuna leva. SKH01 e' research/forward-monitor (solo TP01 e' armato live). Non e'
|
||||
una garanzia: e' una proiezione condizionata "se il futuro somigliasse al passato".
|
||||
|
||||
uv run python scripts/portfolio/forecast_deribit_book.py
|
||||
uv run python scripts/portfolio/forecast_deribit_book.py --capital 5000 --years 1,3,5,10 --cons-frac 0.5
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
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.portfolio import StrategyPortfolio, rebalance_sim
|
||||
from src.portfolio.sleeves import deribit_book_sleeves
|
||||
|
||||
|
||||
def book_monthly_returns(rebal_days: int, cost_rate: float) -> pd.Series:
|
||||
"""Rendimenti MENSILI del book Deribit-only (rebal periodico, netto costo)."""
|
||||
sl = deribit_book_sleeves()
|
||||
w = StrategyPortfolio(sl).weights()
|
||||
cols = {s.name: s.daily() for s in sl}
|
||||
r = rebalance_sim(cols, w, period_days=rebal_days, cost_rate=cost_rate)["daily"]
|
||||
return ((1.0 + r).resample("ME").prod() - 1.0).dropna()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Proiezione accumulo book Deribit-only (compounding, allineamento mensile)")
|
||||
ap.add_argument("--capital", type=float, default=5000.0)
|
||||
ap.add_argument("--years", type=str, default="1,2,3,5,8,10")
|
||||
ap.add_argument("--cons-frac", type=float, default=0.5, help="CAGR conservativo = storico × questo (default 0.5)")
|
||||
ap.add_argument("--sims", type=int, default=4000)
|
||||
ap.add_argument("--block-months", type=int, default=3, help="blocco del bootstrap (mesi)")
|
||||
ap.add_argument("--rebal-days", type=int, default=30)
|
||||
ap.add_argument("--cost-rate", type=float, default=0.0005, help="fee per-lato sul turnover (Deribit taker)")
|
||||
ap.add_argument("--seed", type=int, default=7)
|
||||
a = ap.parse_args()
|
||||
|
||||
cap = a.capital
|
||||
HY = [int(x) for x in a.years.split(",") if x.strip()]
|
||||
m = book_monthly_returns(a.rebal_days, a.cost_rate)
|
||||
mv = m.values
|
||||
nm = len(mv)
|
||||
g_month = float(np.prod(1.0 + mv) ** (1.0 / nm) - 1.0)
|
||||
cagr = (1.0 + g_month) ** 12 - 1.0
|
||||
vol_ann = float(mv.std() * np.sqrt(12))
|
||||
cons_cagr = cagr * a.cons_frac
|
||||
|
||||
print("=" * 90)
|
||||
print(f" PROIEZIONE ACCUMULO — book Deribit-only (TP01+SKH01) | compounding, allineamento mensile, no versamenti")
|
||||
print(f" storico: {nm} mesi · CAGR {cagr*100:.1f}% · vol annua {vol_ann*100:.0f}% (bull crypto, no leva) | capitale €{cap:,.0f}")
|
||||
print("=" * 90)
|
||||
|
||||
# Monte Carlo: block-bootstrap dei rendimenti mensili
|
||||
rng = np.random.default_rng(a.seed)
|
||||
blk = a.block_months
|
||||
maxM = max(HY) * 12
|
||||
nb = maxM // blk + 1
|
||||
starts = rng.integers(0, nm - blk, size=(a.sims, nb))
|
||||
paths = np.concatenate([mv[starts[:, k][:, None] + np.arange(blk)[None, :]] for k in range(nb)], axis=1)[:, :maxM]
|
||||
eqMC = cap * np.cumprod(1.0 + paths, axis=1)
|
||||
|
||||
cons_m = (1.0 + cons_cagr) ** (1.0 / 12) - 1.0
|
||||
print(f"\n ACCUMULO (reinvesti le vincite):")
|
||||
print(f" {'oriz.':>6} | {'det @storico':>13} | {'det @conserv.':>14} | {'MC mediana':>11} | {'MC p10':>9} | {'MC p90':>10}")
|
||||
print(f" {'':>6} | {'('+format(cagr*100,'.0f')+'%)':>13} | {'('+format(cons_cagr*100,'.0f')+'%)':>14} |")
|
||||
print(" " + "-" * 80)
|
||||
for y in HY:
|
||||
mo = y * 12
|
||||
det = cap * (1.0 + g_month) ** mo
|
||||
detc = cap * (1.0 + cons_m) ** mo
|
||||
c = eqMC[:, mo - 1]
|
||||
print(f" {y:>4}a | €{det:>11,.0f} | €{detc:>12,.0f} | €{np.median(c):>9,.0f} | €{np.percentile(c,10):>7,.0f} | €{np.percentile(c,90):>8,.0f}")
|
||||
|
||||
# €/giorno run-rate @conservativo (cresce col capitale)
|
||||
rd = (1.0 + cons_cagr) ** (1.0 / 365.0) - 1.0
|
||||
print(f"\n €/GIORNO run-rate @conservativo ({cons_cagr*100:.1f}% CAGR) — cresce col capitale (rigiochi le vincite):")
|
||||
print(f" {'oriz.':>6} | {'equity':>9} | {'€/giorno':>10} | {'€/mese':>8}")
|
||||
print(" " + "-" * 42)
|
||||
for y in [0] + HY:
|
||||
E = cap * (1.0 + cons_cagr) ** y
|
||||
print(f" {('oggi' if y==0 else str(y)+'a'):>6} | €{E:>7,.0f} | €{E*rd:>7,.2f} | €{E*rd*30:>6,.0f}")
|
||||
E_end = cap * (1.0 + cons_cagr) ** max(HY)
|
||||
print(f" media €/giorno su {max(HY)} anni: €{(E_end-cap)/(max(HY)*365):.2f}/g (profitto €{E_end-cap:,.0f})")
|
||||
need = 50 * 365 / cons_cagr if cons_cagr > 0 else float('inf')
|
||||
print(f" capitale per ~€50/giorno @{cons_cagr*100:.1f}%: ≈ €{need:,.0f}")
|
||||
|
||||
print(f"\n ⚠️ Proiezione condizionata (storico = bull crypto); pianifica sul conservativo. Nessuna leva.")
|
||||
print(f" SKH01 = research/forward-monitor; solo TP01 e' armato live. Non e' una garanzia.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""REPORT del BOOK DERIBIT-ONLY realmente eseguibile = TP01 + SKH01 (75/25).
|
||||
|
||||
Le due gambe direzionali BTC/ETH sullo STESSO venue (Deribit), entrambe dal 2019. Esclude XS01
|
||||
(Hyperliquid, stat-mode) e VRP01 (opzioni modellate). Mostra:
|
||||
1. metriche oneste continuo (rebalance-continuo) vs RIBILANCIAMENTO PERIODICO realistico
|
||||
(settimanale/mensile) con costo turnover Deribit-taker;
|
||||
2. per-anno, accumulo da €2k (e nota €600 reale + min-order $5);
|
||||
3. posizioni correnti per gamba.
|
||||
|
||||
uv run python scripts/portfolio/run_deribit_book.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
|
||||
|
||||
from src.portfolio.portfolio import StrategyPortfolio, metrics, yearly, rebalance_sim, HOLDOUT
|
||||
|
||||
CAP = 2000.0
|
||||
REAL = 600.0 # capitale reale (vedi CLAUDE.md), min-order Deribit $5
|
||||
COST_RATE = 0.0005 # Deribit taker per-lato (~0.10% RT sul turnover netto)
|
||||
|
||||
|
||||
def line(tag, daily, extra=""):
|
||||
m = metrics(daily); h = metrics(daily[daily.index >= HOLDOUT])
|
||||
eqf = CAP * float(np.prod(1.0 + daily.values))
|
||||
print(f" {tag:<26} FULL Sh {m['sharpe']:.2f} ret {m['ret']*100:+.0f}% DD {m['maxdd']*100:.1f}% "
|
||||
f"| HOLD Sh {h['sharpe']:.2f} DD {h['maxdd']*100:.1f}% | €2k→€{eqf:,.0f} {extra}")
|
||||
return m, h
|
||||
|
||||
|
||||
def main():
|
||||
from src.portfolio.sleeves import deribit_book_sleeves
|
||||
sleeves = deribit_book_sleeves()
|
||||
pf = StrategyPortfolio(sleeves, capital=CAP)
|
||||
w = pf.weights()
|
||||
cols = {s.name: s.daily() for s in sleeves}
|
||||
|
||||
print("=" * 100)
|
||||
print(f" BOOK DERIBIT-ONLY (eseguibile) — {' + '.join(f'{k} {v*100:.0f}%' for k, v in w.items())} "
|
||||
f"| capitale €{CAP:,.0f} (reale ≈ ${REAL:,.0f}) | hold-out {HOLDOUT.date()}+")
|
||||
print("=" * 100)
|
||||
|
||||
# standalone per-gamba
|
||||
print("\n PER-GAMBA (standalone):")
|
||||
for s in sleeves:
|
||||
d = s.daily(); m = metrics(d); h = metrics(d[d.index >= HOLDOUT])
|
||||
print(f" {s.name:<16} [{w[s.name]*100:>3.0f}%] FULL Sh {m['sharpe']:.2f} DD {m['maxdd']*100:.0f}% "
|
||||
f"| HOLD Sh {h['sharpe']:.2f} DD {h['maxdd']*100:.0f}%")
|
||||
|
||||
print("\n COMBINATO — rebalance-CONTINUO (idealizzato, no costi) vs PERIODICO (reale, costo turnover):")
|
||||
cont = pf.combined_daily()
|
||||
line("continuo (no costo)", cont)
|
||||
sims = {}
|
||||
for tag, period in (("settimanale (7g)", 7), ("bisettimanale (14g)", 14), ("mensile (30g)", 30)):
|
||||
sim = rebalance_sim(cols, w, period_days=period, cost_rate=COST_RATE)
|
||||
sims[tag] = sim
|
||||
line(f"rebal {tag}", sim["daily"], extra=f"| turnover {sim['turnover_per_year']:.1f}×/anno, {sim['n_rebalances']} ribilanci")
|
||||
|
||||
# raccomandato = mensile
|
||||
rec = sims["mensile (30g)"]["daily"]
|
||||
print("\n PER ANNO (rebal mensile, netto costo):")
|
||||
for y, d in yearly(rec).items():
|
||||
print(f" {y}: ret {d['ret']*100:>+7.1f}% DD {d['dd']*100:>5.1f}%")
|
||||
|
||||
print("\n ACCUMULO (rebal mensile):")
|
||||
for cap, lbl in ((CAP, "€2k nominale"), (REAL, "$600 reale")):
|
||||
eq = cap * np.cumprod(1.0 + rec.values)
|
||||
yrs = len(rec) / 365.25
|
||||
print(f" {lbl:<14}: {cap:,.0f} → {eq[-1]:,.0f} (×{eq[-1]/cap:.1f}, ~{(eq[-1]-cap)/(yrs*365.25):+,.2f}/g)")
|
||||
|
||||
print("\n POSIZIONI CORRENTI (ultima barra chiusa):")
|
||||
for name, pos in pf.current_positions().items():
|
||||
print(f" {name}: {pos if pos is not None else 'segnale dual-TF (no pos-fn) — vedi engine'}")
|
||||
|
||||
print("\n NOTE ONESTE:")
|
||||
print(" · TP01 = unico armato live su Deribit (flat=risk-off). SKH01 = 2a gamba candidata (perp BTC/ETH).")
|
||||
print(" · SKH01 equity daily-step (Sharpe lens). A $600 il min-order è $5: un ribilancio mensile")
|
||||
print(" muove abbastanza nozionale da eseguirsi; il giornaliero NO (Δ sub-$5 = finzione) → usa ≥ settimanale.")
|
||||
print(" · Prima del deploy 2a gamba: validare causalità sul CODICE D'ESECUZIONE reale e costi del book a 230m.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -29,12 +29,14 @@ from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import norm
|
||||
|
||||
# --- make `from src...` work no matter where the agent's script lives -------
|
||||
_ROOT = Path(__file__).resolve().parents[3]
|
||||
@@ -670,6 +672,94 @@ def causality_ok(target_fn, tf: str = "1h", assets=CERTIFIED,
|
||||
reason=("length-mismatch on prefix" if bad else None))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SELECTION-ON-HOLDOUT GATE — codified 2026-06-29 from filone B (intraday ERM).
|
||||
#
|
||||
# LESSON 3: intraday_regime.py picked its "winner" cell by MAX hold-out Sharpe over a ~60-cell
|
||||
# grid, then ran study_marginal on THAT cell -> earns_slot=True. But the slot was an artifact of
|
||||
# SELECTING THE CELL ON THE HOLD-OUT: picking the cell IN-SAMPLE-ONLY (no peeking) lands on a
|
||||
# DIFFERENT, TP01-correlated cell that scores NEUTRAL, and the standalone Sharpe deflates to
|
||||
# DSR~0.0-0.24 over the trials searched. study_marginal alone can't catch this — it judges ONE
|
||||
# stream and never sees how the cell was chosen. The fix is two-fold and lives here:
|
||||
# (1) choose the cell IN-SAMPLE-ONLY (or walk-forward) BEFORE scoring the marginal, and
|
||||
# (2) DEFLATE the standalone Sharpe for the number of cells/families searched.
|
||||
# Twin of the CC01 ("implausible Sharpe -> hidden risk") and alt-sweep ("hold-out-fitting") blind
|
||||
# spots, in its "selection-on-holdout" form.
|
||||
# ===========================================================================
|
||||
def deflated_sharpe(sr_ann, all_sr_ann, daily_ret, dpy: float = 365.25):
|
||||
"""Deflated Sharpe Ratio (Bailey & Lopez de Prado): P(true Sharpe > the MAX Sharpe expected
|
||||
under the null of N independent trials). Penalizes multiple-testing — a standalone Sharpe ~1
|
||||
over a 100+ cell grid is routinely NOT significant once deflated. sr_ann = annualized Sharpe
|
||||
of the CHOSEN config; all_sr_ann = the Sharpe of EVERY cell searched; daily_ret = the chosen
|
||||
config's daily returns (for skew/kurt/T). Returns (DSR, expected_null_max_sharpe_ann);
|
||||
PASS if DSR >= 0.95."""
|
||||
r = np.asarray(pd.Series(daily_ret).dropna().values, float)
|
||||
T = len(r)
|
||||
if T < 30 or np.std(r) == 0:
|
||||
return float("nan"), float("nan")
|
||||
sr = sr_ann / math.sqrt(dpy)
|
||||
trials = np.asarray([s / math.sqrt(dpy) for s in all_sr_ann if np.isfinite(s)], float)
|
||||
N = max(len(trials), 2)
|
||||
var_tr = float(np.var(trials, ddof=1)) if N > 1 else 0.0
|
||||
emc = 0.5772156649
|
||||
z1 = norm.ppf(1 - 1.0 / N)
|
||||
z2 = norm.ppf(1 - 1.0 / (N * math.e))
|
||||
sr0 = math.sqrt(var_tr) * ((1 - emc) * z1 + emc * z2)
|
||||
sk = float(pd.Series(r).skew())
|
||||
ku = float(pd.Series(r).kurt()) + 3.0 # pandas kurt = excess
|
||||
den = math.sqrt(max(1e-9, 1 - sk * sr + (ku - 1) / 4.0 * sr ** 2))
|
||||
dsr = float(norm.cdf((sr - sr0) * math.sqrt(T - 1) / den))
|
||||
return dsr, sr0 * math.sqrt(dpy)
|
||||
|
||||
|
||||
def select_cell_insample(factory, grid, tfs, fee_side: float = FEE_SIDE) -> dict:
|
||||
"""Pick a config WITHOUT looking at the hold-out: rank grid cells by IN-SAMPLE (pre-HOLDOUT)
|
||||
standalone Sharpe of the 50/50 candidate. `factory(tf=..., **params)` -> target_fn; each grid
|
||||
item is a dict of factory kwargs (besides tf). Returns the in-sample-best cell, all rows
|
||||
(sorted), and EVERY cell's FULL Sharpe (for deflated_sharpe). This is the honest replacement
|
||||
for picking the max-hold-out cell."""
|
||||
rows = []
|
||||
for tf in tfs:
|
||||
for params in grid:
|
||||
try:
|
||||
daily = candidate_daily(factory(tf=tf, **params), tf=tf, fee_side=fee_side)
|
||||
except Exception:
|
||||
continue
|
||||
ins = daily[daily.index < HOLDOUT]
|
||||
is_sh = _sh(ins) if len(ins) > 60 else float("nan")
|
||||
rows.append(dict(tf=tf, params=params, insample_sharpe=round(is_sh, 3),
|
||||
full_sharpe=round(_sh(daily), 3)))
|
||||
valid = [r for r in rows if np.isfinite(r["insample_sharpe"])]
|
||||
chosen = max(valid, key=lambda r: r["insample_sharpe"]) if valid else None
|
||||
return dict(chosen=chosen,
|
||||
rows=sorted(valid, key=lambda r: r["insample_sharpe"], reverse=True),
|
||||
all_full_sharpe=[r["full_sharpe"] for r in rows])
|
||||
|
||||
|
||||
def study_family_honest(name: str, factory, grid, tfs, fee_side: float = FEE_SIDE,
|
||||
dsr_min: float = 0.95) -> dict:
|
||||
"""HARDENED family gate. A grid-searched directional candidate earns a slot ONLY if, picking
|
||||
the cell IN-SAMPLE-ONLY (no hold-out peeking), it STILL earns_slot via study_marginal AND its
|
||||
standalone Sharpe survives deflation for the WHOLE grid searched. Use this INSTEAD of
|
||||
cherry-picking the max-hold cell and calling study_marginal on it."""
|
||||
sel = select_cell_insample(factory, grid, tfs, fee_side=fee_side)
|
||||
ch = sel["chosen"]
|
||||
if ch is None:
|
||||
return dict(name=name, chosen=None, earns_slot_honest=False,
|
||||
reason="no valid in-sample cell")
|
||||
fn = factory(tf=ch["tf"], **ch["params"])
|
||||
sm = study_marginal(f"{name} ISpick {ch['params']}", fn, tf=ch["tf"], fee_side=fee_side)
|
||||
daily = candidate_daily(fn, tf=ch["tf"], fee_side=fee_side)
|
||||
dsr, sr0 = deflated_sharpe(_sh(daily), sel["all_full_sharpe"], daily)
|
||||
dsr_pass = bool(np.isfinite(dsr) and dsr >= dsr_min)
|
||||
return dict(name=name, n_cells=len(sel["all_full_sharpe"]), chosen=ch, rows=sel["rows"],
|
||||
marginal=sm, earns_slot_marginal=bool(sm["earns_slot"]),
|
||||
deflated_sharpe=round(dsr, 3) if np.isfinite(dsr) else None,
|
||||
expected_null_max=round(sr0, 3) if np.isfinite(sr0) else None,
|
||||
dsr_pass=dsr_pass,
|
||||
earns_slot_honest=bool(sm["earns_slot"] and dsr_pass))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DRIVERS — run a hypothesis across both assets, several TFs, with a fee sweep.
|
||||
# ===========================================================================
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""CC01 — CASH-AND-CARRY (basis trade) delta-neutral su Hyperliquid. Backtest onesto, STAT-MODE.
|
||||
|
||||
DIVERSO da FC01 (funding cross-sectional, gia' SCARTATO 2026-06-22). Qui NON si pickano
|
||||
vincitori/perdenti cross-section: si HARVESTA il premio di funding AGGREGATO restando
|
||||
delta-neutral sullo STESSO asset.
|
||||
|
||||
MECCANISMO. Long spot + short perp (stesso asset, stessa size):
|
||||
long spot -> +price_ret
|
||||
short perp -> -price_ret + funding (lo short INCASSA il funding quando f>0)
|
||||
netto -> +funding (il prezzo si cancella -> zero esposizione direzionale)
|
||||
Il ritorno della gamba delta-neutral = il funding realizzato, meno fee. Entrambe le gambe sono
|
||||
lineari nel prezzo => restano matchate in nozionale senza ribilanciare (delta ~neutro da solo);
|
||||
i costi reali sono entry/exit + spread, modellati come drag annuo fisso.
|
||||
|
||||
VARIANTI:
|
||||
CC-static -> sempre long-spot/short-perp (basis trade classico): r = funding. Perde se f<0.
|
||||
CC-gated -> harvest solo quando il funding trailing CAUSALE > 0 (esci dai regimi a funding
|
||||
negativo invece di pagarli). r = funding se trail>0 else 0.
|
||||
UNIVERSI: BTC/ETH (sottoinsieme rilevante per l'esecuzione) e i 19 major (basket pieno).
|
||||
|
||||
GIUDIZIO: standalone (Sharpe/DD/anni) + marginal_vs_tp01. CAVEAT ONESTI (pre-risultato):
|
||||
- NON eseguibile a $600: serve spot+perp per gamba (BTC/ETH = 4 posizioni; 19 = 38). Su Deribit
|
||||
lo storico funding e' bloccato e non operiamo spot HL -> STAT-MODE.
|
||||
- Il modello "r=funding" IGNORA il rischio di base (perp != spot), la liquidazione dello short in
|
||||
uno squeeze, e l'inversione brusca del funding. La vol modellata SOTTOSTIMA la coda.
|
||||
- Lo storico funding parte 2023-05 -> NON contiene il deleveraging 2022 (il regime peggiore per il
|
||||
carry). Edge potenzialmente sovrastimato.
|
||||
|
||||
uv run python scripts/research/cash_carry_hl.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))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
from src.portfolio.sleeves import XS_UNIVERSE, _HL_DIR
|
||||
from altlib import marginal_vs_tp01 # type: ignore
|
||||
|
||||
SQ365 = np.sqrt(365.25)
|
||||
ANNUAL_COST = 0.02 # drag annuo fisso: entry/exit + spread + borrow (generoso per un hold continuo)
|
||||
|
||||
|
||||
def load_funding_panel(universe):
|
||||
"""FUND, PREM [date x asset]: funding giornaliero (somma oraria) e premium/basis (ultimo del
|
||||
giorno) per gli asset con dati. PREM serve a iniettare il mark-to-market della BASE (perp!=spot)."""
|
||||
fund, prem = {}, {}
|
||||
for sym in universe:
|
||||
fp = _HL_DIR / f"hlfund_{sym.lower()}_1h.parquet"
|
||||
if not fp.exists():
|
||||
continue
|
||||
df = pd.read_parquet(fp)
|
||||
fund[sym] = df["funding"].resample("1D").sum()
|
||||
prem[sym] = df["premium"].resample("1D").last()
|
||||
FUND = pd.concat(fund, axis=1).sort_index()
|
||||
PREM = pd.concat(prem, axis=1).sort_index().reindex(FUND.index)
|
||||
if FUND.index.tz is None:
|
||||
FUND.index = FUND.index.tz_localize("UTC"); PREM.index = PREM.index.tz_localize("UTC")
|
||||
return FUND, PREM
|
||||
|
||||
|
||||
def cc_returns(universe, mode="static", trail=14, cost=ANNUAL_COST, with_basis=False) -> pd.Series:
|
||||
"""Ritorno giornaliero del basket cash-and-carry equal-weight, netto drag annuo.
|
||||
with_basis=True inietta il mark-to-market reale della base: r = funding - Δpremium (lo short
|
||||
perp marca contro l'allargamento del basis). E' il rischio che 'r=funding' nasconde."""
|
||||
FUND, PREM = load_funding_panel(universe)
|
||||
daily_cost = cost / 365.25
|
||||
leg_raw = FUND - PREM.diff() if with_basis else FUND # per-asset daily mark
|
||||
if mode == "gated":
|
||||
sig = FUND.rolling(trail, min_periods=trail // 2).mean().shift(1) # funding trailing causale
|
||||
active = (sig > 0).astype(float)
|
||||
n_active = active.sum(axis=1).replace(0, np.nan)
|
||||
gross = ((leg_raw * active).sum(axis=1) / n_active).fillna(0.0)
|
||||
drag = (active.sum(axis=1) > 0).astype(float) * daily_cost
|
||||
else:
|
||||
gross = leg_raw.mean(axis=1)
|
||||
drag = daily_cost
|
||||
return (gross - drag).dropna()
|
||||
|
||||
|
||||
def metrics(daily: pd.Series) -> dict:
|
||||
r = daily.values
|
||||
sh = float(np.mean(r) / np.std(r) * SQ365) if np.std(r) > 0 else 0.0
|
||||
eq = np.cumprod(1.0 + np.clip(r, -0.99, None))
|
||||
pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0
|
||||
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0
|
||||
s = pd.Series(eq, index=daily.index)
|
||||
yearly = {}
|
||||
for y, g in s.groupby(s.index.year):
|
||||
if len(g) > 1:
|
||||
v = g.values; p = np.maximum.accumulate(v)
|
||||
yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((p - v) / p)))
|
||||
return dict(sharpe=sh, dd=dd, cagr=cagr, yearly=yearly, gross_ann=float(np.mean(r) * 365.25))
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 92)
|
||||
print(" CC01 — CASH-AND-CARRY (basis trade) delta-neutral su Hyperliquid")
|
||||
print(" long spot + short perp -> r = funding (zero esposizione prezzo). Netto drag 2%/anno.")
|
||||
print("=" * 92)
|
||||
|
||||
configs = [
|
||||
("BTC/ETH CC-static", ["BTC", "ETH"], "static"),
|
||||
("BTC/ETH CC-gated", ["BTC", "ETH"], "gated"),
|
||||
("19-major CC-static", XS_UNIVERSE, "static"),
|
||||
("19-major CC-gated", XS_UNIVERSE, "gated"),
|
||||
]
|
||||
print("\n [A] MODELLO INGENUO (r = funding) — IGNORA il rischio di base:")
|
||||
series = {}
|
||||
for label, uni, mode in configs:
|
||||
r = cc_returns(uni, mode=mode)
|
||||
series[label] = r
|
||||
m = metrics(r)
|
||||
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
||||
print(f"\n --- {label} --- ({r.index[0].date()} -> {r.index[-1].date()}, {len(r)}g)")
|
||||
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}% "
|
||||
f"carry lordo {m['gross_ann']*100:+.1f}%/anno | per-anno: {ys}")
|
||||
|
||||
print("\n [B] CON MARK-TO-MARKET DELLA BASE (r = funding - Δpremium) — il rischio nascosto:")
|
||||
series_b = {}
|
||||
for label, uni, mode in configs:
|
||||
r = cc_returns(uni, mode=mode, with_basis=True)
|
||||
series_b[label] = r
|
||||
m = metrics(r)
|
||||
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
||||
print(f"\n --- {label} (basis) ---")
|
||||
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}% "
|
||||
f"| per-anno: {ys}")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print(" REALITY CHECK — perche' uno Sharpe 11-13 e' un ARTEFATTO, non un edge")
|
||||
print("=" * 92)
|
||||
FUND, _ = load_funding_panel(["BTC", "ETH"])
|
||||
agg = FUND.mean(axis=1)
|
||||
neg = float((agg < 0).mean())
|
||||
by_year = agg.groupby(agg.index.year).apply(lambda x: float(x.sum()))
|
||||
print(f" funding aggregato BTC/ETH: giorni a funding NEGATIVO {neg*100:.0f}% | "
|
||||
f"per-anno (somma): " + " ".join(f"{y}:{v*100:+.1f}%" for y, v in by_year.items()))
|
||||
print(" -> il carry e' PROCYCLICO: +23% nel toro 2024, ~+1% nel 2026 (si comprime nel bear).")
|
||||
print(" -> lo storico funding parte 2023-05: ASSENTE il deleveraging 2022 (funding -, basis blow-out),")
|
||||
print(" il regime che farebbe il vero drawdown. + assenti: liquidazione short in squeeze, slippage")
|
||||
print(" su spot+perp a $600. Sharpe reale di un basis-trade ~1-3 con code brusche, NON 13.")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print(" SCORING MARGINALE vs TP01 (sul modello ONESTO con rischio di base [B])")
|
||||
print(" NB: ADDS/robust_oos qui ESPONGONO un punto cieco dello scorer — si fida della vol")
|
||||
print(" riportata e non ha un gate 'Sharpe implausibile -> rischio nascosto'.")
|
||||
print("=" * 92)
|
||||
for label, r in series_b.items():
|
||||
if r.std() == 0:
|
||||
print(f"\n[{label}] flat — skip"); continue
|
||||
m = marginal_vs_tp01(r)
|
||||
b = m.get("blends", {}).get("w25", {})
|
||||
print(f"\n[{label}]")
|
||||
print(f" verdict={m.get('marginal_verdict')} corr->TP01 full={m.get('corr_full')} "
|
||||
f"hold={m.get('corr_hold')} is_hedge={m.get('is_hedge')} "
|
||||
f"has_insample_edge={m.get('has_insample_edge')} (cand IS Sharpe {m.get('cand_insample_sharpe')})")
|
||||
print(f" cand Sharpe full={m.get('cand_full_sharpe')} hold={m.get('cand_hold_sharpe')} | "
|
||||
f"blend25 full {b.get('full')} (upl {b.get('uplift_full')}) "
|
||||
f"hold {b.get('hold')} (upl {b.get('uplift_hold')}) DD {b.get('dd')}")
|
||||
print(f" multicut persistente={m.get('multicut_persistent')} robust_oos={m.get('robust_oos')} "
|
||||
f"hedge-check up/down {m.get('uplift_tp01_up')}/{m.get('uplift_tp01_down')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,352 @@
|
||||
"""DVOL-DIRECTIONAL — la vol IMPLICITA (Deribit DVOL) come SEGNALE DIREZIONALE/REGIME sul perp BTC/ETH.
|
||||
|
||||
Filone A. Diverso da TUTTO il lavoro DVOL precedente:
|
||||
* `tp01_dvol_overlay.py` (2026-06-26): DVOL come DENOMINATORE del vol-target -> solo de-levering, SCARTATO.
|
||||
* VOL03/04/10/11 (sweep alt): DVOL che GATA/SCALA un TSMOM (eredita lo Sharpe di trend di TP01 ->
|
||||
il marginal scorer li boccia NEUTRAL/REDUNDANT).
|
||||
* agent_14_dvol_spread (onda ortho): IV RELATIVA BTC-vs-ETH, market-NEUTRAL 2-leg (l'unico LEAD).
|
||||
Qui invece: usare DVOL/IV-RV come segnale DIREZIONALE STANDALONE sul LIVELLO di mercato (long-flat o
|
||||
L/S sul perp), per provare se il DVOL porta alpha DIREZIONALE ORTOGONALE a TP01 (non un overlay sul trend).
|
||||
|
||||
Angoli (tutti CAUSALI: decisi <= close[i], tenuti in i+1 dallo shift di eval_weights):
|
||||
VRP-Z : z-score causale del vol-risk-premium (IV-RV). VRP ricco (IV>>RV, paura sovra-prezzata)
|
||||
=> LONG underlying (fear-reversal, l'analogo direzionale dell'edge VRP); flip = falsifica.
|
||||
DVOL-LV : percentile espandente causale del DVOL. "Buy-the-fear" (rank alto => long) vs
|
||||
"buy-the-calm" (rank basso => long).
|
||||
DVOL-MOM : DVOL vs ema(DVOL,k). DVOL in calo (risk-on) => long; in salita (risk-off) => flat (o short, L/S).
|
||||
VRP-LS : variante long-short del VRP-Z (short quando VRP compresso).
|
||||
|
||||
DVOL parte 2021-03 -> pre-DVOL il segnale e' FLAT (0). Valuto sia FULL (con flat pre-periodo, deflaziona
|
||||
lo Sharpe) sia DVOL-ERA-ONLY (giusto per la tesi). Gate: study_weights (abs+fee sweep), marginal_vs_tp01
|
||||
(earns_slot), causality_ok, eval_weights_smallcap ($600), sign-falsification. Storia DVOL <5 anni -> caveat.
|
||||
|
||||
uv run python scripts/research/dvol_directional.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"))
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
# warmup: prima data DVOL (2021-03-24) + finestra max (z/rank) -> evita celle a campione corto
|
||||
DVOL_ERA = pd.Timestamp("2021-10-01", tz="UTC")
|
||||
TVOL = 0.20
|
||||
VOLWIN = 30
|
||||
LEVCAP = 2.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CONTESTO causale per-asset (IV, RV, VRP) — tutto <= close[i]
|
||||
# ===========================================================================
|
||||
def _ctx(df: pd.DataFrame, asset: str):
|
||||
c = df["close"].values.astype(float)
|
||||
r = al.simple_returns(c)
|
||||
bpd = al.bars_per_day(df)
|
||||
iv = al.dvol(df, asset) / 100.0 # IV implicita 30d annualizzata (causale)
|
||||
rv = al.realized_vol(r, max(2, 30 * bpd), bpd * 365.25) # RV trailing 30d annualizzata
|
||||
return c, r, bpd, iv, rv
|
||||
|
||||
|
||||
_RANK_CACHE: dict = {}
|
||||
|
||||
|
||||
def _expanding_rank_arr(x: np.ndarray, min_p: int = 120) -> np.ndarray:
|
||||
"""Percentile espandente causale: rank[i] = frazione di x[:i] < x[i].
|
||||
Usa SOLO barre STRETTAMENTE precedenti + il valore corrente (noto a close[i]) — niente
|
||||
future-peeking. Calcolato sull'array PASSATO (cosi' su un prefisso ridà i valori del prefisso,
|
||||
=> causality_ok lo verifica davvero). O(n^2), n~3k OK."""
|
||||
finite = np.isfinite(x)
|
||||
out = np.full(len(x), np.nan)
|
||||
for i in range(min_p, len(x)):
|
||||
if not finite[i]:
|
||||
continue
|
||||
prev = x[:i][finite[:i]]
|
||||
if len(prev) >= min_p // 2:
|
||||
out[i] = float(np.mean(prev < x[i]))
|
||||
return out
|
||||
|
||||
|
||||
def dvol_rank(df: pd.DataFrame, asset: str, min_p: int = 120) -> np.ndarray:
|
||||
x = np.asarray(al.dvol(df, asset), float)
|
||||
key = (asset, len(x), round(float(np.nan_to_num(x[-1])), 4))
|
||||
c = _RANK_CACHE.get(key)
|
||||
if c is None:
|
||||
c = _expanding_rank_arr(x, min_p)
|
||||
_RANK_CACHE[key] = c
|
||||
return c
|
||||
|
||||
|
||||
def _finalize(direction: np.ndarray, df: pd.DataFrame, long_only: bool) -> np.ndarray:
|
||||
d = np.nan_to_num(direction, nan=0.0)
|
||||
if long_only:
|
||||
d = np.clip(d, 0.0, 1.0)
|
||||
return al.vol_target(d, df, TVOL, VOLWIN, LEVCAP)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# FAMIGLIE DI SEGNALI (factory -> target_fn(df, asset))
|
||||
# ===========================================================================
|
||||
def make_vrp_z(win=90, thr=0.0, sign=+1, long_only=True):
|
||||
"""LONG quando z(IV-RV) * sign > thr. sign=+1 => long su VRP RICCO (fear-reversal)."""
|
||||
def fn(df, asset):
|
||||
c, r, bpd, iv, rv = _ctx(df, asset)
|
||||
vrp = iv - rv
|
||||
z = al.zscore(vrp, max(5, win * bpd))
|
||||
sig = sign * z
|
||||
if long_only:
|
||||
d = np.where(sig > thr, 1.0, 0.0)
|
||||
else:
|
||||
d = np.clip(np.tanh(sig), -1.0, 1.0)
|
||||
return _finalize(d, df, long_only)
|
||||
return fn
|
||||
|
||||
|
||||
def make_dvol_level(q=0.5, side="fear", long_only=True):
|
||||
"""side='fear': long quando rank DVOL > q (buy-the-fear). side='calm': long quando rank < q."""
|
||||
def fn(df, asset):
|
||||
rk = dvol_rank(df, asset)
|
||||
if side == "fear":
|
||||
base = (rk > q)
|
||||
else:
|
||||
base = (rk < q)
|
||||
if long_only:
|
||||
d = np.where(base, 1.0, 0.0)
|
||||
else:
|
||||
d = np.where(base, 1.0, -1.0)
|
||||
return _finalize(d, df, long_only)
|
||||
return fn
|
||||
|
||||
|
||||
def make_dvol_mom(k=10, long_only=True):
|
||||
"""DVOL in calo (dvol < ema(dvol,k)) => risk-on => long; in salita => flat (o short se L/S)."""
|
||||
def fn(df, asset):
|
||||
dv = al.dvol(df, asset)
|
||||
em = al.ema(np.nan_to_num(dv, nan=np.nan), k)
|
||||
falling = dv < em
|
||||
if long_only:
|
||||
d = np.where(falling, 1.0, 0.0)
|
||||
else:
|
||||
d = np.where(falling, 1.0, -1.0)
|
||||
d = np.where(np.isfinite(dv) & np.isfinite(em), d, 0.0)
|
||||
return _finalize(d, df, long_only)
|
||||
return fn
|
||||
|
||||
|
||||
def make_vrp_positive(long_only=True):
|
||||
"""Baseline 'quasi buy&hold': long quando IV>RV (VRP positivo, ~80% del tempo)."""
|
||||
def fn(df, asset):
|
||||
c, r, bpd, iv, rv = _ctx(df, asset)
|
||||
d = np.where((iv - rv) > 0, 1.0, 0.0)
|
||||
return _finalize(d, df, long_only)
|
||||
return fn
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DIAGNOSTICA: il DVOL ha CONTENUTO DIREZIONALE? (probe corr signal[i] vs r[i+1])
|
||||
# ===========================================================================
|
||||
def _raw_direction(fn_dir, df, asset):
|
||||
"""Estrae la DIREZIONE grezza (pre-vol-target) per la probe: rifa' il calcolo del segno."""
|
||||
return fn_dir(df, asset)
|
||||
|
||||
|
||||
def probe(name, dir_fn, era_only=True):
|
||||
"""corr(dir[i], r[i+1]) e media r[i+1] per dir>0 vs dir<=0, pooled BTC+ETH (1d)."""
|
||||
dd, rr = [], []
|
||||
for a in al.CERTIFIED:
|
||||
df = al.get(a, "1d")
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
|
||||
d = np.nan_to_num(dir_fn(df, a), nan=0.0)
|
||||
r = al.simple_returns(df["close"].values.astype(float))
|
||||
rnext = np.roll(r, -1); rnext[-1] = 0.0
|
||||
mask = np.ones(len(df), bool)
|
||||
if era_only:
|
||||
mask &= np.asarray(idx >= DVOL_ERA)
|
||||
mask[-1] = False
|
||||
dd.append(d[mask]); rr.append(rnext[mask])
|
||||
d = np.concatenate(dd); r = np.concatenate(rr)
|
||||
corr = float(np.corrcoef(d, r)[0, 1]) if np.std(d) > 0 and np.std(r) > 0 else 0.0
|
||||
up = float(np.mean(r[d > 0])) * 1e4 if (d > 0).any() else 0.0 # bps
|
||||
dn = float(np.mean(r[d <= 0])) * 1e4 if (d <= 0).any() else 0.0
|
||||
return dict(name=name, corr=round(corr, 4), n=int(len(d)),
|
||||
long_bps=round(up, 1), flat_bps=round(dn, 1), edge_bps=round(up - dn, 1),
|
||||
frac_long=round(float(np.mean(d > 0)), 3))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MARGINAL su finestra DVOL-ERA (piu' giusto della full che include il flat pre-2021)
|
||||
# ===========================================================================
|
||||
def cand_daily_era(target_fn, tf="1d", start=DVOL_ERA, fee_side=al.FEE_SIDE) -> pd.Series:
|
||||
series = {}
|
||||
for a in al.CERTIFIED:
|
||||
df = al.get(a, tf)
|
||||
ev = al.eval_weights(df, al._call_target(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)
|
||||
d = al._to_daily(0.5 * J[al.CERTIFIED[0]] + 0.5 * J[al.CERTIFIED[1]])
|
||||
return d[d.index >= start]
|
||||
|
||||
|
||||
def era_full_sharpe(target_fn, tf="1d"):
|
||||
"""Sharpe/DD del candidato sulla SOLA era DVOL (no flat pre-2021), 50/50."""
|
||||
s = cand_daily_era(target_fn, tf)
|
||||
r = s.values
|
||||
sh = float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if np.std(r) > 0 else 0.0
|
||||
eq = np.cumprod(1 + r); pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
yrs = (s.index[-1] - s.index[0]).days / 365.25 if len(s) > 1 else 1.0
|
||||
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and eq[-1] > 0 else -1.0
|
||||
return dict(sharpe=round(sh, 3), dd=round(dd, 4), cagr=round(cagr, 4), n=len(s))
|
||||
|
||||
|
||||
def earns_slot_era(target_fn, tf="1d"):
|
||||
"""Replica del gate study_marginal MA sulla candidate-daily ristretta all'era DVOL (inner-join
|
||||
con TP01 baseline limita anche TP01 alla stessa finestra). Piu' equo per un segnale DVOL-only."""
|
||||
marg = al.marginal_vs_tp01(cand_daily_era(target_fn, tf))
|
||||
v = marg.get("marginal_verdict")
|
||||
earns = (v == "ADDS" and marg.get("robust_oos", False)
|
||||
and marg.get("beats_noise_null", False) and not marg.get("is_hedge", False))
|
||||
return marg, earns
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MAIN
|
||||
# ===========================================================================
|
||||
def hr(t=""):
|
||||
print("=" * 100)
|
||||
if t:
|
||||
print(t)
|
||||
print("=" * 100)
|
||||
|
||||
|
||||
def main():
|
||||
hr("DVOL-DIREZIONALE — DVOL/IV-RV come segnale DIREZIONALE standalone su BTC/ETH (perp). 1d, causale.")
|
||||
print(f" Era DVOL valutata da {DVOL_ERA.date()} (warmup z/rank). FULL include flat pre-2021 (deflaziona Sharpe).")
|
||||
print(f" vol-target {TVOL:.0%}, leva cap {LEVCAP}x, fee 0.10% RT. TP01 baseline come riferimento marginale.\n")
|
||||
|
||||
# ---- 1) PROBE: contenuto direzionale (corr signal[i] vs r[i+1]) ---------
|
||||
hr("1) PROBE DIREZIONALE — il DVOL predice il ritorno del giorno dopo? (pooled BTC+ETH, era DVOL)")
|
||||
probes = [
|
||||
("VRP-Z+ (long VRP ricco)", make_vrp_z(90, 0.0, +1)),
|
||||
("VRP-Z- (long VRP basso)", make_vrp_z(90, 0.0, -1)),
|
||||
("DVOL-LV fear (rank>0.5)", make_dvol_level(0.5, "fear")),
|
||||
("DVOL-LV calm (rank<0.5)", make_dvol_level(0.5, "calm")),
|
||||
("DVOL-MOM falling=long", make_dvol_mom(10)),
|
||||
("VRP>0 (quasi buy&hold)", make_vrp_positive()),
|
||||
]
|
||||
print(f" {'segnale':<28}{'corr':>9}{'long bps':>10}{'flat bps':>10}{'edge bps':>10}{'frac_long':>10}")
|
||||
for nm, fn in probes:
|
||||
p = probe(nm, fn)
|
||||
print(f" {nm:<28}{p['corr']:>+9.4f}{p['long_bps']:>+10.1f}{p['flat_bps']:>+10.1f}"
|
||||
f"{p['edge_bps']:>+10.1f}{p['frac_long']:>10.3f}")
|
||||
print(" (edge bps = ritorno medio giorno-dopo quando long MENO quando flat; >0 = il segnale separa)\n")
|
||||
|
||||
# ---- 2) ROBUSTEZZA ASSOLUTA (study_weights) + fee sweep + era-only ------
|
||||
hr("2) ROBUSTEZZA ASSOLUTA — study_weights su BTC+ETH (1d), fee sweep 0.00-0.20% RT")
|
||||
grid = {
|
||||
"VRP-Z90 long-flat": make_vrp_z(90, 0.0, +1, True),
|
||||
"VRP-Z60 long-flat": make_vrp_z(60, 0.0, +1, True),
|
||||
"VRP-Z120 long-flat": make_vrp_z(120, 0.0, +1, True),
|
||||
"VRP-Z90 L/S": make_vrp_z(90, 0.0, +1, False),
|
||||
"DVOL-fear q0.4 LF": make_dvol_level(0.4, "fear", True),
|
||||
"DVOL-fear q0.5 LF": make_dvol_level(0.5, "fear", True),
|
||||
"DVOL-calm q0.5 LF": make_dvol_level(0.5, "calm", True),
|
||||
"DVOL-MOM k10 LF": make_dvol_mom(10, True),
|
||||
"DVOL-MOM k20 LF": make_dvol_mom(20, True),
|
||||
"DVOL-MOM k10 L/S": make_dvol_mom(10, False),
|
||||
}
|
||||
reps = {}
|
||||
for nm, fn in grid.items():
|
||||
rep = al.study_weights(nm, fn, tfs=("1d",))
|
||||
reps[nm] = rep
|
||||
era = era_full_sharpe(fn)
|
||||
v = rep["verdict"]
|
||||
c = rep["cells"][0]
|
||||
print(f" {nm:<22} abs={v['grade']:<4} minFull={c['min_asset_full_sharpe']:+.2f} "
|
||||
f"minHold={c['min_asset_holdout_sharpe']:+.2f} feeOK={c['fee_survives']!s:<5} "
|
||||
f"|| ERA-only Sh={era['sharpe']:+.2f} DD={era['dd']*100:.0f}% CAGR={era['cagr']*100:+.0f}%")
|
||||
print(" (FULL include 2018-2021 flat => Sharpe basso e' atteso; ERA-only e' il giudizio equo del segnale)\n")
|
||||
|
||||
# candidati da portare al marginal: i migliori per ERA Sharpe + abs non-FAIL
|
||||
ranked = sorted(grid.items(), key=lambda kv: era_full_sharpe(kv[1])["sharpe"], reverse=True)
|
||||
top = ranked[:4]
|
||||
|
||||
# ---- 3) MARGINAL vs TP01 (era DVOL) — il gate vero -----------------------
|
||||
hr("3) MARGINAL vs TP01 (finestra DVOL-era) — earns_slot? (il gate decisivo)")
|
||||
for nm, fn in top:
|
||||
marg, earns = earns_slot_era(fn)
|
||||
bl = marg.get("blends", {}).get("w25", {})
|
||||
print(f"\n --- {nm} ---")
|
||||
print(f" verdetto={marg.get('marginal_verdict')} EARNS_SLOT(era)={earns}")
|
||||
print(f" corr->TP01 full {marg.get('corr_full')} hold {marg.get('corr_hold')} "
|
||||
f"beta {marg.get('beta_to_tp01')} resid Sh {marg.get('resid_sharpe_full')}")
|
||||
print(f" cand standalone full {marg.get('cand_full_sharpe')}/hold {marg.get('cand_hold_sharpe')} "
|
||||
f"in-sample Sh {marg.get('cand_insample_sharpe')} has_edge={marg.get('has_insample_edge')}")
|
||||
print(f" blend w25: full {bl.get('full')} (uplift {bl.get('uplift_full')}) "
|
||||
f"hold {bl.get('hold')} (uplift {bl.get('uplift_hold')}) DD {bl.get('dd')}")
|
||||
print(f" multi-cut {marg.get('multicut_uplift')} persistent={marg.get('multicut_persistent')} "
|
||||
f"robust_oos={marg.get('robust_oos')} is_hedge={marg.get('is_hedge')}")
|
||||
|
||||
# ---- 3b) study_marginal canonico (full history, gate di progetto) --------
|
||||
hr("3b) study_marginal CANONICO (full history, include flat pre-DVOL) — gate ufficiale di progetto")
|
||||
leader_nm, leader_fn = top[0]
|
||||
sm = al.study_marginal(leader_nm, leader_fn, tf="1d")
|
||||
print(al.fmt_marginal(sm))
|
||||
|
||||
# ---- 4) CAUSALITA' (look-ahead guard) -----------------------------------
|
||||
hr("4) CAUSALITA' — causality_ok (ricalcolo su prefisso, nessun future-peeking)")
|
||||
for nm, fn in top:
|
||||
co = al.causality_ok(fn, tf="1d")
|
||||
print(f" {nm:<22} ok={co['ok']!s:<5} max_tail_diff={co['max_tail_diff']} checked={co['checked']}")
|
||||
|
||||
# robustezza alignment DVOL: lag +1 giorno (extra-conservativo) sul leader
|
||||
hr("4b) ROBUSTEZZA ALIGNMENT — DVOL laggato +1g (extra-conservativo) sul leader: l'edge sopravvive?")
|
||||
|
||||
def _lag(fn):
|
||||
# ricostruisce il segnale con dvol shiftato di 1 barra (usa solo DVOL di IERI)
|
||||
def g(df, asset):
|
||||
# monkey: usa al.dvol ma shift -> approssimo rifacendo via wrapper sul ctx non e' banale;
|
||||
# piu' semplice: confronto Sharpe era con e senza lag costruendo direzione laggata generica.
|
||||
base = fn(df, asset)
|
||||
lagged = np.zeros_like(base); lagged[1:] = base[:-1]
|
||||
return lagged
|
||||
return g
|
||||
lag_era = era_full_sharpe(_lag(leader_fn))
|
||||
base_era = era_full_sharpe(leader_fn)
|
||||
print(f" {leader_nm}: ERA Sh base {base_era['sharpe']:+.2f} -> con segnale laggato +1g "
|
||||
f"{lag_era['sharpe']:+.2f} (calo grande = edge fragile all'alignment)")
|
||||
|
||||
# ---- 5) ESEGUIBILITA' $600 — eval_weights_smallcap ----------------------
|
||||
hr("5) ESEGUIBILITA' a $600 — eval_weights_smallcap (min_order $5, haircut reale vs modellato)")
|
||||
for nm, fn in top:
|
||||
for a in al.CERTIFIED:
|
||||
df = al.get(a, "1d")
|
||||
sc = al.eval_weights_smallcap(df, al._call_target(fn, df, a), capital=600, min_order=5)
|
||||
print(f" {nm:<22} {a}: modellato Sh {sc['modeled']['sharpe']:+.2f} -> reale $600 "
|
||||
f"Sh {sc['realistic']['sharpe']:+.2f} (haircut {sc['sharpe_haircut']:+.2f}) "
|
||||
f"trade eseguiti {sc['n_executed_trades']}")
|
||||
|
||||
# ---- 6) SIGN-FALSIFICATION sul leader -----------------------------------
|
||||
hr("6) SIGN-FALSIFICATION — invertire il segno del leader deve PEGGIORARE (altrimenti e' rumore)")
|
||||
# leader e' VRP-Z+ se nel top; provo l'inverso esplicito su VRP-Z e DVOL
|
||||
flips = [
|
||||
("VRP-Z90 sign +1 (tesi)", make_vrp_z(90, 0.0, +1, True)),
|
||||
("VRP-Z90 sign -1 (flip)", make_vrp_z(90, 0.0, -1, True)),
|
||||
("DVOL-fear q0.5 (tesi)", make_dvol_level(0.5, "fear", True)),
|
||||
("DVOL-calm q0.5 (flip)", make_dvol_level(0.5, "calm", True)),
|
||||
]
|
||||
for nm, fn in flips:
|
||||
e = era_full_sharpe(fn)
|
||||
print(f" {nm:<26} ERA Sh {e['sharpe']:+.2f} DD {e['dd']*100:.0f}% CAGR {e['cagr']*100:+.0f}%")
|
||||
|
||||
hr()
|
||||
print("FINE. Leggere il verdetto onesto nel diario docs/diary/2026-06-29-dvol-directional.md")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,194 @@
|
||||
"""EQ-FUNDNEWS-SHORT — "fondamentali/notizie NEGATIVI ma prezzo SU -> short". Screener FORWARD.
|
||||
|
||||
Idea utente: se i dati finanziari/notizie di un'azienda sono negativi ma la quotazione e' positiva
|
||||
(sale), andare SHORT (scommessa che il prezzo scenda a riallinearsi ai fondamentali).
|
||||
|
||||
GATE DATI (lezione v2.0.0, PRIMA della strategia): NON backtestabile su dati certi.
|
||||
- I fondamentali da rete (Yahoo) sono SNAPSHOT CORRENTI, non point-in-time storici. Applicarli a
|
||||
prezzi passati = LOOK-AHEAD (restatement/survivorship). E' la trappola che ha creato la libreria
|
||||
fasulla v2.0.0. -> nessun backtest. Serve un DB point-in-time (Compustat PIT / news storiche), assente.
|
||||
- Quindi: come per la vol term-structure, l'unica via onesta e' uno SCREENER FORWARD che genera i
|
||||
candidati short OGGI da dati di rete e li LOGGA in avanti. L'edge resta NON PROVATO finche' non
|
||||
accumulato e validato forward. Questo script NON afferma un edge: produce candidati + li registra.
|
||||
|
||||
SEGNALE (tutto da rete, tokenless):
|
||||
- "dati finanziari negativi" = score strutturato da Yahoo quoteSummary (crumb flow):
|
||||
recommendationMean alto (->sell), earnings surprise recenti negative, revenueGrowth<0,
|
||||
recommendationTrend sbilanciato a sell.
|
||||
- "notizie negative" = sentiment lessicale crudo sulle headline (Yahoo news search).
|
||||
- "quotazione positiva" = momentum 1m/3m > 0 (chart API).
|
||||
-> SHORT candidate = (fond_neg alto OPPURE news_neg alto) AND momentum positivo (la DIVERGENZA).
|
||||
|
||||
ESEGUIBILITA' (muro): shortare richiede BORROW (locate+fee, hard-to-borrow caro/assente), perdita
|
||||
illimitata, squeeze; PDT $25k per i day-trade; IB instabile qui; $600 di capitale; universo single-stock
|
||||
(non i nostri ETF). Shortare la FORZA combatte il momentum (anomalia forte) -> premessa rischiosa.
|
||||
|
||||
uv run python scripts/research/eq_fundnews_short.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
RAW = ROOT / "data" / "raw"
|
||||
H = {"User-Agent": "Mozilla/5.0"}
|
||||
|
||||
# universo dimostrativo: large/mid cap liquide, vari settori
|
||||
UNIVERSE = ["AAPL", "MSFT", "NVDA", "TSLA", "META", "AMZN", "INTC", "F", "GM", "BA",
|
||||
"DIS", "PYPL", "NKE", "PFE", "T", "WBA", "XOM", "KO", "CVX", "AMD"]
|
||||
|
||||
NEG_WORDS = {"downgrade", "miss", "missed", "cut", "cuts", "lawsuit", "probe", "fraud", "plunge",
|
||||
"plunges", "warn", "warns", "warning", "slump", "loss", "losses", "weak", "weakness",
|
||||
"decline", "declines", "fall", "falls", "drop", "sinks", "slashes", "recall", "halt",
|
||||
"bankruptcy", "default", "layoff", "layoffs", "sell-off", "bearish", "underperform"}
|
||||
POS_WORDS = {"beat", "beats", "upgrade", "surge", "surges", "record", "strong", "raise", "raises",
|
||||
"soar", "soars", "rally", "jumps", "tops", "bullish", "outperform", "growth"}
|
||||
|
||||
|
||||
def yahoo_session():
|
||||
s = requests.Session(); s.headers.update(H)
|
||||
s.get("https://fc.yahoo.com/", timeout=20)
|
||||
crumb = s.get("https://query2.finance.yahoo.com/v1/test/getcrumb", timeout=20).text.strip()
|
||||
return s, crumb
|
||||
|
||||
|
||||
def fund_neg_score(rec_mean, surp, rev_g, sell_skew):
|
||||
"""Pura: score di negativita' fondamentale [0..1] dai componenti disponibili (media)."""
|
||||
comp = []
|
||||
if rec_mean is not None:
|
||||
comp.append(float(np.clip((rec_mean - 1) / 4, 0, 1))) # 1=buy ->0, 5=sell ->1
|
||||
if surp:
|
||||
comp.append(1.0 if np.mean(surp[:2]) < 0 else 0.0) # ultime 2 surprise negative
|
||||
if rev_g is not None:
|
||||
comp.append(1.0 if rev_g < 0 else float(max(0.0, 1 - rev_g * 5)))
|
||||
if sell_skew is not None:
|
||||
comp.append(float(np.clip(sell_skew * 3, 0, 1)))
|
||||
return float(np.mean(comp)) if comp else None
|
||||
|
||||
|
||||
def headline_sentiment(titles):
|
||||
"""Pura: frazione di sentiment negativo sulle headline (lessico crudo). None se nessun hit."""
|
||||
neg = pos = 0
|
||||
for t in titles:
|
||||
w = set(t.lower().replace(",", " ").replace(".", " ").split())
|
||||
neg += len(w & NEG_WORDS); pos += len(w & POS_WORDS)
|
||||
tot = neg + pos
|
||||
return (neg / tot) if tot else 0.0, neg, pos
|
||||
|
||||
|
||||
def momentum(sym):
|
||||
u = f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?range=3mo&interval=1d"
|
||||
j = requests.get(u, headers=H, timeout=25).json()["chart"]["result"][0]
|
||||
c = pd.Series(j["indicators"]["quote"][0]["close"]).dropna().values
|
||||
if len(c) < 25:
|
||||
return None
|
||||
m1 = c[-1] / c[-21] - 1.0 # ~1 mese
|
||||
m3 = c[-1] / c[0] - 1.0 # ~3 mesi
|
||||
return dict(last=float(c[-1]), mom_1m=float(m1), mom_3m=float(m3))
|
||||
|
||||
|
||||
def fundamentals(s, crumb, sym):
|
||||
mods = "financialData,recommendationTrend,earningsHistory"
|
||||
u = f"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{sym}?modules={mods}&crumb={crumb}"
|
||||
res = s.get(u, timeout=25).json()["quoteSummary"]["result"][0]
|
||||
fd = res.get("financialData", {})
|
||||
rec_mean = fd.get("recommendationMean", {}).get("raw")
|
||||
rev_g = fd.get("revenueGrowth", {}).get("raw")
|
||||
eh = res.get("earningsHistory", {}).get("history", [])
|
||||
surp = [h.get("surprisePercent", {}).get("raw") for h in eh if h.get("surprisePercent")]
|
||||
rt = res.get("recommendationTrend", {}).get("trend", [])
|
||||
sell_skew = None
|
||||
if rt:
|
||||
t = rt[0]; tot = sum(t.get(k, 0) for k in ("strongBuy", "buy", "hold", "sell", "strongSell"))
|
||||
sell_skew = (t.get("sell", 0) + t.get("strongSell", 0)) / tot if tot else None
|
||||
return dict(fund_neg=fund_neg_score(rec_mean, surp, rev_g, sell_skew),
|
||||
rec_mean=rec_mean, rev_growth=rev_g, last_surprise=surp[0] if surp else None,
|
||||
sell_skew=sell_skew)
|
||||
|
||||
|
||||
def news_sentiment(sym):
|
||||
u = f"https://query1.finance.yahoo.com/v1/finance/search?q={sym}&newsCount=8"esCount=0"
|
||||
news = requests.get(u, headers=H, timeout=25).json().get("news", [])
|
||||
if not news:
|
||||
return dict(news_neg=None, n=0)
|
||||
nn, neg, pos = headline_sentiment([n.get("title", "") for n in news])
|
||||
return dict(news_neg=nn, n=len(news), neg_hits=neg, pos_hits=pos)
|
||||
|
||||
|
||||
def screen(universe):
|
||||
s, crumb = yahoo_session()
|
||||
rows = []
|
||||
for sym in universe:
|
||||
try:
|
||||
mom = momentum(sym)
|
||||
if mom is None:
|
||||
continue
|
||||
fun = fundamentals(s, crumb, sym)
|
||||
nw = news_sentiment(sym)
|
||||
fneg = fun["fund_neg"]
|
||||
nneg = nw["news_neg"]
|
||||
rising = mom["mom_1m"] > 0.02 # quotazione positiva
|
||||
fund_bad = fneg is not None and fneg >= 0.5
|
||||
news_bad = nneg is not None and nneg >= 0.5
|
||||
short_cand = rising and (fund_bad or news_bad)
|
||||
rows.append(dict(sym=sym, mom_1m=mom["mom_1m"], mom_3m=mom["mom_3m"],
|
||||
fund_neg=fneg, news_neg=nneg, rec_mean=fun["rec_mean"],
|
||||
rev_growth=fun["rev_growth"], last_surprise=fun["last_surprise"],
|
||||
short_cand=short_cand))
|
||||
time.sleep(0.3)
|
||||
except Exception as e:
|
||||
rows.append(dict(sym=sym, error=repr(e)[:60]))
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" EQ-FUNDNEWS-SHORT — divergenza fondamentali/notizie NEG vs prezzo SU -> short candidate")
|
||||
print(" (SCREENER FORWARD da dati di rete — NON un backtest: edge non provato, vedi header)")
|
||||
print("=" * 100)
|
||||
df = screen(UNIVERSE)
|
||||
ok = df[df.get("error").isna()] if "error" in df else df
|
||||
ok = ok.sort_values("fund_neg", ascending=False, na_position="last")
|
||||
print(f" {'sym':5} {'mom1m':>7} {'mom3m':>7} {'fund_neg':>8} {'news_neg':>8} "
|
||||
f"{'recMean':>7} {'revGr':>7} {'surp':>7} SHORT?")
|
||||
for _, r in ok.iterrows():
|
||||
fn = f"{r['fund_neg']:.2f}" if pd.notna(r['fund_neg']) else " n/a"
|
||||
nn = f"{r['news_neg']:.2f}" if pd.notna(r['news_neg']) else " n/a"
|
||||
rm = f"{r['rec_mean']:.2f}" if pd.notna(r['rec_mean']) else " n/a"
|
||||
rg = f"{r['rev_growth']*100:+.0f}%" if pd.notna(r['rev_growth']) else " n/a"
|
||||
sp = f"{r['last_surprise']*100:+.0f}%" if pd.notna(r['last_surprise']) else " n/a"
|
||||
flag = " <<< SHORT" if r["short_cand"] else ""
|
||||
print(f" {r['sym']:5} {r['mom_1m']*100:>+6.1f}% {r['mom_3m']*100:>+6.1f}% {fn:>8} {nn:>8} "
|
||||
f"{rm:>7} {rg:>7} {sp:>7}{flag}")
|
||||
cands = ok[ok["short_cand"]]["sym"].tolist()
|
||||
print(f"\n CANDIDATI SHORT oggi (fond/news neg + prezzo su): {cands or 'nessuno'}")
|
||||
|
||||
# log forward (idempotente per giorno)
|
||||
today = pd.Timestamp.now("UTC").normalize()
|
||||
ok2 = ok.copy(); ok2.insert(0, "date", today)
|
||||
fp = RAW / "fundnews_short_screen.parquet"
|
||||
hist = pd.read_parquet(fp) if fp.exists() else pd.DataFrame()
|
||||
if len(hist):
|
||||
hist = hist[hist["date"] != today]
|
||||
pd.concat([hist, ok2], ignore_index=True).to_parquet(fp, index=False)
|
||||
print(f" -> snapshot loggato in {fp.name} (forward dataset; serve accumulo+validazione)")
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print(" ONESTA' / ESEGUIBILITA'")
|
||||
print("=" * 100)
|
||||
print(" - NON backtestabile: fondamentali = snapshot correnti, non point-in-time -> look-ahead (v2.0.0).")
|
||||
print(" - Premessa RISCHIOSA: shortare un prezzo che SALE combatte il momentum (anomalia forte);")
|
||||
print(" il rialzo 'malgrado' notizie cattive spesso PREZZA info che i fondamentali trailing non hanno.")
|
||||
print(" - Eseguibilita': borrow (locate/fee, hard-to-borrow), perdita illimitata, squeeze, PDT $25k,")
|
||||
print(" IB instabile, $600. -> NON deployabile. Deliverable = screener forward + log, edge da provare.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,172 @@
|
||||
"""EQ-MR — "scalping azioni IB quando sottoquotate" + CHECK DATI DALLA RETE. Analisi onesta.
|
||||
|
||||
L'utente chiede: su IB, comprare azioni quando "sottoquotate" (oversold/sotto fair-value), con
|
||||
verifica incrociata dei dati dalla rete. Due pezzi, entrambi in-metodo (la lezione fondante del
|
||||
progetto e' "non fidarti di un feed solo" — il disastro v2.0.0):
|
||||
|
||||
1) CHECK DATI DALLA RETE — confronta i nostri dati certificati IB (data/raw/eq_*_1d.parquet,
|
||||
ADJUSTED_LAST) con una SORGENTE INDIPENDENTE di rete (Yahoo Finance chart API, tokenless).
|
||||
Confronto sui RENDIMENTI giornalieri (invarianti all'aggiustamento) + ultimo close. Verdetto:
|
||||
feed CONCORDE (bps piccoli) o DIVERGENTE. E' il template del pre-trade price-check live.
|
||||
|
||||
2) MEAN-REVERSION "SOTTOQUOTATA" sul daily — lo *scalping* intraday NON e' backtestabile (non
|
||||
abbiamo dati intraday, solo eq_*_1d) ne' eseguibile (vedi sotto), quindi si testa la versione
|
||||
onesta: swing mean-reversion su ETF indice (RSI2 oversold + filtro trend MA200, exit a MA5 =
|
||||
Connors). Causale (segnale <= close[i], entry a close[i]), netto fee, hold-out OOS, per-anno.
|
||||
|
||||
ESEGUIBILITA' (muro, da dire subito):
|
||||
- PDT RULE: il day-trading di azioni US sotto $25.000 e' limitato a 3 day-trade/5gg -> lo scalping
|
||||
e' regolatoriamente BLOCCATO al capitale del progetto. (l'analogo equity del muro STAT-MODE a $600).
|
||||
- Niente dati INTRADAY -> scalping non backtestabile.
|
||||
- IB Gateway in questo ambiente e' instabile (timeout ordini diagnosticato) -> niente HFT affidabile.
|
||||
|
||||
uv run python scripts/research/eq_meanrev_ib.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
from eqlib import load_eq # type: ignore
|
||||
|
||||
SQ = np.sqrt(252)
|
||||
H = {"User-Agent": "Mozilla/5.0"}
|
||||
|
||||
|
||||
# ----------------------------- 1) CHECK DATI DALLA RETE -----------------------------
|
||||
def yahoo_daily(sym: str, rng: str = "1y") -> pd.DataFrame:
|
||||
u = f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?range={rng}&interval=1d"
|
||||
j = requests.get(u, headers=H, timeout=30).json()["chart"]["result"][0]
|
||||
ts = pd.to_datetime(j["timestamp"], unit="s", utc=True).normalize()
|
||||
q = j["indicators"]["quote"][0]
|
||||
adj = j["indicators"].get("adjclose", [{}])[0].get("adjclose", q["close"])
|
||||
return pd.DataFrame({"close": q["close"], "adjclose": adj}, index=ts).dropna()
|
||||
|
||||
|
||||
def cross_check(syms):
|
||||
print("=" * 90)
|
||||
print(" 1) CHECK DATI DALLA RETE — IB certificato (eq_*, ADJUSTED) vs Yahoo adjclose (rete indip.)")
|
||||
print("=" * 90)
|
||||
print(f" {'sym':5} {'n com':>6} {'ret maxΔ':>9} {'ret medΔ':>9} {'last IB':>10} {'last YHOO':>10} {'Δbps':>7} esito")
|
||||
for s in syms:
|
||||
try:
|
||||
ib = load_eq(s)["close"].astype(float)
|
||||
ib.index = ib.index.normalize()
|
||||
yh = yahoo_daily(s)["adjclose"] # adjusted vs adjusted (apples-to-apples)
|
||||
J = pd.concat({"ib": ib, "yh": yh}, axis=1, join="inner").dropna().tail(180)
|
||||
if len(J) < 20:
|
||||
print(f" {s:5} overlap insufficiente"); continue
|
||||
R = pd.concat({"a": J["ib"].pct_change(), "b": J["yh"].pct_change()},
|
||||
axis=1, join="inner").dropna()
|
||||
d = (R["a"] - R["b"]).abs()
|
||||
last_bps = abs(J["ib"].iloc[-1] / J["yh"].iloc[-1] - 1) * 1e4
|
||||
ok = d.max() < 0.001 and last_bps < 20 # ret entro 10bps, last entro 20bps
|
||||
print(f" {s:5} {len(J):>6} {d.max()*1e4:>8.1f}b {d.median()*1e4:>8.1f}b "
|
||||
f"{J['ib'].iloc[-1]:>10.2f} {J['yh'].iloc[-1]:>10.2f} {last_bps:>6.1f}b "
|
||||
f"{'CONCORDE' if ok else 'DIVERGENTE -> INDAGARE'}")
|
||||
except Exception as e:
|
||||
print(f" {s:5} ERRORE: {repr(e)[:80]}")
|
||||
print(" -> confronto sui RENDIMENTI adjusted-vs-adjusted: devono combaciare a pochi bps. Una")
|
||||
print(" divergenza non spiegata = feed sospetto, NON tradare prima di averla capita (v2.0.0).")
|
||||
|
||||
|
||||
# ----------------------------- 2) MEAN-REVERSION "SOTTOQUOTATA" -----------------------------
|
||||
def rsi(close: np.ndarray, n: int) -> np.ndarray:
|
||||
d = np.diff(close, prepend=close[0])
|
||||
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1 / n, adjust=False).mean().values
|
||||
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1 / n, adjust=False).mean().values
|
||||
rs = np.divide(up, dn, out=np.full_like(up, np.inf), where=dn > 0)
|
||||
return 100 - 100 / (1 + rs)
|
||||
|
||||
|
||||
def mr_target(close: np.ndarray, entry=10.0, exit_ma=5, trend=200) -> np.ndarray:
|
||||
"""Posizione 0/1 DECISA a close[i] (Connors RSI2): entra se sottoquotata (RSI2<entry) in uptrend
|
||||
(close>MA{trend}); esci quando close>MA{exit_ma}. Causale: usa solo dati <= close[i]."""
|
||||
r2 = rsi(close, 2)
|
||||
ma_t = pd.Series(close).rolling(trend).mean().values
|
||||
ma_x = pd.Series(close).rolling(exit_ma).mean().values
|
||||
pos = np.zeros(len(close)); inpos = False
|
||||
for i in range(len(close)):
|
||||
if not np.isfinite(ma_t[i]):
|
||||
continue
|
||||
if not inpos and close[i] > ma_t[i] and r2[i] < entry:
|
||||
inpos = True
|
||||
elif inpos and close[i] > ma_x[i]:
|
||||
inpos = False
|
||||
pos[i] = 1.0 if inpos else 0.0
|
||||
return pos
|
||||
|
||||
|
||||
def backtest(sym: str, fee_bps=3.0, **kw) -> pd.Series:
|
||||
df = load_eq(sym); close = df["close"].astype(float).values
|
||||
idx = df.index
|
||||
tgt = mr_target(close, **kw)
|
||||
r = np.zeros(len(close)); r[1:] = close[1:] / close[:-1] - 1.0
|
||||
held = np.zeros(len(tgt)); held[1:] = tgt[:-1] # decisa a close[i-1], tenuta in i
|
||||
turn = np.abs(np.diff(held, prepend=0.0))
|
||||
net = held * r - (fee_bps / 1e4) * turn
|
||||
return pd.Series(net, index=idx)
|
||||
|
||||
|
||||
def metrics(daily: pd.Series, lo=None):
|
||||
if lo is not None:
|
||||
daily = daily[daily.index >= lo]
|
||||
rr = daily.values
|
||||
sh = float(np.mean(rr) / np.std(rr) * SQ) if np.std(rr) > 0 else 0.0
|
||||
eq = np.cumprod(1 + rr); pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0
|
||||
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0
|
||||
expo = float((daily != 0).mean())
|
||||
return dict(sharpe=sh, dd=dd, cagr=cagr, expo=expo)
|
||||
|
||||
|
||||
def buyhold(sym, lo=None):
|
||||
df = load_eq(sym); c = df["close"].astype(float)
|
||||
r = c.pct_change().fillna(0.0)
|
||||
return metrics(r, lo=lo)
|
||||
|
||||
|
||||
def run_meanrev(syms, holdout="2015-01-01"):
|
||||
HO = pd.Timestamp(holdout, tz="UTC")
|
||||
print("\n" + "=" * 90)
|
||||
print(f" 2) MEAN-REVERSION 'SOTTOQUOTATA' (RSI2<10 + filtro MA200, exit MA5). Hold-out {holdout}.")
|
||||
print("=" * 90)
|
||||
print(f" {'sym':5} | {'FULL Sh':>7} {'DD':>6} {'CAGR':>6} {'expo':>5} | "
|
||||
f"{'HOLD Sh':>7} {'HOLD CAGR':>9} | {'B&H Sh':>6} {'B&H HOLD':>8}")
|
||||
for s in syms:
|
||||
net = backtest(s)
|
||||
f = metrics(net); h = metrics(net, lo=HO)
|
||||
bh = buyhold(s); bhh = buyhold(s, lo=HO)
|
||||
print(f" {s:5} | {f['sharpe']:>+7.2f} {f['dd']*100:>5.0f}% {f['cagr']*100:>+5.0f}% "
|
||||
f"{f['expo']*100:>4.0f}% | {h['sharpe']:>+7.2f} {h['cagr']*100:>+8.0f}% | "
|
||||
f"{bh['sharpe']:>+6.2f} {bhh['sharpe']:>+7.2f}")
|
||||
print(" expo = % giorni investito. B&H = buy&hold sullo stesso ETF (il benchmark onesto).")
|
||||
|
||||
print("\n Sweep fee (SPY) — quanto regge ai costi reali:")
|
||||
for fb in (0.0, 3.0, 5.0, 10.0):
|
||||
f = metrics(backtest("SPY", fee_bps=fb)); h = metrics(backtest("SPY", fee_bps=fb), lo=HO)
|
||||
print(f" fee {fb:>4.1f}bps RT: FULL Sh {f['sharpe']:+.2f} HOLD Sh {h['sharpe']:+.2f}")
|
||||
|
||||
|
||||
def main():
|
||||
cross_check(["SPY", "QQQ", "IWM", "GLD", "TLT", "HYG"])
|
||||
run_meanrev(["SPY", "QQQ", "IWM", "DIA", "EEM"])
|
||||
print("\n" + "=" * 90)
|
||||
print(" 3) ESEGUIBILITA' (il muro)")
|
||||
print("=" * 90)
|
||||
print(" - PDT RULE: day-trading azioni US < $25k = max 3 day-trade/5gg -> SCALPING BLOCCATO al")
|
||||
print(" capitale del progetto. E' l'analogo equity del muro STAT-MODE a $600 sul crypto.")
|
||||
print(" - Dati INTRADAY assenti (solo eq_*_1d) -> lo scalping non e' backtestabile, solo lo swing MR.")
|
||||
print(" - IB Gateway instabile in questo ambiente (timeout ordini) -> niente HFT affidabile.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,376 @@
|
||||
"""intraday_regime.py — FILONE B: "INTRADAY REGIME BTC/ETH" (eseguibile) — 2026-06-29.
|
||||
|
||||
TESI. Cercare un meccanismo SUB-DAILY sui dati certificati 1h/4h/.../12h BTC/ETH che sia
|
||||
ORTOGONALE sia a TP01 (TSMOM trend daily, long-flat) sia a SKH01 (regime BuzVola/BuzVolume
|
||||
+ Donchian breakout a 230m). SKH01 prova che il sub-daily PUO' funzionare ed essere
|
||||
quasi-ortogonale: qui si esplora un MECCANISMO DIVERSO, basato sulla QUALITA' del moto
|
||||
intraday (efficiency-ratio / vol-expansion / thrust) come REGIME che condiziona una
|
||||
posizione direzionale tenuta ~1 giorno.
|
||||
|
||||
Il killer ricorrente del progetto sotto le 12h e' il MURO-FEE (0.10% RT) + overfitting.
|
||||
La ricetta che SKH01 usa per sopravvivere: DECISIONE sub-daily ma HOLD ~1 giorno -> pochi
|
||||
trade -> la fee non uccide. Qui ogni meccanismo e' costruito per essere a basso turnover
|
||||
(gate di regime che tiene flat la maggior parte del tempo, lookback non microscopici) e
|
||||
viene giudicato col fee-sweep ALLA SUA FREQUENZA REALE. Se muore appena si mette la fee ->
|
||||
SCARTATO e documentato (e' un risultato valido).
|
||||
|
||||
MECCANISMI (tutti come posizione CONTINUA decisa <= close[i], cosi' passano nativamente per
|
||||
eval_weights / study_marginal / day_boundary_robust / eval_weights_smallcap di altlib):
|
||||
|
||||
ERM Efficiency-Ratio regime momentum. ER = |moto netto su L barre| / |percorso| (Kaufman):
|
||||
alto = moto intraday "pulito"/direzionale, basso = chop. Prendi la direzione del moto
|
||||
netto SOLO quando ER >= soglia (regime trendy intraday), altrimenti flat. Vol-target.
|
||||
Storia economica: quando il prezzo intraday e' EFFICIENTE il momentum continua; quando
|
||||
e' choppy non c'e' edge. DIVERSO da SKH01 (regime vol/volume) e da TP01 (TSMOM 1-6 mesi).
|
||||
|
||||
VEM Vol-Expansion Momentum. Direzione = segno del moto su Lmom barre, ATTIVA solo quando la
|
||||
vol realizzata corta > vol realizzata lunga (espansione di volatilita'). Vol-target.
|
||||
|
||||
VBR Volatility/thrust breakout (Larry-Williams-style, ROLLING, no calendario). Segui solo i
|
||||
movimenti significativi: posizione = segno(c[i]-c[i-1]) quando |Δ| > k*ATR, altrimenti
|
||||
tieni la precedente. Momentum-continuation di thrust.
|
||||
|
||||
TOD Time-of-day seasonality (CONTROLLO calendario). Direzione per ora-del-giorno via media
|
||||
espandente causale. Incluso APPOSTA per passarlo a day_boundary_robust: e' il tipo di
|
||||
effetto che ha ucciso open_drive (artefatto di etichettatura del giorno UTC).
|
||||
|
||||
GATE (CLAUDE.md): causale/no-leak, NETTO fee 0.10% RT + sweep 0.00-0.20% a freq reale, OOS
|
||||
hold-out + griglia + plateau, day_boundary_robust per effetti calendario, MARGINAL vs TP01
|
||||
(earns_slot / has_insample_edge / multi-cut / non-hedge), corr con SKH01, haircut $600.
|
||||
|
||||
Esecuzione: uv run python scripts/research/intraday_regime.py
|
||||
Idempotente, niente scritture su disco (solo report a stdout).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
SCREEN_TFS = ("1h", "4h", "6h", "8h", "12h")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TARGET FACTORIES (ogni fattoria ritorna un target_fn(df) causale, posizione continua)
|
||||
# ===========================================================================
|
||||
def make_erm(tf: str, L_days: float, thr: float, long_flat: bool,
|
||||
target_vol: float = 0.20):
|
||||
"""Efficiency-Ratio regime momentum. L_days = lunghezza finestra in GIORNI (-> barre via bpd)."""
|
||||
def fn(df):
|
||||
c = df["close"].values.astype(float)
|
||||
n = len(c)
|
||||
L = max(2, round(L_days * al.bars_per_day(df)))
|
||||
net = np.full(n, np.nan)
|
||||
net[L:] = c[L:] - c[:-L]
|
||||
step = np.abs(np.diff(c, prepend=c[0])) # |c[k]-c[k-1]|, causale
|
||||
path = pd.Series(step).rolling(L, min_periods=L).sum().values
|
||||
er = np.divide(np.abs(net), path, out=np.zeros(n), where=(path > 0))
|
||||
active = (er >= thr) & np.isfinite(net)
|
||||
raw = np.where(active, np.sign(net), 0.0)
|
||||
if long_flat:
|
||||
raw = np.clip(raw, 0.0, None)
|
||||
return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0)
|
||||
return fn
|
||||
|
||||
|
||||
def make_vem(tf: str, Lmom_days: float, Lshort_days: float, Llong_days: float,
|
||||
long_flat: bool, target_vol: float = 0.20):
|
||||
"""Vol-expansion momentum: momentum attivo solo quando rv_corta > rv_lunga (espansione)."""
|
||||
def fn(df):
|
||||
c = df["close"].values.astype(float)
|
||||
n = len(c)
|
||||
bpd = al.bars_per_day(df)
|
||||
Lmom = max(2, round(Lmom_days * bpd))
|
||||
ws, wl = max(2, round(Lshort_days * bpd)), max(3, round(Llong_days * bpd))
|
||||
r = al.simple_returns(c)
|
||||
rv_s = al.rolling_std(r, ws)
|
||||
rv_l = al.rolling_std(r, wl)
|
||||
expand = (rv_s > rv_l) & np.isfinite(rv_s) & np.isfinite(rv_l)
|
||||
net = np.full(n, np.nan)
|
||||
net[Lmom:] = c[Lmom:] - c[:-Lmom]
|
||||
raw = np.where(expand & np.isfinite(net), np.sign(net), 0.0)
|
||||
if long_flat:
|
||||
raw = np.clip(raw, 0.0, None)
|
||||
return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0)
|
||||
return fn
|
||||
|
||||
|
||||
def make_vbr(tf: str, k: float, atr_win: int, long_flat: bool, target_vol: float = 0.20):
|
||||
"""Thrust-breakout rolling: segui i moti significativi (|Δ| > k*ATR), altrimenti hold."""
|
||||
def fn(df):
|
||||
c = df["close"].values.astype(float)
|
||||
a = al.atr(df, atr_win)
|
||||
a_prev = np.roll(a, 1); a_prev[0] = a[0] # ATR noto a inizio barra (causale)
|
||||
delta = np.diff(c, prepend=c[0])
|
||||
sig = np.where(np.abs(delta) > k * a_prev, np.sign(delta), np.nan)
|
||||
raw = pd.Series(sig).ffill().fillna(0.0).values
|
||||
if long_flat:
|
||||
raw = np.clip(raw, 0.0, None)
|
||||
return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0)
|
||||
return fn
|
||||
|
||||
|
||||
def make_tod(tf: str, long_flat: bool, target_vol: float = 0.20, min_obs: int = 20):
|
||||
"""Time-of-day seasonality (controllo calendario). Direzione = segno della media espandente
|
||||
causale del rendimento della stessa ora-del-giorno. Da passare a day_boundary_robust."""
|
||||
def fn(df):
|
||||
c = df["close"].values.astype(float)
|
||||
r = al.simple_returns(c)
|
||||
hour = pd.to_datetime(df["datetime"], utc=True).dt.hour.values
|
||||
n = len(c)
|
||||
sums = {}; cnts = {}
|
||||
raw = np.zeros(n)
|
||||
for i in range(1, n):
|
||||
h_prev = int(hour[i - 1]) # aggiorna con la barra GIA' chiusa
|
||||
sums[h_prev] = sums.get(h_prev, 0.0) + r[i - 1]
|
||||
cnts[h_prev] = cnts.get(h_prev, 0) + 1
|
||||
h = int(hour[i])
|
||||
if cnts.get(h, 0) >= min_obs:
|
||||
raw[i] = 1.0 if sums[h] >= 0 else -1.0
|
||||
if long_flat:
|
||||
raw = np.clip(raw, 0.0, None)
|
||||
return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0)
|
||||
return fn
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SCREENING — griglia leggera per (asset,tf,params) via eval_weights (vettoriale).
|
||||
# ===========================================================================
|
||||
def _screen_cell(fn, tf):
|
||||
"""Min-asset full/hold Sharpe, fee@0.10 e @0.20 RT, turnover, time-in-market."""
|
||||
fulls, holds, f10, f20, turn, tim = [], [], [], [], [], []
|
||||
for a in ASSETS:
|
||||
df = al.get(a, tf)
|
||||
tgt = fn(df)
|
||||
ev = al.eval_weights(df, tgt, fee_side=0.0005) # 0.10% RT
|
||||
ev0 = al.eval_weights(df, tgt, fee_side=0.001) # 0.20% RT
|
||||
fulls.append(ev["full"]["sharpe"]); holds.append(ev["holdout"].get("sharpe", 0.0))
|
||||
f10.append(ev["full"]["sharpe"]); f20.append(ev0["full"]["sharpe"])
|
||||
turn.append(ev["turnover_per_year"]); tim.append(ev["time_in_market"])
|
||||
return dict(tf=tf, min_full=round(min(fulls), 3), min_hold=round(min(holds), 3),
|
||||
min_f10=round(min(f10), 3), min_f20=round(min(f20), 3),
|
||||
turnover=round(float(np.mean(turn)), 1), tim=round(float(np.mean(tim)), 2))
|
||||
|
||||
|
||||
def screen_family(name, factory, grid, tfs=SCREEN_TFS):
|
||||
"""Esegue la griglia, ritorna lista di dict ordinata per min_hold (solo fee-surviving in cima)."""
|
||||
rows = []
|
||||
for tf in tfs:
|
||||
for params in grid:
|
||||
fn = factory(tf=tf, **params)
|
||||
m = _screen_cell(fn, tf)
|
||||
m["params"] = params
|
||||
m["fee_ok"] = bool(m["min_f20"] > 0)
|
||||
rows.append(m)
|
||||
rows.sort(key=lambda r: (r["fee_ok"], r["min_hold"]), reverse=True)
|
||||
print(f"\n===== {name}: top celle (di {len(rows)}) =====")
|
||||
print(f" {'tf':>4} {'minFull':>7} {'minHold':>7} {'f@.10':>6} {'f@.20':>6} "
|
||||
f"{'turn/y':>7} {'tim':>5} feeOK params")
|
||||
for r in rows[:10]:
|
||||
print(f" {r['tf']:>4} {r['min_full']:+7.2f} {r['min_hold']:+7.2f} {r['min_f10']:+6.2f} "
|
||||
f"{r['min_f20']:+6.2f} {r['turnover']:>7.0f} {r['tim']:>5.2f} "
|
||||
f"{str(r['fee_ok']):>5} {r['params']}")
|
||||
return rows
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DEEP-DIVE sul vincitore: marginal vs TP01 + day_boundary + corr SKH01 + haircut $600.
|
||||
# ===========================================================================
|
||||
@lru_cache(maxsize=1)
|
||||
def _skh_daily() -> pd.Series:
|
||||
"""Rendimenti giornalieri SKH01-V2-DD (50/50 BTC+ETH) dallo sleeve di progetto (read-only)."""
|
||||
from src.portfolio.sleeves import _skyhook_returns
|
||||
s = _skyhook_returns()
|
||||
if s.index.tz is None:
|
||||
s.index = s.index.tz_localize("UTC")
|
||||
return s
|
||||
|
||||
|
||||
def corr_to_skh(fn, tf) -> dict:
|
||||
cand = al.candidate_daily(fn, tf=tf)
|
||||
skh = _skh_daily()
|
||||
J = pd.concat({"C": cand, "S": skh}, axis=1, join="inner").dropna()
|
||||
JH = J[J.index >= al.HOLDOUT]
|
||||
return dict(n=int(len(J)),
|
||||
corr_full=round(float(J["C"].corr(J["S"])), 3) if len(J) > 5 else None,
|
||||
corr_hold=round(float(JH["C"].corr(JH["S"])), 3) if len(JH) > 5 else None)
|
||||
|
||||
|
||||
def haircut_600(fn, tf) -> dict:
|
||||
"""Sharpe onesto a $600: salta i ribilanci < $5 (eval_weights_smallcap), per asset + media."""
|
||||
out = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, tf)
|
||||
sc = al.eval_weights_smallcap(df, fn(df), capital=600.0, min_order=5.0)
|
||||
out[a] = dict(modeled=sc["modeled"]["sharpe"], real=sc["realistic"]["sharpe"],
|
||||
haircut=sc["sharpe_haircut"], n_tr=sc["n_executed_trades"])
|
||||
return out
|
||||
|
||||
|
||||
def plateau_erm(tf="8h"):
|
||||
"""Plateau fine L_days x thr al TF vincente (min-asset full/hold/f@.20). Un edge vero ha un
|
||||
PLATEAU, non una cella isolata."""
|
||||
print("\n" + "=" * 78)
|
||||
print(f"PLATEAU ERM @ {tf} (min-asset; L_days righe, thr colonne) — full / hold / f@.20")
|
||||
print("=" * 78)
|
||||
Ls = (1.5, 2.0, 2.5, 3.0); thrs = (0.30, 0.35, 0.40, 0.45, 0.50)
|
||||
print(" L\\thr " + "".join(f"{t:>16.2f}" for t in thrs))
|
||||
for L in Ls:
|
||||
cells = []
|
||||
for t in thrs:
|
||||
m = _screen_cell(make_erm(tf=tf, L_days=L, thr=t, long_flat=False), tf)
|
||||
cells.append(f"{m['min_full']:+.2f}/{m['min_hold']:+.2f}/{m['min_f20']:+.2f}")
|
||||
print(f" {L:>4.1f} " + "".join(f"{c:>16}" for c in cells))
|
||||
|
||||
|
||||
def vs_book(fn, tf):
|
||||
"""Il test decisivo del gate #5: ERM AGGIUNGE oltre il book esistente (TP01+SKH01), o e'
|
||||
SKH01 travestito? Sharpe/DD full & hold dei blend incrementali su griglia giornaliera."""
|
||||
cand = al.candidate_daily(fn, tf=tf)
|
||||
tp = al.tp01_baseline_daily()
|
||||
skh = _skh_daily()
|
||||
J = pd.concat({"T": tp, "S": skh, "C": cand}, axis=1, join="inner").dropna()
|
||||
JH = J[J.index >= al.HOLDOUT]
|
||||
blends = [
|
||||
("TP01", (1.0, 0.0, 0.0)),
|
||||
("TP01+SKH 75/25", (0.75, 0.25, 0.0)),
|
||||
("TP01+SKH+ERM 60/25/15", (0.60, 0.25, 0.15)),
|
||||
("TP01+SKH+ERM 55/20/25", (0.55, 0.20, 0.25)),
|
||||
]
|
||||
print("\n" + "=" * 78)
|
||||
print("vs BOOK ESISTENTE (TP01+SKH01) — ERM aggiunge oltre SKH? (gate #5)")
|
||||
print("=" * 78)
|
||||
print(f" {'blend':<26} {'FULL Sh':>8} {'FULL DD':>8} {'HOLD Sh':>8} {'HOLD DD':>8}")
|
||||
for label, (wt, ws, wc) in blends:
|
||||
bf = wt * J["T"] + ws * J["S"] + wc * J["C"]
|
||||
bh = wt * JH["T"] + ws * JH["S"] + wc * JH["C"]
|
||||
print(f" {label:<26} {al._sh(bf):>+8.2f} {al._dd_ret(bf) * 100:>7.1f}% "
|
||||
f"{al._sh(bh):>+8.2f} {al._dd_ret(bh) * 100:>7.1f}%")
|
||||
|
||||
|
||||
def deep_dive(name, fn, tf, calendar=False):
|
||||
print("\n" + "#" * 78)
|
||||
print(f"# DEEP-DIVE: {name} (tf={tf})")
|
||||
print("#" * 78)
|
||||
|
||||
caus = al.causality_ok(fn, tf=tf)
|
||||
print(f"\n[CAUSALITA'] ok={caus['ok']} max_tail_diff={caus['max_tail_diff']} "
|
||||
f"(checked={caus['checked']})")
|
||||
|
||||
print("\n[FEE-SWEEP a frequenza reale] (study_weights su entrambi gli asset)")
|
||||
sw = al.study_weights(name, fn, tfs=(tf,))
|
||||
print(al.fmt(sw))
|
||||
|
||||
print("\n[MARGINAL vs TP01]")
|
||||
sm = al.study_marginal(name, fn, tf=tf)
|
||||
print(al.fmt_marginal(sm))
|
||||
|
||||
sk = corr_to_skh(fn, tf)
|
||||
print(f"\n[CORR con SKH01] full={sk['corr_full']} hold={sk['corr_hold']} "
|
||||
f"(n_giorni={sk['n']})")
|
||||
|
||||
if calendar:
|
||||
print("\n[DAY-BOUNDARY ROBUST] (OBBLIGATORIO per effetti ora/sessione/giorno)")
|
||||
else:
|
||||
print("\n[DAY-BOUNDARY ROBUST] (sanity: un segnale di prezzo dev'essere ~INVARIANT)")
|
||||
db = al.day_boundary_robust(fn, tf=tf)
|
||||
print(f" verdict={db['verdict']} spread={db.get('spread')} "
|
||||
f"min={db.get('min')} max={db.get('max')} per_offset={db.get('per_offset')}")
|
||||
|
||||
print("\n[HAIRCUT $600] (eval_weights_smallcap: salta ribilanci < $5)")
|
||||
hc = haircut_600(fn, tf)
|
||||
for a, d in hc.items():
|
||||
print(f" {a}: modeled Sh {d['modeled']:+.2f} -> real Sh {d['real']:+.2f} "
|
||||
f"(haircut {d['haircut']:+.2f}, trade eseguiti {d['n_tr']})")
|
||||
|
||||
return dict(name=name, tf=tf, causal=caus["ok"], earns_slot=sm["earns_slot"],
|
||||
marginal=sm["marginal_verdict"], corr_skh=sk, day_boundary=db["verdict"],
|
||||
haircut=hc)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MAIN
|
||||
# ===========================================================================
|
||||
def main():
|
||||
print("=" * 78)
|
||||
print("FILONE B — INTRADAY REGIME BTC/ETH (intraday_regime.py)")
|
||||
print("=" * 78)
|
||||
tp01 = al.tp01_baseline_daily()
|
||||
print(f"Baseline TP01 (50/50) full Sharpe ~{al._sh(tp01):.2f} "
|
||||
f"hold ~{al._sh(tp01[tp01.index >= al.HOLDOUT]):.2f} (riferimento marginale)")
|
||||
|
||||
# ---- Griglie (compatte: plateau leggibile, no overfit di griglia gigante) ----
|
||||
erm_grid = [dict(L_days=L, thr=t, long_flat=lf)
|
||||
for L in (1.0, 2.0, 3.0) for t in (0.35, 0.50) for lf in (False, True)]
|
||||
vem_grid = [dict(Lmom_days=lm, Lshort_days=2.0, Llong_days=10.0, long_flat=lf)
|
||||
for lm in (1.0, 3.0) for lf in (False, True)]
|
||||
vbr_grid = [dict(k=k, atr_win=14, long_flat=lf)
|
||||
for k in (0.5, 1.0, 1.5) for lf in (False, True)]
|
||||
tod_grid = [dict(long_flat=lf) for lf in (False, True)]
|
||||
|
||||
fam = {
|
||||
"ERM": (make_erm, erm_grid, SCREEN_TFS),
|
||||
"VEM": (make_vem, vem_grid, ("4h", "6h", "8h", "12h")),
|
||||
"VBR": (make_vbr, vbr_grid, ("4h", "6h", "8h", "12h")),
|
||||
"TOD": (make_tod, tod_grid, ("1h",)),
|
||||
}
|
||||
screens = {}
|
||||
for name, (factory, grid, tfs) in fam.items():
|
||||
screens[name] = screen_family(name, factory, grid, tfs)
|
||||
|
||||
# ---- Vincitore per famiglia (best min_hold tra le fee-surviving con min_full>0) ----
|
||||
print("\n" + "=" * 78)
|
||||
print("VINCITORI PER FAMIGLIA (best min_hold tra fee-surviving, min_full>0)")
|
||||
print("=" * 78)
|
||||
winners = {}
|
||||
for name, (factory, grid, tfs) in fam.items():
|
||||
ok = [r for r in screens[name] if r["fee_ok"] and r["min_full"] > 0]
|
||||
pool = ok if ok else screens[name]
|
||||
w = max(pool, key=lambda r: r["min_hold"])
|
||||
winners[name] = w
|
||||
print(f" {name}: tf={w['tf']} {w['params']} minFull={w['min_full']:+.2f} "
|
||||
f"minHold={w['min_hold']:+.2f} f@.20={w['min_f20']:+.2f} feeOK={w['fee_ok']}")
|
||||
|
||||
# ---- Deep-dive sui due meccanismi piu' promettenti (per min_hold) + il controllo TOD ----
|
||||
ranked = sorted(["ERM", "VEM", "VBR"],
|
||||
key=lambda n: winners[n]["min_hold"], reverse=True)
|
||||
deep = []
|
||||
for name in ranked[:2]:
|
||||
w = winners[name]
|
||||
factory = fam[name][0]
|
||||
fn = factory(tf=w["tf"], **w["params"])
|
||||
deep.append(deep_dive(f"{name} {w['params']}", fn, w["tf"], calendar=False))
|
||||
|
||||
# controllo calendario: TOD passa SEMPRE per day_boundary_robust
|
||||
wt = winners["TOD"]
|
||||
fn_tod = make_tod(tf=wt["tf"], **wt["params"])
|
||||
deep.append(deep_dive(f"TOD {wt['params']}", fn_tod, wt["tf"], calendar=True))
|
||||
|
||||
# ---- Analisi extra sul vincitore ERM (plateau fine + vs book TP01+SKH01) ----
|
||||
we = winners["ERM"]
|
||||
fn_erm = make_erm(tf=we["tf"], **we["params"])
|
||||
plateau_erm(we["tf"])
|
||||
vs_book(fn_erm, we["tf"])
|
||||
|
||||
# ---- Verdetto sintetico ----
|
||||
print("\n" + "=" * 78)
|
||||
print("SINTESI")
|
||||
print("=" * 78)
|
||||
for d in deep:
|
||||
print(f" {d['name']:<26} tf={d['tf']:>3} | marginal={d['marginal']:<9} "
|
||||
f"earns_slot={d['earns_slot']!s:<5} corrSKH(full/hold)="
|
||||
f"{d['corr_skh']['corr_full']}/{d['corr_skh']['corr_hold']} "
|
||||
f"day_boundary={d['day_boundary']}")
|
||||
any_slot = any(d["earns_slot"] for d in deep)
|
||||
print(f"\n => earns_slot su qualche meccanismo? {any_slot}")
|
||||
print(" (vedi diario docs/diary/2026-06-29-intraday-regime.md per il verdetto ragionato)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,267 @@
|
||||
"""intraday_regime_analysis.py — ANALISI DI ROBUSTEZZA del LEAD ERM (filone B) — 2026-06-29.
|
||||
|
||||
Il lead di B (ERM 8h L=2.0 thr=0.35 L/S) fa earns_slot=True, ma con 2 caveat NON quantificati
|
||||
dallo script di scoperta `intraday_regime.py`:
|
||||
(1) il VINCITORE e' selezionato per min_hold MASSIMO su ~60 celle -> selezione-sull'hold-out;
|
||||
(2) il plateau hold-out e' a UNA SOLA RIGA (positivo solo a L~2.0; L>=2.5 va negativo sull'hold).
|
||||
Insieme = rischio multiple-testing / overfit della finestra recente, mai deflazionato (a
|
||||
differenza del filone C che ha il deflated-Sharpe).
|
||||
|
||||
Questo script attacca esattamente quei nodi, SENZA toccare il live (read-only, branch separato):
|
||||
|
||||
A) DEFLATED SHARPE (Bailey & Lopez de Prado) del vincitore vs TUTTI i trial realmente
|
||||
cercati (ERM+VEM+VBR+TOD, tutte le celle/TF). Se DSR << 0.95 lo Sharpe non e' significativo
|
||||
dopo la correzione per multiple-testing.
|
||||
|
||||
B) SELEZIONE IN-SAMPLE-ONLY: ri-scelgo la cella ERM usando SOLO lo Sharpe PRE-2025 (mai
|
||||
l'hold-out), poi ne valuto earns_slot sull'intera storia. Se una cella scelta SENZA vedere
|
||||
l'hold-out continua ad ADDS, l'edge non e' hold-out-mined.
|
||||
|
||||
C) ENSEMBLE DEL PLATEAU: invece della singola cella migliore, media i pesi su tutto il
|
||||
plateau ERM 8h (L x thr) -> un candidato unico "non-cherry-picked" -> earns_slot. Se la
|
||||
famiglia regge senza scegliere L, il caveat (1)+(2) si attenua.
|
||||
|
||||
D) DOVE VIVE L'EDGE: Sharpe per-anno standalone + uplift per-anno del blend 3-way
|
||||
(TP01+SKH+ERM) vs 2-way (TP01+SKH), e corr(ERM,SKH) per-anno (e' un hedge-di-SKH?).
|
||||
|
||||
Esecuzione: uv run python scripts/research/intraday_regime_analysis.py
|
||||
Idempotente, niente scritture su disco (solo report a stdout).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
from intraday_regime import make_erm, make_vem, make_vbr, make_tod, _skh_daily # noqa: E402
|
||||
|
||||
DPY = 365.25
|
||||
HOLD = al.HOLDOUT
|
||||
ASSETS = ("BTC", "ETH")
|
||||
SCREEN_TFS = ("1h", "4h", "6h", "8h", "12h")
|
||||
|
||||
WIN = dict(tf="8h", L_days=2.0, thr=0.35, long_flat=False) # il lead di B
|
||||
|
||||
deflated_sharpe = al.deflated_sharpe # gate canonico (codificato in altlib da questo filone)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def all_trials():
|
||||
"""Ricostruisce la griglia COMPLETA realmente cercata in intraday_regime.py, ritorna una
|
||||
lista di (tag, factory, tf, params) per pesare il multiple-testing onestamente."""
|
||||
out = []
|
||||
erm_grid = [dict(L_days=L, thr=t, long_flat=lf)
|
||||
for L in (1.0, 2.0, 3.0) for t in (0.35, 0.50) for lf in (False, True)]
|
||||
for tf in SCREEN_TFS:
|
||||
for p in erm_grid:
|
||||
out.append(("ERM", make_erm, tf, p))
|
||||
# + il plateau fine a 8h (L x thr, lf=False) — anche quelle sono celle testate
|
||||
for L in (1.5, 2.0, 2.5, 3.0):
|
||||
for t in (0.30, 0.35, 0.40, 0.45, 0.50):
|
||||
out.append(("ERM-plat", make_erm, "8h", dict(L_days=L, thr=t, long_flat=False)))
|
||||
vem_grid = [dict(Lmom_days=lm, Lshort_days=2.0, Llong_days=10.0, long_flat=lf)
|
||||
for lm in (1.0, 3.0) for lf in (False, True)]
|
||||
for tf in ("4h", "6h", "8h", "12h"):
|
||||
for p in vem_grid:
|
||||
out.append(("VEM", make_vem, tf, p))
|
||||
vbr_grid = [dict(k=k, atr_win=14, long_flat=lf) for k in (0.5, 1.0, 1.5) for lf in (False, True)]
|
||||
for tf in ("4h", "6h", "8h", "12h"):
|
||||
for p in vbr_grid:
|
||||
out.append(("VBR", make_vbr, tf, p))
|
||||
for lf in (False, True):
|
||||
out.append(("TOD", make_tod, "1h", dict(long_flat=lf)))
|
||||
return out
|
||||
|
||||
|
||||
def cand_full_is_sharpe(factory, tf, params):
|
||||
"""(daily, full Sharpe, in-sample<2025 Sharpe) del candidato 50/50 di quella cella."""
|
||||
fn = factory(tf=tf, **params)
|
||||
daily = al.candidate_daily(fn, tf=tf)
|
||||
full = al._sh(daily)
|
||||
ins = al._sh(daily[daily.index < HOLD]) if (daily.index < HOLD).sum() > 60 else float("nan")
|
||||
return daily, full, ins
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def part_A_deflated():
|
||||
print("=" * 78)
|
||||
print("A) DEFLATED SHARPE del vincitore vs TUTTI i trial cercati (multiple-testing)")
|
||||
print("=" * 78)
|
||||
trials = all_trials()
|
||||
sr_all, sr_no_tod, sr_erm = [], [], []
|
||||
win_daily = win_full = None
|
||||
for tag, factory, tf, params in trials:
|
||||
try:
|
||||
daily, full, _ = cand_full_is_sharpe(factory, tf, params)
|
||||
except Exception:
|
||||
continue
|
||||
sr_all.append(full)
|
||||
if tag != "TOD":
|
||||
sr_no_tod.append(full)
|
||||
if tag.startswith("ERM"):
|
||||
sr_erm.append(full)
|
||||
if tag == "ERM" and tf == WIN["tf"] and params.get("L_days") == WIN["L_days"] \
|
||||
and params.get("thr") == WIN["thr"] and params.get("long_flat") is False:
|
||||
win_daily, win_full = daily, full
|
||||
sr_arr = np.array([s for s in sr_all if np.isfinite(s)])
|
||||
print(f" N trial finiti : {len(sr_arr)}")
|
||||
print(f" Sharpe winner (50/50) : {win_full:+.3f}")
|
||||
print(f" Sharpe trial: mean {sr_arr.mean():+.2f} std {sr_arr.std():.2f} "
|
||||
f"max {sr_arr.max():+.2f} >0: {int((sr_arr > 0).sum())}/{len(sr_arr)}")
|
||||
dsr = None
|
||||
for label, pool in (("TUTTI 122", sr_all), ("no-TOD", sr_no_tod), ("solo-ERM", sr_erm)):
|
||||
d, sr0 = deflated_sharpe(win_full, pool, win_daily)
|
||||
n = int(np.isfinite(np.array(pool)).sum())
|
||||
print(f" DSR [{label:>9} N={n:>3}]: {d:.3f} (Sh-max null {sr0:+.2f}) -> "
|
||||
f"{'PASS' if d >= 0.95 else 'FAIL'}")
|
||||
if label == "TUTTI 122":
|
||||
dsr = d
|
||||
return dsr, win_full, None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def part_B_insample_pick():
|
||||
print("\n" + "=" * 78)
|
||||
print("B) SELEZIONE IN-SAMPLE-ONLY (scelgo la cella ERM solo con Sharpe PRE-2025)")
|
||||
print("=" * 78)
|
||||
erm_grid = [dict(L_days=L, thr=t, long_flat=lf)
|
||||
for L in (1.0, 1.5, 2.0, 2.5, 3.0) for t in (0.30, 0.35, 0.40, 0.50)
|
||||
for lf in (False, True)]
|
||||
rows = []
|
||||
for tf in SCREEN_TFS:
|
||||
for p in erm_grid:
|
||||
try:
|
||||
_, full, ins = cand_full_is_sharpe(make_erm, tf, p)
|
||||
except Exception:
|
||||
continue
|
||||
rows.append((ins, full, tf, p))
|
||||
rows = [r for r in rows if np.isfinite(r[0])]
|
||||
rows.sort(key=lambda r: r[0], reverse=True) # ordina per Sharpe IN-SAMPLE (no hold-out)
|
||||
print(f" Top 5 celle per Sharpe IN-SAMPLE (<2025):")
|
||||
for ins, full, tf, p in rows[:5]:
|
||||
print(f" IS {ins:+.2f} FULL {full:+.2f} tf={tf:>3} {p}")
|
||||
ins, full, tf, p = rows[0]
|
||||
print(f"\n -> cella scelta SENZA vedere l'hold-out: tf={tf} {p}")
|
||||
sm = al.study_marginal(f"ERM-ISpick {p}", make_erm(tf=tf, **p), tf=tf)
|
||||
m = sm["marginal"]
|
||||
print(f" earns_slot={sm['earns_slot']} marginal={m['marginal_verdict']} "
|
||||
f"abs={sm['absolute']['verdict']['grade']}")
|
||||
print(f" corr->TP01 {m['corr_full']} has_insample_edge={m['has_insample_edge']} "
|
||||
f"is_hedge={m['is_hedge']} robust_oos={m['robust_oos']}")
|
||||
print(f" blend w25: full uplift {m['blends']['w25']['uplift_full']:+.3f} "
|
||||
f"hold uplift {m['blends']['w25']['uplift_hold']:+.3f}")
|
||||
same = (tf == WIN["tf"] and abs(p["L_days"] - WIN["L_days"]) < 1e-9
|
||||
and abs(p["thr"] - WIN["thr"]) < 1e-9 and p["long_flat"] is False)
|
||||
print(f" coincide col vincitore max-hold? {same}")
|
||||
return sm["earns_slot"], (tf, p)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def ensemble_target(tf, cells):
|
||||
"""Media (equal-weight) dei pesi vol-targeted su piu' celle ERM -> un unico stream per asset.
|
||||
Ritorna un target_fn(df) che ricostruisce l'ensemble per quel df."""
|
||||
def fn(df):
|
||||
ws = [make_erm(tf=tf, **c)(df) for c in cells]
|
||||
return np.nanmean(np.vstack(ws), axis=0)
|
||||
return fn
|
||||
|
||||
|
||||
def part_C_plateau_ensemble():
|
||||
print("\n" + "=" * 78)
|
||||
print("C) ENSEMBLE DEL PLATEAU ERM 8h (media celle L x thr, NIENTE cherry-pick)")
|
||||
print("=" * 78)
|
||||
cells = [dict(L_days=L, thr=t, long_flat=False)
|
||||
for L in (1.5, 2.0, 2.5, 3.0) for t in (0.30, 0.35, 0.40, 0.45, 0.50)]
|
||||
fn = ensemble_target("8h", cells)
|
||||
print(f" celle nell'ensemble: {len(cells)} (L 1.5-3.0 x thr 0.30-0.50, lf=False)")
|
||||
caus = al.causality_ok(fn, tf="8h")
|
||||
print(f" causale: ok={caus['ok']} max_tail_diff={caus['max_tail_diff']}")
|
||||
sm = al.study_marginal("ERM-plateau-ens", fn, tf="8h")
|
||||
m = sm["marginal"]
|
||||
print(f" earns_slot={sm['earns_slot']} marginal={m['marginal_verdict']} "
|
||||
f"abs={sm['absolute']['verdict']['grade']}")
|
||||
print(f" standalone full {m['cand_full_sharpe']} hold {m['cand_hold_sharpe']} "
|
||||
f"in-sample {m.get('cand_insample_sharpe')}")
|
||||
print(f" corr->TP01 {m['corr_full']} has_insample_edge={m['has_insample_edge']} "
|
||||
f"is_hedge={m['is_hedge']} robust_oos={m['robust_oos']}")
|
||||
print(f" blend w25: full {m['blends']['w25']['full']} (uplift "
|
||||
f"{m['blends']['w25']['uplift_full']:+.3f}) hold {m['blends']['w25']['hold']} "
|
||||
f"(uplift {m['blends']['w25']['uplift_hold']:+.3f})")
|
||||
print(f" multicut: {m['multicut_uplift']}")
|
||||
return sm["earns_slot"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def part_D_where_edge():
|
||||
print("\n" + "=" * 78)
|
||||
print("D) DOVE VIVE L'EDGE — per-anno standalone + uplift 3-way vs 2-way + corr(ERM,SKH)")
|
||||
print("=" * 78)
|
||||
fn = make_erm(**WIN)
|
||||
cand = al.candidate_daily(fn, tf=WIN["tf"])
|
||||
tp = al.tp01_baseline_daily()
|
||||
skh = _skh_daily()
|
||||
J = pd.concat({"T": tp, "S": skh, "C": cand}, axis=1, join="inner").dropna()
|
||||
two = 0.75 * J["T"] + 0.25 * J["S"]
|
||||
three = 0.60 * J["T"] + 0.25 * J["S"] + 0.15 * J["C"]
|
||||
print(f" {'anno':>5} {'ERM Sh':>7} {'TP+SKH':>7} {'+ERM':>7} {'Δuplift':>8} "
|
||||
f"{'corr(ERM,SKH)':>14} {'corr(ERM,TP)':>13}")
|
||||
for y in sorted(set(J.index.year)):
|
||||
sub = J[J.index.year == y]
|
||||
if len(sub) < 40:
|
||||
continue
|
||||
s2 = 0.75 * sub["T"] + 0.25 * sub["S"]
|
||||
s3 = 0.60 * sub["T"] + 0.25 * sub["S"] + 0.15 * sub["C"]
|
||||
print(f" {y:>5} {al._sh(sub['C']):>+7.2f} {al._sh(s2):>+7.2f} {al._sh(s3):>+7.2f} "
|
||||
f"{al._sh(s3) - al._sh(s2):>+8.2f} {sub['C'].corr(sub['S']):>+14.2f} "
|
||||
f"{sub['C'].corr(sub['T']):>+13.2f}")
|
||||
print(f" {'FULL':>5} {al._sh(J['C']):>+7.2f} {al._sh(two):>+7.2f} {al._sh(three):>+7.2f} "
|
||||
f"{al._sh(three) - al._sh(two):>+8.2f} {J['C'].corr(J['S']):>+14.2f} "
|
||||
f"{J['C'].corr(J['T']):>+13.2f}")
|
||||
JH = J[J.index >= HOLD]
|
||||
h2 = 0.75 * JH["T"] + 0.25 * JH["S"]
|
||||
h3 = 0.60 * JH["T"] + 0.25 * JH["S"] + 0.15 * JH["C"]
|
||||
print(f" {'HOLD':>5} {al._sh(JH['C']):>+7.2f} {al._sh(h2):>+7.2f} {al._sh(h3):>+7.2f} "
|
||||
f"{al._sh(h3) - al._sh(h2):>+8.2f} {JH['C'].corr(JH['S']):>+14.2f} "
|
||||
f"{JH['C'].corr(JH['T']):>+13.2f}")
|
||||
|
||||
|
||||
def part_E_codified_gate():
|
||||
"""Validazione END-TO-END del gate appena codificato in altlib: study_family_honest sulla
|
||||
famiglia ERM deve dare earns_slot_honest=False (sceglie in-sample-only + deflaziona)."""
|
||||
print("\n" + "=" * 78)
|
||||
print("E) GATE CODIFICATO (al.study_family_honest) sulla famiglia ERM — deve bocciare")
|
||||
print("=" * 78)
|
||||
grid = [dict(L_days=L, thr=t, long_flat=lf)
|
||||
for L in (1.0, 1.5, 2.0, 2.5, 3.0) for t in (0.30, 0.35, 0.40, 0.50)
|
||||
for lf in (False, True)]
|
||||
rep = al.study_family_honest("ERM", make_erm, grid, SCREEN_TFS)
|
||||
ch = rep["chosen"]
|
||||
print(f" n_celle={rep['n_cells']} cella in-sample-best: tf={ch['tf']} {ch['params']}")
|
||||
print(f" earns_slot_marginal={rep['earns_slot_marginal']} "
|
||||
f"deflated_sharpe={rep['deflated_sharpe']} (dsr_pass={rep['dsr_pass']})")
|
||||
print(f" => earns_slot_HONEST = {rep['earns_slot_honest']} "
|
||||
f"(atteso False: slot bocciato dal gate)")
|
||||
return rep["earns_slot_honest"]
|
||||
|
||||
|
||||
def main():
|
||||
print("ANALISI ROBUSTEZZA LEAD ERM (filone B) — read-only, nessun impatto live\n")
|
||||
dsr, win_full, sr0 = part_A_deflated()
|
||||
es_is, is_cell = part_B_insample_pick()
|
||||
es_ens = part_C_plateau_ensemble()
|
||||
part_D_where_edge()
|
||||
es_honest = part_E_codified_gate()
|
||||
print("\n" + "=" * 78)
|
||||
print("SINTESI ANALISI B")
|
||||
print("=" * 78)
|
||||
print(f" A) deflated-Sharpe winner = {dsr:.3f} ({'PASS' if dsr >= 0.95 else 'FAIL'} vs 0.95)")
|
||||
print(f" B) cella scelta in-sample-only earns_slot = {es_is} (cella {is_cell})")
|
||||
print(f" C) ensemble del plateau earns_slot = {es_ens}")
|
||||
print(f" E) gate codificato earns_slot_honest = {es_honest}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""LOGGER FORWARD della vol term-structure Deribit (l'UNICA via legittima per avere il dato).
|
||||
|
||||
Lo scan (probe_vol_termstructure.py) ha stabilito: la storia per-scadenza NON e' pubblica su Deribit
|
||||
(DVOL solo 30g; trade-history IV solo per strumenti vivi; il front rotola). Quindi un calendar-vol
|
||||
NON e' backtestabile oggi. Questo logger COSTRUISCE il dataset in avanti: ogni run prende lo snapshot
|
||||
ATM mark_iv per scadenza, lo interpola a TENOR FISSI (7/30/60/90/180g) e appende una riga per asset a
|
||||
data/raw/vol_term_<asset>.parquet. Idempotente per giorno (riscrive la riga del giorno).
|
||||
|
||||
Da cron giornaliero (NON auto-cablato: e' ricerca forward, va aggiunto a mano se si vuole):
|
||||
uv run python scripts/research/log_vol_termstructure.py
|
||||
|
||||
Dopo ~6-12 mesi di accumulo -> certificare (cross-venue, monotonia, spike) e SOLO ALLORA testare un
|
||||
calendar-vol (front vs back) su dati certificati. Nessun edge creduto prima.
|
||||
"""
|
||||
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 datetime as _dt
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
BASE = "https://www.deribit.com/api/v2/public/"
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
TENORS = [7, 30, 60, 90, 180] # giorni-a-scadenza fissi su cui interpolare l'ATM IV
|
||||
|
||||
|
||||
def _get(method, **params):
|
||||
return requests.get(BASE + method, params=params, timeout=40).json().get("result", None)
|
||||
|
||||
|
||||
def _atm_curve(cur, now_ms):
|
||||
"""[(dte_giorni, atm_iv%)] ordinato per scadenza, dallo strike piu' vicino allo spot."""
|
||||
summ = _get("get_book_summary_by_currency", currency=cur, kind="option")
|
||||
idx = _get("get_index_price", index_name=f"{cur.lower()}_usd")
|
||||
if not summ or not idx:
|
||||
return None, None
|
||||
spot = float(idx["index_price"])
|
||||
best = {} # exp_ms -> (dist_strike, iv)
|
||||
for o in summ:
|
||||
p = o["instrument_name"].split("-")
|
||||
if len(p) != 4 or o.get("mark_iv") is None:
|
||||
continue
|
||||
try:
|
||||
exp_ms = int(_dt.datetime.strptime(p[1], "%d%b%y").replace(
|
||||
tzinfo=_dt.timezone.utc).timestamp() * 1000)
|
||||
except ValueError:
|
||||
continue
|
||||
d = abs(float(p[2]) - spot)
|
||||
if exp_ms not in best or d < best[exp_ms][0]:
|
||||
best[exp_ms] = (d, float(o["mark_iv"]))
|
||||
curve = sorted(((e - now_ms) / 86400_000.0, iv) for e, (_, iv) in best.items() if e > now_ms)
|
||||
return spot, curve
|
||||
|
||||
|
||||
def build_row(spot, curve, now_ms):
|
||||
"""Pura: da (spot, curve=[(dte,iv)], now_ms) -> riga con ATM IV interpolata ai TENOR fissi."""
|
||||
if not curve or len(curve) < 2:
|
||||
return None
|
||||
dtes = np.array([c[0] for c in curve]); ivs = np.array([c[1] for c in curve])
|
||||
row = {"date": pd.Timestamp(now_ms, unit="ms", tz="UTC").normalize(), "spot": spot}
|
||||
for t in TENORS:
|
||||
row[f"iv_{t}d"] = float(np.interp(t, dtes, ivs)) # interp lineare sui DTE disponibili
|
||||
row["slope_7_180"] = row["iv_180d"] - row["iv_7d"]
|
||||
return row
|
||||
|
||||
|
||||
def snapshot(cur, now_ms):
|
||||
spot, curve = _atm_curve(cur, now_ms)
|
||||
return build_row(spot, curve, now_ms) if curve else None
|
||||
|
||||
|
||||
def append_row(cur, row):
|
||||
fp = RAW / f"vol_term_{cur.lower()}.parquet"
|
||||
df = pd.read_parquet(fp) if fp.exists() else pd.DataFrame()
|
||||
df = df[df["date"] != row["date"]] if len(df) else df # idempotente per giorno
|
||||
df = pd.concat([df, pd.DataFrame([row])], ignore_index=True).sort_values("date")
|
||||
df.to_parquet(fp, index=False)
|
||||
return fp, len(df)
|
||||
|
||||
|
||||
def main():
|
||||
now_ms = int(pd.Timestamp.now("UTC").timestamp() * 1000)
|
||||
print("=" * 78)
|
||||
print(" LOG vol term-structure (forward dataset builder)")
|
||||
print("=" * 78)
|
||||
for cur in ("BTC", "ETH"):
|
||||
row = snapshot(cur, now_ms)
|
||||
if row is None:
|
||||
print(f" {cur}: snapshot fallito (chain vuota?)"); continue
|
||||
fp, n = append_row(cur, row)
|
||||
ivs = " ".join(f"{t}g {row[f'iv_{t}d']:.1f}%" for t in TENORS)
|
||||
print(f" {cur} {row['date'].date()} spot ${row['spot']:,.0f} | {ivs} | "
|
||||
f"slope7-180 {row['slope_7_180']:+.1f}pp -> {n} righe in {fp.name}")
|
||||
print("\n (forward-only: serve accumulo di mesi prima di poter certificare e testare un calendar-vol)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,458 @@
|
||||
"""macro_regime_gate.py — Filone D: MACRO REGIME-GATE sul book crypto (eseguibile).
|
||||
|
||||
TESI
|
||||
----
|
||||
Usare segnali macro/cross-market — equity (SPY/QQQ/IWM), credito (HYG/LQD), oro (GLD/SLV),
|
||||
tassi (TLT/IEF) — come GATE risk-on/risk-off applicato al book crypto (BTC/ETH) per migliorare
|
||||
il TIMING del drawdown di TP01. Quando il regime macro e' risk-off (credito che cede, equity
|
||||
sotto trend, fuga sui bond) -> riduci/azzera l'esposizione crypto; risk-on -> lascia agire TP01.
|
||||
E' ESEGUIBILE perche' GATA solo BTC/ETH perp (non aggiunge gambe).
|
||||
|
||||
NON e' un lead-lag direzionale (gia' esplorato e morto: vedi 2026-06-22/-23 leadlag diaries).
|
||||
L'angolo nuovo = un OVERLAY binario/continuo di DE-RISK sul book.
|
||||
|
||||
IL RISCHIO (da CLAUDE.md): il gate di de-risk rischia di essere RIDONDANTE col trend — TP01 e'
|
||||
gia' long-flat e va a 0 nei crash (lezione DVOL-spike "ridondante col trend, Delta 0.00"). Questo
|
||||
script DEVE dimostrare che il gate aggiunge OLTRE quel che TP01 fa da solo, altrimenti SCARTATO.
|
||||
|
||||
CAUSALITA' (fusi orari, regola di prim'ordine)
|
||||
----------------------------------------------
|
||||
- Barre equity: open-labeled a 00:00 del giorno di trading; il CLOSE e' ~20:00-21:00 UTC dello
|
||||
STESSO giorno (NYSE 16:00 ET).
|
||||
- Barre crypto 1d: open-labeled a 00:00; il CLOSE e' a 00:00 UTC del giorno DOPO. TP01 decide la
|
||||
posizione a close[i] e la TIENE durante la barra i+1 (eval_weights shift-a per te).
|
||||
- Quindi: gate[i] allineato (merge_asof backward, equity-label <= crypto-label day i) usa il
|
||||
close equity del giorno i (noto ~20:00 day i) per la posizione tenuta durante la barra i+1
|
||||
(giorno i+1). Margine causale >= 4h. Leak-free. Variante STRICT (equity-label < crypto-label)
|
||||
come margine extra. Verifica con al.causality_ok + day_boundary_robust.
|
||||
|
||||
USO: uv run python scripts/research/macro_regime_gate.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))
|
||||
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
|
||||
|
||||
DATA = _ROOT / "data" / "raw"
|
||||
HOLDOUT = al.HOLDOUT
|
||||
ASSETS = ("BTC", "ETH")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MACRO FRAME — ETF daily, allineati causalmente sul calendario SPY (master).
|
||||
# ===========================================================================
|
||||
def _load_eq(sym: str) -> pd.DataFrame:
|
||||
p = DATA / f"eq_{sym}_1d.parquet"
|
||||
df = pd.read_parquet(p).sort_values("timestamp").reset_index(drop=True)
|
||||
df["dt"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
return df[["timestamp", "dt", "close"]].rename(columns={"close": sym})
|
||||
|
||||
|
||||
def macro_frame(syms=("spy", "qqq", "iwm", "hyg", "lqd", "gld", "slv", "tlt", "ief")) -> pd.DataFrame:
|
||||
"""Frame macro sul calendario NYSE (master = SPY). Ogni colonna e' il close equity,
|
||||
allineato causale (merge_asof backward) -> nessun valore futuro per riga."""
|
||||
base = _load_eq("spy")[["timestamp", "dt"]].copy()
|
||||
out = base
|
||||
for s in syms:
|
||||
e = _load_eq(s)[["timestamp", s]]
|
||||
out = pd.merge_asof(out, e, on="timestamp", direction="backward")
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# GATE BUILDERS — ognuno ritorna (timestamp_ms, gate in [0,1]) sul calendario equity.
|
||||
# Tutti CAUSALI: la riga i usa solo close <= riga i (rolling/SMA, niente expanding-future).
|
||||
# gate=1 => risk-on (TP01 pieno); gate=g_off (0 o 0.5) => risk-off (de-risk).
|
||||
# ===========================================================================
|
||||
def _sma(x: np.ndarray, n: int) -> np.ndarray:
|
||||
return pd.Series(x).rolling(n, min_periods=n).mean().values
|
||||
|
||||
|
||||
def _ret(x: np.ndarray, n: int) -> np.ndarray:
|
||||
r = np.full(len(x), np.nan)
|
||||
r[n:] = x[n:] / x[:-n] - 1.0
|
||||
return r
|
||||
|
||||
|
||||
def gate_trend(mf: pd.DataFrame, col: str, n: int, g_off: float) -> pd.DataFrame:
|
||||
"""Risk-on se col_close > SMA(col, n). Filtro di trend classico (SPY200, HYG, ...)."""
|
||||
c = mf[col].values.astype(float)
|
||||
on = c > _sma(c, n)
|
||||
g = np.where(on, 1.0, g_off)
|
||||
g[~np.isfinite(c)] = np.nan
|
||||
g[np.isnan(_sma(c, n))] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
def gate_ratio(mf: pd.DataFrame, num: str, den: str, n: int, g_off: float) -> pd.DataFrame:
|
||||
"""Risk-on se ratio num/den (proxy spread di credito) > la sua SMA(n).
|
||||
HYG/LQD o HYG/IEF in calo = spread che si allarga = risk-off."""
|
||||
ratio = (mf[num].values.astype(float) / mf[den].values.astype(float))
|
||||
on = ratio > _sma(ratio, n)
|
||||
g = np.where(on, 1.0, g_off)
|
||||
g[~np.isfinite(ratio)] = np.nan
|
||||
g[np.isnan(_sma(ratio, n))] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
def gate_combo(mf: pd.DataFrame, n: int, g_off: float, thr: float = 0.5,
|
||||
continuous: bool = False) -> pd.DataFrame:
|
||||
"""Score di regime = media di 3 voti risk-on: SPY>SMA, HYG>SMA, HYG/LQD>SMA.
|
||||
continuous=True -> gate = g_off + (1-g_off)*score (de-risk graduale).
|
||||
continuous=False -> gate = 1 se score>=thr else g_off (binario su maggioranza)."""
|
||||
spy = mf["spy"].values.astype(float)
|
||||
hyg = mf["hyg"].values.astype(float)
|
||||
ratio = hyg / mf["lqd"].values.astype(float)
|
||||
votes = np.vstack([spy > _sma(spy, n), hyg > _sma(hyg, n), ratio > _sma(ratio, n)]).astype(float)
|
||||
warm = np.isnan(_sma(spy, n)) | np.isnan(_sma(hyg, n)) | np.isnan(_sma(ratio, n))
|
||||
score = votes.mean(axis=0)
|
||||
if continuous:
|
||||
g = g_off + (1.0 - g_off) * score
|
||||
else:
|
||||
g = np.where(score >= thr, 1.0, g_off)
|
||||
g[warm] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
def gate_flight(mf: pd.DataFrame, safe: str, risk: str, n: int, g_off: float) -> pd.DataFrame:
|
||||
"""Fuga verso la sicurezza: risk-off quando il safe-asset (TLT/GLD) sale MENTRE il risk
|
||||
(SPY) scende sull'orizzonte n. Divergenza risk-off classica (flight-to-quality)."""
|
||||
s = mf[safe].values.astype(float)
|
||||
rk = mf[risk].values.astype(float)
|
||||
off = (_ret(s, n) > 0) & (_ret(rk, n) < 0)
|
||||
g = np.where(off, g_off, 1.0)
|
||||
warm = np.isnan(_ret(s, n)) | np.isnan(_ret(rk, n))
|
||||
g[warm] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
def gate_eqvol(mf: pd.DataFrame, n: int, win: int, z: float, g_off: float) -> pd.DataFrame:
|
||||
"""Regime di vol equity: de-risk quando la vol realizzata SPY (win g) e' alta vs la sua
|
||||
storia espandente-causale (z-score > z). Proxy 'VIX spike' senza VIX."""
|
||||
spy = mf["spy"].values.astype(float)
|
||||
r = np.zeros(len(spy)); r[1:] = spy[1:] / spy[:-1] - 1.0
|
||||
rv = pd.Series(r).rolling(win, min_periods=win).std().values
|
||||
zsc = al.zscore(rv, n)
|
||||
off = zsc > z
|
||||
g = np.where(off, g_off, 1.0)
|
||||
g[~np.isfinite(zsc)] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ALIGN GATE -> CRYPTO (causale) + GATED TARGET
|
||||
# ===========================================================================
|
||||
def align_gate(gate_df: pd.DataFrame, crypto_df: pd.DataFrame, strict: bool = False) -> np.ndarray:
|
||||
"""Allinea il gate (calendario equity) alle barre crypto. merge_asof backward:
|
||||
crypto-label day i -> ultimo gate equity con label <= day i (strict: < day i).
|
||||
NaN pre-storia -> gate=1 (nessun de-risk quando non c'e' info)."""
|
||||
left = pd.DataFrame({"timestamp": crypto_df["timestamp"].astype("int64").values})
|
||||
g = gate_df.dropna(subset=["gate"]).sort_values("timestamp")
|
||||
m = pd.merge_asof(left, g, on="timestamp", direction="backward",
|
||||
allow_exact_matches=not strict)
|
||||
return pd.Series(m["gate"].values).ffill().fillna(1.0).values
|
||||
|
||||
|
||||
def tp01_pos(df: pd.DataFrame) -> np.ndarray:
|
||||
return TrendPortfolio(**CANONICAL).target_series(df)
|
||||
|
||||
|
||||
def make_target_fn(gate_builder, strict: bool = False):
|
||||
"""Ritorna target_fn(df, asset) = posizione TP01 * gate macro allineato (causale).
|
||||
gate_builder() costruisce il gate sul calendario equity una volta (cache esterna)."""
|
||||
_MF = macro_frame()
|
||||
gate_df = gate_builder(_MF)
|
||||
|
||||
def target_fn(df: pd.DataFrame, asset: str = "") -> np.ndarray:
|
||||
pos = tp01_pos(df)
|
||||
g = align_gate(gate_df, df, strict=strict)
|
||||
return pos * g
|
||||
return target_fn, gate_df
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# EVALUATION — solo vs gated, combo 50/50 + per-asset, FULL/HOLD/DD/CAGR.
|
||||
# ===========================================================================
|
||||
def _combo_daily(target_fn) -> pd.Series:
|
||||
series = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
ev = al.eval_weights(df, target_fn(df, a))
|
||||
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[ASSETS[0]] + 0.5 * J[ASSETS[1]])
|
||||
|
||||
|
||||
def _sh(s):
|
||||
return al._sh(s)
|
||||
|
||||
|
||||
def _metrics(s: pd.Series) -> dict:
|
||||
sh = _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
|
||||
yrs = max((s.index[-1] - s.index[0]).days / 365.25, 1e-6)
|
||||
cagr = eq[-1] ** (1 / yrs) - 1 if len(eq) and eq[-1] > 0 else -1.0
|
||||
return dict(sharpe=round(sh, 3), dd=round(dd, 4), cagr=round(cagr, 4))
|
||||
|
||||
|
||||
def eval_solo_vs_gated(target_fn):
|
||||
"""Combo 50/50: solo (gate=1) vs gated. Ritorna dict con FULL e HOLD per entrambi."""
|
||||
solo_fn = lambda df, a="": tp01_pos(df)
|
||||
solo = _combo_daily(solo_fn)
|
||||
gated = _combo_daily(target_fn)
|
||||
J = pd.concat({"solo": solo, "gated": gated}, axis=1, join="inner").dropna()
|
||||
JH = J[J.index >= HOLDOUT]
|
||||
return dict(
|
||||
full_solo=_metrics(J["solo"]), full_gated=_metrics(J["gated"]),
|
||||
hold_solo=_metrics(JH["solo"]), hold_gated=_metrics(JH["gated"]),
|
||||
n=len(J), nh=len(JH), solo_series=J["solo"], gated_series=J["gated"])
|
||||
|
||||
|
||||
def per_asset_table(target_fn) -> dict:
|
||||
out = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
solo = al.eval_weights(df, tp01_pos(df))
|
||||
gated = al.eval_weights(df, target_fn(df, a))
|
||||
out[a] = dict(
|
||||
full_solo=dict(sharpe=solo["full"]["sharpe"], dd=solo["full"]["maxdd"], cagr=solo["full"]["cagr"]),
|
||||
full_gated=dict(sharpe=gated["full"]["sharpe"], dd=gated["full"]["maxdd"], cagr=gated["full"]["cagr"]),
|
||||
hold_solo=dict(sharpe=solo["holdout"].get("sharpe", 0.0)),
|
||||
hold_gated=dict(sharpe=gated["holdout"].get("sharpe", 0.0)))
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# REDUNDANCY DIAGNOSTIC — il controllo DECISIVO contro il trend.
|
||||
# ===========================================================================
|
||||
def redundancy_diag(gate_df: pd.DataFrame) -> dict:
|
||||
"""Quanto FA davvero il gate, dato che TP01 e' gia' flat nei crash?
|
||||
- exposure TP01 nei giorni risk-off (gate<1) vs risk-on: se gia' ~0 -> ridondante.
|
||||
- quota di giorni in cui il gate riduce una posizione NON gia' flat (lavoro effettivo).
|
||||
- corr fra (1-gate) e (1-exposure_norm)."""
|
||||
lev = CANONICAL["leverage"]
|
||||
rows = []
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
pos = tp01_pos(df)
|
||||
expo = np.clip(np.abs(pos) / lev, 0, 1) # esposizione normalizzata 0..1
|
||||
g = align_gate(gate_df, df)
|
||||
dt = pd.to_datetime(df["datetime"], utc=True)
|
||||
m = pd.DataFrame({"dt": dt, "expo": expo, "g": g})
|
||||
m = m[m["dt"] >= dt.iloc[0]] # tutto
|
||||
roff = m["g"] < 0.999
|
||||
ron = ~roff
|
||||
flat_thr = 0.05
|
||||
# lavoro effettivo: gate<1 E TP01 non gia' flat
|
||||
work = roff & (m["expo"] > flat_thr)
|
||||
rows.append(dict(
|
||||
asset=a,
|
||||
expo_riskoff=round(float(m.loc[roff, "expo"].mean()) if roff.any() else 0.0, 3),
|
||||
expo_riskon=round(float(m.loc[ron, "expo"].mean()) if ron.any() else 0.0, 3),
|
||||
pct_days_riskoff=round(float(roff.mean()), 3),
|
||||
pct_days_gate_works=round(float(work.mean()), 3),
|
||||
corr_1mg_1mexpo=round(float(np.corrcoef(1 - m["g"], 1 - m["expo"])[0, 1])
|
||||
if m["g"].std() > 0 else float("nan"), 3),
|
||||
))
|
||||
return {r["asset"]: r for r in rows}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RUNNER
|
||||
# ===========================================================================
|
||||
GATES = {
|
||||
# binari classici
|
||||
"SPY>MA200": lambda mf: gate_trend(mf, "spy", 200, 0.0),
|
||||
"SPY>MA100": lambda mf: gate_trend(mf, "spy", 100, 0.0),
|
||||
"SPY>MA50": lambda mf: gate_trend(mf, "spy", 50, 0.0),
|
||||
"SPY>MA200_half": lambda mf: gate_trend(mf, "spy", 200, 0.5),
|
||||
"HYG>MA100": lambda mf: gate_trend(mf, "hyg", 100, 0.0),
|
||||
"HYG/LQD>MA50": lambda mf: gate_ratio(mf, "hyg", "lqd", 50, 0.0),
|
||||
"HYG/LQD>MA100": lambda mf: gate_ratio(mf, "hyg", "lqd", 100, 0.0),
|
||||
# combinati / score
|
||||
"COMBO_maj100": lambda mf: gate_combo(mf, 100, 0.0, thr=0.5),
|
||||
"COMBO_all100": lambda mf: gate_combo(mf, 100, 0.0, thr=0.99),
|
||||
"COMBO_cont100": lambda mf: gate_combo(mf, 100, 0.0, continuous=True),
|
||||
"COMBO_cont100h": lambda mf: gate_combo(mf, 100, 0.5, continuous=True),
|
||||
# flight-to-quality / vol
|
||||
"TLTup&SPYdn20": lambda mf: gate_flight(mf, "tlt", "spy", 20, 0.0),
|
||||
"GLDup&SPYdn20": lambda mf: gate_flight(mf, "gld", "spy", 20, 0.0),
|
||||
"SPYvol_z1": lambda mf: gate_eqvol(mf, 250, 20, 1.0, 0.0),
|
||||
}
|
||||
|
||||
|
||||
def delever_frontier(target_vols=(0.10, 0.12, 0.14, 0.16, 0.18, 0.20)) -> dict:
|
||||
"""CONTROLLO DECISIVO (lezione DVOL-overlay): per meno DD la leva e' target_vol, non un
|
||||
overlay. Frontiera DD/Sharpe di TP01 puro a target_vol decrescente. Se il miglior gate
|
||||
sta SOPRA (DD piu' alto a parita' di Sharpe, o Sharpe piu' basso a parita' di DD) di questa
|
||||
frontiera, il suo taglio di DD e' solo de-levering replicabile meglio senza macro."""
|
||||
out = {}
|
||||
for tv in target_vols:
|
||||
cfg = {**CANONICAL, "target_vol": tv}
|
||||
fn = lambda df, a="", cfg=cfg: TrendPortfolio(**cfg).target_series(df)
|
||||
c = _combo_daily(fn)
|
||||
out[tv] = _metrics(c)
|
||||
return out
|
||||
|
||||
|
||||
def fmt_cmp(label, m_solo, m_gated) -> str:
|
||||
ds = m_gated["sharpe"] - m_solo["sharpe"]
|
||||
dd = m_gated["dd"] - m_solo["dd"]
|
||||
dc = m_gated.get("cagr", 0) - m_solo.get("cagr", 0)
|
||||
return (f" {label:5s} Sh {m_solo['sharpe']:+.2f}->{m_gated['sharpe']:+.2f} (d{ds:+.2f}) "
|
||||
f"DD {m_solo['dd']*100:4.1f}%->{m_gated['dd']*100:4.1f}% (d{dd*100:+.1f}pp) "
|
||||
f"CAGR {m_solo.get('cagr',0)*100:+5.1f}%->{m_gated.get('cagr',0)*100:+5.1f}% (d{dc*100:+.1f}pp)")
|
||||
|
||||
|
||||
def main():
|
||||
pd.set_option("display.width", 160)
|
||||
print("=" * 92)
|
||||
print("MACRO REGIME-GATE sul book crypto (TP01 BTC/ETH) — Filone D")
|
||||
print(f" TP01 CANONICAL = {CANONICAL}")
|
||||
print(f" HOLD-OUT >= {HOLDOUT.date()} fee {al.FEE_SIDE*2*100:.2f}%RT")
|
||||
mf = macro_frame()
|
||||
print(f" Macro frame: {len(mf)} barre {mf['dt'].iloc[0].date()} -> {mf['dt'].iloc[-1].date()} "
|
||||
f"cols={[c for c in mf.columns if c not in ('timestamp','dt')]}")
|
||||
print("=" * 92)
|
||||
|
||||
# ---- 1) SWEEP DI TUTTI I GATE (combo 50/50) -------------------------------------
|
||||
print("\n[1] SWEEP GATE — combo 50/50 BTC+ETH, FULL & HOLD-OUT, vs TP01-solo\n")
|
||||
results = {}
|
||||
for name, builder in GATES.items():
|
||||
tf, gate_df = make_target_fn(builder)
|
||||
cmp = eval_solo_vs_gated(tf)
|
||||
results[name] = (cmp, gate_df, tf)
|
||||
print(f"GATE {name}")
|
||||
print(fmt_cmp("FULL", cmp["full_solo"], cmp["full_gated"]))
|
||||
print(fmt_cmp("HOLD", cmp["hold_solo"], cmp["hold_gated"]))
|
||||
|
||||
# baseline (solo) numbers come from any cmp
|
||||
any_cmp = next(iter(results.values()))[0]
|
||||
print(f"\n [baseline TP01-solo] FULL Sh {any_cmp['full_solo']['sharpe']} DD {any_cmp['full_solo']['dd']*100:.1f}% "
|
||||
f"CAGR {any_cmp['full_solo']['cagr']*100:.1f}% | HOLD Sh {any_cmp['hold_solo']['sharpe']} "
|
||||
f"DD {any_cmp['hold_solo']['dd']*100:.1f}% CAGR {any_cmp['hold_solo']['cagr']*100:.1f}%")
|
||||
|
||||
# ---- 2) SELEZIONE: miglior gate per HOLD-OUT Sharpe, poi per riduzione DD --------
|
||||
def score(name):
|
||||
c = results[name][0]
|
||||
return (c["hold_gated"]["sharpe"], -c["full_gated"]["dd"])
|
||||
best = max(results, key=score)
|
||||
# anche il "miglior DD-cutter" che non peggiora troppo lo Sharpe FULL
|
||||
dd_best = min(results, key=lambda n: results[n][0]["full_gated"]["dd"])
|
||||
print(f"\n[2] Miglior gate per HOLD-OUT Sharpe: {best}")
|
||||
print(f" Miglior gate per DD FULL ridotto : {dd_best}")
|
||||
|
||||
# ---- CONTROLLO DECISIVO: de-lever frontier (target_vol) -------------------------
|
||||
print("\n[2b] CONTROLLO DECISIVO — TP01 puro a target_vol piu' basso (de-lever) vs gate:")
|
||||
fr = delever_frontier()
|
||||
for tv, m in fr.items():
|
||||
print(f" target_vol {tv:.2f}: FULL Sh {m['sharpe']:+.2f} DD {m['dd']*100:4.1f}% CAGR {m['cagr']*100:+5.1f}%")
|
||||
print(" -> i gate de-leveranti (COMBO_cont, SPYvol) vanno confrontati con QUESTA frontiera:")
|
||||
for n in ("COMBO_cont100", "COMBO_cont100h", "SPYvol_z1", "SPY>MA200_half"):
|
||||
if n in results:
|
||||
g = results[n][0]["full_gated"]
|
||||
print(f" {n:16s}: FULL Sh {g['sharpe']:+.2f} DD {g['dd']*100:4.1f}% CAGR {g['cagr']*100:+5.1f}%")
|
||||
|
||||
for tag, name in [("BEST-HOLD", best), ("BEST-DD", dd_best)]:
|
||||
if tag == "BEST-DD" and dd_best == best:
|
||||
continue
|
||||
deep_dive(tag, name, results)
|
||||
|
||||
|
||||
def deep_dive(tag, name, results):
|
||||
cmp, gate_df, tf = results[name]
|
||||
print("\n" + "=" * 92)
|
||||
print(f"[DEEP DIVE {tag}] GATE = {name}")
|
||||
print("=" * 92)
|
||||
|
||||
# per-asset
|
||||
print("\n Per-asset (TP01-solo -> TP01+gate):")
|
||||
pa = per_asset_table(tf)
|
||||
for a in ASSETS:
|
||||
d = pa[a]
|
||||
print(f" {a}: FULL Sh {d['full_solo']['sharpe']:+.2f}->{d['full_gated']['sharpe']:+.2f} "
|
||||
f"DD {d['full_solo']['dd']*100:.0f}%->{d['full_gated']['dd']*100:.0f}% "
|
||||
f"CAGR {d['full_solo']['cagr']*100:+.0f}%->{d['full_gated']['cagr']*100:+.0f}% | "
|
||||
f"HOLD Sh {d['hold_solo']['sharpe']:+.2f}->{d['hold_gated']['sharpe']:+.2f}")
|
||||
|
||||
# ---- 3) CONTROLLO RIDONDANZA COL TREND ------------------------------------------
|
||||
print("\n [3] CONTROLLO RIDONDANZA COL TREND (il test decisivo):")
|
||||
rd = redundancy_diag(gate_df)
|
||||
for a in ASSETS:
|
||||
r = rd[a]
|
||||
print(f" {a}: exposure TP01 nei giorni risk-off={r['expo_riskoff']} vs risk-on={r['expo_riskon']} "
|
||||
f"| giorni risk-off {r['pct_days_riskoff']*100:.0f}% "
|
||||
f"giorni in cui il gate LAVORA (riduce pos non-flat) {r['pct_days_gate_works']*100:.0f}% "
|
||||
f"| corr(1-gate, 1-expo)={r['corr_1mg_1mexpo']}")
|
||||
print(" -> se exposure-risk-off ~ exposure-risk-on e 'gate-lavora' e' basso => RIDONDANTE col trend.")
|
||||
|
||||
# ---- 4) MARGINAL SCORER -----------------------------------------------------------
|
||||
print("\n [4] MARGINAL SCORER vs TP01 (gate come candidato-sleeve):")
|
||||
rep = al.study_marginal(f"GATE[{name}]", tf, tf="1d")
|
||||
print(al.fmt_marginal(rep))
|
||||
|
||||
# overlay-delta: lo STREAM incrementale del gate = gated - solo (e' alpha o hedge?)
|
||||
delta = (cmp["gated_series"] - cmp["solo_series"]).dropna()
|
||||
print("\n overlay-delta (gated - solo) come stream a se':")
|
||||
md = al.marginal_vs_tp01(delta)
|
||||
print(f" verdict={md.get('marginal_verdict')} corr->TP01 {md.get('corr_full')} "
|
||||
f"is_hedge={md.get('is_hedge')} uplift TP01-up {md.get('uplift_tp01_up')} / "
|
||||
f"TP01-down {md.get('uplift_tp01_down')} cand-Sh full {md.get('cand_full_sharpe')}")
|
||||
|
||||
# ---- 5) FEE SWEEP -----------------------------------------------------------------
|
||||
print("\n [5] FEE SWEEP (combo 50/50 gated, FULL Sharpe):")
|
||||
for f in al.FEE_SWEEP:
|
||||
series = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
ev = al.eval_weights(df, tf(df, a), fee_side=f)
|
||||
series[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
d = al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]])
|
||||
print(f" {2*f*100:.2f}%RT -> Sh {_sh(d):+.2f}")
|
||||
|
||||
# ---- 6) ESEGUIBILITA' a $600 ------------------------------------------------------
|
||||
print("\n [6] ESEGUIBILITA' a $600 (eval_weights_smallcap, haircut reale):")
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
sc = al.eval_weights_smallcap(df, tf(df, a), capital=600, min_order=5)
|
||||
print(f" {a}: modeled Sh {sc['modeled']['sharpe']:+.2f} -> real Sh {sc['realistic']['sharpe']:+.2f} "
|
||||
f"haircut {sc['sharpe_haircut']:+.2f} trade eseguiti {sc['n_executed_trades']}")
|
||||
|
||||
# ---- 7) LEAK / BOUNDARY -----------------------------------------------------------
|
||||
print("\n [7] LEAK-FREE & BOUNDARY:")
|
||||
cz = al.causality_ok(tf, tf="1d")
|
||||
print(f" causality_ok={cz['ok']} (max_tail_diff {cz['max_tail_diff']}, checked {cz['checked']})")
|
||||
# variante STRICT (equity-label < crypto-label): margine causale extra
|
||||
tf_strict, _ = make_target_fn(GATES[name], strict=True)
|
||||
cmp_s = eval_solo_vs_gated(tf_strict)
|
||||
print(f" STRICT align (1 barra equity extra di lag): FULL Sh {cmp_s['full_gated']['sharpe']:+.2f} "
|
||||
f"(vs {cmp['full_gated']['sharpe']:+.2f}) HOLD Sh {cmp_s['hold_gated']['sharpe']:+.2f} "
|
||||
f"(vs {cmp['hold_gated']['sharpe']:+.2f}) -> robusto se ~uguale")
|
||||
db = al.day_boundary_robust(tf, tf="1d")
|
||||
print(f" day_boundary_robust={db['verdict']} (spread {db.get('spread')}, per-offset {db.get('per_offset')})")
|
||||
|
||||
# ---- 8) PLATEAU (solo per i trend MA) ---------------------------------------------
|
||||
if name.startswith("SPY>MA") or name.startswith("HYG"):
|
||||
print("\n [8] PLATEAU su finestra MA (SPY trend), g_off=0:")
|
||||
for n in (50, 100, 150, 200, 250):
|
||||
tfn, _ = make_target_fn(lambda mf, n=n: gate_trend(mf, "spy", n, 0.0))
|
||||
cc = eval_solo_vs_gated(tfn)
|
||||
print(f" SPY>MA{n:3d}: FULL Sh {cc['full_gated']['sharpe']:+.2f} DD {cc['full_gated']['dd']*100:.1f}% "
|
||||
f"HOLD Sh {cc['hold_gated']['sharpe']:+.2f} DD {cc['hold_gated']['dd']*100:.1f}%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,410 @@
|
||||
"""META-ALLOCATION — allocazione DINAMICA CAUSALE tra i 4 sleeve esistenti vs PESI FISSI.
|
||||
|
||||
TESI (angolo nuovo, NON un 5o sleeve): il portafoglio attivo combina TP01/XS01/VRP01/SKH01 a
|
||||
PESO FISSO (41.25/18.75/15/25, rinormalizzati per-riga sugli sleeve attivi — vedi
|
||||
src/portfolio/portfolio.combined_daily). Domanda: una regola di allocazione DINAMICA e CAUSALE
|
||||
fra gli stessi 4 sleeve batte i pesi fissi OUT-OF-SAMPLE? Cioe' c'e' meta-alpha di timing di
|
||||
portafoglio, oltre ai pesi fissi?
|
||||
|
||||
MECCANISMI testati (tutti CAUSALI: decisione con dati <= t-1, peso applicato in t; ribilancio
|
||||
SETTIMANALE con costo sul turnover dei pesi |Δw|*cost_rate, cosi' una regola che ribilancia di
|
||||
continuo PAGA il suo attrito — non si bara):
|
||||
1. VOL-PARITY — peso inverso alla vol realizzata rolling (risk-parity causale). Pure + tilt.
|
||||
2. MOMENTUM-OF-SLEEVES — sovrappesa gli sleeve con Sharpe rolling recente migliore (tilt capato).
|
||||
3. DISPERSION-REGIME — tilt verso XS01 quando la dispersione cross-section degli alt e' alta
|
||||
(percentile ESPANDENTE causale), verso il resto altrimenti.
|
||||
4. DRAWDOWN-CONTROL — riduce l'esposizione aggregata (-> cash) o ribilancia verso VRP/SKH
|
||||
quando il portafoglio e' in drawdown rolling (causale sull'equity propria).
|
||||
|
||||
GATE / ONESTA':
|
||||
- FULL e HOLD-OUT (2025-01-01+) Sharpe + maxDD, per-anno, turnover dei pesi/anno.
|
||||
- Confronto vs BASE pesi-fissi sulla STESSA finestra e con lo STESSO motore (entrambi pagano il
|
||||
costo di ribilancio): il miglioramento deve esserci su HOLD-OUT, non solo FULL.
|
||||
- MULTI-CUT: uplift dello Sharpe a piu' date di taglio (2022/23/24/25). Robusto solo se positivo
|
||||
su piu' finestre, non su una sola fortunata.
|
||||
- DE-LEVERING: lo Sharpe e' scale-invariant. Se uno schema ABBASSA DD/vol ma NON alza lo Sharpe,
|
||||
il taglio di DD e' solo de-levering (replicabile abbassando la leva di BASE) -> NON e' alpha di
|
||||
timing. Lo riportiamo esplicitamente confrontando BASE de-levered a pari vol.
|
||||
|
||||
VERDETTO per schema: BATTE-FISSO / solo-de-levering / RIDONDANTE / SCARTATO.
|
||||
|
||||
uv run python scripts/research/meta_allocation.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, XS_UNIVERSE, _HL_DIR
|
||||
from src.portfolio.portfolio import metrics, yearly, HOLDOUT, DAYS_PER_YEAR
|
||||
|
||||
REBAL_DAYS = 7 # ribilancio settimanale
|
||||
COST_RATE = 0.0005 # 5 bps per-lato sul turnover dei pesi (Deribit taker ~ questo ordine)
|
||||
VOL_WIN = 60 # finestra vol realizzata (risk-parity)
|
||||
MOM_WIN = 63 # finestra Sharpe rolling (momentum-of-sleeves, ~1 trimestre)
|
||||
WARMUP = 90 # giorni di warm-up: prima -> fallback ai pesi fissi
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- data
|
||||
def sleeve_matrix() -> tuple[pd.DatetimeIndex, np.ndarray, np.ndarray, list[str], np.ndarray]:
|
||||
"""Matrice daily allineata dei 4 sleeve (outer-join). Ritorna (index, R, active, names, fixed_w).
|
||||
R = rendimenti (0 dove inattivo), active = maschera bool di disponibilita'."""
|
||||
base = active_sleeves()
|
||||
names = [s.name for s in base]
|
||||
fixed_w = np.array([s.weight for s in base], float)
|
||||
cols = {s.name: s.daily() for s in base}
|
||||
J = pd.concat(cols, axis=1, join="outer").sort_index()
|
||||
J = J[J.notna().any(axis=1)]
|
||||
active = J.notna().values
|
||||
R = np.nan_to_num(J.values, nan=0.0)
|
||||
return J.index, R, active, names, fixed_w
|
||||
|
||||
|
||||
def dispersion_series(index: pd.DatetimeIndex) -> np.ndarray:
|
||||
"""Dispersione cross-section dei rendimenti degli alt Hyperliquid (std cross-section dei ritorni
|
||||
daily sull'universo XS01), allineata all'index del portafoglio. NaN dove non c'e' dato HL."""
|
||||
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))
|
||||
C = pd.concat(cols, axis=1, join="inner").sort_index().dropna()
|
||||
ret = C.pct_change()
|
||||
disp = ret.std(axis=1) # dispersione cross-section per giorno
|
||||
disp.index = disp.index.normalize()
|
||||
return disp.reindex(index.normalize()).values
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- weight helpers
|
||||
def _renorm_rows(W: np.ndarray, active: np.ndarray, expo: np.ndarray | None = None) -> np.ndarray:
|
||||
"""Maschera inattivi -> 0, rinormalizza ogni riga alla esposizione `expo` (default 1)."""
|
||||
Wm = W * active
|
||||
rs = Wm.sum(axis=1, keepdims=True)
|
||||
out = np.divide(Wm, rs, out=np.zeros_like(Wm), where=rs > 0)
|
||||
if expo is not None:
|
||||
out = out * expo[:, None]
|
||||
return out
|
||||
|
||||
|
||||
def base_weights(R, active, fixed_w) -> np.ndarray:
|
||||
"""Pesi FISSI rinormalizzati per-riga sugli sleeve attivi (replica combined_daily)."""
|
||||
n, A = R.shape
|
||||
return _renorm_rows(np.tile(fixed_w, (n, 1)), active)
|
||||
|
||||
|
||||
def add_cash(W: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Appende una colonna CASH (rendimento 0) che assorbe 1 - somma-pesi (per schemi che de-levano).
|
||||
Ritorna (W_aug, is_cash_active=True)."""
|
||||
cash = np.clip(1.0 - W.sum(axis=1, keepdims=True), 0.0, 1.0)
|
||||
return np.hstack([W, cash])
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- engine
|
||||
def simulate(R: np.ndarray, active: np.ndarray, Wtgt: np.ndarray,
|
||||
period_days: int = REBAL_DAYS, cost_rate: float = COST_RATE) -> dict:
|
||||
"""Motore di ribilancio PERIODICO realistico, CAUSALE.
|
||||
|
||||
Wtgt[t] = pesi-bersaglio decisi con dati <= t-1 (vedi costruttori schemi), una colonna CASH in
|
||||
coda (rend. 0). Fra un ribilancio e l'altro i pesi DERIVANO col rendimento; ogni `period_days`
|
||||
si torna al target pagando cost_rate*|v-target|. Il costo grava sul rendimento del giorno.
|
||||
period_days=1, cost=0 -> rebalance-continuo (= combined_daily)."""
|
||||
n = R.shape[0]
|
||||
Raug = np.hstack([R, np.zeros((n, 1))]) # colonna cash
|
||||
v = Wtgt[0].copy() # equity iniziale = 1.0, allocata al target
|
||||
out = np.zeros(n)
|
||||
turn_tot = 0.0
|
||||
n_rebal = 0
|
||||
for t in range(n):
|
||||
E_start = float(v.sum())
|
||||
if t > 0 and (t % period_days == 0) and E_start > 0:
|
||||
target = Wtgt[t] * E_start
|
||||
turn = float(np.abs(v - target).sum())
|
||||
v = Wtgt[t] * (E_start - cost_rate * turn)
|
||||
turn_tot += turn / E_start
|
||||
n_rebal += 1
|
||||
v = v * (1.0 + Raug[t])
|
||||
E_end = float(v.sum())
|
||||
out[t] = E_end / E_start - 1.0 if E_start > 0 else 0.0
|
||||
years = n / DAYS_PER_YEAR
|
||||
return dict(daily=pd.Series(out),
|
||||
turnover_per_year=turn_tot / years if years > 0 else 0.0,
|
||||
n_rebalances=n_rebal)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- schemes (causal Wtgt builders, with cash col)
|
||||
def scheme_base(index, R, active, fixed_w, **_):
|
||||
return add_cash(base_weights(R, active, fixed_w))
|
||||
|
||||
|
||||
def _rolling_vol(R, active, win):
|
||||
"""Vol realizzata rolling per-sleeve, SHIFTATA di 1 (causale: usa <= t-1)."""
|
||||
df = pd.DataFrame(np.where(active, R, np.nan))
|
||||
vol = df.rolling(win, min_periods=max(10, win // 2)).std().shift(1).values
|
||||
return vol
|
||||
|
||||
|
||||
def scheme_volpar_pure(index, R, active, fixed_w, win=VOL_WIN, **_):
|
||||
"""Risk-parity puro: w_i ∝ 1/vol_i sugli sleeve attivi (causale). Warm-up -> BASE."""
|
||||
vol = _rolling_vol(R, active, win)
|
||||
inv = np.divide(1.0, vol, out=np.zeros_like(vol), where=(vol > 0) & np.isfinite(vol))
|
||||
W = _renorm_rows(inv, active & np.isfinite(vol) & (vol > 0))
|
||||
bw = base_weights(R, active, fixed_w)
|
||||
bad = W.sum(axis=1) <= 0
|
||||
W[bad] = bw[bad]
|
||||
W[:WARMUP] = bw[:WARMUP]
|
||||
return add_cash(W)
|
||||
|
||||
|
||||
def scheme_volpar_tilt(index, R, active, fixed_w, win=VOL_WIN, **_):
|
||||
"""Tilt dei pesi FISSI per inverso-vol: w_i ∝ fixed_i / vol_i (ancorato ai pesi fissi)."""
|
||||
vol = _rolling_vol(R, active, win)
|
||||
inv = np.divide(1.0, vol, out=np.zeros_like(vol), where=(vol > 0) & np.isfinite(vol))
|
||||
W = _renorm_rows(fixed_w[None, :] * inv, active & np.isfinite(vol) & (vol > 0))
|
||||
bw = base_weights(R, active, fixed_w)
|
||||
bad = W.sum(axis=1) <= 0
|
||||
W[bad] = bw[bad]
|
||||
W[:WARMUP] = bw[:WARMUP]
|
||||
return add_cash(W)
|
||||
|
||||
|
||||
def scheme_momentum(index, R, active, fixed_w, win=MOM_WIN, tilt=0.5, cap=0.55, **_):
|
||||
"""Momentum-of-sleeves: tilt dei pesi fissi per lo Sharpe rolling z-scored (causale), capato.
|
||||
w_i ∝ fixed_i * (1 + tilt*z_i)+, z = standardizzazione cross-sleeve dello Sharpe rolling.
|
||||
Cap per non concentrare. Warm-up / regime piatto -> BASE."""
|
||||
df = pd.DataFrame(np.where(active, R, np.nan))
|
||||
mu = df.rolling(win, min_periods=win // 2).mean().shift(1).values
|
||||
sd = df.rolling(win, min_periods=win // 2).std().shift(1).values
|
||||
sh = np.divide(mu, sd, out=np.full_like(mu, np.nan), where=(sd > 0)) * np.sqrt(DAYS_PER_YEAR)
|
||||
n, A = R.shape
|
||||
W = np.zeros((n, A))
|
||||
bw = base_weights(R, active, fixed_w)
|
||||
for t in range(n):
|
||||
m = active[t] & np.isfinite(sh[t])
|
||||
if m.sum() < 2 or t < WARMUP:
|
||||
W[t] = bw[t]; continue
|
||||
z = np.zeros(A); s = sh[t][m]
|
||||
zsd = s.std()
|
||||
if zsd > 0:
|
||||
z[m] = (sh[t][m] - s.mean()) / zsd
|
||||
raw = fixed_w * np.clip(1.0 + tilt * z, 0.0, None) * m
|
||||
if raw.sum() <= 0:
|
||||
W[t] = bw[t]; continue
|
||||
w = raw / raw.sum()
|
||||
for _ in range(3): # impone il cap iterando
|
||||
over = w > cap
|
||||
if not over.any():
|
||||
break
|
||||
excess = (w[over] - cap).sum()
|
||||
w[over] = cap
|
||||
room = m & ~over
|
||||
if room.sum() == 0 or w[room].sum() == 0:
|
||||
break
|
||||
w[room] += excess * w[room] / w[room].sum()
|
||||
W[t] = w / w.sum()
|
||||
return add_cash(W)
|
||||
|
||||
|
||||
def scheme_dispersion(index, R, active, fixed_w, pct=60, minhist=120, boost=2.0, **_):
|
||||
"""Dispersion-regime: quando la dispersione cross-section degli alt supera il percentile
|
||||
ESPANDENTE causale (pct), boost del peso XS01; sotto, XS01 -> 0 e redistribuito. Pesi fissi
|
||||
altrove. XS01 attivo solo dal 2024 (prima: BASE)."""
|
||||
disp = dispersion_series(index)
|
||||
n, A = R.shape
|
||||
names_idx = 1 # XS01 e' la colonna 1 (vedi active_sleeves)
|
||||
bw = base_weights(R, active, fixed_w)
|
||||
W = bw.copy()
|
||||
hist = []
|
||||
high = np.zeros(n, bool)
|
||||
for t in range(n):
|
||||
d = disp[t - 1] if t > 0 else np.nan # causale: dispersione <= t-1
|
||||
if np.isfinite(d):
|
||||
thr = np.percentile(hist, pct) if len(hist) >= minhist else np.inf
|
||||
high[t] = d >= thr
|
||||
hist.append(d)
|
||||
for t in range(n):
|
||||
if t < WARMUP or not active[t, names_idx]:
|
||||
continue
|
||||
raw = fixed_w.copy()
|
||||
raw[names_idx] *= boost if high[t] else 0.05 # boost XS in regime disperso, quasi-spento altrove
|
||||
W[t] = _renorm_rows(raw[None, :], active[t][None, :])[0]
|
||||
return add_cash(W)
|
||||
|
||||
|
||||
def scheme_dd_cash(index, R, active, fixed_w, dd_thr=0.05, floor=0.5, win=0, **_):
|
||||
"""Drawdown-control (DE-LEVERING esplicito): traccia l'equity di BASE (causale, shiftata),
|
||||
se il drawdown corrente > dd_thr riduce l'esposizione aggregata a `floor` (resto in CASH).
|
||||
E' il caso-test del de-levering: ci aspettiamo DD piu' basso ma Sharpe NON piu' alto."""
|
||||
bw = base_weights(R, active, fixed_w)
|
||||
base_daily = simulate(R, active, add_cash(bw))["daily"].values
|
||||
eq = np.cumprod(1.0 + base_daily)
|
||||
pk = np.maximum.accumulate(eq)
|
||||
dd = (pk - eq) / pk # drawdown realizzato
|
||||
expo = np.ones(R.shape[0])
|
||||
for t in range(R.shape[0]):
|
||||
d = dd[t - 1] if t > 0 else 0.0 # causale
|
||||
expo[t] = floor if d > dd_thr else 1.0
|
||||
expo[:WARMUP] = 1.0
|
||||
W = bw * expo[:, None]
|
||||
return add_cash(W)
|
||||
|
||||
|
||||
def scheme_dd_defensive(index, R, active, fixed_w, dd_thr=0.05, **_):
|
||||
"""Drawdown-control DIFENSIVO: in drawdown ribilancia verso VRP01(2)/SKH01(3) (scorrelati),
|
||||
via TP01(0)/XS01(1). Pienamente investito (no cash) -> isola il timing dal de-levering."""
|
||||
bw = base_weights(R, active, fixed_w)
|
||||
base_daily = simulate(R, active, add_cash(bw))["daily"].values
|
||||
eq = np.cumprod(1.0 + base_daily)
|
||||
pk = np.maximum.accumulate(eq)
|
||||
dd = (pk - eq) / pk
|
||||
n, A = R.shape
|
||||
defensive = np.array([0.10, 0.10, 0.35, 0.45]) # VRP/SKH pesati in DD
|
||||
W = bw.copy()
|
||||
for t in range(n):
|
||||
d = dd[t - 1] if t > 0 else 0.0
|
||||
if t >= WARMUP and d > dd_thr:
|
||||
W[t] = _renorm_rows(defensive[None, :], active[t][None, :])[0]
|
||||
return add_cash(W)
|
||||
|
||||
|
||||
SCHEMES = [
|
||||
("BASE (pesi fissi)", scheme_base),
|
||||
("VOLPAR pure (1/vol)", scheme_volpar_pure),
|
||||
("VOLPAR tilt (fix/vol)", scheme_volpar_tilt),
|
||||
("MOMENTUM-of-sleeves", scheme_momentum),
|
||||
("DISPERSION-regime->XS", scheme_dispersion),
|
||||
("DRAWDOWN-ctrl (cash)", scheme_dd_cash),
|
||||
("DRAWDOWN-ctrl (defens.)", scheme_dd_defensive),
|
||||
]
|
||||
|
||||
CUTS = ["2022-01-01", "2023-01-01", "2024-01-01", "2025-01-01"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- run
|
||||
def run():
|
||||
index, R, active, names, fixed_w = sleeve_matrix()
|
||||
print("=" * 100)
|
||||
print(" META-ALLOCATION — allocazione dinamica causale tra i 4 sleeve vs PESI FISSI")
|
||||
print(f" sleeve: {names}")
|
||||
print(f" pesi fissi: {dict(zip(names, np.round(fixed_w, 4)))}")
|
||||
print(f" finestra {index.min().date()} -> {index.max().date()} | n={len(index)} giorni | "
|
||||
f"hold-out {HOLDOUT.date()}+ | ribilancio {REBAL_DAYS}g | costo {COST_RATE*1e4:.0f}bps/lato")
|
||||
print("=" * 100)
|
||||
|
||||
results = {}
|
||||
for label, fn in SCHEMES:
|
||||
Wtgt = fn(index, R, active, fixed_w)
|
||||
sim = simulate(R, active, Wtgt)
|
||||
d = pd.Series(sim["daily"].values, index=index)
|
||||
results[label] = dict(daily=d, turnover=sim["turnover_per_year"], W=Wtgt)
|
||||
|
||||
base_d = results["BASE (pesi fissi)"]["daily"]
|
||||
mb_full = metrics(base_d)
|
||||
mb_hold = metrics(base_d[base_d.index >= HOLDOUT])
|
||||
|
||||
print(f"\n {'SCHEMA':<26s} | {'FULL Sh':>7s} {'CAGR':>7s} {'DD':>6s} | {'HOLD Sh':>7s} {'HOLD ret':>8s} {'DD':>6s} | turn/y")
|
||||
print(" " + "-" * 96)
|
||||
for label, _ in SCHEMES:
|
||||
d = results[label]["daily"]
|
||||
mf = metrics(d); mh = metrics(d[d.index >= HOLDOUT])
|
||||
print(f" {label:<26s} | {mf['sharpe']:>7.2f} {mf['cagr']*100:>6.1f}% {mf['maxdd']*100:>5.1f}% | "
|
||||
f"{mh['sharpe']:>7.2f} {mh['ret']*100:>+7.1f}% {mh['maxdd']*100:>5.1f}% | {results[label]['turnover']:>5.2f}")
|
||||
|
||||
print(f"\n delta vs BASE (FULL Sh {mb_full['sharpe']:.2f} / HOLD Sh {mb_hold['sharpe']:.2f}):")
|
||||
print(f" {'SCHEMA':<26s} | {'ΔFULL Sh':>9s} {'ΔHOLD Sh':>9s} {'ΔFULL DD':>9s} {'ΔHOLD DD':>9s} | corr(BASE)")
|
||||
print(" " + "-" * 96)
|
||||
for label, _ in SCHEMES:
|
||||
if label.startswith("BASE"):
|
||||
continue
|
||||
d = results[label]["daily"]
|
||||
mf = metrics(d); mh = metrics(d[d.index >= HOLDOUT])
|
||||
corr = float(np.corrcoef(d.values, base_d.values)[0, 1])
|
||||
print(f" {label:<26s} | {mf['sharpe']-mb_full['sharpe']:>+9.2f} {mh['sharpe']-mb_hold['sharpe']:>+9.2f} "
|
||||
f"{(mf['maxdd']-mb_full['maxdd'])*100:>+8.1f}% {(mh['maxdd']-mb_hold['maxdd'])*100:>+8.1f}% | {corr:>6.3f}")
|
||||
|
||||
# ---- MULTI-CUT: uplift Sharpe a piu' date di taglio (anti-overfit hold-out singolo) ----
|
||||
print("\n MULTI-CUT — ΔSharpe (schema − BASE) su finestre [cut, fine]:")
|
||||
header = " " + f"{'SCHEMA':<26s} | " + " ".join(f"{c[:4]:>7s}" for c in CUTS)
|
||||
print(header); print(" " + "-" * (len(header) - 2))
|
||||
for label, _ in SCHEMES:
|
||||
if label.startswith("BASE"):
|
||||
continue
|
||||
d = results[label]["daily"]
|
||||
row = []
|
||||
for c in CUTS:
|
||||
lo = pd.Timestamp(c, tz="UTC")
|
||||
sd = metrics(d[d.index >= lo])["sharpe"]
|
||||
sb = metrics(base_d[base_d.index >= lo])["sharpe"]
|
||||
row.append(f"{sd-sb:>+7.2f}")
|
||||
print(f" {label:<26s} | " + " ".join(row))
|
||||
|
||||
# ---- DE-LEVERING check: BASE de-levered alla vol dello schema -> stesso DD? ----
|
||||
print("\n DE-LEVERING check (Sharpe e' scale-invariant: DD-piu'-basso a pari-Sharpe = solo de-lever):")
|
||||
print(f" {'SCHEMA':<26s} | {'vol/volBASE':>11s} | {'DD schema':>9s} {'DD BASE@volSchema':>18s}")
|
||||
print(" " + "-" * 70)
|
||||
vol_base = base_d.std()
|
||||
dd_base = mb_full["maxdd"]
|
||||
for label, _ in SCHEMES:
|
||||
if label.startswith("BASE"):
|
||||
continue
|
||||
d = results[label]["daily"]
|
||||
ratio = d.std() / vol_base if vol_base > 0 else 1.0
|
||||
# BASE riscalato alla stessa vol dello schema -> il suo DD a quella leva
|
||||
dd_base_scaled = metrics(base_d * ratio)["maxdd"]
|
||||
print(f" {label:<26s} | {ratio:>11.3f} | {metrics(d)['maxdd']*100:>8.1f}% {dd_base_scaled*100:>17.1f}%")
|
||||
|
||||
# ---- PER-ANNO dei due piu' interessanti vs BASE ----
|
||||
print("\n PER-ANNO ret% (BASE vs schemi):")
|
||||
yb = yearly(base_d)
|
||||
yrs = sorted(yb.keys())
|
||||
print(" " + f"{'SCHEMA':<26s} | " + " ".join(f"{y:>7d}" for y in yrs))
|
||||
print(" " + "-" * (28 + 8 * len(yrs)))
|
||||
for label, _ in SCHEMES:
|
||||
d = results[label]["daily"]; yd = yearly(d)
|
||||
print(f" {label:<26s} | " + " ".join(f"{yd.get(y,{'ret':0})['ret']*100:>+6.1f}%" for y in yrs))
|
||||
|
||||
# ---- VERDETTI ----
|
||||
print("\n VERDETTI (BATTE-FISSO richiede ΔHOLD Sh > +0.10 E multi-cut maggioritario positivo E"
|
||||
" non solo de-levering):")
|
||||
vol_base = base_d.std()
|
||||
for label, _ in SCHEMES:
|
||||
if label.startswith("BASE"):
|
||||
continue
|
||||
d = results[label]["daily"]
|
||||
mf = metrics(d); mh = metrics(d[d.index >= HOLDOUT])
|
||||
dfull = mf["sharpe"] - mb_full["sharpe"]
|
||||
dhold = mh["sharpe"] - mb_hold["sharpe"]
|
||||
cut_ups = []
|
||||
for c in CUTS:
|
||||
lo = pd.Timestamp(c, tz="UTC")
|
||||
cut_ups.append(metrics(d[d.index >= lo])["sharpe"] - metrics(base_d[base_d.index >= lo])["sharpe"])
|
||||
n_pos = sum(1 for x in cut_ups if x > 0.02)
|
||||
vr = d.std() / vol_base if vol_base > 0 else 1.0
|
||||
dd_lower = mf["maxdd"] < mb_full["maxdd"] - 0.005
|
||||
is_delever = (vr < 0.97) and dd_lower and (dfull <= 0.03) # vol giu', DD giu', Sharpe non meglio
|
||||
if dhold > 0.10 and dfull > -0.05 and n_pos >= 3:
|
||||
verdict, why = "BATTE-FISSO", f"ΔHOLD {dhold:+.2f}, multi-cut {n_pos}/4 positivi, FULL non peggiore"
|
||||
elif (dhold <= -0.10) or (n_pos == 0 and dfull < -0.07):
|
||||
verdict, why = "SCARTATO", f"peggio OOS (ΔFULL {dfull:+.2f}, ΔHOLD {dhold:+.2f}, multi-cut {n_pos}/4 con turn/y {results[label]['turnover']:.1f})"
|
||||
elif is_delever:
|
||||
verdict, why = "solo-de-levering", f"vol {vr:.2f}×BASE, DD {mf['maxdd']*100:.1f}%<{mb_full['maxdd']*100:.1f}% ma Sharpe non meglio (ΔFULL {dfull:+.2f}) -> replicabile abbassando la leva"
|
||||
else:
|
||||
why = (f"≈BASE OOS (ΔHOLD {dhold:+.2f}); FULL ΔSh {dfull:+.2f}, ΔDD {(mf['maxdd']-mb_full['maxdd'])*100:+.1f}%"
|
||||
+ (" [marginale in-sample, nullo su hold-out]" if abs(dfull) >= 0.03 else ""))
|
||||
verdict = "RIDONDANTE"
|
||||
print(f" {label:<26s} -> {verdict:<16s} {why}")
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print(" CONCLUSIONE: vedi i verdetti sopra. Soglia BATTE-FISSO deliberatamente alta (anti-overfit):")
|
||||
print(" l'allocazione dinamica deve battere i pesi fissi su HOLD-OUT *e* multi-cut, non su una")
|
||||
print(" finestra fortunata, e non per solo de-levering (replicabile abbassando target_vol/leva).")
|
||||
print("=" * 100)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,296 @@
|
||||
"""GAMMA SCALPING (long-vol) — "scalping BTC/ETH con copertura in opzioni", valutato onestamente.
|
||||
|
||||
Interpretazione rigorosa di "fare scalping con copertura in opzioni" = GAMMA SCALPING:
|
||||
compri un'opzione (la COPERTURA = long gamma), delta-hedgi il sottostante a cadenza fissa
|
||||
(lo SCALP), e il P&L netto e' ~ dollar-gamma * (vol realizzata^2 - vol implicita^2). E' lo
|
||||
SPECCHIO ESATTO del VRP01 (short-vol): VRP01 incassa IV-RV (positivo in media), il gamma
|
||||
scalping incassa RV-IV (negativo in media, ma CONVESSO -> paga nei crash).
|
||||
|
||||
Modello (mirror della struttura VRP per comparabilita'):
|
||||
- long STRADDLE ATM, tenor settimanale (7g), strike = spot all'ingresso.
|
||||
- IV = DVOL Deribit (data/raw/dvol_*.parquet) — la stessa fonte IV del VRP.
|
||||
- delta-hedge GIORNALIERO sui prezzi certificati 1d (BTC/ETH).
|
||||
- P&L delta-hedged classico per step: DG_t * [(dS/S)^2 - sigma^2*dt],
|
||||
DG_t = dollar-gamma dello straddle = phi(d1)*S/(sigma*sqrt(tau)).
|
||||
- fee opzioni Deribit cap 12.5% del premio (come VRP) + fee perp sull'hedge turnover.
|
||||
- return-on-notional (pnl/S0), poi VOL-TARGET 20% annuo per apples-to-apples con gli altri sleeve.
|
||||
|
||||
Varianti testate:
|
||||
NAKED -> sempre long gamma (baseline: deve perdere il premio in media).
|
||||
CHEAP-GATED -> entra solo quando IV-rank < gate (compri vol a sconto = specchio del gate VRP).
|
||||
RICH-SKIP -> entra sempre tranne quando IV-rank > skip (evita di pagare vol carissima).
|
||||
|
||||
Output: metriche standalone + per-anno + SCORING MARGINALE vs TP01 (corr, blend uplift, is_hedge,
|
||||
has_insample_edge) + il muro di ESEGUIBILITA' a $600 (min size opzioni Deribit).
|
||||
|
||||
uv run python scripts/research/options_gamma_scalp.py
|
||||
|
||||
ONESTA': premio MODELLATO su DVOL ATM (skew non esplicito) — stesso caveat del VRP. Long-vol da
|
||||
modello: meno pericoloso dello short-vol (loss capped al premio), ma resta un LEAD da modello.
|
||||
"""
|
||||
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
|
||||
import pandas as pd
|
||||
from scipy.stats import norm
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from src.strategies.trend_portfolio import resample_1d
|
||||
from altlib import marginal_vs_tp01 # type: ignore
|
||||
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
SQ365 = np.sqrt(365.25)
|
||||
DT = 1.0 / 365.25
|
||||
|
||||
# fee model (mirror VRP): opzioni Deribit cap 12.5% del premio; perp taker 0.05%/lato sull'hedge.
|
||||
OPT_FEE_FRAC = 0.125
|
||||
HEDGE_FEE_SIDE = 0.0005
|
||||
TENOR_D = 7
|
||||
|
||||
|
||||
def _bs_straddle(S, K, T, sig):
|
||||
"""Premio BS di uno straddle ATM (call+put), r=0."""
|
||||
if T <= 0 or sig <= 0:
|
||||
return abs(S - K)
|
||||
d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T))
|
||||
d2 = d1 - sig * np.sqrt(T)
|
||||
call = S * norm.cdf(d1) - K * norm.cdf(d2)
|
||||
put = K * norm.cdf(-d2) - S * norm.cdf(-d1)
|
||||
return call + put
|
||||
|
||||
|
||||
def _dvol(asset: str) -> pd.Series:
|
||||
dv = pd.read_parquet(RAW / f"dvol_{asset.lower()}.parquet")
|
||||
return pd.Series(dv["close"].values.astype(float),
|
||||
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)) / 100.0
|
||||
|
||||
|
||||
def _load(asset: str):
|
||||
df = resample_1d(load_data(asset, "1h"))
|
||||
s = pd.Series(df["close"].values.astype(float), index=pd.to_datetime(df["datetime"]))
|
||||
if s.index.tz is None:
|
||||
s.index = s.index.tz_localize("UTC")
|
||||
J = pd.concat({"px": s, "dvol": _dvol(asset)}, axis=1, join="inner").sort_index().dropna()
|
||||
return J["px"].values, J["dvol"].values / 1.0, J.index
|
||||
|
||||
|
||||
def _load_hourly(asset: str):
|
||||
df = load_data(asset, "1h")
|
||||
s = pd.Series(df["close"].values.astype(float),
|
||||
index=pd.to_datetime(df["timestamp"], unit="ms", utc=True)).sort_index()
|
||||
return s
|
||||
|
||||
|
||||
def rv_iv_diagnostic(asset: str):
|
||||
"""Il CUORE strutturale: vol realizzata (a vari campionamenti) vs vol implicita (DVOL).
|
||||
Se RV < IV in media -> il long gamma PERDE gross, prima di ogni fee. E' il VRP, di segno opposto."""
|
||||
px, dvf, idx = _load(asset)
|
||||
iv = float(np.mean(dvf))
|
||||
rdaily = np.diff(np.log(px))
|
||||
rv_daily = float(np.std(rdaily) * SQ365)
|
||||
h = _load_hourly(asset)
|
||||
rhour = np.diff(np.log(h.values))
|
||||
rv_hour = float(np.std(rhour) * np.sqrt(365.25 * 24))
|
||||
return dict(asset=asset, iv=iv, rv_daily=rv_daily, rv_hour=rv_hour,
|
||||
spread_daily=iv - rv_daily, spread_hour=iv - rv_hour)
|
||||
|
||||
|
||||
def gamma_scalp_hourly(asset: str, mode: str = "naked",
|
||||
gate_cheap: float = 0.30) -> pd.Series:
|
||||
"""Gamma scalp a rehedge ORARIO (la 'vera' frequenza di scalping): cattura la RV intraday,
|
||||
ma paga 24x la fee di hedge. Tenor 7g = 168 step orari. IV costante nel giorno (DVOL)."""
|
||||
h = _load_hourly(asset)
|
||||
dv = _dvol(asset).reindex(h.index.normalize(), method="ffill")
|
||||
dv.index = h.index
|
||||
J = pd.concat({"px": h, "dvol": dv}, axis=1).dropna()
|
||||
px = J["px"].values; dvf = J["dvol"].values; idx = J.index
|
||||
n = len(px); STEPS = TENOR_D * 24; dt = 1.0 / (365.25 * 24)
|
||||
# IV-rank causale su DVOL giornaliero per il gate
|
||||
daily_iv = _dvol(asset)
|
||||
rets = {}
|
||||
i = 24 * 60
|
||||
while i + STEPS < n:
|
||||
S0 = px[i]; sig = dvf[i]; K = S0
|
||||
if mode == "cheap":
|
||||
day = idx[i].normalize()
|
||||
hist = daily_iv[daily_iv.index < day]
|
||||
if len(hist) >= 60 and float((hist < sig).mean()) > gate_cheap:
|
||||
rets[idx[i + STEPS]] = 0.0; i += STEPS; continue
|
||||
T = TENOR_D / 365.25
|
||||
premium = _bs_straddle(S0, K, T, sig)
|
||||
gamma_pnl = 0.0; hedge_fee = 0.0; prev_delta = None
|
||||
for t in range(1, STEPS + 1):
|
||||
tau = max((STEPS - (t - 1)) * dt, dt)
|
||||
Sp = px[i + t - 1]; Sn = px[i + t]
|
||||
r = Sn / Sp - 1.0
|
||||
d1 = (np.log(Sp / K) + 0.5 * sig ** 2 * tau) / (sig * np.sqrt(tau))
|
||||
dollar_gamma = norm.pdf(d1) * Sp / (sig * np.sqrt(tau))
|
||||
gamma_pnl += dollar_gamma * (r * r - sig * sig * dt)
|
||||
delta = 2.0 * norm.cdf(d1) - 1.0
|
||||
if prev_delta is not None:
|
||||
hedge_fee += HEDGE_FEE_SIDE * abs(delta - prev_delta) * Sp
|
||||
prev_delta = delta
|
||||
pnl = gamma_pnl - OPT_FEE_FRAC * premium - hedge_fee
|
||||
rets[idx[i + STEPS]] = pnl / S0
|
||||
i += STEPS
|
||||
out = pd.Series(rets)
|
||||
out.index = out.index.normalize()
|
||||
return out
|
||||
|
||||
|
||||
def gamma_scalp_asset(asset: str, mode: str = "naked",
|
||||
gate_cheap: float = 0.30, skip_rich: float = 0.90) -> pd.Series:
|
||||
"""Rendimenti settimanali (return-on-notional) di una catena di long-straddle delta-hedged,
|
||||
lumpati sul giorno di scadenza. Causale: IV/strike/gate da dati <= entry; payoff sul path."""
|
||||
px, dvf, idx = _load(asset)
|
||||
n = len(px)
|
||||
rets = {}
|
||||
i = 60
|
||||
while i + TENOR_D < n:
|
||||
S0 = px[i]; sig = dvf[i]; K = S0
|
||||
skip = False
|
||||
if i >= 60 and mode in ("cheap", "rich"):
|
||||
ivr = float((dvf[:i] < dvf[i]).mean()) # IV-rank espandente causale
|
||||
if mode == "cheap" and ivr > gate_cheap: # compra gamma solo se vol a SCONTO
|
||||
skip = True
|
||||
if mode == "rich" and ivr > skip_rich: # evita di pagare vol carissima
|
||||
skip = True
|
||||
if skip:
|
||||
rets[idx[i + TENOR_D]] = 0.0; i += TENOR_D; continue
|
||||
T = TENOR_D / 365.25
|
||||
premium = _bs_straddle(S0, K, T, sig)
|
||||
gamma_pnl = 0.0; hedge_fee = 0.0; prev_delta = None
|
||||
for t in range(1, TENOR_D + 1):
|
||||
tau = (TENOR_D - (t - 1)) / 365.25 # tempo residuo a inizio step
|
||||
Sp = px[i + t - 1]; Sn = px[i + t]
|
||||
r = Sn / Sp - 1.0
|
||||
d1 = (np.log(Sp / K) + 0.5 * sig ** 2 * tau) / (sig * np.sqrt(tau))
|
||||
dollar_gamma = norm.pdf(d1) * Sp / (sig * np.sqrt(tau)) # DG straddle = phi(d1)*S/(sig*sqrt(tau))
|
||||
gamma_pnl += dollar_gamma * (r * r - sig * sig * DT)
|
||||
delta = 2.0 * norm.cdf(d1) - 1.0 # delta straddle ATM
|
||||
if prev_delta is not None:
|
||||
hedge_fee += HEDGE_FEE_SIDE * abs(delta - prev_delta) * Sp
|
||||
prev_delta = delta
|
||||
opt_fee = OPT_FEE_FRAC * premium
|
||||
pnl = gamma_pnl - opt_fee - hedge_fee
|
||||
rets[idx[i + TENOR_D]] = pnl / S0 # return-on-notional
|
||||
i += TENOR_D
|
||||
return pd.Series(rets)
|
||||
|
||||
|
||||
def to_daily_voltgt(weekly_btc: pd.Series, weekly_eth: pd.Series, tgt_vol: float = 0.20) -> pd.Series:
|
||||
"""Book 50/50 BTC+ETH su griglia giornaliera, poi scalato a tgt_vol annuo (apples-to-apples
|
||||
con gli altri sleeve, tutti vol-targeted ~20%)."""
|
||||
wk = pd.concat({"B": weekly_btc, "E": weekly_eth}, axis=1, join="inner").mean(axis=1).sort_index()
|
||||
if wk.empty:
|
||||
return wk
|
||||
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
|
||||
sd = daily.std()
|
||||
if sd > 0:
|
||||
daily = daily * (tgt_vol / (sd * SQ365))
|
||||
return daily
|
||||
|
||||
|
||||
def _metrics(daily: pd.Series) -> dict:
|
||||
r = daily.values
|
||||
sh = float(np.mean(r) / np.std(r) * SQ365) if np.std(r) > 0 else 0.0
|
||||
eq = np.cumprod(1.0 + np.clip(r, -0.99, None))
|
||||
pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk))
|
||||
yrs = (daily.index[-1] - daily.index[0]).days / 365.25
|
||||
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and eq[-1] > 0 else -1.0
|
||||
s = pd.Series(eq, index=daily.index)
|
||||
yearly = {}
|
||||
for y, g in s.groupby(s.index.year):
|
||||
if len(g) > 1:
|
||||
v = g.values; p = np.maximum.accumulate(v)
|
||||
yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((p - v) / p)))
|
||||
return dict(sharpe=sh, dd=dd, cagr=cagr, yearly=yearly)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 92)
|
||||
print(" GAMMA SCALPING (long-vol) — scalping BTC/ETH con copertura in opzioni")
|
||||
print(" Modello: long straddle ATM 7g, delta-hedge 1d, P&L = DG*(RV^2 - IV^2). Mirror del VRP01.")
|
||||
print("=" * 92)
|
||||
|
||||
print("\n DIAGNOSTICA STRUTTURALE — vol implicita (DVOL) vs realizzata (il segno dell'edge):")
|
||||
for a in ("BTC", "ETH"):
|
||||
d = rv_iv_diagnostic(a)
|
||||
print(f" {a}: IV {d['iv']*100:5.1f}% | RV_1d {d['rv_daily']*100:5.1f}% "
|
||||
f"(IV-RV {d['spread_daily']*100:+.1f}pp) RV_1h {d['rv_hour']*100:5.1f}% "
|
||||
f"(IV-RV {d['spread_hour']*100:+.1f}pp)")
|
||||
print(" -> IV-RV > 0 = il mercato PAGA per essere short-vol (VRP). Il long gamma e' su questo lato"
|
||||
", al ROVESCIO: paga il premio. RV_1h>RV_1d -> rehedge orario cattura piu' RV (test sotto).")
|
||||
|
||||
variants = {
|
||||
"NAKED (sempre long gamma)": dict(mode="naked"),
|
||||
"CHEAP-GATED (IV-rank<0.30 = vol scont)": dict(mode="cheap", gate_cheap=0.30),
|
||||
"CHEAP-GATED (IV-rank<0.50)": dict(mode="cheap", gate_cheap=0.50),
|
||||
"RICH-SKIP (no entry se IV-rank>0.90)": dict(mode="rich", skip_rich=0.90),
|
||||
}
|
||||
|
||||
series = {}
|
||||
for label, kw in variants.items():
|
||||
wB = gamma_scalp_asset("BTC", **kw)
|
||||
wE = gamma_scalp_asset("ETH", **kw)
|
||||
daily = to_daily_voltgt(wB, wE)
|
||||
series[label] = daily
|
||||
m = _metrics(daily)
|
||||
ntr = int((wB != 0).sum() + (wE != 0).sum())
|
||||
print(f"\n--- {label} ---")
|
||||
print(f" standalone (vol-tgt 20%): Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% "
|
||||
f"maxDD {m['dd']*100:.1f}% trade {ntr}")
|
||||
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
||||
print(f" per-anno PnL: {ys}")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print(" REHEDGE ORARIO (la 'vera' frequenza di scalping: cattura RV intraday, paga 24x hedge fee)")
|
||||
print("=" * 92)
|
||||
for label, kw in (("NAKED orario", dict(mode="naked")),
|
||||
("CHEAP-GATED orario (IV-rank<0.30)", dict(mode="cheap", gate_cheap=0.30))):
|
||||
wB = gamma_scalp_hourly("BTC", **kw); wE = gamma_scalp_hourly("ETH", **kw)
|
||||
daily = to_daily_voltgt(wB, wE)
|
||||
m = _metrics(daily)
|
||||
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
||||
print(f"\n--- {label} ---")
|
||||
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}%")
|
||||
print(f" per-anno PnL: {ys}")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print(" SCORING MARGINALE vs TP01 (lo standard del progetto: un nuovo sleeve si giudica QUI)")
|
||||
print("=" * 92)
|
||||
for label, daily in series.items():
|
||||
if daily.std() == 0:
|
||||
print(f"\n[{label}] flat — skip"); continue
|
||||
m = marginal_vs_tp01(daily)
|
||||
b25 = m.get("blends", {}).get("w25", {})
|
||||
print(f"\n[{label}]")
|
||||
print(f" verdict={m.get('marginal_verdict')} corr->TP01 full={m.get('corr_full')} "
|
||||
f"hold={m.get('corr_hold')} is_hedge={m.get('is_hedge')} "
|
||||
f"has_insample_edge={m.get('has_insample_edge')} (cand IS Sharpe {m.get('cand_insample_sharpe')})")
|
||||
print(f" cand Sharpe full={m.get('cand_full_sharpe')} hold={m.get('cand_hold_sharpe')} | "
|
||||
f"blend25 full {b25.get('full')} (upl {b25.get('uplift_full')}) "
|
||||
f"hold {b25.get('hold')} (upl {b25.get('uplift_hold')}) DD {b25.get('dd')}")
|
||||
print(f" hedge-check: uplift TP01-up {m.get('uplift_tp01_up')} / TP01-down {m.get('uplift_tp01_down')} "
|
||||
f"yearly-corr {m.get('hedge_yearly_corr')}")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print(" ESEGUIBILITA' a ~$600 (Deribit options min size)")
|
||||
print("=" * 92)
|
||||
pxB = _load("BTC")[0][-1]; pxE = _load("ETH")[0][-1]
|
||||
for a, p, csz, minc in (("BTC", pxB, 1.0, 0.1), ("ETH", pxE, 1.0, 0.1)):
|
||||
notion = p * csz * minc
|
||||
print(f" {a}: spot ${p:,.0f} | contratto {csz} {a}, min {minc} {a} -> notional minimo "
|
||||
f"${notion:,.0f} ({'OK' if notion < 600 else 'OLTRE i $600 -> NON eseguibile'})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,604 @@
|
||||
"""orthogonal_signals.py — SIGNAL-BASED, BY-CONSTRUCTION-ORTHOGONAL streams (2026-06-29).
|
||||
|
||||
TESI (richiesta utente). Il book attivo è TP01 (trend) + XS01 (cross-sectional) + VRP01
|
||||
(short-vol) + SKH01 (regime L/S). Tutti hanno beta direzionale o vol crypto. Cerchiamo uno
|
||||
stream a **beta di mercato ~0 e bassa corr al book** ma con un **EDGE DI SEGNALE reale** —
|
||||
NON la "diversification math" di uno stream a Sharpe~0 (il marginal scorer indurito la boccia:
|
||||
serve has_insample_edge, Sharpe standalone PRE-2025 >= 0.5, deflated-Sharpe sull'intera griglia,
|
||||
e selezione della cella IN-SAMPLE, mai sul max hold-out).
|
||||
|
||||
FOCUS PRIMARIO — RELATIVE-VALUE ETH/BTC (dollar-neutral, 2 gambe). La posizione è sul SPREAD
|
||||
long-ETH/short-BTC (o viceversa): r_spread[i] = pos[i-1]*(r_eth[i]-r_btc[i]) - fee*2*|Δpos|.
|
||||
Per costruzione beta_mercato ~0 -> scorrelato a TP01/SKH. VANTAGGIO: un book a 2 gambe su
|
||||
Deribit perp (BTC+ETH entrambi live) è MOLTO più vicino all'eseguibile a $600 di uno a 19 gambe
|
||||
(XS01 è STAT-MODE). Segnali sul ratio log(ETH/BTC), tutti CAUSALI (decisione <= close[i]):
|
||||
1. RATIO-MOM momentum del ratio (trend dello spread).
|
||||
2. RATIO-REV reversal di breve del ratio.
|
||||
3. RATIO-ACCEL accelerazione (2a differenza / curvatura) del ratio.
|
||||
4. VOLSPREAD vol realizzata relativa BTC vs ETH -> verso l'asset col profilo giusto.
|
||||
5. DVOLSPREAD vol IMPLICITA relativa (al.dvol BTC/ETH) -> re-valida l'ex-lead 'dvol_spread'.
|
||||
6. STATARB-RESID residuo di ETH dopo beta*BTC (rolling OLS causale) -> mean-revert il residuo.
|
||||
|
||||
SECONDARIO — CRYPTO vs MACRO (GLD/QQQ/TLT): long/short crypto vs hedge su momentum relativo,
|
||||
merge_asof backward (equity 5gg/sett vs crypto 24/7), niente look-ahead. Probabile debole.
|
||||
|
||||
GATE (tutti obbligatori, replicano altlib indurito):
|
||||
* CAUSALITÀ: prefix-check sul SPREAD (ricostruisci su prefisso, la coda deve combaciare).
|
||||
* NETTO fee 0.10% RT su 2 gambe + SWEEP (0.00-0.30% RT).
|
||||
* SELEZIONE CELLA IN-SAMPLE-ONLY (pre-2025), MAI sul max hold-out (punto cieco filone B).
|
||||
* DEFLATED-SHARPE su TUTTE le celle cercate (multiple-testing).
|
||||
* has_insample_edge: Sharpe standalone PRE-2025 >= 0.5 (no diversification-math).
|
||||
* OOS hold-out 2025+, plateau su griglia, per-anno.
|
||||
* corr vs BOOK 4-sleeve (|corr|<0.2 ideale) + beta vs mercato (50/50 BTC+ETH) ~0.
|
||||
* marginal_vs_tp01 (ADDS / HEDGE / NOISE / NEUTRAL) -> earns_slot_honest.
|
||||
* EXEC $600: haircut a 2 gambe (min $5/gamba, fee 0.10% RT) — il punto FORTE del filone.
|
||||
|
||||
ONESTÀ BRUTALE: bassa-corr da sola NON basta. dvol_spread era forward-monitor (storia DVOL
|
||||
corta + multiple-testing); ratio_accel era lead debole. Se è diversification-math o
|
||||
hold-out-fitting -> NOISE/SCARTATO con numeri.
|
||||
|
||||
Esecuzione: cd /opt/docker/PythagorasGoal && uv run python scripts/research/orthogonal_signals.py
|
||||
Idempotente, solo stdout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import math
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
_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
|
||||
|
||||
FEE_SIDE = al.FEE_SIDE # 0.0005 = 0.05%/side
|
||||
FEE_SWEEP = (0.0, 0.00025, 0.0005, 0.001, 0.0015) # per-side; ×2 legs ×2 (RT) for RT%
|
||||
HOLDOUT = al.HOLDOUT
|
||||
ANN = 365.25
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# JOINT FRAME + DOLLAR-NEUTRAL EVALUATOR (custom — eval_weights is single-asset)
|
||||
# ===========================================================================
|
||||
def build_joint(tf: str = "1d") -> pd.DataFrame:
|
||||
"""BTC/ETH allineati sull'indice comune (inner join su timestamp). Ritorna un frame con
|
||||
r_btc/r_eth (simple), close_*, log_ratio = log(ETH/BTC), datetime, timestamp."""
|
||||
b = al.get("BTC", tf)[["timestamp", "datetime", "close"]].rename(columns={"close": "cb"})
|
||||
e = al.get("ETH", tf)[["timestamp", "close"]].rename(columns={"close": "ce"})
|
||||
j = b.merge(e, on="timestamp", how="inner").sort_values("timestamp").reset_index(drop=True)
|
||||
j["r_btc"] = al.simple_returns(j["cb"].values)
|
||||
j["r_eth"] = al.simple_returns(j["ce"].values)
|
||||
j["log_ratio"] = np.log(j["ce"].values / j["cb"].values)
|
||||
return j
|
||||
|
||||
|
||||
def spread_ret(j: pd.DataFrame) -> np.ndarray:
|
||||
"""Ritorno dollar-neutral per unità di gross-per-gamba: long $1 ETH, short $1 BTC."""
|
||||
return j["r_eth"].values - j["r_btc"].values
|
||||
|
||||
|
||||
def vol_target_spread(direction: np.ndarray, j: pd.DataFrame, target_vol: float = 0.20,
|
||||
win_days: int = 30, cap: float = 2.0) -> np.ndarray:
|
||||
"""Scala una direzione in [-1,1] a vol-target sullo SPREAD (causale: vol realizzata <= i)."""
|
||||
s = spread_ret(j)
|
||||
bpd = al.bars_per_day(j)
|
||||
bpy = bpd * ANN
|
||||
vol = al.realized_vol(s, max(2, win_days * bpd), bpy)
|
||||
scal = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0)
|
||||
pos = np.clip(np.nan_to_num(direction) * scal, -cap, cap)
|
||||
pos[~np.isfinite(pos)] = 0.0
|
||||
return pos
|
||||
|
||||
|
||||
def eval_spread(j: pd.DataFrame, pos: np.ndarray, fee_side: float = FEE_SIDE) -> dict:
|
||||
"""Backtest ONESTO del SPREAD. pos[i] decisa <= close[i], TENUTA durante la barra i+1
|
||||
(lo shift è qui -> niente leak). Fee su 2 GAMBE: ogni Δpos muove ETH e BTC -> 2×|Δpos|."""
|
||||
pos = np.nan_to_num(np.asarray(pos, float))
|
||||
s = spread_ret(j)
|
||||
held = np.zeros(len(pos)); held[1:] = pos[:-1]
|
||||
gross = held * s
|
||||
turn = np.abs(np.diff(held, prepend=0.0)) # turnover per-gamba
|
||||
net = gross - fee_side * 2.0 * turn # 2 gambe
|
||||
net[0] = 0.0
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(j["datetime"], utc=True))
|
||||
full = al._metrics_from_net(net, idx)
|
||||
hmask = idx >= HOLDOUT
|
||||
hold = al._metrics_from_net(net[hmask], idx[hmask]) if hmask.sum() > 3 else dict(sharpe=0.0, n=0)
|
||||
return dict(full=full, holdout=hold, yearly=al._yearly(net, idx),
|
||||
tim=round(float(np.mean(held != 0)), 3),
|
||||
turnover=round(float(turn.sum() / (len(turn) / (al.bars_per_day(j) * ANN))), 1),
|
||||
net=net, idx=idx)
|
||||
|
||||
|
||||
def spread_daily(j: pd.DataFrame, pos: np.ndarray, fee_side: float = FEE_SIDE) -> pd.Series:
|
||||
"""Serie NET giornaliera del candidato spread (compounded a 1d se TF sub-daily)."""
|
||||
ev = eval_spread(j, pos, fee_side=fee_side)
|
||||
s = pd.Series(ev["net"], index=ev["idx"])
|
||||
return al._to_daily(s)
|
||||
|
||||
|
||||
def eval_spread_smallcap(j: pd.DataFrame, pos: np.ndarray, capital: float = 600.0,
|
||||
min_order: float = 5.0, fee_side: float = FEE_SIDE) -> dict:
|
||||
"""Net REALISTICO a $600 su 2 GAMBE. Un Δpos il cui nozionale PER-GAMBA |Δpos|*capital < $5
|
||||
NON si esegue (held). Le due gambe cambiano dello stesso |Δ| -> il vincolo binding è
|
||||
|Δpos|*capital >= min_order. Riporta Sharpe modellato vs realistico + haircut + n trade."""
|
||||
pos = np.clip(np.nan_to_num(np.asarray(pos, float)), -10, 10)
|
||||
held = np.empty(len(pos)); cur = 0.0; n_tr = 0
|
||||
for i in range(len(pos)):
|
||||
if abs(pos[i] - cur) * capital >= min_order:
|
||||
cur = pos[i]; n_tr += 1
|
||||
held[i] = cur
|
||||
s = spread_ret(j)
|
||||
p = np.zeros(len(held)); p[1:] = held[:-1]
|
||||
turn = np.abs(np.diff(p, prepend=0.0))
|
||||
net = p * s - fee_side * 2.0 * turn; net[0] = 0.0
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(j["datetime"], utc=True))
|
||||
real = al._metrics_from_net(net, idx)
|
||||
modeled = eval_spread(j, pos, fee_side=fee_side)["full"]
|
||||
return dict(realistic=real, modeled=modeled,
|
||||
sharpe_haircut=round(modeled["sharpe"] - real["sharpe"], 3),
|
||||
n_executed_trades=int(n_tr))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CAUSALITY — prefix-check sul SPREAD (la coda del prefisso deve combaciare col full).
|
||||
# ===========================================================================
|
||||
def causality_spread(make_pos, tf: str = "1d", tail: int = 80, tol: float = 1e-6) -> dict:
|
||||
"""make_pos(j) -> pos sull'intero frame. Ricostruisce su prefissi; la coda deve combaciare."""
|
||||
j = build_joint(tf)
|
||||
full = np.nan_to_num(make_pos(j))
|
||||
n = len(j)
|
||||
worst = 0.0; bad = False; checked = 0
|
||||
for cut in (int(n * 0.80), int(n * 0.92)):
|
||||
if cut <= tail + 5 or cut >= n:
|
||||
continue
|
||||
sub = j.iloc[:cut].reset_index(drop=True)
|
||||
s = np.nan_to_num(make_pos(sub))
|
||||
if len(s) != cut:
|
||||
bad = True; continue
|
||||
d = np.abs(s[cut - tail:cut] - full[cut - tail:cut])
|
||||
worst = max(worst, float(np.max(d)) if len(d) else 0.0)
|
||||
checked += 1
|
||||
return dict(ok=bool((not bad) and worst <= tol), max_tail_diff=round(worst, 10), checked=checked)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SIGNAL FACTORIES — factory(tf, **p) -> make_pos(j) -> vol-targeted position on the spread
|
||||
# (direzione in [-1,1] internamente, poi vol_target_spread). 'sgn' (+/-1) testa il verso.
|
||||
# ===========================================================================
|
||||
def f_ratio_mom(tf="1d", L=30, sgn=1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
lr = j["log_ratio"].values
|
||||
mom = lr - pd.Series(lr).shift(L).values # momentum L-barre del ratio
|
||||
z = al.zscore(mom, max(20, L))
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_ratio_rev(tf="1d", L=5, sgn=-1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
lr = j["log_ratio"].values
|
||||
z = al.zscore(lr, L) # deviazione di breve dal trend locale
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_ratio_accel(tf="1d", L=20, sgn=-1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
lr = pd.Series(j["log_ratio"].values)
|
||||
accel = lr - 2 * lr.shift(L) + lr.shift(2 * L) # 2a differenza (curvatura)
|
||||
z = al.zscore(np.nan_to_num(accel.values), max(20, L))
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_volspread(tf="1d", W=30, sgn=1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
bpd = al.bars_per_day(j); bpy = bpd * ANN
|
||||
vb = al.realized_vol(j["r_btc"].values, max(2, W * bpd), bpy)
|
||||
ve = al.realized_vol(j["r_eth"].values, max(2, W * bpd), bpy)
|
||||
z = al.zscore(np.nan_to_num(vb - ve), max(30, W)) # BTC più volatile di ETH?
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_dvolspread(tf="1d", W=30, sgn=1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
db = al.dvol(j, "BTC"); de = al.dvol(j, "ETH") # vol IMPLICITA causale (merge_asof)
|
||||
sp = np.nan_to_num(db - de)
|
||||
z = al.zscore(sp, max(30, W))
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_statarb_resid(tf="1d", W=60, sgn=-1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
x = np.log(j["cb"].values); y = np.log(j["ce"].values) # OLS rolling causale y~a+b x
|
||||
sx = pd.Series(x); sy = pd.Series(y)
|
||||
mx = sx.rolling(W, min_periods=W).mean()
|
||||
my = sy.rolling(W, min_periods=W).mean()
|
||||
cov = (sx * sy).rolling(W, min_periods=W).mean() - mx * my
|
||||
var = (sx * sx).rolling(W, min_periods=W).mean() - mx * mx
|
||||
beta = (cov / var.replace(0, np.nan))
|
||||
resid = (sy - (my - beta * mx) - beta * sx).values # resid al tempo i (usa <= i)
|
||||
z = al.zscore(np.nan_to_num(resid), W)
|
||||
d = sgn * np.tanh(np.nan_to_num(z)) # mean-revert il residuo
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
FAMILIES = {
|
||||
"RATIO-MOM": (f_ratio_mom, [dict(L=L, sgn=s) for L in (15, 30, 45, 60, 90) for s in (1, -1)], ("1d",)),
|
||||
"RATIO-REV": (f_ratio_rev, [dict(L=L, sgn=s) for L in (3, 5, 8, 12, 20) for s in (1, -1)], ("1d",)),
|
||||
"RATIO-ACCEL": (f_ratio_accel, [dict(L=L, sgn=s) for L in (10, 20, 30, 45) for s in (1, -1)], ("1d",)),
|
||||
"VOLSPREAD": (f_volspread, [dict(W=W, sgn=s) for W in (10, 20, 30, 60) for s in (1, -1)], ("1d",)),
|
||||
"DVOLSPREAD": (f_dvolspread, [dict(W=W, sgn=s) for W in (15, 30, 45, 60) for s in (1, -1)], ("1d",)),
|
||||
"STATARB-RESID": (f_statarb_resid, [dict(W=W, sgn=s) for W in (30, 45, 60, 90, 120) for s in (1, -1)], ("1d",)),
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# BOOK + MARKET references (for corr / beta)
|
||||
# ===========================================================================
|
||||
def book_daily() -> pd.Series:
|
||||
"""Serie NET giornaliera del BOOK attivo a 4 sleeve (TP01+XS01+VRP01+SKH01)."""
|
||||
from src.portfolio.sleeves import active_sleeves
|
||||
from src.portfolio.portfolio import StrategyPortfolio
|
||||
pf = StrategyPortfolio(active_sleeves()); pf.backtest()
|
||||
s = pf.combined_daily()
|
||||
if not isinstance(s.index, pd.DatetimeIndex):
|
||||
s.index = pd.to_datetime(s.index, utc=True)
|
||||
if s.index.tz is None:
|
||||
s.index = s.index.tz_localize("UTC")
|
||||
return s.dropna()
|
||||
|
||||
|
||||
def market_daily() -> pd.Series:
|
||||
"""Mercato di riferimento: 50/50 BTC+ETH ritorni semplici giornalieri (per beta ~0)."""
|
||||
series = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
df = al.get(a, "1d")
|
||||
series[a] = pd.Series(al.simple_returns(df["close"].values),
|
||||
index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)))
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
return (0.5 * J["BTC"] + 0.5 * J["ETH"]).dropna()
|
||||
|
||||
|
||||
def beta_to(cand: pd.Series, ref: pd.Series) -> tuple[float, float]:
|
||||
J = pd.concat({"c": cand, "r": ref}, axis=1, join="inner").dropna()
|
||||
if len(J) < 30 or J["r"].var() == 0:
|
||||
return float("nan"), float("nan")
|
||||
c, r = J["c"].values, J["r"].values
|
||||
beta = float(np.cov(c, r)[0, 1] / np.var(r))
|
||||
return round(beta, 3), round(float(J["c"].corr(J["r"])), 3)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# HONEST FAMILY STUDY for the SPREAD (replica study_family_honest a mano):
|
||||
# (1) cella scelta su Sharpe IN-SAMPLE (pre-HOLDOUT), MAI sul max hold-out;
|
||||
# (2) deflated-Sharpe su TUTTE le celle; (3) marginal_vs_tp01 sulla cella scelta.
|
||||
# ===========================================================================
|
||||
def _sh(s) -> float:
|
||||
return al._sh(s)
|
||||
|
||||
|
||||
def study_spread_family(name, factory, grid, tfs, refs, fee_side=FEE_SIDE, dsr_min=0.95) -> dict:
|
||||
rows = []
|
||||
for tf in tfs:
|
||||
for p in grid:
|
||||
try:
|
||||
j = build_joint(tf)
|
||||
pos = factory(tf=tf, **p)(j)
|
||||
daily = spread_daily(j, pos, fee_side=fee_side)
|
||||
except Exception as ex: # pragma: no cover
|
||||
rows.append(dict(tf=tf, params=p, err=str(ex)[:60])); continue
|
||||
ins = daily[daily.index < HOLDOUT]
|
||||
is_sh = _sh(ins) if len(ins) > 60 else float("nan")
|
||||
rows.append(dict(tf=tf, params=p, daily=daily, insample_sharpe=round(is_sh, 3),
|
||||
full_sharpe=round(_sh(daily), 3)))
|
||||
valid = [r for r in rows if r.get("insample_sharpe") is not None
|
||||
and np.isfinite(r.get("insample_sharpe", np.nan))]
|
||||
if not valid:
|
||||
return dict(name=name, chosen=None, earns_slot_honest=False, reason="no valid in-sample cell")
|
||||
chosen = max(valid, key=lambda r: r["insample_sharpe"])
|
||||
all_full = [r["full_sharpe"] for r in rows if r.get("full_sharpe") is not None]
|
||||
|
||||
j = build_joint(chosen["tf"])
|
||||
pos = factory(tf=chosen["tf"], **chosen["params"])(j)
|
||||
daily = chosen["daily"]
|
||||
ev = eval_spread(j, pos, fee_side=fee_side)
|
||||
|
||||
# fee sweep (RT% = per-side ×2 legs? No: RT per leg = 2×side; on 2 legs the cost already
|
||||
# ×2 in eval_spread. Report per-side grid -> "X%RT/leg".)
|
||||
sweep = {}
|
||||
for f in FEE_SWEEP:
|
||||
sweep[f"{2*f*100:.2f}%RT/leg"] = round(eval_spread(j, pos, fee_side=f)["full"]["sharpe"], 3)
|
||||
fee_survives = sweep.get(f"{2*0.0015*100:.2f}%RT/leg", -9) > 0
|
||||
|
||||
# marginal vs TP01
|
||||
marg = al.marginal_vs_tp01(daily)
|
||||
# deflated Sharpe over the WHOLE grid (come da istruzione)
|
||||
dsr, sr0 = al.deflated_sharpe(_sh(daily), all_full, daily)
|
||||
dsr_pass = bool(np.isfinite(dsr) and dsr >= dsr_min)
|
||||
# SENSIBILITÀ: la griglia include sgn=+1 E sgn=-1 (specchi: +Sh e -Sh) -> raddoppia la
|
||||
# dispersione dei trial e gonfia il null-max. DSR "same-sign" = deflazione sul SOLO verso
|
||||
# scelto (conta i soli lookback davvero in competizione). È il limite ottimistico onesto.
|
||||
same = [r["full_sharpe"] for r in rows
|
||||
if r.get("full_sharpe") is not None and r["params"].get("sgn") == chosen["params"].get("sgn")]
|
||||
dsr_ss, sr0_ss = al.deflated_sharpe(_sh(daily), same if len(same) >= 2 else all_full, daily)
|
||||
dsr_ss_pass = bool(np.isfinite(dsr_ss) and dsr_ss >= dsr_min)
|
||||
|
||||
# corr/beta vs BOOK & MARKET
|
||||
bbeta, bcorr = beta_to(daily, refs["book"])
|
||||
mbeta, mcorr = beta_to(daily, refs["market"])
|
||||
btcbeta, btccorr = beta_to(daily, refs["btc"])
|
||||
ethbeta, ethcorr = beta_to(daily, refs["eth"])
|
||||
|
||||
# $600 executability (2 legs)
|
||||
sc = eval_spread_smallcap(j, pos)
|
||||
|
||||
earns = bool(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)
|
||||
and dsr_pass and fee_survives)
|
||||
|
||||
return dict(name=name, n_cells=len(all_full), chosen=chosen, rows=valid,
|
||||
ev=ev, sweep=sweep, fee_survives=fee_survives, marginal=marg,
|
||||
deflated_sharpe=round(dsr, 3) if np.isfinite(dsr) else None,
|
||||
expected_null_max=round(sr0, 3) if np.isfinite(sr0) else None, dsr_pass=dsr_pass,
|
||||
dsr_samesign=round(dsr_ss, 3) if np.isfinite(dsr_ss) else None,
|
||||
expected_null_max_ss=round(sr0_ss, 3) if np.isfinite(sr0_ss) else None,
|
||||
dsr_ss_pass=dsr_ss_pass, n_cells_samesign=len(same),
|
||||
book=dict(beta=bbeta, corr=bcorr), market=dict(beta=mbeta, corr=mcorr),
|
||||
btc=dict(beta=btcbeta, corr=btccorr), eth=dict(beta=ethbeta, corr=ethcorr),
|
||||
smallcap=sc, earns_slot_honest=earns)
|
||||
|
||||
|
||||
def verdict_for(rep: dict) -> tuple[str, str]:
|
||||
if rep.get("chosen") is None:
|
||||
return "SCARTATO", "nessuna cella valida"
|
||||
m = rep["marginal"]
|
||||
is_edge = m.get("has_insample_edge"); is_sh = m.get("cand_insample_sharpe")
|
||||
dsr = rep.get("deflated_sharpe"); mv = m.get("marginal_verdict")
|
||||
ss = rep.get("dsr_samesign"); ss_tag = f"same-sign {ss} {'pass' if rep.get('dsr_ss_pass') else 'fail'}"
|
||||
if rep["earns_slot_honest"]:
|
||||
return "SLEEVE-CANDIDATE-eseguibile", "ADDS + robust_oos + edge in-sample + DSR pass + fee/exec ok"
|
||||
if mv == "DILUTES":
|
||||
return "SCARTATO", "DILUTES (abbassa il blend del book)"
|
||||
if m.get("is_hedge"):
|
||||
return "SCARTATO", "HEDGE (paga solo quando TP01 è debole, non alpha)"
|
||||
if not is_edge:
|
||||
return "NOISE", f"no edge in-sample (Sharpe<2025 {is_sh} < 0.5) -> diversification-math"
|
||||
# ha edge in-sample reale: il discriminante è se MIGLIORA il book (marginal ADDS)
|
||||
if mv == "ADDS":
|
||||
if not rep.get("dsr_pass"):
|
||||
return "LEAD-forward-monitor", (f"ADDS + edge in-sample {is_sh} + executable, MA deflated-Sharpe "
|
||||
f"full-grid {dsr} < 0.95 ({ss_tag}) -> multiple-testing")
|
||||
if not m.get("robust_oos"):
|
||||
return "LEAD-forward-monitor", "ADDS + edge ma non robust_oos (single-window / multi-cut debole)"
|
||||
return "LEAD-forward-monitor", "ADDS ma blocco fee/exec"
|
||||
# edge standalone reale ma marginal NEUTRAL/REDUNDANT: NON migliora il book -> niente slot
|
||||
return "NEUTRAL-standalone", (f"edge in-sample {is_sh} reale ma marginal={mv}: non migliora il book "
|
||||
f"(corr~0 senza uplift = diversification-math; hold-out debole)")
|
||||
|
||||
|
||||
def print_family(rep: dict):
|
||||
print("=" * 100)
|
||||
print(f"### {rep['name']}")
|
||||
if rep.get("chosen") is None:
|
||||
print(" SCARTATO:", rep.get("reason")); return
|
||||
ch = rep["chosen"]; ev = rep["ev"]; m = rep["marginal"]
|
||||
print(f" best cell (IN-SAMPLE pick): tf={ch['tf']} params={ch['params']} "
|
||||
f"[cercate {rep['n_cells']} celle]")
|
||||
print(f" in-sample Sharpe (pre-2025) {ch['insample_sharpe']} | FULL Sharpe {ev['full']['sharpe']} "
|
||||
f"DD {ev['full']['maxdd']*100:.1f}% ret {ev['full']['ret']*100:+.0f}% | "
|
||||
f"HOLD Sharpe {ev['holdout'].get('sharpe')} ret {ev['holdout'].get('ret',0)*100:+.0f}%")
|
||||
print(f" time-in-mkt {ev['tim']} turnover/yr {ev['turnover']}")
|
||||
print(f" per-anno: " + " ".join(f"{y}:{d['ret']*100:+.0f}%(dd{d['dd']*100:.0f})"
|
||||
for y, d in ev["yearly"].items()))
|
||||
print(f" fee sweep (per-leg RT): {rep['sweep']} fee_survives={rep['fee_survives']}")
|
||||
print(f" corr vs BOOK {rep['book']['corr']} (beta {rep['book']['beta']}) | "
|
||||
f"beta vs MERCATO(50/50) {rep['market']['beta']} (corr {rep['market']['corr']})")
|
||||
print(f" corr/beta vs BTC {rep['btc']['corr']}/{rep['btc']['beta']} "
|
||||
f"vs ETH {rep['eth']['corr']}/{rep['eth']['beta']}")
|
||||
print(f" MARGINAL vs TP01: verdict={m.get('marginal_verdict')} corr_full={m.get('corr_full')} "
|
||||
f"corr_hold={m.get('corr_hold')}")
|
||||
print(f" has_insample_edge={m.get('has_insample_edge')} (standalone Sh<2025 {m.get('cand_insample_sharpe')}) "
|
||||
f"robust_oos={m.get('robust_oos')} multicut={m.get('multicut_persistent')} {m.get('multicut_uplift')}")
|
||||
print(f" blend w25: full {m['blends']['w25']['full']} (uplift {m['blends']['w25']['uplift_full']:+.3f}) "
|
||||
f"hold {m['blends']['w25']['hold']} (uplift {m['blends']['w25']['uplift_hold']}) is_hedge={m.get('is_hedge')}")
|
||||
print(f" DEFLATED-Sharpe full-grid {rep['deflated_sharpe']} (null-max {rep['expected_null_max']}, "
|
||||
f"{rep['n_cells']} celle) pass={rep['dsr_pass']} | same-sign {rep['dsr_samesign']} "
|
||||
f"(null-max {rep['expected_null_max_ss']}, {rep['n_cells_samesign']} celle) pass={rep['dsr_ss_pass']}")
|
||||
sc = rep["smallcap"]
|
||||
print(f" EXEC $600 (2 gambe): modeled Sh {sc['modeled']['sharpe']} -> realistic {sc['realistic']['sharpe']} "
|
||||
f"(haircut {sc['sharpe_haircut']}) trade eseguiti {sc['n_executed_trades']}")
|
||||
v, why = verdict_for(rep)
|
||||
print(f" >>> VERDETTO: {v} — {why}")
|
||||
print(f" earns_slot_honest = {rep['earns_slot_honest']}")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SECONDARIO — CRYPTO vs MACRO (GLD/QQQ/TLT) momentum relativo, merge_asof backward.
|
||||
# ===========================================================================
|
||||
def _eq_daily(sym: str) -> pd.Series:
|
||||
p = _ROOT / "data" / "raw" / f"eq_{sym}_1d.parquet"
|
||||
d = pd.read_parquet(p).sort_values("timestamp").reset_index(drop=True)
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(d["timestamp"], unit="ms", utc=True))
|
||||
return pd.Series(d["close"].values, index=idx)
|
||||
|
||||
|
||||
def study_macro_rv(hedge: str, L: int = 60, sgn: int = 1, fee_side: float = FEE_SIDE,
|
||||
refs: dict | None = None) -> dict:
|
||||
"""Long crypto (50/50 BTC+ETH) / short hedge ETF su momentum relativo L-giorni.
|
||||
Allineamento: calendario dell'EQUITY (trading days); crypto allineata backward (no look-ahead).
|
||||
Decisione <= close[i] del giorno di trading."""
|
||||
eqc = _eq_daily(hedge)
|
||||
# crypto close 50/50 (geometric proxy: media dei log-prezzi normalizzati)
|
||||
cb = al.get("BTC", "1d"); ce = al.get("ETH", "1d")
|
||||
cbi = pd.Series(cb["close"].values, index=pd.DatetimeIndex(pd.to_datetime(cb["datetime"], utc=True)))
|
||||
cei = pd.Series(ce["close"].values, index=pd.DatetimeIndex(pd.to_datetime(ce["datetime"], utc=True)))
|
||||
cj = pd.concat({"b": cbi, "e": cei}, axis=1, join="inner").dropna()
|
||||
crypto = np.exp(0.5 * np.log(cj["b"]) + 0.5 * np.log(cj["e"])) # crypto index level
|
||||
# merge_asof backward: per ogni giorno equity, l'ultimo close crypto <= quel giorno
|
||||
L_ = pd.DataFrame({"ts": eqc.index, "eq": eqc.values}).sort_values("ts")
|
||||
R_ = pd.DataFrame({"ts": crypto.index, "cr": crypto.values}).sort_values("ts")
|
||||
mg = pd.merge_asof(L_, R_, on="ts", direction="backward").dropna()
|
||||
eqv = mg["eq"].values; crv = mg["cr"].values; idx = pd.DatetimeIndex(mg["ts"])
|
||||
r_eq = al.simple_returns(eqv); r_cr = al.simple_returns(crv)
|
||||
s = r_cr - r_eq # long crypto / short hedge
|
||||
lr = np.log(crv) - np.log(eqv) # log relative level
|
||||
mom = lr - pd.Series(lr).shift(L).values
|
||||
z = al.zscore(np.nan_to_num(mom), max(20, L))
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
# vol target on the macro spread
|
||||
vol = al.realized_vol(s, 30, ANN)
|
||||
scal = np.where((vol > 0) & np.isfinite(vol), 0.20 / vol, 0.0)
|
||||
pos = np.clip(np.nan_to_num(d) * scal, -2.0, 2.0)
|
||||
held = np.zeros(len(pos)); held[1:] = pos[:-1]
|
||||
net = held * s - fee_side * 2.0 * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0
|
||||
full = al._metrics_from_net(net, idx)
|
||||
hmask = idx >= HOLDOUT
|
||||
hold = al._metrics_from_net(net[hmask], idx[hmask]) if hmask.sum() > 3 else dict(sharpe=0.0, n=0)
|
||||
daily = al._to_daily(pd.Series(net, index=idx))
|
||||
ins = daily[daily.index < HOLDOUT]
|
||||
is_sh = round(_sh(ins), 3) if len(ins) > 60 else None
|
||||
bcorr = beta_to(daily, refs["book"])[1] if refs else None
|
||||
mbeta, mcorr = beta_to(daily, refs["market"]) if refs else (None, None)
|
||||
btcb = beta_to(daily, refs["btc"])[0] if refs else None
|
||||
return dict(hedge=hedge, L=L, sgn=sgn, full=full, holdout=hold,
|
||||
insample_sharpe=is_sh, book_corr=bcorr, mkt_beta=mbeta, mkt_corr=mcorr,
|
||||
btc_beta=btcb, yearly=al._yearly(net, idx), daily=daily)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MAIN
|
||||
# ===========================================================================
|
||||
def main():
|
||||
print("#" * 100)
|
||||
print("# ORTHOGONAL SIGNALS — RELATIVE-VALUE ETH/BTC (dollar-neutral) + CRYPTO-vs-MACRO")
|
||||
print("#" * 100)
|
||||
j = build_joint("1d")
|
||||
print(f"Joint BTC/ETH 1d: {len(j)} barre {j['datetime'].min().date()} -> {j['datetime'].max().date()} "
|
||||
f"(hold-out {HOLDOUT.date()}+)")
|
||||
s = spread_ret(j)
|
||||
print(f"Spread r_eth-r_btc: vol annua {np.std(s)*math.sqrt(ANN)*100:.0f}% "
|
||||
f"corr(r_eth,r_btc)={np.corrcoef(j['r_eth'].values[1:], j['r_btc'].values[1:])[0,1]:.2f}")
|
||||
|
||||
# references
|
||||
print("\nCarico riferimenti BOOK / MERCATO ...")
|
||||
refs = dict(book=book_daily(), market=market_daily())
|
||||
cb = al.get("BTC", "1d"); ce = al.get("ETH", "1d")
|
||||
refs["btc"] = pd.Series(al.simple_returns(cb["close"].values),
|
||||
index=pd.DatetimeIndex(pd.to_datetime(cb["datetime"], utc=True)))
|
||||
refs["eth"] = pd.Series(al.simple_returns(ce["close"].values),
|
||||
index=pd.DatetimeIndex(pd.to_datetime(ce["datetime"], utc=True)))
|
||||
print(f" BOOK 4-sleeve: {len(refs['book'])} giorni, Sharpe {_sh(refs['book']):.2f}")
|
||||
|
||||
# causality smoke-test on one cell per family
|
||||
print("\n--- CAUSALITÀ (prefix-check sullo SPREAD, tol 1e-6) ---")
|
||||
for name, (fac, grid, tfs) in FAMILIES.items():
|
||||
p0 = grid[0]
|
||||
ck = causality_spread(fac(tf=tfs[0], **p0), tf=tfs[0])
|
||||
print(f" {name:14s} ok={ck['ok']} max_tail_diff={ck['max_tail_diff']} checked={ck['checked']}")
|
||||
|
||||
# study each family
|
||||
print("\n" + "#" * 100)
|
||||
print("# RELATIVE-VALUE ETH/BTC — selezione cella IN-SAMPLE, deflated-Sharpe su tutta la griglia")
|
||||
print("#" * 100)
|
||||
summary = []
|
||||
for name, (fac, grid, tfs) in FAMILIES.items():
|
||||
rep = study_spread_family(name, fac, grid, tfs, refs)
|
||||
print_family(rep)
|
||||
v, why = verdict_for(rep)
|
||||
ch = rep.get("chosen")
|
||||
summary.append(dict(name=name, verdict=v,
|
||||
cell=(ch["params"] if ch else None),
|
||||
full=rep["ev"]["full"]["sharpe"] if ch else None,
|
||||
hold=rep["ev"]["holdout"].get("sharpe") if ch else None,
|
||||
is_sh=rep["marginal"].get("cand_insample_sharpe") if ch else None,
|
||||
book_corr=rep["book"]["corr"] if ch else None,
|
||||
mkt_beta=rep["market"]["beta"] if ch else None,
|
||||
dsr=rep.get("deflated_sharpe") if ch else None,
|
||||
marginal=rep["marginal"].get("marginal_verdict") if ch else None,
|
||||
earns=rep.get("earns_slot_honest") if ch else False,
|
||||
haircut=rep["smallcap"]["sharpe_haircut"] if ch else None))
|
||||
|
||||
# secondary: crypto vs macro
|
||||
print("\n" + "#" * 100)
|
||||
print("# SECONDARIO — CRYPTO vs MACRO (relative momentum, merge_asof backward)")
|
||||
print("#" * 100)
|
||||
macro_rows = []
|
||||
for hedge in ("gld", "qqq", "tlt"):
|
||||
best = None
|
||||
for L in (30, 60, 90):
|
||||
for sgn in (1, -1):
|
||||
r = study_macro_rv(hedge, L=L, sgn=sgn, refs=refs)
|
||||
if best is None or (r["insample_sharpe"] or -9) > (best["insample_sharpe"] or -9):
|
||||
best = r
|
||||
macro_rows.append(best)
|
||||
print(f" {hedge.upper():4s} bestIS L={best['L']} sgn={best['sgn']}: "
|
||||
f"FULL Sh {best['full']['sharpe']} DD {best['full']['maxdd']*100:.0f}% "
|
||||
f"HOLD Sh {best['holdout'].get('sharpe')} in-sample Sh {best['insample_sharpe']} "
|
||||
f"corr->BOOK {best['book_corr']} beta->MERCATO {best['mkt_beta']} (corr {best['mkt_corr']}) "
|
||||
f"beta->BTC {best['btc_beta']}")
|
||||
print(" NOTA: la vol crypto (~47%) domina GLD/TLT/QQQ (~15%) -> r_cr-r_hedge ≈ r_cr: la 'relative"
|
||||
" momentum' è di fatto MOMENTUM CRYPTO (la gamba hedge è troppo poco volatile per neutralizzare).")
|
||||
print(" Beta-mercato ~0 solo perché il momentum entra/esce dall'esposizione; ma la corr->BOOK"
|
||||
" 0.17-0.20 (vs ~0.02 degli spread ETH/BTC) tradisce l'overlap col trend di TP01 -> NON ortogonale.")
|
||||
# marginal gate onesto sul migliore macro (per smentire il numero di Sharpe tentatore)
|
||||
bm = max(macro_rows, key=lambda r: (r["insample_sharpe"] or -9))
|
||||
mg = al.marginal_vs_tp01(bm["daily"])
|
||||
print(f" MARGINAL gate sul migliore ({bm['hedge'].upper()}): verdict={mg.get('marginal_verdict')} "
|
||||
f"corr_full={mg.get('corr_full')} blend-uplift w25 full {mg['blends']['w25']['uplift_full']:+.3f} "
|
||||
f"hold {mg['blends']['w25']['uplift_hold']} robust_oos={mg.get('robust_oos')} "
|
||||
f"-> {'overlap col trend (no slot)' if mg.get('marginal_verdict') in ('NEUTRAL','REDUNDANT','DILUTES') else 'da gate completo'}")
|
||||
|
||||
# final table
|
||||
print("\n" + "#" * 100)
|
||||
print("# SINTESI")
|
||||
print("#" * 100)
|
||||
print(f"{'segnale':14s} {'verdetto':28s} {'FULL':>5s} {'HOLD':>5s} {'IS-Sh':>5s} "
|
||||
f"{'corrBK':>6s} {'mβ':>5s} {'DSR':>5s} {'marg':>9s} {'cut':>6s} earns")
|
||||
for r in summary:
|
||||
print(f"{r['name']:14s} {r['verdict']:28s} "
|
||||
f"{str(r['full']):>5s} {str(r['hold']):>5s} {str(r['is_sh']):>5s} "
|
||||
f"{str(r['book_corr']):>6s} {str(r['mkt_beta']):>5s} {str(r['dsr']):>5s} "
|
||||
f"{str(r['marginal']):>9s} {str(r['haircut']):>6s} {r['earns']}")
|
||||
print("\nMACRO (secondario): " + " | ".join(
|
||||
f"{m['hedge'].upper()} IS={m['insample_sharpe']} HOLD={m['holdout'].get('sharpe')} bkcorr={m['book_corr']}"
|
||||
for m in macro_rows))
|
||||
|
||||
winners = [r for r in summary if r["earns"]]
|
||||
leads = [r for r in summary if r["verdict"].startswith("LEAD")]
|
||||
standalone = [r for r in summary if r["verdict"].startswith("NEUTRAL")]
|
||||
print("\n" + "=" * 100)
|
||||
print("CONCLUSIONE — c'è uno stream scorrelato CON edge reale ED eseguibile a 2 gambe?")
|
||||
print("=" * 100)
|
||||
if winners:
|
||||
print("SI -> SLEEVE-CANDIDATE eseguibile a 2 gambe:", ", ".join(r["name"] for r in winners))
|
||||
else:
|
||||
print("NO sleeve pronto. L'ORTOGONALITÀ c'è (corr->BOOK ~0.02, beta-mercato ~0.01: il filone")
|
||||
print("relative-value ETH/BTC è scorrelato per costruzione, ed è ESEGUIBILE a $600 — haircut ~0,")
|
||||
print("fee-surviving a 0.30%RT/gamba). Ma l'EDGE non passa il deflated-Sharpe:")
|
||||
if leads:
|
||||
print(" LEAD (forward-monitor, ADDS + edge in-sample ma DSR<0.95):",
|
||||
", ".join(r["name"] for r in leads))
|
||||
if standalone:
|
||||
print(" NEUTRAL-standalone (edge reale, NON migliora il book):",
|
||||
", ".join(r["name"] for r in standalone))
|
||||
print("Dettaglio nei verdetti per-segnale sopra.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""SCAN DI FATTIBILITA' — la vol term-structure e' scaricabile/certificabile da Deribit pubblico?
|
||||
|
||||
Un calendar-vol (front vs back IV) richiede ATM IV per SCADENZA, STORICA. Il DVOL pubblico e' solo
|
||||
30g. Questo script PROVA cosa l'API pubblica Deribit espone davvero, PRIMA di costruire backtest:
|
||||
1. snapshot CORRENTE della term-structure (mark_iv ATM per scadenza) — book_summary_by_currency.
|
||||
2. esiste STORIA per-scadenza? (DVOL e' 30g fisso; provo trade history con IV per instrument).
|
||||
Verdetto: se la storia per-scadenza NON e' pubblica -> calendar-vol NON backtestabile su dati certi.
|
||||
|
||||
uv run python scripts/research/probe_vol_termstructure.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, time
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import requests
|
||||
import numpy as np
|
||||
|
||||
BASE = "https://www.deribit.com/api/v2/public/"
|
||||
|
||||
|
||||
def _get(method, **params):
|
||||
r = requests.get(BASE + method, params=params, timeout=40)
|
||||
return r.json().get("result", None)
|
||||
|
||||
|
||||
def current_term_structure(cur="BTC"):
|
||||
"""ATM mark_iv per scadenza ORA (snapshot pubblico, tokenless)."""
|
||||
summ = _get("get_book_summary_by_currency", currency=cur, kind="option")
|
||||
if not summ:
|
||||
return None
|
||||
idx = _get("get_index_price", index_name=f"{cur.lower()}_usd")
|
||||
spot = float(idx["index_price"]) if idx else None
|
||||
# raggruppa per scadenza, prendi lo strike piu' vicino allo spot (ATM)
|
||||
by_exp = {}
|
||||
for o in summ:
|
||||
name = o["instrument_name"] # es. BTC-27JUN26-60000-C
|
||||
parts = name.split("-")
|
||||
if len(parts) != 4:
|
||||
continue
|
||||
exp, strike = parts[1], float(parts[2])
|
||||
miv = o.get("mark_iv")
|
||||
if miv is None or spot is None:
|
||||
continue
|
||||
d = abs(strike - spot)
|
||||
if exp not in by_exp or d < by_exp[exp][0]:
|
||||
by_exp[exp] = (d, float(miv), strike)
|
||||
return spot, by_exp
|
||||
|
||||
|
||||
def probe_history(cur="BTC"):
|
||||
"""C'e' STORIA per-scadenza? Provo: (a) DVOL (sappiamo 30g), (b) trade history con IV su uno
|
||||
strumento vivo, (c) se esistono indici vol a tenor diversi."""
|
||||
findings = []
|
||||
# (b) trade history con IV — solo strumenti NON scaduti
|
||||
instr = _get("get_instruments", currency=cur, kind="option", expired="false")
|
||||
live = instr[0]["instrument_name"] if instr else None
|
||||
if live:
|
||||
tr = _get("get_last_trades_by_instrument", instrument_name=live, count=5)
|
||||
has_iv = bool(tr and tr.get("trades") and "iv" in tr["trades"][0])
|
||||
findings.append(f"trade-history IV su {live}: {'SI (ma solo instrument VIVO, no scaduti)' if has_iv else 'NO'}")
|
||||
# (c) altri indici vol a tenor != 30g?
|
||||
findings.append("get_volatility_index_data: solo DVOL 30g (nessun indice 7g/60g/90g pubblico)")
|
||||
return findings
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 88)
|
||||
print(" SCAN FATTIBILITA' — vol term-structure Deribit pubblico")
|
||||
print("=" * 88)
|
||||
for cur in ("BTC", "ETH"):
|
||||
ts = current_term_structure(cur)
|
||||
if not ts:
|
||||
print(f"\n{cur}: nessun dato chain"); continue
|
||||
spot, by_exp = ts
|
||||
print(f"\n{cur} spot ${spot:,.0f} — term-structure ATM mark_iv ORA ({len(by_exp)} scadenze):")
|
||||
# ordina per scadenza (data nel nome)
|
||||
def _key(e):
|
||||
try:
|
||||
return time.mktime(time.strptime(e, "%d%b%y"))
|
||||
except Exception:
|
||||
return 0
|
||||
ivs = []
|
||||
for exp in sorted(by_exp, key=_key):
|
||||
d, miv, strike = by_exp[exp]
|
||||
ivs.append(miv)
|
||||
print(f" {exp:>9} ATM~{strike:>9,.0f} IV {miv:5.1f}%")
|
||||
if len(ivs) >= 2:
|
||||
print(f" -> front {ivs[0]:.1f}% back {ivs[-1]:.1f}% slope {ivs[-1]-ivs[0]:+.1f}pp "
|
||||
f"({'contango' if ivs[-1] > ivs[0] else 'backwardation'})")
|
||||
print(f" STORIA per-scadenza:")
|
||||
for f in probe_history(cur):
|
||||
print(f" - {f}")
|
||||
print("\n" + "=" * 88)
|
||||
print(" VERDETTO sulla backtestabilita' sotto.")
|
||||
print("=" * 88)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,400 @@
|
||||
"""signal_inout_1leg — 1-GAMBA a SEGNALI classici (entrata+uscita) su BTC/ETH (2026-06-29).
|
||||
|
||||
TESI
|
||||
----
|
||||
Un filone con un VANTAGGIO STRUTTURALE diverso dai book multi-gamba (XS01, CC01, basis,
|
||||
short-vol) che restano STAT-MODE perche' a $600 non si eseguono: qui ogni strategia e' a
|
||||
**1 SOLA GAMBA** (un singolo asset, BTC o ETH) con entrata E uscita gestite da SEGNALI
|
||||
CLASSICI (MACD, RSI, Supertrend/ATR-trail, Donchian, Bollinger, EMA-cross, MACD+ADX).
|
||||
Turnover basso => realmente ESEGUIBILE a $600 (cap $300/asset, min $5). Il vantaggio del
|
||||
filone NON e' lo Sharpe assoluto — e' l'eseguibilita'.
|
||||
|
||||
MA la lezione del progetto e' brutale: un 1-gamba direzionale su BTC/ETH o e'
|
||||
(a) TREND-FOLLOWING -> corr ~0.7-0.9 a TP01, marginal REDUNDANT (TP01 travestito), oppure
|
||||
(b) MEAN-REVERSION -> morto sul feed reale (fade negativo anche a fee zero, v2.0.0), o
|
||||
muore di fee a sub-daily.
|
||||
Il soffitto direzionale BTC/ETH e' ~1.3 (= TP01). Quindi NON si giudica sullo Sharpe
|
||||
assoluto ma sul MARGINALE vs TP01 (earns_slot), e la cella si sceglie IN-SAMPLE-ONLY
|
||||
(no peeking del hold-out — il punto cieco del filone B / intraday-ERM).
|
||||
|
||||
GATE (tutti dall'harness condiviso altlib, leak-free by construction)
|
||||
---------------------------------------------------------------------
|
||||
1. study_family_honest(name, factory, grid, tfs):
|
||||
- sceglie la cella per Sharpe IN-SAMPLE (pre-2025), MAI per max hold-out;
|
||||
- study_marginal sulla cella scelta (corr vs TP01, blend uplift full/hold,
|
||||
is_hedge, has_insample_edge, robust_oos multi-cut) -> earns_slot;
|
||||
- deflated-Sharpe (Bailey & Lopez de Prado) su TUTTE le celle del grid (multiple-testing);
|
||||
- earns_slot_honest = earns_slot AND DSR>=0.95.
|
||||
2. causality_ok: la costruzione del segnale e' causale (max_tail_diff ~0, no peeking).
|
||||
3. eval_weights_smallcap(capital=600, min_order=5): HAIRCUT $600 + n. trade eseguiti reali
|
||||
(il punto FORTE del filone — turnover basso = eseguibile davvero).
|
||||
4. fee-sweep 0.00-0.20% RT (dentro study_weights) a frequenza reale.
|
||||
5. day_boundary_robust: per completezza (segnali di prezzo => INVARIANT atteso).
|
||||
|
||||
Decisione finale per ogni segnale:
|
||||
SLEEVE-CANDIDATE-eseguibile -> earns_slot_honest=True AND haircut ~0 (executable). RARO.
|
||||
LEAD-forward-monitor -> marginal ADDS ma DSR/hold corto, o edge esile: monitor.
|
||||
REDUNDANT-vs-TP01 -> trend travestito (corr alta, uplift ~0).
|
||||
SCARTATO -> abs FAIL (negativo), DILUTES, o morte per fee.
|
||||
|
||||
USO: cd /opt/docker/PythagorasGoal && uv run python scripts/research/signal_inout_1leg.py
|
||||
Idempotente, solo stdout. NON committa (lo fa il coordinatore).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
CERTIFIED = al.CERTIFIED
|
||||
TFS = ("1d", "12h", "8h")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# INDICATORI extra (causali) non gia' in altlib
|
||||
# ===========================================================================
|
||||
def macd_lines(c: np.ndarray, fast: int, slow: int, sig: int):
|
||||
"""MACD = EMA(fast) - EMA(slow); signal = EMA(MACD, sig). EMA adjust=False => causale."""
|
||||
macd = al.ema(c, fast) - al.ema(c, slow)
|
||||
signal = al.ema(macd, sig)
|
||||
return macd, signal
|
||||
|
||||
|
||||
def supertrend_dir(df: pd.DataFrame, atr_win: int, mult: float) -> np.ndarray:
|
||||
"""Supertrend classico -> direzione {+1 up, -1 down}. CAUSALE: dir[i] usa close[i] e le
|
||||
bande finali calcolate fino a i-1 (mai high/low di i come prezzo d'ingresso)."""
|
||||
h = df["high"].values.astype(float)
|
||||
l = df["low"].values.astype(float)
|
||||
c = df["close"].values.astype(float)
|
||||
a = al.atr(df, atr_win)
|
||||
hl2 = (h + l) / 2.0
|
||||
upper = hl2 + mult * a
|
||||
lower = hl2 - mult * a
|
||||
n = len(c)
|
||||
fu = np.zeros(n)
|
||||
fl = np.zeros(n)
|
||||
d = np.ones(n)
|
||||
for i in range(n):
|
||||
if i == 0 or not np.isfinite(a[i]):
|
||||
fu[i], fl[i], d[i] = upper[i], lower[i], 1.0
|
||||
continue
|
||||
fu[i] = upper[i] if (upper[i] < fu[i - 1] or c[i - 1] > fu[i - 1]) else fu[i - 1]
|
||||
fl[i] = lower[i] if (lower[i] > fl[i - 1] or c[i - 1] < fl[i - 1]) else fl[i - 1]
|
||||
if c[i] > fu[i - 1]:
|
||||
d[i] = 1.0
|
||||
elif c[i] < fl[i - 1]:
|
||||
d[i] = -1.0
|
||||
else:
|
||||
d[i] = d[i - 1]
|
||||
return d
|
||||
|
||||
|
||||
def adx(df: pd.DataFrame, win: int = 14) -> np.ndarray:
|
||||
"""ADX di Wilder (forza di trend), causale via EWM. Usato come gate anti-whipsaw."""
|
||||
h = df["high"].values.astype(float)
|
||||
l = df["low"].values.astype(float)
|
||||
up = np.zeros(len(h))
|
||||
dn = np.zeros(len(h))
|
||||
up[1:] = h[1:] - h[:-1]
|
||||
dn[1:] = l[:-1] - l[1:]
|
||||
plus_dm = np.where((up > dn) & (up > 0), up, 0.0)
|
||||
minus_dm = np.where((dn > up) & (dn > 0), dn, 0.0)
|
||||
atr_ = al.atr(df, win)
|
||||
den = np.where(atr_ > 0, atr_, np.nan)
|
||||
plus_di = 100 * pd.Series(plus_dm).ewm(alpha=1 / win, adjust=False).mean().values / den
|
||||
minus_di = 100 * pd.Series(minus_dm).ewm(alpha=1 / win, adjust=False).mean().values / den
|
||||
s = plus_di + minus_di
|
||||
dx = 100 * np.abs(plus_di - minus_di) / np.where(s > 0, s, np.nan)
|
||||
return pd.Series(dx).ewm(alpha=1 / win, adjust=False).mean().values
|
||||
|
||||
|
||||
def _hold(pos_raw: np.ndarray) -> np.ndarray:
|
||||
"""ffill dello stato fra i segnali (NaN = mantieni posizione precedente), start flat."""
|
||||
return pd.Series(pos_raw).ffill().fillna(0.0).values
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# FACTORY dei segnali — ognuna: factory(tf, **params) -> target_fn(df)->posizione causale
|
||||
# (tf passa solo per firma; la granularita' la sceglie candidate_daily caricando get(asset,tf))
|
||||
# ===========================================================================
|
||||
def make_macd(mode: str):
|
||||
def factory(tf, fast, slow, sig):
|
||||
def target(df):
|
||||
c = df["close"].values.astype(float)
|
||||
m, s = macd_lines(c, fast, slow, sig)
|
||||
if mode == "LF":
|
||||
return np.where(m > s, 1.0, 0.0)
|
||||
return np.where(m > s, 1.0, -1.0)
|
||||
return target
|
||||
return factory
|
||||
|
||||
|
||||
def make_rsi():
|
||||
"""Mean-reversion long-flat: entra quando RSI<oversold, esce quando RSI>overbought, HOLD in mezzo."""
|
||||
def factory(tf, win, oversold, overbought):
|
||||
def target(df):
|
||||
c = df["close"].values.astype(float)
|
||||
ind = al.rsi(c, win)
|
||||
raw = np.full(len(c), np.nan)
|
||||
raw[ind < oversold] = 1.0
|
||||
raw[ind > overbought] = 0.0
|
||||
return _hold(raw)
|
||||
return target
|
||||
return factory
|
||||
|
||||
|
||||
def make_supertrend(mode: str):
|
||||
def factory(tf, atr_win, mult):
|
||||
def target(df):
|
||||
d = supertrend_dir(df, atr_win, mult)
|
||||
return np.clip(d, 0, None) if mode == "LF" else d
|
||||
return target
|
||||
return factory
|
||||
|
||||
|
||||
def make_donchian(mode: str):
|
||||
"""Turtle: long su breakout del canale alto, esce/short sul canale basso, HOLD in mezzo."""
|
||||
def factory(tf, win):
|
||||
def target(df):
|
||||
hi, lo = al.donchian(df, win)
|
||||
c = df["close"].values.astype(float)
|
||||
raw = np.full(len(c), np.nan)
|
||||
raw[c > hi] = 1.0
|
||||
raw[c < lo] = 0.0 if mode == "LF" else -1.0
|
||||
return _hold(raw)
|
||||
return target
|
||||
return factory
|
||||
|
||||
|
||||
def make_bbands(mode: str):
|
||||
"""MR: long sotto banda bassa, esce al ritorno sopra la media. BO: long sopra banda alta,
|
||||
flat/short sotto banda bassa."""
|
||||
def factory(tf, win, k):
|
||||
def target(df):
|
||||
c = df["close"].values.astype(float)
|
||||
up, mid, lo = al.bbands(c, win, k)
|
||||
raw = np.full(len(c), np.nan)
|
||||
if mode == "MR":
|
||||
raw[c < lo] = 1.0
|
||||
raw[c > mid] = 0.0
|
||||
else: # breakout
|
||||
raw[c > up] = 1.0
|
||||
raw[c < lo] = 0.0
|
||||
return _hold(raw)
|
||||
return target
|
||||
return factory
|
||||
|
||||
|
||||
def make_ema(mode: str):
|
||||
def factory(tf, fast, slow):
|
||||
def target(df):
|
||||
c = df["close"].values.astype(float)
|
||||
ef, es = al.ema(c, fast), al.ema(c, slow)
|
||||
if mode == "LF":
|
||||
return np.where(ef > es, 1.0, 0.0)
|
||||
return np.where(ef > es, 1.0, -1.0)
|
||||
return target
|
||||
return factory
|
||||
|
||||
|
||||
def make_macd_adx():
|
||||
"""MACD long-flat gated da ADX (entra long solo se MACD>signal E trend abbastanza forte)."""
|
||||
def factory(tf, fast, slow, sig, adx_win, adx_thr):
|
||||
def target(df):
|
||||
c = df["close"].values.astype(float)
|
||||
m, s = macd_lines(c, fast, slow, sig)
|
||||
a = adx(df, adx_win)
|
||||
return np.where((m > s) & (a > adx_thr), 1.0, 0.0)
|
||||
return target
|
||||
return factory
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# GRID
|
||||
# ===========================================================================
|
||||
def macd_grid():
|
||||
g = []
|
||||
for fast in (8, 12, 16):
|
||||
for slow in (21, 26, 34):
|
||||
for sig in (9,):
|
||||
if fast < slow:
|
||||
g.append(dict(fast=fast, slow=slow, sig=sig))
|
||||
return g
|
||||
|
||||
|
||||
RSI_GRID = [dict(win=14, oversold=os, overbought=ob)
|
||||
for os in (25, 30, 35) for ob in (60, 65, 70)]
|
||||
SUPER_GRID = [dict(atr_win=w, mult=m) for w in (10, 14, 20) for m in (2.0, 2.5, 3.0)]
|
||||
DONCH_GRID = [dict(win=w) for w in (20, 30, 40, 55)]
|
||||
BB_GRID = [dict(win=20, k=k) for k in (2.0, 2.5)]
|
||||
EMA_GRID = [dict(fast=f, slow=s) for f in (10, 20, 30) for s in (50, 100, 200) if f < s]
|
||||
MACD_ADX_GRID = [dict(fast=12, slow=26, sig=9, adx_win=14, adx_thr=t) for t in (15, 20, 25)]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DRIVER
|
||||
# ===========================================================================
|
||||
def _yr_line(absolute: dict) -> str:
|
||||
"""Per-anno minimo-fra-asset dalla parte assoluta (cella scelta), TF best."""
|
||||
cells = absolute.get("cells", [])
|
||||
if not cells:
|
||||
return ""
|
||||
c = cells[0]
|
||||
out = []
|
||||
for a, pa in c["per_asset"].items():
|
||||
yr = " ".join(f"{y}:{(d['ret'] if isinstance(d, dict) else d) * 100:+.0f}%"
|
||||
for y, d in pa["yearly"].items())
|
||||
out.append(f" {a}: {yr}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def classify(rep: dict, haircut_ok: bool) -> tuple[str, str]:
|
||||
"""Verdetto a 4 etichette + motivo di una riga."""
|
||||
if rep.get("chosen") is None:
|
||||
return "SCARTATO", "nessuna cella in-sample valida"
|
||||
m = rep["marginal"]["marginal"]
|
||||
mv = rep["marginal"]["marginal_verdict"]
|
||||
abs_grade = rep["marginal"]["abs_grade"]
|
||||
corr = m.get("corr_full")
|
||||
uph = (m.get("blends", {}).get("w25", {}) or {}).get("uplift_hold")
|
||||
hsh = m.get("cand_hold_sharpe")
|
||||
trend_like = corr is not None and corr >= 0.5
|
||||
if rep.get("earns_slot_honest"):
|
||||
if haircut_ok:
|
||||
return "SLEEVE-CANDIDATE-eseguibile", "earns_slot_honest=True + haircut $600 ~0 (extra-scettico: possibile selection/fee artifact)"
|
||||
return "LEAD-forward-monitor", "earns_slot_honest=True ma haircut $600 non trascurabile"
|
||||
if mv == "HEDGE":
|
||||
return "LEAD-forward-monitor", "HEDGE: low-corr ma paga solo quando TP01 e' debole (non alpha standing); DSR/abs sotto soglia"
|
||||
if mv == "DILUTES":
|
||||
return "SCARTATO", "DILUTES: trascina giu' il blend TP01 (no edge marginale)"
|
||||
if abs_grade == "FAIL":
|
||||
if trend_like:
|
||||
return "REDUNDANT-vs-TP01", f"trend = TP01 travestito (corr {corr}); la cella in-sample-best (sub-daily) overfitta -> hold-Sh {hsh} OOS"
|
||||
return "SCARTATO", f"abs FAIL: la cella in-sample-best non generalizza OOS (hold-Sh {hsh})"
|
||||
if mv == "REDUNDANT" or trend_like:
|
||||
return "REDUNDANT-vs-TP01", f"trend travestito: corr {corr} a TP01, marginal {mv}, uplift-hold non persistente"
|
||||
if mv == "ADDS":
|
||||
return "LEAD-forward-monitor", "marginal ADDS ma deflated-Sharpe non passa (multiple-testing)"
|
||||
return "REDUNDANT-vs-TP01", f"{mv}: nessun uplift marginale robusto (corr {corr})"
|
||||
|
||||
|
||||
def run_family(name: str, factory, grid, tfs=TFS):
|
||||
print("\n" + "=" * 100)
|
||||
print(f"### {name} (grid {len(grid)} celle x {len(tfs)} TF = {len(grid) * len(tfs)} prove)")
|
||||
print("=" * 100)
|
||||
rep = al.study_family_honest(name, factory, grid, tfs)
|
||||
ch = rep.get("chosen")
|
||||
if ch is None:
|
||||
print(f" -> {rep.get('reason', 'no chosen')}")
|
||||
v, why = classify(rep, False)
|
||||
print(f" VERDETTO: {v} — {why}")
|
||||
return rep, (name, v, why, None)
|
||||
|
||||
fn = factory(tf=ch["tf"], **ch["params"])
|
||||
m = rep["marginal"]["marginal"]
|
||||
mv = rep["marginal"]["marginal_verdict"]
|
||||
absr = rep["marginal"]["absolute"]
|
||||
bl = m.get("blends", {})
|
||||
w25 = bl.get("w25", {})
|
||||
w50 = bl.get("w50", {})
|
||||
|
||||
print(f" cella scelta IN-SAMPLE: tf={ch['tf']} {ch['params']} "
|
||||
f"insample-Sh={ch['insample_sharpe']} full-Sh={ch['full_sharpe']}")
|
||||
print(f" ABS grade={rep['marginal']['abs_grade']} | cand full-Sh={m.get('cand_full_sharpe')} "
|
||||
f"hold-Sh={m.get('cand_hold_sharpe')} (TP01 full {m.get('tp01_full_sharpe')}/hold {m.get('tp01_hold_sharpe')})")
|
||||
print(f" MARGINAL={mv} corr->TP01 full={m.get('corr_full')} hold={m.get('corr_hold')} "
|
||||
f"is_hedge={m.get('is_hedge')} has_insample_edge={m.get('has_insample_edge')} robust_oos={m.get('robust_oos')}")
|
||||
print(f" blend w25: full {w25.get('full')} (uplift {w25.get('uplift_full')}) "
|
||||
f"hold {w25.get('hold')} (uplift {w25.get('uplift_hold')}) DD {w25.get('dd')}")
|
||||
print(f" blend w50: full {w50.get('full')} (uplift {w50.get('uplift_full')}) "
|
||||
f"hold {w50.get('hold')} (uplift {w50.get('uplift_hold')})")
|
||||
print(f" multicut uplift {m.get('multicut_uplift')} persistent={m.get('multicut_persistent')}")
|
||||
print(f" DEFLATED-SHARPE={rep.get('deflated_sharpe')} (null-max {rep.get('expected_null_max')}, "
|
||||
f"n_cells {rep.get('n_cells')}) dsr_pass={rep.get('dsr_pass')}")
|
||||
print(f" >>> earns_slot(marginal)={rep['earns_slot_marginal']} EARNS_SLOT_HONEST={rep['earns_slot_honest']}")
|
||||
|
||||
# causalita'
|
||||
caus = al.causality_ok(fn, tf=ch["tf"])
|
||||
print(f" causality_ok={caus['ok']} max_tail_diff={caus['max_tail_diff']}")
|
||||
|
||||
# HAIRCUT $600 + n. trade (il punto forte del filone)
|
||||
print(" ESEGUIBILITA' $600 (haircut + n.trade eseguiti reali):")
|
||||
haircut_ok = True
|
||||
for a in CERTIFIED:
|
||||
df = al.get(a, ch["tf"])
|
||||
tgt = al._call_target(fn, df, a)
|
||||
sc = al.eval_weights_smallcap(df, tgt, capital=600, min_order=5)
|
||||
print(f" {a}: modeled Sh={sc['modeled']['sharpe']} real$600 Sh={sc['realistic']['sharpe']} "
|
||||
f"haircut={sc['sharpe_haircut']} n_trade_eseguiti={sc['n_executed_trades']} "
|
||||
f"turnover/yr={sc['executed_turnover_per_year']}")
|
||||
if abs(sc["sharpe_haircut"]) > 0.25:
|
||||
haircut_ok = False
|
||||
|
||||
# day-boundary (segnale di prezzo => INVARIANT atteso)
|
||||
try:
|
||||
db = al.day_boundary_robust(fn, tf=ch["tf"])
|
||||
print(f" day_boundary: {db['verdict']} (spread {db.get('spread')})")
|
||||
except Exception as e: # noqa
|
||||
print(f" day_boundary: skip ({type(e).__name__})")
|
||||
|
||||
# per-anno
|
||||
yr = _yr_line(absr)
|
||||
if yr:
|
||||
print(" per-anno (cella scelta):")
|
||||
print(yr)
|
||||
|
||||
v, why = classify(rep, haircut_ok)
|
||||
print(f" VERDETTO: {v} — {why}")
|
||||
return rep, (name, v, why, rep.get("earns_slot_honest"))
|
||||
|
||||
|
||||
def main():
|
||||
print("#" * 100)
|
||||
print("# SIGNAL-IN/OUT 1-GAMBA — MACD/RSI/Supertrend/Donchian/Bollinger/EMA/MACD+ADX su BTC,ETH")
|
||||
print(f"# TP01 baseline daily full Sharpe = {round(al._sh(al.tp01_baseline_daily()), 3)} (il soffitto da battere al MARGINE)")
|
||||
print(f"# TF testati: {TFS} | fee 0.10% RT + sweep 0..0.20% | capitale reale $600")
|
||||
print("#" * 100)
|
||||
|
||||
summary = []
|
||||
families = [
|
||||
("MACD-LF (long-flat)", make_macd("LF"), macd_grid()),
|
||||
("MACD-LS (long-short)", make_macd("LS"), macd_grid()),
|
||||
("RSI-MR (mean-rev long-flat)", make_rsi(), RSI_GRID),
|
||||
("SUPERTREND-LF", make_supertrend("LF"), SUPER_GRID),
|
||||
("SUPERTREND-LS", make_supertrend("LS"), SUPER_GRID),
|
||||
("DONCHIAN-LF (turtle)", make_donchian("LF"), DONCH_GRID),
|
||||
("DONCHIAN-LS (turtle)", make_donchian("LS"), DONCH_GRID),
|
||||
("BBANDS-MR (mean-rev)", make_bbands("MR"), BB_GRID),
|
||||
("BBANDS-BO (breakout)", make_bbands("BO"), BB_GRID),
|
||||
("EMA-CROSS-LF", make_ema("LF"), EMA_GRID),
|
||||
("EMA-CROSS-LS", make_ema("LS"), EMA_GRID),
|
||||
("MACD+ADX-LF (gated)", make_macd_adx(), MACD_ADX_GRID),
|
||||
]
|
||||
for nm, fac, grid in families:
|
||||
try:
|
||||
_, row = run_family(nm, fac, grid)
|
||||
summary.append(row)
|
||||
except Exception as e: # noqa
|
||||
import traceback
|
||||
print(f"\n[ERRORE in {nm}] {type(e).__name__}: {e}")
|
||||
traceback.print_exc()
|
||||
summary.append((nm, "ERRORE", str(e), None))
|
||||
|
||||
print("\n" + "#" * 100)
|
||||
print("# SOMMARIO FINALE")
|
||||
print("#" * 100)
|
||||
for nm, v, why, esh in summary:
|
||||
flag = " <<< ESEGUIBILE+SCORRELATO" if esh else ""
|
||||
print(f" {nm:32s} -> {v}{flag}")
|
||||
print(f" {why}")
|
||||
any_slot = any(esh for *_, esh in summary)
|
||||
print("\nCONCLUSIONE: c'e' un 1-gamba a segnale che AGGIUNGE oltre TP01 ED e' eseguibile a $600?")
|
||||
print(f" -> {'SI (verificare extra-scetticismo: selection/fee artifact)' if any_slot else 'NO — tutto REDUNDANT (trend=TP01) o SCARTATO (MR morta / fee). Risultato valido: base-rate confermata.'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""TP01 × DVOL — la vol IMPLICITA (forward-looking) migliora il risk-sizing di TP01? (ESEGUIBILE)
|
||||
|
||||
A differenza degli sleeve diversificatori (XS01/VRP01/carry = STAT-MODE, non eseguibili a $600),
|
||||
questo TOCCA il book live: TP01 è BTC/ETH perp su Deribit, già armato. Oggi vol-targeta sulla vol
|
||||
REALIZZATA 30g (backward-looking). Ipotesi: il DVOL (vol implicita 30g Deribit, forward-looking,
|
||||
che spesso ANTICIPA i salti di vol realizzata) come denominatore del vol-target → de-risking più
|
||||
tempestivo prima dei crash → hold-out migliore e/o DD più basso, SENZA peggiorare il FULL.
|
||||
|
||||
Onestà: DVOL parte 2021-03 → confronto TUTTE le varianti sulla FINESTRA COMUNE 2021-2026 (perdo
|
||||
2018-2021, incluso il toro 2021 pre-DVOL). Baseline ricalcolato sulla stessa finestra. Hold-out 2025+.
|
||||
Tutto causale (vol/segnale ≤ close[i]), fee 0.10% RT, long-flat, leva cap 2x — config CANONICA TP01.
|
||||
|
||||
VARIANTI (denominatore del vol-target):
|
||||
REALIZED -> 30g realizzata (baseline canonica)
|
||||
DVOL -> DVOL/100 (implicita)
|
||||
BLEND -> 0.5·realizzata + 0.5·DVOL
|
||||
MAX -> max(realizzata, DVOL) (sizing più difensivo: la più alta delle due)
|
||||
DERISK -> realizzata, ma posizione ×0.5 quando DVOL > pctl espandente causale (gate crash)
|
||||
|
||||
uv run python scripts/research/tp01_dvol_overlay.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))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from src.strategies.trend_portfolio import (
|
||||
resample_1d, simple_returns, realized_vol, tsmom_blend, CANONICAL,
|
||||
)
|
||||
|
||||
RAW = ROOT / "data" / "raw"
|
||||
SQ = np.sqrt(365.25)
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
TGT_VOL = CANONICAL["target_vol"]; LEV = CANONICAL["leverage"]; FEE = CANONICAL["fee_side"]
|
||||
HZ = CANONICAL["horizons_days"]; VW = CANONICAL["vol_win_days"]
|
||||
|
||||
|
||||
def _components(asset: str):
|
||||
df = resample_1d(load_data(asset, "1h"))
|
||||
c = df["close"].values.astype(float)
|
||||
idx = pd.to_datetime(df["datetime"])
|
||||
if idx.dt.tz is None:
|
||||
idx = idx.dt.tz_localize("UTC")
|
||||
idx = pd.DatetimeIndex(idx) # tz-aware (UTC)
|
||||
r = simple_returns(c)
|
||||
rv = realized_vol(r, VW, 365.25) # 30g realizzata annualizzata (bpd=1)
|
||||
direction = np.clip(tsmom_blend(c, HZ), 0, None) # long-flat
|
||||
dv = pd.read_parquet(RAW / f"dvol_{asset.lower()}.parquet")
|
||||
dser = pd.Series(dv["close"].values.astype(float) / 100.0,
|
||||
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)).sort_index()
|
||||
dvol = dser.reindex(idx, method="ffill").values
|
||||
return c, r, idx, rv, direction, dvol
|
||||
|
||||
|
||||
def _net_returns(asset: str, mode: str, tvol: float = TGT_VOL) -> pd.Series:
|
||||
c, r, idx, rv, direction, dvol = _components(asset)
|
||||
derisk = np.ones(len(c))
|
||||
if mode == "realized":
|
||||
vol = rv
|
||||
elif mode == "dvol":
|
||||
vol = dvol
|
||||
elif mode == "blend":
|
||||
vol = 0.5 * rv + 0.5 * dvol
|
||||
elif mode == "max":
|
||||
vol = np.fmax(rv, dvol)
|
||||
elif mode == "derisk":
|
||||
vol = rv
|
||||
# gate crash causale: DVOL sopra il suo percentile espandente (90%) -> dimezza l'esposizione
|
||||
dd = pd.Series(dvol, index=idx)
|
||||
rank = dd.expanding(min_periods=60).apply(lambda x: (x[:-1] < x[-1]).mean() if len(x) > 1 else 0.5, raw=True)
|
||||
derisk = np.where(rank.values > 0.90, 0.5, 1.0)
|
||||
else:
|
||||
raise ValueError(mode)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
scal = np.where((vol > 0) & np.isfinite(vol), tvol / vol, 0.0)
|
||||
tgt = np.clip(direction * scal * derisk, -LEV, LEV)
|
||||
tgt[~np.isfinite(tgt)] = 0.0
|
||||
pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1] # decisa a close[t-1], tenuta in t
|
||||
gross = pos * r
|
||||
turn = np.abs(np.diff(pos, prepend=0.0))
|
||||
net = np.clip(gross - FEE * turn, -0.99, None); net[0] = 0.0
|
||||
return pd.Series(net, index=idx)
|
||||
|
||||
|
||||
def portfolio(mode: str, tvol: float = TGT_VOL) -> pd.Series:
|
||||
b = _net_returns("BTC", mode, tvol); e = _net_returns("ETH", mode, tvol)
|
||||
J = pd.concat({"B": b, "E": e}, axis=1, join="inner").fillna(0.0)
|
||||
return 0.5 * J["B"] + 0.5 * J["E"]
|
||||
|
||||
|
||||
def metrics(daily: pd.Series, lo=None) -> dict:
|
||||
if lo is not None:
|
||||
daily = daily[daily.index >= lo]
|
||||
r = daily.values
|
||||
sh = float(np.mean(r) / np.std(r) * SQ) if np.std(r) > 0 else 0.0
|
||||
eq = np.cumprod(1.0 + r); pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0
|
||||
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0
|
||||
s = pd.Series(eq, index=daily.index); yearly = {}
|
||||
for y, g in s.groupby(s.index.year):
|
||||
if len(g) > 1:
|
||||
yearly[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1)
|
||||
return dict(sharpe=sh, dd=dd, cagr=cagr, tot=float(eq[-1] - 1) if len(eq) else 0.0, yearly=yearly)
|
||||
|
||||
|
||||
def main():
|
||||
modes = ["realized", "dvol", "blend", "max", "derisk"]
|
||||
series = {m: portfolio(m) for m in modes}
|
||||
# vero inizio DVOL (dove TUTTE le varianti hanno dati validi) — non il primo indice del prezzo
|
||||
dstart = max(pd.read_parquet(RAW / f"dvol_{a.lower()}.parquet")["timestamp"].min() for a in ("BTC", "ETH"))
|
||||
dstart = pd.Timestamp(dstart, unit="ms", tz="UTC") + pd.Timedelta(days=VW) # +warmup vol-win
|
||||
series = {m: s[s.index >= dstart] for m, s in series.items()}
|
||||
common = series["realized"].index
|
||||
base = series["realized"]
|
||||
|
||||
print("=" * 96)
|
||||
print(f" TP01 × DVOL — vol-target con denominatore di vol diverso. Finestra COMUNE "
|
||||
f"{common[0].date()} -> {common[-1].date()} ({len(base)}g). Hold-out 2025+.")
|
||||
print("=" * 96)
|
||||
print(f" {'variante':<10} {'FULL Sh':>8} {'FULL DD':>8} {'CAGR':>7} | "
|
||||
f"{'HOLD Sh':>8} {'HOLD ret':>9} {'HOLD DD':>8} | per-anno PnL")
|
||||
for m in modes:
|
||||
s = series[m]; f = metrics(s); h = metrics(s, lo=HOLDOUT)
|
||||
ys = " ".join(f"{y}:{p*100:+.0f}" for y, p in sorted(f['yearly'].items()))
|
||||
tag = " (baseline)" if m == "realized" else ""
|
||||
print(f" {m:<10} {f['sharpe']:>+8.2f} {f['dd']*100:>7.1f}% {f['cagr']*100:>+6.0f}% | "
|
||||
f"{h['sharpe']:>+8.2f} {h['tot']*100:>+8.1f}% {h['dd']*100:>7.1f}% | {ys}{tag}")
|
||||
|
||||
print("\n DELTA vs baseline (realized) sulla stessa finestra:")
|
||||
bf = metrics(base); bh = metrics(base, lo=HOLDOUT)
|
||||
for m in modes:
|
||||
if m == "realized":
|
||||
continue
|
||||
f = metrics(series[m]); h = metrics(series[m], lo=HOLDOUT)
|
||||
print(f" {m:<8}: ΔFULL Sh {f['sharpe']-bf['sharpe']:+.2f} ΔFULL DD {(f['dd']-bf['dd'])*100:+.1f}pp "
|
||||
f"ΔHOLD Sh {h['sharpe']-bh['sharpe']:+.2f} ΔHOLD ret {(h['tot']-bh['tot'])*100:+.1f}pp")
|
||||
|
||||
print("\n CONTROLLO DECISIVO — il taglio di DD del DVOL è 'posizioni più piccole' o vero timing?")
|
||||
print(" Confronto le varianti DVOL con il realized a target_vol RIDOTTO (stesso de-levering, ma")
|
||||
print(" senza DVOL). Se realized-ridotto eguaglia/batte il DVOL a parità di DD → DVOL non aggiunge.")
|
||||
for tv in (0.15, 0.13):
|
||||
s = portfolio("realized", tvol=tv); s = s[s.index >= dstart]
|
||||
f = metrics(s); h = metrics(s, lo=HOLDOUT)
|
||||
print(f" realized @ vol-tgt {tv*100:.0f}%: FULL Sh {f['sharpe']:+.2f} DD {f['dd']*100:.1f}% "
|
||||
f"CAGR {f['cagr']*100:+.0f}% | HOLD Sh {h['sharpe']:+.2f}")
|
||||
mx = metrics(series["max"]); mxh = metrics(series["max"], lo=HOLDOUT)
|
||||
print(f" (vs max-DVOL: FULL Sh {mx['sharpe']:+.2f} DD {mx['dd']*100:.1f}% "
|
||||
f"CAGR {mx['cagr']*100:+.0f}% | HOLD Sh {mxh['sharpe']:+.2f})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,433 @@
|
||||
"""XSEC v2 — segnali cross-sectional NON-MOMENTUM su 51 asset Hyperliquid (STAT-MODE).
|
||||
|
||||
TESI (filone C). XS01 (sleeve attivo) e' momentum cross-sectional sui 19 major. Lezione del
|
||||
progetto (diari 2026-06-19/20): ESPANDERE IL NUMERO di asset NON aiuta il momentum (gli small-cap
|
||||
diluiscono/invertono il segnale). Quindi qui NON ri-proviamo l'espansione-universo: cerchiamo un
|
||||
MECCANISMO DIVERSO dal momentum che, market-neutral e scorrelato, possa diversificare il portafoglio.
|
||||
|
||||
Meccanismi provati (tutti L/S dollar-neutral, vol-target ~20%, ribilancio periodico, CAUSALI):
|
||||
REV - short-term REVERSAL cross-sectional grezzo (long i loser di breve, short i winner).
|
||||
IREV - REVERSAL IDIOSINCRATICO: reversal sul RESIDUO dopo aver tolto il mercato (beta-adjusted).
|
||||
LOWVOL - factor LOW-VOL: long bassa vol realizzata / short alta vol (betting-against-vol).
|
||||
IMOM - MOMENTUM IDIOSINCRATICO: momentum sul residuo (toglie il fattore mercato, != raw mom).
|
||||
BAB - betting-against-beta: long basso beta / short alto beta.
|
||||
MOM - (riferimento) momentum grezzo, per confronto.
|
||||
|
||||
GATE (CLAUDE.md, metodologia obbligatoria):
|
||||
1. CAUSALE: score a close[i], peso tenuto in i+1 (l'engine shifta: W[i-1]*dret[i]); vol=0 gata.
|
||||
2. NETTO fee 0.10% RT su OGNI gamba a OGNI ribilancio + sweep fee.
|
||||
3. OOS hold-out 2025-01-01 + plateau su (lookback, H, k) + 2 universi (51 vs 19 major).
|
||||
4. Storia ~2.5 anni + molte config -> DEFLATED Sharpe (multiple-testing) e onesta' brutale.
|
||||
5. Confronto: Sharpe standalone FULL/HOLD/DD, corr vs XS01 e TP01, uplift del portafoglio a 4->5
|
||||
sleeve (portfolio.py, riusa active_sleeves senza modificarli).
|
||||
6. CAVEAT: book a molte gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, non deploy.
|
||||
|
||||
uv run python scripts/research/xsec_v2_nonmom.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, glob, math
|
||||
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.stats import norm
|
||||
|
||||
from src.portfolio.portfolio import to_daily, metrics, HOLDOUT, Sleeve, StrategyPortfolio
|
||||
from src.portfolio.sleeves import tp01_sleeve, xsec_sleeve, active_sleeves, XS_UNIVERSE
|
||||
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
FEE = 0.001 # 0.10% RT (Deribit taker): fee per gamba per lato = FEE/2 = 0.0005
|
||||
TV = 0.20 # vol-target annuo
|
||||
DPY = 365.25
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DATI — matrice prezzi/volumi (outer-join: ragged start, NaN prima del listing)
|
||||
# ===========================================================================
|
||||
def load_matrix(universe=None):
|
||||
px, vol = {}, {}
|
||||
files = sorted(glob.glob(str(RAW / "hl_*_1d.parquet")))
|
||||
for f in files:
|
||||
sym = Path(f).stem.replace("hl_", "").replace("_1d", "").upper()
|
||||
if universe is not None and sym not in universe:
|
||||
continue
|
||||
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)
|
||||
return PX, VOL
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ENGINE cross-sectional NaN-aware (causale). score_at(i)->(score[A], valid[A]).
|
||||
# Convenzione UNICA: long alto score / short basso score. Ogni meccanismo passa
|
||||
# lo score giusto (es. reversal = -ritorno; low-vol = -vol; bab = -beta).
|
||||
# ===========================================================================
|
||||
def xs_engine(PX, VOL, score_at, H, k, target_vol=TV, fee=FEE, min_assets=10, warmup=0):
|
||||
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)
|
||||
for i in range(n):
|
||||
if i >= warmup and i % H == 0:
|
||||
score, valid = score_at(i)
|
||||
valid = valid & np.isfinite(score) & (vol[i] > 0)
|
||||
idxv = np.where(valid)[0]
|
||||
if len(idxv) >= min_assets:
|
||||
kk = min(k, len(idxv) // 2)
|
||||
order = idxv[np.argsort(score[idxv])] # ascendente
|
||||
lo, hi = order[:kk], order[-kk:] # basso score / alto score
|
||||
w = np.zeros(A)
|
||||
w[hi] = 0.5 / kk # long alto score
|
||||
w[lo] = -0.5 / kk # short basso score
|
||||
else:
|
||||
w = np.zeros(A)
|
||||
W[i] = w
|
||||
# rendimento book: W[i-1] guadagna dret[i]; NaN (asset non listato) -> 0
|
||||
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
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SCORE BUILDERS — ognuno ritorna una closure score_at(i) + warmup richiesto.
|
||||
# Tutti CAUSALI: usano dati <= i (close[i] noto al momento della decisione).
|
||||
# ===========================================================================
|
||||
def _precompute(PX):
|
||||
px = PX.values
|
||||
n, A = px.shape
|
||||
DR = PX.pct_change() # ritorni giornalieri (NaN ragged)
|
||||
m = DR.mean(axis=1) # mercato equal-weight (skipna)
|
||||
return px, n, A, DR, m
|
||||
|
||||
|
||||
def make_mom(PX, L, sign=+1):
|
||||
px, n, A, *_ = _precompute(PX)
|
||||
def score_at(i):
|
||||
if i - L < 0:
|
||||
return np.full(A, np.nan), np.zeros(A, bool)
|
||||
r = px[i] / px[i - L] - 1.0
|
||||
valid = np.isfinite(px[i]) & np.isfinite(px[i - L])
|
||||
return sign * r, valid
|
||||
return score_at, L + 1
|
||||
|
||||
|
||||
def make_lowvol(PX, B):
|
||||
px, n, A, DR, m = _precompute(PX)
|
||||
RV = DR.rolling(B, min_periods=int(0.6 * B)).std().values
|
||||
def score_at(i):
|
||||
rv = RV[i]
|
||||
valid = np.isfinite(rv) & np.isfinite(px[i])
|
||||
return -rv, valid # long bassa vol / short alta vol
|
||||
return score_at, B + 1
|
||||
|
||||
|
||||
def _rolling_beta(DR, m, B):
|
||||
mp = int(0.6 * B)
|
||||
Em = m.rolling(B, min_periods=mp).mean()
|
||||
Em2 = (m * m).rolling(B, min_periods=mp).mean()
|
||||
varm = Em2 - Em ** 2
|
||||
Ex = DR.rolling(B, min_periods=mp).mean()
|
||||
Exm = DR.mul(m, axis=0).rolling(B, min_periods=mp).mean()
|
||||
beta = Exm.sub(Ex.mul(Em, axis=0)).div(varm.replace(0, np.nan), axis=0)
|
||||
return beta.values, varm.values
|
||||
|
||||
|
||||
def make_bab(PX, B):
|
||||
px, n, A, DR, m = _precompute(PX)
|
||||
beta, _ = _rolling_beta(DR, m, B)
|
||||
def score_at(i):
|
||||
b = beta[i]
|
||||
valid = np.isfinite(b) & np.isfinite(px[i])
|
||||
return -b, valid # long basso beta / short alto beta
|
||||
return score_at, B + 1
|
||||
|
||||
|
||||
def make_resid(PX, L, B, sign):
|
||||
"""Momentum/reversal IDIOSINCRATICO: residuo = ritorno - beta*mercato (beta su finestra B),
|
||||
cumulato sugli ultimi L giorni. sign=+1 -> momentum residuo; sign=-1 -> reversal residuo."""
|
||||
px, n, A, DR, m = _precompute(PX)
|
||||
beta, _ = _rolling_beta(DR, m, B)
|
||||
SDR = DR.rolling(L, min_periods=int(0.8 * L)).sum().values # somma ritorni asset su L
|
||||
SM = m.rolling(L, min_periods=int(0.8 * L)).sum().values # somma mercato su L
|
||||
cnt = DR.rolling(L, min_periods=1).count().values
|
||||
def score_at(i):
|
||||
b = beta[i]
|
||||
resid_cum = SDR[i] - b * SM[i]
|
||||
valid = np.isfinite(resid_cum) & (cnt[i] >= 0.8 * L) & np.isfinite(px[i])
|
||||
return sign * resid_cum, valid
|
||||
return score_at, max(L, B) + 1
|
||||
|
||||
|
||||
# Catalogo meccanismi: nome -> (builder, lista di config (param dict)).
|
||||
def mechanisms():
|
||||
return {
|
||||
"MOM": (lambda PX, p: make_mom(PX, p["L"], +1),
|
||||
[dict(L=L, H=H, k=k) for L in (30, 60, 90) for H in (5, 10) for k in (5, 8)]),
|
||||
"REV": (lambda PX, p: make_mom(PX, p["L"], -1),
|
||||
[dict(L=L, H=H, k=k) for L in (2, 3, 5, 7, 10) for H in (1, 2, 3, 5) for k in (5, 8)]),
|
||||
"IREV": (lambda PX, p: make_resid(PX, p["L"], p.get("B", 60), -1),
|
||||
[dict(L=L, H=H, k=k, B=60) for L in (3, 5, 7, 10) for H in (2, 3, 5) for k in (5, 8)]),
|
||||
"IMOM": (lambda PX, p: make_resid(PX, p["L"], p.get("B", 60), +1),
|
||||
[dict(L=L, H=H, k=k, B=60) for L in (30, 60, 90) for H in (5, 10) for k in (5, 8)]),
|
||||
"LOWVOL": (lambda PX, p: make_lowvol(PX, p["B"]),
|
||||
[dict(B=B, H=H, k=k) for B in (20, 30, 60) for H in (5, 10) for k in (5, 8)]),
|
||||
"BAB": (lambda PX, p: make_bab(PX, p["B"]),
|
||||
[dict(B=B, H=H, k=k) for B in (30, 60) for H in (5, 10) for k in (5, 8)]),
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# METRICHE / STATISTICA
|
||||
# ===========================================================================
|
||||
def yr_breadth(daily):
|
||||
yr = [float((1 + g).prod() - 1) for _, g in daily.groupby(daily.index.year)]
|
||||
return (sum(v > 0 for v in yr) / len(yr) if yr else 0.0), yr
|
||||
|
||||
|
||||
def deflated_sharpe(sr_ann, all_sr_ann, daily_ret):
|
||||
"""Deflated Sharpe Ratio (Bailey & Lopez de Prado): probabilita' che lo Sharpe vero superi
|
||||
lo Sharpe-massimo atteso sotto il null di N trial indipendenti. Penalizza il multiple-testing.
|
||||
sr_ann: Sharpe annualizzato della config scelta; all_sr_ann: tutti gli Sharpe testati;
|
||||
daily_ret: serie ritorni giornalieri (per skew/kurt/T). Ritorna (DSR, sr0_ann)."""
|
||||
r = np.asarray(pd.Series(daily_ret).dropna().values, float)
|
||||
T = len(r)
|
||||
if T < 30 or np.std(r) == 0:
|
||||
return float("nan"), float("nan")
|
||||
sr = sr_ann / math.sqrt(DPY) # per-osservazione
|
||||
trials = np.asarray([s / math.sqrt(DPY) for s in all_sr_ann if np.isfinite(s)], float)
|
||||
N = max(len(trials), 2)
|
||||
var_tr = float(np.var(trials, ddof=1)) if N > 1 else 0.0
|
||||
emc = 0.5772156649
|
||||
z1 = norm.ppf(1 - 1.0 / N)
|
||||
z2 = norm.ppf(1 - 1.0 / (N * math.e))
|
||||
sr0 = math.sqrt(var_tr) * ((1 - emc) * z1 + emc * z2)
|
||||
sk = float(pd.Series(r).skew())
|
||||
ku = float(pd.Series(r).kurt()) + 3.0 # pandas kurt = excess
|
||||
den = math.sqrt(max(1e-9, 1 - sk * sr + (ku - 1) / 4.0 * sr ** 2))
|
||||
dsr = float(norm.cdf((sr - sr0) * math.sqrt(T - 1) / den))
|
||||
return dsr, sr0 * math.sqrt(DPY)
|
||||
|
||||
|
||||
def evalcfg(daily):
|
||||
f = metrics(daily)
|
||||
h = metrics(daily[daily.index >= HOLDOUT])
|
||||
pct, _ = yr_breadth(daily)
|
||||
return f, h, pct
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RUN griglia per meccanismo / universo
|
||||
# ===========================================================================
|
||||
def run_grid(PX, VOL, mech_name, builder, cfgs, xs_daily, tp_daily, label):
|
||||
rows = []
|
||||
for p in cfgs:
|
||||
score_at, warm = builder(PX, p)
|
||||
daily, turn = xs_engine(PX, VOL, score_at, p["H"], p["k"], warmup=warm)
|
||||
daily = to_daily(daily)
|
||||
if daily.std() == 0 or len(daily) < 60:
|
||||
continue
|
||||
f, h, pct = evalcfg(daily)
|
||||
cx = _corr(daily, xs_daily)
|
||||
ct = _corr(daily, tp_daily)
|
||||
rows.append(dict(cfg=p, daily=daily, full=f["sharpe"], hold=h["sharpe"], dd=f["maxdd"],
|
||||
ret=f["ret"], pct=pct, corrXS=cx, corrTP=ct, turn=turn))
|
||||
return rows
|
||||
|
||||
|
||||
def _corr(a, b):
|
||||
J = pd.concat({"a": a, "b": b}, axis=1, join="inner").dropna()
|
||||
return float(J["a"].corr(J["b"])) if len(J) > 10 else float("nan")
|
||||
|
||||
|
||||
def tag(p):
|
||||
return " ".join(f"{k}{v}" for k, v in p.items())
|
||||
|
||||
|
||||
MOM_FAMILY = ("MOM", "IMOM") # momentum (anche residuo) -> NON e' "non-momentum"
|
||||
|
||||
|
||||
def causality_prefix_check(PX, VOL, builder, cfg, frac=0.85, tail=60, tol=1e-9):
|
||||
"""Guard look-ahead per l'engine cross-sectional: ricostruisce la serie su un PREFISSO della
|
||||
matrice (primi `frac`) e verifica che la coda combaci con la run completa sugli stessi indici.
|
||||
Un feature non-causale (finestra centrata, statistica full-sample, shift(-k)) divergerebbe."""
|
||||
score_full, warm = builder(PX, cfg)
|
||||
full, _ = xs_engine(PX, VOL, score_full, cfg["H"], cfg["k"], warmup=warm)
|
||||
cut = int(len(PX) * frac)
|
||||
PXc, VOLc = PX.iloc[:cut], VOL.iloc[:cut]
|
||||
score_pre, warm2 = builder(PXc, cfg)
|
||||
pre, _ = xs_engine(PXc, VOLc, score_pre, cfg["H"], cfg["k"], warmup=warm2)
|
||||
lo = max(0, cut - tail)
|
||||
a = full.values[lo:cut]
|
||||
b = 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))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PORTAFOGLIO — uplift 4 -> 5 sleeve (riusa active_sleeves SENZA modificarli)
|
||||
# ===========================================================================
|
||||
def portfolio_uplift(cand_fn, fractions=(0.10, 0.15)):
|
||||
base = active_sleeves() # 4 sleeve validati
|
||||
pf0 = StrategyPortfolio(base)
|
||||
bt0 = pf0.backtest() # popola le cache degli sleeve
|
||||
base_full = metrics(pf0.combined_daily())
|
||||
base_hold = metrics(pf0.combined_daily(lo=HOLDOUT))
|
||||
out = {"base": (base_full, base_hold), "variants": {}}
|
||||
for fr in fractions:
|
||||
wraw = fr / (1.0 - fr) # cand_frac = wraw/(sum_base + wraw), sum_base=1
|
||||
cand = Sleeve("XSV2_cand", wraw, cand_fn)
|
||||
pf1 = StrategyPortfolio(base + [cand]) # riusa le cache di base
|
||||
cf = metrics(pf1.combined_daily())
|
||||
ch = metrics(pf1.combined_daily(lo=HOLDOUT))
|
||||
out["variants"][fr] = (cf, ch, pf1.weights().get("XSV2_cand", 0.0))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" XSEC v2 — CROSS-SECTIONAL NON-MOMENTUM su Hyperliquid (STAT-MODE, storia ~2.5 anni)")
|
||||
print("=" * 100)
|
||||
|
||||
tp_daily = tp01_sleeve().daily()
|
||||
xs_daily = xsec_sleeve().daily()
|
||||
print(f" riferimenti: TP01 (corr target) e XS01 (momentum, sleeve attivo).")
|
||||
|
||||
universes = {
|
||||
"51-all": None,
|
||||
"19-major": XS_UNIVERSE,
|
||||
}
|
||||
mats = {}
|
||||
for uname, u in universes.items():
|
||||
PX, VOL = load_matrix(u)
|
||||
mats[uname] = (PX, VOL)
|
||||
print(f" universo {uname:<9}: {PX.shape[1]} asset, {PX.shape[0]} giorni "
|
||||
f"[{PX.index[0].date()} -> {PX.index[-1].date()}]")
|
||||
|
||||
mechs = mechanisms()
|
||||
all_sr = [] # per deflated-Sharpe (tutti i trial)
|
||||
best_per_mech = {} # (uname, mech) -> best row by hold
|
||||
for uname, (PX, VOL) in mats.items():
|
||||
print("\n" + "#" * 100)
|
||||
print(f"# UNIVERSO {uname}")
|
||||
print("#" * 100)
|
||||
for mech_name, (builder, cfgs) in mechs.items():
|
||||
rows = run_grid(PX, VOL, mech_name, builder, cfgs, xs_daily, tp_daily, uname)
|
||||
if not rows:
|
||||
continue
|
||||
all_sr.extend([r["full"] for r in rows])
|
||||
pos_full = sum(r["full"] > 0 for r in rows)
|
||||
# migliore per HOLD-OUT (diversificatore: vogliamo OOS robusto)
|
||||
best = max(rows, key=lambda r: r["hold"])
|
||||
best_per_mech[(uname, mech_name)] = best
|
||||
print(f"\n [{mech_name}] {len(rows)} config | plateau full>0: {pos_full}/{len(rows)}"
|
||||
f" | best-hold: {tag(best['cfg'])}")
|
||||
print(f" {'cfg':<22}{'FULL':>7}{'HOLD':>7}{'DD%':>6}{'ret%':>7}{'anni+':>7}"
|
||||
f"{'corrXS':>8}{'corrTP':>8}{'turn/y':>8}")
|
||||
# mostra le top-3 per HOLD per leggere il plateau
|
||||
for r in sorted(rows, key=lambda r: -r["hold"])[:3]:
|
||||
print(f" {tag(r['cfg']):<22}{r['full']:>7.2f}{r['hold']:>7.2f}{r['dd']*100:>6.0f}"
|
||||
f"{r['ret']*100:>+7.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}"
|
||||
f"{r['turn']:>8.0f}")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# SELEZIONE: miglior candidato NON-MOMENTUM (escluse le famiglie momentum MOM/IMOM).
|
||||
# gate standalone: FULL>0.5, HOLD>0, |corrXS|<0.6 -> ranking per (FULL+HOLD)/2.
|
||||
# IMOM/MOM restano in tabella come RIFERIMENTO (sono momentum, non il target del filone).
|
||||
# -------------------------------------------------------------------
|
||||
print("\n" + "=" * 100)
|
||||
print(" SELEZIONE CANDIDATO non-momentum — gate: FULL>0.5, HOLD>0, |corrXS|<0.6 (escluse MOM/IMOM)")
|
||||
print("=" * 100)
|
||||
nm = [s for s in all_sr if np.isfinite(s)]
|
||||
pool = [(u, mn, r) for (u, mn), r in best_per_mech.items()]
|
||||
nonmom = [(u, mn, r) for (u, mn, r) in pool if mn not in MOM_FAMILY]
|
||||
elig = [(u, mn, r) for (u, mn, r) in nonmom
|
||||
if r["full"] > 0.5 and r["hold"] > 0 and abs(r["corrXS"]) < 0.6]
|
||||
elig.sort(key=lambda x: -0.5 * (x[2]["full"] + x[2]["hold"]))
|
||||
for u, mn, r in sorted(pool, key=lambda x: -0.5 * (x[2]["full"] + x[2]["hold"])):
|
||||
fam = "(momentum-ref)" if mn in MOM_FAMILY else ""
|
||||
flag = "OK" if (u, mn, r) in elig else "--"
|
||||
print(f" [{flag}] {mn:<7} {u:<9} {tag(r['cfg']):<20} FULL {r['full']:+.2f} HOLD {r['hold']:+.2f}"
|
||||
f" DD {r['dd']*100:.0f}% corrXS {r['corrXS']:+.2f} corrTP {r['corrTP']:+.2f} {fam}")
|
||||
|
||||
if not elig:
|
||||
print("\n >>> NESSUN candidato NON-momentum supera il gate standalone. SCARTATO.")
|
||||
_final_note()
|
||||
return
|
||||
|
||||
print(f"\n candidati idonei (non-momentum): {len(elig)}")
|
||||
|
||||
# valuta UPLIFT PORTAFOGLIO per i top-3 idonei (LOWVOL/BAB/...): cache base riusata
|
||||
base = active_sleeves()
|
||||
pf0 = StrategyPortfolio(base); pf0.backtest()
|
||||
bf = metrics(pf0.combined_daily()); bh = metrics(pf0.combined_daily(lo=HOLDOUT))
|
||||
print("\n UPLIFT PORTAFOGLIO (active_sleeves 4 -> 5 sleeve; candidato come 5o sleeve):")
|
||||
print(f" BASE (4 sleeve) FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.0f}%"
|
||||
f" | HOLD Sh {bh['sharpe']:.2f} DD {bh['maxdd']*100:.0f}%")
|
||||
uplifts = {}
|
||||
for u, mn, r in elig[:3]:
|
||||
cand_fn = (lambda d: (lambda: d))(r["daily"])
|
||||
best_var = None
|
||||
for fr in (0.10, 0.15):
|
||||
wraw = fr / (1.0 - fr)
|
||||
cand = Sleeve("XSV2_cand", wraw, cand_fn)
|
||||
pf1 = StrategyPortfolio(base + [cand])
|
||||
cf = metrics(pf1.combined_daily()); ch = metrics(pf1.combined_daily(lo=HOLDOUT))
|
||||
wgt = pf1.weights().get("XSV2_cand", 0.0)
|
||||
print(f" +{mn:<6} [{u}] {tag(r['cfg']):<16} @{wgt*100:>4.1f}% "
|
||||
f"FULL {cf['sharpe']:.2f} ({cf['sharpe']-bf['sharpe']:+.2f}) DD {cf['maxdd']*100:.0f}%"
|
||||
f" | HOLD {ch['sharpe']:.2f} ({ch['sharpe']-bh['sharpe']:+.2f})")
|
||||
d_full, d_hold = cf['sharpe'] - bf['sharpe'], ch['sharpe'] - bh['sharpe']
|
||||
if best_var is None or (d_full + d_hold) > best_var:
|
||||
best_var = d_full + d_hold
|
||||
uplifts[(u, mn)] = best_var
|
||||
|
||||
# TOP candidato = miglior non-momentum idoneo
|
||||
u, mn, best = elig[0]
|
||||
daily = best["daily"]
|
||||
f, h, pct = evalcfg(daily)
|
||||
dsr, sr0 = deflated_sharpe(f["sharpe"], all_sr, daily)
|
||||
caus = causality_prefix_check(*mats[u], mechs[mn][0], best["cfg"])
|
||||
print("\n" + "=" * 100)
|
||||
print(f" TOP CANDIDATO non-momentum: {mn} [{u}] {tag(best['cfg'])}")
|
||||
print("=" * 100)
|
||||
print(f" FULL Sharpe {f['sharpe']:.2f} | HOLD {h['sharpe']:.2f} | DD {f['maxdd']*100:.0f}%"
|
||||
f" | ret {f['ret']*100:+.0f}% | anni+ {pct*100:.0f}% | turnover/y {best['turn']:.0f}")
|
||||
print(f" corr vs XS01 {best['corrXS']:+.2f} | corr vs TP01 {best['corrTP']:+.2f}")
|
||||
print(f" CAUSALITA' (prefix-check): ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}")
|
||||
print(f" DEFLATED Sharpe (N={len(nm)} trial GLOBALI): {dsr:.3f}"
|
||||
f" | soglia Sharpe-max-null annualizz. {sr0:.2f} (serve DSR>0.95)")
|
||||
_, yrs = yr_breadth(daily)
|
||||
per = [(int(y), round(v, 3)) for y, v in zip([yy for yy, _ in daily.groupby(daily.index.year)], yrs)]
|
||||
print(f" per-anno: {per}")
|
||||
|
||||
helps = (uplifts.get((u, mn), -9) or -9) > 0.10 # uplift combinato full+hold meaningful
|
||||
robust = dsr > 0.95 and best["hold"] > 0.3 and best["full"] > 0.7 and caus["ok"]
|
||||
print("\n VERDETTO INDICATIVO:",
|
||||
"PASS-LEAD (forward-monitor)" if (helps and robust) else
|
||||
("DEBOLE/forward-monitor" if (helps or (best['full'] > 0.7 and best['hold'] > 0.3)) else "SCARTATO"))
|
||||
_final_note()
|
||||
|
||||
|
||||
def _final_note():
|
||||
print("\n CAVEAT (immutabili): storia ~2.5 anni (deflated-Sharpe + multiple-testing), book a molte")
|
||||
print(" gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, MAI deploy. Nessuno sleeve")
|
||||
print(" registrato: questo e' solo lavoro statistico (vincoli del filone C).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,387 @@
|
||||
"""XSEC v3 — fattori cross-sectional "low-risk cousins" su ~51 alt Hyperliquid (1d, STAT-MODE).
|
||||
|
||||
TESI (filone C, terza ondata). I primi due filoni cross-sectional hanno coperto: momentum (XS01,
|
||||
sleeve attivo), reversal/idio-reversal, TOTAL-low-vol (LOWVOL) e betting-against-beta (BAB).
|
||||
Restano TRE anomalie "cugine del low-risk", documentate in equity ma MAI provate qui, che POTREBBERO
|
||||
diversificare il portafoglio essendo strutturalmente diverse dal momentum e dal total-vol:
|
||||
|
||||
1. MAX (lottery-demand, Bali-Cakici-Whitelaw 2011). Gli asset col MASSIMO rendimento giornaliero
|
||||
piu' alto nelle ultime B sedute attraggono domanda "da lotteria" e poi sottoperformano.
|
||||
SHORT high-max / LONG low-max -> score = -max(daily_ret over B).
|
||||
Diverso dal momentum (e' la coda destra recente, non il trend) e dal total-vol (un singolo
|
||||
estremo, non la dispersione).
|
||||
|
||||
2. IVOL (idiosyncratic vol, Ang-Hodrick-Xing-Zhang 2006). SHORT alta vol del RESIDUO
|
||||
(dopo aver tolto beta*mercato su finestra B) / LONG bassa. score = -ivol_residuo.
|
||||
DIVERSO da LOWVOL (gia' provato in v2) che usa la vol TOTALE: qui si toglie prima il fattore
|
||||
di mercato, isolando il rischio idiosincratico (ortogonale a BAB, che e' il beta sistematico).
|
||||
|
||||
3. AMIHUD (illiquidity, Amihud 2002). Ranking su |ret|/dollar_volume medio su B (dollar_volume =
|
||||
volume_coin * close, perche' il volume HL e' in coin -> va dollarizzato per confrontare asset).
|
||||
Tesi standard: premio di illiquidita' -> LONG illiquido / SHORT liquido. In crypto il segno e'
|
||||
incerto (flight-to-quality verso i major liquidi), quindi si provano ENTRAMBI i segni e si tiene
|
||||
quello con tesi economica + edge: AMIHUD_ILLIQ (score=+amihud) vs AMIHUD_LIQ (score=-amihud).
|
||||
|
||||
GATE OBBLIGATORI (CLAUDE.md + parita' con xsec_v2_nonmom):
|
||||
- Griglia B in {20,30,60} x H in {5,10} x k in {5,8}, su ENTRAMBI gli universi (51-all, 19-major).
|
||||
- CAUSALE: score a close[i], peso tenuto in i+1 (engine shifta W[i-1]*dret[i]); vol=0 gata.
|
||||
Verifica prefix-consistency (xv.causality_prefix_check) sul best: ok=True, max_tail_diff~0.
|
||||
- NETTO fee 0.10% RT su ogni gamba a ogni ribilancio (engine) + turnover/anno riportato.
|
||||
- DEFLATED Sharpe (Bailey-Lopez de Prado) sul best, con TUTTI gli Sharpe FULL testati come trial
|
||||
(multiple-testing): serve DSR>0.95 per un claim forte.
|
||||
- corr vs XS01 e vs TP01 (vogliamo |corrXS|<0.6 per diversificare).
|
||||
- Uplift del portafoglio 4->5 sleeve a 10% e 15% (active_sleeves, non modificati).
|
||||
- Per-anno (breadth) + HOLD-OUT (2025-01-01+).
|
||||
- ANTI-selection-on-holdout: il best e' scelto per HOLD massimo; si riporta ANCHE il best scelto
|
||||
per Sharpe IN-SAMPLE (<2025) e si verifica che il deflated-Sharpe (che usa il FULL, in-sample
|
||||
incluso) regga comunque.
|
||||
|
||||
CAVEAT immutabili: storia ~2.5 anni (deflated-Sharpe + multiple-testing), book a molte gambe NON
|
||||
eseguibile a $600 -> STAT-MODE / forward-monitor, MAI deploy. Nessuno sleeve registrato.
|
||||
|
||||
uv run python scripts/research/xsec_v3_lowrisk.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"))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import xsec_v2_nonmom as xv # HARNESS collaudato (engine, metriche, statistica, portafoglio)
|
||||
|
||||
HOLDOUT = xv.HOLDOUT
|
||||
metrics = xv.metrics
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SCORE BUILDERS — "low-risk cousins". Tutti CAUSALI (dati <= i). Convenzione
|
||||
# engine: long ALTO score / short BASSO score (vol=0 gata automaticamente).
|
||||
# ===========================================================================
|
||||
def make_max(PX, B):
|
||||
"""MAX / lottery-demand: massimo rendimento giornaliero nelle ultime B sedute.
|
||||
score = -max -> long low-max / short high-max (la 'lotteria' sottoperforma)."""
|
||||
px, n, A, DR, m = xv._precompute(PX)
|
||||
ROLLMAX = DR.rolling(B, min_periods=int(0.6 * B)).max().values
|
||||
def score_at(i):
|
||||
mx = ROLLMAX[i]
|
||||
valid = np.isfinite(mx) & np.isfinite(px[i])
|
||||
return -mx, valid
|
||||
return score_at, B + 1
|
||||
|
||||
|
||||
def make_ivol(PX, B):
|
||||
"""IVOL: vol del RESIDUO dopo beta*mercato su finestra B (OLS in-window, esatto:
|
||||
var_resid = var(y) - beta^2*var(m) con beta = cov/var, >= 0 per costruzione).
|
||||
score = -ivol -> long bassa idio-vol / short alta (anomalia Ang et al.)."""
|
||||
px, n, A, DR, m = xv._precompute(PX)
|
||||
beta, varm = xv._rolling_beta(DR, m, B) # beta (n,A), varm (n,)
|
||||
mp = int(0.6 * B)
|
||||
ExDR = DR.rolling(B, min_periods=mp).mean()
|
||||
ExDR2 = (DR * DR).rolling(B, min_periods=mp).mean()
|
||||
varDR = (ExDR2 - ExDR ** 2).values # var population (coerente con _rolling_beta)
|
||||
resid_var = varDR - (beta ** 2) * varm[:, None]
|
||||
ivol = np.sqrt(np.clip(resid_var, 0.0, None))
|
||||
def score_at(i):
|
||||
iv = ivol[i]
|
||||
valid = np.isfinite(iv) & np.isfinite(beta[i]) & np.isfinite(px[i])
|
||||
return -iv, valid
|
||||
return score_at, B + 1
|
||||
|
||||
|
||||
def make_amihud(PX, VOL, B, sign):
|
||||
"""AMIHUD illiquidity: media su B di |ret| / dollar_volume (volume_coin*close).
|
||||
sign=+1 -> LONG illiquido (premio di illiquidita'); sign=-1 -> LONG liquido."""
|
||||
px, n, A, DR, m = xv._precompute(PX)
|
||||
dvol = (VOL * PX).replace(0, np.nan) # dollarizza il volume in coin
|
||||
illiq = (DR.abs() / dvol).replace([np.inf, -np.inf], np.nan)
|
||||
AMI = illiq.rolling(B, min_periods=int(0.6 * B)).mean().values
|
||||
def score_at(i):
|
||||
a = AMI[i]
|
||||
valid = np.isfinite(a) & np.isfinite(px[i])
|
||||
return sign * a, valid
|
||||
return score_at, B + 1
|
||||
|
||||
|
||||
def amihud_builder(VOL_full, sign):
|
||||
"""Builder (PX,cfg) per AMIHUD che richiede il volume: chiude su VOL_full e lo RIALLINEA a
|
||||
PX.index. Cosi' la causality_prefix_check (che tronca PX a PXc=PX[:cut] e chiama builder(PXc))
|
||||
riceve automaticamente VOLc = VOL_full.reindex(PXc.index) -> nessun look-ahead dal volume."""
|
||||
def builder(PX, p):
|
||||
VOL = VOL_full.reindex(PX.index)
|
||||
return make_amihud(PX, VOL, p["B"], sign)
|
||||
return builder
|
||||
|
||||
|
||||
# Griglia condivisa (parita' con i gate): B x H x k.
|
||||
BHK = [dict(B=B, H=H, k=k) for B in (20, 30, 60) for H in (5, 10) for k in (5, 8)]
|
||||
|
||||
|
||||
def build_mechanisms(VOL):
|
||||
"""Catalogo per-universo: AMIHUD va legato al VOL dell'universo corrente."""
|
||||
return {
|
||||
"MAX": (lambda PX, p: make_max(PX, p["B"]), BHK),
|
||||
"IVOL": (lambda PX, p: make_ivol(PX, p["B"]), BHK),
|
||||
"AMIHUD_ILLIQ": (amihud_builder(VOL, +1), BHK), # long illiquido / short liquido
|
||||
"AMIHUD_LIQ": (amihud_builder(VOL, -1), BHK), # long liquido / short illiquido
|
||||
}
|
||||
|
||||
|
||||
# Tesi economica per il verdetto AMIHUD (quale segno ha senso se mostra edge).
|
||||
AMIHUD_THESIS = {
|
||||
"AMIHUD_ILLIQ": "premio di illiquidita' (long illiquido / short liquido)",
|
||||
"AMIHUD_LIQ": "flight-to-quality verso i major liquidi (long liquido / short illiquido)",
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Helper
|
||||
# ===========================================================================
|
||||
def insample_sharpe(daily):
|
||||
pre = daily[daily.index < HOLDOUT]
|
||||
return metrics(pre)["sharpe"] if len(pre) > 30 else float("nan")
|
||||
|
||||
|
||||
def per_year(daily):
|
||||
_, yrs = xv.yr_breadth(daily)
|
||||
years = [int(y) for y, _ in daily.groupby(daily.index.year)]
|
||||
return [(y, round(v, 3)) for y, v in zip(years, yrs)]
|
||||
|
||||
|
||||
def uplift_for(cand_daily, base, bf, bh, fractions=(0.10, 0.15)):
|
||||
"""Uplift portafoglio 4->5 sleeve riusando le CACHE di `base` (Sleeve cached). Ritorna
|
||||
{fr: (cf, ch, wgt)} e il best combinato (dFULL+dHOLD)."""
|
||||
cand_fn = lambda: cand_daily
|
||||
out, best = {}, None
|
||||
for fr in fractions:
|
||||
wraw = fr / (1.0 - fr) # cand_frac ~ fr (sum_base=1)
|
||||
cand = xv.Sleeve("XSV3_cand", wraw, cand_fn)
|
||||
pf1 = xv.StrategyPortfolio(base + [cand])
|
||||
cf = metrics(pf1.combined_daily())
|
||||
ch = metrics(pf1.combined_daily(lo=HOLDOUT))
|
||||
wgt = pf1.weights().get("XSV3_cand", 0.0)
|
||||
out[fr] = (cf, ch, wgt)
|
||||
d = (cf["sharpe"] - bf["sharpe"]) + (ch["sharpe"] - bh["sharpe"])
|
||||
best = d if best is None else max(best, d)
|
||||
return out, best
|
||||
|
||||
|
||||
INSAMPLE_EDGE = 0.5 # gate del progetto (scorer indurito): edge standalone PRE-holdout >=0.5
|
||||
|
||||
|
||||
def robust_candidate(rows):
|
||||
"""Candidato GIUDICATO: NON il best-by-HOLD nudo (che premia il holdout-fitting: una config
|
||||
negativa in-sample con HOLD alto e' overfit alla finestra OOS, lezione dello scorer indurito),
|
||||
ma il best fra le config con EDGE IN-SAMPLE (>=0.5) E HOLD>0, ordinate per Sharpe BILANCIATO
|
||||
(insample+hold)/2. Se nessuna ha in-sample edge -> None (il meccanismo non ha edge reale,
|
||||
qualunque HOLD alto e' artefatto di selezione)."""
|
||||
elig = [r for r in rows if np.isfinite(r["insample"]) and r["insample"] >= INSAMPLE_EDGE and r["hold"] > 0]
|
||||
if not elig:
|
||||
return None
|
||||
return max(elig, key=lambda r: 0.5 * (r["insample"] + r["hold"]))
|
||||
|
||||
|
||||
def verdict(cand, dsr, caus_ok, uplift_best, has_isedge):
|
||||
full, hold, corrXS = cand["full"], cand["hold"], cand["corrXS"]
|
||||
diversifies = abs(corrXS) < 0.6
|
||||
helps = (uplift_best is not None) and uplift_best > 0.10
|
||||
if not has_isedge:
|
||||
return "SCARTATO", "nessuna config con edge in-sample>=0.5 + HOLD>0 (qualunque HOLD alto e' selezione-su-holdout)"
|
||||
strong = (dsr > 0.95) and (hold > 0.30) and (full > 0.70) and caus_ok and diversifies
|
||||
if strong and helps:
|
||||
return "SLEEVE-CANDIDATE", "edge robusto (DSR>0.95, in-sample+OOS, causale, diversifica, alza il portafoglio)"
|
||||
if (full > 0.5 and hold > 0.0 and diversifies and caus_ok) and (helps or dsr > 0.50):
|
||||
return "LEAD-forward-monitor", "edge in-sample E OOS coerente + diversifica, ma DSR<0.95 (96 trial, storia ~2.5y)"
|
||||
if full > 0.3 and hold > 0.0:
|
||||
return "DEBOLE", "segno giusto ma Sharpe/robustezza insufficienti"
|
||||
return "SCARTATO", "no edge (full/hold non positivi o non diversifica)"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MAIN
|
||||
# ===========================================================================
|
||||
def main():
|
||||
print("=" * 104)
|
||||
print(" XSEC v3 — LOW-RISK COUSINS cross-sectional su Hyperliquid (MAX / IVOL / AMIHUD) — STAT-MODE")
|
||||
print("=" * 104)
|
||||
|
||||
tp_daily = xv.tp01_sleeve().daily()
|
||||
xs_daily = xv.xsec_sleeve().daily()
|
||||
print(" riferimenti corr: TP01 (trend, deployable) e XS01 (momentum cross-sec, sleeve attivo).")
|
||||
|
||||
universes = {"51-all": None, "19-major": xv.XS_UNIVERSE}
|
||||
mats = {}
|
||||
for uname, u in universes.items():
|
||||
PX, VOL = xv.load_matrix(u)
|
||||
mats[uname] = (PX, VOL)
|
||||
print(f" universo {uname:<9}: {PX.shape[1]:>2} asset, {PX.shape[0]} giorni "
|
||||
f"[{PX.index[0].date()} -> {PX.index[-1].date()}]")
|
||||
|
||||
# ---- griglia completa: raccoglie tutte le righe + tutti gli Sharpe FULL (trial DSR) ----
|
||||
MECHS = ("MAX", "IVOL", "AMIHUD_ILLIQ", "AMIHUD_LIQ")
|
||||
rows_by_mech = {mn: [] for mn in MECHS}
|
||||
all_sr = []
|
||||
builders = {} # (uname, mech) -> builder (per causality)
|
||||
for uname, (PX, VOL) in mats.items():
|
||||
mechs = build_mechanisms(VOL)
|
||||
print("\n" + "#" * 104)
|
||||
print(f"# UNIVERSO {uname}")
|
||||
print("#" * 104)
|
||||
for mn in MECHS:
|
||||
builder, cfgs = mechs[mn]
|
||||
builders[(uname, mn)] = builder
|
||||
rows = xv.run_grid(PX, VOL, mn, builder, cfgs, xs_daily, tp_daily, uname)
|
||||
for r in rows:
|
||||
r["uni"] = uname
|
||||
r["mech"] = mn
|
||||
r["insample"] = insample_sharpe(r["daily"])
|
||||
rows_by_mech[mn].extend(rows)
|
||||
all_sr.extend([r["full"] for r in rows])
|
||||
if not rows:
|
||||
print(f"\n [{mn}] nessuna config valida")
|
||||
continue
|
||||
pos_full = sum(r["full"] > 0 for r in rows)
|
||||
pos_hold = sum(r["hold"] > 0 for r in rows)
|
||||
print(f"\n [{mn}] {len(rows)} config | plateau FULL>0: {pos_full}/{len(rows)}"
|
||||
f" | HOLD>0: {pos_hold}/{len(rows)}")
|
||||
print(f" {'cfg':<18}{'FULL':>7}{'inS':>7}{'HOLD':>7}{'DD%':>6}{'ret%':>7}"
|
||||
f"{'anni+':>7}{'corrXS':>8}{'corrTP':>8}{'turn/y':>8}")
|
||||
for r in sorted(rows, key=lambda r: -r["hold"])[:3]:
|
||||
print(f" {xv.tag(r['cfg']):<18}{r['full']:>7.2f}{r['insample']:>7.2f}{r['hold']:>7.2f}"
|
||||
f"{r['dd']*100:>6.0f}{r['ret']*100:>+7.0f}{r['pct']*100:>6.0f}%"
|
||||
f"{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}")
|
||||
|
||||
print(f"\n TRIAL TOTALI testati (per deflated-Sharpe): {len([s for s in all_sr if np.isfinite(s)])}")
|
||||
|
||||
# ---- base portafoglio una sola volta (Sleeve cached, riusati per ogni candidato) ----
|
||||
base = xv.active_sleeves()
|
||||
pf0 = xv.StrategyPortfolio(base); pf0.backtest()
|
||||
bf = metrics(pf0.combined_daily()); bh = metrics(pf0.combined_daily(lo=HOLDOUT))
|
||||
|
||||
# ---- analisi per meccanismo ----
|
||||
# CANDIDATO GIUDICATO = robust_candidate (edge in-sample>=0.5 E HOLD>0, best bilanciato): evita la
|
||||
# trappola del best-by-HOLD nudo (che premia config negative in-sample = overfit alla finestra OOS).
|
||||
# Si riportano comunque, per trasparenza/anti-cherry, anche il naive best-HOLD e il best-inSAMPLE.
|
||||
summary = []
|
||||
chosen_daily = {} # mech -> serie del candidato giudicato (per corr-matrix)
|
||||
for mn in MECHS:
|
||||
rows = rows_by_mech[mn]
|
||||
print("\n" + "=" * 104)
|
||||
print(f" MECCANISMO {mn}")
|
||||
print("=" * 104)
|
||||
if not rows:
|
||||
print(" nessuna config valida -> SCARTATO")
|
||||
summary.append((mn, "SCARTATO", "nessuna config valida", None))
|
||||
continue
|
||||
|
||||
naive_hold = max(rows, key=lambda r: r["hold"])
|
||||
best_is = max(rows, key=lambda r: (r["insample"] if np.isfinite(r["insample"]) else -9))
|
||||
cand = robust_candidate(rows)
|
||||
has_isedge = cand is not None
|
||||
print(f" naive best-HOLD : [{naive_hold['uni']}] {xv.tag(naive_hold['cfg']):<16} "
|
||||
f"FULL {naive_hold['full']:+.2f} inS {naive_hold['insample']:+.2f} HOLD {naive_hold['hold']:+.2f}"
|
||||
f" {'(in-sample NEGATIVO -> holdout-fit)' if naive_hold['insample'] < INSAMPLE_EDGE else ''}")
|
||||
print(f" best-inSAMPLE : [{best_is['uni']}] {xv.tag(best_is['cfg']):<16} "
|
||||
f"FULL {best_is['full']:+.2f} inS {best_is['insample']:+.2f} HOLD {best_is['hold']:+.2f}"
|
||||
f" (anti-selection-on-holdout)")
|
||||
if not has_isedge:
|
||||
print(f" CANDIDATO GIUDICATO: NESSUNA config con in-sample>=0.5 E HOLD>0 "
|
||||
f"-> il meccanismo NON ha edge reale (ogni HOLD alto e' selezione-su-holdout).")
|
||||
verd, why = verdict(naive_hold, float("nan"), True, None, False)
|
||||
print(f"\n >>> VERDETTO {mn}: {verd} — {why}." +
|
||||
(f" tesi: {AMIHUD_THESIS[mn]}" if mn in AMIHUD_THESIS else ""))
|
||||
summary.append((mn, verd, why, dict(uni=naive_hold["uni"], cfg=xv.tag(naive_hold["cfg"]),
|
||||
full=naive_hold["full"], hold=naive_hold["hold"], dd=naive_hold["dd"],
|
||||
dsr=float("nan"), corrXS=naive_hold["corrXS"], up=None, isedge=False)))
|
||||
continue
|
||||
|
||||
daily = cand["daily"]
|
||||
chosen_daily[mn] = daily
|
||||
f, h, pct = xv.evalcfg(daily)
|
||||
dsr, sr0 = xv.deflated_sharpe(f["sharpe"], all_sr, daily)
|
||||
caus = xv.causality_prefix_check(*mats[cand["uni"]], builders[(cand["uni"], mn)], cand["cfg"])
|
||||
ups, up_best = uplift_for(daily, base, bf, bh)
|
||||
|
||||
print(f" CANDIDATO GIUDICATO (in-sample>=0.5 & HOLD>0, best bilanciato):")
|
||||
print(f" [{cand['uni']}] {xv.tag(cand['cfg']):<16} FULL {cand['full']:+.2f} "
|
||||
f"inSAMPLE {cand['insample']:+.2f} HOLD {cand['hold']:+.2f} (in-sample EDGE = OK)")
|
||||
print(f" standalone: DD {f['maxdd']*100:.0f}% ret {f['ret']*100:+.0f}% "
|
||||
f"anni+ {pct*100:.0f}% turnover/y {cand['turn']:.0f}")
|
||||
print(f" corr vs XS01 {cand['corrXS']:+.2f} | corr vs TP01 {cand['corrTP']:+.2f}")
|
||||
print(f" CAUSALITA' prefix-check: ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}")
|
||||
print(f" DEFLATED Sharpe (N={len([s for s in all_sr if np.isfinite(s)])} trial): {dsr:.3f}"
|
||||
f" | soglia Sharpe-max-null annualizz. {sr0:.2f} (serve DSR>0.95)")
|
||||
print(f" per-anno: {per_year(daily)}")
|
||||
print(f" UPLIFT portafoglio (base FULL {bf['sharpe']:.2f} / HOLD {bh['sharpe']:.2f}):")
|
||||
for fr, (cf, ch, wgt) in ups.items():
|
||||
print(f" +cand @{wgt*100:>4.1f}% FULL {cf['sharpe']:.2f} ({cf['sharpe']-bf['sharpe']:+.2f})"
|
||||
f" DD {cf['maxdd']*100:.0f}% | HOLD {ch['sharpe']:.2f} ({ch['sharpe']-bh['sharpe']:+.2f})")
|
||||
|
||||
verd, why = verdict(cand, dsr, caus["ok"], up_best, has_isedge)
|
||||
extra = f" tesi: {AMIHUD_THESIS[mn]}" if mn in AMIHUD_THESIS else ""
|
||||
print(f"\n >>> VERDETTO {mn}: {verd} — {why}.{extra}")
|
||||
summary.append((mn, verd, why, dict(uni=cand["uni"], cfg=xv.tag(cand["cfg"]), full=cand["full"],
|
||||
hold=cand["hold"], dd=f["maxdd"], dsr=dsr, corrXS=cand["corrXS"],
|
||||
up=up_best, isedge=True)))
|
||||
|
||||
# ---- redundancy check: i 3 'low-risk cousins' sono UNA scommessa o TRE? ----
|
||||
if len(chosen_daily) >= 2:
|
||||
print("\n" + "=" * 104)
|
||||
print(" RIDONDANZA — correlazione tra i candidati giudicati (sono lo stesso bet 'evita-speculativo'?)")
|
||||
print("=" * 104)
|
||||
names = list(chosen_daily.keys())
|
||||
print(" " + "".join(f"{n[:8]:>10}" for n in names))
|
||||
for a in names:
|
||||
line = f" {a[:8]:<10}"
|
||||
for b in names:
|
||||
line += f"{xv._corr(chosen_daily[a], chosen_daily[b]):>10.2f}"
|
||||
print(line)
|
||||
print(" NB: corr alta tra i candidati = sono la stessa anomalia low-risk in tre vesti, non tre edge.")
|
||||
|
||||
# ---- AMIHUD: scegli il segno con tesi economica + edge ----
|
||||
print("\n" + "=" * 104)
|
||||
print(" AMIHUD — scelta del segno (tesi economica + edge)")
|
||||
print("=" * 104)
|
||||
a_ill = next((s for s in summary if s[0] == "AMIHUD_ILLIQ"), None)
|
||||
a_liq = next((s for s in summary if s[0] == "AMIHUD_LIQ"), None)
|
||||
for s in (a_ill, a_liq):
|
||||
if s and s[3]:
|
||||
print(f" {s[0]:<14} FULL {s[3]['full']:+.2f} HOLD {s[3]['hold']:+.2f} "
|
||||
f"DSR {s[3]['dsr']:.2f} corrXS {s[3]['corrXS']:+.2f} -> {s[1]} ({AMIHUD_THESIS[s[0]]})")
|
||||
cand_signs = [s for s in (a_ill, a_liq) if s and s[3] and s[3]["full"] > 0 and s[3]["hold"] > 0]
|
||||
if cand_signs:
|
||||
win = max(cand_signs, key=lambda s: 0.5 * (s[3]["full"] + s[3]["hold"]))
|
||||
print(f" -> segno con edge+tesi: {win[0]} ({AMIHUD_THESIS[win[0]]})")
|
||||
else:
|
||||
print(" -> NESSUN segno mostra edge positivo (full>0 e hold>0): AMIHUD SCARTATO in entrambi i versi.")
|
||||
|
||||
# ---- sintesi finale ----
|
||||
print("\n" + "=" * 104)
|
||||
print(" SINTESI FINALE — c'e' un sopravvissuto reale?")
|
||||
print("=" * 104)
|
||||
for mn, verd, why, info in summary:
|
||||
if info:
|
||||
print(f" {mn:<14} {verd:<22} FULL {info['full']:+.2f} HOLD {info['hold']:+.2f} "
|
||||
f"DSR {info['dsr']:.2f} corrXS {info['corrXS']:+.2f} upliftBest {info['up'] if info['up'] is not None else float('nan'):+.2f}")
|
||||
else:
|
||||
print(f" {mn:<14} {verd}")
|
||||
survivors = [s for s in summary if s[1] == "SLEEVE-CANDIDATE"]
|
||||
leads = [s for s in summary if s[1] == "LEAD-forward-monitor"]
|
||||
if survivors:
|
||||
print(f"\n SOPRAVVISSUTO: {', '.join(s[0] for s in survivors)} (sleeve-candidate, comunque STAT-MODE).")
|
||||
elif leads:
|
||||
print(f"\n Nessuno sleeve-candidate. LEAD da forward-monitor: {', '.join(s[0] for s in leads)}.")
|
||||
else:
|
||||
print("\n NESSUN sopravvissuto: tutti DEBOLE/SCARTATO. Risultato valido (la maggior parte muore).")
|
||||
|
||||
print("\n CAVEAT immutabili: storia ~2.5 anni (deflated-Sharpe + multiple-testing), book a molte")
|
||||
print(" gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, MAI deploy. Nessuno sleeve")
|
||||
print(" registrato: e' solo lavoro statistico (vincoli del filone C).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,417 @@
|
||||
"""XSEC v3 — varianti STRUTTURALI di momentum cross-sectional su Hyperliquid (STAT-MODE).
|
||||
|
||||
TESI (filone XS). XS01 (sleeve attivo) e' momentum cross-sectional sui 19 major: blend di lookback
|
||||
[30,90] (z-score cross-sectional mediato) + gate di dispersione, vol-target 20%. Lezione del
|
||||
progetto (diari 2026-06-19/20): "i margini su XS sono nella STRUTTURA DEL SEGNALE, non nel numero
|
||||
di asset". Quindi NON allarghiamo l'universo: testiamo 4 COSTRUZIONI di momentum STRUTTURALMENTE
|
||||
diverse e chiediamo se MIGLIORANO o DIVERSIFICANO XS01 (o se sono solo XS01 travestito).
|
||||
|
||||
Varianti (tutte L/S dollar-neutral, top-k/bottom-k, CAUSALI; long alto score / short basso score):
|
||||
RAMOM - RISK-ADJUSTED momentum: score = ritorno cumulato su L / vol realizzata su L
|
||||
(momentum "Sharpe-like", non grezzo). Penalizza i trend rumorosi.
|
||||
ACCEL - momentum ACCELERATION: score = mom(L_breve) - mom(L_lungo), la curvatura/2a differenza
|
||||
del trend relativo (chi sta accelerando vs chi sta decelerando).
|
||||
FIP - FROG-IN-THE-PAN / information discreteness: score = sign(mom) * ID, dove
|
||||
ID = |%giorni-su - %giorni-giu| su L. Privilegia i trend LISCI (path consistente).
|
||||
VOLSC - VOLATILITY-MANAGED momentum (Moreira-Muir): selezione = momentum, ma la LEVA del book
|
||||
e' scalata dall'inverso della vol di MERCATO cross-section recente (rischia di piu' a
|
||||
mercato calmo, meno in tempesta) invece del vol-target sulla vol della STRATEGIA.
|
||||
|
||||
GIUDIZIO = MARGINALE vs XS01, non assoluto. Una variant con corr~0.9 a XS01 e Sharpe simile NON
|
||||
aggiunge nulla (e' XS01 travestito). Per ognuna calcolo: (a) corr vs XS01 e TP01; (b) uplift del
|
||||
PORTAFOGLIO 4->5 sleeve a 10%/15%; (c) SOSTITUZIONE di XS01 con la variant a parita' di peso. Vince
|
||||
solo se DIVERSIFICA (corr<0.7) E migliora l'hold-out aggiunta, OPPURE DOMINA XS01 a parita' di slot.
|
||||
|
||||
GATE (CLAUDE.md, metodologia obbligatoria):
|
||||
1. griglia L in {30,60,90} (Ls/Ll per ACCEL), H in {5,10}, k in {5,8}, ENTRAMBI universi (51/19).
|
||||
2. CAUSALE: score a close[i], peso tenuto in i+1 (engine shifta); vol=0 gata; prefix-check ok.
|
||||
3. NETTO fee 0.10% RT su ogni gamba/ribilancio + turnover; sweep fee monotona (test).
|
||||
4. DEFLATED Sharpe sul best con TUTTI gli Sharpe FULL come trial (multiple-testing; serve >0.95).
|
||||
5. per-anno + HOLD-OUT 2025-01-01. ANTI selection-on-holdout: riporto best per IN-SAMPLE(<2025)
|
||||
E best per HOLD, e verifico col deflated-Sharpe.
|
||||
6. CAVEAT IMMUTABILE: book a molte gambe NON eseguibile a $600 -> STAT-MODE, MAI deploy.
|
||||
|
||||
uv run python scripts/research/xsec_v3_momstruct.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, xs_engine, evalcfg, ...)
|
||||
from src.portfolio.sleeves import XS_UNIVERSE
|
||||
|
||||
DPY = xv.DPY
|
||||
TV = xv.TV
|
||||
FEE = xv.FEE
|
||||
HOLDOUT = xv.HOLDOUT
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SCORE BUILDERS — closure score_at(i)->(score[A], valid[A]) + warmup. CAUSALI (dati <= i).
|
||||
# Modellati su make_mom/make_resid di xsec_v2_nonmom.py.
|
||||
# ===========================================================================
|
||||
def make_ramom(PX, L):
|
||||
"""Risk-adjusted momentum: score = (px[i]/px[i-L]-1) / std(ritorni giornalieri su L)."""
|
||||
px, n, A, DR, _ = xv._precompute(PX)
|
||||
RVL = DR.rolling(L, min_periods=int(0.8 * L)).std().values
|
||||
def score_at(i):
|
||||
if i - L < 0:
|
||||
return np.full(A, np.nan), np.zeros(A, bool)
|
||||
r = px[i] / px[i - L] - 1.0
|
||||
rv = RVL[i]
|
||||
with np.errstate(invalid="ignore", divide="ignore"):
|
||||
score = r / rv
|
||||
valid = np.isfinite(score) & np.isfinite(px[i]) & np.isfinite(px[i - L]) & (rv > 0)
|
||||
return score, valid
|
||||
return score_at, L + 1
|
||||
|
||||
|
||||
def make_accel(PX, Ls, Ll):
|
||||
"""Acceleration: score = mom(Ls) - mom(Ll) (Ls<Ll), entrambi a close[i]."""
|
||||
px, n, A, *_ = xv._precompute(PX)
|
||||
def score_at(i):
|
||||
if i - Ll < 0:
|
||||
return np.full(A, np.nan), np.zeros(A, bool)
|
||||
r_s = px[i] / px[i - Ls] - 1.0
|
||||
r_l = px[i] / px[i - Ll] - 1.0
|
||||
score = r_s - r_l
|
||||
valid = np.isfinite(px[i]) & np.isfinite(px[i - Ls]) & np.isfinite(px[i - Ll])
|
||||
return score, valid
|
||||
return score_at, Ll + 1
|
||||
|
||||
|
||||
def make_fip(PX, L):
|
||||
"""Frog-in-the-pan / information discreteness: score = sign(mom_L) * |%up - %down| su L.
|
||||
%up/%down calcolati sui SOLI giorni osservati (NaN pre-listing esclusi). Trend lisci -> |.| alto."""
|
||||
px, n, A, DR, _ = xv._precompute(PX)
|
||||
up = (DR > 0).astype(float).where(DR.notna())
|
||||
dn = (DR < 0).astype(float).where(DR.notna())
|
||||
mp = int(0.8 * L)
|
||||
UPc = up.rolling(L, min_periods=mp).sum().values
|
||||
DNc = dn.rolling(L, min_periods=mp).sum().values
|
||||
CNT = DR.rolling(L, min_periods=mp).count().values
|
||||
def score_at(i):
|
||||
if i - L < 0:
|
||||
return np.full(A, np.nan), np.zeros(A, bool)
|
||||
r = px[i] / px[i - L] - 1.0
|
||||
c = CNT[i]
|
||||
with np.errstate(invalid="ignore", divide="ignore"):
|
||||
pu = UPc[i] / c
|
||||
pdn = DNc[i] / c
|
||||
idd = np.abs(pu - pdn)
|
||||
score = np.sign(r) * idd
|
||||
valid = (np.isfinite(px[i]) & np.isfinite(px[i - L]) & np.isfinite(idd) & (c >= mp))
|
||||
return score, valid
|
||||
return score_at, L + 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ENGINE volatility-managed (VOLSC): selezione momentum top-k/bottom-k IDENTICA a xs_engine, ma il
|
||||
# vol-target NON e' sulla vol della STRATEGIA bensi' sull'inverso della vol di MERCATO cross-section
|
||||
# (equal-weight) recente (Moreira-Muir). Distinzione strutturale unica da XS01. CAUSALE (shift(1)).
|
||||
# ===========================================================================
|
||||
def xs_engine_mktvol(PX, VOL, score_at, H, k, B_mkt=20, target_vol=TV, fee=FEE, min_assets=10,
|
||||
warmup=0, cap=3.0):
|
||||
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)
|
||||
for i in range(n):
|
||||
if i >= warmup and i % H == 0:
|
||||
score, valid = score_at(i)
|
||||
valid = valid & np.isfinite(score) & (vol[i] > 0)
|
||||
idxv = np.where(valid)[0]
|
||||
if len(idxv) >= min_assets:
|
||||
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
|
||||
w[lo] = -0.5 / kk
|
||||
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)
|
||||
# vol-target sulla vol di MERCATO (equal-weight), causale (shift 1): leva alta a mercato calmo
|
||||
mkt = PX.pct_change().mean(axis=1)
|
||||
sig_mkt = mkt.rolling(B_mkt, min_periods=int(0.6 * B_mkt)).std().shift(1) * np.sqrt(DPY)
|
||||
scale = np.clip(np.nan_to_num(target_vol / sig_mkt.replace(0, np.nan).values, nan=0.0), 0, cap)
|
||||
turn_py = float(turn.sum() / (n / DPY)) if n else 0.0
|
||||
return pd.Series(s.values * scale, index=PX.index), turn_py
|
||||
|
||||
|
||||
def caus_check_mktvol(PX, VOL, builder, cfg, B_mkt=20, frac=0.85, tail=60, tol=1e-9):
|
||||
"""Prefix-check di causalita' per il pipeline VOLSC (engine custom): ricostruisce su un prefisso
|
||||
e confronta la coda con la run completa. Look-ahead -> divergenza."""
|
||||
sa, warm = builder(PX, cfg)
|
||||
full, _ = xs_engine_mktvol(PX, VOL, sa, cfg["H"], cfg["k"], B_mkt=B_mkt, warmup=warm)
|
||||
cut = int(len(PX) * frac)
|
||||
PXc, VOLc = PX.iloc[:cut], VOL.iloc[:cut]
|
||||
sa2, warm2 = builder(PXc, cfg)
|
||||
pre, _ = xs_engine_mktvol(PXc, VOLc, sa2, cfg["H"], cfg["k"], B_mkt=B_mkt, warmup=warm2)
|
||||
lo = max(0, cut - tail)
|
||||
a = full.values[lo:cut]
|
||||
b = 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))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# REGISTRY varianti: builder(PX,p)->(score_at,warm), griglia config, engine.
|
||||
# ===========================================================================
|
||||
def variants():
|
||||
Lg = (30, 60, 90)
|
||||
Hk = [dict(H=H, k=k) for H in (5, 10) for k in (5, 8)]
|
||||
accel_pairs = [(30, 60), (30, 90), (60, 90)]
|
||||
return {
|
||||
"RAMOM": dict(
|
||||
builder=lambda PX, p: make_ramom(PX, p["L"]),
|
||||
cfgs=[dict(L=L, **hk) for L in Lg for hk in Hk],
|
||||
engine="std", B_mkt=None),
|
||||
"ACCEL": dict(
|
||||
builder=lambda PX, p: make_accel(PX, p["Ls"], p["Ll"]),
|
||||
cfgs=[dict(Ls=ls, Ll=ll, **hk) for (ls, ll) in accel_pairs for hk in Hk],
|
||||
engine="std", B_mkt=None),
|
||||
"FIP": dict(
|
||||
builder=lambda PX, p: make_fip(PX, p["L"]),
|
||||
cfgs=[dict(L=L, **hk) for L in Lg for hk in Hk],
|
||||
engine="std", B_mkt=None),
|
||||
"VOLSC": dict(
|
||||
builder=lambda PX, p: xv.make_mom(PX, p["L"], +1),
|
||||
cfgs=[dict(L=L, **hk) for L in Lg for hk in Hk],
|
||||
engine="mktvol", B_mkt=20),
|
||||
}
|
||||
|
||||
|
||||
def run_variant_cfg(PX, VOL, v, p):
|
||||
sa, warm = v["builder"](PX, p)
|
||||
if v["engine"] == "mktvol":
|
||||
s, turn = xs_engine_mktvol(PX, VOL, sa, p["H"], p["k"], B_mkt=v["B_mkt"], warmup=warm)
|
||||
else:
|
||||
s, turn = xv.xs_engine(PX, VOL, sa, p["H"], p["k"], warmup=warm)
|
||||
return xv.to_daily(s), turn
|
||||
|
||||
|
||||
def tag(p):
|
||||
return " ".join(f"{kk}{vv}" for kk, vv in p.items())
|
||||
|
||||
|
||||
def run_grid(PX, VOL, v, xs_daily, tp_daily, uname):
|
||||
rows = []
|
||||
for p in v["cfgs"]:
|
||||
daily, turn = run_variant_cfg(PX, VOL, v, p)
|
||||
if daily.std() == 0 or len(daily) < 60:
|
||||
continue
|
||||
f, h, pct = xv.evalcfg(daily)
|
||||
ins = xv.metrics(daily[daily.index < HOLDOUT])["sharpe"]
|
||||
rows.append(dict(cfg=p, uni=uname, daily=daily, full=f["sharpe"], hold=h["sharpe"],
|
||||
ins=ins, dd=f["maxdd"], ret=f["ret"], pct=pct,
|
||||
corrXS=xv._corr(daily, xs_daily), corrTP=xv._corr(daily, tp_daily),
|
||||
turn=turn))
|
||||
return rows
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PORTAFOGLIO — base cablata una sola volta (cache sleeve riusate per uplift+sostituzione).
|
||||
# ===========================================================================
|
||||
_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() # warma le cache degli sleeve
|
||||
_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("XSV3_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("XSV3_cand", 0.0))
|
||||
|
||||
|
||||
def substitute_xs01(daily):
|
||||
base, _ = _base()
|
||||
sub = [xv.Sleeve("XSV3_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))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# REPORT
|
||||
# ===========================================================================
|
||||
def per_year(daily):
|
||||
out = []
|
||||
for y, g in daily.groupby(daily.index.year):
|
||||
out.append((int(y), round(float((1 + g).prod() - 1), 3)))
|
||||
return out
|
||||
|
||||
|
||||
def variant_verdict(pick, up_best, sub_full_d, sub_hold_d, caus_ok):
|
||||
cx = abs(pick["corrXS"])
|
||||
if not caus_ok:
|
||||
return "SCARTATO", "non causale (prefix-check fallito)"
|
||||
if pick["full"] <= 0.3 or pick["hold"] <= 0:
|
||||
return "SCARTATO", f"standalone debole (FULL {pick['full']:+.2f}, HOLD {pick['hold']:+.2f})"
|
||||
dominates = (sub_full_d > 0.02 and sub_hold_d > 0.05)
|
||||
diversifies = (cx < 0.7) and (up_best[1] > 0.05) # up_best=(Δfull,Δhold)
|
||||
if dominates:
|
||||
return "MIGLIORA-XS01", f"sostituendo XS01 il book sale FULL {sub_full_d:+.2f} / HOLD {sub_hold_d:+.2f}"
|
||||
if diversifies:
|
||||
return "DIVERSIFICA", f"corrXS {pick['corrXS']:+.2f}<0.7 e uplift HOLD aggiunta {up_best[1]:+.2f}"
|
||||
if cx >= 0.7:
|
||||
return "REDUNDANT", f"corrXS {pick['corrXS']:+.2f} alta -> momentum XS01 travestito"
|
||||
return "REDUNDANT", f"scorrelata (corrXS {pick['corrXS']:+.2f}) ma non additiva (uplift HOLD {up_best[1]:+.2f}, sub HOLD {sub_hold_d:+.2f})"
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 104)
|
||||
print(" XSEC v3 — VARIANTI STRUTTURALI di momentum cross-sectional (RAMOM/ACCEL/FIP/VOLSC) — STAT-MODE")
|
||||
print("=" * 104)
|
||||
|
||||
tp_daily = xv.tp01_sleeve().daily()
|
||||
xs_daily = xv.xsec_sleeve().daily()
|
||||
print(" riferimenti: XS01 (momentum blend+gate, sleeve attivo) e TP01 (trend BTC/ETH).")
|
||||
xs_f = xv.metrics(xs_daily)
|
||||
xs_h = xv.metrics(xs_daily[xs_daily.index >= HOLDOUT])
|
||||
print(f" XS01 standalone: FULL Sh {xs_f['sharpe']:.2f} DD {xs_f['maxdd']*100:.0f}% | "
|
||||
f"HOLD Sh {xs_h['sharpe']:.2f}")
|
||||
|
||||
universes = {"51-all": None, "19-major": XS_UNIVERSE}
|
||||
mats = {}
|
||||
for uname, u in universes.items():
|
||||
PX, VOL = xv.load_matrix(u)
|
||||
mats[uname] = (PX, VOL)
|
||||
print(f" universo {uname:<9}: {PX.shape[1]} asset, {PX.shape[0]} giorni "
|
||||
f"[{PX.index[0].date()} -> {PX.index[-1].date()}]")
|
||||
|
||||
vdefs = variants()
|
||||
all_full = []
|
||||
per_var_rows = {}
|
||||
for vname, v in vdefs.items():
|
||||
rows_all = []
|
||||
for uname, (PX, VOL) in mats.items():
|
||||
rows = run_grid(PX, VOL, v, xs_daily, tp_daily, uname)
|
||||
rows_all += rows
|
||||
all_full += [r["full"] for r in rows]
|
||||
per_var_rows[vname] = rows_all
|
||||
|
||||
base, (bf, bh) = _base()
|
||||
print(f"\n BASE portafoglio (4 sleeve attivi): FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.0f}%"
|
||||
f" | HOLD Sh {bh['sharpe']:.2f} DD {bh['maxdd']*100:.0f}%")
|
||||
|
||||
summary = []
|
||||
for vname, v in vdefs.items():
|
||||
rows = per_var_rows[vname]
|
||||
if not rows:
|
||||
print(f"\n [{vname}] nessuna config valida.")
|
||||
continue
|
||||
n = len(rows)
|
||||
pos_full = sum(r["full"] > 0 for r in rows)
|
||||
pos_hold = sum(r["hold"] > 0 for r in rows)
|
||||
pick_ins = max(rows, key=lambda r: (r["ins"], r["full"])) # selezione ONESTA (in-sample)
|
||||
pick_hold = max(rows, key=lambda r: r["hold"]) # ceiling ottimistico
|
||||
|
||||
print("\n" + "#" * 104)
|
||||
print(f"# {vname} | {n} config x2 universi | plateau FULL>0 {pos_full}/{n} | HOLD>0 {pos_hold}/{n}")
|
||||
print("#" * 104)
|
||||
print(f" {'pick':<12}{'cfg':<24}{'uni':<10}{'FULL':>6}{'INS':>6}{'HOLD':>6}{'DD%':>6}"
|
||||
f"{'ret%':>7}{'an+':>6}{'crXS':>7}{'crTP':>7}{'t/y':>7}")
|
||||
for lbl, r in (("by-INS<2025", pick_ins), ("by-HOLD", pick_hold)):
|
||||
print(f" {lbl:<12}{tag(r['cfg']):<24}{r['uni']:<10}{r['full']:>6.2f}{r['ins']:>6.2f}"
|
||||
f"{r['hold']:>6.2f}{r['dd']*100:>6.0f}{r['ret']*100:>+7.0f}{r['pct']*100:>5.0f}%"
|
||||
f"{r['corrXS']:>+7.2f}{r['corrTP']:>+7.2f}{r['turn']:>7.0f}")
|
||||
# top-3 per IN-SAMPLE per leggere il plateau
|
||||
print(" --- top-3 by IN-SAMPLE Sharpe (plateau) ---")
|
||||
for r in sorted(rows, key=lambda r: -r["ins"])[:3]:
|
||||
print(f" {tag(r['cfg']):<24}{r['uni']:<10}FULL {r['full']:+.2f} INS {r['ins']:+.2f}"
|
||||
f" HOLD {r['hold']:+.2f} corrXS {r['corrXS']:+.2f}")
|
||||
|
||||
# ---- gate sul pick_ins (selezione onesta) ----
|
||||
pick = pick_ins
|
||||
v_uni = pick["uni"]
|
||||
PX, VOL = mats[v_uni]
|
||||
if v["engine"] == "mktvol":
|
||||
caus = caus_check_mktvol(PX, VOL, v["builder"], pick["cfg"], B_mkt=v["B_mkt"])
|
||||
else:
|
||||
caus = xv.causality_prefix_check(PX, VOL, v["builder"], pick["cfg"])
|
||||
dsr, sr0 = xv.deflated_sharpe(pick["full"], all_full, pick["daily"])
|
||||
print(f" CAUSALITA' (prefix-check) ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}")
|
||||
print(f" DEFLATED Sharpe (N={len([s for s in all_full if np.isfinite(s)])} trial GLOBALI): "
|
||||
f"{dsr:.3f} | soglia Sharpe-max-null {sr0:.2f} (serve >0.95)")
|
||||
print(f" per-anno (pick-INS): {per_year(pick['daily'])}")
|
||||
|
||||
# ---- portafoglio: uplift 4->5 e SOSTITUZIONE di XS01 (a parita' di peso) ----
|
||||
print(" UPLIFT (aggiunta come 5o sleeve):")
|
||||
up_best = (-9.0, -9.0)
|
||||
for fr in (0.10, 0.15):
|
||||
cf, ch, wgt = add_uplift(pick["daily"], fr)
|
||||
df_, dh_ = cf["sharpe"] - bf["sharpe"], ch["sharpe"] - bh["sharpe"]
|
||||
print(f" @{wgt*100:>4.1f}% FULL {cf['sharpe']:.2f} ({df_:+.2f}) DD {cf['maxdd']*100:.0f}%"
|
||||
f" | HOLD {ch['sharpe']:.2f} ({dh_:+.2f})")
|
||||
if (df_ + dh_) > (up_best[0] + up_best[1]):
|
||||
up_best = (df_, dh_)
|
||||
sf, sh = substitute_xs01(pick["daily"])
|
||||
sub_full_d, sub_hold_d = sf["sharpe"] - bf["sharpe"], sh["sharpe"] - bh["sharpe"]
|
||||
print(f" SOSTITUZIONE XS01->{vname} (peso {base[1].weight:.4f}): "
|
||||
f"FULL {sf['sharpe']:.2f} ({sub_full_d:+.2f}) DD {sf['maxdd']*100:.0f}%"
|
||||
f" | HOLD {sh['sharpe']:.2f} ({sub_hold_d:+.2f})")
|
||||
|
||||
verdict, why = variant_verdict(pick, up_best, sub_full_d, sub_hold_d, caus["ok"])
|
||||
print(f" >>> VERDETTO {vname}: {verdict} — {why}")
|
||||
summary.append(dict(name=vname, pick=pick, dsr=dsr, caus=caus["ok"], up=up_best,
|
||||
sub=(sub_full_d, sub_hold_d), verdict=verdict))
|
||||
|
||||
# ---- SINTESI ----
|
||||
print("\n" + "=" * 104)
|
||||
print(" SINTESI — giudizio MARGINALE vs XS01 (sleeve attivo)")
|
||||
print("=" * 104)
|
||||
print(f" {'variant':<8}{'FULL':>6}{'HOLD':>6}{'DD%':>6}{'corrXS':>8}{'corrTP':>8}{'DSR':>7}"
|
||||
f"{'+upHOLD':>9}{'subHOLD':>9} verdetto")
|
||||
for s in summary:
|
||||
p = s["pick"]
|
||||
print(f" {s['name']:<8}{p['full']:>6.2f}{p['hold']:>6.2f}{p['dd']*100:>6.0f}"
|
||||
f"{p['corrXS']:>+8.2f}{p['corrTP']:>+8.2f}{s['dsr']:>7.3f}{s['up'][1]:>+9.2f}"
|
||||
f"{s['sub'][1]:>+9.2f} {s['verdict']}")
|
||||
|
||||
winners = [s for s in summary if s["verdict"] in ("MIGLIORA-XS01", "DIVERSIFICA")]
|
||||
print("\n CONCLUSIONE:")
|
||||
if not winners:
|
||||
print(" NESSUNA variante batte o diversifica davvero XS01. Tutte sono momentum-family ad")
|
||||
print(" alta corr con XS01 e/o non additive al portafoglio -> REDUNDANT/SCARTATO. La")
|
||||
print(" struttura del segnale (risk-adj/accel/smoothness/vol-timing) NON apre uno slot nuovo.")
|
||||
else:
|
||||
for s in winners:
|
||||
print(f" {s['name']}: {s['verdict']} (forward-monitor). corrXS {s['pick']['corrXS']:+.2f}, "
|
||||
f"+upHOLD {s['up'][1]:+.2f}, subHOLD {s['sub'][1]:+.2f}, DSR {s['dsr']:.3f}.")
|
||||
print("\n CAVEAT (immutabili): storia ~2.5 anni (deflated-Sharpe + multiple-testing); book a molte")
|
||||
print(" gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, MAI deploy. Nessuno sleeve")
|
||||
print(" registrato: e' lavoro statistico (vincoli del filone XS).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""BOOK DERIBIT-ONLY (TP01 + SKH01) — target NETTO per asset via NETTING SOFTWARE su un solo conto.
|
||||
|
||||
TP01 e SKH01 tradano lo STESSO strumento (BTC/ETH _USDC-PERPETUAL). Su Deribit esiste UNA sola
|
||||
posizione netta per strumento per conto -> non si possono tenere due gambe separate: si combinano in
|
||||
software in un unico target netto, e si manda UN ordine per asset per raggiungerlo.
|
||||
|
||||
Formula (preserva il budget di rischio $300/asset, split 75/25; coerente col blend di ritorni
|
||||
deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01):
|
||||
|
||||
net_target_usd[asset] = clamp( WEIGHT * E * (W_TP01 * tp_frac + W_SKH * skh_sign), ±CAP )
|
||||
|
||||
WEIGHT = 0.5 (book 50/50 BTC+ETH, come i due sleeve)
|
||||
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)
|
||||
E = equity reale del conto (fallback paper se offline)
|
||||
CAP = max_notional_per_asset_usd (config/live.json, $300)
|
||||
|
||||
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
|
||||
-> il target netto si aggiorna -> il reconciler chiude la quota SKH. NB: gli exit sono SOFTWARE
|
||||
(no bracket on-book per SKH, sennò chiuderebbero anche la quota TP01) -> latenza fino alla chiusura
|
||||
della barra 230m corrente. Solo il disaster-SL (-30%) resta on-book, sulla posizione NETTA.
|
||||
|
||||
Questo modulo NON invia nulla: costruisce solo il report/ordine. L'invio è in scripts/live/book_execute.py
|
||||
(doppio gate). Causale: usa solo barre chiuse (eredita la causalità di TP01 e di _skyhook_positions).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from src.live.deribit import INSTRUMENT, notional_to_amount
|
||||
from src.live.shadow import ASSETS, WEIGHT, shadow_report
|
||||
from src.portfolio.sleeves import _skyhook_positions
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
CONFIG = PROJECT_ROOT / "config" / "live.json"
|
||||
|
||||
# Pesi del book Deribit-only (vedi src/portfolio/sleeves.deribit_book_sleeves).
|
||||
W_TP01 = 0.75
|
||||
W_SKH = 0.25
|
||||
FLAT_USD = 1.0
|
||||
|
||||
|
||||
def _cap() -> float:
|
||||
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
|
||||
return float(cfg.get("max_notional_per_asset_usd", 300.0))
|
||||
|
||||
|
||||
def book_net_target(tp_frac: float, skh_sign: int, equity: float, cap: float,
|
||||
weight: float = WEIGHT) -> float:
|
||||
"""Target NETTO (USD notional, segno = direzione) di un asset del book. PURA, testabile.
|
||||
Combina la frazione long-flat di TP01 (peso 0.75) e il segno L/S di SKH01 (peso 0.25),
|
||||
clampata al cap per-asset. Vedi formula nel docstring del modulo."""
|
||||
raw = weight * equity * (W_TP01 * max(tp_frac, 0.0) + W_SKH * float(skh_sign))
|
||||
return max(-cap, min(cap, raw))
|
||||
|
||||
|
||||
def _skh_sign(state) -> int:
|
||||
if state == "flat" or not isinstance(state, dict):
|
||||
return 0
|
||||
return 1 if state.get("dir") == "LONG" else -1
|
||||
|
||||
|
||||
def build_book_order(instrument: str, net_target_usd: float, current_pos_usd: float,
|
||||
mark: float | None, min_usd: float = 5.0) -> dict | None:
|
||||
"""COSTRUISCE (non invia) l'ordine per portare la posizione al target NETTO. Ritorna dict-ordine
|
||||
o None se sotto-soglia. Gestisce long/short e i flip:
|
||||
- |delta| < min_usd -> None (già a target);
|
||||
- target ~0 -> CLOSE (reduce_only, esce sempre);
|
||||
- flip di segno (long<->short) -> needs_flip=True (close + open, gestito dall'executor);
|
||||
- stesso segno, |target|<|cur| -> REDUCE (reduce_only);
|
||||
- altrimenti -> OPEN/INCREASE (buy se delta>0, sell se delta<0)."""
|
||||
delta = net_target_usd - current_pos_usd
|
||||
if abs(delta) < min_usd:
|
||||
return None
|
||||
side = "buy" if delta > 0 else "sell"
|
||||
is_close = abs(net_target_usd) < FLAT_USD and abs(current_pos_usd) > FLAT_USD
|
||||
needs_flip = (current_pos_usd > FLAT_USD and net_target_usd < -FLAT_USD) or \
|
||||
(current_pos_usd < -FLAT_USD and net_target_usd > FLAT_USD)
|
||||
same_sign = (net_target_usd > 0) == (current_pos_usd > 0)
|
||||
is_reduce = is_close or (same_sign and abs(net_target_usd) < abs(current_pos_usd) and not needs_flip)
|
||||
amount = notional_to_amount(instrument, abs(delta), price=mark)
|
||||
if amount == 0.0 and not is_close:
|
||||
return None
|
||||
return dict(
|
||||
instrument=instrument, side=side, amount=amount, type="market",
|
||||
reduce_only=bool(is_reduce or is_close), needs_flip=bool(needs_flip), is_close=bool(is_close),
|
||||
net_target=round(net_target_usd, 2), current=round(current_pos_usd, 2), delta=round(delta, 2),
|
||||
)
|
||||
|
||||
|
||||
def book_report(offline: bool = False, equity_override: float | None = None,
|
||||
live_feed: bool = False) -> dict:
|
||||
"""Stato completo del book (TP01+SKH01) NETTO, per asset. NON invia nulla. Serializzabile.
|
||||
Riusa shadow_report (target TP01 + conto/posizioni/mark reali) e ci somma il segno di SKH01.
|
||||
|
||||
live_feed: se True usa il feed 5m fresco effimero per SKH01 (src/live/livefeed.fresh_5m) ->
|
||||
segnale 230m all'ultima barra chiusa, per l'esecuzione reale. Default False (feed certificato,
|
||||
deterministico: dashboard/test). TP01 resta sul certificato (è giornaliero)."""
|
||||
sh = shadow_report(offline=offline, equity_override=equity_override)
|
||||
cap = _cap()
|
||||
equity = sh["equity"]
|
||||
load5m = None
|
||||
if live_feed:
|
||||
from src.live.livefeed import fresh_5m
|
||||
load5m = fresh_5m
|
||||
try:
|
||||
skh = _skyhook_positions(load5m=load5m)
|
||||
except Exception as e: # non bloccare il report se il feed SKH fallisce
|
||||
skh = {a: "flat" for a in ASSETS}
|
||||
sh = {**sh, "skh_error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
assets, orders = [], []
|
||||
for a_rec in sh["assets"]:
|
||||
a = a_rec["asset"]
|
||||
inst = INSTRUMENT[a]
|
||||
tp_frac = float(a_rec["target"])
|
||||
st = skh.get(a, "flat")
|
||||
sign = _skh_sign(st)
|
||||
net = book_net_target(tp_frac, sign, equity, cap)
|
||||
cur = float(a_rec["position_usd"])
|
||||
mark = a_rec["mark"]
|
||||
order = build_book_order(inst, net, cur, mark, min_usd=5.0)
|
||||
if order:
|
||||
orders.append(order)
|
||||
assets.append(dict(
|
||||
asset=a, instrument=inst,
|
||||
tp_frac=round(tp_frac, 4), skh_sign=sign,
|
||||
skh_state=(st if st == "flat" else {k: st[k] for k in ("dir", "entry", "sl", "tp", "bars_in", "max_bars") if k in st}),
|
||||
net_target=round(net, 2), position_usd=round(cur, 2), mark=mark, mark_src=a_rec.get("mark_src"),
|
||||
order=order,
|
||||
))
|
||||
return dict(
|
||||
last_data=sh["last_data"], online=sh["online"],
|
||||
real_equity=sh["real_equity"], equity=equity, eq_basis=sh["eq_basis"],
|
||||
cap_per_asset=cap, weights=dict(TP01=W_TP01, SKH01=W_SKH),
|
||||
assets=assets, orders=orders,
|
||||
flat=all(abs(x["net_target"]) < FLAT_USD for x in assets),
|
||||
)
|
||||
+96
-16
@@ -14,7 +14,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np, pandas as pd
|
||||
from src.portfolio.portfolio import StrategyPortfolio, metrics, HOLDOUT
|
||||
from src.portfolio.sleeves import active_sleeves
|
||||
from src.portfolio.sleeves import active_sleeves, book_trade_frequency
|
||||
from src.portfolio.gtaa import gtaa_weights
|
||||
from src.live.shadow import shadow_report, tp01_trades
|
||||
from src.version import APP_VERSION
|
||||
@@ -22,6 +22,7 @@ from src.version import APP_VERSION
|
||||
PAPER = PROJECT_ROOT / "data" / "paper_portfolio" / "state.json"
|
||||
PREVDAY = PROJECT_ROOT / "data" / "paper_prevday" / "state.json"
|
||||
COMBO = PROJECT_ROOT / "data" / "paper_combo" / "state.json"
|
||||
STATARB = PROJECT_ROOT / "data" / "paper_statarb" / "state.json"
|
||||
_CACHE = {"t": 0.0, "data": None}
|
||||
_TTL = 120.0
|
||||
|
||||
@@ -32,12 +33,28 @@ def build():
|
||||
pf = StrategyPortfolio(active_sleeves(), capital=2000.0)
|
||||
bt = pf.backtest()
|
||||
eq = bt["equity"]; idx = bt["index"]
|
||||
# BOOK DERIBIT-ONLY eseguibile (TP01 75% + SKH01 25%), riusando i daily gia' calcolati
|
||||
try:
|
||||
sl_by = {s.name: s for s in pf.sleeves}
|
||||
tp_d, sk_d = sl_by["TP01_trend_1d"].daily(), sl_by["SKH01_skyhook"].daily()
|
||||
Jd = pd.concat({"t": tp_d, "s": sk_d}, axis=1, join="inner").fillna(0.0)
|
||||
der = 0.75 * Jd["t"] + 0.25 * Jd["s"]
|
||||
mm = ((1.0 + der).resample("ME").prod() - 1.0).dropna()
|
||||
d_cagr = float(np.prod(1.0 + mm.values) ** (12.0 / len(mm)) - 1.0) if len(mm) else 0.0
|
||||
cons = d_cagr * 0.5
|
||||
rd = (1.0 + cons) ** (1.0 / 365.0) - 1.0 if cons > 0 else 0.0
|
||||
deribit = dict(full=metrics(der), hold=metrics(der[der.index >= HOLDOUT]),
|
||||
cagr=d_cagr, cons_cagr=cons, eday_5k=5000.0 * rd,
|
||||
eq5_5y=5000.0 * (1.0 + cons) ** 5, eq5_10y=5000.0 * (1.0 + cons) ** 10)
|
||||
except Exception as e:
|
||||
deribit = {"error": f"{type(e).__name__}: {e}"}
|
||||
# sparkline: subsample ~400 punti
|
||||
step = max(1, len(eq) // 400)
|
||||
spark = [(str(idx[i].date()), float(eq[i])) for i in range(0, len(eq), step)]
|
||||
paper = json.loads(PAPER.read_text()) if PAPER.exists() else None
|
||||
prevday = json.loads(PREVDAY.read_text()) if PREVDAY.exists() else None
|
||||
combo = json.loads(COMBO.read_text()) if COMBO.exists() else None
|
||||
statarb = json.loads(STATARB.read_text()) if STATARB.exists() else None
|
||||
try:
|
||||
gtaa_w = gtaa_weights() # pesi ETF correnti azionabili (cache eq_*)
|
||||
except Exception:
|
||||
@@ -50,14 +67,18 @@ def build():
|
||||
trades = tp01_trades(limit=15) # entry/exit TP01 dal segnale causale
|
||||
except Exception:
|
||||
trades = []
|
||||
try:
|
||||
freq = book_trade_frequency() # trade/anno contati (SKH01 round-trip + TP01 turnover)
|
||||
except Exception as e:
|
||||
freq = {"error": f"{type(e).__name__}: {e}"}
|
||||
data = dict(
|
||||
version=APP_VERSION,
|
||||
last_data=str(idx[-1].date()),
|
||||
full=bt["full"], holdout=bt["holdout"], weights=bt["weights"],
|
||||
per_sleeve=bt["per_sleeve"], yearly=bt["yearly"],
|
||||
positions=pf.current_positions(), spark=spark, paper=paper, prevday=prevday,
|
||||
combo=combo, gtaa_weights=gtaa_w,
|
||||
shadow=shadow, trades=trades, bh=None,
|
||||
combo=combo, statarb=statarb, gtaa_weights=gtaa_w, deribit=deribit,
|
||||
shadow=shadow, trades=trades, freq=freq, bh=None,
|
||||
)
|
||||
_CACHE.update(t=time.time(), data=data)
|
||||
return data
|
||||
@@ -124,6 +145,22 @@ def html():
|
||||
f"forward da {pd.Timestamp(pv['start_ts'], unit='ms').date()}")
|
||||
else:
|
||||
prevday_html = "non inizializzato (gira <code>scripts/live/paper_prevday.py</code>)"
|
||||
sa = d.get("statarb")
|
||||
if sa:
|
||||
days = (sa["last_ts"] - sa["start_ts"]) / 86400_000
|
||||
rm = sa["cap_modeled"] / 2000.0 - 1
|
||||
rr = sa["cap_real"] / 600.0 - 1
|
||||
statarb_html = (
|
||||
f"<b>${sa['cap_modeled']:,.2f}</b> modeled (€2k nom.) · "
|
||||
f"<b>${sa['cap_real']:,.2f}</b> real-$600 (min-order $5)<br>"
|
||||
f"ret <b>{rm*100:+.2f}%</b> / <b>{rr*100:+.2f}%</b> · "
|
||||
f"maxDD {sa['dd_modeled']*100:.1f}% / {sa['dd_real']*100:.1f}% · "
|
||||
f"fill-haircut {(rm-rr)*100:+.2f}pp<br>"
|
||||
f"posizione spread (long ETH / short BTC per unità): <b>{sa['pos_real']:+.3f}</b> · "
|
||||
f"{sa['n_bars']} barre 1d (~{days:.0f}g) · {sa['n_trades']} flip · "
|
||||
f"forward da {pd.Timestamp(sa['start_ts'], unit='ms').date()}")
|
||||
else:
|
||||
statarb_html = "non inizializzato (gira <code>scripts/live/paper_statarb.py</code>)"
|
||||
cb = d.get("combo")
|
||||
if cb:
|
||||
cdays = (pd.Timestamp(cb["last"]) - pd.Timestamp(cb["start"])).days
|
||||
@@ -146,6 +183,18 @@ def html():
|
||||
+ f" <span style='color:#8a93a0'>(asof {asof})</span>")
|
||||
else:
|
||||
gw_html = "n/d (cache ETF assente — gira fetch_ib_equities.py)"
|
||||
db = d.get("deribit")
|
||||
if db and "error" not in db:
|
||||
f2, h2 = db["full"], db["hold"]
|
||||
deribit_html = (
|
||||
f"FULL <b>Sh {f2['sharpe']:.2f}</b> ret {f2['ret']*100:+.0f}% DD {f2['maxdd']*100:.1f}% · "
|
||||
f"HOLD-OUT <b>Sh {h2['sharpe']:.2f}</b> DD {h2['maxdd']*100:.1f}%<br>"
|
||||
f"<span style='color:#8a93a0;font-size:13px'>accumulo (reinvesti le vincite, no leva) — CAGR storico "
|
||||
f"<b>{db['cagr']*100:.0f}%</b>, conservativo <b>{db['cons_cagr']*100:.0f}%</b>: "
|
||||
f"da €5k → ~€{db['eq5_5y']:,.0f} (5a) / ~€{db['eq5_10y']:,.0f} (10a) · "
|
||||
f"run-rate oggi ~<b>€{db['eday_5k']:.2f}/g</b> @conservativo</span>")
|
||||
else:
|
||||
deribit_html = "n/d" + (f" — {db['error']}" if db and db.get("error") else "")
|
||||
sh = d.get("shadow")
|
||||
if sh and "error" not in sh:
|
||||
bits = " · ".join(
|
||||
@@ -153,13 +202,13 @@ def html():
|
||||
f"{a['target']:+.2f}x" for a in sh["assets"])
|
||||
if sh.get("online"):
|
||||
eq = f"${sh['real_equity']:,.2f}" if sh.get("real_equity") else sh.get("eq_basis", "?")
|
||||
pos = ", ".join(f"{a['asset']} ${a['position_usd']:,.0f}" for a in sh["assets"])
|
||||
shpos = ", ".join(f"{a['asset']} ${a['position_usd']:,.0f}" for a in sh["assets"])
|
||||
ordtxt = ("; ".join(f"{o['side'].upper()} {o['amount']:.0f} {o['instrument']}" for o in sh["orders"])
|
||||
if sh.get("orders") else "nessuno (target flat / gia' a target)")
|
||||
dsl = sh.get("disaster_sls") or []
|
||||
dsl_txt = (" · ".join(f"{x['asset']} stop <b>${x['stop']:,.0f}</b> ({x['amount']:.4f})" for x in dsl)
|
||||
if dsl else "nessuno (flat)")
|
||||
shadow_html = (f"mainnet · sola lettura · conto reale <b>{eq}</b> · pos {pos} · dato {sh['last_data']}<br>"
|
||||
shadow_html = (f"mainnet · sola lettura · conto reale <b>{eq}</b> · pos {shpos} · dato {sh['last_data']}<br>"
|
||||
f"TP01 target: {bits}<br>ordini-che-invierebbe (<b>NON inviati</b>): {ordtxt}<br>"
|
||||
f"🛡️ disaster-SL attivi (−30%): {dsl_txt}")
|
||||
else:
|
||||
@@ -185,6 +234,24 @@ def html():
|
||||
f"<td>{x['amount']:.4f}</td><td>${x['price']:,.1f}</td><td>{x['fee']:.5f}</td></tr>")
|
||||
if not live_trows:
|
||||
live_trows = "<tr><td colspan=6 style='color:#8a93a0'>nessun trade reale eseguito (o conto non leggibile dal container)</td></tr>"
|
||||
fq = d.get("freq")
|
||||
if fq and "error" not in fq:
|
||||
skh = fq["skh"]
|
||||
by = lambda a: " ".join(f"{y}:{c}" for y, c in skh[a]["by_year"].items())
|
||||
tpy = fq["skh_combo_per_year"]
|
||||
freq_html = (
|
||||
f"<b>SKH01</b> (round-trip = apri+chiudi, no-overlap): BTC <b>~{fq['skh_per_year']['BTC']:.0f}/anno</b> · "
|
||||
f"ETH <b>~{fq['skh_per_year']['ETH']:.0f}/anno</b> → combinato <b>~{tpy:.0f}/anno</b> "
|
||||
f"(~{2*tpy:.0f} esecuzioni)<br>"
|
||||
f"<b>TP01</b> (posizione continua vol-target, NON trade discreti): turnover "
|
||||
f"<b>~{fq['tp01_turnover_per_year']:.0f}×/anno</b> per asset · zero quando flat/risk-off<br>"
|
||||
f"<span style='color:#8a93a0;font-size:13px'>→ book eseguibile ~{tpy:.0f}-{tpy+5:.0f} operazioni/anno: "
|
||||
f"poche per design (regime AND breakout). Fee ~$45-60/anno, gia' nette nei numeri storici · min-order $5 OK a $600.<br>"
|
||||
f"SKH01 round-trip/anno — BTC: {by('BTC')} · ETH: {by('ETH')}</span>")
|
||||
tpy_card = f"~{tpy:.0f}"
|
||||
else:
|
||||
freq_html = "n/d" + (f" — {fq['error']}" if fq and fq.get("error") else "")
|
||||
tpy_card = "~75"
|
||||
return f"""<!doctype html><html><head><meta charset=utf-8>
|
||||
<meta http-equiv=refresh content=300><title>PythagorasGoal — Portafoglio</title>
|
||||
<style>body{{font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#0e1116;color:#e6e6e6;margin:0;padding:24px;max-width:980px;margin:auto}}
|
||||
@@ -199,14 +266,26 @@ th{{color:#8a93a0;font-weight:500}}.y{{display:inline-block;background:#161b22;b
|
||||
.section{{font-size:15px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;margin:34px 0 14px;padding:10px 14px;border-radius:9px;background:#12181f;border-left:5px solid #2ecc71;color:#d7dee6}}
|
||||
.section.live{{border-left-color:#e74c3c;background:#1c1316;color:#f0c4c4}}</style></head><body>
|
||||
<h1>PythagorasGoal — Portafoglio attivo (TP01 + XS01 + VRP01 + SKH01)</h1>
|
||||
<div class=sub>monitor · v{d['version']} · ultimo dato {d['last_data']} · esecuzione REALE non attiva (solo micro-test)</div>
|
||||
<div class="section">PAPER — simulato (backtest + forward virtuale)</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=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>
|
||||
|
||||
<div class="section">② TRADES ESEGUITI (reali) + frequenza operativa</div>
|
||||
<div class=box><b>Frequenza operativa</b> (contata sui dati certificati, non stimata):<br>{freq_html}</div>
|
||||
<h3 style="font-size:14px;color:#8a93a0">Trades REALI eseguiti su Deribit</h3>
|
||||
<table><tr><th>data/ora UTC</th><th>strum.</th><th>dir</th><th>amount</th><th>prezzo</th><th>fee USDC</th></tr>{live_trows}</table>
|
||||
<p class=warn>Oggi TP01 e' in target TSMOM risk-off → flat: nessun ordine reale. La tabella si popola quando il segnale arma una posizione e il cron giornaliero la esegue.</p>
|
||||
|
||||
<div class="section">③ STORICO — simulato (backtest + forward virtuale)</div>
|
||||
<div class=cards>
|
||||
<div class=card><div class=k>FULL Sharpe</div><div class="v g">{f['sharpe']:.2f}</div></div>
|
||||
<div class=card><div class=k>HOLD-OUT Sharpe (2025-26)</div><div class="v g">{ho['sharpe']:.2f}</div></div>
|
||||
<div class=card><div class=k>maxDD</div><div class=v>{f['maxdd']*100:.1f}%</div></div>
|
||||
<div class=card><div class=k>CAGR</div><div class=v>{f['cagr']*100:.0f}%</div></div>
|
||||
<div class=card><div class=k>ret totale</div><div class=v>{f['ret']*100:+.0f}%</div></div>
|
||||
<div class=card><div class=k>trade/anno (book eseguibile)</div><div class=v>{tpy_card}</div></div>
|
||||
</div>
|
||||
<div class=box><div class=k style="color:#8a93a0;font-size:12px">EQUITY backtest (2019→oggi, €2k)</div>{svg_spark(d['spark'])}</div>
|
||||
<div class=box><b>Paper forward-only:</b> {paper_html}</div>
|
||||
@@ -214,21 +293,22 @@ th{{color:#8a93a0;font-weight:500}}.y{{display:inline-block;background:#161b22;b
|
||||
<table><tr><th>sleeve</th><th>peso</th><th>FULL Sh</th><th>DD</th><th>HOLD Sh</th></tr>{rows}</table>
|
||||
<h3 style="font-size:14px;color:#8a93a0">Posizioni correnti (ultima barra chiusa)</h3>
|
||||
<table>{pos}</table>
|
||||
<h3 style="font-size:14px;color:#8a93a0">Trades TP01 — entry/exit (segnale causale, ultimi 15)</h3>
|
||||
<h3 style="font-size:14px;color:#8a93a0">Trades TP01 — entry/exit (segnale causale SIMULATO, ultimi 15)</h3>
|
||||
<table><tr><th>data</th><th>asset</th><th>azione</th><th>posizione</th><th>prezzo</th></tr>{trows}</table>
|
||||
<div style="margin-top:10px">{yrs}</div>
|
||||
<div class="section">COMBO DEPLOYABLE — cross-venue (paper, forward-only)</div>
|
||||
<div class="section">③·a COMBO DEPLOYABLE — cross-venue (paper, forward-only)</div>
|
||||
<div class=box><b>TP01 (Deribit) + GTAA (IB)</b> — le DUE gambe ESEGUIBILI a basso capitale, scorrelate (corr ~0.21) → blend Sharpe ~1.5, drawdown dimezzato. <b>Nessuna esecuzione reale</b>:<br>{combo_html}<br>
|
||||
<span style="color:#8a93a0;font-size:13px">posizioni azionabili IB (GTAA, peso ETF):</span> {gw_html}</div>
|
||||
<p class=warn>⚠️ PAPER cross-venue: valida l'operativita' su due conti (Deribit + IB) a rischio zero. Lo Sharpe ~1.5 e' ottimistico (finestra crypto corta/favorevole); il dato robusto e' la diversificazione (corr 0.21, DD dimezzato), non il livello. XS01/VRP01 esclusi (STAT-MODE): qui solo TP01+GTAA.</p>
|
||||
<div class="section">FORWARD-MONITOR — lead paper (non deploy)</div>
|
||||
<div class="section">③·b BOOK DERIBIT-ONLY — eseguibile (TP01 + SKH01)</div>
|
||||
<div class=box><b>TP01 75% + SKH01 25%</b> — le due gambe direzionali BTC/ETH sullo STESSO venue (Deribit), ribilancio mensile. Sottoinsieme realmente armabile (XS01/VRP01 esclusi):<br>{deribit_html}<br>
|
||||
<span style="color:#8a93a0;font-size:13px">forecast: <code>scripts/portfolio/forecast_deribit_book.py</code> · report: <code>scripts/portfolio/run_deribit_book.py</code></span></div>
|
||||
<p class=warn>⚠️ Accumulo = proiezione condizionata (storico bull crypto → pianifica sul conservativo); nessuna leva; SKH01 research/forward-monitor (solo TP01 armato live). A €50/g servono ~€177k @conservativo: la via è capitale+tempo, non leva.</p>
|
||||
<div class="section">③·c FORWARD-MONITOR — lead paper (non deploy)</div>
|
||||
<div class=box><b>PREVDAY range-breakout</b> — lead ORTOGONALE a TP01 (corr ~0.15 full / ~0 hold; marginal ADDS, non-hedge, robusto allo shift del confine-giorno). Forward-only, <b>nessuna esecuzione reale</b>:<br>{prevday_html}</div>
|
||||
<p class=warn>⚠️ LEAD in osservazione, NON deployato. Sopravvissuto alla verifica avversariale dell'onda intraday; lo teniamo in paper per validarlo fuori-campione-vero. I due libri (modeled vs real-$600) mostrano l'haircut di fill che lo scettico aveva segnalato.</p>
|
||||
<div class="section live">LIVE — Deribit mainnet (conto reale, sola lettura)</div>
|
||||
<div class=box><b>Shadow TP01</b> (cosa farebbe ORA sul conto reale, nessun ordine inviato):<br>{shadow_html}</div>
|
||||
<h3 style="font-size:14px;color:#8a93a0">Trades REALI eseguiti su Deribit</h3>
|
||||
<table><tr><th>data/ora UTC</th><th>strum.</th><th>dir</th><th>amount</th><th>prezzo</th><th>fee USDC</th></tr>{live_trows}</table>
|
||||
<p class=warn>⚠️ Paper/monitor. XS01 e' STAT-MODE (book a 19 gambe market-neutral, non eseguibile a €2k, storia ~2.5 anni). VRP01 = lead short-vol MODELLATO (non deploy pieno). SKH01 (Skyhook dual-TF regime+breakout, BTC/ETH) = diversificatore quasi-ortogonale (corr ~0.09) aggiunto @25%: alza il FULL Sharpe del portafoglio 1.68→2.13 e dimezza il DD (14→8%) — RESEARCH/forward-monitor (book a 230m, causalita' verificata su harness ma costi reali e codice d'esecuzione da validare prima del deploy). TP01 e' l'unico deployable pieno: lo "Shadow live" mostra cosa farebbe sul mainnet, ma NON invia ordini.</p>
|
||||
<div class=box><b>STATARB-RESID ETH/BTC</b> — lead ORTOGONALE <b>ed ESEGUIBILE</b> (relative-momentum del residuo ETH−β·BTC, dollar-neutral 2 gambe; corr→book 0.03, β-mkt 0.01; marginal ADDS). Primo stream insieme scorrelato ED eseguibile a $600 (haircut ~0, <b>NON STAT-MODE</b>). Forward-only, <b>nessuna esecuzione reale</b>:<br>{statarb_html}</div>
|
||||
<p class=warn>⚠️ LEAD in osservazione, NON deployati. PREVDAY è sopravvissuto alla verifica avversariale dell'onda intraday. STATARB-RESID (sweep 5-thread 2026-06-29) rompe i muri di ortogonalità ed eseguibilità ma resta sotto il deflated-Sharpe (Sharpe ~0.84, DSR <0.95 su storia ~2.5 anni) → il forward serve a confermare l'edge fuori-campione-vero prima di un eventuale deploy a 2 gambe. I due libri (modeled vs real-$600) mostrano l'haircut di fill (atteso ~0 a 1d).</p>
|
||||
<p class=warn>⚠️ Paper/monitor. XS01 e' STAT-MODE (book a 19 gambe market-neutral, non eseguibile a €2k, storia ~2.5 anni). VRP01 = lead short-vol MODELLATO (non deploy pieno). SKH01 (Skyhook dual-TF regime+breakout, BTC/ETH) = diversificatore quasi-ortogonale (corr ~0.09) aggiunto @25%: alza il FULL Sharpe del portafoglio 1.68→2.13 e dimezza il DD (14→8%) — RESEARCH/forward-monitor (book a 230m, causalita' verificata su harness ma costi reali e codice d'esecuzione da validare prima del deploy). TP01 e' l'unico deployable pieno.</p>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
|
||||
@@ -140,6 +140,47 @@ class DeribitTrader(DeribitRead):
|
||||
return [self.open(instrument, "buy", amount, label="tp01-buy")]
|
||||
return [self._submit(instrument, "sell", amount, reduce_only=True, label="tp01-reduce")]
|
||||
|
||||
# --- RIBILANCIO al target CON SEGNO (book TP01+SKH01: long / short / flip) ---
|
||||
def rebalance_signed(self, instrument: str, target_notional_usd: float, mark: float,
|
||||
min_usd: float = 5.0) -> list[Fill]:
|
||||
"""Porta la posizione su `instrument` al target NETTO con SEGNO (long-short, a differenza di
|
||||
rebalance_to che e' long-only). Gestisce i flip chiudendo prima e riaprendo dall'altro lato.
|
||||
- |delta| < min_usd -> niente;
|
||||
- flip di segno -> close() (sempre permessa) poi apre dall'altra parte;
|
||||
- target ~0 -> close();
|
||||
- stesso segno, |target|<|cur| -> REDUCE reduce_only;
|
||||
- apertura/aumento -> open buy/sell (capped dal guardrail apertura).
|
||||
Ritorna i Fill eseguiti."""
|
||||
cur = self.position_usd(instrument)
|
||||
if abs(target_notional_usd - cur) < min_usd:
|
||||
return []
|
||||
fills: list[Fill] = []
|
||||
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
|
||||
(cur < -FLAT_USD and target_notional_usd > FLAT_USD)
|
||||
if crossing: # flip: flatta, poi riparti da zero
|
||||
f = self.close(instrument, label="book-flip-close")
|
||||
if f:
|
||||
fills.append(f)
|
||||
cur = 0.0
|
||||
if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa)
|
||||
if abs(cur) > FLAT_USD:
|
||||
f = self.close(instrument, label="book-exit")
|
||||
if f:
|
||||
fills.append(f)
|
||||
return fills
|
||||
delta = target_notional_usd - cur
|
||||
amount = notional_to_amount(instrument, abs(delta), price=mark)
|
||||
if amount <= 0:
|
||||
return fills
|
||||
same_sign = (target_notional_usd > 0) == (cur > 0)
|
||||
if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur):
|
||||
side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only
|
||||
fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce"))
|
||||
else:
|
||||
side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target
|
||||
fills.append(self.open(instrument, side, amount, label="book-open"))
|
||||
return fills
|
||||
|
||||
# --- DISASTER BRACKET (assicurazione on-book per outage; da Old) ---
|
||||
def place_disaster_sl(self, instrument: str, side_held: str, amount: float,
|
||||
stop_price: float, label: str = "disaster-sl") -> Fill:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""FEED LIVE EFFIMERO per il segnale SKH01 (book a 230m) — NON tocca i dati certificati su disco.
|
||||
|
||||
SKH01 decide su griglia 230m: per eseguirlo fedelmente il segnale serve fresco all'ultima barra
|
||||
chiusa. Il rebuild certificato (rebuild_history.py) gira 1×/giorno e fa un rebuild COMPLETO (pesante):
|
||||
girarlo ogni ora sarebbe sbagliato e violerebbe la regola "aggiornare lo storico SOLO con
|
||||
rebuild_history + certificare". Quindi qui NON scriviamo su disco: carichiamo il 5m CERTIFICATO e gli
|
||||
appendiamo IN MEMORIA una coda recente presa da Deribit PUBBLICO (ccxt, tokenless, STESSO simbolo
|
||||
inverse del feed certificato -> prezzi entro ~3 bps). I dati certificati restano la verità su disco;
|
||||
questa estensione vive solo nel processo live e per il calcolo del segnale.
|
||||
|
||||
Robusto ai fallimenti: qualunque errore di rete/fetch -> ritorna il feed certificato invariato (il
|
||||
runner degrada a "fermo all'ultimo dato certificato", mai opera a cieco). Solo SKH01 ne ha bisogno:
|
||||
TP01 è giornaliero e gira bene sul feed certificato.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from src.data.downloader import load_data
|
||||
|
||||
# STESSO simbolo del feed certificato (vedi scripts/analysis/rebuild_history.DERIBIT_INSTR):
|
||||
# inverse USD perp, storia lunga, entro ~3 bps dal lineare USDC su cui eseguiamo.
|
||||
DERIBIT_SYMBOL = {"BTC": "BTC/USD:BTC", "ETH": "ETH/USD:ETH"}
|
||||
SCHEMA = ["timestamp", "open", "high", "low", "close", "volume"]
|
||||
|
||||
|
||||
def _fetch_recent_5m(symbol: str, lookback_days: int) -> pd.DataFrame:
|
||||
"""Coda recente di 5m da Deribit pubblico (ccxt). Paginazione in avanti. Solo letture pubbliche."""
|
||||
import ccxt
|
||||
ex = ccxt.deribit({"enableRateLimit": True})
|
||||
tf_ms = 5 * 60 * 1000
|
||||
since = int((time.time() - lookback_days * 86400) * 1000)
|
||||
rows: dict[int, list] = {}
|
||||
guard = 0
|
||||
while guard < 200:
|
||||
guard += 1
|
||||
try:
|
||||
r = ex.fetch_ohlcv(symbol, "5m", since=since, limit=1000)
|
||||
except Exception:
|
||||
break
|
||||
r = [x for x in r if int(x[0]) >= since]
|
||||
if not r:
|
||||
break
|
||||
for x in r:
|
||||
t = int(x[0])
|
||||
rows[t] = [t, float(x[1]), float(x[2]), float(x[3]), float(x[4]), float(x[5] or 0)]
|
||||
nxt = int(r[-1][0]) + tf_ms
|
||||
if nxt <= since:
|
||||
break
|
||||
since = nxt
|
||||
if not rows:
|
||||
return pd.DataFrame(columns=SCHEMA)
|
||||
return pd.DataFrame(rows.values(), columns=SCHEMA).sort_values("timestamp").reset_index(drop=True)
|
||||
|
||||
|
||||
def merge_tail(base: pd.DataFrame, tail: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Fonde la coda fresca sul feed certificato: concat, dedup per timestamp (la coda VINCE sui
|
||||
duplicati, ma le barre certificate storiche restano), riordina. Mantiene lo schema di load_data
|
||||
(inclusa 'datetime' se presente). PURA, testabile senza rete."""
|
||||
if tail is None or tail.empty:
|
||||
return base
|
||||
cols = [c for c in SCHEMA if c in base.columns]
|
||||
t = tail[[c for c in SCHEMA if c in tail.columns]].copy()
|
||||
merged = pd.concat([base[cols], t], ignore_index=True)
|
||||
merged = merged.drop_duplicates("timestamp", keep="last").sort_values("timestamp").reset_index(drop=True)
|
||||
# ricostruisci 'datetime' coerente (build_frames non la usa, ma load_data la espone)
|
||||
merged["datetime"] = pd.to_datetime(merged["timestamp"], unit="ms", utc=True)
|
||||
return merged
|
||||
|
||||
|
||||
def fresh_5m(asset: str, lookback_days: int = 12) -> pd.DataFrame:
|
||||
"""Feed 5m certificato + coda recente effimera (in-memory). Fallback al certificato su errore."""
|
||||
base = load_data(asset, "5m")
|
||||
sym = DERIBIT_SYMBOL.get(asset)
|
||||
if sym is None:
|
||||
return base
|
||||
try:
|
||||
tail = _fetch_recent_5m(sym, lookback_days)
|
||||
except Exception:
|
||||
return base
|
||||
return merge_tail(base, tail)
|
||||
@@ -68,6 +68,44 @@ def yearly(daily: pd.Series) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def rebalance_sim(daily_cols: dict[str, pd.Series], weights: dict,
|
||||
period_days: int, cost_rate: float = 0.0005) -> dict:
|
||||
"""Ribilanciamento PERIODICO REALISTICO (vs il rebalance-continuo implicito di combined_daily).
|
||||
|
||||
Tra una data di ribilanciamento e l'altra ogni sleeve DERIVA col suo rendimento (i pesi si
|
||||
scostano dal target). Ogni `period_days` si riporta al target pagando il turnover:
|
||||
cost = cost_rate * sum_i |valore_i - target_i| (cost_rate = fee per-lato, Deribit taker ~0.0005)
|
||||
Modella l'attrito reale che il rebalance-continuo (combined_daily) ignora. period_days=1 con
|
||||
cost_rate=0 ricade sul rebalance-continuo. Ritorna serie netta + turnover annuo + n ribilanci."""
|
||||
J = pd.concat(daily_cols, axis=1, join="inner").sort_index().fillna(0.0)
|
||||
cols = list(J.columns)
|
||||
w = np.array([weights[c] for c in cols], float); w = w / w.sum()
|
||||
R = J.values
|
||||
n = len(J)
|
||||
E = 1.0
|
||||
v = w * E
|
||||
out = np.zeros(n)
|
||||
n_rebal = 0
|
||||
turn_tot = 0.0
|
||||
for t in range(n):
|
||||
Eprev = E
|
||||
v = v * (1.0 + R[t])
|
||||
E = float(v.sum())
|
||||
if (t + 1) % period_days == 0: # giorno di ribilanciamento
|
||||
target = w * E
|
||||
turn = float(np.abs(v - target).sum())
|
||||
cost = cost_rate * turn
|
||||
E -= cost
|
||||
v = w * E
|
||||
n_rebal += 1
|
||||
turn_tot += turn / max(Eprev, 1e-12)
|
||||
out[t] = E / Eprev - 1.0 if Eprev > 0 else 0.0
|
||||
years = n / DAYS_PER_YEAR
|
||||
return dict(daily=pd.Series(out, index=J.index),
|
||||
turnover_per_year=round(turn_tot / years, 2) if years > 0 else 0.0,
|
||||
n_rebalances=n_rebal, period_days=period_days, cost_rate=cost_rate)
|
||||
|
||||
|
||||
class StrategyPortfolio:
|
||||
def __init__(self, sleeves: list[Sleeve], capital: float = 2000.0):
|
||||
if not sleeves:
|
||||
|
||||
@@ -236,8 +236,86 @@ def _skyhook_returns() -> pd.Series:
|
||||
return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index)
|
||||
|
||||
|
||||
def _skyhook_positions(load5m=None) -> dict:
|
||||
"""Stato corrente del book Skyhook per asset (introspezione live): se c'e' un trade APERTO ORA
|
||||
-> dir/entry/sl/tp/barre-trascorse; altrimenti 'flat'. Replica la logica non-overlap di
|
||||
entry+exit (TP/SL/max_bars) fino all'ultima barra 230m chiusa. Causale: usa solo barre chiuse.
|
||||
|
||||
load5m: callable(asset)->df5 opzionale (per il live: feed certificato + coda fresca effimera,
|
||||
vedi src/live/livefeed.fresh_5m). Default = feed certificato su disco (load_data)."""
|
||||
_load = load5m if load5m is not None else (lambda a: load_data(a, "5m"))
|
||||
out = {}
|
||||
for a in ASSETS:
|
||||
ltf, htf = build_frames(_load(a))
|
||||
ent = skyhook_entries(ltf, htf, SKH01_V2_DD)
|
||||
H = ltf["high"].values; L = ltf["low"].values; Cc = ltf["close"].values
|
||||
n = len(ltf); i = 0; open_pos = "flat"
|
||||
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: # non ancora uscito: posizione APERTA ora
|
||||
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:
|
||||
open_pos = dict(dir="LONG" if d == 1 else "SHORT", entry=round(float(Cc[i]), 2),
|
||||
sl=round(float(sl), 2), tp=round(float(tp), 2),
|
||||
bars_in=int((n - 1) - i), max_bars=int(mb))
|
||||
break
|
||||
i = exit_idx + 1
|
||||
out[a] = open_pos
|
||||
return out
|
||||
|
||||
|
||||
def skyhook_sleeve(weight: float = 0.25) -> Sleeve:
|
||||
return Sleeve("SKH01_skyhook", weight, _skyhook_returns)
|
||||
return Sleeve("SKH01_skyhook", weight, _skyhook_returns, pos_fn=_skyhook_positions)
|
||||
|
||||
|
||||
_TRADE_FREQ_CACHE = None
|
||||
|
||||
|
||||
def book_trade_frequency() -> dict:
|
||||
"""Frequenza operativa del book eseguibile (TP01+SKH01), conteggiata sui dati certificati.
|
||||
Strategia + storico fissi -> cache a livello di MODULO: calcolata una sola volta per processo
|
||||
(le nuove barre aggiungono pochissimo). Niente magic-number: e' contata, non stimata.
|
||||
- SKH01: round-trip DISCRETI (apri+chiudi) no-overlap dell'harness, per asset e per anno.
|
||||
- TP01: posizione CONTINUA vol-target -> 'turnover' annuo (somma |Δpeso|), non trade discreti."""
|
||||
global _TRADE_FREQ_CACHE
|
||||
if _TRADE_FREQ_CACHE is not None:
|
||||
return _TRADE_FREQ_CACHE
|
||||
skh = {}
|
||||
for a in ASSETS:
|
||||
ltf, htf = build_frames(load_data(a, "5m"))
|
||||
ent = skyhook_entries(ltf, htf, SKH01_V2_DD)
|
||||
yr = pd.to_datetime(pd.Series(ltf["timestamp"]).astype("int64"), unit="ms", utc=True).dt.year.values
|
||||
yc: dict[int, int] = {}
|
||||
for i, e in enumerate(ent):
|
||||
if e is not None:
|
||||
y = int(yr[i]); yc[y] = yc.get(y, 0) + 1
|
||||
skh[a] = dict(by_year={int(k): int(v) for k, v in sorted(yc.items())},
|
||||
total=int(sum(yc.values())), first=int(min(yc)), last=int(max(yc)))
|
||||
sf = min(v["first"] for v in skh.values()); sl = max(v["last"] for v in skh.values())
|
||||
skh_combo = sum(v["total"] for v in skh.values()) / (sl - sf + 1)
|
||||
tp = TrendPortfolio(**CANONICAL); turns = []
|
||||
for a in ASSETS:
|
||||
df = resample_1d(load_data(a, "1h"))
|
||||
tgt = np.asarray(tp.target_series(df), dtype=float)
|
||||
years = pd.DatetimeIndex(pd.to_datetime(df["datetime"])).year # ndarray-aligned key
|
||||
d = np.abs(np.diff(np.nan_to_num(tgt, nan=0.0), prepend=0.0)) # turnover = somma |Δpeso|
|
||||
ty = pd.Series(d).groupby(years).sum() # groupby posizionale (RangeIndex)
|
||||
turns.append(float(ty.mean()))
|
||||
_TRADE_FREQ_CACHE = dict(
|
||||
skh=skh,
|
||||
skh_per_year={a: skh[a]["total"] / (skh[a]["last"] - skh[a]["first"] + 1) for a in ASSETS},
|
||||
skh_combo_per_year=skh_combo,
|
||||
tp01_turnover_per_year=sum(turns) / len(turns))
|
||||
return _TRADE_FREQ_CACHE
|
||||
|
||||
|
||||
# ----------------------------- REGISTRY -----------------------------
|
||||
@@ -253,3 +331,16 @@ def active_sleeves() -> list[Sleeve]:
|
||||
vrp_sleeve(weight=0.15), # 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)
|
||||
]
|
||||
|
||||
|
||||
def deribit_book_sleeves() -> list[Sleeve]:
|
||||
"""BOOK DERIBIT-ONLY realmente eseguibile (TP01 + SKH01, 75/25): le DUE gambe direzionali
|
||||
BTC/ETH sullo stesso venue (Deribit), entrambe dal 2019. Esclude XS01 (Hyperliquid, stat-mode)
|
||||
e VRP01 (opzioni modellate). FULL Sharpe ~1.78 / HOLD ~1.17 / DD ~9% (research; SKH01 daily-step).
|
||||
Pensato per il deploy reale a basso capitale: stesso conto, stesso feed, ribilanciamento
|
||||
periodico (vedi src.portfolio.portfolio.rebalance_sim + scripts/portfolio/run_deribit_book.py).
|
||||
TP01 e' gia' armato live; SKH01 e' il candidato 2a gamba (da validare codice d'esecuzione)."""
|
||||
return [
|
||||
tp01_sleeve(weight=0.75),
|
||||
skyhook_sleeve(weight=0.25),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Test del BOOK DERIBIT-ONLY live (TP01+SKH01 nettati in software, un solo conto).
|
||||
|
||||
Coprono: la formula di netting (sizing 75/25 + cap, long/short/flat/flip), la PARITA' coi pesi del
|
||||
backtest (deribit_book_sleeves), la sicurezza del gate (disarmato -> nessun ordine), e il reconcile
|
||||
CON SEGNO (close+open sui flip, reduce reduce_only) — senza toccare la rete (trader fittizio).
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.live.book import W_SKH, W_TP01, book_net_target, build_book_order
|
||||
from src.live.execution import Fill
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formula di netting: 75/25, cap, combinazioni long/short/flat.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_net_target_sizing():
|
||||
E, cap = 600.0, 300.0
|
||||
assert book_net_target(0.0, 0, E, cap) == 0.0 # tutto flat
|
||||
assert book_net_target(0.0, 1, E, cap) == 75.0 # solo SKH long = 0.25*0.5*600
|
||||
assert book_net_target(0.0, -1, E, cap) == -75.0 # solo SKH short
|
||||
assert book_net_target(1.0, 0, E, cap) == 225.0 # solo TP01 pieno = 0.75*0.5*600
|
||||
assert book_net_target(1.0, -1, E, cap) == 150.0 # TP long + SKH short (hedge parziale)
|
||||
assert book_net_target(1.0, 1, E, cap) == 300.0 # capped
|
||||
assert book_net_target(2.0, 1, E, cap) == 300.0 # cap superiore
|
||||
assert book_net_target(2.0, -1, E, cap) == 300.0 # 0.5*600*(1.5-0.25)=375 -> cap 300
|
||||
|
||||
|
||||
def test_net_target_clamps_negative():
|
||||
# TP flat e SKH short forte non sfora il cap negativo
|
||||
assert book_net_target(0.0, -1, 4000.0, 300.0) == -300.0
|
||||
# tp_frac negativo trattato come 0 (TP01 e' long-flat)
|
||||
assert book_net_target(-5.0, 1, 600.0, 300.0) == 75.0
|
||||
|
||||
|
||||
def test_weights_match_backtest_sleeves():
|
||||
"""I pesi del book live DEVONO coincidere con quelli del backtest (deribit_book_sleeves)."""
|
||||
from src.portfolio.sleeves import deribit_book_sleeves
|
||||
w = {s.name.split("_")[0]: s.weight for s in deribit_book_sleeves()}
|
||||
assert abs(w["TP01"] - W_TP01) < 1e-12 and abs(w["SKH01"] - W_SKH) < 1e-12
|
||||
assert abs((W_TP01 + W_SKH) - 1.0) < 1e-12
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Costruzione ordine: side, reduce_only, flip, close, soglia minima.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_build_order_open_long():
|
||||
o = build_book_order("BTC_USDC-PERPETUAL", 75.0, 0.0, 60000.0, min_usd=5.0)
|
||||
assert o["side"] == "buy" and not o["reduce_only"] and not o["needs_flip"] and not o["is_close"]
|
||||
|
||||
|
||||
def test_build_order_reduce_same_sign():
|
||||
o = build_book_order("BTC_USDC-PERPETUAL", 75.0, 150.0, 60000.0)
|
||||
assert o["side"] == "sell" and o["reduce_only"] and not o["needs_flip"]
|
||||
|
||||
|
||||
def test_build_order_flip():
|
||||
o = build_book_order("BTC_USDC-PERPETUAL", -75.0, 75.0, 60000.0)
|
||||
assert o["needs_flip"] and o["side"] == "sell"
|
||||
|
||||
|
||||
def test_build_order_close_to_flat():
|
||||
o = build_book_order("BTC_USDC-PERPETUAL", 0.0, 75.0, 60000.0)
|
||||
assert o["is_close"] and o["reduce_only"] and o["side"] == "sell"
|
||||
|
||||
|
||||
def test_build_order_below_min_is_none():
|
||||
assert build_book_order("BTC_USDC-PERPETUAL", 75.0, 73.0, 60000.0, min_usd=5.0) is None
|
||||
assert build_book_order("BTC_USDC-PERPETUAL", 0.0, 0.0, 60000.0) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sicurezza del GATE: disarmato (execution_enabled=false) -> do_execute False.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_gate_requires_both_switches():
|
||||
def do_execute(enabled, want_execute):
|
||||
return bool(want_execute) and bool(enabled)
|
||||
assert not do_execute(False, False)
|
||||
assert not do_execute(True, False) # armato ma senza --execute
|
||||
assert not do_execute(False, True) # --execute ma disarmato
|
||||
assert do_execute(True, True) # solo con entrambi
|
||||
|
||||
|
||||
def test_config_default_disarmed(tmp_path, monkeypatch):
|
||||
"""load_config di book_execute mette execution_enabled=False di default (fail-safe)."""
|
||||
import importlib
|
||||
be = importlib.import_module("scripts.live.book_execute") if False else None
|
||||
# carica il modulo via path (scripts/ non e' un package importabile per nome)
|
||||
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)
|
||||
monkeypatch.setattr(mod, "CONFIG", tmp_path / "nope.json") # config assente
|
||||
assert mod.load_config()["execution_enabled"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reconcile CON SEGNO (long/short/flip) senza rete: trader fittizio.
|
||||
# ---------------------------------------------------------------------------
|
||||
class FakeTrader:
|
||||
"""Replica la logica di DeribitTrader.rebalance_signed registrando le chiamate, senza rete."""
|
||||
def __init__(self, pos):
|
||||
self.pos = float(pos)
|
||||
self.calls = []
|
||||
|
||||
def position_usd(self, instrument):
|
||||
return self.pos
|
||||
|
||||
def _mk(self, side, amount, reduce_only, label):
|
||||
self.calls.append((label, side, round(amount, 6), reduce_only))
|
||||
return Fill(instrument="X", side=side, amount=amount, filled=amount, price=60000.0,
|
||||
fee_usdc=0.0, order_id="1", state="filled", verified=True)
|
||||
|
||||
def close(self, instrument, label="x"):
|
||||
if abs(self.pos) < 1.0:
|
||||
return None
|
||||
f = self._mk("sell" if self.pos > 0 else "buy", abs(self.pos) / 60000.0, True, label)
|
||||
self.pos = 0.0
|
||||
return f
|
||||
|
||||
def open(self, instrument, side, amount, label="x"):
|
||||
f = self._mk(side, amount, False, label)
|
||||
self.pos += (amount * 60000.0) * (1 if side == "buy" else -1)
|
||||
return f
|
||||
|
||||
def _submit(self, instrument, side, amount, *, reduce_only, label, **k):
|
||||
f = self._mk(side, amount, reduce_only, label)
|
||||
self.pos += (amount * 60000.0) * (1 if side == "buy" else -1)
|
||||
return f
|
||||
|
||||
# importa il metodo reale da DeribitTrader (testiamo proprio quella logica)
|
||||
from src.live.execution import DeribitTrader as _DT
|
||||
rebalance_signed = _DT.rebalance_signed
|
||||
|
||||
|
||||
def test_reconcile_open_from_flat():
|
||||
t = FakeTrader(0.0)
|
||||
t.rebalance_signed("BTC_USDC-PERPETUAL", 75.0, 60000.0, min_usd=5.0)
|
||||
labels = [c[0] for c in t.calls]
|
||||
assert labels == ["book-open"] and t.calls[0][1] == "buy"
|
||||
|
||||
|
||||
def test_reconcile_flip_closes_then_opens():
|
||||
t = FakeTrader(75.0) # long, target short -> flip
|
||||
t.rebalance_signed("BTC_USDC-PERPETUAL", -75.0, 60000.0, min_usd=5.0)
|
||||
labels = [c[0] for c in t.calls]
|
||||
assert labels == ["book-flip-close", "book-open"]
|
||||
assert t.calls[1][1] == "sell" # apre short dopo il close
|
||||
|
||||
|
||||
def test_reconcile_reduce_same_sign_is_reduce_only():
|
||||
t = FakeTrader(150.0)
|
||||
t.rebalance_signed("BTC_USDC-PERPETUAL", 75.0, 60000.0, min_usd=5.0)
|
||||
assert [c[0] for c in t.calls] == ["book-reduce"]
|
||||
assert t.calls[0][3] is True # reduce_only
|
||||
|
||||
|
||||
def test_reconcile_target_flat_closes():
|
||||
t = FakeTrader(75.0)
|
||||
t.rebalance_signed("BTC_USDC-PERPETUAL", 0.0, 60000.0, min_usd=5.0)
|
||||
assert [c[0] for c in t.calls] == ["book-exit"]
|
||||
|
||||
|
||||
def test_reconcile_below_min_noop():
|
||||
t = FakeTrader(73.0)
|
||||
t.rebalance_signed("BTC_USDC-PERPETUAL", 75.0, 60000.0, min_usd=5.0)
|
||||
assert t.calls == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PARITA' d'integrazione: il report reale (offline, niente rete) applica ESATTAMENTE
|
||||
# la formula pura per ogni asset -> wiring corretto e riproducibile.
|
||||
# ---------------------------------------------------------------------------
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feed live effimero (src/live/livefeed): merge/dedup, fallback, loader onorato.
|
||||
# Nessuna rete (la coda fresca è iniettata / il fetch è stubbato a errore).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_merge_tail_dedups_and_tail_wins():
|
||||
import pandas as pd
|
||||
from src.live.livefeed import merge_tail
|
||||
base = pd.DataFrame({"timestamp": [0, 300000, 600000], "open": [1, 2, 3], "high": [1, 2, 3],
|
||||
"low": [1, 2, 3], "close": [1, 2, 3], "volume": [1, 1, 1],
|
||||
"datetime": pd.to_datetime([0, 300000, 600000], unit="ms", utc=True)})
|
||||
tail = pd.DataFrame({"timestamp": [600000, 900000], "open": [9, 4], "high": [9, 4],
|
||||
"low": [9, 4], "close": [9, 4], "volume": [2, 2]})
|
||||
m = merge_tail(base, tail)
|
||||
assert list(m["timestamp"]) == [0, 300000, 600000, 900000] # esteso + ordinato
|
||||
assert m.loc[m["timestamp"] == 600000, "close"].iloc[0] == 9 # la coda VINCE sul duplicato
|
||||
assert "datetime" in m.columns and m["datetime"].is_monotonic_increasing
|
||||
|
||||
|
||||
def test_merge_tail_empty_returns_base():
|
||||
import pandas as pd
|
||||
from src.live.livefeed import merge_tail
|
||||
base = pd.DataFrame({"timestamp": [0], "open": [1], "high": [1], "low": [1], "close": [1], "volume": [1]})
|
||||
assert merge_tail(base, pd.DataFrame()).equals(base)
|
||||
|
||||
|
||||
def test_fresh_5m_falls_back_to_certified_on_error(monkeypatch):
|
||||
import src.live.livefeed as lf
|
||||
from src.data.downloader import load_data
|
||||
monkeypatch.setattr(lf, "_fetch_recent_5m", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("net")))
|
||||
got = lf.fresh_5m("BTC")
|
||||
base = load_data("BTC", "5m")
|
||||
assert len(got) == len(base) and got["timestamp"].iloc[-1] == base["timestamp"].iloc[-1]
|
||||
|
||||
|
||||
def test_skyhook_positions_honors_custom_loader():
|
||||
from src.portfolio import sleeves
|
||||
calls = []
|
||||
|
||||
def spy(a):
|
||||
calls.append(a)
|
||||
return sleeves.load_data(a, "5m")
|
||||
pos = sleeves._skyhook_positions(load5m=spy)
|
||||
assert set(pos.keys()) == {"BTC", "ETH"} and calls == ["BTC", "ETH"]
|
||||
|
||||
|
||||
def test_book_report_uses_pure_formula_offline():
|
||||
from src.live.book import book_report
|
||||
r = book_report(offline=True, equity_override=600.0)
|
||||
assert r["equity"] == 600.0 and r["cap_per_asset"] > 0
|
||||
for a in r["assets"]:
|
||||
expect = book_net_target(a["tp_frac"], a["skh_sign"], 600.0, r["cap_per_asset"])
|
||||
assert abs(a["net_target"] - expect) < 1e-6, f"{a['asset']}: net incoerente con la formula"
|
||||
assert a["skh_sign"] in (-1, 0, 1)
|
||||
# offline -> conto assunto flat -> nessuna posizione reale, report deterministico
|
||||
assert all(a["position_usd"] == 0.0 for a in r["assets"])
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Lock della conclusione 2026-06-26 (CC01 cash-and-carry): il premio di funding e' REALE e
|
||||
positivo, ma PROCYCLICO (si comprime nel bear) — NON un edge all-weather. Lo Sharpe headline ~13 e'
|
||||
un artefatto (rischi di coda assenti dal dataset) -> non testiamo lo Sharpe, testiamo il FATTO
|
||||
economico e la procyclicita'. Diario docs/diary/2026-06-26-cash-carry-hl.md."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
from cash_carry_hl import cc_returns, load_funding_panel, metrics # type: ignore
|
||||
|
||||
|
||||
def test_funding_premium_is_real_and_positive():
|
||||
"""Il carry esiste: funding aggregato BTC/ETH positivo, CC-static con CAGR > 0."""
|
||||
r = cc_returns(["BTC", "ETH"], mode="static")
|
||||
m = metrics(r)
|
||||
assert m["gross_ann"] > 0.0 # premio di funding netto positivo
|
||||
assert m["cagr"] > 0.0
|
||||
|
||||
|
||||
def test_basis_markup_does_not_explain_high_sharpe():
|
||||
"""Aggiungere il mark-to-market della base (Δpremium) NON sgonfia lo Sharpe in modo materiale:
|
||||
prova che il rischio vero e' FUORI dal dataset (2022/liquidazione), non nel basis dei dati."""
|
||||
naive = metrics(cc_returns(["BTC", "ETH"], mode="static"))["sharpe"]
|
||||
basis = metrics(cc_returns(["BTC", "ETH"], mode="static", with_basis=True))["sharpe"]
|
||||
assert basis > 0.7 * naive # cala poco -> il basis-from-data non e' il rischio nascosto
|
||||
|
||||
|
||||
def test_carry_is_procyclical_fades_in_bear():
|
||||
"""Il carry e' procyclico: il funding aggregato del toro 2024 supera quello del bear 2026."""
|
||||
FUND, _ = load_funding_panel(["BTC", "ETH"])
|
||||
agg = FUND.mean(axis=1)
|
||||
by_year = agg.groupby(agg.index.year).sum()
|
||||
assert by_year.get(2024, 0.0) > by_year.get(2026, 0.0)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Test del filone DVOL-DIREZIONALE (scripts/research/dvol_directional.py).
|
||||
|
||||
Verifica le proprieta' che DEVONO valere per ogni segnale del progetto:
|
||||
* il percentile espandente del DVOL e' CAUSALE (rank su un prefisso == rank(full)[:cut]);
|
||||
* il leader (DVOL-fear long-flat) passa causality_ok di altlib (niente future-peeking);
|
||||
* a $600 (eval_weights_smallcap) l'haircut e' trascurabile (segnale eseguibile, low-turnover);
|
||||
* sign-falsification: la tesi (buy-the-fear) batte il suo flip (buy-the-calm) sull'era DVOL.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
import altlib as al # noqa: E402
|
||||
from scripts.research import dvol_directional as dd # noqa: E402
|
||||
|
||||
|
||||
def test_expanding_rank_is_causal():
|
||||
"""rank calcolato su un prefisso deve coincidere con rank(full) ristretto al prefisso."""
|
||||
x = np.asarray(al.dvol(al.get("BTC", "1d"), "BTC"), float)
|
||||
full = dd._expanding_rank_arr(x)
|
||||
cut = int(len(x) * 0.7)
|
||||
pref = dd._expanding_rank_arr(x[:cut])
|
||||
a, b = full[:cut], pref
|
||||
m = np.isfinite(a) & np.isfinite(b)
|
||||
assert m.sum() > 100
|
||||
assert np.max(np.abs(a[m] - b[m])) < 1e-9
|
||||
|
||||
|
||||
def test_leader_causality_ok():
|
||||
"""Il leader (DVOL-fear q0.4 long-flat) non deve avere look-ahead (altlib causality_ok)."""
|
||||
fn = dd.make_dvol_level(0.4, "fear", True)
|
||||
co = al.causality_ok(fn, tf="1d")
|
||||
assert co["ok"], co
|
||||
assert co["max_tail_diff"] <= 1e-6
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asset", ["BTC", "ETH"])
|
||||
def test_leader_executable_at_600(asset):
|
||||
"""A $600 il segnale e' a basso turnover: haircut Sharpe trascurabile, trade eseguiti reali."""
|
||||
fn = dd.make_dvol_level(0.4, "fear", True)
|
||||
df = al.get(asset, "1d")
|
||||
sc = al.eval_weights_smallcap(df, al._call_target(fn, df, asset), capital=600, min_order=5)
|
||||
assert sc["n_executed_trades"] > 10
|
||||
assert abs(sc["sharpe_haircut"]) < 0.10
|
||||
|
||||
|
||||
def test_sign_falsification():
|
||||
"""La tesi (buy-the-fear) deve battere il flip (buy-the-calm) sull'era DVOL."""
|
||||
thesis = dd.era_full_sharpe(dd.make_dvol_level(0.5, "fear", True))["sharpe"]
|
||||
flip = dd.era_full_sharpe(dd.make_dvol_level(0.5, "calm", True))["sharpe"]
|
||||
assert thesis > flip
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Lock 2026-06-26 (fond/news short screener): scoring puro testabile offline. La strategia NON e'
|
||||
backtestabile (fondamentali snapshot = look-ahead) -> deliverable = screener forward + verdetto.
|
||||
Diario docs/diary/2026-06-26-equity-fundnews-short.md."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
from eq_fundnews_short import fund_neg_score, headline_sentiment, UNIVERSE # type: ignore
|
||||
|
||||
|
||||
def test_fund_neg_high_for_bad_company():
|
||||
"""recMean verso sell + surprise negative + ricavi in calo + analisti a sell -> score alto."""
|
||||
s = fund_neg_score(rec_mean=4.5, surp=[-0.10, -0.05], rev_g=-0.08, sell_skew=0.5)
|
||||
assert s > 0.7
|
||||
|
||||
|
||||
def test_fund_neg_low_for_healthy_company():
|
||||
"""recMean buy + surprise positive + ricavi su + nessun sell -> score basso."""
|
||||
s = fund_neg_score(rec_mean=1.5, surp=[0.10, 0.06], rev_g=0.20, sell_skew=0.0)
|
||||
assert s < 0.2
|
||||
|
||||
|
||||
def test_fund_neg_none_when_no_data():
|
||||
assert fund_neg_score(None, None, None, None) is None
|
||||
|
||||
|
||||
def test_headline_sentiment_directions():
|
||||
neg, n_, p_ = headline_sentiment(["Stock plunges as company warns and cuts guidance"])
|
||||
pos, _, _ = headline_sentiment(["Shares surge as earnings beat, analysts upgrade"])
|
||||
neutral, _, _ = headline_sentiment(["Company holds annual shareholder meeting"])
|
||||
assert neg > 0.6 and pos < 0.4 and neutral == 0.0
|
||||
|
||||
|
||||
def test_universe_nonempty():
|
||||
assert len(UNIVERSE) >= 10
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Lock 2026-06-26 (equity MR + check rete): la mean-reversion "sottoquotata" daily NON batte il
|
||||
buy&hold, e il check dati dalla rete (IB vs Yahoo) deve concordare adjusted-vs-adjusted.
|
||||
Diario docs/diary/2026-06-26-equity-meanrev-network-check.md."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
from eq_meanrev_ib import rsi, mr_target, backtest, metrics, buyhold, yahoo_daily # type: ignore
|
||||
from eqlib import load_eq # type: ignore
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def test_rsi_in_bounds():
|
||||
c = np.cumprod(1 + np.linspace(-0.02, 0.02, 300))
|
||||
r = rsi(c, 2)
|
||||
assert np.nanmin(r) >= 0.0 and np.nanmax(r) <= 100.0
|
||||
|
||||
|
||||
def test_mr_target_binary_and_has_trades():
|
||||
"""La posizione è 0/1 ed entra solo da sottoquotata (genera scambi, exposure < 50%)."""
|
||||
c = load_eq("SPY")["close"].astype(float).values
|
||||
pos = mr_target(c)
|
||||
assert set(np.unique(pos)).issubset({0.0, 1.0})
|
||||
expo = pos.mean()
|
||||
assert 0.02 < expo < 0.5 # mean-reversion = poco investito
|
||||
|
||||
|
||||
def test_meanrev_does_not_beat_buyhold_holdout():
|
||||
"""Verdetto onesto: la MR daily NON batte il buy&hold risk-adjusted nel hold-out su SPY."""
|
||||
HO = pd.Timestamp("2015-01-01", tz="UTC")
|
||||
mr = metrics(backtest("SPY"), lo=HO)["sharpe"]
|
||||
bh = buyhold("SPY", lo=HO)["sharpe"]
|
||||
assert mr <= bh + 0.05 # non supera il benchmark (entro rumore)
|
||||
|
||||
|
||||
@pytest.mark.network
|
||||
def test_ib_feed_concords_with_network_source():
|
||||
"""Check dati dalla rete: i rendimenti IB (adjusted) concordano con Yahoo adjclose a pochi bps."""
|
||||
ib = load_eq("SPY")["close"].astype(float); ib.index = ib.index.normalize()
|
||||
yh = yahoo_daily("SPY")["adjclose"]
|
||||
J = pd.concat({"a": ib, "b": yh}, axis=1, join="inner").dropna().tail(120)
|
||||
d = (J["a"].pct_change() - J["b"].pct_change()).abs().dropna()
|
||||
assert d.max() < 0.001 # <10bps adjusted-vs-adjusted
|
||||
assert abs(J["a"].iloc[-1] / J["b"].iloc[-1] - 1) < 0.002
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Lock della conclusione 2026-06-26: il gamma scalping (long-vol) su BTC/ETH PERDE
|
||||
strutturalmente — è lo specchio del VRP01, dal lato sbagliato del premio. Guardia contro
|
||||
ri-litigare l'idea "scalping con copertura in opzioni" come edge.
|
||||
Diario docs/diary/2026-06-26-gamma-scalp-options.md."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
from options_gamma_scalp import ( # type: ignore # noqa: E402
|
||||
_bs_straddle, gamma_scalp_asset, rv_iv_diagnostic, to_daily_voltgt, _metrics,
|
||||
)
|
||||
|
||||
|
||||
def test_bs_straddle_positive_and_increasing_in_vol():
|
||||
"""Premio straddle ATM > 0 e cresce con la vol implicita (sanity del pricer)."""
|
||||
p_lo = _bs_straddle(100.0, 100.0, 7 / 365.25, 0.30)
|
||||
p_hi = _bs_straddle(100.0, 100.0, 7 / 365.25, 0.90)
|
||||
assert p_lo > 0
|
||||
assert p_hi > p_lo
|
||||
|
||||
|
||||
def test_long_gamma_loses_when_rv_below_iv_synthetic():
|
||||
"""Sintetico: con prezzo PIATTO (RV=0) e IV>0, il long gamma deve perdere (solo theta)."""
|
||||
# P&L per step = DG*(r^2 - sigma^2 dt); con r=0 ogni step e' -DG*sigma^2*dt < 0.
|
||||
from options_gamma_scalp import OPT_FEE_FRAC # noqa: F401
|
||||
sig = 0.60
|
||||
# se RV=0 il gamma_pnl e' strettamente negativo -> ritorno negativo. Verifico via il segno
|
||||
# del contributo per-step a r=0.
|
||||
r = 0.0
|
||||
contrib = (r * r - sig * sig * (1.0 / 365.25))
|
||||
assert contrib < 0
|
||||
|
||||
|
||||
def test_iv_exceeds_or_near_daily_rv_btc():
|
||||
"""BTC: a campionamento giornaliero l'IV (DVOL) >= RV in media -> il long gamma paga il VRP."""
|
||||
d = rv_iv_diagnostic("BTC")
|
||||
assert d["iv"] > 0 and d["rv_daily"] > 0
|
||||
assert d["spread_daily"] > 0.0 # IV - RV_1d > 0 su BTC (lato short paga)
|
||||
|
||||
|
||||
def test_naked_gamma_scalp_standalone_sharpe_negative():
|
||||
"""La conclusione: il book gamma-scalp nudo (BTC+ETH) ha Sharpe standalone NEGATIVO."""
|
||||
wB = gamma_scalp_asset("BTC", mode="naked")
|
||||
wE = gamma_scalp_asset("ETH", mode="naked")
|
||||
daily = to_daily_voltgt(wB, wE)
|
||||
assert daily.std() > 0
|
||||
assert _metrics(daily)["sharpe"] < 0.0
|
||||
@@ -81,3 +81,58 @@ def test_causality_flags_lookahead():
|
||||
return f
|
||||
r = al.causality_ok(leaky, tf="1h")
|
||||
assert r["ok"] is False
|
||||
|
||||
|
||||
# --- LESSON 4: selection-on-holdout gate (codified 2026-06-29, filone B) ----------------
|
||||
def _mom_factory():
|
||||
"""Tiny continuous momentum factory parametrized by SMA lookback (for grid tests)."""
|
||||
def factory(tf, win):
|
||||
def fn(df):
|
||||
c = df["close"].values.astype(float)
|
||||
return al.vol_target(np.tanh(3 * (c / al.sma(c, win) - 1)), df, 0.20, 30, 2.0)
|
||||
return fn
|
||||
return factory
|
||||
|
||||
|
||||
def test_deflated_sharpe_penalizes_multiple_testing():
|
||||
"""The SAME Sharpe deflates toward 0 when it was the best of MANY wide-dispersion trials,
|
||||
but survives when it stood alone among a few tight ones (Bailey & Lopez de Prado)."""
|
||||
rng = np.random.default_rng(0)
|
||||
T = 1500
|
||||
idx = pd.date_range("2020-01-01", periods=T, freq="D", tz="UTC")
|
||||
sr_target = 1.0
|
||||
ret = pd.Series(0.01 * (sr_target / np.sqrt(365.25) + rng.standard_normal(T)), index=idx)
|
||||
sr_ann = al._sh(ret)
|
||||
many = list(np.linspace(-3.0, 2.5, 120)) # 120 wide-spread trials
|
||||
few = [0.1, 0.0, -0.1, 0.05] # 4 tight trials
|
||||
dsr_many, sr0_many = al.deflated_sharpe(sr_ann, many, ret)
|
||||
dsr_few, sr0_few = al.deflated_sharpe(sr_ann, few, ret)
|
||||
assert sr0_many > sr0_few # more/wider search -> higher null max
|
||||
assert dsr_many < dsr_few # multiple-testing is penalized
|
||||
assert dsr_many < 0.5 and dsr_few > 0.8
|
||||
assert 0.0 <= dsr_many <= 1.0 and 0.0 <= dsr_few <= 1.0
|
||||
|
||||
|
||||
def test_select_cell_insample_ranks_by_insample_only():
|
||||
"""The cell chosen must be the IN-SAMPLE (<HOLDOUT) Sharpe leader — never the hold-out's —
|
||||
and every searched cell's FULL Sharpe is returned for deflation."""
|
||||
factory = _mom_factory()
|
||||
grid = [dict(win=50), dict(win=150)]
|
||||
sel = al.select_cell_insample(factory, grid, ("12h",))
|
||||
assert sel["chosen"] is not None
|
||||
assert len(sel["all_full_sharpe"]) == len(grid) # one trial per (tf,cell)
|
||||
best_is = max(r["insample_sharpe"] for r in sel["rows"])
|
||||
assert sel["chosen"]["insample_sharpe"] == best_is # picked by in-sample, not hold-out
|
||||
|
||||
|
||||
def test_study_family_honest_contract():
|
||||
"""earns_slot_honest is exactly (in-sample-picked cell earns_slot) AND (deflated-Sharpe PASS).
|
||||
Both sub-gates must be enforced; the combined flag is their conjunction."""
|
||||
factory = _mom_factory()
|
||||
grid = [dict(win=50), dict(win=150)]
|
||||
rep = al.study_family_honest("MOMTEST", factory, grid, ("12h",))
|
||||
for k in ("chosen", "earns_slot_marginal", "deflated_sharpe", "dsr_pass", "earns_slot_honest"):
|
||||
assert k in rep
|
||||
assert rep["n_cells"] == len(grid)
|
||||
assert isinstance(rep["earns_slot_honest"], bool)
|
||||
assert rep["earns_slot_honest"] == bool(rep["earns_slot_marginal"] and rep["dsr_pass"])
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Test del filone B — INTRADAY REGIME (scripts/research/intraday_regime.py).
|
||||
|
||||
Verifica leggera e veloce (un solo asset) che:
|
||||
* i target factory producano array della lunghezza giusta e CAUSALI (no look-ahead);
|
||||
* il meccanismo ERM (efficiency-ratio regime momentum) sia long/short di natura;
|
||||
* la cella canonica ERM 8h L=2 thr=0.35 abbia Sharpe FULL positivo netto fee (sanity dell'edge).
|
||||
NB: NON costruisce la baseline TP01/SKH01 (lento) — quello e' nello script di ricerca.
|
||||
"""
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"intraday_regime", "/opt/docker/PythagorasGoal/scripts/research/intraday_regime.py")
|
||||
ir = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(ir)
|
||||
|
||||
|
||||
def test_erm_target_shape_and_ls():
|
||||
df = al.get("BTC", "8h")
|
||||
tgt = ir.make_erm(tf="8h", L_days=2.0, thr=0.35, long_flat=False)(df)
|
||||
assert len(tgt) == len(df)
|
||||
assert np.isfinite(tgt).all()
|
||||
# L/S: deve esistere sia esposizione long sia short (non e' long-only)
|
||||
assert (tgt > 0).any() and (tgt < 0).any()
|
||||
# long_flat=True -> nessuno short
|
||||
tgt_lf = ir.make_erm(tf="8h", L_days=2.0, thr=0.35, long_flat=True)(df)
|
||||
assert (tgt_lf >= -1e-9).all()
|
||||
|
||||
|
||||
def test_erm_causal_no_leak():
|
||||
# causality_ok ricalcola il target su prefissi troncati e pretende che la coda combaci
|
||||
res = al.causality_ok(ir.make_erm(tf="8h", L_days=2.0, thr=0.35, long_flat=False),
|
||||
tf="8h", assets=("BTC",))
|
||||
assert res["ok"], f"look-ahead in ERM: {res}"
|
||||
|
||||
|
||||
def test_erm_winner_positive_full_sharpe():
|
||||
fn = ir.make_erm(tf="8h", L_days=2.0, thr=0.35, long_flat=False)
|
||||
for a in ("BTC", "ETH"):
|
||||
df = al.get(a, "8h")
|
||||
ev = al.eval_weights(df, fn(df), fee_side=0.0005) # 0.10% RT
|
||||
assert ev["full"]["sharpe"] > 0.5, f"{a} full Sharpe {ev['full']['sharpe']}"
|
||||
|
||||
|
||||
def test_vbr_and_tod_causal():
|
||||
for fn in (ir.make_vbr(tf="8h", k=1.0, atr_win=14, long_flat=False),
|
||||
ir.make_tod(tf="1h", long_flat=True)):
|
||||
res = al.causality_ok(fn, tf=("8h" if fn is not None else "1h"),
|
||||
assets=("BTC",)) if False else None
|
||||
# VBR causale (8h) e TOD causale (1h), un asset per velocita'
|
||||
assert al.causality_ok(ir.make_vbr(tf="8h", k=1.0, atr_win=14, long_flat=False),
|
||||
tf="8h", assets=("BTC",))["ok"]
|
||||
assert al.causality_ok(ir.make_tod(tf="1h", long_flat=True),
|
||||
tf="1h", assets=("BTC",))["ok"]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Test del filone D — MACRO REGIME-GATE (scripts/research/macro_regime_gate.py).
|
||||
|
||||
Verifica leggera che:
|
||||
* il frame macro (ETF daily) carichi e sia su un calendario monotono;
|
||||
* i gate builder producano un gate in [g_off, 1] e CAUSALE (SMA/ratio rolling, no future);
|
||||
* align_gate sia backward-only (la barra crypto i usa solo gate equity con label <= i);
|
||||
* il VERDETTO del filone regga: il gate macro e' RIDONDANTE col trend di TP01 — "lavora"
|
||||
(riduce una posizione TP01 NON gia' flat) solo in una piccola quota di giorni.
|
||||
NB: un solo gate per i test lenti (costruisce TP01) -> velocita'.
|
||||
"""
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal")
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"macro_regime_gate", "/opt/docker/PythagorasGoal/scripts/research/macro_regime_gate.py")
|
||||
mg = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(mg)
|
||||
|
||||
|
||||
def test_macro_frame_loads_and_monotone():
|
||||
mf = mg.macro_frame()
|
||||
for col in ("spy", "qqq", "hyg", "lqd", "gld", "tlt", "ief"):
|
||||
assert col in mf.columns, f"colonna macro mancante: {col}"
|
||||
ts = mf["timestamp"].values
|
||||
assert (np.diff(ts) > 0).all(), "calendario macro non strettamente crescente"
|
||||
# spy non-null dopo l'inizio (master del calendario)
|
||||
assert np.isfinite(mf["spy"].values[-1])
|
||||
|
||||
|
||||
def test_gate_in_range_and_binary():
|
||||
mf = mg.macro_frame()
|
||||
for gdf, g_off in ((mg.gate_trend(mf, "spy", 200, 0.0), 0.0),
|
||||
(mg.gate_combo(mf, 200, 0.5), 0.5)):
|
||||
g = gdf["gate"].values
|
||||
fin = g[np.isfinite(g)]
|
||||
assert len(fin) > 0
|
||||
assert (fin >= g_off - 1e-9).all() and (fin <= 1.0 + 1e-9).all(), \
|
||||
"gate fuori da [g_off, 1]"
|
||||
assert len(gdf) == len(mf)
|
||||
|
||||
|
||||
def test_gate_trend_causal_prefix():
|
||||
# gate_trend usa solo SMA rolling -> ricalcolato su un prefisso, la coda combacia
|
||||
mf = mg.macro_frame()
|
||||
full = mg.gate_trend(mf, "spy", 200, 0.0)["gate"].values
|
||||
k = len(mf) - 300
|
||||
pref = mg.gate_trend(mf.iloc[:k].reset_index(drop=True), "spy", 200, 0.0)["gate"].values
|
||||
a, b = full[:k], pref
|
||||
both = np.isfinite(a) & np.isfinite(b)
|
||||
assert both.any()
|
||||
assert np.allclose(a[both], b[both]), "look-ahead nel gate_trend (prefix != coda)"
|
||||
|
||||
|
||||
def test_align_gate_backward_no_future():
|
||||
# la barra crypto i deve mappare a un gate equity con timestamp <= timestamp crypto i
|
||||
mf = mg.macro_frame()
|
||||
gate_df = mg.gate_trend(mf, "spy", 200, 0.0)
|
||||
df = al.get("BTC", "1d")
|
||||
g = mg.align_gate(gate_df, df)
|
||||
assert len(g) == len(df)
|
||||
assert np.isfinite(g).all()
|
||||
# nessun valore di gate puo' provenire dal futuro: per una manciata di barre crypto,
|
||||
# il gate allineato deve coincidere con l'ultimo gate equity NON-NaN con ts <= ts_crypto
|
||||
gd = gate_df.dropna(subset=["gate"]).sort_values("timestamp")
|
||||
gt, gv = gd["timestamp"].values, gd["gate"].values
|
||||
cts = df["timestamp"].astype("int64").values
|
||||
for i in range(len(cts) - 1, max(len(cts) - 50, 0), -1):
|
||||
prior = np.searchsorted(gt, cts[i], side="right") - 1
|
||||
if prior < 0:
|
||||
continue
|
||||
assert abs(g[i] - gv[prior]) < 1e-9, f"align_gate usa gate futuro a i={i}"
|
||||
|
||||
|
||||
def test_verdict_gate_is_redundant_with_trend():
|
||||
# IL VERDETTO del filone D: TP01 e' gia' flat nei crash -> il gate macro "lavora"
|
||||
# (riduce una posizione NON gia' flat) solo in una piccola quota di giorni.
|
||||
mf = mg.macro_frame()
|
||||
gate_df = mg.gate_combo(mf, 200, 0.0) # combo SPY+HYG+HYG/LQD, de-risk a 0
|
||||
diag = mg.redundancy_diag(gate_df)
|
||||
for a in mg.ASSETS:
|
||||
r = diag[a]
|
||||
# esposizione TP01 nei giorni risk-off gia' bassa, e gate "lavora" raramente
|
||||
assert r["pct_days_gate_works"] < 0.20, \
|
||||
f"{a}: gate lavora {r['pct_days_gate_works']} (atteso piccolo = ridondante col trend)"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Test minimali per scripts/research/meta_allocation.py.
|
||||
|
||||
Verifica le proprieta' STRUTTURALI dell'harness di meta-allocazione (non l'edge — quello e' nel
|
||||
report): (1) i pesi-bersaglio + cash sommano a 1 per riga; (2) gli sleeve inattivi pesano 0;
|
||||
(3) lo schema vol-parity e' CAUSALE (un cambio dei rendimenti in t+k non altera i pesi <= t);
|
||||
(4) il cap del momentum e' rispettato; (5) il motore di simulazione conserva (vol nulla -> equity
|
||||
piatta) e il costo di ribilancio aumenta col turnover.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from scripts.research import meta_allocation as M
|
||||
|
||||
|
||||
def _toy(n=400, A=4, seed=0):
|
||||
rng = np.random.default_rng(seed)
|
||||
R = rng.normal(0.0005, 0.01, size=(n, A))
|
||||
active = np.ones((n, A), bool)
|
||||
index = pd.date_range("2020-01-01", periods=n, freq="1D", tz="UTC")
|
||||
fixed_w = np.array([0.4125, 0.1875, 0.15, 0.25])
|
||||
return index, R, active, fixed_w
|
||||
|
||||
|
||||
def test_weights_plus_cash_sum_to_one():
|
||||
index, R, active, fixed_w = _toy()
|
||||
for fn in (M.scheme_base, M.scheme_volpar_pure, M.scheme_volpar_tilt,
|
||||
M.scheme_momentum, M.scheme_dd_cash, M.scheme_dd_defensive):
|
||||
W = fn(index, R, active, fixed_w) # ultima colonna = cash
|
||||
s = W.sum(axis=1)
|
||||
assert np.allclose(s, 1.0, atol=1e-9), f"{fn.__name__}: righe non sommano a 1 (max dev {np.abs(s-1).max():.2e})"
|
||||
assert (W >= -1e-12).all(), f"{fn.__name__}: pesi negativi"
|
||||
|
||||
|
||||
def test_inactive_sleeves_get_zero_weight():
|
||||
index, R, active, fixed_w = _toy()
|
||||
active[:, 2] = False # spegni lo sleeve 2 ovunque
|
||||
W = M.scheme_base(index, R, active, fixed_w)
|
||||
assert np.allclose(W[:, 2], 0.0), "uno sleeve inattivo riceve peso non nullo"
|
||||
assert np.allclose(W.sum(axis=1), 1.0)
|
||||
|
||||
|
||||
def test_volparity_is_causal():
|
||||
"""Un cambio dei rendimenti da t0 in poi NON deve alterare i pesi calcolati per t < t0."""
|
||||
index, R, active, fixed_w = _toy(n=400)
|
||||
t0 = 360
|
||||
W1 = M.scheme_volpar_pure(index, R, active, fixed_w)
|
||||
R2 = R.copy(); R2[t0:] *= 50.0 # shock futuro enorme
|
||||
W2 = M.scheme_volpar_pure(index, R2, active, fixed_w)
|
||||
assert np.allclose(W1[:t0], W2[:t0]), "VOL-PARITY non causale: pesi passati dipendono dal futuro"
|
||||
|
||||
|
||||
def test_momentum_respects_cap():
|
||||
index, R, active, fixed_w = _toy()
|
||||
cap = 0.55
|
||||
W = M.scheme_momentum(index, R, active, fixed_w, cap=cap)
|
||||
sleeve_w = W[:, :-1] # escludi cash
|
||||
assert sleeve_w.max() <= cap + 1e-6, f"cap momentum violato: max {sleeve_w.max():.3f} > {cap}"
|
||||
|
||||
|
||||
def test_simulate_flat_when_no_returns():
|
||||
index, R, active, fixed_w = _toy()
|
||||
Rz = np.zeros_like(R)
|
||||
W = M.scheme_base(index, Rz, active, fixed_w)
|
||||
sim = M.simulate(Rz, active, W, cost_rate=0.0)
|
||||
assert np.allclose(sim["daily"].values, 0.0, atol=1e-12), "equity non piatta con rendimenti nulli e costo zero"
|
||||
|
||||
|
||||
def test_rebalance_cost_increases_with_turnover():
|
||||
"""Uno schema ad alto turnover (vol-parity) deve pagare piu' costo del peso-fisso (basso turnover)."""
|
||||
index, R, active, fixed_w = _toy(seed=3)
|
||||
Wb = M.scheme_base(index, R, active, fixed_w)
|
||||
Wv = M.scheme_volpar_pure(index, R, active, fixed_w)
|
||||
tb = M.simulate(R, active, Wb)["turnover_per_year"]
|
||||
tv = M.simulate(R, active, Wv)["turnover_per_year"]
|
||||
assert tv > tb, f"il vol-parity dovrebbe avere turnover > peso-fisso (got {tv:.2f} vs {tb:.2f})"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-q"]))
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Test minimali per orthogonal_signals.py — CAUSALITÀ dollar-neutral + dollar-neutrality (beta~0).
|
||||
|
||||
Lo scopo è blindare le due proprietà su cui poggia tutto il filone relative-value ETH/BTC:
|
||||
1. l'evaluator dollar-neutral è CAUSALE: pos[i] decisa a close[i] è tenuta SOLO durante la
|
||||
barra i+1 -> una decisione presa all'ultima barra non può toccare il backtest (no look-ahead),
|
||||
e il prefix-check sul segnale combacia con la coda del full.
|
||||
2. la fee è caricata su 2 GAMBE (ETH + BTC).
|
||||
3. dollar-neutrality: un segnale temporizzato sul ratio ha beta di mercato ~0 (ortogonale per
|
||||
costruzione) — il cuore della richiesta (stream scorrelato al book direzionale).
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
import orthogonal_signals as o # noqa: E402
|
||||
|
||||
|
||||
def _synthetic_joint(n: int = 60, seed: int = 0) -> pd.DataFrame:
|
||||
ts = pd.date_range("2022-01-01", periods=n, freq="D", tz="UTC")
|
||||
rng = np.random.default_rng(seed)
|
||||
cb = 100 * np.cumprod(1 + rng.normal(0, 0.02, n))
|
||||
ce = 100 * np.cumprod(1 + rng.normal(0, 0.03, n))
|
||||
j = pd.DataFrame({"timestamp": ts.view("int64") // 10**6, "datetime": ts, "cb": cb, "ce": ce})
|
||||
j["r_btc"] = o.al.simple_returns(cb)
|
||||
j["r_eth"] = o.al.simple_returns(ce)
|
||||
j["log_ratio"] = np.log(ce / cb)
|
||||
return j
|
||||
|
||||
|
||||
def test_position_held_next_bar_only():
|
||||
"""Una posizione nota a close[k] muove SOLO il ritorno della barra k+1 (eseguibile, no leak)."""
|
||||
j = _synthetic_joint()
|
||||
k = 10
|
||||
pos = np.zeros(len(j)); pos[k] = 1.0
|
||||
ev = o.eval_spread(j, pos, fee_side=0.0)
|
||||
nz = np.nonzero(np.abs(ev["net"]) > 1e-12)[0]
|
||||
assert list(nz) == [k + 1], f"posizione a k={k} deve toccare solo k+1, trovato {nz}"
|
||||
expected = j["r_eth"].values[k + 1] - j["r_btc"].values[k + 1]
|
||||
assert abs(ev["net"][k + 1] - expected) < 1e-12
|
||||
|
||||
|
||||
def test_last_bar_decision_cannot_leak():
|
||||
"""Una decisione presa SOLO all'ultima barra non può influenzare il backtest (è tenuta su una
|
||||
barra i+1 che non esiste) -> net identicamente 0. Guardia anti-look-ahead strutturale."""
|
||||
j = _synthetic_joint()
|
||||
pos = np.zeros(len(j)); pos[-1] = 9.0
|
||||
ev = o.eval_spread(j, pos, fee_side=0.0)
|
||||
assert np.allclose(ev["net"], 0.0)
|
||||
|
||||
|
||||
def test_fee_charged_on_two_legs():
|
||||
"""La fee è su 2 gambe: ogni Δpos paga fee_side su ETH E su BTC -> costo totale = fee*2*turnover."""
|
||||
j = _synthetic_joint()
|
||||
pos = np.zeros(len(j)); pos[5] = 1.0 # held: entra a 6 (Δ=1), esce a 7 (Δ=1) -> turnover=2
|
||||
f = 0.001
|
||||
ev0 = o.eval_spread(j, pos, fee_side=0.0)
|
||||
evf = o.eval_spread(j, pos, fee_side=f)
|
||||
total_fee = float((ev0["net"] - evf["net"]).sum())
|
||||
assert abs(total_fee - f * 2 * 2) < 1e-12, total_fee
|
||||
|
||||
|
||||
def test_prefix_causality_real_signal():
|
||||
"""Prefix-check su dati reali: ricostruendo il segnale su un prefisso, la coda combacia col full."""
|
||||
ck = o.causality_spread(o.f_statarb_resid(W=60), tf="1d")
|
||||
assert ck["ok"] and ck["checked"] >= 1, ck
|
||||
ck2 = o.causality_spread(o.f_ratio_mom(L=30), tf="1d")
|
||||
assert ck2["ok"], ck2
|
||||
|
||||
|
||||
def test_dollar_neutral_low_market_beta():
|
||||
"""Dollar-neutrality: un segnale temporizzato sul ratio ha beta di mercato (50/50 BTC+ETH) ~0.
|
||||
È la proprietà 'ortogonale per costruzione' richiesta dallo studio."""
|
||||
j = o.build_joint("1d")
|
||||
pos = o.f_statarb_resid(W=60)(j)
|
||||
daily = o.spread_daily(j, pos)
|
||||
mkt = o.market_daily()
|
||||
beta, corr = o.beta_to(daily, mkt)
|
||||
assert abs(beta) < 0.10, f"beta di mercato non ~0: {beta}"
|
||||
assert abs(corr) < 0.20, f"corr di mercato troppo alta: {corr}"
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Lock del forward-monitor STATARB-RESID (scripts/live/paper_statarb.py):
|
||||
* config CONGELATA (W=45, sgn=+1) — non deve cambiare silenziosamente;
|
||||
* advance() processa SOLO le barre forward ed è IDEMPOTENTE alla punta;
|
||||
* a 1d il libro REAL-$600 traccia il MODELED (haircut di fill piccolo).
|
||||
Il segnale è quello esatto dello sweep (orthogonal_signals) → niente reimplementazione.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||
import paper_statarb as ps # noqa: E402
|
||||
|
||||
|
||||
def _fresh_state(j, back):
|
||||
ts, _, pos, _ = ps._signal(j)
|
||||
start = int(ts[-back - 1])
|
||||
return dict(start_ts=start, last_ts=start, n_bars=0,
|
||||
pos_modeled=float(pos[-back - 1]), pos_real=float(pos[-back - 1]),
|
||||
cap_modeled=ps.MODELED_CAPITAL, cap_real=ps.REAL_CAPITAL,
|
||||
peak_modeled=ps.MODELED_CAPITAL, peak_real=ps.REAL_CAPITAL,
|
||||
dd_modeled=0.0, dd_real=0.0, n_trades=0)
|
||||
|
||||
|
||||
def _redirect(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(ps, "STATE_DIR", tmp_path)
|
||||
monkeypatch.setattr(ps, "STATE_FILE", tmp_path / "state.json")
|
||||
monkeypatch.setattr(ps, "RETURNS_FILE", tmp_path / "returns.jsonl")
|
||||
monkeypatch.setattr(ps, "TRADES_FILE", tmp_path / "trades.jsonl")
|
||||
|
||||
|
||||
def test_frozen_config():
|
||||
"""La cella vincente in-sample è W=45 sgn=+1 (relative-momentum, non mean-reversion)."""
|
||||
assert ps.W_FROZEN == 45
|
||||
assert ps.SGN_FROZEN == +1
|
||||
|
||||
|
||||
def test_advance_processes_forward_then_idempotent(tmp_path, monkeypatch):
|
||||
_redirect(tmp_path, monkeypatch)
|
||||
j = ps.build_joint("1d")
|
||||
ts, *_ = ps._signal(j)
|
||||
st = _fresh_state(j, back=60)
|
||||
st = ps.advance(st, j)
|
||||
assert st["n_bars"] == 60 # ha processato le 60 barre forward
|
||||
assert st["last_ts"] == int(ts[-1])
|
||||
assert st["cap_modeled"] != ps.MODELED_CAPITAL # l'equity si è mossa
|
||||
# idempotente: ri-avanzare alla punta non aggiunge barre né muove l'equity
|
||||
eq_before = st["cap_modeled"]
|
||||
st2 = ps.advance(dict(st), j)
|
||||
assert st2["n_bars"] == 60
|
||||
assert st2["cap_modeled"] == eq_before
|
||||
|
||||
|
||||
def test_real600_tracks_modeled_low_turnover(tmp_path, monkeypatch):
|
||||
_redirect(tmp_path, monkeypatch)
|
||||
j = ps.build_joint("1d")
|
||||
st = ps.advance(_fresh_state(j, back=250), j)
|
||||
rm = st["cap_modeled"] / ps.MODELED_CAPITAL - 1
|
||||
rr = st["cap_real"] / ps.REAL_CAPITAL - 1
|
||||
assert abs(rm - rr) < 0.05 # haircut di fill piccolo a 1d ($600)
|
||||
+32
-1
@@ -7,7 +7,7 @@ import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily, metrics
|
||||
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily, metrics, rebalance_sim
|
||||
|
||||
|
||||
def _const_sleeve(name, weight, val, n=400):
|
||||
@@ -15,6 +15,37 @@ def _const_sleeve(name, weight, val, n=400):
|
||||
return Sleeve(name, weight, lambda: pd.Series(val, index=idx))
|
||||
|
||||
|
||||
def _ret_series(vals):
|
||||
idx = pd.date_range("2020-01-01", periods=len(vals), freq="1D", tz="UTC")
|
||||
return pd.Series(vals, index=idx)
|
||||
|
||||
|
||||
def test_rebalance_sim_no_cost_period1_matches_continuous():
|
||||
"""period=1 + cost=0 deve coincidere col rebalance-continuo (weighted-return giornaliero)."""
|
||||
rng = np.random.default_rng(0)
|
||||
A = _ret_series(rng.normal(0.001, 0.02, 300))
|
||||
B = _ret_series(rng.normal(0.000, 0.03, 300))
|
||||
w = {"A": 0.6, "B": 0.4}
|
||||
sim = rebalance_sim({"A": A, "B": B}, w, period_days=1, cost_rate=0.0)
|
||||
cont = 0.6 * A + 0.4 * B
|
||||
assert np.allclose(sim["daily"].values, cont.values, atol=1e-12)
|
||||
assert sim["n_rebalances"] == 300
|
||||
|
||||
|
||||
def test_rebalance_sim_cost_reduces_return_and_counts():
|
||||
"""Il costo del turnover abbassa il rendimento; ribilanci meno frequenti = meno costo."""
|
||||
rng = np.random.default_rng(1)
|
||||
A = _ret_series(rng.normal(0.001, 0.02, 360))
|
||||
B = _ret_series(rng.normal(0.001, 0.04, 360))
|
||||
w = {"A": 0.5, "B": 0.5}
|
||||
free = rebalance_sim({"A": A, "B": B}, w, period_days=7, cost_rate=0.0)["daily"]
|
||||
weekly = rebalance_sim({"A": A, "B": B}, w, period_days=7, cost_rate=0.001)
|
||||
monthly = rebalance_sim({"A": A, "B": B}, w, period_days=30, cost_rate=0.001)
|
||||
assert weekly["daily"].sum() < free.sum() # il costo morde
|
||||
assert monthly["n_rebalances"] < weekly["n_rebalances"] # mensile ribilancia meno
|
||||
assert weekly["turnover_per_year"] > 0
|
||||
|
||||
|
||||
def test_single_sleeve_equals_itself():
|
||||
s = _const_sleeve("A", 1.0, 0.001)
|
||||
pf = StrategyPortfolio([s])
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Test minimale per scripts/research/signal_inout_1leg.py:
|
||||
- la costruzione del segnale MACD e' CAUSALE (no look-ahead): causality_ok ok, tail-diff ~0;
|
||||
- una cella esegue end-to-end (study_weights ritorna un verdetto valido).
|
||||
Veloce: solo BTC a 1d, una cella.
|
||||
"""
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research")
|
||||
import altlib as al # noqa: E402
|
||||
import signal_inout_1leg as sig # noqa: E402
|
||||
|
||||
|
||||
def test_macd_target_is_causal():
|
||||
"""Un target MACD costruito con EMA(adjust=False) non deve guardare al futuro."""
|
||||
fn = sig.make_macd("LF")(tf="1d", fast=12, slow=26, sig=9)
|
||||
c = al.causality_ok(fn, tf="1d")
|
||||
assert c["ok"], c
|
||||
assert c["max_tail_diff"] <= 1e-6, c
|
||||
|
||||
|
||||
def test_macd_position_values_and_hold():
|
||||
"""Long-flat in {0,1}; long-short in {-1,1}; nessun NaN."""
|
||||
df = al.get("BTC", "1d")
|
||||
lf = sig.make_macd("LF")(tf="1d", fast=12, slow=26, sig=9)(df)
|
||||
ls = sig.make_macd("LS")(tf="1d", fast=12, slow=26, sig=9)(df)
|
||||
assert len(lf) == len(df) and len(ls) == len(df)
|
||||
assert set(np.unique(lf)).issubset({0.0, 1.0})
|
||||
assert set(np.unique(ls)).issubset({-1.0, 1.0})
|
||||
assert np.isfinite(lf).all() and np.isfinite(ls).all()
|
||||
|
||||
|
||||
def test_one_cell_executes_end_to_end():
|
||||
"""study_weights su una cella MACD-LF deve produrre un verdetto valido."""
|
||||
fn = sig.make_macd("LF")(tf="1d", fast=12, slow=26, sig=9)
|
||||
rep = al.study_weights("MACD-LF-test", fn, tfs=("1d",))
|
||||
assert rep["verdict"]["grade"] in ("PASS", "WEAK", "FAIL")
|
||||
assert rep["cells"] and rep["cells"][0]["per_asset"]
|
||||
|
||||
|
||||
def test_supertrend_and_rsi_targets_run():
|
||||
"""Supertrend (stateful) e RSI (mean-rev) producono posizioni causali eseguibili."""
|
||||
df = al.get("BTC", "1d")
|
||||
st = sig.make_supertrend("LF")(tf="1d", atr_win=14, mult=2.5)(df)
|
||||
rs = sig.make_rsi()(tf="1d", win=14, oversold=30, overbought=65)(df)
|
||||
assert len(st) == len(df) and len(rs) == len(df)
|
||||
assert np.isfinite(st).all() and np.isfinite(rs).all()
|
||||
assert al.causality_ok(sig.make_supertrend("LF")(tf="1d", atr_win=14, mult=2.5), tf="1d")["ok"]
|
||||
@@ -129,6 +129,16 @@ def test_short_override_changes_only_shorts():
|
||||
assert longs_same > 0 and shorts_diff > 0
|
||||
|
||||
|
||||
def test_skyhook_pos_fn_structure():
|
||||
"""pos_fn introspettiva: dict BTC/ETH, ciascuno 'flat' o dict con dir/entry/sl/tp coerenti."""
|
||||
from src.portfolio.sleeves import _skyhook_positions
|
||||
pos = _skyhook_positions()
|
||||
assert set(pos.keys()) == {"BTC", "ETH"}
|
||||
for a, p in pos.items():
|
||||
assert p == "flat" or (isinstance(p, dict) and p["dir"] in ("LONG", "SHORT")
|
||||
and p["sl"] > 0 and p["tp"] > 0 and 0 <= p["bars_in"] <= p["max_bars"])
|
||||
|
||||
|
||||
def test_v2dd_robust_both_assets():
|
||||
"""SKH01-V2-DD: PASS netto fee su BTCÐ, hold-out forte, e maxDD standalone <30%."""
|
||||
import skyhooklib as sk
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Lock della conclusione 2026-06-26: il DVOL come denominatore del vol-target NON migliora TP01
|
||||
risk-adjusted. Il taglio di DD delle varianti DVOL è solo de-levering, replicabile MEGLIO con un
|
||||
target_vol più basso sul realizzato. Diario docs/diary/2026-06-26-tp01-dvol-overlay.md."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
from tp01_dvol_overlay import portfolio, metrics, RAW, VW # type: ignore
|
||||
|
||||
|
||||
def _dstart():
|
||||
s = max(pd.read_parquet(RAW / f"dvol_{a.lower()}.parquet")["timestamp"].min() for a in ("BTC", "ETH"))
|
||||
return pd.Timestamp(s, unit="ms", tz="UTC") + pd.Timedelta(days=VW)
|
||||
|
||||
|
||||
def test_dvol_does_not_beat_realized_risk_adjusted():
|
||||
"""Sulla finestra comune, il realized batte (o eguaglia) le varianti DVOL sullo Sharpe FULL."""
|
||||
lo = _dstart()
|
||||
base = metrics(portfolio("realized")[portfolio("realized").index >= lo])
|
||||
mx = metrics(portfolio("max")[portfolio("max").index >= lo])
|
||||
dv = metrics(portfolio("dvol")[portfolio("dvol").index >= lo])
|
||||
assert base["sharpe"] >= mx["sharpe"] - 1e-9
|
||||
assert base["sharpe"] >= dv["sharpe"] - 1e-9
|
||||
|
||||
|
||||
def test_lower_target_vol_replicates_dd_cut_at_better_sharpe():
|
||||
"""Il taglio di DD del DVOL è solo de-levering: realized @ vol-tgt 15% eguaglia il DD del
|
||||
max-DVOL a Sharpe FULL non inferiore (anzi superiore)."""
|
||||
lo = _dstart()
|
||||
mx = metrics(portfolio("max")[portfolio("max").index >= lo])
|
||||
lo_tv = metrics(portfolio("realized", tvol=0.15)[portfolio("realized", tvol=0.15).index >= lo])
|
||||
assert lo_tv["dd"] <= mx["dd"] + 0.01 # stesso DD (entro 1pp)
|
||||
assert lo_tv["sharpe"] >= mx["sharpe"] # a Sharpe non inferiore
|
||||
|
||||
|
||||
def test_dvol_spike_gate_is_redundant_with_trend():
|
||||
"""Il gate DVOL-spike de-risk non cambia nulla: TP01 è già flat nei crash (momentum<0)."""
|
||||
lo = _dstart()
|
||||
base = portfolio("realized"); dr = portfolio("derisk")
|
||||
base = base[base.index >= lo]; dr = dr[dr.index >= lo]
|
||||
assert abs(metrics(base)["sharpe"] - metrics(dr)["sharpe"]) < 0.02
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Test offline del logger forward della vol term-structure (2026-06-26). La STORIA per-scadenza
|
||||
non e' pubblica su Deribit -> calendar-vol non backtestabile ora; questo logger costruisce il dataset
|
||||
in avanti. Si testa la pura interpolazione ai tenor fissi. Diario 2026-06-26-vol-termstructure-feasibility.md."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
from log_vol_termstructure import build_row, TENORS # type: ignore
|
||||
|
||||
|
||||
def test_build_row_interpolates_all_tenors():
|
||||
"""Curva sintetica in contango -> riga con tutti i tenor + slope positivo."""
|
||||
curve = [(7, 40.0), (30, 45.0), (90, 50.0), (180, 55.0)]
|
||||
row = build_row(100000.0, curve, now_ms=1_700_000_000_000)
|
||||
assert all(f"iv_{t}d" in row for t in TENORS)
|
||||
assert abs(row["iv_30d"] - 45.0) < 1e-6 # nodo esatto
|
||||
assert 45.0 < row["iv_60d"] < 50.0 # interpolato tra 30 e 90
|
||||
assert row["slope_7_180"] > 0 # contango -> slope positivo
|
||||
|
||||
|
||||
def test_build_row_needs_two_points():
|
||||
assert build_row(100.0, [(30, 50.0)], now_ms=1_700_000_000_000) is None
|
||||
assert build_row(100.0, [], now_ms=1_700_000_000_000) is None
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Test del filone C: cross-sectional NON-momentum su Hyperliquid (scripts/research/xsec_v2_nonmom).
|
||||
|
||||
Verifica i GATE strutturali, non i numeri esatti (storia corta, ricerca): l'engine e' CAUSALE
|
||||
(prefix-consistency, zero look-ahead), le fee MONOTONE (piu' fee -> Sharpe <=), il reversal grezzo
|
||||
e' MORTO (plateau negativo), e il low-vol factor sui 19 major e' positivo in-sample (il LEAD).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from src.portfolio.portfolio import to_daily, metrics
|
||||
from src.portfolio.sleeves import XS_UNIVERSE
|
||||
|
||||
import importlib.util
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"xsec_v2_nonmom", PROJECT_ROOT / "scripts" / "research" / "xsec_v2_nonmom.py")
|
||||
xv = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(xv)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def majors():
|
||||
return xv.load_matrix(XS_UNIVERSE)
|
||||
|
||||
|
||||
def test_universe_loads_clean(majors):
|
||||
PX, VOL = majors
|
||||
assert PX.shape[1] == len(XS_UNIVERSE)
|
||||
assert PX.shape[0] > 800 # ~2.5 anni a 1d
|
||||
assert PX.index.is_monotonic_increasing
|
||||
|
||||
|
||||
def test_engine_is_causal_no_lookahead(majors):
|
||||
"""L'engine NON deve guardare al futuro: ricostruito su un prefisso, la coda combacia
|
||||
bit-a-bit con la run completa (gate #1 della metodologia)."""
|
||||
PX, VOL = majors
|
||||
builder, _ = xv.mechanisms()["LOWVOL"]
|
||||
cfg = dict(B=30, H=10, k=5)
|
||||
res = xv.causality_prefix_check(PX, VOL, builder, cfg)
|
||||
assert res["ok"], f"look-ahead rilevato: max_tail_diff={res['max_tail_diff']}"
|
||||
assert res["max_tail_diff"] == 0.0
|
||||
|
||||
# anche un meccanismo residuo (usa beta rolling + mercato) deve essere causale
|
||||
builder_i, _ = xv.mechanisms()["IMOM"]
|
||||
res_i = xv.causality_prefix_check(PX, VOL, builder_i, dict(L=30, H=5, k=8, B=60))
|
||||
assert res_i["ok"], f"IMOM non causale: {res_i['max_tail_diff']}"
|
||||
|
||||
|
||||
def test_fee_is_monotone(majors):
|
||||
"""Piu' fee non puo' MAI alzare lo Sharpe (su una config con turnover non nullo)."""
|
||||
PX, VOL = majors
|
||||
builder, _ = xv.mechanisms()["LOWVOL"]
|
||||
score_at, warm = builder(PX, dict(B=30, H=10, k=5))
|
||||
s0, t0 = xv.xs_engine(PX, VOL, score_at, 10, 5, fee=0.0, warmup=warm)
|
||||
s2, t2 = xv.xs_engine(PX, VOL, score_at, 10, 5, fee=0.002, warmup=warm)
|
||||
assert t0 > 0
|
||||
assert metrics(to_daily(s0))["sharpe"] >= metrics(to_daily(s2))["sharpe"] - 1e-9
|
||||
|
||||
|
||||
def test_raw_reversal_is_dead(majors):
|
||||
"""Reversal cross-sectional grezzo = NESSUN plateau positivo (coerente con la lezione del
|
||||
progetto: la mean-reversion e' artefatto). Almeno meta' delle config dev'essere FULL<=0."""
|
||||
PX, VOL = majors
|
||||
builder, cfgs = xv.mechanisms()["REV"]
|
||||
neg = 0; tot = 0
|
||||
for p in cfgs:
|
||||
score_at, warm = builder(PX, p)
|
||||
d = to_daily(xv.xs_engine(PX, VOL, score_at, p["H"], p["k"], warmup=warm)[0])
|
||||
if d.std() == 0:
|
||||
continue
|
||||
tot += 1
|
||||
if metrics(d)["sharpe"] <= 0:
|
||||
neg += 1
|
||||
assert tot > 0
|
||||
assert neg >= tot / 2, f"reversal inatteso: solo {neg}/{tot} config FULL<=0"
|
||||
|
||||
|
||||
def test_lowvol_factor_positive_insample(majors):
|
||||
"""Il LEAD: low-vol factor sui 19 major (B30 H10 k5) ha FULL Sharpe positivo e robusto
|
||||
(plateau 100% positivo). Numero non vincolato (ricerca), solo il segno/robustezza."""
|
||||
PX, VOL = majors
|
||||
builder, cfgs = xv.mechanisms()["LOWVOL"]
|
||||
score_at, warm = builder(PX, dict(B=30, H=10, k=5))
|
||||
d = to_daily(xv.xs_engine(PX, VOL, score_at, 10, 5, warmup=warm)[0])
|
||||
assert metrics(d)["sharpe"] > 0.5
|
||||
# plateau: ogni config LOWVOL deve avere FULL>0 (factor robusto ai parametri in-sample)
|
||||
pos = 0; tot = 0
|
||||
for p in cfgs:
|
||||
sa, w = builder(PX, p)
|
||||
dd = to_daily(xv.xs_engine(PX, VOL, sa, p["H"], p["k"], warmup=w)[0])
|
||||
if dd.std() == 0:
|
||||
continue
|
||||
tot += 1; pos += metrics(dd)["sharpe"] > 0
|
||||
assert pos == tot, f"plateau low-vol non pieno: {pos}/{tot}"
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Test del filone C v3: cross-sectional 'low-risk cousins' (MAX / IVOL / AMIHUD) su Hyperliquid
|
||||
(scripts/research/xsec_v3_lowrisk). Verifica i GATE strutturali, non i numeri esatti (storia corta):
|
||||
- lo script importa ed esegue (catalogo meccanismi costruibile, una cella di engine gira);
|
||||
- i meccanismi sono CAUSALI (prefix-consistency bit-a-bit), incluso AMIHUD che richiede il
|
||||
riallineamento del volume sul prefisso (path piu' delicato);
|
||||
- la selezione 'robust_candidate' RIFIUTA il holdout-fitting (config negativa in-sample con HOLD
|
||||
alto) come prescritto dallo scorer indurito del progetto;
|
||||
- IVOL sui 19 major ha edge in-sample positivo (il LEAD principale).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import importlib.util
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"xsec_v3_lowrisk", PROJECT_ROOT / "scripts" / "research" / "xsec_v3_lowrisk.py")
|
||||
xv3 = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(xv3)
|
||||
|
||||
xv = xv3.xv # harness collaudato riusato dal modulo v3
|
||||
from src.portfolio.portfolio import to_daily, metrics
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def majors():
|
||||
return xv.load_matrix(xv.XS_UNIVERSE)
|
||||
|
||||
|
||||
def test_imports_and_builds_mechanisms(majors):
|
||||
"""Lo script importa e il catalogo dei 3 'low-risk cousins' (+ entrambi i segni AMIHUD) e' costruibile."""
|
||||
_, VOL = majors
|
||||
mechs = xv3.build_mechanisms(VOL)
|
||||
assert set(mechs) == {"MAX", "IVOL", "AMIHUD_ILLIQ", "AMIHUD_LIQ"}
|
||||
for mn, (_builder, cfgs) in mechs.items():
|
||||
assert len(cfgs) == 12 # B{20,30,60} x H{5,10} x k{5,8}
|
||||
|
||||
|
||||
def test_engine_executes_a_cell(majors):
|
||||
"""Esegue una cella dell'engine (IVOL B30 H5 k8 sui 19 major): serie giornaliera finita, std>0,
|
||||
turnover>0 e Sharpe FULL positivo robusto (il LEAD documentato)."""
|
||||
PX, VOL = majors
|
||||
score_at, warm = xv3.make_ivol(PX, 30)
|
||||
s, turn = xv.xs_engine(PX, VOL, score_at, H=5, k=8, warmup=warm)
|
||||
d = to_daily(s)
|
||||
assert np.isfinite(d.values).all() and d.std() > 0
|
||||
assert turn > 0
|
||||
assert metrics(d)["sharpe"] > 0.5 # IVOL 19-major = LEAD (edge in-sample+OOS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mech,cfg", [
|
||||
("MAX", dict(B=60, H=5, k=5)),
|
||||
("IVOL", dict(B=30, H=5, k=8)),
|
||||
("AMIHUD_ILLIQ", dict(B=30, H=10, k=5)), # path col riallineamento del volume
|
||||
])
|
||||
def test_mechanism_is_causal(majors, mech, cfg):
|
||||
"""Nessun look-ahead: ricostruito su un prefisso, la coda combacia bit-a-bit con la run completa.
|
||||
Per AMIHUD verifica anche che il volume sia riallineato al prefisso (non al full-sample)."""
|
||||
PX, VOL = majors
|
||||
builder, _ = xv3.build_mechanisms(VOL)[mech]
|
||||
res = xv.causality_prefix_check(PX, VOL, builder, cfg)
|
||||
assert res["ok"], f"{mech} look-ahead: max_tail_diff={res['max_tail_diff']}"
|
||||
assert res["max_tail_diff"] == 0.0
|
||||
|
||||
|
||||
def test_robust_candidate_rejects_holdout_fit():
|
||||
"""La selezione GIUDICATA scarta il holdout-fitting: una config NEGATIVA in-sample con HOLD alto
|
||||
non e' eleggibile; serve in-sample>=0.5 E HOLD>0. Se nessuna ha edge in-sample -> None."""
|
||||
rows = [
|
||||
dict(insample=-1.2, hold=1.0, full=0.1), # holdout-fit -> escluso
|
||||
dict(insample=0.9, hold=0.8, full=0.95), # edge in-sample + OOS -> eleggibile
|
||||
dict(insample=0.6, hold=-0.2, full=0.3), # HOLD<0 -> escluso
|
||||
]
|
||||
c = xv3.robust_candidate(rows)
|
||||
assert c is not None and c["insample"] == 0.9
|
||||
assert xv3.robust_candidate([dict(insample=0.1, hold=2.0, full=0.0)]) is None
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Test del filone XS-v3: varianti STRUTTURALI di momentum cross-sectional
|
||||
(scripts/research/xsec_v3_momstruct). Verifica i GATE strutturali, non i numeri esatti (storia
|
||||
corta, ricerca): gli engine sono CAUSALI (prefix-consistency, zero look-ahead, anche quello
|
||||
volatility-managed custom) e una cella della griglia ESEGUE producendo una serie finita non degenere.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
|
||||
from src.portfolio.sleeves import XS_UNIVERSE
|
||||
|
||||
import importlib.util
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"xsec_v3_momstruct", PROJECT_ROOT / "scripts" / "research" / "xsec_v3_momstruct.py")
|
||||
v3 = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(v3)
|
||||
xv = v3.xv
|
||||
|
||||
|
||||
def _majors():
|
||||
return xv.load_matrix(XS_UNIVERSE)
|
||||
|
||||
|
||||
def test_std_engine_variants_are_causal():
|
||||
"""RAMOM/ACCEL/FIP usano xs_engine: ricostruiti su un prefisso, la coda combacia bit-a-bit
|
||||
con la run completa (gate #2 della metodologia)."""
|
||||
PX, VOL = _majors()
|
||||
vdefs = v3.variants()
|
||||
for name, cfg in (("RAMOM", dict(L=30, H=10, k=5)),
|
||||
("ACCEL", dict(Ls=30, Ll=60, H=5, k=8)),
|
||||
("FIP", dict(L=60, H=10, k=5))):
|
||||
res = xv.causality_prefix_check(PX, VOL, vdefs[name]["builder"], cfg)
|
||||
assert res["ok"], f"{name} look-ahead: max_tail_diff={res['max_tail_diff']}"
|
||||
assert res["max_tail_diff"] == 0.0
|
||||
|
||||
|
||||
def test_volscaled_engine_is_causal():
|
||||
"""L'engine volatility-managed custom (vol-target sulla vol di MERCATO, shift 1) e' causale."""
|
||||
PX, VOL = _majors()
|
||||
v = v3.variants()["VOLSC"]
|
||||
res = v3.caus_check_mktvol(PX, VOL, v["builder"], dict(L=60, H=5, k=8), B_mkt=v["B_mkt"])
|
||||
assert res["ok"], f"VOLSC look-ahead: max_tail_diff={res['max_tail_diff']}"
|
||||
assert res["max_tail_diff"] == 0.0
|
||||
|
||||
|
||||
def test_grid_cell_executes_finite():
|
||||
"""Una cella di ogni variante esegue e produce una serie GIORNALIERA finita e non degenere."""
|
||||
PX, VOL = _majors()
|
||||
vdefs = v3.variants()
|
||||
for name, cfg in (("RAMOM", dict(L=60, H=10, k=5)),
|
||||
("ACCEL", dict(Ls=30, Ll=90, H=5, k=8)),
|
||||
("FIP", dict(L=90, H=10, k=8)),
|
||||
("VOLSC", dict(L=60, H=5, k=8))):
|
||||
daily, turn = v3.run_variant_cfg(PX, VOL, vdefs[name], cfg)
|
||||
assert len(daily) > 60
|
||||
assert np.isfinite(daily.values).all()
|
||||
assert daily.std() > 0
|
||||
assert turn > 0
|
||||
Reference in New Issue
Block a user