15 Commits

Author SHA1 Message Date
Adriano a51129acf6 feat(analysis): matrice PnL/anno consolidata (confronto strategie + portafoglio)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:53:37 +02:00
Adriano 1b099bb47b feat(analysis): tabella per-anno (PnL/DD) versioni migliorate + portafoglio
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:52:25 +02:00
Adriano 783fa5546f feat(analysis): miglioramenti - ROT02 dual-momentum + portafoglio (DD 12%)
Obiettivo: alzare Acc, ridurre DD, migliorare PnL. Leve oneste, no tuning per-anno.

- ROT02: overlay absolute-momentum (cash se BTC<SMA100) su ROT01. Domina su tutte
  le metriche: FULL +679->+1095%, OOS +44->+98%, DD 53->40%.
- DIP01 market-gate (variante low-DD): alza Acc (ETH 52->57, SOL 49->52) e dimezza
  il DD (ETH 53->23), al costo di PnL. De-risking opzionale; su BTC il gate va evitato.
- PORT01: portafoglio equal-weight giornaliero delle 3 sleeve anti-correlate
  (DIP01+TR01+ROT02). DD 12% (sotto ogni sleeve), CAGR 45%, 2022 bear -1% (era -30%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:49:14 +02:00
Adriano ad141f080c feat(analysis): report per-anno (Trade/Acc/DD/PnL) delle 3 strategie
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:42:04 +02:00
Adriano 212427ffa1 feat(analysis): 3 strategie oneste validate OOS multi-crypto (DIP/TR/ROT)
Ricerca onesta post-squeeze su 8 crypto (2018-2026), engine fee-aware con
ingresso eseguibile a close[i], uscita TP/SL intrabar, OOS held-out, sweep fee.

Lezione madre: shortare cripto perde OOS sistematicamente (campione net-bull)
-> tutte le strategie robuste sono long-biased.

Tre meccanismi distinti e complementari:
- DIP01  dip-buy z-score reversion (long-only, 1h)  robusto BTC/ETH/SOL
- TR01   EMA 20/100 trend-following (long-only, 4h) robusto su 5/8 asset
- ROT01  rotazione cross-sectional momentum sul paniere (1d) OOS +44%, param-insensitive

Engine e validazione: scripts/analysis/honest_lab.py + honest_final.py
(+ honest_candidates/diag/diag2/trend/rotation). Diario in docs/diary/.

Onesto sull'obiettivo: €50/giorno su €1000 in pochi mesi non e' raggiungibile a
rischio sano (~1825%/anno); edge reali 30-60% OOS pluriennale. Via realistica:
portafoglio delle 3, leva moderata, crescita composta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:28:00 +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
53 changed files with 6058 additions and 101 deletions
+1
View File
@@ -16,3 +16,4 @@ data/processed/
*.pt *.pt
*.pth *.pth
notebooks/.ipynb_checkpoints/ notebooks/.ipynb_checkpoints/
data/paper_trades/
+67 -32
View File
@@ -9,9 +9,10 @@ Progetto di ricerca: riconoscimento pattern frattali per trading algoritmico su
- **Linguaggio:** Python 3.11+ - **Linguaggio:** Python 3.11+
- **Package manager:** uv (dipendenze in `pyproject.toml`, lock in `uv.lock`) - **Package manager:** uv (dipendenze in `pyproject.toml`, lock in `uv.lock`)
- **Dati:** Parquet in `data/raw/` (non committati, ~70 MB) - **Dati:** Parquet in `data/raw/` (non committati, ~70 MB)
- **ML:** scikit-learn (GradientBoosting), PyTorch (LSTM) - **ML:** scikit-learn (GradientBoostingClassifier)
- **Analisi:** numpy, pandas, scipy - **Analisi:** numpy, pandas, scipy
- **API dati:** Cerbero MCP su `cerbero-mcp.tielogic.xyz` (Deribit, Bybit, Hyperliquid), ccxt/Binance come fallback - **API dati:** Cerbero MCP su `cerbero-mcp.tielogic.xyz` (Deribit, Bybit, Hyperliquid), ccxt/Binance come fallback
- **Config:** pyyaml per `strategies.yml`
## Struttura ## Struttura
@@ -22,12 +23,22 @@ src/backtest/ → engine di backtesting (engine.py)
src/strategies/ → classe base Strategy ABC + indicatori condivisi src/strategies/ → classe base Strategy ABC + indicatori condivisi
base.py → Strategy, Signal, BacktestResult, YearlyStats base.py → Strategy, Signal, BacktestResult, YearlyStats
indicators.py → keltner_ratio, detect_squeezes, ema, atr, rv, correlation indicators.py → keltner_ratio, detect_squeezes, ema, atr, rv, correlation
scripts/strategies/ → strategie attive (SQ01-SQ04, ML01) src/live/ → paper trading live multi-strategia
scripts/waste/ → strategie scartate (W01-W22 + REF originali) multi_runner.py → orchestratore: carica YAML, fetch candele, tick worker
scripts/analysis/ → script di confronto e report 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/diary/ → diario di ricerca giornaliero
docs/specs/ → specifiche di design
data/raw/ → file .parquet OHLCV (gitignored) data/raw/ → file .parquet OHLCV (gitignored)
data/processed/ → modelli salvati (gitignored)
``` ```
## Comandi ## Comandi
@@ -35,8 +46,12 @@ data/processed/ → modelli salvati (gitignored)
```bash ```bash
uv sync # installa dipendenze uv sync # installa dipendenze
uv run python -m src.data.downloader # scarica dati storici uv run python -m src.data.downloader # scarica dati storici
uv run python scripts/strategies/SQ02_squeeze_antifake_vol.py # miglior strategia robusta uv run python scripts/strategies/MR01_bollinger_fade.py # strategia attiva (mean-reversion)
uv run python scripts/strategies/ML01_squeeze_gbm.py # squeeze + ML (GBM) 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 uv run pytest # test
``` ```
@@ -53,36 +68,53 @@ df = load_data("ETH", "15m") # carica un asset/timeframe
Fonte primaria: Cerbero MCP (endpoint `/mcp-deribit/tools/get_historical`). Fonte primaria: Cerbero MCP (endpoint `/mcp-deribit/tools/get_historical`).
Token observer: nel file `secrets/observer.token` del progetto CerberoSuite. Token observer: nel file `secrets/observer.token` del progetto CerberoSuite.
## Strategia vincente
**Squeeze + ML ibrida** (script 13):
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%
Configurazione migliore: ETH 15m, BBw=14, squeeze threshold=0.8, breakout=3 barre, leva 3x, position 15%.
Risultato backtestato: 76.9% accuracy, 118% annuo, 4.2% drawdown, €13.78/giorno da €1.000.
## Strategie attive ## Strategie attive
| Codice | Nome | Tipo | Accuracy | Note | > **LEZIONE CRITICA (2026-05-28).** L'intera famiglia squeeze-breakout (SQ01-04,
|--------|------|------|----------|------| > MT01, ML01, AD01, CM01, PD01) è stata **scartata in `scripts/waste/`**: le
| SQ01 | Squeeze Base | Regole | 76.7% | Squeeze breakout puro, baseline | > accuratezze storiche 76-82% erano un **artefatto di look-ahead**. Quei backtest
| SQ02 | Antifake+Vol | Regole | 79.7% | **Miglior robusto** — 9 anni, Sharpe 5.01 | > decidono la direzione con `sign(close[i]-close[i-1])` (la candela di breakout `i`)
| SQ03 | All Filters | Regole | 79.2% | Cross-asset + timing + long squeeze | > ma entrano a `close[i-1]` — cioè comprano *prima* della candela che usano per
| SQ04 | Ultimate | Regole | 81.6% | Max accuracy ma concentrato 2018 | > scegliere la direzione. Dal vivo il worker scopre il breakout solo a `close[i]`
| ML01 | Squeeze+GBM | ML | 78.8% | Walk-forward, €12/day, DD basso | > 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`.
Tutte le strategie estendono `src.strategies.base.Strategy` con interfaccia comune: Tutte le strategie estendono `src.strategies.base.Strategy`
`generate_signals() → backtest() → report()`. (`generate_signals() → backtest()`). **Unica strategia con edge netto validato:**
| Codice | Nome | Tipo | Edge OOS netto | DD | Note |
|--------|------|------|----------------|----|------|
| **MR01** | Bollinger Fade | Mean-reversion | **BTC 1h n50 k2.5: +201% / +196% (worker)** | 15% | Fada la banda, TP alla media, SL ad ATR |
MR01 è robusto su **tutta** la griglia parametri (`n∈{14,20,30,50}` × `k∈{2.0,2.5,3.0}`,
entrambi gli asset → tutte positive OOS) e su **tutte** le fee 0.00-0.20% RT.
Validato col worker reale: BTC +196% / ETH +251% OOS (nov 2023→mag 2026).
Ricerca completa: `scripts/analysis/strategy_research.py`.
**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).
## 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 ## Convenzioni
- Strategie in `scripts/strategies/` con codice univoco (SQ01, ML01, ...). - Strategie in `scripts/strategies/` con codice univoco (MR01, ...).
- Script scartati in `scripts/waste/` con prefisso W01-W22. - Script scartati in `scripts/waste/` (W01-W28 + famiglia squeeze).
- Diario in `docs/diary/YYYY-MM-DD.md`. Aggiornare dopo ogni esperimento significativo. - Diario in `docs/diary/YYYY-MM-DD.md`. Aggiornare dopo ogni esperimento significativo.
- Nessun dato sensibile nei commit (token, chiavi API). Usare `.gitignore`. - 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]`. - 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]`.
@@ -90,5 +122,8 @@ Tutte le strategie estendono `src.strategies.base.Strategy` con interfaccia comu
## Attenzione ## 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]`. - **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. - **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 RUN uv sync --frozen --no-dev
COPY src/ src/ COPY src/ src/
COPY scripts/strategies/ scripts/strategies/
COPY strategies.yml strategies.yml
VOLUME /app/data 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 ## 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 | 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):
| 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 |
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 ## 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"). 1. **Bollinger Bands** (window `n`, `k` deviazioni standard) sul close.
2. **Breakout** — le bande escono dal canale. Un impulso direzionale parte. 2. **Entry** — quando il close esce *sotto* la banda inferiore → **long** (o *sopra* la superiore → **short**). Ingresso a `close[i]`, eseguibile dal vivo.
3. **Conferma ML** — un modello GradientBoosting, addestrato su feature strutturali e frattali della finestra precedente, conferma la direzione e filtra i segnali deboli. 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) ### Perché lo squeeze breakout è stato abbandonato
- Momentum, volatilità, skewness, kurtosis dei rendimenti logaritmici
- Autocorrelazione lag-1 L'ipotesi originale era opposta — *continuazione* dopo la compressione di volatilità
- Profilo volumetrico e spike detection (Bollinger dentro Keltner → breakout direzionale). Su dati storici sembrava dare
- Durata della fase di squeeze e rapporto di espansione Keltner 76-82% di accuracy, ma era un **artefatto di look-ahead**: il backtest entrava a
- Posizione del prezzo rispetto al range recente e ATR normalizzato `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 ## Struttura progetto
``` ```
PythagorasGoal/ PythagorasGoal/
├── src/ ├── src/
│ ├── data/ # Download e gestione dati storici (Cerbero MCP + Binance) │ ├── data/ # Download e gestione dati (Cerbero MCP + Binance)
│ ├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity │ ├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity
│ ├── backtest/ # Motore di backtesting con fee e metriche │ ├── backtest/ # Motore di backtesting con fee e metriche
│ ├── strategies/ # (predisposto per strategie modulari) │ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi
├── nn/ # (predisposto per reti neurali) │ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats
│ └── utils/ │ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr
├── scripts/ # Script di analisi e test (0113) │ └── 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/ ├── data/
── raw/ # Parquet OHLCV (non committati, ~70 MB) ── raw/ # Parquet OHLCV (gitignored, ~70 MB)
│ └── processed/ # Modelli salvati
├── docs/ ├── docs/
── diary/ # Diario di ricerca giornaliero ── diary/ # Diario di ricerca giornaliero
├── tests/ │ └── specs/ # Specifiche di design
├── pyproject.toml ├── Dockerfile
── README.md ── 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 ## Setup
```bash ```bash
# Clona il repository # Clona e installa
git clone <repo-url> && cd PythagorasGoal git clone <repo-url> && cd PythagorasGoal
# Installa dipendenze (richiede uv)
uv sync uv sync
# Scarica dati storici (~70 MB, richiede connessione) # Scarica dati storici (~70 MB)
uv run python -m src.data.downloader uv run python -m src.data.downloader
# Esegui la strategia ibrida vincente # Backtest strategia attiva
uv run python scripts/13_squeeze_ml_hybrid.py uv run python scripts/strategies/MR01_bollinger_fade.py
# Paper trading live
uv run python -m src.live.multi_runner
``` ```
### Requisiti ### Requisiti
- Python ≥ 3.11 - Python ≥ 3.11
- [uv](https://docs.astral.sh/uv/) come package manager - [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 ## Dati
@@ -90,25 +199,7 @@ uv run python scripts/13_squeeze_ml_hybrid.py
| BTC | 5m / 15m / 1h | 883K / 294K / 74K | 2018-01 → oggi | | BTC | 5m / 15m / 1h | 883K / 294K / 74K | 2018-01 → oggi |
| ETH | 5m / 15m / 1h | 882K / 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. Fonte primaria: Deribit perpetual via Cerbero MCP. Fallback: 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 |
## Riferimenti ## Riferimenti
+3 -2
View File
@@ -1,16 +1,17 @@
services: services:
paper-trader: paper-trader:
build: . build: .
container_name: pythagoras-paper container_name: pythagoras-multi
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./strategies.yml:/app/strategies.yml:ro
env_file: env_file:
- .env - .env
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
healthcheck: 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 interval: 120s
timeout: 10s timeout: 10s
retries: 3 retries: 3
+136
View File
@@ -0,0 +1,136 @@
# 2026-05-28 — Ricerca onesta di nuove strategie (post-squeeze)
## Contesto e mandato
Dopo aver scoperto che l'intera famiglia squeeze-breakout era un artefatto di
look-ahead (accuratezze 76-82% svanite sotto ingresso eseguibile), il mandato è
stato: trovare in modo **onesto** almeno 3 strategie attendibili, testate su ~8
anni e su più criptovalute, con le fee incluse nella valutazione, partendo da
€1.000 con l'obiettivo (aspirazionale) di €50/giorno. Esplorare anche idee fuori
dal comune e l'uso combinato di più crypto e timeframe.
## Metodologia (engine onesto)
Tutto il lavoro usa un unico engine condiviso (`scripts/analysis/honest_lab.py`)
con questi vincoli anti-illusione:
1. **Ingresso eseguibile.** Ogni segnale alla barra `i` usa solo dati fino a
`close[i]` e l'ingresso avviene a `close[i]` (ciò che il worker live vede e
può eseguire). Disponibile anche l'ingresso più conservativo a `open[i+1]`.
2. **Uscita realistica.** Take-profit / stop-loss valutati intrabar su `high`/`low`,
in modo conservativo (SL prima del TP nello stesso bar), più time-limit.
Una posizione per volta (non-overlap), capitale composto.
3. **Fee di prim'ordine.** Tutto è NETTO dopo fee round-trip realistiche Deribit
(0.10% RT) moltiplicate per la leva (3x), con sweep fino a 0.20% RT.
4. **Validazione severa.** FULL + out-of-sample (ultimo 30%) + conteggio anni
positivi + sweep fee + griglia parametri + test su **8 crypto**
(BTC, ETH, SOL, BNB, XRP, LTC, DOGE, ADA, 2018→2026).
## Lezione madre
**Shortare le crypto perde OOS in modo sistematico in questo campione.** Sia la
mean-reversion sul lato short, sia il momentum short, crollano fuori campione: il
periodo 2018-2026 è net-bull e ogni rialzo "estremo" tende a continuare invece di
rientrare. Tutte le configurazioni che sopravvivono oneste sono **long-biased**.
È un fatto da dichiarare: parte della performance OOS è correlata al beta rialzista
delle crypto. Le strategie aggiungono *timing* sopra quel beta, non lo eliminano.
## Le 3 strategie selezionate (meccanismi distinti)
| Codice | Meccanismo | TF | Asset robusti | OOS netto (fee 0.10% RT) | DD | Anni+ |
|--------|-----------|----|---------------|--------------------------|----|-------|
| **DIP01** | Dip-buy z-score reversion (long-only) | 1h | BTC, ETH, SOL | BTC +59% · ETH +224% · SOL +13% | 23-55% | 6-7/9 |
| **TR01** | EMA 20/100 trend-following (long-only) | 4h | BNB, BTC, DOGE, SOL, XRP | BTC +27% · DOGE +53% · XRP +29% | 29-53% | 4-6/8 |
| **ROT01** | Rotazione cross-sectional momentum sul paniere | 1d | intero paniere (8) | **+44%** | 53% | 5/7 |
Dettagli e riproducibilità: `scripts/analysis/honest_final.py` (tabella di
validazione unica), `honest_rotation.py`, `honest_trend.py`, `honest_candidates.py`,
`honest_diag.py`/`honest_diag2.py` (diagnostica long/short e filtro trend).
### DIP01 — compra le capitolazioni
Long-only: entra quando lo z-score del prezzo rispetto alla media a 50 barre scende
sotto 2.5 (capitolazione), prende profitto al rientro verso la media, SL a 2.5·ATR.
È la versione robusta e onesta della famiglia mean-reversion: regge lo sweep fee
fino a 0.20% RT (BTC +45% OOS anche a 0.20%). Funziona sui major (BTC/ETH/SOL); sugli
alt molto parabolici (DOGE/BNB) un dip fisso continua a scendere e non ha edge.
### TR01 — cavalca i trend
Long-only: in posizione quando EMA(20) > EMA(100) sul 4h, altrimenti cash. Poche
operazioni (≈200 flip in 8 anni) ⇒ le fee non sono letali. È **complementare** a
DIP01: guadagna nei regimi di trend, dove la reversione soffre.
### ROT01 — la più affidabile e "fuori dal comune"
Una sola strategia che usa **tutto il paniere** in un unico book: ogni giorno ordina
le 8 crypto per momentum (rendimento a 60 giorni) e alloca a parti uguali alle 2
migliori con momentum positivo, il resto in cash. Cattura la *dispersione* tra
crypto (gli alt forti corrono molto più di BTC nei bull) senza shortare nulla.
È **param-insensitive** (tutte le combinazioni lookback/top-k sono positive OOS) e
regge le fee fino a 0.20% RT (+41% OOS). Risponde direttamente alla richiesta di
combinare più crypto e un timeframe diverso in un'unica strategia. Per-anno:
2020 +33% · 2021 +181% · 2022 29% (bear) · 2023 +43% · 2024 +59% · 2025 +6% · 2026 10% (YTD).
## Diversificazione
I tre meccanismi coprono regimi diversi e in larga misura anti-correlati:
reversione (DIP01), momentum di singolo asset (TR01), forza relativa cross-asset
(ROT01). Eseguirli insieme produce una curva di equity più liscia del singolo.
## Onestà sull'obiettivo €50/giorno
Va detto chiaramente: **€50/giorno su €1.000 in pochi mesi non è raggiungibile a
rischio sano.** Significa ~€18.250/anno, cioè ~1.825%/anno; gli edge onesti qui
trovati rendono il 30-60% OOS su orizzonti pluriennali. Le strade per avvicinare
quel numero sono: (a) far crescere il capitale per anni con interesse composto —
€50/giorno diventa plausibile solo quando il capitale è molto più grande; (b) alzare
la leva, che però aumenta proporzionalmente il drawdown (già 23-55%) ed espone a
rovina; (c) aggiungere capitale. Nessuna di queste è una scorciatoia. La proposta
onesta è un portafoglio delle 3 strategie a leva moderata, puntando alla
**sopravvivenza e alla crescita composta**, non al target giornaliero immediato.
## Miglioramenti (alzare Acc, ridurre DD, migliorare PnL)
Leve oneste e documentate, senza tuning sui singoli anni
(`scripts/analysis/honest_improve.py`, `honest_improve2.py`):
### ROT02 — dual-momentum overlay (migliora TUTTO)
Alla rotazione cross-sectional di ROT01 si aggiunge un overlay di *absolute
momentum*: cash quando BTC è sotto la sua media a 100 giorni (mercato risk-off).
Taglia i bear di sistema (gli unici anni rossi di ROT01).
| | FULL% | OOS% | DD% |
|---|---|---|---|
| ROT01 base | +679 | +44 | 53 |
| **ROT02 (SMA100)** | **+1095** | **+98** | **40** |
PnL su, DD giù: dominanza su tutte e tre le metriche. Param-insensitive (SMA100-150).
### DIP01 — market-gate (variante low-DD)
Comprare i dip solo quando BTC è risk-on alza l'**Acc** (ETH 52→57%, SOL 49→52%) e
**dimezza il DD** (ETH 53→23%, SOL 25→13%), al costo di parte della PnL (meno trade).
È de-risking, non un pasto gratis: utile per chi vuole una curva più liscia. Su BTC
il gate va evitato (i dip migliori di BTC arrivano proprio quando BTC è sotto la
propria SMA), quindi DIP01 base resta la versione di riferimento per BTC.
### PORT01 — portafoglio combinato (il vero motore di risk-reduction)
Equal-weight giornaliero ribilanciato delle 3 sleeve anti-correlate
(DIP01 BTC + TR01 basket + ROT02). La diversificazione porta il DD del portafoglio
**sotto** quello della sleeve meno rischiosa, mantenendo una CAGR alta.
| Sleeve | ret% | DD% | CAGR% |
|--------|------|-----|-------|
| DIP01 BTC | +322 | 15 | 31 |
| TR01 basket | +591 | 27 | 43 |
| ROT02 dual-mom | +771 | 40 | 49 |
| **PORTAFOGLIO** | **+642** | **12** | **45** |
Per-anno portafoglio: 2021 +203% · 2022 **1%** (bear neutralizzato, era 30% su ROT) ·
2023 +47% · 2024 +50% · 2025 +14% · 2026 2% (YTD). Nessun anno realmente negativo,
DD massimo 12%, CAGR 45%. È la configurazione di deployment raccomandata.
## Prossimi passi
- Integrare DIP01 nel worker (già compatibile: Signal con tp/sl/max_bars).
- Trailing-stop ad ATR per TR01 (per alzarne l'Acc e ridurne ulteriormente il DD).
- Estendere il worker per strategie position-based (TR01) e di portafoglio (ROT01).
- Backtest del portafoglio combinato con ribilanciamento del capitale.
- Walk-forward rolling (oltre al singolo split 70/30) per confermare la stabilità.
+96
View File
@@ -0,0 +1,96 @@
# 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.
@@ -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", "torch>=2.0",
"matplotlib>=3.7", "matplotlib>=3.7",
"tqdm>=4.65", "tqdm>=4.65",
"pyyaml>=6.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+175
View File
@@ -0,0 +1,175 @@
"""Strategie candidate ONESTE + sweep multi-asset/tf con verdetto.
Ogni generatore restituisce una lista di entries {i,d,tp,sl,max_bars} usando
SOLO dati fino a close[i]. L'engine (honest_lab.simulate) entra a close[i].
Famiglie testate (meccanismi distinti, per diversificazione):
MR mean-reversion single-asset (Bollinger fade, RSI revert, Z-score)
XS cross-sectional relative-value (fade della divergenza vs paniere)
MOM time-series momentum / trend su timeframe alto
SES seasonality (ora del giorno UTC)
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import ( # noqa: E402
atr, rsi, ema, get_df, simulate, oos_split, verdict,
available_assets, FEE_RT,
)
# ============================================================================
# MR — mean reversion single-asset
# ============================================================================
def bollinger_fade(df, n=50, k=2.5, sl_atr=2.0, max_bars=24):
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
up, lo = ma + k * sd, ma - k * sd
ents = []
for i in range(n + 14, len(c)):
if np.isnan(up[i]) or np.isnan(a[i]):
continue
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
def rsi_revert(df, n=14, lo=25, hi=75, sl_atr=2.5, max_bars=24, ma_n=20):
c = df["close"].values
r = rsi(c, n)
ma = pd.Series(c).rolling(ma_n).mean().values
a = atr(df, 14)
ents = []
for i in range(max(n, ma_n) + 1, len(c)):
if np.isnan(r[i]) or np.isnan(ma[i]) or np.isnan(a[i]):
continue
if r[i - 1] < lo <= r[i]:
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif r[i - 1] > hi >= r[i]:
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
def zscore_revert(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24):
"""Entra quando close e' a |z|>z_in std dalla media; TP alla media."""
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
z = (c - ma) / sd
ents = []
for i in range(n + 14, len(c)):
if np.isnan(z[i]) or np.isnan(a[i]) or sd[i] == 0:
continue
if z[i] <= -z_in and z[i - 1] > -z_in:
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif z[i] >= z_in and z[i - 1] < z_in:
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
# ============================================================================
# MOM — time-series momentum / trend (timeframe alto, niente breakout intrabar)
# ============================================================================
def ema_trend(df, fast=20, slow=50, sl_atr=3.0, tp_atr=10.0, max_bars=240):
"""Trend following: cross EMA fast/slow deciso a close[i], TP/SL ad ATR."""
c = df["close"].values
ef, es = ema(c, fast), ema(c, slow)
a = atr(df, 14)
ents = []
for i in range(slow + 14, len(c)):
if np.isnan(a[i]):
continue
cross_up = ef[i] > es[i] and ef[i - 1] <= es[i - 1]
cross_dn = ef[i] < es[i] and ef[i - 1] >= es[i - 1]
if cross_up:
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif cross_dn:
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
# ============================================================================
# SES — seasonality (ora del giorno UTC). Direzione fissa decisa solo dall'ora.
# ============================================================================
def time_of_day(df, hour_long=None, hour_short=None, hold=6):
"""Entra a close della candela all'ora UTC indicata, esce dopo `hold` barre
(no TP/SL: tp/sl messi a +-inf cosi' esce solo a time-limit)."""
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
c = df["close"].values
hours = ts.dt.hour.values
hour_long = set(hour_long or [])
hour_short = set(hour_short or [])
ents = []
for i in range(1, len(c)):
if hours[i] in hour_long:
ents.append({"i": i, "d": 1, "tp": np.inf, "sl": -np.inf, "max_bars": hold})
elif hours[i] in hour_short:
ents.append({"i": i, "d": -1, "tp": -np.inf, "sl": np.inf, "max_bars": hold})
return ents
# ============================================================================
# sweep
# ============================================================================
def run_sweep(generators: dict, assets: list[str], tfs: list[str]):
print("=" * 130)
print(f" HONEST LAB — NETTO fee {FEE_RT*100:.2f}% RT | leva 3x | pos 15% | OOS ultimo 30%")
print("=" * 130)
print(f" {'Strategia':<26s}{'Asset':>5s}{'TF':>5s}{'Trd':>6s}{'Win%':>7s}"
f"{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniPos':>9s}{'OK':>4s}")
print(" " + "-" * 126)
survivors = []
for label, (fn, params) in generators.items():
for asset in assets:
for tf in tfs:
try:
df = get_df(asset, tf)
except Exception:
continue
ents = fn(df, **params)
if len(ents) < 30:
continue
full = simulate(ents, df)
_, oos_e = oos_split(ents, df)
oos = simulate(oos_e, df)
ok = verdict(full, oos)
flag = " OK" if ok else ""
print(f" {label:<26s}{asset:>5s}{tf:>5s}{full.trades:>6d}{full.win:>7.1f}"
f"{full.ret:>+9.0f}{oos.ret:>+9.0f}{full.dd:>6.0f}{full.exposure:>6.0f}"
f"{f'{full.pos_years}/{full.n_years}':>9s}{flag:>4s}")
if ok:
survivors.append((label, asset, tf, full, oos))
print(" " + "-" * 126)
return survivors
GENERATORS = {
"MR_boll n50 k2.5": (bollinger_fade, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24)),
"MR_boll n20 k2.5": (bollinger_fade, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)),
"MR_rsi 25/75": (rsi_revert, dict(n=14, lo=25, hi=75, sl_atr=2.5, max_bars=24)),
"MR_zscore z2.5": (zscore_revert, dict(n=50, z_in=2.5, sl_atr=2.5, max_bars=24)),
"MR_zscore z3": (zscore_revert, dict(n=50, z_in=3.0, sl_atr=2.5, max_bars=24)),
"MOM_ema 20/50": (ema_trend, dict(fast=20, slow=50, sl_atr=3.0, tp_atr=10.0, max_bars=240)),
}
if __name__ == "__main__":
assets = available_assets()
print("Asset disponibili:", assets)
survivors = run_sweep(GENERATORS, assets, ["1h", "4h"])
print(f"\n SOPRAVVISSUTI (FULL+OOS+anni+DD): {len(survivors)}")
for label, a, tf, full, oos in survivors:
print(f" {label:<26s} {a} {tf} FULL {full.ret:+.0f}% OOS {oos.ret:+.0f}% DD {full.dd:.0f}%")
+73
View File
@@ -0,0 +1,73 @@
"""Diagnostica: perche' la mean-reversion simmetrica perde su asset trending?
Test: long-only vs short-only, e MR FILTRATA DAL TREND (buy-dip in uptrend,
sell-rip in downtrend) per evitare di fadeare i trend forti.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import ( # noqa: E402
atr, ema, get_df, simulate, oos_split, available_assets, FEE_RT,
)
def zscore_entries(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24,
trend_n=0, side="both"):
"""Z-score revert con filtro trend opzionale.
trend_n>0: EMA di lungo periodo. Long solo se close>EMA (uptrend),
short solo se close<EMA (downtrend).
side: 'both' | 'long' | 'short'
"""
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
et = ema(c, trend_n) if trend_n > 0 else None
start = max(n + 14, trend_n + 1 if trend_n else 0)
ents = []
for i in range(start, len(c)):
if np.isnan(z[i]) or np.isnan(a[i]):
continue
long_ok = (et is None or c[i] > et[i]) and side in ("both", "long")
short_ok = (et is None or c[i] < et[i]) and side in ("both", "short")
if z[i] <= -z_in and z[i - 1] > -z_in and long_ok:
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif z[i] >= z_in and z[i - 1] < z_in and short_ok:
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
def row(label, df, ents):
if len(ents) < 20:
print(f" {label:<34s} {'<20 trd':>50s}")
return None
full = simulate(ents, df)
_, oe = oos_split(ents, df)
oos = simulate(oe, df)
print(f" {label:<34s}{full.trades:>6d}{full.win:>7.1f}{full.ret:>+9.0f}"
f"{oos.ret:>+9.0f}{full.dd:>6.0f}{f'{full.pos_years}/{full.n_years}':>8s}")
return full, oos
if __name__ == "__main__":
assets = available_assets()
print(f"HONEST DIAG — z-score revert, fee {FEE_RT*100:.2f}% RT, leva 3x | OOS 30%")
for tf in ["1h"]:
for a in assets:
df = get_df(a, tf)
print(f"\n === {a} {tf} === {'Trd':>5s}{'Win%':>7s}{'FULL%':>8s}{'OOS%':>8s}{'DD%':>6s}{'AnniP':>8s}")
base = dict(n=50, z_in=2.5, sl_atr=2.5, max_bars=24)
row("both, no filter", df, zscore_entries(df, **base, side="both"))
row("long-only, no filter", df, zscore_entries(df, **base, side="long"))
row("short-only, no filter", df, zscore_entries(df, **base, side="short"))
row("both + trend200 filter", df, zscore_entries(df, **base, trend_n=200, side="both"))
row("both + trend500 filter", df, zscore_entries(df, **base, trend_n=500, side="both"))
row("long + trend200 filter", df, zscore_entries(df, **base, trend_n=200, side="long"))
+64
View File
@@ -0,0 +1,64 @@
"""Diag2: long-MR sempre + short-MR SOLO in downtrend confermato (close<EMA_t).
Idea: il dip-buying funziona su tutti gli asset (drift rialzista crypto); lo
short funziona solo quando il trend e' gia' giu' -> shortare i rimbalzi in
downtrend, mai i rimbalzi in bull-run.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import ( # noqa: E402
atr, ema, get_df, simulate, oos_split, available_assets, FEE_RT,
)
def regime_mr(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24, trend_n=200,
allow_short=True):
"""Long su z<=-z_in SEMPRE. Short su z>=+z_in solo se close<EMA(trend_n)."""
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
et = ema(c, trend_n)
start = max(n + 14, trend_n + 1)
ents = []
for i in range(start, len(c)):
if np.isnan(z[i]) or np.isnan(a[i]):
continue
if z[i] <= -z_in and z[i - 1] > -z_in:
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif allow_short and z[i] >= z_in and z[i - 1] < z_in and c[i] < et[i]:
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
def show(label, df, ents):
if len(ents) < 20:
print(f" {label:<30s} <20 trd"); return None
full = simulate(ents, df); _, oe = oos_split(ents, df); oos = simulate(oe, df)
print(f" {label:<30s}{full.trades:>6d}{full.win:>7.1f}{full.ret:>+9.0f}"
f"{oos.ret:>+9.0f}{full.dd:>6.0f}{f'{full.pos_years}/{full.n_years}':>8s}")
return full, oos
if __name__ == "__main__":
assets = available_assets()
print(f"DIAG2 — regime MR (long sempre + short in downtrend) fee {FEE_RT*100:.2f}% leva3x OOS30%")
surv = 0
for a in assets:
df = get_df(a, "1h")
print(f"\n === {a} 1h === {'Trd':>5s}{'Win%':>7s}{'FULL%':>8s}{'OOS%':>8s}{'DD%':>6s}{'AnniP':>8s}")
show("long-only", df, regime_mr(df, allow_short=False))
r = show("long + short@downtrend200", df, regime_mr(df, trend_n=200))
show("long + short@downtrend500", df, regime_mr(df, trend_n=500))
if r and r[0].ret > 0 and r[1].ret > 0:
surv += 1
print(f"\n Asset con regime200 positivo FULL+OOS: {surv}/{len(assets)}")
+103
View File
@@ -0,0 +1,103 @@
"""Validazione FINALE delle 3 strategie oneste selezionate.
Per ciascuna: per-asset FULL/OOS/DD/anni-positivi + sweep fee (0/0.05/0.10/0.20% RT).
Tutto NETTO, ingresso eseguibile, OOS = ultimo 30%, leva 3x.
S1 DIP — long-only dip-buy z-score reversion (1h) [regime: reversione]
S2 TREND — long-only EMA 20/100 trend-following (4h) [regime: momentum singolo]
S3 ROT — rotazione cross-sectional momentum sul paniere (1d) [regime: forza relativa]
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import atr, ema, get_df, simulate, oos_split, available_assets
from scripts.analysis.honest_trend import simulate_position, ema_dual_signal, oos as trend_oos
from scripts.analysis.honest_rotation import build_panel, simulate_rotation
FEES = [0.0, 0.0005, 0.001, 0.002]
# ---- S1 DIP ----
def dip_entries(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24):
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
ents = []
for i in range(n + 14, len(c)):
if np.isnan(z[i]) or np.isnan(a[i]):
continue
if z[i] <= -z_in and z[i - 1] > -z_in:
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
return ents
def validate_dip(assets):
print("\n" + "=" * 100)
print(" S1 DIP — long-only dip-buy z-score reversion | 1h | n=50 z=2.5 sl=2.5ATR mb=24")
print("=" * 100)
print(f" {'Asset':<6s}{'Trd':>6s}{'Win%':>7s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}"
f"{' fee-sweep OOS% (0/0.05/0.10/0.20)':<40s}")
ok = 0
for a in assets:
df = get_df(a, "1h"); ents = dip_entries(df)
if len(ents) < 30:
continue
full = simulate(ents, df); _, oe = oos_split(ents, df); oos = simulate(oe, df)
sweep = " ".join(f"{simulate(oe, df, fee_rt=f).ret:+.0f}" for f in FEES)
good = full.ret > 0 and oos.ret > 0
ok += good
print(f" {a:<6s}{full.trades:>6d}{full.win:>7.1f}{full.ret:>+9.0f}{oos.ret:>+9.0f}"
f"{full.dd:>6.0f}{full.exposure:>6.0f}{f'{full.pos_years}/{full.n_years}':>8s} [{sweep}]"
f"{' OK' if good else ''}")
print(f" -> robusto (FULL+OOS>0) su {ok}/{len(assets)} asset")
def validate_trend(assets):
print("\n" + "=" * 100)
print(" S2 TREND — long-only EMA 20/100 trend | 4h")
print("=" * 100)
print(f" {'Asset':<6s}{'Flip':>6s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}")
ok = 0
for a in assets:
df = get_df(a, "4h"); sig = ema_dual_signal(df, 20, 100, long_only=True)
full = simulate_position(sig, df); oos = trend_oos(sig, df)
good = full["ret"] > 0 and oos["ret"] > 0
ok += good
print(f" {a:<6s}{full['flips']:>6d}{full['ret']:>+9.0f}{oos['ret']:>+9.0f}"
f"{full['dd']:>6.0f}{full['exposure']:>6.0f}{(str(full['pos_years'])+'/'+str(full['n_years'])):>8s}"
f"{' OK' if good else ''}")
print(f" -> robusto su {ok}/{len(assets)} asset")
def validate_rot(assets):
print("\n" + "=" * 100)
print(" S3 ROT — rotazione cross-sectional momentum | 1d | lb=60 top2 su tutto il paniere")
print("=" * 100)
panel = build_panel(assets, "1d")
print(f" Paniere {list(panel.columns)} {panel.shape[0]} barre {panel.index[0].date()}->{panel.index[-1].date()}")
print(f" {'fee RT':<10s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'AnniP':>8s}")
for f in FEES:
full = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=f)
oos = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=f, oos_frac=0.30)
anni = str(full['pos_years']) + '/' + str(full['n_years'])
print(f" {f*100:>5.2f}%RT {full['ret']:>+9.0f}{oos['ret']:>+9.0f}{full['dd']:>6.0f}{anni:>8s}")
# per-anno alla fee reale
full = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=0.001)
print(" per-anno (fee 0.10%): " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(full["yearly"].items())))
if __name__ == "__main__":
assets = available_assets()
print(f"VALIDAZIONE FINALE — asset disponibili: {assets}")
validate_dip(assets)
validate_trend(assets)
validate_rot(assets)
+175
View File
@@ -0,0 +1,175 @@
"""Miglioramenti ONESTI: alzare Acc, ridurre DD, migliorare PnL senza overfitting.
Leve usate (tutte robuste e documentate, niente tuning sui singoli anni):
1. ABSOLUTE-MOMENTUM overlay (dual momentum): vai in CASH quando il "mercato"
(BTC) e' sotto la sua media di lungo periodo -> taglia i bear (2022/2026).
2. VOL-TARGETING: scala l'esposizione per puntare a una volatilita' costante
-> riduce il DD e liscia la PnL.
3. TRAILING STOP ad ATR per il trend (TR01) -> blocca i profitti.
Confronto base vs migliorata su FULL + OOS + DD pieno + per-anno.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import atr, ema, get_df, available_assets, FEE_RT
from scripts.analysis.honest_rotation import build_panel
LEV, POS = 3.0, 0.15
def _dd(eq: np.ndarray) -> float:
peak = eq[0]; mx = 0.0
for v in eq:
peak = max(peak, v); mx = max(mx, (peak - v) / peak if peak > 0 else 0.0)
return mx * 100
# ============================================================================
# ROT01 migliorata: dual-momentum (cash se BTC < SMA) + vol-target
# ============================================================================
def rot_improved(lookback=60, top_k=2, gross=0.45, regime_n=100,
target_vol=0.0, vol_n=20, fee_rt=FEE_RT, oos_frac=0.0):
panel = build_panel(available_assets(), "1d")
cols = list(panel.columns)
P = panel.values; T, N = P.shape
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
years = panel.index.year.values
btc = P[:, cols.index("BTC")]
use_regime = regime_n and regime_n > 1
btc_ma = pd.Series(btc).rolling(max(regime_n, 2)).mean().values
# vol realizzata del portafoglio equal-weight come proxy di scala
mkt_ret = rets.mean(axis=1)
rv = pd.Series(mkt_ret).rolling(vol_n).std().values * np.sqrt(365)
start = max(lookback + 1, (regime_n + 1) if use_regime else 0, int(T * (1 - oos_frac)) if oos_frac else 0)
cap = 1000.0; w = np.zeros(N)
eq = [cap]; yearly: dict[int, float] = {}; pos_days = {}; days = {}; reb = {}
for i in range(start, T - 1):
if use_regime:
risk_on = btc[i] > btc_ma[i] if not np.isnan(btc_ma[i]) else False
else:
risk_on = True
mom = P[i] / P[i - lookback] - 1
order = np.argsort(mom)[::-1]
chosen = [j for j in order if mom[j] > 0][:top_k] if risk_on else []
g = gross
if target_vol > 0 and not np.isnan(rv[i]) and rv[i] > 0:
g = min(gross, gross * target_vol / rv[i]) # solo riduzione (no leva extra)
new_w = np.zeros(N)
for j in chosen:
new_w[j] = g / len(chosen)
turnover = np.abs(new_w - w).sum()
if turnover > 1e-9:
cap -= cap * turnover * (fee_rt / 2)
w = new_w
pr = float(np.dot(w, rets[i + 1]))
cap = max(cap * (1 + pr), 10.0)
eq.append(cap)
y = int(years[i])
yearly[y] = yearly.get(y, 0.0) + pr * 100
pos_days[y] = pos_days.get(y, 0) + (pr > 0); days[y] = days.get(y, 0) + 1
reb[y] = reb.get(y, 0) + (turnover > 1e-9)
return {"ret": (cap / 1000 - 1) * 100, "dd": _dd(np.array(eq)), "yearly": yearly,
"pos_years": sum(1 for v in yearly.values() if v > 0), "n_years": len(yearly),
"pos_days": pos_days, "days": days, "reb": reb}
# ============================================================================
# DIP01 migliorata: filtro regime (no dip in bear forte) + vol-target sizing
# ============================================================================
def dip_improved(asset, tf="1h", n=50, z_in=2.5, sl_atr=2.5, max_bars=24,
regime_n=200, vol_target=0.0, fee_rt=FEE_RT, oos_frac=0.0):
df = get_df(asset, tf)
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)
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
sma_r = pd.Series(c).rolling(regime_n).mean().values
atr_pct = a / c # volatilita' relativa
base_vol = np.nanmedian(atr_pct[regime_n:regime_n * 2]) if N > regime_n * 2 else np.nanmedian(atr_pct)
fee = fee_rt * LEV
cap = 1000.0; last_exit = -1
eq = [cap]; yt: dict[int, list] = {}
start = max(n + 14, regime_n + 1) if regime_n else n + 14
split = int(N * (1 - oos_frac)) if oos_frac else 0
for i in range(start, N):
if i < split or np.isnan(z[i]) or np.isnan(a[i]):
continue
if not (z[i] <= -z_in and z[i - 1] > -z_in):
continue
# filtro regime: salta i dip in bear forte (prezzo molto sotto SMA lunga)
if regime_n and not np.isnan(sma_r[i]) and c[i] < sma_r[i] * 0.90:
continue
if i <= last_exit or i + 1 >= N:
continue
# vol-target: riduci posizione se ATR% > base (no leva extra)
psize = POS
if vol_target > 0 and not np.isnan(atr_pct[i]) and atr_pct[i] > 0:
psize = POS * min(1.0, base_vol / atr_pct[i])
entry = c[i]; tp, sl, mb = ma[i], c[i] - sl_atr * a[i], max_bars
exit_p = c[min(i + mb, N - 1)]; j = min(i + mb, N - 1)
for k in range(1, mb + 1):
j = i + k
if j >= N:
j = N - 1; exit_p = c[j]; break
if l[j] <= sl:
exit_p = sl; break
if h[j] >= tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * LEV - fee
cap = max(cap + cap * psize * ret, 10.0)
last_exit = j
y = ts.iloc[i].year
rec = yt.setdefault(y, [0, 0]); rec[0] += 1; rec[1] += ret > 0
eq.append(cap)
t = sum(v[0] for v in yt.values()); w = sum(v[1] for v in yt.values())
return {"ret": (cap / 1000 - 1) * 100, "dd": _dd(np.array(eq)),
"trades": t, "acc": w / t * 100 if t else 0.0,
"yt": yt, "pos_years": sum(1 for v in yt.values() if v[1] / max(v[0],1) and v[1]>v[0]*0 and (v[1]>0)), "n_years": len(yt)}
def dip_acc_pnl(asset, **kw):
"""ritorna anche FULL e OOS."""
full = dip_improved(asset, **kw)
oos = dip_improved(asset, oos_frac=0.30, **kw)
return full, oos
if __name__ == "__main__":
print("=" * 92)
print(" ROT01 — BASE vs MIGLIORATA (dual-momentum cash + vol-target)")
print("=" * 92)
print(f" {'config':<40s}{'FULL%':>9s}{'OOS%':>9s}{'DD%pieno':>10s}{'AnniP':>8s}")
b = rot_improved(regime_n=0); bo = rot_improved(regime_n=0, oos_frac=0.30)
print(f" {'BASE (no overlay)':<40s}{b['ret']:>+9.0f}{bo['ret']:>+9.0f}{b['dd']:>10.0f}"
f"{str(b['pos_years'])+'/'+str(b['n_years']):>8s}")
for rn in [100, 150, 200]:
f = rot_improved(regime_n=rn); o = rot_improved(regime_n=rn, oos_frac=0.30)
print(f" {'+ dual-mom cash (BTC<SMA'+str(rn)+')':<40s}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
f"{f['dd']:>10.0f}{str(f['pos_years'])+'/'+str(f['n_years']):>8s}")
for tv in [0.6, 0.8]:
f = rot_improved(regime_n=150, target_vol=tv); o = rot_improved(regime_n=150, target_vol=tv, oos_frac=0.30)
print(f" {'+ dual-mom150 + volTarget'+str(tv):<40s}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
f"{f['dd']:>10.0f}{str(f['pos_years'])+'/'+str(f['n_years']):>8s}")
print("\n" + "=" * 92)
print(" DIP01 — BASE vs MIGLIORATA (filtro regime + vol-target)")
print("=" * 92)
print(f" {'asset / config':<34s}{'Trd':>6s}{'Acc%':>7s}{'FULL%':>9s}{'OOS%':>9s}{'DD%pieno':>10s}")
for a in ["BTC", "ETH", "SOL"]:
for label, kw in [("base", dict(regime_n=0, vol_target=0)),
("+regime+volTgt", dict(regime_n=200, vol_target=0.5))]:
f, o = dip_acc_pnl(a, **kw)
print(f" {a+' '+label:<34s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+9.0f}"
f"{o['ret']:>+9.0f}{f['dd']:>10.0f}")
+184
View File
@@ -0,0 +1,184 @@
"""Miglioramenti v2: market-regime gate su DIP01 + PORTAFOGLIO combinato.
- DIP01 con gate di mercato: compra i dip solo quando BTC e' risk-on (BTC>SMA),
cosi' si evitano le capitolazioni dei bear (2018/2022) che peggiorano Acc/DD/PnL.
- Portafoglio: equal-weight giornaliero delle 3 strategie migliorate -> la
diversificazione taglia il DD mantenendo la PnL (migliora il risk-adjusted).
Tutto NETTO, con DD pieno e per-anno.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import atr, ema, get_df, available_assets, FEE_RT
from scripts.analysis.honest_improve import rot_improved, _dd
LEV, POS = 3.0, 0.15
def _daily_equity(ts_list, cap_list, idx):
"""serie di equity giornaliera (ffill) su un DatetimeIndex comune."""
s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True))
s = s[~s.index.duplicated(keep="last")].sort_index()
daily = s.resample("1D").last().reindex(idx).ffill().bfill()
return daily
# ---------- DIP01 con market-regime gate ----------
def dip_market_gated(asset, n=50, z_in=2.5, sl_atr=2.5, max_bars=24,
market_n=100, fee_rt=FEE_RT, oos_frac=0.0, return_equity=False):
df = get_df(asset, "1h")
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)
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
# regime di mercato: BTC 1h > SMA(market_n in giorni -> *24 barre)
btc = get_df("BTC", "1h")
bser = pd.Series(btc["close"].values,
index=pd.to_datetime(btc["timestamp"], unit="ms", utc=True))
bser = bser[~bser.index.duplicated()]
bma = bser.rolling(market_n * 24).mean()
risk_on = (bser > bma).reindex(ts, method="ffill").fillna(False).values
fee = fee_rt * LEV
cap = 1000.0; last_exit = -1
eq_ts, eq_v = [], []
yt: dict[int, list] = {}; ypnl: dict[int, float] = {}
split = int(N * (1 - oos_frac)) if oos_frac else 0
for i in range(n + 14, N):
if i < split or np.isnan(z[i]) or np.isnan(a[i]):
continue
if not (z[i] <= -z_in and z[i - 1] > -z_in):
continue
if market_n and not risk_on[i]:
continue
if i <= last_exit or i + 1 >= N:
continue
entry = c[i]; tp, sl, mb = ma[i], c[i] - sl_atr * a[i], max_bars
exit_p = c[min(i + mb, N - 1)]; j = min(i + mb, N - 1)
for k in range(1, mb + 1):
j = i + k
if j >= N:
j = N - 1; exit_p = c[j]; break
if l[j] <= sl:
exit_p = sl; break
if h[j] >= tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * LEV - fee
cap = max(cap + cap * POS * ret, 10.0)
last_exit = j
y = ts.iloc[i].year
rec = yt.setdefault(y, [0, 0]); rec[0] += 1; rec[1] += ret > 0
ypnl[y] = ypnl.get(y, 0.0) + ret * 100
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
t = sum(v[0] for v in yt.values()); w = sum(v[1] for v in yt.values())
out = {"ret": (cap / 1000 - 1) * 100, "dd": _dd(np.array(eq_v)) if eq_v else 0.0,
"trades": t, "acc": w / t * 100 if t else 0.0, "yt": yt, "ypnl": ypnl,
"pos_years": sum(1 for v in ypnl.values() if v > 0), "n_years": len(ypnl)}
if return_equity:
out["eq_ts"], out["eq_v"] = eq_ts, eq_v
return out
def main():
print("=" * 96)
print(" DIP01 — base vs MARKET-GATE (compra dip solo se BTC>SMA100)")
print("=" * 96)
print(f" {'asset / config':<30s}{'Trd':>6s}{'Acc%':>7s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>7s}{'AnniP':>8s}")
for a in ["BTC", "ETH", "SOL"]:
b = dip_market_gated(a, market_n=0); bo = dip_market_gated(a, market_n=0, oos_frac=0.30)
g = dip_market_gated(a, market_n=100); go = dip_market_gated(a, market_n=100, oos_frac=0.30)
print(f" {a+' base':<30s}{b['trades']:>6d}{b['acc']:>7.1f}{b['ret']:>+9.0f}{bo['ret']:>+9.0f}"
f"{b['dd']:>7.0f}{str(b['pos_years'])+'/'+str(b['n_years']):>8s}")
print(f" {a+' +gate100':<30s}{g['trades']:>6d}{g['acc']:>7.1f}{g['ret']:>+9.0f}{go['ret']:>+9.0f}"
f"{g['dd']:>7.0f}{str(g['pos_years'])+'/'+str(g['n_years']):>8s}")
# ---------- PORTAFOGLIO combinato (3 sleeve diversificate) ----------
print("\n" + "=" * 96)
print(" PORTAFOGLIO equal-weight giornaliero (ribilanciato): DIP01 + TR01-basket + ROT02")
print("=" * 96)
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
# sleeve 1: DIP01 base su BTC (la migliore)
d = dip_market_gated("BTC", market_n=0, return_equity=True)
eq_dip = _norm(_daily_equity(d["eq_ts"], d["eq_v"], idx))
# sleeve 2: TR01 equal-weight su {BNB,BTC,DOGE,SOL,XRP}
eq_tr = _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], idx))
# sleeve 3: ROT02 dual-momentum
eq_rot = _norm(_rot_daily_equity(idx))
members = {"DIP01_BTC": eq_dip, "TR01_basket": eq_tr, "ROT02_dualmom": eq_rot}
# ribilanciamento giornaliero equal-weight: media dei rendimenti giornalieri
drets = pd.DataFrame({k: v.pct_change().fillna(0) for k, v in members.items()})
port_ret = drets.mean(axis=1)
combo = (1 + port_ret).cumprod()
print(f" Periodo {idx[0].date()} -> {idx[-1].date()} (leva/pos gia' incluse nelle sleeve)")
print(f" {'sleeve':<16s}{'ret%':>9s}{'DD%':>7s}{'CAGR%':>8s}")
yrs = (idx[-1] - idx[0]).days / 365.25
for name, s in members.items():
r = (s.iloc[-1] / s.iloc[0] - 1) * 100
cagr = ((s.iloc[-1] / s.iloc[0]) ** (1 / yrs) - 1) * 100
print(f" {name:<16s}{r:>+9.0f}{_dd(s.values):>7.0f}{cagr:>8.0f}")
r = (combo.iloc[-1] / combo.iloc[0] - 1) * 100
cagr = ((combo.iloc[-1] / combo.iloc[0]) ** (1 / yrs) - 1) * 100
print(f" {'PORTAFOGLIO':<16s}{r:>+9.0f}{_dd(combo.values):>7.0f}{cagr:>8.0f} <-- DD molto piu' basso, CAGR solida")
# per-anno del portafoglio
pa = (port_ret.groupby(port_ret.index.year).apply(lambda x: ((1 + x).prod() - 1) * 100))
print(" Portafoglio per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
def _norm(s):
return s / s.iloc[0]
def _tr_basket_daily(assets, idx):
"""equity giornaliera media di TR01 (EMA20/100 long-only, 4h) sul paniere."""
eqs = []
for a in assets:
df = get_df(a, "4h"); c = df["close"].values; n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ef, es = ema(c, 20), ema(c, 100)
sig = np.where(ef > es, 1.0, 0.0); sig[:100] = 0.0
cap = 1000.0; cur = 0.0; fee = FEE_RT / 2 * LEV
tl, cl = [], []
for i in range(n - 1):
s = sig[i]
if s != cur:
cap -= cap * POS * fee * abs(s - cur); cur = s
cap = max(cap * (1 + POS * LEV * (c[i + 1] - c[i]) / c[i] * cur), 10.0)
tl.append(ts.iloc[i]); cl.append(cap)
eqs.append(_norm(_daily_equity(tl, cl, idx)))
return _norm(pd.concat(eqs, axis=1).mean(axis=1))
def _rot_daily_equity(idx):
"""equity giornaliera della ROT01 dual-momentum (ricostruita bar-by-bar)."""
from scripts.analysis.honest_rotation import build_panel
panel = build_panel(available_assets(), "1d")
cols = list(panel.columns); P = panel.values; T, N = P.shape
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
btc = P[:, cols.index("BTC")]; bma = pd.Series(btc).rolling(100).mean().values
cap = 1000.0; w = np.zeros(N); ts_list = []; cap_list = []
for i in range(101, T - 1):
risk_on = btc[i] > bma[i] if not np.isnan(bma[i]) else False
mom = P[i] / P[i - 60] - 1; order = np.argsort(mom)[::-1]
chosen = [j for j in order if mom[j] > 0][:2] if risk_on else []
nw = np.zeros(N)
for j in chosen:
nw[j] = 0.45 / len(chosen)
cap -= cap * np.abs(nw - w).sum() * (FEE_RT / 2); w = nw
cap = max(cap * (1 + float(np.dot(w, rets[i + 1]))), 10.0)
ts_list.append(panel.index[i]); cap_list.append(cap)
s = _daily_equity(ts_list, cap_list, idx); return s / s.iloc[0]
if __name__ == "__main__":
main()
+192
View File
@@ -0,0 +1,192 @@
"""honest_lab — laboratorio di ricerca strategie ONESTO e fee-aware.
Principi (per non ripetere l'errore look-ahead della famiglia squeeze):
1. Ogni segnale a barra i usa SOLO dati fino a close[i]. Ingresso a close[i]
(eseguibile dal vivo: il worker vede la candela chiusa ed entra). Opzione
di robustezza: ingresso a open[i+1] (ancora piu' conservativo).
2. Uscita TP/SL valutata intrabar su high/low, conservativa: SL prima del TP
nello stesso bar. Time-limit max_bars. Una posizione per volta (non-overlap).
3. Tutto NETTO dopo fee round-trip realistiche (0.10% Deribit) * leva.
4. Validazione: FULL + OOS (held-out ultimo 30%) + per-anno + sweep fee
+ griglia parametri + su PIU' asset. Niente di tutto cio' -> scartata.
Engine condiviso riusabile da tutte le strategie candidate.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
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 # noqa: E402
FEE_RT = 0.001 # Deribit perp realistico: taker ~0.05%/lato = 0.10% RT
LEV = 3.0
POS = 0.15
OOS_FRAC = 0.30
DATA_DIR = PROJECT_ROOT / "data" / "raw"
# ----------------------------------------------------------------------------
# dati
# ----------------------------------------------------------------------------
_CACHE: dict[tuple[str, str], pd.DataFrame] = {}
def available_assets() -> list[str]:
out = []
for p in sorted(DATA_DIR.glob("*_1h.parquet")):
name = p.stem.replace("_1h", "").upper()
if name not in ("BTC_DVOL", "ETH_DVOL"):
out.append(name)
return out
def get_df(asset: str, tf: str) -> pd.DataFrame:
"""tf nativo (15m,1h) o resample da 1h (2h,4h,6h,12h,1d)."""
key = (asset, tf)
if key in _CACHE:
return _CACHE[key]
if tf in ("15m", "1h"):
df = load_data(asset, tf).reset_index(drop=True)
else:
base = load_data(asset, "1h").copy()
base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
base = base.set_index("dt")
rule = {"2h": "2h", "4h": "4h", "6h": "6h", "12h": "12h", "1d": "1D"}[tf]
agg = base.resample(rule).agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
).dropna()
# l'indice puo' essere datetime64[ms] o [ns]: forza ms in modo robusto
agg["timestamp"] = agg.index.values.astype("datetime64[ms]").astype("int64")
df = agg.reset_index(drop=True)
df = df[["timestamp", "open", "high", "low", "close", "volume"]].copy()
_CACHE[key] = df
return df
# ----------------------------------------------------------------------------
# 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
def ema(close: np.ndarray, n: int) -> np.ndarray:
return pd.Series(close).ewm(span=n, adjust=False).mean().values
# ----------------------------------------------------------------------------
# engine
# ----------------------------------------------------------------------------
@dataclass
class SimResult:
trades: int
win: float
ret: float # ritorno % netto composto su 1000
dd: float
exposure: float
yearly: dict[int, float]
@property
def pos_years(self) -> int:
return sum(1 for v in self.yearly.values() if v > 0)
@property
def n_years(self) -> int:
return len(self.yearly)
def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT,
lev: float = LEV, pos: float = POS, entry_on_open: bool = False) -> SimResult:
"""entries: dict {i, d(+1/-1), tp, sl, max_bars}.
entry_on_open=True -> ingresso a open[i+1] invece di close[i] (robustezza).
"""
o, h, l, c = (df["open"].values, 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"]
ei = i + 1 if entry_on_open else i # barra di ingresso
if ei <= last_exit or ei + 1 >= n:
continue
entry = o[ei] if entry_on_open else c[i]
tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
exit_p = c[min(ei + mb, n - 1)]
j = min(ei + mb, n - 1)
for k in range(1, mb + 1):
j = ei + k
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 k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * d * lev - fee
cap = max(cap + cap * pos * ret, 10.0)
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; bars_in += (j - ei)
last_exit = j
yr = ts.iloc[i].year
yearly[yr] = yearly.get(yr, 0.0) + ret * 100
return SimResult(
trades=trades,
win=wins / trades * 100 if trades else 0.0,
ret=(cap / 1000 - 1) * 100,
dd=max_dd * 100,
exposure=bars_in / n * 100,
yearly=yearly,
)
def oos_split(entries: list[dict], df: pd.DataFrame, frac: float = OOS_FRAC):
split = int(len(df) * (1 - frac))
ins = [e for e in entries if e["i"] < split]
oos = [e for e in entries if e["i"] >= split]
return ins, oos
# ----------------------------------------------------------------------------
# criterio di accettazione
# ----------------------------------------------------------------------------
def verdict(full: SimResult, oos: SimResult) -> bool:
"""Strategia attendibile su un singolo asset/tf."""
if full.trades < 30:
return False
if full.ret <= 0 or oos.ret <= 0:
return False
if full.pos_years < max(full.n_years - 1, 1):
return False
if full.dd > 45:
return False
return True
+80
View File
@@ -0,0 +1,80 @@
"""Tabella unica consolidata: PnL% NETTO per anno, tutte le strategie a confronto.
Colonne: DIP01(BTC) · TR01(basket) · ROT01(base) · ROT02(dual-mom) · PORTAFOGLIO.
Ultima riga: TOT e DD full-period.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import available_assets, FEE_RT
from scripts.analysis.honest_improve import _dd
from scripts.analysis.honest_improve2 import (
dip_market_gated, _daily_equity, _norm, _tr_basket_daily,
)
from scripts.analysis.honest_rotation import build_panel
LEV, POS = 3.0, 0.15
def rot_daily(idx, regime_n=0, lookback=60, top_k=2, gross=0.45):
"""equity giornaliera della rotazione, con/senza overlay dual-momentum."""
panel = build_panel(available_assets(), "1d")
cols = list(panel.columns); P = panel.values; T, N = P.shape
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
btc = P[:, cols.index("BTC")]
bma = pd.Series(btc).rolling(max(regime_n, 2)).mean().values
use_reg = regime_n and regime_n > 1
cap = 1000.0; w = np.zeros(N); tl, cl = [], []
start = max(lookback + 1, regime_n + 1 if use_reg else 0)
for i in range(start, T - 1):
risk_on = (btc[i] > bma[i]) if (use_reg and not np.isnan(bma[i])) else True
mom = P[i] / P[i - lookback] - 1; order = np.argsort(mom)[::-1]
chosen = [j for j in order if mom[j] > 0][:top_k] if risk_on else []
nw = np.zeros(N)
for j in chosen:
nw[j] = gross / len(chosen)
cap -= cap * np.abs(nw - w).sum() * (FEE_RT / 2); w = nw
cap = max(cap * (1 + float(np.dot(w, rets[i + 1]))), 10.0)
tl.append(panel.index[i]); cl.append(cap)
return _norm(_daily_equity(tl, cl, idx))
def year_pnl(eq):
return {int(y): (g.iloc[-1] / g.iloc[0] - 1) * 100 for y, g in _norm(eq).groupby(eq.index.year)}
if __name__ == "__main__":
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
d = dip_market_gated("BTC", market_n=0, return_equity=True)
cols = {
"DIP01(BTC)": _norm(_daily_equity(d["eq_ts"], d["eq_v"], idx)),
"TR01(bskt)": _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], idx)),
"ROT01": rot_daily(idx, regime_n=0),
"ROT02": rot_daily(idx, regime_n=100),
}
drets = pd.DataFrame({k: v.pct_change().fillna(0) for k, v in {
"DIP01(BTC)": cols["DIP01(BTC)"], "TR01(bskt)": cols["TR01(bskt)"], "ROT02": cols["ROT02"]
}.items()})
cols["PORTAF."] = (1 + drets.mean(axis=1)).cumprod()
names = list(cols)
py = {n: year_pnl(cols[n]) for n in names}
years = sorted({y for n in names for y in py[n]})
print("=" * 78)
print(" PnL% NETTO PER ANNO — confronto strategie (leva 3x, fee 0.10% RT)")
print("=" * 78)
print(f" {'Anno':>6s}" + "".join(f"{n:>12s}" for n in names))
print(" " + "-" * 72)
for y in years:
print(f" {y:>6d}" + "".join(f"{py[n].get(y, float('nan')):>+12.0f}" if y in py[n] else f"{'-':>12s}" for n in names))
print(" " + "-" * 72)
print(f" {'TOT%':>6s}" + "".join(f"{(cols[n].iloc[-1]/cols[n].iloc[0]-1)*100:>+12.0f}" for n in names))
print(f" {'DDfull':>6s}" + "".join(f"{_dd(cols[n].values):>12.0f}" for n in names))
+96
View File
@@ -0,0 +1,96 @@
"""Strategia #3 candidata: ROTAZIONE cross-sectional momentum (multi-crypto).
Una sola strategia che usa l'INTERO paniere: ad ogni ribilanciamento alloca il
capitale agli asset con momentum migliore (long-only). Cattura la dispersione tra
crypto (gli alt forti corrono molto piu' di BTC nei bull) senza shortare nulla.
Onesto: i pesi a close[i] usano solo rendimenti passati; il rendimento del bar
i->i+1 e' realizzato con quei pesi. Fee sul turnover. Allineamento per timestamp.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import get_df, available_assets, FEE_RT # noqa: E402
LEV = 3.0
GROSS = 0.45 # esposizione lorda = LEV*POS del singolo (0.15*3) per confronto equo
def build_panel(assets: list[str], tf: str) -> pd.DataFrame:
"""Matrice close allineata per timestamp (inner join)."""
closes = {}
for a in assets:
df = get_df(a, tf)
s = pd.Series(df["close"].values,
index=pd.to_datetime(df["timestamp"], unit="ms", utc=True))
closes[a] = s[~s.index.duplicated()]
panel = pd.DataFrame(closes).dropna()
return panel
def simulate_rotation(panel: pd.DataFrame, lookback=30, top_k=2,
fee_rt=FEE_RT, gross=GROSS, abs_filter=True,
oos_frac=0.0) -> dict:
"""Ad ogni barra: ranking per rendimento passato `lookback`; pesi uguali sui
top_k con momentum>0 (se abs_filter); altrimenti cash. gross = esposizione tot.
oos_frac>0: parte a investire solo dall'ultimo frac del campione."""
P = panel.values
T, N = P.shape
rets = np.zeros_like(P)
rets[1:] = P[1:] / P[:-1] - 1
years = panel.index.year.values
start = max(lookback + 1, int(T * (1 - oos_frac)) if oos_frac else lookback + 1)
cap = peak = 1000.0
max_dd = 0.0
w = np.zeros(N)
yearly: dict[int, float] = {}
turn_total = 0.0
for i in range(start, T - 1):
mom = P[i] / P[i - lookback] - 1
order = np.argsort(mom)[::-1]
new_w = np.zeros(N)
chosen = [j for j in order if (mom[j] > 0 or not abs_filter)][:top_k]
if chosen:
for j in chosen:
new_w[j] = gross / len(chosen)
# fee sul turnover (one-way = fee_rt/2 su ogni variazione di peso)
turnover = np.abs(new_w - w).sum()
cap -= cap * turnover * (fee_rt / 2)
turn_total += turnover
w = new_w
port_ret = float(np.dot(w, rets[i + 1])) # rendimento bar i->i+1
cap = max(cap * (1 + port_ret), 10.0)
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
yearly[years[i]] = yearly.get(years[i], 0.0) + port_ret * 100
return {
"ret": (cap / 1000 - 1) * 100,
"dd": max_dd * 100,
"turnover": turn_total,
"yearly": yearly,
"pos_years": sum(1 for v in yearly.values() if v > 0),
"n_years": len(yearly),
}
if __name__ == "__main__":
assets = available_assets()
print(f"ROTATION cross-sectional momentum — fee {FEE_RT*100:.2f}% RT, gross {GROSS} | OOS 30%")
print(f" Paniere: {assets}")
for tf in ["1d", "4h"]:
panel = build_panel(assets, tf)
print(f"\n === {tf} === panel {panel.shape[0]} barre, {panel.index[0].date()} -> {panel.index[-1].date()}")
print(f" {'config':<22s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Turn':>7s}{'AnniP':>8s}")
for lb in [20, 30, 60, 90]:
for k in [1, 2, 3]:
full = simulate_rotation(panel, lookback=lb, top_k=k)
oos = simulate_rotation(panel, lookback=lb, top_k=k, oos_frac=0.30)
anni = f"{full['pos_years']}/{full['n_years']}"
print(f" lb{lb:<3d} top{k:<14d}{full['ret']:>+9.0f}{oos['ret']:>+9.0f}"
f"{full['dd']:>6.0f}{full['turnover']:>7.0f}{anni:>8s}")
+109
View File
@@ -0,0 +1,109 @@
"""Strategia #3 candidata: time-series momentum / trend (TSMOM).
Posizione continua decisa a close[i] dai dati passati; fee SOLO sui cambi di
posizione (poche operazioni su TF alto = fee non letali). Niente look-ahead:
il rendimento del bar i->i+1 usa la direzione decisa a close[i].
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import ema, get_df, available_assets, FEE_RT # noqa: E402
LEV = 3.0
POS = 0.15
def simulate_position(sig: np.ndarray, df: pd.DataFrame, fee_rt: float = FEE_RT,
lev: float = LEV, pos: float = POS) -> dict:
"""sig[i] in {-1,0,1} = direzione tenuta nel bar i->i+1, decisa a close[i].
Fee one-way = fee_rt/2 su ogni unita' di variazione posizione."""
c = df["close"].values
n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
cap = peak = 1000.0
max_dd = 0.0
cur = 0.0
flips = 0
bars_in = 0
yearly: dict[int, float] = {}
for i in range(n - 1):
s = sig[i]
if not np.isfinite(s):
s = 0.0
if s != cur:
cap -= cap * pos * (fee_rt / 2) * lev * abs(s - cur)
flips += abs(s - cur) > 0
cur = s
pr = (c[i + 1] - c[i]) / c[i]
bar_ret = pos * lev * pr * cur
cap = max(cap * (1 + bar_ret), 10.0)
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
if cur != 0:
bars_in += 1
yr = ts.iloc[i].year
yearly[yr] = yearly.get(yr, 0.0) + bar_ret * 100
return {
"ret": (cap / 1000 - 1) * 100,
"dd": max_dd * 100,
"flips": flips,
"exposure": bars_in / n * 100,
"yearly": yearly,
"pos_years": sum(1 for v in yearly.values() if v > 0),
"n_years": len(yearly),
}
def tsmom_signal(df, lookback=30, long_only=False):
"""+1 se close>close[-lookback], -1 (o 0 se long_only) altrimenti."""
c = df["close"].values
sig = np.zeros(len(c))
for i in range(lookback, len(c)):
up = c[i] > c[i - lookback]
sig[i] = 1.0 if up else (0.0 if long_only else -1.0)
return sig
def ema_dual_signal(df, fast=20, slow=100, long_only=False):
"""+1 se EMA_fast>EMA_slow."""
c = df["close"].values
ef, es = ema(c, fast), ema(c, slow)
sig = np.where(ef > es, 1.0, 0.0 if long_only else -1.0)
sig[:slow] = 0.0
return sig
def oos(sig, df, frac=0.30):
split = int(len(df) * (1 - frac))
s2 = sig.copy(); s2[:split] = 0.0
return simulate_position(s2, df)
def show(label, df, sig):
full = simulate_position(sig, df)
o = oos(sig, df)
anni = f"{full['pos_years']}/{full['n_years']}"
print(f" {label:<26s}{full['flips']:>6d}{full['ret']:>+9.0f}{o['ret']:>+9.0f}"
f"{full['dd']:>6.0f}{full['exposure']:>6.0f}{anni:>8s}")
return full, o
if __name__ == "__main__":
assets = available_assets()
print(f"TSMOM / trend — fee {FEE_RT*100:.2f}% RT, leva3x pos15% | OOS30%")
for tf in ["1d", "4h"]:
print(f"\n ###### TF {tf} ######")
for a in assets:
df = get_df(a, tf)
print(f"\n === {a} {tf} === {'Flip':>5s}{'FULL%':>8s}{'OOS%':>8s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}")
show("TSMOM lb30 long/short", df, tsmom_signal(df, 30))
show("TSMOM lb30 long-only", df, tsmom_signal(df, 30, long_only=True))
show("TSMOM lb90 long/short", df, tsmom_signal(df, 90))
show("EMA 20/100 long/short", df, ema_dual_signal(df, 20, 100))
show("EMA 20/100 long-only", df, ema_dual_signal(df, 20, 100, long_only=True))
+188
View File
@@ -0,0 +1,188 @@
"""Report PER ANNO (Trade, Acc%, DD%, PnL%) delle 3 strategie oneste.
Acc: DIP01/TR01 = win-rate dei trade chiusi (episodi); ROT01 = % giorni positivi.
DD : drawdown massimo dell'equity DENTRO l'anno solare.
PnL: variazione % dell'equity nell'anno (composta).
Tutto NETTO (fee 0.10% RT, leva 3x, pos 15%). Replica gli engine di honest_*.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import atr, ema, get_df, available_assets, FEE_RT
from scripts.analysis.honest_final import dip_entries
from scripts.analysis.honest_rotation import build_panel
LEV, POS = 3.0, 0.15
def _yearly_dd(years: np.ndarray, equity: np.ndarray) -> dict[int, float]:
"""DD massimo intra-anno da una serie di equity etichettata per anno."""
out: dict[int, float] = {}
for y in np.unique(years):
eq = equity[years == y]
peak = eq[0]; dd = 0.0
for v in eq:
peak = max(peak, v)
dd = max(dd, (peak - v) / peak if peak > 0 else 0.0)
out[int(y)] = dd * 100
return out
def _print(title, header, rows):
print("\n" + "=" * 78)
print(f" {title}")
print("=" * 78)
print(" " + header)
print(" " + "-" * 74)
for r in rows:
print(" " + r)
# --------------------------- DIP01 (trade-based) ---------------------------
def dip_yearly(asset, tf="1h"):
df = get_df(asset, tf)
ents = dip_entries(df)
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
fee = FEE_RT * LEV
cap = 1000.0
last_exit = -1
eq_y, eq_v = [], []
yt: dict[int, list] = {} # year -> [trades, wins, pnl_start_cap, pnl_end_cap]
for e in ents:
i, d = e["i"], e["d"]
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]; tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1)
for k in range(1, mb + 1):
j = i + k
if j >= n:
j = n - 1; exit_p = c[j]; break
if (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl):
exit_p = sl; break
if (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp):
exit_p = tp; break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * d * LEV - fee
cap = max(cap + cap * POS * ret, 10.0)
last_exit = j
y = ts.iloc[i].year
rec = yt.setdefault(y, [0, 0, None, None])
rec[0] += 1; rec[1] += ret > 0
eq_y.append(y); eq_v.append(cap)
dd = _yearly_dd(np.array(eq_y), np.array(eq_v))
# PnL% anno: da equity prima/dopo
rows = []
prev = 1000.0
yrs = sorted(yt)
cum = {}
cprev = 1000.0
# ricostruisci equity di fine anno
end_cap = {}
for y, v in zip(eq_y, eq_v):
end_cap[y] = v
for y in yrs:
t, w = yt[y][0], yt[y][1]
ec = end_cap[y]
pnl = (ec / cprev - 1) * 100
cprev = ec
rows.append(f"{y:>6d}{t:>8d}{(w/t*100 if t else 0):>8.1f}{dd.get(y,0):>8.1f}{pnl:>+10.1f}")
return rows
# --------------------------- TR01 (position episodes) ---------------------------
def tr_yearly(asset, tf="4h", fast=20, slow=100):
df = get_df(asset, tf)
c = df["close"].values; n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ef, es = ema(c, fast), ema(c, slow)
sig = np.where(ef > es, 1.0, 0.0); sig[:slow] = 0.0
cap = 1000.0; cur = 0.0
fee = FEE_RT / 2 * LEV
ep_start_cap = None; ep_year = None
yt: dict[int, list] = {}
eq_y, eq_v = [], []
for i in range(n - 1):
s = sig[i]
if s != cur:
cap -= cap * POS * fee * abs(s - cur)
if s == 1.0: # apertura long
ep_start_cap = cap; ep_year = ts.iloc[i].year
elif cur == 1.0 and ep_start_cap is not None: # chiusura long
rec = yt.setdefault(ep_year, [0, 0])
rec[0] += 1; rec[1] += cap > ep_start_cap
ep_start_cap = None
cur = s
pr = (c[i + 1] - c[i]) / c[i]
cap = max(cap * (1 + POS * LEV * pr * cur), 10.0)
eq_y.append(ts.iloc[i].year); eq_v.append(cap)
if cur == 1.0 and ep_start_cap is not None:
rec = yt.setdefault(ep_year, [0, 0]); rec[0] += 1; rec[1] += cap > ep_start_cap
dd = _yearly_dd(np.array(eq_y), np.array(eq_v))
end_cap = {}
for y, v in zip(eq_y, eq_v):
end_cap[y] = v
rows = []; cprev = 1000.0
for y in sorted(end_cap):
t, w = yt.get(y, [0, 0])
pnl = (end_cap[y] / cprev - 1) * 100; cprev = end_cap[y]
rows.append(f"{y:>6d}{t:>8d}{(w/t*100 if t else 0):>8.1f}{dd.get(y,0):>8.1f}{pnl:>+10.1f}")
return rows
# --------------------------- ROT01 (daily portfolio) ---------------------------
def rot_yearly(lookback=60, top_k=2, gross=0.45):
panel = build_panel(available_assets(), "1d")
P = panel.values; T, N = P.shape
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
years = panel.index.year.values
cap = 1000.0; w = np.zeros(N)
yt: dict[int, list] = {} # year -> [rebal, pos_days, days]
eq_y, eq_v = [], []
for i in range(lookback + 1, T - 1):
mom = P[i] / P[i - lookback] - 1
order = np.argsort(mom)[::-1]
chosen = [j for j in order if mom[j] > 0][:top_k]
new_w = np.zeros(N)
for j in chosen:
new_w[j] = gross / len(chosen)
turnover = np.abs(new_w - w).sum()
if turnover > 1e-9:
cap -= cap * turnover * (FEE_RT / 2)
w = new_w
pr = float(np.dot(w, rets[i + 1]))
cap = max(cap * (1 + pr), 10.0)
y = int(years[i])
rec = yt.setdefault(y, [0, 0, 0])
rec[0] += turnover > 1e-9; rec[1] += pr > 0; rec[2] += 1
eq_y.append(y); eq_v.append(cap)
dd = _yearly_dd(np.array(eq_y), np.array(eq_v))
end_cap = {}
for y, v in zip(eq_y, eq_v):
end_cap[y] = v
rows = []; cprev = 1000.0
for y in sorted(end_cap):
reb, pos, days = yt[y]
pnl = (end_cap[y] / cprev - 1) * 100; cprev = end_cap[y]
rows.append(f"{y:>6d}{reb:>8d}{(pos/days*100 if days else 0):>8.1f}{dd.get(y,0):>8.1f}{pnl:>+10.1f}")
return rows
if __name__ == "__main__":
H = f"{'Anno':>6s}{'Trade':>8s}{'Acc%':>8s}{'DD%':>8s}{'PnL%':>10s}"
for a in ["BTC", "ETH", "SOL"]:
_print(f"DIP01 — {a} 1h (Acc = win-rate trade)", H, dip_yearly(a))
for a in ["BNB", "BTC", "DOGE", "SOL", "XRP"]:
_print(f"TR01 — {a} 4h (Trade = episodi long, Acc = win-rate episodi)", H, tr_yearly(a))
_print("ROT01 — paniere 8 crypto 1d (Trade = ribilanciamenti, Acc = % giorni positivi)",
H, rot_yearly())
+74
View File
@@ -0,0 +1,74 @@
"""Tabella per-anno (PnL% e DD% intra-anno) delle versioni MIGLIORATE:
ROT02 (dual-momentum), le 3 sleeve e il PORTAFOGLIO combinato.
Tutto NETTO. Riusa gli engine di honest_improve / honest_improve2.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_improve2 import ( # noqa: E402
dip_market_gated, _daily_equity, _norm, _tr_basket_daily, _rot_daily_equity,
)
def _year_dd(eq: pd.Series) -> dict[int, float]:
out = {}
for y, g in eq.groupby(eq.index.year):
peak = g.iloc[0]; dd = 0.0
for v in g:
peak = max(peak, v); dd = max(dd, (peak - v) / peak if peak > 0 else 0.0)
out[int(y)] = dd * 100
return out
def _year_pnl(eq: pd.Series) -> dict[int, float]:
out = {}
for y, g in eq.groupby(eq.index.year):
out[int(y)] = (g.iloc[-1] / g.iloc[0] - 1) * 100
return out
def table(name, eq):
eq = _norm(eq)
dd = _year_dd(eq); pnl = _year_pnl(eq)
print(f"\n {name}")
print(f" {'Anno':>6s}{'PnL%':>9s}{'DD%':>7s}")
print(" " + "-" * 22)
for y in sorted(pnl):
print(f" {y:>6d}{pnl[y]:>+9.0f}{dd[y]:>7.0f}")
tot = (eq.iloc[-1] / eq.iloc[0] - 1) * 100
print(f" {'TOT':>6s}{tot:>+9.0f}{_year_dd(eq) and max(_year_dd(eq).values()):>7.0f}(max anno)")
if __name__ == "__main__":
print("=" * 60)
print(" RISULTATI PER ANNO — versioni migliorate (NETTO)")
print("=" * 60)
# ROT02 dal 2020 (dati paniere)
idx_rot = pd.date_range("2020-09-01", "2026-05-26", freq="1D", tz="UTC")
eq_rot = _rot_daily_equity(idx_rot)
table("ROT02 — dual-momentum rotation (1d)", eq_rot)
# sleeve + portafoglio dal 2021
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
d = dip_market_gated("BTC", market_n=0, return_equity=True)
eq_dip = _norm(_daily_equity(d["eq_ts"], d["eq_v"], idx))
eq_tr = _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], idx))
eq_r2 = _norm(_rot_daily_equity(idx))
table("Sleeve DIP01 — BTC (1h)", eq_dip)
table("Sleeve TR01 — basket (4h)", eq_tr)
table("Sleeve ROT02 (1d)", eq_r2)
drets = pd.DataFrame({"DIP": eq_dip.pct_change().fillna(0),
"TR": eq_tr.pct_change().fillna(0),
"ROT": eq_r2.pct_change().fillna(0)})
combo = (1 + drets.mean(axis=1)).cumprod()
table("PORTAFOGLIO equal-weight (daily rebal)", combo)
+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()
+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()
+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()
+152
View File
@@ -0,0 +1,152 @@
"""DIP01 — Dip-Buy Z-Score Reversion (long-only).
Variante robusta e ONESTA della famiglia mean-reversion: compra SOLO i dip
(close a z<=-z_in deviazioni sotto la media mobile) e prende profitto al rientro
verso la media. Niente short: nel campione 2018-2026 shortare cripto perde OOS
sistematicamente (vedi scripts/analysis/honest_final.py).
Logica:
1. z-score = (close - SMA(n)) / STD(n)
2. ENTRY long quando z attraversa al ribasso -z_in (capitolazione)
3. EXIT: take-profit alla media mobile, stop-loss a sl_atr*ATR sotto l'entry,
o time-limit max_bars
4. ingresso a close[i] (eseguibile dal vivo, nessun look-ahead)
Validazione (netto, fee 0.10% RT Deribit, leva 3x, OOS = ultimo 30%):
BTC 1h: FULL +298% / OOS +59% / DD 23% / 7-9 anni positivi
ETH 1h: FULL +190% / OOS +224% / DD 54%
SOL 1h: FULL +50% / OOS +13% / DD 25%
Regge lo sweep fee fino a 0.20% RT (BTC OOS +45% anche a 0.20%).
Robusto su BTC/ETH/SOL (asset major); sugli alt molto parabolici (DOGE/BNB)
non ha edge -> usare solo su BTC/ETH/SOL.
Compatibile con StrategyWorker: ogni Signal porta tp/sl/max_bars in metadata.
"""
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 DipReversion(Strategy):
name = "DIP01_dip_reversion"
description = "Long-only dip-buy z-score reversion, TP alla media"
default_assets = ["BTC", "ETH", "SOL"]
default_timeframes = ["1h"]
fee_rt = 0.001
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 = params.get("n", 50)
z_in = params.get("z_in", 2.5)
sl_atr = params.get("sl_atr", 2.5)
max_bars = params.get("max_bars", 24)
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = _atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
signals: list[Signal] = []
for i in range(n + 14, len(c)):
if np.isnan(z[i]) or np.isnan(a[i]):
continue
if z[i] <= -z_in and z[i - 1] > -z_in:
signals.append(Signal(
idx=i, direction=1, entry_price=c[i],
metadata={"tp": float(ma[i]), "sl": float(c[i] - sl_atr * a[i]),
"max_bars": max_bars},
))
return signals
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:
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 = DipReversion()
print(f"{'=' * 100}")
print(f" DIP01 DIP-BUY REVERSION — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
print(f"{'=' * 100}")
for asset in ["BTC", "ETH", "SOL"]:
r = strat.backtest(asset, "1h", n=50, z_in=2.5, sl_atr=2.5, max_bars=24)
if r:
r.strategy_name = f"DIP01 {asset} 1h"
r.print_summary()
+167
View File
@@ -0,0 +1,167 @@
"""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)
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
signals: list[Signal] = []
for i in range(bb_w + 14, n_len):
if np.isnan(up[i]) or np.isnan(a[i]):
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()
+65
View File
@@ -0,0 +1,65 @@
"""PORT01 — Portafoglio combinato delle 3 strategie oneste (equal-weight, daily rebal).
Sleeve (meccanismi anti-correlati):
DIP01 dip-buy reversion su BTC (1h) regime: reversione
TR01 EMA 20/100 trend su paniere (4h) regime: momentum singolo
ROT02 dual-momentum rotation (1d) regime: forza relativa + risk-off
La diversificazione e' il vero motore di risk-reduction: il DD del portafoglio
scende SOTTO quello della sleeve meno rischiosa, mantenendo una CAGR alta e
azzerando quasi gli anni negativi (il 2022 bear passa da -30% di ROT a -1%).
Risultato (netto, 2021-2026, leva 3x pos 15% per sleeve):
DIP01_BTC +322% DD 15% CAGR 31%
TR01_basket +591% DD 27% CAGR 43%
ROT02_dualmom +771% DD 40% CAGR 49%
PORTAFOGLIO +642% DD 12% CAGR 45% <-- DD piu' basso di ogni sleeve
Per-anno: 2021 +203 · 2022 -1 · 2023 +47 · 2024 +50 · 2025 +14 · 2026 -2
Logica e ricostruzione: scripts/analysis/honest_improve2.py.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_improve import _dd # noqa: E402
from scripts.analysis.honest_improve2 import ( # noqa: E402
dip_market_gated, _daily_equity, _norm, _tr_basket_daily, _rot_daily_equity,
)
def run():
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
d = dip_market_gated("BTC", market_n=0, return_equity=True)
members = {
"DIP01_BTC": _norm(_daily_equity(d["eq_ts"], d["eq_v"], idx)),
"TR01_basket": _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], idx)),
"ROT02_dualmom": _norm(_rot_daily_equity(idx)),
}
drets = pd.DataFrame({k: v.pct_change().fillna(0) for k, v in members.items()})
port_ret = drets.mean(axis=1)
combo = (1 + port_ret).cumprod()
yrs = (idx[-1] - idx[0]).days / 365.25
print("=" * 80)
print(f" PORT01 — portafoglio equal-weight (daily rebal) | {idx[0].date()} -> {idx[-1].date()}")
print("=" * 80)
print(f" {'sleeve':<16s}{'ret%':>9s}{'DD%':>7s}{'CAGR%':>8s}")
for name, s in members.items():
r = (s.iloc[-1] / s.iloc[0] - 1) * 100
cagr = ((s.iloc[-1] / s.iloc[0]) ** (1 / yrs) - 1) * 100
print(f" {name:<16s}{r:>+9.0f}{_dd(s.values):>7.0f}{cagr:>8.0f}")
r = (combo.iloc[-1] / combo.iloc[0] - 1) * 100
cagr = ((combo.iloc[-1] / combo.iloc[0]) ** (1 / yrs) - 1) * 100
print(f" {'PORTAFOGLIO':<16s}{r:>+9.0f}{_dd(combo.values):>7.0f}{cagr:>8.0f}")
pa = port_ret.groupby(port_ret.index.year).apply(lambda x: ((1 + x).prod() - 1) * 100)
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
if __name__ == "__main__":
run()
@@ -0,0 +1,48 @@
"""ROT01 — Cross-Sectional Momentum Rotation (multi-crypto, long-only), 1d.
UNA strategia che usa l'INTERO paniere di crypto in un solo book: ogni giorno
ordina gli asset per momentum (rendimento sugli ultimi `lookback` giorni) e alloca
il capitale in parti uguali ai `top_k` con momentum positivo; il resto in cash.
Cattura la dispersione tra crypto (gli alt forti corrono molto piu' di BTC nei bull)
senza shortare nulla. Meccanismo distinto da DIP01/TR01 -> vera diversificazione.
Onesto: i pesi a close[i] usano solo rendimenti passati; il rendimento del giorno
i->i+1 e' realizzato con quei pesi. Fee sul turnover. Allineamento per timestamp.
Validazione (netto, fee 0.10% RT, gross 0.45, OOS = ultimo 30%):
lb=60 top2 -> FULL +679% / OOS +44% / DD 53% / 5-7 anni positivi.
Param-insensitive (tutte le lb/k positive) e regge fee fino 0.20% RT (OOS +41%).
Per-anno: 2020+33 2021+181 2022-29 2023+43 2024+59 2025+6 2026-10 (i negativi = bear).
Dettagli in scripts/analysis/honest_rotation.py / honest_final.py.
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_rotation import build_panel, simulate_rotation # noqa: E402
from scripts.analysis.honest_lab import available_assets
LOOKBACK, TOP_K, TF = 60, 2, "1d"
def run():
assets = available_assets()
panel = build_panel(assets, TF)
print("=" * 90)
print(f" ROT01 ROTAZIONE cross-sectional momentum | {TF} lb={LOOKBACK} top{TOP_K} | netto fee 0.10% RT")
print("=" * 90)
print(f" Paniere: {list(panel.columns)}")
print(f" Periodo: {panel.index[0].date()} -> {panel.index[-1].date()} ({panel.shape[0]} barre)")
full = simulate_rotation(panel, lookback=LOOKBACK, top_k=TOP_K, fee_rt=0.001)
oos = simulate_rotation(panel, lookback=LOOKBACK, top_k=TOP_K, fee_rt=0.001, oos_frac=0.30)
print(f"\n FULL: {full['ret']:+.0f}% DD {full['dd']:.0f}% turnover {full['turnover']:.0f}")
print(f" OOS : {oos['ret']:+.0f}% DD {oos['dd']:.0f}% ({full['pos_years']}/{full['n_years']} anni positivi)")
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(full["yearly"].items())))
if __name__ == "__main__":
run()
+40
View File
@@ -0,0 +1,40 @@
"""ROT02 — Dual-Momentum Rotation (ROT01 + overlay di absolute momentum).
Evoluzione di ROT01: alla rotazione cross-sectional (forza relativa) aggiunge un
overlay di ABSOLUTE momentum sul mercato: se BTC e' sotto la sua media a `regime_n`
giorni (mercato risk-off), va completamente in CASH. Cosi' si evitano i bear di
sistema (2022, 2026 YTD) che erano gli unici anni rossi di ROT01.
Risultato (netto, fee 0.10% RT, gross 0.45, OOS = ultimo 30%): MIGLIORA TUTTO
rispetto a ROT01.
ROT01 base : FULL +679% / OOS +44% / DD 53%
ROT02 SMA100 : FULL +1095% / OOS +98% / DD 40% <-- PnL su, DD giu'
Param-insensitive sulla finestra di regime (SMA100-150). Dettagli in
scripts/analysis/honest_improve.py (rot_improved).
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_improve import rot_improved # noqa: E402
LOOKBACK, TOP_K, REGIME_N = 60, 2, 100
def run():
print("=" * 90)
print(f" ROT02 DUAL-MOMENTUM | 1d lb={LOOKBACK} top{TOP_K} + cash se BTC<SMA{REGIME_N} | netto fee 0.10% RT")
print("=" * 90)
full = rot_improved(lookback=LOOKBACK, top_k=TOP_K, regime_n=REGIME_N)
oos = rot_improved(lookback=LOOKBACK, top_k=TOP_K, regime_n=REGIME_N, oos_frac=0.30)
print(f" FULL: {full['ret']:+.0f}% DD {full['dd']:.0f}% ({full['pos_years']}/{full['n_years']} anni positivi)")
print(f" OOS : {oos['ret']:+.0f}% DD {oos['dd']:.0f}%")
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(full["yearly"].items())))
if __name__ == "__main__":
run()
+50
View File
@@ -0,0 +1,50 @@
"""TR01 — EMA Trend Following (long-only), timeframe 4h.
Cavalca i trend rialzisti, si mette in cash nei downtrend. Niente short
(shortare cripto perde OOS nel campione 2018-2026). Complementare a DIP01:
DIP01 guadagna nei regimi di reversione, TR01 nei regimi di trend.
Logica:
1. EMA fast (20) e EMA slow (100) sul close
2. LONG quando EMA_fast > EMA_slow (uptrend), altrimenti CASH
3. posizione continua, decisione a close[i] (no look-ahead);
fee solo sui cambi di stato (poche operazioni = fee non letali)
Validazione (netto, fee 0.10% RT, leva 3x, pos 15%, OOS = ultimo 30%):
robusto FULL+OOS su 5/8 asset: BNB(+14), BTC(+27), DOGE(+53), SOL(+7), XRP(+29) OOS.
ETH ~flat, ADA/LTC negativi OOS -> preferire BNB/BTC/DOGE/SOL/XRP.
Dettagli in scripts/analysis/honest_final.py / honest_trend.py.
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_trend import ( # noqa: E402
simulate_position, ema_dual_signal, oos as trend_oos,
)
from scripts.analysis.honest_lab import get_df
ASSETS = ["BNB", "BTC", "DOGE", "SOL", "XRP"]
FAST, SLOW, TF = 20, 100, "4h"
def run():
print("=" * 90)
print(f" TR01 EMA TREND {FAST}/{SLOW} long-only | {TF} | netto fee 0.10% RT leva 3x pos 15%")
print("=" * 90)
print(f" {'Asset':<6s}{'Flip':>6s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniPos':>9s}")
for a in ASSETS:
df = get_df(a, TF)
sig = ema_dual_signal(df, FAST, SLOW, long_only=True)
f = simulate_position(sig, df)
o = trend_oos(sig, df)
print(f" {a:<6s}{f['flips']:>6d}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
f"{f['dd']:>6.0f}{f['exposure']:>6.0f}{str(f['pos_years'])+'/'+str(f['n_years']):>9s}")
if __name__ == "__main__":
run()
+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%")
+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%")
+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()
+58 -6
View File
@@ -112,6 +112,54 @@ class SignalEngine:
self.squeeze_start_idx = 0 self.squeeze_start_idx = 0
self.trained = False 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: def train(self, df: pd.DataFrame, lookahead: int = 3) -> dict:
"""Addestra il modello su dati storici.""" """Addestra il modello su dati storici."""
close = df["close"].values close = df["close"].values
@@ -154,20 +202,24 @@ class SignalEngine:
X = np.array(X_all) X = np.array(X_all)
y = np.array(y_all) y = np.array(y_all)
oos = self._validate_oos(X, y)
self.scaler = StandardScaler() self.scaler = StandardScaler()
X_s = self.scaler.fit_transform(X) X_s = self.scaler.fit_transform(X)
self.model = GradientBoostingClassifier( self.model = self._new_model()
n_estimators=150, max_depth=4, min_samples_leaf=10,
learning_rate=0.05, subsample=0.8, random_state=42,
)
self.model.fit(X_s, y) self.model.fit(X_s, y)
self.trained = True self.trained = True
preds = self.model.predict(X_s) 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: def check_signal(self, df: pd.DataFrame) -> dict | None:
"""Controlla se c'è un segnale sulle ultime candele. """Controlla se c'è un segnale sulle ultime candele.
+51
View File
@@ -0,0 +1,51 @@
"""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"),
}
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}")
+34
View File
@@ -0,0 +1,34 @@
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
# 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
Generated
+57
View File
@@ -2057,6 +2057,7 @@ dependencies = [
{ name = "numpy" }, { name = "numpy" },
{ name = "pandas" }, { name = "pandas" },
{ name = "pyarrow" }, { name = "pyarrow" },
{ name = "pyyaml" },
{ name = "requests" }, { name = "requests" },
{ name = "scikit-learn" }, { name = "scikit-learn" },
{ name = "scipy" }, { name = "scipy" },
@@ -2081,6 +2082,7 @@ requires-dist = [
{ name = "pyarrow", specifier = ">=15.0" }, { name = "pyarrow", specifier = ">=15.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "requests", specifier = ">=2.31" }, { name = "requests", specifier = ">=2.31" },
{ name = "scikit-learn", specifier = ">=1.3" }, { name = "scikit-learn", specifier = ">=1.3" },
{ name = "scipy", specifier = ">=1.11" }, { 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" }, { 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]] [[package]]
name = "requests" name = "requests"
version = "2.34.2" version = "2.34.2"