Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12754c4908 | |||
| 756a2bdf04 | |||
| d152941360 | |||
| 55c28e51b2 | |||
| 38c8cdf25b |
@@ -43,4 +43,12 @@ data/games/
|
||||
# archived data (mirrors top-level data/ ignores, which are top-level-anchored)
|
||||
Old/data/
|
||||
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/
|
||||
|
||||
@@ -21,12 +21,19 @@ Cosa è cambiato:
|
||||
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`.
|
||||
|
||||
- **VINCITRICE (l'unica robusta e profittevole): TP01 Trend Portfolio** —
|
||||
`src/strategies/trend_portfolio.py`. TSMOM multi-orizzonte (1-3-6 mesi) vol-targeted,
|
||||
50/50 BTC+ETH. Config canonica **PORT LF4h** (4h, long-flat, vol-target 20%, leva cap 2x):
|
||||
**CAGR ~16.6%, Sharpe ~1.32-1.36, maxDD ~12-14%, positiva ogni anno 2019-2026**.
|
||||
Robusta su tutti i TF (15m-1d), regge fee fino a 0.40% RT, su entrambi gli asset.
|
||||
Paper trader: `scripts/live/paper_trend.py`. Test: `tests/test_trend_portfolio.py`.
|
||||
- **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).
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"capital": 2000.0,
|
||||
"initial_capital": 2000.0,
|
||||
"start_ts": 1781884800000,
|
||||
"last_ts": 1781884800000,
|
||||
"positions": {
|
||||
"BTC": 0.0,
|
||||
"ETH": 0.0
|
||||
},
|
||||
"n_bars": 0,
|
||||
"peak": 2000.0,
|
||||
"max_dd": 0.0
|
||||
}
|
||||
@@ -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,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.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
+13
-12
@@ -1,14 +1,14 @@
|
||||
"""PAPER TRADER — TP01 Trend Portfolio (PORT LF4h), forward-only, simulato.
|
||||
"""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 4h. Stato persistente -> resume al riavvio.
|
||||
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 4h.
|
||||
- Alla prima esecuzione parte dall'ultima barra 4h CHIUSA disponibile (forward-only:
|
||||
- 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 4h chiuse dall'ultima volta: applica il rendimento
|
||||
- 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:
|
||||
@@ -33,7 +33,7 @@ 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_4h, simple_returns
|
||||
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"
|
||||
@@ -43,8 +43,9 @@ WEIGHT = 0.5
|
||||
INITIAL_CAPITAL = 2000.0
|
||||
|
||||
|
||||
def build_4h() -> dict[str, pd.DataFrame]:
|
||||
return {a: resample_4h(load(a, "1h")) for a in ASSETS}
|
||||
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:
|
||||
@@ -80,7 +81,7 @@ def init_state(dfs) -> dict:
|
||||
|
||||
|
||||
def advance(st: dict, dfs: dict) -> dict:
|
||||
"""Processa tutte le barre 4h chiuse DOPO st['last_ts']."""
|
||||
"""Processa tutte le barre 1d chiuse DOPO st['last_ts']."""
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
# precompute per-asset: timestamps, returns, target series (causale)
|
||||
data = {}
|
||||
@@ -144,10 +145,10 @@ def print_status(st: dict, dfs: dict):
|
||||
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 LF4h, 50/50 BTC+ETH, 4h)")
|
||||
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 4h)")
|
||||
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 '' }")
|
||||
@@ -168,7 +169,7 @@ def print_status(st: dict, dfs: dict):
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
dfs = build_4h()
|
||||
dfs = build_bars()
|
||||
if "--reset" in argv:
|
||||
if STATE_FILE.exists():
|
||||
STATE_FILE.unlink()
|
||||
|
||||
@@ -4,13 +4,16 @@ Vincitrice della ricerca su dati certificati BTC/ETH (Deribit mainnet). TSMOM mu
|
||||
(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 LF4h):
|
||||
timeframe 4h, LONG-FLAT (niente short), vol-target 20%, leverage cap 2x.
|
||||
-> CAGR ~16.6%, Sharpe ~1.32, maxDD ~12.3% (backtest 2019-2026 su 50/50 BTC+ETH).
|
||||
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).
|
||||
|
||||
Perche' long-flat e 4h: gli short del trend rendono meno e aggiungono DD; il 4h e' il punto
|
||||
dolce (meno rumore/fee del 15m, meno lag dell'1d). Vedi docs/diary/2026-06-19-research-synthesis.md
|
||||
e scripts/research/trackD_*.py.
|
||||
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
|
||||
@@ -168,16 +171,28 @@ def _bars_per_year(idx: pd.DatetimeIndex) -> float:
|
||||
return 86400 * 365.25 / dt if dt and dt > 0 else 365.25
|
||||
|
||||
|
||||
def resample_4h(df_1h: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Resample 1h -> 4h (confini 00:00 UTC). Schema con 'datetime'."""
|
||||
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("4h", label="left", closed="left").agg(
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user