5 Commits

Author SHA1 Message Date
Adriano Dal Pastro 12754c4908 fix(TP01): bug look-ahead ffill mixed-TF -> deploy a >=12h (1d), strategia DIFENSIVA
Segnalato: ffill MIXED-TIMEFRAME su barre open-labeled (resample label="left") gonfiava il 4h
(~1.60 -> reale ~1.1). Ri-verifica per-SINGOLO-TF leak-free (guard prefix-recompute, leak=0 su
4h/6h/12h/1d): FULL Sh piatto ~1.3, hold-out 2025-26 MIGLIORE a 1d (Sh 0.31 / +3.5% vs buy&hold
-39%). Conclusione adottata: NON scendere sotto le 12h (sotto, costi+overfit dominano senza vantaggio).

- trend_portfolio.py: canonica PORT LF1d; resample_tf/resample_1d (resample_4h deprecato deploy);
  docstring con nota look-ahead + natura DIFENSIVA (taglia DD ~6x, non alpha).
- paper_trend.py: deploy a 1d (resample_1d, build_bars). 5 test passano.
- CLAUDE.md: TP01 ridescritta (>=12h/1d, gotcha ffill mixed-TF, difensiva).
- tp01_lowfreq.py + diario 2026-06-19-tp01-lookahead-fix-lf.md.
Gotcha: mai ffill/combine mixed-TF su timestamp open-labeled (close propagata indietro = leak).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:04:38 +00:00
Adriano Dal Pastro 756a2bdf04 research: stress-test TP01 — robusto come strategia DIFENSIVA (DD-cut), edge ritorno hold-out sottile
Stress sul modulo integrato: FULL regge fee 0.40% + lag + ampio plateau parametri (orizzonti
20/60/120 fa Sh 1.61, non cherry-pick); deflated-Sharpe DSR 0.999 a N=100 (no multiple-testing
artifact). MA il ritorno nel hold-out 2025-26 e' SOTTILE (+2.8%/Sh0.27 a 0.10%, ~flat a 0.40%/lag2):
TP01 PROTEGGE il drawdown (8% vs 60% buy&hold) piu' di quanto profitti. Proprieta' robusta e
deployabile = taglio DD; alpha = no. Da monitorare col paper trader prima di scalare.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:57:22 +00:00
Adriano Dal Pastro d152941360 integra(TP01): merge ricerca branch strategy-research-2026-06 (squash) — strategia vincente + harness + track A-E
Integra il lavoro della linea di ricerca parallela (AdrianoDev), verificato indipendentemente
col mio gauntlet onesto (regge il hold-out 2025-26 su entrambi gli asset, plateau 1h/4h/1d):
- src/strategies/trend_portfolio.py  TP01 (TSMOM 30/90/180 vol-target 20% lev2x long-flat, 50/50 BTC+ETH)
- src/backtest/harness.py            harness onesto (load + backtest_signals no-leakage + OOS)
- scripts/research/track{A,B,C,D,E}_*.py + trackD_timing.py  (le 5 track della ricerca)
- scripts/live/paper_trend.py        paper trader forward-only di TP01 (no esecuzione reale)
- tests/test_trend_portfolio.py (5 test, passano) + 6 diari trackA-E + synthesis
- CLAUDE.md aggiornato con l'esito ricerca (TP01 vincente, mean-rev morto, onesta su €50/g)

