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
*.pth
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+
- **Package manager:** uv (dipendenze in `pyproject.toml`, lock in `uv.lock`)
- **Dati:** Parquet in `data/raw/` (non committati, ~70 MB)
- **ML:** scikit-learn (GradientBoosting), PyTorch (LSTM)
- **ML:** scikit-learn (GradientBoostingClassifier)
- **Analisi:** numpy, pandas, scipy
- **API dati:** Cerbero MCP su `cerbero-mcp.tielogic.xyz` (Deribit, Bybit, Hyperliquid), ccxt/Binance come fallback
- **Config:** pyyaml per `strategies.yml`
## Struttura
@@ -22,12 +23,22 @@ src/backtest/ → engine di backtesting (engine.py)
src/strategies/ → classe base Strategy ABC + indicatori condivisi
base.py → Strategy, Signal, BacktestResult, YearlyStats
indicators.py → keltner_ratio, detect_squeezes, ema, atr, rv, correlation
scripts/strategies/ → strategie attive (SQ01-SQ04, ML01)
scripts/waste/ → strategie scartate (W01-W22 + REF originali)
scripts/analysis/ → script di confronto e report
src/live/ → paper trading live multi-strategia
multi_runner.py → orchestratore: carica YAML, fetch candele, tick worker
strategy_worker.py → worker indipendente: capital, trade log, stato persistente.
Exit guidati da strategia (TP/SL/max_bars via Signal.metadata),
fallback hold_bars/stop -2%. Usa fee_rt della strategia.
strategy_loader.py → import dinamico classi Strategy da scripts/strategies/
cerbero_client.py → client HTTP per Cerbero MCP (Deribit testnet)
signal_engine.py → squeeze + ML real-time (legacy ML01, ora in waste) + validazione OOS
telegram_notifier.py → notifiche Telegram per trade
scripts/strategies/ → strategie con edge validato OOS (solo MR01_bollinger_fade)
scripts/waste/ → strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD)
scripts/analysis/ → ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...)
strategies.yml → config multi-strategy paper trader
docs/diary/ → diario di ricerca giornaliero
docs/specs/ → specifiche di design
data/raw/ → file .parquet OHLCV (gitignored)
data/processed/ → modelli salvati (gitignored)
```
## Comandi
@@ -35,8 +46,12 @@ data/processed/ → modelli salvati (gitignored)
```bash
uv sync # installa dipendenze
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/ML01_squeeze_gbm.py # squeeze + ML (GBM)
uv run python scripts/strategies/MR01_bollinger_fade.py # strategia attiva (mean-reversion)
uv run python scripts/analysis/strategy_research.py # ricerca strategie fee-aware OOS
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
uv run python scripts/analysis/validate_worker_mr01.py # replay worker reale su MR01
uv run python -m src.live.multi_runner # paper trading live multi-strategia
docker compose up -d # deploy Docker
uv run pytest # test
```
@@ -53,36 +68,53 @@ df = load_data("ETH", "15m") # carica un asset/timeframe
Fonte primaria: Cerbero MCP (endpoint `/mcp-deribit/tools/get_historical`).
Token observer: nel file `secrets/observer.token` del progetto CerberoSuite.
## Strategia vincente
**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
| Codice | Nome | Tipo | Accuracy | Note |
|--------|------|------|----------|------|
| SQ01 | Squeeze Base | Regole | 76.7% | Squeeze breakout puro, baseline |
| SQ02 | Antifake+Vol | Regole | 79.7% | **Miglior robusto** — 9 anni, Sharpe 5.01 |
| SQ03 | All Filters | Regole | 79.2% | Cross-asset + timing + long squeeze |
| SQ04 | Ultimate | Regole | 81.6% | Max accuracy ma concentrato 2018 |
| ML01 | Squeeze+GBM | ML | 78.8% | Walk-forward, €12/day, DD basso |
> **LEZIONE CRITICA (2026-05-28).** L'intera famiglia squeeze-breakout (SQ01-04,
> MT01, ML01, AD01, CM01, PD01) è stata **scartata in `scripts/waste/`**: le
> accuratezze storiche 76-82% erano un **artefatto di look-ahead**. Quei backtest
> decidono la direzione con `sign(close[i]-close[i-1])` (la candela di breakout `i`)
> ma entrano a `close[i-1]` — cioè comprano *prima* della candela che usano per
> scegliere la direzione. Dal vivo il worker scopre il breakout solo a `close[i]`
> ed entra lì: l'edge sparisce (win-rate ~47%, lancio di moneta). Sotto ingresso
> onesto e fee reali **tutte perdono, anche a fee zero**. Inoltre i breakout
> *rientrano* (mean-reversion > continuation). Vedi `scripts/analysis/oos_validation.py`
> e `intrabar_test.py`.
Tutte le strategie estendono `src.strategies.base.Strategy` con interfaccia comune:
`generate_signals() → backtest() → report()`.
Tutte le strategie estendono `src.strategies.base.Strategy`
(`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
- Strategie in `scripts/strategies/` con codice univoco (SQ01, ML01, ...).
- Script scartati in `scripts/waste/` con prefisso W01-W22.
- Strategie in `scripts/strategies/` con codice univoco (MR01, ...).
- Script scartati in `scripts/waste/` (W01-W28 + famiglia squeeze).
- Diario in `docs/diary/YYYY-MM-DD.md`. Aggiornare dopo ogni esperimento significativo.
- Nessun dato sensibile nei commit (token, chiavi API). Usare `.gitignore`.
- Verificare sempre assenza di data leakage prima di fidarsi dei risultati. In particolare: `returns[i-w : i]` include `close[i]` che è un candle nel futuro — usare `returns[i-w : i-1]`.
@@ -90,5 +122,8 @@ Tutte le strategie estendono `src.strategies.base.Strategy` con interfaccia comu
## Attenzione
- **Data leakage:** è stata trovata e corretta nello script 05. Ogni volta che si usano rendimenti logaritmici (`np.diff(np.log(close))`), ricordare che `returns[k]` usa `close[k+1]`. I feature devono fermarsi a `returns[i-2]` se il prezzo corrente è `close[i-1]`.
- **Fee:** sempre 0.1% per lato (0.2% round-trip). Includere nel backtest.
- **Fee:** Deribit perp reale = taker ~0.05%/lato (**0.10% round-trip**), maker ~0%. Usare 0.10% RT come baseline (lo 0.20% storico era pessimista 2x). Includere SEMPRE nel backtest: sono vincolo di prim'ordine, molte operazioni = morte per fee. Il worker usa `strategy.fee_rt` (MR01 = 0.001).
- **Leva:** testato con 3x. Aumentare a 5x migliora i rendimenti ma raddoppia il drawdown.
- **GBM:** GradientBoostingClassifier di scikit-learn. Ensemble di alberi decisionali sequenziali. Walk-forward per evitare leakage temporale.
- **Cerbero `get_historical` (fix 2026-05-28):** `end_date` come data nuda è inclusivo dell'intera giornata fino all'ultima candela chiusa (es. `end=oggi` arriva fino ad ora, non più a mezzanotte); accettati anche timestamp con orario (`...T14:00:00`, naive=UTC); nessun cap a ~5000 righe (paginazione interna). Il client passa già `end=oggi`, ora corretto. Prima del fix il paper trader restava a zero trade perché il feed era fermo a mezzanotte.
- **Dati ETH Deribit 15m:** 14-30%/anno di candele *flat* (O=H=L=C, volume 0, run fino a ~54h) per bassa liquidità del perpetuo. Verificato (2026-05-28): escluderle NON cambia i backtest (Δacc ≤0.5pp) → edge robusto. Resta un caveat operativo (slippage/fill in trading reale, irrilevante per paper). BTC pulito eccetto picco ~8% nel 2024.
+3 -1
View File
@@ -8,7 +8,9 @@ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY src/ src/
COPY scripts/strategies/ scripts/strategies/
COPY strategies.yml strategies.yml
VOLUME /app/data
CMD ["uv", "run", "python", "-m", "src.live.paper_trader"]
CMD ["uv", "run", "python", "-m", "src.live.multi_runner"]
+151 -60
View File
@@ -8,80 +8,189 @@ Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di
## Risultati
Tredici strategie testate su dati storici 20182026 (BTC e ETH, timeframe 5m / 15m / 1h). Le migliori cinque:
> ⚠️ **Revisione 2026-05-28.** La famiglia squeeze-breakout (SQ/MT/ML/AD/CM/PD, con
> accuracy storiche dichiarate 76-82%) è stata **scartata**: quei numeri erano un
> **artefatto di look-ahead**. I backtest decidevano la direzione dalla candela di
> breakout `close[i]` ma entravano a `close[i-1]` — impossibile dal vivo. Sotto
> ingresso onesto (`close[i]`) e fee reali, l'edge sparisce e tutte perdono, anche
> a fee zero. Dettagli e prove: `scripts/analysis/oos_validation.py`.
| # | Strategia | Accuracy | ROI annuo | Max DD | €/giorno |
|---|-----------|----------|-----------|--------|----------|
| 1 | ETH 15m Squeeze + ML ibrida | 76.9% | 118% | 4.2% | €13.78 |
| 2 | ETH 1h Squeeze + Vol | 83.9% | 22% | 2.0% | €0.71 |
| 3 | BTC 15m Squeeze + ML ibrida | 78.8% | 69% | 7.0% | €5.51 |
| 4 | ETH 1h Squeeze (BBw=30) | 82.8% | 47% | 3.2% | €1.77 |
| 5 | ETH Walk-Forward ML | 57.7% | 38% | 47% | €3.12 |
Dopo una validazione **out-of-sample, fee-aware** di tutte le famiglie, l'unica con
edge netto reale è il **mean-reversion** (i breakout *rientrano*, non continuano):
La strategia vincente (#1) opera su ETH a 15 minuti con ~1 trade al giorno, leva 3x e drawdown contenuto al 4.2%.
| Codice | Strategia | Mercato | Edge OOS netto | Max DD | Robustezza |
|--------|-----------|---------|----------------|--------|------------|
| **MR01** | Bollinger Fade (mean-reversion) | BTC 1h | **+196 / +201%** | 15% | ✅ |
| **MR01** | Bollinger Fade (mean-reversion) | ETH 1h | **+251%** | ~25% | ⚠️ DD alto |
Netto dopo **fee realistiche Deribit 0.10% RT** (taker), leva 3x, pos 15%, su finestra
held-out (nov 2023→mag 2026). MR01 è positivo su **tutta** la griglia parametri
(`n∈{14,20,30,50}` × `k∈{2.0,2.5,3.0}`) e per **ogni** livello di fee 0.00-0.20% RT —
margine di sicurezza ampio, niente parametro fortunato. Ri-validato col worker live reale.
## Come funziona
### Volatility Squeeze Breakout
### MR01 — Bollinger Fade (mean-reversion)
Il meccanismo centrale sfrutta i cicli naturali di compressione ed espansione della volatilità:
La strategia attiva sfrutta il fatto, emerso dai dati, che su BTC/ETH a 1h gli estremi
di prezzo **rientrano verso la media** più di quanto proseguano:
1. **Compressione** — le Bollinger Bands entrano dentro i Keltner Channel (il prezzo si muove sempre meno, accumulando "energia").
2. **Breakout** — le bande escono dal canale. Un impulso direzionale parte.
3. **Conferma ML** — un modello GradientBoosting, addestrato su feature strutturali e frattali della finestra precedente, conferma la direzione e filtra i segnali deboli.
1. **Bollinger Bands** (window `n`, `k` deviazioni standard) sul close.
2. **Entry** — quando il close esce *sotto* la banda inferiore → **long** (o *sopra* la superiore → **short**). Ingresso a `close[i]`, eseguibile dal vivo.
3. **Take-profit** alla media mobile (il rientro atteso).
4. **Stop-loss** a `sl_atr × ATR` oltre l'estremo; **time-limit** a `max_bars`.
### Feature frattali
Nessun look-ahead: direzione e livelli sono calcolati con dati fino a `close[i]`.
- Rapporti body/shadow normalizzati su finestre multiple (12, 24, 48 candele)
- Momentum, volatilità, skewness, kurtosis dei rendimenti logaritmici
- Autocorrelazione lag-1
- Profilo volumetrico e spike detection
- Durata della fase di squeeze e rapporto di espansione Keltner
- Posizione del prezzo rispetto al range recente e ATR normalizzato
### Perché lo squeeze breakout è stato abbandonato
L'ipotesi originale era opposta — *continuazione* dopo la compressione di volatilità
(Bollinger dentro Keltner → breakout direzionale). Su dati storici sembrava dare
76-82% di accuracy, ma era un **artefatto di look-ahead**: il backtest entrava a
`close[i-1]` con direzione decisa da `close[i]`. Replicando l'esecuzione reale
(ingresso a `close[i]`) l'edge collassa al ~47% (lancio di moneta) e i costi fanno
il resto. Il test sui breakout intra-barra a 5m conferma che il movimento *rientra*
subito (mean-reversion), giustificando MR01. Tutta la famiglia squeeze è in `scripts/waste/`.
### Lezione metodologica
Ogni nuova strategia deve passare: (1) **ingresso eseguibile** senza look-ahead,
(2) backtest **netto** dopo fee realistiche (0.10% RT Deribit), (3) validazione
**out-of-sample** + robustezza su griglia parametri + sweep fee. Strumenti in
`scripts/analysis/` (`strategy_research.py`, `oos_validation.py`, `intrabar_test.py`).
## Struttura progetto
```
PythagorasGoal/
├── src/
│ ├── data/ # Download e gestione dati storici (Cerbero MCP + Binance)
│ ├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity
│ ├── backtest/ # Motore di backtesting con fee e metriche
│ ├── strategies/ # (predisposto per strategie modulari)
├── nn/ # (predisposto per reti neurali)
│ └── utils/
├── scripts/ # Script di analisi e test (0113)
│ ├── data/ # Download e gestione dati (Cerbero MCP + Binance)
│ ├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity
│ ├── backtest/ # Motore di backtesting con fee e metriche
│ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi
│ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats
│ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr
│ └── live/ # Paper trading live su Deribit testnet
│ ├── multi_runner.py # Orchestratore multi-strategia
│ ├── strategy_worker.py # Worker indipendente con stato persistente
│ ├── strategy_loader.py # Import dinamico classi Strategy
│ ├── cerbero_client.py # Client HTTP per Cerbero MCP
│ ├── signal_engine.py # Squeeze + ML real-time (legacy) + validazione OOS
│ └── telegram_notifier.py
├── scripts/
│ ├── strategies/ # Strategie con edge validato OOS (solo MR01_bollinger_fade)
│ ├── waste/ # Strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD)
│ └── analysis/ # Ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...)
├── strategies.yml # Config multi-strategy paper trader
├── data/
── raw/ # Parquet OHLCV (non committati, ~70 MB)
│ └── processed/ # Modelli salvati
── raw/ # Parquet OHLCV (gitignored, ~70 MB)
├── docs/
── diary/ # Diario di ricerca giornaliero
├── tests/
├── pyproject.toml
── README.md
── diary/ # Diario di ricerca giornaliero
│ └── specs/ # Specifiche di design
├── Dockerfile
── docker-compose.yml
└── pyproject.toml
```
## Strategie attive
Tutte le strategie estendono `src.strategies.base.Strategy` (`generate_signals() → backtest()`).
| Codice | Script | Tipo | Descrizione |
|--------|--------|------|-------------|
| **MR01** | `MR01_bollinger_fade.py` | Mean-reversion | Fada la banda di Bollinger, TP alla media, SL ad ATR. Unica con edge netto validato OOS. |
La famiglia squeeze (SQ01-04, ML01, MT01, PD01, CM01, AD01) è in `scripts/waste/`:
edge storico = artefatto di look-ahead (vedi sezione *Come funziona*).
Per eseguire il backtest della strategia:
```bash
uv run python scripts/strategies/MR01_bollinger_fade.py
```
Per la ricerca/validazione fee-aware out-of-sample:
```bash
uv run python scripts/analysis/strategy_research.py # screening famiglie + deep-dive MR01
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
uv run python scripts/analysis/validate_worker_mr01.py # replay del worker live su MR01
```
## Paper Trading Live
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP, ognuna con €1000 USDC virtuali indipendenti. Se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come MR01), il worker chiude su take-profit alla media / stop-loss ad ATR / time-limit; altrimenti usa il fallback `hold_bars`/stop -2%.
### Avvio
```bash
# Locale
uv run python -m src.live.multi_runner
# Docker
docker compose up -d
```
### Configurazione
Le strategie attive sono definite in `strategies.yml`:
```yaml
defaults:
capital: 1000
position_size: 0.15
leverage: 3
strategies:
- name: MR01_bollinger_fade
asset: BTC
tf: 1h
enabled: true
params:
bb_window: 50
k: 2.5
sl_atr: 2.0
max_bars: 24
```
Per aggiungere una strategia: nuova riga in `strategies.yml`, poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
### Persistenza
Ogni strategia ha la sua directory in `data/paper_trades/`:
```
data/paper_trades/
MR01_bollinger_fade__BTC__1h/
trades.jsonl # Storico trade append-only
status.json # Stato corrente (resume al restart, include tp/sl/max_bars)
```
Notifiche Telegram per ogni trade (richiede `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` in `.env`).
## Setup
```bash
# Clona il repository
# Clona e installa
git clone <repo-url> && cd PythagorasGoal
# Installa dipendenze (richiede uv)
uv sync
# Scarica dati storici (~70 MB, richiede connessione)
# Scarica dati storici (~70 MB)
uv run python -m src.data.downloader
# Esegui la strategia ibrida vincente
uv run python scripts/13_squeeze_ml_hybrid.py
# Backtest strategia attiva
uv run python scripts/strategies/MR01_bollinger_fade.py
# Paper trading live
uv run python -m src.live.multi_runner
```
### Requisiti
- Python ≥ 3.11
- [uv](https://docs.astral.sh/uv/) come package manager
- Accesso a Cerbero MCP (`cerbero-mcp.tielogic.xyz`) per i dati Deribit, oppure Binance via ccxt come fallback
- Accesso a Cerbero MCP (`cerbero-mcp.tielogic.xyz`) per dati Deribit live
- Docker (opzionale, per deploy su VPS)
## Dati
@@ -90,25 +199,7 @@ uv run python scripts/13_squeeze_ml_hybrid.py
| BTC | 5m / 15m / 1h | 883K / 294K / 74K | 2018-01 → oggi |
| ETH | 5m / 15m / 1h | 882K / 294K / 74K | 2018-01 → oggi |
Fonte primaria: Deribit perpetual via Cerbero MCP. Fallback per il periodo antecedente: Binance spot via ccxt. Formato: Apache Parquet.
## Strategie testate
| Script | Approccio | Esito |
|--------|-----------|-------|
| 01 | Pattern candlestick discreti (U/D/0) | Nessun edge |
| 02 | DTW pattern matching | Troppo lento, edge minimo |
| 03 | Proiezione FFT (ispirata al paper) | Random (49.8%) |
| 04 | GBM su feature frattali (Hurst, FD) | 63.6% a soglia 0.65 |
| 05 | GBM multi-window (corretto data leakage) | 58.9% |
| 06 | GBM su feature strutturali normalizzate | 58.6%, +57.5% return |
| 07 | LSTM su sequenze candele | 58.4%, comparabile a GBM |
| 08 | Ensemble multi-timeframe (1h + 15m) | 59.2% (consensus 2/3) |
| 09 | Walk-forward ML | 57.7%, Sharpe 7.4, €3.12/day |
| 10 | Ensemble 5 modelli alta precisione | In corso |
| 11 | **Volatility Squeeze Breakout** | **83.9%**, approccio strutturale |
| 12 | Report finale e simulazione crescita | — |
| 13 | **Squeeze + ML ibrida** | **76.9%**, 118% ann, €13.78/day |
Fonte primaria: Deribit perpetual via Cerbero MCP. Fallback: Binance spot via ccxt. Formato: Apache Parquet.
## Riferimenti
+3 -2
View File
@@ -1,16 +1,17 @@
services:
paper-trader:
build: .
container_name: pythagoras-paper
container_name: pythagoras-multi
restart: unless-stopped
volumes:
- ./data:/app/data
- ./strategies.yml:/app/strategies.yml:ro
env_file:
- .env
environment:
- PYTHONUNBUFFERED=1
healthcheck:
test: ["CMD", "python", "-c", "import json; s=json.load(open('/app/data/paper_trades/status.json')); assert s['last_update']"]
test: ["CMD", "python", "-c", "import os; assert any(f.endswith('status.json') for r,d,fs in os.walk('/app/data/paper_trades') for f in fs)"]
interval: 120s
timeout: 10s
retries: 3
+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",
"matplotlib>=3.7",
"tqdm>=4.65",
"pyyaml>=6.0",
]
[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.trained = False
def _new_model(self) -> GradientBoostingClassifier:
return GradientBoostingClassifier(
n_estimators=150, max_depth=4, min_samples_leaf=10,
learning_rate=0.05, subsample=0.8, random_state=42,
)
def _validate_oos(self, X: np.ndarray, y: np.ndarray, test_frac: float = 0.2) -> dict:
"""Split temporale (no shuffle) per stimare la performance out-of-sample.
Allena su training iniziale e valuta sull'ultimo `test_frac` dei campioni.
Oltre all'accuratezza OOS, riporta la precisione sui soli segnali con
confidenza >= ml_thr cioè i trade che la strategia aprirebbe davvero.
"""
n_test = int(len(X) * test_frac)
n_train = len(X) - n_test
if n_train < 30 or n_test < 5:
return {"oos_warning": "test set troppo piccolo", "oos_test_samples": n_test}
scaler = StandardScaler()
X_tr = scaler.fit_transform(X[:n_train])
X_te = scaler.transform(X[n_train:])
y_tr, y_te = y[:n_train], y[n_train:]
model = self._new_model()
model.fit(X_tr, y_tr)
up_idx = list(model.classes_).index(1)
p_up = model.predict_proba(X_te)[:, up_idx]
test_acc = float(np.mean((p_up >= 0.5).astype(int) == y_te) * 100)
oos_train_acc = float(np.mean(model.predict(X_tr) == y_tr) * 100)
long_sig = p_up >= self.ml_thr
short_sig = p_up <= (1 - self.ml_thr)
n_sig = int((long_sig | short_sig).sum())
if n_sig > 0:
correct = int(((long_sig & (y_te == 1)) | (short_sig & (y_te == 0))).sum())
sig_prec = round(correct / n_sig * 100, 1)
else:
sig_prec = None
return {
"oos_train_accuracy": round(oos_train_acc, 1),
"oos_test_accuracy": round(test_acc, 1),
"oos_test_samples": n_test,
"oos_signals": n_sig,
"oos_signal_precision": sig_prec,
}
def train(self, df: pd.DataFrame, lookahead: int = 3) -> dict:
"""Addestra il modello su dati storici."""
close = df["close"].values
@@ -154,20 +202,24 @@ class SignalEngine:
X = np.array(X_all)
y = np.array(y_all)
oos = self._validate_oos(X, y)
self.scaler = StandardScaler()
X_s = self.scaler.fit_transform(X)
self.model = GradientBoostingClassifier(
n_estimators=150, max_depth=4, min_samples_leaf=10,
learning_rate=0.05, subsample=0.8, random_state=42,
)
self.model = self._new_model()
self.model.fit(X_s, y)
self.trained = True
preds = self.model.predict(X_s)
train_acc = np.mean(preds == y) * 100
train_acc = float(np.mean(preds == y) * 100)
return {"samples": len(X), "up_ratio": np.mean(y) * 100, "train_accuracy": train_acc}
return {
"samples": len(X),
"up_ratio": round(float(np.mean(y) * 100), 1),
"train_accuracy": round(train_acc, 1),
**oos,
}
def check_signal(self, df: pd.DataFrame) -> dict | None:
"""Controlla se c'è un segnale sulle ultime candele.
+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 = "pandas" },
{ name = "pyarrow" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "scikit-learn" },
{ name = "scipy" },
@@ -2081,6 +2082,7 @@ requires-dist = [
{ name = "pyarrow", specifier = ">=15.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "requests", specifier = ">=2.31" },
{ name = "scikit-learn", specifier = ">=1.3" },
{ name = "scipy", specifier = ">=1.11" },
@@ -2101,6 +2103,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.34.2"