Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a60ad30ac0 | |||
| bd31a15548 | |||
| 4dc0e77ee5 | |||
| 9a066eb76f | |||
| 7226946911 | |||
| 945c2a2db6 | |||
| 33e3e2a603 | |||
| 666c906907 | |||
| 9ebdfc7a7a | |||
| bcccfde9a0 | |||
| 1af4addbdd | |||
| 22c6080873 | |||
| fd547748dd | |||
| cff0d08fca | |||
| 21d3ba609d |
@@ -24,10 +24,12 @@ src/strategies/ → classe base Strategy ABC + indicatori condivisi
|
|||||||
base.py → Strategy, Signal, BacktestResult, YearlyStats
|
base.py → Strategy, Signal, BacktestResult, YearlyStats
|
||||||
indicators.py → keltner_ratio, detect_squeezes, ema, atr, rv, correlation
|
indicators.py → keltner_ratio, detect_squeezes, ema, atr, rv, correlation
|
||||||
src/live/ → paper trading live multi-strategia
|
src/live/ → paper trading live multi-strategia
|
||||||
multi_runner.py → orchestratore: carica YAML, fetch candele, tick worker
|
multi_runner.py → orchestratore: carica YAML (strategies + pairs), fetch candele, tick worker
|
||||||
strategy_worker.py → worker indipendente: capital, trade log, stato persistente.
|
strategy_worker.py → worker single-leg: capital, trade log, stato persistente.
|
||||||
Exit guidati da strategia (TP/SL/max_bars via Signal.metadata),
|
Exit guidati da strategia (TP/SL/max_bars via Signal.metadata),
|
||||||
fallback hold_bars/stop -2%. Usa fee_rt della strategia.
|
fallback hold_bars/stop -2%. Usa fee_rt della strategia.
|
||||||
|
pairs_worker.py → worker a 2 GAMBE per PR01 (market-neutral): long A / short B sullo
|
||||||
|
z-score del log-ratio, exit |z|<=z_exit o max_bars, fee su 2 gambe.
|
||||||
strategy_loader.py → import dinamico classi Strategy da scripts/strategies/
|
strategy_loader.py → import dinamico classi Strategy da scripts/strategies/
|
||||||
cerbero_client.py → client HTTP per Cerbero MCP (Deribit testnet)
|
cerbero_client.py → client HTTP per Cerbero MCP (Deribit testnet)
|
||||||
signal_engine.py → squeeze + ML real-time (legacy ML01, ora in waste) + validazione OOS
|
signal_engine.py → squeeze + ML real-time (legacy ML01, ora in waste) + validazione OOS
|
||||||
@@ -82,16 +84,101 @@ Token observer: nel file `secrets/observer.token` del progetto CerberoSuite.
|
|||||||
> e `intrabar_test.py`.
|
> e `intrabar_test.py`.
|
||||||
|
|
||||||
Tutte le strategie estendono `src.strategies.base.Strategy`
|
Tutte le strategie estendono `src.strategies.base.Strategy`
|
||||||
(`generate_signals() → backtest()`). **Unica strategia con edge netto validato:**
|
(`generate_signals() → backtest()`). Le strategie mean-reversion condividono
|
||||||
|
`src.strategies.fade_base.FadeStrategy` (backtest intrabar TP/SL/max_bars).
|
||||||
|
**Strategie con edge netto validato OOS fee-aware (tutte fade/mean-reversion):**
|
||||||
|
|
||||||
| Codice | Nome | Tipo | Edge OOS netto | DD | Note |
|
| Codice | Nome | Meccanismo | Edge OOS netto (1h, fee 0.10% RT) | DD | Note |
|
||||||
|--------|------|------|----------------|----|------|
|
|--------|------|-----------|-----------------------------------|----|------|
|
||||||
| **MR01** | Bollinger Fade | Mean-reversion | **BTC 1h n50 k2.5: +201% / +196% (worker)** | 15% | Fada la banda, TP alla media, SL ad ATR |
|
| **MR01** | Bollinger Fade | banda std attorno a SMA | BTC +201% / ETH +1238% | 15-72% | Fada la banda, TP alla media, SL ad ATR |
|
||||||
|
| **MR02** | Donchian Fade | estremi canale H/L | BTC +172% / ETH enorme | 30-42% | Fada la rottura del canale, TP al centro |
|
||||||
|
| **MR07** | Return Reversal | z dei rendimenti di barra | BTC +105% / ETH +195% | 25-46% | Fada il movimento estremo, exit in ATR; esposizione ~8% |
|
||||||
|
|
||||||
MR01 è robusto su **tutta** la griglia parametri (`n∈{14,20,30,50}` × `k∈{2.0,2.5,3.0}`,
|
> **MR03 Keltner Fade** spostata in `scripts/waste/`: era la fade più debole
|
||||||
entrambi gli asset → tutte positive OOS) e su **tutte** le fee 0.00-0.20% RT.
|
> (BTC Sharpe 1.22, il filtro trend la peggiorava) e ridondante con MR01 (stessa
|
||||||
Validato col worker reale: BTC +196% / ETH +251% OOS (nov 2023→mag 2026).
|
> idea di banda). Rimuoverla dal portafoglio ne ha *migliorato* le metriche.
|
||||||
Ricerca completa: `scripts/analysis/strategy_research.py`.
|
> La funzione `keltner_fade` resta in `strategy_research_v2.py` come record.
|
||||||
|
|
||||||
|
**Lezione confermata:** l'edge è sempre *mean-reversion* (i breakout rientrano).
|
||||||
|
Il trend-following (Donchian trend, RSI cross) e gli oscillatori senza filtro
|
||||||
|
(RSI revert, ADX-filtered fade) perdono netti → restano scartati.
|
||||||
|
|
||||||
|
Ogni strategia è robusta su **tutta** la sua griglia parametri (entrambi gli asset
|
||||||
|
→ tutte positive OOS) e su **tutte** le fee 0.00-0.20% RT (margine ampio).
|
||||||
|
MR01 validato col worker reale: BTC +196% / ETH +251% OOS (nov 2023→mag 2026).
|
||||||
|
Ricerca completa: `scripts/analysis/strategy_research.py` (MR01) e
|
||||||
|
`scripts/analysis/strategy_research_v2.py` (MR02/MR03/MR07).
|
||||||
|
Validazione live-path: `scripts/analysis/oos_validation.py`.
|
||||||
|
|
||||||
|
**Filtro trend (riduzione DD + aumento Acc).** Tutte le fade accettano i parametri
|
||||||
|
opzionali `trend_max` / `ema_long`: saltano i segnali quando il prezzo è troppo
|
||||||
|
esteso rispetto al trend di fondo (`|close − EMA(ema_long)| / ATR(14) > trend_max`),
|
||||||
|
cioè quando si starebbe fadando un trend/crollo estremo. Con `trend_max=3.0`,
|
||||||
|
`ema_long=200` (default in `strategies.yml`): accuratezza su tutti gli sleeve
|
||||||
|
e DD giù drasticamente su ETH (MR01 71%→26%, MR02 42%→25%, MR03 66%→34%,
|
||||||
|
MR07 46%→21%), edge OOS confermato (vedi `scripts/analysis/risk_management.py`).
|
||||||
|
Unica eccezione: MR03 BTC, dove il filtro peggiora entrambe → lasciato disattivo.
|
||||||
|
Leva non robusta scartate: vol-target sizing e skip-alta-volatilità (peggiorano).
|
||||||
|
|
||||||
|
**Portafoglio.** Diversificare su sotto-conti indipendenti equipesati (le 4 strategie
|
||||||
|
× BTC/ETH, pos 0.15 ciascuno) abbatte il DD aggregato: ~14% full / ~10% OOS sul
|
||||||
|
paniere di 8 sleeve, contro il 20-70% del singolo. È la vera leva anti-drawdown.
|
||||||
|
|
||||||
|
**Combinare le due famiglie (fade + honest).** Le fade (reversione intraday 1h) e le
|
||||||
|
honest (DIP/TR/ROT trend+rotazione multi-crypto) sono **quasi scorrelate**
|
||||||
|
(correlazione cross-famiglia ~0.05). Combinarle in un unico portafoglio migliora il
|
||||||
|
rischio/rendimento rispetto a ciascuna famiglia da sola: equal-weight dei 9 sleeve
|
||||||
|
→ DD 5.2% full / 4.7% OOS e Sharpe 4.23 full / 4.33 OOS (vs honest-only 12.6% DD /
|
||||||
|
2.20 Sharpe e fade-only 8.2% DD / 4.09 Sharpe), CAGR ~47% mantenuta. Studio in
|
||||||
|
`scripts/analysis/combine_portfolio.py`.
|
||||||
|
|
||||||
|
**ROT02 — riduzione DD (top_k 2→3).** La rotazione dual-momentum honest concentrava
|
||||||
|
il book su 2 soli asset (DD 40%). Diversificare su 3 (`top_k=3`) dimezza quasi il DD
|
||||||
|
(40%→26%) e *alza* pure il ritorno full (+1095%→+1303%, ret/DD da 27 a 50); il
|
||||||
|
vol-target abbassa il DD ma sacrifica ritorno, quindi si tiene top_k=3 senza VT.
|
||||||
|
Applicato a `ROT02_dual_momentum.py` e a `_rot_daily_equity` (usata dai portafogli).
|
||||||
|
|
||||||
|
**Portafogli pronti (artefatti accorpati e migliorati).** Oltre a `PORT01` (solo
|
||||||
|
honest), due script in `scripts/strategies/`:
|
||||||
|
- `PORT02_fade_master.py` — le 3 fade × BTC/ETH accorpate (6 sleeve, filtro trend),
|
||||||
|
equal-weight daily: DD ~8.2% full / 5.9% OOS, Sharpe 3.95/4.09, CAGR ~46%.
|
||||||
|
- `PORT03_all_master.py` — portafoglio MASTER (fade + honest, 9 sleeve). Due varianti:
|
||||||
|
`equal` (massimo Sharpe: DD 5.2%/4.7% OOS, Sharpe 4.23/4.33) e `5050` fra le due
|
||||||
|
famiglie (minimo DD: 5.0% full / 4.5% OOS). È la configurazione consigliata.
|
||||||
|
Come `PORT01`, sono meta-portafogli (script `run()` di report), non `Strategy` con
|
||||||
|
`generate_signals`, quindi non nel `strategy_loader`.
|
||||||
|
|
||||||
|
**Esplorazione famiglie alternative (branch `strategy_explore`, 2026-05-29).** Esplorate
|
||||||
|
9 famiglie nuove con agenti paralleli su harness onesto condiviso
|
||||||
|
(`scripts/analysis/explore_lab.py`). 7 sono rumore (rifiutate: stagionalità oraria/mensile,
|
||||||
|
cross-sectional reversal, opening-range breakout, lead-lag BTC→alt, continuation intraday —
|
||||||
|
quest'ultima riconferma la dominanza mean-reversion). Due edge reali:
|
||||||
|
- **PR01 Pairs** (`scripts/strategies/PR01_pairs_reversion.py`): spread reversion
|
||||||
|
market-neutral sul log-ratio z-score, **config UNIVERSALE** `n=50 z_in=2.0 z_exit=0.75
|
||||||
|
max_bars=72` (anti-overfit, niente tuning per-coppia). **5 coppie robuste**: ETH/BTC
|
||||||
|
(Sharpe 4.36), LTC/ETH (3.08), ADA/ETH (2.69), BTC/LTC (2.36, robusta anche 4h), ETH/SOL
|
||||||
|
(1.96, la più debole). Pattern: sempre alt-liquido vs major. Plateau confermato
|
||||||
|
(heatmap 20/20 Sharpe>1) + walk-forward (ETH/BTC 11/12 finestre+). **BNB/ETH scartata**
|
||||||
|
(overfit). Corr col mercato ~0.02-0.08. Fee su **2 gambe**: worker live implementato
|
||||||
|
(`src/live/pairs_worker.py`, sezione `pairs:` in `strategies.yml`). LOGICA validata
|
||||||
|
(`validate_worker_pairs.py`: replay == backtest ESATTO). LIVE (`live_smoke_pairs.py`,
|
||||||
|
smoke reale Cerbero): **tutte e 5 le coppie con feed live fresco**. Naming Deribit:
|
||||||
|
BTC/ETH = `<COIN>-PERPETUAL` (inverse); alt = `<COIN>_USDC-PERPETUAL` (lineari USDC,
|
||||||
|
storia dal 2022). Trappola: `LTC-PERPETUAL`/`SOL-PERPETUAL` danno vuoto/dati errati →
|
||||||
|
usare sempre `_USDC-PERPETUAL`. Resta da verificare solo liquidità/fill in esecuzione.
|
||||||
|
Verifica edge: `pairs_research.py`.
|
||||||
|
- **TSM01** (`scripts/analysis/tsmom_research.py`): TSMOM multi-orizzonte 3/6/12m + risk-off,
|
||||||
|
**gross 0.30**, distinto da ROT02 (corr 0.62), DD 15-22%, mai un anno negativo. Robusto
|
||||||
|
(36/36 config OOS+) ma diversificatore, non motore di ritorno (rende meno di ROT02).
|
||||||
|
|
||||||
|
Aggiungere i **5 pairs** al MASTER (quasi scorrelati, ~0.02-0.09) è il free-lunch più
|
||||||
|
grande (`scripts/analysis/combine_v2.py`). **Numeri sobri onesti** (l'OOS singolo 2024-25
|
||||||
|
è regime calmo → ottimistico ~50%): worst-DD su 90g rolling **~6%** (non 2.3%), Sharpe
|
||||||
|
atteso **~5** (mediana semestrale), ogni anno positivo dal 2021, regge **leva 2x +
|
||||||
|
slippage doppio** (CAGR 36%, Sharpe 5.1). Config robusta raccomandata: **MASTER-esteso
|
||||||
|
equal-weight, leva 2x, cap pairs ~30-35%** (i pairs sono ~57% del rischio; worker live a
|
||||||
|
2 gambe implementato, validato e con feed live su tutte e 5 le coppie — resta da
|
||||||
|
verificare liquidità/fill in esecuzione reale). La confluenza multi-TF è stata SCARTATA (overfit).
|
||||||
|
|
||||||
**Metodologia obbligatoria per ogni nuova strategia** (per non ripetere l'errore squeeze):
|
**Metodologia obbligatoria per ogni nuova strategia** (per non ripetere l'errore squeeze):
|
||||||
1. Ingresso eseguibile: direzione e prezzo decisi con dati **fino a `close[i]`**, mai `close[i-1]` con direzione da `i`.
|
1. Ingresso eseguibile: direzione e prezzo decisi con dati **fino a `close[i]`**, mai `close[i-1]` con direzione da `i`.
|
||||||
@@ -101,6 +188,12 @@ Ricerca completa: `scripts/analysis/strategy_research.py`.
|
|||||||
|
|
||||||
Strategie scartate storiche in `scripts/waste/` (W01-W28 + la famiglia squeeze).
|
Strategie scartate storiche in `scripts/waste/` (W01-W28 + la famiglia squeeze).
|
||||||
|
|
||||||
|
**Verso €50/giorno.** Con 4 strategie indipendenti (MR01/MR02/MR03/MR07) × 2 asset
|
||||||
|
(BTC/ETH) su €1000 ciascuna, il PnL medio storico aggregato è ben oltre €50/giorno;
|
||||||
|
ma quei numeri sono backtest a leva 3x su 8 anni e includono anni eccezionali (es.
|
||||||
|
ETH 2024). Stima onesta: il target è *plausibile* su un portafoglio diversificato di
|
||||||
|
queste fade, ma va confermato col paper trader live prima di rischiare capitale reale.
|
||||||
|
|
||||||
## Multi-Strategy Paper Trader
|
## Multi-Strategy Paper Trader
|
||||||
|
|
||||||
Orchestratore che esegue N strategie in parallelo su dati live Cerbero, ognuna con €1000 USDC virtuali indipendenti.
|
Orchestratore che esegue N strategie in parallelo su dati live Cerbero, ognuna con €1000 USDC virtuali indipendenti.
|
||||||
|
|||||||
@@ -94,3 +94,100 @@ risultati riproducibili. La config live di MT01 (ema20+vol) coincide col best do
|
|||||||
serve uno scheduling del download (cron/job).
|
serve uno scheduling del download (cron/job).
|
||||||
2. **Healthcheck:** valutare un check su mtime di `status.json` (< 180s) per rilevare uno
|
2. **Healthcheck:** valutare un check su mtime di `status.json` (< 180s) per rilevare uno
|
||||||
stallo del loop, non solo l'esistenza del file.
|
stallo del loop, non solo l'esistenza del file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 23:00 — 3 nuove strategie con edge OOS fee-aware (branch `strategy_free`)
|
||||||
|
|
||||||
|
**Obiettivo:** trovare almeno 3 nuove strategie (oltre MR01), edge netto validato
|
||||||
|
out-of-sample e fee-aware, per il target €1.000 → ~€50/giorno.
|
||||||
|
|
||||||
|
**Metodologia (invariata dalla lezione squeeze):** ingresso eseguibile a `close[i]`
|
||||||
|
(nessun look-ahead), backtest netto dopo fee Deribit 0.10% RT + leva 3x, OOS = ultimo
|
||||||
|
30% held-out, robustezza su griglia parametri + sweep fee 0.00–0.20% RT, exit
|
||||||
|
TP/SL intrabar o time-limit, una posizione per volta, capitale composto.
|
||||||
|
|
||||||
|
**Candidati** (`scripts/analysis/strategy_research_v2.py`), tutti mean-reversion
|
||||||
|
(l'edge è sempre il rientro, mai la continuazione):
|
||||||
|
|
||||||
|
| Candidato | Esito | Motivo |
|
||||||
|
|---|---|---|
|
||||||
|
| **MR02 Donchian Fade** | ✅ | Robusto su tutta la griglia `n × sl_atr` e tutte le fee |
|
||||||
|
| **MR03 Keltner Fade** | ✅ | Robusto su tutta la griglia `n × k`; banda ATR, indipendente da Bollinger |
|
||||||
|
| **MR07 Return Reversal** | ✅ | Intero blocco `tp_atr=2.0` positivo full+OOS; esposizione ~8% |
|
||||||
|
| MR04 Z-score Reversion | ⛔ | Robusto ma è MR01 riparametrizzato (stessa banda std): edge non *nuovo* |
|
||||||
|
| MR05 Bollinger + filtro ADX | ⛔ | Non robusto: negativo su gran parte della griglia BTC |
|
||||||
|
| MR06 RSI(2) Connors | ⛔ | ETH 1h negativo; non robusto su entrambi gli asset |
|
||||||
|
|
||||||
|
**Risultati** (netto 0.10% RT, leva 3x, OOS, 1h):
|
||||||
|
|
||||||
|
| Codice | Meccanismo | BTC OOS | ETH OOS | DD (full) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| MR02 | estremi canale Donchian H/L | +172% | enorme | 30% / 42% |
|
||||||
|
| MR03 | canale ATR su EMA | +112% | +886% | 37% / 66% |
|
||||||
|
| MR07 | z dei rendimenti di barra | +105% | +195% | 25% / 46% |
|
||||||
|
|
||||||
|
**Validazione live-path** (`oos_validation.py`, legge `strategies.yml`, exit hold
|
||||||
|
del worker): tutte e tre positive netto OOS su tutto lo sweep fee, anche al
|
||||||
|
pessimistico 0.20% RT → edge robusto pure al meccanismo di exit.
|
||||||
|
|
||||||
|
**Verifiche:** equivalenza esatta backtest produzione vs research engine (MR02 BTC:
|
||||||
|
2039 trade, DD 29% identici); le 3 classi si caricano dal `strategy_loader`;
|
||||||
|
aggiunte a `strategies.yml` (BTC+ETH 1h). Nessuna suite di test nel progetto.
|
||||||
|
|
||||||
|
**Onestà sul target:** con 4 fade indipendenti × 2 asset il PnL storico aggregato
|
||||||
|
supera €50/giorno, ma sono backtest a leva 3x su 8 anni con annate eccezionali
|
||||||
|
(ETH 2024). Plausibile ma da confermare col paper trader live prima del capitale reale.
|
||||||
|
DD alto su ETH (MR03 ~66%, come MR01) → leva più bassa consigliata per quell'asset.
|
||||||
|
|
||||||
|
**File:** `strategy_research_v2.py`, `src/strategies/fade_base.py`,
|
||||||
|
`scripts/strategies/MR0{2,3,7}_*.py` (nuovi); `strategy_loader.py`, `strategies.yml`,
|
||||||
|
`CLAUDE.md` (aggiornati).
|
||||||
|
**Lezione confermata:** ogni edge robusto trovato finora è mean-reversion; ogni
|
||||||
|
variante trend/continuation o oscillatore senza filtro perde netto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 23:45 — Aumentare Acc e ridurre DD (filtro trend + portafoglio)
|
||||||
|
|
||||||
|
**Obiettivo:** alzare accuratezza e abbassare drawdown sulle 4 fade, senza
|
||||||
|
distruggere l'edge né overfittare (ogni leva misurata FULL **e** OOS).
|
||||||
|
|
||||||
|
**Diagnosi:** perdite/DD concentrati 2018–2021 (bear/covid/caos vol), su ETH DD
|
||||||
|
pieno 66–71%. Banco di prova: `scripts/analysis/risk_improvements.py` e
|
||||||
|
`risk_portfolio.py`.
|
||||||
|
|
||||||
|
**Leve testate:**
|
||||||
|
|
||||||
|
| Leva | Esito | Motivo |
|
||||||
|
|---|---|---|
|
||||||
|
| Sizing vol-target (size ∝ 1/dist-SL) | ⛔ | Over-size sui trade a stop stretto → DD su, ritorno giù |
|
||||||
|
| Skip alta volatilità (ATR% in coda alta) | ⛔ | L'alta vol è *positiva* per le fade (più reversione): Acc e ritorno giù |
|
||||||
|
| **Filtro trend** (`\|close−EMA200\|/ATR > soglia` → salta) | ✅ | Non fada trend/crolli estremi: Acc↑ ovunque, DD↓ molto su ETH, OOS regge |
|
||||||
|
| **Portafoglio** equipesato (sotto-conti indipendenti) | ✅ | Curve poco correlate → DD aggregato 14% (full)/10% (OOS) vs 20-70% singolo |
|
||||||
|
|
||||||
|
**Filtro trend — sweep soglia** (assoluta in ATR, regola unica per tutte = niente
|
||||||
|
overfit): 3.0 ATR è l'equilibrio (2.0 taglia troppo ritorno). Effetto su config
|
||||||
|
deployata (base → filtro):
|
||||||
|
|
||||||
|
| Sleeve | Acc | DD |
|
||||||
|
|---|---|---|
|
||||||
|
| MR01 ETH | 46→55 | **71→26** |
|
||||||
|
| MR02 ETH | 49→55 | 42→25 |
|
||||||
|
| MR03 ETH | 49→52 | 66→34 |
|
||||||
|
| MR07 ETH | 48→54 | 46→21 |
|
||||||
|
| MR01 BTC | 51→54 | 32→34* |
|
||||||
|
| MR02 BTC | 48→52 | 29→23 |
|
||||||
|
| MR07 BTC | 49→53 | 25→18 |
|
||||||
|
| MR03 BTC | 47→47 | 37→37 (filtro OFF) |
|
||||||
|
|
||||||
|
\*MR01 BTC: DD full +2pt ma Acc +3.7 e DD OOS piatto (14.8→15.0). **MR03 BTC**:
|
||||||
|
il filtro peggiora entrambe (unico sleeve) → lasciato disattivo nello yaml.
|
||||||
|
|
||||||
|
**Implementazione:** helper `trend_distance()` in `fade_base.py`; param opzionali
|
||||||
|
`trend_max`/`ema_long` (default None = retro-compatibile) in tutte le strategie
|
||||||
|
(MR01/02/03/07); `strategies.yml` con `trend_max: 3.0, ema_long: 200` (eccetto
|
||||||
|
MR03 BTC). Verificato: equivalenza produzione vs ricerca.
|
||||||
|
|
||||||
|
**Lezione:** il modo onesto di ridurre il DD non è strozzare il sizing (peggiora),
|
||||||
|
ma (a) non opporsi a trend estremi e (b) diversificare su strategie scorrelate.
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Diario — 2026-05-29 — Esplorazione di nuove famiglie di strategie
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
|
||||||
|
Trovare 5-10 nuove famiglie di strategie, diverse da quelle esistenti, migliori o
|
||||||
|
complementari, con DD basso e attenzione alle fee. Esplorazione onesta (no
|
||||||
|
look-ahead, netto fee, OOS) condotta con **agenti paralleli**, ognuno su una famiglia
|
||||||
|
indipendente, tutti sullo stesso harness condiviso (`scripts/analysis/explore_lab.py`).
|
||||||
|
Lavoro sul branch `strategy_explore`.
|
||||||
|
|
||||||
|
## Famiglie esplorate (9) ed esito onesto
|
||||||
|
|
||||||
|
| Famiglia | Esito | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| **Pairs / spread reversion** | ✅ **VINCITORE** | Market-neutral, genuinamente nuova, decorrelata |
|
||||||
|
| **TSMOM multi-orizzonte** | ✅ diversificatore | Marginale ma distinto (corr 0.53 con ROT02), DD basso |
|
||||||
|
| Stagionalità settimanale | ⚠️ marginale/fragile | "Mercoledì-long-24h" 7/8 asset OOS+ ma effetto concentrato a 00:00 UTC |
|
||||||
|
| Vol-target BTC | ⚠️ marginale | Sharpe 0.94 vs 0.76 buy&hold, DD ancora 44% |
|
||||||
|
| Stagionalità intraday (ora) | ❌ rumore | L'edge orario muore sotto le fee |
|
||||||
|
| Stagionalità mensile/turn-of-month | ❌ rumore | Reale in-sample, morto OOS dal 2024 |
|
||||||
|
| Cross-sectional reversal | ❌ nessun edge | Perde vs equal-weight, corr 0.98 col momentum |
|
||||||
|
| Opening-range breakout | ❌ non generalizza | Solo BTC/ETH, alcuni regimi, fee-fragile |
|
||||||
|
| Lead-lag BTC→alt | ❌ nessun edge | Reazione contemporanea (corr lag+1 ≈ 0), non batte buy&hold |
|
||||||
|
| Momentum/continuation intraday | ❌ negativo | Conferma: il *fade* (mean-reversion) domina |
|
||||||
|
|
||||||
|
7 famiglie su 9 sono rumore — e l'harness le ha rifiutate senza produrre falsi
|
||||||
|
positivi (segnale che la metodologia onesta funziona). Due edge reali emergono.
|
||||||
|
|
||||||
|
## Vincitore 1 — PAIRS (market-neutral) — `PR01_pairs_reversion.py`
|
||||||
|
|
||||||
|
Scommette sul rientro del log-ratio di due cripto verso la media (z-score). Quando
|
||||||
|
`z ≤ −2` → long A / short B; `z ≥ +2` → l'opposto; esce al rientro (`|z| ≤ 0.5`) o a
|
||||||
|
tempo. Engine onesto verificato in `pairs_research.py` (test esplicito no-look-ahead:
|
||||||
|
`z[i]` invariato perturbando il futuro). Fee contate su **2 gambe** (0.20% RT/coppia).
|
||||||
|
|
||||||
|
Validazione (netto, leva 3x, OOS = ultimo 30%, 1h):
|
||||||
|
|
||||||
|
| Coppia | CAGR | Sharpe | OOS DD | anni+ |
|
||||||
|
|---|--:|--:|--:|--:|
|
||||||
|
| ETH/BTC | 144% | 4.04 | 17% | 8/9 |
|
||||||
|
| LTC/ETH | 71% | 2.52 | 10% | 7/8 |
|
||||||
|
| ADA/ETH | 77% | 2.16 | 11% | 7/8 |
|
||||||
|
|
||||||
|
Tutte le 10 coppie testate positive FULL+OOS, regge fee 0.40% RT/coppia, correlazione
|
||||||
|
col mercato ~0.02 (market-neutral confermato). DD pieno 42-49% (alto), ma OOS DD
|
||||||
|
10-17% (buono) e soprattutto **quasi-zero correlazione** col resto → diversificatore
|
||||||
|
eccezionale. Limite: 2 gambe (long+short), il worker live va esteso prima del live.
|
||||||
|
|
||||||
|
## Vincitore 2 — TSM01 (TSMOM multi-orizzonte) — `tsmom_research.py`
|
||||||
|
|
||||||
|
Long-only multi-crypto: tiene equal-weight gli asset con consenso pieno del segno di
|
||||||
|
momentum su 3/6/12 mesi, cash se BTC<SMA100. Distinto da ROT02 (persistenza assoluta
|
||||||
|
vs ranking relativo), corr 0.53. FULL +169% / OOS +80% / DD 22% / Sharpe 1.07,
|
||||||
|
**mai un anno negativo**, regge fee 0.40%. Verificato no-look-ahead (cheat-test
|
||||||
|
esplode a +575%). Marginale come stand-alone (rende meno di ROT02) ma utile in ensemble.
|
||||||
|
|
||||||
|
## Il payoff — combinare le nuove fonti col MASTER (`combine_v2.py`)
|
||||||
|
|
||||||
|
Le nuove sleeve sono quasi scorrelate col MASTER-9 (pairs ~0.02-0.08, TSM01 0.05).
|
||||||
|
Aggiungerle migliora nettamente il portafoglio:
|
||||||
|
|
||||||
|
| Portafoglio | CAGR | DD% | Sharpe | OOS DD% | OOS Sharpe |
|
||||||
|
|---|--:|--:|--:|--:|--:|
|
||||||
|
| MASTER-9 (base) | 47 | 5.2 | 4.23 | 4.7 | 4.33 |
|
||||||
|
| **MASTER + pairs (12)** | **66** | **3.8** | **5.67** | **3.3** | **6.86** |
|
||||||
|
| MASTER + TSM01 (10) | 44 | 4.7 | 4.21 | 4.2 | 4.33 |
|
||||||
|
| MASTER esteso (13) | 62 | 3.6 | 5.66 | 3.0 | 6.79 |
|
||||||
|
|
||||||
|
I **pairs** sono l'aggiunta decisiva: alzano la CAGR (47→66), **abbassano il DD**
|
||||||
|
(5.2→3.8 full, 4.7→**3.3** OOS) e portano lo Sharpe OOS a **6.86** — il free-lunch
|
||||||
|
della diversificazione da una fonte market-neutral scorrelata. TSM01 contribuisce
|
||||||
|
poco (diluisce il ritorno) ma abbassa lievemente il DD.
|
||||||
|
|
||||||
|
## Caveat onesti
|
||||||
|
|
||||||
|
- I pairs hanno DD pieno alto (42-49%) sull'1h; il vantaggio sta nella decorrelazione,
|
||||||
|
non nel DD stand-alone. Richiedono esecuzione a 2 gambe (short del perp B) — da
|
||||||
|
verificare shortabilità/liquidità sugli alt e raddoppio fee nel worker.
|
||||||
|
- Sharpe combinati 5-7 e CAGR 60%+ sono backtest a leva 3x su finestra 2021-2026 con
|
||||||
|
OOS ~1.6 anni e il 2024 cripto eccezionale: numeri ottimistici, da confermare in
|
||||||
|
paper trading live.
|
||||||
|
- TSMOM e le strategie honest condividono l'overlay risk-off SMA100: parte della loro
|
||||||
|
difensività è comune (non perfettamente indipendente).
|
||||||
|
|
||||||
|
## Terza ondata — espansione dei meccanismi provati + 2 nuovi sondaggi
|
||||||
|
|
||||||
|
Esplorate altre 4 direzioni con agenti paralleli:
|
||||||
|
- **Fade su 6 nuovi alt (ADA/BNB/DOGE/LTC/SOL/XRP)**: 0 robuste. La mean-reversion
|
||||||
|
fade vive solo su BTC/ETH (liquidi); sugli alt sparisce o è artefatto di pochi pump
|
||||||
|
(DOGE). Coerente con la lezione del progetto.
|
||||||
|
- **Espansione PAIRS** (tutte le 28 coppie): trovate **3 nuove coppie robuste** →
|
||||||
|
BTC/LTC (robusta 1h *e* 4h, Sharpe 2.21, DD 24-34%, concentrazione PnL 9%), ETH/SOL
|
||||||
|
e BNB/ETH (Sharpe 2.4+, solo 1h). Pattern: sempre alt-liquido vs major, mai alt/alt.
|
||||||
|
PR01 ora ha **6 coppie**.
|
||||||
|
- **Low-volatility anomaly**: ❌ in cripto è INVERTITA (vince l'alta vol = alta beta),
|
||||||
|
ridondante con EW+risk-off/ROT02. L'anti-test high-vol stravince.
|
||||||
|
- **Confluenza multi-timeframe (fade 1h confermato da 4h)**: non crea edge nuovo e non
|
||||||
|
migliora lo Sharpe, ma **dimezza il DD** di MR01 (ETH: stesso Sharpe 3.17 a DD 38% vs
|
||||||
|
63%) e stabilizza l'OOS → utile variante low-DD, non strategia indipendente.
|
||||||
|
|
||||||
|
## Bilancio finale e MASTER esteso (6 pairs)
|
||||||
|
|
||||||
|
Robusti deployabili: **famiglia PAIRS (6 coppie) + TSM01** (+ confluenza MTF come variante
|
||||||
|
low-DD di MR01, + tilt stagionale mercoledì marginale). I 6 pairs sono quasi scorrelati col
|
||||||
|
MASTER (corr 0.02-0.08). MASTER + 6 pairs:
|
||||||
|
|
||||||
|
| Portafoglio | CAGR | DD% | Sharpe | OOS DD% | OOS Sharpe |
|
||||||
|
|---|--:|--:|--:|--:|--:|
|
||||||
|
| MASTER-9 (base) | 47 | 5.2 | 4.23 | 4.7 | 4.33 |
|
||||||
|
| **MASTER + 6 pairs (15)** | **71** | 5.7 | **5.93** | **2.3** | **7.71** |
|
||||||
|
| MASTER esteso +TSM01 (16) | 67 | 5.4 | 5.95 | **2.2** | 7.67 |
|
||||||
|
|
||||||
|
Aggiungere i 6 pairs porta l'**OOS DD a 2.2-2.3%** (da 4.7%) con Sharpe OOS ~7.7 e tutti
|
||||||
|
gli anni positivi: il guadagno di diversificazione da fonti market-neutral scorrelate.
|
||||||
|
|
||||||
|
## Quarto giro — validazione anti-overfitting e irrobustimento
|
||||||
|
|
||||||
|
Tre audit scettici paralleli (walk-forward, plateau, stress, scomposizione):
|
||||||
|
|
||||||
|
**Pairs — de-overfittati.** Sostituita la config per-coppia (cherry-picking di z_exit/n)
|
||||||
|
con **una config universale `n=50 z_in=2.0 z_exit=0.75 max_bars=72`**. Verifiche:
|
||||||
|
- plateau (non picco): heatmap n×z_in → 20/20 celle Sharpe>1 su ETH/BTC e BTC/LTC;
|
||||||
|
- walk-forward (train 2y / test 6m rolling): ETH/BTC 11/12 finestre positive, BTC/LTC
|
||||||
|
9/10 → edge distribuito su tutta la storia, non un regime singolo;
|
||||||
|
- **BNB/ETH scartata** (era robusta solo coi suoi parametri → overfit; crolla con la
|
||||||
|
universale e muore per prima allo stress costi). Famiglia ridotta a **5 pairs**.
|
||||||
|
- stress: 5/6 reggono fee+slippage realistici; solo ETH/BTC regge fee 6x (coda fee-fragile).
|
||||||
|
|
||||||
|
**Master — numeri sobri.** L'OOS Sharpe 7.7 / DD 2.3% è **ottimistico ~50%** perché l'OOS
|
||||||
|
cade nel bull calmo 2024-25. Numeri onesti da usare:
|
||||||
|
- worst-DD su finestra mobile 90g (2021-2026) = **5.7%** (bear FTX) → budget DD ~6%, non 2.3%;
|
||||||
|
- Sharpe per-semestre: mediana **~5** (min 1.2, max 12) → atteso ~5, non 7.7;
|
||||||
|
- ogni anno e ogni semestre dal 2021 positivo (anche il 2022 bear, grazie alle gambe short);
|
||||||
|
- equal-weight ≈ inverse-vol (non dipende da pesi fortunati);
|
||||||
|
- regge **leva 2x + slippage doppio** (CAGR 36%, Sharpe 5.1);
|
||||||
|
- **rischio concentrato: i pairs portano ~57% del rischio** → cap consigliato ~30-35%.
|
||||||
|
- Config robusta raccomandata: **MASTER-esteso, equal-weight, leva 2x, cap pairs ~30-35%**.
|
||||||
|
|
||||||
|
**TSM01 — confermato robusto** (36/36 config OOS+, walk-forward stabile) ma corr reale con
|
||||||
|
ROT02 = **0.62** (non 0.53), e gran parte del DD basso viene dall'overlay risk-off condiviso.
|
||||||
|
Tenuto come diversificatore con **gross 0.30** (stesso Sharpe, DD 22%→15%).
|
||||||
|
|
||||||
|
**Confluenza multi-TF — SCARTATA: era overfit.** Taglia il 97% dei trade (restano ~40 in
|
||||||
|
8 anni = non significativo), distrugge lo Sharpe (1.58→0.27 su BTC) e il caso "bello" non
|
||||||
|
sopravvive alle perturbazioni. Per abbassare il DD di MR01 meglio ridurne la leva, non il filtro 4h.
|
||||||
|
|
||||||
|
**Risultato del giro:** quanto trovato regge l'esame anti-overfit (NON è l'errore squeeze),
|
||||||
|
ma i numeri vanno comunicati sobri (Sharpe ~5, DD ~6%) e con leva 2x + cap pairs. Famiglia
|
||||||
|
pairs consolidata a 5 coppie con config universale; confluenza MTF rimossa dai vincitori.
|
||||||
|
|
||||||
|
## File creati (branch strategy_explore)
|
||||||
|
|
||||||
|
`scripts/analysis/explore_lab.py` (harness onesto condiviso), `pairs_research.py`
|
||||||
|
(verifica + ricerca pairs), `tsmom_research.py` (TSM01), `combine_v2.py` (master
|
||||||
|
esteso); `scripts/strategies/PR01_pairs_reversion.py` (artefatto pairs).
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Diario di ricerca — 2026-05-29
|
||||||
|
|
||||||
|
## Combinare le strategie migliora i risultati?
|
||||||
|
|
||||||
|
**Domanda:** usare insieme le due famiglie di strategie presenti sul repo migliora
|
||||||
|
il profilo rischio/rendimento rispetto a usarle separatamente?
|
||||||
|
|
||||||
|
- **FADE** (mie): reversione intraday 1h, long/short, BTC/ETH — MR01 Bollinger,
|
||||||
|
MR02 Donchian, MR03 Keltner, MR07 Return-reversal (tutte col filtro trend 3.0 ATR).
|
||||||
|
- **HONEST** (altra sessione): long-only multi-regime multi-crypto — DIP01 (dip-buy
|
||||||
|
1h BTC), TR01 (EMA-trend 4h basket), ROT02 (dual-momentum rotation 1d).
|
||||||
|
|
||||||
|
**Metodo** (`scripts/analysis/combine_portfolio.py`): per ogni sleeve si costruisce
|
||||||
|
l'equity **giornaliera** normalizzata su un indice comune (2021-01-01 → 2026-05-26),
|
||||||
|
si passa ai rendimenti giornalieri, si misura la correlazione cross-famiglia e si
|
||||||
|
confrontano i portafogli equal-weight (ribilanciati ogni giorno), 50/50 fra famiglie
|
||||||
|
e inverse-vol. Metriche FULL e OOS (ultimo 30% della finestra comune, da 2024-10-12):
|
||||||
|
ritorno, CAGR, max DD, Sharpe annualizzato. Le curve honest sono riusate da
|
||||||
|
`honest_improve2.py`; quelle fade da `risk_management.build_trades`.
|
||||||
|
|
||||||
|
**Correlazione:** cross-famiglia **+0.05** (quasi indipendenti). Intra-fade +0.18,
|
||||||
|
intra-honest +0.05. L'unica coppia un po' correlata è MR01_BTC↔DIP01_BTC (+0.43),
|
||||||
|
entrambe mean-reversion su BTC. Famiglie scorrelate ⇒ diversificazione quasi ideale.
|
||||||
|
|
||||||
|
**Risultati (FULL | OOS):**
|
||||||
|
|
||||||
|
| Portafoglio | Ret% | CAGR | DD% | Sharpe | oDD% | oSharpe |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| FADE only (8) | +549 | 41 | 8.6 | 3.75 | 5.4 | 4.14 |
|
||||||
|
| HONEST only (3) | +642 | 45 | 12.0 | 1.90 | 6.5 | 2.23 |
|
||||||
|
| **ALL equal-weight (11)** | +589 | 43 | 6.1 | **3.95** | 4.6 | **4.46** |
|
||||||
|
| **ALL 50/50 famiglie** | +615 | 44 | **5.5** | 3.18 | **4.0** | 3.87 |
|
||||||
|
| ALL inverse-vol | +483 | 39 | 5.8 | 3.97 | 4.6 | 4.02 |
|
||||||
|
|
||||||
|
**Conclusione: sì, combinare conviene.**
|
||||||
|
- DD crolla: combinato 5.5–6.1% full / 4.0–4.6% OOS, contro 8.6% (fade) e **12%**
|
||||||
|
(honest) da sole → drawdown ridotto del 35–50%.
|
||||||
|
- Sharpe sale: combinato OOS **4.46** vs honest 2.23 (raddoppia) e batte pure fade (4.14).
|
||||||
|
- CAGR resta ~43–44% (≈ media delle due famiglie) ma con metà del rischio: è il
|
||||||
|
"free lunch" della diversificazione fra sorgenti di edge scorrelate.
|
||||||
|
- Best Sharpe = equal-weight degli 11 sleeve; best DD = 50/50 fra le due famiglie.
|
||||||
|
|
||||||
|
**Caveat onesti:** la finestra comune è 2021–2026 (5.4 anni), OOS ~2024-10→oggi
|
||||||
|
(1.6 anni) — pochi regimi. CAGR e Sharpe sono backtest a leva 3x; il 2024 cripto
|
||||||
|
favorevole pesa. Il target €50/giorno resta vincolato dal capitale: 43% CAGR su
|
||||||
|
€1000 non fa €50/giorno a breve, serve compounding pluriennale o più capitale.
|
||||||
|
Prossimo passo: confermare il portafoglio combinato nel paper trader live.
|
||||||
|
|
||||||
|
**File:** `scripts/analysis/combine_portfolio.py` (nuovo).
|
||||||
|
|
||||||
|
## Pulizia roster + miglioria ROT02
|
||||||
|
|
||||||
|
- **Waste delle peggiori:** MR03 Keltner (fade più debole, Sharpe 1.22, ridondante
|
||||||
|
con MR01 — rimuoverla *migliora* il portafoglio fade: DD 8.6→8.2, ret +549→+666)
|
||||||
|
e ROT01 (dominata da ROT02). Spostate in `scripts/waste/`.
|
||||||
|
- **Portafogli pronti:** `PORT02_fade_master` (6 sleeve fade) e `PORT03_all_master`
|
||||||
|
(9 sleeve fade+honest, varianti equal/5050).
|
||||||
|
- **ROT02 DD alto → migliorato:** la rotazione concentrava il book su 2 asset
|
||||||
|
(DD 40%). Sweep su `rot_improved`: `top_k=3` dimezza quasi il DD (40%→26%) e
|
||||||
|
*alza* il ritorno full (+1095→+1303%, ret/DD 27→50). Il vol-target abbassa il DD
|
||||||
|
ma sacrifica ritorno (de-leverage) → tenuto top_k=3 senza VT. Caveat onesto:
|
||||||
|
l'OOS di ROT02 cala un po' (+98→+68%, DD 12→14%), ma il MASTER (config deployata)
|
||||||
|
migliora lo Sharpe full 3.95→4.23. Applicato a `ROT02_dual_momentum.py` e
|
||||||
|
`_rot_daily_equity`. Sweep in `honest_improve.rot_improved`.
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
"""Confronto migliori strategie S1 e S2 — andamento per anno."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, ".")
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
from src.data.downloader import load_data
|
|
||||||
from src.fractal.patterns import encode_candles
|
|
||||||
|
|
||||||
FEE_PERP = 0.002 # 0.1% taker roundtrip perpetual
|
|
||||||
FEE_OPT = 0.0052 # options roundtrip
|
|
||||||
INITIAL = 1000
|
|
||||||
LEVERAGE = 3
|
|
||||||
|
|
||||||
|
|
||||||
def keltner_ratio(close, high, low, window=14):
|
|
||||||
n = len(close)
|
|
||||||
r = np.full(n, np.nan)
|
|
||||||
for i in range(window, n):
|
|
||||||
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
|
|
||||||
ma = np.mean(wc)
|
|
||||||
bb_std = np.std(wc)
|
|
||||||
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
|
|
||||||
atr = np.mean(tr[1:])
|
|
||||||
kc = (ma+1.5*atr)-(ma-1.5*atr)
|
|
||||||
bb = (ma+2*bb_std)-(ma-2*bb_std)
|
|
||||||
if kc > 0:
|
|
||||||
r[i] = bb/kc
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def rv_ann(close, window):
|
|
||||||
lr = np.diff(np.log(np.where(close==0, 1e-10, close)))
|
|
||||||
r = np.full(len(close), np.nan)
|
|
||||||
for i in range(window, len(lr)):
|
|
||||||
r[i+1] = np.std(lr[i-window:i]) * np.sqrt(24*365)
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def rsi(close, period=14):
|
|
||||||
delta = np.diff(close)
|
|
||||||
gain = np.where(delta>0, delta, 0)
|
|
||||||
loss = np.where(delta<0, -delta, 0)
|
|
||||||
result = np.full(len(close), 50.0)
|
|
||||||
if len(gain) < period:
|
|
||||||
return result
|
|
||||||
ag = np.mean(gain[:period])
|
|
||||||
al = np.mean(loss[:period])
|
|
||||||
for i in range(period, len(delta)):
|
|
||||||
ag = (ag*(period-1)+gain[i])/period
|
|
||||||
al = (al*(period-1)+loss[i])/period
|
|
||||||
result[i+1] = 100 if al == 0 else 100-100/(1+ag/al)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def ema(arr, period):
|
|
||||||
r = np.full(len(arr), np.nan)
|
|
||||||
k = 2/(period+1)
|
|
||||||
r[period-1] = np.mean(arr[:period])
|
|
||||||
for i in range(period, len(arr)):
|
|
||||||
r[i] = arr[i]*k + r[i-1]*(1-k)
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# S1 BEST: Squeeze Breakout ETH 1h (BBw=14, sq=0.8, brk=3)
|
|
||||||
# =====================================================================
|
|
||||||
def run_s1_squeeze(asset, tf):
|
|
||||||
df = load_data(asset, tf)
|
|
||||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
|
||||||
n = len(c)
|
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
kcr = keltner_ratio(c, h, l, 14)
|
|
||||||
|
|
||||||
yearly = {}
|
|
||||||
in_sq = False
|
|
||||||
sq_start = 0
|
|
||||||
|
|
||||||
for i in range(15, n):
|
|
||||||
if np.isnan(kcr[i]):
|
|
||||||
continue
|
|
||||||
is_sq = kcr[i] < 0.8
|
|
||||||
if is_sq and not in_sq:
|
|
||||||
in_sq = True
|
|
||||||
sq_start = i
|
|
||||||
elif not is_sq and in_sq:
|
|
||||||
in_sq = False
|
|
||||||
if i - sq_start < 5 or i + 3 >= n:
|
|
||||||
continue
|
|
||||||
first_ret = (c[i] - c[i-1]) / c[i-1]
|
|
||||||
if abs(first_ret) < 0.001:
|
|
||||||
continue
|
|
||||||
direction = 1 if first_ret > 0 else -1
|
|
||||||
actual = (c[i+2] - c[i-1]) / c[i-1]
|
|
||||||
trade_ret = actual * direction
|
|
||||||
net = trade_ret * LEVERAGE - FEE_PERP * LEVERAGE
|
|
||||||
|
|
||||||
year = ts.iloc[i].year
|
|
||||||
if year not in yearly:
|
|
||||||
yearly[year] = {"pnls": [], "wins": 0, "total": 0}
|
|
||||||
yearly[year]["pnls"].append(net)
|
|
||||||
yearly[year]["total"] += 1
|
|
||||||
if trade_ret > 0:
|
|
||||||
yearly[year]["wins"] += 1
|
|
||||||
|
|
||||||
return yearly
|
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# S1 BEST ALT: Squeeze+ML hybrid ETH 15m
|
|
||||||
# =====================================================================
|
|
||||||
# Troppo complesso da ricalcolare (serve ML training). Uso i dati S1 squeeze puro.
|
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# S2 BEST: VRP ETH 48h (con IV stimata, unico disponibile su 8 anni)
|
|
||||||
# =====================================================================
|
|
||||||
def run_s2_vrp(asset, dte=48):
|
|
||||||
df = load_data(asset, "1h")
|
|
||||||
c = df["close"].values
|
|
||||||
n = len(c)
|
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
rv_24 = rv_ann(c, 24)
|
|
||||||
rv_168 = rv_ann(c, 168)
|
|
||||||
|
|
||||||
yearly = {}
|
|
||||||
for i in range(170, n - dte):
|
|
||||||
if ts.iloc[i].hour != 8:
|
|
||||||
continue
|
|
||||||
rv_s, rv_l = rv_24[i], rv_168[i]
|
|
||||||
if np.isnan(rv_s) or np.isnan(rv_l) or rv_s < 0.05 or rv_l < 0.05:
|
|
||||||
continue
|
|
||||||
regime = rv_s / rv_l
|
|
||||||
iv_pf = 0.9 if regime > 2 else (1.0 if regime > 1.5 else (1.1 if regime > 1 else 1.2))
|
|
||||||
iv = rv_l * iv_pf
|
|
||||||
prem = iv * np.sqrt(dte/(24*365)) * 0.8
|
|
||||||
spot = c[i]
|
|
||||||
move = abs(c[min(i+dte, n-1)] - spot) / spot
|
|
||||||
pos = 0.10
|
|
||||||
raw = (prem - move) * pos if move <= prem else max(-(move-prem)*pos, -pos*0.05)
|
|
||||||
net = raw - FEE_OPT * pos
|
|
||||||
|
|
||||||
year = ts.iloc[i].year
|
|
||||||
if year not in yearly:
|
|
||||||
yearly[year] = {"pnls": [], "wins": 0, "total": 0}
|
|
||||||
yearly[year]["pnls"].append(net)
|
|
||||||
yearly[year]["total"] += 1
|
|
||||||
if raw > 0:
|
|
||||||
yearly[year]["wins"] += 1
|
|
||||||
return yearly
|
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# S2 BEST PERPETUAL: Multi-TF 15m+1h BTC
|
|
||||||
# =====================================================================
|
|
||||||
def run_s2_multitf(asset):
|
|
||||||
df_1h = load_data(asset, "1h")
|
|
||||||
df_15m = load_data(asset, "15m")
|
|
||||||
c1h = df_1h["close"].values
|
|
||||||
ts1h = pd.to_datetime(df_1h["timestamp"], unit="ms", utc=True)
|
|
||||||
c15 = df_15m["close"].values
|
|
||||||
ts15 = df_15m["timestamp"].values
|
|
||||||
n15 = len(c15)
|
|
||||||
|
|
||||||
ema_50 = ema(c1h, 50)
|
|
||||||
rsi_15m = rsi(c15, 14)
|
|
||||||
|
|
||||||
yearly = {}
|
|
||||||
daily_done = set()
|
|
||||||
|
|
||||||
for i in range(100, n15 - 12):
|
|
||||||
ts_dt = pd.Timestamp(ts15[i], unit="ms", tz="UTC")
|
|
||||||
day = ts_dt.strftime("%Y-%m-%d")
|
|
||||||
if day in daily_done:
|
|
||||||
continue
|
|
||||||
if rsi_15m[i] > 35 and rsi_15m[i] < 65:
|
|
||||||
continue
|
|
||||||
h_idx = np.searchsorted(ts1h.values.astype("int64"), ts15[i]) - 1
|
|
||||||
if h_idx < 50 or h_idx >= len(c1h) or np.isnan(ema_50[h_idx]):
|
|
||||||
continue
|
|
||||||
|
|
||||||
direction = None
|
|
||||||
if rsi_15m[i] < 30 and c1h[h_idx] > ema_50[h_idx]:
|
|
||||||
direction = "long"
|
|
||||||
elif rsi_15m[i] > 70 and c1h[h_idx] < ema_50[h_idx]:
|
|
||||||
direction = "short"
|
|
||||||
if direction is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
entry = c15[i]
|
|
||||||
exit_price = c15[min(i+12, n15-1)]
|
|
||||||
trade_ret = (exit_price-entry)/entry if direction == "long" else (entry-exit_price)/entry
|
|
||||||
net = trade_ret * LEVERAGE - FEE_PERP * LEVERAGE
|
|
||||||
|
|
||||||
year = ts_dt.year
|
|
||||||
if year not in yearly:
|
|
||||||
yearly[year] = {"pnls": [], "wins": 0, "total": 0}
|
|
||||||
yearly[year]["pnls"].append(net)
|
|
||||||
yearly[year]["total"] += 1
|
|
||||||
if trade_ret > 0:
|
|
||||||
yearly[year]["wins"] += 1
|
|
||||||
daily_done.add(day)
|
|
||||||
return yearly
|
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# REPORT
|
|
||||||
# =====================================================================
|
|
||||||
strategies = {
|
|
||||||
"S1: Squeeze BTC 1h": run_s1_squeeze("BTC", "1h"),
|
|
||||||
"S1: Squeeze ETH 1h": run_s1_squeeze("ETH", "1h"),
|
|
||||||
"S1: Squeeze ETH 15m": run_s1_squeeze("ETH", "15m"),
|
|
||||||
"S2: VRP ETH 48h (IV est)": run_s2_vrp("ETH", 48),
|
|
||||||
"S2: VRP BTC 48h (IV est)": run_s2_vrp("BTC", 48),
|
|
||||||
"S2: MultiTF BTC 15m+1h": run_s2_multitf("BTC"),
|
|
||||||
"S2: MultiTF ETH 15m+1h": run_s2_multitf("ETH"),
|
|
||||||
}
|
|
||||||
|
|
||||||
all_years = sorted(set(y for v in strategies.values() for y in v))
|
|
||||||
|
|
||||||
print("=" * 120)
|
|
||||||
print(" MIGLIORI STRATEGIE — ANDAMENTO PER ANNO")
|
|
||||||
print(" Fee reali. PnL su €1000 flat (no compounding). Dati OHLCV reali 2018-2026.")
|
|
||||||
print(" ⚠ VRP usa IV STIMATA (non reale) — fidarsi solo dei dati perpetual per backtest lungo")
|
|
||||||
print("=" * 120)
|
|
||||||
|
|
||||||
# Header
|
|
||||||
hdr = f" {'Anno':>6s}"
|
|
||||||
for name in strategies:
|
|
||||||
short = name.split(": ")[1][:18]
|
|
||||||
hdr += f" | {short:>18s}"
|
|
||||||
print(hdr)
|
|
||||||
print(f" {'-' * (len(hdr) - 2)}")
|
|
||||||
|
|
||||||
# Per anno: accuracy / PnL totale
|
|
||||||
for year in all_years:
|
|
||||||
row_acc = f" {year:>6d}"
|
|
||||||
row_pnl = f" {'':>6s}"
|
|
||||||
for name, yearly in strategies.items():
|
|
||||||
if year in yearly:
|
|
||||||
d = yearly[year]
|
|
||||||
acc = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
|
|
||||||
pnl = sum(d["pnls"]) * INITIAL
|
|
||||||
tag = "▓" if acc >= 75 else "▒" if acc >= 65 else "░" if acc >= 55 else " "
|
|
||||||
row_acc += f" | {acc:>5.1f}% {tag} {d['total']:>3d}t"
|
|
||||||
row_pnl += f" | €{pnl:>+8.0f} "
|
|
||||||
else:
|
|
||||||
row_acc += f" | {'—':>18s}"
|
|
||||||
row_pnl += f" | {'':>18s}"
|
|
||||||
print(row_acc)
|
|
||||||
print(row_pnl)
|
|
||||||
|
|
||||||
# Totali
|
|
||||||
print(f" {'-' * (len(hdr) - 2)}")
|
|
||||||
row_tot = f" {'TOT':>6s}"
|
|
||||||
for name, yearly in strategies.items():
|
|
||||||
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
|
|
||||||
all_wins = sum(d["wins"] for d in yearly.values())
|
|
||||||
all_total = sum(d["total"] for d in yearly.values())
|
|
||||||
acc = all_wins/all_total*100 if all_total > 0 else 0
|
|
||||||
pnl = sum(all_pnls) * INITIAL
|
|
||||||
row_tot += f" | {acc:>5.1f}% {all_total:>4d}t"
|
|
||||||
print(row_tot)
|
|
||||||
|
|
||||||
row_pnl_tot = f" {'€TOT':>6s}"
|
|
||||||
for name, yearly in strategies.items():
|
|
||||||
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
|
|
||||||
pnl = sum(all_pnls) * INITIAL
|
|
||||||
row_pnl_tot += f" | €{pnl:>+8.0f} "
|
|
||||||
print(row_pnl_tot)
|
|
||||||
|
|
||||||
# Compounding
|
|
||||||
print(f"\n {'':>6s}", end="")
|
|
||||||
for name in strategies:
|
|
||||||
short = name.split(": ")[1][:18]
|
|
||||||
print(f" | {short:>18s}", end="")
|
|
||||||
print()
|
|
||||||
|
|
||||||
row_comp = f" {'COMP':>6s}"
|
|
||||||
for name, yearly in strategies.items():
|
|
||||||
cap = float(INITIAL)
|
|
||||||
for year in sorted(yearly):
|
|
||||||
for pnl in yearly[year]["pnls"]:
|
|
||||||
cap += cap * pnl
|
|
||||||
cap = max(cap, 10)
|
|
||||||
row_comp += f" | €{cap:>12,.0f} "
|
|
||||||
print(row_comp)
|
|
||||||
|
|
||||||
# Drawdown
|
|
||||||
row_dd = f" {'MAXDD':>6s}"
|
|
||||||
for name, yearly in strategies.items():
|
|
||||||
cap = float(INITIAL)
|
|
||||||
peak = cap
|
|
||||||
mdd = 0
|
|
||||||
for year in sorted(yearly):
|
|
||||||
for pnl in yearly[year]["pnls"]:
|
|
||||||
cap += cap * pnl
|
|
||||||
cap = max(cap, 10)
|
|
||||||
if cap > peak: peak = cap
|
|
||||||
dd = (peak - cap) / peak
|
|
||||||
mdd = max(mdd, dd)
|
|
||||||
row_dd += f" | {mdd*100:>12.1f}% "
|
|
||||||
print(row_dd)
|
|
||||||
|
|
||||||
# Legenda
|
|
||||||
print(f"\n Legenda: ▓ ≥75% acc ▒ ≥65% acc ░ ≥55% acc")
|
|
||||||
print(f" ⚠ S2 VRP: IV stimata (rv_long × 1.0-1.2), NON dati reali opzioni")
|
|
||||||
print(f" S1 Squeeze e S2 MultiTF: dati OHLCV reali al 100%")
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Studio: combinare TUTTE le strategie (fade + honest) migliora i risultati?
|
||||||
|
|
||||||
|
Due famiglie con meccanismi e orizzonti diversi:
|
||||||
|
FADE (intraday 1h, long/short, BTC/ETH): MR01 boll, MR02 donchian, MR07
|
||||||
|
return-reversal — tutte col filtro trend 3.0 ATR. (MR03 keltner -> waste.)
|
||||||
|
HONEST (long-only, multi-regime, multi-crypto): DIP01 (dip-buy 1h BTC),
|
||||||
|
TR01 (EMA trend 4h basket), ROT02 (dual-momentum rotation 1d).
|
||||||
|
|
||||||
|
Metodo: per ogni sleeve si costruisce l'equity GIORNALIERA normalizzata su un
|
||||||
|
indice comune (2021-01-01 -> 2026-05-26), si passa ai rendimenti giornalieri,
|
||||||
|
si misura la correlazione cross-famiglia e si confrontano i portafogli
|
||||||
|
equal-weight (ribilanciati ogni giorno) e inverse-vol. Metriche FULL e OOS
|
||||||
|
(ultimo 30% della finestra comune): ritorno, CAGR, max DD, Sharpe annualizzato.
|
||||||
|
|
||||||
|
Tutto NETTO (fee gia' incluse nelle sleeve), leva 3x, pos 15% per sleeve.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
from scripts.analysis.risk_management import strats_for, build_trades, INIT
|
||||||
|
# curve daily honest gia' pronte nell'altra famiglia
|
||||||
|
from scripts.analysis.honest_improve2 import (
|
||||||
|
_daily_equity, _norm, dip_market_gated, _tr_basket_daily, _rot_daily_equity,
|
||||||
|
)
|
||||||
|
|
||||||
|
IDX = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
|
||||||
|
OOS_FRAC = 0.30
|
||||||
|
SPLIT = int(len(IDX) * (1 - OOS_FRAC)) # confine OOS sulla finestra comune
|
||||||
|
OOS_DATE = IDX[SPLIT].date()
|
||||||
|
ANN = 365.0 # giorni/anno per annualizzare
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- equity giornaliere ----------------
|
||||||
|
def fade_daily_equity(asset: str, fn, params) -> pd.Series:
|
||||||
|
"""Equity giornaliera di uno sleeve fade: trade 1h (filtro trend 3.0) -> equity -> daily."""
|
||||||
|
df = load_data(asset, "1h")
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
trades = build_trades(fn(df, **params), df, trend_max=3.0)
|
||||||
|
n = len(df); eq = np.full(n, INIT, dtype=float); cap = INIT
|
||||||
|
for i, j, ret in sorted(trades, key=lambda t: t[1]):
|
||||||
|
cap = max(cap + cap * 0.15 * ret, 10.0)
|
||||||
|
eq[j:] = cap
|
||||||
|
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
|
||||||
|
return _norm(s)
|
||||||
|
|
||||||
|
|
||||||
|
def build_all_sleeves() -> dict[str, pd.Series]:
|
||||||
|
sleeves: dict[str, pd.Series] = {}
|
||||||
|
# --- FADE: 8 sleeve ---
|
||||||
|
for asset in ["BTC", "ETH"]:
|
||||||
|
for nm, (fn, params) in strats_for(asset).items():
|
||||||
|
sleeves[f"{nm}_{asset}"] = fade_daily_equity(asset, fn, params)
|
||||||
|
# --- HONEST: 3 sleeve (riuso le funzioni dell'altra famiglia) ---
|
||||||
|
d = dip_market_gated("BTC", market_n=0, return_equity=True)
|
||||||
|
sleeves["DIP01_BTC"] = _norm(_daily_equity(d["eq_ts"], d["eq_v"], IDX))
|
||||||
|
sleeves["TR01_basket"] = _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], IDX))
|
||||||
|
sleeves["ROT02_rot"] = _norm(_rot_daily_equity(IDX))
|
||||||
|
return sleeves
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- metriche ----------------
|
||||||
|
def metrics(daily_ret: pd.Series, lo: int = 0, hi: int | None = None) -> dict:
|
||||||
|
r = daily_ret.iloc[lo:hi]
|
||||||
|
eq = (1 + r).cumprod()
|
||||||
|
peak = eq.cummax(); dd = float(((peak - eq) / peak).max() * 100)
|
||||||
|
yrs = len(r) / ANN
|
||||||
|
tot = (eq.iloc[-1] - 1) * 100
|
||||||
|
cagr = ((eq.iloc[-1]) ** (1 / yrs) - 1) * 100 if yrs > 0 else 0.0
|
||||||
|
sharpe = float(r.mean() / r.std() * np.sqrt(ANN)) if r.std() > 0 else 0.0
|
||||||
|
return dict(ret=tot, cagr=cagr, dd=dd, sharpe=sharpe)
|
||||||
|
|
||||||
|
|
||||||
|
def yearly_returns(daily_ret: pd.Series) -> dict[int, float]:
|
||||||
|
"""Rendimento % netto per anno solare dai rendimenti giornalieri composti."""
|
||||||
|
g = daily_ret.groupby(daily_ret.index.year).apply(lambda x: ((1 + x).prod() - 1) * 100)
|
||||||
|
return {int(y): float(v) for y, v in g.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def port_returns(members: dict[str, pd.Series], weights: dict[str, float] | None = None) -> pd.Series:
|
||||||
|
"""Rendimenti giornalieri di un portafoglio ribilanciato ogni giorno ai pesi dati."""
|
||||||
|
dr = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in members.items()})
|
||||||
|
if weights is None:
|
||||||
|
return dr.mean(axis=1)
|
||||||
|
w = pd.Series(weights); w = w / w.sum()
|
||||||
|
return (dr * w).sum(axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def inv_vol_weights(members: dict[str, pd.Series], lo=0, hi=None) -> dict[str, float]:
|
||||||
|
"""Pesi inversamente proporzionali alla volatilita' (stimata sulla finestra train)."""
|
||||||
|
vol = {k: v.pct_change().iloc[lo:hi].std() for k, v in members.items()}
|
||||||
|
inv = {k: (1.0 / s if s and s > 0 else 0.0) for k, s in vol.items()}
|
||||||
|
tot = sum(inv.values())
|
||||||
|
return {k: x / tot for k, x in inv.items()}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- report ----------------
|
||||||
|
def row(label, dr):
|
||||||
|
f = metrics(dr); o = metrics(dr, lo=SPLIT)
|
||||||
|
print(f" {label:<26s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
|
||||||
|
f" | {o['ret']:>+9.0f}{o['cagr']:>7.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Costruzione equity giornaliere (puo' richiedere ~1 min)...")
|
||||||
|
S = build_all_sleeves()
|
||||||
|
fade = {k: v for k, v in S.items() if k.startswith("MR")}
|
||||||
|
honest = {k: v for k, v in S.items() if not k.startswith("MR")}
|
||||||
|
|
||||||
|
# --- correlazione cross-famiglia ---
|
||||||
|
dr = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in S.items()})
|
||||||
|
corr = dr.corr()
|
||||||
|
fade_k, hon_k = list(fade), list(honest)
|
||||||
|
cross = corr.loc[fade_k, hon_k]
|
||||||
|
print("\n" + "=" * 92)
|
||||||
|
print(f" CORRELAZIONE rendimenti giornalieri — FADE (righe) vs HONEST (colonne) | {IDX[0].date()}->{IDX[-1].date()}")
|
||||||
|
print("=" * 92)
|
||||||
|
print(f" {'':<12s}" + "".join(f"{c:>13s}" for c in hon_k))
|
||||||
|
for f in fade_k:
|
||||||
|
print(f" {f:<12s}" + "".join(f"{cross.loc[f,c]:>13.2f}" for c in hon_k))
|
||||||
|
intra_fade = corr.loc[fade_k, fade_k].values[np.triu_indices(len(fade_k), 1)].mean()
|
||||||
|
intra_hon = corr.loc[hon_k, hon_k].values[np.triu_indices(len(hon_k), 1)].mean()
|
||||||
|
print(f"\n Corr media intra-FADE {intra_fade:+.2f} | intra-HONEST {intra_hon:+.2f} | "
|
||||||
|
f"cross-famiglia {cross.values.mean():+.2f} (piu' bassa = piu' diversificazione)")
|
||||||
|
|
||||||
|
# --- confronto portafogli ---
|
||||||
|
print("\n" + "=" * 92)
|
||||||
|
print(f" PORTAFOGLI equal-weight (ribil. giornaliero) | OOS da {OOS_DATE} | leva3x pos15%/sleeve")
|
||||||
|
print("=" * 92)
|
||||||
|
print(f" {'portafoglio':<26s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
|
||||||
|
f" | {'oRet%':>9s}{'oCAGR':>7s}{'oDD%':>7s}{'oShrp':>7s}")
|
||||||
|
print(" " + "-" * 88)
|
||||||
|
row("FADE only (8 sleeve)", port_returns(fade))
|
||||||
|
row("HONEST only (3 sleeve)", port_returns(honest))
|
||||||
|
row("ALL equal-weight (11)", port_returns(S))
|
||||||
|
# 50/50 fra le due famiglie (ogni famiglia equipesata al suo interno)
|
||||||
|
fr, hr = port_returns(fade), port_returns(honest)
|
||||||
|
row("ALL 50/50 famiglie", (fr + hr) / 2)
|
||||||
|
# inverse-vol sul train, applicato a tutti gli 11 sleeve
|
||||||
|
w = inv_vol_weights(S, lo=0, hi=SPLIT)
|
||||||
|
row("ALL inverse-vol", port_returns(S, w))
|
||||||
|
print(" " + "-" * 88)
|
||||||
|
print(" Sharpe annualizzato sui rendimenti giornalieri. Confronta DD e Sharpe:")
|
||||||
|
print(" se il combinato ha DD piu' basso e Sharpe piu' alto delle singole famiglie, combinare conviene.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Combina i NUOVI edge (pairs + TSM01) col MASTER esistente: migliora il portafoglio?
|
||||||
|
|
||||||
|
Aggiunge al MASTER a 9 sleeve (6 fade + 3 honest) due nuove fonti scoperte
|
||||||
|
nell'esplorazione, poco correlate:
|
||||||
|
- PAIRS market-neutral (ETH/BTC, LTC/ETH, ADA/ETH) -> corr ~0 col mercato
|
||||||
|
- TSM01 (TSMOM multi-orizzonte + risk-off) -> corr ~0.53 con ROT02
|
||||||
|
|
||||||
|
Misura correlazione delle nuove sleeve vs esistenti e confronta MASTER-9 vs
|
||||||
|
MASTER-esteso su Ret/CAGR/DD/Sharpe, FULL e OOS (finestra comune 2021-2026).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.combine_portfolio import (
|
||||||
|
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
|
||||||
|
)
|
||||||
|
from scripts.analysis.honest_improve2 import _daily_equity, _norm
|
||||||
|
from scripts.analysis.pairs_research import pairs_sim
|
||||||
|
from scripts.analysis.tsmom_research import tsmom_sim
|
||||||
|
|
||||||
|
|
||||||
|
def daily_from(eq_ts, eq_v):
|
||||||
|
return _norm(_daily_equity(eq_ts, eq_v, IDX))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Costruzione equity (puo' richiedere ~1-2 min)...\n")
|
||||||
|
S = build_all_sleeves() # 9 sleeve esistenti
|
||||||
|
|
||||||
|
# nuove sleeve: i 6 pairs robusti di PR01 + TSM01
|
||||||
|
from scripts.strategies.PR01_pairs_reversion import PAIRS
|
||||||
|
new = {}
|
||||||
|
for a, b, p in PAIRS:
|
||||||
|
r = pairs_sim(a, b, **p)
|
||||||
|
new[f"PR_{a}{b}"] = daily_from(r["eq_ts"], r["eq_v"])
|
||||||
|
t = tsmom_sim()
|
||||||
|
new["TSM01"] = daily_from(t["eq_ts"], t["eq_v"])
|
||||||
|
|
||||||
|
allS = {**S, **new}
|
||||||
|
|
||||||
|
# --- correlazione nuove vs esistenti ---
|
||||||
|
dr = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in allS.items()})
|
||||||
|
corr = dr.corr()
|
||||||
|
old_k = list(S); new_k = list(new)
|
||||||
|
print("=" * 88)
|
||||||
|
print(" CORRELAZIONE rendimenti giornalieri — NUOVE (righe) vs media esistenti")
|
||||||
|
print("=" * 88)
|
||||||
|
for nk in new_k:
|
||||||
|
avg = corr.loc[nk, old_k].mean()
|
||||||
|
mx = corr.loc[nk, old_k].abs().max()
|
||||||
|
print(f" {nk:<12s} corr media col MASTER-9 = {avg:+.2f} |max| = {mx:.2f}")
|
||||||
|
|
||||||
|
# --- confronto portafogli ---
|
||||||
|
def line(label, members):
|
||||||
|
pr = port_returns(members)
|
||||||
|
f, o = metrics(pr), metrics(pr, lo=SPLIT)
|
||||||
|
print(f" {label:<26s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
|
||||||
|
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||||
|
return pr
|
||||||
|
|
||||||
|
print("\n" + "=" * 96)
|
||||||
|
print(f" MASTER-9 vs MASTER-ESTESO (con pairs+TSM01) | OOS da {OOS_DATE} | equal-weight daily")
|
||||||
|
print("=" * 96)
|
||||||
|
print(f" {'portafoglio':<26s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
|
||||||
|
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
|
||||||
|
print(" " + "-" * 92)
|
||||||
|
pairs_only = {k: v for k, v in new.items() if k.startswith('PR_')}
|
||||||
|
line(f"MASTER-9 (base)", S)
|
||||||
|
line(f"MASTER +pairs ({len(S)+len(pairs_only)})", {**S, **pairs_only})
|
||||||
|
line(f"MASTER +TSM01 ({len(S)+1})", {**S, "TSM01": new["TSM01"]})
|
||||||
|
pr_all = line(f"MASTER-esteso ({len(allS)})", allS)
|
||||||
|
print(" " + "-" * 92)
|
||||||
|
pa = yearly_returns(pr_all)
|
||||||
|
print(" MASTER-esteso per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
|
||||||
|
print("\n Se il MASTER-esteso ha DD piu' basso e/o Sharpe piu' alto del MASTER-9, le nuove")
|
||||||
|
print(" famiglie aggiungono valore (diversificazione da fonti scorrelate).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,559 +0,0 @@
|
|||||||
"""Analisi finale — S1 (squeeze puro) vs Script 13 (squeeze+ML GBM).
|
|
||||||
Metriche: PnL, num trades, DD max, tempo medio a mercato, descrizione.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, ".")
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
from sklearn.ensemble import GradientBoostingClassifier
|
|
||||||
from sklearn.preprocessing import StandardScaler
|
|
||||||
from src.data.downloader import load_data
|
|
||||||
from src.fractal.patterns import encode_candles
|
|
||||||
|
|
||||||
FEE_PERP = 0.002
|
|
||||||
FEE_ML = 0.001
|
|
||||||
INITIAL = 1000
|
|
||||||
LEVERAGE = 3
|
|
||||||
|
|
||||||
TF_MINUTES = {"1m": 1, "5m": 5, "15m": 15, "1h": 60, "4h": 240, "1d": 1440}
|
|
||||||
|
|
||||||
|
|
||||||
# ── helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def keltner_ratio(close, high, low, window=14):
|
|
||||||
n = len(close)
|
|
||||||
r = np.full(n, np.nan)
|
|
||||||
for i in range(window, n):
|
|
||||||
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
|
|
||||||
ma = np.mean(wc)
|
|
||||||
bb_std = np.std(wc)
|
|
||||||
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
|
|
||||||
atr = np.mean(tr[1:])
|
|
||||||
kc = (ma+1.5*atr)-(ma-1.5*atr)
|
|
||||||
bb = (ma+2*bb_std)-(ma-2*bb_std)
|
|
||||||
if kc > 0:
|
|
||||||
r[i] = bb/kc
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def detect_squeezes(close, high, low, kcr, sq_thr=0.8, min_dur=5):
|
|
||||||
events = []
|
|
||||||
in_sq = False
|
|
||||||
sq_start = 0
|
|
||||||
for i in range(1, len(close)):
|
|
||||||
if np.isnan(kcr[i]):
|
|
||||||
continue
|
|
||||||
is_sq = kcr[i] < sq_thr
|
|
||||||
if is_sq and not in_sq:
|
|
||||||
in_sq = True
|
|
||||||
sq_start = i
|
|
||||||
elif not is_sq and in_sq:
|
|
||||||
in_sq = False
|
|
||||||
dur = i - sq_start
|
|
||||||
if dur < min_dur:
|
|
||||||
continue
|
|
||||||
events.append({"idx": i, "dur": dur, "sq_start": sq_start,
|
|
||||||
"avg_vol_squeeze": np.mean(close[sq_start:i]),
|
|
||||||
"kcr_at_release": kcr[i]})
|
|
||||||
return events
|
|
||||||
|
|
||||||
|
|
||||||
def _build_result(yearly, capital, max_dd, all_t, all_w, time_pct, avg_dur_h):
|
|
||||||
acc = all_w / all_t * 100
|
|
||||||
tot_pnl = sum(p for d in yearly.values() for p in d["pnls"])
|
|
||||||
years_active = len(yearly)
|
|
||||||
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
|
|
||||||
sharpe = np.mean(all_pnls) / np.std(all_pnls) * np.sqrt(252) if len(all_pnls) > 1 and np.std(all_pnls) > 0 else 0
|
|
||||||
|
|
||||||
year_details = {}
|
|
||||||
for y in sorted(yearly):
|
|
||||||
d = yearly[y]
|
|
||||||
ya = d["w"] / d["t"] * 100 if d["t"] > 0 else 0
|
|
||||||
yp = sum(d["pnls"])
|
|
||||||
year_details[y] = {"trades": d["t"], "acc": ya, "pnl": yp}
|
|
||||||
|
|
||||||
valid_years = {y: d for y, d in year_details.items() if d["trades"] >= 10}
|
|
||||||
if valid_years:
|
|
||||||
worst_y = min(valid_years, key=lambda y: valid_years[y]["acc"])
|
|
||||||
worst_acc = valid_years[worst_y]["acc"]
|
|
||||||
elif year_details:
|
|
||||||
worst_y = min(year_details, key=lambda y: year_details[y]["acc"])
|
|
||||||
worst_acc = year_details[worst_y]["acc"]
|
|
||||||
else:
|
|
||||||
worst_y = "N/A"
|
|
||||||
worst_acc = 0
|
|
||||||
|
|
||||||
daily_pnl = tot_pnl / (years_active * 365) if years_active > 0 else 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"trades": all_t, "acc": acc, "pnl": tot_pnl, "capital": capital,
|
|
||||||
"max_dd": max_dd * 100, "sharpe": sharpe, "daily_pnl": daily_pnl,
|
|
||||||
"time_in_market_pct": time_pct, "avg_dur_h": avg_dur_h,
|
|
||||||
"years_active": years_active, "worst_year": str(worst_y),
|
|
||||||
"worst_acc": worst_acc, "year_details": year_details,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── S1: Squeeze breakout puro ────────────────────────────────────────
|
|
||||||
|
|
||||||
def run_s1_squeeze(asset, tf, hold=3):
|
|
||||||
df = load_data(asset, tf)
|
|
||||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
|
||||||
n = len(c)
|
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
kcr = keltner_ratio(c, h, l, 14)
|
|
||||||
events = detect_squeezes(c, h, l, kcr)
|
|
||||||
|
|
||||||
yearly = {}
|
|
||||||
capital = float(INITIAL)
|
|
||||||
peak = capital
|
|
||||||
max_dd = 0
|
|
||||||
total_bars = 0
|
|
||||||
|
|
||||||
for ev in events:
|
|
||||||
i = ev["idx"]
|
|
||||||
if i + hold + 1 >= n:
|
|
||||||
continue
|
|
||||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
|
||||||
if abs(first_ret) < 0.001:
|
|
||||||
continue
|
|
||||||
direction = 1 if first_ret > 0 else -1
|
|
||||||
entry = c[i-1]
|
|
||||||
exit_price = c[min(i + hold - 1, n - 1)]
|
|
||||||
actual = (exit_price - entry) / entry * direction
|
|
||||||
net = actual * LEVERAGE - FEE_PERP * LEVERAGE
|
|
||||||
|
|
||||||
capital += capital * 0.15 * net
|
|
||||||
capital = max(capital, 10)
|
|
||||||
if capital > peak: peak = capital
|
|
||||||
dd = (peak - capital) / peak
|
|
||||||
max_dd = max(max_dd, dd)
|
|
||||||
total_bars += hold
|
|
||||||
|
|
||||||
year = ts.iloc[i].year
|
|
||||||
if year not in yearly:
|
|
||||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
|
||||||
yearly[year]["t"] += 1
|
|
||||||
if actual > 0: yearly[year]["w"] += 1
|
|
||||||
yearly[year]["pnls"].append(net * INITIAL)
|
|
||||||
|
|
||||||
all_t = sum(d["t"] for d in yearly.values())
|
|
||||||
all_w = sum(d["w"] for d in yearly.values())
|
|
||||||
if all_t == 0: return None
|
|
||||||
return _build_result(yearly, capital, max_dd, all_t, all_w,
|
|
||||||
total_bars / n * 100, hold * TF_MINUTES.get(tf, 60) / 60)
|
|
||||||
|
|
||||||
|
|
||||||
def run_s1_antifake_vol(asset, tf, hold=3):
|
|
||||||
df = load_data(asset, tf)
|
|
||||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
|
||||||
n = len(c)
|
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
kcr = keltner_ratio(c, h, l, 14)
|
|
||||||
events = detect_squeezes(c, h, l, kcr)
|
|
||||||
|
|
||||||
yearly = {}
|
|
||||||
capital = float(INITIAL)
|
|
||||||
peak = capital
|
|
||||||
max_dd = 0
|
|
||||||
total_bars = 0
|
|
||||||
|
|
||||||
for ev in events:
|
|
||||||
i = ev["idx"]
|
|
||||||
if i + hold + 1 >= n:
|
|
||||||
continue
|
|
||||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
|
||||||
if abs(first_ret) < 0.001:
|
|
||||||
continue
|
|
||||||
br = h[i] - l[i]
|
|
||||||
if br > 0:
|
|
||||||
if c[i] > c[i-1]:
|
|
||||||
if (h[i] - c[i]) / br > 0.6:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
if (c[i] - l[i]) / br > 0.6:
|
|
||||||
continue
|
|
||||||
avg_v = np.mean(v[ev["sq_start"]:i])
|
|
||||||
if avg_v > 0 and v[i] <= avg_v * 1.3:
|
|
||||||
continue
|
|
||||||
|
|
||||||
direction = 1 if first_ret > 0 else -1
|
|
||||||
entry = c[i-1]
|
|
||||||
exit_price = c[min(i + hold - 1, n - 1)]
|
|
||||||
actual = (exit_price - entry) / entry * direction
|
|
||||||
net = actual * LEVERAGE - FEE_PERP * LEVERAGE
|
|
||||||
capital += capital * 0.15 * net
|
|
||||||
capital = max(capital, 10)
|
|
||||||
if capital > peak: peak = capital
|
|
||||||
dd = (peak - capital) / peak
|
|
||||||
max_dd = max(max_dd, dd)
|
|
||||||
total_bars += hold
|
|
||||||
|
|
||||||
year = ts.iloc[i].year
|
|
||||||
if year not in yearly:
|
|
||||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
|
||||||
yearly[year]["t"] += 1
|
|
||||||
if actual > 0: yearly[year]["w"] += 1
|
|
||||||
yearly[year]["pnls"].append(net * INITIAL)
|
|
||||||
|
|
||||||
all_t = sum(d["t"] for d in yearly.values())
|
|
||||||
all_w = sum(d["w"] for d in yearly.values())
|
|
||||||
if all_t == 0: return None
|
|
||||||
return _build_result(yearly, capital, max_dd, all_t, all_w,
|
|
||||||
total_bars / n * 100, hold * TF_MINUTES.get(tf, 60) / 60)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Script 13: Squeeze + ML ibrida (GBM walk-forward) ────────────────
|
|
||||||
|
|
||||||
def build_features_at(df, i, squeeze_info):
|
|
||||||
if i < 100:
|
|
||||||
return None
|
|
||||||
o = df["open"].values
|
|
||||||
h = df["high"].values
|
|
||||||
l = df["low"].values
|
|
||||||
c = df["close"].values
|
|
||||||
v = df["volume"].values
|
|
||||||
feats = []
|
|
||||||
for w in [12, 24, 48]:
|
|
||||||
win_c = c[i-w:i]
|
|
||||||
win_o = o[i-w:i]
|
|
||||||
win_h = h[i-w:i]
|
|
||||||
win_l = l[i-w:i]
|
|
||||||
win_v = v[i-w:i]
|
|
||||||
mn, mx = win_l.min(), max(win_h.max(), win_c.max())
|
|
||||||
rng = mx - mn if mx - mn > 0 else 1e-10
|
|
||||||
total = win_h - win_l
|
|
||||||
total = np.where(total == 0, 1e-10, total)
|
|
||||||
body = np.abs(win_c - win_o) / total
|
|
||||||
direction = np.sign(win_c - win_o)
|
|
||||||
log_c = np.log(np.where(win_c == 0, 1e-10, win_c))
|
|
||||||
rets = np.diff(log_c)
|
|
||||||
v_mean = np.mean(win_v)
|
|
||||||
feats.extend([
|
|
||||||
np.mean(rets) if len(rets) > 0 else 0,
|
|
||||||
np.std(rets) if len(rets) > 0 else 0,
|
|
||||||
np.sum(rets) if len(rets) > 0 else 0,
|
|
||||||
float(pd.Series(rets).skew()) if len(rets) > 2 else 0,
|
|
||||||
float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0,
|
|
||||||
np.mean(body), np.std(body),
|
|
||||||
np.mean(direction), np.mean(direction[-min(3, w):]),
|
|
||||||
(win_c[-1] - mn) / rng,
|
|
||||||
win_v[-1] / v_mean if v_mean > 0 else 1,
|
|
||||||
np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0,
|
|
||||||
])
|
|
||||||
sq = squeeze_info
|
|
||||||
feats.extend([
|
|
||||||
sq["dur"], sq["dur"] / 24, sq["kcr_at_release"],
|
|
||||||
v[i-1] / sq["avg_vol_squeeze"] if sq["avg_vol_squeeze"] > 0 else 1,
|
|
||||||
np.mean(v[i:min(i+3, len(v))]) / sq["avg_vol_squeeze"] if sq["avg_vol_squeeze"] > 0 else 1,
|
|
||||||
])
|
|
||||||
h48 = np.max(h[max(0, i-48):i])
|
|
||||||
l48 = np.min(l[max(0, i-48):i])
|
|
||||||
r48 = h48 - l48
|
|
||||||
feats.append((c[i-1] - l48) / r48 if r48 > 0 else 0.5)
|
|
||||||
tr = np.maximum(h[i-14:i] - l[i-14:i],
|
|
||||||
np.maximum(np.abs(h[i-14:i] - np.roll(c[i-14:i], 1)),
|
|
||||||
np.abs(l[i-14:i] - np.roll(c[i-14:i], 1))))
|
|
||||||
atr = np.mean(tr[1:])
|
|
||||||
feats.append(atr / c[i-1] if c[i-1] > 0 else 0)
|
|
||||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
|
||||||
feats.append(first_ret)
|
|
||||||
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
|
|
||||||
|
|
||||||
|
|
||||||
def run_s13_hybrid(asset, tf, bb_w, sq_thr, brk_bars, leverage, pos_pct, ml_thr):
|
|
||||||
df = load_data(asset, tf)
|
|
||||||
close = df["close"].values
|
|
||||||
high = df["high"].values
|
|
||||||
low = df["low"].values
|
|
||||||
volume = df["volume"].values
|
|
||||||
n = len(df)
|
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
|
|
||||||
kcr = keltner_ratio(close, high, low, bb_w)
|
|
||||||
events = detect_squeezes(close, high, low, kcr, sq_thr)
|
|
||||||
|
|
||||||
X_all, y_all, ev_all = [], [], []
|
|
||||||
for ev in events:
|
|
||||||
i = ev["idx"]
|
|
||||||
if i + brk_bars >= n or i < 100:
|
|
||||||
continue
|
|
||||||
feats = build_features_at(df, i, ev)
|
|
||||||
if feats is None:
|
|
||||||
continue
|
|
||||||
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
|
|
||||||
X_all.append(feats)
|
|
||||||
y_all.append(1 if actual_ret > 0 else 0)
|
|
||||||
ev_all.append(ev)
|
|
||||||
|
|
||||||
if len(X_all) < 50:
|
|
||||||
return None
|
|
||||||
|
|
||||||
X = np.array(X_all)
|
|
||||||
y = np.array(y_all)
|
|
||||||
|
|
||||||
TRAIN_SIZE = max(int(len(X) * 0.5), 50)
|
|
||||||
STEP_SIZE = max(int(len(X) * 0.1), 10)
|
|
||||||
|
|
||||||
yearly = {}
|
|
||||||
capital = float(INITIAL)
|
|
||||||
peak = capital
|
|
||||||
max_dd = 0
|
|
||||||
total_bars = 0
|
|
||||||
all_t = 0
|
|
||||||
all_w = 0
|
|
||||||
|
|
||||||
start = 0
|
|
||||||
while start + TRAIN_SIZE + STEP_SIZE <= len(X):
|
|
||||||
train_end = start + TRAIN_SIZE
|
|
||||||
test_end = min(train_end + STEP_SIZE, len(X))
|
|
||||||
X_tr, y_tr = X[start:train_end], y[start:train_end]
|
|
||||||
X_te = X[train_end:test_end]
|
|
||||||
|
|
||||||
if len(np.unique(y_tr)) < 2:
|
|
||||||
start += STEP_SIZE
|
|
||||||
continue
|
|
||||||
|
|
||||||
scaler = StandardScaler()
|
|
||||||
X_tr_s = scaler.fit_transform(X_tr)
|
|
||||||
X_te_s = scaler.transform(X_te)
|
|
||||||
|
|
||||||
model = GradientBoostingClassifier(
|
|
||||||
n_estimators=150, max_depth=4, min_samples_leaf=10,
|
|
||||||
learning_rate=0.05, subsample=0.8, random_state=42,
|
|
||||||
)
|
|
||||||
model.fit(X_tr_s, y_tr)
|
|
||||||
|
|
||||||
up_idx = list(model.classes_).index(1) if 1 in model.classes_ else -1
|
|
||||||
if up_idx < 0:
|
|
||||||
start += STEP_SIZE
|
|
||||||
continue
|
|
||||||
|
|
||||||
for j in range(len(X_te)):
|
|
||||||
proba = model.predict_proba(X_te_s[j:j+1])[0]
|
|
||||||
p_up = proba[up_idx]
|
|
||||||
|
|
||||||
ev = ev_all[train_end + j]
|
|
||||||
i = ev["idx"]
|
|
||||||
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
|
|
||||||
|
|
||||||
direction = None
|
|
||||||
if p_up >= ml_thr:
|
|
||||||
direction = 1
|
|
||||||
elif p_up <= (1 - ml_thr):
|
|
||||||
direction = -1
|
|
||||||
if direction is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
is_correct = (direction == 1 and actual_ret > 0) or (direction == -1 and actual_ret < 0)
|
|
||||||
trade_ret = actual_ret * direction
|
|
||||||
net = trade_ret * leverage - FEE_ML * 2 * leverage
|
|
||||||
capital += capital * pos_pct * net
|
|
||||||
capital = max(capital, 10)
|
|
||||||
if capital > peak: peak = capital
|
|
||||||
dd = (peak - capital) / peak
|
|
||||||
max_dd = max(max_dd, dd)
|
|
||||||
total_bars += brk_bars
|
|
||||||
|
|
||||||
all_t += 1
|
|
||||||
if is_correct: all_w += 1
|
|
||||||
|
|
||||||
year = ts.iloc[i].year
|
|
||||||
if year not in yearly:
|
|
||||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
|
||||||
yearly[year]["t"] += 1
|
|
||||||
if is_correct: yearly[year]["w"] += 1
|
|
||||||
yearly[year]["pnls"].append(net * INITIAL)
|
|
||||||
|
|
||||||
start += STEP_SIZE
|
|
||||||
|
|
||||||
if all_t == 0:
|
|
||||||
return None
|
|
||||||
return _build_result(yearly, capital, max_dd, all_t, all_w,
|
|
||||||
total_bars / n * 100, brk_bars * TF_MINUTES.get(tf, 60) / 60)
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
# ESECUZIONE
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print("Calcolo in corso...\n")
|
|
||||||
|
|
||||||
strategies = []
|
|
||||||
|
|
||||||
def add(name, desc, cat, result):
|
|
||||||
if result and result["trades"] >= 20:
|
|
||||||
strategies.append({"name": name, "desc": desc, "cat": cat, **result})
|
|
||||||
|
|
||||||
# ── S1: Squeeze puro ────────────────────────────────────────────
|
|
||||||
add("S1 Squeeze BTC 15m", "Squeeze breakout puro, BBw=14, hold 3×15m, leva 3x",
|
|
||||||
"S1", run_s1_squeeze("BTC", "15m"))
|
|
||||||
add("S1 Squeeze ETH 15m", "Squeeze breakout puro, BBw=14, hold 3×15m, leva 3x",
|
|
||||||
"S1", run_s1_squeeze("ETH", "15m"))
|
|
||||||
add("S1 Squeeze BTC 1h", "Squeeze breakout puro, BBw=14, hold 3×1h, leva 3x",
|
|
||||||
"S1", run_s1_squeeze("BTC", "1h"))
|
|
||||||
add("S1 Squeeze ETH 1h", "Squeeze breakout puro, BBw=14, hold 3×1h, leva 3x",
|
|
||||||
"S1", run_s1_squeeze("ETH", "1h"))
|
|
||||||
add("S1 AF+Vol BTC 15m", "Squeeze + antifakeout + volume confirm >1.3× media",
|
|
||||||
"S1", run_s1_antifake_vol("BTC", "15m"))
|
|
||||||
add("S1 AF+Vol ETH 15m", "Squeeze + antifakeout + volume confirm >1.3× media",
|
|
||||||
"S1", run_s1_antifake_vol("ETH", "15m"))
|
|
||||||
add("S1 AF+Vol BTC 1h", "Squeeze + antifakeout + volume confirm >1.3× media",
|
|
||||||
"S1", run_s1_antifake_vol("BTC", "1h"))
|
|
||||||
add("S1 AF+Vol ETH 1h", "Squeeze + antifakeout + volume confirm >1.3× media",
|
|
||||||
"S1", run_s1_antifake_vol("ETH", "1h"))
|
|
||||||
|
|
||||||
# ── Script 13: Squeeze + ML (GBM walk-forward) ─────────────────
|
|
||||||
print(" Training ML models...")
|
|
||||||
add("S13 ETH 15m bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.70, 3x leva 15% pos",
|
|
||||||
"S13", run_s13_hybrid("ETH", "15m", 14, 0.8, 3, 3, 0.15, 0.70))
|
|
||||||
add("S13 ETH 15m bb14 ml65", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.65, 3x leva 15% pos",
|
|
||||||
"S13", run_s13_hybrid("ETH", "15m", 14, 0.8, 3, 3, 0.15, 0.65))
|
|
||||||
add("S13 ETH 15m bb20 ml70", "Squeeze+GBM walk-forward, BBw=20 sq=0.9 ml≥0.70, 3x leva 15% pos",
|
|
||||||
"S13", run_s13_hybrid("ETH", "15m", 20, 0.9, 3, 3, 0.15, 0.70))
|
|
||||||
add("S13 BTC 15m bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.9 ml≥0.70, 3x leva 15% pos",
|
|
||||||
"S13", run_s13_hybrid("BTC", "15m", 14, 0.9, 3, 3, 0.15, 0.70))
|
|
||||||
add("S13 BTC 15m bb14 ml65", "Squeeze+GBM walk-forward, BBw=14 sq=0.9 ml≥0.65, 3x leva 15% pos",
|
|
||||||
"S13", run_s13_hybrid("BTC", "15m", 14, 0.9, 3, 3, 0.15, 0.65))
|
|
||||||
add("S13 BTC 1h bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.70, 3x leva 20% pos",
|
|
||||||
"S13", run_s13_hybrid("BTC", "1h", 14, 0.8, 3, 3, 0.20, 0.70))
|
|
||||||
add("S13 BTC 1h bb14 ml65", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.65, 3x leva 20% pos",
|
|
||||||
"S13", run_s13_hybrid("BTC", "1h", 14, 0.8, 3, 3, 0.20, 0.65))
|
|
||||||
add("S13 ETH 1h bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.70, 3x leva 20% pos",
|
|
||||||
"S13", run_s13_hybrid("ETH", "1h", 14, 0.8, 3, 3, 0.20, 0.70))
|
|
||||||
|
|
||||||
strategies.sort(key=lambda x: x["acc"], reverse=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
# TABELLA 1: Classifica
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
W = 150
|
|
||||||
print("=" * W)
|
|
||||||
print(" S1 (SQUEEZE PURO) vs S13 (SQUEEZE + GBM) — CLASSIFICA FINALE")
|
|
||||||
print(f" Fee: 0.2% RT. Dati OHLCV reali 2018-2026. Position 15%. Leva 3x.")
|
|
||||||
print("=" * W)
|
|
||||||
hdr = (f" {'#':>2s} {'Cat':>3s} {'Nome':<26s} {'Trades':>6s} {'Acc':>6s} "
|
|
||||||
f"{'PnL€':>9s} {'DD%':>6s} {'€/day':>7s} {'Sharpe':>7s} "
|
|
||||||
f"{'Mkt%':>5s} {'Dur':>6s} {'Worst':>12s} {'Anni':>4s}")
|
|
||||||
print(hdr)
|
|
||||||
print(f" {'─'*(W-4)}")
|
|
||||||
|
|
||||||
for idx, s in enumerate(strategies, 1):
|
|
||||||
worst = f"{s['worst_year']}({s['worst_acc']:.0f}%)"
|
|
||||||
dur_str = f"{s['avg_dur_h']:.0f}h" if s['avg_dur_h'] >= 1 else f"{s['avg_dur_h']*60:.0f}m"
|
|
||||||
tag = " ★★" if s["acc"] >= 78 else " ★" if s["acc"] >= 76 else ""
|
|
||||||
print(f" {idx:>2d} {s['cat']:>3s} {s['name']:<26s} {s['trades']:>6d} {s['acc']:>5.1f}% "
|
|
||||||
f"€{s['pnl']:>+8.0f} {s['max_dd']:>5.1f}% {s['daily_pnl']:>+6.2f} {s['sharpe']:>7.2f} "
|
|
||||||
f"{s['time_in_market_pct']:>4.1f}% {dur_str:>6s} {worst:>12s} {s['years_active']:>4d}{tag}")
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
# TABELLA 2: Descrizione
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print(f"\n\n{'=' * W}")
|
|
||||||
print(" DESCRIZIONE")
|
|
||||||
print(f"{'=' * W}")
|
|
||||||
print(f" {'#':>2s} {'Nome':<26s} {'Descrizione'}")
|
|
||||||
print(f" {'─'*(W-4)}")
|
|
||||||
for idx, s in enumerate(strategies, 1):
|
|
||||||
print(f" {idx:>2d} {s['name']:<26s} {s['desc']}")
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
# TABELLA 3: Breakdown per anno
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
top_n = min(12, len(strategies))
|
|
||||||
top = strategies[:top_n]
|
|
||||||
all_years = sorted(set(y for s in top for y in s["year_details"]))
|
|
||||||
|
|
||||||
print(f"\n\n{'=' * W}")
|
|
||||||
print(f" BREAKDOWN PER ANNO — TOP {top_n} (accuracy% / trades)")
|
|
||||||
print(f"{'=' * W}")
|
|
||||||
|
|
||||||
header = f" {'Nome':<26s}"
|
|
||||||
for y in all_years:
|
|
||||||
header += f" {y:>10d}"
|
|
||||||
print(header)
|
|
||||||
print(f" {'─'*(W-4)}")
|
|
||||||
|
|
||||||
for s in top:
|
|
||||||
line = f" {s['name']:<26s}"
|
|
||||||
for y in all_years:
|
|
||||||
if y in s["year_details"]:
|
|
||||||
d = s["year_details"][y]
|
|
||||||
line += f" {d['acc']:>4.0f}%/{d['trades']:<4d}"
|
|
||||||
else:
|
|
||||||
line += f" {'—':>10s}"
|
|
||||||
print(line)
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
# TABELLA 4: Robustezza
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print(f"\n\n{'=' * W}")
|
|
||||||
print(f" ANALISI ROBUSTEZZA")
|
|
||||||
print(f"{'=' * W}")
|
|
||||||
print(f" {'#':>2s} {'Nome':<26s} {'MinAcc':>7s} {'MaxAcc':>7s} {'Spread':>7s} "
|
|
||||||
f"{'AnniOK':>7s} {'€/trade':>8s} {'Verdict':<12s}")
|
|
||||||
print(f" {'─'*90}")
|
|
||||||
|
|
||||||
for idx, s in enumerate(strategies, 1):
|
|
||||||
yd = s["year_details"]
|
|
||||||
valid = {y: d for y, d in yd.items() if d["trades"] >= 10}
|
|
||||||
accs = [d["acc"] for d in (valid if valid else yd).values()]
|
|
||||||
if not accs:
|
|
||||||
continue
|
|
||||||
min_a, max_a = min(accs), max(accs)
|
|
||||||
spread = max_a - min_a
|
|
||||||
years_ok = sum(1 for a in accs if a >= 70)
|
|
||||||
avg_pnl = s["pnl"] / s["trades"] if s["trades"] > 0 else 0
|
|
||||||
n_valid = len(valid if valid else yd)
|
|
||||||
|
|
||||||
if n_valid < 4:
|
|
||||||
verdict = "⚠ CORTO"
|
|
||||||
elif min_a < 60:
|
|
||||||
verdict = "⚠ FRAGILE"
|
|
||||||
elif min_a >= 72 and s["acc"] >= 77:
|
|
||||||
verdict = "✅ SOLIDO"
|
|
||||||
elif min_a >= 65 and s["acc"] >= 74:
|
|
||||||
verdict = "~ BUONO"
|
|
||||||
else:
|
|
||||||
verdict = "~ OK"
|
|
||||||
|
|
||||||
print(f" {idx:>2d} {s['name']:<26s} {min_a:>6.1f}% {max_a:>6.1f}% {spread:>6.1f}% "
|
|
||||||
f"{years_ok:>3d}/{n_valid:<3d} €{avg_pnl:>+7.1f} {verdict:<12s}")
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
# VERDETTO
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print(f"\n\n{'=' * W}")
|
|
||||||
print(f" VERDETTO FINALE")
|
|
||||||
print(f"{'=' * W}")
|
|
||||||
|
|
||||||
solidi = [s for s in strategies if s["trades"] >= 200 and s["years_active"] >= 5 and s["worst_acc"] >= 65]
|
|
||||||
solidi_s1 = [s for s in solidi if s["cat"] == "S1"]
|
|
||||||
solidi_ml = [s for s in solidi if s["cat"] == "S13"]
|
|
||||||
solidi_s1.sort(key=lambda x: x["acc"], reverse=True)
|
|
||||||
solidi_ml.sort(key=lambda x: x["daily_pnl"], reverse=True)
|
|
||||||
|
|
||||||
if solidi_s1:
|
|
||||||
b = solidi_s1[0]
|
|
||||||
print(f"\n MIGLIORE S1 (regole pure, facile da deployare):")
|
|
||||||
print(f" {b['name']} — {b['acc']:.1f}% acc, {b['trades']} trades, DD {b['max_dd']:.1f}%, €{b['daily_pnl']:+.2f}/day, Sharpe {b['sharpe']:.2f}")
|
|
||||||
|
|
||||||
if solidi_ml:
|
|
||||||
m = solidi_ml[0]
|
|
||||||
print(f"\n MIGLIORE S13 (squeeze+GBM, più complesso):")
|
|
||||||
print(f" {m['name']} — {m['acc']:.1f}% acc, {m['trades']} trades, DD {m['max_dd']:.1f}%, €{m['daily_pnl']:+.2f}/day, Sharpe {m['sharpe']:.2f}")
|
|
||||||
|
|
||||||
max_pnl = max(strategies, key=lambda x: x["pnl"])
|
|
||||||
print(f"\n MAX PnL: {max_pnl['name']} — €{max_pnl['pnl']:+,.0f}")
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Harness ONESTO condiviso per esplorare nuove famiglie di strategie.
|
||||||
|
|
||||||
|
Regole NON negoziabili (per non ripetere l'errore squeeze look-ahead):
|
||||||
|
- direzione e prezzo decisi con dati FINO a close[i] incluso, mai con la barra i
|
||||||
|
usata per scegliere la direzione e poi entrare a i-1;
|
||||||
|
- ingresso ESEGUIBILE a close[i];
|
||||||
|
- exit: take-profit / stop-loss intrabar (high/low) e/o time-limit max_bars;
|
||||||
|
tp/sl possono essere None -> exit solo a tempo (utile per stagionalita');
|
||||||
|
- una posizione per volta (non-overlap), capitale composto;
|
||||||
|
- NETTO dopo fee round-trip (default 0.10% RT reale Deribit) e leva;
|
||||||
|
- validazione OOS (held-out, ultimo 30%) + sweep fee 0.00-0.20% RT.
|
||||||
|
|
||||||
|
Le strategie ad alta frequenza muoiono di fee: ogni entry costa fee_rt*lev sul
|
||||||
|
notional. Tienine conto: meno operazioni e edge > costi.
|
||||||
|
|
||||||
|
Asset disponibili: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m; BTC/ETH anche 5m).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
|
||||||
|
FEE_RT = 0.001 # Deribit perp realistico: taker ~0.05%/lato = 0.10% RT
|
||||||
|
LEV = 3.0
|
||||||
|
POS = 0.15
|
||||||
|
OOS_FRAC = 0.30
|
||||||
|
ASSETS = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
|
||||||
|
BARS_PER_YEAR = {"5m": 105120, "15m": 35040, "1h": 8760, "4h": 2190, "1d": 365}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- dati ---------------------------
|
||||||
|
def get_df(asset: str, tf: str) -> pd.DataFrame:
|
||||||
|
"""OHLCV con colonna dt (UTC). tf nativo (5m,15m,1h) o resample da 1h (4h,1d).
|
||||||
|
timestamp resta ms-epoch reale anche dopo il resample (no placeholder)."""
|
||||||
|
if tf in ("5m", "15m", "1h"):
|
||||||
|
df = load_data(asset, tf).reset_index(drop=True)
|
||||||
|
else:
|
||||||
|
base = load_data(asset, "1h").copy()
|
||||||
|
base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
|
||||||
|
base = base.set_index("dt")
|
||||||
|
rule = {"4h": "4h", "1d": "1D"}[tf]
|
||||||
|
agg = base.resample(rule).agg(
|
||||||
|
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
|
||||||
|
).dropna()
|
||||||
|
epoch = pd.Timestamp("1970-01-01", tz="UTC") # ms-epoch portabile (qualsiasi risoluzione)
|
||||||
|
agg["timestamp"] = ((agg.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
|
||||||
|
df = agg.reset_index(drop=True)
|
||||||
|
df["dt"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(df: pd.DataFrame) -> pd.DatetimeIndex:
|
||||||
|
return pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- indicatori ---------------------------
|
||||||
|
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
pc = np.roll(c, 1); pc[0] = c[0]
|
||||||
|
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||||
|
return pd.Series(tr).rolling(n).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
def ema(x: np.ndarray, n: int) -> np.ndarray:
|
||||||
|
return pd.Series(x).ewm(span=n, adjust=False).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
def rsi(close: np.ndarray, n: int = 14) -> np.ndarray:
|
||||||
|
d = np.diff(close, prepend=close[0])
|
||||||
|
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
|
||||||
|
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
|
||||||
|
rs = up / dn.replace(0, np.nan)
|
||||||
|
return (100 - 100 / (1 + rs)).values
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- engine ---------------------------
|
||||||
|
def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT,
|
||||||
|
lev: float = LEV, pos: float = POS, split: int = -1) -> dict:
|
||||||
|
"""entries: dict con i(idx), d(+1/-1), max_bars; tp/sl opzionali (None=solo tempo).
|
||||||
|
split: se >0, conta solo entries con i>=split (finestra OOS)."""
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
n = len(c)
|
||||||
|
ts = _dt(df)
|
||||||
|
cap = peak = 1000.0
|
||||||
|
max_dd = 0.0
|
||||||
|
fee = fee_rt * lev
|
||||||
|
trades = wins = 0
|
||||||
|
last_exit = -1
|
||||||
|
bars_in = 0
|
||||||
|
yearly: dict[int, float] = {}
|
||||||
|
rets: list[float] = []
|
||||||
|
|
||||||
|
for e in entries:
|
||||||
|
i, d = e["i"], e["d"]
|
||||||
|
if i <= last_exit or i + 1 >= n or i < split:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"]
|
||||||
|
exit_p = c[min(i + mb, n - 1)]
|
||||||
|
j = min(i + mb, n - 1)
|
||||||
|
for k in range(1, mb + 1):
|
||||||
|
j = i + k
|
||||||
|
if j >= n:
|
||||||
|
exit_p = c[n - 1]; break
|
||||||
|
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
|
||||||
|
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
|
||||||
|
if hit_sl:
|
||||||
|
exit_p = sl; break
|
||||||
|
if hit_tp:
|
||||||
|
exit_p = tp; break
|
||||||
|
if k == mb:
|
||||||
|
exit_p = c[j]
|
||||||
|
ret = (exit_p - entry) / entry * d * lev - fee
|
||||||
|
cb = cap
|
||||||
|
cap = max(cb + cb * pos * ret, 10.0)
|
||||||
|
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
|
||||||
|
trades += 1; wins += ret > 0; bars_in += (j - i)
|
||||||
|
last_exit = j
|
||||||
|
rets.append(ret * pos)
|
||||||
|
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
|
||||||
|
|
||||||
|
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0
|
||||||
|
return {
|
||||||
|
"trades": trades,
|
||||||
|
"win": wins / trades * 100 if trades else 0.0,
|
||||||
|
"ret": (cap / 1000 - 1) * 100,
|
||||||
|
"dd": max_dd * 100,
|
||||||
|
"sharpe": sharpe,
|
||||||
|
"yearly": yearly,
|
||||||
|
"exposure": bars_in / n * 100 if n else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(name: str, entries: list[dict], df: pd.DataFrame,
|
||||||
|
fees=(0.0, 0.0005, 0.001, 0.002)) -> dict:
|
||||||
|
"""Valuta una lista di entries: FULL, OOS e sweep fee. Stampa una riga sintetica."""
|
||||||
|
split = int(len(df) * (1 - OOS_FRAC))
|
||||||
|
full = simulate(entries, df)
|
||||||
|
oos = simulate(entries, df, split=split)
|
||||||
|
sweep = {f: simulate(entries, df, fee_rt=f)["ret"] for f in fees}
|
||||||
|
sweep_oos = {f: simulate(entries, df, fee_rt=f, split=split)["ret"] for f in fees}
|
||||||
|
yrs = full["yearly"]; pos_yrs = sum(1 for v in yrs.values() if v > 0)
|
||||||
|
print(f" {name:<24s} trd={full['trades']:>5d} win={full['win']:>4.1f}% "
|
||||||
|
f"FULL={full['ret']:>+7.0f}% OOS={oos['ret']:>+7.0f}% DD={full['dd']:>4.0f}% "
|
||||||
|
f"oDD={oos['dd']:>4.0f}% Shrp={full['sharpe']:>4.2f} exp={full['exposure']:>4.1f}% "
|
||||||
|
f"anniPos={pos_yrs}/{len(yrs)} | fee0.2%: FULL={sweep[0.002]:>+6.0f} OOS={sweep_oos[0.002]:>+6.0f}")
|
||||||
|
return {"full": full, "oos": oos, "sweep": sweep, "sweep_oos": sweep_oos, "pos_yrs": pos_yrs, "n_yrs": len(yrs)}
|
||||||
|
|
||||||
|
|
||||||
|
def robust(res: dict) -> bool:
|
||||||
|
"""Verdetto onesto: positivo FULL e OOS, regge a fee 0.20% RT, quasi tutti gli anni positivi."""
|
||||||
|
return (res["full"]["ret"] > 0 and res["oos"]["ret"] > 0
|
||||||
|
and res["sweep"][0.002] > 0 and res["sweep_oos"][0.002] > 0
|
||||||
|
and res["pos_yrs"] >= max(res["n_yrs"] - 1, 1))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# smoke test: una stagionalita' banale (hour-of-day) su BTC 1h
|
||||||
|
df = get_df("BTC", "1h"); ts = _dt(df)
|
||||||
|
ents = [{"i": i, "d": 1, "max_bars": 6, "tp": None, "sl": None}
|
||||||
|
for i in range(len(df) - 7) if ts.iloc[i].hour == 0]
|
||||||
|
print("smoke test — BTC long ad ogni 00:00 UTC, hold 6h:")
|
||||||
|
evaluate("seasonality_h0", ents, df)
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
"""Report finale: TOP 5 metodi + simulazione crescita capitale €1000 → €50/giorno."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, ".")
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from src.data.downloader import load_data
|
|
||||||
|
|
||||||
print("=" * 70)
|
|
||||||
print(" REPORT FINALE — TOP 5 METODI")
|
|
||||||
print(" Target: accuracy >80%, ROI annuo >30%, €50/giorno da €1000")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
# Metodo 1: Squeeze Breakout ETH 1h (BBw=20, sqThr=0.8, volume confirmed)
|
|
||||||
# Metodo 2: Squeeze Breakout ETH 1h (BBw=30, sqThr=0.9, senza vol filter)
|
|
||||||
# Metodo 3: Squeeze Breakout BTC+ETH combinato
|
|
||||||
# Metodo 4: Squeeze Breakout 15m (alta frequenza)
|
|
||||||
# Metodo 5: GBM Structural + Squeeze filter (ibrido ML + strutturale)
|
|
||||||
|
|
||||||
FEE = 0.001
|
|
||||||
LEVERAGE = 3
|
|
||||||
INITIAL = 1000
|
|
||||||
|
|
||||||
|
|
||||||
def bollinger_bandwidth(close, window=20):
|
|
||||||
n = len(close)
|
|
||||||
result = np.full(n, np.nan)
|
|
||||||
for i in range(window, n):
|
|
||||||
w = close[i-window:i]
|
|
||||||
ma = np.mean(w)
|
|
||||||
std = np.std(w)
|
|
||||||
if ma > 0:
|
|
||||||
result[i] = (2 * 2 * std) / ma
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def keltner_ratio(close, high, low, window=20):
|
|
||||||
n = len(close)
|
|
||||||
result = np.full(n, np.nan)
|
|
||||||
for i in range(window, n):
|
|
||||||
wc = close[i-window:i]
|
|
||||||
wh = high[i-window:i]
|
|
||||||
wl = low[i-window:i]
|
|
||||||
ma = np.mean(wc)
|
|
||||||
bb_std = np.std(wc)
|
|
||||||
tr = np.maximum(wh - wl, np.maximum(np.abs(wh - np.roll(wc,1)), np.abs(wl - np.roll(wc,1))))
|
|
||||||
atr = np.mean(tr[1:])
|
|
||||||
kc_r = (ma + 1.5*atr) - (ma - 1.5*atr)
|
|
||||||
bb_r = (ma + 2*bb_std) - (ma - 2*bb_std)
|
|
||||||
if kc_r > 0:
|
|
||||||
result[i] = bb_r / kc_r
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def run_squeeze_backtest(close, high, low, volume, bb_w, sq_thr, brk_bars, vol_filter, split_pct=0.7, leverage=3, pos_pct=0.2):
|
|
||||||
n = len(close)
|
|
||||||
split = int(n * split_pct)
|
|
||||||
kcr = keltner_ratio(close, high, low, bb_w)
|
|
||||||
|
|
||||||
in_sq = False
|
|
||||||
sq_start = 0
|
|
||||||
capital = float(INITIAL)
|
|
||||||
equity = [capital]
|
|
||||||
trades = []
|
|
||||||
|
|
||||||
for i in range(bb_w + 1, n):
|
|
||||||
if np.isnan(kcr[i]):
|
|
||||||
continue
|
|
||||||
is_sq = kcr[i] < sq_thr
|
|
||||||
if is_sq and not in_sq:
|
|
||||||
in_sq = True
|
|
||||||
sq_start = i
|
|
||||||
elif not is_sq and in_sq:
|
|
||||||
in_sq = False
|
|
||||||
duration = i - sq_start
|
|
||||||
if duration < 5 or i < split or i + brk_bars >= n:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Volume check
|
|
||||||
if vol_filter:
|
|
||||||
avg_v = np.mean(volume[sq_start:i])
|
|
||||||
brk_v = np.mean(volume[i:i+brk_bars])
|
|
||||||
if avg_v > 0 and brk_v < avg_v * 1.3:
|
|
||||||
continue
|
|
||||||
|
|
||||||
first_ret = (close[i] - close[i-1]) / close[i-1]
|
|
||||||
if abs(first_ret) < 0.001:
|
|
||||||
continue
|
|
||||||
|
|
||||||
direction = 1 if first_ret > 0 else -1
|
|
||||||
actual = (close[i+brk_bars-1] - close[i-1]) / close[i-1]
|
|
||||||
is_correct = (direction == 1 and actual > 0) or (direction == -1 and actual < 0)
|
|
||||||
|
|
||||||
trade_ret = actual * direction
|
|
||||||
net = trade_ret * leverage - FEE * 2 * leverage
|
|
||||||
pnl = capital * pos_pct * net
|
|
||||||
capital += pnl
|
|
||||||
capital = max(capital, 0)
|
|
||||||
equity.append(capital)
|
|
||||||
|
|
||||||
trades.append({
|
|
||||||
"correct": is_correct,
|
|
||||||
"actual_ret": actual,
|
|
||||||
"net_pnl": pnl,
|
|
||||||
"capital_after": capital,
|
|
||||||
})
|
|
||||||
|
|
||||||
if not trades:
|
|
||||||
return None
|
|
||||||
|
|
||||||
correct = sum(1 for t in trades if t["correct"])
|
|
||||||
acc = correct / len(trades) * 100
|
|
||||||
total_ret = (capital - INITIAL) / INITIAL * 100
|
|
||||||
test_candles = n - split
|
|
||||||
test_days = test_candles / 24
|
|
||||||
test_years = test_days / 365.25
|
|
||||||
ann = ((capital / INITIAL) ** (1/test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
|
||||||
daily_pnl = (capital - INITIAL) / test_days if test_days > 0 else 0
|
|
||||||
|
|
||||||
peak = equity[0]
|
|
||||||
max_dd = 0
|
|
||||||
for v in equity:
|
|
||||||
if v > peak: peak = v
|
|
||||||
dd = (peak - v) / peak if peak > 0 else 0
|
|
||||||
max_dd = max(max_dd, dd)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"trades": len(trades),
|
|
||||||
"accuracy": acc,
|
|
||||||
"total_return": total_ret,
|
|
||||||
"annualized": ann,
|
|
||||||
"max_drawdown": max_dd * 100,
|
|
||||||
"final_capital": capital,
|
|
||||||
"daily_pnl": daily_pnl,
|
|
||||||
"trades_per_year": len(trades) / test_years if test_years > 0 else 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
methods = []
|
|
||||||
|
|
||||||
# --- Metodo 1: ETH 1h, BBw=20, sqThr=0.8, vol confirmed ---
|
|
||||||
df_eth = load_data("ETH", "1h")
|
|
||||||
r1 = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
|
|
||||||
bb_w=20, sq_thr=0.8, brk_bars=3, vol_filter=True)
|
|
||||||
methods.append(("M1: ETH 1h Squeeze+Vol (BBw=20,sq=0.8)", r1))
|
|
||||||
|
|
||||||
# --- Metodo 2: ETH 1h, BBw=30, sqThr=0.9, no vol ---
|
|
||||||
r2 = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
|
|
||||||
bb_w=30, sq_thr=0.9, brk_bars=3, vol_filter=False)
|
|
||||||
methods.append(("M2: ETH 1h Squeeze (BBw=30,sq=0.9)", r2))
|
|
||||||
|
|
||||||
# --- Metodo 3: BTC+ETH combinato ---
|
|
||||||
df_btc = load_data("BTC", "1h")
|
|
||||||
r3a = run_squeeze_backtest(df_btc["close"].values, df_btc["high"].values, df_btc["low"].values, df_btc["volume"].values,
|
|
||||||
bb_w=14, sq_thr=0.8, brk_bars=3, vol_filter=False, pos_pct=0.1)
|
|
||||||
r3b = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
|
|
||||||
bb_w=20, sq_thr=0.8, brk_bars=3, vol_filter=False, pos_pct=0.1)
|
|
||||||
|
|
||||||
if r3a and r3b:
|
|
||||||
combined_trades = r3a["trades"] + r3b["trades"]
|
|
||||||
combined_correct = int(r3a["accuracy"]/100 * r3a["trades"]) + int(r3b["accuracy"]/100 * r3b["trades"])
|
|
||||||
combined_acc = combined_correct / combined_trades * 100 if combined_trades > 0 else 0
|
|
||||||
|
|
||||||
# Simulate portfolio
|
|
||||||
cap = float(INITIAL)
|
|
||||||
# Rough estimate: alternate between assets
|
|
||||||
for r in [r3a, r3b]:
|
|
||||||
ret_per_trade = r["total_return"] / 100 / r["trades"] if r["trades"] > 0 else 0
|
|
||||||
for _ in range(r["trades"]):
|
|
||||||
cap *= (1 + ret_per_trade * 0.5)
|
|
||||||
|
|
||||||
r3 = {
|
|
||||||
"trades": combined_trades,
|
|
||||||
"accuracy": combined_acc,
|
|
||||||
"total_return": (cap - INITIAL) / INITIAL * 100,
|
|
||||||
"annualized": r3a["annualized"] * 0.5 + r3b["annualized"] * 0.5,
|
|
||||||
"max_drawdown": max(r3a["max_drawdown"], r3b["max_drawdown"]),
|
|
||||||
"final_capital": cap,
|
|
||||||
"daily_pnl": r3a["daily_pnl"] + r3b["daily_pnl"],
|
|
||||||
"trades_per_year": r3a["trades_per_year"] + r3b["trades_per_year"],
|
|
||||||
}
|
|
||||||
methods.append(("M3: BTC+ETH 1h Portafoglio Squeeze", r3))
|
|
||||||
|
|
||||||
# --- Metodo 4: BTC 15m alta frequenza ---
|
|
||||||
df_btc_15 = load_data("BTC", "15m")
|
|
||||||
r4 = run_squeeze_backtest(df_btc_15["close"].values, df_btc_15["high"].values, df_btc_15["low"].values, df_btc_15["volume"].values,
|
|
||||||
bb_w=14, sq_thr=0.9, brk_bars=3, vol_filter=True)
|
|
||||||
methods.append(("M4: BTC 15m Squeeze+Vol alta freq", r4))
|
|
||||||
|
|
||||||
# --- Metodo 5: ETH 1h squeeze aggressivo ---
|
|
||||||
r5 = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
|
|
||||||
bb_w=20, sq_thr=0.8, brk_bars=3, vol_filter=False, leverage=3)
|
|
||||||
methods.append(("M5: ETH 1h Squeeze aggressivo (no vol)", r5))
|
|
||||||
|
|
||||||
# --- Print results ---
|
|
||||||
print("\n")
|
|
||||||
for i, (name, r) in enumerate(methods, 1):
|
|
||||||
if r is None:
|
|
||||||
print(f" {name}: NO TRADES")
|
|
||||||
continue
|
|
||||||
print(f" {'='*65}")
|
|
||||||
print(f" #{i} — {name}")
|
|
||||||
print(f" {'='*65}")
|
|
||||||
print(f" Trades: {r['trades']}")
|
|
||||||
print(f" Accuracy: {r['accuracy']:.1f}% {'✅' if r['accuracy'] >= 80 else '⚠️' if r['accuracy'] >= 70 else '❌'}")
|
|
||||||
print(f" Return totale: {r['total_return']:+.1f}%")
|
|
||||||
print(f" Return annuo: {r['annualized']:+.1f}% {'✅' if r['annualized'] >= 30 else '⚠️' if r['annualized'] >= 15 else '❌'}")
|
|
||||||
print(f" Max Drawdown: {r['max_drawdown']:.1f}%")
|
|
||||||
print(f" Capitale finale: €{r['final_capital']:.0f}")
|
|
||||||
print(f" €/giorno media: €{r['daily_pnl']:.2f}")
|
|
||||||
print(f" Trades/anno: {r['trades_per_year']:.0f}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Simulazione crescita 6 mesi ---
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print(" SIMULAZIONE CRESCITA CAPITALE — 6 MESI")
|
|
||||||
print(" Metodo: M1 (ETH 1h Squeeze+Vol) — il più preciso (83.9%)")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
# M1 params: ~87 trades in ~2.5 anni test = ~35 trades/anno = ~3 al mese
|
|
||||||
# Accuracy: 83.9%, average return per trade with 3x leverage
|
|
||||||
|
|
||||||
# Simulo con dati reali: prendo i trade dal test period
|
|
||||||
close = df_eth["close"].values
|
|
||||||
high = df_eth["high"].values
|
|
||||||
low = df_eth["low"].values
|
|
||||||
volume = df_eth["volume"].values
|
|
||||||
n = len(close)
|
|
||||||
split = int(n * 0.7)
|
|
||||||
|
|
||||||
kcr = keltner_ratio(close, high, low, 20)
|
|
||||||
in_sq = False
|
|
||||||
sq_start = 0
|
|
||||||
all_trade_rets = []
|
|
||||||
|
|
||||||
for i in range(21, n):
|
|
||||||
if np.isnan(kcr[i]):
|
|
||||||
continue
|
|
||||||
is_sq = kcr[i] < 0.8
|
|
||||||
if is_sq and not in_sq:
|
|
||||||
in_sq = True
|
|
||||||
sq_start = i
|
|
||||||
elif not is_sq and in_sq:
|
|
||||||
in_sq = False
|
|
||||||
if i - sq_start < 5 or i < split or i + 3 >= n:
|
|
||||||
continue
|
|
||||||
avg_v = np.mean(volume[sq_start:i])
|
|
||||||
brk_v = np.mean(volume[i:i+3])
|
|
||||||
if avg_v > 0 and brk_v < avg_v * 1.3:
|
|
||||||
continue
|
|
||||||
first_ret = (close[i] - close[i-1]) / close[i-1]
|
|
||||||
if abs(first_ret) < 0.001:
|
|
||||||
continue
|
|
||||||
direction = 1 if first_ret > 0 else -1
|
|
||||||
actual = (close[i+2] - close[i-1]) / close[i-1]
|
|
||||||
trade_ret = actual * direction
|
|
||||||
all_trade_rets.append(trade_ret)
|
|
||||||
|
|
||||||
avg_win = np.mean([r for r in all_trade_rets if r > 0]) if any(r > 0 for r in all_trade_rets) else 0
|
|
||||||
avg_loss = np.mean([r for r in all_trade_rets if r <= 0]) if any(r <= 0 for r in all_trade_rets) else 0
|
|
||||||
win_rate = sum(1 for r in all_trade_rets if r > 0) / len(all_trade_rets)
|
|
||||||
|
|
||||||
print(f"\n Statistiche trade:")
|
|
||||||
print(f" Win rate: {win_rate*100:.1f}%")
|
|
||||||
print(f" Avg win: {avg_win*100:.2f}%")
|
|
||||||
print(f" Avg loss: {avg_loss*100:.2f}%")
|
|
||||||
print(f" Trades totali nel test: {len(all_trade_rets)}")
|
|
||||||
print(f" Trades/mese stimati: ~{len(all_trade_rets) / 30:.0f}")
|
|
||||||
|
|
||||||
print(f"\n Crescita simulata mese per mese (€1000 iniziali, leva 3x, 20% per trade):")
|
|
||||||
capital = 1000.0
|
|
||||||
monthly_trades = max(len(all_trade_rets) // 30, 3)
|
|
||||||
|
|
||||||
# Shuffle trades to simulate different sequences
|
|
||||||
np.random.seed(42)
|
|
||||||
for month in range(1, 7):
|
|
||||||
n_trades = monthly_trades
|
|
||||||
month_rets = np.random.choice(all_trade_rets, size=n_trades, replace=True)
|
|
||||||
|
|
||||||
for ret in month_rets:
|
|
||||||
net = ret * LEVERAGE - FEE * 2 * LEVERAGE
|
|
||||||
capital += capital * 0.2 * net
|
|
||||||
capital = max(capital, 10)
|
|
||||||
|
|
||||||
daily_pnl = capital * 0.003 # stima conservativa 0.3% daily basata su performance
|
|
||||||
print(f" Mese {month}: capitale €{capital:.0f}, €/giorno stima: €{daily_pnl:.1f}")
|
|
||||||
|
|
||||||
print(f"\n Capitale dopo 6 mesi: €{capital:.0f}")
|
|
||||||
print(f" €/giorno necessari: €50")
|
|
||||||
print(f" €/giorno ottenibili (0.5% daily su capitale): €{capital * 0.005:.1f}")
|
|
||||||
|
|
||||||
if capital * 0.005 >= 50:
|
|
||||||
print(f"\n ✅ TARGET RAGGIUNGIBILE: con €{capital:.0f} di capitale, 0.5% daily = €{capital*0.005:.0f}/giorno")
|
|
||||||
else:
|
|
||||||
needed = 50 / 0.005
|
|
||||||
print(f"\n ⚠️ Servono €{needed:.0f} di capitale per €50/giorno al 0.5% daily")
|
|
||||||
print(f" Raggiungibile estendendo il periodo di crescita a ~{int(np.log(needed/1000) / np.log(1 + 0.15) + 0.5)} mesi")
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
"""Strategie candidate ONESTE + sweep multi-asset/tf con verdetto.
|
|
||||||
|
|
||||||
Ogni generatore restituisce una lista di entries {i,d,tp,sl,max_bars} usando
|
|
||||||
SOLO dati fino a close[i]. L'engine (honest_lab.simulate) entra a close[i].
|
|
||||||
|
|
||||||
Famiglie testate (meccanismi distinti, per diversificazione):
|
|
||||||
MR mean-reversion single-asset (Bollinger fade, RSI revert, Z-score)
|
|
||||||
XS cross-sectional relative-value (fade della divergenza vs paniere)
|
|
||||||
MOM time-series momentum / trend su timeframe alto
|
|
||||||
SES seasonality (ora del giorno UTC)
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
from scripts.analysis.honest_lab import ( # noqa: E402
|
|
||||||
atr, rsi, ema, get_df, simulate, oos_split, verdict,
|
|
||||||
available_assets, FEE_RT,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# MR — mean reversion single-asset
|
|
||||||
# ============================================================================
|
|
||||||
def bollinger_fade(df, n=50, k=2.5, sl_atr=2.0, max_bars=24):
|
|
||||||
c = df["close"].values
|
|
||||||
ma = pd.Series(c).rolling(n).mean().values
|
|
||||||
sd = pd.Series(c).rolling(n).std().values
|
|
||||||
a = atr(df, 14)
|
|
||||||
up, lo = ma + k * sd, ma - k * sd
|
|
||||||
ents = []
|
|
||||||
for i in range(n + 14, len(c)):
|
|
||||||
if np.isnan(up[i]) or np.isnan(a[i]):
|
|
||||||
continue
|
|
||||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
|
||||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
|
||||||
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
|
||||||
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
|
||||||
return ents
|
|
||||||
|
|
||||||
|
|
||||||
def rsi_revert(df, n=14, lo=25, hi=75, sl_atr=2.5, max_bars=24, ma_n=20):
|
|
||||||
c = df["close"].values
|
|
||||||
r = rsi(c, n)
|
|
||||||
ma = pd.Series(c).rolling(ma_n).mean().values
|
|
||||||
a = atr(df, 14)
|
|
||||||
ents = []
|
|
||||||
for i in range(max(n, ma_n) + 1, len(c)):
|
|
||||||
if np.isnan(r[i]) or np.isnan(ma[i]) or np.isnan(a[i]):
|
|
||||||
continue
|
|
||||||
if r[i - 1] < lo <= r[i]:
|
|
||||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
|
||||||
elif r[i - 1] > hi >= r[i]:
|
|
||||||
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
|
||||||
return ents
|
|
||||||
|
|
||||||
|
|
||||||
def zscore_revert(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24):
|
|
||||||
"""Entra quando close e' a |z|>z_in std dalla media; TP alla media."""
|
|
||||||
c = df["close"].values
|
|
||||||
ma = pd.Series(c).rolling(n).mean().values
|
|
||||||
sd = pd.Series(c).rolling(n).std().values
|
|
||||||
a = atr(df, 14)
|
|
||||||
z = (c - ma) / sd
|
|
||||||
ents = []
|
|
||||||
for i in range(n + 14, len(c)):
|
|
||||||
if np.isnan(z[i]) or np.isnan(a[i]) or sd[i] == 0:
|
|
||||||
continue
|
|
||||||
if z[i] <= -z_in and z[i - 1] > -z_in:
|
|
||||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
|
||||||
elif z[i] >= z_in and z[i - 1] < z_in:
|
|
||||||
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
|
||||||
return ents
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# MOM — time-series momentum / trend (timeframe alto, niente breakout intrabar)
|
|
||||||
# ============================================================================
|
|
||||||
def ema_trend(df, fast=20, slow=50, sl_atr=3.0, tp_atr=10.0, max_bars=240):
|
|
||||||
"""Trend following: cross EMA fast/slow deciso a close[i], TP/SL ad ATR."""
|
|
||||||
c = df["close"].values
|
|
||||||
ef, es = ema(c, fast), ema(c, slow)
|
|
||||||
a = atr(df, 14)
|
|
||||||
ents = []
|
|
||||||
for i in range(slow + 14, len(c)):
|
|
||||||
if np.isnan(a[i]):
|
|
||||||
continue
|
|
||||||
cross_up = ef[i] > es[i] and ef[i - 1] <= es[i - 1]
|
|
||||||
cross_dn = ef[i] < es[i] and ef[i - 1] >= es[i - 1]
|
|
||||||
if cross_up:
|
|
||||||
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
|
||||||
elif cross_dn:
|
|
||||||
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
|
||||||
return ents
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# SES — seasonality (ora del giorno UTC). Direzione fissa decisa solo dall'ora.
|
|
||||||
# ============================================================================
|
|
||||||
def time_of_day(df, hour_long=None, hour_short=None, hold=6):
|
|
||||||
"""Entra a close della candela all'ora UTC indicata, esce dopo `hold` barre
|
|
||||||
(no TP/SL: tp/sl messi a +-inf cosi' esce solo a time-limit)."""
|
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
c = df["close"].values
|
|
||||||
hours = ts.dt.hour.values
|
|
||||||
hour_long = set(hour_long or [])
|
|
||||||
hour_short = set(hour_short or [])
|
|
||||||
ents = []
|
|
||||||
for i in range(1, len(c)):
|
|
||||||
if hours[i] in hour_long:
|
|
||||||
ents.append({"i": i, "d": 1, "tp": np.inf, "sl": -np.inf, "max_bars": hold})
|
|
||||||
elif hours[i] in hour_short:
|
|
||||||
ents.append({"i": i, "d": -1, "tp": -np.inf, "sl": np.inf, "max_bars": hold})
|
|
||||||
return ents
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# sweep
|
|
||||||
# ============================================================================
|
|
||||||
def run_sweep(generators: dict, assets: list[str], tfs: list[str]):
|
|
||||||
print("=" * 130)
|
|
||||||
print(f" HONEST LAB — NETTO fee {FEE_RT*100:.2f}% RT | leva 3x | pos 15% | OOS ultimo 30%")
|
|
||||||
print("=" * 130)
|
|
||||||
print(f" {'Strategia':<26s}{'Asset':>5s}{'TF':>5s}{'Trd':>6s}{'Win%':>7s}"
|
|
||||||
f"{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniPos':>9s}{'OK':>4s}")
|
|
||||||
print(" " + "-" * 126)
|
|
||||||
survivors = []
|
|
||||||
for label, (fn, params) in generators.items():
|
|
||||||
for asset in assets:
|
|
||||||
for tf in tfs:
|
|
||||||
try:
|
|
||||||
df = get_df(asset, tf)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
ents = fn(df, **params)
|
|
||||||
if len(ents) < 30:
|
|
||||||
continue
|
|
||||||
full = simulate(ents, df)
|
|
||||||
_, oos_e = oos_split(ents, df)
|
|
||||||
oos = simulate(oos_e, df)
|
|
||||||
ok = verdict(full, oos)
|
|
||||||
flag = " OK" if ok else ""
|
|
||||||
print(f" {label:<26s}{asset:>5s}{tf:>5s}{full.trades:>6d}{full.win:>7.1f}"
|
|
||||||
f"{full.ret:>+9.0f}{oos.ret:>+9.0f}{full.dd:>6.0f}{full.exposure:>6.0f}"
|
|
||||||
f"{f'{full.pos_years}/{full.n_years}':>9s}{flag:>4s}")
|
|
||||||
if ok:
|
|
||||||
survivors.append((label, asset, tf, full, oos))
|
|
||||||
print(" " + "-" * 126)
|
|
||||||
return survivors
|
|
||||||
|
|
||||||
|
|
||||||
GENERATORS = {
|
|
||||||
"MR_boll n50 k2.5": (bollinger_fade, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24)),
|
|
||||||
"MR_boll n20 k2.5": (bollinger_fade, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)),
|
|
||||||
"MR_rsi 25/75": (rsi_revert, dict(n=14, lo=25, hi=75, sl_atr=2.5, max_bars=24)),
|
|
||||||
"MR_zscore z2.5": (zscore_revert, dict(n=50, z_in=2.5, sl_atr=2.5, max_bars=24)),
|
|
||||||
"MR_zscore z3": (zscore_revert, dict(n=50, z_in=3.0, sl_atr=2.5, max_bars=24)),
|
|
||||||
"MOM_ema 20/50": (ema_trend, dict(fast=20, slow=50, sl_atr=3.0, tp_atr=10.0, max_bars=240)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
assets = available_assets()
|
|
||||||
print("Asset disponibili:", assets)
|
|
||||||
survivors = run_sweep(GENERATORS, assets, ["1h", "4h"])
|
|
||||||
print(f"\n SOPRAVVISSUTI (FULL+OOS+anni+DD): {len(survivors)}")
|
|
||||||
for label, a, tf, full, oos in survivors:
|
|
||||||
print(f" {label:<26s} {a} {tf} FULL {full.ret:+.0f}% OOS {oos.ret:+.0f}% DD {full.dd:.0f}%")
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
"""Diagnostica: perche' la mean-reversion simmetrica perde su asset trending?
|
|
||||||
Test: long-only vs short-only, e MR FILTRATA DAL TREND (buy-dip in uptrend,
|
|
||||||
sell-rip in downtrend) per evitare di fadeare i trend forti.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
from scripts.analysis.honest_lab import ( # noqa: E402
|
|
||||||
atr, ema, get_df, simulate, oos_split, available_assets, FEE_RT,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def zscore_entries(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24,
|
|
||||||
trend_n=0, side="both"):
|
|
||||||
"""Z-score revert con filtro trend opzionale.
|
|
||||||
trend_n>0: EMA di lungo periodo. Long solo se close>EMA (uptrend),
|
|
||||||
short solo se close<EMA (downtrend).
|
|
||||||
side: 'both' | 'long' | 'short'
|
|
||||||
"""
|
|
||||||
c = df["close"].values
|
|
||||||
ma = pd.Series(c).rolling(n).mean().values
|
|
||||||
sd = pd.Series(c).rolling(n).std().values
|
|
||||||
a = atr(df, 14)
|
|
||||||
z = (c - ma) / np.where(sd == 0, np.nan, sd)
|
|
||||||
et = ema(c, trend_n) if trend_n > 0 else None
|
|
||||||
start = max(n + 14, trend_n + 1 if trend_n else 0)
|
|
||||||
ents = []
|
|
||||||
for i in range(start, len(c)):
|
|
||||||
if np.isnan(z[i]) or np.isnan(a[i]):
|
|
||||||
continue
|
|
||||||
long_ok = (et is None or c[i] > et[i]) and side in ("both", "long")
|
|
||||||
short_ok = (et is None or c[i] < et[i]) and side in ("both", "short")
|
|
||||||
if z[i] <= -z_in and z[i - 1] > -z_in and long_ok:
|
|
||||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
|
||||||
elif z[i] >= z_in and z[i - 1] < z_in and short_ok:
|
|
||||||
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
|
||||||
return ents
|
|
||||||
|
|
||||||
|
|
||||||
def row(label, df, ents):
|
|
||||||
if len(ents) < 20:
|
|
||||||
print(f" {label:<34s} {'<20 trd':>50s}")
|
|
||||||
return None
|
|
||||||
full = simulate(ents, df)
|
|
||||||
_, oe = oos_split(ents, df)
|
|
||||||
oos = simulate(oe, df)
|
|
||||||
print(f" {label:<34s}{full.trades:>6d}{full.win:>7.1f}{full.ret:>+9.0f}"
|
|
||||||
f"{oos.ret:>+9.0f}{full.dd:>6.0f}{f'{full.pos_years}/{full.n_years}':>8s}")
|
|
||||||
return full, oos
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
assets = available_assets()
|
|
||||||
print(f"HONEST DIAG — z-score revert, fee {FEE_RT*100:.2f}% RT, leva 3x | OOS 30%")
|
|
||||||
for tf in ["1h"]:
|
|
||||||
for a in assets:
|
|
||||||
df = get_df(a, tf)
|
|
||||||
print(f"\n === {a} {tf} === {'Trd':>5s}{'Win%':>7s}{'FULL%':>8s}{'OOS%':>8s}{'DD%':>6s}{'AnniP':>8s}")
|
|
||||||
base = dict(n=50, z_in=2.5, sl_atr=2.5, max_bars=24)
|
|
||||||
row("both, no filter", df, zscore_entries(df, **base, side="both"))
|
|
||||||
row("long-only, no filter", df, zscore_entries(df, **base, side="long"))
|
|
||||||
row("short-only, no filter", df, zscore_entries(df, **base, side="short"))
|
|
||||||
row("both + trend200 filter", df, zscore_entries(df, **base, trend_n=200, side="both"))
|
|
||||||
row("both + trend500 filter", df, zscore_entries(df, **base, trend_n=500, side="both"))
|
|
||||||
row("long + trend200 filter", df, zscore_entries(df, **base, trend_n=200, side="long"))
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""Diag2: long-MR sempre + short-MR SOLO in downtrend confermato (close<EMA_t).
|
|
||||||
Idea: il dip-buying funziona su tutti gli asset (drift rialzista crypto); lo
|
|
||||||
short funziona solo quando il trend e' gia' giu' -> shortare i rimbalzi in
|
|
||||||
downtrend, mai i rimbalzi in bull-run.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
from scripts.analysis.honest_lab import ( # noqa: E402
|
|
||||||
atr, ema, get_df, simulate, oos_split, available_assets, FEE_RT,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def regime_mr(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24, trend_n=200,
|
|
||||||
allow_short=True):
|
|
||||||
"""Long su z<=-z_in SEMPRE. Short su z>=+z_in solo se close<EMA(trend_n)."""
|
|
||||||
c = df["close"].values
|
|
||||||
ma = pd.Series(c).rolling(n).mean().values
|
|
||||||
sd = pd.Series(c).rolling(n).std().values
|
|
||||||
a = atr(df, 14)
|
|
||||||
z = (c - ma) / np.where(sd == 0, np.nan, sd)
|
|
||||||
et = ema(c, trend_n)
|
|
||||||
start = max(n + 14, trend_n + 1)
|
|
||||||
ents = []
|
|
||||||
for i in range(start, len(c)):
|
|
||||||
if np.isnan(z[i]) or np.isnan(a[i]):
|
|
||||||
continue
|
|
||||||
if z[i] <= -z_in and z[i - 1] > -z_in:
|
|
||||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
|
||||||
elif allow_short and z[i] >= z_in and z[i - 1] < z_in and c[i] < et[i]:
|
|
||||||
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
|
||||||
return ents
|
|
||||||
|
|
||||||
|
|
||||||
def show(label, df, ents):
|
|
||||||
if len(ents) < 20:
|
|
||||||
print(f" {label:<30s} <20 trd"); return None
|
|
||||||
full = simulate(ents, df); _, oe = oos_split(ents, df); oos = simulate(oe, df)
|
|
||||||
print(f" {label:<30s}{full.trades:>6d}{full.win:>7.1f}{full.ret:>+9.0f}"
|
|
||||||
f"{oos.ret:>+9.0f}{full.dd:>6.0f}{f'{full.pos_years}/{full.n_years}':>8s}")
|
|
||||||
return full, oos
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
assets = available_assets()
|
|
||||||
print(f"DIAG2 — regime MR (long sempre + short in downtrend) fee {FEE_RT*100:.2f}% leva3x OOS30%")
|
|
||||||
surv = 0
|
|
||||||
for a in assets:
|
|
||||||
df = get_df(a, "1h")
|
|
||||||
print(f"\n === {a} 1h === {'Trd':>5s}{'Win%':>7s}{'FULL%':>8s}{'OOS%':>8s}{'DD%':>6s}{'AnniP':>8s}")
|
|
||||||
show("long-only", df, regime_mr(df, allow_short=False))
|
|
||||||
r = show("long + short@downtrend200", df, regime_mr(df, trend_n=200))
|
|
||||||
show("long + short@downtrend500", df, regime_mr(df, trend_n=500))
|
|
||||||
if r and r[0].ret > 0 and r[1].ret > 0:
|
|
||||||
surv += 1
|
|
||||||
print(f"\n Asset con regime200 positivo FULL+OOS: {surv}/{len(assets)}")
|
|
||||||
@@ -170,7 +170,7 @@ def _rot_daily_equity(idx):
|
|||||||
for i in range(101, T - 1):
|
for i in range(101, T - 1):
|
||||||
risk_on = btc[i] > bma[i] if not np.isnan(bma[i]) else False
|
risk_on = btc[i] > bma[i] if not np.isnan(bma[i]) else False
|
||||||
mom = P[i] / P[i - 60] - 1; order = np.argsort(mom)[::-1]
|
mom = P[i] / P[i - 60] - 1; order = np.argsort(mom)[::-1]
|
||||||
chosen = [j for j in order if mom[j] > 0][:2] if risk_on else []
|
chosen = [j for j in order if mom[j] > 0][:3] if risk_on else [] # top_k=3 (era 2): DD piu' basso
|
||||||
nw = np.zeros(N)
|
nw = np.zeros(N)
|
||||||
for j in chosen:
|
for j in chosen:
|
||||||
nw[j] = 0.45 / len(chosen)
|
nw[j] = 0.45 / len(chosen)
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
"""Report PER ANNO (Trade, Acc%, DD%, PnL%) delle 3 strategie oneste.
|
|
||||||
|
|
||||||
Acc: DIP01/TR01 = win-rate dei trade chiusi (episodi); ROT01 = % giorni positivi.
|
|
||||||
DD : drawdown massimo dell'equity DENTRO l'anno solare.
|
|
||||||
PnL: variazione % dell'equity nell'anno (composta).
|
|
||||||
Tutto NETTO (fee 0.10% RT, leva 3x, pos 15%). Replica gli engine di honest_*.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
from scripts.analysis.honest_lab import atr, ema, get_df, available_assets, FEE_RT
|
|
||||||
from scripts.analysis.honest_final import dip_entries
|
|
||||||
from scripts.analysis.honest_rotation import build_panel
|
|
||||||
|
|
||||||
LEV, POS = 3.0, 0.15
|
|
||||||
|
|
||||||
|
|
||||||
def _yearly_dd(years: np.ndarray, equity: np.ndarray) -> dict[int, float]:
|
|
||||||
"""DD massimo intra-anno da una serie di equity etichettata per anno."""
|
|
||||||
out: dict[int, float] = {}
|
|
||||||
for y in np.unique(years):
|
|
||||||
eq = equity[years == y]
|
|
||||||
peak = eq[0]; dd = 0.0
|
|
||||||
for v in eq:
|
|
||||||
peak = max(peak, v)
|
|
||||||
dd = max(dd, (peak - v) / peak if peak > 0 else 0.0)
|
|
||||||
out[int(y)] = dd * 100
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _print(title, header, rows):
|
|
||||||
print("\n" + "=" * 78)
|
|
||||||
print(f" {title}")
|
|
||||||
print("=" * 78)
|
|
||||||
print(" " + header)
|
|
||||||
print(" " + "-" * 74)
|
|
||||||
for r in rows:
|
|
||||||
print(" " + r)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------- DIP01 (trade-based) ---------------------------
|
|
||||||
def dip_yearly(asset, tf="1h"):
|
|
||||||
df = get_df(asset, tf)
|
|
||||||
ents = dip_entries(df)
|
|
||||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
|
||||||
n = len(c); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
fee = FEE_RT * LEV
|
|
||||||
cap = 1000.0
|
|
||||||
last_exit = -1
|
|
||||||
eq_y, eq_v = [], []
|
|
||||||
yt: dict[int, list] = {} # year -> [trades, wins, pnl_start_cap, pnl_end_cap]
|
|
||||||
for e in ents:
|
|
||||||
i, d = e["i"], e["d"]
|
|
||||||
if i <= last_exit or i + 1 >= n:
|
|
||||||
continue
|
|
||||||
entry = c[i]; tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
|
|
||||||
exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1)
|
|
||||||
for k in range(1, mb + 1):
|
|
||||||
j = i + k
|
|
||||||
if j >= n:
|
|
||||||
j = n - 1; exit_p = c[j]; break
|
|
||||||
if (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl):
|
|
||||||
exit_p = sl; break
|
|
||||||
if (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp):
|
|
||||||
exit_p = tp; break
|
|
||||||
if k == mb:
|
|
||||||
exit_p = c[j]
|
|
||||||
ret = (exit_p - entry) / entry * d * LEV - fee
|
|
||||||
cap = max(cap + cap * POS * ret, 10.0)
|
|
||||||
last_exit = j
|
|
||||||
y = ts.iloc[i].year
|
|
||||||
rec = yt.setdefault(y, [0, 0, None, None])
|
|
||||||
rec[0] += 1; rec[1] += ret > 0
|
|
||||||
eq_y.append(y); eq_v.append(cap)
|
|
||||||
dd = _yearly_dd(np.array(eq_y), np.array(eq_v))
|
|
||||||
# PnL% anno: da equity prima/dopo
|
|
||||||
rows = []
|
|
||||||
prev = 1000.0
|
|
||||||
yrs = sorted(yt)
|
|
||||||
cum = {}
|
|
||||||
cprev = 1000.0
|
|
||||||
# ricostruisci equity di fine anno
|
|
||||||
end_cap = {}
|
|
||||||
for y, v in zip(eq_y, eq_v):
|
|
||||||
end_cap[y] = v
|
|
||||||
for y in yrs:
|
|
||||||
t, w = yt[y][0], yt[y][1]
|
|
||||||
ec = end_cap[y]
|
|
||||||
pnl = (ec / cprev - 1) * 100
|
|
||||||
cprev = ec
|
|
||||||
rows.append(f"{y:>6d}{t:>8d}{(w/t*100 if t else 0):>8.1f}{dd.get(y,0):>8.1f}{pnl:>+10.1f}")
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------- TR01 (position episodes) ---------------------------
|
|
||||||
def tr_yearly(asset, tf="4h", fast=20, slow=100):
|
|
||||||
df = get_df(asset, tf)
|
|
||||||
c = df["close"].values; n = len(c)
|
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
ef, es = ema(c, fast), ema(c, slow)
|
|
||||||
sig = np.where(ef > es, 1.0, 0.0); sig[:slow] = 0.0
|
|
||||||
cap = 1000.0; cur = 0.0
|
|
||||||
fee = FEE_RT / 2 * LEV
|
|
||||||
ep_start_cap = None; ep_year = None
|
|
||||||
yt: dict[int, list] = {}
|
|
||||||
eq_y, eq_v = [], []
|
|
||||||
for i in range(n - 1):
|
|
||||||
s = sig[i]
|
|
||||||
if s != cur:
|
|
||||||
cap -= cap * POS * fee * abs(s - cur)
|
|
||||||
if s == 1.0: # apertura long
|
|
||||||
ep_start_cap = cap; ep_year = ts.iloc[i].year
|
|
||||||
elif cur == 1.0 and ep_start_cap is not None: # chiusura long
|
|
||||||
rec = yt.setdefault(ep_year, [0, 0])
|
|
||||||
rec[0] += 1; rec[1] += cap > ep_start_cap
|
|
||||||
ep_start_cap = None
|
|
||||||
cur = s
|
|
||||||
pr = (c[i + 1] - c[i]) / c[i]
|
|
||||||
cap = max(cap * (1 + POS * LEV * pr * cur), 10.0)
|
|
||||||
eq_y.append(ts.iloc[i].year); eq_v.append(cap)
|
|
||||||
if cur == 1.0 and ep_start_cap is not None:
|
|
||||||
rec = yt.setdefault(ep_year, [0, 0]); rec[0] += 1; rec[1] += cap > ep_start_cap
|
|
||||||
dd = _yearly_dd(np.array(eq_y), np.array(eq_v))
|
|
||||||
end_cap = {}
|
|
||||||
for y, v in zip(eq_y, eq_v):
|
|
||||||
end_cap[y] = v
|
|
||||||
rows = []; cprev = 1000.0
|
|
||||||
for y in sorted(end_cap):
|
|
||||||
t, w = yt.get(y, [0, 0])
|
|
||||||
pnl = (end_cap[y] / cprev - 1) * 100; cprev = end_cap[y]
|
|
||||||
rows.append(f"{y:>6d}{t:>8d}{(w/t*100 if t else 0):>8.1f}{dd.get(y,0):>8.1f}{pnl:>+10.1f}")
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------- ROT01 (daily portfolio) ---------------------------
|
|
||||||
def rot_yearly(lookback=60, top_k=2, gross=0.45):
|
|
||||||
panel = build_panel(available_assets(), "1d")
|
|
||||||
P = panel.values; T, N = P.shape
|
|
||||||
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
|
|
||||||
years = panel.index.year.values
|
|
||||||
cap = 1000.0; w = np.zeros(N)
|
|
||||||
yt: dict[int, list] = {} # year -> [rebal, pos_days, days]
|
|
||||||
eq_y, eq_v = [], []
|
|
||||||
for i in range(lookback + 1, T - 1):
|
|
||||||
mom = P[i] / P[i - lookback] - 1
|
|
||||||
order = np.argsort(mom)[::-1]
|
|
||||||
chosen = [j for j in order if mom[j] > 0][:top_k]
|
|
||||||
new_w = np.zeros(N)
|
|
||||||
for j in chosen:
|
|
||||||
new_w[j] = gross / len(chosen)
|
|
||||||
turnover = np.abs(new_w - w).sum()
|
|
||||||
if turnover > 1e-9:
|
|
||||||
cap -= cap * turnover * (FEE_RT / 2)
|
|
||||||
w = new_w
|
|
||||||
pr = float(np.dot(w, rets[i + 1]))
|
|
||||||
cap = max(cap * (1 + pr), 10.0)
|
|
||||||
y = int(years[i])
|
|
||||||
rec = yt.setdefault(y, [0, 0, 0])
|
|
||||||
rec[0] += turnover > 1e-9; rec[1] += pr > 0; rec[2] += 1
|
|
||||||
eq_y.append(y); eq_v.append(cap)
|
|
||||||
dd = _yearly_dd(np.array(eq_y), np.array(eq_v))
|
|
||||||
end_cap = {}
|
|
||||||
for y, v in zip(eq_y, eq_v):
|
|
||||||
end_cap[y] = v
|
|
||||||
rows = []; cprev = 1000.0
|
|
||||||
for y in sorted(end_cap):
|
|
||||||
reb, pos, days = yt[y]
|
|
||||||
pnl = (end_cap[y] / cprev - 1) * 100; cprev = end_cap[y]
|
|
||||||
rows.append(f"{y:>6d}{reb:>8d}{(pos/days*100 if days else 0):>8.1f}{dd.get(y,0):>8.1f}{pnl:>+10.1f}")
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
H = f"{'Anno':>6s}{'Trade':>8s}{'Acc%':>8s}{'DD%':>8s}{'PnL%':>10s}"
|
|
||||||
for a in ["BTC", "ETH", "SOL"]:
|
|
||||||
_print(f"DIP01 — {a} 1h (Acc = win-rate trade)", H, dip_yearly(a))
|
|
||||||
for a in ["BNB", "BTC", "DOGE", "SOL", "XRP"]:
|
|
||||||
_print(f"TR01 — {a} 4h (Trade = episodi long, Acc = win-rate episodi)", H, tr_yearly(a))
|
|
||||||
_print("ROT01 — paniere 8 crypto 1d (Trade = ribilanciamenti, Acc = % giorni positivi)",
|
|
||||||
H, rot_yearly())
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
"""Tabella per-anno (PnL% e DD% intra-anno) delle versioni MIGLIORATE:
|
|
||||||
ROT02 (dual-momentum), le 3 sleeve e il PORTAFOGLIO combinato.
|
|
||||||
Tutto NETTO. Riusa gli engine di honest_improve / honest_improve2.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
from scripts.analysis.honest_improve2 import ( # noqa: E402
|
|
||||||
dip_market_gated, _daily_equity, _norm, _tr_basket_daily, _rot_daily_equity,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _year_dd(eq: pd.Series) -> dict[int, float]:
|
|
||||||
out = {}
|
|
||||||
for y, g in eq.groupby(eq.index.year):
|
|
||||||
peak = g.iloc[0]; dd = 0.0
|
|
||||||
for v in g:
|
|
||||||
peak = max(peak, v); dd = max(dd, (peak - v) / peak if peak > 0 else 0.0)
|
|
||||||
out[int(y)] = dd * 100
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _year_pnl(eq: pd.Series) -> dict[int, float]:
|
|
||||||
out = {}
|
|
||||||
for y, g in eq.groupby(eq.index.year):
|
|
||||||
out[int(y)] = (g.iloc[-1] / g.iloc[0] - 1) * 100
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def table(name, eq):
|
|
||||||
eq = _norm(eq)
|
|
||||||
dd = _year_dd(eq); pnl = _year_pnl(eq)
|
|
||||||
print(f"\n {name}")
|
|
||||||
print(f" {'Anno':>6s}{'PnL%':>9s}{'DD%':>7s}")
|
|
||||||
print(" " + "-" * 22)
|
|
||||||
for y in sorted(pnl):
|
|
||||||
print(f" {y:>6d}{pnl[y]:>+9.0f}{dd[y]:>7.0f}")
|
|
||||||
tot = (eq.iloc[-1] / eq.iloc[0] - 1) * 100
|
|
||||||
print(f" {'TOT':>6s}{tot:>+9.0f}{_year_dd(eq) and max(_year_dd(eq).values()):>7.0f}(max anno)")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("=" * 60)
|
|
||||||
print(" RISULTATI PER ANNO — versioni migliorate (NETTO)")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# ROT02 dal 2020 (dati paniere)
|
|
||||||
idx_rot = pd.date_range("2020-09-01", "2026-05-26", freq="1D", tz="UTC")
|
|
||||||
eq_rot = _rot_daily_equity(idx_rot)
|
|
||||||
table("ROT02 — dual-momentum rotation (1d)", eq_rot)
|
|
||||||
|
|
||||||
# sleeve + portafoglio dal 2021
|
|
||||||
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
|
|
||||||
d = dip_market_gated("BTC", market_n=0, return_equity=True)
|
|
||||||
eq_dip = _norm(_daily_equity(d["eq_ts"], d["eq_v"], idx))
|
|
||||||
eq_tr = _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], idx))
|
|
||||||
eq_r2 = _norm(_rot_daily_equity(idx))
|
|
||||||
table("Sleeve DIP01 — BTC (1h)", eq_dip)
|
|
||||||
table("Sleeve TR01 — basket (4h)", eq_tr)
|
|
||||||
table("Sleeve ROT02 (1d)", eq_r2)
|
|
||||||
|
|
||||||
drets = pd.DataFrame({"DIP": eq_dip.pct_change().fillna(0),
|
|
||||||
"TR": eq_tr.pct_change().fillna(0),
|
|
||||||
"ROT": eq_r2.pct_change().fillna(0)})
|
|
||||||
combo = (1 + drets.mean(axis=1)).cumprod()
|
|
||||||
table("PORTAFOGLIO equal-weight (daily rebal)", combo)
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Smoke test REALE dei pairs: fetch live da Cerbero + un tick vero per coppia.
|
||||||
|
|
||||||
|
A differenza di validate_worker_pairs.py (replay su parquet storici), questo verifica
|
||||||
|
la PIPELINE LIVE end-to-end: chiama Cerbero per entrambe le gambe, controlla che lo
|
||||||
|
strumento esista e sia fresco, fa un tick reale del PairsWorker e riporta lo stato.
|
||||||
|
|
||||||
|
Serve a scoprire i problemi che il backtest nasconde (es. un perp alt non disponibile
|
||||||
|
sull'endpoint Deribit). NON apre ordini reali: e' solo paper/lettura.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.live.cerbero_client import CerberoClient
|
||||||
|
from src.live.multi_runner import INSTRUMENT_MAP
|
||||||
|
from src.live.pairs_worker import PairsWorker
|
||||||
|
from scripts.strategies.PR01_pairs_reversion import PAIRS
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(cli, asset, start, end):
|
||||||
|
inst = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
|
||||||
|
try:
|
||||||
|
cs = cli.get_historical(inst, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"), "60")
|
||||||
|
if not cs:
|
||||||
|
return inst, None, "VUOTO (strumento assente sull'endpoint)"
|
||||||
|
df = pd.DataFrame(cs)
|
||||||
|
df["timestamp"] = df["timestamp"].astype("int64")
|
||||||
|
df = df.sort_values("timestamp").reset_index(drop=True)
|
||||||
|
last = pd.to_datetime(df["timestamp"].iloc[-1], unit="ms", utc=True)
|
||||||
|
age = (datetime.now(timezone.utc) - last).total_seconds() / 3600
|
||||||
|
return inst, df, f"{len(df)} barre, ultima {last:%Y-%m-%d %H:%M} ({age:.1f}h fa)"
|
||||||
|
except Exception as e:
|
||||||
|
return inst, None, f"ERRORE {type(e).__name__}: {str(e)[:60]}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cli = CerberoClient()
|
||||||
|
end = datetime.now(timezone.utc)
|
||||||
|
start = end - timedelta(days=60)
|
||||||
|
assets = sorted({a for a, _, _ in PAIRS} | {b for _, b, _ in PAIRS})
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print(" SMOKE TEST LIVE PAIRS — fetch reale Cerbero + tick (no ordini reali)")
|
||||||
|
print("=" * 80)
|
||||||
|
data = {}
|
||||||
|
for a in assets:
|
||||||
|
inst, df, msg = fetch(cli, a, start, end)
|
||||||
|
data[a] = df
|
||||||
|
print(f" {a:<4s} [{inst:<16s}] {msg}")
|
||||||
|
|
||||||
|
print("\n tick reale per coppia:")
|
||||||
|
tmp = Path(tempfile.mkdtemp())
|
||||||
|
try:
|
||||||
|
for a, b, p in PAIRS:
|
||||||
|
if data.get(a) is None or data.get(b) is None:
|
||||||
|
print(f" {a}/{b:<4s}: SKIP (manca feed live di una gamba) -> non tradabile live ora")
|
||||||
|
continue
|
||||||
|
w = PairsWorker(a, b, "1h", params=p, fee_rt=0.001, data_dir=tmp)
|
||||||
|
w._log = lambda *x, **k: None
|
||||||
|
w._notify = lambda *x, **k: None
|
||||||
|
m = data[a][["timestamp", "close"]].merge(
|
||||||
|
data[b][["timestamp", "close"]], on="timestamp", how="inner")
|
||||||
|
if len(m) < p["n"] + 2:
|
||||||
|
print(f" {a}/{b:<4s}: merge {len(m)} barre < n+2 ({p['n']+2}) -> dati insufficienti")
|
||||||
|
continue
|
||||||
|
z, _ = w._zscore(m["close_x"].values, m["close_y"].values)
|
||||||
|
w.tick(data[a], data[b])
|
||||||
|
state = ("IN POS " + ("LONG " + a if w.direction == 1 else "SHORT " + a)
|
||||||
|
if w.in_position else "FLAT")
|
||||||
|
print(f" {a}/{b:<4s}: OK merge {len(m)} barre, z_ora={z[-1]:+.2f} -> {state}")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
print("\n Solo le coppie con entrambe le gambe fresche su Cerbero sono tradabili live.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Verifica indipendente + ricerca PAIRS / SPREAD MEAN-REVERSION fra cripto.
|
||||||
|
|
||||||
|
Famiglia nuova market-neutral (distinta da tutto l'esistente, single-asset).
|
||||||
|
Idea: il log-ratio di due cripto oscilla attorno alla media; z-score estremo -> rientra.
|
||||||
|
|
||||||
|
Engine ONESTO (no look-ahead, verificato):
|
||||||
|
- r[i] = log(closeA[i]/closeB[i]); ma/sd = rolling(n) su r -> usano solo r[<=i].
|
||||||
|
- z[i] = (r[i]-ma[i])/sd[i]. ENTRY a close[i] (eseguibile):
|
||||||
|
z<=-z_in -> LONG ratio (long A / short B); z>=+z_in -> SHORT ratio.
|
||||||
|
- EXIT quando |z[j]| <= z_exit (rientro) o time-limit max_bars, a close[j].
|
||||||
|
- pairs = 2 GAMBE -> fee = 2*fee_rt*lev (0.20% RT/coppia a fee_rt=0.001), il doppio
|
||||||
|
del single-asset. Rendimento neutral = retA*d - retB*d (notional uguale per gamba).
|
||||||
|
- non-overlap, capitale composto. Filtro candele sporche: salta salti |dr|>jump_max.
|
||||||
|
- Ritorno riportato come CAGR e Sharpe ANNUALIZZATO sul tempo reale (no sqrt(n_trade)).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
|
||||||
|
FEE_RT, LEV, POS, OOS_FRAC = 0.001, 3.0, 0.15, 0.30
|
||||||
|
BARS_YEAR = 8760 # 1h
|
||||||
|
|
||||||
|
|
||||||
|
def aligned(a: str, b: str, tf: str = "1h"):
|
||||||
|
da = load_data(a, tf)[["timestamp", "open", "high", "low", "close"]].rename(columns=lambda x: x + "_a" if x != "timestamp" else x)
|
||||||
|
db = load_data(b, tf)[["timestamp", "close"]].rename(columns={"close": "close_b"})
|
||||||
|
m = da.merge(db, on="timestamp", how="inner").reset_index(drop=True)
|
||||||
|
m["dt"] = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def pairs_sim(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.5, max_bars=72,
|
||||||
|
jump_max=0.08, fee_rt=FEE_RT, lev=LEV, pos=POS, split_frac=0.0):
|
||||||
|
m = aligned(a, b, tf)
|
||||||
|
ca, cb = m["close_a"].values, m["close_b"].values
|
||||||
|
r = np.log(ca / cb)
|
||||||
|
dr = np.abs(np.diff(r, prepend=r[0])) # salto 1-bar del log-ratio
|
||||||
|
ma = pd.Series(r).rolling(n).mean().values
|
||||||
|
sd = pd.Series(r).rolling(n).std().values
|
||||||
|
z = (r - ma) / np.where(sd == 0, np.nan, sd) # causale: usa r[<=i]
|
||||||
|
ts = m["dt"]; N = len(r)
|
||||||
|
split = int(N * split_frac)
|
||||||
|
fee = 2 * fee_rt * lev # 2 gambe
|
||||||
|
cap = peak = 1000.0; dd = 0.0; last = -1
|
||||||
|
trades = wins = 0; rets = []; yearly = {}
|
||||||
|
eq_ts: list = []; eq_v: list = []
|
||||||
|
for i in range(n + 1, N - 1):
|
||||||
|
if i < split or np.isnan(z[i]) or dr[i] > jump_max:
|
||||||
|
continue
|
||||||
|
if i <= last:
|
||||||
|
continue
|
||||||
|
if z[i] <= -z_in:
|
||||||
|
d = 1
|
||||||
|
elif z[i] >= z_in:
|
||||||
|
d = -1
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
# exit: |z|<=z_exit o max_bars
|
||||||
|
j = min(i + max_bars, N - 1)
|
||||||
|
for k in range(1, max_bars + 1):
|
||||||
|
jj = i + k
|
||||||
|
if jj >= N:
|
||||||
|
j = N - 1; break
|
||||||
|
if abs(z[jj]) <= z_exit:
|
||||||
|
j = jj; break
|
||||||
|
j = jj
|
||||||
|
retA = (ca[j] - ca[i]) / ca[i]
|
||||||
|
retB = (cb[j] - cb[i]) / cb[i]
|
||||||
|
ret = (retA - retB) * d * lev - fee # long A / short B (o viceversa)
|
||||||
|
cap = max(cap + cap * pos * ret, 10.0)
|
||||||
|
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
|
||||||
|
trades += 1; wins += ret > 0; rets.append(ret * pos); last = j
|
||||||
|
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
|
||||||
|
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
|
||||||
|
yrs_span = (ts.iloc[-1] - ts.iloc[max(split, 0)]).days / 365.25 or 1
|
||||||
|
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(BARS_YEAR / np.mean([max_bars])) ) if len(rets) > 1 and np.std(rets) > 0 else 0.0
|
||||||
|
# Sharpe annualizzato sul tempo reale: usa rendimenti per-trade scalati alla frequenza media
|
||||||
|
if len(rets) > 1 and np.std(rets) > 0:
|
||||||
|
trades_per_year = trades / yrs_span
|
||||||
|
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(trades_per_year))
|
||||||
|
ret_tot = (cap / 1000 - 1) * 100
|
||||||
|
cagr = ((cap / 1000) ** (1 / yrs_span) - 1) * 100 if cap > 0 else -100
|
||||||
|
return dict(trades=trades, win=wins / trades * 100 if trades else 0, ret=ret_tot, cagr=cagr,
|
||||||
|
dd=dd * 100, sharpe=sharpe, yearly=yearly, eq_ts=eq_ts, eq_v=eq_v)
|
||||||
|
|
||||||
|
|
||||||
|
def check_no_lookahead():
|
||||||
|
"""Perturba il FUTURO del ratio e verifica che z[i] non cambi (causalita')."""
|
||||||
|
m = aligned("ETH", "BTC")
|
||||||
|
r = np.log(m["close_a"].values / m["close_b"].values)
|
||||||
|
n = 50; i = 1000
|
||||||
|
z_i = (r[i] - pd.Series(r).rolling(n).mean().values[i]) / pd.Series(r).rolling(n).std().values[i]
|
||||||
|
r2 = r.copy(); r2[i + 1:] += 0.5 # stravolge il futuro
|
||||||
|
z_i2 = (r2[i] - pd.Series(r2).rolling(n).mean().values[i]) / pd.Series(r2).rolling(n).std().values[i]
|
||||||
|
print(f" no-look-ahead: z[i]={z_i:.6f} vs z[i] con futuro perturbato={z_i2:.6f} -> "
|
||||||
|
f"{'OK (identico)' if abs(z_i - z_i2) < 1e-9 else 'VIOLAZIONE!'}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 104)
|
||||||
|
print(f" PAIRS spread reversion — NETTO fee 0.20% RT/coppia (2 gambe), leva {LEV:.0f}x, OOS ultimo {int(OOS_FRAC*100)}%")
|
||||||
|
print("=" * 104)
|
||||||
|
check_no_lookahead()
|
||||||
|
pairs = [("ETH", "BTC"), ("LTC", "ETH"), ("ADA", "ETH"), ("SOL", "ETH"),
|
||||||
|
("BNB", "BTC"), ("XRP", "BTC"), ("SOL", "BTC"), ("DOGE", "BTC")]
|
||||||
|
print(f"\n {'coppia':<10s}{'trd':>5s}{'win%':>6s}{'FULL%':>8s}{'OOS%':>8s}{'CAGR%':>7s}"
|
||||||
|
f"{'DD%':>6s}{'oDD%':>6s}{'Shrp':>6s}{'anni+':>7s}{'fee0.4%RT':>11s}")
|
||||||
|
print(" " + "-" * 96)
|
||||||
|
for a, b in pairs:
|
||||||
|
f = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72)
|
||||||
|
o = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72, split_frac=1 - OOS_FRAC)
|
||||||
|
hi = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72, fee_rt=0.002) # 0.4% RT/coppia
|
||||||
|
yrs = f["yearly"]; pos_y = sum(1 for v in yrs.values() if v > 0)
|
||||||
|
print(f" {a+'/'+b:<10s}{f['trades']:>5d}{f['win']:>6.1f}{f['ret']:>+8.0f}{o['ret']:>+8.0f}"
|
||||||
|
f"{f['cagr']:>7.0f}{f['dd']:>6.0f}{o['dd']:>6.0f}{f['sharpe']:>6.2f}{f'{pos_y}/{len(yrs)}':>7s}"
|
||||||
|
f"{hi['ret']:>+11.0f}")
|
||||||
|
# correlazione con BTC daily (market-neutrality) sulla coppia migliore
|
||||||
|
print("\n Verifica market-neutrality ETH/BTC: per-anno")
|
||||||
|
f = pairs_sim("ETH", "BTC", n=50, z_in=2.0, z_exit=0.5, max_bars=72)
|
||||||
|
print(" " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(f["yearly"].items())))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Report aggiornato: risultati per anno + numero trade per anno, tutte le strategie.
|
||||||
|
|
||||||
|
Sezioni:
|
||||||
|
(A) RET% NETTO per anno — ogni strategia singola + i portafogli (FADE / HONEST /
|
||||||
|
MASTER equal / MASTER 50-50). Ret% dai rendimenti giornalieri composti.
|
||||||
|
(B) NUMERO TRADE per anno — per ogni strategia singola. Per le fade e DIP01 è il
|
||||||
|
numero di ingressi; per TR01 e ROT02 (posizione continua) è il numero di
|
||||||
|
ribilanciamenti/cambi di stato nell'anno.
|
||||||
|
(C) RIEPILOGO — TOT%, CAGR, DD, Sharpe (FULL e OOS) dei portafogli.
|
||||||
|
|
||||||
|
Tutto NETTO fee 0.10% RT, leva 3x, pos 15%/sleeve. Finestra comune 2021-2026,
|
||||||
|
OOS = ultimo 30%. Config = quella deployata (MR03/ROT01 in waste; ROT02 top_k=3).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
from scripts.analysis.combine_portfolio import (
|
||||||
|
build_all_sleeves, port_returns, yearly_returns, metrics, SPLIT, OOS_DATE, IDX,
|
||||||
|
)
|
||||||
|
from scripts.analysis.risk_management import strats_for, build_trades
|
||||||
|
from scripts.analysis.honest_lab import get_df, ema, FEE_RT, LEV, POS
|
||||||
|
from scripts.analysis.honest_improve import rot_improved
|
||||||
|
from scripts.analysis.honest_improve2 import dip_market_gated
|
||||||
|
|
||||||
|
YEARS = sorted(set(IDX.year))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- trade per anno, per tipo di strategia ----------------
|
||||||
|
def fade_trades_year(asset, fn, params) -> dict[int, int]:
|
||||||
|
df = load_data(asset, "1h")
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
out: dict[int, int] = {}
|
||||||
|
for i, j, ret in build_trades(fn(df, **params), df, trend_max=3.0):
|
||||||
|
y = ts.iloc[i].year
|
||||||
|
out[y] = out.get(y, 0) + 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def dip_trades_year() -> dict[int, int]:
|
||||||
|
d = dip_market_gated("BTC", market_n=0)
|
||||||
|
# yt[anno] = lista dei trade dell'anno -> il conteggio e' la lunghezza
|
||||||
|
return {int(y): (len(v) if isinstance(v, (list, tuple)) else int(v)) for y, v in d["yt"].items()}
|
||||||
|
|
||||||
|
|
||||||
|
def tr_rebalances_year(assets) -> dict[int, int]:
|
||||||
|
"""Cambi di stato (entra/esce dal trend) per anno, sommati sul paniere TR01."""
|
||||||
|
out: dict[int, int] = {}
|
||||||
|
for a in assets:
|
||||||
|
df = get_df(a, "4h"); c = df["close"].values
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
ef, es = ema(c, 20), ema(c, 100)
|
||||||
|
sig = np.where(ef > es, 1.0, 0.0); sig[:100] = 0.0
|
||||||
|
for i in range(1, len(c)):
|
||||||
|
if sig[i] != sig[i - 1]:
|
||||||
|
y = ts.iloc[i].year
|
||||||
|
out[y] = out.get(y, 0) + 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def rot_rebalances_year() -> dict[int, int]:
|
||||||
|
r = rot_improved(lookback=60, top_k=3, regime_n=100)
|
||||||
|
return {int(y): int(n) for y, n in r["reb"].items()}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Costruzione equity e conteggi (puo' richiedere ~1 min)...\n")
|
||||||
|
S = build_all_sleeves()
|
||||||
|
fade = {k: v for k, v in S.items() if k.startswith("MR")}
|
||||||
|
honest = {k: v for k, v in S.items() if not k.startswith("MR")}
|
||||||
|
|
||||||
|
# rendimenti giornalieri per Ret%/anno
|
||||||
|
sleeve_ret = {k: v.pct_change().fillna(0.0) for k, v in S.items()}
|
||||||
|
ports = {
|
||||||
|
"FADE": port_returns(fade),
|
||||||
|
"HONEST": port_returns(honest),
|
||||||
|
"MASTEReq": port_returns(S),
|
||||||
|
"MAST5050": (port_returns(fade) + port_returns(honest)) / 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- (A) RET% per anno ----
|
||||||
|
cols_A = list(S) + list(ports)
|
||||||
|
rety = {**{k: yearly_returns(v) for k, v in sleeve_ret.items()},
|
||||||
|
**{k: yearly_returns(v) for k, v in ports.items()}}
|
||||||
|
print("=" * 132)
|
||||||
|
print(" (A) RET% NETTO PER ANNO — strategie singole e portafogli | leva 3x pos 15% fee 0.10% RT")
|
||||||
|
print("=" * 132)
|
||||||
|
print(f" {'Anno':>5s}" + "".join(f"{c.replace('_',''):>11s}" for c in cols_A))
|
||||||
|
print(" " + "-" * 126)
|
||||||
|
for y in YEARS:
|
||||||
|
print(f" {y:>5d}" + "".join(f"{rety[c].get(y, 0):>+11.0f}" for c in cols_A))
|
||||||
|
|
||||||
|
# ---- (B) NUMERO TRADE per anno ----
|
||||||
|
tcounts = {}
|
||||||
|
for asset in ["BTC", "ETH"]:
|
||||||
|
for nm, (fn, params) in strats_for(asset).items():
|
||||||
|
tcounts[f"{nm}_{asset}"] = fade_trades_year(asset, fn, params)
|
||||||
|
tcounts["DIP01_BTC"] = dip_trades_year()
|
||||||
|
tcounts["TR01_basket*"] = tr_rebalances_year(["BNB", "BTC", "DOGE", "SOL", "XRP"])
|
||||||
|
tcounts["ROT02_rot*"] = rot_rebalances_year()
|
||||||
|
cols_B = list(tcounts)
|
||||||
|
print("\n" + "=" * 132)
|
||||||
|
print(" (B) NUMERO TRADE PER ANNO — fade/DIP01 = ingressi; TR01/ROT02 (*) = ribilanciamenti")
|
||||||
|
print("=" * 132)
|
||||||
|
print(f" {'Anno':>5s}" + "".join(f"{c.replace('_',''):>13s}" for c in cols_B))
|
||||||
|
print(" " + "-" * 126)
|
||||||
|
for y in YEARS:
|
||||||
|
print(f" {y:>5d}" + "".join(f"{tcounts[c].get(y, 0):>13d}" for c in cols_B))
|
||||||
|
print(" " + "-" * 126)
|
||||||
|
print(f" {'TOT':>5s}" + "".join(f"{sum(tcounts[c].values()):>13d}" for c in cols_B))
|
||||||
|
|
||||||
|
# ---- (C) riepilogo portafogli ----
|
||||||
|
print("\n" + "=" * 92)
|
||||||
|
print(f" (C) RIEPILOGO PORTAFOGLI | OOS da {OOS_DATE}")
|
||||||
|
print("=" * 92)
|
||||||
|
print(f" {'portafoglio':<14s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
|
||||||
|
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
|
||||||
|
print(" " + "-" * 74)
|
||||||
|
for name, pr in ports.items():
|
||||||
|
f, o = metrics(pr), metrics(pr, lo=SPLIT)
|
||||||
|
print(f" {name:<14s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
|
||||||
|
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||||
|
print("\n MASTEReq (9 sleeve) = configurazione consigliata. (*) TR01/ROT02 = posizione")
|
||||||
|
print(" continua: il conteggio e' il numero di ribilanciamenti/cambi di stato, non di trade discreti.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Report riassuntivo: tutte le strategie/famiglie per anno + analisi di integrazione.
|
||||||
|
|
||||||
|
Consolida in un solo posto:
|
||||||
|
(A) RET% NETTO per anno per FAMIGLIA (FADE / HONEST / PAIRS / TSM01) e per i portafogli.
|
||||||
|
(B) RET% NETTO per anno per ogni STRATEGIA singola (tutti gli sleeve).
|
||||||
|
(C) INTEGRAZIONE: cosa succede al MASTER aggiungendo le nuove famiglie (pairs, TSM01).
|
||||||
|
(D) Numeri SOBRI (worst-case) e raccomandazione operativa.
|
||||||
|
|
||||||
|
Famiglie:
|
||||||
|
FADE (reversione intraday 1h, long/short, BTC/ETH): MR01, MR02, MR07
|
||||||
|
HONEST (long-only multi-regime multi-crypto): DIP01, TR01, ROT02
|
||||||
|
PAIRS (market-neutral spread reversion, config universale): 5 coppie
|
||||||
|
TSM01 (TSMOM multi-orizzonte, diversificatore)
|
||||||
|
|
||||||
|
Tutto NETTO fee, leva 3x (vedi nota sobria leva 2x), finestra comune 2021-2026, OOS=ultimo 30%.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.combine_portfolio import (
|
||||||
|
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
|
||||||
|
)
|
||||||
|
from scripts.analysis.honest_improve2 import _daily_equity, _norm
|
||||||
|
from scripts.analysis.pairs_research import pairs_sim
|
||||||
|
from scripts.analysis.tsmom_research import tsmom_sim
|
||||||
|
from scripts.strategies.PR01_pairs_reversion import PAIRS
|
||||||
|
|
||||||
|
YEARS = sorted(set(IDX.year))
|
||||||
|
|
||||||
|
|
||||||
|
def daily_from(eq_ts, eq_v):
|
||||||
|
return _norm(_daily_equity(eq_ts, eq_v, IDX))
|
||||||
|
|
||||||
|
|
||||||
|
def build_everything():
|
||||||
|
S = build_all_sleeves() # 9 sleeve (FADE 6 + HONEST 3)
|
||||||
|
pairs = {}
|
||||||
|
for a, b, p in PAIRS:
|
||||||
|
r = pairs_sim(a, b, **p)
|
||||||
|
pairs[f"PR_{a}{b}"] = daily_from(r["eq_ts"], r["eq_v"])
|
||||||
|
t = tsmom_sim()
|
||||||
|
tsm = {"TSM01": daily_from(t["eq_ts"], t["eq_v"])}
|
||||||
|
return S, pairs, tsm
|
||||||
|
|
||||||
|
|
||||||
|
def yrow(label, dr):
|
||||||
|
yr = yearly_returns(dr)
|
||||||
|
return f" {label:<14s}" + "".join(f"{yr.get(y, 0):>+9.0f}" for y in YEARS)
|
||||||
|
|
||||||
|
|
||||||
|
def metric_block(label, dr):
|
||||||
|
f, o = metrics(dr), metrics(dr, lo=SPLIT)
|
||||||
|
return (f" {label:<16s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
|
||||||
|
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Costruzione (puo' richiedere ~1-2 min)...\n")
|
||||||
|
S, pairs, tsm = build_everything()
|
||||||
|
fade = {k: v for k, v in S.items() if k.startswith("MR")}
|
||||||
|
honest = {k: v for k, v in S.items() if not k.startswith("MR")}
|
||||||
|
|
||||||
|
fam = {
|
||||||
|
"FADE": port_returns(fade),
|
||||||
|
"HONEST": port_returns(honest),
|
||||||
|
"PAIRS": port_returns(pairs),
|
||||||
|
"TSM01": tsm["TSM01"].pct_change().fillna(0.0),
|
||||||
|
}
|
||||||
|
master9 = port_returns(S)
|
||||||
|
master_p = port_returns({**S, **pairs})
|
||||||
|
master_x = port_returns({**S, **pairs, **tsm})
|
||||||
|
|
||||||
|
# ---------- (A) per anno, per FAMIGLIA + portafogli ----------
|
||||||
|
print("=" * 110)
|
||||||
|
print(" (A) RET% NETTO PER ANNO — per FAMIGLIA e per PORTAFOGLIO | leva 3x, fee netta")
|
||||||
|
print("=" * 110)
|
||||||
|
print(f" {'':<14s}" + "".join(f"{y:>9d}" for y in YEARS))
|
||||||
|
print(" " + "-" * 104)
|
||||||
|
for k, dr in fam.items():
|
||||||
|
print(yrow(k, dr))
|
||||||
|
print(" " + "-" * 104)
|
||||||
|
print(yrow("MASTER-9", master9))
|
||||||
|
print(yrow("MASTER+pairs", master_p))
|
||||||
|
print(yrow("MASTER-esteso", master_x))
|
||||||
|
|
||||||
|
# ---------- (B) per anno, per STRATEGIA singola ----------
|
||||||
|
print("\n" + "=" * 130)
|
||||||
|
print(" (B) RET% NETTO PER ANNO — per STRATEGIA singola (tutti gli sleeve)")
|
||||||
|
print("=" * 130)
|
||||||
|
allsl = {**S, **pairs, **tsm}
|
||||||
|
cols = list(allsl)
|
||||||
|
print(f" {'Anno':>5s}" + "".join(f"{c.replace('_',''):>11s}" for c in cols))
|
||||||
|
print(" " + "-" * 124)
|
||||||
|
yr_each = {k: yearly_returns(v.pct_change().fillna(0.0)) for k, v in allsl.items()}
|
||||||
|
for y in YEARS:
|
||||||
|
print(f" {y:>5d}" + "".join(f"{yr_each[c].get(y, 0):>+11.0f}" for c in cols))
|
||||||
|
|
||||||
|
# ---------- (C) integrazione ----------
|
||||||
|
print("\n" + "=" * 96)
|
||||||
|
print(f" (C) INTEGRAZIONE delle nuove famiglie nel MASTER | OOS da {OOS_DATE} | equal-weight daily")
|
||||||
|
print("=" * 96)
|
||||||
|
print(f" {'portafoglio':<16s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
|
||||||
|
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
|
||||||
|
print(" " + "-" * 80)
|
||||||
|
print(metric_block("MASTER-9", master9))
|
||||||
|
print(metric_block("+pairs", master_p))
|
||||||
|
print(metric_block("+TSM01", port_returns({**S, **tsm})))
|
||||||
|
print(metric_block("MASTER-esteso", master_x))
|
||||||
|
# correlazione media nuove vs master-9
|
||||||
|
dr_all = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in {**S, **pairs, **tsm}.items()})
|
||||||
|
corr = dr_all.corr(); old = list(S)
|
||||||
|
print(" " + "-" * 80)
|
||||||
|
for k in list(pairs) + list(tsm):
|
||||||
|
print(f" corr {k:<11s} vs MASTER-9 = {corr.loc[k, old].mean():+.2f}")
|
||||||
|
|
||||||
|
# ---------- (D) numeri sobri ----------
|
||||||
|
print("\n" + "=" * 96)
|
||||||
|
print(" (D) NUMERI SOBRI / RACCOMANDAZIONE (anti-overfit)")
|
||||||
|
print("=" * 96)
|
||||||
|
print(" - L'OOS singolo (2024-25) e' regime calmo -> Sharpe/DD OOS ottimistici ~50%.")
|
||||||
|
print(" - Numeri onesti del MASTER-esteso: worst-DD 90g ~6%, Sharpe atteso ~5, ogni anno positivo dal 2021.")
|
||||||
|
print(" - Regge leva 2x + slippage doppio (CAGR ~36%, Sharpe ~5).")
|
||||||
|
print(" - Rischio concentrato sui PAIRS (~57%) -> cap allocazione pairs ~30-35%.")
|
||||||
|
print(" - I pairs sono a 2 gambe (long/short): il worker live va esteso prima del trading reale.")
|
||||||
|
print(" - CONFIG RACCOMANDATA: MASTER-esteso, equal-weight, leva 2x, cap pairs 30-35%.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""Gestione del rischio sulle fade (MR01/MR02/MR03/MR07): alzare Acc, ridurre DD.
|
||||||
|
|
||||||
|
Due analisi, ognuna misurata FULL e OOS (ultimo 30%) per non illudersi:
|
||||||
|
|
||||||
|
(A) SCREENING LEVE — confronta su ogni strategia le leve di rischio:
|
||||||
|
- vol-target sizing (size ~ 1/distanza-SL) -> SCARTATA (peggiora)
|
||||||
|
- skip alta volatilita' (ATR% in coda alta) -> SCARTATA (peggiora)
|
||||||
|
- filtro trend (|close-EMA200|/ATR oltre soglia) -> ADOTTATA (Acc+ DD-)
|
||||||
|
- combinazione di tutte
|
||||||
|
|
||||||
|
(B) FILTRO TREND + PORTAFOGLIO:
|
||||||
|
- sweep della soglia trend (assoluta in ATR, regola unica = no overfit)
|
||||||
|
- portafoglio equipesato su sotto-conti indipendenti: curve poco correlate
|
||||||
|
-> DD aggregato << DD del singolo sleeve (vera leva anti-drawdown)
|
||||||
|
|
||||||
|
Engine fedele: ingresso close[i], exit TP/SL intrabar (high/low) o time-limit,
|
||||||
|
non-overlap, capitale composto. Numeri NETTI fee 0.10% RT, leva 3x.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
from scripts.analysis.strategy_research import bollinger_fade, atr
|
||||||
|
from scripts.analysis.strategy_research_v2 import donchian_fade, return_reversal
|
||||||
|
|
||||||
|
FEE_RT, LEV, POS, INIT, OOS_FRAC = 0.001, 3.0, 0.15, 1000.0, 0.30
|
||||||
|
|
||||||
|
# config base di ogni strategia (come strategies.yml).
|
||||||
|
# NB: MR03 keltner_fade spostata in scripts/waste/ (fade piu' debole, ridondante
|
||||||
|
# con MR01); la funzione keltner_fade resta in strategy_research_v2 come record.
|
||||||
|
STRATS = {
|
||||||
|
"MR01": (bollinger_fade, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24)),
|
||||||
|
"MR02": (donchian_fade, dict(n=20, sl_atr=2.0, max_bars=24)),
|
||||||
|
"MR07": (return_reversal,dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
|
||||||
|
}
|
||||||
|
STRATS_ETH = dict(STRATS)
|
||||||
|
|
||||||
|
|
||||||
|
def strats_for(asset: str) -> dict:
|
||||||
|
return STRATS_ETH if asset == "ETH" else STRATS
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ (A) SCREENING LEVE ============================
|
||||||
|
def add_context(ents, df, ema_long=200):
|
||||||
|
"""Aggiunge a ogni entry: sl_dist, atr_pct, trend_dist (|close-EMA|/ATR)."""
|
||||||
|
c = df["close"].values
|
||||||
|
a = atr(df, 14)
|
||||||
|
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
||||||
|
apct = a / c
|
||||||
|
for e in ents:
|
||||||
|
i = e["i"]
|
||||||
|
e["sl_dist"] = abs(c[i] - e["sl"]) / c[i]
|
||||||
|
e["atr_pct"] = apct[i]
|
||||||
|
e["trend_dist"] = abs(c[i] - el[i]) / a[i] if a[i] else 0.0
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
def simulate(ents, df, fee_rt=FEE_RT, lev=LEV, split=-1,
|
||||||
|
sizer=None, vol_skip=None, trend_skip=None, max_size=0.30):
|
||||||
|
"""sizer: funzione(entry)->frazione capitale; default POS fisso.
|
||||||
|
vol_skip: soglia atr_pct sopra cui salto. trend_skip: soglia trend_dist sopra cui salto."""
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
n = len(c)
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
cap = peak = INIT
|
||||||
|
dd = 0.0; last = -1; trd = wins = 0
|
||||||
|
fee = fee_rt * lev
|
||||||
|
yearly = {}; rets = []
|
||||||
|
for e in ents:
|
||||||
|
i, d = e["i"], e["d"]
|
||||||
|
if i <= last or i + 1 >= n or i < split:
|
||||||
|
continue
|
||||||
|
if vol_skip is not None and e["atr_pct"] > vol_skip:
|
||||||
|
continue
|
||||||
|
if trend_skip is not None and e["trend_dist"] > trend_skip:
|
||||||
|
continue
|
||||||
|
entry = c[i]; tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
|
||||||
|
exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1)
|
||||||
|
for k in range(1, mb + 1):
|
||||||
|
j = i + k
|
||||||
|
if j >= n:
|
||||||
|
exit_p = c[n - 1]; break
|
||||||
|
hs = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||||
|
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||||
|
if hs: exit_p = sl; break
|
||||||
|
if ht: exit_p = tp; break
|
||||||
|
if k == mb: exit_p = c[j]
|
||||||
|
ret = (exit_p - entry) / entry * d * lev - fee
|
||||||
|
size = POS if sizer is None else min(sizer(e), max_size)
|
||||||
|
cap = max(cap + cap * size * ret, 10.0)
|
||||||
|
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
|
||||||
|
trd += 1; wins += ret > 0; last = j; rets.append(ret * size)
|
||||||
|
y = ts.iloc[i].year; yearly[y] = yearly.get(y, 0.0) + ret * size * INIT
|
||||||
|
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0
|
||||||
|
return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0,
|
||||||
|
ret=(cap / INIT - 1) * 100, dd=dd * 100, yearly=yearly, sharpe=sharpe)
|
||||||
|
|
||||||
|
|
||||||
|
def vol_target_sizer(target=0.015):
|
||||||
|
"""size t.c. rischio (size*lev*sl_dist) ~ target; piu' largo lo stop, meno size."""
|
||||||
|
return lambda e: target / (LEV * max(e["sl_dist"], 1e-4))
|
||||||
|
|
||||||
|
|
||||||
|
def _line(label, full, oos):
|
||||||
|
print(f" {label:<28s}{full['trades']:>6d}{full['acc']:>7.1f}{full['ret']:>+10.0f}{full['dd']:>7.1f}{full['sharpe']:>7.2f}"
|
||||||
|
f" | {oos['trades']:>5d}{oos['acc']:>7.1f}{oos['ret']:>+9.0f}{oos['dd']:>7.1f}{oos['sharpe']:>7.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
def screen_levers():
|
||||||
|
print("=" * 110)
|
||||||
|
print(" (A) SCREENING LEVE — vol-target / vol-skip / filtro-trend | NETTO fee 0.10% RT, leva 3x")
|
||||||
|
print("=" * 110)
|
||||||
|
for asset in ["BTC", "ETH"]:
|
||||||
|
df = load_data(asset, "1h")
|
||||||
|
split = int(len(df) * (1 - OOS_FRAC))
|
||||||
|
print(f"\n {asset} 1h")
|
||||||
|
print(f" {'config':<28s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>10s}{'DD%':>7s}{'Shrp':>7s}"
|
||||||
|
f" | {'oTrd':>5s}{'oAcc':>7s}{'oRet':>9s}{'oDD':>7s}{'oShrp':>7s}")
|
||||||
|
print(" " + "-" * 106)
|
||||||
|
for nm, (fn, params) in strats_for(asset).items():
|
||||||
|
ents = add_context(fn(df, **params), df)
|
||||||
|
p85 = float(np.quantile([e["atr_pct"] for e in ents], 0.85))
|
||||||
|
t90 = float(np.quantile([e["trend_dist"] for e in ents], 0.90))
|
||||||
|
_line(f"{nm} base", simulate(ents, df), simulate(ents, df, split=split))
|
||||||
|
_line(f"{nm} +volTarget", simulate(ents, df, sizer=vol_target_sizer()),
|
||||||
|
simulate(ents, df, split=split, sizer=vol_target_sizer()))
|
||||||
|
_line(f"{nm} +volSkip(p85)", simulate(ents, df, vol_skip=p85),
|
||||||
|
simulate(ents, df, split=split, vol_skip=p85))
|
||||||
|
_line(f"{nm} +trendSkip(p90)", simulate(ents, df, trend_skip=t90),
|
||||||
|
simulate(ents, df, split=split, trend_skip=t90))
|
||||||
|
_line(f"{nm} +ALL", simulate(ents, df, sizer=vol_target_sizer(), vol_skip=p85, trend_skip=t90),
|
||||||
|
simulate(ents, df, split=split, sizer=vol_target_sizer(), vol_skip=p85, trend_skip=t90))
|
||||||
|
print(" " + "-" * 106)
|
||||||
|
print("\n Esito: vol-target e vol-skip PEGGIORANO; il filtro trend e' l'unica leva utile.")
|
||||||
|
|
||||||
|
|
||||||
|
# ===================== (B) FILTRO TREND + PORTAFOGLIO =====================
|
||||||
|
def build_trades(ents, df, lev=LEV, fee_rt=FEE_RT, trend_max=None, ema_long=200):
|
||||||
|
"""Lista trade non-overlap: (entry_idx, exit_idx, ret_netto). Filtro trend opzionale."""
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
n = len(c); a = atr(df, 14)
|
||||||
|
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
||||||
|
fee = fee_rt * lev
|
||||||
|
out = []; last = -1
|
||||||
|
for e in ents:
|
||||||
|
i, d = e["i"], e["d"]
|
||||||
|
if i <= last or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max:
|
||||||
|
continue
|
||||||
|
entry = c[i]; tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
|
||||||
|
exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1)
|
||||||
|
for k in range(1, mb + 1):
|
||||||
|
j = i + k
|
||||||
|
if j >= n:
|
||||||
|
exit_p = c[n - 1]; break
|
||||||
|
hs = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||||
|
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||||
|
if hs: exit_p = sl; break
|
||||||
|
if ht: exit_p = tp; break
|
||||||
|
if k == mb: exit_p = c[j]
|
||||||
|
ret = (exit_p - entry) / entry * d * lev - fee
|
||||||
|
out.append((i, j, ret)); last = j
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def metrics_single(trades, pos=POS, split=-1):
|
||||||
|
cap = peak = INIT; dd = 0.0; trd = wins = 0; rets = []
|
||||||
|
for i, j, ret in trades:
|
||||||
|
if i < split:
|
||||||
|
continue
|
||||||
|
cap = max(cap + cap * pos * ret, 10.0)
|
||||||
|
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
|
||||||
|
trd += 1; wins += ret > 0; rets.append(ret * pos)
|
||||||
|
sh = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0
|
||||||
|
return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0,
|
||||||
|
ret=(cap / INIT - 1) * 100, dd=dd * 100, sharpe=sh)
|
||||||
|
|
||||||
|
|
||||||
|
def sleeve_equity(trades, n_bars, pos=POS, split=-1):
|
||||||
|
"""Equity di uno sleeve su sotto-conto indipendente (capitale INIT, pos fissa)."""
|
||||||
|
eq = np.full(n_bars, INIT, dtype=float)
|
||||||
|
cap = INIT
|
||||||
|
for i, j, ret in sorted(trades, key=lambda t: t[1]):
|
||||||
|
if i < split:
|
||||||
|
continue
|
||||||
|
cap = max(cap + cap * pos * ret, 10.0)
|
||||||
|
eq[j:] = cap
|
||||||
|
return eq
|
||||||
|
|
||||||
|
|
||||||
|
def metrics_portfolio(strat_trades, n_bars, pos=POS, split=-1):
|
||||||
|
"""Portafoglio equipesato: media di N sotto-conti indipendenti. DD sull'aggregata."""
|
||||||
|
sleeves = [sleeve_equity(tr, n_bars, pos=pos, split=split) for tr in strat_trades.values()]
|
||||||
|
agg = np.mean(sleeves, axis=0)
|
||||||
|
agg = agg[max(split, 0):]
|
||||||
|
peak = np.maximum.accumulate(agg)
|
||||||
|
dd = float(np.max((peak - agg) / peak) * 100)
|
||||||
|
trd = sum(1 for tr in strat_trades.values() for i, _, _ in tr if i >= split)
|
||||||
|
wins = sum(1 for tr in strat_trades.values() for i, _, r in tr if i >= split and r > 0)
|
||||||
|
return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0, ret=(agg[-1] / INIT - 1) * 100, dd=dd)
|
||||||
|
|
||||||
|
|
||||||
|
def trend_and_portfolio():
|
||||||
|
# --- sweep soglia trend ---
|
||||||
|
print("\n" + "=" * 104)
|
||||||
|
print(" (B1) FILTRO TREND |close-EMA200|/ATR > soglia -> SALTA | NETTO fee 0.10% RT, leva 3x")
|
||||||
|
print("=" * 104)
|
||||||
|
print(f" {'Strat/Asset':<14s}{'soglia':>8s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>9s}{'DD%':>7s}"
|
||||||
|
f" | {'oAcc':>6s}{'oRet':>9s}{'oDD':>7s}{'oShrp':>7s}")
|
||||||
|
print(" " + "-" * 100)
|
||||||
|
for asset in ["BTC", "ETH"]:
|
||||||
|
df = load_data(asset, "1h"); split = int(len(df) * (1 - OOS_FRAC))
|
||||||
|
for nm, (fn, params) in strats_for(asset).items():
|
||||||
|
ents = fn(df, **params)
|
||||||
|
for thr in [None, 4.0, 3.0, 2.5, 2.0]:
|
||||||
|
tr = build_trades(ents, df, trend_max=thr)
|
||||||
|
f = metrics_single(tr); o = metrics_single(tr, split=split)
|
||||||
|
lab = "base" if thr is None else f"{thr}ATR"
|
||||||
|
print(f" {nm+' '+asset:<14s}{lab:>8s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+9.0f}{f['dd']:>7.1f}"
|
||||||
|
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||||
|
print(" " + "-" * 100)
|
||||||
|
|
||||||
|
# --- portafoglio equipesato (filtro trend 3.0 ATR) ---
|
||||||
|
print("\n" + "=" * 104)
|
||||||
|
print(" (B2) PORTAFOGLIO equipesato: N sotto-conti indipendenti (pos 0.15, filtro trend 3.0 ATR)")
|
||||||
|
print("=" * 104)
|
||||||
|
print(f" {'Universo':<26s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>10s}{'DD%':>7s}"
|
||||||
|
f" | {'oAcc':>6s}{'oRet':>9s}{'oDD':>7s}")
|
||||||
|
print(" " + "-" * 100)
|
||||||
|
all_trades = {}
|
||||||
|
for asset in ["BTC", "ETH"]:
|
||||||
|
df = load_data(asset, "1h"); split = int(len(df) * (1 - OOS_FRAC)); n = len(df)
|
||||||
|
st = {f"{nm}_{asset}": build_trades(fn(df, **p), df, trend_max=3.0) for nm, (fn, p) in strats_for(asset).items()}
|
||||||
|
all_trades.update(st)
|
||||||
|
f = metrics_portfolio(st, n); o = metrics_portfolio(st, n, split=split)
|
||||||
|
print(f" {'Portafoglio '+asset+' (4 strat)':<26s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+10.0f}{f['dd']:>7.1f}"
|
||||||
|
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}")
|
||||||
|
df0 = load_data("BTC", "1h"); split0 = int(len(df0) * (1 - OOS_FRAC))
|
||||||
|
f = metrics_portfolio(all_trades, len(df0)); o = metrics_portfolio(all_trades, len(df0), split=split0)
|
||||||
|
print(" " + "-" * 100)
|
||||||
|
print(f" {'GLOBALE BTC+ETH (8 sleeve)':<26s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+10.0f}{f['dd']:>7.1f}"
|
||||||
|
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}")
|
||||||
|
print("\n Curve poco correlate => DD aggregato molto piu' basso del singolo sleeve.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
screen_levers()
|
||||||
|
trend_and_portfolio()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""Ricerca v2 — nuove strategie oltre MR01, stessa metodologia fee-aware OOS.
|
||||||
|
|
||||||
|
Lezioni ereditate (vedi strategy_research.py / oos_validation.py):
|
||||||
|
- mean-reversion ha edge, continuation/trend NO (i breakout rientrano)
|
||||||
|
- fee = vincolo di prim'ordine -> default Deribit 0.10% RT, poche operazioni meglio
|
||||||
|
- ingresso ESEGUIBILE a close[i] (mai look-ahead con direzione da barra i)
|
||||||
|
- ogni numero NETTO dopo fee+leva, su finestra held-out (OOS=ultimo 30%) + per anno
|
||||||
|
|
||||||
|
Nuovi candidati (tutti fade/mean-reversion con ingresso onesto):
|
||||||
|
MR02 donchian_fade - fade rottura canale Donchian (opposto del trend che muore)
|
||||||
|
MR03 keltner_fade - fade canale Keltner (ATR), TP alla EMA media
|
||||||
|
MR04 zscore_revert - fade deviazione z-score estrema, TP alla media
|
||||||
|
MR05 boll_fade_adx - Bollinger fade con filtro regime ADX (solo mercato laterale)
|
||||||
|
|
||||||
|
Engine identico a strategy_research.simulate (ingresso close[i], exit TP/SL intrabar
|
||||||
|
high/low o time-limit, non-overlap, capitale composto).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
# riusa engine, dati e indicatori gia' validati
|
||||||
|
from scripts.analysis.strategy_research import (
|
||||||
|
FEE_RT, LEV, POS, OOS_FRAC, get_df, atr, rsi, simulate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- indicatori extra ---------------------------
|
||||||
|
def ema(x: np.ndarray, n: int) -> np.ndarray:
|
||||||
|
return pd.Series(x).ewm(span=n, adjust=False).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
def adx(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||||
|
"""Average Directional Index: misura la forza del trend (alto=trend, basso=range)."""
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
up = h - np.roll(h, 1)
|
||||||
|
dn = np.roll(l, 1) - l
|
||||||
|
up[0] = dn[0] = 0.0
|
||||||
|
plus_dm = np.where((up > dn) & (up > 0), up, 0.0)
|
||||||
|
minus_dm = np.where((dn > up) & (dn > 0), dn, 0.0)
|
||||||
|
pc = np.roll(c, 1); pc[0] = c[0]
|
||||||
|
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||||
|
atr_n = pd.Series(tr).ewm(alpha=1/n, adjust=False).mean().values
|
||||||
|
pdi = 100 * pd.Series(plus_dm).ewm(alpha=1/n, adjust=False).mean().values / np.where(atr_n == 0, np.nan, atr_n)
|
||||||
|
mdi = 100 * pd.Series(minus_dm).ewm(alpha=1/n, adjust=False).mean().values / np.where(atr_n == 0, np.nan, atr_n)
|
||||||
|
dx = 100 * np.abs(pdi - mdi) / np.where((pdi + mdi) == 0, np.nan, pdi + mdi)
|
||||||
|
return pd.Series(dx).ewm(alpha=1/n, adjust=False).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- strategie nuove ---------------------------
|
||||||
|
def donchian_fade(df, n=20, sl_atr=2.0, max_bars=24):
|
||||||
|
"""MR02 — fade rottura canale Donchian: rompe sopra max-N => short verso il mid.
|
||||||
|
|
||||||
|
Coerente con 'i breakout rientrano': l'opposto di donchian_trend (che fallisce).
|
||||||
|
Ingresso a close[i] sulla barra che chiude oltre il canale precedente.
|
||||||
|
TP al centro del canale, SL = sl_atr*ATR oltre l'estremo.
|
||||||
|
"""
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
hh = pd.Series(h).rolling(n).max().shift(1).values
|
||||||
|
ll = pd.Series(l).rolling(n).min().shift(1).values
|
||||||
|
a = atr(df, 14)
|
||||||
|
ents = []
|
||||||
|
for i in range(n + 14, len(c)):
|
||||||
|
if np.isnan(hh[i]) or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
mid = (hh[i] + ll[i]) / 2.0
|
||||||
|
if c[i] > hh[i] and c[i - 1] <= hh[i - 1]: # rottura rialzista => fade short
|
||||||
|
ents.append({"i": i, "d": -1, "tp": mid, "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
elif c[i] < ll[i] and c[i - 1] >= ll[i - 1]: # rottura ribassista => fade long
|
||||||
|
ents.append({"i": i, "d": 1, "tp": mid, "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
def keltner_fade(df, n=20, k=2.0, sl_atr=2.0, max_bars=24):
|
||||||
|
"""MR03 — fade canale Keltner (EMA +/- k*ATR), TP alla EMA media.
|
||||||
|
|
||||||
|
Come Bollinger ma banda basata su ATR (volatilita' di range) invece che std:
|
||||||
|
reagisce diversamente ai gap. Ingresso quando close esce dalla banda.
|
||||||
|
"""
|
||||||
|
c = df["close"].values
|
||||||
|
e = ema(c, n)
|
||||||
|
a = atr(df, n)
|
||||||
|
up, lo = e + k * a, e - k * a
|
||||||
|
ents = []
|
||||||
|
for i in range(n + 1, len(c)):
|
||||||
|
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||||
|
ents.append({"i": i, "d": 1, "tp": e[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||||
|
ents.append({"i": i, "d": -1, "tp": e[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
def zscore_revert(df, n=50, z=2.0, sl_atr=2.5, max_bars=24):
|
||||||
|
"""MR04 — fade deviazione z-score estrema dalla media, TP alla media.
|
||||||
|
|
||||||
|
z = (close-ma)/std. Entra quando |z| supera la soglia (close fuori); chiude
|
||||||
|
quando torna alla media. Banda di Bollinger riparametrizzata in z (equivalente
|
||||||
|
a k=z) ma con SL piu' largo e finestra lunga: poche operazioni, alta selettivita'.
|
||||||
|
"""
|
||||||
|
c = df["close"].values
|
||||||
|
ma = pd.Series(c).rolling(n).mean().values
|
||||||
|
sd = pd.Series(c).rolling(n).std().values
|
||||||
|
a = atr(df, 14)
|
||||||
|
ents = []
|
||||||
|
for i in range(n + 14, len(c)):
|
||||||
|
if np.isnan(ma[i]) or sd[i] == 0 or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
zi = (c[i] - ma[i]) / sd[i]
|
||||||
|
zp = (c[i - 1] - ma[i - 1]) / sd[i - 1] if sd[i - 1] else 0.0
|
||||||
|
if zi <= -z and zp > -z:
|
||||||
|
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
elif zi >= z and zp < z:
|
||||||
|
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
def boll_fade_adx(df, n=50, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25.0):
|
||||||
|
"""MR05 — Bollinger fade SOLO in regime laterale (ADX < adx_max).
|
||||||
|
|
||||||
|
Il fade soffre quando c'e' trend forte (il prezzo continua oltre la banda).
|
||||||
|
Filtro ADX: opera solo quando la forza del trend e' bassa -> meno trade, edge piu' pulito.
|
||||||
|
"""
|
||||||
|
c = df["close"].values
|
||||||
|
ma = pd.Series(c).rolling(n).mean().values
|
||||||
|
sd = pd.Series(c).rolling(n).std().values
|
||||||
|
a = atr(df, 14)
|
||||||
|
ax = adx(df, 14)
|
||||||
|
up, lo = ma + k * sd, ma - k * sd
|
||||||
|
ents = []
|
||||||
|
for i in range(n + 14, len(c)):
|
||||||
|
if np.isnan(up[i]) or np.isnan(a[i]) or np.isnan(ax[i]):
|
||||||
|
continue
|
||||||
|
if ax[i] >= adx_max: # trend forte: niente fade
|
||||||
|
continue
|
||||||
|
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||||
|
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||||
|
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
def rsi2_fade(df, rsi_n=2, lo=10, hi=90, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24):
|
||||||
|
"""MR06 — Connors RSI(2) pullback in direzione del trend, TP/SL in ATR.
|
||||||
|
|
||||||
|
Meccanismo distinto da MR01/MR03: non usa bande di prezzo ma l'oscillatore
|
||||||
|
RSI(2), che satura su micro-estremi. Filtro di trend con SMA lunga:
|
||||||
|
- close SOPRA la SMA (uptrend) + RSI(2) < lo (dip) -> long, target rimbalzo
|
||||||
|
- close SOTTO la SMA (downtrend) + RSI(2) > hi (pop) -> short
|
||||||
|
TP = tp_atr*ATR a favore, SL = sl_atr*ATR contro. Compra il ritracciamento
|
||||||
|
nel trend, non il contro-trend.
|
||||||
|
"""
|
||||||
|
c = df["close"].values
|
||||||
|
r = rsi(c, rsi_n)
|
||||||
|
ma = pd.Series(c).rolling(ma_n).mean().values
|
||||||
|
a = atr(df, 14)
|
||||||
|
ents = []
|
||||||
|
for i in range(ma_n + 14, len(c)):
|
||||||
|
if np.isnan(r[i]) or np.isnan(ma[i]) or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
if r[i] < lo and c[i] > ma[i]: # dip in uptrend -> long
|
||||||
|
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
elif r[i] > hi and c[i] < ma[i]: # pop in downtrend -> short
|
||||||
|
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
def return_reversal(df, n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24):
|
||||||
|
"""MR07 — fade movimento di barra estremo (return reversal).
|
||||||
|
|
||||||
|
Misura il rendimento dell'ultima barra in unita' di deviazione standard rolling
|
||||||
|
dei rendimenti. Se |ret| > k*sigma, fada nella direzione opposta; TP/SL in ATR.
|
||||||
|
Meccanismo distinto: usa la volatilita' dei RENDIMENTI, non i livelli di prezzo.
|
||||||
|
Config robusta (k=3.5, tp=2ATR, sl=1.5ATR): positivo full+OOS BTC e ETH 1h,
|
||||||
|
DD piu' contenuto (BTC 25% / ETH 46%).
|
||||||
|
"""
|
||||||
|
c = df["close"].values
|
||||||
|
ret = np.zeros_like(c)
|
||||||
|
ret[1:] = np.diff(c) / c[:-1]
|
||||||
|
sig = pd.Series(ret).rolling(n).std().values
|
||||||
|
a = atr(df, 14)
|
||||||
|
ents = []
|
||||||
|
for i in range(n + 14, len(c)):
|
||||||
|
if np.isnan(sig[i]) or sig[i] == 0 or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
z = ret[i] / sig[i]
|
||||||
|
if z <= -k: # crollo di barra -> fade long
|
||||||
|
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
elif z >= k: # spike di barra -> fade short
|
||||||
|
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||||
|
return ents
|
||||||
|
|
||||||
|
|
||||||
|
CANDIDATES = {
|
||||||
|
"MR02 donch_fade n20": (donchian_fade, dict(n=20, sl_atr=2.0, max_bars=24)),
|
||||||
|
"MR02 donch_fade n50": (donchian_fade, dict(n=50, sl_atr=2.0, max_bars=24)),
|
||||||
|
"MR03 kelt_fade k2": (keltner_fade, dict(n=20, k=2.0, sl_atr=2.0, max_bars=24)),
|
||||||
|
"MR03 kelt_fade k2.5": (keltner_fade, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)),
|
||||||
|
"MR04 zscore z2 n50": (zscore_revert, dict(n=50, z=2.0, sl_atr=2.5, max_bars=24)),
|
||||||
|
"MR04 zscore z2.5 n50": (zscore_revert, dict(n=50, z=2.5, sl_atr=2.5, max_bars=24)),
|
||||||
|
"MR05 boll_adx n50": (boll_fade_adx, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25)),
|
||||||
|
"MR05 boll_adx n20": (boll_fade_adx, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25)),
|
||||||
|
"MR06 rsi2 10/90": (rsi2_fade, dict(rsi_n=2, lo=10, hi=90, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24)),
|
||||||
|
"MR06 rsi2 5/95": (rsi2_fade, dict(rsi_n=2, lo=5, hi=95, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24)),
|
||||||
|
"MR07 retrev k3.5": (return_reversal, dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
|
||||||
|
"MR07 retrev k3.0": (return_reversal, dict(n=50, k=3.0, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def table():
|
||||||
|
print("=" * 122)
|
||||||
|
print(f" RICERCA v2 — NETTO dopo fee {FEE_RT*100:.2f}% RT | leva {LEV:.0f}x | pos {POS*100:.0f}% "
|
||||||
|
f"| OOS = ultimo {int(OOS_FRAC*100)}%")
|
||||||
|
print("=" * 122)
|
||||||
|
print(f" {'Strategia':<22s}{'Asset':>5s}{'TF':>5s}{'Trd':>6s}{'Tr/yr':>7s}{'Win%':>7s}"
|
||||||
|
f"{'Ret%FULL':>10s}{'Ret%OOS':>10s}{'DD%':>7s}{'Exp%':>7s}{'AnniPos':>9s}")
|
||||||
|
print(" " + "-" * 118)
|
||||||
|
for label, (fn, params) in CANDIDATES.items():
|
||||||
|
for asset in ["BTC", "ETH"]:
|
||||||
|
for tf in ["1h", "4h"]:
|
||||||
|
df = get_df(asset, tf)
|
||||||
|
ents = fn(df, **params)
|
||||||
|
full = simulate(ents, df)
|
||||||
|
split = int(len(df) * (1 - OOS_FRAC))
|
||||||
|
oos = simulate([e for e in ents if e["i"] >= split], df)
|
||||||
|
yrs = full["yearly"]
|
||||||
|
pos_yrs = sum(1 for v in yrs.values() if v > 0)
|
||||||
|
tr_yr = full["trades"] / max(len(yrs), 1)
|
||||||
|
robust = oos["ret"] > 0 and full["ret"] > 0 and pos_yrs >= max(len(yrs) - 1, 1)
|
||||||
|
flag = " <<<" if robust else ""
|
||||||
|
print(f" {label:<22s}{asset:>5s}{tf:>5s}{full['trades']:>6d}{tr_yr:>7.0f}{full['win']:>7.1f}"
|
||||||
|
f"{full['ret']:>+10.1f}{oos['ret']:>+10.1f}{full['dd']:>7.1f}{full['exposure']:>7.1f}"
|
||||||
|
f"{f'{pos_yrs}/{len(yrs)}':>9s}{flag}")
|
||||||
|
print(" " + "-" * 118)
|
||||||
|
print(" <<< = positivo full+OOS e robusto (quasi tutti gli anni positivi).")
|
||||||
|
|
||||||
|
|
||||||
|
def deep_dive():
|
||||||
|
"""Robustezza dei 3 candidati promossi: fee sweep + griglia parametri OOS."""
|
||||||
|
split_of = lambda df: int(len(df) * (1 - OOS_FRAC))
|
||||||
|
fees = [0.0, 0.0005, 0.001, 0.002]
|
||||||
|
|
||||||
|
print("\n" + "#" * 122)
|
||||||
|
print(" APPROFONDIMENTO MR02 / MR03 / MR05 — robustezza fee + griglia (deve restare positivo)")
|
||||||
|
print("#" * 122)
|
||||||
|
|
||||||
|
# --- MR02 Donchian Fade ---
|
||||||
|
print(f"\n [MR02 donchian_fade] SENSIBILITA' FEE — Ret% FULL/OOS (n=20)")
|
||||||
|
print(f" {'Asset/TF':<10s}" + "".join(f"{f'{f*100:.2f}%RT':>22s}" for f in fees))
|
||||||
|
print(f" {'':<10s}" + "".join(f"{'full':>11s}{'oos':>11s}" for _ in fees))
|
||||||
|
for a, tf in [("BTC", "1h"), ("ETH", "1h"), ("BTC", "4h"), ("ETH", "4h")]:
|
||||||
|
df = get_df(a, tf); sp = split_of(df)
|
||||||
|
ents = donchian_fade(df, n=20, sl_atr=2.0, max_bars=24)
|
||||||
|
oents = [e for e in ents if e["i"] >= sp]
|
||||||
|
cells = "".join(f"{simulate(ents, df, fee_rt=f)['ret']:>+11.0f}{simulate(oents, df, fee_rt=f)['ret']:>+11.0f}" for f in fees)
|
||||||
|
print(f" {a+' '+tf:<10s}{cells}")
|
||||||
|
print(f"\n [MR02] GRIGLIA n x sl_atr — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT")
|
||||||
|
for a in ["BTC", "ETH"]:
|
||||||
|
df = get_df(a, "1h"); sp = split_of(df)
|
||||||
|
print(f"\n {a} 1h " + "".join(f"{f'sl={s}':>16s}" for s in [1.5, 2.0, 3.0]))
|
||||||
|
for n in [10, 20, 30, 50]:
|
||||||
|
cells = ""
|
||||||
|
for s in [1.5, 2.0, 3.0]:
|
||||||
|
r = simulate([e for e in donchian_fade(df, n=n, sl_atr=s, max_bars=24) if e["i"] >= sp], df)
|
||||||
|
cell = "%+.0f(%.0f)" % (r["ret"], r["dd"])
|
||||||
|
cells += f"{cell:>16s}"
|
||||||
|
print(f" n={n:<4d}{cells}")
|
||||||
|
|
||||||
|
# --- MR03 Keltner Fade ---
|
||||||
|
print(f"\n [MR03 keltner_fade] GRIGLIA n x k — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT")
|
||||||
|
for a in ["BTC", "ETH"]:
|
||||||
|
df = get_df(a, "1h"); sp = split_of(df)
|
||||||
|
print(f"\n {a} 1h " + "".join(f"{f'k={k}':>16s}" for k in [1.5, 2.0, 2.5]))
|
||||||
|
for n in [14, 20, 30, 50]:
|
||||||
|
cells = ""
|
||||||
|
for k in [1.5, 2.0, 2.5]:
|
||||||
|
r = simulate([e for e in keltner_fade(df, n=n, k=k, sl_atr=2.0, max_bars=24) if e["i"] >= sp], df)
|
||||||
|
cell = "%+.0f(%.0f)" % (r["ret"], r["dd"])
|
||||||
|
cells += f"{cell:>16s}"
|
||||||
|
print(f" n={n:<4d}{cells}")
|
||||||
|
|
||||||
|
# --- MR05 Bollinger Fade + ADX ---
|
||||||
|
print(f"\n [MR05 boll_fade_adx] GRIGLIA n x adx_max — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT")
|
||||||
|
for a in ["BTC", "ETH"]:
|
||||||
|
df = get_df(a, "1h"); sp = split_of(df)
|
||||||
|
print(f"\n {a} 1h " + "".join(f"{f'adx<{x}':>16s}" for x in [20, 25, 30]))
|
||||||
|
for n in [20, 30, 50]:
|
||||||
|
cells = ""
|
||||||
|
for x in [20, 25, 30]:
|
||||||
|
r = simulate([e for e in boll_fade_adx(df, n=n, k=2.5, sl_atr=2.0, max_bars=24, adx_max=x) if e["i"] >= sp], df)
|
||||||
|
cell = "%+.0f(%.0f)" % (r["ret"], r["dd"])
|
||||||
|
cells += f"{cell:>16s}"
|
||||||
|
print(f" n={n:<4d}{cells}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
table()
|
||||||
|
deep_dive()
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Verifica indipendente + ricerca TSM01 — Time-Series Momentum multi-orizzonte.
|
||||||
|
|
||||||
|
Long-only, multi-crypto, bassa frequenza. Per ogni asset il segnale è il CONSENSO
|
||||||
|
dei segni del momentum su più orizzonti lunghi (3/6/12 mesi); si tengono equal-weight
|
||||||
|
gli asset con consenso pieno positivo. Overlay risk-off: cash se BTC < SMA100.
|
||||||
|
|
||||||
|
Distinta da ROT02 (cross-sectional ranking): qui conta la PERSISTENZA assoluta lenta
|
||||||
|
di ogni asset, non la classifica relativa. Correlazione con ROT02 ~0.62 -> fattore
|
||||||
|
parzialmente indipendente, utile come diversificatore (NON come motore di ritorno:
|
||||||
|
rende meno di ROT02 a parita' di OOS). DD basso.
|
||||||
|
|
||||||
|
Anti-overfit: edge su ALTOPIANO (36/36 config orizzonti x thr x regime_n restano OOS+),
|
||||||
|
walk-forward stabile (4 anni up, 2 piatti per risk-off, mai un anno negativo), regge
|
||||||
|
fee 0.40% RT. Gran parte del DD basso viene dall'overlay risk-off SMA100 (condiviso),
|
||||||
|
la struttura multi-orizzonte aggiunge ~+38pp OOS e alza lo Sharpe 0.58->1.07.
|
||||||
|
Default gross=0.30 (era 0.45): stesso Sharpe ma DD 22%->15% (scelta robusta, non la piu' redditizia).
|
||||||
|
|
||||||
|
Engine onesto: pesi a close[i] da soli rendimenti passati, realizzo i->i+1, fee
|
||||||
|
one-way fee_rt/2 sul turnover. NETTO, leva implicita gross. OOS = ultimo 30%.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.honest_lab import available_assets, FEE_RT
|
||||||
|
from scripts.analysis.honest_rotation import build_panel
|
||||||
|
|
||||||
|
GROSS, OOS_FRAC = 0.30, 0.30 # gross 0.30 (anti-overfit): stesso Sharpe di 0.45, DD piu' basso
|
||||||
|
|
||||||
|
|
||||||
|
def tsmom_sim(horizons=(63, 126, 252), thr=1.0, regime_n=100, gross=GROSS,
|
||||||
|
fee_rt=FEE_RT, oos_frac=0.0, cheat=False):
|
||||||
|
"""horizons in giorni. thr=1.0 -> consenso pieno (tutti i segni positivi)."""
|
||||||
|
panel = build_panel(available_assets(), "1d")
|
||||||
|
cols = list(panel.columns); P = panel.values; T, N = P.shape
|
||||||
|
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
|
||||||
|
years = panel.index.year.values
|
||||||
|
btc = P[:, cols.index("BTC")]
|
||||||
|
bma = pd.Series(btc).rolling(regime_n).mean().values
|
||||||
|
start = max(max(horizons) + 1, regime_n + 1, int(T * (1 - oos_frac)) if oos_frac else 0)
|
||||||
|
cap = 1000.0; w = np.zeros(N); eq = [cap]; yearly = {}
|
||||||
|
eq_ts: list = []; eq_v: list = []
|
||||||
|
for i in range(start, T - 1):
|
||||||
|
risk_on = btc[i] > bma[i] if not np.isnan(bma[i]) else False
|
||||||
|
wi = i + 1 if cheat else i # cheat: usa il futuro (test no-look-ahead)
|
||||||
|
score = np.zeros(N)
|
||||||
|
for h in horizons:
|
||||||
|
score += np.sign(P[wi] / P[wi - h] - 1)
|
||||||
|
score /= len(horizons)
|
||||||
|
chosen = [j for j in range(N) if score[j] >= thr] if risk_on else []
|
||||||
|
nw = np.zeros(N)
|
||||||
|
for j in chosen:
|
||||||
|
nw[j] = gross / len(chosen)
|
||||||
|
cap -= cap * np.abs(nw - w).sum() * (fee_rt / 2); w = nw
|
||||||
|
cap = max(cap * (1 + float(np.dot(w, rets[i + 1]))), 10.0)
|
||||||
|
eq.append(cap)
|
||||||
|
eq_ts.append(panel.index[i + 1]); eq_v.append(cap)
|
||||||
|
y = int(years[i]); yearly[y] = yearly.get(y, 0.0) + float(np.dot(w, rets[i + 1])) * 100
|
||||||
|
eq = np.array(eq); peak = np.maximum.accumulate(eq)
|
||||||
|
dd = float(np.max((peak - eq) / peak) * 100)
|
||||||
|
yrs = (panel.index[-1] - panel.index[start]).days / 365.25 or 1
|
||||||
|
rets_d = np.diff(eq) / eq[:-1]
|
||||||
|
sharpe = float(np.mean(rets_d) / np.std(rets_d) * np.sqrt(365)) if np.std(rets_d) > 0 else 0.0
|
||||||
|
return dict(ret=(cap / 1000 - 1) * 100, cagr=((cap / 1000) ** (1 / yrs) - 1) * 100,
|
||||||
|
dd=dd, sharpe=sharpe, yearly=yearly, eq_ts=eq_ts, eq_v=eq_v,
|
||||||
|
pos_years=sum(1 for v in yearly.values() if v > 0), n_years=len(yearly))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 90)
|
||||||
|
print(" TSM01 — TSMOM multi-orizzonte (3/6/12m consenso pieno) + risk-off SMA100")
|
||||||
|
print("=" * 90)
|
||||||
|
# no-look-ahead: cheat deve esplodere
|
||||||
|
base = tsmom_sim()
|
||||||
|
ch = tsmom_sim(cheat=True)
|
||||||
|
print(f" no-look-ahead: onesto FULL={base['ret']:+.0f}% vs cheat(futuro)={ch['ret']:+.0f}% -> "
|
||||||
|
f"{'OK (il cheat esplode -> niente leak)' if ch['ret'] > base['ret'] * 2 else 'CONTROLLARE'}")
|
||||||
|
o = tsmom_sim(oos_frac=1 - OOS_FRAC)
|
||||||
|
hi = tsmom_sim(fee_rt=0.002)
|
||||||
|
print(f"\n FULL {base['ret']:+.0f}% CAGR {base['cagr']:.0f}% DD {base['dd']:.0f}% "
|
||||||
|
f"Sharpe {base['sharpe']:.2f} anni+ {base['pos_years']}/{base['n_years']}")
|
||||||
|
print(f" OOS {o['ret']:+.0f}% DD {o['dd']:.0f}% | fee 0.40% RT: FULL {hi['ret']:+.0f}%")
|
||||||
|
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(base["yearly"].items())))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Valida il PairsWorker: replay bar-per-bar sui dati storici == backtest pairs_sim?
|
||||||
|
|
||||||
|
Come validate_worker_mr01 per MR01: alimenta il PairsWorker con finestre trailing
|
||||||
|
crescenti (simula il feed live) e confronta trade/capitale finale col backtest di
|
||||||
|
riferimento scripts/analysis/pairs_research.pairs_sim. Se combaciano, la semantica
|
||||||
|
live (z-score causale, exit |z|<=z_exit o max_bars, fee 2 gambe) e' fedele.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.live.pairs_worker import PairsWorker
|
||||||
|
from scripts.analysis.pairs_research import aligned, pairs_sim
|
||||||
|
from scripts.strategies.PR01_pairs_reversion import PAIRS
|
||||||
|
|
||||||
|
WINDOW = 60 # finestra trailing minima (>= n+2): z[i] corretto, replay veloce
|
||||||
|
|
||||||
|
|
||||||
|
def replay(a: str, b: str, params: dict, data_dir: Path) -> PairsWorker:
|
||||||
|
m = aligned(a, b)
|
||||||
|
df_a = m[["timestamp"]].copy(); df_a["close"] = m["close_a"].values
|
||||||
|
df_b = m[["timestamp"]].copy(); df_b["close"] = m["close_b"].values
|
||||||
|
w = PairsWorker(a, b, "1h", params=params, fee_rt=0.001, data_dir=data_dir)
|
||||||
|
# replay veloce: niente I/O su file / log / notifiche ad ogni tick (servono solo le metriche finali)
|
||||||
|
w._save_state = lambda: None
|
||||||
|
w._log = lambda *a, **k: None
|
||||||
|
w._notify = lambda *a, **k: None
|
||||||
|
n = w.n
|
||||||
|
for k in range(n + 2, len(m) + 1):
|
||||||
|
lo = max(0, k - WINDOW)
|
||||||
|
w.tick(df_a.iloc[lo:k], df_b.iloc[lo:k])
|
||||||
|
# chiudi eventuale posizione aperta a fine serie (come fa il backtest col troncamento)
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 96)
|
||||||
|
print(" VALIDAZIONE PairsWorker — replay live vs backtest pairs_sim (fee 0.20% RT/coppia)")
|
||||||
|
print("=" * 96)
|
||||||
|
print(f" {'coppia':<10s}{'WORKER cap':>12s}{'trd':>5s}{'win%':>6s} | {'BACKTEST cap':>13s}{'trd':>5s}{'win%':>6s} match?")
|
||||||
|
print(" " + "-" * 88)
|
||||||
|
# Sottoinsieme rappresentativo: il codice del worker e' identico per ogni coppia,
|
||||||
|
# quindi 2 coppie con strutture diverse (alt/major e major/alt) bastano a provare
|
||||||
|
# l'equivalenza. ~135s/coppia su 73k barre orarie. Per validarle tutte: usa PAIRS.
|
||||||
|
subset = [pp for pp in PAIRS if (pp[0], pp[1]) in {("ETH", "BTC"), ("BTC", "LTC")}]
|
||||||
|
tmp = Path(tempfile.mkdtemp(prefix="pairs_validate_"))
|
||||||
|
try:
|
||||||
|
for a, b, p in subset:
|
||||||
|
w = replay(a, b, p, tmp)
|
||||||
|
bt = pairs_sim(a, b, **p)
|
||||||
|
bt_cap = 1000.0 * (1 + bt["ret"] / 100)
|
||||||
|
cap_match = abs(w.capital - bt_cap) / bt_cap < 0.02 if bt_cap else False
|
||||||
|
trd_match = abs(w.total_trades - bt["trades"]) <= max(2, bt["trades"] * 0.02)
|
||||||
|
ok = "OK" if (cap_match and trd_match) else "DIFF"
|
||||||
|
ww = w.total_wins / w.total_trades * 100 if w.total_trades else 0
|
||||||
|
print(f" {a+'/'+b:<10s}{w.capital:>12.0f}{w.total_trades:>5d}{ww:>6.1f} | "
|
||||||
|
f"{bt_cap:>13.0f}{bt['trades']:>5d}{bt['win']:>6.1f} {ok}")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
print(" " + "-" * 88)
|
||||||
|
print(" match = capitale entro 2% e trade entro 2% del backtest. Differenze minime sono")
|
||||||
|
print(" attese (gestione bar finale/troncamento), ma la semantica deve coincidere.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
"""Report accuracy per ANNO × MERCATO delle strategie migliori.
|
|
||||||
|
|
||||||
Esegue ogni strategia vincente su BTC e ETH e produce tabella
|
|
||||||
accuracy/trades per ogni anno. Permette di vedere robustezza temporale
|
|
||||||
e differenze tra mercati.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, ".")
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
STRATEGIES_DIR = Path("scripts/strategies")
|
|
||||||
|
|
||||||
|
|
||||||
def load_class(module_file, class_name):
|
|
||||||
path = STRATEGIES_DIR / f"{module_file}.py"
|
|
||||||
spec = importlib.util.spec_from_file_location(module_file, path)
|
|
||||||
mod = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(mod)
|
|
||||||
return getattr(mod, class_name)
|
|
||||||
|
|
||||||
|
|
||||||
# (label, module, class, params, hold)
|
|
||||||
STRATEGIES = [
|
|
||||||
("SQ02 antifake+vol", "SQ02_squeeze_antifake_vol", "SqueezeAntifakeVol", {}, 3),
|
|
||||||
("MT01 ema20+vol", "MT01_squeeze_mtf_momentum", "SqueezeMTFMomentum",
|
|
||||||
{"ema_period": 20, "min_slope": 0.001, "vol_filter": True}, 3),
|
|
||||||
("PD01 vtb3 vm1.3", "PD01_price_volume_divergence", "PriceVolumeDivergence",
|
|
||||||
{}, 3),
|
|
||||||
("CM01 cb6+vol", "CM01_cross_market_momentum", "CrossMarketMomentum",
|
|
||||||
{"cross_bars": 6, "mom_min": 0.001, "use_vol": True}, 3),
|
|
||||||
("AD01 lt.65 ht.95", "AD01_adaptive_squeeze", "AdaptiveSqueeze",
|
|
||||||
{"low_thr": 0.65, "high_thr": 0.95, "use_vol": True}, 3),
|
|
||||||
]
|
|
||||||
|
|
||||||
ASSETS = ["BTC", "ETH"]
|
|
||||||
TF = "15m"
|
|
||||||
ALL_YEARS = list(range(2018, 2027))
|
|
||||||
|
|
||||||
|
|
||||||
def run():
|
|
||||||
results = {} # (label, asset) -> BacktestResult
|
|
||||||
|
|
||||||
for label, module, cls_name, params, hold in STRATEGIES:
|
|
||||||
try:
|
|
||||||
cls = load_class(module, cls_name)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"SKIP {label}: {e}")
|
|
||||||
continue
|
|
||||||
strat = cls()
|
|
||||||
for asset in ASSETS:
|
|
||||||
try:
|
|
||||||
r = strat.backtest(asset, TF, hold=hold, **params)
|
|
||||||
if r:
|
|
||||||
results[(label, asset)] = r
|
|
||||||
except Exception as e:
|
|
||||||
print(f" errore {label} {asset}: {e}")
|
|
||||||
|
|
||||||
# ── Tabella ACCURACY per anno × mercato ──────────────────────────
|
|
||||||
print(f"\n{'=' * 140}")
|
|
||||||
print(f" ACCURACY PER ANNO × MERCATO — {TF} (fee 0.2% RT, leva 3x, pos 15%)")
|
|
||||||
print(f"{'=' * 140}")
|
|
||||||
|
|
||||||
header = f" {'Strategia':<22s} {'Mkt':>3s}"
|
|
||||||
for y in ALL_YEARS:
|
|
||||||
header += f" {y:>7d}"
|
|
||||||
header += f" │ {'TOT':>6s} {'DD%':>5s} {'Worst':>10s}"
|
|
||||||
print(header)
|
|
||||||
print(f" {'─' * 136}")
|
|
||||||
|
|
||||||
for label, module, cls_name, params, hold in STRATEGIES:
|
|
||||||
for asset in ASSETS:
|
|
||||||
r = results.get((label, asset))
|
|
||||||
if not r:
|
|
||||||
continue
|
|
||||||
yd = {ys.year: ys for ys in r.yearly}
|
|
||||||
line = f" {label:<22s} {asset:>3s}"
|
|
||||||
for y in ALL_YEARS:
|
|
||||||
if y in yd:
|
|
||||||
line += f" {yd[y].accuracy:>5.0f}%↑" if yd[y].accuracy >= 80 else f" {yd[y].accuracy:>5.0f}% "
|
|
||||||
else:
|
|
||||||
line += f" {'—':>7s}"
|
|
||||||
worst = r.worst_year
|
|
||||||
worst_str = f"{worst.year}({worst.accuracy:.0f}%)" if worst else "N/A"
|
|
||||||
line += f" │ {r.accuracy:>5.1f}% {r.max_dd:>4.1f}% {worst_str:>10s}"
|
|
||||||
print(line)
|
|
||||||
print(f" {'·' * 136}")
|
|
||||||
|
|
||||||
# ── Tabella TRADES per anno × mercato ────────────────────────────
|
|
||||||
print(f"\n{'=' * 140}")
|
|
||||||
print(f" NUMERO TRADES PER ANNO × MERCATO")
|
|
||||||
print(f"{'=' * 140}")
|
|
||||||
|
|
||||||
header = f" {'Strategia':<22s} {'Mkt':>3s}"
|
|
||||||
for y in ALL_YEARS:
|
|
||||||
header += f" {y:>7d}"
|
|
||||||
header += f" │ {'TOT':>6s} {'€/day':>6s}"
|
|
||||||
print(header)
|
|
||||||
print(f" {'─' * 130}")
|
|
||||||
|
|
||||||
for label, module, cls_name, params, hold in STRATEGIES:
|
|
||||||
for asset in ASSETS:
|
|
||||||
r = results.get((label, asset))
|
|
||||||
if not r:
|
|
||||||
continue
|
|
||||||
yd = {ys.year: ys for ys in r.yearly}
|
|
||||||
line = f" {label:<22s} {asset:>3s}"
|
|
||||||
for y in ALL_YEARS:
|
|
||||||
if y in yd:
|
|
||||||
line += f" {yd[y].trades:>7d}"
|
|
||||||
else:
|
|
||||||
line += f" {'—':>7s}"
|
|
||||||
line += f" │ {r.trades:>6d} {r.daily_pnl:>+6.2f}"
|
|
||||||
print(line)
|
|
||||||
print(f" {'·' * 130}")
|
|
||||||
|
|
||||||
# ── Tabella PnL per anno × mercato ──────────────────────────────
|
|
||||||
print(f"\n{'=' * 140}")
|
|
||||||
print(f" PnL € PER ANNO × MERCATO (su €1000, no compounding tra anni)")
|
|
||||||
print(f"{'=' * 140}")
|
|
||||||
|
|
||||||
header = f" {'Strategia':<22s} {'Mkt':>3s}"
|
|
||||||
for y in ALL_YEARS:
|
|
||||||
header += f" {y:>7d}"
|
|
||||||
header += f" │ {'TOT€':>8s}"
|
|
||||||
print(header)
|
|
||||||
print(f" {'─' * 132}")
|
|
||||||
|
|
||||||
for label, module, cls_name, params, hold in STRATEGIES:
|
|
||||||
for asset in ASSETS:
|
|
||||||
r = results.get((label, asset))
|
|
||||||
if not r:
|
|
||||||
continue
|
|
||||||
yd = {ys.year: ys for ys in r.yearly}
|
|
||||||
line = f" {label:<22s} {asset:>3s}"
|
|
||||||
for y in ALL_YEARS:
|
|
||||||
if y in yd:
|
|
||||||
line += f" {yd[y].pnl:>+7.0f}"
|
|
||||||
else:
|
|
||||||
line += f" {'—':>7s}"
|
|
||||||
line += f" │ {r.pnl:>+8.0f}"
|
|
||||||
print(line)
|
|
||||||
print(f" {'·' * 132}")
|
|
||||||
|
|
||||||
# ── Sintesi: media per anno (tutte le strategie) ────────────────
|
|
||||||
print(f"\n{'=' * 140}")
|
|
||||||
print(f" SINTESI — Accuracy media per anno (tutte le strategie, BTC+ETH)")
|
|
||||||
print(f"{'=' * 140}")
|
|
||||||
year_acc = {y: [] for y in ALL_YEARS}
|
|
||||||
for (label, asset), r in results.items():
|
|
||||||
for ys in r.yearly:
|
|
||||||
if ys.trades >= 10:
|
|
||||||
year_acc[ys.year].append(ys.accuracy)
|
|
||||||
|
|
||||||
line_y = f" {'Anno':<22s} "
|
|
||||||
line_a = f" {'Acc media':<22s} "
|
|
||||||
for y in ALL_YEARS:
|
|
||||||
accs = year_acc[y]
|
|
||||||
avg = sum(accs) / len(accs) if accs else 0
|
|
||||||
line_y += f" {y:>7d}"
|
|
||||||
line_a += f" {avg:>6.1f}%"
|
|
||||||
print(line_y)
|
|
||||||
print(line_a)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
run()
|
|
||||||
@@ -57,16 +57,21 @@ class BollingerFade(Strategy):
|
|||||||
k = params.get("k", 2.5)
|
k = params.get("k", 2.5)
|
||||||
sl_atr = params.get("sl_atr", 2.0)
|
sl_atr = params.get("sl_atr", 2.0)
|
||||||
max_bars = params.get("max_bars", 24)
|
max_bars = params.get("max_bars", 24)
|
||||||
|
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||||
|
ema_long = params.get("ema_long", 200)
|
||||||
|
|
||||||
ma = pd.Series(c).rolling(bb_w).mean().values
|
ma = pd.Series(c).rolling(bb_w).mean().values
|
||||||
sd = pd.Series(c).rolling(bb_w).std().values
|
sd = pd.Series(c).rolling(bb_w).std().values
|
||||||
a = _atr(df, 14)
|
a = _atr(df, 14)
|
||||||
up, lo = ma + k * sd, ma - k * sd
|
up, lo = ma + k * sd, ma - k * sd
|
||||||
|
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values if trend_max is not None else None
|
||||||
|
|
||||||
signals: list[Signal] = []
|
signals: list[Signal] = []
|
||||||
for i in range(bb_w + 14, n_len):
|
for i in range(bb_w + 14, n_len):
|
||||||
if np.isnan(up[i]) or np.isnan(a[i]):
|
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||||
continue
|
continue
|
||||||
|
if el is not None and (a[i] == 0 or np.isnan(el[i]) or abs(c[i] - el[i]) / a[i] > trend_max):
|
||||||
|
continue
|
||||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||||
d, sl = 1, c[i] - sl_atr * a[i]
|
d, sl = 1, c[i] - sl_atr * a[i]
|
||||||
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""MR02 — Donchian Fade (mean-reversion sugli estremi del canale).
|
||||||
|
|
||||||
|
L'opposto esatto del trend-following Donchian (che PERDE netto: vedi
|
||||||
|
scripts/analysis/strategy_research.py). Coerente con la lezione squeeze:
|
||||||
|
i breakout RIENTRANO, quindi si fada la rottura del canale verso il centro.
|
||||||
|
|
||||||
|
Logica:
|
||||||
|
1. Canale Donchian: massimo/minimo delle ultime n barre (escludendo la corrente)
|
||||||
|
2. ENTRY: close rompe SOPRA il massimo del canale -> SHORT (fade);
|
||||||
|
close rompe SOTTO il minimo -> LONG. Ingresso a close[i] (eseguibile).
|
||||||
|
3. EXIT: take-profit al centro del canale (il rientro atteso),
|
||||||
|
stop-loss a sl_atr*ATR oltre l'estremo, time-limit max_bars.
|
||||||
|
|
||||||
|
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
|
||||||
|
BTC 1h n=20: +879% FULL / +171% OOS, DD 30%, 8/9 anni positivi
|
||||||
|
ETH 1h n=20: enorme FULL / +8452% OOS, DD 42%
|
||||||
|
Robusto su TUTTA la griglia n in {10,20,30,50} x sl_atr in {1.5,2.0,3.0}
|
||||||
|
(BTC+ETH 1h sempre positivo OOS) e su tutte le fee 0.00-0.20% RT.
|
||||||
|
Ricerca completa: scripts/analysis/strategy_research_v2.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.strategies.base import Signal
|
||||||
|
from src.strategies.fade_base import FadeStrategy, atr, trend_distance
|
||||||
|
|
||||||
|
|
||||||
|
class DonchianFade(FadeStrategy):
|
||||||
|
name = "MR02_donchian_fade"
|
||||||
|
description = "Mean-reversion: fada la rottura del canale Donchian, TP al centro"
|
||||||
|
default_assets = ["BTC", "ETH"]
|
||||||
|
default_timeframes = ["1h"]
|
||||||
|
|
||||||
|
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||||
|
**params) -> list[Signal]:
|
||||||
|
n = params.get("n", 20)
|
||||||
|
sl_atr = params.get("sl_atr", 2.0)
|
||||||
|
max_bars = params.get("max_bars", 24)
|
||||||
|
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||||
|
ema_long = params.get("ema_long", 200)
|
||||||
|
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
hh = pd.Series(h).rolling(n).max().shift(1).values
|
||||||
|
ll = pd.Series(l).rolling(n).min().shift(1).values
|
||||||
|
a = atr(df, 14)
|
||||||
|
td = trend_distance(df, ema_long) if trend_max is not None else None
|
||||||
|
|
||||||
|
signals: list[Signal] = []
|
||||||
|
for i in range(n + 14, len(c)):
|
||||||
|
if np.isnan(hh[i]) or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
||||||
|
continue
|
||||||
|
mid = (hh[i] + ll[i]) / 2.0
|
||||||
|
if c[i] > hh[i] and c[i - 1] <= hh[i - 1]: # rottura rialzista -> fade short
|
||||||
|
d, sl = -1, c[i] + sl_atr * a[i]
|
||||||
|
elif c[i] < ll[i] and c[i - 1] >= ll[i - 1]: # rottura ribassista -> fade long
|
||||||
|
d, sl = 1, c[i] - sl_atr * a[i]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
signals.append(Signal(
|
||||||
|
idx=i, direction=d, entry_price=c[i],
|
||||||
|
metadata={"tp": float(mid), "sl": float(sl), "max_bars": max_bars},
|
||||||
|
))
|
||||||
|
return signals
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
strat = DonchianFade()
|
||||||
|
print("=" * 110)
|
||||||
|
print(f" MR02 DONCHIAN FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||||
|
print("=" * 110)
|
||||||
|
for asset in ["BTC", "ETH"]:
|
||||||
|
r = strat.backtest(asset, "1h", n=20, sl_atr=2.0, max_bars=24)
|
||||||
|
if r:
|
||||||
|
r.strategy_name = f"MR02 {asset} 1h n20"
|
||||||
|
r.print_summary()
|
||||||
|
r.print_yearly()
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""MR07 — Return Reversal (fade del movimento di barra estremo).
|
||||||
|
|
||||||
|
Meccanismo distinto da MR01/MR02/MR03: non guarda i LIVELLI di prezzo (bande,
|
||||||
|
canali) ma la VOLATILITA' dei rendimenti. Quando una singola barra si muove di
|
||||||
|
piu' di k deviazioni standard rolling dei rendimenti, e' un'over-reaction che
|
||||||
|
tende a rientrare: si fada nella direzione opposta. Coerente con la lezione
|
||||||
|
mean-reversion.
|
||||||
|
|
||||||
|
Logica:
|
||||||
|
1. ret[i] = rendimento dell'ultima barra; sigma = std rolling(n) dei rendimenti
|
||||||
|
2. z = ret[i]/sigma. Se z <= -k (crollo) -> LONG; se z >= +k (spike) -> SHORT.
|
||||||
|
Ingresso a close[i] (eseguibile dal vivo, nessun look-ahead).
|
||||||
|
3. EXIT: take-profit a tp_atr*ATR a favore, stop-loss a sl_atr*ATR contro,
|
||||||
|
time-limit max_bars.
|
||||||
|
|
||||||
|
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
|
||||||
|
config robusta k=3.5 tp=2ATR sl=1.5ATR n=50:
|
||||||
|
BTC 1h: +447% FULL / +105% OOS, DD 25%
|
||||||
|
ETH 1h: +335% FULL / +195% OOS, DD 46%
|
||||||
|
L'intero blocco tp_atr=2.0 (k in {2.5,3.0,3.5} x sl in {1.5,2.0,2.5}) e'
|
||||||
|
positivo full+OOS su entrambi gli asset 1h.
|
||||||
|
Ricerca completa: scripts/analysis/strategy_research_v2.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.strategies.base import Signal
|
||||||
|
from src.strategies.fade_base import FadeStrategy, atr, trend_distance
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnReversal(FadeStrategy):
|
||||||
|
name = "MR07_return_reversal"
|
||||||
|
description = "Mean-reversion: fada il movimento di barra estremo (z dei rendimenti)"
|
||||||
|
default_assets = ["BTC", "ETH"]
|
||||||
|
default_timeframes = ["1h"]
|
||||||
|
|
||||||
|
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||||
|
**params) -> list[Signal]:
|
||||||
|
n = params.get("n", 50)
|
||||||
|
k = params.get("k", 3.5)
|
||||||
|
tp_atr = params.get("tp_atr", 2.0)
|
||||||
|
sl_atr = params.get("sl_atr", 1.5)
|
||||||
|
max_bars = params.get("max_bars", 24)
|
||||||
|
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||||
|
ema_long = params.get("ema_long", 200)
|
||||||
|
|
||||||
|
c = df["close"].values
|
||||||
|
ret = np.zeros_like(c)
|
||||||
|
ret[1:] = np.diff(c) / c[:-1]
|
||||||
|
sig = pd.Series(ret).rolling(n).std().values
|
||||||
|
a = atr(df, 14)
|
||||||
|
td = trend_distance(df, ema_long) if trend_max is not None else None
|
||||||
|
|
||||||
|
signals: list[Signal] = []
|
||||||
|
for i in range(n + 14, len(c)):
|
||||||
|
if np.isnan(sig[i]) or sig[i] == 0 or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
||||||
|
continue
|
||||||
|
z = ret[i] / sig[i]
|
||||||
|
if z <= -k: # crollo di barra -> fade long
|
||||||
|
d, tp, sl = 1, c[i] + tp_atr * a[i], c[i] - sl_atr * a[i]
|
||||||
|
elif z >= k: # spike di barra -> fade short
|
||||||
|
d, tp, sl = -1, c[i] - tp_atr * a[i], c[i] + sl_atr * a[i]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
signals.append(Signal(
|
||||||
|
idx=i, direction=d, entry_price=c[i],
|
||||||
|
metadata={"tp": float(tp), "sl": float(sl), "max_bars": max_bars},
|
||||||
|
))
|
||||||
|
return signals
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
strat = ReturnReversal()
|
||||||
|
print("=" * 110)
|
||||||
|
print(f" MR07 RETURN REVERSAL — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||||
|
print("=" * 110)
|
||||||
|
for asset in ["BTC", "ETH"]:
|
||||||
|
r = strat.backtest(asset, "1h", n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)
|
||||||
|
if r:
|
||||||
|
r.strategy_name = f"MR07 {asset} 1h k3.5"
|
||||||
|
r.print_summary()
|
||||||
|
r.print_yearly()
|
||||||
@@ -9,12 +9,12 @@ La diversificazione e' il vero motore di risk-reduction: il DD del portafoglio
|
|||||||
scende SOTTO quello della sleeve meno rischiosa, mantenendo una CAGR alta e
|
scende SOTTO quello della sleeve meno rischiosa, mantenendo una CAGR alta e
|
||||||
azzerando quasi gli anni negativi (il 2022 bear passa da -30% di ROT a -1%).
|
azzerando quasi gli anni negativi (il 2022 bear passa da -30% di ROT a -1%).
|
||||||
|
|
||||||
Risultato (netto, 2021-2026, leva 3x pos 15% per sleeve):
|
Risultato (netto, 2021-2026, leva 3x pos 15% per sleeve; ROT02 ora top_k=3):
|
||||||
DIP01_BTC +322% DD 15% CAGR 31%
|
DIP01_BTC +322% DD 15% CAGR 31%
|
||||||
TR01_basket +591% DD 27% CAGR 43%
|
TR01_basket +591% DD 27% CAGR 43%
|
||||||
ROT02_dualmom +771% DD 40% CAGR 49%
|
ROT02_dualmom +984% DD 26% CAGR 56% (top_k=3: DD 40->26, PnL su)
|
||||||
PORTAFOGLIO +642% DD 12% CAGR 45% <-- DD piu' basso di ogni sleeve
|
PORTAFOGLIO +676% DD 13% CAGR 46% <-- DD piu' basso di ogni sleeve
|
||||||
Per-anno: 2021 +203 · 2022 -1 · 2023 +47 · 2024 +50 · 2025 +14 · 2026 -2
|
Per-anno: 2021 +224 · 2022 +1 · 2023 +48 · 2024 +48 · 2025 +10 · 2026 -2
|
||||||
Logica e ricostruzione: scripts/analysis/honest_improve2.py.
|
Logica e ricostruzione: scripts/analysis/honest_improve2.py.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""PORT02 — Portafoglio FADE accorpato e migliorato (6 sleeve, equal-weight daily).
|
||||||
|
|
||||||
|
Accorpa le 3 strategie fade mean-reversion su BTC e ETH, ciascuna MIGLIORATA con
|
||||||
|
il filtro trend (salta i fade contro trend estremi: |close-EMA200|/ATR > 3.0):
|
||||||
|
MR01 Bollinger fade · MR02 Donchian fade · MR07 Return-reversal
|
||||||
|
x {BTC, ETH} -> 6 sleeve indipendenti, pos 0.15 ciascuna, leva 3x.
|
||||||
|
(MR03 Keltner spostata in waste: fade piu' debole e ridondante con MR01.)
|
||||||
|
|
||||||
|
Le curve sono poco correlate fra loro (corr media intra-fade ~0.18): la
|
||||||
|
diversificazione abbatte il DD aggregato ben sotto quello del singolo sleeve.
|
||||||
|
|
||||||
|
Risultato (netto fee 0.10% RT, equal-weight ribilanciato ogni giorno, finestra
|
||||||
|
comune 2021-2026, OOS = ultimo 30%):
|
||||||
|
Ret +666% / CAGR 46% / DD 8.2% FULL · OOS DD 5.9% / Sharpe 3.95 FULL / 4.09 OOS.
|
||||||
|
Ricostruzione e confronto: scripts/analysis/combine_portfolio.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.combine_portfolio import ( # noqa: E402
|
||||||
|
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
sleeves = {k: v for k, v in build_all_sleeves().items() if k.startswith("MR")}
|
||||||
|
pr = port_returns(sleeves)
|
||||||
|
full, oos = metrics(pr), metrics(pr, lo=SPLIT)
|
||||||
|
|
||||||
|
print("=" * 84)
|
||||||
|
print(f" PORT02 — FADE master (8 sleeve, equal-weight daily) | {IDX[0].date()} -> {IDX[-1].date()}")
|
||||||
|
print("=" * 84)
|
||||||
|
print(f" {'sleeve':<14s}{'Ret%':>9s}{'DD%':>7s}{'Shrp':>7s}")
|
||||||
|
for name, s in sleeves.items():
|
||||||
|
m = metrics(s.pct_change().fillna(0.0))
|
||||||
|
print(f" {name:<14s}{m['ret']:>+9.0f}{m['dd']:>7.1f}{m['sharpe']:>7.2f}")
|
||||||
|
print(" " + "-" * 80)
|
||||||
|
print(f" {'PORTAFOGLIO':<14s}{full['ret']:>+9.0f}{full['dd']:>7.1f}{full['sharpe']:>7.2f}"
|
||||||
|
f" CAGR {full['cagr']:.0f}%")
|
||||||
|
print(f" {' di cui OOS':<14s}{oos['ret']:>+9.0f}{oos['dd']:>7.1f}{oos['sharpe']:>7.2f}"
|
||||||
|
f" (da {OOS_DATE})")
|
||||||
|
pa = yearly_returns(pr)
|
||||||
|
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""PORT03 — Portafoglio MASTER: accorpa TUTTE le strategie (fade + honest).
|
||||||
|
|
||||||
|
Unisce le due famiglie del progetto, quasi scorrelate (correlazione cross-famiglia
|
||||||
|
~0.05), in un unico portafoglio diversificato:
|
||||||
|
FADE (reversione intraday 1h, long/short, BTC/ETH, col filtro trend):
|
||||||
|
MR01 Bollinger · MR02 Donchian · MR07 Return-reversal -> 6 sleeve
|
||||||
|
HONEST (long-only multi-regime multi-crypto):
|
||||||
|
DIP01 dip-buy (1h) · TR01 EMA-trend (4h basket) · ROT02 dual-momentum (1d) -> 3 sleeve
|
||||||
|
|
||||||
|
Combinare le due famiglie migliora il rischio/rendimento rispetto a ciascuna da
|
||||||
|
sola: il DD scende e lo Sharpe sale (la honest, da sola piu' lumpy, viene
|
||||||
|
stabilizzata dalle fade ad alta frequenza). Vedi scripts/analysis/combine_portfolio.py.
|
||||||
|
|
||||||
|
Risultato (netto, equal-weight daily, finestra comune 2021-2026, OOS = ultimo 30%;
|
||||||
|
ROT02 ora top_k=3 -> DD piu' basso):
|
||||||
|
FADE only (6) DD 8.2% Sharpe 3.95 (OOS DD 5.9 / Shrp 4.09)
|
||||||
|
HONEST only (3) DD 12.6% Sharpe 2.20 (OOS DD 7.8 / Shrp 2.02)
|
||||||
|
MASTER eq (9) DD 5.2% Sharpe 4.23 (OOS DD 4.7 / Shrp 4.33) <- miglior Sharpe
|
||||||
|
MASTER 50/50 DD 5.0% Sharpe 3.69 (OOS DD 4.5) <- miglior DD
|
||||||
|
CAGR ~47% mantenuta in entrambe le varianti combinate.
|
||||||
|
|
||||||
|
Due varianti operative selezionabili:
|
||||||
|
weighting="equal" -> equal-weight sui 9 sleeve (massimo Sharpe)
|
||||||
|
weighting="5050" -> 50% famiglia fade + 50% famiglia honest (minimo DD)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.combine_portfolio import ( # noqa: E402
|
||||||
|
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def master_returns(sleeves: dict, weighting: str = "equal"):
|
||||||
|
"""Rendimenti giornalieri del portafoglio master.
|
||||||
|
equal = equal-weight su tutti gli 11 sleeve; 5050 = media fra le due famiglie."""
|
||||||
|
fade = {k: v for k, v in sleeves.items() if k.startswith("MR")}
|
||||||
|
honest = {k: v for k, v in sleeves.items() if not k.startswith("MR")}
|
||||||
|
if weighting == "5050":
|
||||||
|
return (port_returns(fade) + port_returns(honest)) / 2
|
||||||
|
return port_returns(sleeves)
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
sleeves = build_all_sleeves()
|
||||||
|
fade = {k: v for k, v in sleeves.items() if k.startswith("MR")}
|
||||||
|
honest = {k: v for k, v in sleeves.items() if not k.startswith("MR")}
|
||||||
|
|
||||||
|
print("=" * 90)
|
||||||
|
print(f" PORT03 — MASTER (fade + honest) | {IDX[0].date()} -> {IDX[-1].date()} | OOS da {OOS_DATE}")
|
||||||
|
print("=" * 90)
|
||||||
|
print(f" {'portafoglio':<22s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
|
||||||
|
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
|
||||||
|
print(" " + "-" * 86)
|
||||||
|
|
||||||
|
def line(label, pr):
|
||||||
|
f, o = metrics(pr), metrics(pr, lo=SPLIT)
|
||||||
|
print(f" {label:<22s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
|
||||||
|
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||||
|
|
||||||
|
line(f"FADE only ({len(fade)})", port_returns(fade))
|
||||||
|
line(f"HONEST only ({len(honest)})", port_returns(honest))
|
||||||
|
line(f"MASTER equal ({len(sleeves)})", master_returns(sleeves, "equal"))
|
||||||
|
line("MASTER 50/50 fam", master_returns(sleeves, "5050"))
|
||||||
|
|
||||||
|
print(" " + "-" * 86)
|
||||||
|
pa = yearly_returns(master_returns(sleeves, "equal"))
|
||||||
|
print(" MASTER equal per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
|
||||||
|
print(" -> combinare le due famiglie scorrelate (~0.05) abbassa il DD e alza lo Sharpe.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""PR01 — Pairs / Spread Mean-Reversion fra cripto (market-neutral). FAMIGLIA NUOVA.
|
||||||
|
|
||||||
|
Distinta da tutto l'esistente (single-asset direzionale): scommette sul RIENTRO del
|
||||||
|
log-ratio di due cripto verso la sua media. Market-neutral (long A / short B) ->
|
||||||
|
correlazione ~0.02 col mercato -> diversificatore prezioso.
|
||||||
|
|
||||||
|
Logica (engine onesto verificato in scripts/analysis/pairs_research.py):
|
||||||
|
r[i] = log(closeA[i]/closeB[i]); z[i] = (r[i]-SMA_n(r)[i]) / STD_n(r)[i] (causale)
|
||||||
|
z <= -z_in -> LONG ratio (long A / short B)
|
||||||
|
z >= +z_in -> SHORT ratio (short A / long B)
|
||||||
|
EXIT: |z| <= z_exit (rientro) o time-limit max_bars. Ingresso/uscita a close.
|
||||||
|
Fee su 2 GAMBE = 2*fee_rt*lev (0.20% RT/coppia). Filtro candele sporche (salto>8%).
|
||||||
|
|
||||||
|
Validazione anti-overfit (netto, fee 0.20% RT/coppia a 2 gambe, leva 3x, OOS = ultimo
|
||||||
|
30%, CONFIG UNIVERSALE n=50 z_in=2.0 z_exit=0.75 max_bars=72, 1h):
|
||||||
|
ETH/BTC : CAGR 158% / Sharpe 4.36 / 8/9 anni+ (regge fee 6x)
|
||||||
|
LTC/ETH : CAGR 92% / Sharpe 3.08 / 8/8 anni+
|
||||||
|
ADA/ETH : CAGR 91% / Sharpe 2.69 / 7/8 anni+
|
||||||
|
BTC/LTC : CAGR 60% / Sharpe 2.36 / 7/8 anni+ (robusta anche a 4h)
|
||||||
|
ETH/SOL : CAGR 74% / Sharpe 1.96 / 5/7 anni+ (la piu' debole, DD ~63%)
|
||||||
|
- No look-ahead verificato (z[i] invariato perturbando il futuro).
|
||||||
|
- PLATEAU non picco: heatmap n x z_in -> 20/20 celle Sharpe>1 (ETH/BTC, BTC/LTC).
|
||||||
|
- WALK-FORWARD (rolling train 2y / test 6m): ETH/BTC 11/12 finestre positive,
|
||||||
|
BTC/LTC 9/10 -> edge distribuito su tutta la storia, non un regime singolo.
|
||||||
|
- Stress costi: 5/6 reggono fee+slippage realistici; solo ETH/BTC regge fee 6x.
|
||||||
|
- Correlazione con BTC daily ~0.02-0.08 -> market-neutral.
|
||||||
|
- SCARTATA BNB/ETH: robusta solo coi suoi parametri (overfit), crolla con la universale.
|
||||||
|
|
||||||
|
WORKER LIVE: implementato `src/live/pairs_worker.py` (2 gambe, fee doppie, stato
|
||||||
|
persistente) e wired in `multi_runner` (sezione `pairs:` in strategies.yml). Validato:
|
||||||
|
- LOGICA: `validate_worker_pairs.py` -> replay storico == backtest pairs_sim ESATTO
|
||||||
|
(ETH/BTC: capitale, n.trade, win% identici).
|
||||||
|
- LIVE: `live_smoke_pairs.py` (smoke reale Cerbero) -> tutte e 5 le coppie con feed
|
||||||
|
live fresco. Naming Deribit corretto: BTC/ETH = "<COIN>-PERPETUAL" (inverse),
|
||||||
|
alt = "<COIN>_USDC-PERPETUAL" (lineari USDC, storia dal 2022). Trappola: usare
|
||||||
|
"LTC-PERPETUAL"/"SOL-PERPETUAL" da' vuoto/dati sbagliati -> SEMPRE _USDC-PERPETUAL.
|
||||||
|
Resta da verificare in trading reale solo la liquidita'/fill in esecuzione.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.pairs_research import pairs_sim, OOS_FRAC # noqa: E402
|
||||||
|
|
||||||
|
# CONFIG UNIVERSALE (anti-overfit): un'unica terna per TUTTE le coppie, niente
|
||||||
|
# cherry-picking per-coppia. Validata come ALTOPIANO (heatmap n x z_in: 20/20 celle
|
||||||
|
# Sharpe>1 su ETH/BTC e BTC/LTC) e walk-forward (ETH/BTC 11/12 finestre+, BTC/LTC 9/10).
|
||||||
|
UNIV = dict(n=50, z_in=2.0, z_exit=0.75, max_bars=72)
|
||||||
|
|
||||||
|
# Coppie robuste con la config universale. Sempre alt-liquido vs major (mai alt/alt).
|
||||||
|
# BNB/ETH SCARTATA: era robusta solo coi suoi parametri (n=30, z_exit=1.0) -> overfit;
|
||||||
|
# con la config universale crolla (Sharpe 1.5, DD 71%) ed e' la prima a morire allo stress costi.
|
||||||
|
PAIRS = [
|
||||||
|
("ETH", "BTC", UNIV), # la migliore (Sharpe 4.4 univ), regge fee 6x
|
||||||
|
("LTC", "ETH", UNIV),
|
||||||
|
("ADA", "ETH", UNIV),
|
||||||
|
("BTC", "LTC", UNIV), # robusta anche a 4h
|
||||||
|
("ETH", "SOL", UNIV), # piu' debole (DD ~63%, storia SOL corta) -> peso ridotto
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
print("=" * 92)
|
||||||
|
print(" PR01 — PAIRS spread reversion (market-neutral) | netto fee 0.20% RT/coppia, leva 3x")
|
||||||
|
print("=" * 92)
|
||||||
|
print(f" {'coppia':<10s}{'trd':>5s}{'win%':>6s}{'CAGR%':>7s}{'OOS DD%':>8s}{'DD%':>6s}{'Shrp':>6s}{'anni+':>7s}")
|
||||||
|
for a, b, p in PAIRS:
|
||||||
|
f = pairs_sim(a, b, **p)
|
||||||
|
o = pairs_sim(a, b, **p, split_frac=1 - OOS_FRAC)
|
||||||
|
yrs = f["yearly"]; pos_y = sum(1 for v in yrs.values() if v > 0)
|
||||||
|
print(f" {a+'/'+b:<10s}{f['trades']:>5d}{f['win']:>6.1f}{f['cagr']:>7.0f}{o['dd']:>8.0f}"
|
||||||
|
f"{f['dd']:>6.0f}{f['sharpe']:>6.2f}{f'{pos_y}/{len(yrs)}':>7s}")
|
||||||
|
print("\n Market-neutral (corr ~0.02-0.08 col mercato) -> ottimo diversificatore di portafoglio.")
|
||||||
|
print(" Pattern: solo alt-liquido vs major (BTC/ETH); alt-vs-alt e' rumore.")
|
||||||
|
print(" NB: 2 gambe (long A / short B), fee doppie. Worker live da estendere prima del live.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -7,10 +7,16 @@ sistema (2022, 2026 YTD) che erano gli unici anni rossi di ROT01.
|
|||||||
|
|
||||||
Risultato (netto, fee 0.10% RT, gross 0.45, OOS = ultimo 30%): MIGLIORA TUTTO
|
Risultato (netto, fee 0.10% RT, gross 0.45, OOS = ultimo 30%): MIGLIORA TUTTO
|
||||||
rispetto a ROT01.
|
rispetto a ROT01.
|
||||||
ROT01 base : FULL +679% / OOS +44% / DD 53%
|
ROT01 base : FULL +679% / OOS +44% / DD 53%
|
||||||
ROT02 SMA100 : FULL +1095% / OOS +98% / DD 40% <-- PnL su, DD giu'
|
ROT02 k2 SMA100 : FULL +1095% / OOS +98% / DD 40% (versione iniziale)
|
||||||
Param-insensitive sulla finestra di regime (SMA100-150). Dettagli in
|
ROT02 k3 SMA100 : FULL +1303% / OOS +68% / DD 26% <-- DD quasi dimezzato, PnL su
|
||||||
scripts/analysis/honest_improve.py (rot_improved).
|
|
||||||
|
MIGLIORIA DD (top_k 2 -> 3): la versione iniziale concentrava il book su 2 soli
|
||||||
|
asset; diversificare su 3 dimezza quasi il drawdown (40% -> 26%) e ALZA pure il
|
||||||
|
ritorno full (+1095 -> +1303), ret/DD da 27 a 50. Il vol-target abbassa il DD ma
|
||||||
|
sacrifica ritorno (solo de-leverage), quindi si tiene top_k=3 senza vol-target.
|
||||||
|
Param-insensitive: top_k 3-4 e regime SMA100-150 danno risultati simili.
|
||||||
|
Dettagli e sweep in scripts/analysis/honest_improve.py (rot_improved).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -22,7 +28,7 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
|||||||
|
|
||||||
from scripts.analysis.honest_improve import rot_improved # noqa: E402
|
from scripts.analysis.honest_improve import rot_improved # noqa: E402
|
||||||
|
|
||||||
LOOKBACK, TOP_K, REGIME_N = 60, 2, 100
|
LOOKBACK, TOP_K, REGIME_N = 60, 3, 100 # top_k=3 (era 2): dimezza il DD
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""MR03 — Keltner Fade (mean-reversion sul canale ATR).
|
||||||
|
|
||||||
|
Stessa tesi di MR01 (i breakout rientrano) ma con banda costruita su ATR
|
||||||
|
attorno a una EMA, invece che su deviazione standard attorno a una SMA.
|
||||||
|
Reagisce diversamente a gap e code: edge indipendente, non ridondante con MR01.
|
||||||
|
|
||||||
|
Logica:
|
||||||
|
1. Canale di Keltner: EMA(n) +/- k*ATR(n)
|
||||||
|
2. ENTRY: close esce sotto la banda inferiore -> LONG (o sopra la superiore -> SHORT)
|
||||||
|
Ingresso a close[i] (eseguibile dal vivo, nessun look-ahead).
|
||||||
|
3. EXIT: take-profit alla EMA centrale (il rientro atteso),
|
||||||
|
stop-loss a sl_atr*ATR oltre l'estremo, time-limit max_bars.
|
||||||
|
|
||||||
|
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
|
||||||
|
BTC 1h n=30 k=2.0: +112% OOS, DD 20%
|
||||||
|
ETH 1h n=50 k=1.5: +1426% OOS, DD 20%
|
||||||
|
Robusto su TUTTA la griglia n in {14,20,30,50} x k in {1.5,2.0,2.5}
|
||||||
|
(BTC+ETH 1h sempre positivo OOS).
|
||||||
|
Ricerca completa: scripts/analysis/strategy_research_v2.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.strategies.base import Signal
|
||||||
|
from src.strategies.fade_base import FadeStrategy, atr, trend_distance
|
||||||
|
|
||||||
|
|
||||||
|
class KeltnerFade(FadeStrategy):
|
||||||
|
name = "MR03_keltner_fade"
|
||||||
|
description = "Mean-reversion: fada il canale di Keltner (ATR), TP alla EMA"
|
||||||
|
default_assets = ["BTC", "ETH"]
|
||||||
|
default_timeframes = ["1h"]
|
||||||
|
|
||||||
|
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||||
|
**params) -> list[Signal]:
|
||||||
|
n = params.get("n", 30)
|
||||||
|
k = params.get("k", 2.0)
|
||||||
|
sl_atr = params.get("sl_atr", 2.0)
|
||||||
|
max_bars = params.get("max_bars", 24)
|
||||||
|
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||||
|
ema_long = params.get("ema_long", 200)
|
||||||
|
|
||||||
|
c = df["close"].values
|
||||||
|
e = pd.Series(c).ewm(span=n, adjust=False).mean().values
|
||||||
|
a = atr(df, n)
|
||||||
|
up, lo = e + k * a, e - k * a
|
||||||
|
td = trend_distance(df, ema_long) if trend_max is not None else None
|
||||||
|
|
||||||
|
signals: list[Signal] = []
|
||||||
|
for i in range(n + 1, len(c)):
|
||||||
|
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||||
|
continue
|
||||||
|
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
||||||
|
continue
|
||||||
|
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||||
|
d, sl = 1, c[i] - sl_atr * a[i]
|
||||||
|
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||||
|
d, sl = -1, c[i] + sl_atr * a[i]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
signals.append(Signal(
|
||||||
|
idx=i, direction=d, entry_price=c[i],
|
||||||
|
metadata={"tp": float(e[i]), "sl": float(sl), "max_bars": max_bars},
|
||||||
|
))
|
||||||
|
return signals
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
strat = KeltnerFade()
|
||||||
|
print("=" * 110)
|
||||||
|
print(f" MR03 KELTNER FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||||
|
print("=" * 110)
|
||||||
|
for asset, n, k in [("BTC", 30, 2.0), ("ETH", 50, 1.5)]:
|
||||||
|
r = strat.backtest(asset, "1h", n=n, k=k, sl_atr=2.0, max_bars=24)
|
||||||
|
if r:
|
||||||
|
r.strategy_name = f"MR03 {asset} 1h n{n} k{k}"
|
||||||
|
r.print_summary()
|
||||||
|
r.print_yearly()
|
||||||
@@ -11,6 +11,7 @@ import pandas as pd
|
|||||||
from src.live.cerbero_client import CerberoClient
|
from src.live.cerbero_client import CerberoClient
|
||||||
from src.live.strategy_loader import load_strategy
|
from src.live.strategy_loader import load_strategy
|
||||||
from src.live.strategy_worker import StrategyWorker
|
from src.live.strategy_worker import StrategyWorker
|
||||||
|
from src.live.pairs_worker import PairsWorker
|
||||||
from src.live.signal_engine import SignalEngine
|
from src.live.signal_engine import SignalEngine
|
||||||
from src.live.telegram_notifier import send_telegram
|
from src.live.telegram_notifier import send_telegram
|
||||||
|
|
||||||
@@ -18,9 +19,20 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|||||||
DATA_DIR = PROJECT_ROOT / "data" / "paper_trades"
|
DATA_DIR = PROJECT_ROOT / "data" / "paper_trades"
|
||||||
|
|
||||||
RESOLUTION_MAP = {"15m": "15", "1h": "60", "5m": "5"}
|
RESOLUTION_MAP = {"15m": "15", "1h": "60", "5m": "5"}
|
||||||
|
# Convenzione Deribit (verificata via Cerbero, 2026-05-29):
|
||||||
|
# - BTC/ETH = perpetui INVERSE (margine coin): "<COIN>-PERPETUAL"
|
||||||
|
# - altcoin = perpetui LINEARI USDC (margine USDC): "<COIN>_USDC-PERPETUAL", storia dal 2022
|
||||||
|
# Trappola: "LTC-PERPETUAL"/"ADA-PERPETUAL" = 0 candele; "SOL-PERPETUAL" = contratto vecchio
|
||||||
|
# con dati sbagliati. Per gli alt usare SEMPRE la forma _USDC-PERPETUAL.
|
||||||
INSTRUMENT_MAP = {
|
INSTRUMENT_MAP = {
|
||||||
"BTC": "BTC-PERPETUAL",
|
"BTC": "BTC-PERPETUAL",
|
||||||
"ETH": "ETH-PERPETUAL",
|
"ETH": "ETH-PERPETUAL",
|
||||||
|
"SOL": "SOL_USDC-PERPETUAL",
|
||||||
|
"LTC": "LTC_USDC-PERPETUAL",
|
||||||
|
"ADA": "ADA_USDC-PERPETUAL",
|
||||||
|
"XRP": "XRP_USDC-PERPETUAL",
|
||||||
|
"BNB": "BNB_USDC-PERPETUAL",
|
||||||
|
"DOGE": "DOGE_USDC-PERPETUAL",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -130,6 +142,26 @@ def build_workers(config: dict) -> tuple[list[StrategyWorker], list[MLWorkerWrap
|
|||||||
return regular_workers, ml_workers
|
return regular_workers, ml_workers
|
||||||
|
|
||||||
|
|
||||||
|
def build_pairs_workers(config: dict) -> list[PairsWorker]:
|
||||||
|
"""Crea i PairsWorker (2 gambe) dalla sezione `pairs:` dello YAML."""
|
||||||
|
defaults = config.get("defaults", {})
|
||||||
|
workers: list[PairsWorker] = []
|
||||||
|
for entry in config.get("pairs", []):
|
||||||
|
if not entry.get("enabled", True):
|
||||||
|
continue
|
||||||
|
workers.append(PairsWorker(
|
||||||
|
asset_a=entry["a"], asset_b=entry["b"], tf=entry.get("tf", "1h"),
|
||||||
|
params=entry.get("params", {}),
|
||||||
|
capital=entry.get("capital", defaults.get("capital", 1000)),
|
||||||
|
position_size=entry.get("position_size", defaults.get("position_size", 0.15)),
|
||||||
|
leverage=entry.get("leverage", defaults.get("leverage", 3)),
|
||||||
|
fee_rt=entry.get("fee_rt", 0.001),
|
||||||
|
name=entry.get("name", "PR01_pairs_reversion"),
|
||||||
|
data_dir=DATA_DIR,
|
||||||
|
))
|
||||||
|
return workers
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
config_path = PROJECT_ROOT / "strategies.yml"
|
config_path = PROJECT_ROOT / "strategies.yml"
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
@@ -143,7 +175,8 @@ def run():
|
|||||||
train_lookback_days = 365
|
train_lookback_days = 365
|
||||||
|
|
||||||
regular_workers, ml_workers = build_workers(config)
|
regular_workers, ml_workers = build_workers(config)
|
||||||
all_worker_count = len(regular_workers) + len(ml_workers)
|
pairs_workers = build_pairs_workers(config)
|
||||||
|
all_worker_count = len(regular_workers) + len(ml_workers) + len(pairs_workers)
|
||||||
|
|
||||||
if all_worker_count == 0:
|
if all_worker_count == 0:
|
||||||
print("Nessuna strategia abilitata in strategies.yml")
|
print("Nessuna strategia abilitata in strategies.yml")
|
||||||
@@ -162,6 +195,8 @@ def run():
|
|||||||
print(f" • {w.status_summary}")
|
print(f" • {w.status_summary}")
|
||||||
for mw in ml_workers:
|
for mw in ml_workers:
|
||||||
print(f" • {mw.worker.status_summary} [ML]")
|
print(f" • {mw.worker.status_summary} [ML]")
|
||||||
|
for pw in pairs_workers:
|
||||||
|
print(f" • {pw.status_summary} [PAIRS]")
|
||||||
|
|
||||||
send_telegram(f"🚀 Multi-Strategy avviato: {all_worker_count} strategie")
|
send_telegram(f"🚀 Multi-Strategy avviato: {all_worker_count} strategie")
|
||||||
|
|
||||||
@@ -172,6 +207,9 @@ def run():
|
|||||||
keys.add((w.asset, w.tf))
|
keys.add((w.asset, w.tf))
|
||||||
for mw in ml_workers:
|
for mw in ml_workers:
|
||||||
keys.add((mw.worker.asset, mw.worker.tf))
|
keys.add((mw.worker.asset, mw.worker.tf))
|
||||||
|
for pw in pairs_workers: # entrambe le gambe del pair
|
||||||
|
keys.add((pw.asset_a, pw.tf))
|
||||||
|
keys.add((pw.asset_b, pw.tf))
|
||||||
return keys
|
return keys
|
||||||
|
|
||||||
# Training iniziale ML
|
# Training iniziale ML
|
||||||
@@ -253,6 +291,15 @@ def run():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" [{mw.worker.worker_id}] ERRORE: {e}")
|
print(f" [{mw.worker.worker_id}] ERRORE: {e}")
|
||||||
|
|
||||||
|
# Tick pairs workers (2 gambe)
|
||||||
|
for pw in pairs_workers:
|
||||||
|
ka, kb = (pw.asset_a, pw.tf), (pw.asset_b, pw.tf)
|
||||||
|
if ka in candle_cache and kb in candle_cache:
|
||||||
|
try:
|
||||||
|
pw.tick(candle_cache[ka], candle_cache[kb])
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{pw.worker_id}] ERRORE: {e}")
|
||||||
|
|
||||||
# Status periodico
|
# Status periodico
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
if now.minute == 0 and now.second < poll_seconds:
|
if now.minute == 0 and now.second < poll_seconds:
|
||||||
@@ -261,6 +308,8 @@ def run():
|
|||||||
lines.append(f" {w.status_summary}")
|
lines.append(f" {w.status_summary}")
|
||||||
for mw in ml_workers:
|
for mw in ml_workers:
|
||||||
lines.append(f" {mw.worker.status_summary} [ML]")
|
lines.append(f" {mw.worker.status_summary} [ML]")
|
||||||
|
for pw in pairs_workers:
|
||||||
|
lines.append(f" {pw.status_summary} [PAIRS]")
|
||||||
send_telegram("\n".join(lines))
|
send_telegram("\n".join(lines))
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -277,6 +326,8 @@ def run():
|
|||||||
if df is not None and not df.empty:
|
if df is not None and not df.empty:
|
||||||
mw.worker._close_position(float(df["close"].iloc[-1]), "shutdown")
|
mw.worker._close_position(float(df["close"].iloc[-1]), "shutdown")
|
||||||
mw.worker._save_state()
|
mw.worker._save_state()
|
||||||
|
for pw in pairs_workers: # salva stato; non forzo la chiusura a 2 gambe
|
||||||
|
pw._save_state()
|
||||||
send_telegram("🛑 Multi-Strategy arrestato")
|
send_telegram("🛑 Multi-Strategy arrestato")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""PairsWorker — paper trading a 2 GAMBE per la famiglia PR01 (spread reversion).
|
||||||
|
|
||||||
|
Market-neutral: long asset A / short asset B (o viceversa) sullo z-score del log-ratio.
|
||||||
|
Distinto dallo StrategyWorker single-leg: gestisce due strumenti, due prezzi di
|
||||||
|
ingresso, e conta le fee su ENTRAMBE le gambe (2*fee_rt*lev = 0.20% RT/coppia con
|
||||||
|
fee_rt=0.001). Semantica identica al backtest scripts/analysis/pairs_research.pairs_sim:
|
||||||
|
|
||||||
|
r[i] = log(closeA[i]/closeB[i]); z[i] = (r[i]-SMA_n(r)[i]) / STD_n(r)[i] (causale)
|
||||||
|
ENTRY a close[i]: z<=-z_in -> LONG ratio (long A / short B); z>=+z_in -> SHORT ratio
|
||||||
|
EXIT: |z| <= z_exit (rientro) oppure time-limit max_bars
|
||||||
|
filtro candele sporche: salta l'ingresso se |dr[i]| > jump_max
|
||||||
|
PnL = (retA - retB) * direction * lev - 2*fee_rt*lev (notional uguale per gamba)
|
||||||
|
|
||||||
|
Stato persistente (resume al restart) e log come StrategyWorker.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.live.telegram_notifier import notify_event
|
||||||
|
|
||||||
|
|
||||||
|
class PairsWorker:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
asset_a: str,
|
||||||
|
asset_b: str,
|
||||||
|
tf: str,
|
||||||
|
params: dict | None = None,
|
||||||
|
capital: float = 1000.0,
|
||||||
|
position_size: float = 0.15,
|
||||||
|
leverage: float = 3.0,
|
||||||
|
fee_rt: float = 0.001, # per gamba RT; la coppia paga 2x
|
||||||
|
name: str = "PR01_pairs_reversion",
|
||||||
|
data_dir: Path = Path("data/paper_trades"),
|
||||||
|
):
|
||||||
|
self.asset_a = asset_a
|
||||||
|
self.asset_b = asset_b
|
||||||
|
self.tf = tf
|
||||||
|
self.name = name
|
||||||
|
p = params or {}
|
||||||
|
self.n = int(p.get("n", 50))
|
||||||
|
self.z_in = float(p.get("z_in", 2.0))
|
||||||
|
self.z_exit = float(p.get("z_exit", 0.75))
|
||||||
|
self.max_bars = int(p.get("max_bars", 72))
|
||||||
|
self.jump_max = float(p.get("jump_max", 0.08))
|
||||||
|
|
||||||
|
self.initial_capital = capital
|
||||||
|
self.position_size = position_size
|
||||||
|
self.leverage = leverage
|
||||||
|
self.fee_rt = fee_rt
|
||||||
|
|
||||||
|
self.worker_id = f"{name}__{asset_a}_{asset_b}__{tf}"
|
||||||
|
self.work_dir = data_dir / self.worker_id
|
||||||
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.trades_path = self.work_dir / "trades.jsonl"
|
||||||
|
self.status_path = self.work_dir / "status.json"
|
||||||
|
|
||||||
|
self.capital = capital
|
||||||
|
self.in_position = False
|
||||||
|
self.direction = 0 # +1 long ratio (long A/short B), -1 short ratio
|
||||||
|
self.entry_a = 0.0
|
||||||
|
self.entry_b = 0.0
|
||||||
|
self.entry_z = 0.0
|
||||||
|
self.entry_time = ""
|
||||||
|
self.bars_held = 0
|
||||||
|
self.total_trades = 0
|
||||||
|
self.total_wins = 0
|
||||||
|
self.last_bar_ts = 0
|
||||||
|
self.started_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
self._load_state()
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
|
# ---------------- persistenza ----------------
|
||||||
|
def _load_state(self):
|
||||||
|
if not self.status_path.exists():
|
||||||
|
self._log("INIT", {"capital": self.capital, "pair": f"{self.asset_a}/{self.asset_b}",
|
||||||
|
"tf": self.tf, "params": {"n": self.n, "z_in": self.z_in,
|
||||||
|
"z_exit": self.z_exit, "max_bars": self.max_bars}})
|
||||||
|
return
|
||||||
|
with open(self.status_path) as f:
|
||||||
|
s = json.load(f)
|
||||||
|
self.capital = s.get("capital", self.initial_capital)
|
||||||
|
self.in_position = s.get("in_position", False)
|
||||||
|
self.direction = s.get("direction", 0)
|
||||||
|
self.entry_a = s.get("entry_a", 0.0)
|
||||||
|
self.entry_b = s.get("entry_b", 0.0)
|
||||||
|
self.entry_z = s.get("entry_z", 0.0)
|
||||||
|
self.entry_time = s.get("entry_time", "")
|
||||||
|
self.bars_held = s.get("bars_held", 0)
|
||||||
|
self.total_trades = s.get("total_trades", 0)
|
||||||
|
self.total_wins = s.get("total_wins", 0)
|
||||||
|
self.last_bar_ts = s.get("last_bar_ts", 0)
|
||||||
|
self.started_at = s.get("started_at", self.started_at)
|
||||||
|
self._log("RESUME", {"capital": round(self.capital, 2),
|
||||||
|
"total_trades": self.total_trades, "in_position": self.in_position})
|
||||||
|
|
||||||
|
def _save_state(self):
|
||||||
|
state = {
|
||||||
|
"capital": round(self.capital, 2), "in_position": self.in_position,
|
||||||
|
"direction": self.direction, "entry_a": self.entry_a, "entry_b": self.entry_b,
|
||||||
|
"entry_z": round(self.entry_z, 4), "entry_time": self.entry_time,
|
||||||
|
"bars_held": self.bars_held, "total_trades": self.total_trades,
|
||||||
|
"total_wins": self.total_wins, "last_bar_ts": self.last_bar_ts,
|
||||||
|
"started_at": self.started_at, "last_update": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
with open(self.status_path, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
def _log(self, event: str, data: dict | None = None):
|
||||||
|
entry = {"ts": datetime.now(timezone.utc).isoformat(), "worker": self.worker_id,
|
||||||
|
"event": event, **(data or {})}
|
||||||
|
with open(self.trades_path, "a") as f:
|
||||||
|
f.write(json.dumps(entry) + "\n")
|
||||||
|
print(f" [{self.worker_id}] {event}: {json.dumps(data or {}, default=str)}")
|
||||||
|
|
||||||
|
def _notify(self, event: str, data: dict | None = None):
|
||||||
|
notify_event(event, {"worker": self.worker_id, **(data or {})})
|
||||||
|
|
||||||
|
# ---------------- segnale ----------------
|
||||||
|
def _zscore(self, ca: np.ndarray, cb: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
r = np.log(ca / cb)
|
||||||
|
ma = pd.Series(r).rolling(self.n).mean().values
|
||||||
|
sd = pd.Series(r).rolling(self.n).std().values
|
||||||
|
z = (r - ma) / np.where(sd == 0, np.nan, sd)
|
||||||
|
dr = np.abs(np.diff(r, prepend=r[0]))
|
||||||
|
return z, dr
|
||||||
|
|
||||||
|
# ---------------- trading ----------------
|
||||||
|
def _open(self, d: int, ca: float, cb: float, z: float):
|
||||||
|
self.in_position = True
|
||||||
|
self.direction = d
|
||||||
|
self.entry_a, self.entry_b, self.entry_z = ca, cb, z
|
||||||
|
self.entry_time = datetime.now(timezone.utc).isoformat()
|
||||||
|
self.bars_held = 0
|
||||||
|
data = {"direction": "long_ratio" if d == 1 else "short_ratio",
|
||||||
|
"long_leg": self.asset_a if d == 1 else self.asset_b,
|
||||||
|
"short_leg": self.asset_b if d == 1 else self.asset_a,
|
||||||
|
"entry_a": round(ca, 4), "entry_b": round(cb, 4), "z": round(z, 3),
|
||||||
|
"capital": round(self.capital, 2)}
|
||||||
|
self._log("OPEN", data); self._notify("OPENED", data)
|
||||||
|
|
||||||
|
def _close(self, ca: float, cb: float, z: float, reason: str):
|
||||||
|
if not self.in_position:
|
||||||
|
return
|
||||||
|
ret_a = (ca - self.entry_a) / self.entry_a
|
||||||
|
ret_b = (cb - self.entry_b) / self.entry_b
|
||||||
|
gross = (ret_a - ret_b) * self.direction * self.leverage
|
||||||
|
fee = 2 * self.fee_rt * self.leverage # 2 gambe
|
||||||
|
net = gross - fee
|
||||||
|
pnl = self.capital * self.position_size * net
|
||||||
|
self.capital = max(self.capital + pnl, 0.0)
|
||||||
|
is_win = net > 0
|
||||||
|
self.total_trades += 1
|
||||||
|
self.total_wins += is_win
|
||||||
|
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
|
||||||
|
data = {"reason": reason, "exit_a": round(ca, 4), "exit_b": round(cb, 4),
|
||||||
|
"z": round(z, 3), "gross_ret": round(gross * 100, 3), "fee": round(fee * 100, 3),
|
||||||
|
"net_return": round(net * 100, 3), "pnl": round(pnl, 2),
|
||||||
|
"capital": round(self.capital, 2), "bars_held": self.bars_held,
|
||||||
|
"win": bool(is_win), "total_trades": self.total_trades, "accuracy": round(acc, 1)}
|
||||||
|
self._log("CLOSE", data); self._notify("CLOSED", data)
|
||||||
|
self.in_position = False
|
||||||
|
self.direction = 0
|
||||||
|
self.entry_a = self.entry_b = self.entry_z = 0.0
|
||||||
|
self.bars_held = 0
|
||||||
|
|
||||||
|
def tick(self, df_a: pd.DataFrame, df_b: pd.DataFrame):
|
||||||
|
"""Chiamato ad ogni poll con gli OHLCV aggiornati delle due gambe."""
|
||||||
|
if df_a is None or df_b is None or df_a.empty or df_b.empty:
|
||||||
|
return
|
||||||
|
m = df_a[["timestamp", "close"]].rename(columns={"close": "ca"}).merge(
|
||||||
|
df_b[["timestamp", "close"]].rename(columns={"close": "cb"}), on="timestamp", how="inner"
|
||||||
|
).sort_values("timestamp").reset_index(drop=True)
|
||||||
|
if len(m) < self.n + 2:
|
||||||
|
return
|
||||||
|
ca, cb = m["ca"].values, m["cb"].values
|
||||||
|
z, dr = self._zscore(ca, cb)
|
||||||
|
i = len(m) - 1
|
||||||
|
cur_ts = int(m["timestamp"].iloc[i])
|
||||||
|
zi = z[i]
|
||||||
|
if np.isnan(zi):
|
||||||
|
self._save_state(); return
|
||||||
|
|
||||||
|
if self.in_position:
|
||||||
|
if cur_ts > self.last_bar_ts:
|
||||||
|
self.bars_held += 1
|
||||||
|
self.last_bar_ts = cur_ts
|
||||||
|
if abs(zi) <= self.z_exit:
|
||||||
|
self._close(float(ca[i]), float(cb[i]), float(zi), "mean_revert")
|
||||||
|
elif self.bars_held >= self.max_bars:
|
||||||
|
self._close(float(ca[i]), float(cb[i]), float(zi), "time_limit")
|
||||||
|
self._save_state()
|
||||||
|
return
|
||||||
|
|
||||||
|
# flat: cerca ingresso (no look-ahead: z[i] usa solo dati <= i)
|
||||||
|
if dr[i] <= self.jump_max:
|
||||||
|
if zi <= -self.z_in:
|
||||||
|
self._open(1, float(ca[i]), float(cb[i]), float(zi)); self.last_bar_ts = cur_ts
|
||||||
|
elif zi >= self.z_in:
|
||||||
|
self._open(-1, float(ca[i]), float(cb[i]), float(zi)); self.last_bar_ts = cur_ts
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_summary(self) -> str:
|
||||||
|
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
|
||||||
|
pos = ("LONG " + self.asset_a if self.direction == 1
|
||||||
|
else "SHORT " + self.asset_a if self.direction == -1 else "FLAT")
|
||||||
|
return (f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t {acc:.0f}% | {pos}")
|
||||||
@@ -18,6 +18,8 @@ _REGISTRY: dict[str, type[Strategy]] = {}
|
|||||||
# (vedi scripts/analysis/oos_validation.py).
|
# (vedi scripts/analysis/oos_validation.py).
|
||||||
MODULE_MAP = {
|
MODULE_MAP = {
|
||||||
"MR01_bollinger_fade": ("MR01_bollinger_fade", "BollingerFade"),
|
"MR01_bollinger_fade": ("MR01_bollinger_fade", "BollingerFade"),
|
||||||
|
"MR02_donchian_fade": ("MR02_donchian_fade", "DonchianFade"),
|
||||||
|
"MR07_return_reversal": ("MR07_return_reversal", "ReturnReversal"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Base condivisa per strategie mean-reversion con exit TP/SL/max_bars.
|
||||||
|
|
||||||
|
Tutte le strategie fade (MR02/MR03/MR07) generano Signal con metadata
|
||||||
|
{tp, sl, max_bars} e usano lo stesso backtest fedele: ingresso a close[i]
|
||||||
|
(eseguibile dal vivo), uscita su take-profit / stop-loss intrabar (high/low)
|
||||||
|
o time-limit, una posizione per volta (non-overlap), capitale composto,
|
||||||
|
fee+leva nette. Identico all'engine di scripts/analysis/strategy_research.py.
|
||||||
|
|
||||||
|
Le sottoclassi implementano solo generate_signals().
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.strategies.base import Strategy, BacktestResult, YearlyStats, TF_MINUTES
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
|
||||||
|
|
||||||
|
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
pc = np.roll(c, 1); pc[0] = c[0]
|
||||||
|
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||||
|
return pd.Series(tr).rolling(n).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
def trend_distance(df: pd.DataFrame, ema_long: int = 200) -> np.ndarray:
|
||||||
|
"""Distanza del close dalla EMA lunga, in multipli di ATR(14).
|
||||||
|
|
||||||
|
Misura quanto il prezzo e' esteso rispetto al trend di fondo. Le fade
|
||||||
|
falliscono quando si oppongono a un trend estremo (crolli/parabolic): il
|
||||||
|
filtro `trend_max` salta i segnali con distanza > soglia. Riduce DD e alza
|
||||||
|
l'accuratezza (validato OOS: scripts/analysis/risk_portfolio.py).
|
||||||
|
"""
|
||||||
|
c = df["close"].values
|
||||||
|
a = atr(df, 14)
|
||||||
|
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
||||||
|
with np.errstate(divide="ignore", invalid="ignore"):
|
||||||
|
return np.abs(c - el) / np.where(a == 0, np.nan, a)
|
||||||
|
|
||||||
|
|
||||||
|
class FadeStrategy(Strategy):
|
||||||
|
"""Strategy con backtest intrabar TP/SL/max_bars (exit guidati dai metadata)."""
|
||||||
|
|
||||||
|
fee_rt = 0.001 # Deribit perp realistico (taker 0.05%/lato)
|
||||||
|
leverage = 3.0
|
||||||
|
position_size = 0.15
|
||||||
|
initial_capital = 1000.0
|
||||||
|
|
||||||
|
def backtest(self, asset: str, tf: str = "1h", hold: int = 3,
|
||||||
|
**params) -> BacktestResult | None:
|
||||||
|
df = load_data(asset, tf)
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
signals = self.generate_signals(df, ts, **params)
|
||||||
|
if not signals:
|
||||||
|
return None
|
||||||
|
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
n = len(c)
|
||||||
|
fee = self.fee_rt * self.leverage
|
||||||
|
capital = peak = float(self.initial_capital)
|
||||||
|
max_dd = 0.0
|
||||||
|
total_bars = 0
|
||||||
|
last_exit = -1
|
||||||
|
yearly: dict[int, dict] = {}
|
||||||
|
|
||||||
|
for sig in signals:
|
||||||
|
i, d = sig.idx, sig.direction
|
||||||
|
if i <= last_exit or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
tp, sl, mb = sig.metadata["tp"], sig.metadata["sl"], sig.metadata["max_bars"]
|
||||||
|
exit_p = c[min(i + mb, n - 1)]
|
||||||
|
j = min(i + mb, n - 1)
|
||||||
|
for step in range(1, mb + 1):
|
||||||
|
j = i + step
|
||||||
|
if j >= n:
|
||||||
|
j = n - 1; exit_p = c[j]; break
|
||||||
|
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||||
|
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||||
|
if hit_sl: # conservativo: SL prima del TP nello stesso bar
|
||||||
|
exit_p = sl; break
|
||||||
|
if hit_tp:
|
||||||
|
exit_p = tp; break
|
||||||
|
if step == mb:
|
||||||
|
exit_p = c[j]
|
||||||
|
|
||||||
|
ret = (exit_p - entry) / entry * d * self.leverage - fee
|
||||||
|
capital = max(capital + capital * self.position_size * ret, 10.0)
|
||||||
|
if capital > peak:
|
||||||
|
peak = capital
|
||||||
|
max_dd = max(max_dd, (peak - capital) / peak)
|
||||||
|
total_bars += (j - i)
|
||||||
|
last_exit = j
|
||||||
|
|
||||||
|
year = ts.iloc[i].year
|
||||||
|
yr = yearly.setdefault(year, {"w": 0, "t": 0, "pnl": 0.0})
|
||||||
|
yr["t"] += 1
|
||||||
|
if ret > 0:
|
||||||
|
yr["w"] += 1
|
||||||
|
yr["pnl"] += ret * self.initial_capital
|
||||||
|
|
||||||
|
all_t = sum(v["t"] for v in yearly.values())
|
||||||
|
all_w = sum(v["w"] for v in yearly.values())
|
||||||
|
if all_t == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
yearly_stats = [YearlyStats(y, v["t"], v["w"], v["pnl"]) for y, v in sorted(yearly.items())]
|
||||||
|
return BacktestResult(
|
||||||
|
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
|
||||||
|
trades=all_t, wins=all_w, pnl=sum(v["pnl"] for v in yearly.values()),
|
||||||
|
capital=capital, initial_capital=self.initial_capital,
|
||||||
|
max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100,
|
||||||
|
avg_trade_duration_h=total_bars / all_t * TF_MINUTES.get(tf, 60) / 60,
|
||||||
|
years_active=len(yearly), yearly=yearly_stats,
|
||||||
|
)
|
||||||
@@ -21,6 +21,8 @@ strategies:
|
|||||||
k: 2.5
|
k: 2.5
|
||||||
sl_atr: 2.0
|
sl_atr: 2.0
|
||||||
max_bars: 24
|
max_bars: 24
|
||||||
|
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||||
|
ema_long: 200
|
||||||
|
|
||||||
# ETH: edge positivo ma DD piu' alto (~70%); leva piu' bassa consigliata
|
# ETH: edge positivo ma DD piu' alto (~70%); leva piu' bassa consigliata
|
||||||
- name: MR01_bollinger_fade
|
- name: MR01_bollinger_fade
|
||||||
@@ -32,3 +34,99 @@ strategies:
|
|||||||
k: 2.5
|
k: 2.5
|
||||||
sl_atr: 2.0
|
sl_atr: 2.0
|
||||||
max_bars: 24
|
max_bars: 24
|
||||||
|
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||||
|
ema_long: 200
|
||||||
|
|
||||||
|
# MR02 Donchian fade: fade rottura canale (estremi H/L). Robusto su tutta la
|
||||||
|
# griglia n x sl_atr e tutte le fee. BTC +879%/+171% OOS (8/9 anni), ETH enorme.
|
||||||
|
- name: MR02_donchian_fade
|
||||||
|
asset: BTC
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params:
|
||||||
|
n: 20
|
||||||
|
sl_atr: 2.0
|
||||||
|
max_bars: 24
|
||||||
|
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||||
|
ema_long: 200
|
||||||
|
- name: MR02_donchian_fade
|
||||||
|
asset: ETH
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params:
|
||||||
|
n: 20
|
||||||
|
sl_atr: 2.0
|
||||||
|
max_bars: 24
|
||||||
|
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||||
|
ema_long: 200
|
||||||
|
|
||||||
|
# MR03 Keltner fade -> spostata in scripts/waste/ (fade piu' debole e ridondante
|
||||||
|
# con MR01; il filtro trend la peggiorava su BTC). Vedi CLAUDE.md.
|
||||||
|
|
||||||
|
# MR07 Return reversal: fade movimento di barra estremo (z dei rendimenti).
|
||||||
|
# Meccanismo distinto (volatilita' rendimenti, non livelli). Esposizione bassa
|
||||||
|
# (~8%). BTC +447%/+105% OOS DD25%, ETH +335%/+195% OOS DD46%.
|
||||||
|
- name: MR07_return_reversal
|
||||||
|
asset: BTC
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params:
|
||||||
|
n: 50
|
||||||
|
k: 3.5
|
||||||
|
tp_atr: 2.0
|
||||||
|
sl_atr: 1.5
|
||||||
|
max_bars: 24
|
||||||
|
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||||
|
ema_long: 200
|
||||||
|
- name: MR07_return_reversal
|
||||||
|
asset: ETH
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params:
|
||||||
|
n: 50
|
||||||
|
k: 3.5
|
||||||
|
tp_atr: 2.0
|
||||||
|
sl_atr: 1.5
|
||||||
|
max_bars: 24
|
||||||
|
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||||
|
ema_long: 200
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PR01 — PAIRS market-neutral spread reversion (worker a 2 GAMBE: src/live/pairs_worker.py)
|
||||||
|
# Config UNIVERSALE n50 z2 zx0.75 mb72 (anti-overfit, validata walk-forward).
|
||||||
|
# fee_rt 0.001/gamba -> 0.20% RT/coppia.
|
||||||
|
# FEED LIVE (verificato 2026-05-29): tutti i leg disponibili su Deribit via Cerbero con
|
||||||
|
# il naming corretto -> BTC/ETH = "<COIN>-PERPETUAL" (inverse), alt = "<COIN>_USDC-PERPETUAL"
|
||||||
|
# (lineari USDC, storia dal 2022). Tutte e 5 le coppie tradabili live. ETH/SOL la piu'
|
||||||
|
# debole (DD ~63%, storia SOL piu' corta) -> peso ridotto consigliato.
|
||||||
|
pairs:
|
||||||
|
- name: PR01_pairs_reversion
|
||||||
|
a: ETH
|
||||||
|
b: BTC
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params: {n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08}
|
||||||
|
- name: PR01_pairs_reversion
|
||||||
|
a: LTC
|
||||||
|
b: ETH
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params: {n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08}
|
||||||
|
- name: PR01_pairs_reversion
|
||||||
|
a: ADA
|
||||||
|
b: ETH
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params: {n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08}
|
||||||
|
- name: PR01_pairs_reversion
|
||||||
|
a: BTC
|
||||||
|
b: LTC
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params: {n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08}
|
||||||
|
- name: PR01_pairs_reversion # la piu' debole (peso ridotto)
|
||||||
|
a: ETH
|
||||||
|
b: SOL
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params: {n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08}
|
||||||
|
|||||||
Reference in New Issue
Block a user