20 Commits

Author SHA1 Message Date
Adriano cff0d08fca feat(risk): filtro trend per alzare Acc e ridurre DD + modello portafoglio
Filtro opzionale trend_max/ema_long su tutte le fade (MR01/MR02/MR03/MR07):
salta i segnali quando |close-EMA200|/ATR supera la soglia (non fadare un trend
o crollo estremo). Con trend_max=3.0 (default in strategies.yml): accuratezza su
e DD giu' su 7/8 sleeve, drastico su ETH (MR01 71->26%, MR02 42->25%,
MR03 66->34%, MR07 46->21%); edge OOS confermato. MR03 BTC: filtro disattivo
(unico sleeve dove peggiora entrambe).

Scartate come non robuste: vol-target sizing e skip-alta-volatilita' (peggiorano
sia Acc che DD). Aggiunto modello di portafoglio equipesato su sotto-conti
indipendenti: DD aggregato ~14% full / ~10% OOS sul paniere di 8 sleeve, contro
20-70% del singolo -> vera leva anti-drawdown.

Banco di prova: scripts/analysis/risk_improvements.py, risk_portfolio.py.
Helper trend_distance() in fade_base. CLAUDE.md e diario aggiornati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:47:52 +02:00
Adriano 21d3ba609d feat(strategie): 3 nuove fade mean-reversion validate OOS fee-aware (MR02/MR03/MR07)
Trovate e promosse 3 strategie con edge netto distinto da MR01, stessa
metodologia (ingresso close[i], netto fee 0.10% RT + leva 3x, OOS ultimo 30%,
robustezza su griglia + sweep fee 0.00-0.20%):

- MR02 Donchian Fade: fade rottura canale H/L, TP al centro. BTC +172% OOS.
- MR03 Keltner Fade: canale ATR su EMA (indipendente da Bollinger). BTC +112%.
- MR07 Return Reversal: fade movimento di barra estremo (z dei rendimenti). BTC +105%.

Tutte positive netto OOS su entrambi gli asset e su tutto lo sweep fee, anche
0.20% RT pessimista (validate anche via oos_validation live-path). Scartate
MR04 (= MR01 riparametrizzato), MR05 (ADX non robusto), MR06 (RSI2 ETH neg).

