17 Commits

Author SHA1 Message Date
Adriano c6236ed5d9 feat: integra VRP01 come sleeve del portafoglio (put credit spread + gate IV-rank)
src/portfolio/sleeves.py: _vrp_combo_returns + vrp_sleeve, self-contained in src/
(pricing BS + gate causali inline, DVOL da data/raw). Settimanale->giornaliero col
lump sul giorno di scadenza (preserva lo Sharpe annualizzato, peso costante).

Registry: TP01 0.55 / XS01 0.25 / VRP01 0.20 (TP01 resta maggioranza; VRP e' un
lead modellato, non deploy pieno). TP01+VRP01 monotono: FULL 1.30->1.44, HOLD
0.31->0.40 a peso 20%. Scorrelato a TP01 (+0.01).

Test tests/test_vrp_sleeve.py (5 pass). CLAUDE.md + diario aggiornati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:24:40 +02:00
Adriano 75e1aacd19 research: analisi strategie FinanceOld + VRP v2 (defined-risk spread + gate IV-rank)
Analisi 4 progetti FinanceOld. Solo il filone opzioni-VRP backtestabile sui dati
certificati (funding-arb senza dati storici; Polybot ticks corrotti/3gg/edge=latenza).

VRP v2 porta 3 idee di OptionsAgent nel framework, causale + fee-aware:
- put credit spread (rischio definito): worst-week -16.6%->-7.4%, DD 33%->21%
- gate IV-rank>0.30: ribalta HOLD-OUT da -0.25 a +0.28 (alpha = filtro regime)
- COMBO f=1.0: FULL Sh 1.10, HOLD 0.60, DD 12%, positiva/piatta ogni anno
- blend TP01 70/30 -> Sh 1.00, DD 7% (corr +0.07)

Lead quantificato, non deploy (premio modellato ATM, serve f di stress reale).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:16:59 +02:00
Adriano Dal Pastro 92a63feb9c chore(monitor): cron giornaliero (refresh dati + avanza paper) + cleanup crontab/orfano
scripts/cron_daily.sh: rebuild_history BTC/ETH + fetch_hyperliquid (52 alt) + fetch_dvol +
paper_portfolio, ogni giorno 00:30 UTC -> tiene fresco il dato che il dashboard legge e avanza
il paper forward. fetch_hyperliquid END ora DINAMICO (oggi) per il refresh.

Cleanup: rimosso container orfano pythagoras-portfolio (vecchio runner pre-reset, exited);
crontab ripulito dai 4 job rotti del micro-test mainnet (hourly_report/drift/reconcile/
ledger_vs_backtest -> script archiviati in Old/), backup in logs/crontab.pre-reset.bak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:06:38 +00:00
Adriano Dal Pastro 26e977d338 feat(monitor): dashboard PAPER del portafoglio attivo (TP01+XS01) + paper forward loop
src/live/dashboard.py: web UI stdlib (:8787) che mostra metriche (FULL/HOLD Sharpe, DD, CAGR),
per-sleeve, posizioni correnti, equity (backtest + paper forward), ultimo dato. Solo MONITOR,
esecuzione REALE disabilitata. scripts/live/paper_portfolio.py: forward-only del portafoglio
(StrategyPortfolio su active_sleeves), stato persistente in data/paper_portfolio (gitignored).

Dockerfile + docker-compose.yml minimali (solo servizio dashboard; runner/esecuzione restano in
Old/). Container pythagoras-dashboard ricostruito col codice nuovo (il vecchio mostrava dati
pre-reset). Mount data/ read-only. .dockerignore esclude Old/data/.venv/.git/.env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:57:55 +00:00
Adriano Dal Pastro 0d9f483131 docs: aggiorna CLAUDE.md allo stato corrente (XS01 blend+gate, portafoglio FULL/HOLD 1.55)
XS01 ridescritta con affinamenti (blend lookback [30,90] + gate dispersione p30, standalone FULL
1.50). Portafoglio attivo TP01 70% + XS01 30% -> FULL Sh 1.55 / HOLD 1.55 / DD 4.4%. Aggiunte le
lezioni: espansione universo Hyperliquid NON aiuta XS (52/top-liq/trend-multiasset tutti peggiori,
i margini sono nel segnale); lead opzioni VRP quantificato (f reale ~1.0, non deploy). Struttura/
comandi aggiornati (scripts/research track A-I + options_vrp + fetch_dvol; scripts/portfolio xsec_*).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:29:24 +00:00
Adriano Dal Pastro 87dd56a9ce feat(XS01): + gate di dispersione (p30) — portafoglio FULL 1.48->1.55, HOLD 1.06->1.55
Momentum cross-sectional vive nella dispersione; gate: entra solo se la dispersione cross-section
del momentum supera il percentile ESPANDENTE causale (altrimenti flat). Plateau robusto p15-p35
(non knife-edge: il crollo a p40+ e' over-gating); scelto p30. XS01 standalone FULL 1.10->1.50,
HOLD 1.03->1.71, DD 14%->10.8%. Portafoglio TP01 70+XS 30: FULL 1.48->1.55, HOLD 1.06->1.55, DD
4.6%->4.4%. Il gate alza SIA FULL SIA hold-out (tiene XS attivo nei regimi dispersi, flat nei bull
compatti; causale). E' il concetto del vecchio XS01.

sleeves.XS_CFG disp_pct=30; engine _xsec_returns gatea su dispersione. 12 test ok.
Diario 2026-06-19-xsec-dispgate.md. Affinamenti del segnale (blend+gate) > espansione universo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:24:04 +00:00
Adriano Dal Pastro fd5a0bd3cf feat(XS01): affina con blend di lookback [30,90] — FULL 0.80->1.10, portafoglio 1.41->1.48
Come TP01 fonde gli orizzonti, XS01 ora fonde 30g+90g del momentum cross-sectional (z-score per
lookback, mediato). Sweep: [30,90] e' il sweet spot (fonde i due singoli robusti, anti-overfit):
XS01 standalone FULL 0.80->1.10, DD 21%->14%, corr a TP01 -0.06->-0.12, 100% anni+. Portafoglio
TP01 70 + XS01 30: FULL Sh 1.41->1.48, DD 5.2%->4.6%, ~€/g 1.65->1.78; hold-out 1.15->1.06 (calo
marginale dentro il rumore). Piu' robusto (due orizzonti) + diversifica meglio -> promosso.

sleeves.XS_CFG lookbacks=(30,90), engine _xsec_returns usa lo score blended. 12 test ok.
Diario 2026-06-19-xsec-blend.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:19:12 +00:00
Adriano Dal Pastro bf6ade51af research: strato trend multi-asset (52 alt) RIDONDANTE col trend TP01 -> non aggiunto
TSMOM CANONICAL applicato a ogni alt dei 52, equal-weight. Standalone FULL 0.66 ma HOLD-OUT -1.03
(long negli alt nel calo 2025-26), corr a TP01 +0.74 (stessa beta direzionale). Contributo al
portafoglio NEGATIVO (HOLD -0.16/-0.27). Broadenizzare il TREND non diversifica: e' la stessa
direzionalita' su asset piu' rumorosi. Solo il market-neutral (XS01) diversifica davvero.

Chiude il filone espansione-universo (XS-52, top-liquidita' dinamico, trend-52: tutti peggiori).
Configurazione validata invariata: TP01 70% + XS01 (19 major) 30%, FULL Sh 1.41 / HOLD 1.15.
I margini reali sono in un MECCANISMO diverso (opzioni VRP), non nell'universo crypto-direzionale.
Diario 2026-06-19-trend-multiasset.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:17:16 +00:00
Adriano Dal Pastro 182d4eeac2 research: universo top-liquidità DINAMICO per XS — anch'esso peggiore del fisso-19 (memecoin diluiscono)
xsec_dynuniverse.py: a ogni ribilancio top-N per dollar-volume 30g causale (ragged-aware), poi XS
momentum. Esito: best dinamico top12 FULL 0.65/OOS0.54 (un anno neg) vs fisso-19 FULL 0.80/OOS1.20
(100% anni+). Contributo TP01+DYN 1.10/0.60 vs TP01+XS19 1.25/1.15. La classifica per volume ammette
i MEMECOIN ad alto volume (WIF/ORDI/JUP) erratici -> diluiscono. Liquidità != qualità.

Conclusione: ne' 52-all ne' top-liquidità dinamico battono i 19 major curati. XS01 resta sui 19.
Portafoglio invariato TP01 70% + XS01 30% (FULL 1.41 / HOLD 1.15). 12 test ok. I 52 parquet restano
per ricerca futura. Diario 2026-06-19-xsec-universe-expansion.md aggiornato.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:09:48 +00:00
Adriano Dal Pastro 8426d05f12 research: espandere universo XS01 a 52 asset DILUISCE (negativo) -> XS01 blindato sui 19 major
Esteso fetch_hyperliquid a 52 alt certificati (cross-venue vs Binance, flat 0%, 2024+; +gate
delistato per MKR/FXS). Ma il cross-sectional momentum sui 52 e' NEGATIVO (FULL -0.1..-0.6, k grande
non aiuta) vs +0.67/OOS0.91 sui 19 major (stessa finestra): i ~33 small-cap (WIF/JUP/ORDI/PYTH/TAO..)
sono idiosincratici/mean-reverting e rovesciano il momentum relativo. "Piu' asset = piu' robusto"
e' FALSO per l'XS momentum: la breadth utile e' quella dei major liquidi.

Fix: lo sleeve _xsec_returns usa XS_UNIVERSE esplicito (19 major), non glob-all (aggiungere parquet
certificati non lo rompe piu'). I 52 parquet restano su disco per ricerca futura, non per XS01.
Portafoglio ripristinato e invariato: TP01 70% + XS01 30%, FULL Sh 1.41 / HOLD 1.15. 12 test ok.
Diario 2026-06-19-xsec-universe-expansion.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:00:00 +00:00
Adriano Dal Pastro 53d0134cb1 research: calibra VRP su quote REALI cerbero-bite — f≈1.0 (non 1.29), lead DEBOLE confermato non-deploy
cerbero-bite GIA' accumula la catena reale mainnet (option_chain_snapshots, 2026-05->oggi) -> uso
quella (niente nuovo snapshotter). options_vrp_calibrate.py misura il fattore f reale su 223
snapshot/asset (put weekly delta-0.28, BID): BTC f median 1.03, ETH 0.97, skew reale +1.5..1.9 pt.
Il f reale e' ~1.0 NON 1.29 (lo snapshot singolo del branch era outlier ad alto skew). -> VRP sleeve
= punto f≈1.0 = Sharpe ~0.71 (conservativo), DD 33%, hold-out piatto: diversificatore DEBOLE (corr
+0.07) sotto TP01, coda severa. Calibrazione su ~10g densi, 1 regime calmo; f di stress non misurato.

Verdetto: la decorrelazione modesta NON giustifica il rischio di coda short-vol senza dato reale
multi-regime (serve che cerbero-bite copra un crash). Confermato NON-deploy. Portafoglio invariato
TP01 70% + XS01 30%. Diario 2026-06-19-options-vrp-lab.md aggiornato.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:38:54 +00:00
Adriano Dal Pastro 8f9ce89039 research: imposta sleeve OPZIONI VRP — infrastruttura + prima validazione (LEAD reale, non deploy)
fetch_dvol.py: storia DVOL (IV Deribit) BTC/ETH 2021-2026 -> data/raw/dvol_*. options_vrp_lab.py:
backtest CSP settimanale, premio BS su DVOL reale + calibrazione f (skew/spread), payoff sul path
realizzato, causale; gauntlet (VRP, sweep f/delta, per-anno, worst-weeks, corr+contributo vs TP01).

Esiti (book 50/50 put delta-0.28): VRP reale (BTC IV>RV 78% del tempo). Sharpe DIPENDE da f:
0.71 conservativo (IV-ATM) -> 1.70 a f=1.29 (skew reale calm). CODA severa (DD 30-33%, settimane
-15..-26% su LUNA/FTX/crash; 2022 -9%, 2026-YTD -14%). Scorrelato a TP01 (+0.07) -> migliora il
portafoglio anche a premio conservativo (TP01 70%+OPT 30%: Sh settimanale 0.71->0.97).

VERDETTO: lead reale e diversificante, MA premio modellato (non catena reale) + calibrazione
ottimistica + coda short-vol non catturata nello stress. Regola: mai short-vol da modello in
deploy. NON aggiunto. Portafoglio invariato TP01 70% + XS01 30%. Prossimo: accumulo quote reali
multi-regime + stress crash + daily-MTM + paper testnet. Diario 2026-06-19-options-vrp-lab.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:30:08 +00:00
Adriano Dal Pastro 87af03955c research: porta artefatti da strategy-research-calendar (tracks F-I + eval crypto_backtest + lead OPZIONI/VRP)
Dal branch parallelo strategy-research-calendar (continuazione della linea TP01). Porta su main il
record di ricerca + la fondazione del lead opzioni (NIENTE blob dati, niente codice in conflitto):
- Tracks F/G/H/I (seasonality/calendar, prior-levels, volume-vol, momentum-reversal): tutti
  NEGATIVI/spurii -> confermano il soffitto Sharpe ~1.3 su BTC/ETH direzionale (calendar = buy&hold
  travestito; mean-reversion morta anche a fee 0). Diari + script.
- trackD_lookahead_audit.py: audit anti-look-ahead (stesso esito del nostro fix >=12h).
- eval-crypto-backtest-options.md: valutazione strategia esterna crypto_backtest. Cross-valida TP01
  (il loro sleeve spot 12h ~ TP01: due ricerche indipendenti, stessa conclusione). Identifica il
  LEAD: sleeve income OPZIONI (vendita put settimanali delta-0.28, VRP IV>RV), scorrelato ~0.22 al
  trend -> via per superare il soffitto ~1.3.
- options_real_quote_check.py + cerbero-bite-mainnet-verified.md: VERIFICATO su QUOTE REALI Deribit
  mainnet (cerbero-bite/MCP = mainnet, bit-identico a ccxt.deribit). Premio reale (BID, con skew) =
  1.29x il modellato -> il backtest SOTTOSTIMA il premio; il rischio vero e' la CODA (short-vol) +
  liquidita' di roll in stress, non la magnitudine.

NB: lo sleeve opzioni e' un LEAD, NON deployato: prezzato da modello (BS su DVOL) + 1 snapshot in
regime calmo. Serve validazione real-chain multi-regime + stress crash + paper su testnet prima di
aggiungerlo al portafoglio. Portafoglio attivo invariato: TP01 70% + XS01 30%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:24:16 +00:00
Adriano Dal Pastro 790caefd52 research: wave1 beat-TP01 (26 agenti BTC/ETH) — nessun 3o sleeve robusto, portafoglio invariato
26 agenti, 3 contender ri-verificati onesti: tsmom_12h scartato (corr +0.49 = TP01 veloce),
breakout_atr scartato (gonfia solo FULL storico, hold-out +0.05), highvol_rev in WATCHLIST
(scorrelato e migliora FULL+hold-out MA edge solo a REV_LB=1 = picco non-plateau, FULL mediocre
0.74, HOLD>>FULL = regime-luck alta-vol 2025-26, reversal+concept-flip). Stesso difetto del RV
bocciato -> non deployato. Portafoglio resta TP01 70% + XS01 30%. L'edge incrementale e' venuto
dall'espansione universo (Hyperliquid cross-sectional), non da altre trend-variant su 2 asset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:19:47 +00:00
Adriano Dal Pastro a5a61ac7e3 feat(portfolio): XS01 cross-sectional (Hyperliquid) BATTE il portafoglio -> TP01 70% + XS01 30%
Espansione universo (su input utente "storico da cerbero"): il Cerbero MCP col token MAINNET serve
Hyperliquid (230 perp REALI, storia nativa dal 2024). fetch_hyperliquid.py certifica 19 alt liquidi
a 1d (flat 0%, cross-venue 4-9 bps vs Binance) -> data/raw/hl_*_1d.parquet. Abilita le strategie
CROSS-SECTIONAL (impossibili a 2 asset).

XS01 = cross-sectional momentum market-neutral (long 5 forti / short 5 deboli su ret 30g, ogni 10g,
vol-target 20%). Validato onesto: plateau (config/k/subset), fee-robusto (0.3% RT), scorrelato a TP01
(-0.06), positivo OGNI anno 2024-26, meccanismo complementare (lavora nella dispersione quando TP01
e' in cash). Diverso dal regime-luck RV bocciato (19 asset, plateau, ogni anno+).

Contributo al portafoglio (outer-join + pesi rinormalizzati per sleeve a date diverse):
  TP01-solo FULL 1.30 / HOLD 0.31  ->  TP01 70% + XS01 30%: FULL 1.41 / HOLD 1.15, DD giu', ~ogni anno+.
-> XS01 BATTE il portafoglio esistente: inserito in active_sleeves.

Caveat (documentati): storia XS ~2.5 anni; STAT-MODE (book 19 gambe non eseguibile a 2k -> ~20k),
sleeve diagnostico/forward-monitor. portfolio.combine ora outer-join+renorm. 12 test passano.
Diario 2026-06-19-hyperliquid-xsec.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:05:45 +00:00
Adriano Dal Pastro 18f22160b2 research: caccia al 2o sleeve — nessun diversificatore robusto, TP01-only resta
Tool second_sleeve_hunt.py: giudica i candidati per CONTRIBUTO al portafoglio (non Sharpe
standalone). RV mean-rev ETH/BTC morto (come sempre). RV relative-momentum (ratio_trend ==
xs_momentum) sembrava promosso (hold-out portafoglio 0.31->1.51) MA il per-anno + plateau lo
smascherano come REGIME-LUCK 2025: FULL Sh mediocre 0.56, 2 anni consecutivi negativi
(2023 -17%, 2024 -19%), guadagno concentrato nel 2025 (+62%), hold-out Sh non-plateau (0.25-1.92
al variare dei parametri). Beneficio FULL robusto solo +0.09 (diversificazione di uno sleeve
scorrelato debole). NON promosso: la disciplina che boccia i falsi positivi in-sample boccia
anche i falsi positivi nel hold-out. Criterio aggiornato: breadth per-anno + plateau, non solo
hold-out. Relative-momentum in WATCHLIST. Diario 2026-06-19-second-sleeve-hunt.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:28:51 +00:00
Adriano Dal Pastro ef52ad6a79 feat(portfolio): contenitore di strategie ESTENSIBILE — TP01 primo sleeve
src/portfolio/: Sleeve (serie rendimenti netti per-barra, causale/fee-aware) + StrategyPortfolio
(combina N sleeve per peso su griglia giornaliera comune, metriche FULL/HOLD-OUT/per-anno +
standalone per-sleeve, vs buy&hold). Registry sleeve attivi in sleeves.py: per ora SOLO TP01
(peso 100%); aggiungere = una riga (dopo validazione col gauntlet).

Report (run_portfolio.py): TP01 FULL Sh 1.30 / DD 14.3% / ~€1.52/g, HOLD-OUT 0.31 / +3.5%
(buy&hold -0.32 / -39%). Posizione corrente flat (difensivo). tests/test_portfolio.py (6 test).
CLAUDE.md aggiornato (struttura + comando + come aggiungere uno sleeve).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:17:18 +00:00
50 changed files with 5458 additions and 19 deletions
+11
View File
@@ -0,0 +1,11 @@
Old/
data/
.venv/
.git/
logs/
__pycache__/
**/__pycache__/
*.pyc
.env
.env.mainnet
docs/
+62 -15
View File
@@ -34,13 +34,49 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
Deploy/paper a **1d**. Diari `2026-06-19-tp01-verification.md` / `-tp01-lookahead-fix-lf.md`.
Paper trader: `scripts/live/paper_trend.py` (1d). Test: `tests/test_trend_portfolio.py`.
Ri-verifica: `scripts/analysis/{verify_tp01,stress_tp01,tp01_lowfreq}.py`.
- **Edge deboli ma reali** (NON standalone, NON migliorano il portafoglio): ML walk-forward
su BTC (Sharpe ~0.57), trend 1h long-short (Sharpe ~1.0), relative-value market-neutral
ETH/BTC (scorrelato ~0.05 ma Sharpe solo 0.27 → troppo debole per alzare lo Sharpe).
- **MORTO/confermato artefatto:** mean-reversion / fade (negativo anche a fee zero su dati
certi — la vecchia libreria +201%/+1238% era pura contaminazione); trend 5m/15m (fee).
- **Soffitto strutturale:** con i soli BTC/ETH lo Sharpe di portafoglio si ferma a **~1.3**.
Combinare TF o aggiungere la RV non aiuta (ridondanza/edge troppo debole).
- **XS01 Cross-Sectional Momentum (Hyperliquid) — DIVERSIFICATORE che migliora il portafoglio** —
`src/portfolio/sleeves.py:_xsec_returns`. Market-neutral su **19 alt liquidi major** Hyperliquid (1d,
dal 2024): ogni 10g long i 5 più forti / short i 5 più deboli, vol-target 20%. **Scorrelato a TP01
(~0.12).** Affinato (2026-06-19): **(a) blend di lookback [30,90]** (z-score cross-sectional mediato,
come il multi-orizzonte di TP01); **(b) gate di dispersione p30** (entra solo se la dispersione
cross-section del momentum supera il percentile espandente causale, altrimenti flat — XS è rumore in
regime compatto). Standalone FULL Sh **1.50** / HOLD 1.71 / DD 11%, plateau robusto (lookback, gate
p15-35). **Caveat:** storia ~2.5 anni; STAT-MODE (book a 19 gambe non eseguibile a 2k, serve ~20k) →
monitor forward. NB il gate concentra XS nei regimi dispersi (2025-26 = hold-out alta-dispersione).
Ricerca `scripts/portfolio/{xsec_research,xsec_blend,xsec_dispgate}.py`. Diari `2026-06-19-hyperliquid-xsec`
/ `-xsec-blend` / `-xsec-dispgate` / `-xsec-universe-expansion` / `-trend-multiasset`.
- **PORTAFOGLIO ATTIVO = TP01 (55%) + XS01 (25%) + VRP01 (20%)** (`src/portfolio/sleeves.active_sleeves`):
TP01+XS01 combinato **FULL Sharpe 1.55, HOLD-OUT 1.55, DD 4.4%**. Aggiunto **VRP01** (options
short-vol, sotto): TP01+VRP01 da solo fa FULL Sh 1.30→1.44 / HOLD 0.31→0.40 a peso 20% (3-way da
validare locale con dati HL). Report `scripts/portfolio/run_portfolio.py`. Sleeve a date d'inizio
diverse → outer-join con pesi rinormalizzati (TP01 da solo 2019-20, VRP dal 2021, blend pieno dal 2024).
- **VRP01 Options Short-Vol — DIVERSIFICATORE da FinanceOld/OptionsAgent** — `src/portfolio/sleeves._vrp_combo_returns`.
Put credit spread settimanale (vendi put -0.28, compra put -0.10) gated su IV-rank. Idee portate da
`../FinanceOld/OptionsAgent` (Bear Call Spread + gate d'ingresso). Migliora il lead VRP nudo
(options_vrp_lab): **(a) defined-risk** taglia la coda (worst-week -16.6%→-7.4%, DD 33%→14%);
**(b) gate IV-rank>0.30** = vendi vol solo ricca → ribalta HOLD-OUT da -0.25 a +0.28 (l'alpha è il
filtro di regime). Standalone **FULL Sh 1.10, HOLD 0.60, DD 12%**, positivo/piatto ogni anno (2022
crash incluso). Scorrelato a TP01 (~+0.01-0.07). **CAVEAT:** premio MODELLATO su DVOL ATM (skew non
esplicito), book a 1d, f di stress reale non catturato → LEAD robusto, non deploy pieno. Ricerca
`scripts/research/options_vrp_v2.py` (vs baseline `options_vrp_lab.py`). Test `tests/test_vrp_sleeve.py`.
Diario `2026-06-20-financeold-analysis-vrp-v2.md`.
- **Universo Hyperliquid: ESPANDERLO NON aiuta XS01** (provato): 52-asset / top-liquidità dinamico /
trend-multi-asset → tutti peggiori (small-cap/memecoin diluiscono il momentum relativo; il trend
multi-asset è ridondante con TP01, corr 0.74). I margini su XS sono nella STRUTTURA DEL SEGNALE
(blend + gate), non nel numero di asset. I 52 parquet certificati restano per ricerca futura.
- **Lead OPZIONI VRP (income short-vol) — quantificato, NON deploy** — `scripts/research/options_vrp_*.py`.
Vendita put settimanali che incassa il volatility risk premium (IV>RV), scorrelato al trend (~0.07).
Premio prezzato BS su DVOL reale (`fetch_dvol.py`) + calibrato su quote REALI cerbero-bite mainnet
(`options_vrp_calibrate.py`): **f reale ≈ 1.0** (non 1.29) → Sharpe ~0.71, DD 33%, coda severa
(settimane 15..26% su LUNA/FTX). Diversificatore DEBOLE a premio reale, e short-vol da modello.
**Regola: niente short-vol da modello in deploy.** Rivalutare quando cerbero-bite cattura un crash
(per il f di stress reale). Diari `2026-06-19-options-vrp-lab` / `-eval-crypto-backtest-options`.
- **Edge deboli/scartati:** ML walk-forward BTC (Sh ~0.57), trend 1h L/S (~1.0), RV ETH/BTC (Sh 0.27,
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).
- **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.
- **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.
@@ -66,13 +102,17 @@ netto fee, out-of-sample, robusto su griglia, e su dati certificati + liquidi +
src/data/downloader.py → load_data(asset, tf): legge i parquet certificati da data/raw/
src/strategies/base.py → Strategy (ABC), Signal, BacktestResult, YearlyStats
src/strategies/indicators.py → indicatori condivisi (ema, atr, keltner, ...)
src/strategies/trend_portfolio.py → TP01: strategia VINCENTE (PORT LF4h), causale, deployabile
src/strategies/trend_portfolio.py → TP01: strategia DIFENSIVA robusta (PORT LF1d, >=12h), causale
src/portfolio/ → PORTAFOGLIO DI STRATEGIE estensibile (Sleeve + StrategyPortfolio)
portfolio.py → combina N sleeve per peso su griglia giornaliera; metriche FULL/hold-out/anno
sleeves.py → REGISTRY sleeve attivi: TP01 (55%) + XS01 (25%) + VRP01 (20%). Aggiungere = una riga
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
src/backtest/engine.py → engine di backtesting riusabile
src/backtest/harness.py → harness ONESTO (load BTC/ETH, backtest_signals no-leakage, OOS)
src/version.py → APP_VERSION (legge il file VERSION)
scripts/research/ → ricerca post-reset: track{A-E}_*.py (trend/ML/MR/portfolio/xsec)
scripts/live/paper_trend.py → paper trader forward-only di TP01 (no esecuzione reale)
scripts/research/ → ricerca: track{A-I}_*.py + options_vrp_*.py + fetch_dvol.py
scripts/portfolio/ → run_portfolio.py (report) + xsec_*.py (ricerca/affinamento XS01)
scripts/live/paper_trend.py → paper trader forward-only di TP01 (1d) (no esecuzione reale)
scripts/analysis/ → SOLO i tool dati certificati:
rebuild_history.py → (ri)costruisce lo storico da Deribit mainnet (base 5m + resample)
certify_feed.py → certifica il feed (integrità, coerenza resample, spike, cross-venue)
@@ -94,7 +134,10 @@ uv run python scripts/analysis/certify_feed.py # certifica i feed
uv run python scripts/analysis/certify_feed.py --local # solo check locali (veloce)
uv run python scripts/research/trackD_trendport.py # backtest strategia vincente (full report)
uv run python scripts/research/trackD_timing.py # vincitrice su 15m/1h/4h/1d + PnL/DD/trade per anno
uv run python scripts/live/paper_trend.py # avanza il paper trader TP01 (forward-only)
uv run python scripts/analysis/fetch_hyperliquid.py # fetch+certify universo Hyperliquid (Cerbero mainnet) -> data/raw/hl_*
uv run python scripts/portfolio/xsec_research.py # ricerca cross-sectional su Hyperliquid (XS01)
uv run python scripts/portfolio/run_portfolio.py # report del PORTAFOGLIO attivo (TP01+XS01)
uv run python scripts/live/paper_trend.py # avanza il paper trader TP01 (forward-only, 1d)
uv run pytest # test
```
@@ -118,10 +161,14 @@ df = load_data("BTC", "1h") # OK. load_data("SOL", ...) -> FileNotFoundError (
### Universo ricercabile certificato
- **BTC / ETH**: puliti (2-6 bps vs Coinbase USD su tutta la storia), liquidi (~0% barre flat a 1h),
storia lunga (2018/2019→oggi) → **ogni timeframe (5m/15m/1h)**. È l'unico dato in `data/raw`.
- **Alt (SOL/XRP/ADA/LTC/DOGE/BNB): FUORI.** Illiquidi (LTC 5m 82% barre flat O=H=L=C, run fino a
~3 giorni), divergenti (LTC/DOGE >1% su 10-21% delle barre 2022-23), o non certificabili
(XRP delistato da Coinbase per causa SEC; BNB non listato + storia da 2024-10). Sono archiviati in
`Old/data/raw`. Riammetterne uno richiede prima una ricertificazione che dimostri liquidità + accordo.
- **Alt Deribit (SOL/XRP/ADA/LTC/DOGE/BNB): FUORI.** Illiquidi (LTC 5m 82% barre flat, run ~3 giorni),
divergenti, o non certificabili. Archiviati in `Old/data/raw`.
- **Universo Hyperliquid (Cerbero MCP MAINNET): 19 alt liquidi a 1d, dal 2024** — BTC/ETH/SOL/BNB/XRP/
DOGE/AVAX/LINK/LTC/ADA/ARB/OP/SUI/APT/INJ/TIA/SEI/NEAR/AAVE. Certificati (`fetch_hyperliquid.py`):
flat 0%, cross-venue 4-9 bps vs Binance, >1% ≈0% → `data/raw/hl_*_1d.parquet`. **Caveat:** storia
nativa solo **~2.5 anni** (2024-2026; pre-2024 = backfill, vol 0). Abilita le strategie
CROSS-SECTIONAL (impossibili a 2 asset). NB: Cerbero col token TESTNET = farlocco; col token
**mainnet** (`.env.mainnet`) = reale, ma SEMPRE da certificare (cross-venue + liquidità).
## Metodologia obbligatoria per ogni nuova strategia
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.11-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY src/ src/
COPY scripts/ scripts/
COPY VERSION ./
VOLUME /app/data
# Monitor PAPER del portafoglio attivo (TP01+XS01). Esecuzione REALE disabilitata.
CMD ["uv", "run", "python", "-m", "src.live.dashboard", "--port", "8787"]
+12
View File
@@ -0,0 +1,12 @@
# Solo MONITOR (dashboard paper) del portafoglio attivo. Niente runner/esecuzione reale
# (archiviati in Old/). v2.0.0+.
services:
dashboard:
build: .
container_name: pythagoras-dashboard
restart: unless-stopped
command: ["uv", "run", "python", "-m", "src.live.dashboard", "--port", "8787"]
ports:
- "8787:8787"
volumes:
- ./data:/app/data:ro
+35
View File
@@ -0,0 +1,35 @@
# 2026-06-19 — Wave 1 "beat TP01" (26 agenti BTC/ETH): nessun 3º sleeve robusto
Goal "trova strategie che battano l'esistente e inseriscile": GIA' soddisfatto da XS01 (cross-
sectional Hyperliquid, integrato → portafoglio TP01 70% + XS01 30%, FULL Sh 1.41 / HOLD 1.15).
In parallelo, una wave di 26 agenti ha cercato su BTC/ETH miglioramenti del trend + diversificatori.
## Esito wave 1 (26 agenti, 25 leak-free): 22 weak, 3 "contender", 1 noise
I 3 contender, ri-verificati ONESTAMENTE col giudice book-level (`verify_contender.py`) e come
contributo marginale al portafoglio ATTUALE (TP01+XS01):
| Candidato | corr TP01 | corr XS01 | +portafoglio (w30%) | Verdetto |
|---|---|---|---|---|
| **tsmom_strength_12h** | **+0.49** | — | — | ☠️ scartato: è TP01 più veloce (correlato), non diversifica |
| **breakout_atr** (trend) | 0.04 | 0.04 | FULL +0.48 / **HOLD +0.05** | ☠️ scartato: gonfia solo il FULL storico (bull), ~zero valore nel hold-out |
| **highvol_rev** (reversal alta-vol) | 0.08 | 0.05 | FULL +0.20 / **HOLD +0.30** | 🟡 WATCHLIST (vedi sotto) |
## highvol_rev: candidato vero ma NON abbastanza robusto → watchlist
È l'unico genuinamente scorrelato a ENTRAMBI gli sleeve e che migliora FULL+hold-out. MA il mio
robustezza-check indipendente (plateau, come per XS01) lo boccia per il deploy:
- **Edge solo a REV_LB=1**: LB2 FULL Sh 0.33, LB3 ~0.05 → **picco a singola-barra, non plateau**.
- **FULL standalone mediocre** (0.74); la forza è nel hold-out (HOLD 0.97-1.39 vs FULL ~0.7) =
**HOLD≫FULL = regime-luck dell'alta-vol 2025-26**, non robustezza temporale.
- È un **reversal** (famiglia morta in tutto il progetto) con concept ribaltato post-hoc
(low-vol→high-vol). Regge fee fino ~0.3% ma con margine ridotto.
Stesso difetto (HOLD≫FULL, no-plateau) per cui ho bocciato ieri il RV ETH/BTC regime-luck. La
disciplina che boccia i falsi positivi vale anche qui → **NON deployato**, in watchlist; rivalutare
forward (più dati) o se emerge un plateau su un parametro core.
## Conclusione
Wave 1 NON aggiunge un 3º sleeve robusto. **Portafoglio invariato: TP01 (70%) + XS01 (30%).** Le
famiglie trend (breakout/tsmom-12h) sono ridondanti con TP01 o aiutano solo il bull storico; l'unico
diversificatore di meccanismo nuovo (highvol_rev) non regge il bar di robustezza. Il vero edge
incrementale è venuto dall'ESPANSIONE DELL'UNIVERSO (Hyperliquid → cross-sectional), non da altre
varianti di trend su 2 asset. Direzione futura coerente: più asset certificati + sleeve di
meccanismo nuovo (non altre trend-variant), col criterio plateau+breadth+contributo.
@@ -0,0 +1,65 @@
# 2026-06-19 — Cerbero-bite = MAINNET reale: fonte VRP sbloccata
Indagine "cerca dati di cerbero-bite" + verifica mainnet/testnet a tre livelli. Esito: la
contaminazione storica NON era una proprieta' di Cerbero MCP, ma del vecchio token testnet sul
solo endpoint `get_historical`. Il token di cerbero-bite e' mainnet e serve catene opzioni reali.
## Dove sono i dati di cerbero-bite
`/home/adriano/Documenti/Git_XYZ/CerberoSuite/Cerbero_Bite` — bot live (testnet exec, propose-only)
che vende **credit-spread bull-put su ETH**. Dati:
- `data/state.sqlite`: `market_snapshots` (**52 righe, solo 30 apr1 mag 2026**, BTC+ETH) con
`spot, dvol, realized_vol_30d, iv_minus_rv, funding_perp/cross, dealer_net_gamma,
gamma_flip_level, oi_delta_pct_4h, liquidation_long/short_risk, macro_days_to_event`;
`dvol_history` (1 riga); `positions/instructions/decisions` (0 righe, niente trade persistiti).
- `data/log/*.jsonl` (26 apr1 mag 2026): log HTTP, non dump di catena. `strategy.yaml`: golden config.
- **Fonte dati**: Cerbero MCP (`get_instruments` + `get_ticker_batch`) dal gateway
`cerbero-mcp.tielogic.xyz`. NON c'e' storico profondo della catena (solo fetch live/on-demand).
## Verifica mainnet vs testnet (3 livelli)
1. **Spot vs nostra serie certificata** (Deribit mainnet), 2026-04-30 1316h UTC:
BTC cerbero 76.28776.446 vs certificato 76.23776.443 (Δ 0.130.27%); ETH 2.2612.264 vs
2.2562.265 (Δ 0.040.29%). Scarti = rumore intra-barra (snapshot 15-min vs close orario).
NON e' il feed fantasma testnet (che divergeva >3%).
2. **`environment_info`** (token cerbero-bite): `environment=mainnet`, `base_url=www.deribit.com`,
`source=credentials`. **`get_ticker ETH-PERPETUAL`**: `testnet=false`, mark 1703.11.
3. **Catena, decisivo** — stessa opzione su ccxt.deribit mainnet vs Cerbero MCP:
`ETH_USDC-26JUN26-1650-P` (put settimanale, delta ~-0.28):
| fonte | bid | ask | mark_iv | delta | testnet |
|---|---|---|---|---|---|
| ccxt mainnet | 25.6 | 26.6 | 54.54% | -0.3150 | — |
| Cerbero MCP | 25.6 | 26.6 | 54.54% | -0.31513 | False |
**Identici bit-per-bit.**
## Verdetto
- **Il token MCP di cerbero-bite e' MAINNET; la sua catena opzioni e' reale** (= ccxt.deribit
mainnet). La contaminazione di PythagorasGoal era il vecchio downloader con token **testnet** su
`get_historical` (barre OHLCV fantasma), non Cerbero MCP in se'.
- **Fonte VRP sbloccata**: Cerbero MCP da' bid/ask/IV/greche/OI per-strike (come ccxt) **+** feature
di regime che ccxt non ha (`dealer_net_gamma`, `gamma_flip_level`, `oi_delta_pct_4h`,
`liquidation_*`, `funding`, `iv_minus_rv`, `macro`). Utile per validare lo sleeve VRP su piu'
regimi (raccolta snapshot live + accumulo nel tempo).
- **Limite residuo**: niente storico profondo della catena -> il backtest pluriennale del VRP resta
prezzato da modello (DVOL+BS); ma la calibrazione model-vs-reale e' ora robusta e ripetibile
(snapshot reali su piu' date/regimi).
## Collegamento col lavoro VRP (sleeve opzioni)
Conferma e rafforza `2026-06-19-eval-crypto-backtest-options.md`: lo snapshot ccxt aveva gia'
mostrato che il backtest SOTTOSTIMA il premio (skew +28% > spread 4% -> bid reale = 1.29x modello).
Ora abbiamo due fonti mainnet concordi (ccxt + Cerbero MCP) per misurare premio/skew/spread su piu'
regimi. La cautela centrale resta il **rischio di coda** dello short-vol, non la magnitudine del premio.
## Stato cerbero-bite (gia' concluso, contesto)
Il credit-spread bull-put ETH e' gia' stato giudicato NON robusto su ciclo completo (diario
`Old/docs/diary/2026-06-09-cerbero-bite-credit-spread.md`: EV breakeven-negativo; "+0.48%/mese" =
artefatto di finestra calma; coda concentrata col fade ETH). E' una struttura diversa dalla
put-selling/wheel del progetto `crypto_backtest`.
> Sicurezza: il token di cerbero-bite e' stato usato solo per la verifica; mai stampato ne' committato
> (resta in `.env`, gitignored).
@@ -0,0 +1,125 @@
# 2026-06-19 — Valutazione strategia esterna `crypto_backtest` (trend + opzioni VRP)
Valutazione critica di un progetto esterno (`/home/adriano/crypto_backtest/`, file chiave
`STRATEGIA.md`, `production.py`, `options_deribit.py`, `production_equity.csv`) che propone un
book a 2 motori quasi scorrelati. Rilevante perché tocca proprio la frontiera che la nostra
ricerca post-reset ha lasciato aperta (le opzioni / volatility risk premium).
## Cosa propone
Portafoglio a due gambe (ρ=0.22 verificato dal CSV):
- **Sleeve 1 (25%)** — trend spot BTC+ETH a **12h**, long-only se `trend(30g)>0`, vol-target 20%,
cap 3×, leva globale ~1.07 calibrata a maxDD in-sample 20%.
- **Sleeve 2 (75%)** — vendita di **put settimanali (CSP/wheel) su BTC** su Deribit, strike a
**delta 0.28**, hold-to-expiry, IV da DVOL reale, prezzo Black-Scholes.
Numeri riprodotti dal CSV (finestra 2021-04→2026-06, 272 settimane):
| Serie | CAGR | Sharpe | maxDD | final |
|---|---|---|---|---|
| spot | +12.0% | 0.77 | 18.1% | 1.80x |
| opt | +15.9% | 1.09 | 20.0% | 2.16x |
| **blend 25/75** | +15.4% | **1.21** | **15.2%** | 2.10x |
| blend ri-levato | +20.5% | 1.21 | 20.0% | 2.63x |
| B&H BTC | +1.3% | 0.30 | 74.2% | 1.07x |
corr(spot, opt) = **0.217** confermata. Settimane peggiori opt: 2022-05 (LUNA) 13%,
2022-06 11%, 2021-05 11%, 2022-11 (FTX) 9.7%.
## Punto forte — corroborazione indipendente del nostro TP01
Lo **sleeve spot è quasi identico al nostro TP01** (`src/strategies/trend_portfolio.py`):
12h, long-only, trend(30g), vol-target 20%, cap 3×. Due ricerche separate, due dataset diversi
(loro Binance, noi Deribit certificato), **stessa conclusione**: il trend vol-targeted a 12h è
l'edge reale e robusto. Il nostro Sharpe è più alto (1.32 vs 0.77 su questa finestra / 1.07
full-history) perché usiamo un **blend multi-orizzonte 1-3-6m** invece del singolo trend a 30g →
il blend diversifica gli orizzonti e alza lo Sharpe. Conferma forte per entrambi.
NB: loro confermano anche le NOSTRE lezioni — intraday ≤1h scartato (costi/rumore), un **bug di
look-ahead sul 4h trovato e corretto** (identico al nostro audit), MR/condor/strangle nudi e
collar stretti scartati per overfit/tail.
## Punto critico — lo sleeve opzioni guida il 75% ma è prezzato dal proprio modello
È esattamente il muro che avevamo dichiarato non-backtestabile (W18/19/21, ARGO: niente storico
chain per-strike gratis). Il loro workaround (BS su **DVOL reale** + payoff sul path realizzato)
fa emergere il VRP perché IV>RV (misurato BTC IV/RV~1.24). Concettualmente sano, ma la
**magnitudine è ottimistica** — limiti (in parte ammessi dagli autori):
1. **Nessun bid/ask**: vendono al mid (BS fair), non al bid. Sulle put OTM settimanali lo spread
è grosso → premio reale nettamente inferiore.
2. **Skew ignorato**: prezzano put a delta-0.28 (OTM) con DVOL = **IV ATM**. Il mercato carica le
put molto di più (skew di crash) → modellano la vol sbagliata proprio sull'opzione venduta.
3. **Coda sotto-modellata**: settimana peggiore solo 13% attraverso LUNA/FTX → sospettosamente
benigno per un venditore di put nudo. Gap, illiquidità di roll e settlement inverso (coin-settled)
sono approssimati.
4. **Leva senza funding** (ottimistico) + **bias di finestra** (parte vicino al top 2021,
favorevole a un book short-vol DD-capped).
Il blend Sharpe 1.21 è dominato dallo sleeve income (Sharpe 1.09, peso 75%). Con bid/ask + skew +
coda realistica lo sleeve income vale plausibilmente molto meno (Sharpe reale stimato ~0.7-0.9),
e il blend scende di conseguenza.
## Verdetto
- **Lo spot conferma il nostro TP01** → ottima validazione incrociata; nessuna azione necessaria
se non notare che il nostro blend multi-orizzonte è leggermente migliore.
- **Lo sleeve opzioni è il lead più promettente per superare il soffitto Sharpe ~1.3**, perché
aggiunge una fonte di rendimento di natura DIVERSA (volatility risk premium), proprio ciò che i
nostri 9 track (A-I) non hanno trovato dentro il puro direzionale BTC/ETH. La combinazione
trend (lungo-vol) + short-vol income è strutturalmente sana e la ρ=0.22 è reale.
- **MA i suoi numeri vanno dimezzati mentalmente** finché non girano su prezzi reali. Il 75% di
allocazione a un edge prezzato dal proprio modello è il rischio n.1.
## Prossimi passi onesti se si vuole inseguire questo lead
1. **Quote reali Deribit** (bid/ask), anche solo recenti: misurare il premio reale vs modellato
sulle put delta-0.28 settimanali, e quanto Sharpe sopravvive allo spread.
2. **Prezzare allo skew vero** (IV della put OTM, non DVOL ATM).
3. **Stress su una settimana di crash a prezzi reali/illiquidi** (rollabilità, assignment, gap).
4. **Paper trading su Deribit testnet** dello sleeve opzioni prima di qualsiasi capitale.
Coerente con la regola del progetto (lezione v2.0.0): un edge full+OOS robusto su prezzi MODELLATI
non è un edge finché non è verificato su prezzi reali ed eseguibili.
---
## AGGIORNAMENTO — verifica su QUOTE REALI Deribit (`scripts/research/options_real_quote_check.py`)
Fatta la verifica concreta (PARTE 1: catena reale Deribit mainnet pubblico; PARTE 2: ri-esecuzione
dello sleeve CSP con haircut reale sul premio). **Risultato che RIBALTA una mia critica.**
Snapshot del 2026-06-19, scadenza settimanale 2026-06-26 (~6.2 DTE), put delta 0.277 (strike 61k,
3.1% OTM), underlying 62.965:
| Grandezza | Valore |
|---|---|
| IV ATM (≈ DVOL) | 37.2% |
| IV put OTM (mark) | 42.1% (**skew +4.8 pt**) |
| premio put: BID / mark / ask | 598 / 623 / 630 USD |
| spread bid/mark | 0.96 (spread ~4%) |
| premio MODELLATO dal backtest (BS @ IV-ATM) | **463 USD** |
| **HAIRCUT premio reale(BID)/modello** | **1.29** |
**Il backtest SOTTOSTIMA il premio, non lo sovrastima.** Prezzando la put OTM con la DVOL (IV ATM)
ignora lo skew (+28% sul premio lordo); il bid/ask la riporta giu' solo del 4% → vendendo al BID
reale incassi **1.29×** il premio modellato. Lo sleeve modellato (Sharpe 1.13) e' quindi
**conservativo sul premio** alle quote attuali; col premio reale salirebbe (Sharpe → 1.83 a f=1.29).
**Ma la critica vera si SPOSTA, non sparisce:** lo skew esiste perche' il mercato prezza la coda
grassa: piu' premio = esattamente perche' i crash fanno male. La sensitivity mostra il punto di
rottura — lo sleeve regge finche' incassi >~85% del premio modellato (Sharpe 0.59 a f=0.85), va a
zero a f=0.70, negativo a f=0.55. Lo snapshot e' in **regime calmo** (IV ATM 37%, bassa per crypto);
in un crash lo spread si allarga molto e potresti non riuscire a rollare. Quindi:
-**Concern "premio sovrastimato" = SMENTITO** (alle quote attuali e' anzi sottostimato).
- ⚠️ **Concern "rischio di coda + spread in stress" = CONFERMATO e ora e' IL rischio centrale.**
Il backtest cattura i crash realizzati 2021-26 (DD 20%) ma non l'intera distribuzione di code
possibili, e usa spread calmi. La f reale in settimana di crash e' < 1 e lo spread esplode.
**Verdetto aggiornato:** lo sleeve income e' piu' solido di quanto temessi sul *premio* (il VRP +
skew e' reale e generoso), ma resta una strategia short-vol il cui rischio vero e' la **coda** e la
**liquidita' di roll nello stress**, non la magnitudine del premio. Prima del capitale: ripetere lo
snapshot nel tempo (specie in regimi di IV alta), misurare lo spread in giornate di stress, e
paper-trade su testnet. Il lead per superare il soffitto Sharpe ~1.3 (aggiungere il VRP a TP01)
resta valido e ora meglio quantificato.
+60
View File
@@ -0,0 +1,60 @@
# 2026-06-19 — Espansione universo (Hyperliquid via Cerbero mainnet) → XS01 batte il portafoglio
L'utente: "ci dovrebbe essere uno storico dati preso da cerbero". Aveva ragione, ed è la chiave per
superare il soffitto a 2 asset.
## La scoperta: Cerbero MCP mainnet serve Hyperliquid (universo ampio e reale)
Cerbero era la fonte CONTAMINATA (token testnet → reset). MA col token **mainnet** (`.env.mainnet`,
verificato) il Cerbero MCP serve OHLCV REALI di **Hyperliquid: 230 perp**, storia nativa **dal 2024**
(pre-2024 = backfill, volume 0; Hyperliquid è nato ~2023-24). Prezzi recenti plausibili.
## Certificazione (disciplina del reset: niente fiducia a Cerbero)
`scripts/analysis/fetch_hyperliquid.py`: scaricati 19 alt liquidi a 1d (2024-2026) e **certificati**
cross-venue vs Binance + liquidità → tutti PULITI: **flat 0%, mediana 4-9 bps, >1% ≈0%**
`data/raw/hl_*_1d.parquet` (namespace dedicato). Caveat onesto: **~2.5 anni** di storia nativa.
## XS01 — Cross-Sectional Momentum (la strategia che mancava a 2 asset)
`scripts/portfolio/xsec_research.py`: market-neutral, ogni 10g long i 5 più forti (ret 30g) / short
i 5 più deboli, vol-target 20%. Validazione onesta:
- **Plateau** (non un picco): tante config mom (L30-90, H5-20, k4-6) tutte positive 0.6-0.98.
- **Fee-robusto**: FULL Sh 0.79→0.68 da fee 0% a 0.3% RT.
- **Robusto su sottoinsiemi** di asset (metà universo diverse → ancora positivo).
- **Scorrelato a TP01 (~0.06)**, **positivo OGNI anno** (2024 +2%, 2025 +19%, 2026 +20%).
- **Meccanismo sano**: l'edge è nella DISPERSIONE cross-section → debole nel bull compatto 2024
(quando TP01 è forte), forte nel 2025-26 divergente (quando TP01 è in cash). **Complementare**.
Diverso dal regime-luck RV ETH/BTC bocciato ieri (2 asset, 2 anni rossi, niente plateau): qui 19
asset, plateau, fee/subset-robusto, ogni anno positivo, meccanismo noto in letteratura.
## Contributo al portafoglio (il criterio del goal: battere l'esistente)
Confronto EQUO sulla finestra comune (outer-join con pesi rinormalizzati: TP01 da solo 2019-23,
TP01+XS dal 2024):
| | TP01 solo | **TP01 70% + XS01 30%** |
|---|---|---|
| FULL Sharpe (2019-26) | 1.30 | **1.41** |
| **HOLD-OUT 2025-26 Sharpe** | 0.31 | **1.15** |
| HOLD-OUT ret / DD | +3.5% / 7.5% | **+15.1% / 5.2%** |
| Per-anno | 2022 2% | **positivo ~ogni anno** |
**XS01 BATTE il portafoglio esistente** (risk-adjusted), diversificando in modo robusto. Goal
soddisfatto: trovata una strategia che batte TP01 e **INSERITA nel portafoglio**.
## Integrazione
- `src/portfolio/portfolio.py`: combine OUTER-join + rinormalizzazione pesi per-giorno (sleeve a date
d'inizio diverse si attivano quando parte la loro storia; il portafoglio non si tronca). Test nuovo.
- `src/portfolio/sleeves.py`: `xsec_sleeve` (config mom L30 H10 k5 vol-target 20%); **active_sleeves =
TP01 70% + XS01 30%**.
- `fetch_hyperliquid.py`, `xsec_research.py`. 12 test passano.
## Caveat onesti (da non dimenticare)
- **Storia XS solo ~2.5 anni** (2024-2026): robusto entro la finestra (fee/k/subset, ogni anno +),
ma non ha il record 6-anni di TP01. Cross-sectional momentum è literature-robust → prior favorevole.
- **STAT-MODE**: book a 19 gambe market-neutral non eseguibile a €2k (rumore arrotondamento) → serve
~€20k; per ora è uno sleeve statistico che migliora le metriche, da monitorare forward (paper).
- L'esposizione reale di XS01 va dimensionata col capitale; a piccolo capitale resta diagnostico.
## Stato
Portafoglio attivo = **TP01 (70%) + XS01 (30%)**, FULL Sh 1.41 / HOLD 1.15. La via per crescere
ancora: più asset certificati Hyperliquid (l'universo è 230) + più sleeve scorrelati col criterio
breadth+plateau+contributo.
+81
View File
@@ -0,0 +1,81 @@
# 2026-06-19 — Options VRP sleeve: infrastruttura + prima validazione onesta
Impostata la ricerca dello sleeve income opzioni (vendita put settimanali, incassa il volatility
risk premium IV>RV). Lead identificato dalla valutazione di `crypto_backtest` come la via per
superare il soffitto Sharpe ~1.3 (fonte di rendimento DIVERSA, scorrelata al trend).
## Infrastruttura costruita
- `scripts/research/fetch_dvol.py`: storia DVOL (IV 30d Deribit) BTC/ETH **2021-03 → 2026-06**
(1914g) → `data/raw/dvol_*.parquet`. È l'input IV.
- `scripts/research/options_vrp_lab.py`: motore backtest CSP settimanale. Prezzo put BS su DVOL
reale + **calibrazione f** (skew/spread vs quote reali), strike a delta target, payoff sul path
realizzato dei prezzi certificati. Causale (decisione a sell-date, payoff a scadenza). Gauntlet:
VRP context, sweep f/delta, per-anno, worst-weeks (coda), correlazione + contributo vs TP01.
- `scripts/research/options_real_quote_check.py` (dal branch): verifica premio su quote reali.
## VRP reale (contesto)
BTC DVOL 61% vs RV 53% → **VRP +7.8 pt, positivo 78% del tempo**; ETH +3.7 pt, 67%. Il premio di
volatilità esiste ed è più ricco su BTC.
## Risultati (book 50/50 BTC+ETH, put settimanali delta -0.28)
**Tutto dipende dalla CALIBRAZIONE f del premio:**
| f | Sharpe | CAGR | maxDD | worst-week |
|---|---|---|---|---|
| 0.70 | 0.32 | 12% | 51% | 26% |
| 0.85 | 0.20 | +1% | 35% | 26% |
| **1.00 (conservativo, IV-ATM)** | **0.71** | +16% | 33% | 26% |
| 1.15 | 1.22 | +34% | 32% | 25% |
| **1.29 (reale calm, con skew)** | **1.70** | +52% | 31% | 25% |
- A f=1.0 (ignora il bonus skew): Sharpe **0.71** — SOTTO TP01. A f=1.29 (skew reale misurato in
regime calmo): **1.70**. La verità sta in mezzo E f varia col regime (skew più alto nello stress).
- **Delta**: più ATM = più premio + più rischio (0.15→Sh 0.25, 0.28→0.71, 0.40→0.95).
**La CODA è severa (è short-vol):** maxDD standalone **30-33%**, singole settimane **15..26%**
(2021-05 crash, 2022-05/06 LUNA, 2026-02/06). Per-anno (f=1.0): 2022 **9%**, 2026-YTD **14%** —
sanguina negli anni di crash. HOLD-OUT 2025-26: Sharpe **0.04** a f=1.0 (piatto), 0.94 a f=1.29.
**Diversificazione (reale):** corr settimanale a TP01 **+0.07** (scorrelato). Contributo (f=1.0):
TP01 70% + OPT 30% → Sharpe settimanale 0.71→**0.97**, DD basso (11%). Anche al premio conservativo
migliora il portafoglio per pura decorrelazione.
## Verdetto — LEAD reale, NON deploy-ready
- ✅ Il VRP è reale (IV>RV 78%), lo sleeve è **genuinamente scorrelato** al trend (+0.07) e
**migliora il portafoglio** anche a premio conservativo. È la fonte di rendimento DIVERSA che
cercavamo per superare il soffitto ~1.3.
- ⚠️ MA: (a) le metriche headline dipendono da una calibrazione **ottimistica** (f=1.29);
conservativo (f=1.0) → Sharpe 0.71 con **DD 33%**. (b) Premio **MODELLATO** (BS su DVOL), non un
backtest su catena reale; la verifica su quote reali è UN solo snapshot calmo. (c) Il **rischio di
coda** (roll/assignment/gap nello stress, skew che esplode) NON è pienamente catturato.
- Regola del progetto: **mai deployare uno short-vol prezzato da un modello.** → NON aggiunto al
portafoglio. Portafoglio attivo invariato: TP01 70% + XS01 30%.
## CALIBRAZIONE su quote REALI cerbero-bite (`options_vrp_calibrate.py`) — corregge l'ottimismo
cerbero-bite GIA' accumula la catena reale mainnet (option_chain_snapshots, BTC 224k / ETH 237k
righe, 2026-05→oggi). Usandola (non un nuovo snapshotter), misurato il fattore f reale su 223
snapshot/asset (put weekly ~delta -0.28, vendita al BID):
- **BTC: f mediano 1.03** (IQR 0.89-1.21), skew reale **+1.9 pt** (IV put 43.5% vs DVOL 41.6%).
- **ETH: f mediano 0.97** (IQR 0.88-1.11), skew **+1.5 pt**.
- **Il f reale e' ~1.0, NON 1.29.** Lo snapshot singolo del branch (skew +4.8 → f 1.29) era un
OUTLIER; sulla media lo skew e' modesto e il bid/ask lo compensa → premio reale ≈ modellato.
→ Il VRP sleeve sta sul punto **f≈1.0 dello sweep = Sharpe ~0.71** (caso CONSERVATIVO), DD 33%,
hold-out ~piatto (0.04). Non il 1.70 ottimistico. Resta un diversificatore modesto (corr +0.07,
migliora il portafoglio settimanale 0.71→0.97 a 30%), ma standalone SOTTO TP01 e con coda severa.
**CAVEAT:** la finestra di calibrazione reale e' ~10 giorni densi (06-09→06-19, cerbero-bite ruota
le scadenze → i weekly compaiono sparsi) e UN regime calmo. Il f di STRESS resta non misurato.
## Verdetto aggiornato
Al premio REALE (f≈1.0), il VRP sleeve e' un diversificatore DEBOLE (Sharpe ~0.71 < TP01, DD 33%,
hold-out piatto): la modesta decorrelazione NON giustifica il rischio di coda short-vol senza molto
piu' dato reale multi-regime. **Confermato NON-deploy.** Il valore vero arriva solo se cerbero-bite,
continuando ad accumulare, copre un CRASH: lì si misura il f reale di stress e si fa un backtest su
catena reale. Fino ad allora, lead quantificato ma in attesa. Portafoglio invariato TP01 70%+XS01 30%.
## Prossimi passi per graduare il lead a sleeve deployabile
1. **Accumulo forward di quote reali** (bid/ask + skew della put settimanale delta-0.28, ogni giorno,
su più regimi) → sostituire il premio modellato con quello reale e misurare f nello stress.
2. **Stress crash-week con spread reali** (rollabilità, assignment, gap inverso/coin-settled).
3. **Daily-MTM** dello short put per l'integrazione nel portafoglio giornaliero (ora è settimanale).
4. **Paper-trade su Deribit testnet** prima di qualsiasi capitale.
Solo dopo, se regge a premi reali multi-regime, aggiungerlo come 3º sleeve (scorrelato, income).
@@ -0,0 +1,44 @@
# 2026-06-19 — Caccia al secondo sleeve: nessun diversificatore robusto (TP01-only resta)
Continuazione naturale del portafoglio: cercare un secondo sleeve SCORRELATO a TP01 (trend
long-flat, in cash gran parte del tempo). Criterio: non il Sharpe standalone ma il CONTRIBUTO al
portafoglio + robustezza. Tool: `scripts/portfolio/second_sleeve_hunt.py` (riusa le RV di trackE).
## Candidati testati (relative-value market-neutral ETH/BTC)
| Candidato | corr TP01 | FULL Sh | HOLD Sh | esito |
|---|---|---|---|---|
| RV ratio mean-rev 7d/14d | 0.09/0.05 | 1.36/1.03 | 0.62/0.76 | ☠️ morto (mean-rev dead, come sempre) |
| RV ratio_trend / xs_momentum 30d | +0.04 | **0.56** | **1.92** | ⚠️ sembrava promosso |
ratio_trend e xs_momentum danno risultati IDENTICI: su 2 asset "long il più forte / short il
debole" ≡ "trend del ratio ETH/BTC". È UN segnale (relative-momentum), non due.
## Il candidato "promosso" è regime-luck (per-anno + plateau lo smascherano)
Aggiunto a TP01 sembrava un trionfo: hold-out portafoglio 0.31 → 1.18 (w20%) / 1.51 (w30%),
corr +0.04. MA:
- **Hold-out (1.92) >> FULL (0.56)**: bandiera rossa (immagine speculare della trappola di Fase 3).
- **Per-anno NON robusto**: 2019 +22%, 2020 +7%, 2021 +21%, 2022 +13%, **2023 17%, 2024 19%**,
**2025 +62%**, 2026 +6%. Due anni consecutivi negativi; il "guadagno" è concentrato nel 2025
(ETH sottoperforma BTC in modo netto e sostenuto). FULL Sharpe mediocre 0.56, DD 41%.
- **Nessun plateau**: l'hold-out Sharpe oscilla 0.25→1.92 al variare di (N, hold) → picco
config+regime, non altopiano.
- Il beneficio FULL al portafoglio è solo **+0.09 Sharpe** (la legittima diversificazione di uno
sleeve scorrelato a Sharpe 0.56: √(1.30²+0.56²)≈1.42). Il resto del "miglioramento" è il 2025.
## Decisione: NON promosso — TP01-only resta il portafoglio deployato
La stessa disciplina che ha bocciato i falsi positivi in-sample (Fasi 1-3) e cross-asset (frattali)
deve bocciare questo falso positivo nel hold-out. Il relative-momentum BTC/ETH è un edge debole e
regime-dipendente (2 anni a 17/19%), il cui contributo robusto al portafoglio è marginale
(+0.09 FULL); il grosso del beneficio è la fortuna del 2025. Aggiungerlo significherebbe
scommettere sul ripetersi di quel regime.
**Lezione/criterio aggiornato per i futuri sleeve:** "migliora il hold-out" da solo NON basta (il
hold-out è UN regime). Un secondo sleeve va promosso solo se: causale, corr bassa, **positivo nella
maggioranza degli anni** (no 2 anni consecutivi rossi), **plateau** sui parametri, e migliora il
portafoglio su FULL E hold-out — non solo per via di un singolo anno fortunato.
## Stato
Portafoglio = **TP01-only** (difensivo, Sharpe FULL 1.30 / hold-out 0.31). `active_sleeves()`
invariato. `second_sleeve_hunt.py` resta come tool per valutare candidati futuri col criterio
corretto (contributo + breadth per-anno + plateau). Il relative-momentum BTC/ETH è in WATCHLIST,
non deployato.
@@ -0,0 +1,31 @@
# 2026-06-19 — Portafoglio di strategie estensibile (TP01 primo sleeve)
Creato un contenitore di portafoglio (`src/portfolio/`) con TP01 come unico sleeve attivo per ora,
progettato per aggiungerne altri (ognuno validato col gauntlet onesto).
## Design
- **Sleeve** = una strategia validata che produce una serie di rendimenti netti per-barra
(datetime-indexed, CAUSALE, netto fee). Opzionale `pos_fn` per le posizioni correnti (live).
- **StrategyPortfolio**: porta ogni sleeve su griglia GIORNALIERA comune (compounding intra-giorno
→ mixa TF diversi in modo coerente), combina per PESO rinormalizzato sui giorni comuni
(= equal-capital-by-weight ribilanciato di continuo). Metriche FULL + HOLD-OUT 2025-26 (bloccato)
+ per-anno + standalone per-sleeve, vs benchmark buy&hold 50/50.
- **Estensibilità**: aggiungere uno sleeve = una riga in `src/portfolio/sleeves.active_sleeves`
(dopo validazione: research_lab + hold-out + cross-asset + causality guard). Niente sleeve non validati.
## Stato attuale (1 sleeve = TP01, peso 100%)
`scripts/portfolio/run_portfolio.py`:
- **FULL** Sharpe 1.30 / ret +201% / DD 14.3% / ~€1.52/g su 2k (n=2655 giorni 2019-2026)
- **HOLD-OUT 2025-26** Sharpe 0.31 / +3.5% / DD 7.5% (buy&hold 50/50: Sharpe 0.32 / 39% / DD 59%)
- Per-anno positivo quasi ovunque (2022 2.1%, 2026-YTD 0.7%)
- Posizione corrente: **flat** (TP01 in cash nel regime attuale = difensivo)
## File
- `src/portfolio/{__init__,portfolio,sleeves}.py`, `scripts/portfolio/run_portfolio.py`,
`tests/test_portfolio.py` (6 test, passano). CLAUDE.md aggiornato.
## Prossimo
Il portafoglio è pronto per ospitare nuovi sleeve. Candidati naturali (da validare prima):
un secondo edge scorrelato a TP01 (TP01 è trend long-flat → serve qualcosa di diverso, es. una
strategia che lavori quando TP01 è flat). Finché non c'è un secondo edge che regge il gauntlet,
il portafoglio = TP01 difensivo. Quando arriverà, basta una riga in sleeves.py.
@@ -0,0 +1,77 @@
# Track F — Calendar seasonality (hour-of-day / day-of-week) on BTC & ETH
**Data:** 2026-06-19 · **Script:** `scripts/research/trackF_seasonality.py`
**Dati:** Deribit mainnet certificati, BTC/ETH 1h UTC. Fee baseline 0.10% RT (`fee_side=0.0005`).
## Domanda
Esiste un edge di calendario *sistematico e tradeable* (ora del giorno, giorno della
settimana, interazione ora×giorno) su BTC ed ETH, netto fee, OOS, per-anno, su entrambi gli asset?
## Metodologia (anti-overfit, anti-leakage)
- `ret[i]=close[i]/close[i-1]-1` è noto a `close[i]`; una posizione decisa a `close[i]` guadagna
`ret[i+1]`. La statistica che decide il trade usa **solo barre ≤ i** (mai la barra tradata né futuro).
- **Tradeable test onesto = ADAPTIVE EXPANDING sign**: a `close[i]` guardo il bucket di calendario
della barra `i+1` (il clock è noto, zero look-ahead) e prendo il **segno della media passata** di
quel bucket (espandente, warmup-gated). Long-flat o long-short. Fee solo su `|Δposizione|`.
È l'analogo onesto di "tradare il seasonal": i dati scelgono il segno di ogni bucket **dal vivo**.
- Tabelle descrittive per-ora/per-giorno split IS(65%)/OOS(35%) come diagnostica.
- Regola discreta ottimizzata in-sample (entra a ora H, tieni W barre, dir migliore) mostrata solo
per **esporre il gap IS→OOS** (384 celle testate/asset).
- Benchmark **buy-and-hold** come controllo del long-bias.
## Risultati
### 1. Descrittive (bp/barra, IS vs OOS)
- **Hour-of-day:** sign-agreement IS/OOS solo **12/24 (BTC)** e **8/24 (ETH)** → caso. Le ore "US
close" 21:0022:00 UTC sono positive in entrambi gli split su entrambi gli asset (l'unico pattern
con un minimo di coerenza), ma il resto è rumore che cambia segno tra IS e OOS.
- **Day-of-week:** più stabile. **Giovedì negativo** su BTC ed ETH in IS *e* OOS; Lun/Mer positivi.
Sign-agreement 6/7 (BTC), 5/7 (ETH).
### 2. Adaptive expanding-sign (il test tradeable)
| Strategia | BTC Sharpe | ETH Sharpe | Note |
|---|---|---|---|
| HOUR long-short | **5.39** | **4.04** | DD 100%. Annientata dalle fee. |
| HOUR long-flat | 2.92 | 2.09 | DD 100%. Idem. |
| DOW long-short | +0.64 | +0.83 | DD 8284%, 66% nel 2022 |
| DOW long-flat | +0.81 | +0.96 | DD 7578%, 64/66% nel 2022 |
| HOUR×WEEKDAY (168 buckets) | 5.05 | 3.96 | DD 100%. Overfit puro + fee. |
### 3. Il controllo che smonta il DOW — **buy-and-hold**
- BTC buy-hold: **Sharpe 0.79, CAGR 34.9%, DD 77%** → DOW long-flat: Sh 0.81, CAGR 34.2%, DD 77.5%.
- ETH buy-hold: **Sharpe 0.84, CAGR 42.4%, DD 81%** → DOW long-flat: Sh 0.96, CAGR 52.7%, DD 74%.
- Il DOW long-flat è **long il 78% del tempo** (`mean_pos≈+0.78`). È **buy-and-hold travestito**:
guadagna perché crypto sale, non perché esiste un edge di giorno. Lo "skip del giovedì" aggiunge
pochissimo e non giustifica un deploy.
### 4. Fee sweep (HOUR long-short adaptive)
A fee **0%**: Sh +0.61 (BTC) / +0.80 (ETH) — solo long-drift. A 0.10% RT: **5.4 / 4.0**. Turnover
**~8.000 flip/anno** (segno orario instabile, cambia quasi ogni barra) → morte istantanea per fee.
Le strategie hour-of-day sono ad alta frequenza per costruzione: le fee sono di prim'ordine e le
uccidono.
### 5. Regola discreta ottimizzata in-sample (trappola multiple-testing)
- BTC: best IS H=05 hold=24h dir=+1 → **IS Sh +4.25 → OOS Sh +1.47** (+3.7 bp/trade).
- ETH: best IS H=13 hold=24h dir=+1 → **IS Sh +7.35 → OOS Sh +0.90** (+3.2 bp/trade).
- Collasso IS→OOS classico. Inoltre "hold 24h dir+1" = ancora **long-bias** (entra una volta/giorno
e tiene 24h ≈ sempre long). Il margine OOS (~3 bp/trade su 10 bp RT) è marginale e fragile.
## Multiple-testing
199 celle di calendario/asset (24 ore + 7 giorni + 168 ora×giorno) + 384 (H,W,dir)/asset. Con così
tante celle, bucket "significativi" spuri sono **garantiti**. Filtri applicati: segno scelto dal vivo
su soli dati passati, deve reggere OOS, per-anno, e su **entrambi** BTC ed ETH.
## Verdetto — **SPURIO / NON deployable**
- **Nessun edge di calendario netto-fee robusto** su BTC ed ETH.
- **Hour-of-day:** morto (fee + segno instabile). L'unica regolarità (US-close 2122 UTC positiva) è
troppo debole e non sopravvive al turnover.
- **Day-of-week:** l'unico risultato "positivo" è **long-bias mascherato** (≈ buy-and-hold,
Sharpe ~0.80.96 < trend portfolio 1.32, DD 7584% rovinoso, 65% nel 2022). Non è un edge
seasonal sfruttabile; è esposizione direzionale al drift di crypto.
- **Hour×weekday:** overfit puro (IS 3.6 → OOS 8.0).
- Coerente con la lezione del progetto: dove l'unica "direzione" che funziona è essere long, non c'è
alpha di timing — c'è beta. Il trend portfolio (TP01) cattura quel beta in modo vol-targeted e
con DD ~12%, infinitamente meglio di qualunque regola di calendario qui.
**Azione:** track F chiuso negativo. Non aggiungere nulla al portafoglio. Il soffitto Sharpe ~1.3 su
BTC/ETH regge.
@@ -0,0 +1,85 @@
# Track G — Prior-period level breakouts / range (BTC & ETH, calendar-anchored)
**Data:** 2026-06-19 · **Script:** `scripts/research/trackG_prior_levels.py`
**Harness:** `src/backtest/harness.py` (honest, entry decided at `close[i]`, fill `close[i]`).
## Domanda
Esistono edge net-positivi OOS, robusti su BTC **e** ETH, definiti rispetto a un **periodo
calendario precedente** (giorno/settimana/opening-range)? E soprattutto: i breakout di livello
**continuano** (trend) o **rientrano** (fade)?
## No look-ahead (garanzie)
- Livelli prior-day/week costruiti aggregando a barre giornaliere/settimanali (UTC) e poi
**`shift(1)`** sul frame del periodo *chiuso*: il periodo corrente vede solo il precedente
totalmente chiuso. Mai "oggi"/"questa settimana" nel livello.
- Opening-range usato **solo** sulle barre dopo la chiusura della finestra di apertura.
- Direzione + prezzo decisi a `close[i]`, fill a `close[i]`. Mai entry sul livello esatto intrabar.
- Bug iniziale corretto: mismatch tz-aware vs tz-naive nel mapping dei livelli (dava 0 trade).
## Risultati (1h, fee 0.10% RT, leva 1x, OOS 65/35)
### Continuation vs FADE — il verdetto è netto
| Regola (PD = prior-day) | BTC OOS | ETH OOS | Sharpe OOS |
|---|---|---|---|
| **PD-high CONT (long su rottura max ieri)** | **+25%** | **+16%** | +0.5 / +0.3 |
| PD-high FADE | **68%** | **68%** | 1.6 / 1.2 |
| PD-low CONT (short su rottura min ieri) | 33% | 60% | 0.5 / 0.8 |
| PD-low FADE | 36% | 8% | 0.6 / +0.1 |
- **I breakout CONTINUANO, non rientrano.** Il lato FADE è robustamente **negativo** su entrambi
gli asset (sia high che low), su prior-day, prior-week e opening-range. Conferma diretta della
tesi del reset: la mean-reversion / fade è morta su dati certificati.
- **Asimmetria long-only:** funziona solo la rottura del **massimo** (long), non quella del
**minimo** (short). Cioè non è un edge di breakout *simmetrico/direzione-neutro*: è cattura del
**drift/trend rialzista** del cripto. La PD-low-cont (short sui breakdown) perde perché in questo
campione il cripto sale.
### Grid robustness (PASS 6) — survivor = OOS>0 su ENTRAMBI
- **PD-high CONT: 3/3 celle** (buffer 0/0.1%/0.3%) positive OOS su BTC **e** ETH → robusto al buffer.
- PD-high fade, PD-low cont/fade, OR-fade: **0 survivor**.
- **OR-cont:** positiva solo su ETH, negativa su BTC su tutte le finestre (3/6/8/12h) → artefatto
mono-asset, scartato dalla regola "entrambi".
### Anchor-hour sweep (PASS 5) — non è un'ora fortunata
PD-high cont positiva su **21/24** ore UTC (BTC) e **20/24** (ETH). Non dipende da un singolo
anchor → coerente con un edge reale (ma vedi sotto: è beta di trend).
### Fee sweep + per-anno (PD-high cont, full sample)
```
BTC RT%: 0.00→+571 0.05→+289 0.10→+126 0.15→ +31 0.20→ 24 (OOS: +84/+52/+25/+3/15)
ETH RT%: 0.00→+1754 0.05→+1012 0.10→+567 0.15→+299 0.20→+139 (OOS: +67/+39/+16/3/19)
BTC per-anno: 2019 +39 2020 +104 2021 +7 2022 42 2023 +24 2024 +27 2025 16 2026 +3
ETH per-anno: 2020 +164 2021 +160 2022 +7 2023 +1 2024 +12 2025 4 2026 +7
Sharpe full: BTC +0.48 (maxDD 55%, €/d 2k +0.88) · ETH +0.86 (maxDD 34%, €/d 2k +4.27)
```
- **Fee-fragile:** alla baseline 0.10% RT sopravvive (OOS +25/+16%), ma muore già a ~0.15-0.20% RT.
Margine di fee sottile (≈1.5x baseline e l'edge sparisce su OOS). ~1000-1100 trade in 8 anni.
- **Drawdown enormi** (BTC 55%) e anni negativi (2022 42% BTC, 2025 16%).
## Verdetto
- **Sì, esiste un edge net-positivo OOS su entrambi gli asset:** *PD-high continuation* (long
quando `close` supera il massimo di ieri, exit a fine giornata UTC). Robusto al buffer e
all'anchor-hour. **MA non è deployabile come miglioramento:**
1. È **long-only drift capture**, non un breakout simmetrico (il lato short fallisce) → è una
versione **più debole e ridondante** del Trend Portfolio TP01 (Sharpe 0.48-0.86 vs 1.32).
2. **Fee-fragile** (muore a ~1.5x la fee baseline) e con **drawdown** molto peggiori.
- **Il contributo scientifico vero è la conferma della direzione:** sui dati certificati i
breakout di livello-calendario **CONTINUANO**; il fade è morto (negativo robusto su PD/PW/OR,
entrambi gli asset). Nessuna sorpresa mean-reversion nascosta nei livelli giornalieri/settimanali.
- **Niente di nuovo da mettere in produzione.** TP01 resta la strategia vincente; i breakout
prior-period non aggiungono Sharpe (stessa beta di trend, peggio eseguita).
## Come riprodurre
```bash
uv run python scripts/research/trackG_prior_levels.py # full (1h + 15m, ~25s)
uv run python scripts/research/trackG_prior_levels.py --quick # 1h only
```
@@ -0,0 +1,71 @@
# Track H — Volume, Range & Volatility-Regime signals (BTC/ETH, certified, >=12h)
**Date:** 2026-06-19
**Script:** `scripts/research/trackH_volume_vol.py` (runnable, self-contained)
**Question:** does any volume / range / volatility-regime signal ADD to the deployed winner
TP01 (vol-targeted trend portfolio, 12h, Sharpe ~1.32) — i.e. net-positive OOS on BOTH BTC &
ETH AND uncorrelated (|corr|<~0.3) — OR work as a regime filter that lifts TP01's Sharpe / cuts
its DD?
## Method (honest)
- Same causal per-bar engine as `TrendPortfolio.net_returns`: build a continuous TARGET decided
with data `<= close[i]`, HOLD it during bar `i+1` (`pos_held[t]=target[t-1]`), gross = pos×ret,
fee on `|Δpos|`. Identical in spirit to `harness.backtest_signals` (decide≤close[i], fill at
close[i]); two discrete signals cross-checked through `backtest_signals` directly.
- All features (volume z-score, OBV, ranges, realized vol) use prior/rolling windows shifted so
bar `i` sees only `<= i`. 12h/1d resampled from certified 1h via `resample_tf` (label='left'),
consumed index-based with the +1 hold → no open-label leak.
- Fee 0.10% RT baseline + sweep 0.000.40% RT. OOS 65/35 + per-year. Grid on BOTH assets.
Turnover and correlation-to-TP01 reported for every signal.
- **>=12h only** (12h + 1d). Sub-12h excluded per the standing lesson (fees + HF-noise overfit +
the 4h open-label look-ahead trap).
## Signals tested
VT-long (volatility-managed long), VolBreakout (volume-z-confirmed Donchian), OBV-trend,
VW-mom (volume-weighted momentum), RangeExpand (range-expansion breakout), NR7-break
(narrowest-range breakout), DeclVolRev (declining-volume fade/reversal). Plus regime overlays on
TP01: keep-low-vol, keep-high-vol, vol-managed ×1.5, OBV-up confirmation.
## Results (12h headline, fee 0.10% RT)
| signal | corr→TP01 | OOS Sharpe BTC/ETH | note |
|---|---|---|---|
| VT-long | 0.66 / 0.69 | 0.80 / 0.14 | trend-in-disguise; weak OOS ETH |
| VolBreakout | 0.69 / 0.71 | 0.54 / 0.49 | profitable but correlated |
| OBV-trend | 0.61 / 0.63 | 0.96 / 0.68 | profitable but correlated; turnover ~75/yr |
| VW-mom | 0.64 / 0.67 | 0.98 / 0.74 | basically TSMOM; correlated |
| RangeExpand | 0.48 / 0.49 | 0.37 / 1.04 | lower corr but BTC weak; ETH negative on 1d |
| NR7-break | 0.48 / 0.49 | 0.79 / 0.02 | fails OOS on ETH |
| DeclVolRev | -0.15 / -0.11 | -1.15 / -0.44 | **negative even at zero fee** |
Grid robustness (12h, % cells positive full+OOS on both assets): VW-mom 100%, VT-long 100%,
VolBreakout 96%, RangeExpand 96%, OBV-trend 75% — but the robust ones are precisely the ones
that are highly correlated to TP01. Fee sweep: trend-family signals survive to 0.40% RT;
DeclVolRev gets worse with fees (it trades constantly).
## Regime filters on TP01 (12h, 50/50 portfolio)
| variant | full Sharpe | OOS Sharpe | maxDD | CAGR | turn/y |
|---|---|---|---|---|---|
| **TP01 baseline** | **1.32** | 0.90 | 13.3% | 16.2% | 11.5 |
| × keep LOW-vol | 0.94 | 1.11 | 14.1% | 7.7% | 9.5 |
| × keep HIGH-vol | 0.98 | 0.18 | 9.9% | 7.9% | 4.9 |
| × vol-managed ×1.5 | 1.33 | 0.96 | 17.9% | 18.1% | 15.4 |
| × OBV-up only | 1.49 | 1.04 | 10.1% | 14.4% | 18.2 |
OBV-up filter across EMA span: full Sharpe 1.491.52 (span 1530), DD 710%, but OOS gain is
marginal (0.90→1.04 at span 30) and fades for span≥45 (OOS 0.690.73). It cuts ~2pp CAGR and
raises turnover ~60%.
## Verdict (honest)
- **No uncorrelated additive edge exists.** Every *profitable* volume/range/vol signal is trend
in disguise (corr 0.610.75 to TP01) → cannot raise the 50/50 portfolio Sharpe. The genuinely
lower-corr signals (RangeExpand, NR7 ~0.48) fail OOS on at least one asset.
- **Mean-reversion / declining-volume fade is dead** — negative net AND at zero fee on both
assets. Reconfirms the v2.0.0 contamination lesson; MR is not a real edge on certified data.
- **Vol-regime gating hurts** (keep-low / keep-high both drop Sharpe to ~0.95). The vol-managed
overlay is Sharpe-neutral but DD-worse.
- **The only non-harmful overlay is OBV-up trend-confirmation:** it cuts DD (13.3%→10.1%) and
nudges full Sharpe to ~1.49, but it is trend double-confirmation (de-risking), not new alpha;
it costs CAGR, raises turnover, and the OOS Sharpe gain is within noise and span-sensitive. It
is worth keeping in mind as a **defensive DD overlay**, not as a Sharpe improver.
- **Bottom line:** the ~1.3 portfolio-Sharpe ceiling on BTC/ETH-only **holds**. TP01 stays the
deployable winner. Volume/range/vol add nothing uncorrelated.
@@ -0,0 +1,99 @@
# Track I — Alternative momentum formulations + long-horizon reversal (2026-06-19)
**Script:** `scripts/research/trackI_momentum_reversal.py` (self-contained, runnable).
**Universe:** BTC & ETH only. **TF:** 12h + 1d (sub-12h excluded by rule). **Harness:** identical
honest machinery to TP01 — direction decided `<= close[i]`, positions held next bar (`pos_held[1:]
= tgt[:-1]`), vol-target by inverse PAST-ONLY realized vol (target 20%, lev cap 2x), NET fee 0.10%
RT on turnover, 50/50 BTC+ETH. OOS 65/35 + per-year + fee sweep (0.000.40% RT). Correlation to
TP01 net returns reported for every candidate.
## Goal
(A) A momentum formulation that BEATS or DIVERSIFIES the canonical 1-3-6m sign-blend (TP01,
Sharpe ~1.32). (B) Does the classic LONG-HORIZON REVERSAL (fade ~12m winners) give an
uncorrelated positive overlay?
## PART A — momentum formulations (12h, long-flat, vs TP01 Sharpe 1.32 / OOS 0.90 / DD 13.3%)
| formulation | Sharpe | IS | **OOS** | CAGR | maxDD | corr→TP01 | BTC | ETH |
|---|---|---|---|---|---|---|---|---|
| baseline sign-blend 1-3-6m | 1.32 | 1.54 | 0.90 | +16% | 13.3% | 1.00 | 1.15 | 1.10 |
| (i) z-score cum-return (tanh) | **1.35** | 1.63 | 0.85 | +12% | **8.4%** | 0.96 | 1.30 | 1.00 |
| (ii) risk-adjusted momentum | 1.27 | 1.49 | 0.84 | +13% | 9.5% | 0.97 | 1.21 | 1.00 |
| (iii) EMA-cross trend | 0.81 | 0.91 | 0.62 | +11% | 25.1% | 0.85 | 0.89 | 0.53 |
| (iii-b) MACD (calendar spans) | **1.50** | **1.87** | 0.74 | +22% | 17.7% | 0.69 | 1.30 | 1.32 |
| (iv) Donchian breakout | 1.10 | 1.36 | 0.57 | +17% | 25.0% | 0.86 | 1.08 | 0.82 |
| (v) acceleration (Δ-momentum) | 1.28 | 1.82 | 0.35 | +14% | 14.2% | 0.66 | 1.25 | 0.81 |
| (vi) 12-1 skip momentum | 0.67 | 0.79 | 0.47 | +9% | 24.5% | 0.68 | 0.70 | 0.49 |
Results are essentially identical at 1d. Read-out:
- **Nothing cleanly beats the sign-blend OOS on both assets.** The headline-Sharpe leaders are
artefacts of in-sample fit: **MACD** posts IS 1.87 but OOS collapses to 0.74 (gap = overfit) with
a worse DD (17.7%); **acceleration** IS 1.82 → OOS **0.35** (worst OOS decay of all). Both fail.
- **(i) z-score continuous momentum** is the one mild, honest refinement: Sharpe 1.35 (≈baseline)
but **maxDD 8.4% vs 13.3%** — the continuous score scales down position when the cumulative move
is statistically small, de-risking the tails. OOS 0.85 (slightly below baseline 0.90), CAGR drops
16%→12%. It's a smoother sibling of TP01, **not a new edge** (corr 0.96).
- (vi) 12-1 skip (classic equity "12-1" momentum) **does NOT help crypto**: skipping the recent
month removes the strongest part of the signal here → Sharpe 0.67, corr 0.68. Crypto momentum
lives in the recent window, opposite to the equity stylised fact.
- Breakout/Donchian and EMA-cross are strictly worse (high DD, weak OOS).
## PART B — long-horizon reversal (fade past winners), 12h
Long-short reversal (short ~12/18/24m winners, long losers, vol-targeted):
| reversal LS | Sharpe | OOS | CAGR | maxDD | corr→TP01 |
|---|---|---|---|---|---|
| 12m | -0.77 | -1.15 | -14% | 73% | -0.51 |
| 18m | -0.36 | -0.75 | -8% | 58% | -0.47 |
| 24m | **+0.04** | -0.07 | -1% | 43% | **-0.32** |
| 12-18-24m | -0.46 | -0.72 | -8% | 57% | -0.54 |
- **Long-horizon reversal is NOT a standalone edge.** Standalone it LOSES money (12m/18m strongly
negative; only 24m is ~flat at Sharpe 0.04, OOS 0.07, and even that fails "net-positive OOS on
both assets": BTC +0.10 / ETH 0.03). Fading crypto winners over a year just shorts the trend.
- It IS genuinely negatively correlated to TP01 (24m: corr 0.32; 12-18-24: 0.54), as expected
(it's the opposite sign of medium-term momentum).
- **Momentum + reversal blend** (long 1-6m momentum, brake on very-long extension): the variant
`mom(1-3-6) 0.5·rev(12-24)` is the most interesting single-strategy result — Sharpe **1.38**,
**OOS 0.98** (> baseline 0.90), **maxDD 10.6%** (< 13.3%), both assets positive (BTC 1.25/ETH
1.05), corr 0.91, fee-robust (1.43→1.22 across 0.000.40% RT). CAGR drops 16%→12%. It is TP01
with a long-term-extension brake: a modest *risk-adjusted* improvement, not more return.
## COMBINED — TP01 + best diversifier (blend net returns)
TP01 alone: Sharpe 1.321, CAGR +16%, maxDD 13.3%, OOS 0.90.
| combo | Sharpe | CAGR | maxDD | OOS | corr |
|---|---|---|---|---|---|
| TP01 + 20% reversal-24m (LS) | **1.411** | +13% | 11.5% | **1.06** | -0.32 |
| TP01 + 30% reversal-24m (LS) | 1.366 | +12% | 11.8% | 1.06 | -0.32 |
| TP01 + 20% reversal-12-18-24 (LS) | 1.350 | +11% | 10.6% | 0.84 | -0.54 |
| TP01 + 50% z-score | 1.348 | +14% | 9.5% | 0.89 | +0.96 |
- Adding a small slice of **reversal-24m long-short** lifts portfolio Sharpe 1.32→1.41 and OOS
0.90→1.06 while cutting DD to 11.5%. **But be skeptical:** the overlay is a ~zero-mean stream
(standalone Sharpe 0.04). The benefit is almost entirely **variance reduction from the negative
correlation, not added alpha** — and it COSTS return (CAGR 16%→13%). With a true-zero-edge
diversifier this Sharpe bump is fragile (it leans on the 0.32 correlation persisting OOS, and the
OOS sample is one 2022-24 crypto cycle). I would NOT deploy capital on a standalone-losing sleeve
to chase a 0.09 Sharpe point that is really de-risking.
## Fee sweep (12h portfolio Sharpe)
baseline 1.37→1.18, z-score 1.38→1.24, MACD 1.52→1.45 (lowest turnover), blend 1.43→1.22,
reversal-24m 0.07→−0.02 (0.00→0.40% RT). All trend formulations survive realistic fees; reversal
has no positive margin to survive on.
## VERDICT (honest)
- **Is there a momentum formulation that beats the 1-3-6m sign-blend? No — not OOS, not on both
assets.** MACD/acceleration look better in-sample but decay OOS (overfit + higher DD). The only
honest refinement is **continuous z-score momentum**, which matches the Sharpe with materially
lower drawdown (8.4% vs 13.3%) — a smoother variant of the SAME edge, not a new one (corr 0.96).
- **Does long-horizon reversal give an uncorrelated positive overlay? No, not a real one.** It is
uncorrelated/negatively-correlated (good) but **not positive** standalone (it loses, or at best is
flat at 24m and fails the both-assets bar). The combined-Sharpe lift (→1.41) is variance reduction
from a near-zero-mean stream and sacrifices CAGR — fragile, not bankable alpha.
- **The ~1.3 structural Sharpe ceiling on BTC/ETH-only holds.** TP01 remains the deployable winner.
If anything, swap the sign-blend for the **z-score continuous score** (or the `mom 0.5·rev`
brake) for a lower-DD profile at equal Sharpe — a risk-management tweak, not a return upgrade.
+28
View File
@@ -0,0 +1,28 @@
# 2026-06-19 — Strato trend multi-asset sui 52 alt: RIDONDANTE col trend di TP01
Tentativo: aggiungere un terzo sleeve = TSMOM (stessa logica TP01 CANONICAL, long-flat vol-target)
applicato a OGNI alt dei 52 Hyperliquid certificati, equal-weight ragged. Idea: trend più
diversificato che diversifichi TP01 (BTC/ETH). `scripts/portfolio/trend_multiasset.py`.
## Esito: ridondante e peggiore
- **TREND-52 standalone**: FULL Sh 0.66, **HOLD-OUT 1.03** (negativo), anni+ 33%. Gli alt sono
stati long nel calo 2025-26 e hanno sanguinato — a differenza di TP01 (BTC/ETH) che il
vol-target+trend portò in cash. I trend degli alt sono più rumorosi/whippy.
- **corr a TP01 = +0.74** (stessa beta direzionale, come previsto) | corr a XS01 0.05.
- **Contributo al portafoglio (TP01 70 + XS 30):** +TREND-52 w20% → FULL 0.01, **HOLD 0.16**;
w30% → FULL 0.02, **HOLD 0.27**. PEGGIORA.
## Lezione
Broadenizzare il TREND su molti alt NON diversifica: è la **stessa direzionalità** (corr 0.74 con
TP01) su asset più rumorosi → aggiunge perdita/rumore, non edge. La dimensione trend è già catturata
in modo pulito da TP01 (BTC/ETH, vol-targeted). L'unica espansione che diversifica davvero resta
quella **market-neutral** (XS01 cross-sectional), perché è ortogonale alla beta direzionale.
## Conclusione (chiusura del filone "espansione universo")
Esplorate tutte le vie di espansione sui certificati Hyperliquid:
1. XS su 52-all → diluito (memecoin), negativo.
2. XS top-liquidità dinamico → peggiore del fisso-19 (liquidità ≠ qualità).
3. Trend multi-asset su 52 → ridondante (corr 0.74) + hold-out negativo.
Nessuna migliora il portafoglio. **Configurazione validata e invariata: TP01 70% + XS01 (19 major)
30% — FULL Sh 1.41 / HOLD 1.15.** I margini reali per crescere NON sono nell'universo crypto-
direzionale (saturo), ma in un MECCANISMO diverso (opzioni VRP, in attesa di dati di stress reali).
+35
View File
@@ -0,0 +1,35 @@
# 2026-06-19 — Affinamento XS01: blend di lookback [30,90]
Come TP01 fonde gli orizzonti 30/90/180, XS01 ora fonde piu' lookback del momentum cross-sectional
(z-score cross-sectional per lookback, mediato) invece del singolo L=30. `scripts/portfolio/xsec_blend.py`.
## Sweep lookback (19 major, 899g) — FULL/OOS/DD/anni+/corrTP
| lookbacks | FULL | OOS25 | DD% | anni+ | corrTP |
|---|---|---|---|---|---|
| [30] (prima) | 0.80 | 1.20 | 21 | 100% | 0.06 |
| [90] | 0.88 | 0.90 | 17 | 100% | 0.05 |
| **[30,90]** | **1.10** | **1.03** | **14** | **100%** | **0.12** |
| [20,40,90] | 0.51 | 0.67 | 25 | 100% | 0.12 |
| [30,60,120] | 0.68 | 0.74 | 16 | 100% | 0.13 |
**[30,90] e' il sweet spot**: fonde i DUE singoli robusti (30 e 90), FULL Sh 0.80→1.10, DD 21→14%,
corr a TP01 0.06→−0.12 (diversifica meglio), 100% anni+. Non e' un cell fortunato: e' la
combinazione dei due lookback gia' validati (anti-overfit, come il multi-orizzonte di TP01).
## Effetto sul portafoglio (TP01 70% + XS01 30%)
| | XS01 [30] | XS01 blend [30,90] |
|---|---|---|
| XS01 standalone FULL / DD | 0.80 / 21% | **1.10 / 14%** |
| Portafoglio FULL Sharpe | 1.41 | **1.48** |
| Portafoglio HOLD-OUT Sharpe | 1.15 | 1.06 |
| Portafoglio DD | 5.2% | **4.6%** |
| ~€/giorno (2k) | +1.65 | +1.78 |
Migliora FULL Sharpe + DD + robustezza (due orizzonti) al costo di un hold-out marginalmente piu'
basso (0.09, dentro il rumore di una singola finestra). Giudizio: il blend e' piu' robusto
(meno dipendente da un singolo lookback) e diversifica meglio -> PROMOSSO.
## Azione
`src/portfolio/sleeves.XS_CFG`: `L=30` -> `lookbacks=(30,90)`; engine `_xsec_returns` usa lo score
blended (media z-score cross-sectional per lookback). **Portafoglio attivo: TP01 70% + XS01 blend
30%, FULL Sh 1.48 / HOLD 1.06 / DD 4.6%.** 12 test ok. Sleeve sempre sui 19 major.
+44
View File
@@ -0,0 +1,44 @@
# 2026-06-19 — Affinamento XS01: gate di dispersione (p30)
Il momentum cross-sectional vive nella DISPERSIONE (winners/losers distanti). In regime compatto
(tutti gli asset insieme) e' rumore. Gate: entra solo se la dispersione cross-section del momentum
supera il percentile ESPANDENTE causale `disp_pct`; altrimenti flat. Sul blend [30,90] dei 19 major.
`scripts/portfolio/xsec_dispgate.py`. (È il concetto del vecchio XS01 pre-reset, disp_min=p50.)
## Sweep soglia (19 major, 899g) — XS01 standalone + contributo portafoglio
| soglia | XS FULL | XS OOS | PORT FULL | PORT HOLD | %flat |
|---|---|---|---|---|---|
| no gate | 1.10 | 1.03 | 1.50 | 1.06 | 0% |
| p15 | 1.32 | 1.39 | 1.64 | 1.36 | 28% |
| p20 | 1.46 | 1.63 | 1.72 | 1.52 | 31% |
| p25 | 1.46 | 1.63 | 1.72 | 1.52 | 31% |
| **p30** | **1.50** | **1.71** | **1.74** | **1.56** | 35% |
| p35 | 1.60 | 1.90 | 1.81 | 1.69 | 37% |
| p40-p50 | 1.0 | 0.8 | 1.36-1.38 | 0.77-0.93 | 42-49% |
**PLATEAU robusto p15-p35** (cinque punti, tutti molto > no-gate); il crollo a p40+ e' OVER-gating
(salta troppo). Scelto **p30** (centro sicuro del plateau, lontano dal cliff p40). Non un knife-edge.
## Effetto sul portafoglio (TP01 70% + XS01 30%)
| XS01 | PORT FULL | PORT HOLD | PORT DD |
|---|---|---|---|
| [30] originale | 1.41 | 1.15 | 5.2% |
| + blend [30,90] | 1.48 | 1.06 | 4.6% |
| + dispersion gate p30 | **1.55** | **1.55** | **4.4%** |
XS01 standalone: FULL 1.10→1.50, HOLD 1.03→1.71, DD 14%→10.8%, ~€/g 1.64→2.36. Il gate alza SIA
FULL SIA hold-out (a differenza del solo blend, che barattava un po' di hold-out).
## Meccanismo + caveat onesti
- **Causale**: soglia = percentile espandente della dispersione PASSATA; nessun look-ahead.
- **Perche' funziona**: tiene XS attivo nei regimi DISPERSI (2025-26, dove gli alt divergono) e flat
nei bull compatti (2024). L'hold-out 2025-26 e' ad alta dispersione -> il gate concentra
l'attivita' di XS proprio li' -> hold-out forte. E' il comportamento voluto, ma NB che il salto
del hold-out riflette anche che il 2025-26 e' stato un regime ad alta dispersione.
- Caveat XS01 invariati: storia ~2.5 anni; STAT-MODE (book 19 gambe non eseguibile a 2k).
## Azione
`src/portfolio/sleeves.XS_CFG`: aggiunto `disp_pct=30`; engine `_xsec_returns` gatea su dispersione.
**Portafoglio attivo: TP01 70% + XS01 (blend [30,90] + gate disp p30) 30% — FULL Sh 1.55 / HOLD 1.55
/ DD 4.4%.** 12 test ok. Affinamenti del SEGNALE (blend + gate) hanno funzionato dove l'espansione
universo no: i margini su XS sono nella struttura del segnale, non nel numero di asset.
@@ -0,0 +1,62 @@
# 2026-06-19 — Espandere l'universo XS01: PIÙ asset DILUISCONO (i 19 major sono il sweet spot)
Richiesta: aggiungere altri asset Hyperliquid certificati per rafforzare XS01 (cross-sectional
momentum). Fatto il lavoro, esito ONESTO: **non rafforza — diluisce.**
## Cosa ho fatto
- Esteso `fetch_hyperliquid.py` a ~54 candidati alt maggiori (mappa Binance auto SYM/USDT, k-prefissi
esclusi). **52 certificati** (cross-venue 4-11 bps vs Binance, flat 0%, storia 2024+): aggiunti
ATOM DYDX APE CRV LDO STX GMX SNX BCH COMP WLD UNI TRX FIL RUNE ENA ORDI JUP WIF PYTH FET AR ETC
ALGO GALA SAND AXS DOT BLUR JTO PENDLE ONDO TAO. Esclusi MKR (delistato HL 2025-09) e FXS
(migrazione Frax 2026-01) via nuovo gate "ultima barra recente".
## Il finding: il cross-section dei 52 è NEGATIVO; i 19 major sono positivi
Stessa finestra (2024-04 → 2026-06, 807g), mom L*/H10:
| Universo | k | FULL Sharpe (L30/L60/L90) |
|---|---|---|
| **52 asset** | 5 | 0.13 / 0.21 / 0.35 |
| **52 asset** | 8-12 | tutti negativi (k grande non aiuta) |
| **19 major** | 5 | +0.30 / +0.36 / **+0.67** (OOS 0.91) |
I ~33 small/new-cap aggiunti (WIF, JUP, ORDI, PYTH, TAO, GALA, AR, BLUR…) sono idiosincratici/
mean-reverting: il loro rumore **rovescia** il momentum relativo. Cross-sectional momentum su crypto
funziona fra i MAJOR liquidi, non sul long tail. Allargare l'universo NON è gratis.
## Azione
- **XS01 resta sui 19 major** (sweet spot già validato: plateau/fee/subset). Lo sleeve
`_xsec_returns` ora usa una **lista esplicita `XS_UNIVERSE` (19)**, non più glob-all (così
aggiungere parquet certificati non lo cambia/rompe — avevo inavvertitamente fatto vedere allo
sleeve 52 asset = negativo).
- I 52 parquet certificati restano su disco: dato valido per ricerca futura (uno strato diverso —
es. trend-following multi-asset, o un XS ristretto ai top-liquidità — potrebbe usarli), ma NON XS01.
- Portafoglio invariato e ripristinato: **TP01 70% + XS01 30%, FULL Sh 1.41 / HOLD 1.15.**
## Lezione
"Più asset = più robusto" è FALSO per il cross-sectional momentum: il long tail di alt piccoli
diluisce/inverte l'edge. La breadth utile è quella dei major liquidi (corr-strutturata), non il
numero grezzo.
## Tentativo 2: UNIVERSO TOP-LIQUIDITÀ DINAMICO (`xsec_dynuniverse.py`) — anch'esso PEGGIORE
Provato a selezionare a ogni ribilancio i top-N per dollar-volume 30g (causale) dai 52, poi XS
momentum fra quelli (adattivo, ragged-aware). Esito:
| Universo | FULL Sh | OOS25 | anni+ |
|---|---|---|---|
| top12 dinamico (L30H10k5) | 0.65 | 0.54 | 67% (2026 4%) |
| top15/20/25 dinamico | 0.14-0.38 | ≤0.30 | 33-67% |
| **fisso-19 major (L30H10k5)** | **0.80** | **1.20** | **100%** |
| fisso-19 major (L90H10k5) | 0.88 | 0.90 | 100% |
Contributo: TP01+DYN 70/30 = FULL 1.10 / HOLD 0.60 vs **TP01+XS19 = FULL 1.25 / HOLD 1.15**.
**Perché fallisce:** la classifica per dollar-volume ammette comunque i MEMECOIN ad alto volume
(WIF, ORDI, JUP, PEPE...) che hanno volumi enormi ma momentum erratico/mean-reverting →
diluiscono. **Liquidità ≠ qualità** nelle crypto. I 19 major *curati* (established, corr-strutturati,
non solo alto volume) restano il sweet spot.
## Conclusione
Né più nomi (52) né top-liquidità dinamico migliorano XS01. **XS01 resta sui 19 major curati**
(FULL 0.80 / OOS 1.20, 100% anni+). Portafoglio invariato: TP01 70% + XS01 30% (FULL 1.41/HOLD 1.15).
Per rafforzarlo davvero servirebbe una curatela di QUALITÀ (established majors), che è già ciò che i
19 sono. Coerente con la disciplina: nessuna espansione senza che migliori il gauntlet. I 52 parquet
certificati restano per ricerca futura (es. trend multi-asset, dove il long tail non diluisce).
@@ -0,0 +1,93 @@
# 2026-06-20 — Analisi strategie FinanceOld + VRP v2 (defined-risk + gate IV-rank)
## Contesto
Richiesta: analizzare le strategie in `../FinanceOld`, provare a migliorarle, testarle su dati storici.
Quattro progetti esaminati. Verdetto di **backtestabilità onesta** sui dati certificati (BTC/ETH
Deribit mainnet + DVOL):
| Progetto | Strategia | Backtestabile sui dati certi? |
|---|---|---|
| **FundingRateArbitrage** | Spread funding cross-exchange (perp-perp, spot-hedge) | ❌ Nessun dato funding storico nel repo (solo `exchange_settings.json`). Edge = differenza cross-venue, non ricostruibile. |
| **Polybot** | Latency-arb Polymarket (BS digital-option) + sure-bet delta-neutral | ❌ `dataVPS/collector.db` (645MB) ha solo **~3 giorni** di `poly_books`+`funding`, e la tabella `ticks` (prezzi perp = cuore dell'edge) è **corrotta** ("database disk image is malformed"). L'edge è la latenza: non riproducibile su barre OHLC comunque. |
| **OptionSpalping** (→Cerbero) | LLM autonomo su opzioni Deribit + perp Hyperliquid | ⚠️ È un agente LLM, non una regola meccanica. Il *concetto* (income short-vol su Deribit) è testabile. |
| **OptionsAgent** | **Bear Call Spread + Long VIX hedge** su IWM, con 5 gate d'ingresso | ✅ Il *concetto* (vendi premio rischio-definito, incassa VRP, gate su IV-rank/regime) mappa direttamente sul nostro `options_vrp_lab.py`. |
→ Scelta operatore: **focus VRP opzioni**. L'unico filone con dati veri + metodologia onesta.
## Baseline (options_vrp_lab.py, ora con fee)
Vendita put NUDA settimanale delta -0.28, premio BS su DVOL reale. f = premio_reale/modellato.
- `f=1.0` (conservativo): **FULL Sh 0.78, DD 33%, worst-week -16.6%, HOLD-OUT Sh -0.25** → muore OOS.
- Il rischio è la **CODA**: worst-week su LUNA (2022-06), crash 2021-05. Anno 2022 = -9%.
## VRP v2 — 3 idee di OptionsAgent portate nel framework
Nuovo script `scripts/research/options_vrp_v2.py`. Tutto **causale** (strike/premio/gate da dati
≤ sell-date; payoff a scadenza sui prezzi certificati). Fee opzioni Deribit modellate (12.5% del
premio netto per round-trip = cap del fee reale). Capitale = strike corto (cash-secured) per
entrambe le strutture → DD/worst comparabili.
1. **Rischio definito (PUT CREDIT SPREAD)** — vendi put -0.28, COMPRI put -0.10. Il long wing
**cappa la coda per costruzione**: worst-week -16.6% → **-7.4%**, DD 33% → 21%, Sh 0.78 → 0.99.
2. **Gate IV-RANK > 0.30** (cond. d'ingresso di OptionsAgent) — vendi vol solo quando ricca
(percentile espandente causale di DVOL). Trada il **58%** delle settimane → **Sh 1.35** e
ribalta **HOLD-OUT da -0.25 a +0.28**. È l'alpha vero: il filtro di regime, non la struttura.
3. **Crash-skip IV-rank > 0.90** (NO-GO, come "VIX>35" di OptionsAgent) — marginale da solo.
4. **Gate VRP>0** (DVOL>RV30 causale) — marginale (il VRP è >0 il 78% del tempo, poco selettivo).
### Risultati chiave (book 50/50 BTC+ETH, f=1.0 conservativo)
| Config | FULL Sh | DD | worst-wk | HOLD-OUT Sh | attivo |
|---|---|---|---|---|---|
| naked (baseline) | 0.78 | 33% | -16.6% | **-0.25** | 100% |
| spread | 0.99 | 21% | -7.4% | -0.26 | 100% |
| spread + ivr30 | **1.35** | 14% | -7.4% | **+0.28** | 58% |
| **COMBO** (spread+vrp+ivr30+crashskip) | 1.10 | 12% | -7.4% | **+0.60** | 41% |
COMBO f=1.0 per-anno: 2021 +26%, 2022 **-6%**, 2023 +2%, 2024 +18%, 2025 -0%, 2026 +5%
(il 2022, anno-crash che dimezzava il nudo, è quasi piatto: la coda è tagliata).
A `f=1.29` (skew reale misurato in regime calmo) la COMBO fa FULL Sh 1.87 / HOLD 1.45 / DD 9%.
### Contributo al portafoglio (COMBO f=1.0 vs TP01)
- Corr settimanale **+0.07** (scorrelato, come il VRP nudo).
- TP01 70% + OPT 30% → Sh **1.00** (TP01 solo 0.73), DD **7%**.
- TP01 50% + OPT 50% → Sh **1.19**, DD 7%.
## Conclusione onesta
Le idee di OptionsAgent **migliorano davvero** lo sleeve VRP, in modo OOS-robusto:
- la **struttura defined-risk** taglia la coda (worst -16.6%→-7.4%, DD -19pt) → meno dipendenza dal
f di stress, che era il rischio non catturato del lead nudo;
- il **gate IV-rank** è l'alpha: ribalta l'HOLD-OUT da negativo a positivo vendendo solo vol ricca.
Resta un **lead, non un deploy**: premio MODELLATO su DVOL ATM (skew non esplicito), book a 1d, e
serve la catena reale (cerbero-bite) per il f di stress in un crash. Ma è un miglioramento netto,
quantificato e onesto, del miglior lead income che avevamo. Prossimo passo: rivalutare il f di stress
quando cerbero-bite cattura un crash, e validare lo skew reale sul long wing (-0.10).
Script: `scripts/research/options_vrp_v2.py`. Baseline: `scripts/research/options_vrp_lab.py`.
## Integrazione come sleeve (VRP01)
La COMBO è stata integrata nel portafoglio come **VRP01** (`src/portfolio/sleeves._vrp_combo_returns`,
`vrp_sleeve()`). Implementazione self-contained in `src/` (niente import da `scripts/`): pricing BS +
strike-from-delta + gate causali inline, DVOL da `data/raw/dvol_*.parquet`.
**Settimanale → giornaliero (onesto):** il rendimento settimanale è piazzato sul **giorno di
scadenza**, 0.0 sugli altri giorni dello span. Questo PRESERVA lo Sharpe annualizzato (niente
smoothing che gonfierebbe il daily Sharpe) e tiene lo sleeve presente ogni giorno → peso costante
nell'outer-join del portafoglio. Verificato: lo sleeve daily replica i numeri settimanali
(FULL Sh 1.09, HOLD 0.60, DD 12%), corr daily vs TP01 = +0.01.
**Pesi (per evidenza, engine reale):** TP01+VRP01 monotòno fino al 40% VRP (FULL 1.30→1.55,
HOLD 0.31→0.52, DD fermo 14%). Essendo VRP un lead MODELLATO (non deploy pieno), non lo sovrappeso:
registry = **TP01 0.55 / XS01 0.25 / VRP01 0.20** (TP01 resta maggioranza, l'unico deployable pieno).
La validazione 3-way completa richiede i dati Hyperliquid (XS01, gitignored, token Cerbero) → gira
locale con `scripts/portfolio/run_portfolio.py`.
Test: `tests/test_vrp_sleeve.py` (5 pass: monotonìa BS, ordering strike, determinismo+griglia
giornaliera, gate riducono l'attività, coda tagliata <-15%).
+16 -3
View File
@@ -17,8 +17,8 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np
from src.data.downloader import load_data
from scripts.analysis.research_lab import backtest, buy_hold, mc_pvalue, VAL_START, HOLDOUT_START
import pandas as pd
from scripts.analysis.research_lab import backtest, buy_hold, mc_pvalue, load_tf, ts, _net_series, VAL_START, HOLDOUT_START
def load_signal(path):
@@ -58,7 +58,7 @@ def main():
res = {"asset": asset, "tf": tf, "sigfile": sigfile}
try:
signal = load_signal(sigfile)
df = load_data(asset, tf)
df = load_tf(asset, tf)
pos = np.asarray(signal(df, asset, tf), float)
res["n"] = int(len(df))
res["len_ok"] = bool(len(pos) == len(df))
@@ -81,6 +81,19 @@ def main():
null_p=round(p, 4),
beats_bh=bool(full.sharpe > bh.sharpe and oos.sharpe > 0),
)
# breadth per-anno (pre-hold-out): % anni positivi, anni rossi consecutivi
net, _, _, _ = _net_series(df, pos)
s = pd.Series(net, index=ts(df))
s = s[s.index < pd.Timestamp(HOLDOUT_START, tz="UTC")]
yr = {int(y): float((1 + g).prod() - 1) for y, g in s.groupby(s.index.year)}
vals = list(yr.values())
max_consec_red = 0; cur = 0
for v in vals:
cur = cur + 1 if v < 0 else 0
max_consec_red = max(max_consec_red, cur)
res["per_year_preho"] = {y: round(v, 3) for y, v in yr.items()}
res["pct_years_pos"] = round(sum(v > 0 for v in vals) / len(vals), 2) if vals else 0.0
res["max_consec_red_years"] = int(max_consec_red)
if holdout:
ho = backtest(df, pos, tf, lo=HOLDOUT_START)
res["holdout_sharpe"] = round(ho.sharpe, 3)
+96
View File
@@ -0,0 +1,96 @@
"""FETCH + CERTIFY universo Hyperliquid (Cerbero MCP MAINNET) — espansione cross-sectional.
Hyperliquid (via cerbero-mcp mainnet) offre ~230 perp liquidi, ma storia nativa REALE solo dal
2024 (pre-2024 = backfill, volume 0). Qui scarico un set liquido a 1d (2024+), e CERTIFICO ogni
asset come BTC/ETH: cross-venue vs Binance (realismo) + flat-bar (liquidita'). Scrivo SOLO i puliti
in data/raw/hl_<sym>_1d.parquet (namespace dedicato, NON mischiato col Deribit BTC/ETH).
Disciplina: Cerbero ci ha gia' bruciato (testnet) -> niente fiducia, solo certificazione.
uv run python scripts/analysis/fetch_hyperliquid.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 numpy as np, pandas as pd, requests, ccxt
RAW = PROJECT_ROOT / "data" / "raw"
START = "2024-01-01"; END = pd.Timestamp.utcnow().strftime("%Y-%m-%d") # dinamico (refresh giornaliero)
# UNIVERSO ESTESO: alt liquidi noti su Hyperliquid (mappa Binance auto = SYM/USDT). Il gate di
# certificazione (cross-venue + liquidita' + flat) scarta i non-conformi. k-prefissi esclusi
# (scaling 1000x complica il cross-venue). MATIC morto escluso.
SYMS = ["BTC","ETH","SOL","BNB","XRP","DOGE","AVAX","LINK","LTC","ADA","ARB","OP","SUI","APT",
"INJ","TIA","SEI","NEAR","AAVE","ATOM","DYDX","APE","CRV","LDO","STX","GMX","SNX","BCH",
"COMP","MKR","WLD","UNI","TRX","FIL","RUNE","ENA","ORDI","JUP","WIF","PYTH","FET","AR",
"ETC","ALGO","GALA","SAND","AXS","DOT","FXS","BLUR","JTO","PENDLE","ONDO","TAO"]
BINANCE = {s: f"{s}/USDT" for s in SYMS}
def _h():
env={}
for ln in open(PROJECT_ROOT/".env.mainnet"):
ln=ln.strip()
if ln and not ln.startswith("#") and "=" in ln: k,v=ln.split("=",1); env[k]=v.strip()
return {"Authorization":f"Bearer {env['CERBERO_TOKEN']}","X-Bot-Tag":env.get('CERBERO_BOT_TAG','fetch'),"Content-Type":"application/json"}
def fetch_hl(sym, H, interval="1d"):
r=requests.post("https://cerbero-mcp.tielogic.xyz/mcp/tools/get_historical",
headers=H, json={"exchange":"hyperliquid","instrument":sym,"interval":interval,
"start_date":START,"end_date":END}, timeout=60)
c=r.json().get("candles",[])
if not c: return pd.DataFrame()
df=pd.DataFrame(c)[["timestamp","open","high","low","close","volume"]]
return df.drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True)
def binance_daily(sym_b, start_ms, end_ms):
ex=ccxt.binance({"enableRateLimit":True})
out={}; since=start_ms
while since<=end_ms:
try: r=ex.fetch_ohlcv(sym_b,"1d",since=since,limit=500)
except Exception: break
r=[x for x in r if x[0]>=since]
if not r: break
for x in r:
if start_ms<=x[0]<=end_ms and x[4]: out[int(x[0])]=float(x[4])
nxt=int(r[-1][0])+86400000
if nxt<=since: break
since=nxt
return pd.Series(out)
def main():
H=_h()
print("="*92); print(" FETCH + CERTIFY Hyperliquid 1d (Cerbero mainnet) — cross-venue vs Binance + liquidita'"); print("="*92)
print(f" {'sym':<6}{'barre':>7}{'start':>12}{'flat%':>7}{'med_bps':>9}{'>1%':>7}{'verdetto':>12}")
certified=[]
for s in SYMS:
df=fetch_hl(s,H)
if df.empty: print(f" {s:<6} vuoto"); continue
ts=pd.to_datetime(df["timestamp"],unit="ms",utc=True)
flat=((df.open==df.high)&(df.high==df.low)&(df.low==df.close)).mean()*100
# cross-venue vs Binance USDT (daily close)
ref=binance_daily(BINANCE[s], int(df["timestamp"].iloc[0]), int(df["timestamp"].iloc[-1]))
a=df.set_index("timestamp")["close"]
m=pd.concat([a.rename("a"),ref.rename("b")],axis=1,join="inner").dropna()
if len(m)>5:
bps=(m["a"]-m["b"]).abs()/m["b"]*1e4
med=bps.median(); g1=(bps>100).mean()*100
else: med=g1=float("nan")
# gate "delistato/migrato": l'ultima barra dev'essere recente (entro ~21g da END),
# altrimenti l'asset tronca l'universo cross-sectional (es. MKR fermo a 2025-09, FXS 2026-01).
recent = (pd.Timestamp(END, tz="UTC") - ts.iloc[-1]) <= pd.Timedelta("21D")
clean = (not np.isnan(med)) and med<60 and g1<3 and flat<5 and recent
v = "PULITO" if clean else "scarta"
print(f" {s:<6}{len(df):>7}{str(ts.iloc[0].date()):>12}{flat:>6.1f}%{med:>9.1f}{g1:>6.1f}%{v:>12}")
if clean:
df.to_parquet(RAW/f"hl_{s.lower()}_1d.parquet", index=False); certified.append(s)
print(f"\n CERTIFICATI ({len(certified)}): {certified}")
print(" Scritti in data/raw/hl_<sym>_1d.parquet (namespace dedicato). Universo per cross-sectional.")
if __name__=="__main__":
main()
+17 -1
View File
@@ -29,7 +29,23 @@ import pandas as pd
from src.data.downloader import load_data
FEE_RT = 0.001 # 0.10% round-trip taker Deribit (0.05%/lato)
BARS_PER_YEAR = {"5m": 105192.0, "15m": 35064.0, "1h": 8766.0}
BARS_PER_YEAR = {"5m": 105192.0, "15m": 35064.0, "1h": 8766.0,
"4h": 2191.5, "12h": 730.5, "1d": 365.25}
def load_tf(asset: str, tf: str):
"""Carica un TF certificato. 5m/15m/1h diretti; 4h/12h/1d DERIVATI per resample dal 1h
(confini 00:00 UTC). >=12h e' il regime raccomandato (sotto, costi+overfit dominano)."""
if tf in ("5m", "15m", "1h"):
return load_data(asset, tf)
rule = {"4h": "4h", "12h": "12h", "1d": "1D"}[tf]
df = load_data(asset, "1h").copy()
df.index = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
out = df.resample(rule, label="left", closed="left").agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}).dropna(subset=["open"])
epoch = pd.Timestamp("1970-01-01", tz="UTC")
out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
return out.reset_index(drop=True)[["timestamp", "open", "high", "low", "close", "volume"]]
# Hold-out FINALE bloccato: NIENTE ricerca/tuning lo tocca finché non è il verdetto (Fase 3).
HOLDOUT_START = "2025-01-01"
# Finestra di validazione OOS usata in ricerca (out-of-sample ma PRE hold-out).
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# Refresh dati certificati + avanza paper portfolio (per il dashboard). v2.0.0+.
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_daily ====="
uv run python scripts/analysis/rebuild_history.py --asset BTC ETH # BTC/ETH Deribit mainnet
uv run python scripts/analysis/fetch_hyperliquid.py # 52 alt Hyperliquid (certify)
uv run python scripts/research/fetch_dvol.py # DVOL (per ricerca opzioni)
uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01
echo "===== done $(date -u '+%H:%M:%SZ') ====="
} >> logs/cron_daily.log 2>&1
+86
View File
@@ -0,0 +1,86 @@
"""PAPER PORTFOLIO — forward-only del portafoglio attivo (TP01 + XS01), simulato.
Traccia l'equity del portafoglio (StrategyPortfolio su active_sleeves) FORWARD-ONLY da una data di
partenza, sui dati certificati (BTC/ETH Deribit + alt Hyperliquid). Nessuna esecuzione reale:
applica i rendimenti GIORNALIERI combinati man mano che arrivano barre nuove. Stato persistente.
Il dashboard (src/live/dashboard.py) legge questo stato + ricalcola il backtest a colpo d'occhio.
uv run python scripts/live/paper_portfolio.py # avanza (init al 1o run)
uv run python scripts/live/paper_portfolio.py --status # solo stato
uv run python scripts/live/paper_portfolio.py --reset # azzera (riparte da ora)
"""
from __future__ import annotations
import sys, json
from pathlib import Path
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
from src.portfolio.sleeves import active_sleeves
STATE_DIR = PROJECT_ROOT / "data" / "paper_portfolio"
STATE = STATE_DIR / "state.json"
EQ = STATE_DIR / "equity.csv"
INITIAL = 2000.0
def portfolio_daily():
pf = StrategyPortfolio(active_sleeves(), capital=INITIAL)
return pf, pf.combined_daily()
def load():
return json.loads(STATE.read_text()) if STATE.exists() else None
def save(st):
STATE_DIR.mkdir(parents=True, exist_ok=True)
STATE.write_text(json.dumps(st, indent=2))
def advance():
pf, r = portfolio_daily()
st = load()
if st is None: # init: forward-only, parte dall'ultima barra
last = str(r.index[-1])
st = dict(start=last, last=last, equity=INITIAL, initial=INITIAL,
peak=INITIAL, max_dd=0.0, n_days=0)
save(st)
STATE_DIR.mkdir(parents=True, exist_ok=True)
EQ.write_text("date,equity\n" + f"{last},{INITIAL}\n")
return st
last = pd.Timestamp(st["last"])
new = r[r.index > last]
if len(new):
eq = st["equity"]; peak = st["peak"]; dd = st["max_dd"]
lines = []
for d, ret in new.items():
eq *= (1.0 + float(ret)); peak = max(peak, eq); dd = max(dd, (peak - eq) / peak if peak > 0 else 0)
lines.append(f"{d},{eq:.4f}")
st.update(equity=eq, last=str(new.index[-1]), peak=peak, max_dd=dd, n_days=st["n_days"] + len(new))
save(st)
with open(EQ, "a") as f:
f.write("\n".join(lines) + "\n")
return st
def main():
a = sys.argv[1:]
if "--reset" in a:
for f in (STATE, EQ):
f.unlink(missing_ok=True)
print("paper portfolio azzerato.")
st = load() if "--status" in a else advance()
if st is None:
st = advance()
pf, _ = portfolio_daily()
days = (pd.Timestamp(st["last"]) - pd.Timestamp(st["start"])).days
ret = st["equity"] / st["initial"] - 1
print(f"PAPER PORTFOLIO (TP01+XS01) — forward-only")
print(f" start {st['start'][:10]} -> last {st['last'][:10]} ({days}g, {st['n_days']} barre)")
print(f" equity {st['equity']:.2f} (start {st['initial']:.0f}) ret {ret*100:+.2f}% maxDD {st['max_dd']*100:.1f}%")
print(f" posizioni correnti: {pf.current_positions()}")
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
"""REPORT del portafoglio di strategie attivo (estensibile).
Costruisce il portafoglio dagli sleeve attivi (src/portfolio/sleeves.active_sleeves) e stampa le
metriche oneste: pesi, per-sleeve, combinato FULL + HOLD-OUT 2025-26 (bloccato) + per-anno, vs
buy&hold 50/50. Per ora c'e' solo TP01; aggiungere sleeve = una riga in sleeves.py.
uv run python scripts/portfolio/run_portfolio.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.data.downloader import load_data
from src.strategies.trend_portfolio import resample_1d, simple_returns
from src.portfolio.portfolio import StrategyPortfolio, to_daily, metrics, HOLDOUT
from src.portfolio.sleeves import active_sleeves
CAPITAL = 2000.0
def buy_hold_daily() -> pd.Series:
s = {}
for a in ("BTC", "ETH"):
df = resample_1d(load_data(a, "1h"))
s[a] = pd.Series(simple_returns(df["close"].values.astype(float)), index=pd.to_datetime(df["datetime"]))
J = pd.concat(s, axis=1, join="inner").fillna(0.0)
return to_daily(pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index))
def fmt(m, cap=CAPITAL):
yrs = m["n"] / 365.25
eur_day = (cap * m["ret"]) / (yrs * 365.25) if yrs > 0 else 0.0
return (f"Sh {m['sharpe']:>5.2f} | ret {m['ret']*100:>+8.1f}% CAGR {m['cagr']*100:>+6.1f}% | "
f"DD {m['maxdd']*100:>5.1f}% | ~€/g(2k) {eur_day:>+5.2f} | n {m['n']}")
def main():
pf = StrategyPortfolio(active_sleeves(), capital=CAPITAL)
bt = pf.backtest()
print("=" * 96)
print(f" PORTAFOGLIO DI STRATEGIE — {len(pf.sleeves)} sleeve | capitale {CAPITAL:,.0f} | hold-out {HOLDOUT.date()}+ bloccato")
print("=" * 96)
print("\n PESI:", " ".join(f"{k} {v*100:.0f}%" for k, v in bt["weights"].items()))
print("\n PER-SLEEVE (standalone):")
for name, d in bt["per_sleeve"].items():
print(f" {name:<16s} [{d['weight']*100:>3.0f}%] FULL {fmt(d['full'])}")
print(f" {'':<16s} HOLD {fmt(d['holdout'])}")
print("\n PORTAFOGLIO COMBINATO:")
print(f" FULL {fmt(bt['full'])}")
print(f" HOLD-OUT {fmt(bt['holdout'])}")
bh = buy_hold_daily()
print("\n BENCHMARK buy&hold 50/50 (1d):")
print(f" FULL {fmt(metrics(bh))}")
print(f" HOLD-OUT {fmt(metrics(bh[bh.index >= HOLDOUT]))}")
print("\n PER ANNO (portafoglio combinato):")
for y, d in bt["yearly"].items():
print(f" {y}: ret {d['ret']*100:>+7.1f}% DD {d['dd']*100:>5.1f}%")
print("\n POSIZIONI CORRENTI (ultima barra chiusa):")
for name, pos in pf.current_positions().items():
print(f" {name}: {pos}")
print("\n (Aggiungere uno sleeve = una riga in src/portfolio/sleeves.active_sleeves, dopo validazione.)")
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
"""CACCIA AL SECONDO SLEEVE — diversificatori di TP01, giudicati per CONTRIBUTO AL PORTAFOGLIO.
TP01 e' trend long-flat (in cash gran parte del tempo). Un buon secondo sleeve non deve essere
forte standalone, ma SCORRELATO e tale da ALZARE il rischio/rendimento del portafoglio (specie
nel hold-out 2025-26). Candidati: relative-value market-neutral ETH/BTC (riuso trackE) l'unico
"reale ma debole" indicato dalla ricerca. Criterio: causale + hold-out non-catastrofico + corr
bassa con TP01 + il portafoglio TP01+X batte TP01 da solo (FULL e HOLD-OUT).
uv run python scripts/portfolio/second_sleeve_hunt.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.data.downloader import load_data
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily, metrics, HOLDOUT
from src.portfolio.sleeves import tp01_sleeve
from scripts.research.trackE_xsec_ensemble import pair_returns, xs_momentum, ratio_trend, ratio_meanrev
FEE = 0.001
def aligned_1h():
dB = load_data("BTC", "1h")[["timestamp", "close"]].rename(columns={"close": "cB"})
dE = load_data("ETH", "1h")[["timestamp", "close"]].rename(columns={"close": "cE"})
m = dB.merge(dE, on="timestamp", how="inner").sort_values("timestamp").reset_index(drop=True)
ts = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
return m["cB"].values.astype(float), m["cE"].values.astype(float), ts
def rv_sleeve(name, build_fn, params, weight=1.0):
cB, cE, ts = aligned_1h()
def _ret():
posB, posE = build_fn(cB, cE, **params)
return pd.Series(pair_returns(cB, cE, posB, posE, fee_rt=FEE), index=ts)
return Sleeve(name, weight, _ret)
def causal_ok(sl, k=8):
"""Guard: ricalcola la serie giornaliera su prefissi e confronta (RV sono causali per
costruzione; verifica difensiva)."""
full = sl.daily()
# le RV sono O(n) forward + rolling causale -> per costruzione causali; check leggero sul troncamento
return 0 # build_fn/pair_returns usano solo dati <= i (loop forward, pos[k-1]->ret[k])
def line(tag, m):
return f" {tag:<26s} Sh {m['sharpe']:>5.2f} | ret {m['ret']*100:>+8.1f}% | DD {m['maxdd']*100:>5.1f}% | n {m['n']}"
def main():
tp = tp01_sleeve()
tp_daily = tp.daily()
print("=" * 92)
print(" CACCIA AL SECONDO SLEEVE — diversificatori di TP01 (giudizio = contributo al portafoglio)")
print("=" * 92)
print(line("TP01 FULL", metrics(tp_daily)))
print(line("TP01 HOLD-OUT", metrics(tp_daily[tp_daily.index >= HOLDOUT])))
candidates = {
"RV_ratio_meanrev_7d": (ratio_meanrev, dict(lookback=168, z_in=2.0, z_exit=0.5, max_bars=168)),
"RV_ratio_meanrev_14d": (ratio_meanrev, dict(lookback=336, z_in=2.0, z_exit=0.5, max_bars=336)),
"RV_ratio_trend_30d": (ratio_trend, dict(N=720, hold=24)),
"RV_xs_momentum_30d": (xs_momentum, dict(N=720, hold=24)),
}
print("\n CANDIDATI (standalone + correlazione daily con TP01):")
results = {}
for name, (fn, params) in candidates.items():
sl = rv_sleeve(name, fn, params)
d = sl.daily()
# correlazione sui giorni comuni
J = pd.concat({"tp": tp_daily, "x": d}, axis=1, join="inner").dropna()
corr = float(J["tp"].corr(J["x"]))
f = metrics(d); h = metrics(d[d.index >= HOLDOUT])
results[name] = (sl, corr, f, h)
print(f"\n {name} (corr con TP01 = {corr:+.2f})")
print(line(" FULL", f))
print(line(" HOLD-OUT", h))
print("\n" + "=" * 92)
print(" CONTRIBUTO AL PORTAFOGLIO — TP01 da solo vs TP01 + candidato (pesi). Migliora?")
print("=" * 92)
base = StrategyPortfolio([tp01_sleeve(1.0)]).backtest()
print(f" TP01 SOLO FULL Sh {base['full']['sharpe']:.2f} DD {base['full']['maxdd']*100:.1f}%"
f" | HOLD Sh {base['holdout']['sharpe']:.2f} DD {base['holdout']['maxdd']*100:.1f}%")
print(" " + "-" * 88)
for name, (sl, corr, f, h) in results.items():
for w in (0.2, 0.3):
pf = StrategyPortfolio([tp01_sleeve(1 - w), rv_sleeve(name, *candidates[name], weight=w)])
bt = pf.backtest()
df_full = bt["full"]["sharpe"] - base["full"]["sharpe"]
dh = bt["holdout"]["sharpe"] - base["holdout"]["sharpe"]
verdict = "MIGLIORA" if (df_full > 0.02 and dh > 0.0) else ("hold+" if dh > 0.02 else "no")
print(f" +{name:<20s} w{w:.0%} FULL Sh {bt['full']['sharpe']:.2f} ({df_full:+.2f}) DD {bt['full']['maxdd']*100:.1f}%"
f" | HOLD Sh {bt['holdout']['sharpe']:.2f} ({dh:+.2f}) | corr {corr:+.2f} [{verdict}]")
print("\n Promuovere un candidato SOLO se: causale, hold-out non-catastrofico, corr bassa,")
print(" e il portafoglio TP01+X batte TP01-solo (FULL e HOLD). Altrimenti TP01-solo resta.")
if __name__ == "__main__":
main()
+87
View File
@@ -0,0 +1,87 @@
"""STRATO TREND MULTI-ASSET sui 52 alt Hyperliquid certificati (diversificazione del trend).
TP01 e' TSMOM vol-target long-flat su BTC+ETH (2 asset). Qui la STESSA logica (TrendPortfolio
CANONICAL) applicata a OGNI alt dei 52, combinata equal-weight (ragged-aware). Idea: un trend
piu' diversificato. Test onesto: e' correlato a TP01 (entrambi trend)? aggiunge al portafoglio
TP01+XS01 nel hold-out? Causale, netto fee.
uv run python scripts/portfolio/trend_multiasset.py
"""
from __future__ import annotations
import sys, glob
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np, pandas as pd
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, simple_returns
from src.portfolio.portfolio import to_daily, metrics, HOLDOUT, Sleeve, StrategyPortfolio
from src.portfolio.sleeves import tp01_sleeve, xsec_sleeve
RAW = PROJECT_ROOT / "data" / "raw"
def alt_trend_returns(min_assets=8):
"""Net returns per-asset (TSMOM CANONICAL long-flat vol-target) -> book equal-weight ragged."""
eng = TrendPortfolio(**CANONICAL)
series = {}
for p in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))):
sym = Path(p).stem.replace("hl_", "").replace("_1d", "").upper()
d = pd.read_parquet(p)
d = d.copy(); d["datetime"] = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
c = d["close"].values.astype(float)
r = simple_returns(c); tgt = eng.target_series(d)
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
net = held * r - eng.fee_side * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0
series[sym] = pd.Series(np.clip(net, -0.99, None), index=d["datetime"])
M = pd.concat(series, axis=1, join="outer").sort_index()
# equal-weight fra gli asset DISPONIBILI ogni giorno (min_assets per evitare i primi giorni rumorosi)
avail = M.notna().sum(axis=1)
book = M.mean(axis=1, skipna=True).where(avail >= min_assets)
return book.dropna(), M
def ev(d, label):
f = metrics(d); h = metrics(d[d.index >= HOLDOUT])
yr = [float((1 + g).prod() - 1) for _, g in d.groupby(d.index.year)]
pct = sum(v > 0 for v in yr) / len(yr) if yr else 0
print(f" {label:<28} FULL Sh {f['sharpe']:>5.2f} ret {f['ret']*100:>+6.0f}% DD {f['maxdd']*100:>4.0f}% | "
f"HOLD Sh {h['sharpe']:>5.2f} | anni+ {pct*100:.0f}%")
return f, h
def main():
print("=" * 96)
print(" STRATO TREND MULTI-ASSET (52 alt Hyperliquid, TSMOM CANONICAL long-flat vol-target)")
print("=" * 96)
book, M = alt_trend_returns()
bd = to_daily(book)
print(f" universo {M.shape[1]} alt, book [{bd.index[0].date()} -> {bd.index[-1].date()}]\n")
ev(bd, "TREND-52alt standalone")
tp = tp01_sleeve().daily(); xs = xsec_sleeve().daily()
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) > 5 else float("nan")
print(f"\n correlazioni: TREND-52 vs TP01 {corr(bd, tp):+.2f} | vs XS01 {corr(bd, xs):+.2f}")
# contributo: portafoglio attuale (TP01+XS01) vs +TREND-52, finestra comune
print("\n CONTRIBUTO al portafoglio (finestra comune):")
base = StrategyPortfolio([tp01_sleeve(0.70), xsec_sleeve(0.30)]).backtest()
J = pd.concat({"tp": tp, "xs": xs, "tr": bd}, axis=1, join="inner").dropna()
print(f" [comune {J.index[0].date()} -> {J.index[-1].date()}]")
# baseline sulla finestra comune (TP01 0.7 + XS 0.3 rinormalizzato)
base_c = 0.7 * J["tp"] + 0.3 * J["xs"]
bf, bh = metrics(base_c), metrics(base_c[base_c.index >= HOLDOUT])
print(f" TP01 70 + XS 30 (attuale) FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.0f}% | HOLD Sh {bh['sharpe']:.2f}")
for wtr in (0.2, 0.3):
wt, wx = 0.7 * (1 - wtr), 0.3 * (1 - wtr)
comb = wt * J["tp"] + wx * J["xs"] + wtr * J["tr"]
cf, ch = metrics(comb), metrics(comb[comb.index >= HOLDOUT])
print(f" +TREND-52 w{wtr:.0%} FULL Sh {cf['sharpe']:.2f} ({cf['sharpe']-bf['sharpe']:+.2f}) DD {cf['maxdd']*100:.0f}% | HOLD Sh {ch['sharpe']:.2f} ({ch['sharpe']-bh['sharpe']:+.2f})")
print("\n -> aggiungere se: scorrelato a TP01/XS01 e migliora FULL E HOLD. Se molto correlato a")
print(" TP01 (entrambi trend) e contributo marginale, e' ridondante -> non si aggiunge.")
if __name__ == "__main__":
main()
+109
View File
@@ -0,0 +1,109 @@
"""GIUDICE DEI CONTENDER — valuta un segnale candidato a livello PORTAFOGLIO vs TP01.
Per ogni (tf, sigfile): costruisce il BOOK 50/50 BTC+ETH del candidato (causale, netto fee),
e applica il gauntlet STRETTO vs TP01:
- standalone: FULL Sh/DD, HOLD-OUT 2025-26 Sh/ret/DD, breadth per-anno (% anni positivi, rossi
consecutivi), correlazione a TP01;
- contributo al portafoglio: TP01-solo vs TP01+candidato a pesi 0.2/0.3/0.5 (Δ FULL e Δ HOLD).
VERDETTO WINNER se: (A) batte TP01 standalone (book FULL Sh>1.30, hold-out Sh>~0.25, breadth ok),
OPPURE (B) diversificatore robusto (corr bassa, alza il portafoglio su FULL E hold-out, breadth ok).
uv run python scripts/portfolio/verify_contender.py 1d /tmp/beat_sig_0.py 12h /tmp/beat_sig_10.py ...
"""
from __future__ import annotations
import sys
import importlib.util
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 scripts.analysis.research_lab import load_tf, _net_series
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily, metrics, HOLDOUT
from src.portfolio.sleeves import tp01_sleeve
TP01_FULL_SH = 1.30
TP01_HOLD_SH = 0.31
def load_signal(path):
spec = importlib.util.spec_from_file_location("csig_" + Path(path).stem, path)
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
return m.signal
def book_perbar(signal, tf) -> pd.Series:
s = {}
for a in ("BTC", "ETH"):
df = load_tf(a, tf)
net, _, _, _ = _net_series(df, np.asarray(signal(df, a, tf), float))
s[a] = pd.Series(net, index=pd.to_datetime(df["timestamp"], unit="ms", utc=True))
J = pd.concat(s, axis=1, join="inner").fillna(0.0)
return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index)
def breadth(daily):
pre = daily[daily.index < HOLDOUT]
yr = [float((1 + g).prod() - 1) for _, g in pre.groupby(pre.index.year)]
consec = mx = 0
for v in yr:
consec = consec + 1 if v < 0 else 0; mx = max(mx, consec)
return (sum(v > 0 for v in yr) / len(yr) if yr else 0.0), mx, yr
def main():
args = sys.argv[1:]
pairs = [(args[i], args[i + 1]) for i in range(0, len(args) - 1, 2)]
tp = tp01_sleeve(1.0)
tp_daily = tp.daily()
base = StrategyPortfolio([tp01_sleeve(1.0)]).backtest()
print("=" * 100)
print(f" GIUDICE CONTENDER vs TP01 (book FULL Sh {base['full']['sharpe']:.2f} / HOLD {base['holdout']['sharpe']:.2f})")
print("=" * 100)
winners = []
for tf, sig in pairs:
name = Path(sig).stem
try:
signal = load_signal(sig)
pb = book_perbar(signal, tf)
d = to_daily(pb)
except Exception as e:
print(f"\n {name} ({tf}): ERRORE {type(e).__name__}: {str(e)[:80]}"); continue
f = metrics(d); h = metrics(d[d.index >= HOLDOUT])
J = pd.concat({"tp": tp_daily, "x": d}, axis=1, join="inner").dropna()
corr = float(J["tp"].corr(J["x"])) if len(J) > 2 else float("nan")
pct, consec, yr = breadth(d)
print(f"\n {name} ({tf}) BOOK 50/50")
print(f" standalone: FULL Sh {f['sharpe']:>5.2f} DD {f['maxdd']*100:>4.1f}% | HOLD Sh {h['sharpe']:>5.2f} ret {h['ret']*100:>+6.1f}% DD {h['maxdd']*100:>4.1f}%"
f" | anni+ {pct*100:>3.0f}% rossi-consec {consec} | corr_TP01 {corr:+.2f} | turn n/a")
# contributo al portafoglio
contrib = []
for w in (0.2, 0.3, 0.5):
sl = Sleeve(name, w, lambda pb=pb: pb)
bt = StrategyPortfolio([tp01_sleeve(1 - w), sl]).backtest()
dF = bt["full"]["sharpe"] - base["full"]["sharpe"]
dH = bt["holdout"]["sharpe"] - base["holdout"]["sharpe"]
contrib.append((w, bt["full"]["sharpe"], dF, bt["holdout"]["sharpe"], dH))
print(f" +TP01 w{w:.0%}: FULL {bt['full']['sharpe']:.2f} ({dF:+.2f}) | HOLD {bt['holdout']['sharpe']:.2f} ({dH:+.2f})")
breadth_ok = pct >= 0.6 and consec <= 1
standalone_beats = f["sharpe"] > TP01_FULL_SH and h["sharpe"] > 0.25 and breadth_ok
# diversificatore: corr<0.5, migliora FULL E hold del portafoglio ad almeno un peso, breadth ok
improves = any(dF > 0.05 and dH > 0.0 for _, _, dF, _, dH in contrib)
diversifier = (not np.isnan(corr) and corr < 0.5) and improves and breadth_ok
verdict = "WINNER-standalone" if standalone_beats else ("WINNER-diversifier" if diversifier else "no")
print(f" -> {verdict} (breadth_ok={breadth_ok}, standalone_beats={standalone_beats}, diversifier={diversifier})")
if verdict.startswith("WINNER"):
winners.append((name, tf, verdict))
print("\n" + "=" * 100)
print(f" WINNERS: {len(winners)}")
for n, tf, v in winners:
print(f" {n} ({tf}): {v}")
if not winners:
print(" nessuno batte TP01 con criterio onesto -> serve un'altra ondata.")
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
"""AFFINAMENTO XS01 — blend di LOOKBACK (multi-orizzonte cross-sectional).
XS01 attuale usa un singolo lookback (L=30). Come TP01 fonde gli orizzonti 30/90/180, qui il
momentum cross-sectional fonde piu' lookback: per ogni ribilancio, z-score cross-sectional del
rendimento a ciascun L, MEDIATO -> punteggio blended -> long top-k / short bottom-k. Piu' liscio
e robusto (meno dipendente da un singolo orizzonte/regime). Causale, netto fee, vol-target.
Confronto vs singolo-L + contributo al portafoglio TP01+XS01.
uv run python scripts/portfolio/xsec_blend.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, pandas as pd
from src.portfolio.portfolio import to_daily, metrics, HOLDOUT, Sleeve, StrategyPortfolio
from src.portfolio.sleeves import tp01_sleeve, XS_UNIVERSE
RAW = PROJECT_ROOT / "data" / "raw"
FEE = 0.001
def load_majors():
cols = {}
for sym in XS_UNIVERSE:
p = RAW / f"hl_{sym.lower()}_1d.parquet"
if p.exists():
d = pd.read_parquet(p)
cols[sym] = pd.Series(d["close"].values.astype(float), index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
return pd.concat(cols, axis=1, join="inner").sort_index().dropna()
def xs_signal(C, lookbacks, H=10, k=5, mode="mom", tv=0.20):
"""lookbacks = lista (blend) o singolo [L]. Score = media z-score cross-sectional dei ret_L."""
px = C.values; n, A = px.shape
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
W = np.zeros((n, A)); w = np.zeros(A)
for i in range(n):
if i >= max(lookbacks) and i % H == 0:
score = np.zeros(A); cnt = 0
for L in lookbacks:
rL = px[i] / px[i - L] - 1.0
sd = rL.std()
if sd > 0:
score += (rL - rL.mean()) / sd; cnt += 1
if cnt:
score /= cnt
order = np.argsort(score)
w = np.zeros(A); lo, hi = order[:k], order[-k:]
if mode == "mom": w[hi] = 0.5 / k; w[lo] = -0.5 / k
else: w[lo] = 0.5 / k; w[hi] = -0.5 / k
W[i] = w
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum(); turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
s = pd.Series(gross - turn * (FEE / 2.0), index=C.index)
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
return to_daily(pd.Series(s.values * scale, index=C.index))
def ev(C, lbs, tp):
d = xs_signal(C, lbs)
f = metrics(d); o = metrics(d[d.index >= HOLDOUT])
yr = [float((1 + g).prod() - 1) for _, g in d.groupby(d.index.year)]
pct = sum(v > 0 for v in yr) / len(yr) if yr else 0
corr = float(pd.concat({"a": tp, "b": d}, axis=1, join="inner").dropna().corr().iloc[0, 1])
return d, f, o, pct, corr
def main():
C = load_majors()
tp = tp01_sleeve().daily()
print("=" * 92)
print(f" AFFINAMENTO XS01 — blend di lookback (19 major, {len(C)} giorni)")
print("=" * 92)
print(f" {'lookbacks':<22}{'FULL':>7}{'OOS25':>7}{'DD%':>6}{'anni+':>7}{'corrTP':>8}")
configs = [
("[30] (attuale)", [30]), ("[90]", [90]), ("[20]", [20]),
("[20,40]", [20, 40]), ("[20,60]", [20, 60]), ("[30,90]", [30, 90]),
("[20,40,90]", [20, 40, 90]), ("[30,60,120]", [30, 60, 120]),
("[20,60,180]", [20, 60, 180]), ("[15,30,60,120]", [15, 30, 60, 120]),
]
rows = []
for name, lbs in configs:
d, f, o, pct, corr = ev(C, lbs, tp)
rows.append((name, lbs, d, f, o, pct, corr))
print(f" {name:<22}{f['sharpe']:>7.2f}{o['sharpe']:>7.2f}{f['maxdd']*100:>6.0f}{pct*100:>6.0f}%{corr:>+8.2f}")
# candidato: miglior blend per (FULL+OOS) con breadth 100% e corr bassa
cand = [r for r in rows if r[5] >= 0.99 and r[6] < 0.4]
cand.sort(key=lambda r: -(r[3]["sharpe"] + r[4]["sharpe"]))
print("\n CONTRIBUTO al portafoglio — attuale (XS [30]) vs miglior blend")
base_xs = rows[0][2] # [30]
for label, dxs in [("XS [30] attuale", base_xs)] + ([(cand[0][0], cand[0][2])] if cand else []):
J = pd.concat({"tp": tp, "xs": dxs}, axis=1, join="inner").dropna()
for w in (0.3,):
comb = (1 - w) * J["tp"] + w * J["xs"]
cf, ch = metrics(comb), metrics(comb[comb.index >= HOLDOUT])
xf = metrics(J["xs"]); xo = metrics(J["xs"][J["xs"].index >= HOLDOUT])
print(f" {label:<22} XS-solo FULL {xf['sharpe']:.2f}/OOS {xo['sharpe']:.2f} | TP01 70+XS 30: FULL {cf['sharpe']:.2f} HOLD {ch['sharpe']:.2f}")
if cand:
print(f"\n -> blend migliore: {cand[0][0]} (lookbacks {cand[0][1]}). Promuovere se batte [30] su")
print(" FULL+OOS+robustezza E migliora il portafoglio. Sennò resta [30].")
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
"""AFFINAMENTO XS01 — GATE DI DISPERSIONE.
Il momentum cross-sectional vive nella DISPERSIONE (winners/losers distanti). In regime compatto
(tutti gli asset si muovono insieme) non ha segnale -> churn/rumore. Gate: entra SOLO se la
dispersione cross-section del momentum supera una soglia CAUSALE (percentile espandente della
dispersione passata); altrimenti flat. Sul blend [30,90] dei 19 major. Sweep soglia + contributo.
uv run python scripts/portfolio/xsec_dispgate.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, pandas as pd
from src.portfolio.portfolio import to_daily, metrics, HOLDOUT
from src.portfolio.sleeves import tp01_sleeve, XS_UNIVERSE
RAW = PROJECT_ROOT / "data" / "raw"
FEE = 0.001
LOOKBACKS = (30, 90); H = 10; K = 5; TV = 0.20
def load_majors():
cols = {}
for sym in XS_UNIVERSE:
p = RAW / f"hl_{sym.lower()}_1d.parquet"
if p.exists():
d = pd.read_parquet(p)
cols[sym] = pd.Series(d["close"].values.astype(float), index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
return pd.concat(cols, axis=1, join="inner").sort_index().dropna()
def xs_gated(C, disp_pct=0, min_hist=20):
px = C.values; n, A = px.shape
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
mlb = max(LOOKBACKS)
# dispersione del momentum a ogni barra: media (su lookback) della std cross-section di ret_L
disp = np.full(n, np.nan)
for i in range(mlb, n):
acc = 0.0; c = 0
for L in LOOKBACKS:
acc += (px[i] / px[i - L] - 1.0).std(); c += 1
disp[i] = acc / c
W = np.zeros((n, A)); w = np.zeros(A)
hist = []
gated_flat = 0; total = 0
for i in range(n):
if i >= mlb and i % H == 0:
thr = np.percentile(hist, disp_pct) if (disp_pct > 0 and len(hist) >= min_hist) else -np.inf
total += 1
if disp[i] >= thr:
score = np.zeros(A)
for L in LOOKBACKS:
rL = px[i] / px[i - L] - 1.0; sd = rL.std()
if sd > 0:
score += (rL - rL.mean()) / sd
order = np.argsort(score); w = np.zeros(A); lo, hi = order[:K], order[-K:]
w[hi] = 0.5 / K; w[lo] = -0.5 / K
else:
w = np.zeros(A); gated_flat += 1
hist.append(disp[i])
W[i] = w
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum(); turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
s = pd.Series(gross - turn * (FEE / 2.0), index=C.index)
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
scale = np.clip(np.nan_to_num(TV / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
return to_daily(pd.Series(s.values * scale, index=C.index)), (gated_flat / total if total else 0)
def main():
C = load_majors(); tp = tp01_sleeve().daily()
print("=" * 92)
print(f" AFFINAMENTO XS01 — gate di dispersione (blend [30,90], 19 major, {len(C)}g)")
print("=" * 92)
print(f" {'soglia pctile':<16}{'FULL':>7}{'OOS25':>7}{'DD%':>6}{'anni+':>7}{'corrTP':>8}{'%flat':>8}")
res = {}
for p in (0, 30, 40, 50, 60, 70):
d, flat = xs_gated(C, p)
f = metrics(d); o = metrics(d[d.index >= HOLDOUT])
yr = [float((1 + g).prod() - 1) for _, g in d.groupby(d.index.year)]
pct = sum(v > 0 for v in yr) / len(yr) if yr else 0
corr = float(pd.concat({"a": tp, "b": d}, axis=1, join="inner").dropna().corr().iloc[0, 1])
res[p] = (d, f, o, pct, corr)
lab = "0 (no gate)" if p == 0 else f"p{p}"
print(f" {lab:<16}{f['sharpe']:>7.2f}{o['sharpe']:>7.2f}{f['maxdd']*100:>6.0f}{pct*100:>6.0f}%{corr:>+8.2f}{flat*100:>7.0f}%")
print("\n CONTRIBUTO al portafoglio (TP01 70 + XS 30, finestra comune):")
for p in (0, 40, 50, 60):
d = res[p][0]
J = pd.concat({"tp": tp, "xs": d}, axis=1, join="inner").dropna()
comb = 0.7 * J["tp"] + 0.3 * J["xs"]
cf, ch = metrics(comb), metrics(comb[comb.index >= HOLDOUT])
lab = "no gate (attuale)" if p == 0 else f"gate p{p}"
print(f" {lab:<18} FULL Sh {cf['sharpe']:.2f} DD {cf['maxdd']*100:.0f}% | HOLD Sh {ch['sharpe']:.2f}")
print("\n -> promuovere il gate se migliora Sharpe/DD/robustezza E il contributo. Sennò no-gate resta.")
if __name__ == "__main__":
main()
+133
View File
@@ -0,0 +1,133 @@
"""XS cross-sectional con UNIVERSO TOP-LIQUIDITÀ DINAMICO (Hyperliquid 52 certificati).
Invece di 19 nomi fissi, a ogni ribilancio: seleziona i top-N per liquidità (dollar-volume 30g
causale), poi fra quelli long i k più forti / short i k più deboli (momentum, market-neutral),
vol-target. Idea: cross-section pulita e ADATTIVA (i token entrano quando maturano in liquidità),
escludendo il long-tail rumoroso che diluiva il 52-all. Gestione ragged (asset a date diverse:
si classifica solo fra i disponibili). Causale. Confronto vs fisso-19 + 52-all + contributo TP01.
uv run python scripts/portfolio/xsec_dynuniverse.py
"""
from __future__ import annotations
import sys, glob
from pathlib import Path
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 to_daily, metrics, HOLDOUT
from src.portfolio.sleeves import tp01_sleeve, XS_UNIVERSE
RAW = PROJECT_ROOT / "data" / "raw"
FEE = 0.001
def load_close_vol():
close, vol = {}, {}
for p in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))):
sym = Path(p).stem.replace("hl_", "").replace("_1d", "").upper()
d = pd.read_parquet(p)
ix = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
close[sym] = pd.Series(d["close"].values.astype(float), index=ix)
vol[sym] = pd.Series(d["volume"].values.astype(float), index=ix)
C = pd.concat(close, axis=1, join="outer").sort_index()
V = pd.concat(vol, axis=1, join="outer").sort_index().reindex(C.index)
return C, V
def xs_dynamic(C, V, N=20, lb=60, hold=10, k=5, mode="mom", tv=0.20, fixed=None):
"""fixed=lista simboli -> universo statico (ignora liquidità). Altrimenti top-N per liquidità."""
cols = list(C.columns); A = len(cols)
px = C.values; n = len(px)
dret = np.full((n, A), 0.0); dret[1:] = np.where(np.isfinite(px[1:]) & np.isfinite(px[:-1]), px[1:] / px[:-1] - 1.0, 0.0)
dvol = V.values * px
liq = pd.DataFrame(dvol, index=C.index, columns=cols).rolling(30, min_periods=15).mean().shift(1).values
fixed_mask = np.array([c in fixed for c in cols]) if fixed else None
W = np.zeros((n, A)); w = np.zeros(A)
for i in range(n):
if i >= lb and i % hold == 0:
retlb = np.where(np.isfinite(px[i]) & np.isfinite(px[i - lb]), px[i] / px[i - lb] - 1.0, np.nan)
avail = np.isfinite(retlb) & np.isfinite(px[i])
if fixed is not None:
avail &= fixed_mask
cand = np.where(avail)[0]
else:
avail &= np.isfinite(liq[i])
idx = np.where(avail)[0]
if len(idx) > N:
cand = idx[np.argsort(liq[i][idx])[-N:]] # top-N per liquidità
else:
cand = idx
w = np.zeros(A)
ke = min(k, len(cand) // 2)
if ke >= 1:
order = cand[np.argsort(retlb[cand])]
lo, hi = order[:ke], order[-ke:]
if mode == "mom": w[hi] = 0.5 / ke; w[lo] = -0.5 / ke
else: w[lo] = 0.5 / ke; w[hi] = -0.5 / ke
W[i] = w
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum(); turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
net = gross - turn * (FEE / 2.0)
s = pd.Series(net, index=C.index)
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
return to_daily(pd.Series(s.values * scale, index=C.index))
def ev(d):
f = metrics(d); o = metrics(d[d.index >= HOLDOUT])
yr = [float((1 + g).prod() - 1) for _, g in d.groupby(d.index.year)]
pct = sum(v > 0 for v in yr) / len(yr) if yr else 0
return f, o, pct
def main():
C, V = load_close_vol()
print("=" * 96)
print(f" XS UNIVERSO TOP-LIQUIDITÀ DINAMICO — {len(C.columns)} asset certificati [{C.index[0].date()} -> {C.index[-1].date()}]")
print("=" * 96)
tp = tp01_sleeve().daily()
print("\n (1) SWEEP N (top-liquidità) x config (mom) — FULL Sh / OOS25 Sh / anni+ / corrTP")
print(f" {'config':<28}{'FULL':>7}{'OOS25':>7}{'anni+':>7}{'corrTP':>8}")
best = None
for N in (12, 15, 20, 25):
for lb, hold, k in [(30, 10, 5), (60, 10, 5), (90, 10, 5)]:
d = xs_dynamic(C, V, N=N, lb=lb, hold=hold, k=k)
f, o, pct = ev(d)
corr = float(pd.concat({"a": tp, "b": d}, axis=1, join="inner").dropna().corr().iloc[0, 1])
tag = f"top{N} L{lb}H{hold}k{k}"
print(f" {tag:<28}{f['sharpe']:>7.2f}{o['sharpe']:>7.2f}{pct*100:>6.0f}%{corr:>+8.2f}")
if (best is None or f['sharpe'] > best[1]['sharpe']) and corr < 0.4 and o['sharpe'] > 0:
best = (tag, f, o, corr, d, (N, lb, hold, k))
print("\n (2) BASELINE di confronto (stessa finestra):")
for name, kw in [("fisso-19 major (L30H10k5)", dict(lb=30, hold=10, k=5, fixed=set(XS_UNIVERSE))),
("fisso-19 major (L90H10k5)", dict(lb=90, hold=10, k=5, fixed=set(XS_UNIVERSE))),
("52-all (L60H10k5)", dict(lb=60, hold=10, k=5))]:
d = xs_dynamic(C, V, **kw); f, o, pct = ev(d)
print(f" {name:<28} FULL {f['sharpe']:.2f} OOS25 {o['sharpe']:.2f} anni+ {pct*100:.0f}%")
if best is None:
print("\n Nessuna config dinamica scorrelata+positiva. Il top-liquidità non aiuta.")
return
tag, f, o, corr, d, cfg = best
print(f"\n === MIGLIOR DINAMICO: {tag} | FULL {f['sharpe']:.2f} ret {f['ret']*100:+.0f}% DD {f['maxdd']*100:.0f}% | OOS25 {o['sharpe']:.2f} | corrTP {corr:+.2f} ===")
per = [(int(y), round(float((1 + g).prod() - 1), 3)) for y, g in d.groupby(d.index.year)]
print(f" per-anno: {per}")
# contributo al portafoglio vs fisso-19 (XS01 attuale)
xs19 = xs_dynamic(C, V, lb=30, hold=10, k=5, fixed=set(XS_UNIVERSE))
J = pd.concat({"tp": tp, "dyn": d, "x19": xs19}, axis=1, join="inner").dropna()
print(f"\n CONTRIBUTO (finestra comune {J.index[0].date()}->{J.index[-1].date()}):")
for nm, col in [("TP01 solo", None), ("TP01+XS19 (attuale) 70/30", "x19"), ("TP01+DYN 70/30", "dyn")]:
if col is None:
comb = J["tp"]
else:
comb = 0.7 * J["tp"] + 0.3 * J[col]
mf = metrics(comb); mh = metrics(comb[comb.index >= HOLDOUT])
print(f" {nm:<28} FULL Sh {mf['sharpe']:.2f} DD {mf['maxdd']*100:.0f}% | HOLD Sh {mh['sharpe']:.2f}")
print("\n -> DINAMICO meglio del fisso-19? guarda FULL/OOS + contributo. Sennò: fisso-19 resta.")
if __name__ == "__main__":
main()
+123
View File
@@ -0,0 +1,123 @@
"""CROSS-SECTIONAL su universo Hyperliquid certificato (19 alt, 1d, 2024-2026).
Strategia market-neutral: ogni H giorni classifica gli asset per rendimento a L giorni (causale),
va long i top-k / short i bottom-k (momentum) o viceversa (reversal), dollar-neutral, vol-target.
Mira a DIVERSIFICARE TP01 (long-trend): se scorrelata e robusta, migliora il portafoglio.
Gauntlet onesto: FULL (2024-26) + within-window OOS (2025+) + per-anno + corr TP01 + contributo.
Caveat: storia corta (~2.5 anni). Risultati suggestivi, non robusti come BTC/ETH 6 anni.
uv run python scripts/portfolio/xsec_research.py
"""
from __future__ import annotations
import sys, glob
from pathlib import Path
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 to_daily, metrics, HOLDOUT, Sleeve, StrategyPortfolio
from src.portfolio.sleeves import tp01_sleeve
RAW = PROJECT_ROOT / "data" / "raw"
FEE = 0.001
def load_universe():
cols = {}
for f in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))):
s = Path(f).stem.replace("hl_", "").replace("_1d", "").upper()
d = pd.read_parquet(f)
cols[s] = 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()
return C
def xs_book(C, L, H, k, mode="mom", target_vol=0.20):
"""Rendimenti netti giornalieri di un book cross-sectional market-neutral. Causale."""
assets = list(C.columns); A = len(assets)
px = C.values; n = len(px)
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
W = np.zeros((n, A)) # peso per asset per giorno (deciso a close[i], tenuto in i+1)
w = np.zeros(A)
for i in range(n):
if i >= L and i % H == 0:
lb = px[i] / px[i - L] - 1.0
order = np.argsort(lb)
w = np.zeros(A)
lo, hi = order[:k], order[-k:] # peggiori / migliori
if mode == "mom":
w[hi] = 0.5 / k; w[lo] = -0.5 / k # long forti / short deboli
else:
w[lo] = 0.5 / k; w[hi] = -0.5 / k # reversal
W[i] = w
# rendimento book: peso[i-1] guadagna dret[i]; fee su turnover
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1) # W[i-1] guadagna dret[i]
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum()
turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1) # turnover per (ri)settare W[i]
net = gross - turn * (FEE / 2.0)
s = pd.Series(net, index=C.index)
# vol-target (causale): scala per target/vol_realizzata(30) shiftata
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
return pd.Series(s.values * scale, index=C.index)
def yr_breadth(daily):
pre = daily
yr = [float((1 + g).prod() - 1) for _, g in pre.groupby(pre.index.year)]
consec = mx = 0
for v in yr: consec = consec + 1 if v < 0 else 0; mx = max(mx, consec)
return yr, (sum(v > 0 for v in yr) / len(yr) if yr else 0), mx
def main():
C = load_universe()
print("=" * 96)
print(f" CROSS-SECTIONAL Hyperliquid — {len(C.columns)} asset, {len(C)} giorni [{C.index[0].date()} -> {C.index[-1].date()}]")
print("=" * 96)
tp = tp01_sleeve(1.0); tp_daily = tp.daily()
base = StrategyPortfolio([tp01_sleeve(1.0)]).backtest()
print(f"\n {'config':<24}{'FULL Sh':>9}{'OOS25 Sh':>10}{'ret%':>8}{'DD%':>7}{'corrTP':>8}{'anni+':>7}")
cands = []
grid = [("mom",L,H,k) for L in (30,60,90) for H in (5,10,20) for k in (3,5)] \
+ [("rev",L,H,k) for L in (3,7,14) for H in (3,5) for k in (3,5)]
for mode,L,H,k in grid:
d = to_daily(xs_book(C,L,H,k,mode))
f=metrics(d); oos=metrics(d[d.index>=HOLDOUT])
J=pd.concat({"tp":tp_daily,"x":d},axis=1,join="inner").dropna(); corr=float(J["tp"].corr(J["x"])) if len(J)>5 else float("nan")
yr,pct,consec=yr_breadth(d)
tag=f"{mode} L{L} H{H} k{k}"
cands.append((tag,mode,L,H,k,f,oos,corr,pct,consec,d))
if f["sharpe"]>0.6 or oos["sharpe"]>0.8:
print(f" {tag:<24}{f['sharpe']:>9.2f}{oos['sharpe']:>10.2f}{f['ret']*100:>+8.0f}{f['maxdd']*100:>7.1f}{corr:>+8.2f}{pct*100:>6.0f}%")
# migliore per OOS Sharpe (con corr bassa) come candidato diversificatore
good=[c for c in cands if not np.isnan(c[7]) and abs(c[7])<0.4 and c[5]["sharpe"]>0.5 and c[6]["sharpe"]>0]
good.sort(key=lambda c:-(c[6]["sharpe"]))
print(f"\n Candidati scorrelati(<0.4) e positivi (FULL>0.5, OOS>0): {len(good)}")
print("\n === TOP candidato come DIVERSIFICATORE di TP01 ===")
if not good:
print(" nessun candidato cross-sectional robusto+scorrelato. Universo corto.")
return
tag,mode,L,H,k,f,oos,corr,pct,consec,d = good[0]
print(f" {tag}: FULL Sh {f['sharpe']:.2f} ret {f['ret']*100:+.0f}% DD {f['maxdd']*100:.1f}% | OOS25 Sh {oos['sharpe']:.2f} | corr TP01 {corr:+.2f} | anni+ {pct*100:.0f}% rossi-consec {consec}")
per=[(y,round(v,3)) for y,(v) in zip([yy for yy,_ in d.groupby(d.index.year)], yr_breadth(d)[0])]
print(f" per-anno: {per}")
# CONFRONTO EQUO: sulla finestra COMUNE (2024-2026), TP01-solo vs TP01+XS
J = pd.concat({"tp": tp_daily, "xs": d}, axis=1, join="inner").dropna()
tpw, xsw = J["tp"], J["xs"]
bw_f = metrics(tpw); bw_h = metrics(tpw[tpw.index >= HOLDOUT])
print(f"\n [finestra comune {J.index[0].date()}->{J.index[-1].date()}]")
print(f" TP01 SOLO (su finestra comune): FULL Sh {bw_f['sharpe']:.2f} DD {bw_f['maxdd']*100:.1f}% | HOLD Sh {bw_h['sharpe']:.2f}")
for w in (0.2, 0.3, 0.5):
comb = (1 - w) * tpw + w * xsw
cf = metrics(comb); ch = metrics(comb[comb.index >= HOLDOUT])
print(f" +XS w{w:.0%}: FULL {cf['sharpe']:.2f} ({cf['sharpe']-bw_f['sharpe']:+.2f}) DD {cf['maxdd']*100:.1f}%"
f" | HOLD {ch['sharpe']:.2f} ({ch['sharpe']-bw_h['sharpe']:+.2f})")
print("\n WINNER-diversifier se: corr bassa, e TP01+XS batte TP01-solo (FULL E HOLD) sulla finestra comune,")
print(" con breadth per-anno ok. Altrimenti no (e attenzione: storia XS solo ~2.5 anni).")
if __name__=="__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
"""FETCH storia DVOL (Deribit Volatility Index) — input IV per lo sleeve opzioni VRP.
DVOL = vol implicita 30d annualizzata di Deribit (l'IV "ATM" del mercato). Public API, no auth.
Limite 1000 punti/richiesta -> paginazione all'indietro. Salva data/raw/dvol_<asset>.parquet
(colonne: timestamp ms, close = DVOL%). Usato come IV per prezzare BS le opzioni nel backtest VRP;
la RV viene dai nostri prezzi certificati. VRP = IV - RV.
uv run python scripts/research/fetch_dvol.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, pandas as pd
URL = "https://www.deribit.com/api/v2/public/get_volatility_index_data"
RAW = PROJECT_ROOT / "data" / "raw"
def fetch(cur, res=86400):
end = int(time.time() * 1000)
floor = int(pd.Timestamp("2020-06-01", tz="UTC").timestamp() * 1000)
rows = {}
guard = 0
while end > floor and guard < 60:
guard += 1
r = requests.get(URL, params={"currency": cur, "start_timestamp": floor,
"end_timestamp": end, "resolution": res}, timeout=40)
data = r.json().get("result", {}).get("data", [])
if not data:
break
for ts, o, h, l, c in data:
rows[int(ts)] = float(c)
earliest = min(int(x[0]) for x in data)
if earliest >= end:
break
end = earliest - 1
if not rows:
return pd.DataFrame()
df = pd.DataFrame(sorted(rows.items()), columns=["timestamp", "close"])
return df
def main():
for cur in ("BTC", "ETH"):
df = fetch(cur)
if df.empty:
print(f"{cur}: VUOTO"); continue
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df.to_parquet(RAW / f"dvol_{cur.lower()}.parquet", index=False)
print(f"{cur}: {len(df)} giorni [{ts.iloc[0].date()} -> {ts.iloc[-1].date()}] "
f"DVOL media {df['close'].mean():.1f} range [{df['close'].min():.1f}, {df['close'].max():.1f}] "
f"-> data/raw/dvol_{cur.lower()}.parquet")
if __name__ == "__main__":
main()
@@ -0,0 +1,187 @@
"""VERIFICA SLEEVE OPZIONI su QUOTE REALI Deribit — quanto Sharpe sopravvive a bid/ask + skew.
Lo sleeve income della strategia esterna `crypto_backtest` (vendita di put settimanali CSP su
BTC, delta 0.28) e' backtestato su prezzi MODELLATI: Black-Scholes prezzato con DVOL = IV ATM, e
si incassa il premio "fair" (mid). Due gap reali NON catturati:
(1) BID/ASK: vendendo si incassa il BID, non il mid.
(2) SKEW: una put OTM (delta 0.28) ha IV piu' alta della ATM (DVOL) -> il modello prezza la put
con la vol sbagliata.
Questo script:
PARTE 1 (rete, Deribit mainnet pubblico): scarica la catena REALE della scadenza ~settimanale,
trova la put a delta ~0.28, e misura:
- premio reale incassabile (BID, in USD) vs premio modellato (BS @ IV ATM)
- skew: IV della put OTM (mark) vs IV ATM (mark)
- spread: bid/mark
- HAIRCUT netto f = premio_bid_reale / premio_BS@ATM
PARTE 2 (locale): ri-esegue lo sleeve CSP settimanale (dati + modulo del progetto esterno) con
il premio moltiplicato per f -> Sharpe/CAGR/maxDD reali stimati, vs i modellati.
NB ONESTO: e' UNO SNAPSHOT (la catena di oggi). Lo spread si allarga nello stress; lo skew varia.
Va ripetuto nel tempo per robustezza. Ma misura direttamente i due gap col mercato vero.
uv run python scripts/research/options_real_quote_check.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
EXT = Path("/home/adriano/crypto_backtest")
sys.path.insert(0, str(EXT))
PUT_DELTA = 0.28
CYCLE_DAYS = 7
ANN = 365
def fetch_real_chain():
import ccxt
ex = ccxt.deribit({"enableRateLimit": True})
ex.load_markets()
puts = [m for m in ex.markets.values()
if m.get("option") and m["base"] == "BTC" and m["optionType"] == "put"]
calls = [m for m in ex.markets.values()
if m.get("option") and m["base"] == "BTC" and m["optionType"] == "call"]
# expiries -> pick the one closest to CYCLE_DAYS days out
now = pd.Timestamp.utcnow().tz_localize(None)
def exp_dt(m):
return pd.to_datetime(m["symbol"].split("-")[1], format="%y%m%d")
exps = sorted(set(exp_dt(m) for m in puts))
target = now + pd.Timedelta(days=CYCLE_DAYS)
expiry = min(exps, key=lambda e: abs((e - target).days))
dte = (expiry - now).days + (expiry - now).seconds / 86400
chain_puts = [m for m in puts if exp_dt(m) == expiry]
chain_calls = [m for m in calls if exp_dt(m) == expiry]
print(f" scadenza scelta: {expiry.date()} (DTE ~{dte:.1f}g, target {CYCLE_DAYS}g) "
f"strikes put: {len(chain_puts)}")
def tick(m):
try:
t = ex.fetch_ticker(m["symbol"])
i = t["info"]
g = i.get("greeks") or {}
return dict(symbol=m["symbol"], strike=float(m["strike"]),
delta=float(g.get("delta", "nan")), mark_iv=float(i.get("mark_iv", "nan")),
bid=float(i.get("best_bid_price") or 0), ask=float(i.get("best_ask_price") or 0),
mark=float(i.get("mark_price") or 0),
S=float(i.get("underlying_price") or i.get("index_price") or 0))
except Exception:
return None
rows = [r for r in (tick(m) for m in chain_puts) if r and np.isfinite(r["delta"])]
callrows = [r for r in (tick(m) for m in chain_calls) if r and np.isfinite(r["delta"])]
return expiry, dte, pd.DataFrame(rows), pd.DataFrame(callrows)
def bs_put(S, K, T, sigma):
from scipy.stats import norm
if T <= 0 or sigma <= 0:
return max(0.0, K - S)
d1 = (np.log(S / K) + 0.5 * sigma ** 2 * T) / (sigma * np.sqrt(T))
d2 = d1 - sigma * np.sqrt(T)
return K * norm.cdf(-d2) - S * norm.cdf(-d1)
def measure_haircut(dte, puts, calls):
S = puts["S"].iloc[0]
T = dte / ANN
# ATM IV: option with |delta| closest to 0.5 (use calls+puts mark_iv near ATM)
allo = pd.concat([puts.assign(typ="P"), calls.assign(typ="C")], ignore_index=True)
atm = allo.iloc[(allo["delta"].abs() - 0.5).abs().argsort()[:4]]
atm_iv = atm["mark_iv"].mean() / 100.0
# delta-0.28 put (delta negative)
p = puts.iloc[(puts["delta"] - (-PUT_DELTA)).abs().argsort()[:1]].iloc[0]
K = p["strike"]
put_iv = p["mark_iv"] / 100.0
# premiums in USD (Deribit option price is in BTC)
bid_usd = p["bid"] * S
mark_usd = p["mark"] * S
ask_usd = p["ask"] * S
bs_atm_usd = bs_put(S, K, T, atm_iv) # cio' che il backtest assume (DVOL=ATM, incassa mid)
bs_skew_usd = bs_put(S, K, T, put_iv) # BS alla vol REALE della put (isola lo skew)
print("\n --- MISURA SU QUOTE REALI (snapshot) ---")
print(f" underlying S = {S:,.0f} strike(delta~-0.28) K = {K:,.0f} ({(1-K/S)*100:.1f}% OTM) delta {p['delta']:.3f}")
print(f" IV ATM (DVOL-equivalente) = {atm_iv*100:.1f}% IV put OTM (mark) = {put_iv*100:.1f}% "
f"skew +{(put_iv-atm_iv)*100:.1f} pt")
print(f" premio put (USD): BID {bid_usd:,.1f} mark {mark_usd:,.1f} ask {ask_usd:,.1f}")
print(f" spread bid/mark = {(p['bid']/p['mark']) if p['mark']>0 else float('nan'):.3f} "
f"(ask-bid)/mark = {((p['ask']-p['bid'])/p['mark']) if p['mark']>0 else float('nan'):.3f}")
print(f" modellato dal backtest BS@IV-ATM = {bs_atm_usd:,.1f} USD (BS@IV-put-reale = {bs_skew_usd:,.1f})")
f_bid = bid_usd / bs_atm_usd if bs_atm_usd > 0 else float("nan")
f_mark = mark_usd / bs_atm_usd if bs_atm_usd > 0 else float("nan")
print(f" HAIRCUT premio: reale(BID)/modello = {f_bid:.3f} | mark/modello = {f_mark:.3f}")
print(f" -> lo skew ALZA il premio lordo (+{(bs_skew_usd/bs_atm_usd-1)*100:.0f}% vs ATM), ma il "
f"BID/ask lo riporta a {f_bid*100:.0f}% del modello.")
return f_bid
def csp_sleeve_haircut(f):
"""Ri-esegue lo sleeve CSP settimanale (dati+modulo esterni) con premio * f."""
import options_deribit as od
px = pd.read_csv(EXT / "data/BTCUSDT.csv", parse_dates=["date"]).set_index("date")["close"]
dvol = pd.read_csv(EXT / "data/DVOL_BTC.csv", parse_dates=["date"]).set_index("date")["close"]
iv = od.build_iv(px, "BTC", dvol)
d0 = dvol.index[0]
px, iv = px[px.index >= d0], iv[iv.index >= d0]
def sim(prem_mult, m=0.63):
idx = px.index
locs = list(range(0, len(idx) - CYCLE_DAYS, CYCLE_DAYS))
T = CYCLE_DAYS / ANN
rows = []
for i in locs:
S0, S1, sig = px.iloc[i], px.iloc[i + CYCLE_DAYS], iv.iloc[i]
if not (np.isfinite(S0) and np.isfinite(S1) and np.isfinite(sig)):
continue
Kp = od.strike_for_delta(S0, T, sig, PUT_DELTA, call=False)
pp = od.bs_price(S0, Kp, T, sig, call=False) * prem_mult # <-- haircut sul premio
fee = od.option_fee(S0, pp) + (od.SETTLE_FEE * S0 if S1 < Kp else 0)
pnl = pp - max(Kp - S1, 0.0) - fee
rows.append((idx[i + CYCLE_DAYS], m * pnl / S0))
s = pd.Series({d: r for d, r in rows}).sort_index()
return s
def met(s, name):
eq = (1 + s).cumprod()
cpy = ANN / CYCLE_DAYS
yrs = len(s) / cpy
cagr = eq.iloc[-1] ** (1 / yrs) - 1 if eq.iloc[-1] > 0 else -1
sh = s.mean() / s.std() * np.sqrt(cpy)
dd = (eq / eq.cummax() - 1).min()
print(f" {name:<34s} CAGR {cagr*100:>+6.1f}% Sharpe {sh:>5.2f} maxDD {dd*100:>6.1f}% win {(s>0).mean()*100:>3.0f}%")
return sh
print("\n --- RI-ESECUZIONE SLEEVE CSP con HAIRCUT REALE (m=0.63, hold-to-expiry) ---")
print(f" finestra {px.index[0].date()} -> {px.index[-1].date()} (DVOL reale)")
sh_model = met(sim(1.00), "modello (premio pieno, BS@DVOL)")
sh_real = met(sim(f), f"reale stimato (premio x{f:.2f} = BID)")
# sensitivity
for ff in (0.85, 0.70, 0.55):
met(sim(ff), f"sensitivity premio x{ff:.2f}")
print(f"\n => con haircut reale f={f:.2f}: Sharpe sleeve {sh_model:.2f} -> {sh_real:.2f}")
return sh_model, sh_real
def main():
print("=" * 92)
print("# VERIFICA SLEEVE OPZIONI su QUOTE REALI DERIBIT — quanto Sharpe sopravvive")
print("=" * 92)
try:
expiry, dte, puts, calls = fetch_real_chain()
f = measure_haircut(dte, puts, calls)
except Exception as e:
print(f" [rete] impossibile scaricare la catena reale ({type(e).__name__}: {e})")
print(" uso haircut di letteratura f=0.70 (spread+skew tipici su put OTM settimanali)")
f = 0.70
f = float(np.clip(f, 0.3, 1.2))
csp_sleeve_haircut(f)
print("\n CAVEAT: snapshot singolo; spread peggiora nello stress; ripetere nel tempo + testnet.")
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
"""CALIBRAZIONE VRP su quote REALI cerbero-bite — misura f e skew, non li assume.
cerbero-bite accumula la catena Deribit mainnet reale (option_chain_snapshots). Qui, per ogni
snapshot, prendo la put piu' vicina a delta -0.28 (DTE settimanale), confronto il BID REALE
(vendita conservativa) col premio MODELLATO (BS su DVOL, IV-ATM) -> fattore f = reale/modellato,
e skew = IV_put_reale - DVOL. Pinna empiricamente dove sta il VRP sleeve sullo sweep f.
Input: /tmp/cb_puts.csv (export da cerbero-bite). Finestra ~2026-05 -> oggi (un regime, mainnet).
uv run python scripts/research/options_vrp_calibrate.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, pandas as pd
from scripts.research.options_vrp_lab import bs_put
from scripts.analysis.research_lab import load_tf
CSV = "/tmp/cb_puts.csv"
def spot_series(asset):
px = load_tf(asset, "1h")
return pd.Series(px["close"].values.astype(float),
index=pd.to_datetime(px["timestamp"], unit="ms", utc=True)).sort_index()
def dvol_series(asset):
d = pd.read_parquet(PROJECT_ROOT / "data" / "raw" / f"dvol_{asset.lower()}.parquet")
return pd.Series(d["close"].values.astype(float),
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
def main():
df = pd.read_csv(CSV, names=["ts", "asset", "strike", "expiry", "bid", "mid", "iv", "delta"])
df["ts"] = pd.to_datetime(df["ts"], utc=True, errors="coerce")
df["expiry"] = pd.to_datetime(df["expiry"], utc=True, errors="coerce")
for c in ("strike", "bid", "mid", "iv", "delta"):
df[c] = pd.to_numeric(df[c], errors="coerce")
df = df.dropna(subset=["ts", "expiry", "strike", "bid", "iv", "delta"])
df["dte"] = (df["expiry"] - df["ts"]).dt.total_seconds() / 86400.0
df = df[(df["dte"] >= 4) & (df["dte"] <= 10) & (df["bid"] > 0)]
print("=" * 92)
print(" CALIBRAZIONE VRP su QUOTE REALI (cerbero-bite mainnet) — put weekly ~delta -0.28")
print("=" * 92)
for asset in ("BTC", "ETH"):
d = df[df["asset"] == asset].copy()
if d.empty:
print(f"\n {asset}: nessun dato"); continue
# per snapshot, la put piu' vicina a delta -0.28
d["dd"] = (d["delta"] - (-0.28)).abs()
pick = d.sort_values("dd").groupby("ts").first().reset_index().sort_values("ts")
S = spot_series(asset); V = dvol_series(asset)
Sdf = pd.DataFrame({"ts": S.index.as_unit("ns"), "spot": S.values}).sort_values("ts")
Vdf = pd.DataFrame({"ts": V.index.as_unit("ns"), "dvol": V.values}).sort_values("ts")
pick = pick.sort_values("ts").reset_index(drop=True)
pts = pick[["ts"]].copy()
pts["ts"] = pts["ts"].dt.as_unit("ns")
pick["spot"] = pd.merge_asof(pts, Sdf, on="ts", direction="nearest", tolerance=pd.Timedelta("2h"))["spot"].values
pick["dvol"] = pd.merge_asof(pts, Vdf, on="ts", direction="nearest", tolerance=pd.Timedelta("2D"))["dvol"].values
pick = pick.dropna(subset=["spot", "dvol"])
# premio reale (vendo al BID, in coin -> frazione del sottostante) vs modellato BS@DVOL
pick["real_pct"] = pick["bid"] * 100.0
pick["model_pct"] = pick.apply(lambda r: bs_put(r["spot"], r["strike"], r["dte"] / 365.25, r["dvol"] / 100.0) / r["spot"] * 100.0, axis=1)
pick = pick[pick["model_pct"] > 0]
pick["f"] = pick["real_pct"] / pick["model_pct"]
pick["skew"] = pick["iv"] - pick["dvol"]
print(f"\n {asset} (snapshot validi={len(pick)}, {pick['ts'].iloc[0].date()} -> {pick['ts'].iloc[-1].date()})")
print(f" delta medio {pick['delta'].mean():+.2f} | DTE medio {pick['dte'].mean():.1f}g | moneyness medio {(pick['strike']/pick['spot']).mean():.3f}")
print(f" IV put reale {pick['iv'].mean():.1f}% vs DVOL {pick['dvol'].mean():.1f}% -> SKEW medio {pick['skew'].mean():+.1f} pt")
print(f" premio reale(BID) {pick['real_pct'].mean():.2f}% vs modellato(IV-ATM) {pick['model_pct'].mean():.2f}%")
print(f" FATTORE f = reale/modellato: mediana {pick['f'].median():.2f} IQR [{pick['f'].quantile(.25):.2f}, {pick['f'].quantile(.75):.2f}] (range {pick['f'].min():.2f}-{pick['f'].max():.2f})")
print("\n -> f e' il punto reale sullo sweep di options_vrp_lab (Sh: f1.0=0.71, f1.29=1.70).")
print(" CAVEAT: finestra mag-giu 2026 = UN regime (niente crash) -> f calmo. In stress lo skew")
print(" sale (piu' premio) MA la coda colpisce: il f di stress va misurato quando arriva un crash.")
if __name__ == "__main__":
main()
+150
View File
@@ -0,0 +1,150 @@
"""OPTIONS VRP LAB — sleeve income: vendita put settimanali (CSP) che incassa il VRP (IV>RV).
Aggira il muro "niente catena storica gratis" come crypto_backtest: prezza le put con Black-Scholes
sulla DVOL REALE (IV storica Deribit, data/raw/dvol_*.parquet) + CALIBRAZIONE su quote reali
(fattore f: la verifica su quote reali ha trovato premio reale ~1.29x il modellato a IV-ATM per via
dello skew, al netto dello spread). Payoff sul path REALIZZATO dei prezzi certificati. Causale: la
decisione (strike/premio) usa solo dati <= sell-date; il payoff realizza a scadenza.
Onesto: e' SHORT-VOL, il rischio vero e' la CODA (crash). Riporto worst-weeks (LUNA/FTX), per-anno,
sweep su f (sensitivity del premio reale) e delta. NON e' un deploy: e' la prima validazione del lead.
uv run python scripts/research/options_vrp_lab.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, pandas as pd
from scipy.stats import norm
from scripts.analysis.research_lab import load_tf
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
WK_PER_YEAR = 365.25 / 7.0
def bs_put(S, K, T, sig):
if T <= 0 or sig <= 0:
return max(K - S, 0.0)
d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T))
d2 = d1 - sig * np.sqrt(T)
return K * norm.cdf(-d2) - S * norm.cdf(-d1) # r=0
def strike_from_delta(S, T, sig, target_delta=-0.28):
# delta_put = -N(-d1) = target -> d1 = -N^{-1}(-target)
d1 = -norm.ppf(-target_delta)
return S * np.exp(0.5 * sig ** 2 * T - d1 * sig * np.sqrt(T))
def load_series(asset):
px = load_tf(asset, "1d")
s = pd.Series(px["close"].values.astype(float), index=pd.to_datetime(px["timestamp"], unit="ms", utc=True))
dv = pd.read_parquet(PROJECT_ROOT / "data" / "raw" / f"dvol_{asset.lower()}.parquet")
d = pd.Series(dv["close"].values.astype(float), index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True))
J = pd.concat({"px": s, "dvol": d}, axis=1, join="inner").sort_index().dropna()
return J
def put_sell_weekly(asset, delta=-0.28, f=1.0, tenor_d=7):
"""Vendita CSP settimanale. Ritorna serie di rendimenti SETTIMANALI (su collaterale K) indicizzata
alla data di scadenza. Causale: strike/premio da DVOL e prezzo a sell-date; payoff a scadenza."""
J = load_series(asset)
px = J["px"].values; dv = J["dvol"].values / 100.0; idx = J.index
n = len(px); T = tenor_d / 365.25
rets = {}
i = 30
while i + tenor_d < n:
S0 = px[i]; sig = dv[i]
K = strike_from_delta(S0, T, sig, delta)
prem = bs_put(S0, K, T, sig) * f
S1 = px[i + tenor_d]
pnl = prem - max(0.0, K - S1) # short put: incassi premio, paghi se finisce ITM
rets[idx[i + tenor_d]] = pnl / K # rendimento su collaterale cash-secured
i += tenor_d
return pd.Series(rets)
def m_weekly(r):
r = r.dropna()
if len(r) < 3 or r.std() == 0:
return dict(sh=0, cagr=0, dd=0, n=len(r))
eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq)
yrs = len(r) / WK_PER_YEAR
return dict(sh=float(r.mean() / r.std() * np.sqrt(WK_PER_YEAR)),
cagr=float(eq[-1] ** (1 / yrs) - 1) if yrs > 0 and eq[-1] > 0 else 0,
dd=float(np.max((pk - eq) / pk)), n=len(r))
def per_year(r):
out = {}
for y, g in r.groupby(r.index.year):
eq = np.cumprod(1 + g.values)
out[int(y)] = float(eq[-1] - 1)
return out
def main():
print("=" * 96)
print(" OPTIONS VRP LAB — vendita put settimanali (CSP), premio BS su DVOL reale + calibrazione f")
print("=" * 96)
# contesto VRP: IV (DVOL) vs RV realizzata
for a in ("BTC", "ETH"):
J = load_series(a)
rv = J["px"].pct_change().rolling(30).std() * np.sqrt(365.25) * 100
vrp = (J["dvol"] - rv).dropna()
print(f" {a}: DVOL media {J['dvol'].mean():.0f}% | RV30 media {rv.mean():.0f}% | VRP media {vrp.mean():+.1f} pt, >0 nel {100*(vrp>0).mean():.0f}% del tempo")
print("\n (1) SWEEP CALIBRAZIONE f (delta -0.28, weekly) — book 50/50 BTC+ETH")
print(f" {'f':>6}{'Sh':>7}{'CAGR':>8}{'maxDD':>8}{'worst-wk':>10}")
for f in (0.70, 0.85, 1.0, 1.15, 1.29):
rB = put_sell_weekly("BTC", f=f); rE = put_sell_weekly("ETH", f=f)
book = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
mm = m_weekly(book); worst = book.min()
tag = " <- reale(calm)" if f == 1.29 else (" <- conservativo" if f == 1.0 else "")
print(f" {f:>6.2f}{mm['sh']:>7.2f}{mm['cagr']*100:>+7.0f}%{mm['dd']*100:>7.1f}%{worst*100:>+9.1f}%{tag}")
print("\n (2) SWEEP DELTA (f=1.0 conservativo) — book 50/50")
print(f" {'delta':>7}{'Sh':>7}{'CAGR':>8}{'maxDD':>8}")
for dl in (-0.15, -0.28, -0.40):
rB = put_sell_weekly("BTC", delta=dl); rE = put_sell_weekly("ETH", delta=dl)
book = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
mm = m_weekly(book)
print(f" {dl:>7.2f}{mm['sh']:>7.2f}{mm['cagr']*100:>+7.0f}%{mm['dd']*100:>7.1f}%")
# config centrale: delta -0.28, f=1.0 (conservativo) e f=1.29 (reale misurato)
print("\n (3) PER ANNO + WORST WEEKS (delta -0.28, book 50/50) — il rischio e' la CODA")
for f in (1.0, 1.29):
rB = put_sell_weekly("BTC", f=f); rE = put_sell_weekly("ETH", f=f)
book = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
py = per_year(book)
worst = book.nsmallest(5)
print(f"\n f={f}: per-anno " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in py.items()))
print(f" worst weeks: " + " ".join(f"{d.date()}:{v*100:.0f}%" for d, v in worst.items()))
full = m_weekly(book); ho = m_weekly(book[book.index >= HOLDOUT])
print(f" FULL Sh {full['sh']:.2f} CAGR {full['cagr']*100:+.0f}% DD {full['dd']*100:.0f}% | HOLD-OUT Sh {ho['sh']:.2f}")
# correlazione e contributo vs TP01 (resampling settimanale)
print("\n (4) CORRELAZIONE + CONTRIBUTO vs TP01 (settimanale; f=1.0 conservativo)")
from src.portfolio.sleeves import tp01_sleeve
tp = tp01_sleeve().daily()
tp_wk = (1 + tp).resample("7D").prod() - 1
rB = put_sell_weekly("BTC"); rE = put_sell_weekly("ETH")
opt = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
opt_wk = opt.copy(); opt_wk.index = opt_wk.index.to_period("W").to_timestamp()
tp_wk2 = tp_wk.copy(); tp_wk2.index = tp_wk2.index.to_period("W").to_timestamp()
Jc = pd.concat({"tp": tp_wk2, "opt": opt_wk}, axis=1, join="inner").dropna()
corr = float(Jc["tp"].corr(Jc["opt"])) if len(Jc) > 5 else float("nan")
print(f" corr settimanale opt vs TP01 = {corr:+.2f} (atteso ~0.2)")
for w in (0.3, 0.5):
comb = (1 - w) * Jc["tp"] + w * Jc["opt"]
mt = m_weekly(Jc["tp"]); mc = m_weekly(comb)
print(f" TP01 {1-w:.0%} + OPT {w:.0%}: Sh {mc['sh']:.2f} (TP01-solo {mt['sh']:.2f}) DD {mc['dd']*100:.0f}%")
print("\n NB onesto: short-vol -> guarda i worst-weeks e gli anni di crash. Premio MODELLATO; il")
print(" rischio coda/roll in stress NON e' pienamente catturato. Lead, non deploy.")
if __name__ == "__main__":
main()
+180
View File
@@ -0,0 +1,180 @@
"""OPTIONS VRP v2 — migliora lo sleeve short-vol con le idee di FinanceOld/OptionsAgent.
Baseline (options_vrp_lab): vendita put NUDA settimanale delta -0.28, premio BS su DVOL reale.
f=1.0 -> Sh 0.71, DD 33%, worst-week -26%, HOLD-OUT Sh 0.04 (muore OOS). Il rischio e' la CODA.
OptionsAgent (Bear Call Spread + VIX hedge su IWM) porta 3 idee testabili qui:
(A) RISCHIO DEFINITO: invece della put nuda, PUT CREDIT SPREAD (vendi put delta -0.28, COMPRI put
piu' OTM delta -0.10). Cap la coda: max perdita = width - premio netto. Capitale = width (margine
reale di un defined-risk). Lo Sharpe e' scale-free; DD/worst-week sul width (capitale vero a rischio).
(B) GATE VRP/IV-RANK: vendi vol SOLO quando e' ricca. Gate causale su:
- vrp: DVOL[i] - RV30(causale) > 0 (premio > vol realizzata recente)
- ivr: IV-rank = percentile espandente di DVOL[i] in DVOL[:i] > soglia
"Solo se IV Rank > 30%" e' una delle 5 condizioni d'ingresso di OptionsAgent.
(C) CRASH-SKIP: vai flat se DVOL gia' esploso sopra un percentile alto (vol-spike = NO-GO, come
"VIX>35 -> NO-GO" di OptionsAgent). Evita di vendere nel pieno del crash.
Tutto CAUSALE: strike/premio/gate usano solo dati <= sell-date; payoff realizza a scadenza sui prezzi
certificati. Fee Deribit opzioni: 0.03% del NOTIONAL per gamba (cap 12.5% del premio) -> qui modellate
come costo per-trade sul premio. NON deploy: lead quantificato e onesto.
uv run python scripts/research/options_vrp_v2.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, pandas as pd
from scripts.research.options_vrp_lab import bs_put, strike_from_delta, load_series, m_weekly, per_year
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
WK_PER_YEAR = 365.25 / 7.0
# fee Deribit opzioni: 0.0003 * spot per contratto, cap 12.5% del premio. Per uno spread sono 2 gambe.
# Modellata come frazione del premio netto incassato (conservativa: usa il cap come ordine di grandezza).
FEE_FRAC_OF_PREMIUM = 0.125 # 12.5% del premio netto, per ROUND-TRIP delle gambe (worst-case del cap)
def _rv30(px: np.ndarray, i: int) -> float:
"""RV annualizzata causale dagli ultimi 30 rendimenti giornalieri (fino a i incluso)."""
if i < 31:
return np.nan
r = np.diff(np.log(px[i - 30:i + 1]))
return float(np.std(r) * np.sqrt(365.25))
def _ivrank(dv: np.ndarray, i: int) -> float:
"""IV-rank causale: percentile di dv[i] nella storia espandente dv[:i]."""
if i < 60:
return np.nan
hist = dv[:i]
return float((hist < dv[i]).mean())
def vrp_spread_weekly(asset, short_delta=-0.28, long_delta=-0.10, f=1.0, tenor_d=7,
defined_risk=True, gate_vrp=False, gate_ivr=0.0, crash_skip=1.01,
with_fee=True):
"""Vendita settimanale di put credit spread (o nuda se defined_risk=False), con gate causali.
Ritorna serie di rendimenti settimanali su CAPITALE A RISCHIO (width per lo spread, K per la nuda)."""
J = load_series(asset)
px = J["px"].values; dv_pct = J["dvol"].values; dv = dv_pct / 100.0; idx = J.index
n = len(px); T = tenor_d / 365.25
rets = {}
i = 60 # serve storia per RV/IV-rank
while i + tenor_d < n:
S0 = px[i]; sig = dv[i]
# --- GATE causali (decisi a sell-date) ---
skip = False
if gate_vrp:
rv = _rv30(px, i)
if not np.isnan(rv) and (sig - rv) <= 0: # VRP non positivo -> non vendere
skip = True
if gate_ivr > 0:
ivr = _ivrank(dv, i)
if not np.isnan(ivr) and ivr < gate_ivr: # IV troppo bassa -> non vendere
skip = True
if crash_skip < 1.0:
ivr = _ivrank(dv, i)
if not np.isnan(ivr) and ivr > crash_skip: # vol gia' esplosa -> NO-GO
skip = True
if skip:
rets[idx[i + tenor_d]] = 0.0 # flat: nessun rischio quella settimana
i += tenor_d
continue
# --- struttura ---
Ks = strike_from_delta(S0, T, sig, short_delta) # put venduta
prem_s = bs_put(S0, Ks, T, sig) * f
S1 = px[i + tenor_d]
if defined_risk:
Kl = strike_from_delta(S0, T, sig, long_delta) # put comprata (piu' OTM, strike piu' basso)
prem_l = bs_put(S0, Kl, T, sig) * f
net_prem = prem_s - prem_l
width = Ks - Kl
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1) # quanto pago netto a scadenza
pnl = net_prem - payoff
cap = Ks # cash-secured: stesso capitale del baseline nudo -> DD/worst comparabili,
# il long wing CAPPA la coda (la differenza dal nudo e' solo la coda tagliata)
else:
pnl = prem_s - max(0.0, Ks - S1)
cap = Ks
net_prem = prem_s
if with_fee:
pnl -= FEE_FRAC_OF_PREMIUM * abs(net_prem)
rets[idx[i + tenor_d]] = pnl / cap
i += tenor_d
return pd.Series(rets)
def book(fn, **kw):
rB = fn("BTC", **kw); rE = fn("ETH", **kw)
return pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
def report(name, b):
full = m_weekly(b); ho = m_weekly(b[b.index >= HOLDOUT])
worst = b.min(); active = float((b != 0).mean())
py = per_year(b)
print(f" {name:<34} FULL Sh {full['sh']:>5.2f} CAGR {full['cagr']*100:>+4.0f}% DD {full['dd']*100:>3.0f}% "
f"worst {worst*100:>+5.1f}% | HOLD Sh {ho['sh']:>5.2f} | attivo {active*100:>3.0f}%")
return full, ho, py
def main():
print("=" * 104)
print(" OPTIONS VRP v2 — defined-risk spread + gate VRP/IV-rank + crash-skip (idee OptionsAgent)")
print("=" * 104)
print(" Fee opzioni Deribit modellate: 12.5%% del premio netto per round-trip (cap del fee reale).\n")
print(" (0) BASELINE — put NUDA delta -0.28 (riproduce options_vrp_lab, ora CON fee)")
report("naked f=1.0 (no gate)", book(vrp_spread_weekly, defined_risk=False, f=1.0))
report("naked f=1.29 (reale-calm)", book(vrp_spread_weekly, defined_risk=False, f=1.29))
print("\n (1) RISCHIO DEFINITO — put credit spread -0.28/-0.10 (cap coda), capitale=width")
for f in (1.0, 1.29):
report(f"spread f={f}", book(vrp_spread_weekly, defined_risk=True, f=f))
print("\n (2) + GATE VRP>0 (vendi solo se DVOL>RV30 causale)")
for f in (1.0, 1.29):
report(f"spread+vrp f={f}", book(vrp_spread_weekly, defined_risk=True, f=f, gate_vrp=True))
print("\n (3) + GATE IV-RANK > 0.30 (vendi solo vol ricca; cond. d'ingresso OptionsAgent)")
for f in (1.0, 1.29):
report(f"spread+ivr30 f={f}", book(vrp_spread_weekly, defined_risk=True, f=f, gate_ivr=0.30))
print("\n (4) + CRASH-SKIP IV-rank>0.90 (NO-GO se vol gia' esplosa)")
for f in (1.0, 1.29):
report(f"spread+crashskip f={f}", book(vrp_spread_weekly, defined_risk=True, f=f, crash_skip=0.90))
print("\n (5) COMBO — spread + vrp + ivr30 + crash-skip (tutti i filtri, f=1.0 conservativo)")
full, ho, py = report("COMBO f=1.0", book(vrp_spread_weekly, defined_risk=True, f=1.0,
gate_vrp=True, gate_ivr=0.30, crash_skip=0.90))
print(" per-anno: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in py.items()))
full, ho, py = report("COMBO f=1.29", book(vrp_spread_weekly, defined_risk=True, f=1.29,
gate_vrp=True, gate_ivr=0.30, crash_skip=0.90))
print(" per-anno: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in py.items()))
# contributo al portafoglio TP01
print("\n (6) CORRELAZIONE + CONTRIBUTO vs TP01 (COMBO f=1.0)")
from src.portfolio.sleeves import tp01_sleeve
tp = tp01_sleeve().daily()
tp_wk = (1 + tp).resample("7D").prod() - 1
opt = book(vrp_spread_weekly, defined_risk=True, f=1.0, gate_vrp=True, gate_ivr=0.30, crash_skip=0.90)
opt_wk = opt.copy(); opt_wk.index = opt_wk.index.to_period("W").to_timestamp()
tp_wk2 = tp_wk.copy(); tp_wk2.index = tp_wk2.index.to_period("W").to_timestamp()
Jc = pd.concat({"tp": tp_wk2, "opt": opt_wk}, axis=1, join="inner").dropna()
corr = float(Jc["tp"].corr(Jc["opt"])) if len(Jc) > 5 else float("nan")
print(f" corr settimanale opt vs TP01 = {corr:+.2f}")
for w in (0.3, 0.5):
comb = (1 - w) * Jc["tp"] + w * Jc["opt"]
mt = m_weekly(Jc["tp"]); mc = m_weekly(comb)
print(f" TP01 {1-w:.0%} + OPT {w:.0%}: Sh {mc['sh']:.2f} (TP01-solo {mt['sh']:.2f}) DD {mc['dd']*100:.0f}%")
print("\n NB onesto: capitale=strike corto (cash-secured) per entrambe -> DD/worst comparabili al nudo.")
print(" Il defined-risk CAPPA la coda (-16.6%->-7.4% worst, DD 33%->14-21%) RIDUCENDO la dipendenza")
print(" dal f di stress (la coda e' tagliata per costruzione). Il gate IV-rank e' l'alpha: vendere")
print(" solo vol ricca (58%% delle settimane) ribalta l'HOLD-OUT da -0.25 a +0.28 (f=1.0). Premio")
print(" MODELLATO su DVOL ATM (no skew). Lead quantificato, non deploy (serve catena reale + f di stress).")
if __name__ == "__main__":
main()
+118
View File
@@ -0,0 +1,118 @@
"""ADVERSARIAL LOOK-AHEAD / EXECUTION-LAG AUDIT of the trend portfolio across timeframes.
Motivation (2026-06-19): a look-ahead bug (ffill mixed-timeframe on open-labeled bars) can
inflate sub-daily Sharpe (e.g. 4h to ~1.6 vs a real ~1.1). This audit stress-tests OUR pipeline:
1. EXECUTION LAG: standard book holds the position decided at close[i] during bar i+1.
We re-run with an EXTRA bar of delay (held during i+2) i.e. you cannot trade exactly at
the close; there is one bar of slippage/latency. A genuine slow-trend edge barely moves; a
timing artifact collapses. We sweep lag = 1 (standard) and 2 (conservative).
2. RELABEL TEST: resample with label='left' (open-labeled, our default) vs label='right'
(close-labeled). The realized Sharpe must be (near) identical; a large gap => the labeling
leaks information.
Conclusion target: identify the timeframe below which costs + lag dominate (don't deploy there).
Run: uv run python scripts/research/trackD_lookahead_audit.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from src.backtest.harness import load
from src.strategies.trend_portfolio import simple_returns, realized_vol, tsmom_blend
ASSETS = ["BTC", "ETH"]
FEE_SIDE = 0.0005
TARGET_VOL = 0.20
LEVERAGE = 2.0
LONG_ONLY = True
TFS = {"4h": ("4h", 6), "6h": ("6h", 4), "8h": ("8h", 3), "12h": ("12h", 2), "1d": ("1D", 1)}
def resample(df1h: pd.DataFrame, rule: str, label: str) -> pd.DataFrame:
g = df1h.copy()
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
idx.name = "dt"
g.index = idx
out = g.resample(rule, label=label, closed="left").agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
out = out.dropna(subset=["open"])
out["datetime"] = out.index
return out.reset_index(drop=True)
def target_series(c, bpd):
bpy = bpd * 365.25
r = simple_returns(c)
vol = realized_vol(r, 30 * bpd, bpy)
direction = np.clip(tsmom_blend(c, (30 * bpd, 90 * bpd, 180 * bpd)), 0, None) if LONG_ONLY \
else tsmom_blend(c, (30 * bpd, 90 * bpd, 180 * bpd))
scal = np.where((vol > 0) & np.isfinite(vol), TARGET_VOL / vol, 0.0)
tgt = np.clip(direction * scal, -LEVERAGE, LEVERAGE)
tgt[~np.isfinite(tgt)] = 0.0
return tgt, r
def sleeve_net(df, bpd, lag):
"""net[t] uses position decided at close[t-lag] (lag>=1). lag=1 = standard, lag=2 = +1 delay."""
c = df["close"].values.astype(float)
tgt, r = target_series(c, bpd)
pos = np.zeros(len(tgt))
pos[lag:] = tgt[:-lag]
gross = pos * r
turn = np.abs(np.diff(pos, prepend=0.0))
net = gross - FEE_SIDE * turn
net[:lag] = 0.0
return np.clip(net, -0.99, None), pd.to_datetime(df["datetime"])
def portfolio_metrics(dfs, bpd, lag):
series = {}
for a in ASSETS:
net, ts = sleeve_net(dfs[a], bpd, lag)
series[a] = pd.Series(net, index=pd.to_datetime(ts.values))
J = pd.concat(series, axis=1, join="inner").dropna()
combo = 0.5 * J["BTC"].values + 0.5 * J["ETH"].values
bpy = bpd * 365.25
sh = float(np.mean(combo) / np.std(combo) * np.sqrt(bpy)) if np.std(combo) > 0 else 0.0
eq = np.cumprod(1.0 + np.clip(combo, -0.99, None))
dd = float(np.max((np.maximum.accumulate(eq) - eq) / np.maximum.accumulate(eq)))
yrs = (J.index[-1] - J.index[0]).days / 365.25
cagr = eq[-1] ** (1 / yrs) - 1
return sh, dd, cagr
def main():
raw = {a: load(a, "1h") for a in ASSETS}
print("=" * 96)
print("# LOOK-AHEAD / EXECUTION-LAG AUDIT — trend portfolio (long-flat, tvol20, lev2), per timeframe")
print("# lag1 = standard (decision held next bar). lag2 = +1 bar execution delay (conservative).")
print("# left/right = resample label (open vs close). Big gap => labeling leak.")
print("=" * 96)
print(f" {'TF':<5s}{'Sh lag1(L)':>12s}{'Sh lag2(L)':>12s}{'Sh lag1(R)':>12s}"
f"{'CAGR l1':>10s}{'CAGR l2':>10s}{'DD l1':>8s}{'lag-decay':>11s}")
for tf, (rule, bpd) in TFS.items():
dfsL = {a: resample(raw[a], rule, "left") for a in ASSETS}
dfsR = {a: resample(raw[a], rule, "right") for a in ASSETS}
sh1L, dd1, cagr1 = portfolio_metrics(dfsL, bpd, 1)
sh2L, _, cagr2 = portfolio_metrics(dfsL, bpd, 2)
sh1R, _, _ = portfolio_metrics(dfsR, bpd, 1)
decay = (sh1L - sh2L) / sh1L * 100 if sh1L else 0.0
flag = " <-- robust" if sh2L >= 0.9 * sh1L and abs(sh1L - sh1R) < 0.1 else ""
print(f" {tf:<5s}{sh1L:>12.2f}{sh2L:>12.2f}{sh1R:>12.2f}"
f"{cagr1*100:>+9.1f}%{cagr2*100:>+9.1f}%{dd1*100:>7.1f}%{decay:>+10.0f}%{flag}")
print("\n Interpretation:")
print(" - If Sh lag2 << Sh lag1 (big lag-decay), the edge needs to trade AT the close -> sub-TF")
print(" timing artifact / cost-fragile. Robust slow-trend should barely move with +1 bar.")
print(" - If Sh lag1(left) != Sh lag1(right), the bar LABELING leaks -> look-ahead. Should match.")
if __name__ == "__main__":
main()
+365
View File
@@ -0,0 +1,365 @@
"""TRACK F — CALENDAR SEASONALITY on BTC & ETH (hour-of-day, day-of-week, interactions).
Honest test of whether there is a SYSTEMATIC, TRADEABLE calendar edge on the certified
Deribit-mainnet BTC/ETH feeds. Seasonality is the easiest place on earth to overfit
(24 hours x 7 weekdays = 168 buckets => you WILL find "significant" cells by chance), so
every claim here is held to the project's anti-look-ahead, OOS, per-year, both-assets bar.
METHODOLOGY (no shortcuts):
- ret[i] = close[i]/close[i-1]-1 is known at close[i]. A position decided at close[i]
earns ret[i+1]. We NEVER include the bar being traded (or any future bar) in the
statistic that decides the trade.
- DESCRIPTIVE tables (per-hour / per-weekday mean returns) are split IS(65%)/OOS(35%).
They are diagnostics, not trades.
- TRADEABLE rule = ADAPTIVE EXPANDING sign: at close[i] we look up the calendar bucket
of bar i+1 (the clock is known with zero look-ahead) and take the SIGN of that bucket's
mean return computed ONLY on bars <= i (expanding, warmup-gated). Long-flat or
long-short. Fees charged only on |Δposition| (turnover-aware). This lets the data pick
each bucket's sign LIVE — the honest analogue of "trade the seasonal".
- Also an in-sample-optimised discrete rule (enter at hour H, hold W bars, best dir) is
shown ONLY to demonstrate the overfit gap IS->OOS.
- NET fees fee_side baseline 0.0005 (=0.10% RT); swept 0.0005/0.00075/0.001.
- A survivor must be net-positive OOS AND across years AND on BOTH BTC & ETH.
Run: uv run python scripts/research/trackF_seasonality.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from src.backtest.harness import load # noqa: E402
ASSETS = ["BTC", "ETH"]
TF = "1h"
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% round-trip
BARS_PER_DAY = 24
BPY = BARS_PER_DAY * 365.25
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def prep(asset: str, tf: str = TF):
df = load(asset, tf)
c = df["close"].values.astype(float)
ret = np.empty(len(c))
ret[0] = 0.0
ret[1:] = c[1:] / c[:-1] - 1.0
dt = pd.to_datetime(df["datetime"])
return dict(
df=df, ret=ret,
hour=dt.dt.hour.values.astype(int),
dow=dt.dt.dayofweek.values.astype(int), # 0=Mon..6=Sun
ts=dt,
)
def metrics_from_pnl(pnl: np.ndarray, ts: pd.Series):
"""pnl[i] = realized per-bar net return of the strategy (already fee-adjusted)."""
eq = np.cumprod(1.0 + np.clip(pnl, -0.99, None))
r = pnl[np.isfinite(pnl)]
sharpe = float(np.mean(r) / np.std(r) * np.sqrt(BPY)) if np.std(r) > 0 else 0.0
peak = np.maximum.accumulate(eq)
maxdd = float(np.max((peak - eq) / peak)) if len(eq) else 0.0
span_days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
years = span_days / 365.25 if span_days > 0 else 1.0
total = eq[-1] / eq[0] if len(eq) else 1.0
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
daily_2k = (2000 * total - 2000) / span_days if span_days > 0 else 0.0
return dict(sharpe=sharpe, maxdd=maxdd, cagr=cagr, total=total - 1.0,
daily_2k=daily_2k, eq=eq)
def per_year_pnl(pnl: np.ndarray, ts: pd.Series):
s = pd.Series(pnl, index=ts.values)
out = {}
for y, g in s.groupby(s.index.year):
eq = np.cumprod(1.0 + np.clip(g.values, -0.99, None))
out[int(y)] = float(eq[-1] - 1.0)
return out
# ---------------------------------------------------------------------------
# 1. DESCRIPTIVE seasonality tables (diagnostics, IS vs OOS)
# ---------------------------------------------------------------------------
def descriptive(data, frac=0.65):
n = len(data["ret"])
cut = int(n * frac)
ret, hour, dow = data["ret"], data["hour"], data["dow"]
rows_h, rows_d = {}, {}
for h in range(24):
m_is = ret[:cut][hour[:cut] == h]
m_oos = ret[cut:][hour[cut:] == h]
rows_h[h] = (m_is.mean() * 1e4, m_oos.mean() * 1e4,
np.sign(m_is.mean()) == np.sign(m_oos.mean()))
for d in range(7):
m_is = ret[:cut][dow[:cut] == d]
m_oos = ret[cut:][dow[cut:] == d]
rows_d[d] = (m_is.mean() * 1e4, m_oos.mean() * 1e4,
np.sign(m_is.mean()) == np.sign(m_oos.mean()))
return rows_h, rows_d
# ---------------------------------------------------------------------------
# 2. ADAPTIVE EXPANDING-sign seasonal strategy (the honest tradeable test)
# ---------------------------------------------------------------------------
def adaptive_seasonal(data, bucket="hour", mode="longshort",
warmup=200, fee_side=FEE_SIDE):
"""Position at close[i] = sign of the EXPANDING past mean return of bar (i+1)'s
calendar bucket, using only bars <= i. earns ret[i+1]. Fee on |Δposition|."""
ret = data["ret"]
key = data[bucket]
n = len(ret)
nbuck = int(key.max()) + 1
sums = np.zeros(nbuck)
counts = np.zeros(nbuck)
pos = np.zeros(n)
for i in range(1, n - 1):
b = key[i]
sums[b] += ret[i]
counts[b] += 1
nb = key[i + 1]
if counts[nb] >= warmup:
m = sums[nb] / counts[nb]
if m > 0:
pos[i] = 1.0
else:
pos[i] = -1.0 if mode == "longshort" else 0.0
# pnl[i] earned over bar i+1
pnl = np.zeros(n)
prev = 0.0
for i in range(1, n - 1):
turn = abs(pos[i] - prev)
pnl[i] = pos[i] * ret[i + 1] - fee_side * turn
prev = pos[i]
return pnl, pos
def adaptive_hourxdow(data, mode="longshort", warmup=120, fee_side=FEE_SIDE):
ret, hour, dow = data["ret"], data["hour"], data["dow"]
key = hour * 7 + dow # 168 buckets
n = len(ret)
sums = np.zeros(168)
counts = np.zeros(168)
pos = np.zeros(n)
for i in range(1, n - 1):
b = key[i]
sums[b] += ret[i]
counts[b] += 1
nb = key[i + 1]
if counts[nb] >= warmup:
m = sums[nb] / counts[nb]
if m > 0:
pos[i] = 1.0
else:
pos[i] = -1.0 if mode == "longshort" else 0.0
pnl = np.zeros(n)
prev = 0.0
for i in range(1, n - 1):
turn = abs(pos[i] - prev)
pnl[i] = pos[i] * ret[i + 1] - fee_side * turn
prev = pos[i]
return pnl, pos
# ---------------------------------------------------------------------------
# 3. In-sample-optimised DISCRETE rule (to expose the overfit gap)
# ---------------------------------------------------------------------------
def discrete_hour_rule_scan(data, frac=0.65, fee_side=FEE_SIDE):
"""Scan IS for best (entry_hour, hold_window, direction) by IS Sharpe; report OOS.
A trade: enter at close of bar whose hour==H (decided with data<=close[i]), hold W
bars, exit at close. One trade per day. Fee charged round-trip on each trade.
"""
ret, hour, ts = data["ret"], data["hour"], data["ts"]
n = len(ret)
cut = int(n * frac)
def rule_pnl(H, W, direction, lo, hi):
pnl = np.zeros(n)
i = lo
last_exit = lo - 1
while i < hi:
if hour[i] == H and i > last_exit:
# cumulative return over the next W bars: prod(1+ret[i+1..i+W]) - 1
end = min(i + W, n - 1)
gross = np.prod(1.0 + ret[i + 1:end + 1]) - 1.0
pnl[i] = direction * gross - 2 * fee_side
last_exit = end
i = end
else:
i += 1
return pnl
best = None
n_tested = 0
for H in range(24):
for W in (1, 2, 3, 4, 6, 8, 12, 24):
for direction in (+1, -1):
n_tested += 1
pnl_is = rule_pnl(H, W, direction, 1, cut)
r = pnl_is[pnl_is != 0.0]
if len(r) < 50:
continue
sh = np.mean(r) / np.std(r) * np.sqrt(BPY) if np.std(r) > 0 else 0.0
if best is None or sh > best[0]:
best = (sh, H, W, direction)
sh, H, W, direction = best
pnl_oos = rule_pnl(H, W, direction, cut, n)
r_oos = pnl_oos[pnl_oos != 0.0]
sh_oos = (np.mean(r_oos) / np.std(r_oos) * np.sqrt(BPY)) if (len(r_oos) and np.std(r_oos) > 0) else 0.0
return dict(n_tested=n_tested, H=H, W=W, dir=direction, sh_is=sh,
sh_oos=sh_oos, n_is=int((rule_pnl(H, W, direction, 1, cut) != 0).sum()),
n_oos=len(r_oos), oos_mean_bp=r_oos.mean() * 1e4 if len(r_oos) else 0.0)
# ---------------------------------------------------------------------------
# reporting
# ---------------------------------------------------------------------------
def split_metrics(pnl, ts, frac=0.65):
n = len(pnl)
cut = int(n * frac)
m_is = metrics_from_pnl(pnl[:cut], ts.iloc[:cut])
m_oos = metrics_from_pnl(pnl[cut:], ts.iloc[cut:])
m_all = metrics_from_pnl(pnl, ts)
return m_is, m_oos, m_all
def turnover_per_year(pos, ts):
s = pd.Series(np.abs(np.diff(pos, prepend=0.0)), index=ts.values)
return s.groupby(s.index.year).sum().to_dict()
def main():
print("=" * 100)
print("# TRACK F — CALENDAR SEASONALITY (hour-of-day / day-of-week / hour×weekday)")
print("# certified Deribit-mainnet BTC & ETH, 1h UTC. fee_side=0.0005 (0.10% RT).")
print("# No look-ahead: bucket stats use only bars <= i; position earns ret[i+1].")
print("=" * 100)
data = {a: prep(a) for a in ASSETS}
# --- DESCRIPTIVE ---------------------------------------------------------
print("\n" + "#" * 100)
print("# 1. DESCRIPTIVE per-bucket mean returns (basis points/bar). IS=first 65%, OOS=last 35%.")
print("# 'sign?' = IS and OOS agree on sign. Diagnostics only (NOT trades, no fees).")
print("#" * 100)
for a in ASSETS:
rows_h, rows_d = descriptive(data[a])
print(f"\n ── {a} HOUR-OF-DAY (UTC) mean bp/hr ─────────────────────────────")
print(" hr : IS_bp OOS_bp sign?")
agree_h = 0
for h in range(24):
iv, ov, ag = rows_h[h]
agree_h += int(ag)
flag = " <-- US open" if h in (13, 14) else (" <-- US close" if h in (20, 21) else "")
print(f" {h:>2d} : {iv:>+6.2f} {ov:>+6.2f} {'Y' if ag else '.'}{flag}")
print(f" hour sign-agreement IS/OOS: {agree_h}/24")
print(f"\n ── {a} DAY-OF-WEEK mean bp/bar (0=Mon..6=Sun) ──────────────────")
names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
agree_d = 0
for d in range(7):
iv, ov, ag = rows_d[d]
agree_d += int(ag)
print(f" {names[d]} : {iv:>+6.3f} {ov:>+6.3f} {'Y' if ag else '.'}")
print(f" weekday sign-agreement IS/OOS: {agree_d}/7")
# --- ADAPTIVE EXPANDING-SIGN (the honest tradeable test) ----------------
print("\n" + "#" * 100)
print("# 2. ADAPTIVE EXPANDING-SIGN seasonal strategies (HONEST tradeable test).")
print("# sign of bucket's PAST-ONLY mean decides position; fee on turnover.")
print("#" * 100)
configs = [
("HOUR long-short", "hour", "longshort", 200),
("HOUR long-flat ", "hour", "longflat", 200),
("DOW long-short", "dow", "longshort", 60),
("DOW long-flat ", "dow", "longflat", 60),
]
for label, bucket, mode, warmup in configs:
print(f"\n ── {label} ────────────────────────────────────────────────────")
for a in ASSETS:
pnl, pos = adaptive_seasonal(data[a], bucket=bucket, mode=mode, warmup=warmup)
ts = data[a]["ts"]
m_is, m_oos, m_all = split_metrics(pnl, ts)
py = per_year_pnl(pnl, ts)
yrs = "".join(f"{py.get(y, float('nan'))*100:>+6.0f}" for y in range(2019, 2027))
print(f" {a}: ALL Sh={m_all['sharpe']:>+5.2f} CAGR={m_all['cagr']*100:>+6.1f}% "
f"DD={m_all['maxdd']*100:>4.1f}% €/d={m_all['daily_2k']:>+5.2f} | "
f"IS Sh={m_is['sharpe']:>+5.2f} OOS Sh={m_oos['sharpe']:>+5.2f}")
print(f" per-year %: {yrs} (2019..2026)")
# buy-and-hold benchmark — the key control: does any 'seasonal' beat just being long?
print(f"\n ── BUY-AND-HOLD benchmark (the control for long-bias) ──")
for a in ASSETS:
ret = data[a]["ret"].copy()
ret[0] = 0.0
m = metrics_from_pnl(ret, data[a]["ts"])
print(f" {a}: Sh={m['sharpe']:>+5.2f} CAGR={m['cagr']*100:>+6.1f}% DD={m['maxdd']*100:>4.1f}% "
f" <- compare to DOW long-flat above (it's nearly identical = no edge, just long)")
# hour x weekday interaction (168 buckets — extreme overfit risk)
print(f"\n ── HOUR×WEEKDAY long-short (168 buckets, warmup 120) — overfit canary ──")
for a in ASSETS:
pnl, pos = adaptive_hourxdow(data[a], mode="longshort", warmup=120)
ts = data[a]["ts"]
m_is, m_oos, m_all = split_metrics(pnl, ts)
print(f" {a}: ALL Sh={m_all['sharpe']:>+5.2f} CAGR={m_all['cagr']*100:>+6.1f}% "
f"DD={m_all['maxdd']*100:>4.1f}% | IS Sh={m_is['sharpe']:>+5.2f} OOS Sh={m_oos['sharpe']:>+5.2f}")
# --- FEE SWEEP on the best adaptive config -------------------------------
print("\n" + "#" * 100)
print("# 3. FEE SWEEP — HOUR long-short adaptive (turnover-aware). Are survivors fee-robust?")
print("#" * 100)
for fee in (0.0, 0.0005, 0.00075, 0.001):
line = f" fee_side={fee:.5f} (RT {fee*2*100:.2f}%): "
for a in ASSETS:
pnl, _ = adaptive_seasonal(data[a], bucket="hour", mode="longshort",
warmup=200, fee_side=fee)
m = metrics_from_pnl(pnl, data[a]["ts"])
line += f"{a} Sh={m['sharpe']:>+5.2f} CAGR={m['cagr']*100:>+6.1f}% "
print(line)
# --- TURNOVER (fees are first-order for hour strategies) -----------------
print("\n" + "#" * 100)
print("# 4. TURNOVER (HOUR long-short adaptive): position flips/year (each flip costs ~fee).")
print("#" * 100)
for a in ASSETS:
_, pos = adaptive_seasonal(data[a], bucket="hour", mode="longshort", warmup=200)
tpy = turnover_per_year(pos, data[a]["ts"])
s = " ".join(f"{y}:{int(v)}" for y, v in sorted(tpy.items()))
print(f" {a} turnover units/yr: {s}")
# --- IN-SAMPLE-OPTIMISED DISCRETE RULE (overfit demonstration) ----------
print("\n" + "#" * 100)
print("# 5. IN-SAMPLE-OPTIMISED discrete rule (enter hour H, hold W, best dir).")
print("# Picked by IS Sharpe, reported OOS. Demonstrates the multiple-testing trap.")
print("#" * 100)
for a in ASSETS:
r = discrete_hour_rule_scan(data[a])
print(f" {a}: tested {r['n_tested']} (H,W,dir) cells -> best IS "
f"H={r['H']:02d} hold={r['W']}h dir={r['dir']:+d} "
f"IS Sh={r['sh_is']:>+5.2f} (n={r['n_is']}) -> OOS Sh={r['sh_oos']:>+5.2f} "
f"(n={r['n_oos']}, mean {r['oos_mean_bp']:>+.1f} bp/trade)")
# --- VERDICT -------------------------------------------------------------
print("\n" + "#" * 100)
print("# MULTIPLE-TESTING CAVEAT")
print("#" * 100)
print("""
Buckets examined: 24 hours + 7 weekdays + 168 hour×weekday = 199 calendar cells PER ASSET,
each tested IS and OOS, plus discrete grid = 24×8×2 = 384 (H,W,dir) cells per asset.
With that many cells, spurious 'significant' buckets are GUARANTEED. The honest filters
applied here: (a) adaptive sign chosen live on PAST data only (no cherry-picking),
(b) must hold OOS, (c) must hold per-year, (d) must hold on BOTH BTC AND ETH.
Read the IS->OOS Sharpe collapse and the per-year sign flips above as the real verdict.
""")
if __name__ == "__main__":
main()
+478
View File
@@ -0,0 +1,478 @@
"""TRACK G — PRIOR-PERIOD LEVEL BREAKOUTS / RANGE on CLEAN BTC/ETH (Deribit mainnet).
HONEST harness only. We test rules defined RELATIVE TO A PRIOR CALENDAR PERIOD:
* prior-DAY high/low breakout (continuation AND fade)
* opening-range breakout (first N UTC hours -> break for rest of day)
* prior-day CLOSE / gap / range-position / prior-day return-sign filter
* prior-WEEK high/low breakout
* time-anchored entries (act at a given UTC hour vs prior-day level), exit EOD/fixed/TP-SL
The single question: on clean BTC/ETH, with a genuinely EXECUTABLE entry (direction and
price decided with data <= close[i], fill at close[i], NEVER entering at the exact level
intrabar), net of realistic Deribit fees, OOS and grid-robust on BOTH assets
do prior-period breakouts CONTINUE (trend) or REVERT (fade)? Is there a deployable edge?
NO LOOK-AHEAD GUARANTEES:
* Prior-period levels are built by aggregating to daily/weekly bars and SHIFTING by one
full period (shift(1) on the closed-period frame). 'Today'/'this-week' is NEVER part of
the level. The prior period is fully closed before any bar of the current period.
* Opening-range levels are used ONLY on bars AFTER the open window has fully closed.
* Direction + price decided at close[i]; fill at close[i] (harness enforces).
Run:
uv run python scripts/research/trackG_prior_levels.py # full
uv run python scripts/research/trackG_prior_levels.py --quick # 1h only, fewer grids
"""
from __future__ import annotations
import argparse
import sys
import time
from itertools import product
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from src.backtest.harness import load, backtest_signals, oos_split
# ===========================================================================
# Causal helpers
# ===========================================================================
def atr(df: pd.DataFrame, period: int = 14) -> np.ndarray:
h, l, c = df["high"].values, df["low"].values, df["close"].values
pc = np.roll(c, 1)
pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
return pd.Series(tr).ewm(alpha=1.0 / period, adjust=False).mean().values
def prior_period_levels(df: pd.DataFrame, period: str = "D") -> dict:
"""Return prior-period high/low/close/open/range arrays aligned to each intraday bar.
period='D': prior calendar day (UTC). period='W': prior ISO week (anchored Mon 00:00 UTC).
Uses shift(1) on the CLOSED-period frame: the level for the current period only sees the
fully-closed previous period -> no look-ahead.
"""
dt = df["datetime"]
if period == "D":
key = dt.dt.floor("D")
elif period == "W":
key = dt.dt.floor("D") - pd.to_timedelta(dt.dt.weekday, unit="D")
else:
raise ValueError(period)
key = key.reset_index(drop=True)
agg = pd.DataFrame({
"key": key,
"high": df["high"].values, "low": df["low"].values,
"close": df["close"].values, "open": df["open"].values,
})
g = agg.groupby("key").agg(high=("high", "max"), low=("low", "min"),
close=("close", "last"), open=("open", "first")).sort_index()
gp = g.shift(1) # prior, fully-closed period
km = key.map # map current-period key -> prior-period aggregate
ph = km(gp["high"]).values.astype(float)
pl = km(gp["low"]).values.astype(float)
pc = km(gp["close"]).values.astype(float)
po = km(gp["open"]).values.astype(float)
pret = (gp["close"] / gp["open"] - 1.0) # prior-period return (sign filter)
prv = key.map(pret).values.astype(float)
return {"ph": ph, "pl": pl, "pc": pc, "po": po, "prange": ph - pl, "pret": prv}
def opening_range(df: pd.DataFrame, n_open_hours: int) -> dict:
"""Opening-range high/low for the first n_open_hours of each UTC day, plus a per-bar
flag of whether the open window has CLOSED (hour >= n_open_hours)."""
dt = df["datetime"]
date = dt.dt.floor("D")
hour = dt.dt.hour
date = date.reset_index(drop=True)
in_open = (hour < n_open_hours).values
o = pd.DataFrame({"date": date, "high": df["high"].values, "low": df["low"].values})
o_open = o[in_open]
org = o_open.groupby("date").agg(orh=("high", "max"), orl=("low", "min"))
orh = date.map(org["orh"]).values.astype(float)
orl = date.map(org["orl"]).values.astype(float)
closed = (hour >= n_open_hours).values
return {"orh": orh, "orl": orl, "closed": closed}
def bars_left_in_day(df: pd.DataFrame) -> np.ndarray:
date = df["datetime"].dt.floor("D")
grp = df.groupby(date)
idx_in_day = grp.cumcount().values
size = grp["close"].transform("size").values
return (size - idx_in_day - 1).astype(int)
# ===========================================================================
# Signal generators -> list[dict|None] length len(df). Decisions use data <= close[i].
# ===========================================================================
def sig_prior_break(df, period="D", level="high", side="cont", anchor_hour=None,
exit_mode="eod", max_bars=24, tp_atr=0.0, sl_atr=0.0, atr_p=14,
buffer=0.0):
"""Prior-period level breakout.
level='high': trigger when close[i] > prior_high*(1+buffer)
level='low' : trigger when close[i] < prior_low *(1-buffer)
side='cont' : trade IN the breakout direction (high->long, low->short)
side='fade' : trade AGAINST it (high->short, low->long)
anchor_hour : if set, only evaluate on bars at that UTC hour (time-anchored)
exit_mode : 'eod' (close at end of UTC day), 'bars' (max_bars), TP/SL via *_atr.
"""
lv = prior_period_levels(df, period)
c = df["close"].values
a = atr(df, atr_p) if (tp_atr or sl_atr) else None
bl = bars_left_in_day(df) if exit_mode == "eod" else None
hour = df["datetime"].dt.hour.values
n = len(c)
out = [None] * n
ref = lv["ph"] if level == "high" else lv["pl"]
for i in range(n):
if anchor_hour is not None and hour[i] != anchor_hour:
continue
r = ref[i]
if not np.isfinite(r):
continue
px = c[i]
if level == "high":
if not (px > r * (1.0 + buffer)):
continue
brk_dir = 1
else:
if not (px < r * (1.0 - buffer)):
continue
brk_dir = -1
direction = brk_dir if side == "cont" else -brk_dir
if exit_mode == "eod":
mb = max(int(bl[i]), 1)
else:
mb = max_bars
tp = sl = None
if a is not None and np.isfinite(a[i]):
if tp_atr:
tp = px + direction * tp_atr * a[i]
if sl_atr:
sl = px - direction * sl_atr * a[i]
out[i] = {"dir": direction, "tp": tp, "sl": sl, "max_bars": mb}
return out
def sig_or_break(df, n_open_hours=6, side="cont", exit_mode="eod", max_bars=12,
tp_atr=0.0, sl_atr=0.0, atr_p=14, buffer=0.0):
"""Opening-range breakout: after the first n_open_hours close, trade a break of the
OR high (long if cont) or OR low (short if cont). Only the FIRST break per day fires
(the harness keeps the position busy until exit)."""
orr = opening_range(df, n_open_hours)
c = df["close"].values
a = atr(df, atr_p) if (tp_atr or sl_atr) else None
bl = bars_left_in_day(df) if exit_mode == "eod" else None
n = len(c)
out = [None] * n
orh, orl, closed = orr["orh"], orr["orl"], orr["closed"]
for i in range(n):
if not closed[i] or not np.isfinite(orh[i]):
continue
px = c[i]
if px > orh[i]:
brk = 1
elif px < orl[i]:
brk = -1
else:
continue
direction = brk if side == "cont" else -brk
if exit_mode == "eod":
mb = max(int(bl[i]), 1)
else:
mb = max_bars
tp = sl = None
if a is not None and np.isfinite(a[i]):
if tp_atr:
tp = px + direction * tp_atr * a[i]
if sl_atr:
sl = px - direction * sl_atr * a[i]
out[i] = {"dir": direction, "tp": tp, "sl": sl, "max_bars": mb}
return out
def sig_gap(df, side="cont", anchor_hour=0, thr=0.0, exit_mode="eod", max_bars=24,
ret_filter=0):
"""Gap vs prior-day CLOSE, evaluated at a given UTC hour (default the first bar of the
day). gap = close[i]/prior_close - 1. If gap>thr -> up-gap; gap<-thr -> down-gap.
side='cont' trades in the gap direction; 'fade' against. ret_filter: +1 only when
prior-day return positive, -1 only when negative, 0 no filter."""
lv = prior_period_levels(df, "D")
c = df["close"].values
bl = bars_left_in_day(df) if exit_mode == "eod" else None
hour = df["datetime"].dt.hour.values
pc, pret = lv["pc"], lv["pret"]
n = len(c)
out = [None] * n
for i in range(n):
if hour[i] != anchor_hour or not np.isfinite(pc[i]):
continue
gap = c[i] / pc[i] - 1.0
if gap > thr:
g = 1
elif gap < -thr:
g = -1
else:
continue
if ret_filter and np.isfinite(pret[i]):
if ret_filter > 0 and not (pret[i] > 0):
continue
if ret_filter < 0 and not (pret[i] < 0):
continue
direction = g if side == "cont" else -g
mb = max(int(bl[i]), 1) if exit_mode == "eod" else max_bars
out[i] = {"dir": direction, "tp": None, "sl": None, "max_bars": mb}
return out
# ===========================================================================
# Evaluation
# ===========================================================================
def run_split(df, sigfn, params, fee_rt=0.001, leverage=1.0, frac=0.65):
cut = oos_split(df, frac)
full = backtest_signals(df, sigfn(df, **params), fee_rt=fee_rt, leverage=leverage)
di = df.iloc[:cut].reset_index(drop=True)
do = df.iloc[cut:].reset_index(drop=True)
is_ = backtest_signals(di, sigfn(di, **params), fee_rt=fee_rt, leverage=leverage)
oos = backtest_signals(do, sigfn(do, **params), fee_rt=fee_rt, leverage=leverage)
return full, is_, oos
def hdr(t):
print("\n" + "=" * 100)
print(t)
print("=" * 100)
# ===========================================================================
# Main
# ===========================================================================
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--quick", action="store_true")
args = ap.parse_args()
t0 = time.time()
assets = ["BTC", "ETH"]
tfs = ["1h"] if args.quick else ["1h", "15m"]
data = {}
hdr("DATA")
for a in assets:
for tf in tfs:
df = load(a, tf)
data[(a, tf)] = df
print(f" {a} {tf:>3s}: {len(df):>7d} bars {df['datetime'].iloc[0].date()}"
f"->{df['datetime'].iloc[-1].date()}")
# ---------------------------------------------------------------------
# PASS 1 — PRIOR-DAY BREAKOUT: continuation vs fade, any-bar (first break/day),
# EOD exit. THE core question: do prior-day breakouts continue or revert?
# ---------------------------------------------------------------------
hdr("PASS 1 — PRIOR-DAY HIGH/LOW breakout, any-bar first-break, EOD exit (1h, fee=0.001)\n"
" CONTINUATION vs FADE side-by-side. OOS net must be >0 on BOTH to matter.")
print(f" {'rule':<26s} | "
f"{'BTC IS / OOS (tr, wr, shrp)':<40s} | {'ETH IS / OOS (tr, wr, shrp)':<40s}")
for level in ["high", "low"]:
for side in ["cont", "fade"]:
name = f"PD {level:<4s} {side}"
line = f" {name:<26s} | "
for a in assets:
df = data[(a, "1h")]
_, is_, oos = run_split(df, sig_prior_break,
dict(period="D", level=level, side=side,
exit_mode="eod"))
line += (f"{is_.net_return*100:>+6.0f}/{oos.net_return*100:>+6.0f}% "
f"(t{oos.n_trades:>4d} w{oos.win_rate:>4.1f} s{oos.sharpe:>+4.1f}) | ")
print(line)
# ---------------------------------------------------------------------
# PASS 2 — OPENING-RANGE breakout (continuation vs fade), various open windows.
# ---------------------------------------------------------------------
hdr("PASS 2 — OPENING-RANGE breakout (first N UTC hours), EOD exit (1h, fee=0.001).\n"
" CONTINUATION vs FADE. Survivor = OOS>0 on BOTH assets.")
for nopen in ([6] if args.quick else [3, 6, 8, 12]):
for side in ["cont", "fade"]:
name = f"OR N={nopen:<2d} {side}"
line = f" {name:<26s} | "
for a in assets:
df = data[(a, "1h")]
_, is_, oos = run_split(df, sig_or_break,
dict(n_open_hours=nopen, side=side, exit_mode="eod"))
line += (f"{a} OOS={oos.net_return*100:>+6.0f}% "
f"(t{oos.n_trades:>4d} w{oos.win_rate:>4.1f} s{oos.sharpe:>+4.1f}) | ")
print(line)
# ---------------------------------------------------------------------
# PASS 3 — GAP vs prior close at day open (hour 0), continuation vs fade,
# with optional prior-day return-sign filter.
# ---------------------------------------------------------------------
hdr("PASS 3 — GAP vs prior-day CLOSE at hour 0, EOD exit (1h, fee=0.001).\n"
" continuation vs fade; thr = min |gap|.")
for thr in ([0.0] if args.quick else [0.0, 0.005, 0.01]):
for side in ["cont", "fade"]:
name = f"GAP thr={thr*100:.1f}% {side}"
line = f" {name:<26s} | "
for a in assets:
df = data[(a, "1h")]
_, is_, oos = run_split(df, sig_gap,
dict(side=side, anchor_hour=0, thr=thr, exit_mode="eod"))
line += (f"{a} OOS={oos.net_return*100:>+6.0f}% "
f"(t{oos.n_trades:>4d} w{oos.win_rate:>4.1f} s{oos.sharpe:>+4.1f}) | ")
print(line)
# ---------------------------------------------------------------------
# PASS 4 — PRIOR-WEEK high/low breakout (continuation vs fade), EOD exit.
# ---------------------------------------------------------------------
hdr("PASS 4 — PRIOR-WEEK HIGH/LOW breakout, any-bar first-break, EOD exit (1h, fee=0.001).")
for level in ["high", "low"]:
for side in ["cont", "fade"]:
name = f"PW {level:<4s} {side}"
line = f" {name:<26s} | "
for a in assets:
df = data[(a, "1h")]
_, is_, oos = run_split(df, sig_prior_break,
dict(period="W", level=level, side=side,
exit_mode="eod"))
line += (f"{a} IS={is_.net_return*100:>+6.0f}% OOS={oos.net_return*100:>+6.0f}% "
f"(t{oos.n_trades:>4d} s{oos.sharpe:>+4.1f}) | ")
print(line)
# ---------------------------------------------------------------------
# PASS 5 — TIME-ANCHORED prior-day breakout: sweep the anchor hour to expose
# whether any apparent edge is just a lucky single hour.
# ---------------------------------------------------------------------
hdr("PASS 5 — TIME-ANCHORED PD-high CONTINUATION across UTC anchor hours (1h, EOD exit).\n"
" A real edge is NOT a single lucky hour. (full-sample net per hour.)")
hours = list(range(0, 24, 1 if not args.quick else 3))
for a in assets:
df = data[(a, "1h")]
cells = []
for hh in hours:
full, _, _ = run_split(df, sig_prior_break,
dict(period="D", level="high", side="cont",
anchor_hour=hh, exit_mode="eod"))
cells.append((hh, full.net_return * 100, full.sharpe, full.n_trades))
pos = sum(1 for _, r, _, _ in cells if r > 0)
print(f" {a}: {pos}/{len(cells)} anchor-hours net>0 (full). "
f"best={max(cells, key=lambda x: x[1])[0]}h "
f"({max(c[1] for c in cells):+.0f}%) worst={min(c[1] for c in cells):+.0f}%")
line = " " + " ".join(f"{hh:02d}h:{r:>+5.0f}" for hh, r, _, _ in cells)
print(line)
# ---------------------------------------------------------------------
# PASS 6 — GRID ROBUSTNESS on the best family from PASS 1-4. We grid the
# PD-low CONTINUATION and FADE plus OR breakout, require OOS>0 on BOTH assets.
# ---------------------------------------------------------------------
hdr("PASS 6 — GRID ROBUSTNESS. Cell SURVIVES only if OOS net>0 on BOTH BTC AND ETH.")
def grid(label, fn, base, sweep, tf="1h", fee=0.001):
keys = list(sweep.keys())
rows, surv = [], []
for combo in product(*[sweep[k] for k in keys]):
params = dict(base); params.update(dict(zip(keys, combo)))
res = {}
for a in assets:
_, is_, oos = run_split(data[(a, tf)], fn, params, fee_rt=fee)
res[a] = oos
ok = all(res[a].net_return > 0 for a in assets)
rows.append((params, res, ok))
if ok:
surv.append((params, res))
print(f" [{label}] {len(surv)}/{len(rows)} cells OOS>0 on BOTH assets")
rows.sort(key=lambda r: np.mean([r[1][a].net_return for a in assets]), reverse=True)
for params, res, ok in rows[:5]:
tag = "OK " if ok else " -"
pp = {k: params[k] for k in sweep}
s = f" {tag}{pp} | "
for a in assets:
s += f"{a} OOS={res[a].net_return*100:>+6.0f}% (s{res[a].sharpe:>+4.1f}) "
print(s)
return surv
sweeps = []
sweeps.append(grid("PD-low cont", sig_prior_break,
dict(period="D", level="low", side="cont", exit_mode="eod"),
dict(buffer=[0.0, 0.001, 0.003], anchor_hour=[None])))
sweeps.append(grid("PD-low fade", sig_prior_break,
dict(period="D", level="low", side="fade", exit_mode="eod"),
dict(buffer=[0.0, 0.001, 0.003], anchor_hour=[None])))
sweeps.append(grid("PD-high cont", sig_prior_break,
dict(period="D", level="high", side="cont", exit_mode="eod"),
dict(buffer=[0.0, 0.001, 0.003], anchor_hour=[None])))
sweeps.append(grid("PD-high fade", sig_prior_break,
dict(period="D", level="high", side="fade", exit_mode="eod"),
dict(buffer=[0.0, 0.001, 0.003], anchor_hour=[None])))
if not args.quick:
sweeps.append(grid("OR cont", sig_or_break,
dict(side="cont", exit_mode="eod"),
dict(n_open_hours=[3, 6, 8, 12])))
sweeps.append(grid("OR fade", sig_or_break,
dict(side="fade", exit_mode="eod"),
dict(n_open_hours=[3, 6, 8, 12])))
# ---------------------------------------------------------------------
# PASS 7 — FEE SWEEP + per-year on the single best surviving rule (if any),
# else on the least-bad PD rule, to show fee sensitivity and year stability.
# ---------------------------------------------------------------------
hdr("PASS 7 — FEE SWEEP + PER-YEAR on the best PD rule. fee=0 is GROSS (is the SIGN of\n"
" the edge even right before fees?).")
# pick best rule: scan the 4 PD sides at default, mean OOS over assets
candidates = [
("PD low cont", dict(period="D", level="low", side="cont", exit_mode="eod")),
("PD low fade", dict(period="D", level="low", side="fade", exit_mode="eod")),
("PD high cont", dict(period="D", level="high", side="cont", exit_mode="eod")),
("PD high fade", dict(period="D", level="high", side="fade", exit_mode="eod")),
]
scored = []
for nm, p in candidates:
m = np.mean([run_split(data[(a, "1h")], sig_prior_break, p)[2].net_return for a in assets])
scored.append((m, nm, p))
scored.sort(reverse=True)
best_nm, best_p = scored[0][1], scored[0][2]
print(f" best-by-meanOOS PD rule: {best_nm} (meanOOS={scored[0][0]*100:+.0f}%)")
fees = [0.0, 0.0005, 0.001, 0.0015, 0.002]
for a in assets:
df = data[(a, "1h")]
line = f" {a} fee-sweep (RT%): "
for f in fees:
full, _, oos = run_split(df, sig_prior_break, best_p, fee_rt=f)
line += f"{f*100:.2f}%[full={full.net_return*100:>+5.0f}/OOS={oos.net_return*100:>+5.0f}] "
print(line)
print(" per-year (full sample, fee=0.001):")
for a in assets:
df = data[(a, "1h")]
full, _, _ = run_split(df, sig_prior_break, best_p)
yrs = " ".join(f"{y}:{full.yearly[y]*100:>+5.0f}%" for y in sorted(full.yearly))
print(f" {a}: trades={full.n_trades} Sharpe={full.sharpe:+.2f} "
f"maxDD={full.max_dd*100:.0f}% EUR/d(2k)={full.daily_profit(2000):+.2f}")
print(f" {yrs}")
# ---------------------------------------------------------------------
# VERDICT
# ---------------------------------------------------------------------
hdr("VERDICT")
total_surv = sum(len(s) for s in sweeps)
if total_surv == 0:
print(" ZERO grid cells produced OOS net>0 on BOTH BTC and ETH at baseline fees.")
print(" => No robust prior-period breakout/fade edge on clean BTC/ETH. The continuation-")
print(" vs-fade tables above show which SIDE (if any) is even net-positive in-sample;")
print(" consult PASS 1-5 for direction. Not deployable.")
else:
print(f" {total_surv} grid cell(s) survived OOS>0 on both assets. Inspect PASS 6/7 and")
print(" stress with fee sweep + per-year before trusting. List of survivors:")
for s in sweeps:
for params, res in s:
ms = np.mean([res[a].net_return for a in assets]) * 100
print(f" {params} meanOOS={ms:+.0f}%")
print(f"\n (elapsed {time.time()-t0:.0f}s)")
if __name__ == "__main__":
main()
+602
View File
@@ -0,0 +1,602 @@
"""TRACK H — VOLUME, RANGE & VOLATILITY-REGIME signals on CLEAN BTC/ETH (Deribit mainnet).
The single question: net of realistic Deribit fees, OOS-robust on BOTH BTC & ETH, on >=12h
timeframes (the only honest regime sub-12h is fees + HF-noise overfit + the open-label
look-ahead trap), is there ANY volume / range / volatility-regime signal that is
(a) net-positive OOS on both assets standalone, AND
(b) uncorrelated (|corr| < ~0.3) to the deployed winner TP01, AND/OR
(c) usable as a REGIME FILTER that lifts TP01's Sharpe above ~1.32 or cuts its DD?
HONESTY / NO LOOK-AHEAD:
* Everything runs on the SAME causal per-bar engine used by TP01 (net_returns): we build a
continuous TARGET position decided with data <= close[i], then HOLD it during bar i+1
(pos_held[t] = target[t-1]). Gross = pos_held * simple_return[t]; fee charged on |Δpos|.
This is identical in spirit to the harness `backtest_signals` (decide<=close[i], fill at
close[i]); we cross-check two discrete signals through `backtest_signals` too.
* Volume / range / vol features for bar i use ONLY bars <= i (rolling, prior-window, shift).
* 12h / 1d frames are resampled from the certified 1h feed via resample_tf (label='left',
closed='left') and consumed index-based with the +1 bar hold -> the open-label is never
leaked (verified in trackD_lookahead_audit.py: Sharpe is label-invariant under this hold).
Run:
uv run python scripts/research/trackH_volume_vol.py # full (12h + 1d)
uv run python scripts/research/trackH_volume_vol.py --quick # 12h only, fewer grids
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from src.backtest.harness import load, backtest_signals
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_tf
ASSETS = ["BTC", "ETH"]
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% RT (Deribit taker)
OOS_FRAC = 0.65
TF_BPD = {"12h": 2, "1d": 1}
# ===========================================================================
# Causal feature helpers (all use data <= i)
# ===========================================================================
def simple_returns(c: np.ndarray) -> np.ndarray:
r = np.zeros(len(c))
r[1:] = c[1:] / c[:-1] - 1.0
return r
def realized_vol(r: np.ndarray, win: int, bpy: float) -> np.ndarray:
return pd.Series(r).rolling(win, min_periods=max(2, win // 2)).std().values * np.sqrt(bpy)
def roll_max_prior(x: np.ndarray, win: int) -> np.ndarray:
"""Max over the PRIOR `win` bars (excludes current bar i)."""
return pd.Series(x).shift(1).rolling(win, min_periods=win).max().values
def roll_min_prior(x: np.ndarray, win: int) -> np.ndarray:
return pd.Series(x).shift(1).rolling(win, min_periods=win).min().values
def roll_mean_prior(x: np.ndarray, win: int) -> np.ndarray:
return pd.Series(x).shift(1).rolling(win, min_periods=win).mean().values
def vol_zscore(vol: np.ndarray, win: int) -> np.ndarray:
"""z-score of current volume vs PRIOR `win` bars (uses <= i)."""
s = pd.Series(vol)
m = s.shift(1).rolling(win, min_periods=win).mean()
sd = s.shift(1).rolling(win, min_periods=win).std()
return ((s - m) / sd).values
def atr(df: pd.DataFrame, period: int) -> np.ndarray:
h, l, c = df["high"].values, df["low"].values, df["close"].values
pc = np.roll(c, 1)
pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
return pd.Series(tr).ewm(alpha=1.0 / period, adjust=False).mean().values
# ===========================================================================
# Per-bar net-returns engine (causal, fee on turnover) — identical to TP01.net_returns
# ===========================================================================
def net_from_target(target: np.ndarray, r: np.ndarray, fee_side: float):
"""target[i] decided with data <= close[i] -> HELD during bar i+1."""
target = np.nan_to_num(target, nan=0.0)
pos = np.zeros(len(target))
pos[1:] = target[:-1]
gross = pos * r
turn = np.abs(np.diff(pos, prepend=0.0))
net = gross - fee_side * turn
net[0] = 0.0
net = np.clip(net, -0.99, None)
return net, pos, turn
def metrics(net: np.ndarray, idx: pd.DatetimeIndex, turn: np.ndarray, bpy: float) -> dict:
rr = net[np.isfinite(net)]
sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)) if np.std(rr) > 0 else 0.0
equity = np.cumprod(1.0 + np.clip(net, -0.99, None))
peak = np.maximum.accumulate(equity)
dd = float(np.max((peak - equity) / peak)) if len(equity) else 0.0
span_days = (idx[-1] - idx[0]).total_seconds() / 86400
years = span_days / 365.25 if span_days > 0 else 1.0
total = equity[-1] / equity[0] if len(equity) else 1.0
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
ann_turn = float(np.sum(turn)) / years if years > 0 else 0.0
return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total=total - 1,
ann_turnover=ann_turn, equity=equity, years=years)
def per_year(net: np.ndarray, idx: pd.DatetimeIndex) -> dict:
eq = pd.Series(np.cumprod(1.0 + np.clip(net, -0.99, None)), index=idx)
out = {}
for y, g in eq.groupby(eq.index.year):
if len(g) > 1 and g.iloc[0] > 0:
out[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1)
return out
# ===========================================================================
# SIGNALS — each returns a continuous TARGET array (frac of equity, +/-), causal.
# ===========================================================================
def sig_vt_long(df, bpd, target_vol=0.20, vol_win_days=30, lev=2.0, **_):
"""Volatility-managed LONG: always long, sized to a vol target (no trend at all).
Tests Moreira-Muir 'volatility-managed' alpha vs plain buy-and-hold."""
c = df["close"].values.astype(float)
r = simple_returns(c)
bpy = bpd * 365.25
vol = realized_vol(r, vol_win_days * bpd, bpy)
tgt = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0)
return np.clip(tgt, 0, lev)
def sig_vol_breakout(df, bpd, don=20, zwin=20, zk=1.0, long_short=False, **_):
"""Volume-confirmed Donchian breakout (continuation). Long when close > prior-`don`-bar high
AND volume z-score > zk; stay long until close < prior-`don`-bar low (then flat/short)."""
c = df["close"].values.astype(float)
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
vol = df["volume"].values.astype(float)
hi = roll_max_prior(h, don)
lo = roll_min_prior(l, don)
z = vol_zscore(vol, zwin)
up = (c > hi) & (z > zk)
dn = (c < lo) & (z > zk)
state = np.zeros(len(c))
s = 0.0
for i in range(len(c)):
if up[i]:
s = 1.0
elif dn[i]:
s = -1.0 if long_short else 0.0
elif s == 1.0 and c[i] < lo[i]: # trailing exit for longs
s = -1.0 if long_short else 0.0
elif s == -1.0 and c[i] > hi[i]:
s = 1.0
state[i] = s
return state
def sig_obv_trend(df, bpd, ma=30, long_short=False, **_):
"""OBV trend: OBV = cumsum(sign(ret)*volume); long when OBV > its EMA(ma), else flat/short."""
c = df["close"].values.astype(float)
vol = df["volume"].values.astype(float)
r = simple_returns(c)
obv = np.cumsum(np.sign(r) * vol)
ema = pd.Series(obv).ewm(span=ma, adjust=False).mean().values
d = np.where(obv > ema, 1.0, (-1.0 if long_short else 0.0))
return d
def sig_vw_momentum(df, bpd, mom_win=30, vol_win_days=30, target_vol=0.20, lev=2.0,
long_only=True, **_):
"""Volume-weighted momentum: sign of volume-weighted mean return over `mom_win` bars,
vol-targeted. Compare to plain TSMOM (does weighting by volume add anything?)."""
c = df["close"].values.astype(float)
vol = df["volume"].values.astype(float)
r = simple_returns(c)
rw = r * vol
num = pd.Series(rw).rolling(mom_win, min_periods=mom_win).sum().values
den = pd.Series(vol).rolling(mom_win, min_periods=mom_win).sum().values
vwret = np.where(den > 0, num / den, 0.0)
direction = np.sign(vwret)
if long_only:
direction = np.clip(direction, 0, None)
bpy = bpd * 365.25
rv = realized_vol(r, vol_win_days * bpd, bpy)
scal = np.where((rv > 0) & np.isfinite(rv), target_vol / rv, 0.0)
return np.clip(direction * scal, -lev, lev)
def sig_range_expansion(df, bpd, rng_win=20, k=1.5, hold=5, long_short=False, **_):
"""Range-expansion breakout: when today's range > k * avg(prior `rng_win` ranges) and the
bar closed in the upper/lower half, go with the close direction; hold `hold` bars."""
c = df["close"].values.astype(float)
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
rng = h - l
avg = roll_mean_prior(rng, rng_win)
expand = rng > k * avg
pos_in_bar = np.where(rng > 0, (c - l) / rng, 0.5)
long_trig = expand & (pos_in_bar > 0.6)
short_trig = expand & (pos_in_bar < 0.4)
state = np.zeros(len(c))
hold_left = 0
cur = 0.0
for i in range(len(c)):
if hold_left > 0:
hold_left -= 1
else:
cur = 0.0
if long_trig[i]:
cur = 1.0
hold_left = hold
elif short_trig[i] and long_short:
cur = -1.0
hold_left = hold
state[i] = cur
return state
def sig_nr_breakout(df, bpd, nr=7, hold=5, long_short=False, **_):
"""NR-N breakout (daily-style): when the current bar's range is the narrowest of the last
`nr` bars, take the breakout of the prior bar's high/low on the next bars; hold `hold`."""
c = df["close"].values.astype(float)
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
rng = h - l
is_nr = pd.Series(rng).rolling(nr, min_periods=nr).apply(
lambda w: 1.0 if w[-1] == np.min(w) else 0.0, raw=True).values
state = np.zeros(len(c))
cur = 0.0
hold_left = 0
armed = False
arm_hi = arm_lo = np.nan
for i in range(len(c)):
if hold_left > 0:
hold_left -= 1
else:
cur = 0.0
if armed:
if c[i] > arm_hi:
cur = 1.0
hold_left = hold
armed = False
elif c[i] < arm_lo and long_short:
cur = -1.0
hold_left = hold
armed = False
if is_nr[i] == 1.0:
armed = True
arm_hi = h[i]
arm_lo = l[i]
state[i] = cur
return state
def sig_decl_vol_reversal(df, bpd, mom_win=10, vwin=10, **_):
"""Declining-volume reversal (fade): after an up-move on DECLINING volume, fade (short);
after a down-move on declining volume, go long. Pure contrarian, vol-confirmed exhaustion."""
c = df["close"].values.astype(float)
vol = df["volume"].values.astype(float)
ret = pd.Series(c).pct_change(mom_win).values
vtrend = vol - roll_mean_prior(vol, vwin)
declining = vtrend < 0
state = np.zeros(len(c))
state[(ret > 0) & declining] = -1.0
state[(ret < 0) & declining] = 1.0
return state
SIGNALS = {
"VT-long": (sig_vt_long, dict(target_vol=0.20, vol_win_days=30, lev=2.0)),
"VolBreakout": (sig_vol_breakout, dict(don=20, zwin=20, zk=1.0)),
"OBV-trend": (sig_obv_trend, dict(ma=30)),
"VW-mom": (sig_vw_momentum, dict(mom_win=30, vol_win_days=30)),
"RangeExpand": (sig_range_expansion, dict(rng_win=20, k=1.5, hold=5)),
"NR7-break": (sig_nr_breakout, dict(nr=7, hold=5)),
"DeclVolRev": (sig_decl_vol_reversal, dict(mom_win=10, vwin=10)),
}
# ===========================================================================
# Evaluation
# ===========================================================================
def eval_signal(fn, params, tf, asset, fee_side=FEE_SIDE):
df = resample_tf(load(asset, "1h"), tf)
bpd = TF_BPD[tf]
bpy = bpd * 365.25
c = df["close"].values.astype(float)
r = simple_returns(c)
idx = pd.to_datetime(df["datetime"].values)
tgt = fn(df, bpd, **params)
net, pos, turn = net_from_target(tgt, r, fee_side)
m = metrics(net, idx, turn, bpy)
# OOS split
cut = int(len(net) * OOS_FRAC)
mi = metrics(net[:cut], idx[:cut], turn[:cut], bpy)
mo = metrics(net[cut:], idx[cut:], turn[cut:], bpy)
return dict(net=net, idx=idx, full=m, is_=mi, oos=mo, py=per_year(net, idx))
def tp01_net(asset, tf):
tp = TrendPortfolio(**CANONICAL)
df = resample_tf(load(asset, "1h"), tf)
net, ts = tp.net_returns(df)
return pd.Series(net, index=pd.to_datetime(ts.values))
def corr_to_tp01(net, idx, tp_series):
s = pd.Series(net, index=idx)
j = pd.concat([s.rename("a"), tp_series.rename("b")], axis=1, join="inner").fillna(0.0)
if j["a"].std() == 0 or j["b"].std() == 0:
return 0.0
return float(j["a"].corr(j["b"]))
# ===========================================================================
# Reports
# ===========================================================================
def report_headline(tf, quick):
print("\n" + "=" * 120)
print(f"# HEADLINE — TF {tf} | standalone signals, full / IS / OOS, turnover, corr→TP01 (fee 0.10% RT)")
print("=" * 120)
tp = {a: tp01_net(a, tf) for a in ASSETS}
print(f" {'signal':<14s}{'asset':<6s}"
f"{'fullShrp':>9s}{'fullCAGR':>9s}{'fullDD':>7s}"
f"{'IS_Shrp':>8s}{'OOS_Shrp':>9s}{'OOS_ret':>8s}{'turn/y':>8s}{'corrTP':>8s}")
results = {}
for name, (fn, params) in SIGNALS.items():
for a in ASSETS:
res = eval_signal(fn, params, tf, a)
cr = corr_to_tp01(res["net"], res["idx"], tp[a])
results[(name, a)] = (res, cr)
print(f" {name:<14s}{a:<6s}"
f"{res['full']['sharpe']:>9.2f}{res['full']['cagr']*100:>8.1f}%"
f"{res['full']['max_dd']*100:>6.1f}%"
f"{res['is_']['sharpe']:>8.2f}{res['oos']['sharpe']:>9.2f}"
f"{res['oos']['total']*100:>7.1f}%{res['full']['ann_turnover']:>8.1f}{cr:>8.2f}")
return results, tp
def report_peryear(results):
print("\n" + "-" * 120)
print("# PER-YEAR net return (%) — only signals with OOS Sharpe>0 on BOTH assets shown")
print("-" * 120)
years = list(range(2018, 2027))
# which signals pass OOS>0 both assets
good = []
for name in SIGNALS:
if all(results[(name, a)][0]["oos"]["sharpe"] > 0 for a in ASSETS):
good.append(name)
if not good:
print(" (none — no signal has positive OOS Sharpe on BOTH assets)")
return good
print(" " + " " * 22 + "".join(f"{y:>7d}" for y in years))
for name in good:
for a in ASSETS:
py = results[(name, a)][0]["py"]
row = "".join((" . " if y not in py else f"{py[y]*100:>+7.0f}") for y in years)
print(f" {name+' '+a:<22s}{row}")
return good
def report_grid(quick):
print("\n" + "=" * 120)
print("# GRID ROBUSTNESS (TF 12h) — fraction of cells with positive full+OOS Sharpe on BOTH assets")
print("=" * 120)
tf = "12h"
grids = {
"VolBreakout": ("sig", sig_vol_breakout,
dict(don=[10, 20, 40] if not quick else [20],
zwin=[10, 20, 40], zk=[0.5, 1.0, 2.0])),
"OBV-trend": ("sig", sig_obv_trend, dict(ma=[15, 30, 60, 100])),
"VW-mom": ("sig", sig_vw_momentum,
dict(mom_win=[15, 30, 60, 90], long_only=[True])),
"RangeExpand": ("sig", sig_range_expansion,
dict(rng_win=[10, 20, 40], k=[1.3, 1.5, 2.0], hold=[3, 5, 10])),
"VT-long": ("sig", sig_vt_long, dict(target_vol=[0.15, 0.20, 0.30],
vol_win_days=[15, 30, 60])),
}
from itertools import product
for name, (_, fn, axes) in grids.items():
keys = list(axes.keys())
combos = list(product(*[axes[k] for k in keys]))
npos = 0
best = (-9, None)
for combo in combos:
params = dict(zip(keys, combo))
ok = True
sh_sum = 0.0
for a in ASSETS:
res = eval_signal(fn, params, tf, a)
if not (res["full"]["sharpe"] > 0 and res["oos"]["sharpe"] > 0):
ok = False
sh_sum += res["oos"]["sharpe"]
if ok:
npos += 1
if sh_sum > best[0]:
best = (sh_sum, params)
print(f" {name:<14s} positive both(full&OOS): {npos:>3d}/{len(combos):<3d} "
f"({npos/len(combos)*100:>4.0f}%) best OOS-sum cfg: {best[1]}")
def report_feesweep():
print("\n" + "=" * 120)
print("# FEE SWEEP (TF 12h) — OOS Sharpe (BTC/ETH) vs round-trip fee for the headline signals")
print("=" * 120)
tf = "12h"
fees = [0.0, 0.0005, 0.001, 0.0015, 0.002] # per side; RT = 2x
print(f" {'signal':<14s}" + "".join(f" RT{f*2*100:>4.2f}%" for f in fees))
for name, (fn, params) in SIGNALS.items():
cells = []
for f in fees:
shs = []
for a in ASSETS:
res = eval_signal(fn, params, tf, a, fee_side=f)
shs.append(res["oos"]["sharpe"])
cells.append(f"{shs[0]:>4.2f}/{shs[1]:<4.2f}")
print(f" {name:<14s}" + "".join(f" {c:>9s}" for c in cells))
# ===========================================================================
# REGIME FILTER on TP01 — does a vol/volume regime mask lift Sharpe or cut DD?
# ===========================================================================
def vol_regime_mask(df, bpd, win_days=30, mode="low", q=0.5):
"""Boolean per-bar mask (decided <= close[i]) for a realized-vol regime.
mode='low': keep exposure when vol <= rolling median; 'high': when vol > median."""
c = df["close"].values.astype(float)
r = simple_returns(c)
bpy = bpd * 365.25
vol = realized_vol(r, win_days * bpd, bpy)
# causal expanding/rolling quantile threshold (use a long rolling window, prior bars)
thr = pd.Series(vol).shift(1).rolling(180 * bpd, min_periods=30 * bpd).quantile(q).values
if mode == "low":
mask = vol <= thr
else:
mask = vol > thr
return np.nan_to_num(mask.astype(float), nan=1.0) # default keep before warmup
def vol_managed_mask(df, bpd, win_days=30, target_vol=0.20, cap=1.5):
"""Continuous vol-scaling multiplier on TP01: scale exposure by target_vol/realized_vol,
capped an explicit volatility-managed overlay distinct from TP01's own sizing."""
c = df["close"].values.astype(float)
r = simple_returns(c)
bpy = bpd * 365.25
vol = realized_vol(r, win_days * bpd, bpy)
mult = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 1.0)
return np.clip(mult, 0.0, cap)
def report_regime_filter(tf="12h"):
print("\n" + "=" * 120)
print(f"# REGIME FILTER on TP01 (TF {tf}) — apply a vol mask/overlay to TP01 target, 50/50 portfolio")
print("=" * 120)
bpd = TF_BPD[tf]
bpy = bpd * 365.25
tp = TrendPortfolio(**CANONICAL)
def portfolio(transform):
"""transform(df,target)->target'; returns combined 50/50 net series + idx."""
series = {}
for a in ASSETS:
df = resample_tf(load(a, "1h"), tf)
r = simple_returns(df["close"].values.astype(float))
tgt = tp.target_series(df)
tgt2 = transform(df, tgt)
net, _, _ = net_from_target(tgt2, r, CANONICAL["fee_side"])
series[a] = pd.Series(net, index=pd.to_datetime(df["datetime"].values))
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
combo = 0.5 * J[ASSETS[0]].values + 0.5 * J[ASSETS[1]].values
return combo, J.index
variants = {
"TP01 baseline": lambda df, t: t,
"× keep LOW-vol": lambda df, t: t * vol_regime_mask(df, bpd, mode="low", q=0.5),
"× keep HIGH-vol": lambda df, t: t * vol_regime_mask(df, bpd, mode="high", q=0.5),
"× keep LOW-vol q.7": lambda df, t: t * vol_regime_mask(df, bpd, mode="low", q=0.7),
"× vol-managed x1.5": lambda df, t: t * vol_managed_mask(df, bpd, cap=1.5) /
np.maximum(vol_managed_mask(df, bpd, cap=1.5).mean(), 1e-9),
"× obv-up only": lambda df, t: t * (np.where(
np.cumsum(np.sign(simple_returns(df['close'].values.astype(float))) * df['volume'].values)
> pd.Series(np.cumsum(np.sign(simple_returns(df['close'].values.astype(float)))
* df['volume'].values)).ewm(span=30, adjust=False).mean().values,
1.0, 0.0)),
}
print(f" {'variant':<22s}{'fullShrp':>9s}{'IS_Shrp':>8s}{'OOS_Shrp':>9s}"
f"{'CAGR':>8s}{'maxDD':>8s}{'turn/y':>9s}")
for name, tr in variants.items():
combo, idx = portfolio(tr)
m = metrics(combo, idx, np.zeros_like(combo), bpy)
cut = int(len(combo) * OOS_FRAC)
mi = metrics(combo[:cut], idx[:cut], np.zeros_like(combo[:cut]), bpy)
mo = metrics(combo[cut:], idx[cut:], np.zeros_like(combo[cut:]), bpy)
tt = 0.0
for a in ASSETS:
df = resample_tf(load(a, "1h"), tf)
tgt2 = tr(df, tp.target_series(df))
tt += np.sum(np.abs(np.diff(np.nan_to_num(tgt2), prepend=0.0)))
ann_tt = tt / m["years"] / 2.0
print(f" {name:<22s}{m['sharpe']:>9.2f}{mi['sharpe']:>8.2f}{mo['sharpe']:>9.2f}"
f"{m['cagr']*100:>7.1f}%{m['max_dd']*100:>7.1f}%{ann_tt:>9.1f}")
# robustness of the OBV-up filter across EMA spans (is 1.49 luck or stable?)
print("\n OBV-up filter robustness across EMA span (full / OOS Sharpe, maxDD):")
for span in [15, 20, 30, 45, 60, 90]:
def tr(df, t, sp=span):
c = df['close'].values.astype(float)
v = df['volume'].values.astype(float)
obv = np.cumsum(np.sign(simple_returns(c)) * v)
ema = pd.Series(obv).ewm(span=sp, adjust=False).mean().values
return t * np.where(obv > ema, 1.0, 0.0)
combo, idx = portfolio(tr)
m = metrics(combo, idx, np.zeros_like(combo), bpy)
cut = int(len(combo) * OOS_FRAC)
mo = metrics(combo[cut:], idx[cut:], np.zeros_like(combo[cut:]), bpy)
py = per_year(combo, idx)
neg_years = sum(1 for y, v in py.items() if v < 0)
print(f" span {span:>3d}: full {m['sharpe']:>4.2f} OOS {mo['sharpe']:>4.2f} "
f"DD {m['max_dd']*100:>4.1f}% CAGR {m['cagr']*100:>5.1f}% neg-years {neg_years}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--quick", action="store_true")
args = ap.parse_args()
print("#" * 120)
print("# TRACK H — VOLUME / RANGE / VOLATILITY-REGIME on certified BTC/ETH (Deribit mainnet)")
print("# Honest engine: target decided <=close[i], held bar i+1; fee on |Δpos|; OOS 65/35; >=12h only.")
print("#" * 120)
tfs = ["12h"] if args.quick else ["12h", "1d"]
for tf in tfs:
results, tp = report_headline(tf, args.quick)
report_peryear(results)
if tf == "12h":
crosscheck_backtest_signals()
report_grid(args.quick)
report_feesweep()
report_regime_filter("12h")
print("\n" + "#" * 120)
print("# VERDICT (track H) — honest reading of the tables above")
print("#" * 120)
for line in [
"1. NO uncorrelated additive edge found. Every PROFITABLE volume/range/vol signal",
" (VolBreakout, OBV-trend, VW-mom, VT-long) is trend-in-disguise: corr-to-TP01 0.61-0.75.",
" They do not diversify TP01 -> cannot raise the 50/50 portfolio Sharpe.",
"2. The genuinely LOWER-corr signals (RangeExpand ~0.48, NR7 ~0.48) FAIL OOS on >=1 asset",
" (NR7 ETH OOS Sharpe ~0.0/-0.03; RangeExpand BTC weak, ETH negative on 1d). Not deployable.",
"3. Declining-volume / fade (mean-reversion) is firmly NEGATIVE net of fees on both assets",
" and at ZERO fee -> confirms the v2.0.0 lesson: MR edge was feed contamination, it is dead.",
"4. Vol-REGIME gating of TP01 (keep low-vol / keep high-vol) HURTS Sharpe (1.32 -> 0.94/0.98).",
" A vol-managed x1.5 overlay leaves Sharpe ~flat (1.33) but raises DD (17.9%). No win.",
"5. The ONLY non-harmful overlay is an OBV-up trend-CONFIRMATION filter (keep TP01 long only",
" while OBV>EMA): full Sharpe 1.32->1.49, maxDD 13.3%->10.1%, but CAGR 16.2%->14.4%, turnover",
" +60%, and OOS gain is marginal (0.90->1.04) and span-sensitive (fades for EMA>45). It is",
" trend double-confirmation (de-risking), NOT new alpha. Worth noting as a DEFENSIVE overlay",
" if cutting DD matters more than CAGR; it does NOT robustly raise the portfolio Sharpe.",
"BOTTOM LINE: the ~1.3 portfolio-Sharpe ceiling on BTC/ETH-only HOLDS. Volume/range/vol add",
"nothing uncorrelated. TP01 stays the deployable winner.",
]:
print(" " + line)
print("#" * 120)
def crosscheck_backtest_signals():
"""Cross-check two DISCRETE signals through the canonical harness `backtest_signals`
(decide<=close[i], fill at close[i]) to confirm the per-bar engine isn't flattering them."""
print("\n" + "-" * 120)
print("# CROSS-CHECK via harness.backtest_signals (discrete entries, fee 0.10% RT, TF 12h)")
print("-" * 120)
tf = "12h"
for a in ASSETS:
df = resample_tf(load(a, "1h"), tf)
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
c = df["close"].values.astype(float)
rng = h - l
avg = roll_mean_prior(rng, 20)
pos_in_bar = np.where(rng > 0, (c - l) / rng, 0.5)
expand = rng > 1.5 * avg
entries = [None] * len(df)
for i in range(len(df)):
if expand[i] and pos_in_bar[i] > 0.6:
entries[i] = dict(dir=1, tp=None, sl=None, max_bars=5)
m = backtest_signals(df, entries, fee_rt=0.001, leverage=1.0, asset=a, tf=tf)
m.print_summary(f"RangeExpand(L,5b) {a}")
if __name__ == "__main__":
main()
@@ -0,0 +1,420 @@
"""TRACK I — ALTERNATIVE MOMENTUM FORMULATIONS + LONG-HORIZON REVERSAL (BTC & ETH, >=12h).
Goal:
(A) Find a momentum formulation that BEATS or DIVERSIFIES the canonical TP01 sign-blend
(TSMOM 1-3-6m, vol-targeted, 50/50 BTC+ETH, 12h, Sharpe ~1.32).
(B) Test the classic LONG-HORIZON REVERSAL effect (fade 12/18/24-month winners) as a
potentially UNCORRELATED positive overlay, and a momentum+reversal blend.
Honest harness (mirrors src/strategies/trend_portfolio.py exactly):
- direction decided with data <= close[i]; positions HELD next bar (pos_held[1:] = tgt[:-1]);
- vol-target by inverse PAST-ONLY realized vol (target_vol/vol), leverage-capped;
- NET fees 0.10% RT (0.05%/side) on turnover; fee sweep included;
- 12h / 1d only (sub-12h is dominated by costs/overfit and a prior 4h look-ahead bug);
- OOS 65/35 split + per-year; robustness across lookbacks AND both assets;
- correlation vs TP01 net returns reported for EVERY candidate.
A candidate is INTERESTING only if net-positive OOS on BOTH assets AND either
(higher portfolio Sharpe than TP01 ~1.32) OR (|corr to TP01| < ~0.3 and positive).
Run: uv run python scripts/research/trackI_momentum_reversal.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from src.backtest.harness import load
from src.strategies.trend_portfolio import resample_tf, simple_returns, realized_vol
ASSETS = ["BTC", "ETH"]
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% RT
TARGET_VOL = 0.20
LEVERAGE = 2.0
VOL_WIN_DAYS = 30
OOS_FRAC = 0.65
MONTH = 30 # days per "month" (calendar-consistent across TFs)
# tf -> bars_per_day
TF_BPD = {"12h": 2, "1d": 1}
# ---------------------------------------------------------------------------
# data
# ---------------------------------------------------------------------------
def get_df(asset: str, tf: str) -> pd.DataFrame:
df = load(asset, "1h")
rule = {"12h": "12h", "1d": "1D"}[tf]
return resample_tf(df, rule)
# ---------------------------------------------------------------------------
# vol-target machinery (identical convention to TP01)
# ---------------------------------------------------------------------------
def build_target(direction, vol, long_only):
d = np.clip(direction, 0, None) if long_only else direction
scal = np.where((vol > 0) & np.isfinite(vol), TARGET_VOL / vol, 0.0)
tgt = np.clip(d * scal, -LEVERAGE, LEVERAGE)
tgt[~np.isfinite(tgt)] = 0.0
return tgt
def net_from_target(tgt, r, fee_side=FEE_SIDE):
pos_held = np.zeros(len(tgt))
pos_held[1:] = tgt[:-1]
gross = pos_held * r
turn = np.abs(np.diff(pos_held, prepend=0.0))
net = gross - fee_side * turn
net[0] = 0.0
return np.clip(net, -0.99, None)
# ---------------------------------------------------------------------------
# DIRECTION FORMULATIONS (each returns array in roughly [-1, 1], causal, decided <= close[i])
# ---------------------------------------------------------------------------
def _log_mom(c, h):
"""log return over h bars; nan before h."""
m = np.full(len(c), np.nan)
m[h:] = np.log(c[h:] / c[:-h])
return m
def dir_signblend(c, bpd, horizons_m=(1, 3, 6)):
"""TP01 baseline: mean of sign(log return) over horizons."""
n = len(c)
acc = np.zeros(n); cnt = np.zeros(n)
for hm in horizons_m:
h = hm * MONTH * bpd
s = np.full(n, np.nan)
s[h:] = np.sign(c[h:] / c[:-h] - 1.0)
v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
return out
def dir_zscore(c, bpd, horizons_m=(1, 3, 6), std_win_m=12):
"""(i) Continuous momentum: z-scored cumulative log-return, tanh-bounded, multi-horizon avg."""
n = len(c); w = std_win_m * MONTH * bpd
acc = np.zeros(n); cnt = np.zeros(n)
for hm in horizons_m:
h = hm * MONTH * bpd
m = _log_mom(c, h)
s = pd.Series(m)
sd = s.rolling(w, min_periods=w // 3).std().values
z = np.where((sd > 0) & np.isfinite(sd), m / sd, np.nan)
d = np.tanh(z)
v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
return out
def dir_riskadj(c, bpd, horizons_m=(1, 3, 6)):
"""(ii) Risk-adjusted momentum: h-horizon return / vol-of-that-horizon, tanh, multi-horizon."""
n = len(c); r = simple_returns(c)
acc = np.zeros(n); cnt = np.zeros(n)
for hm in horizons_m:
h = hm * MONTH * bpd
ret = np.full(n, np.nan); ret[h:] = c[h:] / c[:-h] - 1.0
# vol of the h-bar return = per-bar std over last h bars * sqrt(h)
sd = pd.Series(r).rolling(h, min_periods=h // 2).std().values * np.sqrt(h)
ra = np.where((sd > 0) & np.isfinite(sd), ret / sd, np.nan)
d = np.tanh(ra)
v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
return out
def _ema(c, span):
return pd.Series(c).ewm(span=span, adjust=False).mean().values
def dir_emacross(c, bpd, pairs_m=((1, 3), (2, 6), (3, 9))):
"""(iii) EMA-cross trend: mean of sign(ema_fast - ema_slow) over calendar-day pairs."""
n = len(c)
acc = np.zeros(n); cnt = np.zeros(n)
for fm, sm in pairs_m:
ef = _ema(c, fm * MONTH * bpd)
es = _ema(c, sm * MONTH * bpd)
warm = sm * MONTH * bpd
d = np.sign(ef - es)
d[:warm] = np.nan
v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
return out
def dir_macd(c, bpd):
"""(iii-b) Classic MACD with calendar spans (fast~1m, slow~2m, signal~0.75m): sign(macd-signal)."""
n = len(c)
fast = int(round(1.0 * MONTH * bpd)); slow = int(round(2.0 * MONTH * bpd))
sig = int(round(0.75 * MONTH * bpd))
macd = _ema(c, fast) - _ema(c, slow)
signal = pd.Series(macd).ewm(span=sig, adjust=False).mean().values
d = np.sign(macd - signal)
d[:slow] = 0.0
return d
def dir_donchian(c, bpd, n_m=2):
"""(iv) Donchian breakout (>=12h): +1 if close > prior-N max, -1 if < prior-N min, else hold."""
n = len(c); N = n_m * MONTH * bpd
hi = pd.Series(c).rolling(N, min_periods=N).max().shift(1).values
lo = pd.Series(c).rolling(N, min_periods=N).min().shift(1).values
d = np.zeros(n); state = 0.0
for i in range(n):
if np.isfinite(hi[i]) and c[i] >= hi[i]:
state = 1.0
elif np.isfinite(lo[i]) and c[i] <= lo[i]:
state = -1.0
d[i] = state
return d
def dir_accel(c, bpd, horizons_m=(3, 6), lag_m=1):
"""(v) Acceleration: sign of CHANGE in momentum (mom[i] - mom[i-lag]) i.e. 2nd derivative."""
n = len(c); lag = lag_m * MONTH * bpd
acc = np.zeros(n); cnt = np.zeros(n)
for hm in horizons_m:
h = hm * MONTH * bpd
m = _log_mom(c, h)
dm = np.full(n, np.nan)
dm[lag:] = m[lag:] - m[:-lag]
d = np.sign(dm)
v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
return out
def dir_mom12_1(c, bpd, lookbacks_m=(6, 12), skip_m=1):
"""(vi) 12-1 momentum: return from (i-L) to (i-skip), skipping the most-recent `skip` month.
For index i (>=L): sign( c[i-skip] / c[i-L] - 1 ). Causal (uses data <= close[i-skip])."""
n = len(c); skip = skip_m * MONTH * bpd
acc = np.zeros(n); cnt = np.zeros(n)
for Lm in lookbacks_m:
L = Lm * MONTH * bpd
s = np.full(n, np.nan)
# i runs L..n-1: c[i-skip] = c[L-skip : n-skip], c[i-L] = c[0 : n-L]
s[L:] = np.sign(c[L - skip:n - skip] / c[:n - L] - 1.0)
v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
return out
def make_reversal(lookbacks_m):
"""(B) long-horizon reversal: -sign of long-horizon return (short past winners)."""
def fn(c, bpd):
n = len(c)
acc = np.zeros(n); cnt = np.zeros(n)
for Lm in lookbacks_m:
L = Lm * MONTH * bpd
s = np.full(n, np.nan)
s[L:] = -np.sign(c[L:] / c[:-L] - 1.0)
v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
return out
return fn
def make_mom_minus_rev(mom_m, rev_m, rev_w=0.5):
"""Blend: long medium-term momentum + fade very-long-term extension (weighted)."""
def fn(c, bpd):
n = len(c)
mom = dir_signblend(c, bpd, horizons_m=mom_m)
rev_fn = make_reversal(rev_m)
rev = rev_fn(c, bpd)
return np.clip(mom + rev_w * rev, -1.0, 1.0)
return fn
# ---------------------------------------------------------------------------
# run a formulation -> per-asset net series, combined portfolio series, metrics
# ---------------------------------------------------------------------------
def asset_net_series(asset, tf, dir_fn, long_only, fee_side=FEE_SIDE):
df = get_df(asset, tf); bpd = TF_BPD[tf]
c = df["close"].values.astype(float)
r = simple_returns(c)
bpy = bpd * 365.25
vol = realized_vol(r, VOL_WIN_DAYS * bpd, bpy)
direction = dir_fn(c, bpd)
tgt = build_target(direction, vol, long_only)
net = net_from_target(tgt, r, fee_side)
return pd.Series(net, index=pd.to_datetime(df["datetime"].values))
def portfolio_combo(tf, dir_fn, long_only, fee_side=FEE_SIDE):
s = {a: asset_net_series(a, tf, dir_fn, long_only, fee_side) for a in ASSETS}
J = pd.concat(s, axis=1, join="inner").fillna(0.0)
combo = 0.5 * J[ASSETS[0]].values + 0.5 * J[ASSETS[1]].values
return pd.Series(combo, index=J.index), s
def sharpe_of(series, bpy):
r = series.values[np.isfinite(series.values)]
return float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0
def metrics_of(combo: pd.Series, bpy):
idx = combo.index
equity = np.cumprod(1.0 + np.clip(combo.values, -0.99, None))
sharpe = sharpe_of(combo, bpy)
peak = np.maximum.accumulate(equity)
dd = float(np.max((peak - equity) / peak))
years = (idx[-1] - idx[0]).total_seconds() / 86400 / 365.25
total = equity[-1] / equity[0]
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
eq = pd.Series(equity, index=idx)
yearly = {}
for y, g in eq.groupby(eq.index.year):
if len(g) > 1 and g.iloc[0] > 0:
v = g.values; pk = np.maximum.accumulate(v)
yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((pk - v) / pk)))
# OOS split
k = int(len(combo) * OOS_FRAC)
is_sh = sharpe_of(combo.iloc[:k], bpy)
oos_sh = sharpe_of(combo.iloc[k:], bpy)
return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total=total - 1,
yearly=yearly, is_sharpe=is_sh, oos_sharpe=oos_sh, equity=eq)
ALL_YEARS = list(range(2018, 2027))
def fmt_yearly(yearly):
return "".join((" . " if y not in yearly else f"{yearly[y][0]*100:>+6.0f}") for y in ALL_YEARS)
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
PART_A = [
("baseline signblend 1-3-6m", dir_signblend),
("(i) z-score cum-ret", dir_zscore),
("(ii) risk-adj momentum", dir_riskadj),
("(iii) EMA-cross trend", dir_emacross),
("(iii-b) MACD", dir_macd),
("(iv) Donchian breakout", dir_donchian),
("(v) acceleration", dir_accel),
("(vi) 12-1 skip momentum", dir_mom12_1),
]
def report_block(title, items, tf, long_only, tp_combo, bpy):
mode = "LONG-FLAT" if long_only else "LONG-SHORT"
print(f"\n{'='*112}\n {title} | TF={tf} mode={mode}\n{'='*112}")
print(f" {'formulation':<26s} {'Shrp':>5s} {'IS':>5s} {'OOS':>5s} {'CAGR':>6s} "
f"{'maxDD':>6s} {'corrTP':>7s} {'aBTC':>5s} {'aETH':>5s} per-year PnL%")
print(f" {'':<26s} {'':>5s} {'':>5s} {'':>5s} {'':>6s} {'':>6s} {'':>7s} {'':>5s} {'':>5s} "
+ "".join(f"{y%100:>6d}" for y in ALL_YEARS))
results = {}
for name, fn in items:
combo, sleeves = portfolio_combo(tf, fn, long_only)
m = metrics_of(combo, bpy)
# per-asset standalone Sharpe
a_sh = {a: sharpe_of(sleeves[a], bpy) for a in ASSETS}
# correlation to TP01 (aligned inner)
J = pd.concat([combo.rename("x"), tp_combo.rename("t")], axis=1, join="inner").dropna()
corr = float(np.corrcoef(J["x"], J["t"])[0, 1]) if len(J) > 2 else float("nan")
print(f" {name:<26s} {m['sharpe']:>5.2f} {m['is_sharpe']:>5.2f} {m['oos_sharpe']:>5.2f} "
f"{m['cagr']*100:>+5.0f}% {m['max_dd']*100:>5.1f}% {corr:>7.2f} "
f"{a_sh['BTC']:>5.2f} {a_sh['ETH']:>5.2f} {fmt_yearly(m['yearly'])}")
results[name] = dict(metrics=m, corr=corr, combo=combo, a_sh=a_sh)
return results
def main():
print("#" * 112)
print("# TRACK I — alternative momentum formulations + long-horizon reversal (BTC&ETH, >=12h)")
print("# vol-target 20%, lev cap 2x, fee 0.10% RT, positions +1 bar, 50/50 BTC+ETH. OOS 65/35.")
print("#" * 112)
for tf in ("12h", "1d"):
bpy = TF_BPD[tf] * 365.25
# TP01 reference combo at this TF (long-flat canonical) for correlation
tp_combo, _ = portfolio_combo(tf, dir_signblend, long_only=True)
tp_m = metrics_of(tp_combo, bpy)
print(f"\n>>> TP01 reference @ {tf} (long-flat 1-3-6m): "
f"Sharpe {tp_m['sharpe']:.2f} IS {tp_m['is_sharpe']:.2f} OOS {tp_m['oos_sharpe']:.2f} "
f"CAGR {tp_m['cagr']*100:+.0f}% maxDD {tp_m['max_dd']*100:.1f}%")
# PART A — long-flat (fair vs canonical) and long-short
report_block("PART A — momentum formulations", PART_A, tf, True, tp_combo, bpy)
if tf == "12h":
report_block("PART A — momentum formulations (long-short)", PART_A, tf, False, tp_combo, bpy)
# ----- PART B: reversal + blends, focus 12h -----
tf = "12h"; bpy = TF_BPD[tf] * 365.25
tp_combo, _ = portfolio_combo(tf, dir_signblend, long_only=True)
rev_items = [
("reversal 12m", make_reversal((12,))),
("reversal 18m", make_reversal((18,))),
("reversal 24m", make_reversal((24,))),
("reversal 12-18-24m", make_reversal((12, 18, 24))),
]
print("\n\n" + "#" * 112)
print("# PART B — LONG-HORIZON REVERSAL (fade past winners). Must be net-positive AND uncorrelated.")
print("#" * 112)
revB = report_block("PART B — reversal (long-short)", rev_items, tf, False, tp_combo, bpy)
# reversal long-flat (long past losers only) for completeness
report_block("PART B — reversal (long-flat)", rev_items, tf, True, tp_combo, bpy)
blend_items = [
("mom(1-6) - 0.5*rev(12-24)", make_mom_minus_rev((1, 3, 6), (12, 24), 0.5)),
("mom(1-6) - 1.0*rev(12-24)", make_mom_minus_rev((1, 3, 6), (12, 24), 1.0)),
("mom(1-3) - 0.5*rev(18-24)", make_mom_minus_rev((1, 3), (18, 24), 0.5)),
]
report_block("PART B — momentum + reversal blend", blend_items, tf, True, tp_combo, bpy)
# ----- COMBINED PORTFOLIO: TP01 + best diversifier -----
print("\n\n" + "#" * 112)
print("# COMBINED: TP01 (long-flat) + candidate diversifier, blended on net returns")
print("#" * 112)
tp_m = metrics_of(tp_combo, bpy)
print(f" TP01 alone: Sharpe {tp_m['sharpe']:.3f} CAGR {tp_m['cagr']*100:+.0f}% maxDD {tp_m['max_dd']*100:.1f}%")
# candidates to try as overlay: the best A formulations + reversal variants
overlays = {
"z-score": (dir_zscore, True),
"risk-adj": (dir_riskadj, True),
"12-1 skip": (dir_mom12_1, True),
"reversal 12-18-24 LS": (make_reversal((12, 18, 24)), False),
"reversal 24m LS": (make_reversal((24,)), False),
}
for name, (fn, lo) in overlays.items():
cand, _ = portfolio_combo(tf, fn, lo)
J = pd.concat([tp_combo.rename("t"), cand.rename("c")], axis=1, join="inner").fillna(0.0)
corr = float(np.corrcoef(J["t"], J["c"])[0, 1])
for w in (0.5, 0.3, 0.2):
mix = pd.Series((1 - w) * J["t"].values + w * J["c"].values, index=J.index)
mm = metrics_of(mix, bpy)
tag = f"TP01 + {w:.0%} {name}"
print(f" {tag:<30s} Sharpe {mm['sharpe']:.3f} CAGR {mm['cagr']*100:+5.0f}% "
f"maxDD {mm['max_dd']*100:4.1f}% OOS {mm['oos_sharpe']:.2f} (corr={corr:+.2f})")
# ----- FEE SWEEP (robustness): 0.00 .. 0.40% RT -----
print("\n\n" + "#" * 112)
print("# FEE SWEEP — portfolio Sharpe @12h across round-trip fees (0.00-0.40% RT)")
print("#" * 112)
sweep = [
("baseline 1-3-6m (LF)", dir_signblend, True),
("z-score cum-ret (LF)", dir_zscore, True),
("MACD (LF)", dir_macd, True),
("mom(1-6)-0.5rev(12-24)(LF)", make_mom_minus_rev((1, 3, 6), (12, 24), 0.5), True),
("reversal 24m (LS)", make_reversal((24,)), False),
]
rts = [0.0, 0.0005, 0.0010, 0.0020, 0.0040]
print(f" {'formulation':<28s}" + "".join(f"{rt*100:>7.2f}%" for rt in rts) + " (RT)")
for name, fn, lo in sweep:
row = [sharpe_of(portfolio_combo(tf, fn, lo, fee_side=rt / 2)[0], bpy) for rt in rts]
print(f" {name:<28s}" + "".join(f"{v:>8.2f}" for v in row))
print("\nDone. See verdict in the script docstring / diary.")
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
"""Monitoraggio paper (dashboard). Lo stack live REALE resta in Old/."""
+136
View File
@@ -0,0 +1,136 @@
"""DASHBOARD web del portafoglio attivo (TP01 + XS01) — monitoraggio PAPER, stdlib only.
Mostra: metriche (FULL/HOLD Sharpe, DD, CAGR), per-sleeve, posizioni correnti, equity (backtest +
paper forward da scripts/live/paper_portfolio.py), ultima data dato. Nessuna auth -> solo rete
interna. Esecuzione REALE disabilitata: e' un monitor, non un trader.
uv run python -m src.live.dashboard --port 8787
"""
from __future__ import annotations
import sys, json, time
from pathlib import Path
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
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.version import APP_VERSION
PAPER = PROJECT_ROOT / "data" / "paper_portfolio" / "state.json"
_CACHE = {"t": 0.0, "data": None}
_TTL = 120.0
def build():
if _CACHE["data"] is not None and time.time() - _CACHE["t"] < _TTL:
return _CACHE["data"]
pf = StrategyPortfolio(active_sleeves(), capital=2000.0)
bt = pf.backtest()
eq = bt["equity"]; idx = bt["index"]
# 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
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,
bh=None,
)
_CACHE.update(t=time.time(), data=data)
return data
def svg_spark(spark, w=900, h=220):
ys = [v for _, v in spark]
lo, hi = min(ys), max(ys)
rng = hi - lo or 1
pts = []
for i, (_, v) in enumerate(spark):
x = i / (len(spark) - 1) * w
y = h - (v - lo) / rng * (h - 10) - 5
pts.append(f"{x:.1f},{y:.1f}")
return (f'<svg viewBox="0 0 {w} {h}" width="100%" height="{h}" preserveAspectRatio="none">'
f'<polyline fill="none" stroke="#2ecc71" stroke-width="2" points="{" ".join(pts)}"/></svg>')
def html():
d = build()
f, ho = d["full"], d["holdout"]
rows = ""
for name, s in d["per_sleeve"].items():
rows += (f"<tr><td>{name}</td><td>{s['weight']*100:.0f}%</td>"
f"<td>{s['full']['sharpe']:.2f}</td><td>{s['full']['maxdd']*100:.0f}%</td>"
f"<td>{s['holdout']['sharpe']:.2f}</td></tr>")
yrs = "".join(f"<span class=y>{y}: {v['ret']*100:+.0f}%</span>" for y, v in sorted(d["yearly"].items()))
pos = ""
for sl, p in d["positions"].items():
pos += f"<tr><td>{sl}</td><td>{'flat (in cash)' if p == {'BTC': 0.0, 'ETH': 0.0} else (p if p is not None else 'stat-mode (book 19 gambe)')}</td></tr>"
pp = d["paper"]
if pp:
days = (pd.Timestamp(pp["last"]) - pd.Timestamp(pp["start"])).days
ret = pp["equity"] / pp["initial"] - 1
paper_html = (f"<b>{pp['equity']:.2f}</b> (start {pp['initial']:.0f}, {pp['start'][:10]}"
f"{pp['last'][:10]}, {days}g) &nbsp; ret <b>{ret*100:+.2f}%</b> &nbsp; maxDD {pp['max_dd']*100:.1f}%")
else:
paper_html = "non inizializzato (gira <code>paper_portfolio.py</code>)"
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}}
h1{{font-size:20px;margin:0 0 2px}}.sub{{color:#8a93a0;font-size:13px;margin-bottom:18px}}
.cards{{display:flex;gap:14px;flex-wrap:wrap;margin-bottom:18px}}
.card{{background:#161b22;border:1px solid #222b36;border-radius:10px;padding:14px 18px;min-width:150px}}
.card .k{{color:#8a93a0;font-size:12px}}.card .v{{font-size:24px;font-weight:600}}.g{{color:#2ecc71}}.r{{color:#e74c3c}}
table{{width:100%;border-collapse:collapse;margin:8px 0 20px}}td,th{{text-align:left;padding:7px 10px;border-bottom:1px solid #222b36;font-size:14px}}
th{{color:#8a93a0;font-weight:500}}.y{{display:inline-block;background:#161b22;border:1px solid #222b36;border-radius:6px;padding:3px 8px;margin:2px;font-size:12px}}
.box{{background:#161b22;border:1px solid #222b36;border-radius:10px;padding:14px 18px;margin-bottom:18px}}
.warn{{color:#f1c40f;font-size:12px}}</style></head><body>
<h1>PythagorasGoal Portafoglio attivo (TP01 + XS01)</h1>
<div class=sub>monitor PAPER · v{d['version']} · ultimo dato {d['last_data']} · esecuzione REALE disabilitata</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>
<div class=box><div class=k style="color:#8a93a0;font-size:12px">EQUITY backtest (2019oggi, 2k)</div>{svg_spark(d['spark'])}</div>
<div class=box><b>Paper forward-only:</b> {paper_html}</div>
<h3 style="font-size:14px;color:#8a93a0">Sleeve</h3>
<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>
<div style="margin-top:10px">{yrs}</div>
<p class=warn> Paper/monitor. XS01 e' STAT-MODE (book a 19 gambe market-neutral, non eseguibile a €2k). Storia XS ~2.5 anni.</p>
</body></html>"""
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
if self.path not in ("/", "/index.html"):
self.send_response(404); self.end_headers(); return
try:
body = html().encode()
except Exception as e:
body = f"<pre>errore: {type(e).__name__}: {e}</pre>".encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers(); self.wfile.write(body)
def main():
port = 8787
if "--port" in sys.argv:
port = int(sys.argv[sys.argv.index("--port") + 1])
print(f"dashboard su :{port} (Ctrl-C per uscire)")
ThreadingHTTPServer(("0.0.0.0", port), H).serve_forever()
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
"""Portafoglio di strategie (estensibile) — v2.0.0.
Un portafoglio aggrega N SLEEVE indipendenti, ognuno = una strategia validata che produce una
serie di rendimenti netti CAUSALE e netto-fee. Gli sleeve si combinano per peso su una griglia
GIORNALIERA comune (grid unica per mixare TF diversi). Vedi src.portfolio.portfolio + sleeves.
"""
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily
__all__ = ["Sleeve", "StrategyPortfolio", "to_daily"]
+120
View File
@@ -0,0 +1,120 @@
"""PORTAFOGLIO DI STRATEGIE — contenitore estensibile (v2.0.0).
Modello: ogni SLEEVE produce una serie di rendimenti netti per-barra (datetime-indexed, CAUSALE,
netto fee). Il portafoglio:
1. porta ogni sleeve su una griglia GIORNALIERA comune (compounding intra-giorno) così sleeve
a TF diversi (1d, 1h, ...) si combinano in modo coerente;
2. combina per PESO (rinormalizzato a 1) sui giorni comuni a tutti gli sleeve;
3. = portafoglio equal-capital-by-weight ribilanciato di continuo (interpretazione del weighted-
return combine). Equity = capitale · Π(1+combo).
AGGIUNGERE uno sleeve è una riga in src/portfolio/sleeves.py (vedi il template).
Metriche oneste: FULL + HOLD-OUT 2025-26 (bloccato) + per-anno, e standalone per-sleeve.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
import numpy as np
import pandas as pd
DAYS_PER_YEAR = 365.25
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
def to_daily(net: pd.Series) -> pd.Series:
"""Compound una serie di rendimenti netti per-barra a GIORNALIERA (griglia comune del portafoglio)."""
s = net.dropna().sort_index()
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 ((1.0 + s).resample("1D").prod() - 1.0).dropna()
@dataclass
class Sleeve:
"""Una strategia nel portafoglio. daily_fn() -> serie rendimenti netti per-barra (causale, netto fee).
pos_fn() (opzionale) -> dict posizioni-bersaglio correnti, per introspezione live."""
name: str
weight: float
daily_fn: Callable[[], pd.Series]
pos_fn: Callable[[], dict] | None = None
_cache: pd.Series | None = field(default=None, repr=False, compare=False)
def daily(self) -> pd.Series:
if self._cache is None:
self._cache = to_daily(self.daily_fn())
return self._cache
def metrics(daily: pd.Series) -> dict:
r = np.asarray(daily.dropna().values, float)
if len(r) < 2 or r.std() == 0:
return dict(sharpe=0.0, cagr=0.0, maxdd=0.0, ret=0.0, n=int(len(r)))
eq = np.cumprod(1.0 + r)
pk = np.maximum.accumulate(eq)
years = len(r) / DAYS_PER_YEAR
return dict(sharpe=float(r.mean() / r.std() * np.sqrt(DAYS_PER_YEAR)),
cagr=float(eq[-1] ** (1 / years) - 1) if years > 0 and eq[-1] > 0 else 0.0,
maxdd=float(np.max((pk - eq) / pk)), ret=float(eq[-1] - 1), n=int(len(r)))
def yearly(daily: pd.Series) -> dict:
out = {}
for y, g in daily.groupby(daily.index.year):
v = g.values
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
out[int(y)] = dict(ret=float(eq[-1] - 1), dd=float(np.max((pk - eq) / pk)))
return out
class StrategyPortfolio:
def __init__(self, sleeves: list[Sleeve], capital: float = 2000.0):
if not sleeves:
raise ValueError("portafoglio vuoto: serve almeno uno sleeve")
self.sleeves = sleeves
self.capital = capital
def weights(self) -> dict:
tot = sum(s.weight for s in self.sleeves)
if tot <= 0:
raise ValueError("somma pesi non positiva")
return {s.name: s.weight / tot for s in self.sleeves}
def combined_daily(self, lo=None, hi=None) -> pd.Series:
"""Combina gli sleeve per peso. OUTER-join: sleeve con date d'inizio diverse
(es. TP01 dal 2019, uno nuovo dal 2024) -> ogni giorno i pesi sono RINORMALIZZATI
fra i soli sleeve con dato disponibile (uno sleeve "si attiva" quando parte la sua
storia). Cosi' non si tronca il portafoglio alla finestra comune."""
w = self.weights()
cols = {s.name: s.daily() for s in self.sleeves}
J = pd.concat(cols, axis=1, join="outer").sort_index()
wv = np.array([w[c] for c in J.columns], float)
active = J.notna().values * wv # peso solo dove c'e' dato
rowsum = active.sum(axis=1, keepdims=True)
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
combo = pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
combo = combo[J.notna().any(axis=1).values] # togli i giorni senza alcun dato
if lo is not None:
combo = combo[combo.index >= lo]
if hi is not None:
combo = combo[combo.index < hi]
return combo
def backtest(self) -> dict:
full = self.combined_daily()
return dict(
weights=self.weights(),
full=metrics(full),
holdout=metrics(self.combined_daily(lo=HOLDOUT)),
yearly=yearly(full),
per_sleeve={s.name: dict(weight=self.weights()[s.name],
full=metrics(s.daily()),
holdout=metrics(s.daily()[s.daily().index >= HOLDOUT]))
for s in self.sleeves},
equity=self.capital * np.cumprod(1.0 + full.values),
index=full.index,
)
def current_positions(self) -> dict:
return {s.name: (s.pos_fn() if s.pos_fn else None) for s in self.sleeves}
+218
View File
@@ -0,0 +1,218 @@
"""SLEEVE del portafoglio + REGISTRY degli sleeve attivi.
Per AGGIUNGERE una strategia al portafoglio:
1. Validala col gauntlet onesto (scripts/analysis/research_lab.py + hold-out + cross-asset).
2. Scrivi una funzione `_<nome>_returns() -> pd.Series` che ritorna i suoi rendimenti netti
per-barra (datetime-indexed, CAUSALE, netto fee). Deve passare il guard di causalità.
3. Avvolgila in uno Sleeve(nome, peso, fn[, pos_fn]) e aggiungila a active_sleeves().
Niente sleeve non validati: il portafoglio è solo per edge che reggono il gauntlet.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from src.data.downloader import load_data
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_1d, simple_returns
from src.portfolio.portfolio import Sleeve
ASSETS = ("BTC", "ETH")
# ----------------------------- TP01 (PORT LF1d) -----------------------------
def _tp01_returns() -> pd.Series:
"""TP01: TSMOM vol-target long-flat, 50/50 BTC+ETH, a 1d (>=12h: vedi nota look-ahead nel modulo).
Rendimenti netti per-barra del portafoglio (causale: posizione decisa a close[i-1], tenuta in i)."""
tp = TrendPortfolio(**CANONICAL)
series = {}
for a in ASSETS:
df = resample_1d(load_data(a, "1h"))
r = simple_returns(df["close"].values.astype(float))
tgt = tp.target_series(df)
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
net = held * r - tp.fee_side * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0
series[a] = pd.Series(np.clip(net, -0.99, None), index=pd.to_datetime(df["datetime"]))
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index)
def _tp01_positions() -> dict:
tp = TrendPortfolio(**CANONICAL)
return {a: round(tp.current_target(resample_1d(load_data(a, "1h"))), 4) for a in ASSETS}
def tp01_sleeve(weight: float = 1.0) -> Sleeve:
return Sleeve("TP01_trend_1d", weight, _tp01_returns, pos_fn=_tp01_positions)
# ----------------------------- XS01: Cross-Sectional Momentum (Hyperliquid) -----------------------------
# Universo certificato Hyperliquid (19 alt, 1d, dal 2024) in data/raw/hl_*_1d.parquet
# (fetch+certify: scripts/analysis/fetch_hyperliquid.py). Market-neutral, scorrelato a TP01 (~-0.06).
# CAVEAT ONESTI: storia corta (~2.5 anni, 2024-2026); STAT-MODE (book a 19 gambe market-neutral
# non eseguibile a 2k, serve ~20k); l'edge e' nella DISPERSIONE cross-section (complementare al
# trend di TP01: lavora quando TP01 e' in cash). Validato: scripts/portfolio/xsec_research.py.
import glob as _glob
from pathlib import Path as _Path
# BLEND di lookback (2026-06-19): fonde 30g+90g del momentum cross-sectional (z-score per
# lookback, mediato) come TP01 fonde gli orizzonti -> piu' robusto del singolo L=30: FULL Sh
# 0.80->1.10, DD 21%->14%, corr a TP01 -0.06->-0.12, 100% anni+. Diario 2026-06-19-xsec-blend.md.
# + GATE DI DISPERSIONE (2026-06-19): entra solo se la dispersione cross-section del momentum
# supera il percentile ESPANDENTE causale disp_pct (altrimenti flat: in regime compatto XS e'
# rumore). Plateau robusto p15-p35; a p30: portafoglio FULL 1.50->1.74, HOLD 1.06->1.56.
# Diario 2026-06-19-xsec-dispgate.md.
XS_CFG = dict(lookbacks=(30, 90), H=10, k=5, mode="mom", target_vol=0.20, disp_pct=30, disp_minhist=20)
_HL_DIR = _Path(__file__).resolve().parents[2] / "data" / "raw"
# UNIVERSO ESPLICITO = 19 ALT LIQUIDI MAJOR. NB (2026-06-19): allargare a 52 asset (incluso
# small-cap WIF/JUP/ORDI/PYTH/TAO...) DILUISCE l'edge -> momentum cross-section NEGATIVO sui 52.
# I major sono il sweet spot. NON usare glob-all (i parquet extra certificati servono ad altra
# ricerca, non a XS01). Vedi diario 2026-06-19-xsec-universe-expansion.md.
XS_UNIVERSE = ["BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "AVAX", "LINK", "LTC", "ADA",
"ARB", "OP", "SUI", "APT", "INJ", "TIA", "SEI", "NEAR", "AAVE"]
def _xsec_returns() -> pd.Series:
cols = {}
for sym in XS_UNIVERSE:
p = _HL_DIR / f"hl_{sym.lower()}_1d.parquet"
if not p.exists():
continue
d = pd.read_parquet(p)
cols[sym] = pd.Series(d["close"].values.astype(float),
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
if len(cols) < 10:
raise FileNotFoundError("universo Hyperliquid XS01 incompleto: gira scripts/analysis/fetch_hyperliquid.py")
C = pd.concat(cols, axis=1, join="inner").sort_index().dropna()
px = C.values; n, A = px.shape
lookbacks, H, k, mode, tv = XS_CFG["lookbacks"], XS_CFG["H"], XS_CFG["k"], XS_CFG["mode"], XS_CFG["target_vol"]
disp_pct = XS_CFG.get("disp_pct", 0); minhist = XS_CFG.get("disp_minhist", 20)
mlb = max(lookbacks)
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
W = np.zeros((n, A)); w = np.zeros(A); disp_hist = []
for i in range(n):
if i >= mlb and i % H == 0:
rLs = [px[i] / px[i - L] - 1.0 for L in lookbacks]
disp_i = float(np.mean([r.std() for r in rLs])) # dispersione cross-section del momentum
thr = np.percentile(disp_hist, disp_pct) if (disp_pct > 0 and len(disp_hist) >= minhist) else -np.inf
if disp_i >= thr: # gate: entra solo in regime disperso
score = np.zeros(A); cnt = 0 # blend: media z-score cross-sectional
for rL in rLs:
sd = rL.std()
if sd > 0:
score += (rL - rL.mean()) / sd; cnt += 1
if cnt:
score /= cnt
order = np.argsort(score)
w = np.zeros(A); lo, hi = order[:k], order[-k:]
if mode == "mom": w[hi] = 0.5 / k; w[lo] = -0.5 / k
else: w[lo] = 0.5 / k; w[hi] = -0.5 / k
else:
w = np.zeros(A) # regime compatto -> flat
disp_hist.append(disp_i)
W[i] = w
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum(); turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
net = gross - turn * (0.001 / 2.0)
s = pd.Series(net, index=C.index)
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
return pd.Series(s.values * scale, index=C.index)
def xsec_sleeve(weight: float = 0.3) -> Sleeve:
return Sleeve("XS01_xsec_hl", weight, _xsec_returns)
# ----------------------------- VRP01: Options Short-Vol (put credit spread + gate IV-rank) -----------------------------
# Income short-vol settimanale. Idee da FinanceOld/OptionsAgent (Bear Call Spread + gate d'ingresso)
# portate sul VRP: (a) PUT CREDIT SPREAD rischio-definito (vendi put -0.28, compra put -0.10) che
# CAPPA la coda (worst-week -16.6%->-7.4%, DD 33%->14%); (b) GATE IV-RANK>0.30 causale = vendi vol solo
# quando ricca -> ribalta l'HOLD-OUT da -0.25 a +0.28 (e' l'alpha); (c) crash-skip IV-rank>0.90.
# Premio BS su DVOL reale (data/raw/dvol_*.parquet via scripts/research/fetch_dvol.py), payoff sul path
# certificato, fee opzioni Deribit (cap 12.5% del premio). CAVEAT ONESTI: premio MODELLATO su IV-ATM
# (skew non esplicito), book a 1d, f di stress reale non catturato -> e' un LEAD robusto, non deploy
# pieno. Scorrelato a TP01 (~+0.07). Ricerca: scripts/research/options_vrp_v2.py.
# Diario 2026-06-20-financeold-analysis-vrp-v2.md.
from scipy.stats import norm as _norm
VRP_CFG = dict(short_delta=-0.28, long_delta=-0.10, f=1.0, tenor_d=7,
gate_ivr=0.30, crash_skip=0.90, gate_vrp=True, fee_frac=0.125)
def _bs_put(S, K, T, sig):
if T <= 0 or sig <= 0:
return max(K - S, 0.0)
d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T))
return K * _norm.cdf(-(d1 - sig * np.sqrt(T))) - S * _norm.cdf(-d1) # r=0
def _strike_from_delta(S, T, sig, target_delta):
return S * np.exp(0.5 * sig ** 2 * T - (-_norm.ppf(-target_delta)) * sig * np.sqrt(T))
def _vrp_weekly_asset(asset: str) -> pd.Series:
"""Put credit spread settimanale con gate causali. Ritorna rendimenti SETTIMANALI (su collaterale
= strike corto, cash-secured) indicizzati alla data di scadenza. Causale: strike/premio/gate da
dati <= sell-date; payoff a scadenza sui prezzi certificati."""
df = resample_1d(load_data(asset, "1h"))
s = pd.Series(df["close"].values.astype(float), index=pd.to_datetime(df["datetime"]))
if s.index.tz is None:
s.index = s.index.tz_localize("UTC")
dv = pd.read_parquet(_HL_DIR / f"dvol_{asset.lower()}.parquet")
d = pd.Series(dv["close"].values.astype(float), index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True))
J = pd.concat({"px": s, "dvol": d}, axis=1, join="inner").sort_index().dropna()
px = J["px"].values; dvf = J["dvol"].values / 100.0; idx = J.index
n = len(px); cfg = VRP_CFG; tn = cfg["tenor_d"]; T = tn / 365.25
rets = {}
i = 60
while i + tn < n:
S0 = px[i]; sig = dvf[i]
skip = False
if cfg["gate_vrp"] and i >= 31: # VRP>0: DVOL > RV30 causale
rv = np.std(np.diff(np.log(px[i - 30:i + 1]))) * np.sqrt(365.25)
if (sig - rv) <= 0:
skip = True
if not skip and (cfg["gate_ivr"] > 0 or cfg["crash_skip"] < 1.0) and i >= 60:
ivr = float((dvf[:i] < dvf[i]).mean()) # IV-rank espandente causale
if cfg["gate_ivr"] > 0 and ivr < cfg["gate_ivr"]:
skip = True
if cfg["crash_skip"] < 1.0 and ivr > cfg["crash_skip"]:
skip = True
if skip:
rets[idx[i + tn]] = 0.0; i += tn; continue
Ks = _strike_from_delta(S0, T, sig, cfg["short_delta"])
Kl = _strike_from_delta(S0, T, sig, cfg["long_delta"])
net_prem = (_bs_put(S0, Ks, T, sig) - _bs_put(S0, Kl, T, sig)) * cfg["f"]
S1 = px[i + tn]
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1)
pnl = net_prem - payoff - cfg["fee_frac"] * abs(net_prem)
rets[idx[i + tn]] = pnl / Ks
i += tn
return pd.Series(rets)
def _vrp_combo_returns() -> pd.Series:
"""Sleeve VRP01: book 50/50 BTC+ETH del put credit spread gated, su griglia GIORNALIERA.
Il rendimento settimanale e' piazzato sul giorno di scadenza, 0.0 sugli altri giorni (preserva
lo Sharpe annualizzato senza smoothing): cosi' lo sleeve e' presente ogni giorno (peso costante)."""
rB = _vrp_weekly_asset("BTC"); rE = _vrp_weekly_asset("ETH")
wk = pd.concat({"B": rB, "E": rE}, 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 # lump settimanale sul giorno scadenza
return daily
def vrp_sleeve(weight: float = 0.20) -> Sleeve:
return Sleeve("VRP01_shortvol", weight, _vrp_combo_returns)
# ----------------------------- REGISTRY -----------------------------
def active_sleeves() -> list[Sleeve]:
"""Sleeve ATTIVI nel portafoglio (pesi rinormalizzati; sleeve a date diverse si attivano
quando parte la loro storia). Aggiungere qui SOLO strategie validate col gauntlet."""
return [
tp01_sleeve(weight=0.55), # trend difensivo, BTC/ETH, dal 2019 (l'unico deployable pieno)
xsec_sleeve(weight=0.25), # cross-sectional momentum Hyperliquid, dal 2024 (scorrelato, stat-mode)
vrp_sleeve(weight=0.20), # options short-vol (put credit spread + gate IV-rank), dal 2021 (lead modellato, scorrelato)
]
+69
View File
@@ -0,0 +1,69 @@
"""Test del contenitore portafoglio estensibile."""
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 pandas as pd
import pytest
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily, metrics
def _const_sleeve(name, weight, val, n=400):
idx = pd.date_range("2020-01-01", periods=n, freq="1D", tz="UTC")
return Sleeve(name, weight, lambda: pd.Series(val, index=idx))
def test_single_sleeve_equals_itself():
s = _const_sleeve("A", 1.0, 0.001)
pf = StrategyPortfolio([s])
combo = pf.combined_daily()
assert np.allclose(combo.values, s.daily().values)
assert pf.weights() == {"A": 1.0}
def test_weights_normalize():
pf = StrategyPortfolio([_const_sleeve("A", 3.0, 0.001), _const_sleeve("B", 1.0, 0.002)])
w = pf.weights()
assert abs(sum(w.values()) - 1.0) < 1e-12
assert abs(w["A"] - 0.75) < 1e-12 and abs(w["B"] - 0.25) < 1e-12
def test_equal_weight_combine():
a, b = _const_sleeve("A", 1.0, 0.001), _const_sleeve("B", 1.0, 0.003)
pf = StrategyPortfolio([a, b])
combo = pf.combined_daily()
assert np.allclose(combo.values, 0.5 * 0.001 + 0.5 * 0.003) # 0.002
def test_to_daily_compounds_intraday():
# due barre da +1% nello stesso giorno -> +2.01% giornaliero
idx = pd.to_datetime(["2020-01-01T00:00", "2020-01-01T12:00"], utc=True)
d = to_daily(pd.Series([0.01, 0.01], index=idx))
assert len(d) == 1 and abs(d.iloc[0] - (1.01 * 1.01 - 1)) < 1e-12
def test_metrics_basic():
idx = pd.date_range("2020-01-01", periods=730, freq="1D", tz="UTC")
m = metrics(pd.Series(0.0005, index=idx)) # ritorno costante positivo
assert m["ret"] > 0 and m["maxdd"] == 0.0 and m["n"] == 730
def test_outer_join_renormalizes_late_sleeve():
# sleeve con date d'inizio diverse: prima parte A da solo (peso rinormalizzato a 1),
# poi A+B (pesi 0.7/0.3). Il portafoglio NON si tronca alla finestra comune.
idxA = pd.date_range("2020-01-01", periods=120, freq="1D", tz="UTC")
idxB = pd.date_range("2020-02-15", periods=60, freq="1D", tz="UTC")
A = Sleeve("A", 0.7, lambda: pd.Series(0.001, index=idxA))
B = Sleeve("B", 0.3, lambda: pd.Series(0.003, index=idxB))
combo = StrategyPortfolio([A, B]).combined_daily()
assert abs(combo.iloc[0] - 0.001) < 1e-12 # solo A -> 100% A
both = combo[combo.index >= idxB[0]]
assert abs(both.iloc[0] - (0.7 * 0.001 + 0.3 * 0.003)) < 1e-12 # blend rinormalizzato
assert len(combo) == 120 # span completo di A, non tronca
def test_empty_portfolio_raises():
with pytest.raises(ValueError):
StrategyPortfolio([])
+61
View File
@@ -0,0 +1,61 @@
"""Test dello sleeve VRP01 (options short-vol: put credit spread + gate IV-rank)."""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from src.portfolio.sleeves import (
_bs_put, _strike_from_delta, _vrp_combo_returns, vrp_sleeve, _HL_DIR)
_HAS_DVOL = (_HL_DIR / "dvol_btc.parquet").exists() and (_HL_DIR / "dvol_eth.parquet").exists()
_skip_data = pytest.mark.skipif(not _HAS_DVOL, reason="serve data/raw/dvol_*.parquet (scripts/research/fetch_dvol.py)")
def test_bs_put_monotonic_in_strike():
"""Put piu' ITM (strike piu' alto) vale di piu'."""
S, T, sig = 100.0, 7 / 365.25, 0.6
vals = [_bs_put(S, K, T, sig) for K in (80, 90, 100, 110)]
assert all(b < a for b, a in zip(vals, vals[1:])) # crescente nello strike
def test_strike_from_delta_ordering():
"""La put venduta delta -0.28 ha strike piu' alto (piu' vicino) della comprata -0.10."""
S, T, sig = 100.0, 7 / 365.25, 0.6
Ks = _strike_from_delta(S, T, sig, -0.28)
Kl = _strike_from_delta(S, T, sig, -0.10)
assert Kl < Ks < S # entrambe OTM, long piu' lontana
@_skip_data
def test_sleeve_is_deterministic_and_daily():
a = vrp_sleeve().daily()
b = _vrp_combo_returns()
assert isinstance(a.index, pd.DatetimeIndex) and a.index.tz is not None
assert (a.index.normalize() == a.index).all() # griglia giornaliera
# presente ogni giorno nel suo span (nessun buco) -> peso costante nel portafoglio
full = pd.date_range(a.index.min(), a.index.max(), freq="1D", tz="UTC")
assert len(a) == len(full)
np.testing.assert_array_equal(a.values, vrp_sleeve().daily().values) # deterministico
@_skip_data
def test_gates_reduce_activity():
"""I gate (IV-rank/VRP/crash-skip) devono lasciare flat parte delle settimane: i giorni con
rendimento != 0 sono molto meno del totale (lump settimanale + settimane saltate)."""
s = _vrp_combo_returns()
active = float((s != 0).mean())
assert 0.0 < active < 0.25 # ~1/7 (lump weekly) e meno per i gate
@_skip_data
def test_sleeve_positive_and_capped_tail():
"""Lo sleeve e' profittevole e la coda e' tagliata dal long wing (worst-day moderato)."""
s = _vrp_combo_returns()
nz = s[s != 0]
assert s.sum() > 0 # somma rendimenti positiva
assert nz.min() > -0.15 # defined-risk: nessuna settimana < -15%