diff --git a/.gitignore b/.gitignore index 67c6c66..bdb86e4 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ data/games/ # archived data (mirrors top-level data/ ignores, which are top-level-anchored) Old/data/ Old/**/__pycache__/ + +# run logs (rigenerabili dagli script) +logs/ diff --git a/docs/diary/2026-06-19-fractal-multiagent-search.md b/docs/diary/2026-06-19-fractal-multiagent-search.md new file mode 100644 index 0000000..a03e99c --- /dev/null +++ b/docs/diary/2026-06-19-fractal-multiagent-search.md @@ -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`. diff --git a/docs/diary/2026-06-19-research-phase0-1.md b/docs/diary/2026-06-19-research-phase0-1.md new file mode 100644 index 0000000..710709a --- /dev/null +++ b/docs/diary/2026-06-19-research-phase0-1.md @@ -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. diff --git a/docs/diary/2026-06-19-research-phase2-options.md b/docs/diary/2026-06-19-research-phase2-options.md new file mode 100644 index 0000000..e45bd92 --- /dev/null +++ b/docs/diary/2026-06-19-research-phase2-options.md @@ -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. diff --git a/docs/diary/2026-06-19-research-phase3-confirm.md b/docs/diary/2026-06-19-research-phase3-confirm.md new file mode 100644 index 0000000..0a89ddc --- /dev/null +++ b/docs/diary/2026-06-19-research-phase3-confirm.md @@ -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. diff --git a/scripts/analysis/eval_signal.py b/scripts/analysis/eval_signal.py new file mode 100644 index 0000000..c6d8fdb --- /dev/null +++ b/scripts/analysis/eval_signal.py @@ -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 <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() diff --git a/scripts/analysis/options_analysis.py b/scripts/analysis/options_analysis.py new file mode 100644 index 0000000..eca974a --- /dev/null +++ b/scripts/analysis/options_analysis.py @@ -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() diff --git a/scripts/analysis/phase1_survivors.py b/scripts/analysis/phase1_survivors.py new file mode 100644 index 0000000..8b1105d --- /dev/null +++ b/scripts/analysis/phase1_survivors.py @@ -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() diff --git a/scripts/analysis/phase2_families.py b/scripts/analysis/phase2_families.py new file mode 100644 index 0000000..2375fe6 --- /dev/null +++ b/scripts/analysis/phase2_families.py @@ -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() diff --git a/scripts/analysis/phase3_confirm.py b/scripts/analysis/phase3_confirm.py new file mode 100644 index 0000000..9ddec79 --- /dev/null +++ b/scripts/analysis/phase3_confirm.py @@ -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() diff --git a/scripts/analysis/research_lab.py b/scripts/analysis/research_lab.py new file mode 100644 index 0000000..5aefc2f --- /dev/null +++ b/scripts/analysis/research_lab.py @@ -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()