Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cff0d08fca | |||
| 21d3ba609d | |||
| 48435f6858 | |||
| 9879b46688 | |||
| ca88e62a11 | |||
| 8fd2c16cac | |||
| 31be1b43aa | |||
| bdcef09057 | |||
| d39c75b103 | |||
| f42fec9fac | |||
| 56bad4741e | |||
| b79c87e4af |
@@ -16,3 +16,4 @@ data/processed/
|
||||
*.pt
|
||||
*.pth
|
||||
notebooks/.ipynb_checkpoints/
|
||||
data/paper_trades/
|
||||
|
||||
@@ -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,84 @@ df = load_data("ETH", "15m") # carica un asset/timeframe
|
||||
Fonte primaria: Cerbero MCP (endpoint `/mcp-deribit/tools/get_historical`).
|
||||
Token observer: nel file `secrets/observer.token` del progetto CerberoSuite.
|
||||
|
||||
## Strategia vincente
|
||||
|
||||
**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()`). Le strategie mean-reversion condividono
|
||||
`src.strategies.fade_base.FadeStrategy` (backtest intrabar TP/SL/max_bars).
|
||||
**Strategie con edge netto validato OOS fee-aware (tutte fade/mean-reversion):**
|
||||
|
||||
| Codice | Nome | Meccanismo | Edge OOS netto (1h, fee 0.10% RT) | DD | Note |
|
||||
|--------|------|-----------|-----------------------------------|----|------|
|
||||
| **MR01** | Bollinger Fade | banda std attorno a SMA | BTC +201% / ETH +1238% | 15-72% | Fada la banda, TP alla media, SL ad ATR |
|
||||
| **MR02** | Donchian Fade | estremi canale H/L | BTC +172% / ETH enorme | 30-42% | Fada la rottura del canale, TP al centro |
|
||||
| **MR03** | Keltner Fade | canale ATR attorno a EMA | BTC +112% / ETH +886% | 20-66% | Banda indipendente da Bollinger |
|
||||
| **MR07** | Return Reversal | z dei rendimenti di barra | BTC +105% / ETH +195% | 25-46% | Fada il movimento estremo, exit in ATR; esposizione ~8% |
|
||||
|
||||
**Lezione confermata:** l'edge è sempre *mean-reversion* (i breakout rientrano).
|
||||
Il trend-following (Donchian trend, RSI cross) e gli oscillatori senza filtro
|
||||
(RSI revert, ADX-filtered fade) perdono netti → restano scartati.
|
||||
|
||||
Ogni strategia è robusta su **tutta** la sua griglia parametri (entrambi gli asset
|
||||
→ tutte positive OOS) e su **tutte** le fee 0.00-0.20% RT (margine ampio).
|
||||
MR01 validato col worker reale: BTC +196% / ETH +251% OOS (nov 2023→mag 2026).
|
||||
Ricerca completa: `scripts/analysis/strategy_research.py` (MR01) e
|
||||
`scripts/analysis/strategy_research_v2.py` (MR02/MR03/MR07).
|
||||
Validazione live-path: `scripts/analysis/oos_validation.py`.
|
||||
|
||||
**Filtro trend (riduzione DD + aumento Acc).** Tutte le fade accettano i parametri
|
||||
opzionali `trend_max` / `ema_long`: saltano i segnali quando il prezzo è troppo
|
||||
esteso rispetto al trend di fondo (`|close − EMA(ema_long)| / ATR(14) > trend_max`),
|
||||
cioè quando si starebbe fadando un trend/crollo estremo. Con `trend_max=3.0`,
|
||||
`ema_long=200` (default in `strategies.yml`): accuratezza su tutti gli sleeve
|
||||
e DD giù drasticamente su ETH (MR01 71%→26%, MR02 42%→25%, MR03 66%→34%,
|
||||
MR07 46%→21%), edge OOS confermato (vedi `scripts/analysis/risk_portfolio.py`).
|
||||
Unica eccezione: MR03 BTC, dove il filtro peggiora entrambe → lasciato disattivo.
|
||||
Leva non robusta scartate: vol-target sizing e skip-alta-volatilità (peggiorano).
|
||||
|
||||
**Portafoglio.** Diversificare su sotto-conti indipendenti equipesati (le 4 strategie
|
||||
× BTC/ETH, pos 0.15 ciascuno) abbatte il DD aggregato: ~14% full / ~10% OOS sul
|
||||
paniere di 8 sleeve, contro il 20-70% del singolo. È la vera leva anti-drawdown.
|
||||
|
||||
**Metodologia obbligatoria per ogni nuova strategia** (per non ripetere l'errore squeeze):
|
||||
1. Ingresso eseguibile: direzione e prezzo decisi con dati **fino a `close[i]`**, mai `close[i-1]` con direzione da `i`.
|
||||
2. Backtest **NETTO** dopo fee realistiche Deribit (**0.10% RT** taker, non 0.20%) + leva.
|
||||
3. Validazione **out-of-sample** (held-out) + robustezza su griglia parametri + sweep fee.
|
||||
4. Crea script in `scripts/strategies/`, aggiungi a `MODULE_MAP` (`strategy_loader.py`) e a `strategies.yml`.
|
||||
|
||||
Strategie scartate storiche in `scripts/waste/` (W01-W28 + la famiglia squeeze).
|
||||
|
||||
**Verso €50/giorno.** Con 4 strategie indipendenti (MR01/MR02/MR03/MR07) × 2 asset
|
||||
(BTC/ETH) su €1000 ciascuna, il PnL medio storico aggregato è ben oltre €50/giorno;
|
||||
ma quei numeri sono backtest a leva 3x su 8 anni e includono anni eccezionali (es.
|
||||
ETH 2024). Stima onesta: il target è *plausibile* su un portafoglio diversificato di
|
||||
queste fade, ma va confermato col paper trader live prima di rischiare capitale reale.
|
||||
|
||||
## Multi-Strategy Paper Trader
|
||||
|
||||
Orchestratore che esegue N strategie in parallelo su dati live Cerbero, ognuna con €1000 USDC virtuali indipendenti.
|
||||
|
||||
**Config:** `strategies.yml` — lista strategie con asset, tf, sizing, parametri. Attualmente solo MR01 (BTC/ETH 1h).
|
||||
**Persistenza:** `data/paper_trades/{strategy}___{asset}__{tf}/` con `trades.jsonl` (append-only) + `status.json` (resume al restart, include tp/sl/max_bars).
|
||||
**Hot-add:** aggiungi riga YAML → `docker compose restart` → storico intatto.
|
||||
**Exit strategia:** se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come MR01), il worker esce su take-profit/stop-loss/time-limit a quei livelli; altrimenti usa il fallback `hold_bars`/stop -2%.
|
||||
**Notifiche:** Telegram per ogni trade (richiede `.env` con `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID`).
|
||||
|
||||
## Convenzioni
|
||||
|
||||
- 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 +153,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
@@ -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"]
|
||||
|
||||
@@ -8,80 +8,189 @@ Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di
|
||||
|
||||
## Risultati
|
||||
|
||||
Tredici strategie testate su dati storici 2018–2026 (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)
|
||||
│ ├── 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/ # (predisposto per strategie modulari)
|
||||
│ ├── nn/ # (predisposto per reti neurali)
|
||||
│ └── utils/
|
||||
├── scripts/ # Script di analisi e test (01–13)
|
||||
│ ├── 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
@@ -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
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# 2026-05-28 — Giorno 3: Bug dati Cerbero, paper trader fermo, fix MT01 multi-timeframe
|
||||
|
||||
### 12:20 — Sintomo: paper trader live a zero trade
|
||||
|
||||
**Cosa:** check del container `pythagoras-multi` (multi-strategy paper trader, 6 strategie).
|
||||
**Reale:** container healthy da ore, ma **0 trade** su tutte le strategie, tutte FLAT a €1000.
|
||||
Primo falso indizio: `last_bar_ts: 0` in tutti gli `status.json`. Indagando il worker,
|
||||
quel campo si aggiorna **solo a posizione aperta** (contatore `hold_bars`), non ad ogni
|
||||
candela → non è la causa. Il loop era vivo (status.json riscritti ogni 60s).
|
||||
**Lezione:** non fidarsi del nome di un campo; verificare nel codice quando viene scritto.
|
||||
L'healthcheck del container controlla solo l'esistenza di `status.json`, non la freschezza
|
||||
→ un loop bloccato risulterebbe comunque "healthy".
|
||||
|
||||
### 12:45 — Causa radice: bug lato Cerbero MCP `get_historical`
|
||||
|
||||
**Cosa:** probe dirette all'endpoint `/mcp-deribit/tools/get_historical`.
|
||||
**Reale:** due bug lato server:
|
||||
1. **`end_date` data-nuda tronca a mezzanotte:** `end=oggi` restituiva candele solo fino a
|
||||
`oggi 00:00`. Il `df` live finiva sempre alla barra di mezzanotte e **non avanzava** durante
|
||||
la giornata → nessun breakout fresco sull'ultima barra → nessun ingresso (condizione worker
|
||||
`last_signal.idx >= last_idx - 1`).
|
||||
2. **Cap a ~5000 righe** che ignora `start_date`: una richiesta di 365g a 15m restituiva ~52
|
||||
giorni. Ecco perché ML01 si addestrava su soli 88 samples (overfit, train_acc 100%).
|
||||
**Lezione:** lo zero-trade non era nelle strategie ma nel feed dati. Sempre validare la
|
||||
freschezza/copertura dei dati prima di sospettare la logica.
|
||||
|
||||
### 13:30 — Fix lato Cerbero + verifica
|
||||
|
||||
**Cosa:** report passato al dev di `cerbero-mcp`; fix deployato (riavvio container) + doc
|
||||
aggiornata in `cerbero-mcp/docs/API_REFERENCE.md`.
|
||||
**Reale dopo deploy (verificato con probe):**
|
||||
- `end=oggi` (data nuda) → ultima candela = ora corrente (age ~3 min). ✅
|
||||
- 365g a 15m → **35.099 candele**, span 365.6g, nessun cap. ✅
|
||||
- Supportati anche timestamp con orario (`...T14:00:00`, naive = UTC). ✅
|
||||
|
||||
Nostro client (`src/live/cerbero_client.py`) invariato: passa già `end=oggi`, ora corretto.
|
||||
**Lezione:** "trust but verify" — la doc dichiarava i fix prima che fossero deployati; solo
|
||||
la probe diretta ha confermato cosa era davvero attivo sul server.
|
||||
|
||||
### 14:00 — Problema residuo: MT01 usava un trend 1h STANTIO
|
||||
|
||||
**Cosa:** check di tutte le strategie sul percorso di codice reale con dati freschi.
|
||||
**Reale:**
|
||||
- Tutte le 6 strategie girano senza crash; SQ01/SQ02 generano molti segnali.
|
||||
- **MT01 leggeva il trend 1h dal parquet statico** (`load_data(asset,"1h")`), non da Cerbero.
|
||||
Il parquet finiva a mezzanotte → per ogni barra 15m di oggi `searchsorted` cadeva oltre la
|
||||
fine e si agganciava sempre alla candela di mezzanotte (gap 14.8h). La conferma
|
||||
multi-timeframe — il cuore di MT01 — era di fatto congelata e il gap cresce ogni giorno.
|
||||
- In `data/raw/` mancavano del tutto i parquet **15m** (`btc_15m`, `eth_15m`) → backtest 15m rotti.
|
||||
**Lezione:** una strategia live che dipende da un file statico ha un punto cieco temporale;
|
||||
il dato live e quello di backtest devono provenire da fonti coerenti.
|
||||
|
||||
### 14:30 — Fix MT01: trend 1h live da Cerbero
|
||||
|
||||
**Cosa:** modifica al runner perché MT01 prenda l'1h live, non dal parquet.
|
||||
- `MT01.generate_signals` accetta un `df_1h` opzionale (fallback al parquet se assente).
|
||||
- `StrategyWorker.tick(df, df_1h=None)` lo inoltra ai signal.
|
||||
- `multi_runner` fa fetch 1h live (resolution 60) per gli asset MT01 ad ogni poll (`htf_cache`).
|
||||
**Reale (verificato a codice montato, pre-rebuild):** gap del trend 1h sull'ultima barra
|
||||
**0.75h** (fresco) contro **14.8h** col parquet statico. Segnali invariati sullo storico.
|
||||
**Lezione:** isolare la dipendenza dal file statico rende MT01 immune al drift tra un
|
||||
`download_all()` e l'altro.
|
||||
|
||||
### 14:55 — Rigenerazione dati + rebuild
|
||||
|
||||
**Cosa:** `download_asset` per 15m+1h (saltati 1m/5m, lenti e inutilizzati), poi
|
||||
`docker compose up -d --build` (il codice `src/` è baked nell'immagine).
|
||||
**Reale:** parquet rigenerati con storia completa 2018→2026 e freschi (15m fino alle 14:45,
|
||||
1h fino alle 14:00). Container ripartito: 6 strategie attive, ML01 riaddestrato su **534
|
||||
samples** (anno pieno), MT01 senza errori, fetch 1h live OK.
|
||||
|
||||
### 15:00 — Regressione backtest sui dati rigenerati
|
||||
|
||||
**Cosa:** rilanciati i backtest per confermare che i numeri documentati si riproducano sui
|
||||
dati ricreati da zero (BTC/ETH 15m, hold=3, fee 0.2% RT, leva 3x, pos 15%).
|
||||
**Reale:** accuratezze e drawdown **identici**, solo +1/+3 trade dalle barre recenti in più.
|
||||
|
||||
| Strategia | Ottenuto | Documentato | Esito |
|
||||
|---|---|---|---|
|
||||
| SQ01 BTC 15m | 76.7% / DD 6.7% / 4063t | 76.7% / 6.7% / 4062 | ✓ |
|
||||
| SQ01 ETH 15m | 76.4% / 6.2% / 2951t | 76.4% / 6.2% / 2948 | ✓ |
|
||||
| SQ02 BTC 15m | 79.7% / 6.5% / 1251t | 79.7% / 6.5% / 1250 | ✓ |
|
||||
| SQ02 ETH 15m | 78.6% / 3.4% / 944t | 78.6% / 3.4% / 942 | ✓ |
|
||||
| **MT01 BTC 15m (ema20+vol)** | **82.7% / 5.9% / 503t** | 82.7% / 5.9% / 503 | ✓ esatto |
|
||||
| MT01 ETH 15m (ema20+vol) | 81.2% / 2.9% / 404t | — | ok |
|
||||
|
||||
**Lezione:** l'integrità dei dati rigenerati è confermata — la pipeline di download produce
|
||||
risultati riproducibili. La config live di MT01 (ema20+vol) coincide col best documentato.
|
||||
|
||||
### Punti aperti
|
||||
|
||||
1. **Backtest e drift dati:** MT01 live ora è immune (1h da Cerbero), ma i backtest girano
|
||||
sempre sui dati fino all'ultimo `download_all()`. Per dati di backtest sempre freschi
|
||||
serve uno scheduling del download (cron/job).
|
||||
2. **Healthcheck:** valutare un check su mtime di `status.json` (< 180s) per rilevare uno
|
||||
stallo del loop, non solo l'esistenza del file.
|
||||
|
||||
---
|
||||
|
||||
### 23:00 — 3 nuove strategie con edge OOS fee-aware (branch `strategy_free`)
|
||||
|
||||
**Obiettivo:** trovare almeno 3 nuove strategie (oltre MR01), edge netto validato
|
||||
out-of-sample e fee-aware, per il target €1.000 → ~€50/giorno.
|
||||
|
||||
**Metodologia (invariata dalla lezione squeeze):** ingresso eseguibile a `close[i]`
|
||||
(nessun look-ahead), backtest netto dopo fee Deribit 0.10% RT + leva 3x, OOS = ultimo
|
||||
30% held-out, robustezza su griglia parametri + sweep fee 0.00–0.20% RT, exit
|
||||
TP/SL intrabar o time-limit, una posizione per volta, capitale composto.
|
||||
|
||||
**Candidati** (`scripts/analysis/strategy_research_v2.py`), tutti mean-reversion
|
||||
(l'edge è sempre il rientro, mai la continuazione):
|
||||
|
||||
| Candidato | Esito | Motivo |
|
||||
|---|---|---|
|
||||
| **MR02 Donchian Fade** | ✅ | Robusto su tutta la griglia `n × sl_atr` e tutte le fee |
|
||||
| **MR03 Keltner Fade** | ✅ | Robusto su tutta la griglia `n × k`; banda ATR, indipendente da Bollinger |
|
||||
| **MR07 Return Reversal** | ✅ | Intero blocco `tp_atr=2.0` positivo full+OOS; esposizione ~8% |
|
||||
| MR04 Z-score Reversion | ⛔ | Robusto ma è MR01 riparametrizzato (stessa banda std): edge non *nuovo* |
|
||||
| MR05 Bollinger + filtro ADX | ⛔ | Non robusto: negativo su gran parte della griglia BTC |
|
||||
| MR06 RSI(2) Connors | ⛔ | ETH 1h negativo; non robusto su entrambi gli asset |
|
||||
|
||||
**Risultati** (netto 0.10% RT, leva 3x, OOS, 1h):
|
||||
|
||||
| Codice | Meccanismo | BTC OOS | ETH OOS | DD (full) |
|
||||
|---|---|---|---|---|
|
||||
| MR02 | estremi canale Donchian H/L | +172% | enorme | 30% / 42% |
|
||||
| MR03 | canale ATR su EMA | +112% | +886% | 37% / 66% |
|
||||
| MR07 | z dei rendimenti di barra | +105% | +195% | 25% / 46% |
|
||||
|
||||
**Validazione live-path** (`oos_validation.py`, legge `strategies.yml`, exit hold
|
||||
del worker): tutte e tre positive netto OOS su tutto lo sweep fee, anche al
|
||||
pessimistico 0.20% RT → edge robusto pure al meccanismo di exit.
|
||||
|
||||
**Verifiche:** equivalenza esatta backtest produzione vs research engine (MR02 BTC:
|
||||
2039 trade, DD 29% identici); le 3 classi si caricano dal `strategy_loader`;
|
||||
aggiunte a `strategies.yml` (BTC+ETH 1h). Nessuna suite di test nel progetto.
|
||||
|
||||
**Onestà sul target:** con 4 fade indipendenti × 2 asset il PnL storico aggregato
|
||||
supera €50/giorno, ma sono backtest a leva 3x su 8 anni con annate eccezionali
|
||||
(ETH 2024). Plausibile ma da confermare col paper trader live prima del capitale reale.
|
||||
DD alto su ETH (MR03 ~66%, come MR01) → leva più bassa consigliata per quell'asset.
|
||||
|
||||
**File:** `strategy_research_v2.py`, `src/strategies/fade_base.py`,
|
||||
`scripts/strategies/MR0{2,3,7}_*.py` (nuovi); `strategy_loader.py`, `strategies.yml`,
|
||||
`CLAUDE.md` (aggiornati).
|
||||
**Lezione confermata:** ogni edge robusto trovato finora è mean-reversion; ogni
|
||||
variante trend/continuation o oscillatore senza filtro perde netto.
|
||||
|
||||
---
|
||||
|
||||
### 23:45 — Aumentare Acc e ridurre DD (filtro trend + portafoglio)
|
||||
|
||||
**Obiettivo:** alzare accuratezza e abbassare drawdown sulle 4 fade, senza
|
||||
distruggere l'edge né overfittare (ogni leva misurata FULL **e** OOS).
|
||||
|
||||
**Diagnosi:** perdite/DD concentrati 2018–2021 (bear/covid/caos vol), su ETH DD
|
||||
pieno 66–71%. Banco di prova: `scripts/analysis/risk_improvements.py` e
|
||||
`risk_portfolio.py`.
|
||||
|
||||
**Leve testate:**
|
||||
|
||||
| Leva | Esito | Motivo |
|
||||
|---|---|---|
|
||||
| Sizing vol-target (size ∝ 1/dist-SL) | ⛔ | Over-size sui trade a stop stretto → DD su, ritorno giù |
|
||||
| Skip alta volatilità (ATR% in coda alta) | ⛔ | L'alta vol è *positiva* per le fade (più reversione): Acc e ritorno giù |
|
||||
| **Filtro trend** (`\|close−EMA200\|/ATR > soglia` → salta) | ✅ | Non fada trend/crolli estremi: Acc↑ ovunque, DD↓ molto su ETH, OOS regge |
|
||||
| **Portafoglio** equipesato (sotto-conti indipendenti) | ✅ | Curve poco correlate → DD aggregato 14% (full)/10% (OOS) vs 20-70% singolo |
|
||||
|
||||
**Filtro trend — sweep soglia** (assoluta in ATR, regola unica per tutte = niente
|
||||
overfit): 3.0 ATR è l'equilibrio (2.0 taglia troppo ritorno). Effetto su config
|
||||
deployata (base → filtro):
|
||||
|
||||
| Sleeve | Acc | DD |
|
||||
|---|---|---|
|
||||
| MR01 ETH | 46→55 | **71→26** |
|
||||
| MR02 ETH | 49→55 | 42→25 |
|
||||
| MR03 ETH | 49→52 | 66→34 |
|
||||
| MR07 ETH | 48→54 | 46→21 |
|
||||
| MR01 BTC | 51→54 | 32→34* |
|
||||
| MR02 BTC | 48→52 | 29→23 |
|
||||
| MR07 BTC | 49→53 | 25→18 |
|
||||
| MR03 BTC | 47→47 | 37→37 (filtro OFF) |
|
||||
|
||||
\*MR01 BTC: DD full +2pt ma Acc +3.7 e DD OOS piatto (14.8→15.0). **MR03 BTC**:
|
||||
il filtro peggiora entrambe (unico sleeve) → lasciato disattivo nello yaml.
|
||||
|
||||
**Implementazione:** helper `trend_distance()` in `fade_base.py`; param opzionali
|
||||
`trend_max`/`ema_long` (default None = retro-compatibile) in tutte le strategie
|
||||
(MR01/02/03/07); `strategies.yml` con `trend_max: 3.0, ema_long: 200` (eccetto
|
||||
MR03 BTC). Verificato: equivalenza produzione vs ricerca.
|
||||
|
||||
**Lezione:** il modo onesto di ridurre il DD non è strozzare il sizing (peggiora),
|
||||
ma (a) non opporsi a trend estremi e (b) diversificare su strategie scorrelate.
|
||||
@@ -0,0 +1,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)
|
||||
@@ -14,6 +14,7 @@ dependencies = [
|
||||
"torch>=2.0",
|
||||
"matplotlib>=3.7",
|
||||
"tqdm>=4.65",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Migliorare Acc e ridurre DD sulle fade (MR01/MR02/MR03/MR07) senza overfit.
|
||||
|
||||
Leve testate, ognuna misurata FULL e OOS (ultimo 30%) per non illudersi:
|
||||
- vol-target sizing: size per trade ~ 1/distanza-SL -> rischio costante, DD piu' liscio
|
||||
- filtro vol regime: salta i trade con ATR% in coda alta (periodi caotici)
|
||||
- filtro anti-trend: non fadare contro un trend forte (|close-EMA_long|/ATR grande)
|
||||
- portfolio: equity curve combinata delle 4 strategie su un conto unico
|
||||
|
||||
Engine fedele (ingresso close[i], exit TP/SL intrabar o time-limit, non-overlap,
|
||||
capitale composto) con sizing per-trade. Numeri NETTI fee 0.10% RT, leva 3x.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from scripts.analysis.strategy_research import bollinger_fade, atr
|
||||
from scripts.analysis.strategy_research_v2 import donchian_fade, keltner_fade, return_reversal
|
||||
|
||||
FEE_RT, LEV, POS, INIT, OOS_FRAC = 0.001, 3.0, 0.15, 1000.0, 0.30
|
||||
|
||||
# config base di ogni strategia (come strategies.yml)
|
||||
STRATS = {
|
||||
"MR01": (bollinger_fade, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24)),
|
||||
"MR02": (donchian_fade, dict(n=20, sl_atr=2.0, max_bars=24)),
|
||||
"MR03": (keltner_fade, dict(n=30, k=2.0, sl_atr=2.0, max_bars=24)),
|
||||
"MR07": (return_reversal,dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
|
||||
}
|
||||
STRATS_ETH3 = dict(STRATS); STRATS_ETH3["MR03"] = (keltner_fade, dict(n=50, k=2.0, sl_atr=2.0, max_bars=24))
|
||||
|
||||
|
||||
def add_context(ents, df, ema_long=200):
|
||||
"""Aggiunge a ogni entry: sl_dist_pct, atr_pct, trend_dist (|close-EMA|/ATR)."""
|
||||
c = df["close"].values
|
||||
a = atr(df, 14)
|
||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
||||
apct = a / c
|
||||
for e in ents:
|
||||
i = e["i"]
|
||||
e["sl_dist"] = abs(c[i] - e["sl"]) / c[i]
|
||||
e["atr_pct"] = apct[i]
|
||||
e["trend_dist"] = abs(c[i] - el[i]) / a[i] if a[i] else 0.0
|
||||
return ents
|
||||
|
||||
|
||||
def simulate(ents, df, fee_rt=FEE_RT, lev=LEV, split=-1,
|
||||
sizer=None, vol_skip=None, trend_skip=None, max_size=0.30):
|
||||
"""sizer: funzione(entry)->frazione capitale; default POS fisso.
|
||||
vol_skip: soglia atr_pct sopra cui salto. trend_skip: soglia trend_dist sopra cui salto."""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
cap = peak = INIT
|
||||
dd = 0.0; last = -1; trd = wins = 0
|
||||
fee = fee_rt * lev
|
||||
yearly = {}; rets = []
|
||||
for e in ents:
|
||||
i, d = e["i"], e["d"]
|
||||
if i <= last or i + 1 >= n or i < split:
|
||||
continue
|
||||
if vol_skip is not None and e["atr_pct"] > vol_skip:
|
||||
continue
|
||||
if trend_skip is not None and e["trend_dist"] > trend_skip:
|
||||
continue
|
||||
entry = c[i]; tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1)
|
||||
for k in range(1, mb + 1):
|
||||
j = i + k
|
||||
if j >= n:
|
||||
exit_p = c[n - 1]; break
|
||||
hs = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hs: exit_p = sl; break
|
||||
if ht: exit_p = tp; break
|
||||
if k == mb: exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * d * lev - fee
|
||||
size = POS if sizer is None else min(sizer(e), max_size)
|
||||
cap = max(cap + cap * size * ret, 10.0)
|
||||
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
|
||||
trd += 1; wins += ret > 0; last = j; rets.append(ret * size)
|
||||
y = ts.iloc[i].year; yearly[y] = yearly.get(y, 0.0) + ret * size * INIT
|
||||
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0
|
||||
return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0,
|
||||
ret=(cap / INIT - 1) * 100, dd=dd * 100, yearly=yearly, sharpe=sharpe)
|
||||
|
||||
|
||||
def vol_target_sizer(target=0.015):
|
||||
"""size t.c. rischio (size*lev*sl_dist) ~ target; piu' largo lo stop, meno size."""
|
||||
return lambda e: target / (LEV * max(e["sl_dist"], 1e-4))
|
||||
|
||||
|
||||
def line(label, full, oos):
|
||||
print(f" {label:<28s}{full['trades']:>6d}{full['acc']:>7.1f}{full['ret']:>+10.0f}{full['dd']:>7.1f}{full['sharpe']:>7.2f}"
|
||||
f" | {oos['trades']:>5d}{oos['acc']:>7.1f}{oos['ret']:>+9.0f}{oos['dd']:>7.1f}{oos['sharpe']:>7.2f}")
|
||||
|
||||
|
||||
def main():
|
||||
for asset in ["BTC", "ETH"]:
|
||||
df = load_data(asset, "1h")
|
||||
split = int(len(df) * (1 - OOS_FRAC))
|
||||
table = STRATS_ETH3 if asset == "ETH" else STRATS
|
||||
# quantili vol globali per la soglia (p90)
|
||||
print("\n" + "=" * 110)
|
||||
print(f" {asset} 1h — leve di riduzione DD / aumento Acc | NETTO fee 0.10% RT, leva 3x")
|
||||
print("=" * 110)
|
||||
print(f" {'config':<28s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>10s}{'DD%':>7s}{'Shrp':>7s}"
|
||||
f" | {'oTrd':>5s}{'oAcc':>7s}{'oRet':>9s}{'oDD':>7s}{'oShrp':>7s}")
|
||||
print(" " + "-" * 106)
|
||||
for nm, (fn, params) in table.items():
|
||||
ents = add_context(fn(df, **params), df)
|
||||
apct = np.array([e["atr_pct"] for e in ents])
|
||||
p85 = float(np.quantile(apct, 0.85))
|
||||
tdist = np.array([e["trend_dist"] for e in ents])
|
||||
t90 = float(np.quantile(tdist, 0.90))
|
||||
|
||||
base_f = simulate(ents, df); base_o = simulate(ents, df, split=split)
|
||||
line(f"{nm} base", base_f, base_o)
|
||||
vt_f = simulate(ents, df, sizer=vol_target_sizer()); vt_o = simulate(ents, df, split=split, sizer=vol_target_sizer())
|
||||
line(f"{nm} +volTarget", vt_f, vt_o)
|
||||
vs_f = simulate(ents, df, vol_skip=p85); vs_o = simulate(ents, df, split=split, vol_skip=p85)
|
||||
line(f"{nm} +volSkip(p85)", vs_f, vs_o)
|
||||
ts_f = simulate(ents, df, trend_skip=t90); ts_o = simulate(ents, df, split=split, trend_skip=t90)
|
||||
line(f"{nm} +trendSkip(p90)", ts_f, ts_o)
|
||||
allf = simulate(ents, df, sizer=vol_target_sizer(), vol_skip=p85, trend_skip=t90)
|
||||
allo = simulate(ents, df, split=split, sizer=vol_target_sizer(), vol_skip=p85, trend_skip=t90)
|
||||
line(f"{nm} +ALL", allf, allo)
|
||||
print(" " + "-" * 106)
|
||||
print("\n Shrp = Sharpe annuo-naive sui ritorni per-trade. oXxx = stessa metrica su OOS (ultimo 30%).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Affina il filtro trend (soglia assoluta ATR) e costruisce il portafoglio combinato.
|
||||
|
||||
Due risultati:
|
||||
(1) trend filter: salta le fade quando |close-EMA200|/ATR > soglia (non fadare un
|
||||
trend estremo). Soglia ASSOLUTA in multipli di ATR -> stessa regola per tutte
|
||||
le strategie/asset, basso rischio di overfit. Sweep soglie, FULL e OOS.
|
||||
(2) portafoglio: equity curve combinata delle 4 strategie sullo stesso conto
|
||||
(rischio diviso fra N posizioni). Curve poco correlate -> DD aggregato << DD
|
||||
della singola strategia. Confronto singola vs portafoglio, con/senza filtro.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from scripts.analysis.strategy_research import bollinger_fade, atr
|
||||
from scripts.analysis.strategy_research_v2 import donchian_fade, keltner_fade, return_reversal
|
||||
|
||||
FEE_RT, LEV, INIT, OOS_FRAC = 0.001, 3.0, 1000.0, 0.30
|
||||
|
||||
STRATS = {
|
||||
"MR01": (bollinger_fade, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24)),
|
||||
"MR02": (donchian_fade, dict(n=20, sl_atr=2.0, max_bars=24)),
|
||||
"MR03": (keltner_fade, dict(n=30, k=2.0, sl_atr=2.0, max_bars=24)),
|
||||
"MR07": (return_reversal,dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
|
||||
}
|
||||
STRATS_ETH = dict(STRATS); STRATS_ETH["MR03"] = (keltner_fade, dict(n=50, k=2.0, sl_atr=2.0, max_bars=24))
|
||||
|
||||
|
||||
def build_trades(ents, df, lev=LEV, fee_rt=FEE_RT, trend_max=None, ema_long=200):
|
||||
"""Ritorna lista trade non-overlap: (entry_idx, exit_idx, ret_netto). Filtro trend opzionale."""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c); a = atr(df, 14)
|
||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
||||
fee = fee_rt * lev
|
||||
out = []; last = -1
|
||||
for e in ents:
|
||||
i, d = e["i"], e["d"]
|
||||
if i <= last or i + 1 >= n:
|
||||
continue
|
||||
if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max:
|
||||
continue
|
||||
entry = c[i]; tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1)
|
||||
for k in range(1, mb + 1):
|
||||
j = i + k
|
||||
if j >= n:
|
||||
exit_p = c[n - 1]; break
|
||||
hs = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hs: exit_p = sl; break
|
||||
if ht: exit_p = tp; break
|
||||
if k == mb: exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * d * lev - fee
|
||||
out.append((i, j, ret)); last = j
|
||||
return out
|
||||
|
||||
|
||||
def metrics_single(trades, ts, pos=0.15, split=-1):
|
||||
cap = peak = INIT; dd = 0.0; trd = wins = 0; rets = []
|
||||
for i, j, ret in trades:
|
||||
if i < split:
|
||||
continue
|
||||
cap = max(cap + cap * pos * ret, 10.0)
|
||||
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
|
||||
trd += 1; wins += ret > 0; rets.append(ret * pos)
|
||||
sh = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0
|
||||
return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0,
|
||||
ret=(cap / INIT - 1) * 100, dd=dd * 100, sharpe=sh)
|
||||
|
||||
|
||||
def sleeve_equity(trades, n_bars, pos=0.15, split=-1):
|
||||
"""Equity curve di uno sleeve su sotto-conto indipendente (capitale INIT, pos fissa).
|
||||
Ritorna array lungo n_bars (step aggiornato alla chiusura di ogni trade)."""
|
||||
eq = np.full(n_bars, INIT, dtype=float)
|
||||
cap = INIT
|
||||
for i, j, ret in sorted(trades, key=lambda t: t[1]):
|
||||
if i < split:
|
||||
continue
|
||||
cap = max(cap + cap * pos * ret, 10.0)
|
||||
eq[j:] = cap # da j in poi il sotto-conto vale cap
|
||||
return eq
|
||||
|
||||
|
||||
def metrics_portfolio(strat_trades, n_bars, ts, pos=0.15, split=-1):
|
||||
"""Portafoglio equipesato: capitale diviso in N sotto-conti indipendenti, ciascuno
|
||||
con la sua strategia a `pos` fisso. Equity aggregata = media dei sotto-conti (somma
|
||||
normalizzata a base INIT). DD misurato sull'equity aggregata. Niente leva sovrapposta."""
|
||||
sleeves = [sleeve_equity(tr, n_bars, pos=pos, split=split) for tr in strat_trades.values()]
|
||||
agg = np.mean(sleeves, axis=0) # media -> base INIT, diversificazione reale
|
||||
# restringi alla finestra effettiva (da split in poi se OOS)
|
||||
lo = max(split, 0)
|
||||
agg = agg[lo:]
|
||||
peak = np.maximum.accumulate(agg)
|
||||
dd = float(np.max((peak - agg) / peak) * 100)
|
||||
trd = sum(1 for tr in strat_trades.values() for i, _, _ in tr if i >= split)
|
||||
wins = sum(1 for tr in strat_trades.values() for i, _, r in tr if i >= split and r > 0)
|
||||
return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0,
|
||||
ret=(agg[-1] / INIT - 1) * 100, dd=dd, sharpe=0.0)
|
||||
|
||||
|
||||
def main():
|
||||
# ---------- (1) sweep soglia trend ----------
|
||||
print("=" * 104)
|
||||
print(" (1) FILTRO TREND |close-EMA200|/ATR > soglia -> SALTA | NETTO fee 0.10% RT, leva 3x")
|
||||
print("=" * 104)
|
||||
print(f" {'Strat/Asset':<14s}{'soglia':>8s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>9s}{'DD%':>7s}"
|
||||
f" | {'oAcc':>6s}{'oRet':>9s}{'oDD':>7s}{'oShrp':>7s}")
|
||||
print(" " + "-" * 100)
|
||||
thresholds = [None, 4.0, 3.0, 2.5, 2.0]
|
||||
for asset in ["BTC", "ETH"]:
|
||||
df = load_data(asset, "1h"); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
split = int(len(df) * (1 - OOS_FRAC))
|
||||
table = STRATS_ETH if asset == "ETH" else STRATS
|
||||
for nm, (fn, params) in table.items():
|
||||
ents = fn(df, **params)
|
||||
for thr in thresholds:
|
||||
tr = build_trades(ents, df, trend_max=thr)
|
||||
f = metrics_single(tr, ts); o = metrics_single(tr, ts, split=split)
|
||||
lab = "base" if thr is None else f"{thr}ATR"
|
||||
print(f" {nm+' '+asset:<14s}{lab:>8s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+9.0f}{f['dd']:>7.1f}"
|
||||
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||
print(" " + "-" * 100)
|
||||
|
||||
# ---------- (2) portafoglio combinato ----------
|
||||
print("\n" + "=" * 104)
|
||||
print(" (2) PORTAFOGLIO equipesato: capitale diviso in N sotto-conti indipendenti")
|
||||
print(" (pos 0.15 ciascuno, filtro trend 3.0 ATR). DD aggregato vs singola strategia.")
|
||||
print("=" * 104)
|
||||
print(f" {'Universo':<26s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>10s}{'DD%':>7s}{'':>7s}"
|
||||
f" | {'oAcc':>6s}{'oRet':>9s}{'oDD':>7s}{'':>7s}")
|
||||
print(" " + "-" * 100)
|
||||
all_trades = {}
|
||||
for asset in ["BTC", "ETH"]:
|
||||
df = load_data(asset, "1h"); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
split = int(len(df) * (1 - OOS_FRAC)); n = len(df)
|
||||
table = STRATS_ETH if asset == "ETH" else STRATS
|
||||
st = {f"{nm}_{asset}": build_trades(fn(df, **p), df, trend_max=3.0) for nm, (fn, p) in table.items()}
|
||||
all_trades.update(st)
|
||||
f = metrics_portfolio(st, n, ts); o = metrics_portfolio(st, n, ts, split=split)
|
||||
print(f" {'Portafoglio '+asset+' (4 strat)':<26s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+10.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
|
||||
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||
# globale 8 sleeve
|
||||
df0 = load_data("BTC", "1h"); ts0 = pd.to_datetime(df0["timestamp"], unit="ms", utc=True)
|
||||
split0 = int(len(df0) * (1 - OOS_FRAC))
|
||||
f = metrics_portfolio(all_trades, len(df0), ts0); o = metrics_portfolio(all_trades, len(df0), ts0, split=split0)
|
||||
print(" " + "-" * 100)
|
||||
print(f" {'GLOBALE BTC+ETH (8 sleeve)':<26s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+10.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
|
||||
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||
print("\n Nota: ogni sleeve gira su un sotto-conto indipendente (pos 0.15); l'equity di")
|
||||
print(" portafoglio e' la media dei sotto-conti. Curve poco correlate => DD aggregato")
|
||||
print(" molto piu' basso del DD del singolo sleeve (la vera leva anti-drawdown).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Ricerca v2 — nuove strategie oltre MR01, stessa metodologia fee-aware OOS.
|
||||
|
||||
Lezioni ereditate (vedi strategy_research.py / oos_validation.py):
|
||||
- mean-reversion ha edge, continuation/trend NO (i breakout rientrano)
|
||||
- fee = vincolo di prim'ordine -> default Deribit 0.10% RT, poche operazioni meglio
|
||||
- ingresso ESEGUIBILE a close[i] (mai look-ahead con direzione da barra i)
|
||||
- ogni numero NETTO dopo fee+leva, su finestra held-out (OOS=ultimo 30%) + per anno
|
||||
|
||||
Nuovi candidati (tutti fade/mean-reversion con ingresso onesto):
|
||||
MR02 donchian_fade - fade rottura canale Donchian (opposto del trend che muore)
|
||||
MR03 keltner_fade - fade canale Keltner (ATR), TP alla EMA media
|
||||
MR04 zscore_revert - fade deviazione z-score estrema, TP alla media
|
||||
MR05 boll_fade_adx - Bollinger fade con filtro regime ADX (solo mercato laterale)
|
||||
|
||||
Engine identico a strategy_research.simulate (ingresso close[i], exit TP/SL intrabar
|
||||
high/low o time-limit, non-overlap, capitale composto).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# riusa engine, dati e indicatori gia' validati
|
||||
from scripts.analysis.strategy_research import (
|
||||
FEE_RT, LEV, POS, OOS_FRAC, get_df, atr, rsi, simulate,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------- indicatori extra ---------------------------
|
||||
def ema(x: np.ndarray, n: int) -> np.ndarray:
|
||||
return pd.Series(x).ewm(span=n, adjust=False).mean().values
|
||||
|
||||
|
||||
def adx(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||
"""Average Directional Index: misura la forza del trend (alto=trend, basso=range)."""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
up = h - np.roll(h, 1)
|
||||
dn = np.roll(l, 1) - l
|
||||
up[0] = dn[0] = 0.0
|
||||
plus_dm = np.where((up > dn) & (up > 0), up, 0.0)
|
||||
minus_dm = np.where((dn > up) & (dn > 0), dn, 0.0)
|
||||
pc = np.roll(c, 1); pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
atr_n = pd.Series(tr).ewm(alpha=1/n, adjust=False).mean().values
|
||||
pdi = 100 * pd.Series(plus_dm).ewm(alpha=1/n, adjust=False).mean().values / np.where(atr_n == 0, np.nan, atr_n)
|
||||
mdi = 100 * pd.Series(minus_dm).ewm(alpha=1/n, adjust=False).mean().values / np.where(atr_n == 0, np.nan, atr_n)
|
||||
dx = 100 * np.abs(pdi - mdi) / np.where((pdi + mdi) == 0, np.nan, pdi + mdi)
|
||||
return pd.Series(dx).ewm(alpha=1/n, adjust=False).mean().values
|
||||
|
||||
|
||||
# --------------------------- strategie nuove ---------------------------
|
||||
def donchian_fade(df, n=20, sl_atr=2.0, max_bars=24):
|
||||
"""MR02 — fade rottura canale Donchian: rompe sopra max-N => short verso il mid.
|
||||
|
||||
Coerente con 'i breakout rientrano': l'opposto di donchian_trend (che fallisce).
|
||||
Ingresso a close[i] sulla barra che chiude oltre il canale precedente.
|
||||
TP al centro del canale, SL = sl_atr*ATR oltre l'estremo.
|
||||
"""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
hh = pd.Series(h).rolling(n).max().shift(1).values
|
||||
ll = pd.Series(l).rolling(n).min().shift(1).values
|
||||
a = atr(df, 14)
|
||||
ents = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(hh[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
mid = (hh[i] + ll[i]) / 2.0
|
||||
if c[i] > hh[i] and c[i - 1] <= hh[i - 1]: # rottura rialzista => fade short
|
||||
ents.append({"i": i, "d": -1, "tp": mid, "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
elif c[i] < ll[i] and c[i - 1] >= ll[i - 1]: # rottura ribassista => fade long
|
||||
ents.append({"i": i, "d": 1, "tp": mid, "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def keltner_fade(df, n=20, k=2.0, sl_atr=2.0, max_bars=24):
|
||||
"""MR03 — fade canale Keltner (EMA +/- k*ATR), TP alla EMA media.
|
||||
|
||||
Come Bollinger ma banda basata su ATR (volatilita' di range) invece che std:
|
||||
reagisce diversamente ai gap. Ingresso quando close esce dalla banda.
|
||||
"""
|
||||
c = df["close"].values
|
||||
e = ema(c, n)
|
||||
a = atr(df, n)
|
||||
up, lo = e + k * a, e - k * a
|
||||
ents = []
|
||||
for i in range(n + 1, len(c)):
|
||||
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||
ents.append({"i": i, "d": 1, "tp": e[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||
ents.append({"i": i, "d": -1, "tp": e[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def zscore_revert(df, n=50, z=2.0, sl_atr=2.5, max_bars=24):
|
||||
"""MR04 — fade deviazione z-score estrema dalla media, TP alla media.
|
||||
|
||||
z = (close-ma)/std. Entra quando |z| supera la soglia (close fuori); chiude
|
||||
quando torna alla media. Banda di Bollinger riparametrizzata in z (equivalente
|
||||
a k=z) ma con SL piu' largo e finestra lunga: poche operazioni, alta selettivita'.
|
||||
"""
|
||||
c = df["close"].values
|
||||
ma = pd.Series(c).rolling(n).mean().values
|
||||
sd = pd.Series(c).rolling(n).std().values
|
||||
a = atr(df, 14)
|
||||
ents = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(ma[i]) or sd[i] == 0 or np.isnan(a[i]):
|
||||
continue
|
||||
zi = (c[i] - ma[i]) / sd[i]
|
||||
zp = (c[i - 1] - ma[i - 1]) / sd[i - 1] if sd[i - 1] else 0.0
|
||||
if zi <= -z and zp > -z:
|
||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif zi >= z and zp < z:
|
||||
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def boll_fade_adx(df, n=50, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25.0):
|
||||
"""MR05 — Bollinger fade SOLO in regime laterale (ADX < adx_max).
|
||||
|
||||
Il fade soffre quando c'e' trend forte (il prezzo continua oltre la banda).
|
||||
Filtro ADX: opera solo quando la forza del trend e' bassa -> meno trade, edge piu' pulito.
|
||||
"""
|
||||
c = df["close"].values
|
||||
ma = pd.Series(c).rolling(n).mean().values
|
||||
sd = pd.Series(c).rolling(n).std().values
|
||||
a = atr(df, 14)
|
||||
ax = adx(df, 14)
|
||||
up, lo = ma + k * sd, ma - k * sd
|
||||
ents = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(up[i]) or np.isnan(a[i]) or np.isnan(ax[i]):
|
||||
continue
|
||||
if ax[i] >= adx_max: # trend forte: niente fade
|
||||
continue
|
||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def rsi2_fade(df, rsi_n=2, lo=10, hi=90, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24):
|
||||
"""MR06 — Connors RSI(2) pullback in direzione del trend, TP/SL in ATR.
|
||||
|
||||
Meccanismo distinto da MR01/MR03: non usa bande di prezzo ma l'oscillatore
|
||||
RSI(2), che satura su micro-estremi. Filtro di trend con SMA lunga:
|
||||
- close SOPRA la SMA (uptrend) + RSI(2) < lo (dip) -> long, target rimbalzo
|
||||
- close SOTTO la SMA (downtrend) + RSI(2) > hi (pop) -> short
|
||||
TP = tp_atr*ATR a favore, SL = sl_atr*ATR contro. Compra il ritracciamento
|
||||
nel trend, non il contro-trend.
|
||||
"""
|
||||
c = df["close"].values
|
||||
r = rsi(c, rsi_n)
|
||||
ma = pd.Series(c).rolling(ma_n).mean().values
|
||||
a = atr(df, 14)
|
||||
ents = []
|
||||
for i in range(ma_n + 14, len(c)):
|
||||
if np.isnan(r[i]) or np.isnan(ma[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if r[i] < lo and c[i] > ma[i]: # dip in uptrend -> long
|
||||
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif r[i] > hi and c[i] < ma[i]: # pop in downtrend -> short
|
||||
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def return_reversal(df, n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24):
|
||||
"""MR07 — fade movimento di barra estremo (return reversal).
|
||||
|
||||
Misura il rendimento dell'ultima barra in unita' di deviazione standard rolling
|
||||
dei rendimenti. Se |ret| > k*sigma, fada nella direzione opposta; TP/SL in ATR.
|
||||
Meccanismo distinto: usa la volatilita' dei RENDIMENTI, non i livelli di prezzo.
|
||||
Config robusta (k=3.5, tp=2ATR, sl=1.5ATR): positivo full+OOS BTC e ETH 1h,
|
||||
DD piu' contenuto (BTC 25% / ETH 46%).
|
||||
"""
|
||||
c = df["close"].values
|
||||
ret = np.zeros_like(c)
|
||||
ret[1:] = np.diff(c) / c[:-1]
|
||||
sig = pd.Series(ret).rolling(n).std().values
|
||||
a = atr(df, 14)
|
||||
ents = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(sig[i]) or sig[i] == 0 or np.isnan(a[i]):
|
||||
continue
|
||||
z = ret[i] / sig[i]
|
||||
if z <= -k: # crollo di barra -> fade long
|
||||
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif z >= k: # spike di barra -> fade short
|
||||
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
CANDIDATES = {
|
||||
"MR02 donch_fade n20": (donchian_fade, dict(n=20, sl_atr=2.0, max_bars=24)),
|
||||
"MR02 donch_fade n50": (donchian_fade, dict(n=50, sl_atr=2.0, max_bars=24)),
|
||||
"MR03 kelt_fade k2": (keltner_fade, dict(n=20, k=2.0, sl_atr=2.0, max_bars=24)),
|
||||
"MR03 kelt_fade k2.5": (keltner_fade, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)),
|
||||
"MR04 zscore z2 n50": (zscore_revert, dict(n=50, z=2.0, sl_atr=2.5, max_bars=24)),
|
||||
"MR04 zscore z2.5 n50": (zscore_revert, dict(n=50, z=2.5, sl_atr=2.5, max_bars=24)),
|
||||
"MR05 boll_adx n50": (boll_fade_adx, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25)),
|
||||
"MR05 boll_adx n20": (boll_fade_adx, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25)),
|
||||
"MR06 rsi2 10/90": (rsi2_fade, dict(rsi_n=2, lo=10, hi=90, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24)),
|
||||
"MR06 rsi2 5/95": (rsi2_fade, dict(rsi_n=2, lo=5, hi=95, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24)),
|
||||
"MR07 retrev k3.5": (return_reversal, dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
|
||||
"MR07 retrev k3.0": (return_reversal, dict(n=50, k=3.0, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
|
||||
}
|
||||
|
||||
|
||||
def table():
|
||||
print("=" * 122)
|
||||
print(f" RICERCA v2 — NETTO dopo fee {FEE_RT*100:.2f}% RT | leva {LEV:.0f}x | pos {POS*100:.0f}% "
|
||||
f"| OOS = ultimo {int(OOS_FRAC*100)}%")
|
||||
print("=" * 122)
|
||||
print(f" {'Strategia':<22s}{'Asset':>5s}{'TF':>5s}{'Trd':>6s}{'Tr/yr':>7s}{'Win%':>7s}"
|
||||
f"{'Ret%FULL':>10s}{'Ret%OOS':>10s}{'DD%':>7s}{'Exp%':>7s}{'AnniPos':>9s}")
|
||||
print(" " + "-" * 118)
|
||||
for label, (fn, params) in CANDIDATES.items():
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["1h", "4h"]:
|
||||
df = get_df(asset, tf)
|
||||
ents = fn(df, **params)
|
||||
full = simulate(ents, df)
|
||||
split = int(len(df) * (1 - OOS_FRAC))
|
||||
oos = simulate([e for e in ents if e["i"] >= split], df)
|
||||
yrs = full["yearly"]
|
||||
pos_yrs = sum(1 for v in yrs.values() if v > 0)
|
||||
tr_yr = full["trades"] / max(len(yrs), 1)
|
||||
robust = oos["ret"] > 0 and full["ret"] > 0 and pos_yrs >= max(len(yrs) - 1, 1)
|
||||
flag = " <<<" if robust else ""
|
||||
print(f" {label:<22s}{asset:>5s}{tf:>5s}{full['trades']:>6d}{tr_yr:>7.0f}{full['win']:>7.1f}"
|
||||
f"{full['ret']:>+10.1f}{oos['ret']:>+10.1f}{full['dd']:>7.1f}{full['exposure']:>7.1f}"
|
||||
f"{f'{pos_yrs}/{len(yrs)}':>9s}{flag}")
|
||||
print(" " + "-" * 118)
|
||||
print(" <<< = positivo full+OOS e robusto (quasi tutti gli anni positivi).")
|
||||
|
||||
|
||||
def deep_dive():
|
||||
"""Robustezza dei 3 candidati promossi: fee sweep + griglia parametri OOS."""
|
||||
split_of = lambda df: int(len(df) * (1 - OOS_FRAC))
|
||||
fees = [0.0, 0.0005, 0.001, 0.002]
|
||||
|
||||
print("\n" + "#" * 122)
|
||||
print(" APPROFONDIMENTO MR02 / MR03 / MR05 — robustezza fee + griglia (deve restare positivo)")
|
||||
print("#" * 122)
|
||||
|
||||
# --- MR02 Donchian Fade ---
|
||||
print(f"\n [MR02 donchian_fade] SENSIBILITA' FEE — Ret% FULL/OOS (n=20)")
|
||||
print(f" {'Asset/TF':<10s}" + "".join(f"{f'{f*100:.2f}%RT':>22s}" for f in fees))
|
||||
print(f" {'':<10s}" + "".join(f"{'full':>11s}{'oos':>11s}" for _ in fees))
|
||||
for a, tf in [("BTC", "1h"), ("ETH", "1h"), ("BTC", "4h"), ("ETH", "4h")]:
|
||||
df = get_df(a, tf); sp = split_of(df)
|
||||
ents = donchian_fade(df, n=20, sl_atr=2.0, max_bars=24)
|
||||
oents = [e for e in ents if e["i"] >= sp]
|
||||
cells = "".join(f"{simulate(ents, df, fee_rt=f)['ret']:>+11.0f}{simulate(oents, df, fee_rt=f)['ret']:>+11.0f}" for f in fees)
|
||||
print(f" {a+' '+tf:<10s}{cells}")
|
||||
print(f"\n [MR02] GRIGLIA n x sl_atr — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT")
|
||||
for a in ["BTC", "ETH"]:
|
||||
df = get_df(a, "1h"); sp = split_of(df)
|
||||
print(f"\n {a} 1h " + "".join(f"{f'sl={s}':>16s}" for s in [1.5, 2.0, 3.0]))
|
||||
for n in [10, 20, 30, 50]:
|
||||
cells = ""
|
||||
for s in [1.5, 2.0, 3.0]:
|
||||
r = simulate([e for e in donchian_fade(df, n=n, sl_atr=s, max_bars=24) if e["i"] >= sp], df)
|
||||
cell = "%+.0f(%.0f)" % (r["ret"], r["dd"])
|
||||
cells += f"{cell:>16s}"
|
||||
print(f" n={n:<4d}{cells}")
|
||||
|
||||
# --- MR03 Keltner Fade ---
|
||||
print(f"\n [MR03 keltner_fade] GRIGLIA n x k — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT")
|
||||
for a in ["BTC", "ETH"]:
|
||||
df = get_df(a, "1h"); sp = split_of(df)
|
||||
print(f"\n {a} 1h " + "".join(f"{f'k={k}':>16s}" for k in [1.5, 2.0, 2.5]))
|
||||
for n in [14, 20, 30, 50]:
|
||||
cells = ""
|
||||
for k in [1.5, 2.0, 2.5]:
|
||||
r = simulate([e for e in keltner_fade(df, n=n, k=k, sl_atr=2.0, max_bars=24) if e["i"] >= sp], df)
|
||||
cell = "%+.0f(%.0f)" % (r["ret"], r["dd"])
|
||||
cells += f"{cell:>16s}"
|
||||
print(f" n={n:<4d}{cells}")
|
||||
|
||||
# --- MR05 Bollinger Fade + ADX ---
|
||||
print(f"\n [MR05 boll_fade_adx] GRIGLIA n x adx_max — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT")
|
||||
for a in ["BTC", "ETH"]:
|
||||
df = get_df(a, "1h"); sp = split_of(df)
|
||||
print(f"\n {a} 1h " + "".join(f"{f'adx<{x}':>16s}" for x in [20, 25, 30]))
|
||||
for n in [20, 30, 50]:
|
||||
cells = ""
|
||||
for x in [20, 25, 30]:
|
||||
r = simulate([e for e in boll_fade_adx(df, n=n, k=2.5, sl_atr=2.0, max_bars=24, adx_max=x) if e["i"] >= sp], df)
|
||||
cell = "%+.0f(%.0f)" % (r["ret"], r["dd"])
|
||||
cells += f"{cell:>16s}"
|
||||
print(f" n={n:<4d}{cells}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
table()
|
||||
deep_dive()
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,172 @@
|
||||
"""MR01 — Bollinger Fade (mean-reversion).
|
||||
|
||||
L'UNICA famiglia con edge netto reale dopo l'analisi out-of-sample fee-aware
|
||||
(vedi scripts/analysis/strategy_research.py). Contrario della tesi squeeze:
|
||||
i breakout RIENTRANO, quindi si fada l'estremo verso la media.
|
||||
|
||||
Logica:
|
||||
1. Bollinger Band (window n, k deviazioni) sul close
|
||||
2. ENTRY: close esce sotto la banda inferiore -> long (o sopra la superiore -> short)
|
||||
3. EXIT: take-profit alla media mobile (il rientro atteso),
|
||||
stop-loss a sl_atr*ATR oltre l'estremo, oppure time-limit max_bars
|
||||
4. ingresso a close[i] (eseguibile dal vivo, nessun look-ahead)
|
||||
|
||||
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
|
||||
BTC 1h n=50 k=2.5: +201% OOS, DD 15%, ~tutti gli anni positivi
|
||||
ETH 1h n=50 k=2.0: +1238% OOS, DD 23%
|
||||
Robusto su TUTTA la griglia n in {14,20,30,50} x k in {2.0,2.5,3.0}
|
||||
e su tutte le fee 0.00-0.20% RT (margine di sicurezza ampio).
|
||||
|
||||
NOTA LIVE: usa TP alla media + SL ad ATR + max_bars. Lo StrategyWorker attuale
|
||||
esce solo a hold_bars/stop -2% fisso: per tradarla come validata il worker deve
|
||||
supportare gli exit TP/SL passati in metadata (vedi metadata di ogni Signal).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
def _atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
pc = np.roll(c, 1); pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
return pd.Series(tr).rolling(n).mean().values
|
||||
|
||||
|
||||
class BollingerFade(Strategy):
|
||||
name = "MR01_bollinger_fade"
|
||||
description = "Mean-reversion: fada la banda di Bollinger, TP alla media"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["1h"]
|
||||
fee_rt = 0.001 # Deribit perp realistico (taker 0.05%/lato)
|
||||
leverage = 3.0
|
||||
position_size = 0.15
|
||||
initial_capital = 1000.0
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
n_len = len(c)
|
||||
bb_w = params.get("bb_window", 50)
|
||||
k = params.get("k", 2.5)
|
||||
sl_atr = params.get("sl_atr", 2.0)
|
||||
max_bars = params.get("max_bars", 24)
|
||||
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||
ema_long = params.get("ema_long", 200)
|
||||
|
||||
ma = pd.Series(c).rolling(bb_w).mean().values
|
||||
sd = pd.Series(c).rolling(bb_w).std().values
|
||||
a = _atr(df, 14)
|
||||
up, lo = ma + k * sd, ma - k * sd
|
||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values if trend_max is not None else None
|
||||
|
||||
signals: list[Signal] = []
|
||||
for i in range(bb_w + 14, n_len):
|
||||
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if el is not None and (a[i] == 0 or np.isnan(el[i]) or abs(c[i] - el[i]) / a[i] > trend_max):
|
||||
continue
|
||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||
d, sl = 1, c[i] - sl_atr * a[i]
|
||||
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||
d, sl = -1, c[i] + sl_atr * a[i]
|
||||
else:
|
||||
continue
|
||||
signals.append(Signal(
|
||||
idx=i, direction=d, entry_price=c[i],
|
||||
metadata={"tp": float(ma[i]), "sl": float(sl), "max_bars": max_bars},
|
||||
))
|
||||
return signals
|
||||
|
||||
def backtest(self, asset: str, tf: str = "1h", hold: int = 3,
|
||||
**params) -> BacktestResult | None:
|
||||
"""Backtest fedele: TP alla media / SL ad ATR / time-limit, fee+leva nette."""
|
||||
df = load_data(asset, tf)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
signals = self.generate_signals(df, ts, **params)
|
||||
if not signals:
|
||||
return None
|
||||
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c)
|
||||
fee = self.fee_rt * self.leverage
|
||||
capital = peak = float(self.initial_capital)
|
||||
max_dd = 0.0
|
||||
total_bars = 0
|
||||
last_exit = -1
|
||||
yearly: dict[int, dict] = {}
|
||||
|
||||
for sig in signals:
|
||||
i, d = sig.idx, sig.direction
|
||||
if i <= last_exit or i + 1 >= n:
|
||||
continue
|
||||
entry = c[i]
|
||||
tp, sl, mb = sig.metadata["tp"], sig.metadata["sl"], sig.metadata["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]
|
||||
j = min(i + mb, n - 1)
|
||||
for step in range(1, mb + 1):
|
||||
j = i + step
|
||||
if j >= n:
|
||||
j = n - 1; exit_p = c[j]; break
|
||||
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hit_sl:
|
||||
exit_p = sl; break
|
||||
if hit_tp:
|
||||
exit_p = tp; break
|
||||
if step == mb:
|
||||
exit_p = c[j]
|
||||
|
||||
ret = (exit_p - entry) / entry * d * self.leverage - fee
|
||||
capital = max(capital + capital * self.position_size * ret, 10.0)
|
||||
if capital > peak:
|
||||
peak = capital
|
||||
max_dd = max(max_dd, (peak - capital) / peak)
|
||||
total_bars += (j - i)
|
||||
last_exit = j
|
||||
|
||||
year = ts.iloc[i].year
|
||||
yr = yearly.setdefault(year, {"w": 0, "t": 0, "pnl": 0.0})
|
||||
yr["t"] += 1
|
||||
if ret > 0:
|
||||
yr["w"] += 1
|
||||
yr["pnl"] += ret * self.initial_capital
|
||||
|
||||
all_t = sum(v["t"] for v in yearly.values())
|
||||
all_w = sum(v["w"] for v in yearly.values())
|
||||
if all_t == 0:
|
||||
return None
|
||||
|
||||
yearly_stats = [YearlyStats(y, v["t"], v["w"], v["pnl"]) for y, v in sorted(yearly.items())]
|
||||
return BacktestResult(
|
||||
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
|
||||
trades=all_t, wins=all_w, pnl=sum(v["pnl"] for v in yearly.values()),
|
||||
capital=capital, initial_capital=self.initial_capital,
|
||||
max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100,
|
||||
avg_trade_duration_h=total_bars / all_t * TF_MINUTES.get(tf, 60) / 60,
|
||||
years_active=len(yearly), yearly=yearly_stats,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strat = BollingerFade()
|
||||
print(f"{'=' * 110}")
|
||||
print(f" MR01 BOLLINGER FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||
print(f"{'=' * 110}")
|
||||
results = []
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for k in [2.0, 2.5]:
|
||||
r = strat.backtest(asset, "1h", bb_window=50, k=k, sl_atr=2.0, max_bars=24)
|
||||
if r:
|
||||
r.strategy_name = f"MR01 {asset} 1h n50 k{k}"
|
||||
results.append(r)
|
||||
for r in results:
|
||||
r.print_summary()
|
||||
if results:
|
||||
results[0].print_yearly()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""MR02 — Donchian Fade (mean-reversion sugli estremi del canale).
|
||||
|
||||
L'opposto esatto del trend-following Donchian (che PERDE netto: vedi
|
||||
scripts/analysis/strategy_research.py). Coerente con la lezione squeeze:
|
||||
i breakout RIENTRANO, quindi si fada la rottura del canale verso il centro.
|
||||
|
||||
Logica:
|
||||
1. Canale Donchian: massimo/minimo delle ultime n barre (escludendo la corrente)
|
||||
2. ENTRY: close rompe SOPRA il massimo del canale -> SHORT (fade);
|
||||
close rompe SOTTO il minimo -> LONG. Ingresso a close[i] (eseguibile).
|
||||
3. EXIT: take-profit al centro del canale (il rientro atteso),
|
||||
stop-loss a sl_atr*ATR oltre l'estremo, time-limit max_bars.
|
||||
|
||||
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
|
||||
BTC 1h n=20: +879% FULL / +171% OOS, DD 30%, 8/9 anni positivi
|
||||
ETH 1h n=20: enorme FULL / +8452% OOS, DD 42%
|
||||
Robusto su TUTTA la griglia n in {10,20,30,50} x sl_atr in {1.5,2.0,3.0}
|
||||
(BTC+ETH 1h sempre positivo OOS) e su tutte le fee 0.00-0.20% RT.
|
||||
Ricerca completa: scripts/analysis/strategy_research_v2.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Signal
|
||||
from src.strategies.fade_base import FadeStrategy, atr, trend_distance
|
||||
|
||||
|
||||
class DonchianFade(FadeStrategy):
|
||||
name = "MR02_donchian_fade"
|
||||
description = "Mean-reversion: fada la rottura del canale Donchian, TP al centro"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["1h"]
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
n = params.get("n", 20)
|
||||
sl_atr = params.get("sl_atr", 2.0)
|
||||
max_bars = params.get("max_bars", 24)
|
||||
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||
ema_long = params.get("ema_long", 200)
|
||||
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
hh = pd.Series(h).rolling(n).max().shift(1).values
|
||||
ll = pd.Series(l).rolling(n).min().shift(1).values
|
||||
a = atr(df, 14)
|
||||
td = trend_distance(df, ema_long) if trend_max is not None else None
|
||||
|
||||
signals: list[Signal] = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(hh[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
||||
continue
|
||||
mid = (hh[i] + ll[i]) / 2.0
|
||||
if c[i] > hh[i] and c[i - 1] <= hh[i - 1]: # rottura rialzista -> fade short
|
||||
d, sl = -1, c[i] + sl_atr * a[i]
|
||||
elif c[i] < ll[i] and c[i - 1] >= ll[i - 1]: # rottura ribassista -> fade long
|
||||
d, sl = 1, c[i] - sl_atr * a[i]
|
||||
else:
|
||||
continue
|
||||
signals.append(Signal(
|
||||
idx=i, direction=d, entry_price=c[i],
|
||||
metadata={"tp": float(mid), "sl": float(sl), "max_bars": max_bars},
|
||||
))
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strat = DonchianFade()
|
||||
print("=" * 110)
|
||||
print(f" MR02 DONCHIAN FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||
print("=" * 110)
|
||||
for asset in ["BTC", "ETH"]:
|
||||
r = strat.backtest(asset, "1h", n=20, sl_atr=2.0, max_bars=24)
|
||||
if r:
|
||||
r.strategy_name = f"MR02 {asset} 1h n20"
|
||||
r.print_summary()
|
||||
r.print_yearly()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""MR03 — Keltner Fade (mean-reversion sul canale ATR).
|
||||
|
||||
Stessa tesi di MR01 (i breakout rientrano) ma con banda costruita su ATR
|
||||
attorno a una EMA, invece che su deviazione standard attorno a una SMA.
|
||||
Reagisce diversamente a gap e code: edge indipendente, non ridondante con MR01.
|
||||
|
||||
Logica:
|
||||
1. Canale di Keltner: EMA(n) +/- k*ATR(n)
|
||||
2. ENTRY: close esce sotto la banda inferiore -> LONG (o sopra la superiore -> SHORT)
|
||||
Ingresso a close[i] (eseguibile dal vivo, nessun look-ahead).
|
||||
3. EXIT: take-profit alla EMA centrale (il rientro atteso),
|
||||
stop-loss a sl_atr*ATR oltre l'estremo, time-limit max_bars.
|
||||
|
||||
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
|
||||
BTC 1h n=30 k=2.0: +112% OOS, DD 20%
|
||||
ETH 1h n=50 k=1.5: +1426% OOS, DD 20%
|
||||
Robusto su TUTTA la griglia n in {14,20,30,50} x k in {1.5,2.0,2.5}
|
||||
(BTC+ETH 1h sempre positivo OOS).
|
||||
Ricerca completa: scripts/analysis/strategy_research_v2.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Signal
|
||||
from src.strategies.fade_base import FadeStrategy, atr, trend_distance
|
||||
|
||||
|
||||
class KeltnerFade(FadeStrategy):
|
||||
name = "MR03_keltner_fade"
|
||||
description = "Mean-reversion: fada il canale di Keltner (ATR), TP alla EMA"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["1h"]
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
n = params.get("n", 30)
|
||||
k = params.get("k", 2.0)
|
||||
sl_atr = params.get("sl_atr", 2.0)
|
||||
max_bars = params.get("max_bars", 24)
|
||||
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||
ema_long = params.get("ema_long", 200)
|
||||
|
||||
c = df["close"].values
|
||||
e = pd.Series(c).ewm(span=n, adjust=False).mean().values
|
||||
a = atr(df, n)
|
||||
up, lo = e + k * a, e - k * a
|
||||
td = trend_distance(df, ema_long) if trend_max is not None else None
|
||||
|
||||
signals: list[Signal] = []
|
||||
for i in range(n + 1, len(c)):
|
||||
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
||||
continue
|
||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||
d, sl = 1, c[i] - sl_atr * a[i]
|
||||
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||
d, sl = -1, c[i] + sl_atr * a[i]
|
||||
else:
|
||||
continue
|
||||
signals.append(Signal(
|
||||
idx=i, direction=d, entry_price=c[i],
|
||||
metadata={"tp": float(e[i]), "sl": float(sl), "max_bars": max_bars},
|
||||
))
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strat = KeltnerFade()
|
||||
print("=" * 110)
|
||||
print(f" MR03 KELTNER FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||
print("=" * 110)
|
||||
for asset, n, k in [("BTC", 30, 2.0), ("ETH", 50, 1.5)]:
|
||||
r = strat.backtest(asset, "1h", n=n, k=k, sl_atr=2.0, max_bars=24)
|
||||
if r:
|
||||
r.strategy_name = f"MR03 {asset} 1h n{n} k{k}"
|
||||
r.print_summary()
|
||||
r.print_yearly()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""MR07 — Return Reversal (fade del movimento di barra estremo).
|
||||
|
||||
Meccanismo distinto da MR01/MR02/MR03: non guarda i LIVELLI di prezzo (bande,
|
||||
canali) ma la VOLATILITA' dei rendimenti. Quando una singola barra si muove di
|
||||
piu' di k deviazioni standard rolling dei rendimenti, e' un'over-reaction che
|
||||
tende a rientrare: si fada nella direzione opposta. Coerente con la lezione
|
||||
mean-reversion.
|
||||
|
||||
Logica:
|
||||
1. ret[i] = rendimento dell'ultima barra; sigma = std rolling(n) dei rendimenti
|
||||
2. z = ret[i]/sigma. Se z <= -k (crollo) -> LONG; se z >= +k (spike) -> SHORT.
|
||||
Ingresso a close[i] (eseguibile dal vivo, nessun look-ahead).
|
||||
3. EXIT: take-profit a tp_atr*ATR a favore, stop-loss a sl_atr*ATR contro,
|
||||
time-limit max_bars.
|
||||
|
||||
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
|
||||
config robusta k=3.5 tp=2ATR sl=1.5ATR n=50:
|
||||
BTC 1h: +447% FULL / +105% OOS, DD 25%
|
||||
ETH 1h: +335% FULL / +195% OOS, DD 46%
|
||||
L'intero blocco tp_atr=2.0 (k in {2.5,3.0,3.5} x sl in {1.5,2.0,2.5}) e'
|
||||
positivo full+OOS su entrambi gli asset 1h.
|
||||
Ricerca completa: scripts/analysis/strategy_research_v2.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Signal
|
||||
from src.strategies.fade_base import FadeStrategy, atr, trend_distance
|
||||
|
||||
|
||||
class ReturnReversal(FadeStrategy):
|
||||
name = "MR07_return_reversal"
|
||||
description = "Mean-reversion: fada il movimento di barra estremo (z dei rendimenti)"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["1h"]
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
n = params.get("n", 50)
|
||||
k = params.get("k", 3.5)
|
||||
tp_atr = params.get("tp_atr", 2.0)
|
||||
sl_atr = params.get("sl_atr", 1.5)
|
||||
max_bars = params.get("max_bars", 24)
|
||||
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||
ema_long = params.get("ema_long", 200)
|
||||
|
||||
c = df["close"].values
|
||||
ret = np.zeros_like(c)
|
||||
ret[1:] = np.diff(c) / c[:-1]
|
||||
sig = pd.Series(ret).rolling(n).std().values
|
||||
a = atr(df, 14)
|
||||
td = trend_distance(df, ema_long) if trend_max is not None else None
|
||||
|
||||
signals: list[Signal] = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(sig[i]) or sig[i] == 0 or np.isnan(a[i]):
|
||||
continue
|
||||
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
||||
continue
|
||||
z = ret[i] / sig[i]
|
||||
if z <= -k: # crollo di barra -> fade long
|
||||
d, tp, sl = 1, c[i] + tp_atr * a[i], c[i] - sl_atr * a[i]
|
||||
elif z >= k: # spike di barra -> fade short
|
||||
d, tp, sl = -1, c[i] - tp_atr * a[i], c[i] + sl_atr * a[i]
|
||||
else:
|
||||
continue
|
||||
signals.append(Signal(
|
||||
idx=i, direction=d, entry_price=c[i],
|
||||
metadata={"tp": float(tp), "sl": float(sl), "max_bars": max_bars},
|
||||
))
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strat = ReturnReversal()
|
||||
print("=" * 110)
|
||||
print(f" MR07 RETURN REVERSAL — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||
print("=" * 110)
|
||||
for asset in ["BTC", "ETH"]:
|
||||
r = strat.backtest(asset, "1h", n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)
|
||||
if r:
|
||||
r.strategy_name = f"MR07 {asset} 1h k3.5"
|
||||
r.print_summary()
|
||||
r.print_yearly()
|
||||
@@ -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%")
|
||||
@@ -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%")
|
||||
@@ -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%")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Import dinamico delle classi Strategy da scripts/strategies/."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from src.strategies.base import Strategy
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
STRATEGIES_DIR = PROJECT_ROOT / "scripts" / "strategies"
|
||||
|
||||
_REGISTRY: dict[str, type[Strategy]] = {}
|
||||
|
||||
# Solo strategie con edge netto validato out-of-sample (fee-aware).
|
||||
# La famiglia squeeze-breakout (SQ/MT/ML/AD/CM/PD) e' stata spostata in
|
||||
# scripts/waste/: l'edge storico era un artefatto di look-ahead
|
||||
# (vedi scripts/analysis/oos_validation.py).
|
||||
MODULE_MAP = {
|
||||
"MR01_bollinger_fade": ("MR01_bollinger_fade", "BollingerFade"),
|
||||
"MR02_donchian_fade": ("MR02_donchian_fade", "DonchianFade"),
|
||||
"MR03_keltner_fade": ("MR03_keltner_fade", "KeltnerFade"),
|
||||
"MR07_return_reversal": ("MR07_return_reversal", "ReturnReversal"),
|
||||
}
|
||||
|
||||
|
||||
def load_strategy(name: str) -> Strategy:
|
||||
"""Carica e istanzia una Strategy per nome."""
|
||||
if name in _REGISTRY:
|
||||
return _REGISTRY[name]()
|
||||
|
||||
if name not in MODULE_MAP:
|
||||
raise ValueError(f"Strategia sconosciuta: {name}. Disponibili: {list(MODULE_MAP)}")
|
||||
|
||||
module_file, class_name = MODULE_MAP[name]
|
||||
module_path = STRATEGIES_DIR / f"{module_file}.py"
|
||||
|
||||
if not module_path.exists():
|
||||
raise FileNotFoundError(f"File strategia non trovato: {module_path}")
|
||||
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
spec = importlib.util.spec_from_file_location(f"strategies.{module_file}", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
cls = getattr(module, class_name)
|
||||
_REGISTRY[name] = cls
|
||||
return cls()
|
||||
|
||||
|
||||
def list_available() -> list[str]:
|
||||
return list(MODULE_MAP.keys())
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Base condivisa per strategie mean-reversion con exit TP/SL/max_bars.
|
||||
|
||||
Tutte le strategie fade (MR02/MR03/MR07) generano Signal con metadata
|
||||
{tp, sl, max_bars} e usano lo stesso backtest fedele: ingresso a close[i]
|
||||
(eseguibile dal vivo), uscita su take-profit / stop-loss intrabar (high/low)
|
||||
o time-limit, una posizione per volta (non-overlap), capitale composto,
|
||||
fee+leva nette. Identico all'engine di scripts/analysis/strategy_research.py.
|
||||
|
||||
Le sottoclassi implementano solo generate_signals().
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, BacktestResult, YearlyStats, TF_MINUTES
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
pc = np.roll(c, 1); pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
return pd.Series(tr).rolling(n).mean().values
|
||||
|
||||
|
||||
def trend_distance(df: pd.DataFrame, ema_long: int = 200) -> np.ndarray:
|
||||
"""Distanza del close dalla EMA lunga, in multipli di ATR(14).
|
||||
|
||||
Misura quanto il prezzo e' esteso rispetto al trend di fondo. Le fade
|
||||
falliscono quando si oppongono a un trend estremo (crolli/parabolic): il
|
||||
filtro `trend_max` salta i segnali con distanza > soglia. Riduce DD e alza
|
||||
l'accuratezza (validato OOS: scripts/analysis/risk_portfolio.py).
|
||||
"""
|
||||
c = df["close"].values
|
||||
a = atr(df, 14)
|
||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
return np.abs(c - el) / np.where(a == 0, np.nan, a)
|
||||
|
||||
|
||||
class FadeStrategy(Strategy):
|
||||
"""Strategy con backtest intrabar TP/SL/max_bars (exit guidati dai metadata)."""
|
||||
|
||||
fee_rt = 0.001 # Deribit perp realistico (taker 0.05%/lato)
|
||||
leverage = 3.0
|
||||
position_size = 0.15
|
||||
initial_capital = 1000.0
|
||||
|
||||
def backtest(self, asset: str, tf: str = "1h", hold: int = 3,
|
||||
**params) -> BacktestResult | None:
|
||||
df = load_data(asset, tf)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
signals = self.generate_signals(df, ts, **params)
|
||||
if not signals:
|
||||
return None
|
||||
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c)
|
||||
fee = self.fee_rt * self.leverage
|
||||
capital = peak = float(self.initial_capital)
|
||||
max_dd = 0.0
|
||||
total_bars = 0
|
||||
last_exit = -1
|
||||
yearly: dict[int, dict] = {}
|
||||
|
||||
for sig in signals:
|
||||
i, d = sig.idx, sig.direction
|
||||
if i <= last_exit or i + 1 >= n:
|
||||
continue
|
||||
entry = c[i]
|
||||
tp, sl, mb = sig.metadata["tp"], sig.metadata["sl"], sig.metadata["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]
|
||||
j = min(i + mb, n - 1)
|
||||
for step in range(1, mb + 1):
|
||||
j = i + step
|
||||
if j >= n:
|
||||
j = n - 1; exit_p = c[j]; break
|
||||
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hit_sl: # conservativo: SL prima del TP nello stesso bar
|
||||
exit_p = sl; break
|
||||
if hit_tp:
|
||||
exit_p = tp; break
|
||||
if step == mb:
|
||||
exit_p = c[j]
|
||||
|
||||
ret = (exit_p - entry) / entry * d * self.leverage - fee
|
||||
capital = max(capital + capital * self.position_size * ret, 10.0)
|
||||
if capital > peak:
|
||||
peak = capital
|
||||
max_dd = max(max_dd, (peak - capital) / peak)
|
||||
total_bars += (j - i)
|
||||
last_exit = j
|
||||
|
||||
year = ts.iloc[i].year
|
||||
yr = yearly.setdefault(year, {"w": 0, "t": 0, "pnl": 0.0})
|
||||
yr["t"] += 1
|
||||
if ret > 0:
|
||||
yr["w"] += 1
|
||||
yr["pnl"] += ret * self.initial_capital
|
||||
|
||||
all_t = sum(v["t"] for v in yearly.values())
|
||||
all_w = sum(v["w"] for v in yearly.values())
|
||||
if all_t == 0:
|
||||
return None
|
||||
|
||||
yearly_stats = [YearlyStats(y, v["t"], v["w"], v["pnl"]) for y, v in sorted(yearly.items())]
|
||||
return BacktestResult(
|
||||
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
|
||||
trades=all_t, wins=all_w, pnl=sum(v["pnl"] for v in yearly.values()),
|
||||
capital=capital, initial_capital=self.initial_capital,
|
||||
max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100,
|
||||
avg_trade_duration_h=total_bars / all_t * TF_MINUTES.get(tf, 60) / 60,
|
||||
years_active=len(yearly), yearly=yearly_stats,
|
||||
)
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
defaults:
|
||||
capital: 1000
|
||||
position_size: 0.15
|
||||
leverage: 3
|
||||
hold_bars: 3
|
||||
poll_seconds: 60
|
||||
retrain_hours: 24
|
||||
|
||||
# Solo MR01 Bollinger fade (mean-reversion): unica con edge netto validato
|
||||
# out-of-sample e fee-aware. La famiglia squeeze e' in scripts/waste/.
|
||||
# ATTENZIONE: MR01 esce su TP-alla-media / SL-ad-ATR / max_bars (vedi metadata
|
||||
# dei Signal). Lo StrategyWorker attuale esce solo a hold_bars/stop -2% fisso:
|
||||
# va aggiornato per usare gli exit in metadata PRIMA di tradare MR01 dal vivo.
|
||||
strategies:
|
||||
- name: MR01_bollinger_fade
|
||||
asset: BTC
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
bb_window: 50
|
||||
k: 2.5
|
||||
sl_atr: 2.0
|
||||
max_bars: 24
|
||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||
ema_long: 200
|
||||
|
||||
# ETH: edge positivo ma DD piu' alto (~70%); leva piu' bassa consigliata
|
||||
- name: MR01_bollinger_fade
|
||||
asset: ETH
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
bb_window: 50
|
||||
k: 2.5
|
||||
sl_atr: 2.0
|
||||
max_bars: 24
|
||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||
ema_long: 200
|
||||
|
||||
# MR02 Donchian fade: fade rottura canale (estremi H/L). Robusto su tutta la
|
||||
# griglia n x sl_atr e tutte le fee. BTC +879%/+171% OOS (8/9 anni), ETH enorme.
|
||||
- name: MR02_donchian_fade
|
||||
asset: BTC
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
n: 20
|
||||
sl_atr: 2.0
|
||||
max_bars: 24
|
||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||
ema_long: 200
|
||||
- name: MR02_donchian_fade
|
||||
asset: ETH
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
n: 20
|
||||
sl_atr: 2.0
|
||||
max_bars: 24
|
||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||
ema_long: 200
|
||||
|
||||
# MR03 Keltner fade: fade canale ATR su EMA (banda indipendente da Bollinger).
|
||||
# Robusto su tutta la griglia n x k. BTC n30 k2.0 +112% OOS DD20%.
|
||||
# ETH: edge ampio ma DD pieno ~65% (tratto dell'asset, come MR01) -> leva bassa.
|
||||
- name: MR03_keltner_fade
|
||||
asset: BTC
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
n: 30
|
||||
k: 2.0
|
||||
sl_atr: 2.0
|
||||
max_bars: 24
|
||||
# NB: su MR03 BTC il filtro trend PEGGIORA Acc e DD (unico sleeve) -> disattivo.
|
||||
- name: MR03_keltner_fade
|
||||
asset: ETH
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
n: 50
|
||||
k: 2.0
|
||||
sl_atr: 2.0
|
||||
max_bars: 24
|
||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||
ema_long: 200
|
||||
|
||||
# MR07 Return reversal: fade movimento di barra estremo (z dei rendimenti).
|
||||
# Meccanismo distinto (volatilita' rendimenti, non livelli). Esposizione bassa
|
||||
# (~8%). BTC +447%/+105% OOS DD25%, ETH +335%/+195% OOS DD46%.
|
||||
- name: MR07_return_reversal
|
||||
asset: BTC
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
n: 50
|
||||
k: 3.5
|
||||
tp_atr: 2.0
|
||||
sl_atr: 1.5
|
||||
max_bars: 24
|
||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||
ema_long: 200
|
||||
- name: MR07_return_reversal
|
||||
asset: ETH
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
n: 50
|
||||
k: 3.5
|
||||
tp_atr: 2.0
|
||||
sl_atr: 1.5
|
||||
max_bars: 24
|
||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||
ema_long: 200
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user