Squash (non merge) per NON portare in git i ~68MB di data/_feed_backup/*.bak che il branch
aveva committato per errore: esclusi + data/_feed_backup/ e data/paper_trend/ ora gitignorati.
Storia granulare del branch conservata sul ref origin/strategy-research-2026-06.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:55:04 +00:00
Adriano Dal Pastro 55c28e51b2 research: verify TP01 (branch parallelo) col gauntlet onesto — REGGE il hold-out 2025-26
TP01 (TSMOM 30/90/180 vol-target 20% lev2x long-flat, 50/50 BTC+ETH) passa dove il mio trend 1h
era caduto: hold-out 2025-26 +2.8%/DD8% vs buy&hold -39%/DD60%, positivo su ENTRAMBI gli asset,
plateau 1h/4h/1d. La chiave e' il vol-targeting (esposizione ~1/vol -> cash nei crash) che non
avevo combinato col trend. Edge DIFENSIVO reale (Sharpe full 1.36 vs B&H 0.92, ma CAGR 16.6% vs 48%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:50:36 +00:00
Adriano Dal Pastro 38c8cdf25b research(v2.0.0): honest harness + fasi 0-3 + ricerca frattale 63 agenti — nessun edge robusto su BTC/ETH
Harness onesto research_lab.py (serie di posizione causale, fee-aware, null model a
rotazione circolare, hold-out 2025+ bloccato; self-test cheat/noise che valida il banco).
- Fase 1: triage superstiti (DIP, shape-ML) -> morti net-fee.
- Fase 2: esplorazione famiglie (reversal morta; solo trend long-only/MA-cross passa i gate base).
- Fase 3: conferma avversariale del trend -> regime-luck del toro, bocciato sul hold-out 2025-26.
- Ricerca frattale multi-agente (Workflow, 63 agenti, 52 ipotesi dai due documenti) con guard
  anti-look-ahead (eval_signal.py) + hold-out + test cross-asset -> 0 edge robusto (l'unico
  "confermato" su ETH fallisce su BTC con lo stesso codice).
- Analisi options: VRP reale +10/+14 vol pt ma finestra 6 sett. regime unico -> non validabile;
  ruolo solo overlay tail-cap, tenere cerbero-bite ad accumulare.

Quinta conferma indipendente: su BTC/ETH-solo-prezzo non c'e' un edge facile. Il processo
disciplinato ha evitato un falso "+49% vs -49%" che sul vecchio feed contaminato sarebbe
finito in produzione. Diari docs/diary/2026-06-19-research-phase0-1 / -phase2-options /
-phase3-confirm / -fractal-multiagent-search.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:37:05 +00:00
34 changed files with 5239 additions and 1 deletions
+9
View File
@@ -43,3 +43,12 @@ data/games/
# archived data (mirrors top-level data/ ignores, which are top-level-anchored) # archived data (mirrors top-level data/ ignores, which are top-level-anchored)
Old/data/ Old/data/
Old/**/__pycache__/ Old/**/__pycache__/
# run logs (rigenerabili dagli script)
logs/
# cache della ricerca trackE (rigenerabile)
.cache_trackE_*.npy
# feed backup pre-rebuild (binari rigenerabili, NON in git) + stato paper trader (runtime)
data/_feed_backup/
data/paper_trend/
+39 -1
View File
@@ -16,6 +16,37 @@ Cosa è cambiato:
- L'esecuzione è **DISABILITATA**, il conto mainnet è flat. **Non c'è trading live attivo.** - L'esecuzione è **DISABILITATA**, il conto mainnet è flat. **Non c'è trading live attivo.**
- Si riparte dalla ricerca di strategie NUOVE, su dati certi, con la metodologia qui sotto. - Si riparte dalla ricerca di strategie NUOVE, su dati certi, con la metodologia qui sotto.
### Ricerca post-reset (2026-06-19) — esito
Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condiviso
`src/backtest/harness.py`). Sintesi in `docs/diary/2026-06-19-research-synthesis.md`.
- **TP01 Trend Portfolio — strategia DIFENSIVA robusta (non alpha)** —
`src/strategies/trend_portfolio.py`. TSMOM multi-orizzonte (1-3-6 mesi) vol-targeted, long-flat,
50/50 BTC+ETH. Config canonica **PORT LF1d** (**>=12h, 1d raccomandato**, vol-target 20%, leva cap 2x):
**FULL Sharpe ~1.30, maxDD ~14%; HOLD-OUT 2025-26 Sharpe ~0.31 / +3.5%** mentre il buy&hold 50/50
faceva 39%/DD60%. Verificata indipendentemente col gauntlet onesto (hold-out + cross-asset +
plateau + deflated-Sharpe 0.999): **regge**. **Valore = taglio del drawdown ~6× vs buy&hold**, NON
generazione di ritorno (CAGR ~16% vs ~48% del buy&hold sul toro).
⚠️ **LOOK-AHEAD (2026-06-19):** un ffill MIXED-TIMEFRAME su barre open-labeled gonfiava il 4h
(~1.60 → reale ~1.1). Il calcolo per-singolo-TF è leak-free, ma **NON scendere sotto le 12h**:
costi+overfitting dominano senza vantaggio (FULL Sh piatto ~1.3 da 12h a 4h; hold-out migliore a 1d).
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).
- **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.
Script ricerca: `scripts/research/track{A,B,C,D,E}_*.py` + `trackD_timing.py`.
## Obiettivo ## Obiettivo
Ricerca: riconoscimento pattern frattali per trading algoritmico su crypto. Target dichiarato Ricerca: riconoscimento pattern frattali per trading algoritmico su crypto. Target dichiarato
@@ -35,9 +66,13 @@ 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/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/base.py → Strategy (ABC), Signal, BacktestResult, YearlyStats
src/strategies/indicators.py → indicatori condivisi (ema, atr, keltner, ...) src/strategies/indicators.py → indicatori condivisi (ema, atr, keltner, ...)
src/strategies/trend_portfolio.py → TP01: strategia VINCENTE (PORT LF4h), causale, deployabile
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py) src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
src/backtest/engine.py → engine di backtesting riusabile src/backtest/engine.py → engine di backtesting riusabile
src/backtest/harness.py → harness ONESTO (load BTC/ETH, backtest_signals no-leakage, OOS)
src/version.py → APP_VERSION (legge il file VERSION) 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/analysis/ → SOLO i tool dati certificati: scripts/analysis/ → SOLO i tool dati certificati:
rebuild_history.py → (ri)costruisce lo storico da Deribit mainnet (base 5m + resample) 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) certify_feed.py → certifica il feed (integrità, coerenza resample, spike, cross-venue)
@@ -57,7 +92,10 @@ uv sync # installa dipende
uv run python scripts/analysis/rebuild_history.py --asset BTC ETH # (ri)costruisci storico da Deribit mainnet uv run python scripts/analysis/rebuild_history.py --asset BTC ETH # (ri)costruisci storico da Deribit mainnet
uv run python scripts/analysis/certify_feed.py # certifica i feed (locale + cross-venue) uv run python scripts/analysis/certify_feed.py # certifica i feed (locale + cross-venue)
uv run python scripts/analysis/certify_feed.py --local # solo check locali (veloce) uv run python scripts/analysis/certify_feed.py --local # solo check locali (veloce)
uv run pytest # test (da ripopolare con le nuove strategie) 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 pytest # test
``` ```
```python ```python
@@ -0,0 +1,62 @@
# 2026-06-19 — Ricerca frattale multi-agente (63 agenti) su BTC/ETH
Su richiesta: 50+ agenti in parallelo a cercare strategie NUOVE ispirate ai due documenti
frattali (`Libro_frattali` + `Pythagoras_Trading_Prediction`), timing/asset diversi, ognuna
validata sull'harness onesto. Eseguito come Workflow: **63 agenti, ~2h, 3.8M token.**
## Cosa è stato testato
16 concetti frattali estratti dai documenti (sotto la patina esoterica: coscienza, frequenze
Solfeggio, numeri sacri → idee testabili): alfabeto candle U/D/0 (3-6, LONG), Fourier/cicli,
ricorrenza di Poincaré (analoghi kNN), centro di inversione Evideon (mirror tempo+prezzo),
indicatore H-C (~588/25 ≈ 23.5 barre), numeri-universali come periodi, invarianza di forma,
entropia di Shannon ("coscienza") come gate, confluenza multi-TF, grammatica composizionale,
fase-ricorrenza. × BTC/ETH × {5m,15m,1h} = **52 ipotesi**.
Ogni segnale: scritto da un agente come funzione `signal()`, valutato da `eval_signal.py`
(stesso harness onesto) con **guard automatico anti-look-ahead** (ricalcolo su prefissi).
Superstiti → verifica avversariale con sblocco una-tantum del **hold-out 2025-26**.
## Esito
- **Verdetti**: 29 rumore, 12 "real" (netto-fee positivo ma non battono il buy&hold), 11 "edge"
(in-sample: battono B&H + null p<0.05 + leak=0).
- **Guard anti-look-ahead**: nessun leak passato (gli agenti hanno prodotto segnali causali; i
pochi tentativi con futuro sono stati auto-squalificati e corretti).
- **Hold-out (la prova del nove)**: dei **11 superstiti in-sample, 10 REFUTATI** — performance
catastrofica nel 2025-26 (hurst-DFA 0.49, hc-cycle 0.83, vol-accel 1.16, universal-periods
0.42…−1.04, spectral-entropy 0.38/+0.29, multitf 0.49, solfeggio-BTC 0.64). Stessa firma di
sempre: **regime-luck del toro 2018-2024, sparito out-of-sample.**
- **1 "confermato"** dalla verifica per-agente: `momentum_solfeggio_cycle` **ETH 1h** (holdout
Sharpe +1.19, ret +49% mentre il buy&hold ETH faceva 49%). Sembrava un trionfo.
## Ma il "vincitore" cade al test cross-asset (kill decisivo)
Il guard per-agente valuta un asset alla volta e non poteva vedere il quadro. Ho rieseguito **lo
stesso identico codice** sui due major:
| Signal (identico) | FULL Sharpe | HOLD-OUT Sharpe | HOLD-OUT ret |
|---|---|---|---|
| Solfeggio-cycle su **ETH** 1h | 1.54 | **+1.19** | +49% |
| Solfeggio-cycle su **BTC** 1h | 1.17 | **0.25** | 7.5% |
Un edge robusto non fallisce sull'altro major. La stessa logica (long-only ~20% esposta, filtro
SMA(588), timing su ciclo ~24) che ha "schivato" il crash ETH 2025 **perde su BTC nello stesso
hold-out**. È **fortuna di regime di un singolo asset**, non skill. Aggravanti: costanti
numerologiche ad-hoc (24/588/56, "odore" di overfit, già notato dal verificatore); e con 52 trial,
trovare 1 segnale che passa un singolo regime di hold-out è atteso per puro caso (1/11 ≈ chance).
## VERDETTO
**La ricerca frattale multi-agente (52 ipotesi, 63 agenti) NON ha trovato alcun edge robusto.**
I concetti frattali/esoterici si sono comportati esattamente come le famiglie convenzionali (Fasi
1-3): edge in-sample da regime-luck del toro, refutati dal hold-out; e l'unico che passava il
hold-out su un asset fallisce sull'altro. **Nessuna magia nei numeri Solfeggio/sacri.**
Il valore: il processo disciplinato (guard anti-look-ahead + hold-out bloccato + **test cross-asset**)
ha catturato un falso "trionfo" (+49% vs 49%!) che sul vecchio sistema contaminato sarebbe finito
dritto in produzione. È la quinta conferma indipendente che su BTC/ETH non c'è un edge facile.
## Stato della ricerca dopo tutte le fasi
Testato: mean-reversion, momentum/trend, vol, lead-lag, hurst, shape-ML, e 16 famiglie frattali ×
multi-TF/asset. **Niente di robusto, fee-surviving, OOS e cross-asset.** Le direzioni oneste
restano: (a) accettare il ceiling = long risk-managed (no alpha); (b) allargare l'universo dati
CERTIFICATO oltre BTC/ETH; (c) fonti di segnale ortogonali al prezzo (on-chain, basis multi-venue,
opzioni multi-regime) — tutte richiedono nuovi dati certificati. Artefatti: `eval_signal.py`,
workflow `fractal-strategy-search`, ~52 segnali in `/tmp/pyth_sig_*.py`.
@@ -0,0 +1,58 @@
# 2026-06-19 — Ricerca v2.0.0: Fase 0 (harness) + Fase 1 (triage superstiti)
Primo log di ricerca post-reset. Universo certificato: BTC/ETH, 1h. Hold-out 2025+ BLOCCATO.
## Fase 0 — harness onesto (`scripts/analysis/research_lab.py`)
Banco di prova causale per costruzione (modello a SERIE DI POSIZIONE: `pos[i]` decisa entro
`close[i]`, guadagna `close[i]→close[i+1]`, fee sul turnover |Δpos|). Metriche
Sharpe/CAGR/DD/exposure/turnover, finestra OOS, **null model a rotazione circolare**
(p-value: il timing batte il caso?), baseline buy&hold, sweep fee.
**Self-test del banco (valida l'HARNESS, non una strategia):**
- buy&hold BTC: Sharpe 0.79 (sanity OK).
- CHEAT look-ahead (pos = segno del rendimento futuro): Sharpe **58**, p=0.005 → l'engine
VEDE un edge reale quando esiste.
- NOISE causale a basso turnover: Sharpe **0.14**, p=0.26 → l'engine NON inventa edge dal
nulla (niente leak, niente skill spuria).
Il banco è affidabile. → ogni numero qui sotto è netto fee e causale.
## Fase 1 — triage dei 2 superstiti (`scripts/analysis/phase1_survivors.py`)
Sul feed pulito solo SH01 (shape-ML) e frammenti HONEST mostravano segnale residuo. Delle
HONEST solo **DIP** è testabile su BTC/ETH (TR01/ROT02 richiedono alt esclusi). Re-implementati
come serie di posizione, passati ai gate onesti.
### DIP reversion (long-only) — ☠️ MORTO
Griglia 3×3 (n, k) **tutta negativa** su entrambi gli asset (nessun plateau). Config centrale
n50 k2.0: FULL Sharpe 0.17 (BTC) / 0.06 (ETH); a fee 0% appena +0.02/+0.09 (niente edge nemmeno
lordo). OOS-VAL marginale (+0.36/+0.16) ma **null p=0.84-0.89** (peggio del caso). Rumore.
### SH01 shape-ML (walk-forward LogReg) — ☠️ FEE-DEAD
Pattern coerente su BTC/ETH, long/short e long-only:
| Variante | Sh fee0% | Sh fee0.05% | Sh fee0.10% | trade/anno | null p |
|---|---|---|---|---|---|
| BTC L/S | +0.32 | 0.70 | 1.71 | 877 | 0.167 |
| BTC long-only | +0.73 | 0.06 | 0.84 | 555 | 0.072 |
| ETH L/S | +0.31 | 0.40 | 1.11 | 773 | 0.137 |
| ETH long-only | +0.46 | 0.04 | 0.53 | 485 | 0.142 |
C'è un **sussurro di segnale LORDO** (Sharpe 0.3-0.7 a fee zero) ma il turnover (485-877
trade/anno) lo divora: a fee reale tutte negative, e **nessuna batte il null** (p>0.05). Net-fee:
rumore.
## VERDETTO Fase 1
**Né DIP né shape-ML sopravvivono su BTC/ETH certificato net-fee.** Nessuno dà Sharpe netto >0,
nessuno batte il null (p<0.05), nessuno batte il **buy&hold** (Sharpe 0.79/0.84 — di fatto la
"strategia" più forte vista finora). Si conferma: i "superstiti" della vecchia libreria erano,
come il resto, non-edge. Chiusi.
## Lead onesto per la Fase 2
L'unico segnale non-nullo è il **gross shape-ML** (Sharpe 0.3-0.7 a fee zero), ucciso dal
turnover. Direzione: esprimere quel segnale a **turnover molto più basso** (orizzonte di holding
lungo, soglia forte, o come GATE di regime invece che flip per-barra) per vedere se il sussurro
lordo sopravvive alle fee. È un lead, NON un edge. Inoltre: la barra reale da battere è il
**buy&hold** (Sharpe ~0.8) — una strategia di timing deve fare meglio di "stai sempre long",
net-fee.
@@ -0,0 +1,69 @@
# 2026-06-19 — Ricerca v2.0.0: Fase 2 (famiglie) + analisi OPTIONS
Universo certificato BTC/ETH. Barra da battere = **buy&hold** (Sharpe 0.79 BTC / 0.84 ETH).
Tutto netto fee 0.10% RT, hold-out 2025+ BLOCCATO. Harness: `research_lab.py`.
## Fase 2 — esplorazione famiglie (`phase2_families.py`)
24 combinazioni famiglia×asset×TF, ognuna: scan griglia → config migliore → gate onesti
(FULL/OOS-VAL, vs buy&hold, null p-value a rotazione, sweep fee).
### Esiti per famiglia
- **REVERSAL (mean-reversion breve): ☠️ MORTA OVUNQUE.** FULL Sharpe da 1 a 5.6 (peggio a
15m: fee-death, 5.6 BTC / 4.6 ETH), gross ≈0, null p 0.45-0.82. **Smentisce definitivamente
la tesi storica del progetto ("l'edge è sempre mean-reversion")**: era artefatto del feed.
- **TSMOM / MA-cross / Donchian (trend, long-only): segnale REALE ma MODESTO.** Le versioni
long-only (basso turnover) battono o eguagliano il buy&hold:
- **MA-cross long-only**: ETH FULL **1.12** / OOS 0.89 / p **0.007**; BTC FULL **0.90** / OOS
1.99 / p **0.040**. Plateau sulla griglia (ETH 12/48 e 48/192 entrambi 1.1), coerente sui
DUE asset, basso turnover (53-106 trade/anno). **Unici 2 a passare: battono B&H + OOS>0 + p<0.05.**
- Donchian long-only: FULL 0.84-0.94, OOS ottimo (BTC 2.37) ma p 0.08-0.10 (pochi trade → null
rumoroso). TSMOM long-only: ETH 0.83 (≈B&H). Le L/S perdono (turnover + short su asset in trend).
- **VOL-TARGET overlay**: ≈ buy&hold (FULL 0.77-0.84), p alto → non distinguibile dal B&H, ma è
un riduttore di vol/DD (mantiene lo Sharpe scalando l'esposizione).
- **HURST-gate, LEAD-LAG BTC↔ETH**: niente. (Hurst-mom ETH p=0.043 ma sotto il B&H; lead-lag
fee-dead.)
### Verdetto Fase 2
L'unica cosa reale su BTC/ETH certificato è il **trend-following long-only** (MA-cross in testa):
un **long con gestione del rischio** che batte il buy&hold di poco (Sharpe ~0.9-1.1 vs 0.8)
evitando i drawdown peggiori. È un effetto noto in letteratura (time-series momentum), NON alpha
market-neutral. **Caveat multiple-testing**: 2 flag su ~24 test ≈ soglia del caso; ma la stessa
famiglia vince su ENTRAMBI gli asset con plateau → è un LEAD genuino, non confermato. La barra
vera resta il B&H, e l'OOS-VAL alto di BTC (1.99) puzza di "2024 anno di trend forte" → serve la
prova del hold-out 2025-26 + regimi bear + stress fee/slippage + deflated-Sharpe (Fase 3).
## Analisi OPTIONS (`options_analysis.py`)
Dati reali cerbero-bite mainnet, ma finestra **~2026-05-01→06-11 (~6 sett., REGIME UNICO calmo)**.
### Livelli misurati (reali)
- **VRP (IV RV) positivo il 100% del tempo**: BTC +10, ETH +14 punti di vol annua. Le opzioni
sono sistematicamente CARE in questa finestra → vendere vol/covered-call avrebbe incassato premio.
- **Skew put positivo**: BTC IV put-10%OTM 44% vs call 35% (skew +10 pt); ETH 54 vs 49 (+5). Il
crash è prezzato (assicurazione cara).
- **Costo put protettiva** (mensile, %-del-notional): ~10% OTM = **0.98% BTC / 1.36% ETH**; ATM
3.3%/5.0%; ~15% OTM 0.83%/0.71%. Liquidità: ATM spread ~3%, OTM 7-12%. Mensile ben popolato
(499-2043 strike), settimanale OTM sottile. Funding perp ≈ 0 (nessun carry).
### Verdetto OPTIONS
**Nessun edge su opzioni è validabile ora**: 6 settimane, regime unico calmo. Il segnale
VRP-positivo / sell-vol è ESATTAMENTE ciò che brilla in calma e salta in aria nei crash (è il
rischio che viene pagato) — non testabile senza un crash nel campione. Ruoli legittimi (entrambi
NON validabili ora, solo forward):
- **(a) Tail-cap / catastrofe**: put OTM standing su un book long (il candidato trend ha DD grossi).
Costa ~1-1.5%/mese a 10% OTM — gateabile coi premi reali misurati qui. Overlay per-trade 24h
INFATTIBILE (strike OTM corti inesistenti/illiquidi); standing settimanale/mensile FATTIBILE.
- **(b) Harvest del VRP** (covered call / put-spread): +10-14 pt ci sono ORA, ma è una scommessa
short-vol che richiede un crash nel campione per essere giudicata onestamente. Non l'abbiamo.
**Raccomandazione**: le opzioni NON sono un'avenue di ricerca a breve (manca storia multi-regime).
Mosse: (1) lasciare cerbero-bite ad accumulare (gratis, reale, costruisce in avanti il dataset
multi-regime); (2) rivalutare quando la finestra attraversa un crash/alta-vol; (3) intanto, l'unico
uso giustificato è come OVERLAY (tail-cap su una strategia spot), gateato sui premi reali qui sopra.
## Prossimo passo
Fase 3 sul solo candidato reale (trend-following long-only, MA-cross): sblocco UNA volta del
hold-out 2025-26, comportamento nei bear (2018/2022), stress fee×2 + slippage + lag, deflated-Sharpe
per il multiple-testing. Se regge → è la prima strategia onesta del progetto v2.0.0 (modesta:
migliora il buy&hold, non lo stravolge). Se non regge → anche il trend era sample-luck.
@@ -0,0 +1,62 @@
# 2026-06-19 — Ricerca v2.0.0: Fase 3, conferma avversariale del candidato trend
Candidato: **trend-following long-only (MA-cross)**, l'unico a passare i gate base in Fase 2.
Protocollo: selezione config solo pre-hold-out → sblocco una-tantum del hold-out 2025-26 →
breakdown bear → stress → deflated-Sharpe. Script `phase3_confirm.py`.
## Esito: ☠️ NON CONFERMATO — era regime-luck del mercato toro
### (1) Pre-hold-out (2018-2024): forte e robusto
Plateau pieno: BTC Sharpe 0.91-1.16, ETH 1.19-1.48 su tutte le config. **Deflated-Sharpe**
(N=60 trial): BTC DSR **0.990**, ETH **0.982** → l'effetto trend era REALE e robusto al
multiple-testing **sul 2018-2024**.
### (2) HOLD-OUT 2025-26 (sbloccato una volta) — FALLISCE
| | buy&hold | trend 24/96 | trend 96/288 (slow) |
|---|---|---|---|
| BTC Sharpe | 0.37 | **0.81** | 0.00 |
| BTC ret | 32.9% | 33.6% | 5.0% |
| ETH Sharpe | 0.32 | **0.95** | 0.01 |
| ETH ret | 49.3% | 52.0% | 11.3% |
Il 2025-26 è stato un periodo in DISCESA (buy&hold negativo). Il trend long-only — che "dovrebbe"
schivare i bear — si è fatto **frullare** (whipsaw): perde quanto o PIÙ del buy&hold, Sharpe negativo
su ogni config. Solo la MA lentissima (96/288) limita i danni a ~flat (5/11%), ma è cherry-pick
post-hoc e comunque NON positiva.
### (3) Per anno — il meccanismo
Il trend cattura ~70-80% degli anni TORO (2019-2024) e attutisce i bear IN-SAMPLE (2018 1% vs
39%; 2022 47% vs 65%). MA nel 2025 OUT-OF-SAMPLE ha fatto **peggio** del buy&hold (BTC 25% vs
7%; ETH 41% vs 11%): frullato in un mercato choppy/discendente. È il classico fallimento del
trend-following nei bear laterali. → l'edge 2018-24 era **beta del toro con risk-management**, non
alpha persistente.
### (4) Stress
FULL regge modestamente (Sharpe 0.65-0.91 anche a fee2x+lag), ma HOLD-OUT è negativo ovunque
(0.81 → 1.34) e peggiora sotto stress. Fragile.
### (5) Deflated-Sharpe
DSR>0.95 sul pre-hold-out → conferma che l'effetto era statisticamente reale **nel campione di
training**. Lezione chiave: **robustezza statistica in-sample ≠ persistenza out-of-sample.** Il
hold-out bloccato ha colto ciò che DSR da solo non poteva — il cambio di regime.
## VERDETTO FINALE (Fasi 0-3)
**Nessun edge validato, fee-surviving e out-of-sample esiste su BTC/ETH tra le famiglie testate.**
Il trend-following era il miglior candidato: reale 2018-24 (toro), ma **bocciato sul hold-out
2025-26** (whipsaw, sotto il buy&hold). La barra realistica resta il **buy&hold** (Sharpe ~0.8
sullo storico, ma 0.3/0.4 nel 2025-26: anche "stai long" è stato duro di recente).
Il processo disciplinato ha funzionato: **ha evitato di deployare un falso edge** (che, sul vecchio
sistema contaminato, sarebbe finito in produzione). Questo è il valore del reset.
## Implicazioni / direzioni
- **Non deployare** il trend come edge: è regime-dipendente, non batte il buy&hold OOS.
- Con **solo BTC/ETH prezzo**, il pozzo dei segnali è poco profondo: timing puro non ha edge robusto.
- Opzioni: nessun ruolo a breve (confermato). Tenere cerbero-bite ad accumulare per uno studio
multi-regime futuro.
- Scelte oneste per andare avanti: (a) accettare che il "ceiling" su BTC/ETH è un long risk-managed
(no alpha) e ottimizzare quello (vol-target per ridurre DD, non per battere il mercato); (b)
allargare l'universo dati CERTIFICATO (servono asset liquidi+puliti oltre BTC/ETH, che Deribit non
offre bene → valutare un secondo venue mainnet certificabile); (c) fonti di segnale ortogonali al
prezzo (on-chain, funding/basis multi-venue, opzioni multi-regime) — tutte richiedono nuovi dati
certificati prima di poterci credere.
@@ -0,0 +1,63 @@
# 2026-06-19 — Sintesi ricerca post-reset (5 track) e verdetto
Prima ondata di ricerca sui dati **certificati** BTC/ETH (Deribit mainnet, ~2 bps vs
Coinbase USD), con harness onesto condiviso `src/backtest/harness.py` (ingresso eseguibile
a `close[i]`, fee 0.10% RT, exit intrabar TP/SL, OOS/per-anno). Branch
`strategy-research-2026-06`.
## I 5 track
| Track | Famiglia | Esito |
|-------|----------|-------|
| **A** | Trend/Momentum (TSMOM, Donchian, EMA, vol-scaled) | 5m/15m morti (fee); 1h = residuo reale ma celle singole non robuste |
| **B** | ML walk-forward (logistic/GBM su feature di forma) | edge debole ma REALE su BTC (+83% OOS, Sharpe 0.57), ~+0.58 €/d su 2000 |
| **C** | Mean-reversion / range (fade, RSI2, VWAP) | **MORTO** — negativo anche a fee=0. Conferma: la vecchia libreria fade era artefatto |
| **D** | **Trend portfolio vol-targeted BTC+ETH** | ✅ **DEPLOYABLE** — robusto, positivo ogni anno |
| **E** | Cross-sectional BTC↔ETH + ensemble | RV debole (muore a 1.5bps/gamba); ensemble dimezza il DD ma non alza il ritorno |
## Il vincitore: Track D — trend portfolio (l'unico che guadagna in modo robusto)
TSMOM multi-orizzonte (blend 1-3-6 mesi su barre 1h), **vol-targeting** (posizione ∝
1/vol realizzata, target 20% annuo), portafoglio **50/50 BTC+ETH**, fee 0.10% RT. Un solo
set di parametri per entrambi gli asset.
- **LONG-SHORT 50/50:** CAGR +14.2%, **Sharpe 1.00**, maxDD 18.9%, positivo ogni anno 2019-2026.
- **LONG-FLAT 50/50 (migliore risk-adj):** CAGR +15.9%, **Sharpe 1.32**, **maxDD 13.3%**.
- Robusto: plateau di Sharpe ~1.0 su griglia target-vol/leva/orizzonti; regge fee fino a 0.40% RT;
su entrambi gli asset; **non** è un picco fortunato (a differenza delle "star" di Track A).
- Tesi confermata: il valore del trend è **tagliare il drawdown** (B&H DD ~78% → trend DD ~13-19%)
con Sharpe ≥ B&H → si può scalare il rischio (target-vol) e diversificare BTC+ETH.
- Caveat onesto: l'edge è più forte 2018-21 (Sharpe 1.63) che 2022-26 (Sharpe 0.57). Dimensionare
sul regime recente.
## Il verdetto sul target €50/giorno
Una strategia che **guadagna** in modo robusto ESISTE (Track D). Ma il target "€50/giorno
medio partendo da 2000 in 1-2 anni" **non è raggiungibile onestamente**: sono ~2.5%/giorno.
La leva NON è la scorciatoia (alza il DD verso la rovina). La vera leva è **target-vol +
capitale + tempo**:
| target-vol | leva usata | CAGR | Sharpe | maxDD | €/giorno (2k) |
|-----------|-----------|------|--------|-------|---------------|
| 20% | 0.23x | +14% | 1.00 | 19% | +0.73 |
| 40% | 0.45x | +28% | 1.00 | 35% | +3.73 |
| 60% | 0.68x | +40% | 1.00 | 48% | +7.96 |
| 80% | 0.90x | +50% | 0.99 | 60% | +13.78 |
Per **€50/giorno steady-state** servono ~**137k di capitale** (config conservativa, DD~19%),
oppure DD da rovina. Partendo da 2000 a CAGR ~28% (target-vol 40%, DD 35%) il capitale che
genera €50/giorno arriva in ~10-13 anni, non in 1-2.
## Conclusione operativa
1. **Esiste un edge dispiegabile e onesto**: il trend portfolio vol-targeted (Track D).
È il primo risultato robusto post-reset.
2. **Non esiste alcuna scorciatoia** verso €50/giorno su 2000 in 1-2 anni con questi dati
(BTC/ETH 5m-1h). Il limite è strutturale: due asset, alta correlazione, fee.
3. Prossimi passi onesti se si vuole alzare il soffitto: (a) dimensionare Track D a un
target-vol/DD tollerabile e farlo girare in paper, (b) cercare edge di **magnitudine
diversa** (non più diversificazione di edge deboli) — il che richiede dati che oggi non
abbiamo certificati (universo più ampio, microstruttura, funding/opzioni backtestabili).
Script: `scripts/research/track{A,B,C,D,E}_*.py`. Diari di dettaglio: `2026-06-19-track*.md`.
@@ -0,0 +1,43 @@
# 2026-06-19 — TP01: look-ahead ffill mixed-TF, ri-verifica e adozione bassa frequenza (>=12h)
Segnalazione utente/agente: un look-ahead **ffill MIXED-TIMEFRAME su barre open-labeled**
(`resample(label="left")`) gonfiava il 4h a Sharpe ~1.60; il risultato reale è ~1.1.
Conclusione: **NON scendere sotto le 12h** — costi e overfitting dominano.
## Cosa ho verificato (`scripts/analysis/tp01_lowfreq.py`)
Ricalcolo TP01 PULITO **per singolo TF** (barre discrete, posizione shiftata +1, NESSUN
ffill/combine mixed-TF), con un **guard di causalità esplicito** (ricalcolo `target_series` su
prefisso → `tgt[i]` invariato). Esito (fee 0.10% RT, hold-out 2025-26 bloccato):
| TF | leak | FULL Sh | FULL ret | HOLD Sh | HOLD ret | HOLD DD |
|---|---|---|---|---|---|---|
| 4h | **0** | 1.36 | +204% | 0.27 | +2.8% | 8.3% |
| 6h | **0** | 1.42 | +217% | 0.21 | +2.1% | 7.9% |
| 12h | **0** | 1.32 | +198% | 0.22 | +2.3% | 8.6% |
| **1d** | **0** | 1.30 | +201% | **0.31** | **+3.5%** | 7.5% |
| buy&hold 50/50 1d | — | 0.92 | +1671% | **0.32** | **39%** | 59% |
## Lettura
- **Il path single-TF che ho usato in verify/stress è LEAK-FREE** (guard=0 su ogni TF): il
gonfiaggio 1.60 stava nel path **mixed-TF ffill** (ensemble/combine, es. trackE), NON nel
portafoglio single-TF. Per questo il mio 4h era 1.36 (non 1.60).
- **La conclusione "≥12h" è comunque CORRETTA e la adotto**: il FULL Sharpe è PIATTO ~1.3 da 12h
a 4h → scendere sotto le 12h NON dà vantaggio reale, aggiunge solo costi/turnover e rischio
overfit/look-ahead (lo stress mostrava il margine hold-out del 4h fragile a lag/fee). **1d è il
migliore**: hold-out Sharpe 0.31 (il più alto), DD 7.5%, turnover/costi minimi, leak-free.
- Allinea anche col numero dell'agente: il "reale ~1.1" è del path mixed-TF corretto; il mio
single-TF pulito dà ~1.3 FULL. In ogni caso **edge difensivo modesto**, non alpha.
## Decisioni applicate
- **Canonica deploy → PORT LF1d** (era LF4h). `trend_portfolio.py`: docstring aggiornata + nota
look-ahead; aggiunti `resample_tf`/`resample_1d`, `resample_4h` marcato deprecato per il deploy.
- **Paper trader → 1d** (`paper_trend.py`: `resample_1d`, `build_bars`, etichette 1d; gira, 5 test ok).
- **CLAUDE.md**: TP01 ridescritta come DIFENSIVA, canonica ≥12h/1d, gotcha look-ahead documentato.
- **Gotcha riusabile:** mai ffill/combine MIXED-TIMEFRAME su timestamp open-labeled (`label="left"`):
la close del bar (nota solo a fine bar) verrebbe propagata indietro all'open-label → look-ahead.
Il calcolo per-singolo-TF a barre discrete (posizione +1) è sicuro; il guard prefix-recompute lo prova.
## Verdetto invariato
TP01 resta la prima strategia onesta del progetto: **difensiva** (taglia il DD ~6× vs buy&hold,
hold-out 2025-26 positivo su entrambi gli asset), modesta nel ritorno. Deploy a **1d**, forward-only
paper trader, prima di qualsiasi capitale reale.
@@ -0,0 +1,84 @@
# 2026-06-19 — Verifica TP01 (branch strategy-research-2026-06) col gauntlet onesto
Una ricerca PARALLELA (branch `strategy-research-2026-06`, AdrianoDev) dallo stesso baseline
v2.0.0 ha trovato TP01 come "unica vincitrice". La mia linea (Fasi 2-3) aveva bocciato il trend
sul hold-out 2025-26. Ho riprodotto TP01 VERBATIM (`scripts/analysis/verify_tp01.py`) e l'ho
passato al mio gauntlet. **TP01 REGGE — la mia conclusione precedente era incompleta.**
## TP01 = TSMOM 30/90/180g, **vol-target 20%**, leva cap 2x, **long-flat**, portafoglio 50/50 BTC+ETH (4h)
## Esiti del gauntlet
**(A) Multi-TF (4h cherry-picked?) — NO, plateau robusto:**
| TF | FULL Sharpe | HOLD-OUT Sharpe |
|---|---|---|
| 15m | 0.93 | 0.31 |
| 1h | 1.32 | +0.20 |
| **4h** | **1.36** | **+0.27** |
| 1d | 1.30 | +0.31 |
1h/4h/1d danno tutti FULL ~1.3 e hold-out positivo → non è un artefatto di un singolo TF (solo il 15m, fee-sensibile, fallisce).
**(C) HOLD-OUT 2025-26 (il test che ha ucciso il mio trend 1h) — TP01 PROTEGGE:**
| | Sharpe | ret | DD |
|---|---|---|---|
| **TP01 portfolio** | **+0.27** | **+2.8%** | **8.3%** |
| buy&hold 50/50 | 0.35 | **39.4%** | 59.8% |
**(D) Cross-asset nel hold-out — regge su ENTRAMBI** (BTC sleeve +2.9% Sh 0.24, ETH +2.4% Sh 0.24).
A differenza del "vincitore" frattale (+ETH/BTC), TP01 protegge coerentemente su BTC E ETH.
**(B) Per anno:** positiva quasi ogni anno 2019-2026 (eccezioni piccole: 2022 2.4%, 2026-YTD 0.9%),
DD annui 1-12%. Il claim "positiva ogni anno" è lievemente ottimistico ma sostanzialmente vero.
## Perché TP01 regge dove il MIO trend (Fase 3) è caduto
La differenza chiave è il **VOL-TARGETING** (che NON avevo combinato col trend): TP01 scala
l'esposizione ∝ 1/vol_realizzata → nel crollo 2025-26 la vol è esplosa e TP01 si è messo
quasi in cash, schivando il drawdown. Il mio MA-cross 1h aveva esposizione fissa ed è rimasto
long nel chop → frullato. Concorrono: TSMOM multi-orizzonte (più liscio del MA-cross), long-flat
(niente perdite short), diversificazione 50/50. **La mia "trend = regime-luck" era vera per il
trend NUDO; TP01 = trend + vol-target + portafoglio è un'altra cosa, e robusta.**
## Cosa È onestamente TP01 (no oversell)
- **Edge DIFENSIVO, non alpha**: FULL Sharpe 1.36 vs buy&hold 0.92 — MA CAGR +16.6% vs +48.1%.
Su tutto il toro il buy&hold ha reso ~8x di più. Il valore di TP01 è il **DD** (13.8% vs 77.5%
full; 8% vs 60% nel hold-out) e la **protezione dai crash**.
- Nel hold-out 2025-26 ha fatto solo +2.8% (Sharpe 0.27, basso): ha **protetto, non profittato**.
- Un solo regime di hold-out, ma il vol-targeting è meccanico (high vol → low expo) → generalizza
per costruzione meglio di un timing fittato.
- Config canonica (30/90/180, vol20%, lev2x) non iper-tunata; 4h non cherry-picked (plateau).
## VERDETTO
**TP01 è la PRIMA strategia onesta e robusta del progetto post-reset.** Supera il mio gauntlet
(hold-out positivo su entrambi gli asset, plateau multi-TF, causale, fee-aware). È modesta e
difensiva (Sharpe ~1.3, soffitto strutturale dichiarato corretto), ma è reale: migliora il
rischio/rendimento del buy&hold tagliando i drawdown e proteggendo nei crash. La ricerca parallela
ha fatto centro proprio sul pezzo che la mia linea non aveva combinato (vol-target sul trend).
**Raccomandazione:** integrare il branch su main (modulo `trend_portfolio.py` + paper trader),
trattare TP01 come baseline operativa difensiva. Aspettative oneste verso il target €50/g: a
Sharpe 1.3 / CAGR 16.6% servono molto capitale o leva (con più DD) — TP01 è un fondamento solido,
non una scorciatoia.
## STRESS-TEST (`scripts/analysis/stress_tp01.py`, integrato e rieseguito sul modulo vero)
| Dimensione | Esito |
|---|---|
| **Sweep fee** | FULL robusto fino a **0.40% RT** (Sh 1.44→1.36→1.28→1.13). HOLD-OUT SOTTILE: +2.8%/Sh0.27 a 0.10% → ~flat (Sh 0.03) a 0.40% |
| **Lag/slippage** | FULL robusto (1.29-1.43). HOLD-OUT si erode: lag1(4h)→Sh0.12, lag2→−0.02, lag1+fee0.20%→0.04 |
| **Plateau parametri** | OTTIMO — target_vol/leva/orizzonti/vol_win tutti reggono o migliorano (orizzonti 20/60/120 → Sh 1.61). **NON un picco cherry-picked** |
| **Deflated-Sharpe** | DSR **0.999** a N=10/40/100 trial → il Sharpe FULL non è artefatto di multiple-testing |
**Verdetto stress (onesto):**
- **Robustezza FULL-period: FORTE.** TP01 supera fee 0.40%, lag, ampio plateau di parametri, e
deflated-Sharpe. NON è overfit né cherry-picked — la proprietà robusta è il **taglio del
drawdown** (13.8% vs 77.5% full; 8% vs 60% hold-out), invariante a tutto lo stress.
- **Edge di RITORNO nel hold-out: REALE ma SOTTILE e sensibile alla frizione.** Nel 2025-26 ha
schivato il crash in modo affidabile (DD 8% vs 60%) ma ha **protetto più che profittato** (+2.8%,
Sh 0.27), e quel sottile positivo si assottiglia a zero sotto fee2x o lag 2 barre.
**Conclusione:** la proprietà **deployabile e robusta di TP01 è la PROTEZIONE del drawdown**, non
la generazione di alpha. È una strategia difensiva genuina (prima del progetto a superare gauntlet
+ stress), ma a basso ritorno: il valore è "Sharpe ~1.3 con DD ~6× più piccolo del buy&hold",
non "battere il mercato". Per il capitale reale: il vol-targeting + long-flat sono meccanici e
generalizzano; il rischio residuo è la frizione di esecuzione sul filo del sottile edge di ritorno
nei regimi avversi → da monitorare col paper trader forward-only prima di scalare.
+74
View File
@@ -0,0 +1,74 @@
# 2026-06-19 — Track A: Trend / Momentum su BTC & ETH (dati certificati)
Prima ricerca di strategie NUOVE post-reset (track A = trend/momentum). Tool:
`scripts/research/trackA_trend.py` (harness onesto `src/backtest/harness.py`, fee 0.10% RT,
IS/OOS 65/35, griglia su entrambi gli asset, fee sweep, stress leva). Run:
`uv run python scripts/research/trackA_trend.py`.
## Cosa è stato testato
- **TSMOM** (segno del ritorno N-barre, hold H) long/short e long-only.
- **EMA crossover** (fast/slow) come filtro di trend.
- **Donchian breakout** (entry ONESTO: breakout rilevato con `close[i]`, fill a `close[i]`).
- **Vol-scaled / regime-gated TSMOM** (momentum preso solo se |z| > gate, z = ritorno/vol).
- Griglia ampia su **BTC e ETH**, **1h / 15m / 5m**. 480 celle OOS totali.
Tutto entry-eseguibile: direzione e prezzo decisi con dati ≤ `close[i]`, fill a `close[i]`.
Nessun uso di `returns[i]` (che codifica `close[i+1]`). Hold approssimato come catena di
posizioni non sovrapposte di H barre (la fee si ammortizza su H barre — costo onesto).
## Risultati — la fotografia onesta
**Celle positive OOS per timeframe:**
| TF | celle positive / totali |
|----|----|
| **1h** | 39 / 160 |
| **15m** | **0 / 160** |
| **5m** | **0 / 160** |
**Trend intraday (5m/15m) è MORTO**: lo drag della fee (più trade = più 0.10% RT) annienta
qualsiasi segnale. Drawdown 80-99%, Sharpe da 0.6 a 2.2. Niente da salvare.
**Su 1h** c'è qualche cella positiva, ma il contesto la ridimensiona:
- La finestra **OOS è un singolo regime**: il taglio 65% cade a **set/dic 2023**, quindi
l'OOS è ~2023→2026 (in gran parte toro 2024). Tutto il 2018-2022 (orso 2018, crash 2020,
toro 2021, orso 2022) è IN-SAMPLE. "Positivo OOS" qui ≈ "il trend ha fatto soldi nel toro 2024".
- **Benchmark buy & hold sulla stessa finestra OOS**: BTC **+134%**, ETH **21%**.
- Tutte le `TSMOM_LONG` e metà delle celle BTC fanno **MENO** del B&H → è **beta**, non edge.
- Le poche che battono il B&H lo fanno **solo su ETH** (dove il B&H era negativo): catturano
anche i ribassi. Quello è timing reale — ma vedi sotto.
**Le "star":** VOLSCALED_TSMOM BTC 1h (N=20,H=48,vw=100,z=0.5) = +367% OOS, Sh 0.91, DD 32%,
€/d(2k) +2.56; ETH 1h (N=20,H=48,vw=50,z=1.0) = +197% OOS, Sh 0.60. **MA sono celle fortunate:**
i vicini di griglia crollano (stesso N/H, vw=50 invece di 100 → +21% invece di +367%; z=1.0 → +34%).
Non è un altopiano robusto, è un picco isolato. E il P&L è concentrato nel 2024 (+110% su BTC),
con 2025/2026 deboli o negativi per molte celle.
**Consistenza cross-asset (un edge vero regge su ENTRAMBI):** su 480 celle, solo **2** sono
positive OOS su BTC *e* ETH:
- `TSMOM_LONG 1h N=200 H=48` → ma è long-only ≈ beta (fa meno del B&H su BTC).
- `DONCHIAN 1h N=200 H=12` → l'unico candidato "vero" simmetrico, ma **marginale**:
OOS BTC +9% / ETH +15%, **Sharpe 0.15-0.19**, troppo debole per dispiegarlo.
**Fee sweep / leva:** le star reggono lo sweep 0.0005-0.002 (è 1h, poche operazioni), e lo Sharpe
è invariante alla leva (come deve) — ma la leva 3x porta i DD a 75-91% e affonda le celle marginali.
## Verdetto
**Nessun edge trend/momentum dispiegabile, onestamente, su BTC/ETH oggi.**
- 5m/15m: morti per fee. Chiuso.
- 1h: esiste un **residuo di segnale trend** (le celle che battono il B&H negativo di ETH non sono
solo beta), ma è (a) testato su **un solo regime OOS** (toro 2023-2026), (b) **non robusto** di
griglia (picchi isolati), (c) sull'unica cella simmetrica robusta-su-entrambi (Donchian N=200)
**troppo debole** (Sharpe ~0.17). Sharpe netti ~0.3-0.9 nel caso migliore = sotto la soglia per
rischiare capitale reale.
Conferma la lezione del reset (il superstite storico era trend-following, non mean-reversion): il
trend è la direzione *meno sbagliata*, ma sui dati certi non basta a fare un edge. Coerente con
Track C (mean-reversion = artefatto).
## Prossimi passi possibili (non ancora edge)
- Walk-forward multi-regime (non un singolo taglio 65/35) per stressare Donchian-1h-N200 su orso 2018/2022.
- Trend 1h **con filtro di volatilità/regime più ricco** o portafoglio BTC+ETH per diversificare il
rischio di regime — ma solo se emerge robustezza di griglia, non altri picchi fortunati.
- Restare scettici: finché un trend non è positivo su griglia + su entrambi gli asset + su ≥2 regimi
OOS, **non si dispiega**.
+93
View File
@@ -0,0 +1,93 @@
# 2026-06-19 — Track B: ML / feature-prediction su BTC & ETH (walk-forward onesto)
Esperimento di ricerca sulla direzione **machine-learning** post-reset, su dati Deribit
mainnet certificati (solo BTC/ETH). Tool: `scripts/research/trackB_ml.py` (runnable
`uv run python scripts/research/trackB_ml.py`). Tutto netto fee, strict walk-forward,
held-out tail mai usato per scegliere i config.
## Metodologia (anti look-ahead — la lezione della v2.0.0)
- **Feature** (21): ritorni multi-lag (1/2/3/6/12/24), geometria candela (body/upper/lower
shadow su range, range normalizzato, body lag-1), momentum48 + accelerazione, RSI14,
estensione ATR-normalizzata vs EMA24, vol realizzata 24/72 + ratio, posizione del close
nel range 24/72, z-score del volume. **Tutte backward** (note solo a `close[i]`).
- **Label**: segno del ritorno forward su H barre, `sign(close[i+H]/close[i])`.
- **Strict walk-forward**: per predire il blocco che inizia a `b`, si addestra
scaler+modello SOLO su indici `< b-H` (gap di H → label completamente realizzata nel
passato), finestra rolling delle ultime W barre. Retrain ogni K=250 barre. Mai fit sul
futuro. **Nessun leakage** (verificato: la label più recente del train usa `close[b-1]`).
- **Esecuzione**: entry a `close[i]` nella direzione predetta, hold fino a H barre
(no TP/SL); il no-overlap dell'harness distanzia i trade ≥ H barre.
- **Modello**: `LogisticRegression(class_weight='balanced')`. Soglia di probabilità per
filtrare i segnali deboli (long se p>0.5+thr, short se p<0.5-thr, altrimenti flat).
- **Selezione su DEV** (primo 75%), **conferma una volta sola** sull'held-out tail (ultimo 25%).
- Griglia: W∈{4000,8000,16000}, H∈{6,12,24,48}, thr∈{0,0.03,0.06,0.10}, BTC & ETH, 1h.
Fee-sweep 0.05/0.10/0.15/0.20% RT. Turnover/time-in-market sempre riportati.
## Risultato — esiste un segnale, ma è debole e a basso turnover
**Pattern netto e robusto della griglia**: la positività compare SOLO nelle celle a basso
turnover → **W grande (16000) + H lungo (24) + soglia alta (0.10)**. Tutto ciò che gira
veloce (thr basso, H corto, e soprattutto il **15m**) **muore sulle fee**.
- **15m**: 0/12 celle positive in dev (la migliore 47%, le altre 99%). Stesso win-rate
5256% del 1h, ma il turnover lo polverizza. Conferma di prim'ordine: l'edge per-trade è
minuscolo, sopravvive solo se si tradano poche barre.
- **1h, dev**: 19/96 celle net-positive con Sharpe>0. Famiglie threshold-robuste:
`BTC W16000 H12`, `BTC W8000 H12`, `BTC W16000 H24`, più ETH W16000 H12/H48 marginali.
### Held-out tail (2024→2026, mai toccato in sviluppo)
| config | trades | wr% | net% | Sharpe | DD% | mkt% | €/g(2k) | long% | B&H tail |
|---|---|---|---|---|---|---|---|---|---|
| **BTC W16000 H24 thr0.10** | 333 | 52.9 | **+83.7** | 0.57 | 23 | 12 | **+0.58** | 44 | +3.9% |
| BTC W16000 H12 thr0.10 | 382 | 53.4 | +37.6 | 0.35 | 25 | 7 | +0.26 | 54 | +3.9% |
| ETH W16000 H12 thr0.10 | 364 | 57.7 | +23.7 | 0.24 | 35 | 7 | +0.18 | 68 | 38.4% |
| ETH W16000 H48 thr0.06 | 215 | 55.3 | 13.3 | 0.08 | 64 | 16 | 0.10 | 67 | 38.4% |
**Non è solo beta.** Il B&H sul tail è +3.9% (BTC) e 38.4% (ETH), eppure le celle migliori
fanno +37…+84% (BTC) con **long ~4454%** (bilanciato long/short), e ETH +23.7% **mentre ETH
scendeva 38%** (short corretti). Quindi c'è segnale direzionale genuino, non cattura di trend
rialzista. Payoff asimmetrico: ~53% WR ma avgWin>avgLoss (BTC: +2.04% vs 1.63%).
### Fee-sweep (held-out)
- `BTC W16000 H12 thr0.10`: 0.05%→+66.6 | **0.10%→+37.6** | 0.15%→+13.7 | 0.20%→−6.1.
Sopravvive fino a ~0.15% RT, poi muore. Margine sottile.
- `BTC W8000 H12 thr0.06`: positivo solo a 0.05%, già 35% a 0.10%. Fragile.
- ETH e le celle a turnover medio: muoiono tra 0.10 e 0.15%.
### Stabilità per-anno (full walk-forward, BTC W16000 H24 thr0.10)
`+11% (2020) / +188% (2021) / +14% (2022) / 38% (2023) / +13% (2024) / +75% (2025) / +7% (2026)`,
CAGR full ~22%, ma **DD 56%** e fortissima concentrazione su 2021/2025 con un 2023 a 38%.
## Verdetto onesto — NON deployabile verso l'obiettivo
1. **L'edge è reale ma minuscolo.** A differenza della vecchia libreria (artefatto puro), qui
il segnale sopravvive a strict walk-forward, a fee 0.10% RT e batte il B&H sul tail. È un
risultato genuino e va registrato: la direzione ML **non è morta**.
2. **Ma è incompatibile col target.** €/giorno su €2000 = +0.26…+0.58 baseline (anche la stima
rosea full-WF CAGR 22% → ~€13/g). Il target è **€50/g** → siamo ~100x sotto.
3. **Fragilità**: vive solo a basso turnover (thr alto, H lungo, W grande), DD 2356%,
ritorni concentrati in pochi anni con un anno a 38%, e l'edge si assottiglia già a
0.15% RT. Un singolo cambio di regime lo annulla.
4. **ETH ≠ "specialmente buono"** (contrariamente all'indizio dello shape-ML precedente): qui
ETH è più sottile e più rumoroso di BTC sull'held-out; l'unico merito è aver shortato
correttamente il drawdown 2024-25.
**Conclusione**: la logistic-regression walk-forward su feature di forma+momentum trova un
debole edge **momentum direzionale a basso turnover** su BTC (più tenue su ETH), onesto e
netto-fee, ma **troppo piccolo, troppo concentrato e troppo fee-sensibile** per essere
deployato standalone. Al massimo un **componente** di un futuro ensemble, e solo nelle
configurazioni a bassissimo turnover. Nessun config raggiunge, neanche lontanamente, i €50/g.
## Prossimi passi possibili (non eseguiti)
- Provare **predizione di magnitudine/asimmetria** (large-up vs large-down) e position-sizing
proporzionale alla confidenza, invece del semplice segno.
- **GradientBoosting / feature non lineari** (flag `--gbm` predisposto) — ma attenzione
all'overfit; il rischio è di "trovare" edge che il walk-forward onesto non conferma.
- **Ensemble** del segnale ML a basso turnover con un filtro di regime (vol/trend) per tagliare
il 2023. Ma serve dimostrare che il filtro non è scelto col senno di poi.
- Restare scettici: finché €/g resta ~100x sotto target, l'ML da solo NON è la risposta.
+70
View File
@@ -0,0 +1,70 @@
# 2026-06-19 — Track C: mean-reversion / range re-examination (HONEST) → DEAD
Obiettivo: stabilire rigorosamente se **un qualunque** edge di mean-reversion / range a
breve orizzonte sopravvive su BTC/ETH **certificati** (Deribit mainnet) con **ingresso
eseguibile onesto**, oppure confermarne definitivamente la morte. Entrambi gli esiti sono
validi; nessun risultato forzato.
Tool: `scripts/research/trackC_meanrev.py` (self-contained, runnable), sopra l'harness
onesto `src/backtest/harness.py` (direzione+prezzo decisi con dati ≤ `close[i]`, fill a
`close[i]`, exit intrabar TP/SL da `i+1`, fee netto). Universo: BTC/ETH × {1h,15m,5m}.
## Cosa è stato testato (5 famiglie, ingresso onesto)
- **ZFADE** — Bollinger/z-score fade: `z(close,lookback)`thr → long, ≥ +thr → short.
TP al mean mobile o a `tp_atr·ATR`; SL a `sl_atr·ATR`. **Entry a close[i]**, NON al tocco
della banda (era proprio quello l'artefatto storico).
- **RSI2** — RSI(2) oversold/overbought (+ variante con filtro trend SMA200).
- **RETREV** — return reversal: fade del rendimento cumulato estremo (|z| > thr·σ).
- **VWAP** — reversione sulla distanza dal VWAP rolling (in unità di σ della distanza).
- **SESSION** — autocorrelazione next-bar per ora UTC (descrittivo).
Metodologia applicata: OOS 65/35, griglia parametri su **entrambi** gli asset, fee-sweep
{0, 0.5, 1.0, 1.5, 2.0} bps RT, cross-check liquidità (flat-bar O=H=L=C) e time-in-market.
## Sanity liquidità
Flat-bar O=H=L=C: BTC/ETH 1h ≈ 0.01%, 15m 0.090.14%. Book vivo → l'eventuale edge NON
potrebbe nascondersi in barre ferme (a differenza degli alt archiviati). Confermato pulito.
## Risultati — tutto negativo, su ogni asse
**PASS 1 (screen 1h, fee 0.10% RT):** ogni famiglia OOS negativa su entrambi gli asset.
Es. ZFADE z2/mean: BTC OOS 85%, ETH OOS 83%. RSI2 10/90: BTC 92%, ETH 96%.
RETREV/VWAP idem. Win-rate spesso "alto" (RSI2 ~63%, VWAP ~64%) ma **perde lo stesso**
le poche perdite sono enormi, la reversione non paga il rischio + fee.
**PASS 2 (griglia 1h):** ZFADE **0/18** celle con OOS>0 su entrambi; RSI2 **0/36**. La
cella meno-peggio (ZFADE lookback20 z3) resta BTC 40% / ETH 33% OOS. Nessun sopravvissuto.
**PASS 3 (fee-sweep, incl. fee=0 GROSS):** il colpo decisivo. **Anche a fee=0** (lordo)
la z-fade è negativa: BTC full 74% / OOS 46%, ETH full 98% / OOS 48%. Quindi non è
"morte da fee": **la direzione stessa della fade è sbagliata** sul feed pulito. Salendo le
fee degrada monotòno fino a 100%.
**PASS 4 (timeframe 5m/15m/1h):** più veloce = peggio. A 5m full 100% su entrambi
(41.889 / 38.660 trade), €/giorno su 2000 ≈ 0.70/0.75. Coerente con "molte operazioni =
morte per fee", ma il PASS 3 mostra che il problema è a monte: niente edge nemmeno lordo.
**PASS 5 (sessione UTC):** esiste una **debole** autocorrelazione negativa next-bar in
poche ore (BTC 13h 0.166, 2h 0.154, 21h 0.129; ETH 13h 0.152, 4h 0.117), e una
positiva alle 03h UTC (BTC +0.158, ETH +0.202 = ora "trending"). Struttura reale ma
debolissima (|ρ|≤0.17): non sopravvive a fee + dimensionamento del rischio (lo conferma il
fatto che tutte le versioni *tradate* perdono anche lorde).
## Verdetto
**Nessuna** configurazione MR produce OOS netto>0 su entrambi BTC ed ETH a fee baseline.
Più forte: **a fee zero la fade è già negativa** → l'edge MR storico (+201%/+1238% "OOS")
era un **artefatto del feed contaminato** (wick fantasma testnet + entry su estremi mai
scambiati), non una proprietà del mercato. Sul dato certificato, con ingresso eseguibile,
la mean-reversion a breve orizzonte **non è un edge**: è morta sia lorda che netta.
Coerente con la tesi del reset (`2026-06-19-deribit-history.md`, §3): FADE morto ogni anno.
Track C chiusa come direzione di alpha. La debole struttura intraday-by-hour (PASS 5) è
annotata ma non azionabile da sola; semmai un *filtro* futuro, non una strategia.
## Artefatti
- `scripts/research/trackC_meanrev.py` — riproducibile: `uv run python
scripts/research/trackC_meanrev.py [--quick]` (~40s quick, ~3min full).
+96
View File
@@ -0,0 +1,96 @@
# 2026-06-19 — Track D: Robust walk-forward TREND PORTFOLIO (BTC+ETH), vol-targeted + leverage
Follow-up to Track A. Thesis under test: trend-following's real value in crypto is **drawdown
reduction** vs buy & hold (it sidesteps crashes), and that lower DD lets you apply **leverage** and
**diversify** BTC+ETH into a deployable, risk-adjusted *earning* system — even if each single signal
has modest Sharpe. Tool: `scripts/research/trackD_trendport.py` (run
`uv run python scripts/research/trackD_trendport.py`).
## Method (honest, no look-ahead)
Equity built directly from a **target-position series** (the harness's documented "build your own
equity" path), NOT per-trade chaining:
- `target[i]` decided with data **≤ close[i]**; **held during the next bar** (close[i]→close[i+1]).
- `pnl[t] = target[t-1]·r[t]`, `r[t]=close[t]/close[t-1]-1` — positions **shifted +1 bar** ⇒ no leakage.
- Fees on **turnover**: `0.05%/side·|target[t-1]-target[t-2]|` (0.10% RT baseline; swept 0.100.40% RT).
- **Vol-targeting** (main lever): `target = direction · (target_vol / realized_vol)`, clipped to the
leverage cap. `realized_vol` = annualized rolling std of past bar returns (30d window), ≤ close[i].
- **Portfolio** = 50/50 BTC+ETH net-return series, rebalanced each bar on common timestamps.
Leakage sanity check passed: an *oracle* target using next-bar sign explodes (10^119×) — proving the
engine holds `target[i-1]` over bar `i` — while our signals (TSMOM blend, MA-slope, Donchian) only use
`close[i]` and earlier. Zero-position equity = exactly 1.0.
## What was tested
TSMOM multi-horizon blend (1/3/6-month-equiv on 1h bars), MA-slope (EMA200 slope), Donchian breakout
with trailing channel stop — each vol-targeted, long-short **and** long-flat, per-asset and combined.
Grid: target-vol × leverage-cap × horizon-set; explicit EARLY(2018-21)/LATE(2022-26) split;
fee & leverage sweep; full per-year 2018-2026.
## Results — the honest picture
**1) The thesis holds: massive DD reduction, and diversification helps.**
| Strategy (50/50 port, tvol20%, LS) | CAGR | Sharpe | maxDD | volA |
|---|---|---|---|---|
| **B&H 50/50** | +48% | 0.92 | **77.8%** | 70% |
| TSMOM 1-3-6m blend | +14.2% | **1.00** | **18.9%** | 14% |
| MA-slope | +14.1% | 0.79 | 21.9% | 19% |
| Donchian-trailing | +14.7% | 0.89 | 17.7% | 17% |
Trend cuts maxDD from ~78% to ~18% while keeping a Sharpe **above** buy&hold (1.00 vs 0.92). The
portfolio Sharpe (1.00) **beats both sleeves** (BTC 0.95, ETH 0.75) — diversification works as claimed.
The **long-flat** variant is even cleaner: Sharpe **1.32**, maxDD **13.3%** (no short funding/borrow risk).
**2) It is genuinely robust (not a lucky cell).**
- *Per-year (headline LS):* every full year **positive** 2019-2025 (+19/+36/+19/+6/+2/+14/+4%) and 2026 +8%.
- *Grid:* Sharpe ≈1.00 across **all** target-vol (10-40%) × leverage caps — flat plateau (vol-targeting
just scales). DD scales ~linearly with target-vol (10%→DD10%, 40%→DD35%).
- *Horizon-set:* every subset (1m/3m/6m/1-3m/3-6m/1-2-4m/2-4-8m) is **positive**; Sharpe 0.37→1.39.
Shorter horizons (1m, 1-2-4m) score best (Sharpe 1.34-1.39) — a real plateau, not one combo.
- *Fee:* survives to 0.40% RT (Sharpe 1.00→0.39, still positive at 4× baseline fee).
**3) The honest caveat — most of the edge is the EARLY regime.**
Walk-forward split, same param set both assets:
- **EARLY 2018-2021:** CAGR +26%, Sharpe **1.63**, DD 18%.
- **LATE 2022-2026:** CAGR +7.3%, Sharpe **0.57**, DD 19%.
The signal is real and still net-positive every late year, but its quality **halved** post-2021
(crypto vol compressed, trends choppier). This is the same warning Track A raised, now quantified: the
edge is strongest 2019-2021 and merely *modest* in the 2022-26 regime.
**4) Leverage is a red herring; target-vol is the real dial — and it costs DD linearly.**
At tvol=20% on 60-80% crypto vol, positions stay **sub-1x** (avg gross 0.23×): the leverage cap
**never binds**. To deploy real leverage you raise target-vol; Sharpe stays ~1.0, DD scales:
| target_vol | avg gross | CAGR | Sharpe | maxDD |
|---|---|---|---|---|
| 20% | 0.23× | +14% | 1.00 | 19% |
| 40% | 0.45× | +28% | 1.00 | 35% |
| 60% | 0.68× | +40% | 1.00 | 48% |
| 80% | 0.90× | +50% | 1.00 | 60% |
| 100% | 1.12× | +58% | 0.99 | 69% |
## Verdict — is this a deployable earning system?
**Yes as a risk-adjusted system; NO as a fast path to €50/day on €2000.**
- This is the **first post-reset config that is genuinely robust**: Sharpe ~1.0 (long-flat 1.3),
positive every year 2018-2026, robust across grid/horizon/fee, on both assets, on certified data,
with honest no-look-ahead accounting. It is a real, deployable trend portfolio and a clear
improvement over Track A's lucky single cells. The thesis (DD reduction → leverageable, diversifiable)
is **confirmed**.
- **But the earnings are modest.** Headline (tvol20%, 2x cap, LS): CAGR **+14.2%**, DD 19% ⇒ steady-state
**~€0.73/day on €2000**. To average **€50/day at this CAGR you need ~€137k capital**, not €2000.
- **Leverage can't close the gap cheaply.** Pushing target-vol to 80% gives CAGR ~50% (DD **60%**) — and
at €2000, 50%/yr is still only ~€2.7/day in steady state. Reaching €50/day in 1-2 years from €2000
would require both heavy leverage (DD 60-70%, near-ruin) **and** lucky path — not a sane plan.
- **Regime risk:** the edge is much weaker post-2021 (Sharpe 0.57 LATE). Deploy sized for the LATE
regime, not the EARLY one.
**Recommendation:** treat this as the **core risk engine** (compounding ~14%/yr at DD<20%, or
long-flat ~16%/yr at DD 13%), deployable now at low size to validate live execution. It grows €2000,
but to *€50/day* the lever is **capital + time**, not leverage. Realistic near-term: ~€0.7-1.5/day on
€2000; €50/day needs ~€70-140k or a second uncorrelated edge stacked on top.
## Deliverable
`scripts/research/trackD_trendport.py` — self-contained, prints B&H benchmark, broad scan, grid
robustness, horizon robustness, walk-forward early/late, fee+leverage sweep, headline config per-year,
and the path-to-€50/day table. Reusable building blocks (vol-targeting, target→equity, portfolio).
@@ -0,0 +1,140 @@
# 2026-06-19 — Track E: Cross-sectional BTC↔ETH relative-value + ENSEMBLE synthesis
Due parti, entrambe oneste e su dati Deribit-mainnet certificati (solo BTC/ETH). Tool:
`scripts/research/trackE_xsec_ensemble.py` (runnable, self-contained, riusa il walk-forward
ML di Track B e il Donchian di Track A). Harness onesto: direzione/posizione decise con dati
`close[i]`, realizzo sul bar successivo (shift di 1 barra, niente look-ahead). Fee
turnover-based: `|Δpos|·fee_rt/2` **per gamba** (un flip +1↔−1 = un round-trip = 0.10% RT).
Run: `uv run python scripts/research/trackE_xsec_ensemble.py` (`--quick` salta lo sleeve ML;
`--no-cache` ricalcola la proba ML). Il proba ML viene cacheato (`.cache_trackE_*.npy`).
---
## PART 1 — Relative value (spread BTC↔ETH, 1h, market-neutral)
**Premessa strutturale.** BTC/ETH log-ret 1h sono correlati **0.84**. Con due soli asset
l'unica struttura tradabile è lo **spread**. E con due asset, *"long il più forte / short il
più debole"* (XS-momentum) è **algebraicamente identico** a *"trada il trend del ratio
ETH/BTC"* — infatti nel codice (A) e (B) producono numeri identici. Sono lo stesso edge.
**Lead-lag: nullo.** `corr(rB[i], rE[i+1]) = 0.018`, `corr(rE[i], rB[i+1]) = 0.007`,
autocorrelazioni 0.01..0.02. Nessun potere predittivo cross-asset → lead-lag **non**
perseguito come sleeve (sarebbe rumore moltiplicato per le fee).
**(A/B) XS momentum / ratio trend (griglia N∈{24,72,168,336}, hold∈{6,24,72}):**
- Solo **4/12 celle** OOS net-positive, e sparse (N24/h24, N24/h72, N72/h72, N168/h24).
- Le celle FULL forti (N168/h24: +150% full, Sharpe 0.68, DD 27%) hanno **OOS debole**
(+11%, Sh 0.30). La migliore per OOS-Sharpe è N24/h24 (OOS Sh 0.31, OOS net +11%).
- **Fee sweep (N24/h24):** gross (0bp) FULL +356%/OOS +74% Sh 1.20 → a 1.0bp/gamba FULL +27%/
OOS +11% Sh 0.31 → **muore già a 1.5bp/gamba** (OOS 11%). Margine fee sottilissimo.
- **Per-anno** concentrato sui grandi movimenti del ratio 2020-2021 (e 2024), piatto/negativo
altrove (2022 9%, 2023 19%, 2025 6%, 2026 16%). Non è un altopiano: è un edge debole,
fee-sensibile, regime-dipendente.
**(C) Ratio mean-reversion (z-fade di log(ETH/BTC)):** negativa ovunque (es. lb168/zin2.0:
FULL 85%, OOS 44%, Sh 1.56). Coerente con Track C: anche sullo spread la MR a breve non è
un edge sul dato pulito.
**Verdetto PART 1:** esiste un **debole** edge di relative-value (XS-momentum ≡ ratio-trend),
net-positivo OOS solo in alcune celle, Sharpe OOS ~0.3, che **muore a ~1.5bp/gamba** ed è
concentrato in pochi anni. È **reale ma marginale** — degno di entrare in un ensemble come
sleeve diversificante, non come strategia standalone. La sua virtù: è **quasi scorrelato**
dagli edge direzionali (vedi sotto).
---
## PART 2 — Ensemble (3 sleeve residui in UN portafoglio)
Sleeve combinati (gross 1 ciascuno, equal-weight 1/N → gross totale ~1):
- **S1 = BTC-ML** (Track B, cella onesta a basso turnover W16000 H24 thr0.10, 1h).
- **S2 = BTC-Trend** (Track A, l'unica cella trend robusta cross-asset: Donchian N=200 H=12).
- **S3 = Relative-value** (PART 1, miglior cella OOS: XS-momentum N=24 hold=24).
**Finestra comune attiva** (dove tutti e 3 sono live, dopo il warmup ML): 2020-06 → 2026-06,
52.636 barre.
### Matrice di correlazione degli sleeve (ret per-barra, finestra comune)
| | S2_trend | S3_relval | S1_ml |
|----------|----------|-----------|--------|
| S2_trend | +1.000 | +0.010 | 0.063 |
| S3_relval| +0.010 | +1.000 | 0.010 |
| S1_ml | 0.063 | 0.010 | +1.000 |
**Sleeve quasi perfettamente scorrelati** (|ρ| ≤ 0.06). In teoria, terreno ideale per la
diversificazione.
### Per-sleeve (finestra comune, scala $ uguale)
| sleeve | net | Sharpe | maxDD | €/g(2k) |
|-----------|-------|--------|-------|---------|
| S2_trend | +5% | +0.15 | 34% | +0.04 |
| S3_relval | +8% | +0.16 | 41% | +0.07 |
| **S1_ml** | +382% | **+0.87** | 56% | +3.51 |
### Ensemble
| portafoglio | net | Sharpe | maxDD | CAGR | €/g(2k) |
|----------------------|-------|--------|-------|-------|---------|
| best single (S1_ml) | +382% | +0.87 | 56% | +30% | +3.51 |
| **EQUAL-WEIGHT 1/N** | +109% | **+0.83** | **30%** | +13% | +1.00 |
| inverse-vol (IS wts) | +76% | +0.70 | 29% | +10% | +0.69 |
| EQ-WEIGHT **OOS**(65/35)| +32% | **+1.02** | **12%** | +14% | +0.83 |
Per-anno equal-weight: 2020 +16%, 2021 +50%, 2022 +2%, **2023 13%** (vs 38% dell'ML da
solo!), 2024 +18%, 2025 +19%, 2026 3%. **Molto più liscio**, niente anno-catastrofe.
### La diversificazione aiuta? Sì sul rischio, NO sul rendimento risk-adjusted
- **Sharpe:** ensemble 0.83 vs best-single 0.87 → **non batte** il miglior sleeve singolo.
- **maxDD:** ensemble **30%** vs best-single 56% → **dimezzato**. E OOS 12% vs ML-solo molto
più profondo. Per-anno senza il 38% del 2023.
- **Risk-matched** (levare l'ensemble 1.84x per pareggiare il 56% DD dell'ML): €/g +2.23
contro €/g +3.51 dell'ML da solo → a pari drawdown l'ensemble rende **MENO** (ratio 0.64).
**Perché?** Gli sleeve sono scorrelati ma **enormemente diseguali** (Sharpe 0.87 vs 0.15 vs
0.16). L'equal-weight 1/N "annacqua" l'unico sleeve forte con due deboli: la matematica
della diversificazione alza lo Sharpe solo se gli sleeve sono di *qualità comparabile*. Qui
non lo sono, quindi 1/N non può superare il singolo migliore. Pesare verso l'ML (quality-
weighting) converge banalmente a "esegui solo l'ML" — e sarebbe in-sample.
**Il guadagno vero dell'ensemble è la ROBUSTEZZA, non il rendimento:** stesso Sharpe del
miglior sleeve a **metà del drawdown**, per-anno molto più stabile, niente dipendenza da un
singolo modello/regime (l'ML da solo concentra tutto in 2021/2025 con un 38% nel 2023). Per
chi deve *sopravvivere*, l'ensemble è preferibile; per chi massimizza il rendimento a pari
rischio, l'ML puro vince di un soffio.
---
## Verdetto onesto — è un motore da €50/giorno? NO.
1. **Relative-value:** edge debole, reale ma marginale (Sharpe OOS ~0.3), fee-sensibile
(muore a 1.5bp/gamba), concentrato 2020-2021/2024. Utile **solo** come sleeve scorrelato.
Lead-lag e ratio-MR: nulli/negativi.
2. **Ensemble:** gli sleeve sono **quasi scorrelati** (|ρ|≤0.06) — risultato genuino e bello.
L'ensemble equal-weight ottiene **Sharpe ~0.83 a metà del drawdown** del miglior sleeve e
un per-anno molto più liscio. **Ma NON alza il tetto risk-adjusted** (a pari DD rende meno
dell'ML puro) perché un solo sleeve domina.
3. **Distanza dal target:** ensemble **€1.00/giorno su €2000** (best single €3.51 ma a DD
56% e concentrato). Il target è **€50/giorno → ~50x sotto** (l'ML puro ~14x sotto ma con
rischio/concentrazione inaccettabili). Levare per colmare il gap moltiplica il drawdown
ben oltre il tollerabile (1.84x già porta al 51% DD per ~€2.2/g).
**Conclusione:** la sintesi di Track E conferma la fotografia dei track A/B/C — esistono
**edge residui deboli ma reali e scorrelati** su BTC/ETH. Combinarli in un ensemble **migliora
la robustezza** (DD dimezzato, per-anno stabile, niente single-point-of-failure) ma **non crea
rendimento dal nulla**: il sistema combinato rende ~€1/giorno su €2000, ~50x sotto l'obiettivo,
e non è un motore dispiegabile. Il miglior uso pratico dei risultati: se un giorno si tradasse,
l'ensemble equal-weight (ML + trend + relative-value) è la forma **più onesta e meno fragile**
del poco edge disponibile — ma serve un edge **di un'altra magnitudine** per avvicinare i €50/g.
## Prossimi passi possibili (non eseguiti)
- Cercare uno sleeve **di qualità comparabile all'ML** (Sharpe ≥0.5 indipendente) — solo
allora 1/N alzerebbe lo Sharpe oltre il singolo. Senza, l'ensemble resta solo "risk smoother".
- Relative-value su **timeframe diversi** del ratio (giornaliero?) o con **position sizing**
proporzionale alla forza del segnale, restando scettici sul fee-margin sottile.
- Non aumentare la leva per inseguire €50/g: il DD esplode prima del rendimento.
## Artefatti
- `scripts/research/trackE_xsec_ensemble.py` — riproducibile (`uv run ...`, ~8s con cache ML).
+96
View File
@@ -0,0 +1,96 @@
"""EVALUATOR STANDARD per i segnali della ricerca multi-agente (Fase frattale, v2.0.0).
Ogni agente scrive SOLO una funzione `signal(df, asset, tf) -> np.ndarray` (posizione per barra
in [-1,1], decisa entro close[i]) in un file. Questo evaluator la valuta in modo UNIFORME e ONESTO
sull'harness research_lab, e — cruciale — esegue un GUARD ANTI-LOOK-AHEAD automatico: ricalcola il
segnale su prefissi del df e verifica che pos[i] non dipenda da barre future (leak>0 = sospetto).
uv run python scripts/analysis/eval_signal.py <signal_file.py> <BTC|ETH> <5m|15m|1h> [--holdout]
Stampa una riga "RESULT_JSON:{...}" con tutte le metriche (gli agenti riportano quei campi esatti).
"""
from __future__ import annotations
import sys
import json
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
from src.data.downloader import load_data
from scripts.analysis.research_lab import backtest, buy_hold, mc_pvalue, VAL_START, HOLDOUT_START
def load_signal(path):
spec = importlib.util.spec_from_file_location("usig", path)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
if not hasattr(m, "signal"):
raise AttributeError("il file non definisce signal(df, asset, tf)")
return m.signal
def causality_guard(signal, df, asset, tf, k=12):
"""Ricalcola il segnale su prefissi df[:i+1] e confronta pos[i] col run completo.
Se differiscono -> il segnale usa dati FUTURI (look-ahead). Ritorna #violazioni (0 = pulito)."""
full = np.asarray(signal(df, asset, tf), float)
n = len(df)
if len(full) != n:
return -1
rng = np.random.default_rng(0)
idx = rng.integers(int(n * 0.6), n - 1, size=k)
bad = 0
for i in idx:
try:
p = np.asarray(signal(df.iloc[:i + 1].copy(), asset, tf), float)
except Exception:
bad += 1; continue
if len(p) != i + 1 or not np.isclose(np.nan_to_num(p[i]), np.nan_to_num(full[i]), atol=1e-6):
bad += 1
return bad
def main():
args = sys.argv[1:]
holdout = "--holdout" in args
args = [a for a in args if a != "--holdout"]
sigfile, asset, tf = args[0], args[1].upper(), args[2]
res = {"asset": asset, "tf": tf, "sigfile": sigfile}
try:
signal = load_signal(sigfile)
df = load_data(asset, tf)
pos = np.asarray(signal(df, asset, tf), float)
res["n"] = int(len(df))
res["len_ok"] = bool(len(pos) == len(df))
if not res["len_ok"]:
res["error"] = f"len(pos)={len(pos)} != len(df)={len(df)}"
print("RESULT_JSON:" + json.dumps(res)); return
res["finite"] = bool(np.isfinite(np.nan_to_num(pos, nan=0.0)).all())
res["leak"] = int(causality_guard(signal, df, asset, tf))
full = backtest(df, pos, tf)
oos = backtest(df, pos, tf, lo=VAL_START, hi=HOLDOUT_START)
bh = buy_hold(df, tf)
_, p, _, _ = mc_pvalue(df, pos, tf, n=250)
res.update(
implemented=True,
full_sharpe=round(full.sharpe, 3), full_ret=round(full.ret, 3), full_dd=round(full.maxdd, 3),
oos_sharpe=round(oos.sharpe, 3), bh_sharpe=round(bh.sharpe, 3),
gross_sharpe=round(backtest(df, pos, tf, fee_rt=0.0).sharpe, 3),
fee02_sharpe=round(backtest(df, pos, tf, fee_rt=0.002).sharpe, 3),
turnover=round(full.ntrades, 1), exposure=round(full.exposure, 3),
null_p=round(p, 4),
beats_bh=bool(full.sharpe > bh.sharpe and oos.sharpe > 0),
)
if holdout:
ho = backtest(df, pos, tf, lo=HOLDOUT_START)
res["holdout_sharpe"] = round(ho.sharpe, 3)
res["holdout_ret"] = round(ho.ret, 3)
res["holdout_dd"] = round(ho.maxdd, 3)
except Exception as e:
res["implemented"] = False
res["error"] = f"{type(e).__name__}: {str(e)[:200]}"
print("RESULT_JSON:" + json.dumps(res))
if __name__ == "__main__":
main()
+121
View File
@@ -0,0 +1,121 @@
"""ANALISI OPTIONS per BTC/ETH — onesta sui dati REALI disponibili (cerbero-bite mainnet).
Dati: Old/data/options (chain per-strike + dvol + market_snapshots). Finestra ~2026-05-01→06-11
(~6 settimane, REGIME UNICO calmo). NON si può validare OOS un edge su opzioni qui; si possono
MISURARE i livelli reali (VRP, premi put, skew, liquidità) e ragionare sull'USO delle opzioni
per il book BTC/ETH certificato. cerbero-bite è ancora vivo -> la fonte continua ad accumulare.
uv run python scripts/analysis/options_analysis.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
OPT = PROJECT_ROOT / "Old" / "data" / "options"
def load(name):
return pd.read_parquet(OPT / name)
def market_snapshots_analysis():
print("=" * 90)
print(" (1) MARKET SNAPSHOTS — VRP, DVOL, funding, dealer-gamma (livelli reali)")
print("=" * 90)
ms = load("market_snapshots.parquet")
t = pd.to_datetime(ms["timestamp"], utc=True, errors="coerce")
print(f" copertura: {t.min()} -> {t.max()} ({len(ms)} righe)")
for a in ("BTC", "ETH"):
d = ms[ms["asset"] == a].dropna(subset=["iv_minus_rv"])
if len(d) == 0:
print(f" {a}: nessun dato"); continue
vrp = d["iv_minus_rv"].astype(float)
dvol = d["dvol"].astype(float)
rv = d["realized_vol_30d"].astype(float)
fund = d["funding_perp_annualized"].astype(float) if "funding_perp_annualized" in d else pd.Series([np.nan])
gam = d["dealer_net_gamma"].astype(float) if "dealer_net_gamma" in d else pd.Series([np.nan])
print(f"\n {a} (n={len(d)})")
print(f" VRP (IV-RV): media {vrp.mean():+.1f} mediana {vrp.median():+.1f} "
f">0 nel {100*(vrp>0).mean():.0f}% del tempo [IV-RV in punti di vol annua]")
print(f" DVOL: media {dvol.mean():.1f} range [{dvol.min():.1f}, {dvol.max():.1f}]")
print(f" Realized30d: media {rv.mean():.1f}")
print(f" Funding perp: media {fund.mean():+.1f}% annuo")
if gam.notna().any():
print(f" Dealer net-γ: >0 nel {100*(gam>0).mean():.0f}% del tempo (>0 = dealer long gamma = mean-rev)")
def chain_analysis(asset):
print("\n" + "=" * 90)
print(f" (2) CHAIN {asset} — premi put protettivi, skew, liquidità (livelli reali)")
print("=" * 90)
ch = load(f"{asset.lower()}_chain.parquet")
for col in ("strike", "bid", "ask", "mid", "iv", "delta", "gamma"):
if col in ch:
ch[col] = pd.to_numeric(ch[col], errors="coerce")
ch["option_type"] = ch["option_type"].astype(str)
dv = load("dvol_history.parquet")
dv = dv[dv["asset"] == asset][["timestamp", "spot"]].copy()
dv["spot"] = pd.to_numeric(dv["spot"], errors="coerce")
# timestamp -> datetime UTC nativo (sono datetime64[tz], NON ms int: to_numeric li romperebbe)
ch["t"] = pd.to_datetime(ch["timestamp"], utc=True, errors="coerce")
dv["t"] = pd.to_datetime(dv["timestamp"], utc=True, errors="coerce")
ch = ch.dropna(subset=["t"]).sort_values("t").reset_index(drop=True)
dv = dv.dropna(subset=["t", "spot"]).sort_values("t").reset_index(drop=True)
# spot causale per timestamp della chain (merge_asof nearest, tolleranza 1h)
ch = pd.merge_asof(ch, dv[["t", "spot"]], on="t", direction="nearest",
tolerance=pd.Timedelta("1h"))
ch = ch.dropna(subset=["spot", "mid", "strike"])
# days-to-expiry
exp = pd.to_datetime(ch["expiry"], utc=True, errors="coerce")
ch["dte"] = (exp - ch["t"]).dt.total_seconds() / 86_400.0
ch = ch[(ch["dte"] > 0.5) & (ch["dte"] < 90)]
ch["money"] = ch["strike"] / ch["spot"]
ch["prem_pct"] = ch["mid"] * 100 # mid è in COIN (frazione del sottostante) -> %-del-notional
# NB: iv è GIÀ in percento (35.94 = 35.94%, coerente col DVOL ~40) -> non riscalare
ch["spread_pct"] = (ch["ask"] - ch["bid"]) / ch["mid"].replace(0, np.nan) * 100
puts = ch[ch["option_type"].str.lower().str.startswith("p")]
calls = ch[ch["option_type"].str.lower().str.startswith("c")]
def band(df, mlo, mhi, dlo, dhi):
s = df[(df["money"] >= mlo) & (df["money"] <= mhi) & (df["dte"] >= dlo) & (df["dte"] <= dhi)]
return s
print(" PUT protettive — premio reale (mid/spot) e liquidità per tenor/moneyness:")
print(f" {'tenor':<10s}{'moneyness':<14s}{'premio%':>9s}{'/mese%':>9s}{'spread%':>9s}{'n':>7s}{'strike?':>9s}")
for dlo, dhi, tn in [(5, 12, "settim."), (18, 45, "mensile")]:
for mlo, mhi, ml in [(0.97, 1.03, "ATM"), (0.88, 0.93, "~10% OTM"), (0.83, 0.88, "~15% OTM")]:
s = band(puts, mlo, mhi, dlo, dhi)
if len(s) == 0:
print(f" {tn:<10s}{ml:<14s}{'':>9s}{'':>9s}{'':>9s}{0:>7d}{'NO':>9s}")
continue
prem = s["prem_pct"].median()
permonth = prem * 30.0 / s["dte"].median()
print(f" {tn:<10s}{ml:<14s}{prem:>8.2f}%{permonth:>8.2f}%{s['spread_pct'].median():>8.1f}%"
f"{len(s):>7d}{'SI':>9s}")
# skew: IV put 10% OTM vs IV call 10% OTM (stesso tenor mensile)
pv = band(puts, 0.88, 0.93, 12, 50)["iv"].median()
cv = band(calls, 1.07, 1.12, 12, 50)["iv"].median()
atmv = band(ch, 0.98, 1.02, 12, 50)["iv"].median()
if pd.notna(pv) and pd.notna(cv):
print(f" SKEW: IV put 10%OTM {pv:.0f}% vs call 10%OTM {cv:.0f}% vs ATM {atmv:.0f}%"
f" -> skew put {pv-cv:+.0f} pt vol (>0 = put care = paura del crash prezzata)")
def main():
market_snapshots_analysis()
for a in ("BTC", "ETH"):
chain_analysis(a)
print("\n" + "=" * 90)
print(" NB: finestra ~6 settimane, REGIME UNICO calmo -> livelli REALI misurabili, ma NESSUN")
print(" edge su opzioni è validabile OOS qui. Vedi commento finale.")
print("=" * 90)
if __name__ == "__main__":
main()
+158
View File
@@ -0,0 +1,158 @@
"""FASE 1 — triage dei 2 superstiti su BTC/ETH, sull'harness onesto (research_lab).
Sul feed pulito solo SH01 (shape-ML) e frammenti HONEST mostravano segnale residuo. Delle
HONEST solo DIP (dip-reversion) è testabile su BTC/ETH (TR01/ROT02 richiedono alt esclusi).
Qui ri-implemento DIP e SH01-shape-ML come SERIE DI POSIZIONE e li passo ai gate onesti
(FULL/OOS-VAL, vs buy&hold, null p-value, sweep fee, griglia). Hold-out 2025+ resta BLOCCATO.
uv run python scripts/analysis/phase1_survivors.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 sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from src.data.downloader import load_data
from scripts.analysis.research_lab import (
backtest, buy_hold, mc_pvalue, report, VAL_START, HOLDOUT_START, FEE_RT,
)
# ----------------------------- DIP reversion (long-only) -----------------------------
def dip_signal(df, n=50, k=2.0, z_exit=0.0, max_bars=72):
"""Long-only: entra (pos=1) quando lo z-score causale del prezzo vs MA(n) <= -k (dip),
esce quando z>=z_exit o dopo max_bars. Decisione a close[i] (z[i] usa close[i]), guadagna
close[i]->close[i+1]. Niente fill su estremi di candela."""
c = df["close"].values.astype(float)
s = pd.Series(c)
ma = s.rolling(n).mean().values
sd = s.rolling(n).std().values
z = np.where(sd > 0, (c - ma) / sd, np.nan)
pos = np.zeros(len(c))
inpos = False
held = 0
for i in range(len(c)):
if not inpos:
if not np.isnan(z[i]) and z[i] <= -k:
inpos, held = True, 0
pos[i] = 1.0
else:
held += 1
if (not np.isnan(z[i]) and z[i] >= z_exit) or held >= max_bars:
inpos = False # esce al close[i]: pos[i]=0
else:
pos[i] = 1.0
return pos
# ----------------------------- SH01 shape-ML (walk-forward) -----------------------------
def _shape_features(df, W):
"""~12 feature di FORMA causali per barra, dalla finestra che termina a i (usa solo <=i)."""
o = df["open"].values.astype(float); h = df["high"].values.astype(float)
l = df["low"].values.astype(float); c = df["close"].values.astype(float)
s = pd.Series(c)
ret1 = s.pct_change()
rng = (h - l) / np.where(c > 0, c, np.nan)
body = (c - o) / np.where(h - l > 0, h - l, np.nan)
up_sh = (h - np.maximum(o, c)) / np.where(h - l > 0, h - l, np.nan)
dn_sh = (np.minimum(o, c) - l) / np.where(h - l > 0, h - l, np.nan)
# RSI(14)
d = s.diff()
gain = d.clip(lower=0).rolling(14).mean()
loss = (-d.clip(upper=0)).rolling(14).mean()
rsi = 100 - 100 / (1 + gain / loss.replace(0, np.nan))
hi_w = pd.Series(h).rolling(W).max(); lo_w = pd.Series(l).rolling(W).min()
feat = {
"mom_w": s / s.shift(W) - 1.0, # rendimento sulla finestra
"mom_half": s / s.shift(W // 2) - 1.0, # accelerazione
"vol_w": ret1.rolling(W).std(),
"rsi": rsi / 100.0,
"ma_dist": (c - s.rolling(W).mean()) / s.rolling(W).std(),
"pos_in_range": (c - lo_w) / (hi_w - lo_w).replace(0, np.nan), # dove sta il close nel range W
"range": pd.Series(rng).rolling(3).mean(),
"body": pd.Series(body).rolling(3).mean(),
"up_shadow": pd.Series(up_sh).rolling(3).mean(),
"dn_shadow": pd.Series(dn_sh).rolling(3).mean(),
"ret1": ret1,
"skew_w": ret1.rolling(W).skew(),
}
X = pd.DataFrame(feat).values
return X
def shape_ml_signal(df, W=24, H=12, th=0.55, refit=750, warmup=3000, long_short=True):
"""LogisticRegression walk-forward sulla forma. Label = segno del rendimento a H barre.
Al tempo di decisione i si allena SOLO su campioni j con esito già realizzato (j+H <= i):
strettamente causale, nessun leak. Rifit ogni `refit` barre (velocità). pos = +1 se
P(up)>th, -1 se P(up)<1-th (long_short), altrimenti 0."""
c = df["close"].values.astype(float)
n = len(c)
X = _shape_features(df, W)
fwd = np.full(n, np.nan)
fwd[:n - H] = c[H:] / c[:n - H] - 1.0
y = (fwd > 0).astype(float)
valid = ~np.isnan(X).any(axis=1)
pos = np.zeros(n)
model = scaler = None
start = max(warmup, W + H + 200)
for i in range(start, n):
if model is None or (i - start) % refit == 0:
# campioni di training: feature valide E label realizzata entro i (j+H <= i)
tr = np.where(valid & (np.arange(n) + H <= i) & (np.arange(n) >= W))[0]
tr = tr[tr < i - H]
if len(tr) >= 500 and len(np.unique(y[tr])) == 2:
scaler = StandardScaler().fit(X[tr])
model = LogisticRegression(max_iter=200, C=1.0).fit(scaler.transform(X[tr]), y[tr])
if model is not None and valid[i]:
p_up = float(model.predict_proba(scaler.transform(X[i:i + 1]))[0, 1])
pos[i] = 1.0 if p_up > th else (-1.0 if (long_short and p_up < 1 - th) else 0.0)
return pos
# ----------------------------------- run -----------------------------------
def main():
TF = "1h"
print("=" * 90)
print(f" FASE 1 — triage superstiti su BTC/ETH {TF} | netto fee 0.10% RT | hold-out {HOLDOUT_START}+ BLOCCATO")
print("=" * 90)
data = {a: load_data(a, TF) for a in ("BTC", "ETH")}
# ---------- DIP: griglia robustezza (plateau?) ----------
print("\n" + "#" * 90)
print(" DIP reversion (long-only) — griglia FULL Sharpe (plateau = robusto, picco = overfit)")
print("#" * 90)
GRID = [(n, k) for n in (30, 50, 100) for k in (1.5, 2.0, 2.5)]
for a in ("BTC", "ETH"):
df = data[a]
print(f"\n {a}: " + " ".join(
f"n{n}k{k}{backtest(df, dip_signal(df, n=n, k=k), TF).sharpe:>5.2f}" for n, k in GRID))
# report onesto sulla config centrale
for a in ("BTC", "ETH"):
report(f"DIP {a} (n50 k2.0)", data[a], dip_signal(data[a], n=50, k=2.0), TF)
# ---------- SH01 shape-ML: config record + paio di varianti ----------
print("\n" + "#" * 90)
print(" SH01 shape-ML (walk-forward LogReg) — long/short")
print("#" * 90)
for a in ("BTC", "ETH"):
df = data[a]
pos = shape_ml_signal(df, W=24, H=12, th=0.55, long_short=True)
report(f"SH-ML {a} (W24 H12 th.55 L/S)", df, pos, TF)
# variante long-only (meno fee)
pos_lo = shape_ml_signal(df, W=24, H=12, th=0.55, long_short=False)
report(f"SH-ML {a} (W24 H12 th.55 LONG-only)", df, pos_lo, TF)
print("\n" + "=" * 90)
print(" VERDETTO: un edge è REALE solo se FULL e OOS-VAL Sharpe > 0, regge il sweep fee,")
print(" e BATTE il null (p<0.05). Altrimenti = rumore, si chiude.")
print("=" * 90)
if __name__ == "__main__":
main()
+221
View File
@@ -0,0 +1,221 @@
"""FASE 2 — esplorazione larga per famiglie su BTC/ETH, harness onesto (research_lab).
Famiglie (serie di posizione, causali, netto fee, vs buy&hold + null p-value):
TSMOM (momentum) | REVERSAL | MA-cross | DONCHIAN breakout | VOL-TARGET overlay |
LEAD-LAG BTC<->ETH | HURST-gated momentum. Multi-TF dove sensato (1h + 15m).
La barra DA BATTERE è il buy&hold (Sharpe ~0.8 su BTC/ETH): una strategia di timing vale solo
se fa MEGLIO net-fee. Per ogni famiglia: scan griglia (FULL Sharpe), poi report onesto sulla
config migliore. Selezionare il best-di-griglia GONFIA -> i gate veri sono OOS-VAL + null p<0.05.
uv run python scripts/analysis/phase2_families.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 scripts.analysis.research_lab import (
backtest, buy_hold, mc_pvalue, window_mask, ts, VAL_START, HOLDOUT_START, BARS_PER_YEAR,
)
# --------------------------------- famiglie ---------------------------------
def tsmom(df, L, mode="ls"):
c = pd.Series(df["close"].values.astype(float))
pos = np.sign(np.nan_to_num((c / c.shift(L) - 1).values))
return np.maximum(pos, 0) if mode == "lo" else pos
def reversal(df, L, mode="ls"):
c = pd.Series(df["close"].values.astype(float))
pos = -np.sign(np.nan_to_num((c / c.shift(L) - 1).values))
return np.maximum(pos, 0) if mode == "lo" else pos
def ma_cross(df, fast, slow, mode="ls"):
c = pd.Series(df["close"].values.astype(float))
ef = c.ewm(span=fast, adjust=False).mean()
es = c.ewm(span=slow, adjust=False).mean()
pos = np.sign((ef - es).values)
return np.maximum(pos, 0) if mode == "lo" else pos
def donchian(df, L, mode="ls"):
h = pd.Series(df["high"].values.astype(float)).rolling(L).max().shift(1).values
l = pd.Series(df["low"].values.astype(float)).rolling(L).min().shift(1).values
c = df["close"].values.astype(float)
pos = np.zeros(len(c)); cur = 0
for i in range(len(c)):
if not np.isnan(h[i]) and c[i] > h[i]:
cur = 1
elif not np.isnan(l[i]) and c[i] < l[i]:
cur = -1 if mode == "ls" else 0
pos[i] = cur
return pos
def vol_target(df, tf, target=0.6, L=72):
"""Overlay SEMPRE-LONG con esposizione scalata dalla vol realizzata (target vol annua)."""
c = pd.Series(df["close"].values.astype(float))
rv_ann = c.pct_change().rolling(L).std().values * np.sqrt(BARS_PER_YEAR[tf])
pos = np.clip(np.nan_to_num(target / np.where(rv_ann > 0, rv_ann, np.nan), nan=0.0), 0, 1)
return pos
def rolling_hurst(c, W=120, step=6, lags=(2, 4, 8, 16, 32)):
logc = np.log(c); n = len(c); H = np.full(n, np.nan)
lg = np.log(lags)
for i in range(W, n, step):
seg = logc[i - W:i]
tau = [np.std(seg[lag:] - seg[:-lag]) for lag in lags]
if min(tau) > 0:
H[i] = np.polyfit(lg, np.log(tau), 1)[0]
return pd.Series(H).ffill().fillna(0.5).values
def hurst_mom(df, L=48, W=120, mode="ls"):
H = rolling_hurst(df["close"].values.astype(float), W)
return np.where(H > 0.5, tsmom(df, L, mode), 0.0)
def leadlag_df(target_df, other_df, L):
"""Costruisce un df col close del TARGET e la posizione = segno del rendimento a L barre
dell'ALTRO asset (allineato per timestamp). Ritorna (df_merged, pos)."""
a = target_df[["timestamp", "open", "high", "low", "close"]]
b = other_df[["timestamp", "close"]].rename(columns={"close": "other"})
m = a.merge(b, on="timestamp", how="inner").reset_index(drop=True)
o = pd.Series(m["other"].values.astype(float))
pos = np.sign(np.nan_to_num((o / o.shift(L) - 1).values))
return m, pos
# --------------------------------- reporting ---------------------------------
ROWS = []
def summarize(family, asset, tf, df, pos, mc_n=300):
full = backtest(df, pos, tf)
oos = backtest(df, pos, tf, lo=VAL_START, hi=HOLDOUT_START)
bh = buy_hold(df, tf)
gross = backtest(df, pos, tf, fee_rt=0.0).sharpe
_, p, _, _ = mc_pvalue(df, pos, tf, n=mc_n)
beats_bh = full.sharpe > bh.sharpe and oos.sharpe > 0
real = (full.sharpe > 0 and oos.sharpe > 0 and not np.isnan(p) and p < 0.05)
verdict = "★EDGE?" if (real and beats_bh) else ("real?" if real else "rumore")
ROWS.append(dict(fam=family, asset=asset, tf=tf, full=full.sharpe, oos=oos.sharpe,
gross=gross, bh=bh.sharpe, p=p, trd=full.ntrades, verdict=verdict))
print(f" {family:<16s} {asset} {tf:<3s} | FULL {full.sharpe:>5.2f} OOS {oos.sharpe:>5.2f} "
f"gross {gross:>5.2f} | B&H {bh.sharpe:>4.2f} | p {p:>.3f} | trd/y {full.ntrades:>6.0f} | {verdict}")
def scan_best(family, asset, tf, df, fn, grid, label_fn):
"""Scansiona la griglia (FULL Sharpe), stampa la riga compatta, ritorna la pos migliore."""
best = None
line = []
for params in grid:
pos = fn(df, *params)
s = backtest(df, pos, tf).sharpe
line.append(f"{label_fn(params)}={s:>4.1f}")
if best is None or s > best[0]:
best = (s, params, pos)
print(f" {asset} {tf} grid: " + " ".join(line))
return best[2], best[1]
def main():
print("=" * 100)
print(" FASE 2 — esplorazione famiglie BTC/ETH | netto fee 0.10% RT | barra = buy&hold | hold-out bloccato")
print("=" * 100)
D1 = {a: load_data(a, "1h") for a in ("BTC", "ETH")}
D15 = {a: load_data(a, "15m") for a in ("BTC", "ETH")}
def block(title):
print("\n" + "#" * 100 + f"\n {title}\n" + "#" * 100)
# ---- TSMOM (momentum) 1h + 15m, L/S e long-only ----
block("TSMOM (momentum)")
Ls = [(12,), (24,), (48,), (96,), (192,)]
for a in ("BTC", "ETH"):
pos, p = scan_best("TSMOM-LS", a, "1h", D1[a], lambda d, L: tsmom(d, L, "ls"), Ls, lambda x: f"L{x[0]}")
summarize("TSMOM-LS", a, "1h", D1[a], pos)
pos, p = scan_best("TSMOM-LO", a, "1h", D1[a], lambda d, L: tsmom(d, L, "lo"), Ls, lambda x: f"L{x[0]}")
summarize("TSMOM-LO", a, "1h", D1[a], pos)
pos, p = scan_best("TSMOM-LS", a, "15m", D15[a], lambda d, L: tsmom(d, L, "ls"), [(48,),(96,),(192,),(384,)], lambda x: f"L{x[0]}")
summarize("TSMOM-LS", a, "15m", D15[a], pos)
# ---- REVERSAL 1h + 15m ----
block("REVERSAL (mean-reversion breve)")
Lr = [(1,), (3,), (6,), (12,), (24,)]
for a in ("BTC", "ETH"):
pos, p = scan_best("REV-LS", a, "1h", D1[a], lambda d, L: reversal(d, L, "ls"), Lr, lambda x: f"L{x[0]}")
summarize("REV-LS", a, "1h", D1[a], pos)
pos, p = scan_best("REV-LS", a, "15m", D15[a], lambda d, L: reversal(d, L, "ls"), Lr, lambda x: f"L{x[0]}")
summarize("REV-LS", a, "15m", D15[a], pos)
# ---- MA cross ----
block("MA-CROSS (trend)")
g = [(12, 48), (24, 96), (48, 192), (24, 200)]
for a in ("BTC", "ETH"):
pos, p = scan_best("MAX-LS", a, "1h", D1[a], lambda d, f, s: ma_cross(d, f, s, "ls"), g, lambda x: f"{x[0]}/{x[1]}")
summarize("MAX-LS", a, "1h", D1[a], pos)
pos, p = scan_best("MAX-LO", a, "1h", D1[a], lambda d, f, s: ma_cross(d, f, s, "lo"), g, lambda x: f"{x[0]}/{x[1]}")
summarize("MAX-LO", a, "1h", D1[a], pos)
# ---- Donchian breakout ----
block("DONCHIAN breakout")
Ld = [(24,), (48,), (96,), (192,)]
for a in ("BTC", "ETH"):
pos, p = scan_best("DONCH-LS", a, "1h", D1[a], lambda d, L: donchian(d, L, "ls"), Ld, lambda x: f"L{x[0]}")
summarize("DONCH-LS", a, "1h", D1[a], pos)
pos, p = scan_best("DONCH-LO", a, "1h", D1[a], lambda d, L: donchian(d, L, "lo"), Ld, lambda x: f"L{x[0]}")
summarize("DONCH-LO", a, "1h", D1[a], pos)
# ---- Vol-target overlay (vs buy&hold) ----
block("VOL-TARGET overlay (sempre-long scalato) — riduce la vol/DD del buy&hold?")
for a in ("BTC", "ETH"):
pos, p = scan_best("VOLTGT", a, "1h", D1[a], lambda d, t: vol_target(d, "1h", t, 72),
[(0.4,), (0.6,), (0.8,), (1.0,)], lambda x: f"t{x[0]}")
summarize("VOLTGT", a, "1h", D1[a], pos)
# ---- Hurst-gated momentum ----
block("HURST-gated momentum (momentum solo in regime trending H>0.5)")
for a in ("BTC", "ETH"):
pos, p = scan_best("HURST-MOM", a, "1h", D1[a], lambda d, L: hurst_mom(d, L, 120, "ls"),
[(24,), (48,), (96,)], lambda x: f"L{x[0]}")
summarize("HURST-MOM", a, "1h", D1[a], pos)
# ---- Lead-lag BTC<->ETH ----
block("LEAD-LAG BTC<->ETH (posiziona un asset col rendimento passato dell'altro)")
for tgt, oth in (("ETH", "BTC"), ("BTC", "ETH")):
Ll = [1, 3, 6, 12, 24]
best = None; line = []
for L in Ll:
m, pos = leadlag_df(D1[tgt], D1[oth], L)
s = backtest(m, pos, "1h").sharpe
line.append(f"L{L}={s:>4.1f}")
if best is None or s > best[0]:
best = (s, L, m, pos)
print(f" {oth}->{tgt} 1h grid: " + " ".join(line))
_, L, m, pos = best
summarize(f"LL {oth}>{tgt}", tgt, "1h", m, pos)
# ---- classifica finale ----
print("\n" + "=" * 100)
print(" CLASSIFICA — net-fee FULL Sharpe (★EDGE? = batte B&H, OOS>0 e null p<0.05)")
print("=" * 100)
for r in sorted(ROWS, key=lambda r: -r["full"]):
print(f" {r['fam']:<16s} {r['asset']} {r['tf']:<3s} | FULL {r['full']:>5.2f} | OOS {r['oos']:>5.2f} | "
f"B&H {r['bh']:>4.2f} | p {r['p']:>.3f} | {r['verdict']}")
edges = [r for r in ROWS if r["verdict"] == "★EDGE?"]
print(f"\n Candidati che battono il buy&hold net-fee + OOS>0 + null p<0.05: {len(edges)}")
for r in edges:
print(f" -> {r['fam']} {r['asset']} {r['tf']}: FULL {r['full']:.2f} OOS {r['oos']:.2f} p {r['p']:.3f}")
if __name__ == "__main__":
main()
+152
View File
@@ -0,0 +1,152 @@
"""FASE 3 — conferma avversariale del SOLO candidato reale: trend-following long-only (MA-cross).
Protocollo onesto:
1. SELEZIONE config SOLO sul pre-hold-out (< 2025-01-01). Niente sbirciate al hold-out.
2. HOLD-OUT 2025-26 sbloccato UNA volta (la prova del nove, mai usato in ricerca).
3. Breakdown PER ANNO vs buy&hold: il trend-LO deve "schivare" i bear (2018/2022).
4. STRESS: fee 2x, lag di esecuzione (1 barra), slippage.
5. DEFLATED SHARPE (Bailey & López de Prado): lo Sharpe regge alla correzione per multiple-testing?
uv run python scripts/analysis/phase3_confirm.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np
import pandas as pd
from scipy.stats import norm, skew, kurtosis
from src.data.downloader import load_data
from scripts.analysis.research_lab import (
backtest, buy_hold, window_mask, ts, _net_series, HOLDOUT_START, BARS_PER_YEAR,
)
from scripts.analysis.phase2_families import ma_cross
GRID = [(12, 48), (24, 96), (48, 192), (24, 200), (96, 288)] # MA-cross griglia (fast/slow)
REPR = (24, 96) # config rappresentativa PRE-COMMITTATA
TF = "1h"
def lag(pos, k=1):
"""Esecuzione in ritardo di k barre (agisci k barre dopo la decisione)."""
return np.concatenate([np.zeros(k), pos[:-k]])
def per_year(df, pos, tf):
c = df["close"].values.astype(float)
net, _, fwd, _ = _net_series(df, pos)
yrs = ts(df).dt.year.values
out = {}
for y in sorted(set(yrs)):
m = yrs == y
if m.sum() < 2:
continue
strat = float(np.prod(1 + net[m]) - 1) * 100
bh = float(np.prod(1 + fwd[m]) - 1) * 100
expo = float(np.mean(np.abs(pos[m])))
out[y] = (strat, bh, expo)
return out
def deflated_sharpe(net, sr_trials_perbar, N):
"""DSR: prob. che il vero Sharpe > la soglia attesa-massima sotto N trial (multiple testing).
Tutto in Sharpe PER BARRA. >0.95 = significativo dopo correzione."""
sr = net.mean() / net.std()
T = len(net)
g3 = float(skew(net)); g4 = float(kurtosis(net, fisher=False))
var_sr = float(np.var(sr_trials_perbar, ddof=1)) if len(sr_trials_perbar) > 1 else 0.0
ge = 0.5772156649
z1 = norm.ppf(1 - 1.0 / N); z2 = norm.ppf(1 - 1.0 / (N * np.e))
sr0 = np.sqrt(var_sr) * ((1 - ge) * z1 + ge * z2) # Sharpe atteso-massimo sotto null, N trial
den = np.sqrt(max(1 - g3 * sr + (g4 - 1) / 4.0 * sr ** 2, 1e-9))
dsr = float(norm.cdf((sr - sr0) * np.sqrt(T - 1) / den))
bpy = BARS_PER_YEAR[TF]
return dsr, sr * np.sqrt(bpy), sr0 * np.sqrt(bpy)
def main():
print("=" * 96)
print(" FASE 3 — conferma avversariale: TREND-following long-only (MA-cross) BTC/ETH")
print("=" * 96)
data = {a: load_data(a, TF) for a in ("BTC", "ETH")}
# ---------- 1) selezione SOLO pre-hold-out ----------
print(f"\n (1) SELEZIONE su pre-hold-out (< {HOLDOUT_START}) — Sharpe per config (plateau = robusto)")
for a in ("BTC", "ETH"):
line = []
for f, s in GRID:
pos = ma_cross(data[a], f, s, "lo")
sh = backtest(data[a], pos, TF, hi=HOLDOUT_START).sharpe
line.append(f"{f}/{s}={sh:>4.2f}")
print(f" {a}: " + " ".join(line))
print(f" -> config rappresentativa PRE-COMMITTATA per i test seguenti: {REPR[0]}/{REPR[1]}")
# ---------- 2) HOLD-OUT 2025-26 (sbloccato una volta) ----------
print(f"\n (2) HOLD-OUT {HOLDOUT_START}+ — LA PROVA DEL NOVE (mai usato in ricerca)")
for a in ("BTC", "ETH"):
bh = buy_hold(data[a], TF, lo=HOLDOUT_START)
print(f" {a}: buy&hold hold-out Sh {bh.sharpe:>5.2f} ret {bh.ret*100:>+7.1f}% DD {bh.maxdd*100:>4.1f}%")
for f, s in GRID:
pos = ma_cross(data[a], f, s, "lo")
r = backtest(data[a], pos, TF, lo=HOLDOUT_START)
star = " <-REPR" if (f, s) == REPR else ""
print(f" {f}/{s:<3d} Sh {r.sharpe:>5.2f} ret {r.ret*100:>+7.1f}% DD {r.maxdd*100:>4.1f}% expo {r.exposure:.2f}{star}")
# ---------- 3) per anno vs buy&hold (schiva i bear?) ----------
print(f"\n (3) PER ANNO — strat {REPR[0]}/{REPR[1]} vs buy&hold (expo = quanto è long; bear test 2018/2022)")
for a in ("BTC", "ETH"):
pos = ma_cross(data[a], *REPR, "lo")
py = per_year(data[a], pos, TF)
print(f" {a}:")
for y, (st, bh, ex) in py.items():
flag = " <- BEAR" if bh < -20 else ""
print(f" {y}: strat {st:>+7.0f}% | buy&hold {bh:>+7.0f}% | expo {ex:.2f}{flag}")
# ---------- 4) stress ----------
print(f"\n (4) STRESS — strat {REPR[0]}/{REPR[1]} | FULL e HOLD-OUT Sharpe")
print(f" {'scenario':<24s}{'BTC FULL':>10s}{'BTC HO':>9s}{'ETH FULL':>10s}{'ETH HO':>9s}")
scen = [
("base fee0.10%", dict(fee_rt=0.001), False),
("fee 0.20% (2x)", dict(fee_rt=0.002), False),
("lag 1 barra", dict(fee_rt=0.001), True),
("fee2x + lag", dict(fee_rt=0.002), True),
]
for name, kw, do_lag in scen:
row = [name]
for a in ("BTC", "ETH"):
pos = ma_cross(data[a], *REPR, "lo")
if do_lag:
pos = lag(pos, 1)
full = backtest(data[a], pos, TF, **kw).sharpe
ho = backtest(data[a], pos, TF, lo=HOLDOUT_START, **kw).sharpe
row += [f"{full:>9.2f}", f"{ho:>8.2f}"]
print(f" {row[0]:<24s}{row[1]:>10s}{row[2]:>9s}{row[3]:>10s}{row[4]:>9s}")
# ---------- 5) deflated Sharpe ----------
print(f"\n (5) DEFLATED SHARPE — corregge il multiple-testing (DSR>0.95 = regge)")
# trial set = TUTTE le config trend long-only provate (proxy del numero di tentativi)
N_TRIALS = 60 # stima conservativa dei backtest provati in Fase 2 (tutte le famiglie/asset/TF)
for a in ("BTC", "ETH"):
trials = [backtest(data[a], ma_cross(data[a], f, s, "lo"), TF, hi=HOLDOUT_START) for f, s in GRID]
sr_trials = []
for f, s in GRID:
net, _, _, _ = _net_series(data[a], ma_cross(data[a], f, s, "lo"))
m = window_mask(data[a], hi=HOLDOUT_START)
sr_trials.append(net[m].mean() / net[m].std())
net, _, _, _ = _net_series(data[a], ma_cross(data[a], *REPR, "lo"))
m = window_mask(data[a], hi=HOLDOUT_START)
dsr, sr_ann, sr0_ann = deflated_sharpe(net[m], sr_trials, N_TRIALS)
verdict = "REGGE" if dsr > 0.95 else "NON regge"
print(f" {a} (pre-hold-out): Sharpe {sr_ann:.2f} vs soglia-max-attesa(N={N_TRIALS}) {sr0_ann:.2f} "
f"-> DSR {dsr:.3f} [{verdict}]")
print("\n" + "=" * 96)
print(" VERDETTO: edge ONESTO solo se (2) hold-out positivo, (3) schiva i bear, (4) regge lo")
print(" stress, (5) DSR>0.95. Altrimenti: anche il trend era sample-luck del mercato toro.")
print("=" * 96)
if __name__ == "__main__":
main()
+192
View File
@@ -0,0 +1,192 @@
"""HARNESS DI RICERCA ONESTO — BTC/ETH, v2.0.0 (Fase 0).
Dopo che l'intera libreria precedente si è rivelata artefatto di feed/harness disonesti,
la prima cosa di cui fidarsi NON è una strategia ma il banco di prova. Questo modulo è
quel banco: causale per costruzione, netto fee, con baseline e null model.
MODELLO CANONICO = SERIE DI POSIZIONE.
Una strategia è una funzione signal(df, **params) -> pd.Series/np.array che dà la
posizione target per barra in [-1, +1]. REGOLA: position[i] è decisa con dati FINO a
close[i] (mai oltre) e GUADAGNA il rendimento close[i] -> close[i+1]. L'engine moltiplica
position[i] * fwd[i] (fwd strettamente futuro rispetto alla decisione) -> niente look-ahead
per costruzione, e niente fill sull'estremo di candela (si entra al close). La fee è
addebitata sul TURNOVER |Δposition| (un round-trip 0->1->0 = 2 unità = fee_rt intera).
GATE (vedi CLAUDE.md): ingresso eseguibile (qui per costruzione), netto fee 0.10% RT,
OOS held-out, robustezza su griglia, onestà statistica (null model + buy&hold), walk-forward
per i modelli fittati, liquidità (BTC/ETH ok).
uv run python scripts/analysis/research_lab.py # self-test del banco
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
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
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}
# 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).
VAL_START = "2023-01-01"
def ts(df) -> pd.Series:
return pd.to_datetime(df["timestamp"], unit="ms", utc=True)
def window_mask(df, lo: str | None = None, hi: str | None = None) -> np.ndarray:
t = ts(df)
m = np.ones(len(df), bool)
if lo is not None:
m &= (t >= pd.Timestamp(lo, tz="UTC")).values
if hi is not None:
m &= (t < pd.Timestamp(hi, tz="UTC")).values
return m
@dataclass
class BT:
n: int
ret: float # rendimento composto sulla finestra (pos 1x, leva 1x)
cagr: float
sharpe: float # annualizzato
maxdd: float # % (positivo)
exposure: float # |pos| medio
turnover: float # Σ|Δpos| / anno
ntrades: float # round-trip equivalenti / anno
def line(self, label="") -> str:
return (f" {label:<22s} Sh {self.sharpe:>6.2f} | ret {self.ret*100:>+8.1f}% "
f"CAGR {self.cagr*100:>+6.1f}% | DD {self.maxdd*100:>5.1f}% | "
f"expo {self.exposure:>4.2f} trd/y {self.ntrades:>6.1f} | n {self.n}")
def _net_series(df, position, fee_rt=FEE_RT):
"""Ritorna (net, gross, fwd, pos) per barra. net[i] = pos[i]*fwd[i] - fee sul cambio a i."""
c = df["close"].values.astype(float)
pos = np.nan_to_num(np.asarray(position, float), nan=0.0)
pos = np.clip(pos, -1.0, 1.0)
n = len(c)
fwd = np.zeros(n)
fwd[:-1] = c[1:] / c[:-1] - 1.0 # rendimento close[i]->close[i+1] (futuro vs decisione a i)
gross = pos * fwd
dpos = np.abs(np.diff(np.concatenate([[0.0], pos]))) # cambio di posizione a i (si tradea al close[i])
fee = dpos * (fee_rt / 2.0) # fee_rt = round-trip (2 unità di turnover); /2 per unità
net = gross - fee
return net, gross, fwd, pos
def backtest(df, position, tf="1h", fee_rt=FEE_RT, lo=None, hi=None) -> BT:
net, gross, fwd, pos = _net_series(df, position, fee_rt)
m = window_mask(df, lo, hi)
net_w, pos_w = net[m], pos[m]
dpos_w = np.abs(np.diff(np.concatenate([[0.0], pos_w])))
bpy = BARS_PER_YEAR[tf]
n = int(m.sum())
if n < 2:
return BT(n, 0, float("nan"), 0, 0, 0, 0, 0)
eq = np.cumprod(1.0 + net_w)
total = float(eq[-1] - 1.0)
years = n / bpy
cagr = float((1 + total) ** (1 / years) - 1) if years > 0 and total > -1 else float("nan")
mu, sd = float(net_w.mean()), float(net_w.std())
sharpe = mu / sd * np.sqrt(bpy) if sd > 0 else 0.0
peak = np.maximum.accumulate(eq)
maxdd = float(np.max((peak - eq) / peak)) if n else 0.0
expo = float(np.mean(np.abs(pos_w)))
turn_y = float(dpos_w.sum() / years) if years > 0 else 0.0
return BT(n, total, cagr, sharpe, maxdd, expo, turn_y, turn_y / 2.0)
def buy_hold(df, tf="1h", fee_rt=FEE_RT, lo=None, hi=None) -> BT:
return backtest(df, np.ones(len(df)), tf, fee_rt, lo, hi)
def mc_pvalue(df, position, tf="1h", fee_rt=FEE_RT, n=500, lo=None, hi=None, seed=0):
"""Null model a ROTAZIONE CIRCOLARE: ruota la serie di posizione di un offset casuale.
Preserva ESATTAMENTE exposure, turnover e distribuzione degli holding; distrugge solo
l'allineamento col mercato. p = P(Sharpe_ruotato >= Sharpe_reale). p alto = il timing
non batte il caso (nessuna skill)."""
pos = np.nan_to_num(np.asarray(position, float))
base = backtest(df, pos, tf, fee_rt, lo, hi).sharpe
N = len(pos)
if np.abs(np.diff(pos)).sum() == 0: # posizione costante -> rotazione degenere
return base, float("nan"), float("nan"), float("nan")
rng = np.random.default_rng(seed)
sims = np.empty(n)
for k in range(n):
off = int(rng.integers(1, N))
sims[k] = backtest(df, np.roll(pos, off), tf, fee_rt, lo, hi).sharpe
p = float((np.sum(sims >= base) + 1) / (n + 1))
return base, p, float(sims.mean()), float(sims.std())
def report(name, df, position, tf="1h", fee_rt=FEE_RT, mc_n=400):
"""Stampa il verdetto onesto: FULL / OOS-VAL / vs buy&hold / null p-value / sweep fee."""
print(f"\n === {name} ({tf}) ===")
print(backtest(df, position, tf, fee_rt).line("FULL"))
print(backtest(df, position, tf, fee_rt, lo=VAL_START, hi=HOLDOUT_START).line(f"OOS-VAL {VAL_START[:4]}-24"))
print(buy_hold(df, tf, fee_rt).line("buy&hold FULL"))
base, p, msh, ssd = mc_pvalue(df, position, tf, fee_rt, n=mc_n)
verdict = "RUMORE" if (np.isnan(p) or p > 0.05) else "batte il null"
print(f" null (rotazione, n={mc_n}): Sharpe reale {base:.2f} vs random {msh:.2f}±{ssd:.2f} "
f"-> p={p if not np.isnan(p) else float('nan'):.3f} [{verdict}]")
print(" sweep fee RT:", " ".join(
f"{f*100:.2f}%→Sh{backtest(df, position, tf, f).sharpe:.2f}" for f in (0.0, 0.0005, 0.001, 0.002)))
# ============================ SELF-TEST DEL BANCO ============================
def self_test():
"""Valida l'HARNESS, non una strategia. Tre prove:
(1) buy&hold: Sharpe positivo, DD grande (sanity dei numeri).
(2) CHEAT look-ahead (pos = segno del rendimento FUTURO): Sharpe enorme, p≈0
-> l'engine SA vedere un edge quando esiste davvero.
(3) NOISE causale (pos da rumore del passato): Sharpe≈0, p≈0.5
-> l'engine NON inventa edge dal nulla (niente leak)."""
print("=" * 78)
print(" SELF-TEST HARNESS — deve: vedere il cheat, NON vedere il rumore")
print("=" * 78)
df = load_data("BTC", "1h")
t = ts(df)
c = df["close"].values.astype(float)
bh = buy_hold(df, "1h")
print(bh.line("(1) buy&hold BTC"))
assert bh.sharpe > 0, "buy&hold dovrebbe avere Sharpe>0 sullo storico BTC"
# (2) CHEAT: posizione = segno del rendimento del prossimo bar (USA IL FUTURO)
fwd = np.zeros(len(c)); fwd[:-1] = c[1:] / c[:-1] - 1.0
cheat = np.sign(fwd)
bt_cheat = backtest(df, cheat, "1h")
_, p_cheat, _, _ = mc_pvalue(df, cheat, "1h", n=200, seed=1)
print(bt_cheat.line("(2) CHEAT look-ahead"))
print(f" -> null p={p_cheat:.4f} (atteso ≈0: l'edge finto È enorme e battibile dal caso ~mai)")
assert bt_cheat.sharpe > 20, "il cheat dovrebbe dare Sharpe enorme se l'engine è corretto"
assert p_cheat < 0.02, "il cheat dovrebbe battere il null in modo schiacciante"
# (3) NOISE causale a BASSO turnover (blocchi ~50 barre): isola la SKILL dalla fee-death.
# Posizione casuale (non usa il futuro) tenuta a blocchi -> turnover basso -> se l'engine non
# inventa edge dal nulla, Sharpe≈0 e il null p≈0.5 (random rotazioni indistinguibili).
rng = np.random.default_rng(42)
blk = 50
raw = np.sign(rng.standard_normal(len(c) // blk + 1))
noise_pos = np.repeat(raw, blk)[:len(c)]
noise_pos = pd.Series(noise_pos).shift(1).fillna(0).values # solo passato
bt_noise = backtest(df, noise_pos, "1h")
base_n, p_noise, msh, ssd = mc_pvalue(df, noise_pos, "1h", n=400, seed=2)
print(bt_noise.line("(3) NOISE causale"))
print(f" -> null p={p_noise:.3f} (atteso alto/≈0.5: nessuna skill, indistinguibile dal caso)")
assert bt_noise.sharpe < 2.0, "il rumore causale non deve sembrare SKILLATO (Sharpe positivo grande = leak)"
assert p_noise > 0.10, "il rumore causale non deve battere il null (p basso = edge spurio/leak)"
print("\n ✓ HARNESS VALIDATO: vede il cheat (Sharpe enorme, p≈0), non inventa edge dal rumore (p alto).")
print(f" Hold-out finale BLOCCATO da {HOLDOUT_START} (non usato in ricerca). OOS-VAL: {VAL_START}→hold-out.")
if __name__ == "__main__":
self_test()
+121
View File
@@ -0,0 +1,121 @@
"""STRESS-TEST di TP01 (integrato da strategy-research-2026-06) — robustezza avversariale.
Usa il modulo VERO integrato (src/strategies/trend_portfolio). Oltre a hold-out/cross-asset/multi-TF
(gia' in verify_tp01.py), qui: sweep FEE (fino 0.40% RT), LAG di esecuzione + slippage, PLATEAU dei
parametri (config cherry-picked?), DEFLATED-SHARPE (multiple-testing track A-E).
uv run python scripts/analysis/stress_tp01.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np
import pandas as pd
from scipy.stats import norm, skew, kurtosis
from src.data.downloader import load_data
from src.strategies.trend_portfolio import TrendPortfolio, resample_4h, simple_returns, CANONICAL
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
DF4H = {a: resample_4h(load_data(a, "1h")) for a in ("BTC", "ETH")}
def combo(cfg, lag_bars=0, fee_side=0.0005):
"""Rendimenti per-barra del portafoglio 50/50 con config cfg, lag extra e fee dati."""
tp = TrendPortfolio(**{**cfg, "fee_side": fee_side})
series = {}
for a in ("BTC", "ETH"):
df = DF4H[a]
r = simple_returns(df["close"].values.astype(float))
tgt = tp.target_series(df)
held = np.zeros(len(tgt))
s = 1 + lag_bars
held[s:] = tgt[:-s] # tenuta = decisa s barre prima (causale + lag)
net = held * r - 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 0.5 * J["BTC"].values + 0.5 * J["ETH"].values, J.index
def met(combo_r, idx):
rr = combo_r[np.isfinite(combo_r)]
if len(rr) < 2 or np.std(rr) == 0:
return dict(sh=0, ret=0, dd=0)
bpy = 86400 * 365.25 / pd.Series(idx).diff().dt.total_seconds().median()
eq = np.cumprod(1 + rr); pk = np.maximum.accumulate(eq)
return dict(sh=float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)),
ret=float(eq[-1] - 1), dd=float(np.max((pk - eq) / pk)))
def full_ho(cfg, lag_bars=0, fee_side=0.0005):
cr, idx = combo(cfg, lag_bars, fee_side)
ho = idx >= HOLDOUT
return met(cr, idx), met(cr[ho], idx[ho])
def main():
print("=" * 88)
print(" STRESS-TEST TP01 (PORT LF4h canonica) — robustezza avversariale")
print("=" * 88)
base_f, base_h = full_ho(CANONICAL)
print(f"\n BASELINE (4h, fee 0.10% RT): FULL Sh {base_f['sh']:.2f} ret {base_f['ret']*100:+.0f}% DD {base_f['dd']*100:.1f}%"
f" | HOLD-OUT Sh {base_h['sh']:.2f} ret {base_h['ret']*100:+.1f}% DD {base_h['dd']*100:.1f}%")
print("\n (1) SWEEP FEE (RT) — regge fino a 0.40%?")
print(f" {'fee RT':<10s}{'FULL Sh':>9s}{'FULL ret':>10s}{'HOLD Sh':>9s}{'HOLD ret':>10s}")
for frt in (0.0, 0.001, 0.002, 0.004):
f, h = full_ho(CANONICAL, fee_side=frt / 2)
print(f" {frt*100:>5.2f}% {f['sh']:>8.2f}{f['ret']*100:>+9.0f}%{h['sh']:>9.2f}{h['ret']*100:>+9.1f}%")
print("\n (2) LAG di esecuzione + slippage (fee 0.20% per simulare slippage)")
print(f" {'scenario':<22s}{'FULL Sh':>9s}{'HOLD Sh':>9s}{'HOLD ret':>10s}")
for name, lag, frt in [("base", 0, 0.001), ("lag 1 barra (4h)", 1, 0.001),
("lag 2 barre", 2, 0.001), ("lag1 + fee0.20% slip", 1, 0.002)]:
f, h = full_ho(CANONICAL, lag_bars=lag, fee_side=frt / 2)
print(f" {name:<22s}{f['sh']:>8.2f}{h['sh']:>9.2f}{h['ret']*100:>+9.1f}%")
print("\n (3) PLATEAU PARAMETRI — la config canonica e' un picco o un altopiano?")
print(f" {'variazione':<26s}{'FULL Sh':>9s}{'HOLD Sh':>9s}")
grid = [
("canonica (vt.20 lev2 30/90/180 vw30)", CANONICAL),
("target_vol 0.15", {**CANONICAL, "target_vol": 0.15}),
("target_vol 0.25", {**CANONICAL, "target_vol": 0.25}),
("leverage 1.5", {**CANONICAL, "leverage": 1.5}),
("leverage 3.0", {**CANONICAL, "leverage": 3.0}),
("horizons 20/60/120", {**CANONICAL, "horizons_days": (20, 60, 120)}),
("horizons 60/120/240", {**CANONICAL, "horizons_days": (60, 120, 240)}),
("vol_win 20", {**CANONICAL, "vol_win_days": 20}),
("vol_win 45", {**CANONICAL, "vol_win_days": 45}),
]
sr_trials = []
for name, cfg in grid:
f, h = full_ho(cfg)
cr, idx = combo(cfg)
sr_trials.append(cr[np.isfinite(cr)].mean() / cr[np.isfinite(cr)].std()) # Sharpe per-barra
print(f" {name:<26s}{f['sh']:>8.2f}{h['sh']:>9.2f}")
print("\n (4) DEFLATED SHARPE — corregge il multiple-testing (track A-E + sweep). DSR>0.95 = regge")
cr, idx = combo(CANONICAL)
rr = cr[np.isfinite(cr)]
sr = rr.mean() / rr.std(); T = len(rr)
g3 = float(skew(rr)); g4 = float(kurtosis(rr, fisher=False))
var_sr = float(np.var(sr_trials, ddof=1))
ge = 0.5772156649
for N in (10, 40, 100): # N = numero di trial/config provati (conservativo)
z1 = norm.ppf(1 - 1.0 / N); z2 = norm.ppf(1 - 1.0 / (N * np.e))
sr0 = np.sqrt(var_sr) * ((1 - ge) * z1 + ge * z2)
den = np.sqrt(max(1 - g3 * sr + (g4 - 1) / 4.0 * sr ** 2, 1e-9))
dsr = float(norm.cdf((sr - sr0) * np.sqrt(T - 1) / den))
bpy = 86400 * 365.25 / pd.Series(idx).diff().dt.total_seconds().median()
print(f" N={N:>3d} trial -> soglia-max-attesa Sh {sr0*np.sqrt(bpy):.2f} | DSR {dsr:.3f} [{'REGGE' if dsr>0.95 else 'NON regge'}]")
print("\n" + "=" * 88)
print(" Verdetto: TP01 robusto se regge fee 0.40%+lag (HOLD positivo), plateau (no picco), DSR>0.95.")
print("=" * 88)
if __name__ == "__main__":
main()
+96
View File
@@ -0,0 +1,96 @@
"""TP01 a BASSA FREQUENZA (>=12h) — ri-verifica dopo il bug look-ahead ffill-mixed-TF.
L'utente/agente ha trovato un look-ahead (ffill mixed-timeframe su barre open-labeled) che
gonfiava il 4h (~1.60 -> reale ~1.1) e ha concluso: NON scendere sotto le 12h (costi+overfit
dominano). Qui ricalcolo TP01 in modo PULITO per singolo TF (barre discrete, posizione shiftata
+1, NESSUN ffill/combine mixed-TF) su 4h/12h/1d, con un GUARD di causalita' esplicito sulla serie
resamplata (ricalcolo su prefisso). Fee 0.10% RT, hold-out 2025-26 bloccato.
uv run python scripts/analysis/tp01_lowfreq.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 TrendPortfolio, simple_returns, CANONICAL
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
def resample_tf(df_1h, rule):
g = df_1h.copy()
g.index = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
out = g.resample(rule, label="left", closed="left").agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}).dropna(subset=["open"])
out["datetime"] = out.index
return out.reset_index(drop=True)
def sleeve_net(df, tp):
"""Per-barra netto di uno sleeve: posizione decisa a close[i-1], tenuta in i (causale, no ffill)."""
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
return np.clip(net, -0.99, None)
def causality_ok(df, tp, k=10):
"""Ricalcola target_series su prefissi e verifica che tgt[i] non cambi (no look-ahead)."""
full = tp.target_series(df); n = len(df)
rng = np.random.default_rng(0); bad = 0
for i in rng.integers(int(n * 0.6), n - 1, size=k):
p = tp.target_series(df.iloc[:i + 1].copy())
if len(p) != i + 1 or not np.isclose(np.nan_to_num(p[i]), np.nan_to_num(full[i]), atol=1e-9):
bad += 1
return bad
def met(rr, idx):
rr = rr[np.isfinite(rr)]
if len(rr) < 2 or np.std(rr) == 0:
return dict(sh=0, ret=0, dd=0, n=len(rr))
bpy = 86400 * 365.25 / pd.Series(idx).diff().dt.total_seconds().median()
eq = np.cumprod(1 + rr); pk = np.maximum.accumulate(eq)
return dict(sh=float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)), ret=float(eq[-1] - 1),
dd=float(np.max((pk - eq) / pk)), n=len(rr))
def main():
print("=" * 92)
print(" TP01 RI-VERIFICA BASSA FREQUENZA — calcolo pulito per-TF (no ffill mixed-TF) | fee 0.10% RT")
print("=" * 92)
tp = TrendPortfolio(**CANONICAL)
print(f" {'TF':<5s}{'leak':>6s}{'FULL Sh':>9s}{'FULL ret':>10s}{'FULL DD':>9s}{'HOLD Sh':>9s}{'HOLD ret':>10s}{'HOLD DD':>9s}")
for tf, rule in [("4h", "4h"), ("6h", "6h"), ("12h", "12h"), ("1d", "1D")]:
series = {}; leak = 0
for a in ("BTC", "ETH"):
df = resample_tf(load_data(a, "1h"), rule)
leak += causality_ok(df, tp)
series[a] = pd.Series(sleeve_net(df, tp), index=pd.to_datetime(df["datetime"]))
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
combo = 0.5 * J["BTC"].values + 0.5 * J["ETH"].values
idx = J.index; ho = idx >= HOLDOUT
f = met(combo, idx); h = met(combo[ho], idx[ho])
print(f" {tf:<5s}{leak:>6d}{f['sh']:>9.2f}{f['ret']*100:>+9.0f}%{f['dd']*100:>8.1f}%"
f"{h['sh']:>9.2f}{h['ret']*100:>+9.1f}%{h['dd']*100:>8.1f}%")
# buy&hold 50/50 a 1d come riferimento hold-out
bh = {}
for a in ("BTC", "ETH"):
df = resample_tf(load_data(a, "1h"), "1D")
bh[a] = pd.Series(simple_returns(df["close"].values.astype(float)), index=pd.to_datetime(df["datetime"]))
Jb = pd.concat(bh, axis=1, join="inner").fillna(0.0)
cb = 0.5 * Jb["BTC"].values + 0.5 * Jb["ETH"].values; ix = Jb.index; ho = ix >= HOLDOUT
bhf = met(cb, ix); bhh = met(cb[ho], ix[ho])
print(f"\n buy&hold 50/50 (1d): FULL Sh {bhf['sh']:.2f} ret {bhf['ret']*100:+.0f}% DD {bhf['dd']*100:.0f}%"
f" | HOLD-OUT Sh {bhh['sh']:.2f} ret {bhh['ret']*100:+.0f}% DD {bhh['dd']*100:.0f}%")
print("\n (leak=0 = nessun look-ahead nel calcolo per-TF. Confronta con la tesi: >=12h trustworthy.)")
if __name__ == "__main__":
main()
+149
View File
@@ -0,0 +1,149 @@
"""VERIFICA AVVERSARIALE di TP01 (branch strategy-research-2026-06) col MIO gauntlet onesto.
TP01 = TSMOM multi-orizzonte (30/90/180g) long-flat, vol-target 20%, leva cap 2x, portafoglio
50/50 BTC+ETH. Codice riprodotto VERBATIM dal branch (src/strategies/trend_portfolio.py).
La loro tesi: 'positiva ogni anno 2019-2026, Sharpe ~1.32'. Il mio test decisivo: il HOLD-OUT
2025-26 (che ha bocciato il mio trend 1h in Fase 3) + cross-asset + multi-TF (cherry-picking 4h?).
uv run python scripts/analysis/verify_tp01.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
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
CANONICAL = dict(target_vol=0.20, leverage=2.0, long_only=True,
horizons_days=(30, 90, 180), vol_win_days=30, fee_side=0.0005)
# ---- TP01 riprodotto VERBATIM dal branch ----
def simple_returns(c):
r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0; return r
def realized_vol(r, win, bpy):
return pd.Series(r).rolling(win, min_periods=win // 2).std().values * np.sqrt(bpy)
def tsmom_blend(c, horizons):
n = len(c); acc = np.zeros(n); cnt = np.zeros(n)
for h in horizons:
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 target_series(df, p, bpd):
c = df["close"].values.astype(float); bpy = bpd * 365.25
r = simple_returns(c)
vol = realized_vol(r, p["vol_win_days"] * bpd, bpy)
direction = tsmom_blend(c, tuple(d * bpd for d in p["horizons_days"]))
if p["long_only"]:
direction = np.clip(direction, 0, None)
scal = np.where((vol > 0) & np.isfinite(vol), p["target_vol"] / vol, 0.0)
tgt = np.clip(direction * scal, -p["leverage"], p["leverage"]); tgt[~np.isfinite(tgt)] = 0.0
return tgt
def net_returns(df, p, bpd):
c = df["close"].values.astype(float); r = simple_returns(c)
tgt = target_series(df, p, bpd)
pos_held = np.zeros(len(tgt)); pos_held[1:] = tgt[:-1] # decisa a close[t-1], tenuta in t -> causale
gross = pos_held * r
turn = np.abs(np.diff(pos_held, prepend=0.0))
net = gross - p["fee_side"] * turn; net[0] = 0.0
return np.clip(net, -0.99, None), pos_held
def resample(df_1h, rule):
g = df_1h.copy(); idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True); g.index = idx
out = g.resample(rule, label="left", closed="left").agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}).dropna(subset=["open"])
out["timestamp"] = out.index
return out.reset_index(drop=True)
def metrics(combo, idx):
rr = combo[np.isfinite(combo)]
if len(rr) < 2 or np.std(rr) == 0:
return dict(sharpe=0, cagr=0, dd=0, ret=0, n=len(rr))
dt = pd.Series(idx).diff().dt.total_seconds().median()
bpy = 86400 * 365.25 / dt
eq = np.cumprod(1 + rr); peak = np.maximum.accumulate(eq)
years = (idx[-1] - idx[0]).total_seconds() / 86400 / 365.25
return dict(sharpe=float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)),
cagr=float(eq[-1] ** (1 / years) - 1) if years > 0 else 0,
dd=float(np.max((peak - eq) / peak)), ret=float(eq[-1] - 1), n=len(rr))
def portfolio_combo(tf_rule, bpd):
series = {}
for a in ("BTC", "ETH"):
df = load_data(a, "1h")
if tf_rule:
df = resample(df, tf_rule)
net, _ = net_returns(df, CANONICAL, bpd)
series[a] = pd.Series(net, index=pd.to_datetime(df["timestamp"], unit="ms", utc=True) if not tf_rule
else pd.DatetimeIndex(df["timestamp"]))
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
combo = 0.5 * J["BTC"].values + 0.5 * J["ETH"].values
return combo, J.index, J
def line(label, combo, idx):
m = metrics(combo, idx)
return f" {label:<22s} Sharpe {m['sharpe']:>5.2f} | ret {m['ret']*100:>+8.1f}% CAGR {m['cagr']*100:>+6.1f}% | DD {m['dd']*100:>5.1f}% | n {m['n']}"
def main():
print("=" * 92)
print(" VERIFICA TP01 (TSMOM 30/90/180 vol-target 20% lev2x long-flat, 50/50 BTC+ETH)")
print(" col gauntlet onesto: FULL vs buy&hold | HOLD-OUT 2025-26 bloccato | per-anno | multi-TF")
print("=" * 92)
TFS = [("15m", "15min", 96), ("1h", None, 24), ("4h", "4h", 6), ("1d", "1D", 1)]
print("\n (A) MULTI-TF: il 4h e' cherry-picked? FULL + HOLD-OUT per ogni timeframe")
for tf, rule, bpd in TFS:
combo, idx, J = portfolio_combo(rule, bpd)
ho = idx >= HOLDOUT
full = metrics(combo, idx)
hold = metrics(combo[ho], idx[ho])
tag = " <- canonica" if tf == "4h" else ""
print(f" {tf:<3s} FULL Sh {full['sharpe']:>5.2f} CAGR {full['cagr']*100:>+6.1f}% DD {full['dd']*100:>4.1f}% "
f"| HOLD-OUT Sh {hold['sharpe']:>5.2f} ret {hold['ret']*100:>+6.1f}% DD {hold['dd']*100:>4.1f}%{tag}")
# focus 4h canonica
combo, idx, J = portfolio_combo("4h", 6)
print("\n (B) 4h CANONICA — per anno (la tesi: positiva OGNI anno 2019-2026)")
s = pd.Series(combo, index=idx)
for y, g in s.groupby(s.index.year):
eq = np.cumprod(1 + g.values); pk = np.maximum.accumulate(eq)
ho_flag = " <- HOLD-OUT (mai usato per scegliere config?)" if y >= 2025 else ""
print(f" {y}: ret {(eq[-1]-1)*100:>+7.1f}% DD {np.max((pk-eq)/pk)*100:>5.1f}%{ho_flag}")
print("\n (C) HOLD-OUT 2025-26 — TP01 vs buy&hold 50/50 (4h)")
ho = idx >= HOLDOUT
print(line("TP01 portfolio HO", combo[ho], idx[ho]))
# buy&hold 50/50 sullo stesso indice/finestra
bh = {}
for a in ("BTC", "ETH"):
df = resample(load_data(a, "1h"), "4h")
r = simple_returns(df["close"].values.astype(float))
bh[a] = pd.Series(r, index=pd.DatetimeIndex(df["timestamp"]))
Jb = pd.concat(bh, axis=1, join="inner").reindex(idx).fillna(0.0)
bh_combo = 0.5 * Jb["BTC"].values + 0.5 * Jb["ETH"].values
print(line("buy&hold 50/50 HO", bh_combo[ho], idx[ho]))
print(line("TP01 portfolio FULL", combo, idx))
print(line("buy&hold 50/50 FULL", bh_combo, idx))
print("\n (D) CROSS-ASSET nel HOLD-OUT (lo stesso edge regge su ENTRAMBI?)")
for a in ("BTC", "ETH"):
df = resample(load_data(a, "1h"), "4h")
net, _ = net_returns(df, CANONICAL, 6)
ix = pd.DatetimeIndex(df["timestamp"]); m = ix >= HOLDOUT
print(line(f"TP01 {a} sleeve HO", net[m], ix[m]))
if __name__ == "__main__":
main()
View File
+191
View File
@@ -0,0 +1,191 @@
"""PAPER TRADER — TP01 Trend Portfolio (PORT LF1d), forward-only, simulato.
Esegue la strategia VINCENTE (src/strategies/trend_portfolio.py, config CANONICAL) in
paper trading FORWARD-ONLY su capitale virtuale (default 2000 USDT), portafoglio 50/50
BTC+ETH a 1d. Stato persistente -> resume al riavvio.
DESIGN (onesto, niente esecuzione reale: l'esecuzione e' DISABILITATA nel progetto):
- Legge i parquet certificati locali (data/raw, BTC/ETH 1h) e resampla a 1d.
- Alla prima esecuzione parte dall'ultima barra 1d CHIUSA disponibile (forward-only:
NON include lo storico nel PnL di paper, traccia solo da ora in avanti).
- Ad ogni run processa le NUOVE barre 1d chiuse dall'ultima volta: applica il rendimento
della posizione tenuta, addebita le fee sul turnover, registra i trade sui cambi di
posizione, poi ricalcola la posizione-bersaglio (decisa con dati <= ultima barra chiusa).
- Per avere barre fresche, aggiornare prima i dati:
uv run python scripts/analysis/rebuild_history.py --asset BTC ETH
Stato: data/paper_trend/state.json + trades.jsonl (append-only).
uv run python scripts/live/paper_trend.py # avanza il paper col dato disponibile
uv run python scripts/live/paper_trend.py --status # solo stato, non avanza
uv run python scripts/live/paper_trend.py --reset # azzera lo stato (riparte da ora)
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.backtest.harness import load
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_1d, simple_returns
STATE_DIR = PROJECT_ROOT / "data" / "paper_trend"
STATE_FILE = STATE_DIR / "state.json"
TRADES_FILE = STATE_DIR / "trades.jsonl"
ASSETS = ["BTC", "ETH"]
WEIGHT = 0.5
INITIAL_CAPITAL = 2000.0
def build_bars() -> dict[str, pd.DataFrame]:
# Deploy a 1d (>=12h): sotto le 12h costi+overfit dominano (vedi trend_portfolio docstring + bug ffill mixed-TF).
return {a: resample_1d(load(a, "1h")) for a in ASSETS}
def load_state() -> dict | None:
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text())
return None
def save_state(st: dict):
STATE_DIR.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(st, indent=2))
def append_trade(rec: dict):
STATE_DIR.mkdir(parents=True, exist_ok=True)
with open(TRADES_FILE, "a") as f:
f.write(json.dumps(rec) + "\n")
def init_state(dfs) -> dict:
last_ts = min(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS)
tp = TrendPortfolio(**CANONICAL)
positions = {}
for a in ASSETS:
df = dfs[a]
df = df[df["timestamp"] <= last_ts]
positions[a] = tp.current_target(df)
return dict(
capital=INITIAL_CAPITAL, initial_capital=INITIAL_CAPITAL,
start_ts=last_ts, last_ts=last_ts, positions=positions, n_bars=0,
peak=INITIAL_CAPITAL, max_dd=0.0,
)
def advance(st: dict, dfs: dict) -> dict:
"""Processa tutte le barre 1d chiuse DOPO st['last_ts']."""
tp = TrendPortfolio(**CANONICAL)
# precompute per-asset: timestamps, returns, target series (causale)
data = {}
for a in ASSETS:
df = dfs[a]
c = df["close"].values.astype(float)
data[a] = dict(
ts=df["timestamp"].values.astype("int64"),
dt=pd.to_datetime(df["datetime"]).values,
r=simple_returns(c),
tgt=tp.target_series(df),
)
# common new timestamps after last_ts (present in both assets)
common = sorted(set(data["BTC"]["ts"]).intersection(data["ETH"]["ts"]))
new_ts = [t for t in common if t > st["last_ts"]]
if not new_ts:
return st
pos = dict(st["positions"])
cap = st["capital"]
peak = st.get("peak", cap)
max_dd = st.get("max_dd", 0.0)
idx = {a: {int(t): i for i, t in enumerate(data[a]["ts"])} for a in ASSETS}
for t in new_ts:
# 1) apply held position return over this bar, charge turnover fees vs new target
combo = 0.0
new_pos = {}
for a in ASSETS:
i = idx[a][int(t)]
r = float(data[a]["r"][i])
held = pos[a]
new_t = float(data[a]["tgt"][i])
turn = abs(new_t - held)
net = held * r - CANONICAL["fee_side"] * turn
combo += WEIGHT * net
new_pos[a] = new_t
# record a trade when the SIGN of position changes (entry/exit/flip)
if np.sign(new_t) != np.sign(held):
append_trade(dict(
ts=int(t), dt=str(pd.Timestamp(data[a]["dt"][i])),
asset=a, action="ENTRY" if new_t != 0 else "EXIT",
from_pos=round(held, 4), to_pos=round(new_t, 4),
capital=round(cap, 2),
))
cap *= (1.0 + max(combo, -0.99))
peak = max(peak, cap)
max_dd = max(max_dd, (peak - cap) / peak if peak > 0 else 0.0)
pos = new_pos
st.update(capital=cap, last_ts=int(new_ts[-1]), positions=pos,
n_bars=st.get("n_bars", 0) + len(new_ts), peak=peak, max_dd=max_dd)
return st
def print_status(st: dict, dfs: dict):
start = pd.Timestamp(st["start_ts"], unit="ms", tz="UTC")
last = pd.Timestamp(st["last_ts"], unit="ms", tz="UTC")
days = (last - start).total_seconds() / 86400
cap = st["capital"]
ret = cap / st["initial_capital"] - 1
daily = (cap - st["initial_capital"]) / days if days > 0 else 0.0
print("=" * 72)
print(" PAPER TRADER — TP01 Trend Portfolio (PORT LF1d, 50/50 BTC+ETH, 1d)")
print("=" * 72)
print(f" start {start:%Y-%m-%d %H:%M} UTC")
print(f" last bar {last:%Y-%m-%d %H:%M} UTC ({days:.1f} giorni, {st['n_bars']} barre 1d)")
print(f" capitale {cap:,.2f} USDT (start {st['initial_capital']:,.0f})")
print(f" ritorno {ret*100:+.2f}% | €/giorno {daily:+.2f} | maxDD {st['max_dd']*100:.1f}%")
print(f" posizioni now { 'flat' if all(p==0 for p in st['positions'].values()) else '' }")
for a in ASSETS:
p = st["positions"][a]
state = "FLAT" if p == 0 else ("LONG" if p > 0 else "SHORT")
print(f" {a}: {state:<5s} target {p:+.3f}x (frazione di equity dello sleeve)")
# what the strategy decides at the latest available closed bar
print(" ── prossima decisione (ultima barra chiusa disponibile) ──")
tp = TrendPortfolio(**CANONICAL)
for a in ASSETS:
w = tp.current_target(dfs[a])
print(f" {a}: target {w:+.3f}x")
if TRADES_FILE.exists():
n = sum(1 for _ in open(TRADES_FILE))
print(f" trade registrati: {n} ({TRADES_FILE})")
def main():
argv = sys.argv[1:]
dfs = build_bars()
if "--reset" in argv:
if STATE_FILE.exists():
STATE_FILE.unlink()
if TRADES_FILE.exists():
TRADES_FILE.unlink()
print("stato azzerato.")
st = load_state()
if st is None:
st = init_state(dfs)
save_state(st)
print("paper trader inizializzato (forward-only da ora).\n")
elif "--status" not in argv:
st = advance(st, dfs)
save_state(st)
print_status(st, dfs)
if __name__ == "__main__":
main()
+320
View File
@@ -0,0 +1,320 @@
"""TRACK A — TREND / MOMENTUM research on certified BTC/ETH (Deribit mainnet).
Honest harness only (src.backtest.harness). Rules enforced:
* Direction & entry price decided with data <= close[i]; fill at close[i].
* Net of fees (0.10% RT baseline) + fee sweep + leverage stress.
* IS / OOS split (65/35). Grid robustness across params AND both assets.
Run: uv run python scripts/research/trackA_trend.py
This script is deliberately skeptical: it prints full grids so the reader can see
whether an "edge" is a single lucky cell or a robust neighborhood. The verdict at the
end is printed from the actual numbers, not asserted.
"""
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, backtest_signals, oos_split
ASSETS = ["BTC", "ETH"]
TFS = ["1h", "15m", "5m"]
FEE = 0.001
# ---------------------------------------------------------------------------
# Signal builders. Each returns a list[dict|None] of length len(df).
# All features use ONLY data up to and including close[i]. Entry fills at close[i].
# Position is approximated as a chained, non-overlapping hold of `hold` bars whose
# direction is recomputed at each (free) bar -> amortizes fee over `hold` bars while
# staying honest about responsiveness.
# ---------------------------------------------------------------------------
def sig_tsmom(df, lookback, hold, long_only=False):
c = df["close"].values
n = len(c)
ent = [None] * n
dirs = np.where(c[lookback:] > c[:-lookback], 1, -1)
for k, d in enumerate(dirs):
if long_only and d < 0:
continue
ent[lookback + k] = {"dir": int(d), "max_bars": hold}
return ent
def _ema(x, span):
return pd.Series(x).ewm(span=span, adjust=False).mean().values
def sig_ema_cross(df, fast, slow, hold, long_only=False):
c = df["close"].values
n = len(c)
ef = _ema(c, fast)
es = _ema(c, slow)
ent = [None] * n
for i in range(slow, n):
d = 1 if ef[i] > es[i] else -1
if long_only and d < 0:
ent[i] = None
continue
ent[i] = {"dir": d, "max_bars": hold}
return ent
def sig_donchian(df, lookback, hold, long_only=False):
"""Breakout: close[i] strictly above prior `lookback` highs -> long; below lows -> short.
Detection AND entry both at close[i] (honest)."""
c = df["close"].values
h = df["high"].values
l = df["low"].values
n = len(c)
ent = [None] * n
# prior-window high/low EXCLUDING current bar (shift by 1) -> honest
hh = pd.Series(h).rolling(lookback).max().shift(1).values
ll = pd.Series(l).rolling(lookback).min().shift(1).values
for i in range(lookback, n):
if not np.isfinite(hh[i]):
continue
if c[i] > hh[i]:
d = 1
elif c[i] < ll[i]:
d = -1
else:
continue
if long_only and d < 0:
continue
ent[i] = {"dir": d, "max_bars": hold}
return ent
def sig_vol_scaled_tsmom(df, lookback, hold, vol_win, z_gate):
"""Momentum gated by trend strength: only take a position when |past return| exceeds
z_gate * rolling stdev of bar returns (regime gate). Honest: all <= close[i]."""
c = df["close"].values
n = len(c)
logret = np.zeros(n)
logret[1:] = np.diff(np.log(c))
vol = pd.Series(logret).rolling(vol_win).std().values
ent = [None] * n
start = max(lookback, vol_win) + 1
for i in range(start, n):
r = np.log(c[i] / c[i - lookback])
v = vol[i] * np.sqrt(lookback)
if not np.isfinite(v) or v == 0:
continue
z = r / v
if abs(z) < z_gate:
continue
d = 1 if z > 0 else -1
ent[i] = {"dir": d, "max_bars": hold}
return ent
# ---------------------------------------------------------------------------
# Evaluation helpers
# ---------------------------------------------------------------------------
def eval_is_oos(df, entries, asset, tf, fee=FEE, lev=1.0):
cut = oos_split(df, 0.65)
full = backtest_signals(df, entries, fee_rt=fee, leverage=lev, asset=asset, tf=tf)
ent_is = [e if i < cut else None for i, e in enumerate(entries)]
ent_oos = [e if i >= cut else None for i, e in enumerate(entries)]
m_is = backtest_signals(df, ent_is, fee_rt=fee, leverage=lev, asset=asset, tf=tf)
m_oos = backtest_signals(df, ent_oos, fee_rt=fee, leverage=lev, asset=asset, tf=tf)
return full, m_is, m_oos
def buy_hold(df, cut=None):
c = df["close"].values
if cut is None:
cut = oos_split(df, 0.65)
return c[-1] / c[0] - 1, c[-1] / c[cut] - 1 # (full, oos)
def print_benchmarks():
print("\n" + "=" * 110)
print("# BUY & HOLD BENCHMARK (the bar any long/short trend edge must clear)")
print("# NOTE: OOS window is the LAST 35% = ~late-2023 -> 2026, a single (mostly bull) regime.")
print("# 2018-2022 (bear+crash+bull+bear) is ENTIRELY in-sample. 'positive OOS' is weak evidence.")
print("=" * 110)
for tf in TFS:
for asset in ASSETS:
df = load(asset, tf)
cut = oos_split(df, 0.65)
bf, bo = buy_hold(df, cut)
print(f" {asset} {tf:>3s} OOS starts {df['datetime'].iloc[cut].date()} "
f"B&H full={bf*100:>+7.0f}% B&H OOS={bo*100:>+7.0f}%")
def line(label, m):
print(f" {label:<30s} tr={m.n_trades:>6d} wr={m.win_rate:>4.1f}% "
f"ret={m.net_return*100:>+8.0f}% CAGR={m.cagr*100:>+6.1f}% "
f"Sh={m.sharpe:>5.2f} DD={m.max_dd*100:>4.1f}% mkt={m.time_in_market*100:>3.0f}% "
f"€/d={m.daily_profit(2000):>+6.2f}")
# ---------------------------------------------------------------------------
# Experiments
# ---------------------------------------------------------------------------
def run_grid(name, builder, param_grid, builder_kwargs_fn, tfs=TFS, assets=ASSETS):
"""Generic grid runner. Prints OOS-focused table. Returns list of result dicts."""
print("\n" + "=" * 110)
print(f"# {name}")
print("=" * 110)
results = []
for tf in tfs:
for asset in assets:
df = load(asset, tf)
print(f"\n -- {asset} {tf} (n={len(df)}) --")
for params in param_grid:
ent = builder(df, **builder_kwargs_fn(params))
full, m_is, m_oos = eval_is_oos(df, ent, asset, tf)
tag = ",".join(f"{k}={v}" for k, v in params.items())
line(f"{tag} [OOS]", m_oos)
results.append(dict(name=name, asset=asset, tf=tf, params=params,
full=full, is_=m_is, oos=m_oos))
return results
def summarize_survivors(all_results):
print("\n" + "#" * 110)
print("# SURVIVOR SCREEN — positive OOS net return AND positive full-sample, Sharpe(OOS)>0")
print("#" * 110)
survivors = [r for r in all_results
if r["oos"].net_return > 0 and r["full"].net_return > 0
and r["oos"].sharpe > 0 and r["oos"].n_trades >= 20]
if not survivors:
print(" NONE. No config is net-positive OOS with positive full-sample and Sharpe>0.")
return []
survivors.sort(key=lambda r: r["oos"].sharpe, reverse=True)
# precompute B&H OOS per (asset,tf)
bh = {}
for tf in TFS:
for a in ASSETS:
bh[(a, tf)] = buy_hold(load(a, tf))[1]
print(" (BEATS B&H = OOS return exceeds buy&hold over same OOS window; otherwise it's just beta)")
for r in survivors[:40]:
tag = ",".join(f"{k}={v}" for k, v in r["params"].items())
bho = bh[(r["asset"], r["tf"])]
beat = "BEATS B&H" if r["oos"].net_return > bho else "<= B&H (beta)"
print(f" {r['name'][:18]:<18s} {r['asset']} {r['tf']:>3s} {tag:<28s} "
f"OOS: ret={r['oos'].net_return*100:>+7.0f}% Sh={r['oos'].sharpe:>4.2f} "
f"DD={r['oos'].max_dd*100:>4.0f}% €/d={r['oos'].daily_profit(2000):>+5.2f} | "
f"B&H={bho*100:>+5.0f}% {beat}")
return survivors
def robustness_report(survivors):
"""For top survivors, check fee sweep + leverage stress + cross-asset consistency."""
if not survivors:
return
print("\n" + "#" * 110)
print("# ROBUSTNESS: fee sweep (0.0005/0.001/0.0015/0.002) + leverage (1x/2x/3x) on top survivors")
print("#" * 110)
seen = set()
for r in survivors[:8]:
key = (r["name"], r["asset"], r["tf"], tuple(r["params"].items()))
if key in seen:
continue
seen.add(key)
df = load(r["asset"], r["tf"])
# rebuild entries
builder = BUILDERS[r["name"]]
ent = builder(df, **KW_FN[r["name"]](r["params"]))
tag = ",".join(f"{k}={v}" for k, v in r["params"].items())
print(f"\n {r['name']} {r['asset']} {r['tf']} {tag}")
print(" fee sweep (OOS net return):")
for fee in (0.0005, 0.001, 0.0015, 0.002):
_, _, m_oos = eval_is_oos(df, ent, r["asset"], r["tf"], fee=fee)
flag = "" if m_oos.net_return > 0 else " <-- DIES"
print(f" fee={fee:.4f}: OOS ret={m_oos.net_return*100:>+8.0f}% Sh={m_oos.sharpe:>4.2f}{flag}")
print(" leverage stress (OOS, fee=0.001):")
for lev in (1.0, 2.0, 3.0):
_, _, m_oos = eval_is_oos(df, ent, r["asset"], r["tf"], lev=lev)
print(f" {lev:.0f}x: OOS ret={m_oos.net_return*100:>+8.0f}% "
f"Sh={m_oos.sharpe:>4.2f} DD={m_oos.max_dd*100:>4.0f}% €/d={m_oos.daily_profit(2000):>+5.2f}")
# yearly OOS
_, _, m_oos = eval_is_oos(df, ent, r["asset"], r["tf"])
print(" OOS yearly:")
for y in sorted(m_oos.yearly):
print(f" {y}: {m_oos.yearly[y]*100:>+7.1f}%")
# registry so robustness_report can rebuild entries
BUILDERS = {
"TSMOM": sig_tsmom,
"TSMOM_LONG": sig_tsmom,
"EMA_CROSS": sig_ema_cross,
"DONCHIAN": sig_donchian,
"VOLSCALED_TSMOM": sig_vol_scaled_tsmom,
}
KW_FN = {
"TSMOM": lambda p: dict(lookback=p["N"], hold=p["H"]),
"TSMOM_LONG": lambda p: dict(lookback=p["N"], hold=p["H"], long_only=True),
"EMA_CROSS": lambda p: dict(fast=p["f"], slow=p["s"], hold=p["H"]),
"DONCHIAN": lambda p: dict(lookback=p["N"], hold=p["H"]),
"VOLSCALED_TSMOM": lambda p: dict(lookback=p["N"], hold=p["H"], vol_win=p["vw"], z_gate=p["z"]),
}
def main():
pd.set_option("display.width", 200)
print_benchmarks()
all_results = []
# ---- 1. TSMOM (long/short) ----
tsmom_grid = [dict(N=n, H=h) for n in (10, 20, 50, 100, 200) for h in (6, 12, 24, 48)]
all_results += run_grid("TSMOM", sig_tsmom, tsmom_grid,
KW_FN["TSMOM"])
# ---- 2. TSMOM long-only (crypto has strong upward drift; honest to test) ----
all_results += run_grid("TSMOM_LONG", lambda df, **k: sig_tsmom(df, long_only=True, **k),
[dict(N=n, H=h) for n in (20, 50, 100, 200) for h in (12, 24, 48)],
KW_FN["TSMOM"])
# ---- 3. EMA crossover ----
ema_grid = [dict(f=f, s=s, H=h)
for (f, s) in ((10, 30), (20, 50), (20, 100), (50, 200))
for h in (12, 24, 48)]
all_results += run_grid("EMA_CROSS", sig_ema_cross, ema_grid, KW_FN["EMA_CROSS"])
# ---- 4. Donchian breakout ----
don_grid = [dict(N=n, H=h) for n in (20, 50, 100, 200) for h in (12, 24, 48)]
all_results += run_grid("DONCHIAN", sig_donchian, don_grid, KW_FN["DONCHIAN"])
# ---- 5. Vol-scaled / regime-gated TSMOM ----
vs_grid = [dict(N=n, H=h, vw=vw, z=z)
for n in (20, 50, 100) for h in (24, 48)
for vw in (50, 100) for z in (0.5, 1.0)]
all_results += run_grid("VOLSCALED_TSMOM", sig_vol_scaled_tsmom, vs_grid,
KW_FN["VOLSCALED_TSMOM"])
# ---- survivor screen + robustness ----
survivors = summarize_survivors(all_results)
robustness_report(survivors)
# ---- cross-asset robustness note ----
print("\n" + "#" * 110)
print("# CROSS-ASSET / CROSS-TF CONSISTENCY of survivors (a real edge holds on BOTH BTC & ETH)")
print("#" * 110)
from collections import defaultdict
by_strat = defaultdict(list)
for r in survivors:
by_strat[(r["name"], r["tf"], tuple(r["params"].items()))].append(r["asset"])
both = [(k, v) for k, v in by_strat.items() if set(v) >= {"BTC", "ETH"}]
if not both:
print(" No single (strategy, tf, params) cell is an OOS survivor on BOTH BTC and ETH.")
print(" => any apparent edge is asset/regime-specific, not a robust trend edge.")
else:
for (name, tf, params), assets in both:
print(f" {name} {tf} {dict(params)} survives on: {assets}")
print("\nDONE. Read the survivor screen + robustness above for the honest verdict.")
if __name__ == "__main__":
main()
+398
View File
@@ -0,0 +1,398 @@
"""TRACK B — Machine-learning / feature-prediction on BTC & ETH (Deribit-certified).
Honest, strict walk-forward ML research. The whole point is to NOT repeat the death of
the old library (look-ahead). Everything here obeys:
* Features for bar i use ONLY data <= close[i] (all rolling windows are backward).
* Labels (sign of forward return over H bars) use close[i+H]; in walk-forward we only
train on samples whose label is FULLY realized in the past relative to the prediction
bar (a gap of H is enforced between train-end and the prediction block).
* Scaler + model are fit ONLY on past data, retrained periodically, never on the future.
* Net of fees (fee_rt sweep 0.0005 .. 0.002, baseline 0.001). Turnover reported.
* Grid over W (lookback for training), H (horizon), threshold, asset, tf.
* A final held-out segment (last HELD_OUT_FRAC) is NEVER used to choose configs;
configs are selected on the DEV portion, then confirmed once on the held-out tail.
Run: uv run python scripts/research/trackB_ml.py
uv run python scripts/research/trackB_ml.py --quick (smaller grid, faster)
uv run python scripts/research/trackB_ml.py --gbm (also try GradientBoosting)
Entry convention (harness): for a signalled bar i we open at close[i] in the predicted
direction and hold up to H bars (max_bars=H, no TP/SL) — a pure test of directional sign.
No-overlap is enforced by the harness, so trades are naturally spaced >= H bars.
"""
from __future__ import annotations
import argparse
import sys
import time
import warnings
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from src.backtest.harness import backtest_signals, load
warnings.filterwarnings("ignore")
HELD_OUT_FRAC = 0.25 # final tail reserved for confirmation only
RETRAIN_K = 250 # retrain every K bars (block prediction)
MIN_TRAIN = 400 # minimum usable training samples
# ---------------------------------------------------------------------------
# Feature engineering — ALL backward-looking (safe at close[i])
# ---------------------------------------------------------------------------
def _rsi(close: pd.Series, n: int = 14) -> pd.Series:
d = close.diff()
up = d.clip(lower=0).ewm(alpha=1 / n, adjust=False).mean()
dn = (-d.clip(upper=0)).ewm(alpha=1 / n, adjust=False).mean()
rs = up / dn.replace(0, np.nan)
return (100 - 100 / (1 + rs)).fillna(50.0)
def _atr(df: pd.DataFrame, n: int = 14) -> pd.Series:
h, l, c = df["high"], df["low"], df["close"]
pc = c.shift(1)
tr = pd.concat([(h - l), (h - pc).abs(), (l - pc).abs()], axis=1).max(axis=1)
return tr.ewm(alpha=1 / n, adjust=False).mean()
def build_features(df: pd.DataFrame) -> tuple[np.ndarray, list[str], np.ndarray]:
"""Return (X, names, warmup_valid_mask). Every column known at close[i]."""
c = df["close"].astype(float)
h = df["high"].astype(float)
l = df["low"].astype(float)
o = df["open"].astype(float)
v = df["volume"].astype(float)
logc = np.log(c)
feats: dict[str, pd.Series] = {}
# multi-lag simple returns (ret[i] uses close[i],close[i-k] -> known at i)
for k in (1, 2, 3, 6, 12, 24):
feats[f"ret{k}"] = c.pct_change(k)
# candle geometry (current bar fully known at its close)
rng = (h - l).replace(0, np.nan)
feats["body"] = (c - o) / rng
feats["upsh"] = (h - np.maximum(c, o)) / rng
feats["dnsh"] = (np.minimum(c, o) - l) / rng
feats["range_n"] = (h - l) / c
# one-lag candle geometry
feats["body1"] = ((c - o) / rng).shift(1)
# momentum/acceleration
feats["mom48"] = c.pct_change(48)
feats["accel"] = c.pct_change(6) - c.pct_change(12)
# RSI
feats["rsi14"] = _rsi(c, 14) / 100.0
# ATR-normalized extension from a trend baseline
ema = c.ewm(span=24, adjust=False).mean()
atr = _atr(df, 14)
feats["ext_atr"] = (c - ema) / atr.replace(0, np.nan)
# realized vol (std of 1-bar returns)
r1 = c.pct_change()
feats["rvol24"] = r1.rolling(24).std()
feats["rvol72"] = r1.rolling(72).std()
feats["vol_ratio"] = feats["rvol24"] / feats["rvol72"].replace(0, np.nan)
# position of close within recent window (0=low,1=high)
for w in (24, 72):
lo = l.rolling(w).min()
hi = h.rolling(w).max()
feats[f"pos{w}"] = (c - lo) / (hi - lo).replace(0, np.nan)
# volume z-score
vlog = np.log1p(v)
feats["volz"] = (vlog - vlog.rolling(72).mean()) / vlog.rolling(72).std().replace(0, np.nan)
names = list(feats.keys())
X = np.column_stack([feats[k].to_numpy(dtype=float) for k in names])
valid = np.isfinite(X).all(axis=1)
return X, names, valid
def forward_labels(df: pd.DataFrame, H: int):
"""label[i] = 1 if close[i+H] > close[i] else 0 ; fwd[i] = forward return."""
c = df["close"].to_numpy(float)
n = len(c)
fwd = np.full(n, np.nan)
fwd[: n - H] = c[H:] / c[: n - H] - 1.0
y = (fwd > 0).astype(float)
lab_valid = np.isfinite(fwd)
return y, fwd, lab_valid
# ---------------------------------------------------------------------------
# Strict walk-forward probability
# ---------------------------------------------------------------------------
def walk_forward_proba(X, y, feat_valid, lab_valid, warmup, W, H, K, model_factory):
"""Return proba_up[i] for all i (NaN where not predicted). No leakage:
when predicting block starting at b, training labels must be realized: i + H <= b-1,
i.e. train indices < b - H. Training window is the last W such indices."""
n = len(y)
proba = np.full(n, np.nan)
start = warmup + W + H
b = start
while b < n:
end_block = min(b + K, n)
train_hi = b - H # exclusive; ensures label realized by b-1
train_lo = max(warmup, train_hi - W)
idx = np.arange(train_lo, train_hi)
idx = idx[feat_valid[idx] & lab_valid[idx]]
if len(idx) >= MIN_TRAIN:
ytr = y[idx]
if np.unique(ytr).size == 2:
Xtr = X[idx]
sc = StandardScaler().fit(Xtr)
model = model_factory()
model.fit(sc.transform(Xtr), ytr)
# predict the block (features known at each bar's own close)
blk = np.arange(b, end_block)
fv = feat_valid[blk]
if fv.any():
pb = model.predict_proba(sc.transform(X[blk[fv]]))[:, 1]
proba[blk[fv]] = pb
b = end_block
return proba
def proba_to_entries(proba, threshold, H, n):
"""Long if proba>0.5+thr, short if proba<0.5-thr, else flat. Hold H bars."""
entries = [None] * n
hi = 0.5 + threshold
lo = 0.5 - threshold
for i in range(n):
p = proba[i]
if not np.isfinite(p):
continue
if p > hi:
entries[i] = {"dir": 1, "tp": None, "sl": None, "max_bars": H}
elif p < lo:
entries[i] = {"dir": -1, "tp": None, "sl": None, "max_bars": H}
return entries
def mask_entries(entries, lo, hi):
"""Keep only entries with index in [lo, hi); others -> None (for IS/OOS split)."""
out = [None] * len(entries)
for i in range(lo, min(hi, len(entries))):
out[i] = entries[i]
return out
def trade_stats(df, entries, H):
"""Replicate harness no-overlap to get per-trade gross returns -> avg win/loss + long frac."""
c = df["close"].to_numpy(float)
n = len(c)
grosses = []
dirs = []
busy = -1
for i in range(n):
e = entries[i]
if e is None or i <= busy:
continue
j = min(i + H, n - 1)
g = (c[j] - c[i]) / c[i] * e["dir"]
grosses.append(g)
dirs.append(e["dir"])
busy = j
g = np.array(grosses)
if len(g) == 0:
return 0, 0.0, 0.0, 0.0, 0.0
wins = g[g > 0]
losses = g[g <= 0]
avg_w = wins.mean() if len(wins) else 0.0
avg_l = losses.mean() if len(losses) else 0.0
long_frac = float(np.mean(np.array(dirs) > 0))
return len(g), avg_w, avg_l, g.mean(), long_frac
def buy_hold(df, lo, hi):
"""Buy & hold net return over [lo,hi) bars (beta benchmark)."""
c = df["close"].to_numpy(float)
hi = min(hi, len(c))
if hi - lo < 2:
return 0.0
return c[hi - 1] / c[lo] - 1.0
# ---------------------------------------------------------------------------
# Driver
# ---------------------------------------------------------------------------
def run():
ap = argparse.ArgumentParser()
ap.add_argument("--quick", action="store_true", help="smaller grid (faster)")
ap.add_argument("--gbm", action="store_true", help="also try GradientBoosting on best LR cells")
ap.add_argument("--tf", default="1h")
args = ap.parse_args()
assets = ["BTC", "ETH"]
tf = args.tf
if args.quick:
Ws = [8000]
Hs = [12, 24]
thresholds = [0.0, 0.05, 0.10]
else:
Ws = [4000, 8000, 16000]
Hs = [6, 12, 24, 48]
thresholds = [0.0, 0.03, 0.06, 0.10]
def lr_factory():
return LogisticRegression(C=1.0, max_iter=300, class_weight="balanced")
print("=" * 100)
print(f"TRACK B — walk-forward ML tf={tf} retrain_K={RETRAIN_K} held_out_tail={HELD_OUT_FRAC:.0%}")
print(f" Ws={Ws} Hs={Hs} thresholds={thresholds} model=LogisticRegression(balanced)")
print("=" * 100)
# cache features per asset
cache = {}
for a in assets:
df = load(a, tf)
X, names, fvalid = build_features(df)
warmup = int(np.argmax(fvalid)) if fvalid.any() else 0
cache[a] = (df, X, names, fvalid, warmup)
print(f"features ({len(names)}): {names}\n")
# ---- DEV grid search (configs chosen ONLY on dev portion) ----------------
results = [] # dict rows
t0 = time.time()
for a in assets:
df, X, names, fvalid, warmup = cache[a]
n = len(df)
dev_hi = int(n * (1 - HELD_OUT_FRAC)) # dev = [0, dev_hi), held = [dev_hi, n)
for W in Ws:
for H in Hs:
y, _fwd, lvalid = forward_labels(df, H)
proba = walk_forward_proba(X, y, fvalid, lvalid, warmup, W, H,
RETRAIN_K, lr_factory)
for thr in thresholds:
ent_full = proba_to_entries(proba, thr, H, n)
ent_dev = mask_entries(ent_full, warmup, dev_hi)
m = backtest_signals(df, ent_dev, fee_rt=0.001, asset=a, tf=tf)
nt, aw, al, gmean, lf = trade_stats(df, ent_dev, H)
results.append(dict(asset=a, W=W, H=H, thr=thr, seg="DEV",
m=m, nt=nt, aw=aw, al=al, gmean=gmean,
proba=proba))
print(f" [{a}] dev grid done ({time.time()-t0:.0f}s)")
# print dev table
print("\n--- DEV walk-forward (config selection set) ---")
hdr = f"{'asset':5} {'W':>6} {'H':>3} {'thr':>5} {'trd':>5} {'wr%':>5} {'net%':>8} {'CAGR%':>7} {'Shrp':>6} {'DD%':>5} {'mkt%':>5} {'avgW%':>6} {'avgL%':>6} {'€/d':>6}"
print(hdr)
for r in sorted(results, key=lambda r: -r["m"].sharpe):
m = r["m"]
print(f"{r['asset']:5} {r['W']:>6} {r['H']:>3} {r['thr']:>5.2f} {m.n_trades:>5} "
f"{m.win_rate:>5.1f} {m.net_return*100:>+8.1f} {m.cagr*100:>+7.1f} {m.sharpe:>6.2f} "
f"{m.max_dd*100:>5.1f} {m.time_in_market*100:>5.0f} {r['aw']*100:>+6.2f} {r['al']*100:>+6.2f} "
f"{m.daily_profit(2000):>+6.2f}")
# ---- selection: positive net AND sharpe>0 on dev, then robustness ----------
pos = [r for r in results if r["m"].net_return > 0 and r["m"].sharpe > 0 and r["m"].n_trades >= 30]
pos.sort(key=lambda r: -r["m"].sharpe)
print(f"\n{len(pos)}/{len(results)} dev cells net-positive with Sharpe>0 & >=30 trades.")
# robustness: a config family (asset,W,H) is robust if positive across thresholds
fam = {}
for r in results:
fam.setdefault((r["asset"], r["W"], r["H"]), []).append(r)
robust_fams = []
for key, rs in fam.items():
npos = sum(1 for r in rs if r["m"].net_return > 0 and r["m"].sharpe > 0)
if npos >= max(2, int(0.6 * len(rs))):
robust_fams.append((key, npos, len(rs)))
robust_fams.sort(key=lambda x: -x[1])
print("\nThreshold-robust (asset,W,H) families [>=60% thresholds net+ & Sharpe>0]:")
if not robust_fams:
print(" NONE.")
for key, npos, tot in robust_fams:
print(f" {key}: {npos}/{tot} thresholds positive")
# ---- HELD-OUT confirmation on best robust cells ---------------------------
print("\n" + "=" * 100)
print("HELD-OUT TAIL CONFIRMATION (never used for selection)")
print("=" * 100)
# choose up to 6 best dev cells that belong to a robust family
robust_keys = {k for k, _, _ in robust_fams}
cand = [r for r in pos if (r["asset"], r["W"], r["H"]) in robust_keys][:6]
if not cand:
cand = pos[:6]
if not cand:
print("No positive dev cells to confirm. ML did not beat fees on dev.")
print(hdr)
held_rows = []
for r in cand:
a, W, H, thr = r["asset"], r["W"], r["H"], r["thr"]
df = cache[a][0]
n = len(df)
dev_hi = int(n * (1 - HELD_OUT_FRAC))
ent_full = proba_to_entries(r["proba"], thr, H, n)
ent_held = mask_entries(ent_full, dev_hi, n)
m = backtest_signals(df, ent_held, fee_rt=0.001, asset=a, tf=tf)
nt, aw, al, gmean, lf = trade_stats(df, ent_held, H)
bh = buy_hold(df, dev_hi, n)
held_rows.append((r, m, aw, al, lf, bh))
print(f"{a:5} {W:>6} {H:>3} {thr:>5.2f} {m.n_trades:>5} {m.win_rate:>5.1f} "
f"{m.net_return*100:>+8.1f} {m.cagr*100:>+7.1f} {m.sharpe:>6.2f} {m.max_dd*100:>5.1f} "
f"{m.time_in_market*100:>5.0f} {aw*100:>+6.2f} {al*100:>+6.2f} {m.daily_profit(2000):>+6.2f} "
f"long={lf*100:>3.0f}% B&H={bh*100:>+7.1f}%")
# ---- FEE SWEEP on the held-out winners ------------------------------------
print("\n--- FEE SWEEP (held-out tail) on confirmed cells ---")
fees = [0.0005, 0.001, 0.0015, 0.002]
print(" (B&H = buy&hold over held-out tail; if net% << B&H the 'edge' is just beta)")
for r, _, _, _, _, _ in held_rows[:4]:
a, W, H, thr = r["asset"], r["W"], r["H"], r["thr"]
df = cache[a][0]
n = len(df)
dev_hi = int(n * (1 - HELD_OUT_FRAC))
ent_held = mask_entries(proba_to_entries(r["proba"], thr, H, n), dev_hi, n)
line = f" {a} W{W} H{H} thr{thr:.2f}: "
for f in fees:
m = backtest_signals(df, ent_held, fee_rt=f, asset=a, tf=tf)
line += f"[{f*100:.2f}%]net={m.net_return*100:>+6.1f}% Shrp={m.sharpe:>+4.2f} "
print(line)
# ---- per-year on the single best held-out cell ----------------------------
if held_rows:
held_rows.sort(key=lambda x: -x[1].sharpe)
r, m, aw, al, lf, bh = held_rows[0]
a, W, H, thr = r["asset"], r["W"], r["H"], r["thr"]
print(f"\n--- Per-year (best held-out): {a} W{W} H{H} thr{thr:.2f} ---")
df = cache[a][0]
n = len(df)
dev_hi = int(n * (1 - HELD_OUT_FRAC))
# full walk-forward per-year (dev+held) to see regime stability
mfull = backtest_signals(df, mask_entries(proba_to_entries(r["proba"], thr, H, n),
cache[a][4], n), fee_rt=0.001, asset=a, tf=tf)
mfull.print_summary(f"{a} W{W}H{H}thr{thr:.2f} FULL-WF")
mfull.print_yearly()
print(f"\nTotal runtime {time.time()-t0:.0f}s")
print("\n" + "=" * 100)
print("VERDICT (see docs/diary/2026-06-19-trackB-ml.md for the full write-up)")
print("=" * 100)
print(
" * A weak but REAL low-turnover directional signal exists on BTC (thinner on ETH):\n"
" large train window (W~16000) + long horizon (H~24) + high prob threshold (~0.10).\n"
" * It beats fees at 0.10% RT AND beats buy&hold on the held-out tail with a balanced\n"
" long/short mix (so it is NOT just bull-market beta). Payoff: ~53% WR, avgWin>avgLoss.\n"
" * BUT: high-turnover cells (low thr / short H / 15m) ALL die on fees -> the edge is small.\n"
" Returns concentrate in a few years (2021,2025) with a -38% year (2023); DD 23-56%.\n"
" * EUR/day on 2000 ~= +0.3..+0.6 baseline. Target is 50/day -> ~100x short. NOT deployable\n"
" standalone; at best a small component, and only the lowest-turnover configs are honest."
)
if __name__ == "__main__":
run()
+380
View File
@@ -0,0 +1,380 @@
"""TRACK C — Mean-reversion / range re-examination on CLEAN BTC/ETH (Deribit mainnet).
HONEST harness only. The OLD 'fade' library (Bollinger fade, Donchian fade, return
reversal) was an ARTIFACT of look-ahead + ghost wicks on a contaminated feed; on the
rebuilt+certified data those are negative every year. This script asks, skeptically:
Does ANY short-horizon mean-reversion / range edge survive on clean BTC/ETH with a
genuinely EXECUTABLE entry (direction + price decided with data <= close[i],
fill at close[i]), net of realistic Deribit fees, out-of-sample and grid-robust?
Methodology enforced here:
* Entry decided with data through close[i]; fill at close[i] (harness guarantees it).
No entering "at the band edge" / candle extreme only known intrabar.
* NET fees fee_rt=0.001 baseline + sweep {0.0005, 0.0015, 0.002}.
* OOS 65/35 split + parameter grid across BOTH BTC & ETH.
* Liquidity/plausibility cross-check: time-in-market, avg bars, and whether the edge
concentrates in flat (O=H=L=C heavy) periods.
Run:
uv run python scripts/research/trackC_meanrev.py # full (slow, all TFs)
uv run python scripts/research/trackC_meanrev.py --quick # 1h + 15m only
"""
from __future__ import annotations
import argparse
import sys
import time
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, Metrics
# ===========================================================================
# Indicator helpers — ALL causal: value at index i uses ONLY data through i.
# ===========================================================================
def zscore(close: np.ndarray, lookback: int) -> np.ndarray:
s = pd.Series(close)
ma = s.rolling(lookback).mean()
sd = s.rolling(lookback).std(ddof=0)
z = (s - ma) / sd
return z.values, ma.values, sd.values
def rsi(close: np.ndarray, period: int) -> np.ndarray:
s = pd.Series(close)
d = s.diff()
up = d.clip(lower=0.0)
dn = (-d).clip(lower=0.0)
# Wilder smoothing via ewm alpha=1/period (causal)
ru = up.ewm(alpha=1.0 / period, adjust=False).mean()
rd = dn.ewm(alpha=1.0 / period, adjust=False).mean()
rs = ru / rd.replace(0, np.nan)
out = 100 - 100 / (1 + rs)
return out.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
# ===========================================================================
# Signal generators — each returns a list[dict|None] length len(df).
# Direction/levels decided strictly with data through close[i].
# ===========================================================================
def sig_zfade(df, lookback=20, z=2.0, tp_mode="mean", tp_atr=1.0, sl_atr=2.0,
max_bars=24, atr_p=14):
"""Bollinger / z-score fade. z<-thr -> long (reversion up); z>+thr -> short.
TP at the moving mean (tp_mode='mean') or at tp_atr*ATR toward the mean.
SL at sl_atr*ATR beyond entry. Entry at close[i]."""
c = df["close"].values
z_arr, ma, _ = zscore(c, lookback)
a = atr(df, atr_p)
n = len(c)
out = [None] * n
for i in range(lookback, n):
zi = z_arr[i]
if not np.isfinite(zi) or not np.isfinite(a[i]):
continue
px = c[i]
if zi <= -z:
direction = 1
tp = ma[i] if tp_mode == "mean" else px + tp_atr * a[i]
sl = px - sl_atr * a[i] if sl_atr else None
elif zi >= z:
direction = -1
tp = ma[i] if tp_mode == "mean" else px - tp_atr * a[i]
sl = px + sl_atr * a[i] if sl_atr else None
else:
continue
# guardrail: never set TP on wrong side of entry
if direction == 1 and tp <= px:
tp = px + tp_atr * a[i]
if direction == -1 and tp >= px:
tp = px - tp_atr * a[i]
out[i] = {"dir": direction, "tp": tp, "sl": sl, "max_bars": max_bars}
return out
def sig_rsi2(df, period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12,
atr_p=14, sma_filter=0):
"""RSI(2)-style oversold/overbought reversion. RSI<lo -> long, RSI>hi -> short.
Optional trend filter: only long above SMA(sma_filter), only short below."""
c = df["close"].values
r = rsi(c, period)
a = atr(df, atr_p)
sma = pd.Series(c).rolling(sma_filter).mean().values if sma_filter else None
n = len(c)
out = [None] * n
for i in range(max(period, atr_p, sma_filter), n):
ri = r[i]
if not np.isfinite(ri) or not np.isfinite(a[i]):
continue
px = c[i]
if ri <= lo:
if sma is not None and not (px > sma[i]):
continue
out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
"sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
elif ri >= hi:
if sma is not None and not (px < sma[i]):
continue
out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
"sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
return out
def sig_retrev(df, ret_lb=1, thr_sigma=2.0, vol_lb=50, tp_atr=1.0, sl_atr=2.0,
max_bars=6, atr_p=14):
"""Return reversal: fade an extreme cumulative return over the last ret_lb bars.
Extreme = |ret| > thr_sigma * rolling std of that return. Entry at close[i]."""
c = df["close"].values
s = pd.Series(c)
ret = np.log(s / s.shift(ret_lb))
sd = ret.rolling(vol_lb).std(ddof=0)
a = atr(df, atr_p)
n = len(c)
out = [None] * n
rv = ret.values
sv = sd.values
for i in range(vol_lb + ret_lb, n):
if not np.isfinite(rv[i]) or not np.isfinite(sv[i]) or sv[i] == 0 or not np.isfinite(a[i]):
continue
z = rv[i] / sv[i]
px = c[i]
if z <= -thr_sigma:
out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
"sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
elif z >= thr_sigma:
out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
"sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
return out
def sig_vwap(df, sess_bars=24, thr=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=12, atr_p=14):
"""Rolling-VWAP distance reversion. Distance in std-of-distance units over a
rolling session window. Far above VWAP -> short, far below -> long. Entry close[i]."""
c = df["close"].values
v = df["volume"].values.astype(float)
tp = (df["high"].values + df["low"].values + c) / 3.0
pv = pd.Series(tp * v)
vol = pd.Series(v)
vwap = (pv.rolling(sess_bars).sum() / vol.rolling(sess_bars).sum()).values
dist = pd.Series(c - vwap)
dsd = dist.rolling(sess_bars).std(ddof=0).values
a = atr(df, atr_p)
n = len(c)
out = [None] * n
for i in range(sess_bars * 2, n):
if not np.isfinite(vwap[i]) or not np.isfinite(dsd[i]) or dsd[i] == 0 or not np.isfinite(a[i]):
continue
z = (c[i] - vwap[i]) / dsd[i]
px = c[i]
if z <= -thr:
out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
"sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
elif z >= thr:
out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
"sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
return out
# ===========================================================================
# Evaluation utilities
# ===========================================================================
def flat_fraction(df: pd.DataFrame) -> float:
o, h, l, c = df["open"], df["high"], df["low"], df["close"]
return float(((h == l) & (o == c)).mean())
def run_split(df, sigfn, params, fee_rt=0.001, leverage=1.0):
"""Run full / IS / OOS for a single config. Returns (full, is_, oos)."""
entries = sigfn(df, **params)
cut = oos_split(df, 0.65)
full = backtest_signals(df, entries, fee_rt=fee_rt, leverage=leverage)
df_is = df.iloc[:cut].reset_index(drop=True)
df_oos = df.iloc[cut:].reset_index(drop=True)
is_ = backtest_signals(df_is, sigfn(df_is, **params), fee_rt=fee_rt, leverage=leverage)
oos = backtest_signals(df_oos, sigfn(df_oos, **params), fee_rt=fee_rt, leverage=leverage)
return full, is_, oos
def hdr(title):
print("\n" + "=" * 92)
print(title)
print("=" * 92)
# ===========================================================================
# Main
# ===========================================================================
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--quick", action="store_true", help="1h+15m only (skip slow 5m)")
args = ap.parse_args()
t0 = time.time()
tfs = ["1h", "15m"] if args.quick else ["1h", "15m", "5m"]
assets = ["BTC", "ETH"]
# preload + liquidity sanity
data = {}
hdr("DATA / LIQUIDITY SANITY (flat-bar fraction O=H=L=C; should be ~0 on clean BTC/ETH)")
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()} flat={flat_fraction(df)*100:5.2f}%")
# -------------------------------------------------------------------
# PASS 1 — broad screen per family on 1h, both assets (IS/OOS).
# -------------------------------------------------------------------
hdr("PASS 1 — FAMILY SCREEN on 1h (honest entry, fee_rt=0.001, lev=1). "
"Look for OOS>0 on BOTH assets.")
families = {
"ZFADE z2/mean ": (sig_zfade, dict(lookback=20, z=2.0, tp_mode="mean", sl_atr=2.0, max_bars=24)),
"ZFADE z2.5/atr": (sig_zfade, dict(lookback=20, z=2.5, tp_mode="atr", tp_atr=1.5, sl_atr=2.0, max_bars=24)),
"ZFADE z3/mean ": (sig_zfade, dict(lookback=40, z=3.0, tp_mode="mean", sl_atr=3.0, max_bars=48)),
"RSI2 10/90 ": (sig_rsi2, dict(period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12)),
"RSI2 5/95 ": (sig_rsi2, dict(period=2, lo=5, hi=95, tp_atr=1.5, sl_atr=2.5, max_bars=12)),
"RSI2 +trend ": (sig_rsi2, dict(period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12, sma_filter=200)),
"RETREV 2sig/6b ": (sig_retrev, dict(ret_lb=1, thr_sigma=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=6)),
"RETREV 3sig/12b": (sig_retrev, dict(ret_lb=3, thr_sigma=3.0, tp_atr=1.5, sl_atr=2.5, max_bars=12)),
"VWAP 2/sess24": (sig_vwap, dict(sess_bars=24, thr=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=12)),
}
for name, (fn, params) in families.items():
line = f" {name} | "
for a in assets:
df = data[(a, "1h")]
full, is_, oos = run_split(df, fn, params)
line += (f"{a}: IS={is_.net_return*100:>+6.0f}% OOS={oos.net_return*100:>+6.0f}% "
f"(tr={oos.n_trades:>4d} wr={oos.win_rate:>4.1f} shrp={oos.sharpe:>+4.1f} "
f"mkt={oos.time_in_market*100:>3.0f}% ab={oos.avg_bars:>4.1f}) ")
print(line)
# -------------------------------------------------------------------
# PASS 2 — parameter GRID on the two most-promising families (z-fade, rsi2),
# require OOS>0 on BOTH assets to count a cell as "surviving".
# -------------------------------------------------------------------
hdr("PASS 2 — GRID ROBUSTNESS (1h). A cell 'survives' only if OOS net>0 on BOTH BTC AND ETH.")
def grid(fn, base, sweep, tf="1h"):
keys = list(sweep.keys())
survivors = []
total = 0
rows = []
from itertools import product
for combo in product(*[sweep[k] for k in keys]):
params = dict(base)
params.update(dict(zip(keys, combo)))
total += 1
res = {}
for a in assets:
_, is_, oos = run_split(data[(a, tf)], fn, params)
res[a] = (is_, oos)
ok = all(res[a][1].net_return > 0 for a in assets)
both_oos = np.mean([res[a][1].net_return for a in assets]) * 100
rows.append((params, res, ok))
if ok:
survivors.append((params, res))
print(f" {fn.__name__}: {len(survivors)}/{total} cells with OOS>0 on BOTH assets")
# show best few by mean OOS
rows.sort(key=lambda r: np.mean([r[1][a][1].net_return for a in assets]), reverse=True)
for params, res, ok in rows[:6]:
tag = "OK " if ok else " -"
pp = {k: params[k] for k in sweep}
s = f" {tag} {pp} | "
for a in assets:
oos = res[a][1]
s += f"{a} OOS={oos.net_return*100:>+6.0f}% (wr={oos.win_rate:>4.1f} shrp={oos.sharpe:>+4.1f}) "
print(s)
return survivors
zsurv = grid(sig_zfade,
dict(tp_mode="mean", max_bars=24),
dict(lookback=[20, 40, 60], z=[2.0, 2.5, 3.0], sl_atr=[2.0, 3.0]))
rsurv = grid(sig_rsi2,
dict(period=2, tp_atr=1.0),
dict(lo=[5, 10, 15], hi=[85, 90, 95], sl_atr=[2.0, 3.0], max_bars=[6, 12]))
# -------------------------------------------------------------------
# PASS 3 — FEE SWEEP on whatever looks least-bad (z-fade z2/mean) to show fee
# sensitivity (MR is high-frequency: fees are first-order).
# -------------------------------------------------------------------
hdr("PASS 3 — FEE SWEEP (z-fade lookback=20 z=2 mean, 1h). fee=0 is GROSS: is there\n"
" ANY edge before fees, or is the fade direction itself wrong on clean data?")
fees = [0.0, 0.0005, 0.001, 0.0015, 0.002]
base = dict(lookback=20, z=2.0, tp_mode="mean", sl_atr=2.0, max_bars=24)
for a in assets:
df = data[(a, "1h")]
line = f" {a}: "
for f in fees:
full, is_, oos = run_split(df, sig_zfade, base, fee_rt=f)
line += f"fee={f*1000:.1f}bp→ full={full.net_return*100:>+6.0f}% OOS={oos.net_return*100:>+6.0f}% "
print(line)
# -------------------------------------------------------------------
# PASS 4 — faster TFs (15m, 5m) on the canonical z-fade, to test the "more MR
# opportunities" hypothesis vs the "fee death" reality.
# -------------------------------------------------------------------
hdr("PASS 4 — z-fade across timeframes (lookback=20 z=2 mean). Faster TF = more fees.")
for tf in tfs:
for a in assets:
df = data[(a, tf)]
full, is_, oos = run_split(df, sig_zfade, base)
print(f" {a} {tf:>3s}: full={full.net_return*100:>+7.0f}% IS={is_.net_return*100:>+7.0f}% "
f"OOS={oos.net_return*100:>+7.0f}% tr={full.n_trades:>5d} wr={full.win_rate:>4.1f}% "
f"shrp={full.sharpe:>+4.1f} mkt={full.time_in_market*100:>3.0f}% €/d={full.daily_profit(2000):>+5.2f}")
# -------------------------------------------------------------------
# PASS 5 — SESSION / overnight effect (UTC hour-of-day) on 1h returns.
# Pure descriptive: is there a systematically mean-reverting hour bucket?
# -------------------------------------------------------------------
hdr("PASS 5 — UTC hour-of-day next-bar return autocorrelation (descriptive, no trade).")
for a in assets:
df = data[(a, "1h")]
c = df["close"].values
ret = pd.Series(np.log(c[1:] / c[:-1])) # ret[k] = log(c[k+1]/c[k])
prev = ret.shift(1)
hours = df["datetime"].dt.hour.values[1:1 + len(ret)]
tmp = pd.DataFrame({"h": hours[:len(ret)], "r": ret.values, "p": prev.values}).dropna()
# autocorr of consecutive bar returns per hour bucket (negative = mean-reverting)
ac = tmp.groupby("h").apply(lambda g: g["r"].corr(g["p"]) if len(g) > 30 else np.nan)
worst = ac.nsmallest(3)
best = ac.nlargest(3)
print(f" {a}: most mean-reverting UTC hours (neg autocorr): "
+ ", ".join(f"{int(h)}h={v:+.3f}" for h, v in worst.items())
+ " | most trending: "
+ ", ".join(f"{int(h)}h={v:+.3f}" for h, v in best.items()))
# -------------------------------------------------------------------
# VERDICT
# -------------------------------------------------------------------
hdr("VERDICT")
n_surv = len(zsurv) + len(rsurv)
if n_surv == 0:
print(" No grid cell produced OOS net>0 on BOTH BTC and ETH at baseline fees.")
print(" => Consistent with the reset thesis: the old MR 'edge' was a feed artifact.")
print(" On clean Deribit data with honest executable entry, short-horizon MR is NOT")
print(" a robust net-positive edge. (See per-pass tables above for the evidence.)")
else:
print(f" {n_surv} grid cell(s) survived OOS>0 on both assets. Inspect above; then stress")
print(" with fee sweep / faster TFs before believing. Surviving configs:")
for params, res in (zsurv + rsurv):
ms = np.mean([res[a][1].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()
+176
View File
@@ -0,0 +1,176 @@
"""TRACK D on DIFFERENT TIMEFRAMES — per-year PnL and per-year max drawdown.
Takes the winning config (TSMOM 1-3-6 month blend, vol-target 20%, leverage cap 2x,
50/50 BTC+ETH portfolio) and runs it across timeframes 15m / 1h / 4h / 1d.
Honesty preserved: same building blocks as trackD_trendport.py (positions shifted +1 bar,
fee 0.10% RT on turnover, vol-targeting on past-only realized vol). Horizons are kept
CALENDAR-consistent across TFs (1/3/6 months -> bars = months*30*bars_per_day), so we test
the SAME economic strategy sampled at different frequencies, not different strategies.
4h/1d are RESAMPLED from the certified 1h feed (00:00 UTC boundaries).
Run: uv run python scripts/research/trackD_timing.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 scripts.research.trackD_trendport import (
simple_returns, realized_vol, sig_tsmom_blend, build_target,
equity_from_target,
)
ASSETS = ["BTC", "ETH"]
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% RT
TARGET_VOL = 0.20
LEVERAGE = 2.0
# timeframe -> (load_tf, resample_rule_or_None, bars_per_day)
TIMEFRAMES = {
"15m": ("15m", None, 96),
"1h": ("1h", None, 24),
"4h": ("1h", "4h", 6),
"1d": ("1h", "1D", 1),
}
def resample_ohlc(df: pd.DataFrame, rule: str) -> pd.DataFrame:
g = df.copy()
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
idx.name = "dt"
g.index = idx
out = g.resample(rule, label="left", closed="left").agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
out = out.dropna(subset=["open"])
out["datetime"] = out.index
epoch = pd.Timestamp("1970-01-01", tz="UTC")
out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
return out.reset_index(drop=True)[["timestamp", "open", "high", "low", "close", "volume", "datetime"]]
def get_df(tf_key: str, asset: str) -> pd.DataFrame:
load_tf, rule, _ = TIMEFRAMES[tf_key]
df = load(asset, load_tf)
if rule:
df = resample_ohlc(df, rule)
return df
def run_asset(df, bars_per_day, target_vol=TARGET_VOL, leverage=LEVERAGE,
long_only=False, fee_side=FEE_SIDE):
c = df["close"].values.astype(float)
r = simple_returns(c)
bpy = bars_per_day * 365.25
# recompute building blocks at this TF's bar frequency
h1, h3, h6 = 30 * bars_per_day, 90 * bars_per_day, 180 * bars_per_day
vol_win = 30 * bars_per_day
# realized_vol / tsmom use BARS_PER_YEAR from trackD (1h) for annualization of vol;
# we must annualize with THIS tf's bpy -> compute vol locally
vol = pd.Series(r).rolling(vol_win, min_periods=vol_win // 2).std().values * np.sqrt(bpy)
direction = sig_tsmom_blend(c, horizons=(h1, h3, h6))
tgt = build_target(direction, vol, target_vol, leverage, long_only)
equity, net = equity_from_target(tgt, r, fee_side)
# discrete position SIGN for trade counting (entry = sign change to a new non-zero state)
sign = np.sign(tgt)
return dict(net=net, ts=df["datetime"], equity=equity, bpy=bpy, sign=sign, target=tgt)
def portfolio_series(sleeves):
a = pd.Series(sleeves["BTC"]["net"], index=pd.to_datetime(sleeves["BTC"]["ts"].values))
b = pd.Series(sleeves["ETH"]["net"], index=pd.to_datetime(sleeves["ETH"]["ts"].values))
j = pd.concat([a.rename("a"), b.rename("b")], axis=1, join="inner").fillna(0.0)
combo = 0.5 * j["a"].values + 0.5 * j["b"].values
idx = pd.to_datetime(j.index)
equity = np.cumprod(1.0 + np.clip(combo, -0.99, None))
return idx, combo, equity
def overall_metrics(idx, combo, equity, bpy):
rr = combo[np.isfinite(combo)]
sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)) if np.std(rr) > 0 else 0.0
peak = np.maximum.accumulate(equity)
dd = float(np.max((peak - equity) / peak))
span_days = (idx[-1] - idx[0]).total_seconds() / 86400
years = span_days / 365.25
total = equity[-1] / equity[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, max_dd=dd, cagr=cagr, total=total - 1, daily_2k=daily_2k)
def per_year(idx, equity):
"""Return {year: (pnl_pct, maxdd_pct)} where maxdd is the worst drawdown WITHIN the year."""
eq = pd.Series(equity, index=idx)
out = {}
for y, g in eq.groupby(eq.index.year):
if len(g) < 2:
continue
pnl = g.iloc[-1] / g.iloc[0] - 1.0
v = g.values
peak = np.maximum.accumulate(v)
ddy = float(np.max((peak - v) / peak))
out[int(y)] = (float(pnl), ddy)
return out
def trades_per_year(sleeves):
"""Count entries per year, summed across both sleeves. An 'entry' = the position SIGN
changing to a new non-zero value (flat->long, flat->short, or a direction flip)."""
counts: dict[int, int] = {}
for a in ASSETS:
sign = sleeves[a]["sign"]
ts = pd.to_datetime(sleeves[a]["ts"].values)
for i in range(1, len(sign)):
s, prev = sign[i], sign[i - 1]
if s != 0 and s != prev: # entry: from flat or opposite into a non-zero state
counts[ts[i].year] = counts.get(ts[i].year, 0) + 1
return counts
ALL_YEARS = list(range(2018, 2027))
def main():
print("=" * 118)
print("# TRACK D WINNER ACROSS TIMEFRAMES — TSMOM 1-3-6m blend, vol-target 20%, lev 2x, 50/50 BTC+ETH")
print("# fee 0.10% RT on turnover, positions +1 bar (no look-ahead). 4h/1d resampled from certified 1h.")
print("=" * 118)
for mode_long_only, mode_name in ((False, "LONG-SHORT"), (True, "LONG-FLAT")):
print("\n" + "#" * 118)
print(f"# MODE = {mode_name}")
print("#" * 118)
for tf_key in TIMEFRAMES:
bpd = TIMEFRAMES[tf_key][2]
sleeves = {a: run_asset(get_df(tf_key, a), bpd, long_only=mode_long_only)
for a in ASSETS}
idx, combo, equity = portfolio_series(sleeves)
ov = overall_metrics(idx, combo, equity, sleeves["BTC"]["bpy"])
py = per_year(idx, equity)
tpy = trades_per_year(sleeves)
total_trades = sum(tpy.values())
print(f"\n ── TF {tf_key:<3s} │ ret {ov['total']*100:>+8.0f}% CAGR {ov['cagr']*100:>+6.1f}% "
f"Sharpe {ov['sharpe']:>4.2f} maxDD {ov['max_dd']*100:>4.1f}% "
f"€/day(2k) {ov['daily_2k']:>+6.2f} trades {total_trades}")
# per-year PnL / DD / trades rows
print(f" {'PnL %':<8s}" + "".join(
(" . " if y not in py else f"{py[y][0]*100:>+7.0f}") for y in ALL_YEARS))
print(f" {'maxDD %':<8s}" + "".join(
(" . " if y not in py else f"{py[y][1]*100:>7.1f}") for y in ALL_YEARS))
print(f" {'trades':<8s}" + "".join(
(" . " if y not in py else f"{tpy.get(y,0):>7d}") for y in ALL_YEARS))
# year header for reference
print("\n " + "year ".ljust(8) + "".join(f"{y:>7d}" for y in ALL_YEARS))
if __name__ == "__main__":
main()
+460
View File
@@ -0,0 +1,460 @@
"""TRACK D — ROBUST WALK-FORWARD TREND PORTFOLIO (BTC+ETH), vol-targeted + leverage.
Thesis under test: trend-following's real value in crypto is DRAWDOWN REDUCTION vs
buy & hold (it sidesteps crashes). That lower DD lets us apply LEVERAGE and DIVERSIFY
across BTC+ETH to build a deployable, risk-adjusted EARNING system, even if each single
signal has only a modest Sharpe. Question: does a properly-built, anti-overfit trend
portfolio actually EARN robustly across regimes 2018-2026?
METHOD (strict, honest):
* NO LOOK-AHEAD. We build equity directly from a TARGET-POSITION series.
- target[i] is decided using ONLY data <= close[i].
- target[i] is HELD during the next bar (close[i] -> close[i+1]).
- bar return r[t] = close[t]/close[t-1] - 1 (uses close[t], close[t-1]; both <= t).
- pnl on bar t = target[t-1] * r[t] (shift positions by 1 -> no leakage).
- fees: fee_per_side * |target[t-1] - target[t-2]| (turnover cost, charged on rebalances).
This is the harness's documented "build your own equity from a position series" path.
* VOL-TARGETING: position = directional_signal * (target_vol / realized_vol), capped at
leverage. realized_vol uses past returns only (rolling std up to close[i]). This is the
main lever — it lets a modest signal run at a controlled risk level.
* WALK-FORWARD / MULTI-REGIME: per-year returns for ALL years 2018-2026. Plus an explicit
EARLY (2018-2021) tune / LATE (2022-2026) confirm split. ONE robust param set, both assets.
* PORTFOLIO: equal-weight BTC+ETH sleeves, rebalanced each bar. Report combined Sharpe/DD/CAGR.
* GRID ROBUSTNESS: chosen config must be positive across a neighborhood AND across regimes.
* FEE & LEVERAGE SWEEP: fee/side 0.0005..0.002 (0.10..0.40% RT); leverage cap 1x..3x.
Run: uv run python scripts/research/trackD_trendport.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
ASSETS = ["BTC", "ETH"]
TF = "1h"
BARS_PER_YEAR = 24 * 365.25 # 1h bars
FEE_SIDE = 0.0005 # 0.05% per side = 0.10% round trip (Deribit taker)
# horizons in 1h bars ~ 1 / 3 / 6 "months" (30d months)
H1, H3, H6 = 30 * 24, 90 * 24, 180 * 24
# ---------------------------------------------------------------------------
# Core building blocks (all <= close[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) -> np.ndarray:
"""Annualized realized vol from bar returns up to and including i (no leakage)."""
vol = pd.Series(r).rolling(win, min_periods=win // 2).std().values
return vol * np.sqrt(BARS_PER_YEAR)
def sig_tsmom_blend(c: np.ndarray, horizons=(H1, H3, H6)) -> np.ndarray:
"""Multi-horizon TSMOM: average of sign(close[i]/close[i-h]-1) over horizons -> [-1,1]."""
n = len(c)
acc = np.zeros(n)
cnt = np.zeros(n)
for h in horizons:
s = np.full(n, np.nan)
s[h:] = np.sign(c[h:] / c[:-h] - 1.0)
valid = np.isfinite(s)
acc[valid] += s[valid]
cnt[valid] += 1
out = np.zeros(n)
nz = cnt > 0
out[nz] = acc[nz] / cnt[nz]
return out
def sig_ma_slope(c: np.ndarray, span: int, slope_win: int = 24) -> np.ndarray:
"""Sign of the slope of an EMA: ema[i] vs ema[i-slope_win]. -> {-1,0,+1}."""
ema = pd.Series(c).ewm(span=span, adjust=False).mean().values
n = len(c)
out = np.zeros(n)
out[slope_win:] = np.sign(ema[slope_win:] - ema[:-slope_win])
return out
def sig_donchian_state(c, h, l, n_break: int, n_exit: int) -> np.ndarray:
"""Donchian breakout with trailing (channel) stop, returns a stateful {-1,0,+1} series.
Long when close[i] > prior n_break high; exit/flip via prior n_exit low channel (trailing).
Detection uses prior-window extremes EXCLUDING current bar (shift 1) and close[i] -> honest."""
hh = pd.Series(h).rolling(n_break).max().shift(1).values
ll = pd.Series(l).rolling(n_break).min().shift(1).values
xh = pd.Series(h).rolling(n_exit).max().shift(1).values # trailing exit for shorts
xl = pd.Series(l).rolling(n_exit).min().shift(1).values # trailing exit for longs
n = len(c)
state = np.zeros(n)
pos = 0
for i in range(n):
if not np.isfinite(hh[i]):
state[i] = 0
continue
if pos == 1:
if c[i] < xl[i]:
pos = 0
elif pos == -1:
if c[i] > xh[i]:
pos = 0
if pos == 0:
if c[i] > hh[i]:
pos = 1
elif c[i] < ll[i]:
pos = -1
state[i] = pos
return state
# ---------------------------------------------------------------------------
# Position construction (vol-targeting + leverage cap + long/flat option)
# ---------------------------------------------------------------------------
def build_target(direction: np.ndarray, vol: np.ndarray, target_vol: float,
leverage: float, long_only: bool) -> np.ndarray:
"""target[i] = direction[i] * (target_vol / vol[i]), clipped to [-leverage, leverage].
direction[i] in [-1,1]; vol[i] annualized realized vol (<= close[i]). long_only clips <0 to 0."""
d = direction.copy()
if long_only:
d = np.clip(d, 0, None)
scal = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0)
tgt = d * scal
tgt = np.clip(tgt, -leverage, leverage)
tgt[~np.isfinite(tgt)] = 0.0
return tgt
def equity_from_target(target: np.ndarray, r: np.ndarray, fee_side: float):
"""Build equity from a target-position series with NO look-ahead.
pos held during bar t = target[t-1]; pnl[t] = target[t-1]*r[t]; fee on turnover."""
n = len(target)
pos_held = np.zeros(n)
pos_held[1:] = target[:-1] # held during bar t = decided at close[t-1]
gross = pos_held * r
turn = np.abs(np.diff(pos_held, prepend=0.0))
net = gross - fee_side * turn
net[0] = 0.0
net = np.clip(net, -0.99, None) # cannot lose more than capital on a bar
equity = np.cumprod(1.0 + net)
return equity, net
# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------
def metrics(equity: np.ndarray, net: np.ndarray, ts: pd.Series) -> dict:
rr = net[np.isfinite(net)]
sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(BARS_PER_YEAR)) if np.std(rr) > 0 else 0.0
peak = np.maximum.accumulate(equity)
dd = float(np.max((peak - equity) / peak))
span_days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
years = span_days / 365.25
total = equity[-1] / equity[0]
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
eq_s = pd.Series(equity, index=ts)
yearly = {}
for y, g in eq_s.groupby(eq_s.index.year):
if len(g) > 1 and g.iloc[0] > 0:
yearly[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1)
daily_2k = (2000 * total - 2000) / span_days if span_days > 0 else 0.0
return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total=total - 1,
yearly=yearly, daily_2k=daily_2k, vol_ann=float(np.std(rr) * np.sqrt(BARS_PER_YEAR)))
def avg_gross(target: np.ndarray) -> float:
"""Average absolute position = average gross leverage actually deployed."""
t = target[np.isfinite(target)]
return float(np.mean(np.abs(t))) if len(t) else 0.0
def fmt(m, label):
return (f" {label:<34s} ret={m['total']*100:>+9.0f}% CAGR={m['cagr']*100:>+6.1f}% "
f"Sh={m['sharpe']:>5.2f} DD={m['max_dd']*100:>4.1f}% volA={m['vol_ann']*100:>4.0f}% "
f"€/d(2k)={m['daily_2k']:>+7.2f}")
# ---------------------------------------------------------------------------
# Strategy assembly
# ---------------------------------------------------------------------------
def make_direction(df: pd.DataFrame, kind: str, params: dict) -> np.ndarray:
c = df["close"].values.astype(float)
if kind == "TSMOM":
return sig_tsmom_blend(c, params.get("horizons", (H1, H3, H6)))
if kind == "MASLOPE":
return sig_ma_slope(c, params["span"], params.get("slope_win", 24))
if kind == "DONCHIAN":
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
return sig_donchian_state(c, h, l, params["n_break"], params["n_exit"])
raise ValueError(kind)
def run_asset(df, kind, params, target_vol, leverage, long_only, fee_side=FEE_SIDE):
c = df["close"].values.astype(float)
r = simple_returns(c)
vol = realized_vol(r, params.get("vol_win", 30 * 24))
direction = make_direction(df, kind, params)
tgt = build_target(direction, vol, target_vol, leverage, long_only)
equity, net = equity_from_target(tgt, r, fee_side)
ts = df["datetime"]
m = metrics(equity, net, ts)
m["target"] = tgt
m["net"] = net
m["ts"] = ts
m["equity"] = equity
return m
def buy_hold(df):
c = df["close"].values.astype(float)
r = simple_returns(c)
equity = np.cumprod(1.0 + np.clip(r, -0.99, None))
return metrics(equity, r, df["datetime"])
# ---------------------------------------------------------------------------
# Portfolio (equal-weight BTC+ETH, rebalanced each bar on common timestamps)
# ---------------------------------------------------------------------------
def portfolio(net_btc_df, net_eth_df, w=(0.5, 0.5)):
"""Combine two per-bar net-return series aligned on common timestamps."""
a = pd.Series(net_btc_df["net"], index=net_btc_df["ts"].values)
b = pd.Series(net_eth_df["net"], index=net_eth_df["ts"].values)
j = pd.concat([a.rename("a"), b.rename("b")], axis=1, join="inner").fillna(0.0)
combo = w[0] * j["a"].values + w[1] * j["b"].values
equity = np.cumprod(1.0 + np.clip(combo, -0.99, None))
ts = pd.Series(pd.to_datetime(j.index))
return metrics(equity, combo, ts)
# ---------------------------------------------------------------------------
# Reporting helpers
# ---------------------------------------------------------------------------
ALL_YEARS = list(range(2018, 2027))
def print_yearly_row(label, m):
cells = []
for y in ALL_YEARS:
v = m["yearly"].get(y)
cells.append(" . " if v is None else f"{v*100:>+6.0f}%")
print(f" {label:<26s} " + " ".join(cells))
def yearly_header():
print(f" {'config':<26s} " + " ".join(f"{y:>7d}" for y in ALL_YEARS))
# ---------------------------------------------------------------------------
# Experiments
# ---------------------------------------------------------------------------
def main():
pd.set_option("display.width", 220)
dfs = {a: load(a, TF) for a in ASSETS}
print("=" * 130)
print("# TRACK D — VOL-TARGETED TREND PORTFOLIO (BTC+ETH, 1h, Deribit certified)")
print("# Equity built from target-position series; positions shifted +1 bar (no look-ahead);")
print("# fee = 0.05%/side (0.10% RT) on turnover. Vol-targeting scales by inverse realized vol.")
print("=" * 130)
print("\n# BUY & HOLD BENCHMARK (the DD/return bar trend must beat on risk-adjusted basis)")
yearly_header()
bh = {}
for a in ASSETS:
bh[a] = buy_hold(dfs[a])
print(fmt(bh[a], f"B&H {a}"))
print_yearly_row(f"B&H {a} yearly", bh[a])
bh_port = portfolio({"net": simple_returns(dfs["BTC"]["close"].values), "ts": dfs["BTC"]["datetime"]},
{"net": simple_returns(dfs["ETH"]["close"].values), "ts": dfs["ETH"]["datetime"]})
print(fmt(bh_port, "B&H 50/50 BTC+ETH"))
print_yearly_row("B&H port yearly", bh_port)
# ----------------------------------------------------------------------
# 1. BROAD SCAN: strategies x vol-target x leverage x long-only, per asset & portfolio
# ----------------------------------------------------------------------
print("\n" + "=" * 130)
print("# 1) BROAD SCAN — per-asset & 50/50 portfolio, vol-target=20%, leverage cap 2x")
print("# (TSMOM 1-3-6m blend / MA-slope / Donchian-trailing; long-short vs long-flat)")
print("=" * 130)
strat_defs = [
("TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24)),
("MASLOPE", dict(span=200, slope_win=48, vol_win=30 * 24)),
("DONCHIAN", dict(n_break=200, n_exit=100, vol_win=30 * 24)),
]
for long_only in (False, True):
mode = "LONG-FLAT" if long_only else "LONG-SHORT"
print(f"\n --- {mode} ---")
for kind, params in strat_defs:
sleeves = {}
for a in ASSETS:
m = run_asset(dfs[a], kind, params, target_vol=0.20, leverage=2.0, long_only=long_only)
sleeves[a] = m
print(fmt(m, f"{kind} {a}"))
port = portfolio(sleeves["BTC"], sleeves["ETH"])
print(fmt(port, f"{kind} PORTFOLIO 50/50"))
print_yearly_row(f"{kind} port yearly", port)
# ----------------------------------------------------------------------
# 2. GRID ROBUSTNESS on the portfolio: vol-target x leverage x vol-window
# using the multi-horizon TSMOM blend (the most diversified trend signal)
# ----------------------------------------------------------------------
print("\n" + "=" * 130)
print("# 2) GRID ROBUSTNESS — TSMOM 1-3-6m blend, 50/50 portfolio (LONG-SHORT)")
print("# Sweep target-vol x leverage-cap. A real config is positive across the neighborhood.")
print("=" * 130)
hdr = " " + "tvol\\lev".ljust(8) + "".join(f"{lev:.0f}x".rjust(26) for lev in (1.0, 1.5, 2.0, 3.0))
print(hdr)
grid = {}
for tvol in (0.10, 0.15, 0.20, 0.30, 0.40):
row = f" {tvol*100:>6.0f}% "
for lev in (1.0, 1.5, 2.0, 3.0):
sleeves = {}
for a in ASSETS:
sleeves[a] = run_asset(dfs[a], "TSMOM",
dict(horizons=(H1, H3, H6), vol_win=30 * 24),
target_vol=tvol, leverage=lev, long_only=False)
port = portfolio(sleeves["BTC"], sleeves["ETH"])
grid[(tvol, lev)] = port
row += f" Sh{port['sharpe']:>4.2f} DD{port['max_dd']*100:>3.0f} C{port['cagr']*100:>+4.0f}"
print(row)
# ----------------------------------------------------------------------
# 3. HORIZON-SET robustness (is the 1-3-6m blend a plateau or a lucky combo?)
# ----------------------------------------------------------------------
print("\n" + "=" * 130)
print("# 3) HORIZON-SET ROBUSTNESS — TSMOM blend, portfolio, tvol=20% lev=2x (LONG-SHORT)")
print("=" * 130)
horizon_sets = {
"1m only": (H1,), "3m only": (H3,), "6m only": (H6,),
"1-3m": (H1, H3), "3-6m": (H3, H6), "1-3-6m": (H1, H3, H6),
"1-2-4m": (30 * 24, 60 * 24, 120 * 24), "2-4-8m": (60 * 24, 120 * 24, 240 * 24),
}
yearly_header()
for name, hs in horizon_sets.items():
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=hs, vol_win=30 * 24),
target_vol=0.20, leverage=2.0, long_only=False) for a in ASSETS}
port = portfolio(sleeves["BTC"], sleeves["ETH"])
print(fmt(port, f"TSMOM {name}"))
print()
for name, hs in horizon_sets.items():
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=hs, vol_win=30 * 24),
target_vol=0.20, leverage=2.0, long_only=False) for a in ASSETS}
port = portfolio(sleeves["BTC"], sleeves["ETH"])
print_yearly_row(f"{name}", port)
# ----------------------------------------------------------------------
# 4. WALK-FORWARD: EARLY (<=2021) tune / LATE (>=2022) confirm
# Same single param set for BOTH assets; we just split the equity by date.
# ----------------------------------------------------------------------
print("\n" + "=" * 130)
print("# 4) WALK-FORWARD — split portfolio equity into EARLY (2018-2021) vs LATE (2022-2026)")
print("# One param set, both assets. Both halves must earn for the edge to be regime-robust.")
print("=" * 130)
cfg = dict(kind="TSMOM", params=dict(horizons=(H1, H3, H6), vol_win=30 * 24),
target_vol=0.20, leverage=2.0, long_only=False)
sleeves = {a: run_asset(dfs[a], cfg["kind"], cfg["params"], cfg["target_vol"],
cfg["leverage"], cfg["long_only"]) for a in ASSETS}
a = pd.Series(sleeves["BTC"]["net"], index=sleeves["BTC"]["ts"].values)
b = pd.Series(sleeves["ETH"]["net"], index=sleeves["ETH"]["ts"].values)
j = pd.concat([a.rename("a"), b.rename("b")], axis=1, join="inner").fillna(0.0)
combo = 0.5 * j["a"].values + 0.5 * j["b"].values
idx = pd.to_datetime(j.index)
for lab, mask in (("EARLY 2018-2021", idx.year <= 2021), ("LATE 2022-2026", idx.year >= 2022)):
sub = combo[mask]
eq = np.cumprod(1.0 + np.clip(sub, -0.99, None))
m = metrics(eq, sub, pd.Series(idx[mask]))
print(fmt(m, lab))
print_yearly_row(f"{lab} yearly", m)
# ----------------------------------------------------------------------
# 5. FEE & LEVERAGE SWEEP on the headline portfolio config
# ----------------------------------------------------------------------
print("\n" + "=" * 130)
print("# 5) FEE & LEVERAGE SWEEP — TSMOM 1-3-6m blend portfolio, tvol=20%")
print("=" * 130)
print(" fee sweep (leverage cap 2x):")
for fee in (0.0005, 0.00075, 0.001, 0.0015, 0.002):
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24),
target_vol=0.20, leverage=2.0, long_only=False, fee_side=fee)
for a in ASSETS}
port = portfolio(sleeves["BTC"], sleeves["ETH"])
print(fmt(port, f"fee/side={fee:.5f} (RT={2*fee*100:.2f}%)"))
print(" leverage sweep (fee 0.05%/side):")
for lev in (1.0, 1.5, 2.0, 2.5, 3.0):
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24),
target_vol=0.20, leverage=lev, long_only=False)
for a in ASSETS}
port = portfolio(sleeves["BTC"], sleeves["ETH"])
print(fmt(port, f"leverage cap={lev:.1f}x"))
# ----------------------------------------------------------------------
# 6. HEADLINE ROBUST CONFIG — full per-year table + sleeves + portfolio
# ----------------------------------------------------------------------
print("\n" + "=" * 130)
print("# 6) HEADLINE ROBUST CONFIG: TSMOM 1-3-6m blend, vol-target 20%, leverage cap 2x, LONG-SHORT")
print("=" * 130)
yearly_header()
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24),
target_vol=0.20, leverage=2.0, long_only=False) for a in ASSETS}
for a in ASSETS:
print(fmt(sleeves[a], f"sleeve {a}"))
print_yearly_row(f"sleeve {a} yearly", sleeves[a])
port = portfolio(sleeves["BTC"], sleeves["ETH"])
print(fmt(port, "PORTFOLIO 50/50"))
print_yearly_row("PORTFOLIO yearly", port)
# also long-flat headline (deployable variant — no shorts/funding complexity)
print()
sleeves_lf = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24),
target_vol=0.20, leverage=2.0, long_only=True) for a in ASSETS}
port_lf = portfolio(sleeves_lf["BTC"], sleeves_lf["ETH"])
print(fmt(port_lf, "PORTFOLIO 50/50 LONG-FLAT"))
print_yearly_row("PORTFOLIO LF yearly", port_lf)
# ----------------------------------------------------------------------
# 7. €/DAY ON 2000 — what leverage gets us toward 50/day, and the DD it costs
# ----------------------------------------------------------------------
print("\n" + "=" * 130)
print("# 7) PATH TO ~50 EUR/day on 2000 — the REAL lever is TARGET-VOL, not the leverage cap.")
print("# At tvol=20%% on 60-80%% crypto vol, positions stay sub-1x: the leverage cap NEVER binds.")
print("# To deploy real leverage you raise target-vol; Sharpe is ~constant, DD scales ~linearly.")
print("# 'avg gross' = mean |position| = leverage actually used. (cap fixed at 3x here)")
print("=" * 130)
print(f" {'target_vol':<12s}{'avgGross':>10s}{'CAGR':>9s}{'Sharpe':>9s}{'maxDD':>8s}"
f"{'€/day(2k,avg)':>16s}{'final/2k':>12s}")
for tvol in (0.20, 0.40, 0.60, 0.80, 1.00):
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24),
target_vol=tvol, leverage=3.0, long_only=False) for a in ASSETS}
port = portfolio(sleeves["BTC"], sleeves["ETH"])
ag = 0.5 * (avg_gross(sleeves["BTC"]["target"]) + avg_gross(sleeves["ETH"]["target"]))
print(f" {tvol*100:>8.0f}% {ag:>9.2f}x{port['cagr']*100:>+8.1f}%{port['sharpe']:>9.2f}"
f"{port['max_dd']*100:>7.1f}%{port['daily_2k']:>+16.2f}{(1+port['total']):>12.1f}x")
# steady-state €/day at current capital under headline CAGR
print("\n Steady-state €/day implied by headline CAGR (NOT path-dependent), at various capital:")
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24),
target_vol=0.20, leverage=2.0, long_only=False) for a in ASSETS}
port = portfolio(sleeves["BTC"], sleeves["ETH"])
g = port["cagr"]
daily_rate = (1 + g) ** (1 / 365.25) - 1
for cap in (2000, 5000, 10000, 50000, 100000):
print(f" capital={cap:>7d} ~€/day = {cap*daily_rate:>+8.2f} (CAGR={g*100:+.1f}%)")
need = 50.0 / daily_rate if daily_rate > 0 else float("inf")
print(f"\n To average ~50 EUR/day at this CAGR you'd need ~{need:,.0f} capital "
f"(at leverage 2x, maxDD~{port['max_dd']*100:.0f}%).")
print("\nDONE. See the report/diary for the honest verdict.")
if __name__ == "__main__":
main()
+526
View File
@@ -0,0 +1,526 @@
"""TRACK E — CROSS-SECTIONAL BTC↔ETH relative-value + ENSEMBLE synthesis.
Two parts, both on certified Deribit-mainnet data (only BTC/ETH), both honest:
PART 1 — RELATIVE VALUE (market-neutral-ish spread trading on TWO assets):
* XS relative momentum: go long the stronger asset, short the weaker (dollar-neutral).
* ETH/BTC ratio TREND (z-momentum) and ratio MEAN-REVERSION (z-fade of log-ratio).
* Lead-lag (descriptive): does BTC's last-bar move predict ETH's next bar (and vice versa)?
All positions are decided with data <= close[i] and HELD over the NEXT bar (i->i+1):
realized PnL on bar k uses position set at k-1 -> strict 1-bar shift, NO look-ahead.
Fees are turnover-based: |Δpos| * fee_rt/2 PER LEG (a +1↔-1 flip = one round trip = fee_rt).
PART 2 — ENSEMBLE:
Combine the genuinely-positive residual sleeves into ONE portfolio equity curve:
(S1) BTC low-turnover ML momentum (trackB best honest cell: W16000 H24 thr0.10, 1h)
(S2) Trend-1h, the only cross-asset-robust trend cell from trackA (Donchian N=200 H=12)
(S3) the best relative-value sleeve found in PART 1 (if any net-positive OOS)
Report combined Sharpe / maxDD / CAGR / EUR-per-day-on-2000 AND the sleeve correlation
matrix. A real ensemble edge must be net-positive OOS and LOWER drawdown than its parts.
Run: uv run python scripts/research/trackE_xsec_ensemble.py
uv run python scripts/research/trackE_xsec_ensemble.py --quick (skip slow ML sleeve)
uv run python scripts/research/trackE_xsec_ensemble.py --no-cache (recompute ML proba)
"""
from __future__ import annotations
import argparse
import sys
import time
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
# reuse trackB ML machinery (strict walk-forward, no leakage) and trackA donchian
from scripts.research.trackB_ml import (
build_features, forward_labels, walk_forward_proba, proba_to_entries, mask_entries,
RETRAIN_K,
)
from scripts.research.trackA_trend import sig_donchian
from sklearn.linear_model import LogisticRegression
FEE = 0.001 # 0.10% round-trip baseline (per leg for the pair)
BARS_PER_YEAR_1H = 24 * 365.25
# ===========================================================================
# Generic honest stats on a per-bar RETURN series (returns realized bar (k-1)->k)
# ===========================================================================
def equity_from_returns(rets: np.ndarray) -> np.ndarray:
eq = np.cumprod(1.0 + np.nan_to_num(rets))
return eq
def sharpe(rets: np.ndarray, bpy: float = BARS_PER_YEAR_1H) -> float:
r = rets[np.isfinite(rets)]
if len(r) < 3 or np.std(r) == 0:
return 0.0
return float(np.mean(r) / np.std(r) * np.sqrt(bpy))
def max_dd(equity: np.ndarray) -> float:
peak = np.maximum.accumulate(equity)
dd = (peak - equity) / peak
return float(np.max(dd)) if len(dd) else 0.0
def cagr(equity: np.ndarray, ts: pd.Series) -> float:
if len(equity) < 2:
return 0.0
days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
years = days / 365.25 if days > 0 else 1.0
if years <= 0 or equity[-1] <= 0:
return -1.0
return float(equity[-1] ** (1 / years) - 1)
def daily_profit(equity: np.ndarray, ts: pd.Series, capital: float = 2000.0) -> float:
if len(equity) < 2:
return 0.0
days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
if days <= 0:
return 0.0
final = capital * equity[-1] / equity[0]
return (final - capital) / days
def yearly_returns(rets: np.ndarray, ts: pd.Series) -> dict:
eq = equity_from_returns(rets)
s = pd.Series(eq, index=pd.DatetimeIndex(ts))
out = {}
for y, g in s.groupby(s.index.year):
if len(g) > 1 and g.iloc[0] > 0:
out[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1)
return out
def stat_block(rets: np.ndarray, ts: pd.Series, bpy: float = BARS_PER_YEAR_1H) -> dict:
eq = equity_from_returns(rets)
return dict(
net=float(eq[-1] - 1.0), sharpe=sharpe(rets, bpy), max_dd=max_dd(eq),
cagr=cagr(eq, ts), eur_day=daily_profit(eq, ts), equity=eq,
turnover=float(np.mean(np.abs(np.diff(np.sign(rets) != 0)))), # placeholder, unused
)
# ===========================================================================
# RELATIVE-VALUE ENGINE — two legs, turnover-based fees, strict 1-bar shift.
# pos arrays are decided at close[i] (data<=i). Realized return on bar k uses pos[k-1].
# ===========================================================================
def pair_returns(cB: np.ndarray, cE: np.ndarray, posB: np.ndarray, posE: np.ndarray,
fee_rt: float = FEE) -> np.ndarray:
"""Per-bar net return series for a two-leg book. rets[k] realized on bar (k-1)->k.
Fee = (|ΔposB| + |ΔposE|) * fee_rt/2 charged when the position is (re)set."""
n = len(cB)
aretB = np.zeros(n); aretE = np.zeros(n)
aretB[1:] = cB[1:] / cB[:-1] - 1.0
aretE[1:] = cE[1:] / cE[:-1] - 1.0
rets = np.zeros(n)
for k in range(1, n):
gross = posB[k - 1] * aretB[k] + posE[k - 1] * aretE[k]
pBp = posB[k - 2] if k >= 2 else 0.0
pEp = posE[k - 2] if k >= 2 else 0.0
turn = abs(posB[k - 1] - pBp) + abs(posE[k - 1] - pEp)
rets[k] = gross - turn * fee_rt / 2.0
return rets
# --- signal builders: return (posB, posE) arrays, leg notional `leg` (gross = 2*leg) ---
def xs_momentum(cB, cE, N, hold, leg=0.5):
"""Cross-sectional momentum: long the asset with higher N-bar return, short the other."""
n = len(cB)
posB = np.zeros(n); posE = np.zeros(n)
curB = curE = 0.0
for i in range(n):
if i >= N and (i % hold == 0):
mB = cB[i] / cB[i - N] - 1.0
mE = cE[i] / cE[i - N] - 1.0
d = 1 if mB > mE else -1 # +1 => BTC stronger -> long BTC short ETH
curB = leg * d; curE = -leg * d
posB[i] = curB; posE[i] = curE
return posB, posE
def ratio_trend(cB, cE, N, hold, leg=0.5):
"""Trend on ETH/BTC ratio: ratio rising over N bars -> long ratio (long ETH, short BTC)."""
ratio = cE / cB
n = len(cB)
posB = np.zeros(n); posE = np.zeros(n)
curB = curE = 0.0
for i in range(n):
if i >= N and (i % hold == 0):
d = 1 if ratio[i] > ratio[i - N] else -1 # +1 => ratio up -> long ratio
curE = leg * d; curB = -leg * d
posB[i] = curB; posE[i] = curE
return posB, posE
def ratio_meanrev(cB, cE, lookback, z_in, z_exit, max_bars, leg=0.5):
"""Mean-reversion (z-fade) on log(ETH/BTC). z>+z_in -> short ratio; z<-z_in -> long ratio.
Exit when |z|<z_exit (reverted to mean) or after max_bars. Stateful, honest at close[i]."""
logr = np.log(cE / cB)
s = pd.Series(logr)
ma = s.rolling(lookback).mean().values
sd = s.rolling(lookback).std(ddof=0).values
z = (logr - ma) / sd
n = len(cB)
posB = np.zeros(n); posE = np.zeros(n)
state = 0 # +1 long ratio, -1 short ratio, 0 flat
bars_in = 0
for i in range(n):
if not np.isfinite(z[i]):
posB[i] = 0.0; posE[i] = 0.0; continue
if state == 0:
if z[i] >= z_in:
state = -1; bars_in = 0 # ratio too high -> short ratio
elif z[i] <= -z_in:
state = 1; bars_in = 0 # ratio too low -> long ratio
else:
bars_in += 1
if abs(z[i]) <= z_exit or bars_in >= max_bars or (state == 1 and z[i] >= z_in) \
or (state == -1 and z[i] <= -z_in):
state = 0
posE[i] = leg * state; posB[i] = -leg * state
return posB, posE
# ===========================================================================
# OOS / fee-sweep helpers for the relative-value sleeves
# ===========================================================================
def rv_eval(cB, cE, ts, build_fn, params, fee_rt=FEE, frac=0.65):
posB, posE = build_fn(cB, cE, **params)
rets = pair_returns(cB, cE, posB, posE, fee_rt=fee_rt)
cut = int(len(cB) * frac)
full = stat_block(rets, ts)
is_ = stat_block(rets[:cut], ts.iloc[:cut])
oos = stat_block(rets[cut:], ts.iloc[cut:])
# turnover: average per-bar leg turnover (both legs)
turn = (np.abs(np.diff(posB, prepend=0)) + np.abs(np.diff(posE, prepend=0)))
tstats = dict(rets=rets, posB=posB, posE=posE,
trades=int((turn > 1e-9).sum()), avg_turn=float(turn.mean()))
return full, is_, oos, tstats
def fmt(s):
return (f"net={s['net']*100:>+8.0f}% Sh={s['sharpe']:>+5.2f} DD={s['max_dd']*100:>4.0f}% "
f"CAGR={s['cagr']*100:>+6.1f}% €/d={s['eur_day']:>+6.2f}")
# ===========================================================================
# PART 1
# ===========================================================================
def part1_relative_value(quick=False):
print("=" * 104)
print("PART 1 — CROSS-SECTIONAL / RELATIVE-VALUE (BTC↔ETH, 1h, market-neutral spread)")
print("=" * 104)
b = load("BTC", "1h"); e = load("ETH", "1h")
m = pd.merge(b[["timestamp", "close"]], e[["timestamp", "close"]],
on="timestamp", suffixes=("_b", "_e")).reset_index(drop=True)
ts = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
cB = m["close_b"].to_numpy(float); cE = m["close_e"].to_numpy(float)
cut = int(len(m) * 0.65)
print(f" common 1h bars: {len(m)} {ts.iloc[0].date()}{ts.iloc[-1].date()} "
f"(OOS starts {ts.iloc[cut].date()})")
rb = np.log(cB[1:] / cB[:-1]); re = np.log(cE[1:] / cE[:-1])
print(f" contemporaneous corr(BTC,ETH 1h logret) = {np.corrcoef(rb, re)[0,1]:.3f} "
f"(very high → the only tradable structure is the SPREAD)")
# ---- LEAD-LAG (descriptive, both directions, IS vs OOS) ----
print("\n -- LEAD-LAG (descriptive: does last-bar move of X predict next bar of Y?) --")
def ll(a_prev, b_next):
a = a_prev[np.isfinite(a_prev) & np.isfinite(b_next)]
bb = b_next[np.isfinite(a_prev) & np.isfinite(b_next)]
return np.corrcoef(a, bb)[0, 1] if len(a) > 30 else np.nan
print(f" corr(rB[i], rE[i+1]) = {ll(rb[:-1], re[1:]):+.4f} "
f"corr(rE[i], rB[i+1]) = {ll(re[:-1], rb[1:]):+.4f}")
print(f" corr(rB[i], rB[i+1]) = {ll(rb[:-1], rb[1:]):+.4f} "
f"corr(rE[i], rE[i+1]) = {ll(re[:-1], re[1:]):+.4f}")
print(" → |lead-lag| ~0.01-0.02: NO exploitable cross-predictive edge. Not pursued as a sleeve.")
results = {}
# ---- A) XS relative momentum grid ----
print("\n -- (A) XS RELATIVE MOMENTUM: long stronger / short weaker (dollar-neutral, gross=1) --")
print(" param FULL | OOS")
Ns = [24, 72, 168, 336] if not quick else [72, 168]
holds = [6, 24, 72] if not quick else [24, 72]
best_xs = None
for N in Ns:
for hold in holds:
full, is_, oos, tstat = rv_eval(cB, cE, ts, xs_momentum, dict(N=N, hold=hold))
ok = oos["net"] > 0 and oos["sharpe"] > 0
print(f" N={N:>3} hold={hold:>2} | {fmt(full)} | OOS {fmt(oos)} "
f"tr={tstat['trades']:>4} {'OK' if ok else ''}")
if oos["net"] > 0 and (best_xs is None or oos["sharpe"] > best_xs[2]["sharpe"]):
best_xs = (dict(N=N, hold=hold), full, oos, tstat, "xs_momentum")
results["xs_momentum"] = best_xs
# ---- B) ETH/BTC ratio TREND grid ----
print("\n -- (B) ETH/BTC RATIO TREND: long ratio when rising over N (long ETH/short BTC) --")
print(" NOTE: with only TWO assets this is ALGEBRAICALLY IDENTICAL to (A) — 'long the")
print(" stronger''trade the ratio trend'. Shown separately only to make that explicit.")
best_rt = None
for N in Ns:
for hold in holds:
full, is_, oos, tstat = rv_eval(cB, cE, ts, ratio_trend, dict(N=N, hold=hold))
ok = oos["net"] > 0 and oos["sharpe"] > 0
print(f" N={N:>3} hold={hold:>2} | {fmt(full)} | OOS {fmt(oos)} "
f"tr={tstat['trades']:>4} {'OK' if ok else ''}")
if oos["net"] > 0 and (best_rt is None or oos["sharpe"] > best_rt[2]["sharpe"]):
best_rt = (dict(N=N, hold=hold), full, oos, tstat, "ratio_trend")
results["ratio_trend"] = best_rt
# ---- C) ETH/BTC ratio MEAN-REVERSION grid ----
print("\n -- (C) ETH/BTC RATIO MEAN-REVERSION: z-fade of log(ETH/BTC) --")
best_mr = None
LBs = [48, 168, 336] if not quick else [168]
zins = [1.5, 2.0, 2.5] if not quick else [2.0]
for lb in LBs:
for zin in zins:
full, is_, oos, tstat = rv_eval(cB, cE, ts, ratio_meanrev,
dict(lookback=lb, z_in=zin, z_exit=0.5, max_bars=72))
ok = oos["net"] > 0 and oos["sharpe"] > 0
print(f" lb={lb:>3} zin={zin} | {fmt(full)} | OOS {fmt(oos)} "
f"tr={tstat['trades']:>4} {'OK' if ok else ''}")
if oos["net"] > 0 and (best_mr is None or oos["sharpe"] > best_mr[2]["sharpe"]):
best_mr = (dict(lookback=lb, z_in=zin, z_exit=0.5, max_bars=72),
full, oos, tstat, "ratio_meanrev")
results["ratio_meanrev"] = best_mr
# ---- choose the single best RV sleeve (positive OOS, highest OOS Sharpe) ----
cands = [v for v in results.values() if v is not None]
cands.sort(key=lambda v: v[2]["sharpe"], reverse=True)
best = cands[0] if cands else None
print("\n -- RELATIVE-VALUE SUMMARY (best per family that is OOS net-positive) --")
for fam in ("xs_momentum", "ratio_trend", "ratio_meanrev"):
v = results[fam]
if v is None:
print(f" {fam:<14}: no OOS net-positive cell.")
else:
params, full, oos, tstat, _ = v
print(f" {fam:<14}: {params} FULL {fmt(full)} | OOS {fmt(oos)}")
if best is None:
print("\n >> NO relative-value sleeve is OOS net-positive. No RV edge to add to the ensemble.")
return None, (cB, cE, ts)
params, full, oos, tstat, fam = best
print(f"\n >> BEST RV sleeve: {fam} {params} (OOS Sharpe {oos['sharpe']:+.2f})")
# ---- per-year + fee sweep + grid-neighbourhood robustness on the winner ----
build_fn = {"xs_momentum": xs_momentum, "ratio_trend": ratio_trend,
"ratio_meanrev": ratio_meanrev}[fam]
fullr, _, _, _ = rv_eval(cB, cE, ts, build_fn, params)
print("\n per-year (full):")
yr = yearly_returns(fullr["rets"] if False else pair_returns(cB, cE,
*build_fn(cB, cE, **params)), ts)
for y in sorted(yr):
print(f" {y}: {yr[y]*100:>+7.1f}%")
print("\n fee sweep (full-sample net, baseline 0.10% RT/leg):")
for f in (0.0, 0.0005, 0.001, 0.0015, 0.002):
fr, _, fo, _ = rv_eval(cB, cE, ts, build_fn, params, fee_rt=f)
print(f" fee={f*1000:.1f}bp/leg → FULL net={fr['net']*100:>+7.0f}% "
f"OOS net={fo['net']*100:>+7.0f}% (Sh {fo['sharpe']:+.2f})")
return best, (cB, cE, ts)
# ===========================================================================
# PART 2 — ENSEMBLE
# ===========================================================================
def lr_factory():
return LogisticRegression(C=1.0, max_iter=300, class_weight="balanced")
def ml_sleeve_btc(cache=True, no_cache=False):
"""BTC low-turnover ML momentum sleeve (trackB best honest cell W16000 H24 thr0.10)."""
W, H, thr = 16000, 24, 0.10
df = load("BTC", "1h")
cpath = Path(__file__).resolve().parent / ".cache_trackE_btc_ml_proba.npy"
proba = None
if cache and not no_cache and cpath.exists():
arr = np.load(cpath)
if len(arr) == len(df):
proba = arr
print(f" [S1 ML] loaded cached proba ({cpath.name})")
if proba is None:
print(f" [S1 ML] walk-forward LogisticRegression W{W} H{H} (slow ~1-2min)...")
t0 = time.time()
X, names, fvalid = build_features(df)
warmup = int(np.argmax(fvalid)) if fvalid.any() else 0
y, _fwd, lvalid = forward_labels(df, H)
proba = walk_forward_proba(X, y, fvalid, lvalid, warmup, W, H, RETRAIN_K, lr_factory)
np.save(cpath, proba)
print(f" [S1 ML] done ({time.time()-t0:.0f}s), cached.")
n = len(df)
entries = proba_to_entries(proba, thr, H, n)
m = backtest_signals(df, entries, fee_rt=FEE, asset="BTC", tf="1h")
return m, df, f"BTC-ML W{W}H{H}thr{thr}"
def trend_sleeve_btc():
"""Trend-1h sleeve: Donchian N=200 H=12 on BTC (the only cross-asset-robust trend cell)."""
df = load("BTC", "1h")
entries = sig_donchian(df, lookback=200, hold=12)
m = backtest_signals(df, entries, fee_rt=FEE, asset="BTC", tf="1h")
return m, df, "BTC-Trend Donchian200/12"
def metrics_to_returns(m):
"""Per-bar return series from a harness Metrics equity, indexed by its timestamps."""
eq = m.equity.astype(float)
ts = m.eq_index
rets = np.zeros(len(eq))
rets[1:] = eq[1:] / np.where(eq[:-1] == 0, np.nan, eq[:-1]) - 1.0
rets = np.nan_to_num(rets)
return pd.Series(rets, index=pd.DatetimeIndex(ts))
def part2_ensemble(rv_best, rv_data, quick=False, no_cache=False):
print("\n" + "=" * 104)
print("PART 2 — ENSEMBLE (combine weakly-correlated residual sleeves into one portfolio)")
print("=" * 104)
sleeves = {} # name -> pd.Series of per-bar returns indexed by ts
# S2 trend (fast, always)
mt, dft, tname = trend_sleeve_btc()
sleeves["S2_trend"] = metrics_to_returns(mt)
print(f" [S2] {tname:<28} net={mt.net_return*100:>+7.0f}% Sh={mt.sharpe:+.2f} "
f"DD={mt.max_dd*100:.0f}% €/d={mt.daily_profit(2000):+.2f}")
# S3 relative value (from PART 1)
if rv_best is not None:
params, full, oos, tstat, fam = rv_best
cB, cE, ts = rv_data
build_fn = {"xs_momentum": xs_momentum, "ratio_trend": ratio_trend,
"ratio_meanrev": ratio_meanrev}[fam]
posB, posE = build_fn(cB, cE, **params)
rv_rets = pair_returns(cB, cE, posB, posE, fee_rt=FEE)
sleeves["S3_relval"] = pd.Series(rv_rets, index=pd.DatetimeIndex(ts))
print(f" [S3] RV {fam} {params} net={full['net']*100:>+7.0f}% "
f"Sh={full['sharpe']:+.2f} DD={full['max_dd']*100:.0f}% €/d={full['eur_day']:+.2f}")
else:
print(" [S3] no relative-value sleeve (none was OOS net-positive in PART 1).")
# S1 ML (slow; skipped in --quick)
if not quick:
m1, df1, mlname = ml_sleeve_btc(no_cache=no_cache)
sleeves["S1_ml"] = metrics_to_returns(m1)
print(f" [S1] {mlname:<28} net={m1.net_return*100:>+7.0f}% Sh={m1.sharpe:+.2f} "
f"DD={m1.max_dd*100:.0f}% €/d={m1.daily_profit(2000):+.2f}")
else:
print(" [S1] ML sleeve SKIPPED (--quick).")
# ---- align all sleeves on a common 1h timeline (BTC clock) ----
master = sleeves["S2_trend"].index
aligned = pd.DataFrame(index=master)
for name, s in sleeves.items():
aligned[name] = s.reindex(master).fillna(0.0)
# the portfolio is only meaningful where the slowest sleeve is live.
# find first bar where each sleeve has produced non-zero activity, take the max.
starts = {}
for name in aligned.columns:
nz = np.nonzero(aligned[name].to_numpy() != 0.0)[0]
starts[name] = nz[0] if len(nz) else len(aligned)
start = max(starts.values())
aligned = aligned.iloc[start:]
ts_a = pd.Series(aligned.index)
print(f"\n Common active window: {aligned.index[0].date()}{aligned.index[-1].date()} "
f"({len(aligned)} bars). Sleeves: {list(aligned.columns)}")
# ---- sleeve correlation matrix (per-bar returns over common window) ----
print("\n SLEEVE CORRELATION MATRIX (per-bar returns, common window):")
corr = aligned.corr()
cols = list(aligned.columns)
print(" " + "".join(f"{c:>10}" for c in cols))
for c in cols:
print(f" {c:>9} " + "".join(f"{corr.loc[c, c2]:>+10.3f}" for c2 in cols))
# ---- per-sleeve stats on the COMMON window (apples-to-apples) ----
print("\n PER-SLEEVE (common window, equal $ scale):")
sl_stats = {}
for c in cols:
st = stat_block(aligned[c].to_numpy(), ts_a)
sl_stats[c] = st
print(f" {c:>9}: {fmt(st)}")
# ---- ensemble: equal-weight (honest, no in-sample tuning) ----
w = 1.0 / len(cols)
ens_eq_w = aligned.to_numpy() @ (np.ones(len(cols)) * w)
ens = stat_block(ens_eq_w, ts_a)
# ---- ensemble: inverse-vol weights (flagged: weights use full-sample vol = mild IS) ----
vols = np.array([np.std(aligned[c].to_numpy()) for c in cols])
iv = (1.0 / np.where(vols == 0, np.nan, vols))
iv = np.nan_to_num(iv); iv = iv / iv.sum()
ens_iv = stat_block(aligned.to_numpy() @ iv, ts_a)
print("\n ENSEMBLE PORTFOLIO (common window):")
best_single = max(sl_stats.values(), key=lambda s: s["sharpe"])
best_single_name = max(sl_stats, key=lambda c: sl_stats[c]["sharpe"])
print(f" best single sleeve : {best_single_name} {fmt(best_single)}")
print(f" EQUAL-WEIGHT (1/N) : {fmt(ens)}")
print(f" inverse-vol (IS wts): {fmt(ens_iv)} [weights use full-sample vol — mild in-sample]")
# ---- OOS check on the ensemble (65/35 of the common window) ----
cut = int(len(ens_eq_w) * 0.65)
ens_is = stat_block(ens_eq_w[:cut], ts_a.iloc[:cut])
ens_oos = stat_block(ens_eq_w[cut:], ts_a.iloc[cut:])
print(f"\n EQUAL-WEIGHT IS : {fmt(ens_is)}")
print(f" EQUAL-WEIGHT OOS : {fmt(ens_oos)} (OOS starts {ts_a.iloc[cut].date()})")
# per-year of the equal-weight ensemble
print("\n Equal-weight ensemble per-year:")
for y, v in sorted(yearly_returns(ens_eq_w, ts_a).items()):
print(f" {y}: {v*100:>+7.1f}%")
# ---- verdict on diversification ----
print("\n DIVERSIFICATION CHECK:")
print(f" ensemble Sharpe {ens['sharpe']:+.2f} vs best single {best_single['sharpe']:+.2f} "
f"({'BEATS' if ens['sharpe'] > best_single['sharpe'] else 'does NOT beat'} best single)")
print(f" ensemble maxDD {ens['max_dd']*100:.0f}% vs best single {best_single['max_dd']*100:.0f}% "
f"({'LOWER' if ens['max_dd'] < best_single['max_dd'] else 'NOT lower'} than best single)")
# RISK-MATCHED: lever the ensemble to the best-single maxDD, compare €/day at equal risk.
# (Sharpe is leverage-invariant; this isolates 'more return per unit of drawdown'.)
if ens["max_dd"] > 0 and best_single["eur_day"] != 0:
lev = best_single["max_dd"] / ens["max_dd"]
rm = stat_block(ens_eq_w * lev, ts_a)
print(f" RISK-MATCHED: lever ensemble {lev:.2f}x to ~{best_single['max_dd']*100:.0f}% DD "
f"→ €/d={rm['eur_day']:+.2f} (DD {rm['max_dd']*100:.0f}%) vs best-single €/d={best_single['eur_day']:+.2f}")
print(f" → at equal drawdown the ensemble earns "
f"{'MORE' if rm['eur_day'] > best_single['eur_day'] else 'LESS'} than the best single sleeve "
f"(ratio {rm['eur_day']/best_single['eur_day']:.2f}); this tracks the Sharpe ratio.")
if ens["eur_day"] > 0:
print(f" ensemble €/day(2k) {ens['eur_day']:+.2f} vs target ~50.00 "
f"→ ~{(50.0/ens['eur_day']):.0f}x short of the goal.")
else:
print(" ensemble €/day(2k) <= 0 → no earning engine.")
return ens, sl_stats, corr
# ===========================================================================
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--quick", action="store_true", help="skip slow ML sleeve + smaller RV grid")
ap.add_argument("--no-cache", action="store_true", help="recompute ML walk-forward proba")
args = ap.parse_args()
t0 = time.time()
rv_best, rv_data = part1_relative_value(quick=args.quick)
part2_ensemble(rv_best, rv_data, quick=args.quick, no_cache=args.no_cache)
print(f"\n(elapsed {time.time()-t0:.0f}s)")
print("\n" + "=" * 104)
print("See docs/diary/2026-06-19-trackE-xsec-ensemble.md for the full honest write-up.")
print("=" * 104)
if __name__ == "__main__":
main()
+249
View File
@@ -0,0 +1,249 @@
"""HONEST BACKTEST HARNESS — universo certificato BTC/ETH (Deribit mainnet).
Foundation per la ricerca post-reset (2026-06-19). Tutte le strategie nuove devono
usare QUESTO harness per garantire:
1. NESSUN look-ahead: la direzione e il prezzo d'ingresso si decidono con dati fino
a close[i] incluso, e si ENTRA a close[i] (la barra successiva, i+1, e' la prima
in cui si e' realmente in posizione). L'exit intrabar guarda high/low di i+1..
2. Fee realistiche Deribit: 0.10% round-trip (taker) di default.
3. Metriche oneste: equity compounding, CAGR, Sharpe (da rendimenti per-barra),
max drawdown, per-anno, e split OOS.
Convenzione segnali (entry-eseguibile):
Una strategia produce, per ogni indice i, un dict opzionale:
{'dir': +1/-1, 'tp': prezzo|None, 'sl': prezzo|None, 'max_bars': int|None}
decidendo SOLO con dati [.. i] (close[i] incluso). L'engine apre a close[i] e
gestisce l'uscita dalle barre i+1 in poi (TP/SL intrabar al livello, SL prioritario;
altrimenti max_bars al close).
Uso tipico:
from src.backtest.harness import load, backtest_signals, Metrics
df = load("BTC", "1h")
entries = my_signal_fn(df) # list[dict|None] lunga len(df)
m = backtest_signals(df, entries, fee_rt=0.001, leverage=1.0)
m.print_summary("MYSTRAT BTC 1h")
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
from src.data.downloader import load_data
CERTIFIED = {"BTC", "ETH"}
def load(asset: str, tf: str) -> pd.DataFrame:
"""Carica un feed certificato. Solleva su asset non certificato (guardrail fisico)."""
if asset.upper() not in CERTIFIED:
raise ValueError(f"Asset non certificato: {asset}. Universo = {CERTIFIED}.")
df = load_data(asset, tf).reset_index(drop=True)
df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
return df
# ---------------------------------------------------------------------------
# Metriche
# ---------------------------------------------------------------------------
@dataclass
class Metrics:
asset: str = ""
tf: str = ""
n_trades: int = 0
wins: int = 0
net_return: float = 0.0 # ritorno totale frazionale (final/initial - 1)
cagr: float = 0.0
sharpe: float = 0.0 # annualizzato dai rendimenti per-barra dell'equity
max_dd: float = 0.0 # frazione (0.10 = 10%)
time_in_market: float = 0.0 # frazione barre in posizione
avg_bars: float = 0.0
final_capital: float = 0.0
initial_capital: float = 0.0
bars_per_year: float = 0.0
yearly: dict = field(default_factory=dict) # year -> net return frazionale dell'anno
equity: np.ndarray = field(default_factory=lambda: np.array([]))
eq_index: pd.DatetimeIndex | None = None
@property
def win_rate(self) -> float:
return self.wins / self.n_trades * 100 if self.n_trades else 0.0
@property
def profit_per_day_on(self, capital: float = 2000.0) -> float: # placeholder
return 0.0
def daily_profit(self, capital: float = 2000.0) -> float:
"""€/giorno medio se partito con `capital` (su tutto lo span, compounding incluso)."""
if self.eq_index is None or len(self.equity) < 2:
return 0.0
idx = self.eq_index
days = (idx.iloc[-1] - idx.iloc[0]).total_seconds() / 86400 if hasattr(idx, "iloc") \
else (idx[-1] - idx[0]).total_seconds() / 86400
if days <= 0:
return 0.0
final = capital * (self.final_capital / self.initial_capital)
return (final - capital) / days
def print_summary(self, label: str = ""):
print(f" {label:<26s} trades={self.n_trades:>5d} wr={self.win_rate:>4.1f}% "
f"ret={self.net_return*100:>+8.0f}% CAGR={self.cagr*100:>+6.1f}% "
f"Sharpe={self.sharpe:>5.2f} DD={self.max_dd*100:>4.1f}% "
f"mkt={self.time_in_market*100:>4.0f}% €/d(2k)={self.daily_profit(2000):>+6.2f}")
def print_yearly(self):
for y in sorted(self.yearly):
print(f" {y}: {self.yearly[y]*100:>+7.1f}%")
def _sharpe(equity: np.ndarray, bars_per_year: float) -> float:
if len(equity) < 3:
return 0.0
r = np.diff(equity) / equity[:-1]
r = r[np.isfinite(r)]
if len(r) == 0 or np.std(r) == 0:
return 0.0
return float(np.mean(r) / np.std(r) * np.sqrt(bars_per_year))
def _max_dd(equity: np.ndarray) -> float:
peak = np.maximum.accumulate(equity)
dd = (peak - equity) / peak
return float(np.max(dd)) if len(dd) else 0.0
def backtest_signals(
df: pd.DataFrame,
entries: list,
fee_rt: float = 0.001,
leverage: float = 1.0,
position_size: float = 1.0,
initial_capital: float = 1000.0,
allow_overlap: bool = False,
asset: str = "",
tf: str = "",
) -> Metrics:
"""Esegue il backtest su una lista di entry-dict (uno per barra, None = niente segnale).
entry dict: {'dir': +1/-1, 'tp': float|None, 'sl': float|None, 'max_bars': int|None}
- apertura a close[i] (decisa con dati <= i)
- exit dalle barre i+1.. : TP/SL toccati intrabar (al livello, SL prioritario),
altrimenti chiusura al close dopo max_bars (default 24 se assente).
- non si apre una nuova posizione finche' la precedente non e' chiusa (allow_overlap=False).
- PnL compounding: ogni trade muove capital di position_size * leverage * (ret_netto).
"""
c = df["close"].values.astype(float)
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
capital = float(initial_capital)
equity = np.full(n, capital, dtype=float)
yearly: dict[int, float] = {}
yearly_start: dict[int, float] = {}
n_trades = wins = 0
bars_in_market = 0
bars_sum = 0
i = 0
busy_until = -1
for i in range(n):
e = entries[i] if i < len(entries) else None
if e is None or e.get("dir", 0) == 0:
equity[i] = capital
continue
if not allow_overlap and i <= busy_until:
equity[i] = capital
continue
direction = int(e["dir"])
entry = c[i]
tp = e.get("tp")
sl = e.get("sl")
max_bars = int(e.get("max_bars") or 24)
exit_price = c[min(i + max_bars, n - 1)]
exit_idx = min(i + max_bars, n - 1)
for j in range(i + 1, min(i + max_bars + 1, n)):
hit_sl = sl is not None and (
(direction == 1 and l[j] <= sl) or (direction == -1 and h[j] >= sl))
hit_tp = tp is not None and (
(direction == 1 and h[j] >= tp) or (direction == -1 and l[j] <= tp))
if hit_sl:
exit_price = sl
exit_idx = j
break
if hit_tp:
exit_price = tp
exit_idx = j
break
exit_price = c[j]
exit_idx = j
gross = (exit_price - entry) / entry * direction
net = gross * leverage - fee_rt * leverage
capital += capital * position_size * net
capital = max(capital, 1.0)
year = ts.iloc[i].year
if year not in yearly_start:
yearly_start[year] = capital / (1 + position_size * net) if (1 + position_size * net) else capital
n_trades += 1
if gross > 0:
wins += 1
bars = exit_idx - i
bars_in_market += bars
bars_sum += bars
busy_until = exit_idx
# propaga equity fino a exit_idx (mark a fine trade, semplice ma onesto a livello trade)
equity[i:exit_idx + 1] = capital
# riempi i buchi finali
for k in range(1, n):
if equity[k] == initial_capital and equity[k - 1] != initial_capital:
equity[k] = equity[k - 1]
# forward fill robusto
last = initial_capital
for k in range(n):
if equity[k] != last and equity[k] != initial_capital:
last = equity[k]
else:
equity[k] = last
# per-anno dal vettore equity
eq_s = pd.Series(equity, index=ts)
yearly_ret = {}
for y, grp in eq_s.groupby(eq_s.index.year):
if len(grp) > 1 and grp.iloc[0] > 0:
yearly_ret[int(y)] = float(grp.iloc[-1] / grp.iloc[0] - 1)
span_days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
years = span_days / 365.25 if span_days > 0 else 1.0
bars_per_year = n / years if years > 0 else n
cagr = (capital / initial_capital) ** (1 / years) - 1 if years > 0 and capital > 0 else -1.0
return Metrics(
asset=asset, tf=tf,
n_trades=n_trades, wins=wins,
net_return=capital / initial_capital - 1,
cagr=cagr,
sharpe=_sharpe(equity, bars_per_year),
max_dd=_max_dd(equity),
time_in_market=bars_in_market / n if n else 0.0,
avg_bars=bars_sum / n_trades if n_trades else 0.0,
final_capital=capital,
initial_capital=initial_capital,
bars_per_year=bars_per_year,
yearly=yearly_ret,
equity=equity,
eq_index=ts,
)
def oos_split(df: pd.DataFrame, frac: float = 0.65):
"""Indice di taglio IS/OOS (default 65% in-sample)."""
return int(len(df) * frac)
+198
View File
@@ -0,0 +1,198 @@
"""TREND PORTFOLIO (TP01) — l'UNICA strategia profittevole e robusta post-reset (2026-06-19).
Vincitrice della ricerca su dati certificati BTC/ETH (Deribit mainnet). TSMOM multi-orizzonte
(1-3-6 mesi) vol-targeted, portafoglio 50/50 BTC+ETH. Validata onestamente (no look-ahead,
fee 0.10% RT, positiva ogni anno 2019-2026, robusta su griglia e su tutti i timeframe 15m-1d).
Config canonica deployabile (PORT LF1d):
timeframe >=12h (1d RACCOMANDATO), LONG-FLAT (niente short), vol-target 20%, leverage cap 2x.
-> FULL Sharpe ~1.30, maxDD ~14%, HOLD-OUT 2025-26 Sharpe ~0.31 (calcolo per-TF leak-free).
NB LOOK-AHEAD (2026-06-19): un ffill MIXED-TIMEFRAME su barre open-labeled (label="left")
gonfiava il 4h (~1.60 -> reale ~1.1). Il calcolo per-SINGOLO-TF e' leak-free (guard
prefix-recompute), ma sotto le 12h costi+overfitting dominano SENZA vantaggio reale (FULL Sh
piatto ~1.3 da 12h a 4h; hold-out MIGLIORE a 1d). -> NON scendere sotto le 12h; deploy a 1d.
TP01 e' DIFENSIVA (taglia il DD ~6x vs buy&hold), NON alpha. Vedi
docs/diary/2026-06-19-tp01-lookahead-fix-lf.md e scripts/analysis/tp01_lowfreq.py.
API (tutto causale, decide con dati <= close[i]):
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL
tp = TrendPortfolio(**CANONICAL)
targets = tp.target_series(df_4h) # array posizioni-bersaglio (frazione di equity, +/-)
w = tp.current_target(df_4h) # ultima posizione-bersaglio (per il live)
res = tp.backtest_portfolio({'BTC': df_btc_4h, 'ETH': df_eth_4h}) # metriche onesta
NB: il vero "trade" e' un cambio di posizione; turnover basso (~37 ingressi/anno a 4h).
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
# config canonica raccomandata per il deploy
CANONICAL = dict(
target_vol=0.20,
leverage=2.0,
long_only=True, # LONG-FLAT
horizons_days=(30, 90, 180),
vol_win_days=30,
fee_side=0.0005, # 0.05%/lato = 0.10% RT (Deribit taker)
)
# variante headline long-short a 1h (riferimento storico, Sharpe ~1.0)
HEADLINE_LS_1H = dict(
target_vol=0.20, leverage=2.0, long_only=False,
horizons_days=(30, 90, 180), vol_win_days=30, fee_side=0.0005,
)
BARS_PER_DAY = {"5m": 288, "15m": 96, "1h": 24, "4h": 6, "1d": 1}
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, bars_per_year: float) -> np.ndarray:
"""Vol realizzata annualizzata dai rendimenti fino a i incluso (nessun leakage)."""
return pd.Series(r).rolling(win, min_periods=win // 2).std().values * np.sqrt(bars_per_year)
def tsmom_blend(c: np.ndarray, horizons: tuple[int, ...]) -> np.ndarray:
"""Media dei sign(close[i]/close[i-h]-1) sugli orizzonti -> direzione in [-1, 1]."""
n = len(c)
acc = np.zeros(n)
cnt = np.zeros(n)
for h in horizons:
s = np.full(n, np.nan)
s[h:] = np.sign(c[h:] / c[:-h] - 1.0)
valid = np.isfinite(s)
acc[valid] += s[valid]
cnt[valid] += 1
out = np.zeros(n)
nz = cnt > 0
out[nz] = acc[nz] / cnt[nz]
return out
@dataclass
class TrendPortfolio:
target_vol: float = 0.20
leverage: float = 2.0
long_only: bool = True
horizons_days: tuple[int, ...] = (30, 90, 180)
vol_win_days: int = 30
fee_side: float = 0.0005
def _bpd(self, df: pd.DataFrame) -> int:
"""Inferisce barre/giorno dalla mediana del passo temporale."""
dt = pd.to_datetime(df["datetime"]).diff().dt.total_seconds().median()
return max(1, round(86400 / dt))
def target_series(self, df: pd.DataFrame) -> np.ndarray:
"""Posizione-bersaglio per barra (frazione di equity, segno = direzione).
target[i] usa SOLO dati <= close[i] -> va TENUTA durante la barra i+1."""
c = df["close"].values.astype(float)
bpd = self._bpd(df)
bpy = bpd * 365.25
r = simple_returns(c)
vol = realized_vol(r, self.vol_win_days * bpd, bpy)
horizons = tuple(d * bpd for d in self.horizons_days)
direction = tsmom_blend(c, horizons)
if self.long_only:
direction = np.clip(direction, 0, None)
scal = np.where((vol > 0) & np.isfinite(vol), self.target_vol / vol, 0.0)
tgt = np.clip(direction * scal, -self.leverage, self.leverage)
tgt[~np.isfinite(tgt)] = 0.0
return tgt
def current_target(self, df: pd.DataFrame) -> float:
"""Posizione-bersaglio decisa all'ultima barra CHIUSA (per il paper/live)."""
return float(self.target_series(df)[-1])
def net_returns(self, df: pd.DataFrame) -> tuple[np.ndarray, pd.Series]:
"""Rendimenti netti per barra di un singolo sleeve (no look-ahead, fee su turnover)."""
c = df["close"].values.astype(float)
r = simple_returns(c)
tgt = self.target_series(df)
pos_held = np.zeros(len(tgt))
pos_held[1:] = tgt[:-1] # tenuta durante barra t = decisa a close[t-1]
gross = pos_held * r
turn = np.abs(np.diff(pos_held, prepend=0.0))
net = gross - self.fee_side * turn
net[0] = 0.0
net = np.clip(net, -0.99, None)
return net, pd.to_datetime(df["datetime"])
def backtest_portfolio(self, dfs: dict[str, pd.DataFrame],
weights: dict[str, float] | None = None) -> dict:
"""Backtest del portafoglio equal-weight (default 50/50) sui timestamp comuni."""
weights = weights or {a: 1.0 / len(dfs) for a in dfs}
series = {}
for a, df in dfs.items():
net, ts = self.net_returns(df)
series[a] = pd.Series(net, index=pd.to_datetime(ts.values))
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
combo = sum(weights[a] * J[a].values for a in dfs)
idx = J.index
equity = np.cumprod(1.0 + np.clip(combo, -0.99, None))
return _metrics(equity, combo, idx)
def _metrics(equity: np.ndarray, combo: np.ndarray, idx: pd.DatetimeIndex) -> dict:
bpy = _bars_per_year(idx)
rr = combo[np.isfinite(combo)]
sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)) if np.std(rr) > 0 else 0.0
peak = np.maximum.accumulate(equity)
dd = float(np.max((peak - equity) / peak))
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]
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)] = dict(pnl=float(g.iloc[-1] / g.iloc[0] - 1),
dd=float(np.max((pk - v) / pk)))
return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total_return=total - 1,
yearly=yearly, equity=equity, index=idx)
def _bars_per_year(idx: pd.DatetimeIndex) -> float:
if len(idx) < 2:
return 365.25
dt = pd.Series(idx).diff().dt.total_seconds().median()
return 86400 * 365.25 / dt if dt and dt > 0 else 365.25
def resample_tf(df_1h: pd.DataFrame, rule: str) -> pd.DataFrame:
"""Resample 1h -> rule (confini 00:00 UTC). Schema con 'datetime'.
NB: usare SOLO per-singolo-TF (qui leak-free); MAI ffill/combine mixed-TF su questi
timestamp open-labeled (label='left') -> look-ahead. Deploy a >=12h (vedi docstring modulo)."""
g = df_1h.copy()
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
idx.name = "dt"
g.index = idx
out = g.resample(rule, label="left", closed="left").agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
out = out.dropna(subset=["open"])
out["datetime"] = out.index
epoch = pd.Timestamp("1970-01-01", tz="UTC")
out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
return out.reset_index(drop=True)[["timestamp", "open", "high", "low", "close", "volume", "datetime"]]
def resample_1d(df_1h: pd.DataFrame) -> pd.DataFrame:
"""TF canonico di deploy (>=12h). Resample 1h -> 1d."""
return resample_tf(df_1h, "1D")
def resample_4h(df_1h: pd.DataFrame) -> pd.DataFrame:
"""DEPRECATO per il deploy (sotto le 12h: costi+overfit dominano). Retro-compat ricerca."""
return resample_tf(df_1h, "4h")
+73
View File
@@ -0,0 +1,73 @@
"""Test della strategia vincente TP01 (trend portfolio) e del loop paper."""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from src.backtest.harness import load
from src.strategies.trend_portfolio import (
TrendPortfolio, CANONICAL, resample_4h, simple_returns, tsmom_blend)
def _dfs():
return {a: resample_4h(load(a, "1h")) for a in ("BTC", "ETH")}
def test_no_lookahead_target_is_causal():
"""target_series[:k] non deve cambiare se aggiungo barre future."""
df = resample_4h(load("BTC", "1h"))
tp = TrendPortfolio(**CANONICAL)
full = tp.target_series(df)
k = len(df) - 500
partial = tp.target_series(df.iloc[:k].reset_index(drop=True))
# le ultime 200 posizioni del troncato devono combaciare col full (warmup a parte)
assert np.allclose(full[k - 200:k], partial[-200:], atol=1e-9)
def test_canonical_backtest_is_profitable_and_robust():
tp = TrendPortfolio(**CANONICAL)
r = tp.backtest_portfolio(_dfs())
assert r["cagr"] > 0.10, f"CAGR troppo basso: {r['cagr']}"
assert r["sharpe"] > 1.1, f"Sharpe troppo basso: {r['sharpe']}"
assert r["max_dd"] < 0.25, f"maxDD troppo alto: {r['max_dd']}"
# ogni anno (2019-2025 completi) non deve perdere piu' del 5%
for y, d in r["yearly"].items():
if 2019 <= y <= 2025:
assert d["pnl"] > -0.05, f"anno {y} troppo negativo: {d['pnl']}"
def test_long_only_never_short():
df = resample_4h(load("ETH", "1h"))
tp = TrendPortfolio(**CANONICAL) # long_only=True
assert (tp.target_series(df) >= 0).all()
def test_paper_advance_matches_backtest_slice():
"""Il loop paper incrementale deve riprodurre l'equity del backtest su una fetta."""
dfs = _dfs()
tp = TrendPortfolio(**CANONICAL)
# backtest portfolio reference (combina i net per timestamp comune)
series = {}
for a, df in dfs.items():
net, ts = tp.net_returns(df)
series[a] = pd.Series(net, index=pd.to_datetime(ts.values))
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
combo = 0.5 * J["BTC"].values + 0.5 * J["ETH"].values
# equity sull'ultimo tratto (skip warmup)
tail = combo[-500:]
eq_ref = np.cumprod(1.0 + np.clip(tail, -0.99, None))
# ricostruzione "alla paper" deve dare lo stesso fattore
factor = float(eq_ref[-1] / eq_ref[0])
assert factor > 0
# sanity: il fattore equivale al prodotto dei (1+combo)
assert np.isclose(factor, np.prod(1.0 + np.clip(tail, -0.99, None)) / (1.0), rtol=1e-9)
def test_tsmom_blend_range():
c = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 5000))
b = tsmom_blend(c, (30, 90, 180))
assert b.min() >= -1.0 and b.max() <= 1.0