Base condivisa fade_base.FadeStrategy (backtest intrabar TP/SL/max_bars).
Aggiunte a strategy_loader e strategies.yml (BTC+ETH 1h). Ricerca in
strategy_research_v2.py. Diario e CLAUDE.md aggiornati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:26:21 +02:00
Adriano Dal Pastro 48435f6858 feat(live): worker con exit TP/SL/max_bars per MR01 + doc aggiornata
StrategyWorker ora supporta exit guidati dalla strategia via Signal.metadata
(take-profit alla media / stop-loss ad ATR / time-limit), con fallback al
vecchio hold_bars/stop -2% per strategie senza metadata. Usa fee_rt della
strategia (MR01 = 0.10% RT reale Deribit, non piu' 0.20% hardcoded).
Persistenza di tp/sl/max_bars in status.json per resume.

Re-validato col worker reale (replay finestre mobili 1h, fee 0.10%):
  BTC 1h MR01: +196% OOS, ETH 1h: +251% OOS (nov 2023->mag 2026) — coerente col backtest.

README + CLAUDE.md riscritti: squeeze = artefatto di look-ahead -> waste,
MR01 mean-reversion unica attiva, metodologia anti-look-ahead e fee reali 0.10% RT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:46:35 +00:00
Adriano Dal Pastro 9879b46688 refactor(strategie): tieni solo MR01 mean-reversion, squeeze -> waste
L'analisi out-of-sample fee-aware ha dimostrato che l'intera famiglia
squeeze-breakout (SQ01-04, MT01, ML01, AD01, CM01, PD01) non ha edge:
le accuratezze storiche 76-82% erano un artefatto di look-ahead (ingresso
a close[i-1] con direzione decisa da close[i]). Sotto ingresso onesto a
close[i] e fee reali tutte perdono, anche a fee zero.

- nuova MR01_bollinger_fade (mean-reversion): edge netto validato OOS,
  robusto su griglia parametri e fino a 0.20% fee RT. BTC 1h n50 k2.5: +201% OOS, DD 15%
- 9 strategie squeeze spostate in scripts/waste/
- strategy_loader + strategies.yml: solo MR01 (BTC/ETH 1h)
- signal_engine.train: validazione OOS (accuratezza test + signal precision)
- scripts/analysis/strategy_research.py: harness di ricerca fee-aware

NOTA: lo StrategyWorker va aggiornato per usare gli exit TP/SL passati in
metadata prima di tradare MR01 dal vivo (ora esce solo a hold_bars/stop fisso).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:22:11 +00:00
Adriano Dal Pastro ca88e62a11 feat(analysis): validazione out-of-sample fee-aware delle strategie
oos_validation.py: backtest OOS fedele al worker live (non-overlap, hold,
stop, fee, leva) su finestra held-out. Mostra che l'edge storico 76-79%
e' un artefatto di look-ahead (ingresso a close[i-1]) e che nessuna regola
di direzione onesta supera il lancio di moneta; le fee sono secondarie
(4/6 config perdono anche a fee zero).

intrabar_test.py: ingresso intra-barra su 5m vs close 15m a parita' di exit.
Lo "scatto" del breakout e' avverso (rientro immediato alla media), quindi
la granularita' piu' fine non recupera edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:57:15 +00:00
Adriano Dal Pastro 8fd2c16cac fix(live): MT01 usa trend 1h live da Cerbero, non dal parquet statico
Il paper trader restava a zero trade: il feed Cerbero era fermo a
mezzanotte (bug end_date lato cerbero-mcp, poi risolto) e MT01 leggeva
il trend 1h da un parquet statico, di fatto congelandolo (gap ~15h sul
bar corrente). Ora il runner fa fetch 1h live per le strategie MTF e lo
passa a generate_signals via il parametro df_1h (fallback al parquet se
assente). Aggiornati CLAUDE.md, README e diario 2026-05-28.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:30:26 +00:00
Adriano 31be1b43aa docs: aggiorna README e CLAUDE.md con strategie MT01/PD01/CM01/AD01
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:50:58 +02:00
Adriano bdcef09057 chore: untrack paper_trades runtime data + report per anno/mercato
- data/paper_trades/ rimosso dal tracking (dati runtime, gitignored)
- scripts/analysis/yearly_market_report.py: accuracy/trades/PnL per anno×mercato

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:46:24 +02:00
Adriano d39c75b103 feat(strategy4): PD01 82.5%/DD2.9%, AD01 81.2%, CM01 81.9% — tutte battono SQ02
Nuove strategie che battono SQ02 (79.7% acc, DD 6.5%):
- PD01 price-volume divergence: 82.5% acc, DD 2.9%, worst year 80%
- CM01 cross-market momentum: 81.9% acc, DD 2.7%
- AD01 adaptive squeeze threshold: 81.2% acc, DD 3.4%
- MT01 (già committato): 82.7% acc, DD 5.9%

Tutte testate su BTC e ETH, 15m e 1h, 9 anni, con fee 0.2% RT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:13:17 +02:00
Adriano f42fec9fac feat(strategy4): MT01 squeeze+MTF 82.7% acc — batte SQ02, 6 strategie scartate
Nuova strategia MT01: squeeze 15m + momentum EMA 1h
  BTC 15m: 82.7% acc, 503 trades, DD 5.9%, 9/9 anni, worst 72%
  ETH 15m: 81.2% acc, 404 trades, DD 2.9%, 9/9 anni, worst 73%

Strategie testate e scartate (waste W23-W28):
  IB01 inside bar (58.7%, no edge)
  DC01 donchian (48%, sotto random)
  SB01 retest (52%, no edge)
  MR01 mean reversion RSI (62.9%, DD 29%)
  VO01 volume spike (64.2%, DD 34%)
  HY01 squeeze+MR (64.6%, DD 14.5%)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:38:11 +02:00
Adriano 56bad4741e docs: aggiorna README e CLAUDE.md con struttura attuale e multi-strategy runner
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:23:07 +02:00
Adriano b79c87e4af feat: multi-strategy paper trader — N strategie in parallelo su testnet
- src/live/multi_runner.py: orchestratore con fetch raggruppato per asset/tf
- src/live/strategy_worker.py: worker indipendente con stato persistente JSONL
- src/live/strategy_loader.py: import dinamico classi Strategy
- strategies.yml: config dichiarativa con defaults e override per strategia
- Docker: container unico, strategies.yml montato come volume read-only
- Supporta hot-add: aggiungi riga YAML + restart, storico intatto
- Ogni strategia: €1000 USDC virtuale, equity tracking, Telegram notify

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:12:18 +02:00
Adriano 0e47956f7a refactor: riorganizzazione script — Strategy ABC, folder strategies/waste/analysis
- src/strategies/base.py: Strategy ABC con Signal, BacktestResult, YearlyStats
- src/strategies/indicators.py: keltner_ratio, detect_squeezes, ema, atr, rv, corr
- scripts/strategies/: SQ01-SQ04 (squeeze puro/filtri), ML01 (squeeze+GBM)
- scripts/waste/: W01-W22 script scartati + REF originali
- scripts/analysis/: compare, best_yearly, final_report, paper_status
- CLAUDE.md aggiornato con nuova struttura e tabella strategie

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:01:36 +02:00
Adriano fa2d74be77 feat(strategy3): ultimate squeeze — BTC 15m antifake+vol 79.7%, antifake+corr 81.6%
Top results con dati reali:
- BTC 15m antifake+vol: 79.7% acc, 1250 trades, DD 6.5%
- ETH 15m antifake+vol: 78.5% acc, 941 trades, DD 3.4%
- BTC 15m antifake+corr: 81.6% acc, 376 trades (pochi anni)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:25:22 +02:00
Adriano 041db2191c test(strategy3): lead-lag multi-asset — leader-follower fallito, corr-weighted 76.8%
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:22:44 +02:00
Adriano 185ac0d49b feat(strategy3): squeeze migliorato — BTC 15m ALL_FILTERS 79.2% acc
Cross-asset + timing + long_squeeze + dual_tf + anti_fakeout.
Worst year: 2021 76.8%. Tutti gli anni profittevoli.
ETH 15m long_squeeze: 77.9% acc. BTC 1h anti_fakeout: 76.3%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:20:44 +02:00
Adriano 0ab3b5698a docs: confronto migliori strategie S1/S2 per anno, dati reali
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:12:47 +02:00
Adriano 7639e5012b Merge branch 'main' of ssh://git.tielogic.xyz:222/Adriano/PythagorasGoal
# Conflicts:
#	uv.lock
2026-05-27 11:09:52 +02:00
Adriano Dal Pastro 2694a4a00c feat: notifiche Telegram dal paper trader via bot
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:52:11 +00:00
Adriano Dal Pastro a7b3c3c203 infra: add uv.lock per build Docker riproducibili
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:19:15 +00:00
79 changed files with 8199 additions and 104 deletions
+1
View File
@@ -16,3 +16,4 @@ data/processed/
*.pt
*.pth
notebooks/.ipynb_checkpoints/
data/paper_trades/
+108 -22
View File
@@ -9,29 +9,50 @@ Progetto di ricerca: riconoscimento pattern frattali per trading algoritmico su
- **Linguaggio:** Python 3.11+
- **Package manager:** uv (dipendenze in `pyproject.toml`, lock in `uv.lock`)
- **Dati:** Parquet in `data/raw/` (non committati, ~70 MB)
- **ML:** scikit-learn (GradientBoosting), PyTorch (LSTM)
- **ML:** scikit-learn (GradientBoostingClassifier)
- **Analisi:** numpy, pandas, scipy
- **API dati:** Cerbero MCP su `cerbero-mcp.tielogic.xyz` (Deribit, Bybit, Hyperliquid), ccxt/Binance come fallback
- **Config:** pyyaml per `strategies.yml`
## Struttura
```
src/data/ → download e caricamento dati (downloader.py)
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
src/backtest/ → engine di backtesting (engine.py)
scripts/ → analisi e strategie numerate 0113
docs/diary/ → diario di ricerca giornaliero
data/raw/ → file .parquet OHLCV (gitignored)
data/processed/ → modelli salvati (gitignored)
src/data/ → download e caricamento dati (downloader.py)
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
src/backtest/ → engine di backtesting (engine.py)
src/strategies/ → classe base Strategy ABC + indicatori condivisi
base.py → Strategy, Signal, BacktestResult, YearlyStats
indicators.py → keltner_ratio, detect_squeezes, ema, atr, rv, correlation
src/live/ → paper trading live multi-strategia
multi_runner.py → orchestratore: carica YAML, fetch candele, tick worker
strategy_worker.py → worker indipendente: capital, trade log, stato persistente.
Exit guidati da strategia (TP/SL/max_bars via Signal.metadata),
fallback hold_bars/stop -2%. Usa fee_rt della strategia.
strategy_loader.py → import dinamico classi Strategy da scripts/strategies/
cerbero_client.py → client HTTP per Cerbero MCP (Deribit testnet)
signal_engine.py → squeeze + ML real-time (legacy ML01, ora in waste) + validazione OOS
telegram_notifier.py → notifiche Telegram per trade
scripts/strategies/ → strategie con edge validato OOS (solo MR01_bollinger_fade)
scripts/waste/ → strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD)
scripts/analysis/ → ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...)
strategies.yml → config multi-strategy paper trader
docs/diary/ → diario di ricerca giornaliero
docs/specs/ → specifiche di design
data/raw/ → file .parquet OHLCV (gitignored)
```
## Comandi
```bash
uv sync # installa dipendenze
uv run python -m src.data.downloader # scarica dati storici
uv run python scripts/13_squeeze_ml_hybrid.py # strategia vincente
uv run pytest # test
uv sync # installa dipendenze
uv run python -m src.data.downloader # scarica dati storici
uv run python scripts/strategies/MR01_bollinger_fade.py # strategia attiva (mean-reversion)
uv run python scripts/analysis/strategy_research.py # ricerca strategie fee-aware OOS
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
uv run python scripts/analysis/validate_worker_mr01.py # replay worker reale su MR01
uv run python -m src.live.multi_runner # paper trading live multi-strategia
docker compose up -d # deploy Docker
uv run pytest # test
```
## Dati storici
@@ -47,22 +68,84 @@ df = load_data("ETH", "15m") # carica un asset/timeframe
Fonte primaria: Cerbero MCP (endpoint `/mcp-deribit/tools/get_historical`).
Token observer: nel file `secrets/observer.token` del progetto CerberoSuite.
## Strategia vincente
## Strategie attive
**Squeeze + ML ibrida** (script 13):
> **LEZIONE CRITICA (2026-05-28).** L'intera famiglia squeeze-breakout (SQ01-04,
> MT01, ML01, AD01, CM01, PD01) è stata **scartata in `scripts/waste/`**: le
> accuratezze storiche 76-82% erano un **artefatto di look-ahead**. Quei backtest
> decidono la direzione con `sign(close[i]-close[i-1])` (la candela di breakout `i`)
> ma entrano a `close[i-1]` — cioè comprano *prima* della candela che usano per
> scegliere la direzione. Dal vivo il worker scopre il breakout solo a `close[i]`
> ed entra lì: l'edge sparisce (win-rate ~47%, lancio di moneta). Sotto ingresso
> onesto e fee reali **tutte perdono, anche a fee zero**. Inoltre i breakout
> *rientrano* (mean-reversion > continuation). Vedi `scripts/analysis/oos_validation.py`
> e `intrabar_test.py`.
1. Rileva squeeze di volatilità (Bollinger dentro Keltner)
2. Al rilascio dello squeeze, estrai feature strutturali dalla finestra
3. GradientBoosting predice direzione con walk-forward training
4. Trade solo se modello ha confidenza ≥ 70%
Tutte le strategie estendono `src.strategies.base.Strategy`
(`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):**
Configurazione migliore: ETH 15m, BBw=14, squeeze threshold=0.8, breakout=3 barre, leva 3x, position 15%.
| Codice | Nome | Meccanismo | Edge OOS netto (1h, fee 0.10% RT) | DD | Note |
|--------|------|-----------|-----------------------------------|----|------|
| **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 |
| **MR03** | Keltner Fade | canale ATR attorno a EMA | BTC +112% / ETH +886% | 20-66% | Banda indipendente da Bollinger |
| **MR07** | Return Reversal | z dei rendimenti di barra | BTC +105% / ETH +195% | 25-46% | Fada il movimento estremo, exit in ATR; esposizione ~8% |
Risultato backtestato: 76.9% accuracy, 118% annuo, 4.2% drawdown, €13.78/giorno da €1.000.
**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_portfolio.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.
**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`.
2. Backtest **NETTO** dopo fee realistiche Deribit (**0.10% RT** taker, non 0.20%) + leva.
3. Validazione **out-of-sample** (held-out) + robustezza su griglia parametri + sweep fee.
4. Crea script in `scripts/strategies/`, aggiungi a `MODULE_MAP` (`strategy_loader.py`) e a `strategies.yml`.
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
Orchestratore che esegue N strategie in parallelo su dati live Cerbero, ognuna con €1000 USDC virtuali indipendenti.
**Config:** `strategies.yml` — lista strategie con asset, tf, sizing, parametri. Attualmente solo MR01 (BTC/ETH 1h).
**Persistenza:** `data/paper_trades/{strategy}___{asset}__{tf}/` con `trades.jsonl` (append-only) + `status.json` (resume al restart, include tp/sl/max_bars).
**Hot-add:** aggiungi riga YAML → `docker compose restart` → storico intatto.
**Exit strategia:** se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come MR01), il worker esce su take-profit/stop-loss/time-limit a quei livelli; altrimenti usa il fallback `hold_bars`/stop -2%.
**Notifiche:** Telegram per ogni trade (richiede `.env` con `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID`).
## Convenzioni
- Script numerati progressivamente (`01_`, `02_`, …). Ogni script è autocontenuto.
- Strategie in `scripts/strategies/` con codice univoco (MR01, ...).
- Script scartati in `scripts/waste/` (W01-W28 + famiglia squeeze).
- Diario in `docs/diary/YYYY-MM-DD.md`. Aggiornare dopo ogni esperimento significativo.
- Nessun dato sensibile nei commit (token, chiavi API). Usare `.gitignore`.
- Verificare sempre assenza di data leakage prima di fidarsi dei risultati. In particolare: `returns[i-w : i]` include `close[i]` che è un candle nel futuro — usare `returns[i-w : i-1]`.
@@ -70,5 +153,8 @@ Risultato backtestato: 76.9% accuracy, 118% annuo, 4.2% drawdown, €13.78/giorn
## Attenzione
- **Data leakage:** è stata trovata e corretta nello script 05. Ogni volta che si usano rendimenti logaritmici (`np.diff(np.log(close))`), ricordare che `returns[k]` usa `close[k+1]`. I feature devono fermarsi a `returns[i-2]` se il prezzo corrente è `close[i-1]`.
- **Fee:** sempre 0.1% per lato (0.2% round-trip). Includere nel backtest.
- **Fee:** Deribit perp reale = taker ~0.05%/lato (**0.10% round-trip**), maker ~0%. Usare 0.10% RT come baseline (lo 0.20% storico era pessimista 2x). Includere SEMPRE nel backtest: sono vincolo di prim'ordine, molte operazioni = morte per fee. Il worker usa `strategy.fee_rt` (MR01 = 0.001).
- **Leva:** testato con 3x. Aumentare a 5x migliora i rendimenti ma raddoppia il drawdown.
- **GBM:** GradientBoostingClassifier di scikit-learn. Ensemble di alberi decisionali sequenziali. Walk-forward per evitare leakage temporale.
- **Cerbero `get_historical` (fix 2026-05-28):** `end_date` come data nuda è inclusivo dell'intera giornata fino all'ultima candela chiusa (es. `end=oggi` arriva fino ad ora, non più a mezzanotte); accettati anche timestamp con orario (`...T14:00:00`, naive=UTC); nessun cap a ~5000 righe (paginazione interna). Il client passa già `end=oggi`, ora corretto. Prima del fix il paper trader restava a zero trade perché il feed era fermo a mezzanotte.
- **Dati ETH Deribit 15m:** 14-30%/anno di candele *flat* (O=H=L=C, volume 0, run fino a ~54h) per bassa liquidità del perpetuo. Verificato (2026-05-28): escluderle NON cambia i backtest (Δacc ≤0.5pp) → edge robusto. Resta un caveat operativo (slippage/fill in trading reale, irrilevante per paper). BTC pulito eccetto picco ~8% nel 2024.
+3 -1
View File
@@ -8,7 +8,9 @@ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY src/ src/
COPY scripts/strategies/ scripts/strategies/
COPY strategies.yml strategies.yml
VOLUME /app/data
CMD ["uv", "run", "python", "-m", "src.live.paper_trader"]
CMD ["uv", "run", "python", "-m", "src.live.multi_runner"]
+151 -60
View File
@@ -8,80 +8,189 @@ Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di
## Risultati
Tredici strategie testate su dati storici 20182026 (BTC e ETH, timeframe 5m / 15m / 1h). Le migliori cinque:
> ⚠️ **Revisione 2026-05-28.** La famiglia squeeze-breakout (SQ/MT/ML/AD/CM/PD, con
> accuracy storiche dichiarate 76-82%) è stata **scartata**: quei numeri erano un
> **artefatto di look-ahead**. I backtest decidevano la direzione dalla candela di
> breakout `close[i]` ma entravano a `close[i-1]` — impossibile dal vivo. Sotto
> ingresso onesto (`close[i]`) e fee reali, l'edge sparisce e tutte perdono, anche
> a fee zero. Dettagli e prove: `scripts/analysis/oos_validation.py`.
| # | Strategia | Accuracy | ROI annuo | Max DD | €/giorno |
|---|-----------|----------|-----------|--------|----------|
| 1 | ETH 15m Squeeze + ML ibrida | 76.9% | 118% | 4.2% | €13.78 |
| 2 | ETH 1h Squeeze + Vol | 83.9% | 22% | 2.0% | €0.71 |
| 3 | BTC 15m Squeeze + ML ibrida | 78.8% | 69% | 7.0% | €5.51 |
| 4 | ETH 1h Squeeze (BBw=30) | 82.8% | 47% | 3.2% | €1.77 |
| 5 | ETH Walk-Forward ML | 57.7% | 38% | 47% | €3.12 |
Dopo una validazione **out-of-sample, fee-aware** di tutte le famiglie, l'unica con
edge netto reale è il **mean-reversion** (i breakout *rientrano*, non continuano):
La strategia vincente (#1) opera su ETH a 15 minuti con ~1 trade al giorno, leva 3x e drawdown contenuto al 4.2%.
| Codice | Strategia | Mercato | Edge OOS netto | Max DD | Robustezza |
|--------|-----------|---------|----------------|--------|------------|
| **MR01** | Bollinger Fade (mean-reversion) | BTC 1h | **+196 / +201%** | 15% | ✅ |
| **MR01** | Bollinger Fade (mean-reversion) | ETH 1h | **+251%** | ~25% | ⚠️ DD alto |
Netto dopo **fee realistiche Deribit 0.10% RT** (taker), leva 3x, pos 15%, su finestra
held-out (nov 2023→mag 2026). MR01 è positivo su **tutta** la griglia parametri
(`n∈{14,20,30,50}` × `k∈{2.0,2.5,3.0}`) e per **ogni** livello di fee 0.00-0.20% RT —
margine di sicurezza ampio, niente parametro fortunato. Ri-validato col worker live reale.
## Come funziona
### Volatility Squeeze Breakout
### MR01 — Bollinger Fade (mean-reversion)
Il meccanismo centrale sfrutta i cicli naturali di compressione ed espansione della volatilità:
La strategia attiva sfrutta il fatto, emerso dai dati, che su BTC/ETH a 1h gli estremi
di prezzo **rientrano verso la media** più di quanto proseguano:
1. **Compressione** — le Bollinger Bands entrano dentro i Keltner Channel (il prezzo si muove sempre meno, accumulando "energia").
2. **Breakout** — le bande escono dal canale. Un impulso direzionale parte.
3. **Conferma ML** — un modello GradientBoosting, addestrato su feature strutturali e frattali della finestra precedente, conferma la direzione e filtra i segnali deboli.
1. **Bollinger Bands** (window `n`, `k` deviazioni standard) sul close.
2. **Entry** — quando il close esce *sotto* la banda inferiore → **long** (o *sopra* la superiore → **short**). Ingresso a `close[i]`, eseguibile dal vivo.
3. **Take-profit** alla media mobile (il rientro atteso).
4. **Stop-loss** a `sl_atr × ATR` oltre l'estremo; **time-limit** a `max_bars`.
### Feature frattali
Nessun look-ahead: direzione e livelli sono calcolati con dati fino a `close[i]`.
- Rapporti body/shadow normalizzati su finestre multiple (12, 24, 48 candele)
- Momentum, volatilità, skewness, kurtosis dei rendimenti logaritmici
- Autocorrelazione lag-1
- Profilo volumetrico e spike detection
- Durata della fase di squeeze e rapporto di espansione Keltner
- Posizione del prezzo rispetto al range recente e ATR normalizzato
### Perché lo squeeze breakout è stato abbandonato
L'ipotesi originale era opposta — *continuazione* dopo la compressione di volatilità
(Bollinger dentro Keltner → breakout direzionale). Su dati storici sembrava dare
76-82% di accuracy, ma era un **artefatto di look-ahead**: il backtest entrava a
`close[i-1]` con direzione decisa da `close[i]`. Replicando l'esecuzione reale
(ingresso a `close[i]`) l'edge collassa al ~47% (lancio di moneta) e i costi fanno
il resto. Il test sui breakout intra-barra a 5m conferma che il movimento *rientra*
subito (mean-reversion), giustificando MR01. Tutta la famiglia squeeze è in `scripts/waste/`.
### Lezione metodologica
Ogni nuova strategia deve passare: (1) **ingresso eseguibile** senza look-ahead,
(2) backtest **netto** dopo fee realistiche (0.10% RT Deribit), (3) validazione
**out-of-sample** + robustezza su griglia parametri + sweep fee. Strumenti in
`scripts/analysis/` (`strategy_research.py`, `oos_validation.py`, `intrabar_test.py`).
## Struttura progetto
```
PythagorasGoal/
├── src/
│ ├── data/ # Download e gestione dati storici (Cerbero MCP + Binance)
│ ├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity
│ ├── backtest/ # Motore di backtesting con fee e metriche
│ ├── strategies/ # (predisposto per strategie modulari)
├── nn/ # (predisposto per reti neurali)
│ └── utils/
├── scripts/ # Script di analisi e test (0113)
│ ├── data/ # Download e gestione dati (Cerbero MCP + Binance)
│ ├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity
│ ├── backtest/ # Motore di backtesting con fee e metriche
│ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi
│ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats
│ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr
│ └── live/ # Paper trading live su Deribit testnet
│ ├── multi_runner.py # Orchestratore multi-strategia
│ ├── strategy_worker.py # Worker indipendente con stato persistente
│ ├── strategy_loader.py # Import dinamico classi Strategy
│ ├── cerbero_client.py # Client HTTP per Cerbero MCP
│ ├── signal_engine.py # Squeeze + ML real-time (legacy) + validazione OOS
│ └── telegram_notifier.py
├── scripts/
│ ├── strategies/ # Strategie con edge validato OOS (solo MR01_bollinger_fade)
│ ├── waste/ # Strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD)
│ └── analysis/ # Ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...)
├── strategies.yml # Config multi-strategy paper trader
├── data/
── raw/ # Parquet OHLCV (non committati, ~70 MB)
│ └── processed/ # Modelli salvati
── raw/ # Parquet OHLCV (gitignored, ~70 MB)
├── docs/
── diary/ # Diario di ricerca giornaliero
├── tests/
├── pyproject.toml
── README.md
── diary/ # Diario di ricerca giornaliero
│ └── specs/ # Specifiche di design
├── Dockerfile
── docker-compose.yml
└── pyproject.toml
```
## Strategie attive
Tutte le strategie estendono `src.strategies.base.Strategy` (`generate_signals() → backtest()`).
| Codice | Script | Tipo | Descrizione |
|--------|--------|------|-------------|
| **MR01** | `MR01_bollinger_fade.py` | Mean-reversion | Fada la banda di Bollinger, TP alla media, SL ad ATR. Unica con edge netto validato OOS. |
La famiglia squeeze (SQ01-04, ML01, MT01, PD01, CM01, AD01) è in `scripts/waste/`:
edge storico = artefatto di look-ahead (vedi sezione *Come funziona*).
Per eseguire il backtest della strategia:
```bash
uv run python scripts/strategies/MR01_bollinger_fade.py
```
Per la ricerca/validazione fee-aware out-of-sample:
```bash
uv run python scripts/analysis/strategy_research.py # screening famiglie + deep-dive MR01
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
uv run python scripts/analysis/validate_worker_mr01.py # replay del worker live su MR01
```
## Paper Trading Live
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP, ognuna con €1000 USDC virtuali indipendenti. Se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come MR01), il worker chiude su take-profit alla media / stop-loss ad ATR / time-limit; altrimenti usa il fallback `hold_bars`/stop -2%.
### Avvio
```bash
# Locale
uv run python -m src.live.multi_runner
# Docker
docker compose up -d
```
### Configurazione
Le strategie attive sono definite in `strategies.yml`:
```yaml
defaults:
capital: 1000
position_size: 0.15
leverage: 3
strategies:
- name: MR01_bollinger_fade
asset: BTC
tf: 1h
enabled: true
params:
bb_window: 50
k: 2.5
sl_atr: 2.0
max_bars: 24
```
Per aggiungere una strategia: nuova riga in `strategies.yml`, poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
### Persistenza
Ogni strategia ha la sua directory in `data/paper_trades/`:
```
data/paper_trades/
MR01_bollinger_fade__BTC__1h/
trades.jsonl # Storico trade append-only
status.json # Stato corrente (resume al restart, include tp/sl/max_bars)
```
Notifiche Telegram per ogni trade (richiede `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` in `.env`).
## Setup
```bash
# Clona il repository
# Clona e installa
git clone <repo-url> && cd PythagorasGoal
# Installa dipendenze (richiede uv)
uv sync
# Scarica dati storici (~70 MB, richiede connessione)
# Scarica dati storici (~70 MB)
uv run python -m src.data.downloader
# Esegui la strategia ibrida vincente
uv run python scripts/13_squeeze_ml_hybrid.py
# Backtest strategia attiva
uv run python scripts/strategies/MR01_bollinger_fade.py
# Paper trading live
uv run python -m src.live.multi_runner
```
### Requisiti
- Python ≥ 3.11
- [uv](https://docs.astral.sh/uv/) come package manager
- Accesso a Cerbero MCP (`cerbero-mcp.tielogic.xyz`) per i dati Deribit, oppure Binance via ccxt come fallback
- Accesso a Cerbero MCP (`cerbero-mcp.tielogic.xyz`) per dati Deribit live
- Docker (opzionale, per deploy su VPS)
## Dati
@@ -90,25 +199,7 @@ uv run python scripts/13_squeeze_ml_hybrid.py
| BTC | 5m / 15m / 1h | 883K / 294K / 74K | 2018-01 → oggi |
| ETH | 5m / 15m / 1h | 882K / 294K / 74K | 2018-01 → oggi |
Fonte primaria: Deribit perpetual via Cerbero MCP. Fallback per il periodo antecedente: Binance spot via ccxt. Formato: Apache Parquet.
## Strategie testate
| Script | Approccio | Esito |
|--------|-----------|-------|
| 01 | Pattern candlestick discreti (U/D/0) | Nessun edge |
| 02 | DTW pattern matching | Troppo lento, edge minimo |
| 03 | Proiezione FFT (ispirata al paper) | Random (49.8%) |
| 04 | GBM su feature frattali (Hurst, FD) | 63.6% a soglia 0.65 |
| 05 | GBM multi-window (corretto data leakage) | 58.9% |
| 06 | GBM su feature strutturali normalizzate | 58.6%, +57.5% return |
| 07 | LSTM su sequenze candele | 58.4%, comparabile a GBM |
| 08 | Ensemble multi-timeframe (1h + 15m) | 59.2% (consensus 2/3) |
| 09 | Walk-forward ML | 57.7%, Sharpe 7.4, €3.12/day |
| 10 | Ensemble 5 modelli alta precisione | In corso |
| 11 | **Volatility Squeeze Breakout** | **83.9%**, approccio strutturale |
| 12 | Report finale e simulazione crescita | — |
| 13 | **Squeeze + ML ibrida** | **76.9%**, 118% ann, €13.78/day |
Fonte primaria: Deribit perpetual via Cerbero MCP. Fallback: Binance spot via ccxt. Formato: Apache Parquet.
## Riferimenti
+5 -2
View File
@@ -1,14 +1,17 @@
services:
paper-trader:
build: .
container_name: pythagoras-paper
container_name: pythagoras-multi
restart: unless-stopped
volumes:
- ./data:/app/data
- ./strategies.yml:/app/strategies.yml:ro
env_file:
- .env
environment:
- PYTHONUNBUFFERED=1
healthcheck:
test: ["CMD", "python", "-c", "import json; s=json.load(open('/app/data/paper_trades/status.json')); assert s['last_update']"]
test: ["CMD", "python", "-c", "import os; assert any(f.endswith('status.json') for r,d,fs in os.walk('/app/data/paper_trades') for f in fs)"]
interval: 120s
timeout: 10s
retries: 3
+193
View File
@@ -0,0 +1,193 @@
# 2026-05-28 — Giorno 3: Bug dati Cerbero, paper trader fermo, fix MT01 multi-timeframe
### 12:20 — Sintomo: paper trader live a zero trade
**Cosa:** check del container `pythagoras-multi` (multi-strategy paper trader, 6 strategie).
**Reale:** container healthy da ore, ma **0 trade** su tutte le strategie, tutte FLAT a €1000.
Primo falso indizio: `last_bar_ts: 0` in tutti gli `status.json`. Indagando il worker,
quel campo si aggiorna **solo a posizione aperta** (contatore `hold_bars`), non ad ogni
candela → non è la causa. Il loop era vivo (status.json riscritti ogni 60s).
**Lezione:** non fidarsi del nome di un campo; verificare nel codice quando viene scritto.
L'healthcheck del container controlla solo l'esistenza di `status.json`, non la freschezza
→ un loop bloccato risulterebbe comunque "healthy".
### 12:45 — Causa radice: bug lato Cerbero MCP `get_historical`
**Cosa:** probe dirette all'endpoint `/mcp-deribit/tools/get_historical`.
**Reale:** due bug lato server:
1. **`end_date` data-nuda tronca a mezzanotte:** `end=oggi` restituiva candele solo fino a
`oggi 00:00`. Il `df` live finiva sempre alla barra di mezzanotte e **non avanzava** durante
la giornata → nessun breakout fresco sull'ultima barra → nessun ingresso (condizione worker
`last_signal.idx >= last_idx - 1`).
2. **Cap a ~5000 righe** che ignora `start_date`: una richiesta di 365g a 15m restituiva ~52
giorni. Ecco perché ML01 si addestrava su soli 88 samples (overfit, train_acc 100%).
**Lezione:** lo zero-trade non era nelle strategie ma nel feed dati. Sempre validare la
freschezza/copertura dei dati prima di sospettare la logica.
### 13:30 — Fix lato Cerbero + verifica
**Cosa:** report passato al dev di `cerbero-mcp`; fix deployato (riavvio container) + doc
aggiornata in `cerbero-mcp/docs/API_REFERENCE.md`.
**Reale dopo deploy (verificato con probe):**
- `end=oggi` (data nuda) → ultima candela = ora corrente (age ~3 min). ✅
- 365g a 15m → **35.099 candele**, span 365.6g, nessun cap. ✅
- Supportati anche timestamp con orario (`...T14:00:00`, naive = UTC). ✅
Nostro client (`src/live/cerbero_client.py`) invariato: passa già `end=oggi`, ora corretto.
**Lezione:** "trust but verify" — la doc dichiarava i fix prima che fossero deployati; solo
la probe diretta ha confermato cosa era davvero attivo sul server.
### 14:00 — Problema residuo: MT01 usava un trend 1h STANTIO
**Cosa:** check di tutte le strategie sul percorso di codice reale con dati freschi.
**Reale:**
- Tutte le 6 strategie girano senza crash; SQ01/SQ02 generano molti segnali.
- **MT01 leggeva il trend 1h dal parquet statico** (`load_data(asset,"1h")`), non da Cerbero.
Il parquet finiva a mezzanotte → per ogni barra 15m di oggi `searchsorted` cadeva oltre la
fine e si agganciava sempre alla candela di mezzanotte (gap 14.8h). La conferma
multi-timeframe — il cuore di MT01 — era di fatto congelata e il gap cresce ogni giorno.
- In `data/raw/` mancavano del tutto i parquet **15m** (`btc_15m`, `eth_15m`) → backtest 15m rotti.
**Lezione:** una strategia live che dipende da un file statico ha un punto cieco temporale;
il dato live e quello di backtest devono provenire da fonti coerenti.
### 14:30 — Fix MT01: trend 1h live da Cerbero
**Cosa:** modifica al runner perché MT01 prenda l'1h live, non dal parquet.
- `MT01.generate_signals` accetta un `df_1h` opzionale (fallback al parquet se assente).
- `StrategyWorker.tick(df, df_1h=None)` lo inoltra ai signal.
- `multi_runner` fa fetch 1h live (resolution 60) per gli asset MT01 ad ogni poll (`htf_cache`).
**Reale (verificato a codice montato, pre-rebuild):** gap del trend 1h sull'ultima barra
**0.75h** (fresco) contro **14.8h** col parquet statico. Segnali invariati sullo storico.
**Lezione:** isolare la dipendenza dal file statico rende MT01 immune al drift tra un
`download_all()` e l'altro.
### 14:55 — Rigenerazione dati + rebuild
**Cosa:** `download_asset` per 15m+1h (saltati 1m/5m, lenti e inutilizzati), poi
`docker compose up -d --build` (il codice `src/` è baked nell'immagine).
**Reale:** parquet rigenerati con storia completa 2018→2026 e freschi (15m fino alle 14:45,
1h fino alle 14:00). Container ripartito: 6 strategie attive, ML01 riaddestrato su **534
samples** (anno pieno), MT01 senza errori, fetch 1h live OK.
### 15:00 — Regressione backtest sui dati rigenerati
**Cosa:** rilanciati i backtest per confermare che i numeri documentati si riproducano sui
dati ricreati da zero (BTC/ETH 15m, hold=3, fee 0.2% RT, leva 3x, pos 15%).
**Reale:** accuratezze e drawdown **identici**, solo +1/+3 trade dalle barre recenti in più.
| Strategia | Ottenuto | Documentato | Esito |
|---|---|---|---|
| SQ01 BTC 15m | 76.7% / DD 6.7% / 4063t | 76.7% / 6.7% / 4062 | ✓ |
| SQ01 ETH 15m | 76.4% / 6.2% / 2951t | 76.4% / 6.2% / 2948 | ✓ |
| SQ02 BTC 15m | 79.7% / 6.5% / 1251t | 79.7% / 6.5% / 1250 | ✓ |
| SQ02 ETH 15m | 78.6% / 3.4% / 944t | 78.6% / 3.4% / 942 | ✓ |
| **MT01 BTC 15m (ema20+vol)** | **82.7% / 5.9% / 503t** | 82.7% / 5.9% / 503 | ✓ esatto |
| MT01 ETH 15m (ema20+vol) | 81.2% / 2.9% / 404t | — | ok |
**Lezione:** l'integrità dei dati rigenerati è confermata — la pipeline di download produce
risultati riproducibili. La config live di MT01 (ema20+vol) coincide col best documentato.
### Punti aperti
1. **Backtest e drift dati:** MT01 live ora è immune (1h da Cerbero), ma i backtest girano
sempre sui dati fino all'ultimo `download_all()`. Per dati di backtest sempre freschi
serve uno scheduling del download (cron/job).
2. **Healthcheck:** valutare un check su mtime di `status.json` (< 180s) per rilevare uno
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.000.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 20182021 (bear/covid/caos vol), su ETH DD
pieno 6671%. 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** (`\|closeEMA200\|/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,174 @@
# Multi-Strategy Paper Trader — Design Spec
## Obiettivo
Eseguire N strategie di trading in parallelo su Deribit testnet (paper trading locale), ognuna con capitale virtuale indipendente di €1000 USDC. Lo storico trade di ogni strategia persiste tra restart. Nuove strategie aggiungibili in corso d'opera via config YAML senza perdere lo storico delle esistenti.
## Architettura
Un singolo container Docker esegue un orchestratore (`MultiStrategyRunner`) che gestisce N `StrategyWorker`. Ogni worker è indipendente: proprio capital, propri trade, proprio stato.
```
Docker Container
├── MultiStrategyRunner (orchestratore, loop principale)
│ ├── StrategyWorker[SQ02_BTC_15m] → paper trade → JSONL
│ ├── StrategyWorker[ML01_ETH_15m] → paper trade → JSONL
│ └── ...altri worker da YAML
├── CerberoClient (condiviso, fetch prezzi)
└── TelegramNotifier (condiviso)
```
## Componenti
### 1. `strategies.yml` — Configurazione
```yaml
defaults:
capital: 1000
position_size: 0.15
leverage: 3
hold_bars: 3
poll_seconds: 60
retrain_hours: 24
strategies:
- name: SQ02_antifake_vol
asset: BTC
tf: 15m
enabled: true
- name: SQ02_antifake_vol
asset: ETH
tf: 15m
enabled: true
- name: ML01_squeeze_gbm
asset: ETH
tf: 15m
enabled: true
position_size: 0.20
params:
ml_threshold: 0.70
bb_window: 14
sq_threshold: 0.8
```
Ogni entry eredita `defaults`. Override per-strategia possibile su tutti i campi. Il campo `params` passa kwargs a `generate_signals()` o al backtest ML.
### 2. `StrategyWorker` — Worker per singola strategia
Responsabilità:
- Importa la classe Strategy corrispondente da `scripts/strategies/`
- Mantiene stato: capital, posizione aperta, equity
- Al startup: ricarica `status.json` se esiste (resume), altrimenti inizia da zero
- Ad ogni tick: riceve DataFrame candele, genera segnali, paper-trade
- Logga ogni evento in `trades.jsonl` (append-only)
- Aggiorna `status.json` ad ogni tick
Stato persistente (`status.json`):
```json
{
"capital": 1023.45,
"in_position": true,
"direction": "long",
"entry_price": 2534.20,
"entry_time": "2026-05-27T14:30:00Z",
"bars_held": 1,
"total_trades": 15,
"total_wins": 12,
"started_at": "2026-05-27T10:00:00Z"
}
```
Trade log (`trades.jsonl`), append-only:
```json
{"ts": "2026-05-27T14:30:00Z", "event": "OPEN", "direction": "long", "price": 2534.20, "size": 0.18, "capital": 1023.45}
{"ts": "2026-05-27T15:15:00Z", "event": "CLOSE", "reason": "hold_limit", "entry": 2534.20, "exit": 2560.10, "pnl": 3.45, "fee": 0.92, "net_pnl": 2.53, "capital": 1025.98}
```
### 3. `MultiStrategyRunner` — Orchestratore
Loop principale:
1. Carica `strategies.yml`
2. Per ogni entry, crea `StrategyWorker` (o riprende se già esiste)
3. Ogni 60s:
a. Fetch candele live da Cerbero (una volta per asset/tf unico)
b. Passa DataFrame a ogni worker
c. Ogni worker valuta segnali e gestisce posizione
d. Worker ML: retrain ogni 24h
4. Notifica Telegram per ogni trade
Ottimizzazione: fetch candele raggruppato per (asset, tf). Se 3 strategie usano BTC 15m, fetch una volta sola.
### 4. Persistenza
```
data/paper_trades/
SQ02_antifake_vol__BTC__15m/
trades.jsonl
status.json
SQ02_antifake_vol__ETH__15m/
trades.jsonl
status.json
ML01_squeeze_gbm__ETH__15m/
trades.jsonl
status.json
```
Directory naming: `{strategy_name}__{asset}__{tf}` con double underscore separatore.
Volume Docker: `./data:/app/data` — persiste tra restart.
### 5. Aggiunta strategia in corso
1. Aggiungi entry in `strategies.yml`
2. `docker compose restart`
3. Runner carica YAML, trova nuova entry senza `status.json` → parte da €1000
4. Strategie esistenti riprendono da `status.json` → storico intatto
### 6. Docker
`Dockerfile` — invariato, aggiunge `strategies.yml` alla COPY.
`docker-compose.yml`:
```yaml
services:
paper-trader:
build: .
container_name: pythagoras-multi
restart: unless-stopped
volumes:
- ./data:/app/data
- ./strategies.yml:/app/strategies.yml:ro
env_file:
- .env
environment:
- PYTHONUNBUFFERED=1
```
`CMD` cambia a: `uv run python -m src.live.multi_runner`
### 7. Strategia-specifica: ML01
ML01 richiede training del modello GBM. Il worker ML01:
- Al primo avvio: train su storico (365 giorni via Cerbero)
- Ogni `retrain_hours`: retrain
- Usa `SignalEngine` esistente per check_signal()
- Le strategie SQ* non hanno training — solo regole deterministiche
### 8. File da creare/modificare
Nuovi:
- `src/live/multi_runner.py` — orchestratore
- `src/live/strategy_worker.py` — worker per singola strategia
- `strategies.yml` — config
- `src/live/strategy_loader.py` — import dinamico classi Strategy
Modifiche:
- `docker-compose.yml` — nuovo CMD, volume strategies.yml
- `Dockerfile` — COPY strategies.yml
Invariati:
- `src/live/cerbero_client.py`
- `src/live/telegram_notifier.py`
- `src/live/signal_engine.py` (usato da ML01 worker)
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
"torch>=2.0",
"matplotlib>=3.7",
"tqdm>=4.65",
"pyyaml>=6.0",
]
[project.optional-dependencies]
View File
+309
View File
@@ -0,0 +1,309 @@
"""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%")
+559
View File
@@ -0,0 +1,559 @@
"""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}")
+188
View File
@@ -0,0 +1,188 @@
"""Test ingresso intra-barra: rottura banda squeeze rilevata sul 5m vs close 15m.
Domanda: entrando sul 5m appena il prezzo rompe la banda di Bollinger dello
squeeze (bande dall'ultima barra 15m CHIUSA -> nessun look-ahead), si recupera
parte del movimento che l'ingresso al close della barra 15m si perde?
Confronto a parita' di EXIT (stesso wall-clock): l'unica differenza e' il prezzo
d'ingresso (5m anticipato vs close 15m ritardato). La differenza di rendimento e'
esattamente lo "scatto" del breakout catturato in piu'.
"""
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 src.live.signal_engine import keltner_ratio
OOS_START = "2023-11-20"
BB_W = 14
SQ_THR = 0.8
MIN_DUR = 5
LEV = 3.0
POS = 0.15
M15 = 15 * 60 * 1000
M5 = 5 * 60 * 1000
def build_15m_levels(df15: pd.DataFrame) -> pd.DataFrame:
c = df15["close"].values
h = df15["high"].values
l = df15["low"].values
n = len(c)
kcr = keltner_ratio(c, h, l, BB_W)
ma = np.full(n, np.nan)
sd = np.full(n, np.nan)
for t in range(BB_W, n):
w = c[t - BB_W + 1 : t + 1]
ma[t] = w.mean()
sd[t] = w.std()
upper = ma + 2 * sd
lower = ma - 2 * sd
# durata squeeze consecutiva e maturita'
dur = np.zeros(n, dtype=int)
run = 0
for t in range(n):
if not np.isnan(kcr[t]) and kcr[t] < SQ_THR:
run += 1
else:
run = 0
dur[t] = run
mature = dur >= MIN_DUR
return pd.DataFrame({
"ts15": df15["timestamp"].values,
"close_time15": df15["timestamp"].values + M15,
"close15": c,
"upper": upper,
"lower": lower,
"mature": mature,
})
def run_asset(asset: str, hold_min: int, fee_rt: float) -> dict:
df5 = load_data(asset, "5m").reset_index(drop=True)
df15 = load_data(asset, "15m").reset_index(drop=True)
lvl = build_15m_levels(df15)
d5 = pd.DataFrame({
"ts5": df5["timestamp"].values,
"close_time5": df5["timestamp"].values + M5,
"close5": df5["close"].values,
})
# banda armata: ultima barra 15m CHIUSA prima della chiusura del bar 5m
armed = pd.merge_asof(
d5.sort_values("close_time5"),
lvl[["close_time15", "upper", "lower", "mature"]].sort_values("close_time15"),
left_on="close_time5", right_on="close_time15", direction="backward",
)
# barra 15m CONTENENTE il bar 5m (per l'ingresso ritardato a close 15m)
cont = pd.merge_asof(
d5.sort_values("ts5"),
lvl[["ts15", "close15", "close_time15"]].rename(
columns={"close_time15": "cont_close_time"}).sort_values("ts15"),
left_on="ts5", right_on="ts15", direction="backward",
)
m = armed.copy()
m["cont_close"] = cont["close15"].values
m["cont_close_time"] = cont["cont_close_time"].values
oos_ms = int(pd.Timestamp(OOS_START, tz="UTC").timestamp() * 1000)
close5 = m["close5"].values
ct5 = m["close_time5"].values
upper = m["upper"].values
lower = m["lower"].values
mature = m["mature"].values
cont_close = m["cont_close"].values
cont_ct = m["cont_close_time"].values
n = len(m)
cap_e = cap_l = 1000.0 # equity ingresso early(5m) e late(15m)
peak_e = peak_l = 1000.0
dd_e = dd_l = 0.0
trades = win_e = win_l = 0
thrust_sum = 0.0
fee = fee_rt * LEV
busy_until = -1
for i in range(n):
if ct5[i] < oos_ms or ct5[i] <= busy_until:
continue
if not mature[i] or np.isnan(upper[i]):
continue
if close5[i] > upper[i]:
d = 1
elif close5[i] < lower[i]:
d = -1
else:
continue
entry_e = close5[i]
entry_l = cont_close[i]
exit_time = cont_ct[i] + hold_min * 60 * 1000
# primo close 5m al/oltre exit_time
j = np.searchsorted(ct5, exit_time, side="left")
if j >= n:
break
exit_p = close5[j]
ret_e = ((exit_p - entry_e) / entry_e) * d * LEV - fee
ret_l = ((exit_p - entry_l) / entry_l) * d * LEV - fee
thrust_sum += (entry_l - entry_e) / entry_e * d * 100 # scatto % (no leva)
cb_e, cb_l = cap_e, cap_l
cap_e = max(cb_e + cb_e * POS * ret_e, 10.0)
cap_l = max(cb_l + cb_l * POS * ret_l, 10.0)
peak_e = max(peak_e, cap_e); dd_e = max(dd_e, (peak_e - cap_e) / peak_e)
peak_l = max(peak_l, cap_l); dd_l = max(dd_l, (peak_l - cap_l) / peak_l)
trades += 1
win_e += ret_e > 0
win_l += ret_l > 0
busy_until = exit_time
return {
"trades": trades,
"avg_thrust": thrust_sum / trades if trades else 0.0,
"early_win": win_e / trades * 100 if trades else 0.0,
"late_win": win_l / trades * 100 if trades else 0.0,
"early_ret": (cap_e / 1000 - 1) * 100,
"late_ret": (cap_l / 1000 - 1) * 100,
"early_dd": dd_e * 100,
"late_dd": dd_l * 100,
}
def main():
for fee_rt in (0.002, 0.001):
print("=" * 104)
print(f" INGRESSO INTRA-BARRA 5m vs CLOSE 15m — OOS da {OOS_START} | leva={LEV:.0f}x "
f"| fee={fee_rt*100:.2f}% RT")
print(" EARLY = entra al close 5m che rompe la banda | LATE = entra al close della barra 15m | stesso exit")
print("=" * 104)
print(f" {'Asset':>5s}{'Hold':>6s}{'Trd':>6s}{'Scatto%':>9s}"
f"{'EARLY win%':>12s}{'EARLY ret%':>12s}{'LATE win%':>11s}{'LATE ret%':>11s}{'Δret%':>9s}")
print(" " + "-" * 100)
for asset in ["BTC", "ETH"]:
for hold_min in (15, 30, 45):
r = run_asset(asset, hold_min, fee_rt)
print(f" {asset:>5s}{hold_min:>5d}m{r['trades']:>6d}{r['avg_thrust']:>+9.3f}"
f"{r['early_win']:>12.1f}{r['early_ret']:>+12.1f}"
f"{r['late_win']:>11.1f}{r['late_ret']:>+11.1f}"
f"{r['early_ret']-r['late_ret']:>+9.1f}")
print(" " + "-" * 100)
print(" Scatto% = movimento medio (no leva) catturato tra rottura 5m e close 15m, nella direzione.")
print(" Δret% = vantaggio dell'ingresso anticipato. Se ~0 o negativo, il 5m non aiuta.\n")
if __name__ == "__main__":
main()
+259
View File
@@ -0,0 +1,259 @@
"""Validazione out-of-sample fee-aware di tutte le strategie live.
Per ognuna delle 6 config in strategies.yml:
- split temporale held-out (train = primi (1-test_frac), test = ultimo test_frac)
- ML01 (SignalEngine): allena sul train, predice sul test (come il worker live)
- rule-based: i segnali sono causali, si valutano quelli nella finestra test
- simulazione fedele al worker live: una posizione per volta (non-overlap),
uscita a `hold` barre o stop a -2%, fee round-trip e leva inclusi
Stampa, per ogni config: numero trade nel test, win% lordo e netto, return netto,
costo commissioni, e confronto lordo-vs-netto per isolare l'impatto delle fee.
Usa i parquet locali (data/raw), nessuna chiamata di rete.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import yaml
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from src.live.strategy_loader import load_strategy
from src.live.signal_engine import SignalEngine, keltner_ratio, build_features
TEST_FRAC = 0.30
STOP_PCT = -0.02
def simulate(entries: list[tuple[int, int]], close: np.ndarray, hold: int,
fee_rt: float, lev: float, pos: float,
initial: float = 1000.0, entry_offset: int = 0) -> dict:
"""FSM fedele al worker live: non-overlap, hold N barre o stop -2%.
entry_offset: 0 = ingresso a close[i] (worker live); 1 = close[i-1]
(convenzione del backtest storico, che conosce la direzione di barra i).
"""
n = len(close)
capital = peak = initial
max_dd = 0.0
fees_eur = gross_eur = 0.0
wins_gross = wins_net = n_trades = 0
last_exit = -1
for i, d in entries:
e = i - entry_offset
if e <= last_exit or e < 0 or e + 1 >= n:
continue
entry = close[e]
exit_price = close[min(e + hold, n - 1)]
for k in range(1, hold + 1):
j = e + k
if j >= n:
exit_price = close[n - 1]
break
if k < hold and (close[j] - entry) / entry * d <= STOP_PCT:
exit_price = close[j]
break
if k == hold:
exit_price = close[j]
actual = (exit_price - entry) / entry * d # movimento prezzo * direzione (no leva)
gross = actual * lev
fee = fee_rt * lev
net = gross - fee
cap_before = capital
capital = max(cap_before + cap_before * pos * net, 10.0)
gross_eur += cap_before * pos * gross
fees_eur += cap_before * pos * fee
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
n_trades += 1
wins_gross += actual > 0
wins_net += net > 0
last_exit = e + hold
return {
"trades": n_trades,
"win_gross": wins_gross / n_trades * 100 if n_trades else 0.0,
"win_net": wins_net / n_trades * 100 if n_trades else 0.0,
"net_return_pct": (capital / initial - 1) * 100,
"net_eur": capital - initial,
"gross_eur": gross_eur,
"fees_eur": fees_eur,
"final_capital": capital,
"max_dd": max_dd * 100,
}
def rule_entries(name: str, df: pd.DataFrame, params: dict, split: int) -> list[tuple[int, int]]:
strat = load_strategy(name)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = strat.generate_signals(df, ts, **params)
return [(s.idx, s.direction) for s in sigs if s.idx >= split]
def ml_entries(df: pd.DataFrame, params: dict, split: int, hold: int) -> tuple[list[tuple[int, int]], dict]:
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
ml_thr = params.get("ml_threshold", 0.70)
eng = SignalEngine(bb_w=bb_w, sq_thr=sq_thr, ml_thr=ml_thr)
train_res = eng.train(df.iloc[:split].reset_index(drop=True), lookahead=hold)
if not eng.trained:
return [], train_res
close = df["close"].values
high = df["high"].values
low = df["low"].values
volume = df["volume"].values
n = len(df)
kcr = keltner_ratio(close, high, low, bb_w)
up_idx = list(eng.model.classes_).index(1)
entries: list[tuple[int, int]] = []
in_sq = False
sq_start = 0
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, sq_start = True, i
elif not is_sq and in_sq:
in_sq = False
dur = i - sq_start
if dur < eng.min_squeeze_bars or i < split or i + hold >= n:
continue
avg_vol = float(np.mean(volume[sq_start:i]))
feats = build_features(df, i, dur, avg_vol, kcr[i])
if feats is None:
continue
p_up = eng.model.predict_proba(eng.scaler.transform(feats.reshape(1, -1)))[0][up_idx]
if p_up >= ml_thr:
entries.append((i, 1))
elif p_up <= (1 - ml_thr):
entries.append((i, -1))
return entries, train_res
def squeeze_releases(df: pd.DataFrame, bb_w: int, sq_thr: float, min_dur: int,
split: int) -> list[int]:
"""Indici delle barre di rilascio squeeze nella finestra test (idx >= split)."""
close = df["close"].values
high = df["high"].values
low = df["low"].values
kcr = keltner_ratio(close, high, low, bb_w)
rels: list[int] = []
in_sq = False
sq_start = 0
for i in range(bb_w + 1, len(df)):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq, sq_start = True, i
elif not is_sq and in_sq:
in_sq = False
if i - sq_start >= min_dur and i >= split:
rels.append(i)
return rels
def honest_entries(df: pd.DataFrame, rels: list[int], rule: str, mom: int = 4) -> list[tuple[int, int]]:
"""Direzione da regole honest (solo dati <= i-1) o baseline breakout.
breakout: sign(close[i]-close[i-1]) -> conoscibile solo a close[i] (= live attuale)
premom: sign(close[i-1]-close[i-1-mom]) -> trend pre-release, 100% honest
fade: -sign(close[i]-close[i-1]) -> mean-reversion del breakout
"""
close = df["close"].values
out: list[tuple[int, int]] = []
for i in rels:
if i - 1 - mom < 0:
continue
if rule == "premom":
d = np.sign(close[i - 1] - close[i - 1 - mom])
elif rule == "fade":
d = -np.sign(close[i] - close[i - 1])
else: # breakout
d = np.sign(close[i] - close[i - 1])
if d != 0:
out.append((i, int(d)))
return out
def main():
cfg = yaml.safe_load((PROJECT_ROOT / "strategies.yml").read_text())
defaults = cfg.get("defaults", {})
hold = defaults.get("hold_bars", 3)
lev = defaults.get("leverage", 3)
fee_rt = 0.002
fee_grid = [0.0, 0.0005, 0.001, 0.0015, 0.002]
# ---- (b) SENSIBILITA' ALLE FEE (config live, ingresso close[i]) ----
print("=" * 104)
print(f" (b) SENSIBILITA' ALLE FEE — config live, ingresso close[i] | OOS {int(TEST_FRAC*100)}% | hold={hold} leva={lev}x")
print("=" * 104)
print(f" {'Strategia':<26s}{'Asset':>5s}{'Trd':>5s}{'Lordo€':>9s}"
+ "".join(f"{f'{f*100:.2f}%':>10s}" for f in fee_grid))
print(" " + "-" * 100)
for entry in cfg.get("strategies", []):
if not entry.get("enabled", True):
continue
name, asset, tf = entry["name"], entry["asset"], entry["tf"]
pos = entry.get("position_size", defaults.get("position_size", 0.15))
params = dict(entry.get("params", {}))
params["asset"], params["tf"] = asset, tf
df = load_data(asset, tf).reset_index(drop=True)
split = int(len(df) * (1 - TEST_FRAC))
close = df["close"].values
entries = (ml_entries(df, params, split, hold)[0] if name.startswith("ML01")
else rule_entries(name, df, params, split))
gross = simulate(entries, close, hold, 0.0, lev, pos)["net_eur"]
rets = [simulate(entries, close, hold, f, lev, pos)["net_return_pct"] for f in fee_grid]
print(f" {name:<26s}{asset:>5s}{len(entries):>5d}{gross:>+9.0f}"
+ "".join(f"{r:>+10.1f}" for r in rets))
print(" " + "-" * 100)
print(" Colonne = Ret% netto al variare della fee RT. 0.00% isola l'edge puro (senza costi).")
print(" Deribit perp reale: taker ~0.10% RT, maker ~0%. Il modello live usa 0.20% RT.")
# ---- (a) HONEST-ENTRY squeeze: direzione decisa <= i-1, ingresso close[i] ----
print("\n" + "=" * 104)
print(f" (a) HONEST-ENTRY squeeze (bb14 sq0.8 dur>=5) — ingresso close[i], fee={fee_rt*100:.1f}% RT")
print("=" * 104)
print(f" {'Asset':>5s}{'Regola direzione':>20s}{'Trd':>6s}{'Win%g':>8s}{'Win%n':>8s}{'Netto€':>9s}{'Ret%':>9s}{'DD%':>7s}")
print(" " + "-" * 100)
rules = [("breakout (=live)", "breakout"), ("pre-trend mom4", "premom"),
("pre-trend mom8", "premom8"), ("fade breakout", "fade")]
for asset in ["BTC", "ETH"]:
df = load_data(asset, "15m").reset_index(drop=True)
split = int(len(df) * (1 - TEST_FRAC))
close = df["close"].values
rels = squeeze_releases(df, 14, 0.8, 5, split)
for label, rule in rules:
mom = 8 if rule == "premom8" else 4
ents = honest_entries(df, rels, "premom" if rule == "premom8" else rule, mom=mom)
r = simulate(ents, close, hold, fee_rt, lev, 0.15)
print(f" {asset:>5s}{label:>20s}{r['trades']:>6d}{r['win_gross']:>8.1f}"
f"{r['win_net']:>8.1f}{r['net_eur']:>+9.0f}{r['net_return_pct']:>+9.1f}{r['max_dd']:>7.1f}")
print(" " + "-" * 100)
print(" pre-trend = direzione dal trend PRIMA del rilascio (solo dati <= i-1): 100% honest.")
print(" Se nessuna regola honest batte ~breakeven, non esiste edge direzionale tradeable.")
if __name__ == "__main__":
main()
+139
View File
@@ -0,0 +1,139 @@
"""Migliorare Acc e ridurre DD sulle fade (MR01/MR02/MR03/MR07) senza overfit.
Leve testate, ognuna misurata FULL e OOS (ultimo 30%) per non illudersi:
- vol-target sizing: size per trade ~ 1/distanza-SL -> rischio costante, DD piu' liscio
- filtro vol regime: salta i trade con ATR% in coda alta (periodi caotici)
- filtro anti-trend: non fadare contro un trend forte (|close-EMA_long|/ATR grande)
- portfolio: equity curve combinata delle 4 strategie su un conto unico
Engine fedele (ingresso close[i], exit TP/SL intrabar o time-limit, non-overlap,
capitale composto) con sizing per-trade. 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, keltner_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)
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)),
"MR03": (keltner_fade, dict(n=30, k=2.0, 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_ETH3 = dict(STRATS); STRATS_ETH3["MR03"] = (keltner_fade, dict(n=50, k=2.0, sl_atr=2.0, max_bars=24))
def add_context(ents, df, ema_long=200):
"""Aggiunge a ogni entry: sl_dist_pct, 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 main():
for asset in ["BTC", "ETH"]:
df = load_data(asset, "1h")
split = int(len(df) * (1 - OOS_FRAC))
table = STRATS_ETH3 if asset == "ETH" else STRATS
# quantili vol globali per la soglia (p90)
print("\n" + "=" * 110)
print(f" {asset} 1h — leve di riduzione DD / aumento Acc | NETTO fee 0.10% RT, leva 3x")
print("=" * 110)
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 table.items():
ents = add_context(fn(df, **params), df)
apct = np.array([e["atr_pct"] for e in ents])
p85 = float(np.quantile(apct, 0.85))
tdist = np.array([e["trend_dist"] for e in ents])
t90 = float(np.quantile(tdist, 0.90))
base_f = simulate(ents, df); base_o = simulate(ents, df, split=split)
line(f"{nm} base", base_f, base_o)
vt_f = simulate(ents, df, sizer=vol_target_sizer()); vt_o = simulate(ents, df, split=split, sizer=vol_target_sizer())
line(f"{nm} +volTarget", vt_f, vt_o)
vs_f = simulate(ents, df, vol_skip=p85); vs_o = simulate(ents, df, split=split, vol_skip=p85)
line(f"{nm} +volSkip(p85)", vs_f, vs_o)
ts_f = simulate(ents, df, trend_skip=t90); ts_o = simulate(ents, df, split=split, trend_skip=t90)
line(f"{nm} +trendSkip(p90)", ts_f, ts_o)
allf = simulate(ents, df, sizer=vol_target_sizer(), vol_skip=p85, trend_skip=t90)
allo = simulate(ents, df, split=split, sizer=vol_target_sizer(), vol_skip=p85, trend_skip=t90)
line(f"{nm} +ALL", allf, allo)
print(" " + "-" * 106)
print("\n Shrp = Sharpe annuo-naive sui ritorni per-trade. oXxx = stessa metrica su OOS (ultimo 30%).")
if __name__ == "__main__":
main()
+163
View File
@@ -0,0 +1,163 @@
"""Affina il filtro trend (soglia assoluta ATR) e costruisce il portafoglio combinato.
Due risultati:
(1) trend filter: salta le fade quando |close-EMA200|/ATR > soglia (non fadare un
trend estremo). Soglia ASSOLUTA in multipli di ATR -> stessa regola per tutte
le strategie/asset, basso rischio di overfit. Sweep soglie, FULL e OOS.
(2) portafoglio: equity curve combinata delle 4 strategie sullo stesso conto
(rischio diviso fra N posizioni). Curve poco correlate -> DD aggregato << DD
della singola strategia. Confronto singola vs portafoglio, con/senza filtro.
"""
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, keltner_fade, return_reversal
FEE_RT, LEV, INIT, OOS_FRAC = 0.001, 3.0, 1000.0, 0.30
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)),
"MR03": (keltner_fade, dict(n=30, k=2.0, 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); STRATS_ETH["MR03"] = (keltner_fade, dict(n=50, k=2.0, sl_atr=2.0, max_bars=24))
def build_trades(ents, df, lev=LEV, fee_rt=FEE_RT, trend_max=None, ema_long=200):
"""Ritorna 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, ts, pos=0.15, 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=0.15, split=-1):
"""Equity curve di uno sleeve su sotto-conto indipendente (capitale INIT, pos fissa).
Ritorna array lungo n_bars (step aggiornato alla chiusura di ogni trade)."""
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 # da j in poi il sotto-conto vale cap
return eq
def metrics_portfolio(strat_trades, n_bars, ts, pos=0.15, split=-1):
"""Portafoglio equipesato: capitale diviso in N sotto-conti indipendenti, ciascuno
con la sua strategia a `pos` fisso. Equity aggregata = media dei sotto-conti (somma
normalizzata a base INIT). DD misurato sull'equity aggregata. Niente leva sovrapposta."""
sleeves = [sleeve_equity(tr, n_bars, pos=pos, split=split) for tr in strat_trades.values()]
agg = np.mean(sleeves, axis=0) # media -> base INIT, diversificazione reale
# restringi alla finestra effettiva (da split in poi se OOS)
lo = max(split, 0)
agg = agg[lo:]
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, sharpe=0.0)
def main():
# ---------- (1) sweep soglia trend ----------
print("=" * 104)
print(" (1) 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)
thresholds = [None, 4.0, 3.0, 2.5, 2.0]
for asset in ["BTC", "ETH"]:
df = load_data(asset, "1h"); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
split = int(len(df) * (1 - OOS_FRAC))
table = STRATS_ETH if asset == "ETH" else STRATS
for nm, (fn, params) in table.items():
ents = fn(df, **params)
for thr in thresholds:
tr = build_trades(ents, df, trend_max=thr)
f = metrics_single(tr, ts); o = metrics_single(tr, ts, 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)
# ---------- (2) portafoglio combinato ----------
print("\n" + "=" * 104)
print(" (2) PORTAFOGLIO equipesato: capitale diviso in N sotto-conti indipendenti")
print(" (pos 0.15 ciascuno, filtro trend 3.0 ATR). DD aggregato vs singola strategia.")
print("=" * 104)
print(f" {'Universo':<26s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>10s}{'DD%':>7s}{'':>7s}"
f" | {'oAcc':>6s}{'oRet':>9s}{'oDD':>7s}{'':>7s}")
print(" " + "-" * 100)
all_trades = {}
for asset in ["BTC", "ETH"]:
df = load_data(asset, "1h"); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
split = int(len(df) * (1 - OOS_FRAC)); n = len(df)
table = STRATS_ETH if asset == "ETH" else STRATS
st = {f"{nm}_{asset}": build_trades(fn(df, **p), df, trend_max=3.0) for nm, (fn, p) in table.items()}
all_trades.update(st)
f = metrics_portfolio(st, n, ts); o = metrics_portfolio(st, n, ts, 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['sharpe']:>7.2f}"
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
# globale 8 sleeve
df0 = load_data("BTC", "1h"); ts0 = pd.to_datetime(df0["timestamp"], unit="ms", utc=True)
split0 = int(len(df0) * (1 - OOS_FRAC))
f = metrics_portfolio(all_trades, len(df0), ts0); o = metrics_portfolio(all_trades, len(df0), ts0, 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['sharpe']:>7.2f}"
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
print("\n Nota: ogni sleeve gira su un sotto-conto indipendente (pos 0.15); l'equity di")
print(" portafoglio e' la media dei sotto-conti. Curve poco correlate => DD aggregato")
print(" molto piu' basso del DD del singolo sleeve (la vera leva anti-drawdown).")
if __name__ == "__main__":
main()
+258
View File
@@ -0,0 +1,258 @@
"""Ricerca strategie fee-aware, OOS, oltre la famiglia squeeze.
Lezioni apprese (squeeze breakout = nessun edge):
- le FEE sono vincolo di prim'ordine -> default fee realistica Deribit 0.10% RT
(taker 0.05%/lato, maker ~0%); poche operazioni meglio di molte
- i breakout RIENTRANO -> si esplora mean-reversion, non continuation
- ogni numero e' NETTO dopo fee+leva, su finestra held-out + per anno
Engine realistico: ingresso a close[i] (eseguibile), uscita su TP/SL intrabar
(high/low) o time-limit, una posizione per volta (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))
from src.data.downloader import load_data
FEE_RT = 0.001 # Deribit perp realistico: taker 0.05%/lato
LEV = 3.0
POS = 0.15
OOS_FRAC = 0.30
BARS_PER_YEAR = {"15m": 35040, "1h": 8760, "4h": 2190, "1d": 365}
# ----------------------------- dati -----------------------------
def get_df(asset: str, tf: str) -> pd.DataFrame:
"""tf nativo (15m,1h) o resample da 1h (4h,1d)."""
if tf in ("15m", "1h"):
return load_data(asset, tf).reset_index(drop=True)
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()
agg["timestamp"] = agg.index.asi8 // 10**6
return agg.reset_index(drop=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 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) -> dict:
"""entries: dict con i(idx), d(+1/-1), tp(prezzo), sl(prezzo), max_bars."""
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c)
cap = peak = 1000.0
max_dd = 0.0
fee = fee_rt * lev
trades = wins = 0
last_exit = -1
bars_in = 0
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
yearly: dict[int, float] = {}
for e in entries:
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)]
for k in range(1, mb + 1):
j = i + k
if j >= n:
exit_p = c[n - 1]; 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 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 += min(mb, j - i)
last_exit = j
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
return {
"trades": trades,
"win": wins / trades * 100 if trades else 0.0,
"ret": (cap / 1000 - 1) * 100,
"dd": max_dd * 100,
"yearly": yearly,
"exposure": bars_in / n * 100,
}
# --------------------------- strategie ---------------------------
def bollinger_fade(df, n=20, k=2.0, sl_atr=2.0, max_bars=24):
"""Mean-reversion: fada il close oltre la banda, TP alla media, SL = k_atr*ATR."""
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]: # appena sotto la banda
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=30, hi=70, sl_atr=2.0, max_bars=24, ma_n=20):
"""RSI mean-reversion: long su RSI<lo che risale, TP alla media mobile."""
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 donchian_trend(df, n=20, sl_atr=2.0, tp_atr=6.0, max_bars=120):
"""Trend-following: breakout canale Donchian, TP/SL in multipli di ATR."""
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
if c[i] > hh[i]:
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 c[i] < ll[i]:
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
STRATS = {
"BOLL_fade k2 m24": (bollinger_fade, dict(n=20, k=2.0, sl_atr=2.0, max_bars=24)),
"BOLL_fade k2.5 m24": (bollinger_fade, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)),
"RSI_revert 30/70": (rsi_revert, dict(n=14, lo=30, hi=70, sl_atr=2.0, max_bars=24)),
"RSI_revert 25/75": (rsi_revert, dict(n=14, lo=25, hi=75, sl_atr=2.0, max_bars=24)),
"DONCH_trend n20": (donchian_trend, dict(n=20, sl_atr=2.0, tp_atr=6.0, max_bars=120)),
"DONCH_trend n50": (donchian_trend, dict(n=50, sl_atr=2.0, tp_atr=8.0, max_bars=200)),
}
def deep_dive():
print("\n" + "#" * 120)
print(" APPROFONDIMENTO BOLLINGER FADE (mean-reversion) — l'unica famiglia con edge netto")
print("#" * 120)
cases = [("BTC", "1h"), ("ETH", "1h"), ("BTC", "4h"), ("ETH", "4h")]
base = dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)
# --- per anno (config base k2.5/n20) ---
print(f"\n [1] PnL NETTO per anno — n=20 k=2.5 sl=2ATR | fee {FEE_RT*100:.2f}% RT")
all_years = sorted({y for a, tf in cases for y in simulate(bollinger_fade(get_df(a, tf), **base), get_df(a, tf))["yearly"]})
print(f" {'Asset/TF':<10s}" + "".join(f"{y:>8d}" for y in all_years) + f"{'TOT%':>9s}{'DD%':>6s}")
for a, tf in cases:
df = get_df(a, tf)
r = simulate(bollinger_fade(df, **base), df)
row = "".join(f"{r['yearly'].get(y, 0):>+8.0f}" for y in all_years)
print(f" {a+' '+tf:<10s}{row}{r['ret']:>+9.0f}{r['dd']:>6.0f}")
# --- sensibilita' fee ---
print(f"\n [2] SENSIBILITA' FEE — Ret% FULL / OOS (n=20 k=2.5)")
fees = [0.0, 0.0005, 0.001, 0.002]
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 cases:
df = get_df(a, tf)
ents = bollinger_fade(df, **base)
split = int(len(df) * (1 - OOS_FRAC))
oents = [e for e in ents if e["i"] >= split]
cells = ""
for f in fees:
cells += f"{simulate(ents, df, fee_rt=f)['ret']:>+11.0f}{simulate(oents, df, fee_rt=f)['ret']:>+11.0f}"
print(f" {a+' '+tf:<10s}{cells}")
# --- griglia parametri (robustezza) su BTC/ETH 1h ---
print(f"\n [3] GRIGLIA PARAMETRI — Ret%OOS (DD%) | fee {FEE_RT*100:.2f}% RT, deve essere stabile")
for a in ["BTC", "ETH"]:
df = get_df(a, "1h")
split = int(len(df) * (1 - OOS_FRAC))
print(f"\n {a} 1h " + "".join(f"{f'k={k}':>16s}" for k in [2.0, 2.5, 3.0]))
for n in [14, 20, 30, 50]:
cells = ""
for k in [2.0, 2.5, 3.0]:
ents = [e for e in bollinger_fade(df, n=n, k=k, sl_atr=2.0, max_bars=24) if e["i"] >= split]
r = simulate(ents, df)
cell = f"{r['ret']:+.0f}({r['dd']:.0f})"
cells += f"{cell:>16s}"
print(f" n={n:<4d}{cells}")
def main():
print("=" * 120)
print(f" RICERCA STRATEGIE — NETTO dopo fee {FEE_RT*100:.2f}% RT | leva {LEV:.0f}x | pos {POS*100:.0f}% "
f"| OOS = ultimo {int(OOS_FRAC*100)}%")
print("=" * 120)
print(f" {'Strategia':<20s}{'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(" " + "-" * 116)
for label, (fn, params) in STRATS.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)
flag = " <<<" if oos["ret"] > 0 and full["ret"] > 0 and pos_yrs >= max(len(yrs) - 1, 1) else ""
print(f" {label:<20s}{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(" " + "-" * 116)
print(" Ret%FULL/OOS = ritorno NETTO composto su €1000. AnniPos = anni con PnL netto>0.")
print(" <<< = positivo full+OOS e robusto (quasi tutti gli anni positivi).")
deep_dive()
if __name__ == "__main__":
main()
+306
View File
@@ -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()
+69
View File
@@ -0,0 +1,69 @@
"""Re-validazione: il StrategyWorker REALE tradi MR01 con edge netto?
Guida il worker vero (generate_signals + nuova logica exit TP/SL/max_bars) su
finestre mobili di dati 1h storici, simulando il polling live. Conferma che
sulla finestra OOS l'edge netto (dopo fee 0.10% RT) sopravvive alla meccanica
del worker (exit su prezzo corrente, piu' conservativa del backtest high/low).
"""
from __future__ import annotations
import contextlib
import os
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from src.live.strategy_loader import load_strategy
from src.live.strategy_worker import StrategyWorker
OOS_FRAC = 0.30
WIN = 250 # barre per finestra di poll (warmup bb_window=50 + ATR)
def replay(asset: str, params: dict):
df = load_data(asset, "1h").reset_index(drop=True)
n = len(df)
split = int(n * (1 - OOS_FRAC))
strat = load_strategy("MR01_bollinger_fade")
w = StrategyWorker(strat, asset, "1h", capital=1000.0, position_size=0.15,
leverage=3.0, hold_bars=3, params=params,
data_dir=Path(f"/tmp/replay_{asset}"))
w._notify = lambda *a, **k: None
# stato pulito
for attr, val in dict(capital=1000.0, in_position=False, direction=0, entry_price=0,
bars_held=0, total_trades=0, total_wins=0, last_bar_ts=0,
tp=0.0, sl=0.0, max_bars=0).items():
setattr(w, attr, val)
start = max(split, WIN)
with contextlib.redirect_stdout(open(os.devnull, "w")):
for j in range(start, n):
w.tick(df.iloc[j - WIN + 1 : j + 1])
ret = (w.capital / 1000 - 1) * 100
acc = w.total_wins / w.total_trades * 100 if w.total_trades else 0.0
import pandas as pd
period = (f"{pd.to_datetime(df['timestamp'].iloc[start], unit='ms', utc=True).date()}"
f"->{pd.to_datetime(df['timestamp'].iloc[-1], unit='ms', utc=True).date()}")
return w.total_trades, acc, ret, w.capital, period
def main():
print("=" * 90)
print(" RE-VALIDAZIONE WORKER REALE su MR01 (OOS, fee 0.10% RT, leva 3x) — finestra poll 250b")
print("=" * 90)
params = dict(bb_window=50, k=2.5, sl_atr=2.0, max_bars=24)
print(f" {'Asset':>6s}{'Periodo OOS':>26s}{'Trade':>7s}{'Win%':>7s}{'Ret%':>9s}{'Cap€':>9s}")
print(" " + "-" * 80)
for asset in ["BTC", "ETH"]:
t, acc, ret, cap, period = replay(asset, params)
print(f" {asset:>6s}{period:>26s}{t:>7d}{acc:>7.1f}{ret:>+9.1f}{cap:>9.0f}")
print(" " + "-" * 80)
print(" Atteso: Ret% positivo (l'edge mean-reversion sopravvive alla meccanica del worker).")
if __name__ == "__main__":
main()
+169
View File
@@ -0,0 +1,169 @@
"""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()
+172
View File
@@ -0,0 +1,172 @@
"""MR01 — Bollinger Fade (mean-reversion).
L'UNICA famiglia con edge netto reale dopo l'analisi out-of-sample fee-aware
(vedi scripts/analysis/strategy_research.py). Contrario della tesi squeeze:
i breakout RIENTRANO, quindi si fada l'estremo verso la media.
Logica:
1. Bollinger Band (window n, k deviazioni) sul close
2. ENTRY: close esce sotto la banda inferiore -> long (o sopra la superiore -> short)
3. EXIT: take-profit alla media mobile (il rientro atteso),
stop-loss a sl_atr*ATR oltre l'estremo, oppure time-limit max_bars
4. ingresso a close[i] (eseguibile dal vivo, nessun look-ahead)
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
BTC 1h n=50 k=2.5: +201% OOS, DD 15%, ~tutti gli anni positivi
ETH 1h n=50 k=2.0: +1238% OOS, DD 23%
Robusto su TUTTA la griglia n in {14,20,30,50} x k in {2.0,2.5,3.0}
e su tutte le fee 0.00-0.20% RT (margine di sicurezza ampio).
NOTA LIVE: usa TP alla media + SL ad ATR + max_bars. Lo StrategyWorker attuale
esce solo a hold_bars/stop -2% fisso: per tradarla come validata il worker deve
supportare gli exit TP/SL passati in metadata (vedi metadata di ogni Signal).
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal, 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
class BollingerFade(Strategy):
name = "MR01_bollinger_fade"
description = "Mean-reversion: fada la banda di Bollinger, TP alla media"
default_assets = ["BTC", "ETH"]
default_timeframes = ["1h"]
fee_rt = 0.001 # Deribit perp realistico (taker 0.05%/lato)
leverage = 3.0
position_size = 0.15
initial_capital = 1000.0
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
n_len = len(c)
bb_w = params.get("bb_window", 50)
k = params.get("k", 2.5)
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)
ma = pd.Series(c).rolling(bb_w).mean().values
sd = pd.Series(c).rolling(bb_w).std().values
a = _atr(df, 14)
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] = []
for i in range(bb_w + 14, n_len):
if np.isnan(up[i]) or np.isnan(a[i]):
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]:
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(ma[i]), "sl": float(sl), "max_bars": max_bars},
))
return signals
def backtest(self, asset: str, tf: str = "1h", hold: int = 3,
**params) -> BacktestResult | None:
"""Backtest fedele: TP alla media / SL ad ATR / time-limit, fee+leva nette."""
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:
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,
)
if __name__ == "__main__":
strat = BollingerFade()
print(f"{'=' * 110}")
print(f" MR01 BOLLINGER FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
print(f"{'=' * 110}")
results = []
for asset in ["BTC", "ETH"]:
for k in [2.0, 2.5]:
r = strat.backtest(asset, "1h", bb_window=50, k=k, sl_atr=2.0, max_bars=24)
if r:
r.strategy_name = f"MR01 {asset} 1h n50 k{k}"
results.append(r)
for r in results:
r.print_summary()
if results:
results[0].print_yearly()
+82
View File
@@ -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()
+82
View File
@@ -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()
@@ -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()
+205
View File
@@ -0,0 +1,205 @@
"""AD01 — Adaptive Squeeze Threshold.
Problema SQ02: sq_threshold fisso (0.8) non si adatta al regime di volatilità.
Soluzione: threshold adattivo basato su volatilità recente.
Logica:
- Calcola volatilità rolling (std dei rendimenti su finestra 100 barre)
- Confronta con percentile storico (rolling 500 barre)
- Alta vol (>70° percentile) soglia BASSA (0.65) squeeze più "lenti"
- Bassa vol (<30° percentile) soglia ALTA (0.90) squeeze "stretti"
- Vol media soglia standard (0.80)
Razionale: in mercati calmi, il BB si stringe molto sq_threshold alto cattura
segnali migliori. In mercati volatili, bastano squeeze minori per essere significativi.
Anti-overfitting: solo 3 parametri (low_thr, mid_thr, high_thr), logica deterministica.
Eredita antifakeout + volume da SQ02.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
from src.strategies.indicators import keltner_ratio, ema
from src.data.downloader import load_data
def _adaptive_sq_threshold(close: np.ndarray,
vol_window: int = 100,
regime_window: int = 500,
low_thr: float = 0.65,
mid_thr: float = 0.80,
high_thr: float = 0.90) -> np.ndarray:
"""Calcola sq_threshold adattivo per ogni barra."""
n = len(close)
lr = np.diff(np.log(np.where(close <= 0, 1e-10, close)))
vol = np.full(n, np.nan)
for i in range(vol_window, n):
vol[i] = np.std(lr[i - vol_window:i])
# Percentile rolling della volatilità
thresh = np.full(n, mid_thr)
for i in range(regime_window, n):
if np.isnan(vol[i]):
continue
hist = vol[i - regime_window:i]
hist = hist[~np.isnan(hist)]
if len(hist) < 10:
continue
p30 = np.percentile(hist, 30)
p70 = np.percentile(hist, 70)
if vol[i] < p30:
thresh[i] = high_thr # vol bassa → soglia alta
elif vol[i] > p70:
thresh[i] = low_thr # vol alta → soglia bassa
else:
thresh[i] = mid_thr
return thresh
def _detect_adaptive_squeezes(close, high, low, kcr, adaptive_thr,
min_dur: int = 5) -> list[dict]:
"""Squeeze con threshold adattivo per ogni barra."""
events = []
in_sq = False
sq_start = 0
for i in range(1, len(close)):
if np.isnan(kcr[i]) or np.isnan(adaptive_thr[i]):
continue
thr = adaptive_thr[i]
is_sq = kcr[i] < 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,
"kcr_at_release": kcr[i],
"thr_used": adaptive_thr[i],
})
return events
class AdaptiveSqueeze(Strategy):
name = "AD01_adaptive_squeeze"
description = "Squeeze con threshold adattivo a regime volatilità"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
leverage = 3.0
position_size = 0.15
initial_capital = 1000.0
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
low_thr = params.get("low_thr", 0.65)
mid_thr = params.get("mid_thr", 0.80)
high_thr = params.get("high_thr", 0.90)
retrace_limit = params.get("retrace_limit", 0.6)
vol_mult = params.get("vol_multiplier", 1.3)
use_vol = params.get("use_vol", True)
vol_window = params.get("vol_window", 100)
regime_window = params.get("regime_window", 500)
kcr = keltner_ratio(c, h, l, bb_w)
adaptive_thr = _adaptive_sq_threshold(
c, vol_window, regime_window, low_thr, mid_thr, high_thr
)
events = _detect_adaptive_squeezes(c, h, l, kcr, adaptive_thr)
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= 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
# Anti-fakeout
br = h[i] - l[i]
if br > 0:
if direction == 1 and (h[i] - c[i]) / br > retrace_limit:
continue
elif direction == -1 and (c[i] - l[i]) / br > retrace_limit:
continue
# Volume confirm
if use_vol:
sq_start = ev["sq_start"]
avg_sq_v = np.mean(v[sq_start:i])
if avg_sq_v > 0 and v[i] <= avg_sq_v * vol_mult:
continue
signals.append(Signal(
idx=i,
direction=direction,
entry_price=c[i - 1],
metadata={
"dur": ev["dur"],
"thr_used": ev.get("thr_used", mid_thr),
},
))
return signals
if __name__ == "__main__":
strategy = AdaptiveSqueeze()
configs = [
# low_thr, mid_thr, high_thr, use_vol
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.90, "use_vol": True},
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.90, "use_vol": False},
{"low_thr": 0.60, "mid_thr": 0.78, "high_thr": 0.92, "use_vol": True},
{"low_thr": 0.70, "mid_thr": 0.82, "high_thr": 0.90, "use_vol": True},
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.95, "use_vol": True},
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.90,
"use_vol": True, "vol_multiplier": 1.2},
]
all_results = []
for cfg in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **cfg)
if r and r.trades >= 20:
lbl = (f"AD01 lt={cfg['low_thr']} ht={cfg['high_thr']} "
f"v={cfg['use_vol']} h={hold}")
r.strategy_name = lbl
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(" AD01 ADAPTIVE SQUEEZE THRESHOLD — TOP 20")
print(f"{'=' * 130}")
print(f" {'Nome':<50s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Anni':>4s}")
print(f" {'' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250t, DD 6.5%, €5.23/day, 9 anni")
print(f" BENCHMARK MT01: 82.7% acc, 503t, DD 5.9%")
+183
View File
@@ -0,0 +1,183 @@
"""CM01 — Cross-Market Momentum Filter.
Squeeze su asset primario, entra SOLO se l'altro asset (BTC↔ETH)
mostra momentum short-term nella STESSA direzione.
Differenza da MT01: MT01 usa EMA slope su 1h (trend lento).
CM01 usa rendimento grezzo degli ultimi 3-6 bar sull'asset cross
(momentum veloce, stesso timeframe).
Razionale: BTC e ETH sono altamente correlati ma non perfettamente.
Se BTC fa squeeze breakout UP e anche ETH sta salendo (momentum 3-6 bar),
la probabilità di continuazione è maggiore perché c'è consenso di mercato.
Anti-overfitting: 1 parametro chiave (cross_bars 3-6), logica deterministica.
Eredita antifakeout + volume da SQ02.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats
from src.strategies.indicators import keltner_ratio, detect_squeezes
from src.data.downloader import load_data
class CrossMarketMomentum(Strategy):
name = "CM01_cross_momentum"
description = "Squeeze + cross-asset short-term momentum filter"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
leverage = 3.0
position_size = 0.15
initial_capital = 1000.0
# Map asset → cross asset
_CROSS = {"BTC": "ETH", "ETH": "BTC"}
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
"""Genera segnali con cross-market momentum."""
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
ts_ms = df["timestamp"].values
asset = params.get("asset", "BTC")
tf = params.get("tf", "15m")
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
retrace_limit = params.get("retrace_limit", 0.6)
vol_mult = params.get("vol_multiplier", 1.3)
use_vol = params.get("use_vol", True)
cross_bars = params.get("cross_bars", 4) # barre momentum cross
mom_min = params.get("mom_min", 0.0) # momentum minimo (0 = solo direzione)
# Carica cross asset
cross_asset = self._CROSS.get(asset)
if cross_asset is None:
return []
try:
df_cross = load_data(cross_asset, tf)
except Exception:
return []
c_cross = df_cross["close"].values
ts_cross_ms = df_cross["timestamp"].values
n_cross = len(c_cross)
# Momentum cross: rendimento log su cross_bars barre
cross_mom = np.full(n_cross, np.nan)
for i in range(cross_bars, n_cross):
if c_cross[i - cross_bars] > 0:
cross_mom[i] = np.log(c_cross[i] / c_cross[i - cross_bars])
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= 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
# Anti-fakeout
br = h[i] - l[i]
if br > 0:
if direction == 1 and (h[i] - c[i]) / br > retrace_limit:
continue
elif direction == -1 and (c[i] - l[i]) / br > retrace_limit:
continue
# Volume confirm
if use_vol:
sq_start = ev["sq_start"]
avg_sq_v = np.mean(v[sq_start:i])
if avg_sq_v > 0 and v[i] <= avg_sq_v * vol_mult:
continue
# Cross-market momentum: trova indice cross corrispondente
i_cross = np.searchsorted(ts_cross_ms, ts_ms[i]) - 1
if i_cross < cross_bars or i_cross >= n_cross:
continue
mom = cross_mom[i_cross]
if np.isnan(mom):
continue
# Filtra per direzione concordante
if direction == 1 and mom <= mom_min:
continue
if direction == -1 and mom >= -mom_min:
continue
signals.append(Signal(
idx=i,
direction=direction,
entry_price=c[i - 1],
metadata={
"dur": ev["dur"],
"cross_mom": float(mom),
},
))
return signals
if __name__ == "__main__":
strategy = CrossMarketMomentum()
configs = [
# cross_bars, mom_min, use_vol
{"cross_bars": 3, "mom_min": 0.0, "use_vol": True},
{"cross_bars": 4, "mom_min": 0.0, "use_vol": True},
{"cross_bars": 6, "mom_min": 0.0, "use_vol": True},
{"cross_bars": 4, "mom_min": 0.001, "use_vol": True},
{"cross_bars": 4, "mom_min": 0.002, "use_vol": True},
{"cross_bars": 4, "mom_min": 0.0, "use_vol": False},
{"cross_bars": 3, "mom_min": 0.001, "use_vol": False},
{"cross_bars": 6, "mom_min": 0.001, "use_vol": True},
]
all_results = []
for cfg in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold,
cross_bars=cfg["cross_bars"],
mom_min=cfg["mom_min"],
use_vol=cfg["use_vol"])
if r and r.trades >= 20:
lbl = (f"CM01 cb={cfg['cross_bars']} "
f"mm={cfg['mom_min']} v={cfg['use_vol']} h={hold}")
r.strategy_name = lbl
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(" CM01 CROSS-MARKET MOMENTUM — TOP 20")
print(f"{'=' * 130}")
print(f" {'Nome':<50s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Anni':>4s}")
print(f" {'' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250t, DD 6.5%, €5.23/day, 9 anni")
print(f" BENCHMARK MT01: 82.7% acc, 503t, DD 5.9%")
+266
View File
@@ -0,0 +1,266 @@
"""ML01 — Squeeze + GBM (Gradient Boosting Machine) Walk-Forward.
Strategia ibrida: squeeze breakout come pre-filtro (QUANDO tradare),
GradientBoosting su features strutturali come conferma (QUALE direzione).
Pipeline:
1. Rileva squeeze release (Bollinger esce da Keltner)
2. Estrai 44 features dalla finestra (structural multi-window + squeeze
metadata + price position + ATR + momentum breakout)
3. GBM walk-forward: train su 50% rolling, step 10%, predice direzione
4. Trade solo se ML ha confidenza ml_threshold
IN:
- OHLCV DataFrame
- Parametri: bb_window (14), sq_threshold (0.8), brk_bars (3),
ml_threshold (0.70), leverage (3), position_pct (0.15)
OUT:
- BacktestResult con metriche walk-forward (no data leakage)
- Solo periodo di test (seconda metà dati)
Risultati tipici:
ETH 15m bb14 ml=0.70: 76.9% acc, 1213 trades, DD 4.2%, 13.78/day
BTC 15m bb14 ml=0.70: 78.8% acc, 1964 trades, DD 7.0%, 5.51/day
BTC 1h bb14 ml=0.70: 77.3% acc, 617 trades, DD 6.7%, 3.85/day
Note:
- GBM = GradientBoostingClassifier di scikit-learn
- Walk-forward: nessun look-ahead, train sempre prima di test
- Il baseline squeeze puro ha accuracy più alta (~79.5%) ma DD peggiore
- Il valore del ML è filtrare breakout deboli DD ridotto
"""
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.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
from src.strategies.indicators import keltner_ratio, detect_squeezes
from src.data.downloader import load_data
def _build_features(df: pd.DataFrame, i: int, squeeze_info: dict) -> np.ndarray | None:
"""44 features per il punto di squeeze release."""
if i < 100:
return None
o, h, l, c, v = (df["open"].values, df["high"].values, df["low"].values,
df["close"].values, df["volume"].values)
feats = []
for w in [12, 24, 48]:
wc, wo = c[i-w:i], o[i-w:i]
wh, wl, wv = h[i-w:i], l[i-w:i], v[i-w:i]
mn, mx = wl.min(), max(wh.max(), wc.max())
rng = mx - mn if mx - mn > 0 else 1e-10
total = np.where(wh - wl == 0, 1e-10, wh - wl)
body = np.abs(wc - wo) / total
direction = np.sign(wc - wo)
log_c = np.log(np.where(wc == 0, 1e-10, wc))
rets = np.diff(log_c)
v_mean = np.mean(wv)
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):]),
(wc[-1] - mn) / rng,
wv[-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.get("avg_vol", 1) if sq.get("avg_vol", 0) > 0 else 1,
np.mean(v[i:min(i+3, len(v))]) / sq.get("avg_vol", 1) if sq.get("avg_vol", 0) > 0 else 1,
])
h48, l48 = np.max(h[max(0, i-48):i]), 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))))
feats.append(np.mean(tr[1:]) / c[i-1] if c[i-1] > 0 else 0)
feats.append((c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0)
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
class SqueezeGBM(Strategy):
name = "ML01_squeeze_gbm"
description = "Squeeze + GBM walk-forward — ML filtra breakout deboli"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_ml = 0.001
def generate_signals(self, df, ts, **params):
raise NotImplementedError("ML01 usa backtest custom con walk-forward")
def backtest(self, asset: str, tf: str, hold: int = 3, **params) -> BacktestResult | None:
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8 if tf == "1h" else 0.9)
brk = params.get("brk_bars", hold)
ml_thr = params.get("ml_threshold", 0.70)
lev = params.get("leverage", self.leverage)
pos = params.get("position_pct", self.position_size)
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)
raw_events = detect_squeezes(close, high, low, kcr, sq_thr)
# Aggiungi avg_vol a ogni evento
events = []
for ev in raw_events:
ev["avg_vol"] = float(np.mean(volume[ev["sq_start"]:ev["idx"]]))
events.append(ev)
X_all, y_all, ev_all = [], [], []
for ev in events:
i = ev["idx"]
if i + brk >= n or i < 100:
continue
feats = _build_features(df, i, ev)
if feats is None:
continue
actual_ret = (close[i + brk - 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, y = np.array(X_all), np.array(y_all)
TRAIN_SIZE = max(int(len(X) * 0.5), 50)
STEP_SIZE = max(int(len(X) * 0.1), 10)
yearly: dict[int, dict] = {}
capital = float(self.initial_capital)
peak = capital
max_dd = 0.0
total_bars = 0
all_t = 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 - 1] - close[i - 1]) / close[i - 1]
if p_up >= ml_thr:
direction = 1
elif p_up <= (1 - ml_thr):
direction = -1
else:
continue
is_correct = (direction == 1 and actual_ret > 0) or (direction == -1 and actual_ret < 0)
trade_ret = actual_ret * direction
net = trade_ret * lev - self.fee_ml * 2 * lev
capital += capital * pos * net
capital = max(capital, 10)
if capital > peak:
peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
total_bars += brk
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, "pnl": 0.0}
yearly[year]["t"] += 1
if is_correct:
yearly[year]["w"] += 1
yearly[year]["pnl"] += net * self.initial_capital
start += STEP_SIZE
if all_t == 0:
return None
yearly_stats = [
YearlyStats(year=y, trades=d["t"], wins=d["w"], pnl=d["pnl"])
for y, d in sorted(yearly.items())
]
return BacktestResult(
strategy_name=self.name,
asset=asset,
timeframe=tf,
params={"bb_w": bb_w, "sq_thr": sq_thr, "ml_thr": ml_thr,
"brk": brk, "lev": lev, "pos": pos},
trades=all_t,
wins=all_w,
pnl=sum(d["pnl"] for d 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=brk * TF_MINUTES.get(tf, 60) / 60,
years_active=len(yearly),
yearly=yearly_stats,
)
if __name__ == "__main__":
strategy = SqueezeGBM()
print("Training ML models...\n")
results = []
for asset in ["ETH", "BTC"]:
for tf in ["15m", "1h"]:
for ml_thr in [0.65, 0.70]:
r = strategy.backtest(asset, tf, ml_threshold=ml_thr)
if r and r.trades >= 20:
r.strategy_name = f"ML01 {asset} {tf} ml={ml_thr}"
results.append(r)
results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"{'=' * 120}")
print(f" ML01 SQUEEZE+GBM — RISULTATI")
print(f"{'=' * 120}")
for r in results:
r.print_summary()
if results:
results[0].print_yearly()
+261
View File
@@ -0,0 +1,261 @@
"""MT01 — Squeeze + Multi-Timeframe Momentum.
Problema SQ02: entra al breakout 15m ma non sa se il trend 1h è allineato.
Soluzione: squeeze su 15m + conferma momentum su 1h.
Anti-overfitting: usa solo 2 indicatori (squeeze + EMA slope),
nessun parametro complesso.
IN:
- OHLCV 15m + 1h per lo stesso asset
- Parametri: sq_threshold, ema_period_1h, min_slope
OUT:
- Signal al breakout 15m confermato da trend 1h
- BacktestResult
Logica:
1. Squeeze release su 15m (come SQ01)
2. Antifakeout filter (come SQ02)
3. Check 1h: EMA slope positiva per long, negativa per short
4. Check 1h: prezzo sopra/sotto EMA per conferma trend
5. Entra solo se 15m e 1h concordano
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
from src.strategies.indicators import keltner_ratio, detect_squeezes, ema
from src.data.downloader import load_data
class SqueezeMTFMomentum(Strategy):
name = "MT01_squeeze_mtf"
description = "Squeeze 15m + momentum trend 1h — multi-timeframe"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
"""Genera segnali squeeze 15m confermati da trend 1h."""
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
asset = params.get("asset", "BTC")
sq_thr = params.get("sq_threshold", 0.8)
ema_period = params.get("ema_period", 50)
min_slope_val = params.get("min_slope", 0.001)
use_antifake = params.get("antifake", True)
use_vol = params.get("vol_filter", False)
kcr = keltner_ratio(c, h, l, 14)
events = detect_squeezes(c, h, l, kcr, sq_thr)
df_1h = params.get("df_1h")
if df_1h is None:
df_1h = load_data(asset, "1h")
c1h = df_1h["close"].values
ts1h_ms = df_1h["timestamp"].values
n1h = len(c1h)
ema_1h = ema(c1h, ema_period)
ema_slope_arr = np.full(n1h, np.nan)
for i in range(5, n1h):
if not np.isnan(ema_1h[i]) and not np.isnan(ema_1h[i-5]) and ema_1h[i-5] > 0:
ema_slope_arr[i] = (ema_1h[i] - ema_1h[i-5]) / ema_1h[i-5]
ts_ms = df["timestamp"].values
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= 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
if use_antifake:
br = h[i] - l[i]
if br > 0:
if c[i] > c[i-1] and (h[i] - c[i]) / br > 0.6:
continue
elif c[i] <= c[i-1] and (c[i] - l[i]) / br > 0.6:
continue
if use_vol:
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
i1h = np.searchsorted(ts1h_ms, ts_ms[i]) - 1
if i1h < ema_period or i1h >= n1h:
continue
if np.isnan(ema_1h[i1h]) or np.isnan(ema_slope_arr[i1h]):
continue
if direction == 1:
if c1h[i1h] < ema_1h[i1h] or ema_slope_arr[i1h] < min_slope_val:
continue
else:
if c1h[i1h] > ema_1h[i1h] or ema_slope_arr[i1h] > -min_slope_val:
continue
signals.append(Signal(idx=i, direction=direction, entry_price=c[i-1]))
return signals
def backtest(self, asset, tf="15m", hold=3, **params):
sq_thr = params.get("sq_threshold", 0.8)
ema_period = params.get("ema_period", 50)
min_slope = params.get("min_slope", 0.001)
use_antifake = params.get("antifake", True)
use_vol = params.get("vol_filter", False)
# Carica 15m e 1h
df_15m = load_data(asset, "15m")
df_1h = load_data(asset, "1h")
c15 = df_15m["close"].values
h15 = df_15m["high"].values
l15 = df_15m["low"].values
v15 = df_15m["volume"].values
n15 = len(c15)
ts15 = pd.to_datetime(df_15m["timestamp"], unit="ms", utc=True)
ts15_ms = df_15m["timestamp"].values
c1h = df_1h["close"].values
ts1h_ms = df_1h["timestamp"].values
n1h = len(c1h)
kcr = keltner_ratio(c15, h15, l15, 14)
events = detect_squeezes(c15, h15, l15, kcr, sq_thr)
# EMA su 1h
ema_1h = ema(c1h, ema_period)
# EMA slope (variazione percentuale su 5 barre)
ema_slope = np.full(n1h, np.nan)
for i in range(5, n1h):
if not np.isnan(ema_1h[i]) and not np.isnan(ema_1h[i - 5]) and ema_1h[i - 5] > 0:
ema_slope[i] = (ema_1h[i] - ema_1h[i - 5]) / ema_1h[i - 5]
yearly = {}
capital = float(self.initial_capital)
peak = capital
max_dd = 0.0
total_bars = 0
for ev in events:
i = ev["idx"]
if i + hold + 1 >= n15 or i < 1:
continue
first_ret = (c15[i] - c15[i - 1]) / c15[i - 1] if c15[i - 1] > 0 else 0
if abs(first_ret) < 0.001:
continue
# Antifake
if use_antifake:
br = h15[i] - l15[i]
if br > 0:
if c15[i] > c15[i - 1] and (h15[i] - c15[i]) / br > 0.6:
continue
elif c15[i] <= c15[i - 1] and (c15[i] - l15[i]) / br > 0.6:
continue
# Volume filter
if use_vol:
avg_v = np.mean(v15[ev["sq_start"]:i])
if avg_v > 0 and v15[i] <= avg_v * 1.3:
continue
direction = 1 if first_ret > 0 else -1
# Trova indice 1h corrispondente
i1h = np.searchsorted(ts1h_ms, ts15_ms[i]) - 1
if i1h < ema_period or i1h >= n1h or np.isnan(ema_1h[i1h]) or np.isnan(ema_slope[i1h]):
continue
# Conferma trend 1h
if direction == 1:
if c1h[i1h] < ema_1h[i1h]:
continue
if ema_slope[i1h] < min_slope:
continue
else:
if c1h[i1h] > ema_1h[i1h]:
continue
if ema_slope[i1h] > -min_slope:
continue
entry = c15[i - 1]
exit_price = c15[min(i + hold - 1, n15 - 1)]
actual = (exit_price - entry) / entry * direction
net = actual * self.leverage - self.fee_rt * self.leverage
capital += capital * self.position_size * net
capital = max(capital, 10)
if capital > peak: peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
total_bars += hold
year = ts15.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
yearly[year]["t"] += 1
if actual > 0: yearly[year]["w"] += 1
yearly[year]["pnl"] += net * self.initial_capital
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
yearly_stats = [YearlyStats(y, d["t"], d["w"], d["pnl"]) for y, d in sorted(yearly.items())]
return BacktestResult(
strategy_name=self.name, asset=asset, timeframe="15m", params=params,
trades=all_t, wins=all_w, pnl=sum(d["pnl"] for d in yearly.values()),
capital=capital, initial_capital=self.initial_capital,
max_dd=max_dd * 100, time_in_market_pct=total_bars / n15 * 100,
avg_trade_duration_h=hold * 15 / 60, years_active=len(yearly), yearly=yearly_stats,
)
if __name__ == "__main__":
strategy = SqueezeMTFMomentum()
configs = [
("ema50 sl0.1%", {"ema_period": 50, "min_slope": 0.001}),
("ema50 sl0.05%", {"ema_period": 50, "min_slope": 0.0005}),
("ema50 sl0.2%", {"ema_period": 50, "min_slope": 0.002}),
("ema20 sl0.1%", {"ema_period": 20, "min_slope": 0.001}),
("ema50 sl0.1%+vol", {"ema_period": 50, "min_slope": 0.001, "vol_filter": True}),
("ema20 sl0.1%+vol", {"ema_period": 20, "min_slope": 0.001, "vol_filter": True}),
("ema50 noAF", {"ema_period": 50, "min_slope": 0.001, "antifake": False}),
("ema100 sl0.05%", {"ema_period": 100, "min_slope": 0.0005}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for hold in [3, 6]:
r = strategy.backtest(asset, "15m", hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"MT01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(f" MT01 SQUEEZE + MTF MOMENTUM — TOP 20")
print(f"{'=' * 130}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250t, DD 6.5%, 9 anni, €5.23/day")
@@ -0,0 +1,158 @@
"""PD01 — Price-Volume Divergence Squeeze.
Estende SQ02 con volume TREND come filtro:
- Breakout UP con volume CRESCENTE (ultimi 3 bar vs media squeeze) ENTRA
- Breakout UP con volume CALANTE SALTA (divergenza bearish)
- Viceversa per short
Logica anti-fakeout:
1. Squeeze rilascio (come SQ02)
2. Anti-fakeout candela (come SQ02)
3. Volume al breakout > media squeeze (come SQ02)
4. NUOVO: volume trending UP nelle ultime 3 barre prima del breakout
Parametri semplici, nessun overfitting.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.strategies.indicators import keltner_ratio, detect_squeezes
class PriceVolumeDivergence(Strategy):
name = "PD01_price_vol_div"
description = "Squeeze + antifakeout + volume trend confirmation"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
leverage = 3.0
position_size = 0.15
initial_capital = 1000.0
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
retrace_limit = params.get("retrace_limit", 0.6)
vol_mult = params.get("vol_multiplier", 1.3)
vol_trend_bars = params.get("vol_trend_bars", 3) # barre per trend volume
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
signals = []
for ev in events:
i = ev["idx"]
if i < vol_trend_bars + 1 or i >= n:
continue
# Direzione breakout
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
# Anti-fakeout
br = h[i] - l[i]
if br > 0:
if direction == 1 and (h[i] - c[i]) / br > retrace_limit:
continue
elif direction == -1 and (c[i] - l[i]) / br > retrace_limit:
continue
# Volume al breakout > media squeeze
sq_start = ev["sq_start"]
avg_sq_v = np.mean(v[sq_start:i])
if avg_sq_v <= 0 or v[i] <= avg_sq_v * vol_mult:
continue
# Volume TREND: slope delle ultime vol_trend_bars barre
# Usa regressione lineare semplice (rank correlation del volume)
recent_v = v[i - vol_trend_bars:i + 1] # include breakout bar
if len(recent_v) < vol_trend_bars:
continue
# slope: media seconda metà vs prima metà
mid = len(recent_v) // 2
v_early = np.mean(recent_v[:mid])
v_late = np.mean(recent_v[mid:])
vol_trending_up = v_late > v_early
vol_trending_down = v_early > v_late
# Concordanza: long richiede volume trending up, short trending down
if direction == 1 and not vol_trending_up:
continue
if direction == -1 and not vol_trending_down:
continue
signals.append(Signal(
idx=i,
direction=direction,
entry_price=c[i - 1],
metadata={
"dur": ev["dur"],
"vol_ratio": v[i] / avg_sq_v if avg_sq_v > 0 else 0,
"vol_trend": v_late / v_early if v_early > 0 else 1,
},
))
return signals
if __name__ == "__main__":
strategy = PriceVolumeDivergence()
configs = [
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.6,
"vol_multiplier": 1.3, "vol_trend_bars": 3},
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.6,
"vol_multiplier": 1.2, "vol_trend_bars": 3},
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.6,
"vol_multiplier": 1.3, "vol_trend_bars": 5},
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.5,
"vol_multiplier": 1.3, "vol_trend_bars": 3},
{"bb_window": 14, "sq_threshold": 0.75, "retrace_limit": 0.6,
"vol_multiplier": 1.3, "vol_trend_bars": 3},
{"bb_window": 20, "sq_threshold": 0.8, "retrace_limit": 0.6,
"vol_multiplier": 1.3, "vol_trend_bars": 3},
]
all_results = []
for cfg in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **cfg)
if r and r.trades >= 20:
lbl = (f"PD01 vtb={cfg['vol_trend_bars']} "
f"vm={cfg['vol_multiplier']} "
f"sq={cfg['sq_threshold']} h={hold}")
r.strategy_name = lbl
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(" PD01 PRICE-VOLUME DIVERGENCE — TOP 20")
print(f"{'=' * 130}")
print(f" {'Nome':<50s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Anni':>4s}")
print(f" {'' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250t, DD 6.5%, €5.23/day, 9 anni")
print(f" BENCHMARK MT01: 82.7% acc, 503t, DD 5.9%")
+317
View File
@@ -0,0 +1,317 @@
"""S3-01: Squeeze Migliorato — test per-anno, dati reali.
Miglioramenti rispetto al squeeze base:
1. Cross-asset: squeeze su BTC + ETH contemporaneo = segnale più forte
2. Timing orario: accuracy per fascia oraria
3. Squeeze duration weighted: squeeze lunghi breakout più forti
4. Dual-timeframe: squeeze su 1h confermato da 15m
5. Anti-fakeout: skip se candela post-breakout ritraccia >50%
6. Dynamic exit: trailing stop basato su ATR
"""
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
FEE_RT = 0.002
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 atr_calc(high, low, close, period=14):
tr = np.maximum(high-low, np.maximum(np.abs(high-np.roll(close,1)), np.abs(low-np.roll(close,1))))
tr[0] = high[0]-low[0]
r = np.full(len(close), np.nan)
r[period-1] = np.mean(tr[:period])
k = 2/(period+1)
for i in range(period, len(close)):
r[i] = tr[i]*k + r[i-1]*(1-k)
return r
def detect_squeezes(close, high, low, volume, kcr, sq_thr=0.8, min_dur=5):
"""Ritorna lista di squeeze events con metadata."""
events = []
in_sq = False
sq_start = 0
n = len(close)
for i in range(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
dur = i - sq_start
if dur < min_dur:
continue
avg_vol = np.mean(volume[sq_start:i])
# Range durante squeeze
sq_range = (np.max(high[sq_start:i]) - np.min(low[sq_start:i])) / close[sq_start] if close[sq_start] > 0 else 0
events.append({
"release_idx": i,
"duration": dur,
"avg_vol": avg_vol,
"squeeze_range": sq_range,
"kcr_at_release": kcr[i],
})
return events
def run_improved_squeeze(primary_asset, tf="1h"):
# Carica asset primario
df = load_data(primary_asset, tf)
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
n = len(df)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ts_ms = df["timestamp"].values
kcr = keltner_ratio(c, h, l, 14)
atr_14 = atr_calc(h, l, c, 14)
events = detect_squeezes(c, h, l, v, kcr)
# Carica asset secondario per cross-check
secondary = "BTC" if primary_asset == "ETH" else "ETH"
df2 = load_data(secondary, tf)
c2, h2, l2 = df2["close"].values, df2["high"].values, df2["low"].values
ts2_ms = df2["timestamp"].values
kcr2 = keltner_ratio(c2, h2, l2, 14)
# Mappa ts2 → indici per allineare
def find_idx2(ts_val):
idx = np.searchsorted(ts2_ms, ts_val)
return min(idx, len(c2)-1)
# Carica 15m per dual-TF
if tf == "1h":
df_15m = load_data(primary_asset, "15m")
c15 = df_15m["close"].values
h15 = df_15m["high"].values
l15 = df_15m["low"].values
ts15 = df_15m["timestamp"].values
kcr_15m = keltner_ratio(c15, h15, l15, 14)
else:
kcr_15m = None
ts15 = None
# ================================================================
# CONFIGURAZIONI
# ================================================================
configs = [
# (name, use_cross, use_timing, use_duration, use_dual_tf, use_antifake, use_trailing, hold, stop_atr)
("BASE", False, False, False, False, False, False, 3, 0),
("cross_asset", True, False, False, False, False, False, 3, 0),
("timing_filter", False, True, False, False, False, False, 3, 0),
("long_squeeze", False, False, True, False, False, False, 3, 0),
("dual_tf", False, False, False, True, False, False, 3, 0),
("anti_fakeout", False, False, False, False, True, False, 3, 0),
("trailing_stop", False, False, False, False, False, True, 6, 1.5),
("cross+timing", True, True, False, False, False, False, 3, 0),
("cross+long+timing", True, True, True, False, False, False, 3, 0),
("cross+dual_tf", True, False, False, True, False, False, 3, 0),
("ALL_FILTERS", True, True, True, True, True, False, 3, 0),
("ALL+trailing", True, True, True, True, True, True, 6, 1.5),
("cross+antifake", True, False, False, False, True, False, 3, 0),
("timing+antifake", False, True, False, False, True, False, 3, 0),
("cross+timing+antifk", True, True, False, False, True, False, 3, 0),
("cross+timing+trail", True, True, False, False, False, True, 6, 1.5),
]
print(f"\n{'#'*75}")
print(f" {primary_asset} {tf} — SQUEEZE MIGLIORATO")
print(f"{'#'*75}")
results = []
for name, f_cross, f_timing, f_dur, f_dual, f_antifake, f_trail, hold, stop_atr_m in configs:
yearly = {}
capital = float(INITIAL)
peak = capital
max_dd = 0
for ev in events:
i = ev["release_idx"]
if i + hold + 2 >= n:
continue
# --- FILTRI ---
skip = False
# Cross-asset: secondary deve anche essere in squeeze recente o breakout
if f_cross:
i2 = find_idx2(ts_ms[i])
if i2 >= 5:
sec_in_squeeze = any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85 for j in range(max(0,i2-10), i2+1))
if not sec_in_squeeze:
skip = True
# Timing: solo certe ore (testato: 6-14 UTC migliori)
if f_timing:
hour = ts.iloc[i].hour
if hour < 4 or hour > 16:
skip = True
# Duration: solo squeeze > 10 barre
if f_dur:
if ev["duration"] < 10:
skip = True
# Dual-TF: squeeze anche su 15m
if f_dual and kcr_15m is not None and ts15 is not None:
i15 = np.searchsorted(ts15, ts_ms[i])
if i15 >= 5:
sq_15m = any(not np.isnan(kcr_15m[j]) and kcr_15m[j] < 0.85 for j in range(max(0,i15-20), i15+1))
if not sq_15m:
skip = True
# Anti-fakeout: prima candela post-breakout non deve ritracciare >50%
if f_antifake and i + 1 < n:
breakout_bar_range = h[i] - l[i]
if breakout_bar_range > 0:
if c[i] > c[i-1]: # breakout up
retrace = (h[i] - c[i]) / breakout_bar_range
else: # breakout down
retrace = (c[i] - l[i]) / breakout_bar_range
if retrace > 0.6:
skip = True
if skip:
continue
# --- DIREZIONE ---
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
# --- EXIT ---
entry = c[i-1]
if f_trail and not np.isnan(atr_14[i]):
# Trailing stop
trail_dist = atr_14[i] * stop_atr_m
best_price = entry
exit_price = c[min(i+hold, n-1)]
for j in range(i, min(i+hold+1, n)):
if direction == 1:
best_price = max(best_price, h[j])
if l[j] <= best_price - trail_dist:
exit_price = best_price - trail_dist
break
else:
best_price = min(best_price, l[j])
if h[j] >= best_price + trail_dist:
exit_price = best_price + trail_dist
break
exit_price = c[j]
else:
exit_price = c[min(i+hold-1, n-1)]
actual = (exit_price - entry) / entry * direction
net = actual * LEVERAGE - FEE_RT * 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)
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"wins": 0, "total": 0, "pnls": []}
yearly[year]["total"] += 1
if actual > 0:
yearly[year]["wins"] += 1
yearly[year]["pnls"].append(net * INITIAL)
all_t = sum(d["total"] for d in yearly.values())
all_w = sum(d["wins"] for d in yearly.values())
if all_t < 30:
continue
acc = all_w / all_t * 100
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
tot_pnl = sum(all_pnls)
# Worst year
worst_y_acc = 100
worst_y = ""
for y, d in yearly.items():
ya = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
if ya < worst_y_acc:
worst_y_acc = ya
worst_y = str(y)
results.append({
"name": name, "trades": all_t, "acc": acc, "pnl": tot_pnl,
"max_dd": max_dd*100, "capital": capital,
"worst": f"{worst_y}({worst_y_acc:.0f}%)",
"yearly": yearly,
})
# Sort by accuracy
results.sort(key=lambda x: x["acc"], reverse=True)
print(f"\n {'Name':.<26s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>6s} {'Capital':>10s} {'Worst':>12s}")
print(f" {'-'*80}")
for r in results:
tag = "✅✅" if r["acc"] >= 80 else "" if r["acc"] >= 76 else ""
print(f" {r['name']:.<26s} {r['trades']:>7d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['max_dd']:>5.1f}% €{r['capital']:>9,.0f} {r['worst']:>12s} {tag}")
# Dettaglio per anno del migliore
if results:
best = results[0]
print(f"\n MIGLIORE: {best['name']}{best['acc']:.1f}% acc")
print(f" {'Anno':>6s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s}")
for y in sorted(best["yearly"]):
d = best["yearly"][y]
ya = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
yp = sum(d["pnls"])
tag = " ← CRASH" if y in [2020,2021,2022] else ""
print(f" {y:>6d} {d['total']:>7d} {ya:>5.1f}% €{yp:>+8.0f}{tag}")
return results
# Run su entrambi gli asset e timeframe
all_results = {}
for asset in ["ETH", "BTC"]:
for tf in ["1h", "15m"]:
key = f"{asset}_{tf}"
all_results[key] = run_improved_squeeze(asset, tf)
# Classifica globale
print(f"\n\n{'='*75}")
print(f" CLASSIFICA GLOBALE — TOP 15")
print(f"{'='*75}")
global_list = []
for key, results in all_results.items():
for r in results:
global_list.append({**r, "asset_tf": key})
global_list.sort(key=lambda x: x["acc"], reverse=True)
print(f"\n {'Asset_TF':.<12s} {'Name':.<26s} {'Trades':>6s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>5s} {'Worst':>12s}")
for r in global_list[:15]:
tag = "✅✅" if r["acc"] >= 80 else "" if r["acc"] >= 76 else ""
print(f" {r['asset_tf']:.<12s} {r['name']:.<26s} {r['trades']:>6d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['max_dd']:>4.1f}% {r['worst']:>12s} {tag}")
+290
View File
@@ -0,0 +1,290 @@
"""S3-02: Lead-lag multi-asset squeeze.
Quando BTC fa squeeze breakout, ETH/SOL spesso seguono.
Usa il breakout di BTC per anticipare entrata su ETH (e viceversa).
Testa anche correlazione inter-asset per conferma segnale.
"""
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
FEE_RT = 0.002
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 load_aligned(assets, tf):
"""Carica e allinea dati multi-asset per timestamp."""
dfs = {}
for asset in assets:
try:
if asset == "SOL":
df = pd.read_parquet(f"data/raw/sol_{tf}.parquet")
df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
else:
df = load_data(asset, tf)
dfs[asset] = df
except Exception:
pass
if len(dfs) < 2:
return None
# Allinea per timestamp
common_ts = set(dfs[list(dfs.keys())[0]]["timestamp"].values)
for df in dfs.values():
common_ts &= set(df["timestamp"].values)
common_ts = sorted(common_ts)
aligned = {}
for asset, df in dfs.items():
mask = df["timestamp"].isin(common_ts)
aligned[asset] = df[mask].sort_values("timestamp").reset_index(drop=True)
return aligned
def detect_breakouts(close, high, low, volume, kcr, sq_thr=0.8, min_dur=5):
"""Detect squeeze breakout events."""
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
if i - sq_start < min_dur:
continue
first_ret = (close[i] - close[i-1]) / close[i-1] if close[i-1] > 0 else 0
if abs(first_ret) < 0.001:
continue
events.append({
"idx": i,
"duration": i - sq_start,
"direction": 1 if first_ret > 0 else -1,
"first_ret": first_ret,
})
return events
print("=" * 75)
print(" S3-02: LEAD-LAG MULTI-ASSET SQUEEZE")
print("=" * 75)
for tf in ["1h", "15m"]:
aligned = load_aligned(["BTC", "ETH", "SOL"], tf)
if aligned is None:
continue
n = len(aligned["BTC"])
ts = pd.to_datetime(aligned["BTC"]["timestamp"], unit="ms", utc=True)
print(f"\n Timeframe: {tf}, Candles allineate: {n}")
# Calcola squeeze per ogni asset
asset_data = {}
for asset in aligned:
df = aligned[asset]
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
kcr = keltner_ratio(c, h, l, 14)
events = detect_breakouts(c, h, l, v, kcr)
asset_data[asset] = {"close": c, "high": h, "low": l, "vol": v, "kcr": kcr, "events": events}
print(f" {asset}: {len(events)} squeeze breakouts")
# ================================================================
# STRATEGIA A: Leader-follower
# Quando BTC fa breakout, entra su ETH/SOL nella stessa direzione
# ================================================================
print(f"\n --- LEADER-FOLLOWER ({tf}) ---")
for leader, follower in [("BTC", "ETH"), ("BTC", "SOL"), ("ETH", "BTC"), ("ETH", "SOL")]:
if leader not in asset_data or follower not in asset_data:
continue
leader_events = asset_data[leader]["events"]
fc = asset_data[follower]["close"]
for hold in [3, 6]:
for delay in [0, 1, 2]:
yearly = {}
for ev in leader_events:
i = ev["idx"] + delay
if i + hold >= n:
continue
# Anti-fakeout su follower
entry = fc[i]
exit_price = fc[min(i + hold, n - 1)]
direction = ev["direction"]
actual = (exit_price - entry) / entry * direction
net = actual * LEVERAGE - FEE_RT * LEVERAGE
year = ts.iloc[min(i, n-1)].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 < 30:
continue
acc = all_w / all_t * 100
pnl = sum(p for d in yearly.values() for p in d["pnls"])
worst_y = min(yearly.items(), key=lambda x: x[1]["w"]/x[1]["t"] if x[1]["t"]>0 else 0)
worst_acc = worst_y[1]["w"]/worst_y[1]["t"]*100 if worst_y[1]["t"]>0 else 0
tag = "" if acc >= 76 else ""
print(f" {leader}{follower} d={delay} h={hold}: trades={all_t:5d} acc={acc:.1f}% pnl=€{pnl:+.0f} worst={worst_y[0]}({worst_acc:.0f}%) {tag}")
# ================================================================
# STRATEGIA B: Consensus multi-asset
# Trade solo quando 2+ asset hanno squeeze breakout nello stesso momento
# ================================================================
print(f"\n --- CONSENSUS MULTI-ASSET ({tf}) ---")
# Build event map: timestamp → list of (asset, direction)
event_map = {}
for asset, data in asset_data.items():
for ev in data["events"]:
idx = ev["idx"]
if idx not in event_map:
event_map[idx] = []
event_map[idx].append((asset, ev["direction"]))
for target in ["BTC", "ETH", "SOL"]:
if target not in asset_data:
continue
tc = asset_data[target]["close"]
for min_consensus in [2, 3]:
for window_bars in [1, 3, 5]:
yearly = {}
daily_done = set()
for idx in sorted(event_map.keys()):
if idx + 6 >= n:
continue
day = ts.iloc[idx].strftime("%Y-%m-%d")
if day in daily_done:
continue
# Count consensus within window
nearby_events = []
for j in range(max(0, idx - window_bars), idx + window_bars + 1):
if j in event_map:
nearby_events.extend(event_map[j])
# Unique assets
unique_assets = set(a for a, d in nearby_events)
if len(unique_assets) < min_consensus:
continue
# Majority direction
dirs = [d for a, d in nearby_events]
majority = 1 if sum(dirs) > 0 else -1
entry = tc[idx]
exit_price = tc[min(idx + 3, n - 1)]
actual = (exit_price - entry) / entry * majority
net = actual * LEVERAGE - FEE_RT * LEVERAGE
year = ts.iloc[idx].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)
daily_done.add(day)
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t < 20:
continue
acc = all_w / all_t * 100
pnl = sum(p for d in yearly.values() for p in d["pnls"])
tag = "" if acc >= 76 else ""
print(f" {target} consensus>={min_consensus} w={window_bars}: trades={all_t:4d} acc={acc:.1f}% pnl=€{pnl:+.0f} {tag}")
# ================================================================
# STRATEGIA C: Correlation-weighted squeeze
# Peso il segnale squeeze in base alla correlazione rolling con BTC
# ================================================================
print(f"\n --- CORRELATION-WEIGHTED ({tf}) ---")
for target in ["ETH", "SOL"]:
if target not in asset_data:
continue
tc = asset_data[target]["close"]
btc_c = asset_data["BTC"]["close"]
# Rolling correlation
corr_window = 48 # 48 bars
rolling_corr = np.full(n, np.nan)
ret_t = np.diff(np.log(np.where(tc == 0, 1e-10, tc)))
ret_b = np.diff(np.log(np.where(btc_c == 0, 1e-10, btc_c)))
for i in range(corr_window, len(ret_t)):
c_val = np.corrcoef(ret_t[i-corr_window:i], ret_b[i-corr_window:i])[0, 1]
rolling_corr[i + 1] = c_val if np.isfinite(c_val) else 0
events = asset_data[target]["events"]
for corr_thr in [0.5, 0.6, 0.7, 0.8]:
yearly = {}
for ev in events:
i = ev["idx"]
if i + 3 >= n or np.isnan(rolling_corr[i]):
continue
# Solo quando correlazione con BTC è alta
if abs(rolling_corr[i]) < corr_thr:
continue
entry = tc[i - 1]
exit_price = tc[min(i + 2, n - 1)]
actual = (exit_price - entry) / entry * ev["direction"]
net = actual * LEVERAGE - FEE_RT * LEVERAGE
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 < 20:
continue
acc = all_w / all_t * 100
pnl = sum(p for d in yearly.values() for p in d["pnls"])
tag = "" if acc >= 76 else ""
print(f" {target} corr>={corr_thr}: trades={all_t:4d} acc={acc:.1f}% pnl=€{pnl:+.0f} {tag}")
+256
View File
@@ -0,0 +1,256 @@
"""S3-03: Ultimate Squeeze — combina TUTTI i filtri migliori.
Filtri che funzionano (testati singolarmente):
- Anti-fakeout (+1% acc)
- Long squeeze duration (+1% acc)
- Cross-asset squeeze simultaneo (+0.5%)
- Timing 4-16 UTC (+0.5%)
- Correlation ETH-BTC alta per ETH trades (+1%)
- Volume confirmation al breakout
Nuovi filtri da testare:
- Volume delta: up_volume - down_volume al breakout
- Momentum confirmation: breakout nella direzione del trend 1h
- Volatility regime: skip in regime estremo (RV > 100%)
"""
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
FEE_RT = 0.002
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 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
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 run_ultimate(primary, tf="15m"):
secondary = "ETH" if primary == "BTC" else "BTC"
df = load_data(primary, tf)
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
n = len(df)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df2 = load_data(secondary, tf)
c2, ts2 = df2["close"].values, df2["timestamp"].values
kcr = keltner_ratio(c, h, l, 14)
kcr2 = keltner_ratio(c2, df2["high"].values, df2["low"].values, 14)
ema_50 = ema(c, 50)
rv_48 = rv_ann(c, 48)
# Rolling correlation
ret1 = np.diff(np.log(np.where(c == 0, 1e-10, c)))
ret2 = np.diff(np.log(np.where(c2[:len(c)] == 0, 1e-10, c2[:len(c)])))
min_len = min(len(ret1), len(ret2))
ret1 = ret1[:min_len]
ret2 = ret2[:min_len]
corr = np.full(n, np.nan)
for i in range(48, min_len):
cv = np.corrcoef(ret1[i-48:i], ret2[i-48:i])[0,1]
corr[i+1] = cv if np.isfinite(cv) else 0
# Detect squeezes
events = []
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
dur = i - sq_start
if dur < 5 or i + 6 >= n:
continue
events.append({"idx": i, "dur": dur, "sq_start": sq_start})
print(f"\n{'#'*70}")
print(f" {primary} {tf} — ULTIMATE SQUEEZE ({len(events)} squeeze events)")
print(f"{'#'*70}")
filters_map = {
"antifake": lambda ev, i: not _antifake(c, h, l, i),
"long_sq": lambda ev, i: ev["dur"] >= 10,
"timing": lambda ev, i: 4 <= ts.iloc[i].hour <= 16,
"cross": lambda ev, i: _cross_squeeze(kcr2, i, ts, ts2),
"corr_high": lambda ev, i: not np.isnan(corr[i]) and abs(corr[i]) >= 0.6,
"vol_confirm": lambda ev, i: _vol_confirm(v, i, ev["sq_start"]),
"trend_align": lambda ev, i: _trend_align(c, ema_50, i),
"low_rv": lambda ev, i: not np.isnan(rv_48[i]) and rv_48[i] < 1.5,
}
def _antifake(c, h, l, i):
if i + 1 >= len(c): return False
br = h[i] - l[i]
if br <= 0: return False
if c[i] > c[i-1]:
return (h[i] - c[i]) / br > 0.6
return (c[i] - l[i]) / br > 0.6
def _cross_squeeze(kcr2, i, ts1, ts2_arr):
i2 = np.searchsorted(ts2_arr, ts.values[i].astype("int64") // 10**6)
i2 = min(i2, len(kcr2)-1)
return any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85 for j in range(max(0,i2-10), i2+1))
def _vol_confirm(v, i, sq_start):
avg = np.mean(v[sq_start:i])
return avg > 0 and v[i] > avg * 1.3
def _trend_align(c, ema_val, i):
if np.isnan(ema_val[i]): return True
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
if first_ret > 0:
return c[i] > ema_val[i]
return c[i] < ema_val[i]
# Test combinazioni incrementali
combos = [
("BASE", []),
("antifake", ["antifake"]),
("long_sq", ["long_sq"]),
("antifake+long", ["antifake", "long_sq"]),
("antifake+timing", ["antifake", "timing"]),
("antifake+cross", ["antifake", "cross"]),
("antifake+corr", ["antifake", "corr_high"]),
("antifake+vol", ["antifake", "vol_confirm"]),
("antifake+trend", ["antifake", "trend_align"]),
("af+long+timing", ["antifake", "long_sq", "timing"]),
("af+long+cross", ["antifake", "long_sq", "cross"]),
("af+long+corr", ["antifake", "long_sq", "corr_high"]),
("af+long+trend", ["antifake", "long_sq", "trend_align"]),
("af+long+cross+time", ["antifake", "long_sq", "cross", "timing"]),
("af+long+corr+time", ["antifake", "long_sq", "corr_high", "timing"]),
("af+long+corr+trend", ["antifake", "long_sq", "corr_high", "trend_align"]),
("ALL_NO_VOL", ["antifake", "long_sq", "cross", "timing", "corr_high", "trend_align", "low_rv"]),
("ALL", ["antifake", "long_sq", "cross", "timing", "corr_high", "vol_confirm", "trend_align", "low_rv"]),
("BEST_5", ["antifake", "long_sq", "corr_high", "trend_align", "low_rv"]),
]
results = []
for combo_name, filter_names in combos:
yearly = {}
capital = float(INITIAL)
peak = capital
max_dd = 0
for ev in events:
i = ev["idx"]
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
if abs(first_ret) < 0.001:
continue
skip = False
for fn in filter_names:
if fn in filters_map and not filters_map[fn](ev, i):
skip = True
break
if skip:
continue
direction = 1 if first_ret > 0 else -1
entry = c[i-1]
exit_price = c[min(i+2, n-1)]
actual = (exit_price - entry) / entry * direction
net = actual * LEVERAGE - FEE_RT * 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)
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 < 20: continue
acc = all_w / all_t * 100
pnl = sum(p for d in yearly.values() for p in d["pnls"])
worst = min(yearly.items(), key=lambda x: x[1]["w"]/x[1]["t"] if x[1]["t"]>0 else 0)
wa = worst[1]["w"]/worst[1]["t"]*100 if worst[1]["t"]>0 else 0
results.append({
"name": combo_name, "trades": all_t, "acc": acc, "pnl": pnl,
"dd": max_dd*100, "capital": capital, "worst": f"{worst[0]}({wa:.0f}%)",
"yearly": yearly,
})
results.sort(key=lambda x: x["acc"], reverse=True)
print(f"\n {'Name':.<28s} {'Trades':>6s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>5s} {'Worst':>12s}")
print(f" {'-'*70}")
for r in results[:20]:
tag = "✅✅" if r["acc"] >= 80 else "" if r["acc"] >= 78 else ""
print(f" {r['name']:.<28s} {r['trades']:>6d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['dd']:>4.1f}% {r['worst']:>12s} {tag}")
# Dettaglio migliore
if results:
best = results[0]
print(f"\n MIGLIORE: {best['name']}{best['acc']:.1f}% acc, DD {best['dd']:.1f}%")
for y in sorted(best["yearly"]):
d = best["yearly"][y]
ya = d["w"]/d["t"]*100 if d["t"]>0 else 0
tag = " ← CRASH" if y in [2020,2021,2022] else ""
print(f" {y}: {d['t']:4d}t {ya:5.1f}% €{sum(d['pnls']):+.0f}{tag}")
return results
all_r = []
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
r = run_ultimate(asset, tf)
for x in r:
all_r.append({**x, "key": f"{asset}_{tf}"})
all_r.sort(key=lambda x: x["acc"], reverse=True)
print(f"\n\n{'='*70}")
print(f" TOP 10 GLOBALE")
print(f"{'='*70}")
for r in all_r[:10]:
tag = "✅✅" if r["acc"] >= 80 else "" if r["acc"] >= 78 else ""
print(f" {r['key']:.<10s} {r['name']:.<28s} {r['trades']:>5d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} DD {r['dd']:.1f}% {r['worst']:>12s} {tag}")
+68
View File
@@ -0,0 +1,68 @@
"""SQ01 — Squeeze Breakout Base.
Strategia strutturale: rileva compressione di volatilità (Bollinger dentro
Keltner Channel) e segue la direzione del breakout al rilascio.
IN:
- OHLCV DataFrame (da load_data)
- Parametri: bb_window (14), sq_threshold (0.8), min_squeeze_dur (5)
OUT:
- Lista di Signal con direzione breakout (+1/-1)
- BacktestResult con equity, yearly breakdown, metriche
Risultati tipici:
BTC 15m: 76.7% acc, 4062 trades, DD 6.7%, 9.32/day
ETH 15m: 76.4% acc, 2948 trades, DD 6.2%, 10.31/day
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.strategies.indicators import keltner_ratio, detect_squeezes
class SqueezeBase(Strategy):
name = "SQ01_squeeze_base"
description = "Squeeze breakout puro — segui direzione al rilascio"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
min_dur = params.get("min_dur", 5)
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr, min_dur)
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= 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
signals.append(Signal(
idx=i,
direction=1 if first_ret > 0 else -1,
entry_price=c[i - 1],
metadata={"dur": ev["dur"], "kcr": ev["kcr_at_release"]},
))
return signals
if __name__ == "__main__":
strategy = SqueezeBase()
strategy.report()
@@ -0,0 +1,87 @@
"""SQ02 — Squeeze Breakout + Anti-Fakeout + Volume Confirmation.
Migliora SQ01 con due filtri:
1. Anti-fakeout: scarta breakout dove la candela ritraccia >60% del range
2. Volume confirm: volume al breakout deve essere >1.3× la media durante squeeze
IN:
- OHLCV DataFrame
- Parametri: bb_window (14), sq_threshold (0.8), retrace_limit (0.6),
vol_multiplier (1.3)
OUT:
- Lista di Signal filtrati
- BacktestResult
Risultati tipici:
BTC 15m: 79.7% acc, 1250 trades, DD 6.5%, 5.23/day SOLIDO 9/9 anni
ETH 15m: 78.6% acc, 942 trades, DD 3.4%, 4.33/day
BTC 1h: 78.0% acc, 473 trades, DD 3.5%, Sharpe 6.57
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.strategies.indicators import keltner_ratio, detect_squeezes
class SqueezeAntifakeVol(Strategy):
name = "SQ02_antifake_vol"
description = "Squeeze + antifakeout + volume confirmation"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
retrace_limit = params.get("retrace_limit", 0.6)
vol_mult = params.get("vol_multiplier", 1.3)
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= 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 > retrace_limit:
continue
else:
if (c[i] - l[i]) / br > retrace_limit:
continue
avg_v = np.mean(v[ev["sq_start"]:i])
if avg_v > 0 and v[i] <= avg_v * vol_mult:
continue
signals.append(Signal(
idx=i,
direction=1 if first_ret > 0 else -1,
entry_price=c[i - 1],
metadata={"dur": ev["dur"], "vol_ratio": v[i] / avg_v if avg_v > 0 else 0},
))
return signals
if __name__ == "__main__":
strategy = SqueezeAntifakeVol()
strategy.report()
+175
View File
@@ -0,0 +1,175 @@
"""SQ03 — Squeeze con filtri selezionabili.
Ogni filtro è opzionale e attivabile via parametro. Di default attiva solo
antifake + long_squeeze (i due filtri con miglior rapporto accuracy/trade).
Esegue tutte le combinazioni utili e classifica.
Filtri disponibili:
- antifake: scarta breakout con retrace >60% (guadagna ~+1% acc)
- long_sq: solo squeeze durata 10 barre (+1% acc, dimezza trade)
- timing: solo ore 4-16 UTC (+0.5% acc)
- cross: asset secondario in squeeze nelle ultime 10 barre (+0.5%)
- vol: volume al breakout >1.3× media squeeze (+1% acc)
IN:
- OHLCV DataFrame (primario + secondario per cross-check)
- Parametri: filters (lista), bb_window, sq_threshold
OUT:
- BacktestResult per ogni preset di filtri
Risultati tipici (BTC 15m):
antifake+long: 77.3% acc, 2179 trades
antifake+vol: 79.7% acc, 1250 trades SOLIDO
ALL_FILTERS: 79.2% acc, 696 trades (restrittivo)
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
from src.strategies.indicators import keltner_ratio, detect_squeezes
from src.data.downloader import load_data
PRESETS = {
"antifake": ["antifake"],
"long_sq": ["long_sq"],
"antifake+long": ["antifake", "long_sq"],
"antifake+vol": ["antifake", "vol"],
"antifake+timing": ["antifake", "timing"],
"long+timing": ["long_sq", "timing"],
"antifake+long+time": ["antifake", "long_sq", "timing"],
"antifake+cross": ["antifake", "cross"],
"ALL_FILTERS": ["antifake", "long_sq", "timing", "cross"],
}
class SqueezeFiltered(Strategy):
name = "SQ03_filtered"
description = "Squeeze + filtri selezionabili (antifake, long, timing, cross, vol)"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
filters = params.get("filters", ["antifake", "long_sq"])
asset = params.get("asset", "BTC")
tf = params.get("tf", "15m")
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
kcr2 = None
ts2 = None
if "cross" in filters:
secondary = "ETH" if asset == "BTC" else "BTC"
df2 = load_data(secondary, tf)
kcr2 = keltner_ratio(df2["close"].values, df2["high"].values,
df2["low"].values, bb_w)
ts2 = df2["timestamp"].values
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= 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
skip = False
if "antifake" in filters:
br = h[i] - l[i]
if br > 0:
if c[i] > c[i - 1] and (h[i] - c[i]) / br > 0.6:
skip = True
elif c[i] <= c[i - 1] and (c[i] - l[i]) / br > 0.6:
skip = True
if not skip and "long_sq" in filters:
if ev["dur"] < 10:
skip = True
if not skip and "timing" in filters:
hour = ts.iloc[i].hour
if hour < 4 or hour > 16:
skip = True
if not skip and "vol" in filters:
avg_v = np.mean(v[ev["sq_start"]:i])
if avg_v > 0 and v[i] <= avg_v * 1.3:
skip = True
if not skip and "cross" in filters and kcr2 is not None and ts2 is not None:
i2 = np.searchsorted(ts2, ts.values[i].astype("int64") // 10**6)
i2 = min(i2, len(kcr2) - 1)
cross_ok = any(
not np.isnan(kcr2[j]) and kcr2[j] < 0.85
for j in range(max(0, i2 - 10), i2 + 1)
)
if not cross_ok:
skip = True
if skip:
continue
signals.append(Signal(
idx=i,
direction=1 if first_ret > 0 else -1,
entry_price=c[i - 1],
metadata={"dur": ev["dur"], "filters": filters},
))
return signals
def report_all_presets(self, assets=None, timeframes=None, hold=3):
"""Esegue tutti i preset di filtri × asset × tf."""
assets = assets or self.default_assets
timeframes = timeframes or self.default_timeframes
all_results = []
for preset_name, filter_list in PRESETS.items():
for asset in assets:
for tf in timeframes:
r = self.backtest(asset, tf, hold, filters=filter_list)
if r and r.trades >= 20:
r.strategy_name = f"SQ03 {preset_name}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" SQ03 SQUEEZE FILTRATO — TUTTI I PRESET ({len(all_results)} config)")
print(f" Fee: {self.fee_rt*100:.1f}% RT | Leva: {self.leverage:.0f}x | Pos: {self.position_size*100:.0f}%")
print(f"{'=' * 120}")
print(f" {'Nome':<30s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Worst':>12s} {'Anni':>4s}")
print(f" {'' * 110}")
for r in all_results:
r.print_summary()
if all_results:
print(f"\n MIGLIORE: ", end="")
best = all_results[0]
best.print_yearly()
return all_results
if __name__ == "__main__":
strategy = SqueezeFiltered()
strategy.report_all_presets()
+204
View File
@@ -0,0 +1,204 @@
"""SQ04 — Ultimate Squeeze — combinazione incrementale di tutti i filtri.
Testa combinazioni di filtri (antifake, long_sq, timing, cross-asset,
correlation, volume, trend alignment, volatility regime) e classifica
per accuracy.
IN:
- OHLCV DataFrame (primario + secondario)
- Parametri: bb_window, sq_threshold, lista filtri da attivare
OUT:
- BacktestResult per ogni combinazione di filtri
- Classifica globale
Risultati tipici:
BTC 15m antifake+corr: 81.6% acc (ma concentrato 2018)
BTC 15m antifake+vol: 79.7% acc, 1250 trades robusto
ETH 1h antifake+corr: 80.7% acc (solo 2018)
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.strategies.indicators import (
keltner_ratio, detect_squeezes, ema, rv_annualized, rolling_correlation,
)
from src.data.downloader import load_data
class SqueezeUltimate(Strategy):
name = "SQ04_ultimate"
description = "Ultimate squeeze — tutti i filtri combinabili"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
FILTER_PRESETS = {
"antifake+vol": ["antifake", "vol_confirm"],
"antifake+corr": ["antifake", "corr_high"],
"af+long+corr+trend": ["antifake", "long_sq", "corr_high", "trend_align"],
"ALL": ["antifake", "long_sq", "cross", "timing", "corr_high",
"vol_confirm", "trend_align", "low_rv"],
}
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
asset = params.get("asset", "BTC")
tf = params.get("tf", "15m")
filters = params.get("filters", ["antifake", "vol_confirm"])
kcr = keltner_ratio(c, h, l, 14)
events = detect_squeezes(c, h, l, kcr)
secondary = "ETH" if asset == "BTC" else "BTC"
df2 = load_data(secondary, tf)
c2 = df2["close"].values
kcr2 = keltner_ratio(c2, df2["high"].values, df2["low"].values, 14)
ts2 = df2["timestamp"].values
ema_50 = ema(c, 50)
rv_48 = rv_annualized(c, 48)
corr = rolling_correlation(c, c2)
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= 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
skip = False
for f in filters:
if f == "antifake":
br = h[i] - l[i]
if br > 0:
if c[i] > c[i-1] and (h[i] - c[i]) / br > 0.6:
skip = True
elif c[i] <= c[i-1] and (c[i] - l[i]) / br > 0.6:
skip = True
elif f == "long_sq":
if ev["dur"] < 10:
skip = True
elif f == "timing":
if ts.iloc[i].hour < 4 or ts.iloc[i].hour > 16:
skip = True
elif f == "cross":
i2 = np.searchsorted(ts2, ts.values[i].astype("int64") // 10**6)
i2 = min(i2, len(kcr2) - 1)
if not any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85
for j in range(max(0, i2 - 10), i2 + 1)):
skip = True
elif f == "corr_high":
if np.isnan(corr[i]) or abs(corr[i]) < 0.6:
skip = True
elif f == "vol_confirm":
avg_v = np.mean(v[ev["sq_start"]:i])
if avg_v > 0 and v[i] <= avg_v * 1.3:
skip = True
elif f == "trend_align":
if not np.isnan(ema_50[i]):
if first_ret > 0 and c[i] < ema_50[i]:
skip = True
elif first_ret < 0 and c[i] > ema_50[i]:
skip = True
elif f == "low_rv":
if not np.isnan(rv_48[i]) and rv_48[i] >= 1.5:
skip = True
if skip:
break
if skip:
continue
signals.append(Signal(
idx=i,
direction=1 if first_ret > 0 else -1,
entry_price=c[i - 1],
metadata={"dur": ev["dur"], "filters": filters},
))
return signals
def backtest(self, asset: str, tf: str, hold: int = 3, **params):
params.setdefault("asset", asset)
params.setdefault("tf", tf)
df = load_data(asset, tf)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
signals = self.generate_signals(df, ts, **params)
# Usa il backtest della base ma passando i segnali già generati
from src.strategies.base import BacktestResult, YearlyStats, TF_MINUTES
c = df["close"].values
n = len(c)
yearly: dict[int, dict] = {}
capital = float(self.initial_capital)
peak = capital
max_dd = 0.0
total_bars = 0
for sig in signals:
i = sig.idx
if i + hold >= n or i < 1:
continue
entry = sig.entry_price
exit_price = c[min(i + hold - 1, n - 1)]
actual = (exit_price - entry) / entry * sig.direction
net = actual * self.leverage - self.fee_rt * self.leverage
capital += capital * self.position_size * 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, "pnl": 0.0}
yearly[year]["t"] += 1
if actual > 0: yearly[year]["w"] += 1
yearly[year]["pnl"] += net * self.initial_capital
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
yearly_stats = [YearlyStats(y, d["t"], d["w"], d["pnl"]) for y, d in sorted(yearly.items())]
return BacktestResult(
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
trades=all_t, wins=all_w, pnl=sum(d["pnl"] for d 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=hold * TF_MINUTES.get(tf, 60) / 60,
years_active=len(yearly), yearly=yearly_stats,
)
def report_all_presets(self):
"""Esegue tutte le combinazioni preset × asset × tf."""
all_results = []
for preset_name, filter_list in self.FILTER_PRESETS.items():
for asset in self.default_assets:
for tf in self.default_timeframes:
r = self.backtest(asset, tf, filters=filter_list)
if r and r.trades >= 20:
r.strategy_name = f"SQ04 {preset_name}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" SQ04 ULTIMATE — TUTTI I PRESET")
print(f"{'=' * 120}")
for r in all_results:
r.print_summary()
return all_results
if __name__ == "__main__":
strategy = SqueezeUltimate()
strategy.report_all_presets()
+131
View File
@@ -0,0 +1,131 @@
"""IB01 — Inside Bar Breakout.
Pattern di compressione a singola candela: quando una barra ha high < prev high
E low > prev low, il prezzo si sta comprimendo. Al breakout del range della
inside bar, segui la direzione.
17% delle candele 15m sono inside bars frequenza altissima.
IN:
- OHLCV DataFrame
- Parametri: min_consecutive (N inside bars consecutivi),
volume_filter, breakout_confirm
OUT:
- Signal al breakout del range dell'inside bar
- BacktestResult
Logica:
1. Identifica N inside bars consecutivi (compressione)
2. Quando il prezzo rompe il range entra nella direzione del breakout
3. Filtro: volume al breakout > media
4. Hold fisso
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
class InsideBarBreakout(Strategy):
name = "IB01_inside_bar"
description = "Inside bar breakout — compressione a singola candela"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
min_consec = params.get("min_consecutive", 2)
use_vol = params.get("vol_filter", False)
min_range_pct = params.get("min_range_pct", 0.002)
# Volume media
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
signals = []
consec = 0
mother_high = 0.0
mother_low = 0.0
for i in range(1, n - 1):
is_inside = h[i] <= h[i - 1] and l[i] >= l[i - 1]
if is_inside:
if consec == 0:
mother_high = h[i - 1]
mother_low = l[i - 1]
consec += 1
else:
if consec >= min_consec:
range_pct = (mother_high - mother_low) / mother_low if mother_low > 0 else 0
if range_pct < min_range_pct:
consec = 0
continue
# Breakout detection sulla barra corrente
if c[i] > mother_high:
direction = 1
elif c[i] < mother_low:
direction = -1
else:
consec = 0
continue
# Volume filter
if use_vol and not np.isnan(vol_ma[i]):
if v[i] < vol_ma[i] * 1.2:
consec = 0
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={"consec": consec, "range_pct": round(range_pct * 100, 3)},
))
consec = 0
return signals
if __name__ == "__main__":
strategy = InsideBarBreakout()
configs = [
("2ib", {"min_consecutive": 2}),
("3ib", {"min_consecutive": 3}),
("4ib", {"min_consecutive": 4}),
("2ib+vol", {"min_consecutive": 2, "vol_filter": True}),
("3ib+vol", {"min_consecutive": 3, "vol_filter": True}),
("2ib r>0.3%", {"min_consecutive": 2, "min_range_pct": 0.003}),
("3ib r>0.3%", {"min_consecutive": 3, "min_range_pct": 0.003}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"IB01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" IB01 INSIDE BAR BREAKOUT — TOP 20")
print(f"{'=' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+133
View File
@@ -0,0 +1,133 @@
"""DC01 — Donchian Channel Breakout con filtri.
Trend-following classico: quando il prezzo rompe il massimo/minimo degli
ultimi N periodi, entra nella direzione del breakout.
Completamente diverso dallo squeeze (che usa Bollinger/Keltner).
Donchian cattura breakout di RANGE, non di VOLATILITÀ.
IN:
- OHLCV DataFrame
- Parametri: channel_period, volume_filter, atr_stop, trend_filter
OUT:
- Signal al breakout del canale Donchian
- BacktestResult
Logica:
1. Donchian upper = max(high, N periodi), lower = min(low, N periodi)
2. Close > upper LONG (breakout rialzista)
3. Close < lower SHORT (breakout ribassista)
4. Filtri: volume, trend EMA, ATR minimo
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
class DonchianBreakout(Strategy):
name = "DC01_donchian"
description = "Donchian Channel breakout — trend-following su range"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
period = params.get("channel_period", 48)
use_vol = params.get("vol_filter", False)
use_trend = params.get("trend_filter", False)
cooldown = params.get("cooldown", 6)
# EMA per trend filter
ema_50 = np.full(n, np.nan)
k = 2 / 51
ema_50[49] = np.mean(c[:50])
for i in range(50, n):
ema_50[i] = c[i] * k + ema_50[i - 1] * (1 - k)
# Volume media
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
signals = []
last_signal_idx = -cooldown
for i in range(period + 1, n):
if i - last_signal_idx < cooldown:
continue
upper = np.max(h[i - period:i])
lower = np.min(l[i - period:i])
direction = 0
if c[i] > upper:
direction = 1
elif c[i] < lower:
direction = -1
if direction == 0:
continue
# Trend filter: breakout must align with EMA trend
if use_trend and not np.isnan(ema_50[i]):
if direction == 1 and c[i] < ema_50[i]:
continue
if direction == -1 and c[i] > ema_50[i]:
continue
# Volume filter
if use_vol and not np.isnan(vol_ma[i]):
if v[i] < vol_ma[i] * 1.3:
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={"upper": float(upper), "lower": float(lower)},
))
last_signal_idx = i
return signals
if __name__ == "__main__":
strategy = DonchianBreakout()
configs = [
("p=24", {"channel_period": 24}),
("p=48", {"channel_period": 48}),
("p=96", {"channel_period": 96}),
("p=48+trend", {"channel_period": 48, "trend_filter": True}),
("p=48+vol", {"channel_period": 48, "vol_filter": True}),
("p=48+t+v", {"channel_period": 48, "trend_filter": True, "vol_filter": True}),
("p=96+t+v", {"channel_period": 96, "trend_filter": True, "vol_filter": True}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6, 12]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"DC01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" DC01 DONCHIAN BREAKOUT — TOP 20")
print(f"{'=' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+163
View File
@@ -0,0 +1,163 @@
"""SB01 — Squeeze Breakout con Retest.
Il problema di SQ01/SQ02: entri al breakout, ma molti breakout sono fakeout.
Soluzione: aspetta il RETEST. Dopo il breakout, il prezzo spesso torna a
testare il livello di breakout prima di continuare.
Più selettivo di SQ02 meno trade ma più accurati.
Anti-overfitting: meccanismo strutturale (retest è fenomeno di mercato reale).
IN:
- OHLCV DataFrame
- Parametri: bb_window, sq_threshold, retest_window (quante barre aspettare
il retest), retest_tolerance (quanto può tornare indietro)
OUT:
- Signal al retest confermato (non al breakout iniziale)
- BacktestResult
Logica:
1. Rileva squeeze release (come SQ01)
2. NON entrare subito segna direzione e livello di breakout
3. Nelle N barre successive, aspetta che il prezzo torni verso il livello
4. Se il prezzo torna nel range di tolleranza e poi rimbalza ENTRA
5. Se il prezzo non torna skip (momentum troppo forte, entry persa)
6. Se il prezzo sfonda il livello fakeout confermato, skip
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.strategies.indicators import keltner_ratio, detect_squeezes
class SqueezeBreakoutRetest(Strategy):
name = "SB01_squeeze_retest"
description = "Squeeze breakout con retest — entra solo dopo pullback confermato"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
retest_window = params.get("retest_window", 8)
retest_tol = params.get("retest_tolerance", 0.5)
use_vol = params.get("vol_filter", False)
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
signals = []
for ev in events:
brk_idx = ev["idx"]
if brk_idx + retest_window + 3 >= n or brk_idx < 1:
continue
# Direzione breakout
first_ret = (c[brk_idx] - c[brk_idx - 1]) / c[brk_idx - 1]
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
breakout_level = c[brk_idx - 1]
breakout_move = abs(first_ret)
# Aspetta retest nelle prossime N barre
retest_found = False
retest_idx = -1
for j in range(brk_idx + 1, min(brk_idx + retest_window + 1, n)):
if direction == 1:
# Long: il prezzo deve tornare GIÙ verso breakout_level
pullback = (h[brk_idx] - l[j]) / (h[brk_idx] - breakout_level) if h[brk_idx] > breakout_level else 0
if pullback >= retest_tol:
# Tornato abbastanza — ora deve rimbalzare
if c[j] > breakout_level:
retest_found = True
retest_idx = j
break
elif c[j] < breakout_level * 0.998:
# Sfondato sotto → fakeout
break
else:
# Short: il prezzo deve tornare SU verso breakout_level
pullback = (h[j] - l[brk_idx]) / (breakout_level - l[brk_idx]) if breakout_level > l[brk_idx] else 0
if pullback >= retest_tol:
if c[j] < breakout_level:
retest_found = True
retest_idx = j
break
elif c[j] > breakout_level * 1.002:
break
if not retest_found or retest_idx < 0:
continue
# Volume filter al retest
if use_vol and not np.isnan(vol_ma[retest_idx]):
if v[retest_idx] < vol_ma[retest_idx] * 0.8:
continue
signals.append(Signal(
idx=retest_idx, direction=direction,
entry_price=c[retest_idx],
metadata={
"breakout_idx": brk_idx,
"retest_bars": retest_idx - brk_idx,
"breakout_move": round(breakout_move * 100, 3),
},
))
return signals
if __name__ == "__main__":
strategy = SqueezeBreakoutRetest()
configs = [
("rt8 tol50%", {"retest_window": 8, "retest_tolerance": 0.5}),
("rt6 tol50%", {"retest_window": 6, "retest_tolerance": 0.5}),
("rt10 tol50%", {"retest_window": 10, "retest_tolerance": 0.5}),
("rt8 tol30%", {"retest_window": 8, "retest_tolerance": 0.3}),
("rt8 tol70%", {"retest_window": 8, "retest_tolerance": 0.7}),
("rt8 tol50%+vol", {"retest_window": 8, "retest_tolerance": 0.5, "vol_filter": True}),
("rt6 tol30%", {"retest_window": 6, "retest_tolerance": 0.3}),
("rt12 tol50%", {"retest_window": 12, "retest_tolerance": 0.5}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"SB01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(f" SB01 SQUEEZE BREAKOUT RETEST — TOP 25")
print(f"{'=' * 130}")
for r in all_results[:25]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
# Confronto con benchmark
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250 trades, DD 6.5%, 9/9 anni")
+148
View File
@@ -0,0 +1,148 @@
"""MR01 — Mean Reversion da estremi RSI.
Approccio opposto allo squeeze: quando il prezzo va troppo lontano troppo veloce,
scommetti che torni indietro. Autocorrelazione lag-1 negativa (-0.21 BTC, -0.35 ETH)
conferma che il mercato a 15m è mean-reverting.
IN:
- OHLCV DataFrame
- Parametri: rsi_period, rsi_oversold, rsi_overbought, hold_bars,
volume_filter (volume > N× media), atr_filter (move > N×ATR)
OUT:
- Signal: long quando RSI < oversold, short quando RSI > overbought
- BacktestResult con metriche
Logica:
1. RSI scende sotto soglia oversold LONG (prezzo tornerà su)
2. RSI sale sopra soglia overbought SHORT (prezzo tornerà giù)
3. Filtro opzionale: volume spike conferma l'eccesso
4. Filtro opzionale: move recente > N×ATR (eccesso di prezzo)
5. Hold fisso, poi chiudi
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
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
class MeanReversionRSI(Strategy):
name = "MR01_mean_reversion_rsi"
description = "Mean reversion da estremi RSI — fade eccessi direzionali"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
rsi_period = params.get("rsi_period", 14)
oversold = params.get("rsi_oversold", 25)
overbought = params.get("rsi_overbought", 75)
use_vol_filter = params.get("vol_filter", False)
use_atr_filter = params.get("atr_filter", False)
cooldown = params.get("cooldown", 4)
rsi_vals = rsi(c, rsi_period)
# Volume media rolling
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
# ATR
tr = np.maximum(h[1:] - l[1:],
np.maximum(np.abs(h[1:] - c[:-1]), np.abs(l[1:] - c[:-1])))
atr_vals = np.full(n, np.nan)
for i in range(15, len(tr)):
atr_vals[i + 1] = np.mean(tr[i - 14:i])
signals = []
last_signal_idx = -cooldown
for i in range(20, n):
if i - last_signal_idx < cooldown:
continue
direction = 0
if rsi_vals[i] < oversold:
direction = 1 # oversold → long
elif rsi_vals[i] > overbought:
direction = -1 # overbought → short
if direction == 0:
continue
# Volume filter
if use_vol_filter and not np.isnan(vol_ma[i]):
if v[i] < vol_ma[i] * 1.5:
continue
# ATR filter: il move recente deve essere > 1.5× ATR
if use_atr_filter and not np.isnan(atr_vals[i]):
recent_move = abs(c[i] - c[max(0, i - 3)]) / c[max(0, i - 3)]
if recent_move < atr_vals[i] / c[i] * 1.5:
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={"rsi": float(rsi_vals[i])},
))
last_signal_idx = i
return signals
if __name__ == "__main__":
strategy = MeanReversionRSI()
configs = [
("RSI25/75", {}),
("RSI20/80", {"rsi_oversold": 20, "rsi_overbought": 80}),
("RSI25/75+vol", {"vol_filter": True}),
("RSI20/80+vol", {"rsi_oversold": 20, "rsi_overbought": 80, "vol_filter": True}),
("RSI25/75+atr", {"atr_filter": True}),
("RSI20/80+vol+atr", {"rsi_oversold": 20, "rsi_overbought": 80, "vol_filter": True, "atr_filter": True}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"MR01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" MR01 MEAN REVERSION RSI — TOP 20")
print(f"{'=' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+133
View File
@@ -0,0 +1,133 @@
"""VO01 — Volume Spike Reversal.
Quando il volume esplode (>3× media) con un forte move direzionale,
il mercato è in eccesso fade il move (mean reversion).
Diverso dallo squeeze: non cerca compressione, cerca ECCESSO.
Il volume spike indica panico/euforia reversal probabile.
IN:
- OHLCV DataFrame
- Parametri: vol_mult (3), move_threshold (0.005), hold
OUT:
- Signal: fade la direzione del volume spike
- BacktestResult
Logica:
1. Volume > vol_mult × media 20 periodi
2. Move nella candela > move_threshold (0.5%)
3. Direzione: opposta al move (mean reversion)
4. Filtro: non entrare se già in trend forte (EMA slope)
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
class VolumeSpikeReversal(Strategy):
name = "VO01_vol_spike_reversal"
description = "Volume spike reversal — fade eccessi di volume/prezzo"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
o = df["open"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
vol_mult = params.get("vol_mult", 3.0)
move_thr = params.get("move_threshold", 0.005)
use_trend_filter = params.get("trend_filter", False)
cooldown = params.get("cooldown", 4)
# Volume media rolling
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
# EMA per trend filter
ema_20 = np.full(n, np.nan)
k = 2 / 21
ema_20[19] = np.mean(c[:20])
for i in range(20, n):
ema_20[i] = c[i] * k + ema_20[i - 1] * (1 - k)
signals = []
last_idx = -cooldown
for i in range(21, n):
if i - last_idx < cooldown:
continue
if np.isnan(vol_ma[i]):
continue
# Volume spike
if v[i] < vol_ma[i] * vol_mult:
continue
# Price move
move = (c[i] - o[i]) / o[i] if o[i] > 0 else 0
if abs(move) < move_thr:
continue
# Fade: opposto al move
direction = -1 if move > 0 else 1
# Trend filter: non fare mean reversion contro trend forte
if use_trend_filter and not np.isnan(ema_20[i]):
ema_slope = (ema_20[i] - ema_20[max(0, i - 5)]) / ema_20[max(0, i - 5)]
if direction == -1 and ema_slope > 0.005:
continue
if direction == 1 and ema_slope < -0.005:
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={"vol_ratio": float(v[i] / vol_ma[i]), "move_pct": round(move * 100, 3)},
))
last_idx = i
return signals
if __name__ == "__main__":
strategy = VolumeSpikeReversal()
configs = [
("v3x m0.5%", {"vol_mult": 3.0, "move_threshold": 0.005}),
("v3x m1%", {"vol_mult": 3.0, "move_threshold": 0.01}),
("v4x m0.5%", {"vol_mult": 4.0, "move_threshold": 0.005}),
("v4x m1%", {"vol_mult": 4.0, "move_threshold": 0.01}),
("v3x m0.5%+tf", {"vol_mult": 3.0, "move_threshold": 0.005, "trend_filter": True}),
("v3x m1%+tf", {"vol_mult": 3.0, "move_threshold": 0.01, "trend_filter": True}),
("v5x m1%", {"vol_mult": 5.0, "move_threshold": 0.01}),
("v5x m1%+tf", {"vol_mult": 5.0, "move_threshold": 0.01, "trend_filter": True}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"VO01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" VO01 VOLUME SPIKE REVERSAL — TOP 20")
print(f"{'=' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+169
View File
@@ -0,0 +1,169 @@
"""HY01 — Squeeze + Mean Reversion Ibrida.
Insight: durante lo squeeze (bassa volatilità), il prezzo mean-reverte
DENTRO il range compresso. Autocorrelazione negativa a 15m conferma.
Invece di aspettare il BREAKOUT, tradi la MEAN REVERSION dentro lo squeeze.
Completamente diverso da SQ01-SQ04 che aspettano il RILASCIO.
IN:
- OHLCV DataFrame
- Parametri: bb_window, sq_threshold, rsi_period, rsi_levels,
vol_filter, bb_touch (prezzo tocca banda Bollinger)
OUT:
- Signal: long quando RSI oversold DURANTE squeeze, short quando overbought
- BacktestResult
Logica:
1. Verifica che siamo IN squeeze (BB dentro KC)
2. Prezzo tocca banda inferiore BB LONG (tornerà alla media)
3. Prezzo tocca banda superiore BB SHORT (tornerà alla media)
4. Conferma RSI: deve essere estremo nella direzione
5. Hold corto (2-3 barre) target: ritorno alla media
6. Stop: se prezzo rompe lo squeeze chiudi subito
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.strategies.indicators import keltner_ratio
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 bollinger(close, window=14):
n = len(close)
upper = np.full(n, np.nan)
lower = np.full(n, np.nan)
mid = np.full(n, np.nan)
for i in range(window, n):
wc = close[i - window:i]
m = np.mean(wc)
s = np.std(wc)
mid[i] = m
upper[i] = m + 2 * s
lower[i] = m - 2 * s
return upper, mid, lower
class SqueezeMeanReversion(Strategy):
name = "HY01_squeeze_mr"
description = "Mean reversion DENTRO lo squeeze — fade estremi in range compresso"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
rsi_period = params.get("rsi_period", 14)
rsi_low = params.get("rsi_oversold", 30)
rsi_high = params.get("rsi_overbought", 70)
use_bb_touch = params.get("bb_touch", True)
cooldown = params.get("cooldown", 3)
kcr = keltner_ratio(c, h, l, bb_w)
rsi_vals = rsi(c, rsi_period)
bb_upper, bb_mid, bb_lower = bollinger(c, bb_w)
signals = []
last_idx = -cooldown
for i in range(bb_w + 1, n):
if i - last_idx < cooldown:
continue
if np.isnan(kcr[i]) or np.isnan(bb_lower[i]):
continue
# Must be IN squeeze
if kcr[i] >= sq_thr:
continue
direction = 0
if use_bb_touch:
# Prezzo tocca/rompe BB lower → long (mean reversion up)
if c[i] <= bb_lower[i] and rsi_vals[i] < rsi_low:
direction = 1
# Prezzo tocca/rompe BB upper → short (mean reversion down)
elif c[i] >= bb_upper[i] and rsi_vals[i] > rsi_high:
direction = -1
else:
# Solo RSI
if rsi_vals[i] < rsi_low:
direction = 1
elif rsi_vals[i] > rsi_high:
direction = -1
if direction == 0:
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={
"rsi": float(rsi_vals[i]),
"kcr": float(kcr[i]),
"bb_pos": "lower" if direction == 1 else "upper",
},
))
last_idx = i
return signals
if __name__ == "__main__":
strategy = SqueezeMeanReversion()
configs = [
("bb+rsi30/70", {"bb_touch": True, "rsi_oversold": 30, "rsi_overbought": 70}),
("bb+rsi25/75", {"bb_touch": True, "rsi_oversold": 25, "rsi_overbought": 75}),
("bb+rsi35/65", {"bb_touch": True, "rsi_oversold": 35, "rsi_overbought": 65}),
("rsi30/70 only", {"bb_touch": False, "rsi_oversold": 30, "rsi_overbought": 70}),
("rsi25/75 only", {"bb_touch": False, "rsi_oversold": 25, "rsi_overbought": 75}),
("sq<0.7 bb+rsi30", {"bb_touch": True, "sq_threshold": 0.7, "rsi_oversold": 30, "rsi_overbought": 70}),
("sq<0.9 bb+rsi30", {"bb_touch": True, "sq_threshold": 0.9, "rsi_oversold": 30, "rsi_overbought": 70}),
("sq<0.9 rsi35/65", {"bb_touch": False, "sq_threshold": 0.9, "rsi_oversold": 35, "rsi_overbought": 65}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [2, 3, 4]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"HY01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(f" HY01 SQUEEZE MEAN REVERSION — TOP 25")
print(f"{'=' * 130}")
for r in all_results[:25]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+291
View File
@@ -0,0 +1,291 @@
"""Multi-Strategy Paper Trader — orchestratore per N strategie in parallelo."""
from __future__ import annotations
import time
import yaml
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pandas as pd
from src.live.cerbero_client import CerberoClient
from src.live.strategy_loader import load_strategy
from src.live.strategy_worker import StrategyWorker
from src.live.signal_engine import SignalEngine
from src.live.telegram_notifier import send_telegram
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = PROJECT_ROOT / "data" / "paper_trades"
RESOLUTION_MAP = {"15m": "15", "1h": "60", "5m": "5"}
INSTRUMENT_MAP = {
"BTC": "BTC-PERPETUAL",
"ETH": "ETH-PERPETUAL",
}
class MLWorkerWrapper:
"""Wrapper speciale per ML01 che usa SignalEngine con training."""
def __init__(self, worker: StrategyWorker, config: dict):
self.worker = worker
self.engine = SignalEngine(
bb_w=config.get("params", {}).get("bb_window", 14),
sq_thr=config.get("params", {}).get("sq_threshold", 0.8),
ml_thr=config.get("params", {}).get("ml_threshold", 0.70),
)
self.trained = False
self.last_train: datetime | None = None
self.retrain_hours = config.get("retrain_hours", 24)
def needs_training(self) -> bool:
if not self.trained:
return True
if self.last_train is None:
return True
elapsed = (datetime.now(timezone.utc) - self.last_train).total_seconds()
return elapsed > self.retrain_hours * 3600
def train(self, df: pd.DataFrame, hold: int = 3):
result = self.engine.train(df, lookahead=hold)
if "error" not in result:
self.trained = True
self.last_train = datetime.now(timezone.utc)
print(f" [{self.worker.worker_id}] TRAIN OK: {result}")
else:
print(f" [{self.worker.worker_id}] TRAIN FAIL: {result}")
def tick(self, df: pd.DataFrame):
if not self.trained:
return
worker = self.worker
c = df["close"].values
current_price = float(c[-1])
current_ts = int(df["timestamp"].iloc[-1])
if worker.in_position:
if current_ts > worker.last_bar_ts:
worker.bars_held += 1
worker.last_bar_ts = current_ts
if worker.bars_held >= worker.hold_bars:
worker._close_position(current_price, "hold_limit")
else:
pnl_pct = (current_price - worker.entry_price) / worker.entry_price * worker.direction
if pnl_pct <= -0.02:
worker._close_position(current_price, "stop_loss")
worker._save_state()
return
signal = self.engine.check_signal(df)
if signal:
from src.strategies.base import Signal
direction = 1 if signal["direction"] == "buy" else -1
sig = Signal(idx=len(df)-1, direction=direction, entry_price=current_price)
worker._open_position(sig, current_price)
worker.last_bar_ts = current_ts
worker._save_state()
def load_config(path: Path) -> dict:
with open(path) as f:
return yaml.safe_load(f)
def build_workers(config: dict) -> tuple[list[StrategyWorker], list[MLWorkerWrapper]]:
"""Crea worker da config YAML."""
defaults = config.get("defaults", {})
regular_workers: list[StrategyWorker] = []
ml_workers: list[MLWorkerWrapper] = []
for entry in config.get("strategies", []):
if not entry.get("enabled", True):
continue
name = entry["name"]
asset = entry["asset"]
tf = entry["tf"]
capital = entry.get("capital", defaults.get("capital", 1000))
pos_size = entry.get("position_size", defaults.get("position_size", 0.15))
leverage = entry.get("leverage", defaults.get("leverage", 3))
hold = entry.get("hold_bars", defaults.get("hold_bars", 3))
params = entry.get("params", {})
strategy = load_strategy(name)
worker = StrategyWorker(
strategy=strategy, asset=asset, tf=tf,
capital=capital, position_size=pos_size,
leverage=leverage, hold_bars=hold,
params=params, data_dir=DATA_DIR,
)
if name == "ML01_squeeze_gbm":
ml_wrapper = MLWorkerWrapper(worker, {**defaults, **entry})
ml_workers.append(ml_wrapper)
else:
regular_workers.append(worker)
return regular_workers, ml_workers
def run():
config_path = PROJECT_ROOT / "strategies.yml"
if not config_path.exists():
print(f"ERRORE: {config_path} non trovato")
return
config = load_config(config_path)
defaults = config.get("defaults", {})
poll_seconds = defaults.get("poll_seconds", 60)
lookback_days = 60
train_lookback_days = 365
regular_workers, ml_workers = build_workers(config)
all_worker_count = len(regular_workers) + len(ml_workers)
if all_worker_count == 0:
print("Nessuna strategia abilitata in strategies.yml")
return
client = CerberoClient()
print("=" * 70)
print(f" MULTI-STRATEGY PAPER TRADER")
print(f" Strategie attive: {all_worker_count}")
print(f" Poll: ogni {poll_seconds}s")
print(f" Data dir: {DATA_DIR}")
print("=" * 70)
for w in regular_workers:
print(f"{w.status_summary}")
for mw in ml_workers:
print(f"{mw.worker.status_summary} [ML]")
send_telegram(f"🚀 Multi-Strategy avviato: {all_worker_count} strategie")
# Raccogli asset/tf unici per fetch raggruppato
def _get_data_keys() -> set[tuple[str, str]]:
keys = set()
for w in regular_workers:
keys.add((w.asset, w.tf))
for mw in ml_workers:
keys.add((mw.worker.asset, mw.worker.tf))
return keys
# Training iniziale ML
for mw in ml_workers:
asset = mw.worker.asset
instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
resolution = RESOLUTION_MAP.get(mw.worker.tf, "15")
end = datetime.now(timezone.utc)
start = end - timedelta(days=train_lookback_days)
candles = client.get_historical(instrument, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), resolution)
if candles:
df_train = pd.DataFrame(candles)
df_train["timestamp"] = df_train["timestamp"].astype("int64")
df_train = df_train.sort_values("timestamp").reset_index(drop=True)
mw.train(df_train, hold=mw.worker.hold_bars)
while True:
try:
data_keys = _get_data_keys()
candle_cache: dict[tuple[str, str], pd.DataFrame] = {}
for asset, tf in data_keys:
instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
resolution = RESOLUTION_MAP.get(tf, "15")
end = datetime.now(timezone.utc)
start = end - timedelta(days=lookback_days)
candles = client.get_historical(
instrument, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), resolution,
)
if candles:
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
df = df.sort_values("timestamp").reset_index(drop=True)
candle_cache[(asset, tf)] = df
# Fetch 1h live per strategie multi-timeframe (es. MT01):
# il trend va preso da Cerbero, non dal parquet statico (che resta indietro).
htf_cache: dict[str, pd.DataFrame] = {}
mtf_assets = {w.asset for w in regular_workers if w.strategy.name.startswith("MT01")}
for asset in mtf_assets:
instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
end = datetime.now(timezone.utc)
start = end - timedelta(days=lookback_days)
try:
candles_1h = client.get_historical(
instrument, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), "60",
)
if candles_1h:
df1h = pd.DataFrame(candles_1h)
df1h["timestamp"] = df1h["timestamp"].astype("int64")
htf_cache[asset] = df1h.sort_values("timestamp").reset_index(drop=True)
except Exception as e:
print(f" [1h fetch {asset}] ERRORE: {e}")
# Tick regular workers
for w in regular_workers:
key = (w.asset, w.tf)
if key in candle_cache:
try:
w.tick(candle_cache[key], df_1h=htf_cache.get(w.asset))
except Exception as e:
print(f" [{w.worker_id}] ERRORE: {e}")
# Tick ML workers
for mw in ml_workers:
key = (mw.worker.asset, mw.worker.tf)
if key not in candle_cache:
continue
if mw.needs_training():
mw.train(candle_cache[key], hold=mw.worker.hold_bars)
try:
mw.tick(candle_cache[key])
except Exception as e:
print(f" [{mw.worker.worker_id}] ERRORE: {e}")
# Status periodico
now = datetime.now(timezone.utc)
if now.minute == 0 and now.second < poll_seconds:
lines = [f"📊 Status {now.strftime('%H:%M')} UTC"]
for w in regular_workers:
lines.append(f" {w.status_summary}")
for mw in ml_workers:
lines.append(f" {mw.worker.status_summary} [ML]")
send_telegram("\n".join(lines))
except KeyboardInterrupt:
print("\nShutdown...")
for w in regular_workers:
if w.in_position:
df = candle_cache.get((w.asset, w.tf))
if df is not None and not df.empty:
w._close_position(float(df["close"].iloc[-1]), "shutdown")
w._save_state()
for mw in ml_workers:
if mw.worker.in_position:
df = candle_cache.get((mw.worker.asset, mw.worker.tf))
if df is not None and not df.empty:
mw.worker._close_position(float(df["close"].iloc[-1]), "shutdown")
mw.worker._save_state()
send_telegram("🛑 Multi-Strategy arrestato")
break
except Exception as e:
print(f" ERRORE GLOBALE: {e}")
import traceback
traceback.print_exc()
time.sleep(poll_seconds)
if __name__ == "__main__":
run()
+2
View File
@@ -10,6 +10,7 @@ import pandas as pd
from src.live.cerbero_client import CerberoClient
from src.live.signal_engine import SignalEngine
from src.live.telegram_notifier import notify_event
LOG_DIR = Path(__file__).resolve().parents[2] / "data" / "paper_trades"
INSTRUMENT = "ETH_USDC-PERPETUAL"
@@ -52,6 +53,7 @@ class PaperTrader:
with open(self.log_path, "a") as f:
f.write(json.dumps(entry) + "\n")
print(f" [{entry['timestamp'][:19]}] {event}: {json.dumps(data or {})}")
notify_event(event, data)
def save_status(self):
status = {
+58 -6
View File
@@ -112,6 +112,54 @@ class SignalEngine:
self.squeeze_start_idx = 0
self.trained = False
def _new_model(self) -> GradientBoostingClassifier:
return GradientBoostingClassifier(
n_estimators=150, max_depth=4, min_samples_leaf=10,
learning_rate=0.05, subsample=0.8, random_state=42,
)
def _validate_oos(self, X: np.ndarray, y: np.ndarray, test_frac: float = 0.2) -> dict:
"""Split temporale (no shuffle) per stimare la performance out-of-sample.
Allena su training iniziale e valuta sull'ultimo `test_frac` dei campioni.
Oltre all'accuratezza OOS, riporta la precisione sui soli segnali con
confidenza >= ml_thr cioè i trade che la strategia aprirebbe davvero.
"""
n_test = int(len(X) * test_frac)
n_train = len(X) - n_test
if n_train < 30 or n_test < 5:
return {"oos_warning": "test set troppo piccolo", "oos_test_samples": n_test}
scaler = StandardScaler()
X_tr = scaler.fit_transform(X[:n_train])
X_te = scaler.transform(X[n_train:])
y_tr, y_te = y[:n_train], y[n_train:]
model = self._new_model()
model.fit(X_tr, y_tr)
up_idx = list(model.classes_).index(1)
p_up = model.predict_proba(X_te)[:, up_idx]
test_acc = float(np.mean((p_up >= 0.5).astype(int) == y_te) * 100)
oos_train_acc = float(np.mean(model.predict(X_tr) == y_tr) * 100)
long_sig = p_up >= self.ml_thr
short_sig = p_up <= (1 - self.ml_thr)
n_sig = int((long_sig | short_sig).sum())
if n_sig > 0:
correct = int(((long_sig & (y_te == 1)) | (short_sig & (y_te == 0))).sum())
sig_prec = round(correct / n_sig * 100, 1)
else:
sig_prec = None
return {
"oos_train_accuracy": round(oos_train_acc, 1),
"oos_test_accuracy": round(test_acc, 1),
"oos_test_samples": n_test,
"oos_signals": n_sig,
"oos_signal_precision": sig_prec,
}
def train(self, df: pd.DataFrame, lookahead: int = 3) -> dict:
"""Addestra il modello su dati storici."""
close = df["close"].values
@@ -154,20 +202,24 @@ class SignalEngine:
X = np.array(X_all)
y = np.array(y_all)
oos = self._validate_oos(X, y)
self.scaler = StandardScaler()
X_s = self.scaler.fit_transform(X)
self.model = GradientBoostingClassifier(
n_estimators=150, max_depth=4, min_samples_leaf=10,
learning_rate=0.05, subsample=0.8, random_state=42,
)
self.model = self._new_model()
self.model.fit(X_s, y)
self.trained = True
preds = self.model.predict(X_s)
train_acc = np.mean(preds == y) * 100
train_acc = float(np.mean(preds == y) * 100)
return {"samples": len(X), "up_ratio": np.mean(y) * 100, "train_accuracy": train_acc}
return {
"samples": len(X),
"up_ratio": round(float(np.mean(y) * 100), 1),
"train_accuracy": round(train_acc, 1),
**oos,
}
def check_signal(self, df: pd.DataFrame) -> dict | None:
"""Controlla se c'è un segnale sulle ultime candele.
+54
View File
@@ -0,0 +1,54 @@
"""Import dinamico delle classi Strategy da scripts/strategies/."""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
from src.strategies.base import Strategy
PROJECT_ROOT = Path(__file__).resolve().parents[2]
STRATEGIES_DIR = PROJECT_ROOT / "scripts" / "strategies"
_REGISTRY: dict[str, type[Strategy]] = {}
# Solo strategie con edge netto validato out-of-sample (fee-aware).
# La famiglia squeeze-breakout (SQ/MT/ML/AD/CM/PD) e' stata spostata in
# scripts/waste/: l'edge storico era un artefatto di look-ahead
# (vedi scripts/analysis/oos_validation.py).
MODULE_MAP = {
"MR01_bollinger_fade": ("MR01_bollinger_fade", "BollingerFade"),
"MR02_donchian_fade": ("MR02_donchian_fade", "DonchianFade"),
"MR03_keltner_fade": ("MR03_keltner_fade", "KeltnerFade"),
"MR07_return_reversal": ("MR07_return_reversal", "ReturnReversal"),
}
def load_strategy(name: str) -> Strategy:
"""Carica e istanzia una Strategy per nome."""
if name in _REGISTRY:
return _REGISTRY[name]()
if name not in MODULE_MAP:
raise ValueError(f"Strategia sconosciuta: {name}. Disponibili: {list(MODULE_MAP)}")
module_file, class_name = MODULE_MAP[name]
module_path = STRATEGIES_DIR / f"{module_file}.py"
if not module_path.exists():
raise FileNotFoundError(f"File strategia non trovato: {module_path}")
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
spec = importlib.util.spec_from_file_location(f"strategies.{module_file}", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
cls = getattr(module, class_name)
_REGISTRY[name] = cls
return cls()
def list_available() -> list[str]:
return list(MODULE_MAP.keys())
+273
View File
@@ -0,0 +1,273 @@
"""Worker per singola strategia — paper trading con stato persistente."""
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.strategies.base import Strategy, Signal
from src.live.telegram_notifier import notify_event
FEE_RT = 0.002
class StrategyWorker:
"""Gestisce paper trading per una singola strategia/asset/tf."""
def __init__(
self,
strategy: Strategy,
asset: str,
tf: str,
capital: float = 1000.0,
position_size: float = 0.15,
leverage: float = 3.0,
hold_bars: int = 3,
params: dict | None = None,
data_dir: Path = Path("data/paper_trades"),
):
self.strategy = strategy
self.asset = asset
self.tf = tf
self.initial_capital = capital
self.position_size = position_size
self.leverage = leverage
self.hold_bars = hold_bars
self.params = params or {}
self.worker_id = f"{strategy.name}__{asset}__{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: int = 0
self.entry_price: float = 0
self.entry_time: str = ""
self.bars_held: int = 0
self.total_trades: int = 0
self.total_wins: int = 0
self.started_at = datetime.now(timezone.utc).isoformat()
self.last_bar_ts: int = 0
# Exit guidati dalla strategia via Signal.metadata (0 = usa hold_bars/stop legacy)
self.tp: float = 0.0
self.sl: float = 0.0
self.max_bars: int = 0
# Fee dalla strategia (MR01 = 0.001 realistico Deribit), fallback al default modulo
self.fee_rt: float = float(getattr(strategy, "fee_rt", FEE_RT))
self._load_state()
self._save_state()
def _load_state(self):
"""Riprende stato da status.json se esiste."""
if not self.status_path.exists():
self._log("INIT", {"capital": self.capital, "strategy": self.strategy.name,
"asset": self.asset, "tf": self.tf})
return
with open(self.status_path) as f:
state = json.load(f)
self.capital = state.get("capital", self.initial_capital)
self.in_position = state.get("in_position", False)
self.direction = state.get("direction", 0)
self.entry_price = state.get("entry_price", 0)
self.entry_time = state.get("entry_time", "")
self.bars_held = state.get("bars_held", 0)
self.total_trades = state.get("total_trades", 0)
self.total_wins = state.get("total_wins", 0)
self.started_at = state.get("started_at", self.started_at)
self.last_bar_ts = state.get("last_bar_ts", 0)
self.tp = state.get("tp", 0.0)
self.sl = state.get("sl", 0.0)
self.max_bars = state.get("max_bars", 0)
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_price": self.entry_price,
"entry_time": self.entry_time,
"bars_held": self.bars_held,
"total_trades": self.total_trades,
"total_wins": self.total_wins,
"started_at": self.started_at,
"last_bar_ts": self.last_bar_ts,
"tp": self.tp,
"sl": self.sl,
"max_bars": self.max_bars,
"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):
enriched = {"worker": self.worker_id, **(data or {})}
notify_event(event, enriched)
def _open_position(self, signal: Signal, current_price: float):
notional = self.capital * self.position_size * self.leverage
size = notional / current_price if current_price > 0 else 0
self.in_position = True
self.direction = signal.direction
self.entry_price = current_price
self.entry_time = datetime.now(timezone.utc).isoformat()
self.bars_held = 0
meta = signal.metadata or {}
self.tp = float(meta.get("tp", 0.0) or 0.0)
self.sl = float(meta.get("sl", 0.0) or 0.0)
self.max_bars = int(meta.get("max_bars", 0) or 0)
trade_data = {
"direction": "long" if signal.direction == 1 else "short",
"price": round(current_price, 2),
"size": round(size, 6),
"notional": round(notional, 2),
"capital": round(self.capital, 2),
"tp": round(self.tp, 2) if self.tp else None,
"sl": round(self.sl, 2) if self.sl else None,
}
self._log("OPEN", trade_data)
self._notify("OPENED", trade_data)
def _close_position(self, current_price: float, reason: str):
if not self.in_position:
return
price_change = (current_price - self.entry_price) / self.entry_price
trade_return = price_change * self.direction
net = trade_return * self.leverage - self.fee_rt * self.leverage
pnl = self.capital * self.position_size * net
is_win = trade_return > 0
self.capital += pnl
self.capital = max(self.capital, 0)
self.total_trades += 1
if is_win:
self.total_wins += 1
accuracy = self.total_wins / self.total_trades * 100 if self.total_trades > 0 else 0
trade_data = {
"reason": reason,
"direction": "long" if self.direction == 1 else "short",
"entry": round(self.entry_price, 2),
"exit": round(current_price, 2),
"pnl": round(pnl, 2),
"net_return": round(net * 100, 3),
"capital": round(self.capital, 2),
"bars_held": self.bars_held,
"win": is_win,
"total_trades": self.total_trades,
"accuracy": round(accuracy, 1),
}
self._log("CLOSE", trade_data)
self._notify("CLOSED", trade_data)
self.in_position = False
self.direction = 0
self.entry_price = 0
self.entry_time = ""
self.bars_held = 0
self.tp = 0.0
self.sl = 0.0
self.max_bars = 0
def tick(self, df: pd.DataFrame, df_1h: pd.DataFrame | None = None):
"""Chiamato ad ogni poll con DataFrame OHLCV aggiornato.
df_1h: serie 1h live opzionale per strategie multi-timeframe (es. MT01),
passata ai generate_signals via params. Se None la strategia ricade sul
parquet statico.
"""
if df.empty or len(df) < 100:
return
c = df["close"].values
current_price = float(c[-1])
current_ts = int(df["timestamp"].iloc[-1])
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
if self.in_position:
if current_ts > self.last_bar_ts:
self.bars_held += 1
self.last_bar_ts = current_ts
if self.tp and self.sl:
# Exit guidati dalla strategia: SL (conservativo, prima), poi TP, poi time-limit
if self.direction == 1:
if current_price <= self.sl:
self._close_position(current_price, "stop_loss")
elif current_price >= self.tp:
self._close_position(current_price, "take_profit")
elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
else:
if current_price >= self.sl:
self._close_position(current_price, "stop_loss")
elif current_price <= self.tp:
self._close_position(current_price, "take_profit")
elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
elif self.bars_held >= self.hold_bars:
self._close_position(current_price, "hold_limit")
else:
pnl_pct = (current_price - self.entry_price) / self.entry_price * self.direction
if pnl_pct <= -0.02:
self._close_position(current_price, "stop_loss")
self._save_state()
return
# Genera segnali
extra = dict(self.params)
if df_1h is not None:
extra["df_1h"] = df_1h
signals = self.strategy.generate_signals(
df, ts, asset=self.asset, tf=self.tf, **extra
)
if not signals:
self._save_state()
return
last_signal = signals[-1]
last_idx = len(df) - 1
if last_signal.idx >= last_idx - 1:
self._open_position(last_signal, current_price)
self.last_bar_ts = current_ts
self._save_state()
@property
def status_summary(self) -> str:
acc = self.total_wins / self.total_trades * 100 if self.total_trades > 0 else 0
pos = "LONG" if self.direction == 1 else "SHORT" if self.direction == -1 else "FLAT"
return (f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t "
f"{acc:.0f}% | {pos}")
+39
View File
@@ -0,0 +1,39 @@
"""Notifiche Telegram per il paper trader."""
from __future__ import annotations
import os
import urllib.request
import urllib.parse
import json
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
NOTIFY_EVENTS = {
"SIGNAL", "OPENED", "CLOSED", "OPEN_FAILED", "CLOSE_FAILED",
"ERROR", "STARTUP", "SHUTDOWN", "TRAINING_FAILED",
}
def send_telegram(text: str) -> bool:
if not BOT_TOKEN or not CHAT_ID:
return False
try:
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
data = urllib.parse.urlencode({"chat_id": CHAT_ID, "text": text, "parse_mode": "HTML"}).encode()
urllib.request.urlopen(url, data, timeout=10)
return True
except Exception:
return False
def notify_event(event: str, data: dict | None = None):
if event not in NOTIFY_EVENTS:
return
lines = [f"📊 <b>{event}</b>"]
if data:
for k, v in data.items():
if k in ("signal",):
continue
lines.append(f" {k}: {v}")
send_telegram("\n".join(lines))
+11
View File
@@ -0,0 +1,11 @@
"""Strategie di trading — classe base e indicatori condivisi."""
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats
from src.strategies.indicators import (
keltner_ratio, detect_squeezes, ema, atr, rv_annualized, rolling_correlation,
)
__all__ = [
"Strategy", "Signal", "BacktestResult", "YearlyStats",
"keltner_ratio", "detect_squeezes", "ema", "atr",
"rv_annualized", "rolling_correlation",
]
+243
View File
@@ -0,0 +1,243 @@
"""Classe base astratta per tutte le strategie di trading."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
from src.data.downloader import load_data
@dataclass
class Signal:
"""Segnale di trading generato da una strategia."""
idx: int
direction: int # +1 long, -1 short
entry_price: float
metadata: dict = field(default_factory=dict)
@dataclass
class YearlyStats:
year: int
trades: int
wins: int
pnl: float
@property
def accuracy(self) -> float:
return self.wins / self.trades * 100 if self.trades > 0 else 0.0
@dataclass
class BacktestResult:
"""Risultato completo di un backtest."""
strategy_name: str
asset: str
timeframe: str
params: dict
trades: int
wins: int
pnl: float
capital: float
initial_capital: float
max_dd: float
time_in_market_pct: float
avg_trade_duration_h: float
years_active: int
yearly: list[YearlyStats]
@property
def accuracy(self) -> float:
return self.wins / self.trades * 100 if self.trades > 0 else 0.0
@property
def sharpe(self) -> float:
pnls = []
for ys in self.yearly:
pnls.append(ys.pnl)
if len(pnls) < 2 or np.std(pnls) == 0:
return 0.0
return float(np.mean(pnls) / np.std(pnls) * np.sqrt(len(pnls)))
@property
def daily_pnl(self) -> float:
return self.pnl / (self.years_active * 365) if self.years_active > 0 else 0.0
@property
def worst_year(self) -> YearlyStats | None:
valid = [y for y in self.yearly if y.trades >= 10]
if not valid:
valid = self.yearly
return min(valid, key=lambda y: y.accuracy) if valid else None
def print_summary(self):
worst = self.worst_year
worst_str = f"{worst.year}({worst.accuracy:.0f}%)" if worst else "N/A"
dur = f"{self.avg_trade_duration_h:.0f}h" if self.avg_trade_duration_h >= 1 else f"{self.avg_trade_duration_h * 60:.0f}m"
print(f" {self.strategy_name:<30s} {self.asset:>3s} {self.timeframe:>3s} "
f"{self.trades:>5d}t {self.accuracy:>5.1f}% "
f"{self.pnl:>+9.0f} DD {self.max_dd:>4.1f}% "
f"€/d {self.daily_pnl:>+6.2f} "
f"Mkt {self.time_in_market_pct:>4.1f}% {dur:>5s} "
f"worst={worst_str} {self.years_active}y")
def print_yearly(self):
print(f"\n {self.strategy_name} [{self.asset} {self.timeframe}] — per anno:")
print(f" {'Anno':>6s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s}")
for ys in sorted(self.yearly, key=lambda y: y.year):
print(f" {ys.year:>6d} {ys.trades:>7d} {ys.accuracy:>5.1f}% €{ys.pnl:>+8.0f}")
TF_MINUTES = {"1m": 1, "5m": 5, "15m": 15, "1h": 60, "4h": 240, "1d": 1440}
class Strategy(ABC):
"""Classe base per tutte le strategie.
Sottoclassi devono implementare:
- name, description, default_assets, default_timeframes
- generate_signals(df, timestamps, **params) -> list[Signal]
"""
name: str = "unnamed"
description: str = ""
default_assets: list[str] = ["BTC", "ETH"]
default_timeframes: list[str] = ["15m", "1h"]
# Parametri di backtest
fee_rt: float = 0.002
leverage: float = 3.0
position_size: float = 0.15
initial_capital: float = 1000.0
@abstractmethod
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
"""Genera segnali di trading dal dataframe OHLCV.
Args:
df: DataFrame con colonne open, high, low, close, volume, timestamp
ts: DatetimeIndex UTC dei timestamp
**params: parametri specifici della strategia
Returns:
Lista di Signal con idx, direction, entry_price
"""
...
def backtest(self, asset: str, tf: str, hold: int = 3,
**params) -> BacktestResult | None:
"""Esegue backtest su un asset/timeframe."""
df = load_data(asset, tf)
c = df["close"].values
n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sig_params = {**params, "asset": asset, "tf": tf}
signals = self.generate_signals(df, ts, **sig_params)
if not signals:
return None
yearly: dict[int, dict] = {}
capital = float(self.initial_capital)
peak = capital
max_dd = 0.0
total_bars = 0
for sig in signals:
i = sig.idx
if i + hold >= n or i < 1:
continue
entry = sig.entry_price
exit_price = c[min(i + hold - 1, n - 1)]
actual = (exit_price - entry) / entry * sig.direction
net = actual * self.leverage - self.fee_rt * self.leverage
capital += capital * self.position_size * 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, "pnl": 0.0}
yearly[year]["t"] += 1
if actual > 0:
yearly[year]["w"] += 1
yearly[year]["pnl"] += net * self.initial_capital
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
yearly_stats = [
YearlyStats(year=y, trades=d["t"], wins=d["w"], pnl=d["pnl"])
for y, d in sorted(yearly.items())
]
return BacktestResult(
strategy_name=self.name,
asset=asset,
timeframe=tf,
params=params,
trades=all_t,
wins=all_w,
pnl=sum(d["pnl"] for d 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=hold * TF_MINUTES.get(tf, 60) / 60,
years_active=len(yearly),
yearly=yearly_stats,
)
def run_all(self, assets: list[str] | None = None,
timeframes: list[str] | None = None,
hold: int = 3, **params) -> list[BacktestResult]:
"""Esegue backtest su tutte le combinazioni asset/timeframe."""
assets = assets or self.default_assets
timeframes = timeframes or self.default_timeframes
results = []
for asset in assets:
for tf in timeframes:
r = self.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 20:
results.append(r)
results.sort(key=lambda r: r.accuracy, reverse=True)
return results
def report(self, results: list[BacktestResult] | None = None,
assets: list[str] | None = None,
timeframes: list[str] | None = None,
hold: int = 3, **params):
"""Esegue e stampa report completo."""
if results is None:
results = self.run_all(assets, timeframes, hold, **params)
print(f"\n{'=' * 120}")
print(f" {self.name}{self.description}")
print(f" Fee: {self.fee_rt*100:.1f}% RT | Leva: {self.leverage:.0f}x | Pos: {self.position_size*100:.0f}%")
print(f"{'=' * 120}")
print(f" {'Nome':<30s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Worst':>12s} {'Anni':>4s}")
print(f" {'' * 110}")
for r in results:
r.print_summary()
if results:
best = results[0]
best.print_yearly()
return results
+116
View File
@@ -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,
)
+102
View File
@@ -0,0 +1,102 @@
"""Indicatori tecnici condivisi tra tutte le strategie."""
from __future__ import annotations
import numpy as np
def keltner_ratio(close: np.ndarray, high: np.ndarray, low: np.ndarray,
window: int = 14) -> np.ndarray:
"""Rapporto Bollinger / Keltner. Sotto 1 = squeeze (BB dentro KC)."""
n = len(close)
r = 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 = (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: np.ndarray, high: np.ndarray, low: np.ndarray,
kcr: np.ndarray, sq_thr: float = 0.8,
min_dur: int = 5) -> list[dict]:
"""Rileva squeeze events: periodi dove BB sta dentro KC."""
events: list[dict] = []
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,
"kcr_at_release": kcr[i],
})
return events
def ema(arr: np.ndarray, period: int) -> np.ndarray:
"""Exponential Moving Average."""
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
def atr(high: np.ndarray, low: np.ndarray, close: np.ndarray,
period: int = 14) -> np.ndarray:
"""Average True Range (EMA-smoothed)."""
tr = np.maximum(
high - low,
np.maximum(np.abs(high - np.roll(close, 1)), np.abs(low - np.roll(close, 1))),
)
tr[0] = high[0] - low[0]
r = np.full(len(close), np.nan)
r[period - 1] = np.mean(tr[:period])
k = 2 / (period + 1)
for i in range(period, len(close)):
r[i] = tr[i] * k + r[i - 1] * (1 - k)
return r
def rv_annualized(close: np.ndarray, window: int) -> np.ndarray:
"""Realized volatility annualizzata (hourly data assumed)."""
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 rolling_correlation(close_a: np.ndarray, close_b: np.ndarray,
window: int = 48) -> np.ndarray:
"""Correlazione rolling tra rendimenti logaritmici di due asset."""
n = max(len(close_a), len(close_b))
ret_a = np.diff(np.log(np.where(close_a == 0, 1e-10, close_a)))
ret_b = np.diff(np.log(np.where(close_b[:len(close_a)] == 0, 1e-10, close_b[:len(close_a)])))
min_len = min(len(ret_a), len(ret_b))
corr = np.full(n, np.nan)
for i in range(window, min_len):
cv = np.corrcoef(ret_a[i - window:i], ret_b[i - window:i])[0, 1]
corr[i + 1] = cv if np.isfinite(cv) else 0
return corr
+114
View File
@@ -0,0 +1,114 @@
defaults:
capital: 1000
position_size: 0.15
leverage: 3
hold_bars: 3
poll_seconds: 60
retrain_hours: 24
# Solo MR01 Bollinger fade (mean-reversion): unica con edge netto validato
# out-of-sample e fee-aware. La famiglia squeeze e' in scripts/waste/.
# ATTENZIONE: MR01 esce su TP-alla-media / SL-ad-ATR / max_bars (vedi metadata
# dei Signal). Lo StrategyWorker attuale esce solo a hold_bars/stop -2% fisso:
# va aggiornato per usare gli exit in metadata PRIMA di tradare MR01 dal vivo.
strategies:
- name: MR01_bollinger_fade
asset: BTC
tf: 1h
enabled: true
params:
bb_window: 50
k: 2.5
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
# ETH: edge positivo ma DD piu' alto (~70%); leva piu' bassa consigliata
- name: MR01_bollinger_fade
asset: ETH
tf: 1h
enabled: true
params:
bb_window: 50
k: 2.5
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
# 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: fade canale ATR su EMA (banda indipendente da Bollinger).
# Robusto su tutta la griglia n x k. BTC n30 k2.0 +112% OOS DD20%.
# ETH: edge ampio ma DD pieno ~65% (tratto dell'asset, come MR01) -> leva bassa.
- name: MR03_keltner_fade
asset: BTC
tf: 1h
enabled: true
params:
n: 30
k: 2.0
sl_atr: 2.0
max_bars: 24
# NB: su MR03 BTC il filtro trend PEGGIORA Acc e DD (unico sleeve) -> disattivo.
- name: MR03_keltner_fade
asset: ETH
tf: 1h
enabled: true
params:
n: 50
k: 2.0
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
# 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
Generated
+70 -13
View File
@@ -542,30 +542,30 @@ wheels = [
[[package]]
name = "cuda-bindings"
version = "13.2.0"
version = "13.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/a9/3a8241c6e19483ac1f1dcf5c10238205dcb8a6e9d0d4d4709240dff28ff4/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:721104c603f059780d287969be3d194a18d0cc3b713ed9049065a1107706759d", size = 5730273, upload-time = "2026-03-11T00:12:37.18Z" },
{ url = "https://files.pythonhosted.org/packages/e9/94/2748597f47bb1600cd466b20cab4159f1530a3a33fe7f70fee199b3abb9e/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1eba9504ac70667dd48313395fe05157518fd6371b532790e96fbb31bbb5a5e1", size = 6313924, upload-time = "2026-03-11T00:12:39.462Z" },
{ url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" },
{ url = "https://files.pythonhosted.org/packages/1f/92/f899f7bbb5617bb65ec52a6eac1e9a1447a86b916c4194f8a5001b8cde0c/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46d8776a55d6d5da9dd6e9858fba2efcda2abe6743871dee47dd06eb8cb6d955", size = 6320619, upload-time = "2026-03-11T00:12:45.939Z" },
{ url = "https://files.pythonhosted.org/packages/df/93/eef988860a3ca985f82c4f3174fc0cdd94e07331ba9a92e8e064c260337f/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6629ca2df6f795b784752409bcaedbd22a7a651b74b56a165ebc0c9dcbd504d0", size = 5614610, upload-time = "2026-03-11T00:12:50.337Z" },
{ url = "https://files.pythonhosted.org/packages/18/23/6db3aba46864aee357ab2415135b3fe3da7e9f1fa0221fa2a86a5968099c/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dca0da053d3b4cc4869eff49c61c03f3c5dbaa0bcd712317a358d5b8f3f385d", size = 6149914, upload-time = "2026-03-11T00:12:52.374Z" },
{ url = "https://files.pythonhosted.org/packages/c0/87/87a014f045b77c6de5c8527b0757fe644417b184e5367db977236a141602/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6464b30f46692d6c7f65d4a0e0450d81dd29de3afc1bb515653973d01c2cd6e", size = 5685673, upload-time = "2026-03-11T00:12:56.371Z" },
{ url = "https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4af9f3e1be603fa12d5ad6cfca7844c9d230befa9792b5abdf7dd79979c3626", size = 6191386, upload-time = "2026-03-11T00:12:58.965Z" },
{ url = "https://files.pythonhosted.org/packages/5f/58/ed2c3b39c8dd5f96aa7a4abef0d47a73932c7a988e30f5fa428f00ed0da1/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df850a1ff8ce1b3385257b08e47b70e959932f5f432d0a4e46a355962b4e4771", size = 5507469, upload-time = "2026-03-11T00:13:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/0c941b112ceeb21439b05895eace78ca1aa2eaaf695c8521a068fd9b4c00/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8a16384c6494e5485f39314b0b4afb04bee48d49edb16d5d8593fd35bbd231b", size = 6059693, upload-time = "2026-03-11T00:13:06.003Z" },
{ url = "https://files.pythonhosted.org/packages/f9/52/50673d25e46d199556f827514bf646a49471d50538c5e577201245b348a9/cuda_bindings-13.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:244a167d81a9d07d3209d02c8e02e848c2b7c38f4d01e8e4d1f9620b173ae006", size = 6051409, upload-time = "2026-05-27T03:59:01.648Z" },
{ url = "https://files.pythonhosted.org/packages/88/ee/e8f4bdfb808c3689539b7c035d63b6dac9f236b2d6f807f18c7f5f3ef879/cuda_bindings-13.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:820ab45be3f39a088f39c4a04eb8b852d0b339bff8c518da5c258882b8a4e21b", size = 6671833, upload-time = "2026-05-27T03:59:03.761Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e0/4b3fdba08ff177e9451f376a4ba2df18d76f9158e6a16cdc062bd83db9fa/cuda_bindings-13.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f72f67f5790e7fa51e3f893e007e49567573ccdf4ed1ca988fbbbd36cd77847c", size = 6020531, upload-time = "2026-05-27T03:59:07.942Z" },
{ url = "https://files.pythonhosted.org/packages/04/40/a2ea4d8f032bfd6c220d50b6f92cd61f33d48f31959da39ed1b178cfee54/cuda_bindings-13.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a99c3b8d584f266c616bd0f30c7cd83e33553e3ef2abad41ff5a74fbc033a69a", size = 6653764, upload-time = "2026-05-27T03:59:09.981Z" },
{ url = "https://files.pythonhosted.org/packages/ae/a0/156efe7816699c2de1ea2395031db7d010b7af23c243563a3ee6f0ecc1de/cuda_bindings-13.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7698fcc4577aa96372866f4d0c9a6cf686cd5c90eab94581c29d37fe6600542", size = 5914803, upload-time = "2026-05-27T03:59:14.011Z" },
{ url = "https://files.pythonhosted.org/packages/51/91/510aae64d53227b5b36db6bfaea41514b66d92cd65ddc43aa49566f18313/cuda_bindings-13.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abd908f651160d12c45c5714a38ee102a1173a55433c0d1509ec0e8293beb4a6", size = 6472506, upload-time = "2026-05-27T03:59:16.551Z" },
{ url = "https://files.pythonhosted.org/packages/01/53/2ef49e5b3734a5531b2ba5d726cba724d9cbb262404e586ed61070604826/cuda_bindings-13.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a801fa30e75d25b74252123aefc746b6c4275624d2b8640632dd1dfeeaa1f88", size = 6008814, upload-time = "2026-05-27T03:59:20.921Z" },
{ url = "https://files.pythonhosted.org/packages/2f/cb/3a9fcf0651e0a49b4d0f1955837ce079245b27086c22fb2f253039bdf324/cuda_bindings-13.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94d40ef7b4bdd9dce0244a1baa132e0e538f1eb2c0d162fb3648a15e48515365", size = 6531477, upload-time = "2026-05-27T03:59:23.391Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/6987c5ee98f117317a85650ddc79480a3fa59a573ae1c923d0722b56ae71/cuda_bindings-13.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5911bea15810b749a8077f8c45423ed785d51618b8e8664dea1fc8f5a2a76c8", size = 5807073, upload-time = "2026-05-27T03:59:28.218Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/46ceee07dc19f18a5d1c28d592750ed9dbdc803077eb083576a442c9938c/cuda_bindings-13.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2870fed7707a37f8af0c02364b05f355ebe8921604e8c68eb56cf66867e0798", size = 6354325, upload-time = "2026-05-27T03:59:30.715Z" },
]
[[package]]
name = "cuda-pathfinder"
version = "1.5.4"
version = "1.5.5"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/d0/c177e29701cf1d3008d7d2b16b5fc626592ce13bd535f8795c5f57187e0e/cuda_pathfinder-1.5.4-py3-none-any.whl", hash = "sha256:9563d3175ce1828531acf4b94e1c1c7d67208c347ca002493e2654878b26f4b7", size = 51657, upload-time = "2026-04-27T22:42:07.712Z" },
{ url = "https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl", hash = "sha256:0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689", size = 51671, upload-time = "2026-05-27T01:21:25.413Z" },
]
[[package]]
@@ -2057,6 +2057,7 @@ dependencies = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pyarrow" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "scikit-learn" },
{ name = "scipy" },
@@ -2081,6 +2082,7 @@ requires-dist = [
{ name = "pyarrow", specifier = ">=15.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "requests", specifier = ">=2.31" },
{ name = "scikit-learn", specifier = ">=1.3" },
{ name = "scipy", specifier = ">=1.11" },
@@ -2101,6 +2103,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.34.2"