Compare commits
23 Commits
988739b2f5
...
strategy3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e47956f7a | |||
| fa2d74be77 | |||
| 041db2191c | |||
| 185ac0d49b | |||
| 0ab3b5698a | |||
| 7639e5012b | |||
| 5930f366d1 | |||
| 613c2ccda1 | |||
| 2694a4a00c | |||
| f6e111f72d | |||
| e7be299b27 | |||
| a6056c4ac7 | |||
| a7b3c3c203 | |||
| 6755063c7b | |||
| 591f045cde | |||
| 8c4ddebe85 | |||
| bf26eb0439 | |||
| 6e9862c183 | |||
| 1617330d10 | |||
| c44f008e4d | |||
| 5a6821f958 | |||
| 6c3a5b4e77 | |||
| 19284d3001 |
@@ -0,0 +1,94 @@
|
||||
# PythagorasGoal — Istruzioni per agenti
|
||||
|
||||
## Panoramica
|
||||
|
||||
Progetto di ricerca: riconoscimento pattern frattali per trading algoritmico su criptovalute (BTC, ETH). L'obiettivo è arrivare a €50/giorno di profitto partendo da €1.000, entro 6–8 mesi.
|
||||
|
||||
## Stack
|
||||
|
||||
- **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)
|
||||
- **Analisi:** numpy, pandas, scipy
|
||||
- **API dati:** Cerbero MCP su `cerbero-mcp.tielogic.xyz` (Deribit, Bybit, Hyperliquid), ccxt/Binance come fallback
|
||||
|
||||
## Struttura
|
||||
|
||||
```
|
||||
src/data/ → download e caricamento dati (downloader.py)
|
||||
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
|
||||
src/backtest/ → engine di backtesting (engine.py)
|
||||
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
|
||||
docs/diary/ → diario di ricerca giornaliero
|
||||
data/raw/ → file .parquet OHLCV (gitignored)
|
||||
data/processed/ → modelli salvati (gitignored)
|
||||
```
|
||||
|
||||
## Comandi
|
||||
|
||||
```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 pytest # test
|
||||
```
|
||||
|
||||
## Dati storici
|
||||
|
||||
Scaricati e salvati localmente in Parquet. Per rigenerarli:
|
||||
|
||||
```python
|
||||
from src.data.downloader import download_all, load_data
|
||||
download_all() # scarica BTC + ETH su 5m/15m/1h dal 2018
|
||||
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 |
|
||||
|
||||
Tutte le strategie estendono `src.strategies.base.Strategy` con interfaccia comune:
|
||||
`generate_signals() → backtest() → report()`.
|
||||
|
||||
## Convenzioni
|
||||
|
||||
- Strategie in `scripts/strategies/` con codice univoco (SQ01, ML01, ...).
|
||||
- Script scartati in `scripts/waste/` con prefisso W01-W22.
|
||||
- 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]`.
|
||||
|
||||
## 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.
|
||||
- **Leva:** testato con 3x. Aumentare a 5x migliora i rendimenti ma raddoppia il drawdown.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
COPY src/ src/
|
||||
|
||||
VOLUME /app/data
|
||||
|
||||
CMD ["uv", "run", "python", "-m", "src.live.paper_trader"]
|
||||
@@ -0,0 +1,120 @@
|
||||
# PythagorasGoal
|
||||
|
||||
Sistema di riconoscimento pattern frattali e predizione per il trading di criptovalute (BTC, ETH), ispirato al framework teorico di Serleto & Malanga (*Pythagoras Trading Prediction*).
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di €50 al giorno entro 6–8 mesi, tramite strategie algoritmiche che combinano analisi frattale, squeeze di volatilità e machine learning.
|
||||
|
||||
## Risultati
|
||||
|
||||
Tredici strategie testate su dati storici 2018–2026 (BTC e ETH, timeframe 5m / 15m / 1h). Le migliori cinque:
|
||||
|
||||
| # | 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 |
|
||||
|
||||
La strategia vincente (#1) opera su ETH a 15 minuti con ~1 trade al giorno, leva 3x e drawdown contenuto al 4.2%.
|
||||
|
||||
## Come funziona
|
||||
|
||||
### Volatility Squeeze Breakout
|
||||
|
||||
Il meccanismo centrale sfrutta i cicli naturali di compressione ed espansione della volatilità:
|
||||
|
||||
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.
|
||||
|
||||
### Feature frattali
|
||||
|
||||
- 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
|
||||
|
||||
## Struttura progetto
|
||||
|
||||
```
|
||||
PythagorasGoal/
|
||||
├── src/
|
||||
│ ├── data/ # Download e gestione dati storici (Cerbero MCP + Binance)
|
||||
│ ├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity
|
||||
│ ├── backtest/ # Motore di backtesting con fee e metriche
|
||||
│ ├── strategies/ # (predisposto per strategie modulari)
|
||||
│ ├── nn/ # (predisposto per reti neurali)
|
||||
│ └── utils/
|
||||
├── scripts/ # Script di analisi e test (01–13)
|
||||
├── data/
|
||||
│ ├── raw/ # Parquet OHLCV (non committati, ~70 MB)
|
||||
│ └── processed/ # Modelli salvati
|
||||
├── docs/
|
||||
│ └── diary/ # Diario di ricerca giornaliero
|
||||
├── tests/
|
||||
├── pyproject.toml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Clona il repository
|
||||
git clone <repo-url> && cd PythagorasGoal
|
||||
|
||||
# Installa dipendenze (richiede uv)
|
||||
uv sync
|
||||
|
||||
# Scarica dati storici (~70 MB, richiede connessione)
|
||||
uv run python -m src.data.downloader
|
||||
|
||||
# Esegui la strategia ibrida vincente
|
||||
uv run python scripts/13_squeeze_ml_hybrid.py
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
## Dati
|
||||
|
||||
| Asset | Timeframe | Candele | Copertura |
|
||||
|-------|-----------|---------|-----------|
|
||||
| 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 |
|
||||
|
||||
## Riferimenti
|
||||
|
||||
- Serleto, L. & Malanga, C. — *Pythagoras Trading Prediction* (2024)
|
||||
- Serleto, L. & Malanga, C. — *Libro dei Frattali* (2024)
|
||||
|
||||
## Licenza
|
||||
|
||||
Uso privato. Non destinato alla distribuzione.
|
||||
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
paper-trader:
|
||||
build: .
|
||||
container_name: pythagoras-paper
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
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']"]
|
||||
interval: 120s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -64,9 +64,99 @@
|
||||
| ROI annuo >30% | max ~20% (structural) | serve +10% |
|
||||
| €50/giorno da €1000 | richiede ~5% daily | richiede crescita capitale su 6 mesi |
|
||||
|
||||
### 01:00 — Strategia 5 corretta (senza leakage)
|
||||
|
||||
**Reale dopo fix:** 53-58% accuracy (BTC LA=3 thr=0.65). Massimo 72.7% ma solo 11 trade. Conferma: senza leakage, edge tipico è 55-60%.
|
||||
|
||||
### 01:15 — SVOLTA: Strategia 11 — Volatility Squeeze Breakout
|
||||
|
||||
**Cosa:** approccio completamente diverso. Non predire la direzione direttamente. Identifica periodi di COMPRESSIONE (Bollinger dentro Keltner = squeeze), poi segui il breakout quando la volatilità ESPLODE.
|
||||
**Perché:** dopo compressione, il prezzo accumula "energia" e il breakout ha forte momentum direzionale. Approccio fisicamente motivato, non ML puro.
|
||||
**Atteso:** migliore di ML generico perché sfruttiamo un pattern strutturale ben definito
|
||||
**Reale:** **ECCEZIONALE**
|
||||
|
||||
| Config | Asset | TF | Trades | Accuracy | Ann. Return |
|
||||
|---|---|---|---|---|---|
|
||||
| BBw=20 sqThr=0.8 +VOL | ETH | 1h | 87 | **83.9%** | 22.2% |
|
||||
| BBw=30 sqThr=0.9 | ETH | 1h | 203 | **82.8%** | 46.8% |
|
||||
| BBw=20 sqThr=0.8 | ETH | 1h | 285 | **79.3%** | **65.7%** |
|
||||
| BBw=14 sqThr=0.8 | BTC | 1h | 438 | **77.6%** | **53.3%** |
|
||||
| BBw=14 sqThr=0.8 +VOL | BTC | 15m | 315 | **75.6%** | 6.0% |
|
||||
|
||||
**Lezione CRUCIALE:** gli approcci strutturali (compressione→espansione) battono ML generico di 20+ punti percentuali in accuracy. La struttura frattale del prezzo si manifesta nei cicli di compressione-espansione.
|
||||
|
||||
### Target assessment
|
||||
|
||||
| Target | Risultato | Status |
|
||||
|--------|-----------|--------|
|
||||
| Accuracy >80% | 83.9% (ETH 1h +VOL) | ✅ RAGGIUNTO |
|
||||
| ROI annuo >30% | 65.7% (ETH 1h) | ✅ RAGGIUNTO |
|
||||
| Fees considerate | 0.1% maker/taker | ✅ |
|
||||
|
||||
### 01:30 — Strategia 9: Walk-forward ML — COMPLETATA
|
||||
|
||||
**Cosa:** GBM con features structural+fractal, walk-forward validation (train 15K, step 3K), BTC e ETH su 2 lookahead × 4 threshold
|
||||
**Reale:**
|
||||
- BTC: max 58.4% acc, +75% ret, 8.8% ann, Sharpe 3.27 (LA=3, thr=0.70)
|
||||
- **ETH LA=3 thr=0.70: 57.7% acc, +758% ret, 38.1% ann, Sharpe 7.40, €3.12/giorno**
|
||||
- **ETH LA=6 thr=0.70: 56.5% acc, +1994% ret, 57.9% ann, Sharpe 6.72, €8.20/giorno**
|
||||
**Lezione:** walk-forward elimina il bias del singolo split. ETH più predittibile di BTC con ML. Sharpe >7 eccezionale per un sistema reale. Drawdown alto (47-52%) → servono nervi saldi.
|
||||
|
||||
### TOP 5 DEFINITIVO (aggiornato con strategia 9)
|
||||
|
||||
| # | Nome | Acc. | ROI ann | Sharpe | DD | €/day | Best for |
|
||||
|---|------|------|---------|--------|----|-------|----------|
|
||||
| 1 | ETH Squeeze+Vol (BBw=20) | **83.9%** | 22.2% | - | 2.0% | €0.71 | Precisione |
|
||||
| 2 | ETH Squeeze (BBw=30,sq=0.9) | **82.8%** | **46.8%** | - | 3.2% | €1.77 | Bilanciato |
|
||||
| 3 | ETH WF-ML (LA=3,thr=0.70) | 57.7% | **38.1%** | **7.40** | 47% | **€3.12** | Daily PnL |
|
||||
| 4 | ETH Squeeze aggressivo | 79.3% | **65.7%** | - | 3.6% | €2.79 | Max ROI |
|
||||
| 5 | ETH WF-ML (LA=6,thr=0.70) | 56.5% | **57.9%** | **6.72** | 53% | **€8.20** | Max growth |
|
||||
|
||||
### Piano operativo consigliato
|
||||
|
||||
**Fase 1 (mesi 1-3):** usa M2 (squeeze BBw=30, 82.8% acc, 3.2% DD) per crescita sicura
|
||||
**Fase 2 (mesi 4-6):** aggiungi M3 (WF-ML) per accelerare crescita con capitale più alto
|
||||
**Fase 3 (mese 6+):** combina entrambi — squeeze per trade sicuri, ML per volume
|
||||
|
||||
### 02:00 — Strategia 13: Squeeze + ML IBRIDA — IL VINCITORE
|
||||
|
||||
**Cosa:** squeeze breakout come pre-filtro (QUANDO tradare), GBM su features frattali/strutturali come conferma direzionale (QUALE direzione). Walk-forward validation. 12 configurazioni testate su BTC + ETH, 1h + 15m.
|
||||
**Atteso:** combinare accuratezza squeeze (>80%) con volume trade ML
|
||||
**Reale:**
|
||||
|
||||
**Vincitore assoluto: ETH 15m BBw=14 sq=0.8 ml_thr=0.70**
|
||||
- 76.9% accuracy, 118.1% annualizzato, 4.2% max drawdown
|
||||
- **€13.78/giorno da €1000** (!!)
|
||||
- 1213 trades nel test, ~313/anno → ~1 trade/giorno
|
||||
- Con 3x leva, 15% position size
|
||||
|
||||
**Runner-up: BTC 15m BBw=14 sq=0.9 ml_thr=0.70**
|
||||
- 78.8% accuracy, 68.8% ann, 7% DD, €5.51/day
|
||||
|
||||
**Osservazioni chiave:**
|
||||
1. Il 15m batte il 1h per frequenza trade (più segnali di squeeze a timeframe basso)
|
||||
2. ML non migliora drammaticamente l'accuracy rispetto a squeeze puro (baseline ETH 15m squeeze: 79.5%) ma RIDUCE il drawdown (da ~8% a 4.2%)
|
||||
3. Il vero valore del ML è nel filtraggio: scarta i breakout deboli, tiene i forti
|
||||
4. ETH più predittibile di BTC in tutte le configurazioni
|
||||
|
||||
**Piano per €50/giorno:**
|
||||
- Capitale attuale: €1000
|
||||
- Crescita stimata: 118% annuo
|
||||
- €1000 → €3600 in ~8 mesi
|
||||
- €3600 × €13.78/€1000 = €49.60/giorno ≈ target
|
||||
|
||||
### TOP 5 DEFINITIVO FINALE
|
||||
|
||||
| # | Config | Acc. | Ann. | DD | €/day | Tipo |
|
||||
|---|--------|------|------|----|-------|------|
|
||||
| 1 | ETH 15m Squeeze+ML (BBw=14,sq=0.8,ml=0.70) | 76.9% | **118%** | 4.2% | **€13.78** | Ibrido |
|
||||
| 2 | ETH 1h Squeeze+Vol (BBw=20,sq=0.8) | **83.9%** | 22% | 2.0% | €0.71 | Strutturale |
|
||||
| 3 | BTC 15m Squeeze+ML (BBw=14,sq=0.9,ml=0.70) | **78.8%** | 69% | 7.0% | €5.51 | Ibrido |
|
||||
| 4 | ETH 1h Squeeze (BBw=30,sq=0.9) | **82.8%** | **47%** | 3.2% | €1.77 | Strutturale |
|
||||
| 5 | ETH WF-ML (LA=3,thr=0.70) | 57.7% | 38% | 47% | €3.12 | ML puro |
|
||||
|
||||
### Prossimi passi
|
||||
|
||||
1. Verificare strategia 5 corretta (senza leakage)
|
||||
2. Risultati strategia 9 (walk-forward) e 10 (high precision ensemble)
|
||||
3. Se accuracy ancora insufficiente: provare features da 5m aggregati, o approach completamente diverso (reinforcement learning?)
|
||||
4. Valutare combinazione: multi-asset (BTC+ETH) per diversificazione
|
||||
1. Implementare sistema live con Cerbero MCP per segnali real-time
|
||||
2. Paper trading per 2-4 settimane prima di capitale reale
|
||||
3. Risk management: stop-loss, max daily loss, correlation filter
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Confronto migliori strategie S1 e S2 — andamento per anno."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
from src.fractal.patterns import encode_candles
|
||||
|
||||
FEE_PERP = 0.002 # 0.1% taker roundtrip perpetual
|
||||
FEE_OPT = 0.0052 # options roundtrip
|
||||
INITIAL = 1000
|
||||
LEVERAGE = 3
|
||||
|
||||
|
||||
def keltner_ratio(close, high, low, window=14):
|
||||
n = len(close)
|
||||
r = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc = (ma+1.5*atr)-(ma-1.5*atr)
|
||||
bb = (ma+2*bb_std)-(ma-2*bb_std)
|
||||
if kc > 0:
|
||||
r[i] = bb/kc
|
||||
return r
|
||||
|
||||
|
||||
def rv_ann(close, window):
|
||||
lr = np.diff(np.log(np.where(close==0, 1e-10, close)))
|
||||
r = np.full(len(close), np.nan)
|
||||
for i in range(window, len(lr)):
|
||||
r[i+1] = np.std(lr[i-window:i]) * np.sqrt(24*365)
|
||||
return r
|
||||
|
||||
|
||||
def rsi(close, period=14):
|
||||
delta = np.diff(close)
|
||||
gain = np.where(delta>0, delta, 0)
|
||||
loss = np.where(delta<0, -delta, 0)
|
||||
result = np.full(len(close), 50.0)
|
||||
if len(gain) < period:
|
||||
return result
|
||||
ag = np.mean(gain[:period])
|
||||
al = np.mean(loss[:period])
|
||||
for i in range(period, len(delta)):
|
||||
ag = (ag*(period-1)+gain[i])/period
|
||||
al = (al*(period-1)+loss[i])/period
|
||||
result[i+1] = 100 if al == 0 else 100-100/(1+ag/al)
|
||||
return result
|
||||
|
||||
|
||||
def ema(arr, period):
|
||||
r = np.full(len(arr), np.nan)
|
||||
k = 2/(period+1)
|
||||
r[period-1] = np.mean(arr[:period])
|
||||
for i in range(period, len(arr)):
|
||||
r[i] = arr[i]*k + r[i-1]*(1-k)
|
||||
return r
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# S1 BEST: Squeeze Breakout ETH 1h (BBw=14, sq=0.8, brk=3)
|
||||
# =====================================================================
|
||||
def run_s1_squeeze(asset, tf):
|
||||
df = load_data(asset, tf)
|
||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
||||
n = len(c)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
kcr = keltner_ratio(c, h, l, 14)
|
||||
|
||||
yearly = {}
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
|
||||
for i in range(15, n):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < 0.8
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
if i - sq_start < 5 or i + 3 >= n:
|
||||
continue
|
||||
first_ret = (c[i] - c[i-1]) / c[i-1]
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
actual = (c[i+2] - c[i-1]) / c[i-1]
|
||||
trade_ret = actual * direction
|
||||
net = trade_ret * LEVERAGE - FEE_PERP * LEVERAGE
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"pnls": [], "wins": 0, "total": 0}
|
||||
yearly[year]["pnls"].append(net)
|
||||
yearly[year]["total"] += 1
|
||||
if trade_ret > 0:
|
||||
yearly[year]["wins"] += 1
|
||||
|
||||
return yearly
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# S1 BEST ALT: Squeeze+ML hybrid ETH 15m
|
||||
# =====================================================================
|
||||
# Troppo complesso da ricalcolare (serve ML training). Uso i dati S1 squeeze puro.
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# S2 BEST: VRP ETH 48h (con IV stimata, unico disponibile su 8 anni)
|
||||
# =====================================================================
|
||||
def run_s2_vrp(asset, dte=48):
|
||||
df = load_data(asset, "1h")
|
||||
c = df["close"].values
|
||||
n = len(c)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
rv_24 = rv_ann(c, 24)
|
||||
rv_168 = rv_ann(c, 168)
|
||||
|
||||
yearly = {}
|
||||
for i in range(170, n - dte):
|
||||
if ts.iloc[i].hour != 8:
|
||||
continue
|
||||
rv_s, rv_l = rv_24[i], rv_168[i]
|
||||
if np.isnan(rv_s) or np.isnan(rv_l) or rv_s < 0.05 or rv_l < 0.05:
|
||||
continue
|
||||
regime = rv_s / rv_l
|
||||
iv_pf = 0.9 if regime > 2 else (1.0 if regime > 1.5 else (1.1 if regime > 1 else 1.2))
|
||||
iv = rv_l * iv_pf
|
||||
prem = iv * np.sqrt(dte/(24*365)) * 0.8
|
||||
spot = c[i]
|
||||
move = abs(c[min(i+dte, n-1)] - spot) / spot
|
||||
pos = 0.10
|
||||
raw = (prem - move) * pos if move <= prem else max(-(move-prem)*pos, -pos*0.05)
|
||||
net = raw - FEE_OPT * pos
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"pnls": [], "wins": 0, "total": 0}
|
||||
yearly[year]["pnls"].append(net)
|
||||
yearly[year]["total"] += 1
|
||||
if raw > 0:
|
||||
yearly[year]["wins"] += 1
|
||||
return yearly
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# S2 BEST PERPETUAL: Multi-TF 15m+1h BTC
|
||||
# =====================================================================
|
||||
def run_s2_multitf(asset):
|
||||
df_1h = load_data(asset, "1h")
|
||||
df_15m = load_data(asset, "15m")
|
||||
c1h = df_1h["close"].values
|
||||
ts1h = pd.to_datetime(df_1h["timestamp"], unit="ms", utc=True)
|
||||
c15 = df_15m["close"].values
|
||||
ts15 = df_15m["timestamp"].values
|
||||
n15 = len(c15)
|
||||
|
||||
ema_50 = ema(c1h, 50)
|
||||
rsi_15m = rsi(c15, 14)
|
||||
|
||||
yearly = {}
|
||||
daily_done = set()
|
||||
|
||||
for i in range(100, n15 - 12):
|
||||
ts_dt = pd.Timestamp(ts15[i], unit="ms", tz="UTC")
|
||||
day = ts_dt.strftime("%Y-%m-%d")
|
||||
if day in daily_done:
|
||||
continue
|
||||
if rsi_15m[i] > 35 and rsi_15m[i] < 65:
|
||||
continue
|
||||
h_idx = np.searchsorted(ts1h.values.astype("int64"), ts15[i]) - 1
|
||||
if h_idx < 50 or h_idx >= len(c1h) or np.isnan(ema_50[h_idx]):
|
||||
continue
|
||||
|
||||
direction = None
|
||||
if rsi_15m[i] < 30 and c1h[h_idx] > ema_50[h_idx]:
|
||||
direction = "long"
|
||||
elif rsi_15m[i] > 70 and c1h[h_idx] < ema_50[h_idx]:
|
||||
direction = "short"
|
||||
if direction is None:
|
||||
continue
|
||||
|
||||
entry = c15[i]
|
||||
exit_price = c15[min(i+12, n15-1)]
|
||||
trade_ret = (exit_price-entry)/entry if direction == "long" else (entry-exit_price)/entry
|
||||
net = trade_ret * LEVERAGE - FEE_PERP * LEVERAGE
|
||||
|
||||
year = ts_dt.year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"pnls": [], "wins": 0, "total": 0}
|
||||
yearly[year]["pnls"].append(net)
|
||||
yearly[year]["total"] += 1
|
||||
if trade_ret > 0:
|
||||
yearly[year]["wins"] += 1
|
||||
daily_done.add(day)
|
||||
return yearly
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# REPORT
|
||||
# =====================================================================
|
||||
strategies = {
|
||||
"S1: Squeeze BTC 1h": run_s1_squeeze("BTC", "1h"),
|
||||
"S1: Squeeze ETH 1h": run_s1_squeeze("ETH", "1h"),
|
||||
"S1: Squeeze ETH 15m": run_s1_squeeze("ETH", "15m"),
|
||||
"S2: VRP ETH 48h (IV est)": run_s2_vrp("ETH", 48),
|
||||
"S2: VRP BTC 48h (IV est)": run_s2_vrp("BTC", 48),
|
||||
"S2: MultiTF BTC 15m+1h": run_s2_multitf("BTC"),
|
||||
"S2: MultiTF ETH 15m+1h": run_s2_multitf("ETH"),
|
||||
}
|
||||
|
||||
all_years = sorted(set(y for v in strategies.values() for y in v))
|
||||
|
||||
print("=" * 120)
|
||||
print(" MIGLIORI STRATEGIE — ANDAMENTO PER ANNO")
|
||||
print(" Fee reali. PnL su €1000 flat (no compounding). Dati OHLCV reali 2018-2026.")
|
||||
print(" ⚠ VRP usa IV STIMATA (non reale) — fidarsi solo dei dati perpetual per backtest lungo")
|
||||
print("=" * 120)
|
||||
|
||||
# Header
|
||||
hdr = f" {'Anno':>6s}"
|
||||
for name in strategies:
|
||||
short = name.split(": ")[1][:18]
|
||||
hdr += f" | {short:>18s}"
|
||||
print(hdr)
|
||||
print(f" {'-' * (len(hdr) - 2)}")
|
||||
|
||||
# Per anno: accuracy / PnL totale
|
||||
for year in all_years:
|
||||
row_acc = f" {year:>6d}"
|
||||
row_pnl = f" {'':>6s}"
|
||||
for name, yearly in strategies.items():
|
||||
if year in yearly:
|
||||
d = yearly[year]
|
||||
acc = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
|
||||
pnl = sum(d["pnls"]) * INITIAL
|
||||
tag = "▓" if acc >= 75 else "▒" if acc >= 65 else "░" if acc >= 55 else " "
|
||||
row_acc += f" | {acc:>5.1f}% {tag} {d['total']:>3d}t"
|
||||
row_pnl += f" | €{pnl:>+8.0f} "
|
||||
else:
|
||||
row_acc += f" | {'—':>18s}"
|
||||
row_pnl += f" | {'':>18s}"
|
||||
print(row_acc)
|
||||
print(row_pnl)
|
||||
|
||||
# Totali
|
||||
print(f" {'-' * (len(hdr) - 2)}")
|
||||
row_tot = f" {'TOT':>6s}"
|
||||
for name, yearly in strategies.items():
|
||||
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
|
||||
all_wins = sum(d["wins"] for d in yearly.values())
|
||||
all_total = sum(d["total"] for d in yearly.values())
|
||||
acc = all_wins/all_total*100 if all_total > 0 else 0
|
||||
pnl = sum(all_pnls) * INITIAL
|
||||
row_tot += f" | {acc:>5.1f}% {all_total:>4d}t"
|
||||
print(row_tot)
|
||||
|
||||
row_pnl_tot = f" {'€TOT':>6s}"
|
||||
for name, yearly in strategies.items():
|
||||
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
|
||||
pnl = sum(all_pnls) * INITIAL
|
||||
row_pnl_tot += f" | €{pnl:>+8.0f} "
|
||||
print(row_pnl_tot)
|
||||
|
||||
# Compounding
|
||||
print(f"\n {'':>6s}", end="")
|
||||
for name in strategies:
|
||||
short = name.split(": ")[1][:18]
|
||||
print(f" | {short:>18s}", end="")
|
||||
print()
|
||||
|
||||
row_comp = f" {'COMP':>6s}"
|
||||
for name, yearly in strategies.items():
|
||||
cap = float(INITIAL)
|
||||
for year in sorted(yearly):
|
||||
for pnl in yearly[year]["pnls"]:
|
||||
cap += cap * pnl
|
||||
cap = max(cap, 10)
|
||||
row_comp += f" | €{cap:>12,.0f} "
|
||||
print(row_comp)
|
||||
|
||||
# Drawdown
|
||||
row_dd = f" {'MAXDD':>6s}"
|
||||
for name, yearly in strategies.items():
|
||||
cap = float(INITIAL)
|
||||
peak = cap
|
||||
mdd = 0
|
||||
for year in sorted(yearly):
|
||||
for pnl in yearly[year]["pnls"]:
|
||||
cap += cap * pnl
|
||||
cap = max(cap, 10)
|
||||
if cap > peak: peak = cap
|
||||
dd = (peak - cap) / peak
|
||||
mdd = max(mdd, dd)
|
||||
row_dd += f" | {mdd*100:>12.1f}% "
|
||||
print(row_dd)
|
||||
|
||||
# Legenda
|
||||
print(f"\n Legenda: ▓ ≥75% acc ▒ ≥65% acc ░ ≥55% acc")
|
||||
print(f" ⚠ S2 VRP: IV stimata (rv_long × 1.0-1.2), NON dati reali opzioni")
|
||||
print(f" S1 Squeeze e S2 MultiTF: dati OHLCV reali al 100%")
|
||||
@@ -0,0 +1,559 @@
|
||||
"""Analisi finale — S1 (squeeze puro) vs Script 13 (squeeze+ML GBM).
|
||||
Metriche: PnL, num trades, DD max, tempo medio a mercato, descrizione.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.ensemble import GradientBoostingClassifier
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
from src.data.downloader import load_data
|
||||
from src.fractal.patterns import encode_candles
|
||||
|
||||
FEE_PERP = 0.002
|
||||
FEE_ML = 0.001
|
||||
INITIAL = 1000
|
||||
LEVERAGE = 3
|
||||
|
||||
TF_MINUTES = {"1m": 1, "5m": 5, "15m": 15, "1h": 60, "4h": 240, "1d": 1440}
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def keltner_ratio(close, high, low, window=14):
|
||||
n = len(close)
|
||||
r = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc = (ma+1.5*atr)-(ma-1.5*atr)
|
||||
bb = (ma+2*bb_std)-(ma-2*bb_std)
|
||||
if kc > 0:
|
||||
r[i] = bb/kc
|
||||
return r
|
||||
|
||||
|
||||
def detect_squeezes(close, high, low, kcr, sq_thr=0.8, min_dur=5):
|
||||
events = []
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
for i in range(1, len(close)):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < sq_thr
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
dur = i - sq_start
|
||||
if dur < min_dur:
|
||||
continue
|
||||
events.append({"idx": i, "dur": dur, "sq_start": sq_start,
|
||||
"avg_vol_squeeze": np.mean(close[sq_start:i]),
|
||||
"kcr_at_release": kcr[i]})
|
||||
return events
|
||||
|
||||
|
||||
def _build_result(yearly, capital, max_dd, all_t, all_w, time_pct, avg_dur_h):
|
||||
acc = all_w / all_t * 100
|
||||
tot_pnl = sum(p for d in yearly.values() for p in d["pnls"])
|
||||
years_active = len(yearly)
|
||||
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
|
||||
sharpe = np.mean(all_pnls) / np.std(all_pnls) * np.sqrt(252) if len(all_pnls) > 1 and np.std(all_pnls) > 0 else 0
|
||||
|
||||
year_details = {}
|
||||
for y in sorted(yearly):
|
||||
d = yearly[y]
|
||||
ya = d["w"] / d["t"] * 100 if d["t"] > 0 else 0
|
||||
yp = sum(d["pnls"])
|
||||
year_details[y] = {"trades": d["t"], "acc": ya, "pnl": yp}
|
||||
|
||||
valid_years = {y: d for y, d in year_details.items() if d["trades"] >= 10}
|
||||
if valid_years:
|
||||
worst_y = min(valid_years, key=lambda y: valid_years[y]["acc"])
|
||||
worst_acc = valid_years[worst_y]["acc"]
|
||||
elif year_details:
|
||||
worst_y = min(year_details, key=lambda y: year_details[y]["acc"])
|
||||
worst_acc = year_details[worst_y]["acc"]
|
||||
else:
|
||||
worst_y = "N/A"
|
||||
worst_acc = 0
|
||||
|
||||
daily_pnl = tot_pnl / (years_active * 365) if years_active > 0 else 0
|
||||
|
||||
return {
|
||||
"trades": all_t, "acc": acc, "pnl": tot_pnl, "capital": capital,
|
||||
"max_dd": max_dd * 100, "sharpe": sharpe, "daily_pnl": daily_pnl,
|
||||
"time_in_market_pct": time_pct, "avg_dur_h": avg_dur_h,
|
||||
"years_active": years_active, "worst_year": str(worst_y),
|
||||
"worst_acc": worst_acc, "year_details": year_details,
|
||||
}
|
||||
|
||||
|
||||
# ── S1: Squeeze breakout puro ────────────────────────────────────────
|
||||
|
||||
def run_s1_squeeze(asset, tf, hold=3):
|
||||
df = load_data(asset, tf)
|
||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
||||
n = len(c)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
kcr = keltner_ratio(c, h, l, 14)
|
||||
events = detect_squeezes(c, h, l, kcr)
|
||||
|
||||
yearly = {}
|
||||
capital = float(INITIAL)
|
||||
peak = capital
|
||||
max_dd = 0
|
||||
total_bars = 0
|
||||
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i + hold + 1 >= n:
|
||||
continue
|
||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
entry = c[i-1]
|
||||
exit_price = c[min(i + hold - 1, n - 1)]
|
||||
actual = (exit_price - entry) / entry * direction
|
||||
net = actual * LEVERAGE - FEE_PERP * LEVERAGE
|
||||
|
||||
capital += capital * 0.15 * net
|
||||
capital = max(capital, 10)
|
||||
if capital > peak: peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
total_bars += hold
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0: yearly[year]["w"] += 1
|
||||
yearly[year]["pnls"].append(net * INITIAL)
|
||||
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t == 0: return None
|
||||
return _build_result(yearly, capital, max_dd, all_t, all_w,
|
||||
total_bars / n * 100, hold * TF_MINUTES.get(tf, 60) / 60)
|
||||
|
||||
|
||||
def run_s1_antifake_vol(asset, tf, hold=3):
|
||||
df = load_data(asset, tf)
|
||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
||||
n = len(c)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
kcr = keltner_ratio(c, h, l, 14)
|
||||
events = detect_squeezes(c, h, l, kcr)
|
||||
|
||||
yearly = {}
|
||||
capital = float(INITIAL)
|
||||
peak = capital
|
||||
max_dd = 0
|
||||
total_bars = 0
|
||||
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i + hold + 1 >= n:
|
||||
continue
|
||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
br = h[i] - l[i]
|
||||
if br > 0:
|
||||
if c[i] > c[i-1]:
|
||||
if (h[i] - c[i]) / br > 0.6:
|
||||
continue
|
||||
else:
|
||||
if (c[i] - l[i]) / br > 0.6:
|
||||
continue
|
||||
avg_v = np.mean(v[ev["sq_start"]:i])
|
||||
if avg_v > 0 and v[i] <= avg_v * 1.3:
|
||||
continue
|
||||
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
entry = c[i-1]
|
||||
exit_price = c[min(i + hold - 1, n - 1)]
|
||||
actual = (exit_price - entry) / entry * direction
|
||||
net = actual * LEVERAGE - FEE_PERP * LEVERAGE
|
||||
capital += capital * 0.15 * net
|
||||
capital = max(capital, 10)
|
||||
if capital > peak: peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
total_bars += hold
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0: yearly[year]["w"] += 1
|
||||
yearly[year]["pnls"].append(net * INITIAL)
|
||||
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t == 0: return None
|
||||
return _build_result(yearly, capital, max_dd, all_t, all_w,
|
||||
total_bars / n * 100, hold * TF_MINUTES.get(tf, 60) / 60)
|
||||
|
||||
|
||||
# ── Script 13: Squeeze + ML ibrida (GBM walk-forward) ────────────────
|
||||
|
||||
def build_features_at(df, i, squeeze_info):
|
||||
if i < 100:
|
||||
return None
|
||||
o = df["open"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
c = df["close"].values
|
||||
v = df["volume"].values
|
||||
feats = []
|
||||
for w in [12, 24, 48]:
|
||||
win_c = c[i-w:i]
|
||||
win_o = o[i-w:i]
|
||||
win_h = h[i-w:i]
|
||||
win_l = l[i-w:i]
|
||||
win_v = v[i-w:i]
|
||||
mn, mx = win_l.min(), max(win_h.max(), win_c.max())
|
||||
rng = mx - mn if mx - mn > 0 else 1e-10
|
||||
total = win_h - win_l
|
||||
total = np.where(total == 0, 1e-10, total)
|
||||
body = np.abs(win_c - win_o) / total
|
||||
direction = np.sign(win_c - win_o)
|
||||
log_c = np.log(np.where(win_c == 0, 1e-10, win_c))
|
||||
rets = np.diff(log_c)
|
||||
v_mean = np.mean(win_v)
|
||||
feats.extend([
|
||||
np.mean(rets) if len(rets) > 0 else 0,
|
||||
np.std(rets) if len(rets) > 0 else 0,
|
||||
np.sum(rets) if len(rets) > 0 else 0,
|
||||
float(pd.Series(rets).skew()) if len(rets) > 2 else 0,
|
||||
float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0,
|
||||
np.mean(body), np.std(body),
|
||||
np.mean(direction), np.mean(direction[-min(3, w):]),
|
||||
(win_c[-1] - mn) / rng,
|
||||
win_v[-1] / v_mean if v_mean > 0 else 1,
|
||||
np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0,
|
||||
])
|
||||
sq = squeeze_info
|
||||
feats.extend([
|
||||
sq["dur"], sq["dur"] / 24, sq["kcr_at_release"],
|
||||
v[i-1] / sq["avg_vol_squeeze"] if sq["avg_vol_squeeze"] > 0 else 1,
|
||||
np.mean(v[i:min(i+3, len(v))]) / sq["avg_vol_squeeze"] if sq["avg_vol_squeeze"] > 0 else 1,
|
||||
])
|
||||
h48 = np.max(h[max(0, i-48):i])
|
||||
l48 = np.min(l[max(0, i-48):i])
|
||||
r48 = h48 - l48
|
||||
feats.append((c[i-1] - l48) / r48 if r48 > 0 else 0.5)
|
||||
tr = np.maximum(h[i-14:i] - l[i-14:i],
|
||||
np.maximum(np.abs(h[i-14:i] - np.roll(c[i-14:i], 1)),
|
||||
np.abs(l[i-14:i] - np.roll(c[i-14:i], 1))))
|
||||
atr = np.mean(tr[1:])
|
||||
feats.append(atr / c[i-1] if c[i-1] > 0 else 0)
|
||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
||||
feats.append(first_ret)
|
||||
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
|
||||
|
||||
|
||||
def run_s13_hybrid(asset, tf, bb_w, sq_thr, brk_bars, leverage, pos_pct, ml_thr):
|
||||
df = load_data(asset, tf)
|
||||
close = df["close"].values
|
||||
high = df["high"].values
|
||||
low = df["low"].values
|
||||
volume = df["volume"].values
|
||||
n = len(df)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
kcr = keltner_ratio(close, high, low, bb_w)
|
||||
events = detect_squeezes(close, high, low, kcr, sq_thr)
|
||||
|
||||
X_all, y_all, ev_all = [], [], []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i + brk_bars >= n or i < 100:
|
||||
continue
|
||||
feats = build_features_at(df, i, ev)
|
||||
if feats is None:
|
||||
continue
|
||||
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
|
||||
X_all.append(feats)
|
||||
y_all.append(1 if actual_ret > 0 else 0)
|
||||
ev_all.append(ev)
|
||||
|
||||
if len(X_all) < 50:
|
||||
return None
|
||||
|
||||
X = np.array(X_all)
|
||||
y = np.array(y_all)
|
||||
|
||||
TRAIN_SIZE = max(int(len(X) * 0.5), 50)
|
||||
STEP_SIZE = max(int(len(X) * 0.1), 10)
|
||||
|
||||
yearly = {}
|
||||
capital = float(INITIAL)
|
||||
peak = capital
|
||||
max_dd = 0
|
||||
total_bars = 0
|
||||
all_t = 0
|
||||
all_w = 0
|
||||
|
||||
start = 0
|
||||
while start + TRAIN_SIZE + STEP_SIZE <= len(X):
|
||||
train_end = start + TRAIN_SIZE
|
||||
test_end = min(train_end + STEP_SIZE, len(X))
|
||||
X_tr, y_tr = X[start:train_end], y[start:train_end]
|
||||
X_te = X[train_end:test_end]
|
||||
|
||||
if len(np.unique(y_tr)) < 2:
|
||||
start += STEP_SIZE
|
||||
continue
|
||||
|
||||
scaler = StandardScaler()
|
||||
X_tr_s = scaler.fit_transform(X_tr)
|
||||
X_te_s = scaler.transform(X_te)
|
||||
|
||||
model = GradientBoostingClassifier(
|
||||
n_estimators=150, max_depth=4, min_samples_leaf=10,
|
||||
learning_rate=0.05, subsample=0.8, random_state=42,
|
||||
)
|
||||
model.fit(X_tr_s, y_tr)
|
||||
|
||||
up_idx = list(model.classes_).index(1) if 1 in model.classes_ else -1
|
||||
if up_idx < 0:
|
||||
start += STEP_SIZE
|
||||
continue
|
||||
|
||||
for j in range(len(X_te)):
|
||||
proba = model.predict_proba(X_te_s[j:j+1])[0]
|
||||
p_up = proba[up_idx]
|
||||
|
||||
ev = ev_all[train_end + j]
|
||||
i = ev["idx"]
|
||||
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
|
||||
|
||||
direction = None
|
||||
if p_up >= ml_thr:
|
||||
direction = 1
|
||||
elif p_up <= (1 - ml_thr):
|
||||
direction = -1
|
||||
if direction is None:
|
||||
continue
|
||||
|
||||
is_correct = (direction == 1 and actual_ret > 0) or (direction == -1 and actual_ret < 0)
|
||||
trade_ret = actual_ret * direction
|
||||
net = trade_ret * leverage - FEE_ML * 2 * leverage
|
||||
capital += capital * pos_pct * net
|
||||
capital = max(capital, 10)
|
||||
if capital > peak: peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
total_bars += brk_bars
|
||||
|
||||
all_t += 1
|
||||
if is_correct: all_w += 1
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
||||
yearly[year]["t"] += 1
|
||||
if is_correct: yearly[year]["w"] += 1
|
||||
yearly[year]["pnls"].append(net * INITIAL)
|
||||
|
||||
start += STEP_SIZE
|
||||
|
||||
if all_t == 0:
|
||||
return None
|
||||
return _build_result(yearly, capital, max_dd, all_t, all_w,
|
||||
total_bars / n * 100, brk_bars * TF_MINUTES.get(tf, 60) / 60)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# ESECUZIONE
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
print("Calcolo in corso...\n")
|
||||
|
||||
strategies = []
|
||||
|
||||
def add(name, desc, cat, result):
|
||||
if result and result["trades"] >= 20:
|
||||
strategies.append({"name": name, "desc": desc, "cat": cat, **result})
|
||||
|
||||
# ── S1: Squeeze puro ────────────────────────────────────────────
|
||||
add("S1 Squeeze BTC 15m", "Squeeze breakout puro, BBw=14, hold 3×15m, leva 3x",
|
||||
"S1", run_s1_squeeze("BTC", "15m"))
|
||||
add("S1 Squeeze ETH 15m", "Squeeze breakout puro, BBw=14, hold 3×15m, leva 3x",
|
||||
"S1", run_s1_squeeze("ETH", "15m"))
|
||||
add("S1 Squeeze BTC 1h", "Squeeze breakout puro, BBw=14, hold 3×1h, leva 3x",
|
||||
"S1", run_s1_squeeze("BTC", "1h"))
|
||||
add("S1 Squeeze ETH 1h", "Squeeze breakout puro, BBw=14, hold 3×1h, leva 3x",
|
||||
"S1", run_s1_squeeze("ETH", "1h"))
|
||||
add("S1 AF+Vol BTC 15m", "Squeeze + antifakeout + volume confirm >1.3× media",
|
||||
"S1", run_s1_antifake_vol("BTC", "15m"))
|
||||
add("S1 AF+Vol ETH 15m", "Squeeze + antifakeout + volume confirm >1.3× media",
|
||||
"S1", run_s1_antifake_vol("ETH", "15m"))
|
||||
add("S1 AF+Vol BTC 1h", "Squeeze + antifakeout + volume confirm >1.3× media",
|
||||
"S1", run_s1_antifake_vol("BTC", "1h"))
|
||||
add("S1 AF+Vol ETH 1h", "Squeeze + antifakeout + volume confirm >1.3× media",
|
||||
"S1", run_s1_antifake_vol("ETH", "1h"))
|
||||
|
||||
# ── Script 13: Squeeze + ML (GBM walk-forward) ─────────────────
|
||||
print(" Training ML models...")
|
||||
add("S13 ETH 15m bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.70, 3x leva 15% pos",
|
||||
"S13", run_s13_hybrid("ETH", "15m", 14, 0.8, 3, 3, 0.15, 0.70))
|
||||
add("S13 ETH 15m bb14 ml65", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.65, 3x leva 15% pos",
|
||||
"S13", run_s13_hybrid("ETH", "15m", 14, 0.8, 3, 3, 0.15, 0.65))
|
||||
add("S13 ETH 15m bb20 ml70", "Squeeze+GBM walk-forward, BBw=20 sq=0.9 ml≥0.70, 3x leva 15% pos",
|
||||
"S13", run_s13_hybrid("ETH", "15m", 20, 0.9, 3, 3, 0.15, 0.70))
|
||||
add("S13 BTC 15m bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.9 ml≥0.70, 3x leva 15% pos",
|
||||
"S13", run_s13_hybrid("BTC", "15m", 14, 0.9, 3, 3, 0.15, 0.70))
|
||||
add("S13 BTC 15m bb14 ml65", "Squeeze+GBM walk-forward, BBw=14 sq=0.9 ml≥0.65, 3x leva 15% pos",
|
||||
"S13", run_s13_hybrid("BTC", "15m", 14, 0.9, 3, 3, 0.15, 0.65))
|
||||
add("S13 BTC 1h bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.70, 3x leva 20% pos",
|
||||
"S13", run_s13_hybrid("BTC", "1h", 14, 0.8, 3, 3, 0.20, 0.70))
|
||||
add("S13 BTC 1h bb14 ml65", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.65, 3x leva 20% pos",
|
||||
"S13", run_s13_hybrid("BTC", "1h", 14, 0.8, 3, 3, 0.20, 0.65))
|
||||
add("S13 ETH 1h bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.70, 3x leva 20% pos",
|
||||
"S13", run_s13_hybrid("ETH", "1h", 14, 0.8, 3, 3, 0.20, 0.70))
|
||||
|
||||
strategies.sort(key=lambda x: x["acc"], reverse=True)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# TABELLA 1: Classifica
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
W = 150
|
||||
print("=" * W)
|
||||
print(" S1 (SQUEEZE PURO) vs S13 (SQUEEZE + GBM) — CLASSIFICA FINALE")
|
||||
print(f" Fee: 0.2% RT. Dati OHLCV reali 2018-2026. Position 15%. Leva 3x.")
|
||||
print("=" * W)
|
||||
hdr = (f" {'#':>2s} {'Cat':>3s} {'Nome':<26s} {'Trades':>6s} {'Acc':>6s} "
|
||||
f"{'PnL€':>9s} {'DD%':>6s} {'€/day':>7s} {'Sharpe':>7s} "
|
||||
f"{'Mkt%':>5s} {'Dur':>6s} {'Worst':>12s} {'Anni':>4s}")
|
||||
print(hdr)
|
||||
print(f" {'─'*(W-4)}")
|
||||
|
||||
for idx, s in enumerate(strategies, 1):
|
||||
worst = f"{s['worst_year']}({s['worst_acc']:.0f}%)"
|
||||
dur_str = f"{s['avg_dur_h']:.0f}h" if s['avg_dur_h'] >= 1 else f"{s['avg_dur_h']*60:.0f}m"
|
||||
tag = " ★★" if s["acc"] >= 78 else " ★" if s["acc"] >= 76 else ""
|
||||
print(f" {idx:>2d} {s['cat']:>3s} {s['name']:<26s} {s['trades']:>6d} {s['acc']:>5.1f}% "
|
||||
f"€{s['pnl']:>+8.0f} {s['max_dd']:>5.1f}% {s['daily_pnl']:>+6.2f} {s['sharpe']:>7.2f} "
|
||||
f"{s['time_in_market_pct']:>4.1f}% {dur_str:>6s} {worst:>12s} {s['years_active']:>4d}{tag}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# TABELLA 2: Descrizione
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
print(f"\n\n{'=' * W}")
|
||||
print(" DESCRIZIONE")
|
||||
print(f"{'=' * W}")
|
||||
print(f" {'#':>2s} {'Nome':<26s} {'Descrizione'}")
|
||||
print(f" {'─'*(W-4)}")
|
||||
for idx, s in enumerate(strategies, 1):
|
||||
print(f" {idx:>2d} {s['name']:<26s} {s['desc']}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# TABELLA 3: Breakdown per anno
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
top_n = min(12, len(strategies))
|
||||
top = strategies[:top_n]
|
||||
all_years = sorted(set(y for s in top for y in s["year_details"]))
|
||||
|
||||
print(f"\n\n{'=' * W}")
|
||||
print(f" BREAKDOWN PER ANNO — TOP {top_n} (accuracy% / trades)")
|
||||
print(f"{'=' * W}")
|
||||
|
||||
header = f" {'Nome':<26s}"
|
||||
for y in all_years:
|
||||
header += f" {y:>10d}"
|
||||
print(header)
|
||||
print(f" {'─'*(W-4)}")
|
||||
|
||||
for s in top:
|
||||
line = f" {s['name']:<26s}"
|
||||
for y in all_years:
|
||||
if y in s["year_details"]:
|
||||
d = s["year_details"][y]
|
||||
line += f" {d['acc']:>4.0f}%/{d['trades']:<4d}"
|
||||
else:
|
||||
line += f" {'—':>10s}"
|
||||
print(line)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# TABELLA 4: Robustezza
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
print(f"\n\n{'=' * W}")
|
||||
print(f" ANALISI ROBUSTEZZA")
|
||||
print(f"{'=' * W}")
|
||||
print(f" {'#':>2s} {'Nome':<26s} {'MinAcc':>7s} {'MaxAcc':>7s} {'Spread':>7s} "
|
||||
f"{'AnniOK':>7s} {'€/trade':>8s} {'Verdict':<12s}")
|
||||
print(f" {'─'*90}")
|
||||
|
||||
for idx, s in enumerate(strategies, 1):
|
||||
yd = s["year_details"]
|
||||
valid = {y: d for y, d in yd.items() if d["trades"] >= 10}
|
||||
accs = [d["acc"] for d in (valid if valid else yd).values()]
|
||||
if not accs:
|
||||
continue
|
||||
min_a, max_a = min(accs), max(accs)
|
||||
spread = max_a - min_a
|
||||
years_ok = sum(1 for a in accs if a >= 70)
|
||||
avg_pnl = s["pnl"] / s["trades"] if s["trades"] > 0 else 0
|
||||
n_valid = len(valid if valid else yd)
|
||||
|
||||
if n_valid < 4:
|
||||
verdict = "⚠ CORTO"
|
||||
elif min_a < 60:
|
||||
verdict = "⚠ FRAGILE"
|
||||
elif min_a >= 72 and s["acc"] >= 77:
|
||||
verdict = "✅ SOLIDO"
|
||||
elif min_a >= 65 and s["acc"] >= 74:
|
||||
verdict = "~ BUONO"
|
||||
else:
|
||||
verdict = "~ OK"
|
||||
|
||||
print(f" {idx:>2d} {s['name']:<26s} {min_a:>6.1f}% {max_a:>6.1f}% {spread:>6.1f}% "
|
||||
f"{years_ok:>3d}/{n_valid:<3d} €{avg_pnl:>+7.1f} {verdict:<12s}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# VERDETTO
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
print(f"\n\n{'=' * W}")
|
||||
print(f" VERDETTO FINALE")
|
||||
print(f"{'=' * W}")
|
||||
|
||||
solidi = [s for s in strategies if s["trades"] >= 200 and s["years_active"] >= 5 and s["worst_acc"] >= 65]
|
||||
solidi_s1 = [s for s in solidi if s["cat"] == "S1"]
|
||||
solidi_ml = [s for s in solidi if s["cat"] == "S13"]
|
||||
solidi_s1.sort(key=lambda x: x["acc"], reverse=True)
|
||||
solidi_ml.sort(key=lambda x: x["daily_pnl"], reverse=True)
|
||||
|
||||
if solidi_s1:
|
||||
b = solidi_s1[0]
|
||||
print(f"\n MIGLIORE S1 (regole pure, facile da deployare):")
|
||||
print(f" {b['name']} — {b['acc']:.1f}% acc, {b['trades']} trades, DD {b['max_dd']:.1f}%, €{b['daily_pnl']:+.2f}/day, Sharpe {b['sharpe']:.2f}")
|
||||
|
||||
if solidi_ml:
|
||||
m = solidi_ml[0]
|
||||
print(f"\n MIGLIORE S13 (squeeze+GBM, più complesso):")
|
||||
print(f" {m['name']} — {m['acc']:.1f}% acc, {m['trades']} trades, DD {m['max_dd']:.1f}%, €{m['daily_pnl']:+.2f}/day, Sharpe {m['sharpe']:.2f}")
|
||||
|
||||
max_pnl = max(strategies, key=lambda x: x["pnl"])
|
||||
print(f"\n MAX PnL: {max_pnl['name']} — €{max_pnl['pnl']:+,.0f}")
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Report finale: TOP 5 metodi + simulazione crescita capitale €1000 → €50/giorno."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
from src.data.downloader import load_data
|
||||
|
||||
print("=" * 70)
|
||||
print(" REPORT FINALE — TOP 5 METODI")
|
||||
print(" Target: accuracy >80%, ROI annuo >30%, €50/giorno da €1000")
|
||||
print("=" * 70)
|
||||
|
||||
# Metodo 1: Squeeze Breakout ETH 1h (BBw=20, sqThr=0.8, volume confirmed)
|
||||
# Metodo 2: Squeeze Breakout ETH 1h (BBw=30, sqThr=0.9, senza vol filter)
|
||||
# Metodo 3: Squeeze Breakout BTC+ETH combinato
|
||||
# Metodo 4: Squeeze Breakout 15m (alta frequenza)
|
||||
# Metodo 5: GBM Structural + Squeeze filter (ibrido ML + strutturale)
|
||||
|
||||
FEE = 0.001
|
||||
LEVERAGE = 3
|
||||
INITIAL = 1000
|
||||
|
||||
|
||||
def bollinger_bandwidth(close, window=20):
|
||||
n = len(close)
|
||||
result = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
w = close[i-window:i]
|
||||
ma = np.mean(w)
|
||||
std = np.std(w)
|
||||
if ma > 0:
|
||||
result[i] = (2 * 2 * std) / ma
|
||||
return result
|
||||
|
||||
|
||||
def keltner_ratio(close, high, low, window=20):
|
||||
n = len(close)
|
||||
result = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc = close[i-window:i]
|
||||
wh = high[i-window:i]
|
||||
wl = low[i-window:i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(wh - wl, np.maximum(np.abs(wh - np.roll(wc,1)), np.abs(wl - np.roll(wc,1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc_r = (ma + 1.5*atr) - (ma - 1.5*atr)
|
||||
bb_r = (ma + 2*bb_std) - (ma - 2*bb_std)
|
||||
if kc_r > 0:
|
||||
result[i] = bb_r / kc_r
|
||||
return result
|
||||
|
||||
|
||||
def run_squeeze_backtest(close, high, low, volume, bb_w, sq_thr, brk_bars, vol_filter, split_pct=0.7, leverage=3, pos_pct=0.2):
|
||||
n = len(close)
|
||||
split = int(n * split_pct)
|
||||
kcr = keltner_ratio(close, high, low, bb_w)
|
||||
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
capital = float(INITIAL)
|
||||
equity = [capital]
|
||||
trades = []
|
||||
|
||||
for i in range(bb_w + 1, n):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < sq_thr
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
duration = i - sq_start
|
||||
if duration < 5 or i < split or i + brk_bars >= n:
|
||||
continue
|
||||
|
||||
# Volume check
|
||||
if vol_filter:
|
||||
avg_v = np.mean(volume[sq_start:i])
|
||||
brk_v = np.mean(volume[i:i+brk_bars])
|
||||
if avg_v > 0 and brk_v < avg_v * 1.3:
|
||||
continue
|
||||
|
||||
first_ret = (close[i] - close[i-1]) / close[i-1]
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
actual = (close[i+brk_bars-1] - close[i-1]) / close[i-1]
|
||||
is_correct = (direction == 1 and actual > 0) or (direction == -1 and actual < 0)
|
||||
|
||||
trade_ret = actual * direction
|
||||
net = trade_ret * leverage - FEE * 2 * leverage
|
||||
pnl = capital * pos_pct * net
|
||||
capital += pnl
|
||||
capital = max(capital, 0)
|
||||
equity.append(capital)
|
||||
|
||||
trades.append({
|
||||
"correct": is_correct,
|
||||
"actual_ret": actual,
|
||||
"net_pnl": pnl,
|
||||
"capital_after": capital,
|
||||
})
|
||||
|
||||
if not trades:
|
||||
return None
|
||||
|
||||
correct = sum(1 for t in trades if t["correct"])
|
||||
acc = correct / len(trades) * 100
|
||||
total_ret = (capital - INITIAL) / INITIAL * 100
|
||||
test_candles = n - split
|
||||
test_days = test_candles / 24
|
||||
test_years = test_days / 365.25
|
||||
ann = ((capital / INITIAL) ** (1/test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
||||
daily_pnl = (capital - INITIAL) / test_days if test_days > 0 else 0
|
||||
|
||||
peak = equity[0]
|
||||
max_dd = 0
|
||||
for v in equity:
|
||||
if v > peak: peak = v
|
||||
dd = (peak - v) / peak if peak > 0 else 0
|
||||
max_dd = max(max_dd, dd)
|
||||
|
||||
return {
|
||||
"trades": len(trades),
|
||||
"accuracy": acc,
|
||||
"total_return": total_ret,
|
||||
"annualized": ann,
|
||||
"max_drawdown": max_dd * 100,
|
||||
"final_capital": capital,
|
||||
"daily_pnl": daily_pnl,
|
||||
"trades_per_year": len(trades) / test_years if test_years > 0 else 0,
|
||||
}
|
||||
|
||||
|
||||
methods = []
|
||||
|
||||
# --- Metodo 1: ETH 1h, BBw=20, sqThr=0.8, vol confirmed ---
|
||||
df_eth = load_data("ETH", "1h")
|
||||
r1 = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
|
||||
bb_w=20, sq_thr=0.8, brk_bars=3, vol_filter=True)
|
||||
methods.append(("M1: ETH 1h Squeeze+Vol (BBw=20,sq=0.8)", r1))
|
||||
|
||||
# --- Metodo 2: ETH 1h, BBw=30, sqThr=0.9, no vol ---
|
||||
r2 = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
|
||||
bb_w=30, sq_thr=0.9, brk_bars=3, vol_filter=False)
|
||||
methods.append(("M2: ETH 1h Squeeze (BBw=30,sq=0.9)", r2))
|
||||
|
||||
# --- Metodo 3: BTC+ETH combinato ---
|
||||
df_btc = load_data("BTC", "1h")
|
||||
r3a = run_squeeze_backtest(df_btc["close"].values, df_btc["high"].values, df_btc["low"].values, df_btc["volume"].values,
|
||||
bb_w=14, sq_thr=0.8, brk_bars=3, vol_filter=False, pos_pct=0.1)
|
||||
r3b = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
|
||||
bb_w=20, sq_thr=0.8, brk_bars=3, vol_filter=False, pos_pct=0.1)
|
||||
|
||||
if r3a and r3b:
|
||||
combined_trades = r3a["trades"] + r3b["trades"]
|
||||
combined_correct = int(r3a["accuracy"]/100 * r3a["trades"]) + int(r3b["accuracy"]/100 * r3b["trades"])
|
||||
combined_acc = combined_correct / combined_trades * 100 if combined_trades > 0 else 0
|
||||
|
||||
# Simulate portfolio
|
||||
cap = float(INITIAL)
|
||||
# Rough estimate: alternate between assets
|
||||
for r in [r3a, r3b]:
|
||||
ret_per_trade = r["total_return"] / 100 / r["trades"] if r["trades"] > 0 else 0
|
||||
for _ in range(r["trades"]):
|
||||
cap *= (1 + ret_per_trade * 0.5)
|
||||
|
||||
r3 = {
|
||||
"trades": combined_trades,
|
||||
"accuracy": combined_acc,
|
||||
"total_return": (cap - INITIAL) / INITIAL * 100,
|
||||
"annualized": r3a["annualized"] * 0.5 + r3b["annualized"] * 0.5,
|
||||
"max_drawdown": max(r3a["max_drawdown"], r3b["max_drawdown"]),
|
||||
"final_capital": cap,
|
||||
"daily_pnl": r3a["daily_pnl"] + r3b["daily_pnl"],
|
||||
"trades_per_year": r3a["trades_per_year"] + r3b["trades_per_year"],
|
||||
}
|
||||
methods.append(("M3: BTC+ETH 1h Portafoglio Squeeze", r3))
|
||||
|
||||
# --- Metodo 4: BTC 15m alta frequenza ---
|
||||
df_btc_15 = load_data("BTC", "15m")
|
||||
r4 = run_squeeze_backtest(df_btc_15["close"].values, df_btc_15["high"].values, df_btc_15["low"].values, df_btc_15["volume"].values,
|
||||
bb_w=14, sq_thr=0.9, brk_bars=3, vol_filter=True)
|
||||
methods.append(("M4: BTC 15m Squeeze+Vol alta freq", r4))
|
||||
|
||||
# --- Metodo 5: ETH 1h squeeze aggressivo ---
|
||||
r5 = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
|
||||
bb_w=20, sq_thr=0.8, brk_bars=3, vol_filter=False, leverage=3)
|
||||
methods.append(("M5: ETH 1h Squeeze aggressivo (no vol)", r5))
|
||||
|
||||
# --- Print results ---
|
||||
print("\n")
|
||||
for i, (name, r) in enumerate(methods, 1):
|
||||
if r is None:
|
||||
print(f" {name}: NO TRADES")
|
||||
continue
|
||||
print(f" {'='*65}")
|
||||
print(f" #{i} — {name}")
|
||||
print(f" {'='*65}")
|
||||
print(f" Trades: {r['trades']}")
|
||||
print(f" Accuracy: {r['accuracy']:.1f}% {'✅' if r['accuracy'] >= 80 else '⚠️' if r['accuracy'] >= 70 else '❌'}")
|
||||
print(f" Return totale: {r['total_return']:+.1f}%")
|
||||
print(f" Return annuo: {r['annualized']:+.1f}% {'✅' if r['annualized'] >= 30 else '⚠️' if r['annualized'] >= 15 else '❌'}")
|
||||
print(f" Max Drawdown: {r['max_drawdown']:.1f}%")
|
||||
print(f" Capitale finale: €{r['final_capital']:.0f}")
|
||||
print(f" €/giorno media: €{r['daily_pnl']:.2f}")
|
||||
print(f" Trades/anno: {r['trades_per_year']:.0f}")
|
||||
print()
|
||||
|
||||
|
||||
# --- Simulazione crescita 6 mesi ---
|
||||
print("\n" + "=" * 70)
|
||||
print(" SIMULAZIONE CRESCITA CAPITALE — 6 MESI")
|
||||
print(" Metodo: M1 (ETH 1h Squeeze+Vol) — il più preciso (83.9%)")
|
||||
print("=" * 70)
|
||||
|
||||
# M1 params: ~87 trades in ~2.5 anni test = ~35 trades/anno = ~3 al mese
|
||||
# Accuracy: 83.9%, average return per trade with 3x leverage
|
||||
|
||||
# Simulo con dati reali: prendo i trade dal test period
|
||||
close = df_eth["close"].values
|
||||
high = df_eth["high"].values
|
||||
low = df_eth["low"].values
|
||||
volume = df_eth["volume"].values
|
||||
n = len(close)
|
||||
split = int(n * 0.7)
|
||||
|
||||
kcr = keltner_ratio(close, high, low, 20)
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
all_trade_rets = []
|
||||
|
||||
for i in range(21, n):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < 0.8
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
if i - sq_start < 5 or i < split or i + 3 >= n:
|
||||
continue
|
||||
avg_v = np.mean(volume[sq_start:i])
|
||||
brk_v = np.mean(volume[i:i+3])
|
||||
if avg_v > 0 and brk_v < avg_v * 1.3:
|
||||
continue
|
||||
first_ret = (close[i] - close[i-1]) / close[i-1]
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
actual = (close[i+2] - close[i-1]) / close[i-1]
|
||||
trade_ret = actual * direction
|
||||
all_trade_rets.append(trade_ret)
|
||||
|
||||
avg_win = np.mean([r for r in all_trade_rets if r > 0]) if any(r > 0 for r in all_trade_rets) else 0
|
||||
avg_loss = np.mean([r for r in all_trade_rets if r <= 0]) if any(r <= 0 for r in all_trade_rets) else 0
|
||||
win_rate = sum(1 for r in all_trade_rets if r > 0) / len(all_trade_rets)
|
||||
|
||||
print(f"\n Statistiche trade:")
|
||||
print(f" Win rate: {win_rate*100:.1f}%")
|
||||
print(f" Avg win: {avg_win*100:.2f}%")
|
||||
print(f" Avg loss: {avg_loss*100:.2f}%")
|
||||
print(f" Trades totali nel test: {len(all_trade_rets)}")
|
||||
print(f" Trades/mese stimati: ~{len(all_trade_rets) / 30:.0f}")
|
||||
|
||||
print(f"\n Crescita simulata mese per mese (€1000 iniziali, leva 3x, 20% per trade):")
|
||||
capital = 1000.0
|
||||
monthly_trades = max(len(all_trade_rets) // 30, 3)
|
||||
|
||||
# Shuffle trades to simulate different sequences
|
||||
np.random.seed(42)
|
||||
for month in range(1, 7):
|
||||
n_trades = monthly_trades
|
||||
month_rets = np.random.choice(all_trade_rets, size=n_trades, replace=True)
|
||||
|
||||
for ret in month_rets:
|
||||
net = ret * LEVERAGE - FEE * 2 * LEVERAGE
|
||||
capital += capital * 0.2 * net
|
||||
capital = max(capital, 10)
|
||||
|
||||
daily_pnl = capital * 0.003 # stima conservativa 0.3% daily basata su performance
|
||||
print(f" Mese {month}: capitale €{capital:.0f}, €/giorno stima: €{daily_pnl:.1f}")
|
||||
|
||||
print(f"\n Capitale dopo 6 mesi: €{capital:.0f}")
|
||||
print(f" €/giorno necessari: €50")
|
||||
print(f" €/giorno ottenibili (0.5% daily su capitale): €{capital * 0.005:.1f}")
|
||||
|
||||
if capital * 0.005 >= 50:
|
||||
print(f"\n ✅ TARGET RAGGIUNGIBILE: con €{capital:.0f} di capitale, 0.5% daily = €{capital*0.005:.0f}/giorno")
|
||||
else:
|
||||
needed = 50 / 0.005
|
||||
print(f"\n ⚠️ Servono €{needed:.0f} di capitale per €50/giorno al 0.5% daily")
|
||||
print(f" Raggiungibile estendendo il periodo di crescita a ~{int(np.log(needed/1000) / np.log(1 + 0.15) + 0.5)} mesi")
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Mostra lo stato del paper trader."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from src.live.cerbero_client import CerberoClient
|
||||
|
||||
LOG_DIR = Path("data/paper_trades")
|
||||
|
||||
print("=" * 50)
|
||||
print(" PAPER TRADER STATUS")
|
||||
print("=" * 50)
|
||||
|
||||
# Status file
|
||||
status_path = LOG_DIR / "status.json"
|
||||
if status_path.exists():
|
||||
with open(status_path) as f:
|
||||
status = json.load(f)
|
||||
print(f"\n In posizione: {status['in_position']}")
|
||||
if status["in_position"]:
|
||||
print(f" Direzione: {status['direction']}")
|
||||
print(f" Entry price: {status['entry_price']}")
|
||||
print(f" Entry time: {status['entry_time']}")
|
||||
print(f" Barre tenute: {status['bars_held']}")
|
||||
print(f" Ultimo update: {status['last_update']}")
|
||||
else:
|
||||
print("\n Nessun file di stato trovato.")
|
||||
|
||||
# Account
|
||||
print("\n--- ACCOUNT DERIBIT TESTNET ---")
|
||||
c = CerberoClient()
|
||||
try:
|
||||
acc = c.get_account_summary("USDC")
|
||||
print(f" Equity: ${acc['equity']:,.2f}")
|
||||
print(f" Balance: ${acc['balance']:,.2f}")
|
||||
print(f" PnL: ${acc['total_pnl']:,.2f}")
|
||||
except Exception as e:
|
||||
print(f" Errore: {e}")
|
||||
|
||||
# Posizioni
|
||||
try:
|
||||
pos = c.get_positions("USDC")
|
||||
print(f"\n Posizioni aperte: {len(pos)}")
|
||||
for p in pos:
|
||||
print(f" {p.get('instrument','?')}: {p.get('size',0)} {p.get('direction','?')} @ ${p.get('avg_price',0)}")
|
||||
except Exception as e:
|
||||
print(f" Errore: {e}")
|
||||
|
||||
# Ultimi log
|
||||
print("\n--- ULTIMI LOG ---")
|
||||
log_files = sorted(LOG_DIR.glob("trades_*.jsonl"))
|
||||
if log_files:
|
||||
with open(log_files[-1]) as f:
|
||||
lines = f.readlines()
|
||||
for line in lines[-10:]:
|
||||
entry = json.loads(line)
|
||||
print(f" [{entry['timestamp'][:19]}] {entry['event']}")
|
||||
else:
|
||||
print(" Nessun log trovato.")
|
||||
|
||||
# Statistiche trade
|
||||
all_trades = []
|
||||
for lf in log_files:
|
||||
with open(lf) as f:
|
||||
for line in f:
|
||||
entry = json.loads(line)
|
||||
if entry["event"] == "CLOSED":
|
||||
all_trades.append(entry)
|
||||
|
||||
if all_trades:
|
||||
wins = sum(1 for t in all_trades if t.get("pnl_pct", 0) > 0)
|
||||
total = len(all_trades)
|
||||
total_pnl = sum(t.get("pnl_pct", 0) for t in all_trades)
|
||||
print(f"\n--- STATISTICHE ---")
|
||||
print(f" Trade chiusi: {total}")
|
||||
print(f" Win rate: {wins/total*100:.0f}%")
|
||||
print(f" PnL totale: {total_pnl:.2f}%")
|
||||
@@ -0,0 +1,266 @@
|
||||
"""ML01 — Squeeze + GBM (Gradient Boosting Machine) Walk-Forward.
|
||||
|
||||
Strategia ibrida: squeeze breakout come pre-filtro (QUANDO tradare),
|
||||
GradientBoosting su features strutturali come conferma (QUALE direzione).
|
||||
|
||||
Pipeline:
|
||||
1. Rileva squeeze release (Bollinger esce da Keltner)
|
||||
2. Estrai 44 features dalla finestra (structural multi-window + squeeze
|
||||
metadata + price position + ATR + momentum breakout)
|
||||
3. GBM walk-forward: train su 50% rolling, step 10%, predice direzione
|
||||
4. Trade solo se ML ha confidenza ≥ ml_threshold
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame
|
||||
- Parametri: bb_window (14), sq_threshold (0.8), brk_bars (3),
|
||||
ml_threshold (0.70), leverage (3), position_pct (0.15)
|
||||
|
||||
OUT:
|
||||
- BacktestResult con metriche walk-forward (no data leakage)
|
||||
- Solo periodo di test (seconda metà dati)
|
||||
|
||||
Risultati tipici:
|
||||
ETH 15m bb14 ml=0.70: 76.9% acc, 1213 trades, DD 4.2%, €13.78/day
|
||||
BTC 15m bb14 ml=0.70: 78.8% acc, 1964 trades, DD 7.0%, €5.51/day
|
||||
BTC 1h bb14 ml=0.70: 77.3% acc, 617 trades, DD 6.7%, €3.85/day
|
||||
|
||||
Note:
|
||||
- GBM = GradientBoostingClassifier di scikit-learn
|
||||
- Walk-forward: nessun look-ahead, train sempre prima di test
|
||||
- Il baseline squeeze puro ha accuracy più alta (~79.5%) ma DD peggiore
|
||||
- Il valore del ML è filtrare breakout deboli → DD ridotto
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.ensemble import GradientBoostingClassifier
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
||||
from src.strategies.indicators import keltner_ratio, detect_squeezes
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
def _build_features(df: pd.DataFrame, i: int, squeeze_info: dict) -> np.ndarray | None:
|
||||
"""44 features per il punto di squeeze release."""
|
||||
if i < 100:
|
||||
return None
|
||||
o, h, l, c, v = (df["open"].values, df["high"].values, df["low"].values,
|
||||
df["close"].values, df["volume"].values)
|
||||
feats = []
|
||||
for w in [12, 24, 48]:
|
||||
wc, wo = c[i-w:i], o[i-w:i]
|
||||
wh, wl, wv = h[i-w:i], l[i-w:i], v[i-w:i]
|
||||
mn, mx = wl.min(), max(wh.max(), wc.max())
|
||||
rng = mx - mn if mx - mn > 0 else 1e-10
|
||||
total = np.where(wh - wl == 0, 1e-10, wh - wl)
|
||||
body = np.abs(wc - wo) / total
|
||||
direction = np.sign(wc - wo)
|
||||
log_c = np.log(np.where(wc == 0, 1e-10, wc))
|
||||
rets = np.diff(log_c)
|
||||
v_mean = np.mean(wv)
|
||||
feats.extend([
|
||||
np.mean(rets) if len(rets) > 0 else 0,
|
||||
np.std(rets) if len(rets) > 0 else 0,
|
||||
np.sum(rets) if len(rets) > 0 else 0,
|
||||
float(pd.Series(rets).skew()) if len(rets) > 2 else 0,
|
||||
float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0,
|
||||
np.mean(body), np.std(body),
|
||||
np.mean(direction), np.mean(direction[-min(3, w):]),
|
||||
(wc[-1] - mn) / rng,
|
||||
wv[-1] / v_mean if v_mean > 0 else 1,
|
||||
np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0,
|
||||
])
|
||||
sq = squeeze_info
|
||||
feats.extend([
|
||||
sq["dur"], sq["dur"] / 24, sq["kcr_at_release"],
|
||||
v[i-1] / sq.get("avg_vol", 1) if sq.get("avg_vol", 0) > 0 else 1,
|
||||
np.mean(v[i:min(i+3, len(v))]) / sq.get("avg_vol", 1) if sq.get("avg_vol", 0) > 0 else 1,
|
||||
])
|
||||
h48, l48 = np.max(h[max(0, i-48):i]), np.min(l[max(0, i-48):i])
|
||||
r48 = h48 - l48
|
||||
feats.append((c[i-1] - l48) / r48 if r48 > 0 else 0.5)
|
||||
tr = np.maximum(h[i-14:i] - l[i-14:i],
|
||||
np.maximum(np.abs(h[i-14:i] - np.roll(c[i-14:i], 1)),
|
||||
np.abs(l[i-14:i] - np.roll(c[i-14:i], 1))))
|
||||
feats.append(np.mean(tr[1:]) / c[i-1] if c[i-1] > 0 else 0)
|
||||
feats.append((c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0)
|
||||
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
|
||||
|
||||
|
||||
class SqueezeGBM(Strategy):
|
||||
name = "ML01_squeeze_gbm"
|
||||
description = "Squeeze + GBM walk-forward — ML filtra breakout deboli"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
fee_ml = 0.001
|
||||
|
||||
def generate_signals(self, df, ts, **params):
|
||||
raise NotImplementedError("ML01 usa backtest custom con walk-forward")
|
||||
|
||||
def backtest(self, asset: str, tf: str, hold: int = 3, **params) -> BacktestResult | None:
|
||||
bb_w = params.get("bb_window", 14)
|
||||
sq_thr = params.get("sq_threshold", 0.8 if tf == "1h" else 0.9)
|
||||
brk = params.get("brk_bars", hold)
|
||||
ml_thr = params.get("ml_threshold", 0.70)
|
||||
lev = params.get("leverage", self.leverage)
|
||||
pos = params.get("position_pct", self.position_size)
|
||||
|
||||
df = load_data(asset, tf)
|
||||
close = df["close"].values
|
||||
high = df["high"].values
|
||||
low = df["low"].values
|
||||
volume = df["volume"].values
|
||||
n = len(df)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
kcr = keltner_ratio(close, high, low, bb_w)
|
||||
raw_events = detect_squeezes(close, high, low, kcr, sq_thr)
|
||||
|
||||
# Aggiungi avg_vol a ogni evento
|
||||
events = []
|
||||
for ev in raw_events:
|
||||
ev["avg_vol"] = float(np.mean(volume[ev["sq_start"]:ev["idx"]]))
|
||||
events.append(ev)
|
||||
|
||||
X_all, y_all, ev_all = [], [], []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i + brk >= n or i < 100:
|
||||
continue
|
||||
feats = _build_features(df, i, ev)
|
||||
if feats is None:
|
||||
continue
|
||||
actual_ret = (close[i + brk - 1] - close[i - 1]) / close[i - 1]
|
||||
X_all.append(feats)
|
||||
y_all.append(1 if actual_ret > 0 else 0)
|
||||
ev_all.append(ev)
|
||||
|
||||
if len(X_all) < 50:
|
||||
return None
|
||||
|
||||
X, y = np.array(X_all), np.array(y_all)
|
||||
TRAIN_SIZE = max(int(len(X) * 0.5), 50)
|
||||
STEP_SIZE = max(int(len(X) * 0.1), 10)
|
||||
|
||||
yearly: dict[int, dict] = {}
|
||||
capital = float(self.initial_capital)
|
||||
peak = capital
|
||||
max_dd = 0.0
|
||||
total_bars = 0
|
||||
all_t = all_w = 0
|
||||
|
||||
start = 0
|
||||
while start + TRAIN_SIZE + STEP_SIZE <= len(X):
|
||||
train_end = start + TRAIN_SIZE
|
||||
test_end = min(train_end + STEP_SIZE, len(X))
|
||||
X_tr, y_tr = X[start:train_end], y[start:train_end]
|
||||
X_te = X[train_end:test_end]
|
||||
|
||||
if len(np.unique(y_tr)) < 2:
|
||||
start += STEP_SIZE
|
||||
continue
|
||||
|
||||
scaler = StandardScaler()
|
||||
X_tr_s = scaler.fit_transform(X_tr)
|
||||
X_te_s = scaler.transform(X_te)
|
||||
|
||||
model = GradientBoostingClassifier(
|
||||
n_estimators=150, max_depth=4, min_samples_leaf=10,
|
||||
learning_rate=0.05, subsample=0.8, random_state=42,
|
||||
)
|
||||
model.fit(X_tr_s, y_tr)
|
||||
up_idx = list(model.classes_).index(1) if 1 in model.classes_ else -1
|
||||
if up_idx < 0:
|
||||
start += STEP_SIZE
|
||||
continue
|
||||
|
||||
for j in range(len(X_te)):
|
||||
proba = model.predict_proba(X_te_s[j:j+1])[0]
|
||||
p_up = proba[up_idx]
|
||||
ev = ev_all[train_end + j]
|
||||
i = ev["idx"]
|
||||
actual_ret = (close[i + brk - 1] - close[i - 1]) / close[i - 1]
|
||||
|
||||
if p_up >= ml_thr:
|
||||
direction = 1
|
||||
elif p_up <= (1 - ml_thr):
|
||||
direction = -1
|
||||
else:
|
||||
continue
|
||||
|
||||
is_correct = (direction == 1 and actual_ret > 0) or (direction == -1 and actual_ret < 0)
|
||||
trade_ret = actual_ret * direction
|
||||
net = trade_ret * lev - self.fee_ml * 2 * lev
|
||||
capital += capital * pos * net
|
||||
capital = max(capital, 10)
|
||||
if capital > peak:
|
||||
peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
total_bars += brk
|
||||
|
||||
all_t += 1
|
||||
if is_correct:
|
||||
all_w += 1
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
|
||||
yearly[year]["t"] += 1
|
||||
if is_correct:
|
||||
yearly[year]["w"] += 1
|
||||
yearly[year]["pnl"] += net * self.initial_capital
|
||||
|
||||
start += STEP_SIZE
|
||||
|
||||
if all_t == 0:
|
||||
return None
|
||||
|
||||
yearly_stats = [
|
||||
YearlyStats(year=y, trades=d["t"], wins=d["w"], pnl=d["pnl"])
|
||||
for y, d in sorted(yearly.items())
|
||||
]
|
||||
|
||||
return BacktestResult(
|
||||
strategy_name=self.name,
|
||||
asset=asset,
|
||||
timeframe=tf,
|
||||
params={"bb_w": bb_w, "sq_thr": sq_thr, "ml_thr": ml_thr,
|
||||
"brk": brk, "lev": lev, "pos": pos},
|
||||
trades=all_t,
|
||||
wins=all_w,
|
||||
pnl=sum(d["pnl"] for d in yearly.values()),
|
||||
capital=capital,
|
||||
initial_capital=self.initial_capital,
|
||||
max_dd=max_dd * 100,
|
||||
time_in_market_pct=total_bars / n * 100,
|
||||
avg_trade_duration_h=brk * TF_MINUTES.get(tf, 60) / 60,
|
||||
years_active=len(yearly),
|
||||
yearly=yearly_stats,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = SqueezeGBM()
|
||||
print("Training ML models...\n")
|
||||
results = []
|
||||
for asset in ["ETH", "BTC"]:
|
||||
for tf in ["15m", "1h"]:
|
||||
for ml_thr in [0.65, 0.70]:
|
||||
r = strategy.backtest(asset, tf, ml_threshold=ml_thr)
|
||||
if r and r.trades >= 20:
|
||||
r.strategy_name = f"ML01 {asset} {tf} ml={ml_thr}"
|
||||
results.append(r)
|
||||
results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
|
||||
print(f"{'=' * 120}")
|
||||
print(f" ML01 SQUEEZE+GBM — RISULTATI")
|
||||
print(f"{'=' * 120}")
|
||||
for r in results:
|
||||
r.print_summary()
|
||||
if results:
|
||||
results[0].print_yearly()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""SQ01 — Squeeze Breakout Base.
|
||||
|
||||
Strategia strutturale: rileva compressione di volatilità (Bollinger dentro
|
||||
Keltner Channel) e segue la direzione del breakout al rilascio.
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame (da load_data)
|
||||
- Parametri: bb_window (14), sq_threshold (0.8), min_squeeze_dur (5)
|
||||
|
||||
OUT:
|
||||
- Lista di Signal con direzione breakout (+1/-1)
|
||||
- BacktestResult con equity, yearly breakdown, metriche
|
||||
|
||||
Risultati tipici:
|
||||
BTC 15m: 76.7% acc, 4062 trades, DD 6.7%, €9.32/day
|
||||
ETH 15m: 76.4% acc, 2948 trades, DD 6.2%, €10.31/day
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal
|
||||
from src.strategies.indicators import keltner_ratio, detect_squeezes
|
||||
|
||||
|
||||
class SqueezeBase(Strategy):
|
||||
name = "SQ01_squeeze_base"
|
||||
description = "Squeeze breakout puro — segui direzione al rilascio"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
n = len(c)
|
||||
|
||||
bb_w = params.get("bb_window", 14)
|
||||
sq_thr = params.get("sq_threshold", 0.8)
|
||||
min_dur = params.get("min_dur", 5)
|
||||
|
||||
kcr = keltner_ratio(c, h, l, bb_w)
|
||||
events = detect_squeezes(c, h, l, kcr, sq_thr, min_dur)
|
||||
|
||||
signals = []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i < 1 or i >= n:
|
||||
continue
|
||||
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
signals.append(Signal(
|
||||
idx=i,
|
||||
direction=1 if first_ret > 0 else -1,
|
||||
entry_price=c[i - 1],
|
||||
metadata={"dur": ev["dur"], "kcr": ev["kcr_at_release"]},
|
||||
))
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = SqueezeBase()
|
||||
strategy.report()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""SQ02 — Squeeze Breakout + Anti-Fakeout + Volume Confirmation.
|
||||
|
||||
Migliora SQ01 con due filtri:
|
||||
1. Anti-fakeout: scarta breakout dove la candela ritraccia >60% del range
|
||||
2. Volume confirm: volume al breakout deve essere >1.3× la media durante squeeze
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame
|
||||
- Parametri: bb_window (14), sq_threshold (0.8), retrace_limit (0.6),
|
||||
vol_multiplier (1.3)
|
||||
|
||||
OUT:
|
||||
- Lista di Signal filtrati
|
||||
- BacktestResult
|
||||
|
||||
Risultati tipici:
|
||||
BTC 15m: 79.7% acc, 1250 trades, DD 6.5%, €5.23/day — SOLIDO 9/9 anni
|
||||
ETH 15m: 78.6% acc, 942 trades, DD 3.4%, €4.33/day
|
||||
BTC 1h: 78.0% acc, 473 trades, DD 3.5%, Sharpe 6.57
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal
|
||||
from src.strategies.indicators import keltner_ratio, detect_squeezes
|
||||
|
||||
|
||||
class SqueezeAntifakeVol(Strategy):
|
||||
name = "SQ02_antifake_vol"
|
||||
description = "Squeeze + antifakeout + volume confirmation"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
bb_w = params.get("bb_window", 14)
|
||||
sq_thr = params.get("sq_threshold", 0.8)
|
||||
retrace_limit = params.get("retrace_limit", 0.6)
|
||||
vol_mult = params.get("vol_multiplier", 1.3)
|
||||
|
||||
kcr = keltner_ratio(c, h, l, bb_w)
|
||||
events = detect_squeezes(c, h, l, kcr, sq_thr)
|
||||
|
||||
signals = []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i < 1 or i >= n:
|
||||
continue
|
||||
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
|
||||
br = h[i] - l[i]
|
||||
if br > 0:
|
||||
if c[i] > c[i - 1]:
|
||||
if (h[i] - c[i]) / br > retrace_limit:
|
||||
continue
|
||||
else:
|
||||
if (c[i] - l[i]) / br > retrace_limit:
|
||||
continue
|
||||
|
||||
avg_v = np.mean(v[ev["sq_start"]:i])
|
||||
if avg_v > 0 and v[i] <= avg_v * vol_mult:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i,
|
||||
direction=1 if first_ret > 0 else -1,
|
||||
entry_price=c[i - 1],
|
||||
metadata={"dur": ev["dur"], "vol_ratio": v[i] / avg_v if avg_v > 0 else 0},
|
||||
))
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = SqueezeAntifakeVol()
|
||||
strategy.report()
|
||||
@@ -0,0 +1,175 @@
|
||||
"""SQ03 — Squeeze con filtri selezionabili.
|
||||
|
||||
Ogni filtro è opzionale e attivabile via parametro. Di default attiva solo
|
||||
antifake + long_squeeze (i due filtri con miglior rapporto accuracy/trade).
|
||||
Esegue tutte le combinazioni utili e classifica.
|
||||
|
||||
Filtri disponibili:
|
||||
- antifake: scarta breakout con retrace >60% (guadagna ~+1% acc)
|
||||
- long_sq: solo squeeze durata ≥10 barre (+1% acc, dimezza trade)
|
||||
- timing: solo ore 4-16 UTC (+0.5% acc)
|
||||
- cross: asset secondario in squeeze nelle ultime 10 barre (+0.5%)
|
||||
- vol: volume al breakout >1.3× media squeeze (+1% acc)
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame (primario + secondario per cross-check)
|
||||
- Parametri: filters (lista), bb_window, sq_threshold
|
||||
|
||||
OUT:
|
||||
- BacktestResult per ogni preset di filtri
|
||||
|
||||
Risultati tipici (BTC 15m):
|
||||
antifake+long: 77.3% acc, 2179 trades
|
||||
antifake+vol: 79.7% acc, 1250 trades — SOLIDO
|
||||
ALL_FILTERS: 79.2% acc, 696 trades (restrittivo)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
||||
from src.strategies.indicators import keltner_ratio, detect_squeezes
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
PRESETS = {
|
||||
"antifake": ["antifake"],
|
||||
"long_sq": ["long_sq"],
|
||||
"antifake+long": ["antifake", "long_sq"],
|
||||
"antifake+vol": ["antifake", "vol"],
|
||||
"antifake+timing": ["antifake", "timing"],
|
||||
"long+timing": ["long_sq", "timing"],
|
||||
"antifake+long+time": ["antifake", "long_sq", "timing"],
|
||||
"antifake+cross": ["antifake", "cross"],
|
||||
"ALL_FILTERS": ["antifake", "long_sq", "timing", "cross"],
|
||||
}
|
||||
|
||||
|
||||
class SqueezeFiltered(Strategy):
|
||||
name = "SQ03_filtered"
|
||||
description = "Squeeze + filtri selezionabili (antifake, long, timing, cross, vol)"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
bb_w = params.get("bb_window", 14)
|
||||
sq_thr = params.get("sq_threshold", 0.8)
|
||||
filters = params.get("filters", ["antifake", "long_sq"])
|
||||
asset = params.get("asset", "BTC")
|
||||
tf = params.get("tf", "15m")
|
||||
|
||||
kcr = keltner_ratio(c, h, l, bb_w)
|
||||
events = detect_squeezes(c, h, l, kcr, sq_thr)
|
||||
|
||||
kcr2 = None
|
||||
ts2 = None
|
||||
if "cross" in filters:
|
||||
secondary = "ETH" if asset == "BTC" else "BTC"
|
||||
df2 = load_data(secondary, tf)
|
||||
kcr2 = keltner_ratio(df2["close"].values, df2["high"].values,
|
||||
df2["low"].values, bb_w)
|
||||
ts2 = df2["timestamp"].values
|
||||
|
||||
signals = []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i < 1 or i >= n:
|
||||
continue
|
||||
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
|
||||
skip = False
|
||||
|
||||
if "antifake" in filters:
|
||||
br = h[i] - l[i]
|
||||
if br > 0:
|
||||
if c[i] > c[i - 1] and (h[i] - c[i]) / br > 0.6:
|
||||
skip = True
|
||||
elif c[i] <= c[i - 1] and (c[i] - l[i]) / br > 0.6:
|
||||
skip = True
|
||||
|
||||
if not skip and "long_sq" in filters:
|
||||
if ev["dur"] < 10:
|
||||
skip = True
|
||||
|
||||
if not skip and "timing" in filters:
|
||||
hour = ts.iloc[i].hour
|
||||
if hour < 4 or hour > 16:
|
||||
skip = True
|
||||
|
||||
if not skip and "vol" in filters:
|
||||
avg_v = np.mean(v[ev["sq_start"]:i])
|
||||
if avg_v > 0 and v[i] <= avg_v * 1.3:
|
||||
skip = True
|
||||
|
||||
if not skip and "cross" in filters and kcr2 is not None and ts2 is not None:
|
||||
i2 = np.searchsorted(ts2, ts.values[i].astype("int64") // 10**6)
|
||||
i2 = min(i2, len(kcr2) - 1)
|
||||
cross_ok = any(
|
||||
not np.isnan(kcr2[j]) and kcr2[j] < 0.85
|
||||
for j in range(max(0, i2 - 10), i2 + 1)
|
||||
)
|
||||
if not cross_ok:
|
||||
skip = True
|
||||
|
||||
if skip:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i,
|
||||
direction=1 if first_ret > 0 else -1,
|
||||
entry_price=c[i - 1],
|
||||
metadata={"dur": ev["dur"], "filters": filters},
|
||||
))
|
||||
return signals
|
||||
|
||||
def report_all_presets(self, assets=None, timeframes=None, hold=3):
|
||||
"""Esegue tutti i preset di filtri × asset × tf."""
|
||||
assets = assets or self.default_assets
|
||||
timeframes = timeframes or self.default_timeframes
|
||||
all_results = []
|
||||
|
||||
for preset_name, filter_list in PRESETS.items():
|
||||
for asset in assets:
|
||||
for tf in timeframes:
|
||||
r = self.backtest(asset, tf, hold, filters=filter_list)
|
||||
if r and r.trades >= 20:
|
||||
r.strategy_name = f"SQ03 {preset_name}"
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
|
||||
print(f"\n{'=' * 120}")
|
||||
print(f" SQ03 SQUEEZE FILTRATO — TUTTI I PRESET ({len(all_results)} config)")
|
||||
print(f" Fee: {self.fee_rt*100:.1f}% RT | Leva: {self.leverage:.0f}x | Pos: {self.position_size*100:.0f}%")
|
||||
print(f"{'=' * 120}")
|
||||
print(f" {'Nome':<30s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
|
||||
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
|
||||
f"{'Mkt%':>5s} {'Dur':>5s} {'Worst':>12s} {'Anni':>4s}")
|
||||
print(f" {'─' * 110}")
|
||||
|
||||
for r in all_results:
|
||||
r.print_summary()
|
||||
|
||||
if all_results:
|
||||
print(f"\n MIGLIORE: ", end="")
|
||||
best = all_results[0]
|
||||
best.print_yearly()
|
||||
|
||||
return all_results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = SqueezeFiltered()
|
||||
strategy.report_all_presets()
|
||||
@@ -0,0 +1,204 @@
|
||||
"""SQ04 — Ultimate Squeeze — combinazione incrementale di tutti i filtri.
|
||||
|
||||
Testa combinazioni di filtri (antifake, long_sq, timing, cross-asset,
|
||||
correlation, volume, trend alignment, volatility regime) e classifica
|
||||
per accuracy.
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame (primario + secondario)
|
||||
- Parametri: bb_window, sq_threshold, lista filtri da attivare
|
||||
|
||||
OUT:
|
||||
- BacktestResult per ogni combinazione di filtri
|
||||
- Classifica globale
|
||||
|
||||
Risultati tipici:
|
||||
BTC 15m antifake+corr: 81.6% acc (ma concentrato 2018)
|
||||
BTC 15m antifake+vol: 79.7% acc, 1250 trades — robusto
|
||||
ETH 1h antifake+corr: 80.7% acc (solo 2018)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal
|
||||
from src.strategies.indicators import (
|
||||
keltner_ratio, detect_squeezes, ema, rv_annualized, rolling_correlation,
|
||||
)
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
class SqueezeUltimate(Strategy):
|
||||
name = "SQ04_ultimate"
|
||||
description = "Ultimate squeeze — tutti i filtri combinabili"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
|
||||
FILTER_PRESETS = {
|
||||
"antifake+vol": ["antifake", "vol_confirm"],
|
||||
"antifake+corr": ["antifake", "corr_high"],
|
||||
"af+long+corr+trend": ["antifake", "long_sq", "corr_high", "trend_align"],
|
||||
"ALL": ["antifake", "long_sq", "cross", "timing", "corr_high",
|
||||
"vol_confirm", "trend_align", "low_rv"],
|
||||
}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
asset = params.get("asset", "BTC")
|
||||
tf = params.get("tf", "15m")
|
||||
filters = params.get("filters", ["antifake", "vol_confirm"])
|
||||
|
||||
kcr = keltner_ratio(c, h, l, 14)
|
||||
events = detect_squeezes(c, h, l, kcr)
|
||||
|
||||
secondary = "ETH" if asset == "BTC" else "BTC"
|
||||
df2 = load_data(secondary, tf)
|
||||
c2 = df2["close"].values
|
||||
kcr2 = keltner_ratio(c2, df2["high"].values, df2["low"].values, 14)
|
||||
ts2 = df2["timestamp"].values
|
||||
|
||||
ema_50 = ema(c, 50)
|
||||
rv_48 = rv_annualized(c, 48)
|
||||
corr = rolling_correlation(c, c2)
|
||||
|
||||
signals = []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i < 1 or i >= n:
|
||||
continue
|
||||
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
|
||||
skip = False
|
||||
for f in filters:
|
||||
if f == "antifake":
|
||||
br = h[i] - l[i]
|
||||
if br > 0:
|
||||
if c[i] > c[i-1] and (h[i] - c[i]) / br > 0.6:
|
||||
skip = True
|
||||
elif c[i] <= c[i-1] and (c[i] - l[i]) / br > 0.6:
|
||||
skip = True
|
||||
elif f == "long_sq":
|
||||
if ev["dur"] < 10:
|
||||
skip = True
|
||||
elif f == "timing":
|
||||
if ts.iloc[i].hour < 4 or ts.iloc[i].hour > 16:
|
||||
skip = True
|
||||
elif f == "cross":
|
||||
i2 = np.searchsorted(ts2, ts.values[i].astype("int64") // 10**6)
|
||||
i2 = min(i2, len(kcr2) - 1)
|
||||
if not any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85
|
||||
for j in range(max(0, i2 - 10), i2 + 1)):
|
||||
skip = True
|
||||
elif f == "corr_high":
|
||||
if np.isnan(corr[i]) or abs(corr[i]) < 0.6:
|
||||
skip = True
|
||||
elif f == "vol_confirm":
|
||||
avg_v = np.mean(v[ev["sq_start"]:i])
|
||||
if avg_v > 0 and v[i] <= avg_v * 1.3:
|
||||
skip = True
|
||||
elif f == "trend_align":
|
||||
if not np.isnan(ema_50[i]):
|
||||
if first_ret > 0 and c[i] < ema_50[i]:
|
||||
skip = True
|
||||
elif first_ret < 0 and c[i] > ema_50[i]:
|
||||
skip = True
|
||||
elif f == "low_rv":
|
||||
if not np.isnan(rv_48[i]) and rv_48[i] >= 1.5:
|
||||
skip = True
|
||||
if skip:
|
||||
break
|
||||
|
||||
if skip:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i,
|
||||
direction=1 if first_ret > 0 else -1,
|
||||
entry_price=c[i - 1],
|
||||
metadata={"dur": ev["dur"], "filters": filters},
|
||||
))
|
||||
return signals
|
||||
|
||||
def backtest(self, asset: str, tf: str, hold: int = 3, **params):
|
||||
params.setdefault("asset", asset)
|
||||
params.setdefault("tf", tf)
|
||||
df = load_data(asset, tf)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
signals = self.generate_signals(df, ts, **params)
|
||||
# Usa il backtest della base ma passando i segnali già generati
|
||||
from src.strategies.base import BacktestResult, YearlyStats, TF_MINUTES
|
||||
c = df["close"].values
|
||||
n = len(c)
|
||||
yearly: dict[int, dict] = {}
|
||||
capital = float(self.initial_capital)
|
||||
peak = capital
|
||||
max_dd = 0.0
|
||||
total_bars = 0
|
||||
for sig in signals:
|
||||
i = sig.idx
|
||||
if i + hold >= n or i < 1:
|
||||
continue
|
||||
entry = sig.entry_price
|
||||
exit_price = c[min(i + hold - 1, n - 1)]
|
||||
actual = (exit_price - entry) / entry * sig.direction
|
||||
net = actual * self.leverage - self.fee_rt * self.leverage
|
||||
capital += capital * self.position_size * net
|
||||
capital = max(capital, 10)
|
||||
if capital > peak: peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
total_bars += hold
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0: yearly[year]["w"] += 1
|
||||
yearly[year]["pnl"] += net * self.initial_capital
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t == 0: return None
|
||||
yearly_stats = [YearlyStats(y, d["t"], d["w"], d["pnl"]) for y, d in sorted(yearly.items())]
|
||||
return BacktestResult(
|
||||
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
|
||||
trades=all_t, wins=all_w, pnl=sum(d["pnl"] for d in yearly.values()),
|
||||
capital=capital, initial_capital=self.initial_capital,
|
||||
max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100,
|
||||
avg_trade_duration_h=hold * TF_MINUTES.get(tf, 60) / 60,
|
||||
years_active=len(yearly), yearly=yearly_stats,
|
||||
)
|
||||
|
||||
def report_all_presets(self):
|
||||
"""Esegue tutte le combinazioni preset × asset × tf."""
|
||||
all_results = []
|
||||
for preset_name, filter_list in self.FILTER_PRESETS.items():
|
||||
for asset in self.default_assets:
|
||||
for tf in self.default_timeframes:
|
||||
r = self.backtest(asset, tf, filters=filter_list)
|
||||
if r and r.trades >= 20:
|
||||
r.strategy_name = f"SQ04 {preset_name}"
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
|
||||
print(f"\n{'=' * 120}")
|
||||
print(f" SQ04 ULTIMATE — TUTTI I PRESET")
|
||||
print(f"{'=' * 120}")
|
||||
for r in all_results:
|
||||
r.print_summary()
|
||||
return all_results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = SqueezeUltimate()
|
||||
strategy.report_all_presets()
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Strategia 11: Volatility compression → breakout.
|
||||
Approccio diverso: non predire la direzione direttamente.
|
||||
1. Identifica momenti di COMPRESSIONE (Bollinger squeeze, ATR basso, bassa fractal dim)
|
||||
2. Quando la volatilità ESPLODE dopo compressione, segui la direzione del breakout
|
||||
3. Alta precisione perché il breakout DOPO compressione ha forte momentum
|
||||
Target: pochi trade molto precisi, con leva.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
from src.fractal.indicators import volatility_ratio
|
||||
|
||||
FEE_PCT = 0.001
|
||||
LEVERAGE = 3
|
||||
INITIAL_CAPITAL = 1000
|
||||
|
||||
|
||||
def bollinger_bandwidth(close: np.ndarray, window: int = 20) -> np.ndarray:
|
||||
"""Bandwidth = (upper - lower) / middle."""
|
||||
result = np.full(len(close), np.nan)
|
||||
for i in range(window, len(close)):
|
||||
w = close[i - window : i]
|
||||
ma = np.mean(w)
|
||||
std = np.std(w)
|
||||
if ma > 0:
|
||||
result[i] = (2 * 2 * std) / ma
|
||||
return result
|
||||
|
||||
|
||||
def keltner_channel_ratio(close: np.ndarray, high: np.ndarray, low: np.ndarray, window: int = 20) -> np.ndarray:
|
||||
"""Ratio of Bollinger to Keltner — squeeze when < 1."""
|
||||
result = np.full(len(close), np.nan)
|
||||
for i in range(window, len(close)):
|
||||
w_c = close[i - window : i]
|
||||
w_h = high[i - window : i]
|
||||
w_l = low[i - window : i]
|
||||
|
||||
ma = np.mean(w_c)
|
||||
bb_std = np.std(w_c)
|
||||
bb_upper = ma + 2 * bb_std
|
||||
bb_lower = ma - 2 * bb_std
|
||||
|
||||
tr = np.maximum(w_h - w_l, np.maximum(np.abs(w_h - np.roll(w_c, 1)), np.abs(w_l - np.roll(w_c, 1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc_upper = ma + 1.5 * atr
|
||||
kc_lower = ma - 1.5 * atr
|
||||
|
||||
kc_range = kc_upper - kc_lower
|
||||
bb_range = bb_upper - bb_lower
|
||||
if kc_range > 0:
|
||||
result[i] = bb_range / kc_range
|
||||
return result
|
||||
|
||||
|
||||
def detect_squeeze_release(
|
||||
close: np.ndarray,
|
||||
high: np.ndarray,
|
||||
low: np.ndarray,
|
||||
volume: np.ndarray,
|
||||
bb_window: int = 20,
|
||||
squeeze_threshold: float = 0.8,
|
||||
breakout_bars: int = 3,
|
||||
volume_mult: float = 1.5,
|
||||
) -> list[dict]:
|
||||
"""Detect squeeze → breakout events."""
|
||||
bw = bollinger_bandwidth(close, bb_window)
|
||||
kcr = keltner_channel_ratio(close, high, low, bb_window)
|
||||
|
||||
events = []
|
||||
in_squeeze = False
|
||||
squeeze_start = 0
|
||||
|
||||
for i in range(bb_window + 1, len(close)):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
|
||||
is_squeeze = kcr[i] < squeeze_threshold
|
||||
|
||||
if is_squeeze and not in_squeeze:
|
||||
in_squeeze = True
|
||||
squeeze_start = i
|
||||
elif not is_squeeze and in_squeeze:
|
||||
in_squeeze = False
|
||||
squeeze_duration = i - squeeze_start
|
||||
|
||||
if squeeze_duration < 5:
|
||||
continue
|
||||
|
||||
# Check breakout direction using next few bars
|
||||
if i + breakout_bars >= len(close):
|
||||
continue
|
||||
|
||||
breakout_ret = (close[i + breakout_bars - 1] - close[i - 1]) / close[i - 1]
|
||||
|
||||
# Volume confirmation
|
||||
avg_vol = np.mean(volume[squeeze_start:i])
|
||||
breakout_vol = np.mean(volume[i:i + breakout_bars])
|
||||
vol_confirmed = breakout_vol > avg_vol * volume_mult if avg_vol > 0 else False
|
||||
|
||||
# Momentum confirmation
|
||||
mom_3 = (close[i + 2] - close[i - 1]) / close[i - 1] if i + 2 < len(close) else 0
|
||||
|
||||
events.append({
|
||||
"idx": i,
|
||||
"squeeze_duration": squeeze_duration,
|
||||
"breakout_ret": breakout_ret,
|
||||
"vol_confirmed": vol_confirmed,
|
||||
"mom_3": mom_3,
|
||||
"bb_expansion": bw[i] / bw[squeeze_start] if bw[squeeze_start] > 0 else 1,
|
||||
})
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def run_squeeze_strategy(asset: str, tf: str = "1h"):
|
||||
print(f"\n{'#'*60}")
|
||||
print(f" {asset} {tf} — VOLATILITY SQUEEZE BREAKOUT")
|
||||
print(f"{'#'*60}")
|
||||
|
||||
df = load_data(asset, tf)
|
||||
close = df["close"].values
|
||||
high = df["high"].values
|
||||
low = df["low"].values
|
||||
volume = df["volume"].values
|
||||
n = len(df)
|
||||
|
||||
split_idx = int(n * 0.7)
|
||||
|
||||
for bb_w in [14, 20, 30]:
|
||||
for sq_thr in [0.7, 0.8, 0.9]:
|
||||
for brk_bars in [3, 6]:
|
||||
events = detect_squeeze_release(close, high, low, volume,
|
||||
bb_window=bb_w, squeeze_threshold=sq_thr,
|
||||
breakout_bars=brk_bars, volume_mult=1.3)
|
||||
|
||||
test_events = [e for e in events if e["idx"] >= split_idx]
|
||||
if len(test_events) < 10:
|
||||
continue
|
||||
|
||||
# Strategy: follow breakout direction, with volume confirmation
|
||||
capital = float(INITIAL_CAPITAL)
|
||||
correct = 0
|
||||
total = 0
|
||||
|
||||
for e in test_events:
|
||||
i = e["idx"]
|
||||
if i + brk_bars * 2 >= n:
|
||||
continue
|
||||
|
||||
# First 1-bar direction as signal
|
||||
first_bar_ret = (close[i] - close[i - 1]) / close[i - 1]
|
||||
if abs(first_bar_ret) < 0.001:
|
||||
continue
|
||||
|
||||
direction = "long" if first_bar_ret > 0 else "short"
|
||||
|
||||
# Actual result after holding for brk_bars more
|
||||
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
|
||||
|
||||
is_correct = (direction == "long" and actual_ret > 0) or (direction == "short" and actual_ret < 0)
|
||||
total += 1
|
||||
if is_correct:
|
||||
correct += 1
|
||||
|
||||
trade_ret = actual_ret if direction == "long" else -actual_ret
|
||||
net = trade_ret * LEVERAGE - FEE_PCT * 2 * LEVERAGE
|
||||
capital += capital * 0.2 * net
|
||||
capital = max(capital, 0)
|
||||
|
||||
# Enhanced: volume-confirmed only
|
||||
if total > 0:
|
||||
acc = correct / total * 100
|
||||
ret = (capital - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100
|
||||
test_candles = n - split_idx
|
||||
test_years = test_candles / (24 * 365.25)
|
||||
ann = ((capital / INITIAL_CAPITAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
||||
if acc >= 55 and total >= 15:
|
||||
print(f" BBw={bb_w:2d} sqThr={sq_thr:.1f} brk={brk_bars}: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}%")
|
||||
|
||||
# Volume-confirmed only
|
||||
cap_vc = float(INITIAL_CAPITAL)
|
||||
correct_vc = 0
|
||||
total_vc = 0
|
||||
|
||||
for e in test_events:
|
||||
if not e["vol_confirmed"]:
|
||||
continue
|
||||
i = e["idx"]
|
||||
if i + brk_bars * 2 >= n:
|
||||
continue
|
||||
|
||||
first_bar_ret = (close[i] - close[i - 1]) / close[i - 1]
|
||||
if abs(first_bar_ret) < 0.001:
|
||||
continue
|
||||
|
||||
direction = "long" if first_bar_ret > 0 else "short"
|
||||
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
|
||||
|
||||
is_correct = (direction == "long" and actual_ret > 0) or (direction == "short" and actual_ret < 0)
|
||||
total_vc += 1
|
||||
if is_correct:
|
||||
correct_vc += 1
|
||||
|
||||
trade_ret = actual_ret if direction == "long" else -actual_ret
|
||||
net = trade_ret * LEVERAGE - FEE_PCT * 2 * LEVERAGE
|
||||
cap_vc += cap_vc * 0.2 * net
|
||||
cap_vc = max(cap_vc, 0)
|
||||
|
||||
if total_vc >= 10:
|
||||
acc_vc = correct_vc / total_vc * 100
|
||||
ret_vc = (cap_vc - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100
|
||||
ann_vc = ((cap_vc / INITIAL_CAPITAL) ** (1 / (test_candles/(24*365.25))) - 1) * 100 if cap_vc > 0 else -100
|
||||
if acc_vc >= 55:
|
||||
print(f" +VOL BBw={bb_w:2d} sqThr={sq_thr:.1f} brk={brk_bars}: trades={total_vc:4d} acc={acc_vc:.1f}% ret={ret_vc:+.1f}% ann={ann_vc:+.1f}%")
|
||||
|
||||
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["1h", "15m"]:
|
||||
run_squeeze_strategy(asset, tf)
|
||||
@@ -0,0 +1,408 @@
|
||||
"""Strategia 13: Squeeze + ML ibrida.
|
||||
Squeeze breakout come PRE-FILTRO (quando tradare),
|
||||
ML come CONFERMA DIREZIONALE (quale direzione).
|
||||
|
||||
Pipeline:
|
||||
1. Rileva squeeze release (Bollinger esce da Keltner)
|
||||
2. Estrai features frattali/strutturali dalla finestra
|
||||
3. ML predice direzione con confidenza
|
||||
4. Trade SOLO se squeeze + ML concordano
|
||||
|
||||
Obiettivo: accuracy squeeze (>80%) + volume trade ML.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.ensemble import GradientBoostingClassifier
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
from src.data.downloader import load_data
|
||||
from src.fractal.patterns import encode_candles
|
||||
|
||||
FEE = 0.001
|
||||
INITIAL = 1000
|
||||
|
||||
|
||||
def keltner_ratio(close, high, low, window=20):
|
||||
n = len(close)
|
||||
result = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc = close[i-window:i]
|
||||
wh = high[i-window:i]
|
||||
wl = low[i-window:i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(wh - wl, np.maximum(np.abs(wh - np.roll(wc, 1)), np.abs(wl - np.roll(wc, 1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc_r = (ma + 1.5*atr) - (ma - 1.5*atr)
|
||||
bb_r = (ma + 2*bb_std) - (ma - 2*bb_std)
|
||||
if kc_r > 0:
|
||||
result[i] = bb_r / kc_r
|
||||
return result
|
||||
|
||||
|
||||
def detect_squeeze_releases(close, high, low, volume, bb_w, sq_thr, min_duration=5):
|
||||
kcr = keltner_ratio(close, high, low, bb_w)
|
||||
events = []
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
|
||||
for i in range(bb_w + 1, len(close)):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < sq_thr
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
duration = i - sq_start
|
||||
if duration < min_duration:
|
||||
continue
|
||||
|
||||
avg_vol = np.mean(volume[sq_start:i])
|
||||
events.append({
|
||||
"idx": i,
|
||||
"squeeze_start": sq_start,
|
||||
"duration": duration,
|
||||
"avg_vol_squeeze": avg_vol,
|
||||
"kcr_at_release": kcr[i],
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
def build_features_at(df, i, squeeze_info):
|
||||
"""Features per il punto di squeeze release."""
|
||||
if i < 100:
|
||||
return None
|
||||
|
||||
o = df["open"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
c = df["close"].values
|
||||
v = df["volume"].values
|
||||
|
||||
feats = []
|
||||
|
||||
# Structural features multi-window
|
||||
for w in [12, 24, 48]:
|
||||
win_c = c[i-w:i]
|
||||
win_o = o[i-w:i]
|
||||
win_h = h[i-w:i]
|
||||
win_l = l[i-w:i]
|
||||
win_v = v[i-w:i]
|
||||
|
||||
mn, mx = win_l.min(), max(win_h.max(), win_c.max())
|
||||
rng = mx - mn if mx - mn > 0 else 1e-10
|
||||
|
||||
total = win_h - win_l
|
||||
total = np.where(total == 0, 1e-10, total)
|
||||
body = np.abs(win_c - win_o) / total
|
||||
direction = np.sign(win_c - win_o)
|
||||
|
||||
log_c = np.log(np.where(win_c == 0, 1e-10, win_c))
|
||||
rets = np.diff(log_c)
|
||||
|
||||
v_mean = np.mean(win_v)
|
||||
|
||||
feats.extend([
|
||||
np.mean(rets) if len(rets) > 0 else 0,
|
||||
np.std(rets) if len(rets) > 0 else 0,
|
||||
np.sum(rets) if len(rets) > 0 else 0,
|
||||
float(pd.Series(rets).skew()) if len(rets) > 2 else 0,
|
||||
float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0,
|
||||
np.mean(body),
|
||||
np.std(body),
|
||||
np.mean(direction),
|
||||
np.mean(direction[-min(3, w):]),
|
||||
(win_c[-1] - mn) / rng,
|
||||
win_v[-1] / v_mean if v_mean > 0 else 1,
|
||||
np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0,
|
||||
])
|
||||
|
||||
# Squeeze-specific features
|
||||
sq = squeeze_info
|
||||
feats.extend([
|
||||
sq["duration"],
|
||||
sq["duration"] / 24, # durata in giorni
|
||||
sq["kcr_at_release"],
|
||||
v[i-1] / sq["avg_vol_squeeze"] if sq["avg_vol_squeeze"] > 0 else 1,
|
||||
np.mean(v[i:min(i+3, len(v))]) / sq["avg_vol_squeeze"] if sq["avg_vol_squeeze"] > 0 else 1,
|
||||
])
|
||||
|
||||
# Price position
|
||||
h48 = np.max(h[max(0, i-48):i])
|
||||
l48 = np.min(l[max(0, i-48):i])
|
||||
r48 = h48 - l48
|
||||
feats.append((c[i-1] - l48) / r48 if r48 > 0 else 0.5)
|
||||
|
||||
# ATR normalized
|
||||
tr = np.maximum(h[i-14:i] - l[i-14:i],
|
||||
np.maximum(np.abs(h[i-14:i] - np.roll(c[i-14:i], 1)),
|
||||
np.abs(l[i-14:i] - np.roll(c[i-14:i], 1))))
|
||||
atr = np.mean(tr[1:])
|
||||
feats.append(atr / c[i-1] if c[i-1] > 0 else 0)
|
||||
|
||||
# First bar momentum (la barra di breakout)
|
||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
||||
feats.append(first_ret)
|
||||
|
||||
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
|
||||
|
||||
|
||||
def run_hybrid(asset, tf, bb_w, sq_thr, brk_bars, leverage, pos_pct):
|
||||
print(f"\n{'='*65}")
|
||||
print(f" {asset} {tf} — HYBRID Squeeze+ML (BBw={bb_w}, sq={sq_thr}, brk={brk_bars})")
|
||||
print(f" Leverage: {leverage}x, Position: {pos_pct*100:.0f}%")
|
||||
print(f"{'='*65}")
|
||||
|
||||
df = load_data(asset, tf)
|
||||
close = df["close"].values
|
||||
high = df["high"].values
|
||||
low = df["low"].values
|
||||
volume = df["volume"].values
|
||||
n = len(df)
|
||||
|
||||
events = detect_squeeze_releases(close, high, low, volume, bb_w, sq_thr)
|
||||
print(f" Squeeze releases totali: {len(events)}")
|
||||
|
||||
# Build dataset: solo ai punti di squeeze
|
||||
X_all, y_all, ev_all = [], [], []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i + brk_bars >= n or i < 100:
|
||||
continue
|
||||
feats = build_features_at(df, i, ev)
|
||||
if feats is None:
|
||||
continue
|
||||
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
|
||||
X_all.append(feats)
|
||||
y_all.append(1 if actual_ret > 0 else 0)
|
||||
ev_all.append(ev)
|
||||
|
||||
if len(X_all) < 50:
|
||||
print(" Troppi pochi campioni.")
|
||||
return None
|
||||
|
||||
X = np.array(X_all)
|
||||
y = np.array(y_all)
|
||||
|
||||
print(f" Samples: {len(X)}, Up: {np.mean(y)*100:.1f}%")
|
||||
|
||||
# Walk-forward
|
||||
TRAIN_SIZE = max(int(len(X) * 0.5), 50)
|
||||
STEP_SIZE = max(int(len(X) * 0.1), 10)
|
||||
|
||||
results = {}
|
||||
|
||||
for ml_thr in [0.50, 0.55, 0.60, 0.65, 0.70]:
|
||||
capital = float(INITIAL)
|
||||
equity = [capital]
|
||||
trades_list = []
|
||||
correct = 0
|
||||
total = 0
|
||||
|
||||
start = 0
|
||||
while start + TRAIN_SIZE + STEP_SIZE <= len(X):
|
||||
train_end = start + TRAIN_SIZE
|
||||
test_end = min(train_end + STEP_SIZE, len(X))
|
||||
|
||||
X_tr = X[start:train_end]
|
||||
y_tr = y[start:train_end]
|
||||
X_te = X[train_end:test_end]
|
||||
y_te = y[train_end:test_end]
|
||||
|
||||
if len(np.unique(y_tr)) < 2:
|
||||
start += STEP_SIZE
|
||||
continue
|
||||
|
||||
scaler = StandardScaler()
|
||||
X_tr_s = scaler.fit_transform(X_tr)
|
||||
X_te_s = scaler.transform(X_te)
|
||||
|
||||
model = GradientBoostingClassifier(
|
||||
n_estimators=150, max_depth=4, min_samples_leaf=10,
|
||||
learning_rate=0.05, subsample=0.8, random_state=42,
|
||||
)
|
||||
model.fit(X_tr_s, y_tr)
|
||||
|
||||
up_idx = list(model.classes_).index(1) if 1 in model.classes_ else -1
|
||||
if up_idx < 0:
|
||||
start += STEP_SIZE
|
||||
continue
|
||||
|
||||
for j in range(len(X_te)):
|
||||
proba = model.predict_proba(X_te_s[j:j+1])[0]
|
||||
p_up = proba[up_idx]
|
||||
|
||||
ev = ev_all[train_end + j]
|
||||
i = ev["idx"]
|
||||
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
|
||||
|
||||
# ML decide direction
|
||||
direction = None
|
||||
if p_up >= ml_thr:
|
||||
direction = "long"
|
||||
elif p_up <= (1 - ml_thr):
|
||||
direction = "short"
|
||||
|
||||
if direction is None:
|
||||
continue
|
||||
|
||||
is_correct = (direction == "long" and actual_ret > 0) or (direction == "short" and actual_ret < 0)
|
||||
total += 1
|
||||
if is_correct:
|
||||
correct += 1
|
||||
|
||||
trade_ret = actual_ret if direction == "long" else -actual_ret
|
||||
net = trade_ret * leverage - FEE * 2 * leverage
|
||||
pnl = capital * pos_pct * net
|
||||
capital += pnl
|
||||
capital = max(capital, 0)
|
||||
equity.append(capital)
|
||||
|
||||
trades_list.append({
|
||||
"idx": i,
|
||||
"direction": direction,
|
||||
"p_up": p_up,
|
||||
"actual_ret": actual_ret,
|
||||
"correct": is_correct,
|
||||
"pnl": pnl,
|
||||
})
|
||||
|
||||
start += STEP_SIZE
|
||||
|
||||
if total == 0:
|
||||
continue
|
||||
|
||||
acc = correct / total * 100
|
||||
|
||||
# Max drawdown
|
||||
peak = equity[0]
|
||||
max_dd = 0
|
||||
for v in equity:
|
||||
if v > peak:
|
||||
peak = v
|
||||
dd = (peak - v) / peak if peak > 0 else 0
|
||||
max_dd = max(max_dd, dd)
|
||||
|
||||
# Annualized
|
||||
first_ev = ev_all[TRAIN_SIZE] if TRAIN_SIZE < len(ev_all) else ev_all[0]
|
||||
last_ev = ev_all[-1]
|
||||
test_candles = last_ev["idx"] - first_ev["idx"]
|
||||
if tf == "1h":
|
||||
test_days = test_candles / 24
|
||||
elif tf == "15m":
|
||||
test_days = test_candles / (24 * 4)
|
||||
else:
|
||||
test_days = test_candles / 24
|
||||
test_years = test_days / 365.25 if test_days > 0 else 1
|
||||
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
||||
daily_pnl = (capital - INITIAL) / test_days if test_days > 0 else 0
|
||||
trades_yr = total / test_years if test_years > 0 else 0
|
||||
|
||||
tag = ""
|
||||
if acc >= 80:
|
||||
tag = " ✅✅"
|
||||
elif acc >= 70:
|
||||
tag = " ✅"
|
||||
|
||||
print(f" ml_thr={ml_thr:.2f}: trades={total:4d} acc={acc:.1f}%{tag} ret={(capital-INITIAL)/INITIAL*100:+.1f}% ann={ann:+.1f}% dd={max_dd*100:.1f}% sharpe= trades/yr={trades_yr:.0f} €/day={daily_pnl:.2f}")
|
||||
|
||||
results[ml_thr] = {
|
||||
"trades": total, "accuracy": acc, "capital": capital,
|
||||
"annualized": ann, "max_dd": max_dd * 100, "daily_pnl": daily_pnl,
|
||||
"trades_yr": trades_yr,
|
||||
}
|
||||
|
||||
# Modalità "squeeze puro" come baseline
|
||||
capital_sq = float(INITIAL)
|
||||
correct_sq = 0
|
||||
total_sq = 0
|
||||
split = int(len(X) * 0.5)
|
||||
|
||||
for k in range(split, len(X)):
|
||||
ev = ev_all[k]
|
||||
i = ev["idx"]
|
||||
if i + brk_bars >= n:
|
||||
continue
|
||||
first_ret = (close[i] - close[i-1]) / close[i-1]
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
|
||||
is_correct = (direction == 1 and actual_ret > 0) or (direction == -1 and actual_ret < 0)
|
||||
total_sq += 1
|
||||
if is_correct:
|
||||
correct_sq += 1
|
||||
trade_ret = actual_ret * direction
|
||||
net = trade_ret * leverage - FEE * 2 * leverage
|
||||
capital_sq += capital_sq * pos_pct * net
|
||||
capital_sq = max(capital_sq, 0)
|
||||
|
||||
if total_sq > 0:
|
||||
acc_sq = correct_sq / total_sq * 100
|
||||
print(f" BASELINE squeeze puro: trades={total_sq:4d} acc={acc_sq:.1f}% ret={(capital_sq-INITIAL)/INITIAL*100:+.1f}%")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ===== MAIN: test su multiple configurazioni =====
|
||||
print("=" * 70)
|
||||
print(" STRATEGIA 13: SQUEEZE + ML IBRIDA")
|
||||
print("=" * 70)
|
||||
|
||||
configs = [
|
||||
# (asset, tf, bb_w, sq_thr, brk_bars, leverage, pos_pct)
|
||||
("ETH", "1h", 20, 0.8, 3, 3, 0.2),
|
||||
("ETH", "1h", 30, 0.9, 3, 3, 0.2),
|
||||
("ETH", "1h", 14, 0.8, 3, 3, 0.2),
|
||||
("ETH", "1h", 20, 0.9, 3, 3, 0.2),
|
||||
("BTC", "1h", 14, 0.8, 3, 3, 0.2),
|
||||
("BTC", "1h", 20, 0.8, 3, 3, 0.2),
|
||||
("BTC", "1h", 14, 0.9, 6, 3, 0.2),
|
||||
("ETH", "15m", 14, 0.8, 3, 3, 0.15),
|
||||
("ETH", "15m", 20, 0.9, 3, 3, 0.15),
|
||||
("BTC", "15m", 14, 0.9, 3, 3, 0.15),
|
||||
# Aggressive
|
||||
("ETH", "1h", 20, 0.8, 3, 5, 0.3),
|
||||
("ETH", "1h", 30, 0.9, 3, 5, 0.3),
|
||||
]
|
||||
|
||||
all_results = []
|
||||
for cfg in configs:
|
||||
r = run_hybrid(*cfg)
|
||||
if r:
|
||||
for thr, data in r.items():
|
||||
all_results.append({
|
||||
"config": f"{cfg[0]} {cfg[1]} BBw={cfg[2]} sq={cfg[3]} brk={cfg[4]} lev={cfg[5]} pos={cfg[6]}",
|
||||
"ml_thr": thr,
|
||||
**data,
|
||||
})
|
||||
|
||||
# Sort by accuracy
|
||||
print("\n\n" + "=" * 70)
|
||||
print(" CLASSIFICA PER ACCURACY (top 20)")
|
||||
print("=" * 70)
|
||||
sorted_acc = sorted(all_results, key=lambda x: x["accuracy"], reverse=True)
|
||||
for r in sorted_acc[:20]:
|
||||
tag = "✅✅" if r["accuracy"] >= 80 else "✅" if r["accuracy"] >= 70 else ""
|
||||
print(f" {r['config']:55s} ml={r['ml_thr']:.2f} → acc={r['accuracy']:.1f}% {tag:4s} trades={r['trades']:4d} ret={(r['capital']-INITIAL)/INITIAL*100:+.1f}% ann={r['annualized']:+.1f}% dd={r['max_dd']:.1f}% €/day={r['daily_pnl']:.2f}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" CLASSIFICA PER ROI ANNUO (top 20, min 20 trades)")
|
||||
print("=" * 70)
|
||||
sorted_roi = sorted([r for r in all_results if r["trades"] >= 20], key=lambda x: x["annualized"], reverse=True)
|
||||
for r in sorted_roi[:20]:
|
||||
tag = "✅✅" if r["accuracy"] >= 80 else "✅" if r["accuracy"] >= 70 else ""
|
||||
print(f" {r['config']:55s} ml={r['ml_thr']:.2f} → acc={r['accuracy']:.1f}% {tag:4s} ann={r['annualized']:+.1f}% trades={r['trades']:4d} dd={r['max_dd']:.1f}% €/day={r['daily_pnl']:.2f}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" SWEET SPOT: acc>=75% AND ann>=20% AND trades>=15")
|
||||
print("=" * 70)
|
||||
sweet = [r for r in all_results if r["accuracy"] >= 75 and r["annualized"] >= 20 and r["trades"] >= 15]
|
||||
sweet.sort(key=lambda x: x["accuracy"] * x["annualized"], reverse=True)
|
||||
for r in sweet:
|
||||
print(f" {r['config']:55s} ml={r['ml_thr']:.2f} → acc={r['accuracy']:.1f}% ann={r['annualized']:+.1f}% trades={r['trades']:4d} dd={r['max_dd']:.1f}% €/day={r['daily_pnl']:.2f}")
|
||||
@@ -0,0 +1,317 @@
|
||||
"""S3-01: Squeeze Migliorato — test per-anno, dati reali.
|
||||
Miglioramenti rispetto al squeeze base:
|
||||
1. Cross-asset: squeeze su BTC + ETH contemporaneo = segnale più forte
|
||||
2. Timing orario: accuracy per fascia oraria
|
||||
3. Squeeze duration weighted: squeeze lunghi → breakout più forti
|
||||
4. Dual-timeframe: squeeze su 1h confermato da 15m
|
||||
5. Anti-fakeout: skip se candela post-breakout ritraccia >50%
|
||||
6. Dynamic exit: trailing stop basato su ATR
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_RT = 0.002
|
||||
INITIAL = 1000
|
||||
LEVERAGE = 3
|
||||
|
||||
|
||||
def keltner_ratio(close, high, low, window=14):
|
||||
n = len(close)
|
||||
r = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc = (ma+1.5*atr)-(ma-1.5*atr)
|
||||
bb = (ma+2*bb_std)-(ma-2*bb_std)
|
||||
if kc > 0:
|
||||
r[i] = bb/kc
|
||||
return r
|
||||
|
||||
|
||||
def atr_calc(high, low, close, period=14):
|
||||
tr = np.maximum(high-low, np.maximum(np.abs(high-np.roll(close,1)), np.abs(low-np.roll(close,1))))
|
||||
tr[0] = high[0]-low[0]
|
||||
r = np.full(len(close), np.nan)
|
||||
r[period-1] = np.mean(tr[:period])
|
||||
k = 2/(period+1)
|
||||
for i in range(period, len(close)):
|
||||
r[i] = tr[i]*k + r[i-1]*(1-k)
|
||||
return r
|
||||
|
||||
|
||||
def detect_squeezes(close, high, low, volume, kcr, sq_thr=0.8, min_dur=5):
|
||||
"""Ritorna lista di squeeze events con metadata."""
|
||||
events = []
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
n = len(close)
|
||||
|
||||
for i in range(1, n):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < sq_thr
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
dur = i - sq_start
|
||||
if dur < min_dur:
|
||||
continue
|
||||
avg_vol = np.mean(volume[sq_start:i])
|
||||
# Range durante squeeze
|
||||
sq_range = (np.max(high[sq_start:i]) - np.min(low[sq_start:i])) / close[sq_start] if close[sq_start] > 0 else 0
|
||||
events.append({
|
||||
"release_idx": i,
|
||||
"duration": dur,
|
||||
"avg_vol": avg_vol,
|
||||
"squeeze_range": sq_range,
|
||||
"kcr_at_release": kcr[i],
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
def run_improved_squeeze(primary_asset, tf="1h"):
|
||||
# Carica asset primario
|
||||
df = load_data(primary_asset, tf)
|
||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
||||
n = len(df)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
ts_ms = df["timestamp"].values
|
||||
|
||||
kcr = keltner_ratio(c, h, l, 14)
|
||||
atr_14 = atr_calc(h, l, c, 14)
|
||||
events = detect_squeezes(c, h, l, v, kcr)
|
||||
|
||||
# Carica asset secondario per cross-check
|
||||
secondary = "BTC" if primary_asset == "ETH" else "ETH"
|
||||
df2 = load_data(secondary, tf)
|
||||
c2, h2, l2 = df2["close"].values, df2["high"].values, df2["low"].values
|
||||
ts2_ms = df2["timestamp"].values
|
||||
kcr2 = keltner_ratio(c2, h2, l2, 14)
|
||||
|
||||
# Mappa ts2 → indici per allineare
|
||||
def find_idx2(ts_val):
|
||||
idx = np.searchsorted(ts2_ms, ts_val)
|
||||
return min(idx, len(c2)-1)
|
||||
|
||||
# Carica 15m per dual-TF
|
||||
if tf == "1h":
|
||||
df_15m = load_data(primary_asset, "15m")
|
||||
c15 = df_15m["close"].values
|
||||
h15 = df_15m["high"].values
|
||||
l15 = df_15m["low"].values
|
||||
ts15 = df_15m["timestamp"].values
|
||||
kcr_15m = keltner_ratio(c15, h15, l15, 14)
|
||||
else:
|
||||
kcr_15m = None
|
||||
ts15 = None
|
||||
|
||||
# ================================================================
|
||||
# CONFIGURAZIONI
|
||||
# ================================================================
|
||||
configs = [
|
||||
# (name, use_cross, use_timing, use_duration, use_dual_tf, use_antifake, use_trailing, hold, stop_atr)
|
||||
("BASE", False, False, False, False, False, False, 3, 0),
|
||||
("cross_asset", True, False, False, False, False, False, 3, 0),
|
||||
("timing_filter", False, True, False, False, False, False, 3, 0),
|
||||
("long_squeeze", False, False, True, False, False, False, 3, 0),
|
||||
("dual_tf", False, False, False, True, False, False, 3, 0),
|
||||
("anti_fakeout", False, False, False, False, True, False, 3, 0),
|
||||
("trailing_stop", False, False, False, False, False, True, 6, 1.5),
|
||||
("cross+timing", True, True, False, False, False, False, 3, 0),
|
||||
("cross+long+timing", True, True, True, False, False, False, 3, 0),
|
||||
("cross+dual_tf", True, False, False, True, False, False, 3, 0),
|
||||
("ALL_FILTERS", True, True, True, True, True, False, 3, 0),
|
||||
("ALL+trailing", True, True, True, True, True, True, 6, 1.5),
|
||||
("cross+antifake", True, False, False, False, True, False, 3, 0),
|
||||
("timing+antifake", False, True, False, False, True, False, 3, 0),
|
||||
("cross+timing+antifk", True, True, False, False, True, False, 3, 0),
|
||||
("cross+timing+trail", True, True, False, False, False, True, 6, 1.5),
|
||||
]
|
||||
|
||||
print(f"\n{'#'*75}")
|
||||
print(f" {primary_asset} {tf} — SQUEEZE MIGLIORATO")
|
||||
print(f"{'#'*75}")
|
||||
|
||||
results = []
|
||||
|
||||
for name, f_cross, f_timing, f_dur, f_dual, f_antifake, f_trail, hold, stop_atr_m in configs:
|
||||
yearly = {}
|
||||
capital = float(INITIAL)
|
||||
peak = capital
|
||||
max_dd = 0
|
||||
|
||||
for ev in events:
|
||||
i = ev["release_idx"]
|
||||
if i + hold + 2 >= n:
|
||||
continue
|
||||
|
||||
# --- FILTRI ---
|
||||
skip = False
|
||||
|
||||
# Cross-asset: secondary deve anche essere in squeeze recente o breakout
|
||||
if f_cross:
|
||||
i2 = find_idx2(ts_ms[i])
|
||||
if i2 >= 5:
|
||||
sec_in_squeeze = any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85 for j in range(max(0,i2-10), i2+1))
|
||||
if not sec_in_squeeze:
|
||||
skip = True
|
||||
|
||||
# Timing: solo certe ore (testato: 6-14 UTC migliori)
|
||||
if f_timing:
|
||||
hour = ts.iloc[i].hour
|
||||
if hour < 4 or hour > 16:
|
||||
skip = True
|
||||
|
||||
# Duration: solo squeeze > 10 barre
|
||||
if f_dur:
|
||||
if ev["duration"] < 10:
|
||||
skip = True
|
||||
|
||||
# Dual-TF: squeeze anche su 15m
|
||||
if f_dual and kcr_15m is not None and ts15 is not None:
|
||||
i15 = np.searchsorted(ts15, ts_ms[i])
|
||||
if i15 >= 5:
|
||||
sq_15m = any(not np.isnan(kcr_15m[j]) and kcr_15m[j] < 0.85 for j in range(max(0,i15-20), i15+1))
|
||||
if not sq_15m:
|
||||
skip = True
|
||||
|
||||
# Anti-fakeout: prima candela post-breakout non deve ritracciare >50%
|
||||
if f_antifake and i + 1 < n:
|
||||
breakout_bar_range = h[i] - l[i]
|
||||
if breakout_bar_range > 0:
|
||||
if c[i] > c[i-1]: # breakout up
|
||||
retrace = (h[i] - c[i]) / breakout_bar_range
|
||||
else: # breakout down
|
||||
retrace = (c[i] - l[i]) / breakout_bar_range
|
||||
if retrace > 0.6:
|
||||
skip = True
|
||||
|
||||
if skip:
|
||||
continue
|
||||
|
||||
# --- DIREZIONE ---
|
||||
first_ret = (c[i] - c[i-1]) / c[i-1]
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
|
||||
# --- EXIT ---
|
||||
entry = c[i-1]
|
||||
if f_trail and not np.isnan(atr_14[i]):
|
||||
# Trailing stop
|
||||
trail_dist = atr_14[i] * stop_atr_m
|
||||
best_price = entry
|
||||
exit_price = c[min(i+hold, n-1)]
|
||||
for j in range(i, min(i+hold+1, n)):
|
||||
if direction == 1:
|
||||
best_price = max(best_price, h[j])
|
||||
if l[j] <= best_price - trail_dist:
|
||||
exit_price = best_price - trail_dist
|
||||
break
|
||||
else:
|
||||
best_price = min(best_price, l[j])
|
||||
if h[j] >= best_price + trail_dist:
|
||||
exit_price = best_price + trail_dist
|
||||
break
|
||||
exit_price = c[j]
|
||||
else:
|
||||
exit_price = c[min(i+hold-1, n-1)]
|
||||
|
||||
actual = (exit_price - entry) / entry * direction
|
||||
net = actual * LEVERAGE - FEE_RT * LEVERAGE
|
||||
|
||||
capital += capital * 0.15 * net
|
||||
capital = max(capital, 10)
|
||||
if capital > peak: peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"wins": 0, "total": 0, "pnls": []}
|
||||
yearly[year]["total"] += 1
|
||||
if actual > 0:
|
||||
yearly[year]["wins"] += 1
|
||||
yearly[year]["pnls"].append(net * INITIAL)
|
||||
|
||||
all_t = sum(d["total"] for d in yearly.values())
|
||||
all_w = sum(d["wins"] for d in yearly.values())
|
||||
if all_t < 30:
|
||||
continue
|
||||
|
||||
acc = all_w / all_t * 100
|
||||
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
|
||||
tot_pnl = sum(all_pnls)
|
||||
|
||||
# Worst year
|
||||
worst_y_acc = 100
|
||||
worst_y = ""
|
||||
for y, d in yearly.items():
|
||||
ya = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
|
||||
if ya < worst_y_acc:
|
||||
worst_y_acc = ya
|
||||
worst_y = str(y)
|
||||
|
||||
results.append({
|
||||
"name": name, "trades": all_t, "acc": acc, "pnl": tot_pnl,
|
||||
"max_dd": max_dd*100, "capital": capital,
|
||||
"worst": f"{worst_y}({worst_y_acc:.0f}%)",
|
||||
"yearly": yearly,
|
||||
})
|
||||
|
||||
# Sort by accuracy
|
||||
results.sort(key=lambda x: x["acc"], reverse=True)
|
||||
|
||||
print(f"\n {'Name':.<26s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>6s} {'Capital':>10s} {'Worst':>12s}")
|
||||
print(f" {'-'*80}")
|
||||
for r in results:
|
||||
tag = "✅✅" if r["acc"] >= 80 else "✅" if r["acc"] >= 76 else ""
|
||||
print(f" {r['name']:.<26s} {r['trades']:>7d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['max_dd']:>5.1f}% €{r['capital']:>9,.0f} {r['worst']:>12s} {tag}")
|
||||
|
||||
# Dettaglio per anno del migliore
|
||||
if results:
|
||||
best = results[0]
|
||||
print(f"\n MIGLIORE: {best['name']} → {best['acc']:.1f}% acc")
|
||||
print(f" {'Anno':>6s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s}")
|
||||
for y in sorted(best["yearly"]):
|
||||
d = best["yearly"][y]
|
||||
ya = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
|
||||
yp = sum(d["pnls"])
|
||||
tag = " ← CRASH" if y in [2020,2021,2022] else ""
|
||||
print(f" {y:>6d} {d['total']:>7d} {ya:>5.1f}% €{yp:>+8.0f}{tag}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# Run su entrambi gli asset e timeframe
|
||||
all_results = {}
|
||||
for asset in ["ETH", "BTC"]:
|
||||
for tf in ["1h", "15m"]:
|
||||
key = f"{asset}_{tf}"
|
||||
all_results[key] = run_improved_squeeze(asset, tf)
|
||||
|
||||
# Classifica globale
|
||||
print(f"\n\n{'='*75}")
|
||||
print(f" CLASSIFICA GLOBALE — TOP 15")
|
||||
print(f"{'='*75}")
|
||||
|
||||
global_list = []
|
||||
for key, results in all_results.items():
|
||||
for r in results:
|
||||
global_list.append({**r, "asset_tf": key})
|
||||
|
||||
global_list.sort(key=lambda x: x["acc"], reverse=True)
|
||||
print(f"\n {'Asset_TF':.<12s} {'Name':.<26s} {'Trades':>6s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>5s} {'Worst':>12s}")
|
||||
for r in global_list[:15]:
|
||||
tag = "✅✅" if r["acc"] >= 80 else "✅" if r["acc"] >= 76 else ""
|
||||
print(f" {r['asset_tf']:.<12s} {r['name']:.<26s} {r['trades']:>6d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['max_dd']:>4.1f}% {r['worst']:>12s} {tag}")
|
||||
@@ -0,0 +1,290 @@
|
||||
"""S3-02: Lead-lag multi-asset squeeze.
|
||||
Quando BTC fa squeeze breakout, ETH/SOL spesso seguono.
|
||||
Usa il breakout di BTC per anticipare entrata su ETH (e viceversa).
|
||||
Testa anche correlazione inter-asset per conferma segnale.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_RT = 0.002
|
||||
INITIAL = 1000
|
||||
LEVERAGE = 3
|
||||
|
||||
|
||||
def keltner_ratio(close, high, low, window=14):
|
||||
n = len(close)
|
||||
r = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc = (ma+1.5*atr)-(ma-1.5*atr)
|
||||
bb = (ma+2*bb_std)-(ma-2*bb_std)
|
||||
if kc > 0: r[i] = bb/kc
|
||||
return r
|
||||
|
||||
|
||||
def load_aligned(assets, tf):
|
||||
"""Carica e allinea dati multi-asset per timestamp."""
|
||||
dfs = {}
|
||||
for asset in assets:
|
||||
try:
|
||||
if asset == "SOL":
|
||||
df = pd.read_parquet(f"data/raw/sol_{tf}.parquet")
|
||||
df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
else:
|
||||
df = load_data(asset, tf)
|
||||
dfs[asset] = df
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if len(dfs) < 2:
|
||||
return None
|
||||
|
||||
# Allinea per timestamp
|
||||
common_ts = set(dfs[list(dfs.keys())[0]]["timestamp"].values)
|
||||
for df in dfs.values():
|
||||
common_ts &= set(df["timestamp"].values)
|
||||
common_ts = sorted(common_ts)
|
||||
|
||||
aligned = {}
|
||||
for asset, df in dfs.items():
|
||||
mask = df["timestamp"].isin(common_ts)
|
||||
aligned[asset] = df[mask].sort_values("timestamp").reset_index(drop=True)
|
||||
|
||||
return aligned
|
||||
|
||||
|
||||
def detect_breakouts(close, high, low, volume, kcr, sq_thr=0.8, min_dur=5):
|
||||
"""Detect squeeze breakout events."""
|
||||
events = []
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
for i in range(1, len(close)):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < sq_thr
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
if i - sq_start < min_dur:
|
||||
continue
|
||||
first_ret = (close[i] - close[i-1]) / close[i-1] if close[i-1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
events.append({
|
||||
"idx": i,
|
||||
"duration": i - sq_start,
|
||||
"direction": 1 if first_ret > 0 else -1,
|
||||
"first_ret": first_ret,
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
print("=" * 75)
|
||||
print(" S3-02: LEAD-LAG MULTI-ASSET SQUEEZE")
|
||||
print("=" * 75)
|
||||
|
||||
for tf in ["1h", "15m"]:
|
||||
aligned = load_aligned(["BTC", "ETH", "SOL"], tf)
|
||||
if aligned is None:
|
||||
continue
|
||||
|
||||
n = len(aligned["BTC"])
|
||||
ts = pd.to_datetime(aligned["BTC"]["timestamp"], unit="ms", utc=True)
|
||||
|
||||
print(f"\n Timeframe: {tf}, Candles allineate: {n}")
|
||||
|
||||
# Calcola squeeze per ogni asset
|
||||
asset_data = {}
|
||||
for asset in aligned:
|
||||
df = aligned[asset]
|
||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
||||
kcr = keltner_ratio(c, h, l, 14)
|
||||
events = detect_breakouts(c, h, l, v, kcr)
|
||||
asset_data[asset] = {"close": c, "high": h, "low": l, "vol": v, "kcr": kcr, "events": events}
|
||||
print(f" {asset}: {len(events)} squeeze breakouts")
|
||||
|
||||
# ================================================================
|
||||
# STRATEGIA A: Leader-follower
|
||||
# Quando BTC fa breakout, entra su ETH/SOL nella stessa direzione
|
||||
# ================================================================
|
||||
print(f"\n --- LEADER-FOLLOWER ({tf}) ---")
|
||||
|
||||
for leader, follower in [("BTC", "ETH"), ("BTC", "SOL"), ("ETH", "BTC"), ("ETH", "SOL")]:
|
||||
if leader not in asset_data or follower not in asset_data:
|
||||
continue
|
||||
|
||||
leader_events = asset_data[leader]["events"]
|
||||
fc = asset_data[follower]["close"]
|
||||
|
||||
for hold in [3, 6]:
|
||||
for delay in [0, 1, 2]:
|
||||
yearly = {}
|
||||
|
||||
for ev in leader_events:
|
||||
i = ev["idx"] + delay
|
||||
if i + hold >= n:
|
||||
continue
|
||||
|
||||
# Anti-fakeout su follower
|
||||
entry = fc[i]
|
||||
exit_price = fc[min(i + hold, n - 1)]
|
||||
direction = ev["direction"]
|
||||
actual = (exit_price - entry) / entry * direction
|
||||
net = actual * LEVERAGE - FEE_RT * LEVERAGE
|
||||
|
||||
year = ts.iloc[min(i, n-1)].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0:
|
||||
yearly[year]["w"] += 1
|
||||
yearly[year]["pnls"].append(net * INITIAL)
|
||||
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t < 30:
|
||||
continue
|
||||
acc = all_w / all_t * 100
|
||||
pnl = sum(p for d in yearly.values() for p in d["pnls"])
|
||||
worst_y = min(yearly.items(), key=lambda x: x[1]["w"]/x[1]["t"] if x[1]["t"]>0 else 0)
|
||||
worst_acc = worst_y[1]["w"]/worst_y[1]["t"]*100 if worst_y[1]["t"]>0 else 0
|
||||
tag = "✅" if acc >= 76 else ""
|
||||
print(f" {leader}→{follower} d={delay} h={hold}: trades={all_t:5d} acc={acc:.1f}% pnl=€{pnl:+.0f} worst={worst_y[0]}({worst_acc:.0f}%) {tag}")
|
||||
|
||||
# ================================================================
|
||||
# STRATEGIA B: Consensus multi-asset
|
||||
# Trade solo quando 2+ asset hanno squeeze breakout nello stesso momento
|
||||
# ================================================================
|
||||
print(f"\n --- CONSENSUS MULTI-ASSET ({tf}) ---")
|
||||
|
||||
# Build event map: timestamp → list of (asset, direction)
|
||||
event_map = {}
|
||||
for asset, data in asset_data.items():
|
||||
for ev in data["events"]:
|
||||
idx = ev["idx"]
|
||||
if idx not in event_map:
|
||||
event_map[idx] = []
|
||||
event_map[idx].append((asset, ev["direction"]))
|
||||
|
||||
for target in ["BTC", "ETH", "SOL"]:
|
||||
if target not in asset_data:
|
||||
continue
|
||||
tc = asset_data[target]["close"]
|
||||
|
||||
for min_consensus in [2, 3]:
|
||||
for window_bars in [1, 3, 5]:
|
||||
yearly = {}
|
||||
daily_done = set()
|
||||
|
||||
for idx in sorted(event_map.keys()):
|
||||
if idx + 6 >= n:
|
||||
continue
|
||||
|
||||
day = ts.iloc[idx].strftime("%Y-%m-%d")
|
||||
if day in daily_done:
|
||||
continue
|
||||
|
||||
# Count consensus within window
|
||||
nearby_events = []
|
||||
for j in range(max(0, idx - window_bars), idx + window_bars + 1):
|
||||
if j in event_map:
|
||||
nearby_events.extend(event_map[j])
|
||||
|
||||
# Unique assets
|
||||
unique_assets = set(a for a, d in nearby_events)
|
||||
if len(unique_assets) < min_consensus:
|
||||
continue
|
||||
|
||||
# Majority direction
|
||||
dirs = [d for a, d in nearby_events]
|
||||
majority = 1 if sum(dirs) > 0 else -1
|
||||
|
||||
entry = tc[idx]
|
||||
exit_price = tc[min(idx + 3, n - 1)]
|
||||
actual = (exit_price - entry) / entry * majority
|
||||
net = actual * LEVERAGE - FEE_RT * LEVERAGE
|
||||
|
||||
year = ts.iloc[idx].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0:
|
||||
yearly[year]["w"] += 1
|
||||
yearly[year]["pnls"].append(net * INITIAL)
|
||||
daily_done.add(day)
|
||||
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t < 20:
|
||||
continue
|
||||
acc = all_w / all_t * 100
|
||||
pnl = sum(p for d in yearly.values() for p in d["pnls"])
|
||||
tag = "✅" if acc >= 76 else ""
|
||||
print(f" {target} consensus>={min_consensus} w={window_bars}: trades={all_t:4d} acc={acc:.1f}% pnl=€{pnl:+.0f} {tag}")
|
||||
|
||||
# ================================================================
|
||||
# STRATEGIA C: Correlation-weighted squeeze
|
||||
# Peso il segnale squeeze in base alla correlazione rolling con BTC
|
||||
# ================================================================
|
||||
print(f"\n --- CORRELATION-WEIGHTED ({tf}) ---")
|
||||
|
||||
for target in ["ETH", "SOL"]:
|
||||
if target not in asset_data:
|
||||
continue
|
||||
tc = asset_data[target]["close"]
|
||||
btc_c = asset_data["BTC"]["close"]
|
||||
|
||||
# Rolling correlation
|
||||
corr_window = 48 # 48 bars
|
||||
rolling_corr = np.full(n, np.nan)
|
||||
ret_t = np.diff(np.log(np.where(tc == 0, 1e-10, tc)))
|
||||
ret_b = np.diff(np.log(np.where(btc_c == 0, 1e-10, btc_c)))
|
||||
for i in range(corr_window, len(ret_t)):
|
||||
c_val = np.corrcoef(ret_t[i-corr_window:i], ret_b[i-corr_window:i])[0, 1]
|
||||
rolling_corr[i + 1] = c_val if np.isfinite(c_val) else 0
|
||||
|
||||
events = asset_data[target]["events"]
|
||||
|
||||
for corr_thr in [0.5, 0.6, 0.7, 0.8]:
|
||||
yearly = {}
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i + 3 >= n or np.isnan(rolling_corr[i]):
|
||||
continue
|
||||
|
||||
# Solo quando correlazione con BTC è alta
|
||||
if abs(rolling_corr[i]) < corr_thr:
|
||||
continue
|
||||
|
||||
entry = tc[i - 1]
|
||||
exit_price = tc[min(i + 2, n - 1)]
|
||||
actual = (exit_price - entry) / entry * ev["direction"]
|
||||
net = actual * LEVERAGE - FEE_RT * LEVERAGE
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0:
|
||||
yearly[year]["w"] += 1
|
||||
yearly[year]["pnls"].append(net * INITIAL)
|
||||
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t < 20:
|
||||
continue
|
||||
acc = all_w / all_t * 100
|
||||
pnl = sum(p for d in yearly.values() for p in d["pnls"])
|
||||
tag = "✅" if acc >= 76 else ""
|
||||
print(f" {target} corr>={corr_thr}: trades={all_t:4d} acc={acc:.1f}% pnl=€{pnl:+.0f} {tag}")
|
||||
@@ -0,0 +1,256 @@
|
||||
"""S3-03: Ultimate Squeeze — combina TUTTI i filtri migliori.
|
||||
Filtri che funzionano (testati singolarmente):
|
||||
- Anti-fakeout (+1% acc)
|
||||
- Long squeeze duration (+1% acc)
|
||||
- Cross-asset squeeze simultaneo (+0.5%)
|
||||
- Timing 4-16 UTC (+0.5%)
|
||||
- Correlation ETH-BTC alta per ETH trades (+1%)
|
||||
- Volume confirmation al breakout
|
||||
|
||||
Nuovi filtri da testare:
|
||||
- Volume delta: up_volume - down_volume al breakout
|
||||
- Momentum confirmation: breakout nella direzione del trend 1h
|
||||
- Volatility regime: skip in regime estremo (RV > 100%)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_RT = 0.002
|
||||
INITIAL = 1000
|
||||
LEVERAGE = 3
|
||||
|
||||
|
||||
def keltner_ratio(close, high, low, window=14):
|
||||
n = len(close)
|
||||
r = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc = (ma+1.5*atr)-(ma-1.5*atr)
|
||||
bb = (ma+2*bb_std)-(ma-2*bb_std)
|
||||
if kc > 0: r[i] = bb/kc
|
||||
return r
|
||||
|
||||
|
||||
def ema(arr, period):
|
||||
r = np.full(len(arr), np.nan)
|
||||
k = 2/(period+1)
|
||||
r[period-1] = np.mean(arr[:period])
|
||||
for i in range(period, len(arr)):
|
||||
r[i] = arr[i]*k + r[i-1]*(1-k)
|
||||
return r
|
||||
|
||||
|
||||
def rv_ann(close, window):
|
||||
lr = np.diff(np.log(np.where(close == 0, 1e-10, close)))
|
||||
r = np.full(len(close), np.nan)
|
||||
for i in range(window, len(lr)):
|
||||
r[i+1] = np.std(lr[i-window:i]) * np.sqrt(24*365)
|
||||
return r
|
||||
|
||||
|
||||
def run_ultimate(primary, tf="15m"):
|
||||
secondary = "ETH" if primary == "BTC" else "BTC"
|
||||
|
||||
df = load_data(primary, tf)
|
||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
||||
n = len(df)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
df2 = load_data(secondary, tf)
|
||||
c2, ts2 = df2["close"].values, df2["timestamp"].values
|
||||
|
||||
kcr = keltner_ratio(c, h, l, 14)
|
||||
kcr2 = keltner_ratio(c2, df2["high"].values, df2["low"].values, 14)
|
||||
|
||||
ema_50 = ema(c, 50)
|
||||
rv_48 = rv_ann(c, 48)
|
||||
|
||||
# Rolling correlation
|
||||
ret1 = np.diff(np.log(np.where(c == 0, 1e-10, c)))
|
||||
ret2 = np.diff(np.log(np.where(c2[:len(c)] == 0, 1e-10, c2[:len(c)])))
|
||||
min_len = min(len(ret1), len(ret2))
|
||||
ret1 = ret1[:min_len]
|
||||
ret2 = ret2[:min_len]
|
||||
corr = np.full(n, np.nan)
|
||||
for i in range(48, min_len):
|
||||
cv = np.corrcoef(ret1[i-48:i], ret2[i-48:i])[0,1]
|
||||
corr[i+1] = cv if np.isfinite(cv) else 0
|
||||
|
||||
# Detect squeezes
|
||||
events = []
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
for i in range(15, n):
|
||||
if np.isnan(kcr[i]): continue
|
||||
is_sq = kcr[i] < 0.8
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
dur = i - sq_start
|
||||
if dur < 5 or i + 6 >= n:
|
||||
continue
|
||||
events.append({"idx": i, "dur": dur, "sq_start": sq_start})
|
||||
|
||||
print(f"\n{'#'*70}")
|
||||
print(f" {primary} {tf} — ULTIMATE SQUEEZE ({len(events)} squeeze events)")
|
||||
print(f"{'#'*70}")
|
||||
|
||||
filters_map = {
|
||||
"antifake": lambda ev, i: not _antifake(c, h, l, i),
|
||||
"long_sq": lambda ev, i: ev["dur"] >= 10,
|
||||
"timing": lambda ev, i: 4 <= ts.iloc[i].hour <= 16,
|
||||
"cross": lambda ev, i: _cross_squeeze(kcr2, i, ts, ts2),
|
||||
"corr_high": lambda ev, i: not np.isnan(corr[i]) and abs(corr[i]) >= 0.6,
|
||||
"vol_confirm": lambda ev, i: _vol_confirm(v, i, ev["sq_start"]),
|
||||
"trend_align": lambda ev, i: _trend_align(c, ema_50, i),
|
||||
"low_rv": lambda ev, i: not np.isnan(rv_48[i]) and rv_48[i] < 1.5,
|
||||
}
|
||||
|
||||
def _antifake(c, h, l, i):
|
||||
if i + 1 >= len(c): return False
|
||||
br = h[i] - l[i]
|
||||
if br <= 0: return False
|
||||
if c[i] > c[i-1]:
|
||||
return (h[i] - c[i]) / br > 0.6
|
||||
return (c[i] - l[i]) / br > 0.6
|
||||
|
||||
def _cross_squeeze(kcr2, i, ts1, ts2_arr):
|
||||
i2 = np.searchsorted(ts2_arr, ts.values[i].astype("int64") // 10**6)
|
||||
i2 = min(i2, len(kcr2)-1)
|
||||
return any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85 for j in range(max(0,i2-10), i2+1))
|
||||
|
||||
def _vol_confirm(v, i, sq_start):
|
||||
avg = np.mean(v[sq_start:i])
|
||||
return avg > 0 and v[i] > avg * 1.3
|
||||
|
||||
def _trend_align(c, ema_val, i):
|
||||
if np.isnan(ema_val[i]): return True
|
||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
||||
if first_ret > 0:
|
||||
return c[i] > ema_val[i]
|
||||
return c[i] < ema_val[i]
|
||||
|
||||
# Test combinazioni incrementali
|
||||
combos = [
|
||||
("BASE", []),
|
||||
("antifake", ["antifake"]),
|
||||
("long_sq", ["long_sq"]),
|
||||
("antifake+long", ["antifake", "long_sq"]),
|
||||
("antifake+timing", ["antifake", "timing"]),
|
||||
("antifake+cross", ["antifake", "cross"]),
|
||||
("antifake+corr", ["antifake", "corr_high"]),
|
||||
("antifake+vol", ["antifake", "vol_confirm"]),
|
||||
("antifake+trend", ["antifake", "trend_align"]),
|
||||
("af+long+timing", ["antifake", "long_sq", "timing"]),
|
||||
("af+long+cross", ["antifake", "long_sq", "cross"]),
|
||||
("af+long+corr", ["antifake", "long_sq", "corr_high"]),
|
||||
("af+long+trend", ["antifake", "long_sq", "trend_align"]),
|
||||
("af+long+cross+time", ["antifake", "long_sq", "cross", "timing"]),
|
||||
("af+long+corr+time", ["antifake", "long_sq", "corr_high", "timing"]),
|
||||
("af+long+corr+trend", ["antifake", "long_sq", "corr_high", "trend_align"]),
|
||||
("ALL_NO_VOL", ["antifake", "long_sq", "cross", "timing", "corr_high", "trend_align", "low_rv"]),
|
||||
("ALL", ["antifake", "long_sq", "cross", "timing", "corr_high", "vol_confirm", "trend_align", "low_rv"]),
|
||||
("BEST_5", ["antifake", "long_sq", "corr_high", "trend_align", "low_rv"]),
|
||||
]
|
||||
|
||||
results = []
|
||||
for combo_name, filter_names in combos:
|
||||
yearly = {}
|
||||
capital = float(INITIAL)
|
||||
peak = capital
|
||||
max_dd = 0
|
||||
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
|
||||
skip = False
|
||||
for fn in filter_names:
|
||||
if fn in filters_map and not filters_map[fn](ev, i):
|
||||
skip = True
|
||||
break
|
||||
if skip:
|
||||
continue
|
||||
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
entry = c[i-1]
|
||||
exit_price = c[min(i+2, n-1)]
|
||||
actual = (exit_price - entry) / entry * direction
|
||||
net = actual * LEVERAGE - FEE_RT * LEVERAGE
|
||||
|
||||
capital += capital * 0.15 * net
|
||||
capital = max(capital, 10)
|
||||
if capital > peak: peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0: yearly[year]["w"] += 1
|
||||
yearly[year]["pnls"].append(net * INITIAL)
|
||||
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t < 20: continue
|
||||
|
||||
acc = all_w / all_t * 100
|
||||
pnl = sum(p for d in yearly.values() for p in d["pnls"])
|
||||
worst = min(yearly.items(), key=lambda x: x[1]["w"]/x[1]["t"] if x[1]["t"]>0 else 0)
|
||||
wa = worst[1]["w"]/worst[1]["t"]*100 if worst[1]["t"]>0 else 0
|
||||
|
||||
results.append({
|
||||
"name": combo_name, "trades": all_t, "acc": acc, "pnl": pnl,
|
||||
"dd": max_dd*100, "capital": capital, "worst": f"{worst[0]}({wa:.0f}%)",
|
||||
"yearly": yearly,
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: x["acc"], reverse=True)
|
||||
|
||||
print(f"\n {'Name':.<28s} {'Trades':>6s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>5s} {'Worst':>12s}")
|
||||
print(f" {'-'*70}")
|
||||
for r in results[:20]:
|
||||
tag = "✅✅" if r["acc"] >= 80 else "✅" if r["acc"] >= 78 else ""
|
||||
print(f" {r['name']:.<28s} {r['trades']:>6d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['dd']:>4.1f}% {r['worst']:>12s} {tag}")
|
||||
|
||||
# Dettaglio migliore
|
||||
if results:
|
||||
best = results[0]
|
||||
print(f"\n MIGLIORE: {best['name']} → {best['acc']:.1f}% acc, DD {best['dd']:.1f}%")
|
||||
for y in sorted(best["yearly"]):
|
||||
d = best["yearly"][y]
|
||||
ya = d["w"]/d["t"]*100 if d["t"]>0 else 0
|
||||
tag = " ← CRASH" if y in [2020,2021,2022] else ""
|
||||
print(f" {y}: {d['t']:4d}t {ya:5.1f}% €{sum(d['pnls']):+.0f}{tag}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
all_r = []
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["15m", "1h"]:
|
||||
r = run_ultimate(asset, tf)
|
||||
for x in r:
|
||||
all_r.append({**x, "key": f"{asset}_{tf}"})
|
||||
|
||||
all_r.sort(key=lambda x: x["acc"], reverse=True)
|
||||
print(f"\n\n{'='*70}")
|
||||
print(f" TOP 10 GLOBALE")
|
||||
print(f"{'='*70}")
|
||||
for r in all_r[:10]:
|
||||
tag = "✅✅" if r["acc"] >= 80 else "✅" if r["acc"] >= 78 else ""
|
||||
print(f" {r['key']:.<10s} {r['name']:.<28s} {r['trades']:>5d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} DD {r['dd']:.1f}% {r['worst']:>12s} {tag}")
|
||||
@@ -0,0 +1,160 @@
|
||||
"""S2-01: Mean Reversion oraria con filtro orario.
|
||||
Idea: crypto ha bias di ritorno alla media nelle ore notturne (00-06 UTC)
|
||||
e di momentum nelle ore diurne USA (14-20 UTC).
|
||||
- Compra quando RSI < 30 in ore notturne
|
||||
- Vendi quando RSI > 70 in ore notturne
|
||||
- Hold max 4h, stop loss 1.5%
|
||||
Timeframe: 1h. Ingresso quasi giornaliero.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE = 0.001
|
||||
INITIAL = 1000
|
||||
LEVERAGE = 3
|
||||
|
||||
|
||||
def rsi(close: np.ndarray, period: int = 14) -> np.ndarray:
|
||||
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)
|
||||
avg_gain = np.mean(gain[:period])
|
||||
avg_loss = np.mean(loss[:period])
|
||||
for i in range(period, len(delta)):
|
||||
avg_gain = (avg_gain * (period - 1) + gain[i]) / period
|
||||
avg_loss = (avg_loss * (period - 1) + loss[i]) / period
|
||||
if avg_loss == 0:
|
||||
result[i + 1] = 100
|
||||
else:
|
||||
rs = avg_gain / avg_loss
|
||||
result[i + 1] = 100 - 100 / (1 + rs)
|
||||
return result
|
||||
|
||||
|
||||
def bollinger_pct(close: np.ndarray, window: int = 20) -> np.ndarray:
|
||||
result = np.full(len(close), 0.5)
|
||||
for i in range(window, len(close)):
|
||||
w = close[i - window : i]
|
||||
ma = np.mean(w)
|
||||
std = np.std(w)
|
||||
if std > 0:
|
||||
result[i] = (close[i] - (ma - 2 * std)) / (4 * std)
|
||||
return result
|
||||
|
||||
|
||||
def run_mean_reversion(asset, tf="1h"):
|
||||
df = load_data(asset, tf)
|
||||
close = df["close"].values
|
||||
high = df["high"].values
|
||||
low = df["low"].values
|
||||
n = len(df)
|
||||
|
||||
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
hours = timestamps.dt.hour.values
|
||||
|
||||
rsi_vals = rsi(close, 14)
|
||||
bb_pct = bollinger_pct(close, 20)
|
||||
|
||||
split = int(n * 0.7)
|
||||
|
||||
configs = [
|
||||
# (rsi_low, rsi_high, allowed_hours, hold_max, stop_pct, name)
|
||||
(25, 75, list(range(0, 7)), 4, 0.015, "night_0-6_rsi25"),
|
||||
(30, 70, list(range(0, 7)), 4, 0.015, "night_0-6_rsi30"),
|
||||
(25, 75, list(range(0, 10)), 6, 0.02, "extended_0-9"),
|
||||
(30, 70, list(range(20, 24)) + list(range(0, 6)), 4, 0.015, "late_night"),
|
||||
(20, 80, list(range(0, 24)), 4, 0.015, "all_hours_rsi20"),
|
||||
# Bollinger band mean reversion
|
||||
]
|
||||
|
||||
print(f"\n{'#'*60}")
|
||||
print(f" {asset} {tf} — MEAN REVERSION")
|
||||
print(f"{'#'*60}")
|
||||
|
||||
for rsi_low, rsi_high, allowed, hold_max, stop, name in configs:
|
||||
capital = float(INITIAL)
|
||||
correct = 0
|
||||
total = 0
|
||||
daily_trades = {}
|
||||
|
||||
for i in range(max(split, 20), n - hold_max):
|
||||
hour = hours[i]
|
||||
if hour not in allowed:
|
||||
continue
|
||||
|
||||
day = timestamps[i].strftime("%Y-%m-%d")
|
||||
if daily_trades.get(day, 0) >= 2:
|
||||
continue
|
||||
|
||||
direction = None
|
||||
if rsi_vals[i] < rsi_low and bb_pct[i] < 0.2:
|
||||
direction = "long"
|
||||
elif rsi_vals[i] > rsi_high and bb_pct[i] > 0.8:
|
||||
direction = "short"
|
||||
|
||||
if direction is None:
|
||||
continue
|
||||
|
||||
entry = close[i]
|
||||
best_exit = i + 1
|
||||
for j in range(i + 1, min(i + hold_max + 1, n)):
|
||||
price = close[j]
|
||||
if direction == "long":
|
||||
pnl_pct = (price - entry) / entry
|
||||
if pnl_pct <= -stop:
|
||||
best_exit = j
|
||||
break
|
||||
if pnl_pct >= stop * 1.5:
|
||||
best_exit = j
|
||||
break
|
||||
else:
|
||||
pnl_pct = (entry - price) / entry
|
||||
if pnl_pct <= -stop:
|
||||
best_exit = j
|
||||
break
|
||||
if pnl_pct >= stop * 1.5:
|
||||
best_exit = j
|
||||
break
|
||||
best_exit = j
|
||||
|
||||
exit_price = close[best_exit]
|
||||
if direction == "long":
|
||||
trade_ret = (exit_price - entry) / entry
|
||||
else:
|
||||
trade_ret = (entry - exit_price) / entry
|
||||
|
||||
net = trade_ret * LEVERAGE - FEE * 2 * LEVERAGE
|
||||
capital += capital * 0.15 * net
|
||||
capital = max(capital, 0)
|
||||
|
||||
is_correct = trade_ret > 0
|
||||
total += 1
|
||||
if is_correct:
|
||||
correct += 1
|
||||
daily_trades[day] = daily_trades.get(day, 0) + 1
|
||||
|
||||
if total < 20:
|
||||
continue
|
||||
|
||||
acc = correct / total * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
test_days = (n - split) / 24
|
||||
test_years = test_days / 365.25
|
||||
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
||||
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
|
||||
days_with_trades = len(daily_trades)
|
||||
trades_per_day = total / days_with_trades if days_with_trades > 0 else 0
|
||||
|
||||
tag = "✅" if acc >= 60 and ann >= 30 else ""
|
||||
print(f" {name:25s}: trades={total:5d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% dd_est ~{abs(min(0, ret/3)):.0f}% €/day={dpnl:.2f} days_active={days_with_trades} {tag}")
|
||||
|
||||
|
||||
for asset in ["ETH", "BTC"]:
|
||||
run_mean_reversion(asset, "1h")
|
||||
run_mean_reversion(asset, "15m")
|
||||
@@ -0,0 +1,129 @@
|
||||
"""S2-02: Funding Rate Strategy.
|
||||
Quando il funding rate è molto positivo → troppi long → short il perpetual.
|
||||
Quando molto negativo → troppi short → long il perpetual.
|
||||
Si cattura sia il mean reversion del prezzo che il funding rate stesso.
|
||||
Ingresso: quando funding > 0.03% o < -0.03% (8h rate).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE = 0.001
|
||||
INITIAL = 1000
|
||||
LEVERAGE = 3
|
||||
|
||||
|
||||
def simulate_funding_strategy(asset):
|
||||
"""Simula funding rate strategy usando il proxy: overnight returns.
|
||||
Crypto funding settlement ogni 8h → prezzo tende a correggersi dopo settlement.
|
||||
Proxy: se ultime 8h hanno avuto forte trend, aspettati reversal dopo settlement.
|
||||
"""
|
||||
print(f"\n{'#'*60}")
|
||||
print(f" {asset} — FUNDING RATE PROXY STRATEGY")
|
||||
print(f"{'#'*60}")
|
||||
|
||||
df_1h = load_data(asset, "1h")
|
||||
close = df_1h["close"].values
|
||||
volume = df_1h["volume"].values
|
||||
n = len(close)
|
||||
split = int(n * 0.7)
|
||||
|
||||
timestamps = pd.to_datetime(df_1h["timestamp"], unit="ms", utc=True)
|
||||
hours = timestamps.dt.hour.values
|
||||
|
||||
# Funding settlement su Deribit: 00:00, 08:00, 16:00 UTC
|
||||
settlement_hours = {0, 8, 16}
|
||||
|
||||
configs = [
|
||||
(0.01, 0.02, 8, 0.02, "mild_1pct"),
|
||||
(0.015, 0.025, 8, 0.015, "moderate_1.5pct"),
|
||||
(0.02, 0.03, 8, 0.015, "strong_2pct"),
|
||||
(0.01, 0.015, 4, 0.01, "fast_1pct_4h"),
|
||||
(0.02, 0.03, 12, 0.02, "slow_2pct_12h"),
|
||||
(0.025, 0.04, 6, 0.015, "extreme_2.5pct"),
|
||||
]
|
||||
|
||||
for entry_thr, tp_mult_unused, hold_max, stop, name in configs:
|
||||
capital = float(INITIAL)
|
||||
correct = 0
|
||||
total = 0
|
||||
daily_trades = {}
|
||||
|
||||
for i in range(max(split, 8), n - hold_max):
|
||||
hour = hours[i]
|
||||
if hour not in settlement_hours:
|
||||
continue
|
||||
|
||||
day = timestamps[i].strftime("%Y-%m-%d")
|
||||
if daily_trades.get(day, 0) >= 1:
|
||||
continue
|
||||
|
||||
# 8h return prima del settlement = proxy per funding pressure
|
||||
ret_8h = (close[i] - close[i - 8]) / close[i - 8]
|
||||
|
||||
# Volume spike = conferma
|
||||
vol_avg = np.mean(volume[max(0, i - 48) : i])
|
||||
vol_recent = np.mean(volume[i - 8 : i])
|
||||
vol_spike = vol_recent / vol_avg if vol_avg > 0 else 1
|
||||
|
||||
direction = None
|
||||
if ret_8h > entry_thr and vol_spike > 1.1:
|
||||
direction = "short" # troppi long, attendi reversal
|
||||
elif ret_8h < -entry_thr and vol_spike > 1.1:
|
||||
direction = "long" # troppi short, attendi rimbalzo
|
||||
|
||||
if direction is None:
|
||||
continue
|
||||
|
||||
entry_price = close[i]
|
||||
for j in range(i + 1, min(i + hold_max + 1, n)):
|
||||
price = close[j]
|
||||
if direction == "long":
|
||||
pnl_pct = (price - entry_price) / entry_price
|
||||
else:
|
||||
pnl_pct = (entry_price - price) / entry_price
|
||||
|
||||
if pnl_pct <= -stop or pnl_pct >= stop * 2 or j == min(i + hold_max, n - 1):
|
||||
exit_price = price
|
||||
break
|
||||
else:
|
||||
exit_price = close[min(i + hold_max, n - 1)]
|
||||
|
||||
if direction == "long":
|
||||
trade_ret = (exit_price - entry_price) / entry_price
|
||||
else:
|
||||
trade_ret = (entry_price - exit_price) / entry_price
|
||||
|
||||
# Add funding rate income (approx 0.01% per 8h period if direction correct)
|
||||
funding_income = 0.0001 * (hold_max / 8) if trade_ret > 0 else 0
|
||||
|
||||
net = (trade_ret + funding_income) * LEVERAGE - FEE * 2 * LEVERAGE
|
||||
capital += capital * 0.2 * net
|
||||
capital = max(capital, 0)
|
||||
|
||||
total += 1
|
||||
if trade_ret > 0:
|
||||
correct += 1
|
||||
daily_trades[day] = daily_trades.get(day, 0) + 1
|
||||
|
||||
if total < 10:
|
||||
continue
|
||||
|
||||
acc = correct / total * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
test_days = (n - split) / 24
|
||||
test_years = test_days / 365.25
|
||||
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
||||
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
|
||||
days_active = len(daily_trades)
|
||||
|
||||
tag = "✅" if acc >= 60 and ann >= 30 else ""
|
||||
print(f" {name:20s}: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% €/day={dpnl:.2f} active_days={days_active} {tag}")
|
||||
|
||||
|
||||
for asset in ["ETH", "BTC"]:
|
||||
simulate_funding_strategy(asset)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""S2-03: Volatility Selling — Straddle/Strangle corto simulato.
|
||||
La IV crypto è cronicamente sopra la realized vol → vendere premium è profittevole.
|
||||
Simulazione: vendi straddle ATM → profitto = max(0, premium - |move|).
|
||||
Premium stimato da IV storica. Ingresso giornaliero.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import norm
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE = 0.001
|
||||
INITIAL = 1000
|
||||
|
||||
|
||||
def realized_vol(close: np.ndarray, window: int = 24) -> np.ndarray:
|
||||
"""Annualized realized volatility rolling."""
|
||||
log_ret = np.diff(np.log(np.where(close == 0, 1e-10, close)))
|
||||
result = np.full(len(close), 0.5)
|
||||
for i in range(window, len(log_ret)):
|
||||
rv = np.std(log_ret[i - window : i]) * np.sqrt(24 * 365)
|
||||
result[i + 1] = rv
|
||||
return result
|
||||
|
||||
|
||||
def implied_vol_proxy(close: np.ndarray, window: int = 48) -> np.ndarray:
|
||||
"""IV proxy: realized vol * premium factor.
|
||||
Storicamente IV crypto ≈ 1.2-1.5x realized vol (variance risk premium).
|
||||
"""
|
||||
rv = realized_vol(close, window)
|
||||
# Premium factor varia: alto in panic, basso in calma
|
||||
result = np.full(len(close), 0.5)
|
||||
for i in range(window, len(close)):
|
||||
short_rv = realized_vol(close[max(0, i-12):i+1], min(12, i))[-1] if i >= 12 else rv[i]
|
||||
if rv[i] > 0:
|
||||
regime = short_rv / rv[i]
|
||||
premium = 1.15 + 0.3 * max(0, regime - 1) # più alto in regime volatile
|
||||
else:
|
||||
premium = 1.2
|
||||
result[i] = rv[i] * premium
|
||||
return result
|
||||
|
||||
|
||||
def bs_straddle_price(spot: float, iv: float, dte_hours: float) -> float:
|
||||
"""Black-Scholes straddle price (call + put ATM)."""
|
||||
if dte_hours <= 0 or iv <= 0:
|
||||
return 0
|
||||
t = dte_hours / (24 * 365)
|
||||
d1 = (0.5 * iv * iv * t) / (iv * np.sqrt(t))
|
||||
call = spot * (2 * norm.cdf(d1) - 1)
|
||||
return call * 2 # straddle = 2 * ATM call (approx for ATM)
|
||||
|
||||
|
||||
def run_vol_selling(asset):
|
||||
print(f"\n{'#'*60}")
|
||||
print(f" {asset} — VOLATILITY SELLING (SHORT STRADDLE)")
|
||||
print(f"{'#'*60}")
|
||||
|
||||
df = load_data(asset, "1h")
|
||||
close = df["close"].values
|
||||
n = len(close)
|
||||
split = int(n * 0.7)
|
||||
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
rv = realized_vol(close, 24)
|
||||
iv_proxy = implied_vol_proxy(close)
|
||||
|
||||
configs = [
|
||||
# (dte_hours, iv_floor, iv_rv_ratio_min, position_pct, name)
|
||||
(24, 0.3, 1.15, 0.1, "daily_24h"),
|
||||
(12, 0.3, 1.15, 0.08, "half_day_12h"),
|
||||
(48, 0.3, 1.10, 0.12, "2day_48h"),
|
||||
(24, 0.4, 1.20, 0.1, "daily_highIV"),
|
||||
(8, 0.25, 1.10, 0.06, "ultra_short_8h"),
|
||||
(24, 0.3, 1.30, 0.15, "daily_bigPremium"),
|
||||
]
|
||||
|
||||
for dte, iv_floor, ratio_min, pos_pct, name in configs:
|
||||
capital = float(INITIAL)
|
||||
correct = 0
|
||||
total = 0
|
||||
daily_trades = {}
|
||||
|
||||
for i in range(max(split, 50), n - dte):
|
||||
day = timestamps[i].strftime("%Y-%m-%d")
|
||||
if daily_trades.get(day, 0) >= 1:
|
||||
continue
|
||||
|
||||
hour = timestamps[i].dt.hour if hasattr(timestamps[i], 'dt') else timestamps.iloc[i].hour
|
||||
if hour != 8: # entrata alle 08 UTC ogni giorno
|
||||
continue
|
||||
|
||||
current_iv = iv_proxy[i]
|
||||
current_rv = rv[i]
|
||||
|
||||
if current_iv < iv_floor:
|
||||
continue
|
||||
if current_rv > 0 and current_iv / current_rv < ratio_min:
|
||||
continue
|
||||
|
||||
spot = close[i]
|
||||
premium = bs_straddle_price(spot, current_iv, dte)
|
||||
premium_pct = premium / spot
|
||||
|
||||
# Actual move during holding period
|
||||
exit_idx = min(i + dte, n - 1)
|
||||
actual_move = abs(close[exit_idx] - spot)
|
||||
actual_move_pct = actual_move / spot
|
||||
|
||||
# P&L: premium received - actual move (capped at max loss)
|
||||
max_loss = spot * 0.05 # cap loss at 5% of spot
|
||||
pnl = premium - min(actual_move, max_loss + premium)
|
||||
|
||||
pnl_on_capital = pnl / spot * pos_pct
|
||||
fee_cost = FEE * 4 * pos_pct # 4 legs: sell call, sell put, buy back
|
||||
net_pnl = pnl_on_capital - fee_cost
|
||||
|
||||
capital += capital * net_pnl
|
||||
capital = max(capital, 0)
|
||||
|
||||
total += 1
|
||||
if pnl > 0:
|
||||
correct += 1
|
||||
daily_trades[day] = daily_trades.get(day, 0) + 1
|
||||
|
||||
if total < 20:
|
||||
continue
|
||||
|
||||
acc = correct / total * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
test_days = (n - split) / 24
|
||||
test_years = test_days / 365.25
|
||||
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
||||
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
|
||||
days_active = len(daily_trades)
|
||||
|
||||
tag = "✅" if acc >= 60 and ann >= 30 else ""
|
||||
print(f" {name:20s}: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% €/day={dpnl:.2f} active={days_active} {tag}")
|
||||
|
||||
|
||||
for asset in ["ETH", "BTC"]:
|
||||
run_vol_selling(asset)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""S2-04: Momentum microstructure su 5m.
|
||||
Approccio: cattura micro-trend intraday.
|
||||
- Identifica breakout da consolidamento su 5m
|
||||
- Conferma con volume e acceleration
|
||||
- Hold breve (15-30 min), stop stretto
|
||||
- Target: molti piccoli guadagni, alta frequenza
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE = 0.001
|
||||
INITIAL = 1000
|
||||
LEVERAGE = 3
|
||||
|
||||
|
||||
def ema(arr: np.ndarray, period: int) -> np.ndarray:
|
||||
result = np.full(len(arr), np.nan)
|
||||
k = 2 / (period + 1)
|
||||
result[period - 1] = np.mean(arr[:period])
|
||||
for i in range(period, len(arr)):
|
||||
result[i] = arr[i] * k + result[i - 1] * (1 - k)
|
||||
return result
|
||||
|
||||
|
||||
def atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> np.ndarray:
|
||||
tr = np.maximum(high - low, np.maximum(np.abs(high - np.roll(close, 1)), np.abs(low - np.roll(close, 1))))
|
||||
tr[0] = high[0] - low[0]
|
||||
return ema(tr, period)
|
||||
|
||||
|
||||
def run_momentum(asset):
|
||||
print(f"\n{'#'*60}")
|
||||
print(f" {asset} 5m — MOMENTUM MICROSTRUCTURE")
|
||||
print(f"{'#'*60}")
|
||||
|
||||
df = load_data(asset, "5m")
|
||||
close = df["close"].values
|
||||
high = df["high"].values
|
||||
low = df["low"].values
|
||||
volume = df["volume"].values
|
||||
n = len(close)
|
||||
split = int(n * 0.7)
|
||||
|
||||
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
ema_fast = ema(close, 8)
|
||||
ema_slow = ema(close, 21)
|
||||
ema_trend = ema(close, 55)
|
||||
atr_vals = atr(high, low, close, 14)
|
||||
|
||||
configs = [
|
||||
# (consolidation_bars, breakout_atr_mult, hold_bars, stop_atr, tp_atr, min_vol_mult, name)
|
||||
(12, 1.5, 3, 1.0, 2.0, 1.3, "tight_12bar"),
|
||||
(12, 1.5, 6, 1.5, 2.5, 1.2, "medium_12bar"),
|
||||
(24, 2.0, 6, 1.5, 3.0, 1.5, "wide_24bar"),
|
||||
(6, 1.2, 3, 1.0, 1.5, 1.1, "fast_6bar"),
|
||||
(12, 1.5, 3, 0.8, 2.0, 1.3, "tight_stop"),
|
||||
(18, 1.8, 4, 1.2, 2.5, 1.4, "balanced_18bar"),
|
||||
]
|
||||
|
||||
for consol_bars, brk_mult, hold_bars, stop_m, tp_m, vol_mult, name in configs:
|
||||
capital = float(INITIAL)
|
||||
correct = 0
|
||||
total = 0
|
||||
daily_trades = {}
|
||||
|
||||
for i in range(max(split, 60), n - hold_bars):
|
||||
if np.isnan(ema_fast[i]) or np.isnan(ema_slow[i]) or np.isnan(atr_vals[i]) or atr_vals[i] == 0:
|
||||
continue
|
||||
|
||||
day = timestamps.iloc[i].strftime("%Y-%m-%d")
|
||||
if daily_trades.get(day, 0) >= 5:
|
||||
continue
|
||||
|
||||
# Consolidation: range delle ultime N barre < 1.5 ATR
|
||||
consol_range = np.max(high[i - consol_bars : i]) - np.min(low[i - consol_bars : i])
|
||||
if consol_range > 1.5 * atr_vals[i]:
|
||||
continue
|
||||
|
||||
# Breakout: current bar breaks consolidation range
|
||||
consol_high = np.max(high[i - consol_bars : i])
|
||||
consol_low = np.min(low[i - consol_bars : i])
|
||||
|
||||
breakout_up = close[i] > consol_high + atr_vals[i] * (brk_mult - 1)
|
||||
breakout_down = close[i] < consol_low - atr_vals[i] * (brk_mult - 1)
|
||||
|
||||
if not (breakout_up or breakout_down):
|
||||
continue
|
||||
|
||||
# Volume confirmation
|
||||
vol_avg = np.mean(volume[max(0, i - 24) : i])
|
||||
if vol_avg > 0 and volume[i] < vol_avg * vol_mult:
|
||||
continue
|
||||
|
||||
# Trend filter: only trade in direction of trend
|
||||
if breakout_up and close[i] < ema_trend[i]:
|
||||
continue
|
||||
if breakout_down and close[i] > ema_trend[i]:
|
||||
continue
|
||||
|
||||
direction = "long" if breakout_up else "short"
|
||||
entry = close[i]
|
||||
stop_price = entry - atr_vals[i] * stop_m if direction == "long" else entry + atr_vals[i] * stop_m
|
||||
tp_price = entry + atr_vals[i] * tp_m if direction == "long" else entry - atr_vals[i] * tp_m
|
||||
|
||||
exit_price = close[min(i + hold_bars, n - 1)]
|
||||
for j in range(i + 1, min(i + hold_bars + 1, n)):
|
||||
if direction == "long":
|
||||
if low[j] <= stop_price:
|
||||
exit_price = stop_price
|
||||
break
|
||||
if high[j] >= tp_price:
|
||||
exit_price = tp_price
|
||||
break
|
||||
else:
|
||||
if high[j] >= stop_price:
|
||||
exit_price = stop_price
|
||||
break
|
||||
if low[j] <= tp_price:
|
||||
exit_price = tp_price
|
||||
break
|
||||
exit_price = close[j]
|
||||
|
||||
if direction == "long":
|
||||
trade_ret = (exit_price - entry) / entry
|
||||
else:
|
||||
trade_ret = (entry - exit_price) / entry
|
||||
|
||||
net = trade_ret * LEVERAGE - FEE * 2 * LEVERAGE
|
||||
capital += capital * 0.1 * net
|
||||
capital = max(capital, 0)
|
||||
|
||||
total += 1
|
||||
if trade_ret > 0:
|
||||
correct += 1
|
||||
daily_trades[day] = daily_trades.get(day, 0) + 1
|
||||
|
||||
if total < 30:
|
||||
continue
|
||||
|
||||
acc = correct / total * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
test_days = (n - split) / (24 * 12)
|
||||
test_years = test_days / 365.25
|
||||
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
||||
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
|
||||
days_active = len(daily_trades)
|
||||
|
||||
tag = "✅" if acc >= 55 and ann >= 30 else ""
|
||||
print(f" {name:20s}: trades={total:5d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% €/day={dpnl:.2f} active={days_active} t/day={total/days_active:.1f} {tag}")
|
||||
|
||||
|
||||
for asset in ["ETH", "BTC"]:
|
||||
run_momentum(asset)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""S2-05: Gap fade + overnight reversal.
|
||||
Crypto non ha gap di apertura classici, ma ha "gap di sessione":
|
||||
- Asia open (00 UTC): tende a continuare il trend USA precedente
|
||||
- EU open (07 UTC): spesso corregge eccessi notturni
|
||||
- USA open (13-14 UTC): alta volatilità, breakout o reversal
|
||||
|
||||
Strategia: fai fade dell'overextension al cambio sessione.
|
||||
Se il prezzo ha fatto >1.5% nella sessione precedente, aspettati reversal.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE = 0.001
|
||||
INITIAL = 1000
|
||||
LEVERAGE = 3
|
||||
|
||||
|
||||
def run_gap_fade(asset, tf="1h"):
|
||||
print(f"\n{'#'*60}")
|
||||
print(f" {asset} {tf} — GAP FADE / SESSION REVERSAL")
|
||||
print(f"{'#'*60}")
|
||||
|
||||
df = load_data(asset, tf)
|
||||
close = df["close"].values
|
||||
high = df["high"].values
|
||||
low = df["low"].values
|
||||
n = len(close)
|
||||
split = int(n * 0.7)
|
||||
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
hours = timestamps.dt.hour.values
|
||||
|
||||
session_opens = {
|
||||
"asia": 0,
|
||||
"eu": 7,
|
||||
"usa": 14,
|
||||
}
|
||||
|
||||
configs = [
|
||||
# (session_name, lookback_hours, entry_thr, hold_hours, stop_pct, name)
|
||||
("eu", 7, 0.015, 4, 0.012, "eu_fade_1.5pct"),
|
||||
("eu", 7, 0.02, 4, 0.015, "eu_fade_2pct"),
|
||||
("eu", 7, 0.01, 6, 0.01, "eu_fade_1pct_6h"),
|
||||
("usa", 7, 0.015, 4, 0.012, "usa_fade_1.5pct"),
|
||||
("usa", 7, 0.02, 4, 0.015, "usa_fade_2pct"),
|
||||
("asia", 8, 0.02, 6, 0.015, "asia_fade_2pct"),
|
||||
("eu", 7, 0.025, 3, 0.015, "eu_fade_2.5pct_fast"),
|
||||
("usa", 6, 0.015, 3, 0.01, "usa_fade_fast"),
|
||||
]
|
||||
|
||||
for session, lookback, entry_thr, hold, stop, name in configs:
|
||||
capital = float(INITIAL)
|
||||
correct = 0
|
||||
total = 0
|
||||
daily_trades = {}
|
||||
|
||||
session_hour = session_opens[session]
|
||||
|
||||
for i in range(max(split, lookback + 1), n - hold):
|
||||
if hours[i] != session_hour:
|
||||
continue
|
||||
|
||||
day = timestamps.iloc[i].strftime("%Y-%m-%d")
|
||||
if daily_trades.get(day, 0) >= 1:
|
||||
continue
|
||||
|
||||
prev_ret = (close[i] - close[i - lookback]) / close[i - lookback]
|
||||
|
||||
direction = None
|
||||
if prev_ret > entry_thr:
|
||||
direction = "short" # fade the rally
|
||||
elif prev_ret < -entry_thr:
|
||||
direction = "long" # fade the dump
|
||||
|
||||
if direction is None:
|
||||
continue
|
||||
|
||||
entry = close[i]
|
||||
exit_price = close[min(i + hold, n - 1)]
|
||||
|
||||
for j in range(i + 1, min(i + hold + 1, n)):
|
||||
if direction == "long":
|
||||
if (close[j] - entry) / entry >= stop * 2:
|
||||
exit_price = close[j]
|
||||
break
|
||||
if (entry - close[j]) / entry >= stop:
|
||||
exit_price = close[j]
|
||||
break
|
||||
else:
|
||||
if (entry - close[j]) / entry >= stop * 2:
|
||||
exit_price = close[j]
|
||||
break
|
||||
if (close[j] - entry) / entry >= stop:
|
||||
exit_price = close[j]
|
||||
break
|
||||
exit_price = close[j]
|
||||
|
||||
if direction == "long":
|
||||
trade_ret = (exit_price - entry) / entry
|
||||
else:
|
||||
trade_ret = (entry - exit_price) / entry
|
||||
|
||||
net = trade_ret * LEVERAGE - FEE * 2 * LEVERAGE
|
||||
capital += capital * 0.2 * net
|
||||
capital = max(capital, 0)
|
||||
|
||||
total += 1
|
||||
if trade_ret > 0:
|
||||
correct += 1
|
||||
daily_trades[day] = daily_trades.get(day, 0) + 1
|
||||
|
||||
if total < 15:
|
||||
continue
|
||||
|
||||
acc = correct / total * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
test_days = (n - split) / 24
|
||||
test_years = test_days / 365.25
|
||||
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
||||
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
|
||||
days_active = len(daily_trades)
|
||||
|
||||
tag = "✅" if acc >= 58 and ann >= 30 else ""
|
||||
print(f" {name:25s}: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% €/day={dpnl:.2f} active={days_active} {tag}")
|
||||
|
||||
|
||||
for asset in ["ETH", "BTC"]:
|
||||
run_gap_fade(asset)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""S2-06: Iron Condor simulato + Variance Risk Premium harvesting.
|
||||
Vendi un range: se il prezzo sta dentro il range a scadenza → profitto.
|
||||
Più sofisticato del vol selling puro:
|
||||
- Calcolo IV vs RV (variance risk premium)
|
||||
- Selezione larghezza condor in base a IV/RV ratio
|
||||
- Dynamic position sizing: più capital quando IV/RV ratio è alto
|
||||
- Ingresso giornaliero, scadenze 24h e 48h
|
||||
- Include: tail risk protection (chiudi se move > 2 ATR)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE = 0.001
|
||||
INITIAL = 1000
|
||||
|
||||
|
||||
def realized_vol_ann(close: np.ndarray, window: int) -> np.ndarray:
|
||||
log_ret = np.diff(np.log(np.where(close == 0, 1e-10, close)))
|
||||
result = np.full(len(close), 0.5)
|
||||
for i in range(window, len(log_ret)):
|
||||
result[i + 1] = np.std(log_ret[i - window : i]) * np.sqrt(24 * 365)
|
||||
return result
|
||||
|
||||
|
||||
def run_iron_condor(asset, tf="1h"):
|
||||
print(f"\n{'#'*60}")
|
||||
print(f" {asset} {tf} — IRON CONDOR / VARIANCE PREMIUM")
|
||||
print(f"{'#'*60}")
|
||||
|
||||
df = load_data(asset, tf)
|
||||
close = df["close"].values
|
||||
high = df["high"].values
|
||||
low = df["low"].values
|
||||
n = len(close)
|
||||
split = int(n * 0.7)
|
||||
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
rv_24 = realized_vol_ann(close, 24)
|
||||
rv_48 = realized_vol_ann(close, 48)
|
||||
rv_168 = realized_vol_ann(close, 168) # 1 week
|
||||
|
||||
IV_PREMIUM = 1.25 # IV typically 1.2-1.3x RV in crypto
|
||||
|
||||
configs = [
|
||||
# (dte_hours, condor_width_mult, max_loss_pct, vrp_min, pos_pct, name)
|
||||
(24, 1.0, 0.03, 1.10, 0.15, "24h_1x_std"),
|
||||
(24, 1.5, 0.04, 1.10, 0.12, "24h_1.5x_safe"),
|
||||
(24, 0.8, 0.025, 1.15, 0.18, "24h_0.8x_aggr"),
|
||||
(48, 1.0, 0.035, 1.10, 0.15, "48h_1x_std"),
|
||||
(48, 1.5, 0.05, 1.10, 0.12, "48h_1.5x_safe"),
|
||||
(48, 0.7, 0.025, 1.20, 0.20, "48h_0.7x_highVRP"),
|
||||
(72, 1.2, 0.04, 1.10, 0.12, "72h_1.2x"),
|
||||
(24, 1.0, 0.03, 1.30, 0.20, "24h_veryHighVRP"),
|
||||
(24, 1.2, 0.035, 1.10, 0.15, "24h_1.2x_balanced"),
|
||||
]
|
||||
|
||||
for dte, width_mult, max_loss, vrp_min, pos_pct, name in configs:
|
||||
capital = float(INITIAL)
|
||||
correct = 0
|
||||
total = 0
|
||||
daily_trades = {}
|
||||
max_dd = 0
|
||||
peak = capital
|
||||
|
||||
for i in range(max(split, 170), n - dte):
|
||||
day = timestamps.iloc[i].strftime("%Y-%m-%d")
|
||||
if daily_trades.get(day, 0) >= 1:
|
||||
continue
|
||||
|
||||
hour = timestamps.iloc[i].hour
|
||||
if hour != 8:
|
||||
continue
|
||||
|
||||
rv_short = rv_24[i]
|
||||
rv_long = rv_168[i]
|
||||
|
||||
if rv_short <= 0 or rv_long <= 0:
|
||||
continue
|
||||
|
||||
iv_est = rv_long * IV_PREMIUM
|
||||
vrp_ratio = iv_est / rv_short
|
||||
|
||||
if vrp_ratio < vrp_min:
|
||||
continue
|
||||
|
||||
spot = close[i]
|
||||
t_years = dte / (24 * 365)
|
||||
|
||||
# Condor range: spot ± width * daily_std * sqrt(t)
|
||||
daily_std = rv_short / np.sqrt(365)
|
||||
range_width = width_mult * daily_std * np.sqrt(dte / 24) * spot
|
||||
|
||||
upper_strike = spot + range_width
|
||||
lower_strike = spot - range_width
|
||||
|
||||
# Premium collected (simplified BS for condor)
|
||||
# Premium ≈ IV * sqrt(t) * (width factor)
|
||||
premium_pct = iv_est * np.sqrt(t_years) * 0.4 * (1 / width_mult)
|
||||
|
||||
# Check if price stays in range
|
||||
exit_idx = min(i + dte, n - 1)
|
||||
price_path = close[i : exit_idx + 1]
|
||||
max_move = max(np.max(price_path) - spot, spot - np.min(price_path))
|
||||
final_price = close[exit_idx]
|
||||
|
||||
in_range = lower_strike <= final_price <= upper_strike
|
||||
breached_hard = max_move > spot * max_loss
|
||||
|
||||
if breached_hard:
|
||||
pnl_pct = -max_loss * pos_pct
|
||||
elif in_range:
|
||||
pnl_pct = premium_pct * pos_pct
|
||||
else:
|
||||
# Partial loss: exceeded range but not catastrophic
|
||||
excess = max(0, final_price - upper_strike, lower_strike - final_price)
|
||||
loss = min(excess / spot, max_loss)
|
||||
pnl_pct = (premium_pct - loss) * pos_pct
|
||||
|
||||
fee_cost = FEE * 2 * pos_pct
|
||||
net_pnl = pnl_pct - fee_cost
|
||||
|
||||
capital += capital * net_pnl
|
||||
capital = max(capital, 0)
|
||||
|
||||
if capital > peak:
|
||||
peak = capital
|
||||
dd = (peak - capital) / peak if peak > 0 else 0
|
||||
max_dd = max(max_dd, dd)
|
||||
|
||||
total += 1
|
||||
if net_pnl > 0:
|
||||
correct += 1
|
||||
daily_trades[day] = daily_trades.get(day, 0) + 1
|
||||
|
||||
if total < 20:
|
||||
continue
|
||||
|
||||
acc = correct / total * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
test_days = (n - split) / 24
|
||||
test_years = test_days / 365.25
|
||||
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
||||
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
|
||||
days_active = len(daily_trades)
|
||||
|
||||
tag = "✅✅" if acc >= 70 and ann >= 50 else "✅" if acc >= 65 and ann >= 30 else ""
|
||||
print(f" {name:22s}: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% dd={max_dd*100:.1f}% €/day={dpnl:.2f} active={days_active} {tag}")
|
||||
|
||||
|
||||
for asset in ["ETH", "BTC"]:
|
||||
run_iron_condor(asset)
|
||||
|
||||
# === COMBINAZIONE: Iron Condor + Funding + Gap Fade ===
|
||||
print(f"\n{'#'*60}")
|
||||
print(f" COMBINAZIONE: MULTI-STRATEGY PORTFOLIO")
|
||||
print(f"{'#'*60}")
|
||||
|
||||
# Simula portafoglio: 50% iron condor ETH, 25% iron condor BTC, 25% gap fade ETH
|
||||
print(" (Dettagli nel prossimo script con backtest combinato)")
|
||||
@@ -0,0 +1,252 @@
|
||||
"""S2-07: Variance Risk Premium harvesting — versione raffinata.
|
||||
Ottimizzazione del vol selling con:
|
||||
1. IV/RV ratio dinamico per entry timing
|
||||
2. Tail risk cutoff (chiudi se move > N sigma)
|
||||
3. Position sizing proporzionale al premium
|
||||
4. Combinazione con directional bias (da gap fade)
|
||||
5. Multi-asset portfolio (ETH + BTC)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import norm
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE = 0.001
|
||||
INITIAL = 1000
|
||||
|
||||
|
||||
def realized_vol(close, window=24):
|
||||
log_ret = np.diff(np.log(np.where(close == 0, 1e-10, close)))
|
||||
result = np.full(len(close), 0.5)
|
||||
for i in range(window, len(log_ret)):
|
||||
result[i + 1] = np.std(log_ret[i - window : i]) * np.sqrt(24 * 365)
|
||||
return result
|
||||
|
||||
|
||||
def run_vrp(asset):
|
||||
print(f"\n{'#'*60}")
|
||||
print(f" {asset} 1h — VARIANCE RISK PREMIUM REFINED")
|
||||
print(f"{'#'*60}")
|
||||
|
||||
df = load_data(asset, "1h")
|
||||
close = df["close"].values
|
||||
high = df["high"].values
|
||||
low = df["low"].values
|
||||
n = len(close)
|
||||
split = int(n * 0.7)
|
||||
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
rv_24 = realized_vol(close, 24)
|
||||
rv_48 = realized_vol(close, 48)
|
||||
rv_168 = realized_vol(close, 168)
|
||||
|
||||
configs = [
|
||||
# (dte_h, iv_mult, cutoff_sigma, pos_base, entry_hour, dynamic_sizing, name)
|
||||
(24, 1.20, 2.5, 0.10, 8, False, "24h_base"),
|
||||
(24, 1.25, 2.5, 0.12, 8, False, "24h_highPrem"),
|
||||
(24, 1.20, 2.0, 0.10, 8, False, "24h_tightCut"),
|
||||
(24, 1.20, 3.0, 0.12, 8, False, "24h_wideCut"),
|
||||
(48, 1.20, 2.5, 0.12, 8, False, "48h_base"),
|
||||
(48, 1.25, 2.5, 0.15, 8, False, "48h_highPrem"),
|
||||
(48, 1.30, 2.5, 0.15, 8, False, "48h_vhighPrem"),
|
||||
(48, 1.20, 3.0, 0.15, 8, False, "48h_wideCut"),
|
||||
(24, 1.20, 2.5, 0.10, 8, True, "24h_dynSize"),
|
||||
(48, 1.20, 2.5, 0.12, 8, True, "48h_dynSize"),
|
||||
(24, 1.20, 2.5, 0.10, 0, False, "24h_midnight"),
|
||||
(24, 1.20, 2.5, 0.10, 16, False, "24h_afternoon"),
|
||||
(36, 1.22, 2.5, 0.12, 8, False, "36h_medium"),
|
||||
(24, 1.15, 2.5, 0.08, 8, False, "24h_lowPrem_safe"),
|
||||
(48, 1.20, 2.0, 0.10, 8, True, "48h_tight_dyn"),
|
||||
]
|
||||
|
||||
for dte, iv_mult, cutoff, pos_base, entry_h, dyn_size, name in configs:
|
||||
capital = float(INITIAL)
|
||||
correct = 0
|
||||
total = 0
|
||||
daily_trades = {}
|
||||
peak_capital = capital
|
||||
max_dd = 0
|
||||
|
||||
for i in range(max(split, 170), n - dte):
|
||||
day = timestamps.iloc[i].strftime("%Y-%m-%d")
|
||||
if daily_trades.get(day, 0) >= 1:
|
||||
continue
|
||||
|
||||
if timestamps.iloc[i].hour != entry_h:
|
||||
continue
|
||||
|
||||
rv_s = rv_24[i]
|
||||
rv_l = rv_168[i]
|
||||
if rv_s <= 0.05 or rv_l <= 0.05:
|
||||
continue
|
||||
|
||||
iv_est = rv_l * iv_mult
|
||||
vrp = iv_est - rv_s
|
||||
|
||||
if vrp <= 0:
|
||||
continue
|
||||
|
||||
spot = close[i]
|
||||
t = dte / (24 * 365)
|
||||
daily_std = rv_s / np.sqrt(365)
|
||||
|
||||
# Premium = IV * sqrt(t) * spot * factor
|
||||
premium = iv_est * np.sqrt(t) * spot * 0.4
|
||||
premium_pct = premium / spot
|
||||
|
||||
# Expected move based on IV
|
||||
expected_move = iv_est * np.sqrt(t) * spot
|
||||
|
||||
# Cutoff: close if actual move > cutoff * expected_move
|
||||
max_allowed_move = expected_move * cutoff
|
||||
|
||||
# Dynamic sizing: more when VRP is high
|
||||
if dyn_size:
|
||||
vrp_ratio = vrp / rv_s
|
||||
pos_pct = min(pos_base * (1 + vrp_ratio), pos_base * 2)
|
||||
else:
|
||||
pos_pct = pos_base
|
||||
|
||||
# Check actual path
|
||||
exit_idx = min(i + dte, n - 1)
|
||||
actual_move = abs(close[exit_idx] - spot)
|
||||
|
||||
# Early exit: check if intra-period move exceeds cutoff
|
||||
breached = False
|
||||
for j in range(i + 1, exit_idx + 1):
|
||||
intra_move = abs(close[j] - spot)
|
||||
if intra_move > max_allowed_move:
|
||||
breached = True
|
||||
exit_idx = j
|
||||
actual_move = intra_move
|
||||
break
|
||||
|
||||
if breached:
|
||||
loss = min(actual_move / spot, 0.05) * pos_pct
|
||||
pnl = -loss
|
||||
else:
|
||||
profit = premium_pct * pos_pct
|
||||
partial_loss = max(0, actual_move / spot - premium_pct) * pos_pct * 0.5
|
||||
pnl = profit - partial_loss
|
||||
|
||||
fee_cost = FEE * 2 * pos_pct
|
||||
net = pnl - fee_cost
|
||||
|
||||
capital += capital * net
|
||||
capital = max(capital, 0)
|
||||
|
||||
if capital > peak_capital:
|
||||
peak_capital = capital
|
||||
dd = (peak_capital - capital) / peak_capital if peak_capital > 0 else 0
|
||||
max_dd = max(max_dd, dd)
|
||||
|
||||
total += 1
|
||||
if pnl > 0:
|
||||
correct += 1
|
||||
daily_trades[day] = daily_trades.get(day, 0) + 1
|
||||
|
||||
if total < 20:
|
||||
continue
|
||||
|
||||
acc = correct / total * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
test_days = (n - split) / 24
|
||||
test_years = test_days / 365.25
|
||||
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
||||
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
|
||||
days_active = len(daily_trades)
|
||||
|
||||
tag = "✅✅" if acc >= 70 and ann >= 50 else "✅" if acc >= 65 and ann >= 30 else ""
|
||||
print(f" {name:22s}: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% dd={max_dd*100:.1f}% €/day={dpnl:.2f} active={days_active} {tag}")
|
||||
|
||||
return daily_trades
|
||||
|
||||
|
||||
# Run both assets
|
||||
results = {}
|
||||
for asset in ["ETH", "BTC"]:
|
||||
results[asset] = run_vrp(asset)
|
||||
|
||||
# Multi-asset portfolio simulation
|
||||
print(f"\n{'#'*60}")
|
||||
print(f" MULTI-ASSET PORTFOLIO: ETH + BTC")
|
||||
print(f"{'#'*60}")
|
||||
|
||||
df_eth = load_data("ETH", "1h")
|
||||
df_btc = load_data("BTC", "1h")
|
||||
close_eth = df_eth["close"].values
|
||||
close_btc = df_btc["close"].values
|
||||
n = min(len(close_eth), len(close_btc))
|
||||
split = int(n * 0.7)
|
||||
ts = pd.to_datetime(df_eth["timestamp"].values[:n], unit="ms", utc=True)
|
||||
|
||||
rv_eth = realized_vol(close_eth[:n], 168)
|
||||
rv_btc = realized_vol(close_btc[:n], 168)
|
||||
|
||||
capital = float(INITIAL)
|
||||
total = 0
|
||||
correct = 0
|
||||
peak = capital
|
||||
max_dd = 0
|
||||
daily_trades = {}
|
||||
|
||||
for i in range(max(split, 170), n - 48):
|
||||
day = ts[i].strftime("%Y-%m-%d")
|
||||
if daily_trades.get(day, 0) >= 1:
|
||||
continue
|
||||
if ts[i].hour != 8:
|
||||
continue
|
||||
|
||||
for asset_close, rv_arr, name in [(close_eth[:n], rv_eth, "ETH"), (close_btc[:n], rv_btc, "BTC")]:
|
||||
rv = rv_arr[i]
|
||||
if rv <= 0.05:
|
||||
continue
|
||||
iv = rv * 1.22
|
||||
spot = asset_close[i]
|
||||
t = 48 / (24 * 365)
|
||||
premium_pct = iv * np.sqrt(t) * 0.4
|
||||
expected_move = iv * np.sqrt(t) * spot
|
||||
max_move = expected_move * 2.5
|
||||
|
||||
exit_idx = min(i + 48, n - 1)
|
||||
actual_move = abs(asset_close[exit_idx] - spot)
|
||||
|
||||
breached = False
|
||||
for j in range(i + 1, exit_idx + 1):
|
||||
if abs(asset_close[j] - spot) > max_move:
|
||||
breached = True
|
||||
actual_move = abs(asset_close[j] - spot)
|
||||
break
|
||||
|
||||
pos_pct = 0.07 # 7% per asset = 14% total
|
||||
if breached:
|
||||
pnl = -min(actual_move / spot, 0.05) * pos_pct
|
||||
else:
|
||||
profit = premium_pct * pos_pct
|
||||
partial = max(0, actual_move / spot - premium_pct) * pos_pct * 0.5
|
||||
pnl = profit - partial
|
||||
|
||||
capital += capital * (pnl - FEE * 2 * pos_pct)
|
||||
capital = max(capital, 0)
|
||||
total += 1
|
||||
if pnl > 0:
|
||||
correct += 1
|
||||
|
||||
if capital > peak:
|
||||
peak = capital
|
||||
dd = (peak - capital) / peak if peak > 0 else 0
|
||||
max_dd = max(max_dd, dd)
|
||||
daily_trades[day] = daily_trades.get(day, 0) + 1
|
||||
|
||||
if total > 0:
|
||||
acc = correct / total * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
test_days = (n - split) / 24
|
||||
test_years = test_days / 365.25
|
||||
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
||||
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
|
||||
print(f"\n ETH+BTC 48h portfolio: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% dd={max_dd*100:.1f}% €/day={dpnl:.2f} active={len(daily_trades)}")
|
||||
@@ -0,0 +1,245 @@
|
||||
"""S2-08: VRP Honest Test.
|
||||
Problemi del test precedente:
|
||||
1. IV stimata con moltiplicatore fisso → troppo ottimista
|
||||
2. Nessun stress test su crash
|
||||
3. Nessun costo di margin
|
||||
4. Walk-forward mancante
|
||||
|
||||
Fix:
|
||||
- IV calcolata come rolling ratio IV/RV da dati DVOL reali (90 giorni)
|
||||
e applicata storicamente con variabilità
|
||||
- Stress test esplicito su periodi di crisi
|
||||
- Margin requirement: 5% del notional bloccato
|
||||
- Walk-forward: retrain IV/RV ratio ogni 30 giorni
|
||||
- Fee realistiche: 0.05% maker + 0.05% taker per gamba = 0.2% roundtrip straddle
|
||||
- Slippage: 0.1% per esecuzione
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
# Costi REALISTICI Deribit options
|
||||
FEE_PER_LEG = 0.0003 # 0.03% per leg (Deribit option fee)
|
||||
SLIPPAGE = 0.001 # 0.1% bid-ask spread per leg
|
||||
TOTAL_COST_ROUNDTRIP = (FEE_PER_LEG + SLIPPAGE) * 4 # 4 legs: sell call, sell put, buy back both
|
||||
MARGIN_REQUIREMENT = 0.05 # 5% del notional bloccato come margine
|
||||
INITIAL = 1000
|
||||
|
||||
|
||||
def realized_vol_ann(close, window):
|
||||
log_ret = np.diff(np.log(np.where(close == 0, 1e-10, close)))
|
||||
result = np.full(len(close), np.nan)
|
||||
for i in range(window, len(log_ret)):
|
||||
result[i + 1] = np.std(log_ret[i - window : i]) * np.sqrt(24 * 365)
|
||||
return result
|
||||
|
||||
|
||||
def iv_estimate_realistic(rv_short, rv_long, regime_vol):
|
||||
"""Stima IV realistica basata su regime.
|
||||
In calma: IV ≈ 1.1-1.2x RV
|
||||
In stress: IV ≈ 0.8-1.0x RV (perché RV è già esplosa ma IV non tiene il passo)
|
||||
Post-crash: IV ≈ 1.5-2.0x RV (IV elevata, RV sta scendendo)
|
||||
"""
|
||||
if rv_short <= 0 or rv_long <= 0:
|
||||
return rv_long * 1.1 if rv_long > 0 else 0.5
|
||||
|
||||
# Regime detection
|
||||
regime_ratio = rv_short / rv_long
|
||||
|
||||
if regime_ratio > 2.0:
|
||||
# CRASH in corso: RV short term esplosa, IV non scala altrettanto
|
||||
premium = 0.85 + np.random.normal(0, 0.05)
|
||||
elif regime_ratio > 1.3:
|
||||
# Alta volatilità: premium compresso
|
||||
premium = 1.0 + np.random.normal(0, 0.05)
|
||||
elif regime_ratio < 0.7:
|
||||
# Post-crash calma: IV ancora alta, RV scesa
|
||||
premium = 1.3 + np.random.normal(0, 0.1)
|
||||
else:
|
||||
# Normale: premium standard
|
||||
premium = 1.15 + np.random.normal(0, 0.08)
|
||||
|
||||
premium = max(0.7, min(premium, 1.8)) # clamp
|
||||
return rv_long * premium
|
||||
|
||||
|
||||
def straddle_premium_pct(iv, dte_hours):
|
||||
"""Premium straddle ATM in % del spot. Approssimazione BS."""
|
||||
if iv <= 0 or dte_hours <= 0:
|
||||
return 0
|
||||
t = dte_hours / (24 * 365)
|
||||
# ATM straddle ≈ spot * iv * sqrt(t) * 0.8 (approssimazione standard)
|
||||
return iv * np.sqrt(t) * 0.8
|
||||
|
||||
|
||||
def run_vrp_honest(asset, dte_hours=24, n_simulations=5):
|
||||
print(f"\n{'='*65}")
|
||||
print(f" {asset} — VRP HONEST TEST (DTE={dte_hours}h)")
|
||||
print(f" Fees: {TOTAL_COST_ROUNDTRIP*100:.2f}% roundtrip, Slippage incluso")
|
||||
print(f" Margin: {MARGIN_REQUIREMENT*100}% del notional")
|
||||
print(f"{'='*65}")
|
||||
|
||||
df = load_data(asset, "1h")
|
||||
close = df["close"].values
|
||||
n = len(close)
|
||||
split = int(n * 0.7)
|
||||
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
rv_24 = realized_vol_ann(close, 24)
|
||||
rv_72 = realized_vol_ann(close, 72)
|
||||
rv_168 = realized_vol_ann(close, 168)
|
||||
|
||||
# Identifica periodi di crisi per report separato
|
||||
crisis_periods = {
|
||||
"COVID crash (Mar 2020)": ("2020-03-01", "2020-04-01"),
|
||||
"May 2021 crash": ("2021-05-01", "2021-06-01"),
|
||||
"Luna/3AC (Jun 2022)": ("2022-06-01", "2022-07-15"),
|
||||
"FTX collapse (Nov 2022)": ("2022-11-01", "2022-12-15"),
|
||||
}
|
||||
|
||||
all_sim_results = []
|
||||
|
||||
for sim in range(n_simulations):
|
||||
np.random.seed(42 + sim)
|
||||
capital = float(INITIAL)
|
||||
total = 0
|
||||
correct = 0
|
||||
peak = capital
|
||||
max_dd = 0
|
||||
daily_trades = {}
|
||||
crisis_pnl = {k: 0.0 for k in crisis_periods}
|
||||
|
||||
for i in range(max(split, 170), n - dte_hours):
|
||||
day = timestamps.iloc[i].strftime("%Y-%m-%d")
|
||||
if daily_trades.get(day, 0) >= 1:
|
||||
continue
|
||||
if timestamps.iloc[i].hour != 8:
|
||||
continue
|
||||
|
||||
rv_s = rv_24[i]
|
||||
rv_m = rv_72[i]
|
||||
rv_l = rv_168[i]
|
||||
|
||||
if np.isnan(rv_s) or np.isnan(rv_l) or rv_s <= 0.05 or rv_l <= 0.05:
|
||||
continue
|
||||
|
||||
# IV realistica con variabilità
|
||||
iv = iv_estimate_realistic(rv_s, rv_l, rv_m)
|
||||
|
||||
# Premium straddle
|
||||
prem_pct = straddle_premium_pct(iv, dte_hours)
|
||||
|
||||
if prem_pct <= TOTAL_COST_ROUNDTRIP:
|
||||
continue # non vale la pena, costi > premium
|
||||
|
||||
spot = close[i]
|
||||
|
||||
# Position size: limitata dal margine
|
||||
margin_per_unit = spot * MARGIN_REQUIREMENT
|
||||
max_notional = capital / margin_per_unit * spot
|
||||
pos_pct = min(0.15, capital / (spot * MARGIN_REQUIREMENT * 10)) # conservativo
|
||||
|
||||
# Actual path
|
||||
exit_idx = min(i + dte_hours, n - 1)
|
||||
actual_move_pct = abs(close[exit_idx] - spot) / spot
|
||||
|
||||
# Intra-period max move (per stress check)
|
||||
path = close[i : exit_idx + 1]
|
||||
max_adverse_pct = max(np.max(path) - spot, spot - np.min(path)) / spot
|
||||
|
||||
# P&L straddle short
|
||||
if actual_move_pct <= prem_pct:
|
||||
# In profitto: premium - actual move
|
||||
raw_pnl_pct = (prem_pct - actual_move_pct) * pos_pct
|
||||
else:
|
||||
# In perdita: move > premium
|
||||
loss = actual_move_pct - prem_pct
|
||||
# Cap loss at 3x premium (risk management)
|
||||
loss = min(loss, prem_pct * 3)
|
||||
raw_pnl_pct = -loss * pos_pct
|
||||
|
||||
# Costi
|
||||
cost = TOTAL_COST_ROUNDTRIP * pos_pct
|
||||
net_pnl_pct = raw_pnl_pct - cost
|
||||
|
||||
capital += capital * net_pnl_pct
|
||||
capital = max(capital, 10) # floor
|
||||
|
||||
if capital > peak:
|
||||
peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
|
||||
total += 1
|
||||
if raw_pnl_pct > 0:
|
||||
correct += 1
|
||||
|
||||
daily_trades[day] = daily_trades.get(day, 0) + 1
|
||||
|
||||
# Track crisis PnL
|
||||
for crisis_name, (c_start, c_end) in crisis_periods.items():
|
||||
if c_start <= day <= c_end:
|
||||
crisis_pnl[crisis_name] += capital * net_pnl_pct
|
||||
|
||||
if total < 20:
|
||||
continue
|
||||
|
||||
acc = correct / total * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
test_days = (n - split) / 24
|
||||
test_years = test_days / 365.25
|
||||
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
||||
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
|
||||
|
||||
all_sim_results.append({
|
||||
"sim": sim,
|
||||
"trades": total,
|
||||
"accuracy": acc,
|
||||
"return": ret,
|
||||
"annualized": ann,
|
||||
"max_dd": max_dd * 100,
|
||||
"daily_pnl": dpnl,
|
||||
"final_capital": capital,
|
||||
"days_active": len(daily_trades),
|
||||
"crisis_pnl": crisis_pnl,
|
||||
})
|
||||
|
||||
if not all_sim_results:
|
||||
print(" No results!")
|
||||
return
|
||||
|
||||
# Aggregate across simulations
|
||||
accs = [r["accuracy"] for r in all_sim_results]
|
||||
anns = [r["annualized"] for r in all_sim_results]
|
||||
dds = [r["max_dd"] for r in all_sim_results]
|
||||
dpnls = [r["daily_pnl"] for r in all_sim_results]
|
||||
rets = [r["return"] for r in all_sim_results]
|
||||
|
||||
print(f"\n {'Metric':<20s} {'Mean':>10s} {'Min':>10s} {'Max':>10s}")
|
||||
print(f" {'-'*50}")
|
||||
print(f" {'Accuracy':.<20s} {np.mean(accs):>9.1f}% {np.min(accs):>9.1f}% {np.max(accs):>9.1f}%")
|
||||
print(f" {'Annualized':.<20s} {np.mean(anns):>9.1f}% {np.min(anns):>9.1f}% {np.max(anns):>9.1f}%")
|
||||
print(f" {'Max Drawdown':.<20s} {np.mean(dds):>9.1f}% {np.min(dds):>9.1f}% {np.max(dds):>9.1f}%")
|
||||
print(f" {'€/day':.<20s} {np.mean(dpnls):>9.2f}€ {np.min(dpnls):>9.2f}€ {np.max(dpnls):>9.2f}€")
|
||||
print(f" {'Total return':.<20s} {np.mean(rets):>9.1f}% {np.min(rets):>9.1f}% {np.max(rets):>9.1f}%")
|
||||
print(f" {'Trades':.<20s} {all_sim_results[0]['trades']:>10d}")
|
||||
print(f" {'Days active':.<20s} {all_sim_results[0]['days_active']:>10d}")
|
||||
|
||||
# Crisis performance
|
||||
print(f"\n STRESS TEST — Performance durante crisi:")
|
||||
for crisis_name in crisis_periods:
|
||||
crisis_vals = [r["crisis_pnl"][crisis_name] for r in all_sim_results]
|
||||
avg_crisis = np.mean(crisis_vals)
|
||||
print(f" {crisis_name:30s}: avg PnL = €{avg_crisis:+.2f}")
|
||||
|
||||
return all_sim_results
|
||||
|
||||
|
||||
# Run con diversi DTE
|
||||
for asset in ["ETH", "BTC"]:
|
||||
for dte in [24, 48]:
|
||||
run_vrp_honest(asset, dte, n_simulations=10)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""S2-09: VRP test per-anno — verità nuda.
|
||||
Test su OGNI anno separatamente per vedere performance durante crash.
|
||||
Niente compounding — PnL medio per trade in punti percentuali.
|
||||
Costi realistici Deribit options.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_ROUNDTRIP = 0.0052 # 0.52% roundtrip (4 legs × 0.13%)
|
||||
INITIAL = 1000
|
||||
|
||||
|
||||
def rv_ann(close, window):
|
||||
lr = np.diff(np.log(np.where(close == 0, 1e-10, close)))
|
||||
r = np.full(len(close), np.nan)
|
||||
for i in range(window, len(lr)):
|
||||
r[i + 1] = np.std(lr[i - window : i]) * np.sqrt(24 * 365)
|
||||
return r
|
||||
|
||||
|
||||
def straddle_prem(iv, dte_h):
|
||||
if iv <= 0 or dte_h <= 0:
|
||||
return 0
|
||||
return iv * np.sqrt(dte_h / (24 * 365)) * 0.8
|
||||
|
||||
|
||||
def run_per_year(asset, dte=24):
|
||||
print(f"\n{'='*70}")
|
||||
print(f" {asset} — VRP PER ANNO (DTE={dte}h, NO compounding)")
|
||||
print(f" Fee roundtrip: {FEE_ROUNDTRIP*100:.2f}%")
|
||||
print(f"{'='*70}")
|
||||
|
||||
df = load_data(asset, "1h")
|
||||
close = df["close"].values
|
||||
n = len(close)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
rv_24 = rv_ann(close, 24)
|
||||
rv_168 = rv_ann(close, 168)
|
||||
|
||||
# IV/RV premium: conservative estimate per regime
|
||||
# Storicamene crypto VRP ≈ 15-30% (IV/RV ≈ 1.15-1.30)
|
||||
# Ma durante crash VRP va NEGATIVO (RV > IV)
|
||||
|
||||
years = sorted(set(ts.dt.year))
|
||||
|
||||
print(f"\n {'Year':>6s} {'Trades':>7s} {'Wins':>5s} {'Acc%':>6s} {'AvgPnL%':>9s} {'TotPnL€':>9s} {'Worst%':>8s} {'MaxMove%':>9s}")
|
||||
print(f" {'-'*70}")
|
||||
|
||||
all_pnls = []
|
||||
yearly_stats = []
|
||||
|
||||
for year in years:
|
||||
year_mask = ts.dt.year == year
|
||||
year_indices = np.where(year_mask.values)[0]
|
||||
|
||||
if len(year_indices) < 200:
|
||||
continue
|
||||
|
||||
trades_pnl = []
|
||||
trades_detail = []
|
||||
|
||||
for i in year_indices:
|
||||
if i < 170 or i + dte >= n:
|
||||
continue
|
||||
if ts.iloc[i].hour != 8:
|
||||
continue
|
||||
|
||||
rv_s = rv_24[i]
|
||||
rv_l = rv_168[i]
|
||||
if np.isnan(rv_s) or np.isnan(rv_l) or rv_s < 0.05 or rv_l < 0.05:
|
||||
continue
|
||||
|
||||
# IV estimate: regime-dependent
|
||||
regime = rv_s / rv_l if rv_l > 0 else 1.0
|
||||
|
||||
if regime > 2.0:
|
||||
# CRASH: RV esplosa, IV probabilmente = RV o meno
|
||||
iv_premium_factor = 0.9
|
||||
elif regime > 1.5:
|
||||
iv_premium_factor = 1.0
|
||||
elif regime > 1.0:
|
||||
iv_premium_factor = 1.1
|
||||
else:
|
||||
# Calm: VRP positivo
|
||||
iv_premium_factor = 1.2
|
||||
|
||||
iv = rv_l * iv_premium_factor
|
||||
prem = straddle_prem(iv, dte)
|
||||
|
||||
spot = close[i]
|
||||
exit_idx = min(i + dte, n - 1)
|
||||
actual_move = abs(close[exit_idx] - spot) / spot
|
||||
|
||||
# P&L (senza compounding — flat € su €1000)
|
||||
pos_size = INITIAL * 0.10 # 10% fisso, no leverage
|
||||
if actual_move <= prem:
|
||||
raw_pnl = (prem - actual_move) * pos_size
|
||||
else:
|
||||
raw_pnl = -(actual_move - prem) * pos_size
|
||||
raw_pnl = max(raw_pnl, -pos_size * 0.05) # cap loss
|
||||
|
||||
cost = FEE_ROUNDTRIP * pos_size
|
||||
net_pnl = raw_pnl - cost
|
||||
|
||||
trades_pnl.append(net_pnl)
|
||||
trades_detail.append({
|
||||
"prem": prem,
|
||||
"move": actual_move,
|
||||
"regime": regime,
|
||||
"rv_s": rv_s,
|
||||
"iv": iv,
|
||||
})
|
||||
all_pnls.append(net_pnl)
|
||||
|
||||
if not trades_pnl:
|
||||
continue
|
||||
|
||||
wins = sum(1 for p in trades_pnl if p > 0)
|
||||
acc = wins / len(trades_pnl) * 100
|
||||
avg_pnl = np.mean(trades_pnl)
|
||||
tot_pnl = np.sum(trades_pnl)
|
||||
worst = np.min(trades_pnl)
|
||||
max_move = max(t["move"] for t in trades_detail) * 100
|
||||
|
||||
tag = ""
|
||||
if year in [2020, 2021, 2022]:
|
||||
tag = " ← CRASH YEAR"
|
||||
if acc >= 70 and avg_pnl > 0:
|
||||
tag += " ✅"
|
||||
|
||||
print(f" {year:>6d} {len(trades_pnl):>7d} {wins:>5d} {acc:>5.1f}% {avg_pnl:>+8.2f}€ {tot_pnl:>+8.0f}€ {worst:>+7.2f}€ {max_move:>8.1f}% {tag}")
|
||||
|
||||
yearly_stats.append({
|
||||
"year": year, "trades": len(trades_pnl), "acc": acc,
|
||||
"avg_pnl": avg_pnl, "tot_pnl": tot_pnl, "worst": worst,
|
||||
})
|
||||
|
||||
# Summary
|
||||
if all_pnls:
|
||||
total_trades = len(all_pnls)
|
||||
total_wins = sum(1 for p in all_pnls if p > 0)
|
||||
print(f"\n {'TOTALE':>6s} {total_trades:>7d} {total_wins:>5d} {total_wins/total_trades*100:>5.1f}% {np.mean(all_pnls):>+8.2f}€ {np.sum(all_pnls):>+8.0f}€ {np.min(all_pnls):>+7.2f}€")
|
||||
|
||||
# Con compounding realistico
|
||||
capital = float(INITIAL)
|
||||
peak = capital
|
||||
max_dd = 0
|
||||
for pnl in all_pnls:
|
||||
capital += pnl * (capital / INITIAL) # scala con capitale
|
||||
capital = max(capital, 10)
|
||||
if capital > peak:
|
||||
peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
|
||||
years_total = (yearly_stats[-1]["year"] - yearly_stats[0]["year"] + 1)
|
||||
ann = ((capital / INITIAL) ** (1 / years_total) - 1) * 100 if capital > 0 else -100
|
||||
daily_avg = (capital - INITIAL) / (total_trades) # approx 1 trade/day
|
||||
|
||||
print(f"\n CON COMPOUNDING:")
|
||||
print(f" Capitale finale: €{capital:,.0f}")
|
||||
print(f" ROI annualizzato: {ann:+.1f}%")
|
||||
print(f" Max Drawdown: {max_dd*100:.1f}%")
|
||||
print(f" €/trade medio: €{daily_avg:.2f}")
|
||||
|
||||
# Worst year
|
||||
worst_year = min(yearly_stats, key=lambda x: x["tot_pnl"])
|
||||
best_year = max(yearly_stats, key=lambda x: x["tot_pnl"])
|
||||
print(f"\n Anno peggiore: {worst_year['year']} → {worst_year['tot_pnl']:+.0f}€ ({worst_year['acc']:.0f}% acc)")
|
||||
print(f" Anno migliore: {best_year['year']} → {best_year['tot_pnl']:+.0f}€ ({best_year['acc']:.0f}% acc)")
|
||||
|
||||
|
||||
for asset in ["ETH", "BTC"]:
|
||||
for dte in [24, 48]:
|
||||
run_per_year(asset, dte)
|
||||
@@ -0,0 +1,297 @@
|
||||
"""S2-10: VRP + filtri multipli per alzare accuracy.
|
||||
Filtri testati:
|
||||
1. NO vol sell se squeeze attivo (compressione → breakout imminente, NON vendere vol)
|
||||
2. NO vol sell se RV short-term > RV long-term (regime esplosivo)
|
||||
3. NO vol sell se move delle ultime 4h > 2% (momentum in corso)
|
||||
4. NO vol sell se volume spike > 2x media (evento in corso)
|
||||
5. COMBINAZIONI dei filtri sopra
|
||||
Test per-anno, NO compounding per PnL medio, compounding a fine report.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_ROUNDTRIP = 0.0052
|
||||
INITIAL = 1000
|
||||
|
||||
|
||||
def rv_ann(close, window):
|
||||
lr = np.diff(np.log(np.where(close == 0, 1e-10, close)))
|
||||
r = np.full(len(close), np.nan)
|
||||
for i in range(window, len(lr)):
|
||||
r[i + 1] = np.std(lr[i - window : i]) * np.sqrt(24 * 365)
|
||||
return r
|
||||
|
||||
|
||||
def keltner_ratio(close, high, low, window=14):
|
||||
n = len(close)
|
||||
result = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc = close[i - window : i]
|
||||
wh = high[i - window : i]
|
||||
wl = low[i - window : i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(wh - wl, np.maximum(np.abs(wh - np.roll(wc, 1)), np.abs(wl - np.roll(wc, 1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc_r = (ma + 1.5 * atr) - (ma - 1.5 * atr)
|
||||
bb_r = (ma + 2 * bb_std) - (ma - 2 * bb_std)
|
||||
if kc_r > 0:
|
||||
result[i] = bb_r / kc_r
|
||||
return result
|
||||
|
||||
|
||||
def straddle_prem(iv, dte_h):
|
||||
if iv <= 0 or dte_h <= 0:
|
||||
return 0
|
||||
return iv * np.sqrt(dte_h / (24 * 365)) * 0.8
|
||||
|
||||
|
||||
def run_filtered(asset, dte=48):
|
||||
print(f"\n{'='*75}")
|
||||
print(f" {asset} — VRP + FILTRI (DTE={dte}h)")
|
||||
print(f"{'='*75}")
|
||||
|
||||
df = load_data(asset, "1h")
|
||||
close = df["close"].values
|
||||
high = df["high"].values
|
||||
low = df["low"].values
|
||||
volume = df["volume"].values
|
||||
n = len(close)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
rv_24 = rv_ann(close, 24)
|
||||
rv_168 = rv_ann(close, 168)
|
||||
kcr = keltner_ratio(close, high, low, 14)
|
||||
|
||||
# Pre-calcolo filtri
|
||||
vol_avg_48 = np.full(n, np.nan)
|
||||
for i in range(48, n):
|
||||
vol_avg_48[i] = np.mean(volume[i - 48 : i])
|
||||
|
||||
ret_4h = np.full(n, 0.0)
|
||||
for i in range(4, n):
|
||||
ret_4h[i] = abs(close[i] - close[i - 4]) / close[i - 4]
|
||||
|
||||
filter_configs = [
|
||||
# (name, use_squeeze, use_regime, use_momentum, use_volume, squeeze_thr, regime_thr, mom_thr, vol_thr)
|
||||
("BASELINE (no filter)", False, False, False, False, 0, 0, 0, 0),
|
||||
("squeeze_only", True, False, False, False, 0.85, 0, 0, 0),
|
||||
("regime_only", False, True, False, False, 0, 1.3, 0, 0),
|
||||
("momentum_only", False, False, True, False, 0, 0, 0.02, 0),
|
||||
("volume_only", False, False, False, True, 0, 0, 0, 2.0),
|
||||
("squeeze+regime", True, True, False, False, 0.85, 1.3, 0, 0),
|
||||
("squeeze+momentum", True, False, True, False, 0.85, 0, 0.02, 0),
|
||||
("squeeze+volume", True, False, False, True, 0.85, 0, 0, 2.0),
|
||||
("regime+momentum", False, True, True, False, 0, 1.3, 0.02, 0),
|
||||
("ALL_FILTERS", True, True, True, True, 0.85, 1.3, 0.02, 2.0),
|
||||
("squeeze+regime+mom", True, True, True, False, 0.85, 1.3, 0.02, 0),
|
||||
("squeeze_tight", True, False, False, False, 0.80, 0, 0, 0),
|
||||
("squeeze_tight+regime", True, True, False, False, 0.80, 1.3, 0, 0),
|
||||
("regime_strict", False, True, False, False, 0, 1.2, 0, 0),
|
||||
("mom_strict", False, False, True, False, 0, 0, 0.015, 0),
|
||||
("squeeze_tight+mom_strict", True, False, True, False, 0.80, 0, 0.015, 0),
|
||||
("ALL_TIGHT", True, True, True, True, 0.80, 1.2, 0.015, 1.8),
|
||||
]
|
||||
|
||||
results_table = []
|
||||
|
||||
for name, f_sq, f_reg, f_mom, f_vol, sq_thr, reg_thr, mom_thr, vol_thr in filter_configs:
|
||||
all_pnls = []
|
||||
yearly = {}
|
||||
|
||||
for i in range(170, n - dte):
|
||||
if ts.iloc[i].hour != 8:
|
||||
continue
|
||||
|
||||
rv_s = rv_24[i]
|
||||
rv_l = rv_168[i]
|
||||
if np.isnan(rv_s) or np.isnan(rv_l) or rv_s < 0.05 or rv_l < 0.05:
|
||||
continue
|
||||
|
||||
# === FILTRI ===
|
||||
skip = False
|
||||
|
||||
if f_sq and not np.isnan(kcr[i]):
|
||||
in_squeeze = kcr[i] < sq_thr
|
||||
# Controlla se squeeze nelle ultime 5 barre
|
||||
recent_squeeze = any(not np.isnan(kcr[j]) and kcr[j] < sq_thr for j in range(max(0, i - 5), i + 1))
|
||||
if recent_squeeze:
|
||||
skip = True
|
||||
|
||||
if f_reg and rv_l > 0:
|
||||
if rv_s / rv_l > reg_thr:
|
||||
skip = True
|
||||
|
||||
if f_mom:
|
||||
if ret_4h[i] > mom_thr:
|
||||
skip = True
|
||||
|
||||
if f_vol and not np.isnan(vol_avg_48[i]) and vol_avg_48[i] > 0:
|
||||
if volume[i] > vol_avg_48[i] * vol_thr:
|
||||
skip = True
|
||||
|
||||
if skip:
|
||||
continue
|
||||
|
||||
# === TRADE ===
|
||||
regime = rv_s / rv_l if rv_l > 0 else 1.0
|
||||
if regime > 2.0:
|
||||
iv_pf = 0.9
|
||||
elif regime > 1.5:
|
||||
iv_pf = 1.0
|
||||
elif regime > 1.0:
|
||||
iv_pf = 1.1
|
||||
else:
|
||||
iv_pf = 1.2
|
||||
iv = rv_l * iv_pf
|
||||
|
||||
prem = straddle_prem(iv, dte)
|
||||
spot = close[i]
|
||||
exit_idx = min(i + dte, n - 1)
|
||||
actual_move = abs(close[exit_idx] - spot) / spot
|
||||
|
||||
pos_size = INITIAL * 0.10
|
||||
if actual_move <= prem:
|
||||
raw = (prem - actual_move) * pos_size
|
||||
else:
|
||||
raw = -(actual_move - prem) * pos_size
|
||||
raw = max(raw, -pos_size * 0.05)
|
||||
|
||||
net = raw - FEE_ROUNDTRIP * pos_size
|
||||
all_pnls.append(net)
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = []
|
||||
yearly[year].append(net)
|
||||
|
||||
if len(all_pnls) < 50:
|
||||
continue
|
||||
|
||||
wins = sum(1 for p in all_pnls if p > 0)
|
||||
acc = wins / len(all_pnls) * 100
|
||||
avg_pnl = np.mean(all_pnls)
|
||||
tot_pnl = np.sum(all_pnls)
|
||||
worst_trade = np.min(all_pnls)
|
||||
avg_win = np.mean([p for p in all_pnls if p > 0]) if wins > 0 else 0
|
||||
avg_loss = np.mean([p for p in all_pnls if p <= 0]) if wins < len(all_pnls) else 0
|
||||
|
||||
# Worst year
|
||||
worst_year_acc = 100
|
||||
worst_year_name = ""
|
||||
for y, ypnls in sorted(yearly.items()):
|
||||
yw = sum(1 for p in ypnls if p > 0) / len(ypnls) * 100 if ypnls else 0
|
||||
if yw < worst_year_acc:
|
||||
worst_year_acc = yw
|
||||
worst_year_name = str(y)
|
||||
|
||||
# Compounded return
|
||||
capital = float(INITIAL)
|
||||
peak = capital
|
||||
max_dd = 0
|
||||
for pnl in all_pnls:
|
||||
capital += pnl * (capital / INITIAL)
|
||||
capital = max(capital, 10)
|
||||
if capital > peak:
|
||||
peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
|
||||
n_years = len(yearly)
|
||||
ann = ((capital / INITIAL) ** (1 / n_years) - 1) * 100 if capital > 0 and n_years > 0 else -100
|
||||
|
||||
results_table.append({
|
||||
"name": name,
|
||||
"trades": len(all_pnls),
|
||||
"acc": acc,
|
||||
"avg_pnl": avg_pnl,
|
||||
"avg_win": avg_win,
|
||||
"avg_loss": avg_loss,
|
||||
"ann": ann,
|
||||
"max_dd": max_dd * 100,
|
||||
"worst_year": f"{worst_year_name}({worst_year_acc:.0f}%)",
|
||||
"capital": capital,
|
||||
})
|
||||
|
||||
# Sort by accuracy
|
||||
results_table.sort(key=lambda x: x["acc"], reverse=True)
|
||||
|
||||
print(f"\n {'Name':.<30s} {'Trades':>7s} {'Acc':>6s} {'AvgPnL':>8s} {'AvgWin':>8s} {'AvgLoss':>8s} {'Ann%':>7s} {'DD%':>5s} {'Worst_Yr':>12s} {'Capital':>10s}")
|
||||
print(f" {'-'*105}")
|
||||
for r in results_table:
|
||||
tag = "✅✅" if r["acc"] >= 75 else "✅" if r["acc"] >= 70 else ""
|
||||
print(f" {r['name']:.<30s} {r['trades']:>7d} {r['acc']:>5.1f}% {r['avg_pnl']:>+7.2f}€ {r['avg_win']:>+7.2f}€ {r['avg_loss']:>+7.2f}€ {r['ann']:>+6.1f}% {r['max_dd']:>4.1f}% {r['worst_year']:>12s} €{r['capital']:>9,.0f} {tag}")
|
||||
|
||||
# Dettaglio per anno del migliore
|
||||
best = results_table[0]
|
||||
print(f"\n MIGLIORE: {best['name']} → {best['acc']:.1f}% acc, {best['ann']:+.1f}% ann, DD {best['max_dd']:.1f}%")
|
||||
|
||||
# Rerun best per year
|
||||
best_name = best["name"]
|
||||
best_cfg = None
|
||||
for cfg in filter_configs:
|
||||
if cfg[0] == best_name:
|
||||
best_cfg = cfg
|
||||
break
|
||||
|
||||
if best_cfg:
|
||||
_, f_sq, f_reg, f_mom, f_vol, sq_thr, reg_thr, mom_thr, vol_thr = best_cfg
|
||||
yearly_detail = {}
|
||||
|
||||
for i in range(170, n - dte):
|
||||
if ts.iloc[i].hour != 8:
|
||||
continue
|
||||
rv_s = rv_24[i]
|
||||
rv_l = rv_168[i]
|
||||
if np.isnan(rv_s) or np.isnan(rv_l) or rv_s < 0.05 or rv_l < 0.05:
|
||||
continue
|
||||
|
||||
skip = False
|
||||
if f_sq:
|
||||
if any(not np.isnan(kcr[j]) and kcr[j] < sq_thr for j in range(max(0, i - 5), i + 1)):
|
||||
skip = True
|
||||
if f_reg and rv_l > 0 and rv_s / rv_l > reg_thr:
|
||||
skip = True
|
||||
if f_mom and ret_4h[i] > mom_thr:
|
||||
skip = True
|
||||
if f_vol and not np.isnan(vol_avg_48[i]) and vol_avg_48[i] > 0 and volume[i] > vol_avg_48[i] * vol_thr:
|
||||
skip = True
|
||||
if skip:
|
||||
continue
|
||||
|
||||
regime = rv_s / rv_l if rv_l > 0 else 1.0
|
||||
iv_pf = {True: 0.9, False: 1.2}[regime > 2.0] if regime > 2.0 else (1.0 if regime > 1.5 else (1.1 if regime > 1.0 else 1.2))
|
||||
iv = rv_l * iv_pf
|
||||
prem = straddle_prem(iv, dte)
|
||||
spot = close[i]
|
||||
exit_idx = min(i + dte, n - 1)
|
||||
move = abs(close[exit_idx] - spot) / spot
|
||||
pos_size = INITIAL * 0.10
|
||||
if move <= prem:
|
||||
raw = (prem - move) * pos_size
|
||||
else:
|
||||
raw = max(-(move - prem) * pos_size, -pos_size * 0.05)
|
||||
net = raw - FEE_ROUNDTRIP * pos_size
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly_detail:
|
||||
yearly_detail[year] = []
|
||||
yearly_detail[year].append(net)
|
||||
|
||||
print(f"\n Dettaglio per anno ({best_name}):")
|
||||
for y in sorted(yearly_detail):
|
||||
pnls = yearly_detail[y]
|
||||
w = sum(1 for p in pnls if p > 0)
|
||||
a = w / len(pnls) * 100
|
||||
tag = " ← CRASH" if y in [2020, 2021, 2022] else ""
|
||||
print(f" {y}: trades={len(pnls):4d} acc={a:.1f}% avg=€{np.mean(pnls):+.2f} tot=€{np.sum(pnls):+.0f}{tag}")
|
||||
|
||||
|
||||
for asset in ["ETH", "BTC"]:
|
||||
run_filtered(asset, dte=48)
|
||||
run_filtered(asset, dte=24)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""S2-11: VRP con DVOL REALE — unico test valido.
|
||||
Solo 90 giorni di dati, ma REALI.
|
||||
Confronta DVOL (IV reale Deribit) vs RV realizzata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_ROUNDTRIP = 0.0052
|
||||
INITIAL = 1000
|
||||
|
||||
|
||||
def rv_ann(close, window):
|
||||
lr = np.diff(np.log(np.where(close == 0, 1e-10, close)))
|
||||
r = np.full(len(close), np.nan)
|
||||
for i in range(window, len(lr)):
|
||||
r[i + 1] = np.std(lr[i - window : i]) * np.sqrt(24 * 365)
|
||||
return r
|
||||
|
||||
|
||||
def straddle_prem(iv_pct, dte_h):
|
||||
"""iv_pct è la IV in decimale (es 0.50 = 50%)."""
|
||||
if iv_pct <= 0 or dte_h <= 0:
|
||||
return 0
|
||||
return iv_pct * np.sqrt(dte_h / (24 * 365)) * 0.8
|
||||
|
||||
|
||||
for asset in ["ETH", "BTC"]:
|
||||
print(f"\n{'='*70}")
|
||||
print(f" {asset} — VRP CON DVOL REALE (90 giorni)")
|
||||
print(f"{'='*70}")
|
||||
|
||||
df_price = load_data(asset, "1h")
|
||||
df_dvol = pd.read_parquet(f"data/raw/{asset.lower()}_dvol.parquet")
|
||||
|
||||
close = df_price["close"].values
|
||||
ts_price = df_price["timestamp"].values
|
||||
n = len(close)
|
||||
|
||||
dvol_ts = df_dvol["timestamp"].values
|
||||
dvol_vals = df_dvol["dvol"].values / 100 # converti a decimale
|
||||
|
||||
rv_24 = rv_ann(close, 24)
|
||||
rv_48 = rv_ann(close, 48)
|
||||
|
||||
# Allinea DVOL ai candles 1h (DVOL è giornaliero)
|
||||
dvol_aligned = np.full(n, np.nan)
|
||||
for j in range(len(dvol_ts)):
|
||||
mask = (ts_price >= dvol_ts[j]) & (ts_price < dvol_ts[j] + 86400000)
|
||||
dvol_aligned[mask] = dvol_vals[j]
|
||||
|
||||
valid_count = np.sum(~np.isnan(dvol_aligned))
|
||||
print(f" Candele con DVOL reale: {valid_count}")
|
||||
print(f" DVOL range: {np.nanmin(dvol_aligned)*100:.1f}% — {np.nanmax(dvol_aligned)*100:.1f}%")
|
||||
|
||||
# Analisi IV vs RV reale
|
||||
iv_rv_ratios = []
|
||||
for i in range(n):
|
||||
if np.isnan(dvol_aligned[i]) or np.isnan(rv_24[i]) or rv_24[i] <= 0:
|
||||
continue
|
||||
iv_rv_ratios.append(dvol_aligned[i] / rv_24[i])
|
||||
|
||||
if iv_rv_ratios:
|
||||
print(f"\n IV/RV ratio REALE:")
|
||||
print(f" Mean: {np.mean(iv_rv_ratios):.3f}")
|
||||
print(f" Median: {np.median(iv_rv_ratios):.3f}")
|
||||
print(f" Min: {np.min(iv_rv_ratios):.3f}")
|
||||
print(f" Max: {np.max(iv_rv_ratios):.3f}")
|
||||
print(f" >1 (VRP+): {sum(1 for r in iv_rv_ratios if r > 1)/len(iv_rv_ratios)*100:.0f}% del tempo")
|
||||
|
||||
# Backtest VRP reale
|
||||
for dte in [24, 48]:
|
||||
print(f"\n --- DTE={dte}h ---")
|
||||
capital = float(INITIAL)
|
||||
trades = []
|
||||
daily_done = set()
|
||||
|
||||
for i in range(100, n - dte):
|
||||
if np.isnan(dvol_aligned[i]) or np.isnan(rv_24[i]):
|
||||
continue
|
||||
|
||||
ts_dt = pd.Timestamp(ts_price[i], unit="ms", tz="UTC")
|
||||
if ts_dt.hour != 8:
|
||||
continue
|
||||
|
||||
day = ts_dt.strftime("%Y-%m-%d")
|
||||
if day in daily_done:
|
||||
continue
|
||||
|
||||
iv = dvol_aligned[i]
|
||||
rv = rv_24[i]
|
||||
|
||||
# Filtro regime: skip se RV > IV (no premium)
|
||||
if rv > iv:
|
||||
continue
|
||||
|
||||
prem = straddle_prem(iv, dte)
|
||||
spot = close[i]
|
||||
exit_idx = min(i + dte, n - 1)
|
||||
actual_move = abs(close[exit_idx] - spot) / spot
|
||||
|
||||
pos_pct = 0.10
|
||||
if actual_move <= prem:
|
||||
raw = (prem - actual_move) * pos_pct
|
||||
else:
|
||||
raw = -(actual_move - prem) * pos_pct
|
||||
raw = max(raw, -pos_pct * 0.05)
|
||||
|
||||
net = raw - FEE_ROUNDTRIP * pos_pct
|
||||
capital += capital * net
|
||||
capital = max(capital, 10)
|
||||
|
||||
trades.append({
|
||||
"day": day,
|
||||
"iv": iv * 100,
|
||||
"rv": rv * 100,
|
||||
"premium": prem * 100,
|
||||
"move": actual_move * 100,
|
||||
"pnl": net * capital,
|
||||
"win": raw > 0,
|
||||
})
|
||||
daily_done.add(day)
|
||||
|
||||
if not trades:
|
||||
print(" Nessun trade!")
|
||||
continue
|
||||
|
||||
wins = sum(1 for t in trades if t["win"])
|
||||
acc = wins / len(trades) * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
avg_iv = np.mean([t["iv"] for t in trades])
|
||||
avg_rv = np.mean([t["rv"] for t in trades])
|
||||
avg_prem = np.mean([t["premium"] for t in trades])
|
||||
avg_move = np.mean([t["move"] for t in trades])
|
||||
|
||||
print(f" Trades: {len(trades)}")
|
||||
print(f" Accuracy: {acc:.1f}%")
|
||||
print(f" Return: {ret:+.1f}%")
|
||||
print(f" Capital: €{capital:.0f}")
|
||||
print(f" Avg IV: {avg_iv:.1f}%")
|
||||
print(f" Avg RV: {avg_rv:.1f}%")
|
||||
print(f" Avg Prem: {avg_prem:.2f}%")
|
||||
print(f" Avg Move: {avg_move:.2f}%")
|
||||
print(f" IV > Move (win condition): {sum(1 for t in trades if t['premium'] > t['move'])/len(trades)*100:.0f}%")
|
||||
|
||||
# Worst trade
|
||||
worst = min(trades, key=lambda t: t["pnl"])
|
||||
print(f" Worst: day={worst['day']} iv={worst['iv']:.1f}% move={worst['move']:.2f}%")
|
||||
@@ -0,0 +1,320 @@
|
||||
"""S2-12: Strategie SOLO su perpetual — dati 100% reali.
|
||||
Niente opzioni, niente IV stimata. Solo prezzo OHLCV.
|
||||
Mix di approcci diversi da quelli già testati su main.
|
||||
|
||||
1. Intraday range breakout con filtro volatilità
|
||||
2. Daily open range breakout (prima ora di trading)
|
||||
3. RSI divergence (prezzo fa nuovo min/max, RSI no)
|
||||
4. Close-to-close momentum filtrato da volatilità regime
|
||||
5. Multi-timeframe confirmation (15m signal + 1h trend)
|
||||
|
||||
Test per-anno, onesto, con fee reali perpetual (0.05% taker × 2 = 0.1% roundtrip).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_RT = 0.002 # 0.1% taker roundtrip
|
||||
INITIAL = 1000
|
||||
LEVERAGE = 3
|
||||
|
||||
|
||||
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
|
||||
if al == 0:
|
||||
result[i + 1] = 100
|
||||
else:
|
||||
result[i + 1] = 100 - 100 / (1 + ag / al)
|
||||
return result
|
||||
|
||||
|
||||
def ema(arr, period):
|
||||
r = np.full(len(arr), np.nan)
|
||||
k = 2 / (period + 1)
|
||||
r[period - 1] = np.mean(arr[:period])
|
||||
for i in range(period, len(arr)):
|
||||
r[i] = arr[i] * k + r[i - 1] * (1 - k)
|
||||
return r
|
||||
|
||||
|
||||
def run_all_perpetual(asset):
|
||||
print(f"\n{'#'*70}")
|
||||
print(f" {asset} — STRATEGIE PERPETUAL (dati reali)")
|
||||
print(f" Fee: {FEE_RT*100}% roundtrip, Leva: {LEVERAGE}x")
|
||||
print(f"{'#'*70}")
|
||||
|
||||
df_1h = load_data(asset, "1h")
|
||||
df_15m = load_data(asset, "15m")
|
||||
c1h = df_1h["close"].values
|
||||
h1h = df_1h["high"].values
|
||||
l1h = df_1h["low"].values
|
||||
v1h = df_1h["volume"].values
|
||||
n1h = len(c1h)
|
||||
ts1h = pd.to_datetime(df_1h["timestamp"], unit="ms", utc=True)
|
||||
|
||||
rsi_14 = rsi(c1h, 14)
|
||||
ema_20 = ema(c1h, 20)
|
||||
ema_50 = ema(c1h, 50)
|
||||
|
||||
results = {}
|
||||
|
||||
# ======================================================
|
||||
# STRAT 1: Daily Open Range Breakout
|
||||
# Prima ora (08-09 UTC) definisce il range. Breakout = entrata.
|
||||
# ======================================================
|
||||
for hold, stop_m in [(6, 1.0), (12, 1.5), (4, 0.8)]:
|
||||
name = f"ORB_h{hold}_s{stop_m}"
|
||||
capital = float(INITIAL)
|
||||
yearly = {}
|
||||
|
||||
for i in range(50, n1h - hold):
|
||||
if ts1h.iloc[i].hour != 9: # fine della prima ora
|
||||
continue
|
||||
day = ts1h.iloc[i].strftime("%Y-%m-%d")
|
||||
if day in yearly and len(yearly[day]) >= 1:
|
||||
continue
|
||||
|
||||
range_high = h1h[i - 1]
|
||||
range_low = l1h[i - 1]
|
||||
range_size = range_high - range_low
|
||||
if range_size <= 0:
|
||||
continue
|
||||
|
||||
# ATR per stop
|
||||
atr_14 = np.mean(h1h[max(0,i-14):i] - l1h[max(0,i-14):i])
|
||||
if atr_14 <= 0:
|
||||
continue
|
||||
|
||||
# Breakout detection: la candela attuale rompe il range
|
||||
if c1h[i] > range_high:
|
||||
direction = "long"
|
||||
elif c1h[i] < range_low:
|
||||
direction = "short"
|
||||
else:
|
||||
continue
|
||||
|
||||
entry = c1h[i]
|
||||
stop_dist = atr_14 * stop_m
|
||||
exit_price = c1h[min(i + hold, n1h - 1)]
|
||||
|
||||
for j in range(i + 1, min(i + hold + 1, n1h)):
|
||||
if direction == "long":
|
||||
if l1h[j] <= entry - stop_dist:
|
||||
exit_price = entry - stop_dist
|
||||
break
|
||||
if h1h[j] >= entry + stop_dist * 2:
|
||||
exit_price = entry + stop_dist * 2
|
||||
break
|
||||
else:
|
||||
if h1h[j] >= entry + stop_dist:
|
||||
exit_price = entry + stop_dist
|
||||
break
|
||||
if l1h[j] <= entry - stop_dist * 2:
|
||||
exit_price = entry - stop_dist * 2
|
||||
break
|
||||
exit_price = c1h[j]
|
||||
|
||||
trade_ret = ((exit_price - entry) / entry if direction == "long" else (entry - exit_price) / entry)
|
||||
net = trade_ret * LEVERAGE - FEE_RT * LEVERAGE
|
||||
capital += capital * 0.15 * net
|
||||
capital = max(capital, 10)
|
||||
|
||||
year = ts1h.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = []
|
||||
yearly[year].append(net > 0)
|
||||
if day not in yearly:
|
||||
yearly[day] = []
|
||||
|
||||
if sum(len(v) for v in yearly.values() if isinstance(v, list) and all(isinstance(x, bool) for x in v)) > 30:
|
||||
all_wins = [w for v in yearly.values() if isinstance(v, list) for w in v if isinstance(w, bool)]
|
||||
acc = sum(all_wins) / len(all_wins) * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
results[name] = {"acc": acc, "ret": ret, "trades": len(all_wins), "capital": capital}
|
||||
|
||||
# ======================================================
|
||||
# STRAT 2: RSI Divergence
|
||||
# Prezzo fa nuovo low, RSI no = bullish divergence → long
|
||||
# ======================================================
|
||||
for lookback, rsi_thr_low, rsi_thr_high, hold in [(20, 30, 70, 6), (14, 25, 75, 8), (10, 35, 65, 4)]:
|
||||
name = f"RSIdiv_lb{lookback}_h{hold}"
|
||||
capital = float(INITIAL)
|
||||
trades_list = []
|
||||
|
||||
for i in range(max(50, lookback + 1), n1h - hold):
|
||||
day = ts1h.iloc[i].strftime("%Y-%m-%d")
|
||||
|
||||
# Bullish divergence: price new low, RSI higher low
|
||||
price_new_low = c1h[i] < np.min(c1h[i - lookback : i])
|
||||
rsi_higher = rsi_14[i] > np.min(rsi_14[i - lookback : i]) and rsi_14[i] < rsi_thr_low
|
||||
|
||||
# Bearish divergence: price new high, RSI lower high
|
||||
price_new_high = c1h[i] > np.max(c1h[i - lookback : i])
|
||||
rsi_lower = rsi_14[i] < np.max(rsi_14[i - lookback : i]) and rsi_14[i] > rsi_thr_high
|
||||
|
||||
direction = None
|
||||
if price_new_low and rsi_higher:
|
||||
direction = "long"
|
||||
elif price_new_high and rsi_lower:
|
||||
direction = "short"
|
||||
|
||||
if direction is None:
|
||||
continue
|
||||
|
||||
entry = c1h[i]
|
||||
exit_price = c1h[min(i + hold, n1h - 1)]
|
||||
trade_ret = ((exit_price - entry) / entry if direction == "long" else (entry - exit_price) / entry)
|
||||
net = trade_ret * LEVERAGE - FEE_RT * LEVERAGE
|
||||
capital += capital * 0.12 * net
|
||||
capital = max(capital, 10)
|
||||
trades_list.append({"year": ts1h.iloc[i].year, "win": trade_ret > 0})
|
||||
|
||||
if len(trades_list) > 30:
|
||||
acc = sum(1 for t in trades_list if t["win"]) / len(trades_list) * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
results[name] = {"acc": acc, "ret": ret, "trades": len(trades_list), "capital": capital}
|
||||
|
||||
# ======================================================
|
||||
# STRAT 3: Momentum regime — trend following solo in low-vol regime
|
||||
# ======================================================
|
||||
for fast, slow, vol_w, vol_thr, hold in [
|
||||
(8, 21, 48, 0.8, 12),
|
||||
(5, 13, 24, 0.8, 6),
|
||||
(13, 34, 72, 0.7, 24),
|
||||
(8, 21, 48, 0.9, 8),
|
||||
]:
|
||||
name = f"MomReg_f{fast}s{slow}_h{hold}"
|
||||
ema_f = ema(c1h, fast)
|
||||
ema_s = ema(c1h, slow)
|
||||
|
||||
rv_short = np.full(n1h, np.nan)
|
||||
rv_long = np.full(n1h, np.nan)
|
||||
lr = np.diff(np.log(np.where(c1h == 0, 1e-10, c1h)))
|
||||
for idx in range(vol_w, len(lr)):
|
||||
rv_short[idx + 1] = np.std(lr[idx - min(12, vol_w) : idx])
|
||||
rv_long[idx + 1] = np.std(lr[idx - vol_w : idx])
|
||||
|
||||
capital = float(INITIAL)
|
||||
trades_list = []
|
||||
daily_done = set()
|
||||
|
||||
for i in range(max(60, slow + 1), n1h - hold):
|
||||
if np.isnan(ema_f[i]) or np.isnan(ema_s[i]) or np.isnan(rv_short[i]) or np.isnan(rv_long[i]):
|
||||
continue
|
||||
if rv_long[i] <= 0:
|
||||
continue
|
||||
|
||||
day = ts1h.iloc[i].strftime("%Y-%m-%d")
|
||||
if day in daily_done:
|
||||
continue
|
||||
|
||||
# Only trade in low-vol regime
|
||||
vol_ratio = rv_short[i] / rv_long[i]
|
||||
if vol_ratio > vol_thr:
|
||||
continue
|
||||
|
||||
# EMA crossover signal
|
||||
cross_up = ema_f[i] > ema_s[i] and ema_f[i - 1] <= ema_s[i - 1]
|
||||
cross_down = ema_f[i] < ema_s[i] and ema_f[i - 1] >= ema_s[i - 1]
|
||||
|
||||
if not (cross_up or cross_down):
|
||||
continue
|
||||
|
||||
direction = "long" if cross_up else "short"
|
||||
entry = c1h[i]
|
||||
exit_price = c1h[min(i + hold, n1h - 1)]
|
||||
trade_ret = ((exit_price - entry) / entry if direction == "long" else (entry - exit_price) / entry)
|
||||
net = trade_ret * LEVERAGE - FEE_RT * LEVERAGE
|
||||
capital += capital * 0.15 * net
|
||||
capital = max(capital, 10)
|
||||
trades_list.append({"year": ts1h.iloc[i].year, "win": trade_ret > 0})
|
||||
daily_done.add(day)
|
||||
|
||||
if len(trades_list) > 30:
|
||||
acc = sum(1 for t in trades_list if t["win"]) / len(trades_list) * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
results[name] = {"acc": acc, "ret": ret, "trades": len(trades_list), "capital": capital}
|
||||
|
||||
# ======================================================
|
||||
# STRAT 4: Multi-TF confirmation (15m entry, 1h trend)
|
||||
# ======================================================
|
||||
c15 = df_15m["close"].values
|
||||
h15 = df_15m["high"].values
|
||||
l15 = df_15m["low"].values
|
||||
ts15 = df_15m["timestamp"].values
|
||||
n15 = len(c15)
|
||||
|
||||
ema_1h_50 = ema(c1h, 50)
|
||||
rsi_15m = rsi(c15, 14)
|
||||
|
||||
capital = float(INITIAL)
|
||||
trades_list = []
|
||||
daily_done = set()
|
||||
|
||||
for i in range(100, n15 - 12):
|
||||
ts_dt = pd.Timestamp(ts15[i], unit="ms", tz="UTC")
|
||||
day = ts_dt.strftime("%Y-%m-%d")
|
||||
if day in daily_done:
|
||||
continue
|
||||
|
||||
# 15m signal: RSI extreme
|
||||
if rsi_15m[i] > 35 and rsi_15m[i] < 65:
|
||||
continue
|
||||
|
||||
# Find matching 1h candle
|
||||
h_idx = np.searchsorted(ts1h.values.astype("int64"), ts15[i]) - 1
|
||||
if h_idx < 50 or h_idx >= n1h or np.isnan(ema_1h_50[h_idx]):
|
||||
continue
|
||||
|
||||
# 1h trend confirmation
|
||||
trend_up = c1h[h_idx] > ema_1h_50[h_idx]
|
||||
trend_down = c1h[h_idx] < ema_1h_50[h_idx]
|
||||
|
||||
direction = None
|
||||
if rsi_15m[i] < 30 and trend_up:
|
||||
direction = "long" # oversold in uptrend
|
||||
elif rsi_15m[i] > 70 and trend_down:
|
||||
direction = "short" # overbought in downtrend
|
||||
|
||||
if direction is None:
|
||||
continue
|
||||
|
||||
entry = c15[i]
|
||||
hold_bars = 12 # 12 × 15m = 3h
|
||||
exit_price = c15[min(i + hold_bars, n15 - 1)]
|
||||
trade_ret = ((exit_price - entry) / entry if direction == "long" else (entry - exit_price) / entry)
|
||||
net = trade_ret * LEVERAGE - FEE_RT * LEVERAGE
|
||||
capital += capital * 0.12 * net
|
||||
capital = max(capital, 10)
|
||||
trades_list.append({"year": ts_dt.year, "win": trade_ret > 0})
|
||||
daily_done.add(day)
|
||||
|
||||
if len(trades_list) > 30:
|
||||
acc = sum(1 for t in trades_list if t["win"]) / len(trades_list) * 100
|
||||
ret = (capital - INITIAL) / INITIAL * 100
|
||||
results["MultiTF_15m1h"] = {"acc": acc, "ret": ret, "trades": len(trades_list), "capital": capital}
|
||||
|
||||
# === PRINT RESULTS ===
|
||||
print(f"\n {'Strategy':<25s} {'Trades':>7s} {'Acc':>6s} {'Return':>10s} {'Capital':>10s}")
|
||||
print(f" {'-'*60}")
|
||||
for name, r in sorted(results.items(), key=lambda x: x[1]["acc"], reverse=True):
|
||||
tag = "✅" if r["acc"] >= 60 and r["ret"] > 30 else ""
|
||||
print(f" {name:<25s} {r['trades']:>7d} {r['acc']:>5.1f}% {r['ret']:>+9.1f}% €{r['capital']:>9,.0f} {tag}")
|
||||
|
||||
|
||||
for asset in ["ETH", "BTC"]:
|
||||
run_all_perpetual(asset)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Client HTTP per Cerbero MCP — Deribit testnet."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
BASE_URL = "https://cerbero-mcp.tielogic.xyz"
|
||||
TOKEN = "_hm0FkyC67P9OXJTy7R9SE2lfhGz_Wa6i89KqH_uXrk"
|
||||
BOT_TAG = "pythagoras-paper"
|
||||
TIMEOUT = 15
|
||||
|
||||
|
||||
@dataclass
|
||||
class CerberoClient:
|
||||
base_url: str = BASE_URL
|
||||
token: str = TOKEN
|
||||
bot_tag: str = BOT_TAG
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"X-Bot-Tag": self.bot_tag,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _post(self, path: str, payload: dict | None = None) -> dict:
|
||||
resp = requests.post(
|
||||
f"{self.base_url}{path}",
|
||||
headers=self._headers(),
|
||||
json=payload or {},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# --- Market data ---
|
||||
|
||||
def get_ticker(self, instrument: str = "ETH-PERPETUAL") -> dict:
|
||||
return self._post("/mcp-deribit/tools/get_ticker", {"instrument": instrument})
|
||||
|
||||
def get_historical(self, instrument: str, start_date: str, end_date: str, resolution: str = "15") -> list[dict]:
|
||||
data = self._post("/mcp-deribit/tools/get_historical", {
|
||||
"instrument": instrument,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"resolution": resolution,
|
||||
})
|
||||
return data.get("candles", [])
|
||||
|
||||
# --- Account ---
|
||||
|
||||
def get_account_summary(self, currency: str = "USDC") -> dict:
|
||||
return self._post("/mcp-deribit/tools/get_account_summary", {"currency": currency})
|
||||
|
||||
def get_positions(self, currency: str = "ETH") -> list[dict]:
|
||||
return self._post("/mcp-deribit/tools/get_positions", {"currency": currency})
|
||||
|
||||
# --- Trading ---
|
||||
|
||||
def place_order(
|
||||
self,
|
||||
instrument: str,
|
||||
side: str,
|
||||
amount: float,
|
||||
order_type: str = "market",
|
||||
price: float | None = None,
|
||||
leverage: int | None = 3,
|
||||
label: str | None = None,
|
||||
) -> dict:
|
||||
payload: dict[str, Any] = {
|
||||
"instrument_name": instrument,
|
||||
"side": side,
|
||||
"amount": amount,
|
||||
"type": order_type,
|
||||
}
|
||||
if price is not None:
|
||||
payload["price"] = price
|
||||
if leverage is not None:
|
||||
payload["leverage"] = leverage
|
||||
if label:
|
||||
payload["label"] = label
|
||||
return self._post("/mcp-deribit/tools/place_order", payload)
|
||||
|
||||
def close_position(self, instrument: str) -> dict:
|
||||
return self._post("/mcp-deribit/tools/close_position", {"instrument_name": instrument})
|
||||
|
||||
def set_stop_loss(self, order_id: str, stop_price: float) -> dict:
|
||||
return self._post("/mcp-deribit/tools/set_stop_loss", {"order_id": order_id, "stop_price": stop_price})
|
||||
|
||||
def set_take_profit(self, order_id: str, tp_price: float) -> dict:
|
||||
return self._post("/mcp-deribit/tools/set_take_profit", {"order_id": order_id, "tp_price": tp_price})
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Paper trader: loop principale che monitora, segnala e opera su Deribit testnet."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from src.live.cerbero_client import CerberoClient
|
||||
from src.live.signal_engine import SignalEngine
|
||||
from src.live.telegram_notifier import notify_event
|
||||
|
||||
LOG_DIR = Path(__file__).resolve().parents[2] / "data" / "paper_trades"
|
||||
INSTRUMENT = "ETH_USDC-PERPETUAL"
|
||||
TRAIN_INSTRUMENT = "ETH-PERPETUAL"
|
||||
CURRENCY = "USDC"
|
||||
RESOLUTION = "15"
|
||||
LEVERAGE = 3
|
||||
POSITION_PCT = 0.15
|
||||
HOLD_BARS = 3
|
||||
POLL_SECONDS = 60
|
||||
LOOKBACK_DAYS = 60
|
||||
TRAIN_LOOKBACK_DAYS = 365
|
||||
VIRTUAL_CAPITAL = 1000.0 # simula capitale reale, ignora balance testnet
|
||||
|
||||
|
||||
class PaperTrader:
|
||||
def __init__(self):
|
||||
self.client = CerberoClient()
|
||||
self.engine = SignalEngine(bb_w=14, sq_thr=0.8, ml_thr=0.70)
|
||||
|
||||
self.virtual_capital = VIRTUAL_CAPITAL
|
||||
self.in_position = False
|
||||
self.position_entry_time: datetime | None = None
|
||||
self.position_direction: str | None = None
|
||||
self.position_entry_price: float = 0
|
||||
self.position_size: float = 0
|
||||
self.bars_held = 0
|
||||
self.last_bar_ts: int = 0
|
||||
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
self.log_path = LOG_DIR / f"trades_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl"
|
||||
self.status_path = LOG_DIR / "status.json"
|
||||
|
||||
def log(self, event: str, data: dict | None = None):
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"event": event,
|
||||
**(data or {}),
|
||||
}
|
||||
with open(self.log_path, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
print(f" [{entry['timestamp'][:19]}] {event}: {json.dumps(data or {})}")
|
||||
notify_event(event, data)
|
||||
|
||||
def save_status(self):
|
||||
status = {
|
||||
"virtual_capital": round(self.virtual_capital, 2),
|
||||
"in_position": self.in_position,
|
||||
"direction": self.position_direction,
|
||||
"entry_price": self.position_entry_price,
|
||||
"position_size": self.position_size,
|
||||
"entry_time": self.position_entry_time.isoformat() if self.position_entry_time else None,
|
||||
"bars_held": self.bars_held,
|
||||
"last_update": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
with open(self.status_path, "w") as f:
|
||||
json.dump(status, f, indent=2)
|
||||
|
||||
def fetch_candles(self, days: int = LOOKBACK_DAYS, instrument: str | None = None) -> pd.DataFrame:
|
||||
end = datetime.now(timezone.utc)
|
||||
start = end - timedelta(days=days)
|
||||
candles = self.client.get_historical(
|
||||
instrument or TRAIN_INSTRUMENT,
|
||||
start.strftime("%Y-%m-%d"),
|
||||
end.strftime("%Y-%m-%d"),
|
||||
RESOLUTION,
|
||||
)
|
||||
if not candles:
|
||||
return pd.DataFrame()
|
||||
df = pd.DataFrame(candles)
|
||||
df["timestamp"] = df["timestamp"].astype("int64")
|
||||
df = df.sort_values("timestamp").reset_index(drop=True)
|
||||
return df
|
||||
|
||||
def train_model(self):
|
||||
self.log("TRAINING", {"lookback_days": TRAIN_LOOKBACK_DAYS, "instrument": TRAIN_INSTRUMENT})
|
||||
df = self.fetch_candles(TRAIN_LOOKBACK_DAYS, TRAIN_INSTRUMENT)
|
||||
if df.empty:
|
||||
self.log("TRAINING_FAILED", {"reason": "no data"})
|
||||
return False
|
||||
result = self.engine.train(df, lookahead=HOLD_BARS)
|
||||
self.log("TRAINING_DONE", result)
|
||||
return "error" not in result
|
||||
|
||||
def open_position(self, direction: str, signal: dict):
|
||||
ticker = self.client.get_ticker(INSTRUMENT)
|
||||
price = ticker["last_price"]
|
||||
|
||||
notional = self.virtual_capital * POSITION_PCT * LEVERAGE
|
||||
amount = round(notional / price, 3)
|
||||
amount = max(amount, 0.001)
|
||||
|
||||
side = "buy" if direction == "buy" else "sell"
|
||||
|
||||
self.log("OPENING", {
|
||||
"side": side,
|
||||
"amount": amount,
|
||||
"price": price,
|
||||
"virtual_capital": round(self.virtual_capital, 2),
|
||||
"notional": round(notional, 2),
|
||||
"signal": signal,
|
||||
})
|
||||
|
||||
try:
|
||||
result = self.client.place_order(
|
||||
instrument=INSTRUMENT,
|
||||
side=side,
|
||||
amount=amount,
|
||||
order_type="market",
|
||||
leverage=LEVERAGE,
|
||||
label="pythagoras-squeeze",
|
||||
)
|
||||
self.in_position = True
|
||||
self.position_direction = side
|
||||
self.position_entry_price = price
|
||||
self.position_size = amount
|
||||
self.position_entry_time = datetime.now(timezone.utc)
|
||||
self.bars_held = 0
|
||||
self.log("OPENED", {"order_result": result})
|
||||
except Exception as e:
|
||||
self.log("OPEN_FAILED", {"error": str(e)})
|
||||
|
||||
def close_current_position(self, reason: str):
|
||||
if not self.in_position:
|
||||
return
|
||||
|
||||
ticker = self.client.get_ticker(INSTRUMENT)
|
||||
exit_price = ticker["last_price"]
|
||||
|
||||
if self.position_direction == "buy":
|
||||
trade_pnl = (exit_price - self.position_entry_price) * self.position_size
|
||||
else:
|
||||
trade_pnl = (self.position_entry_price - exit_price) * self.position_size
|
||||
|
||||
fee = self.position_size * (self.position_entry_price + exit_price) * 0.001
|
||||
net_pnl = trade_pnl - fee
|
||||
pnl_pct = net_pnl / self.virtual_capital * 100
|
||||
|
||||
self.log("CLOSING", {
|
||||
"reason": reason,
|
||||
"entry_price": self.position_entry_price,
|
||||
"exit_price": exit_price,
|
||||
"size": self.position_size,
|
||||
"trade_pnl": round(trade_pnl, 2),
|
||||
"fee": round(fee, 2),
|
||||
"net_pnl": round(net_pnl, 2),
|
||||
"pnl_pct": round(pnl_pct, 3),
|
||||
"bars_held": self.bars_held,
|
||||
"capital_before": round(self.virtual_capital, 2),
|
||||
})
|
||||
|
||||
try:
|
||||
result = self.client.close_position(INSTRUMENT)
|
||||
self.virtual_capital += net_pnl
|
||||
self.log("CLOSED", {
|
||||
"result": result,
|
||||
"net_pnl": round(net_pnl, 2),
|
||||
"pnl_pct": round(pnl_pct, 3),
|
||||
"virtual_capital": round(self.virtual_capital, 2),
|
||||
})
|
||||
except Exception as e:
|
||||
self.log("CLOSE_FAILED", {"error": str(e)})
|
||||
|
||||
self.in_position = False
|
||||
self.position_direction = None
|
||||
self.position_entry_price = 0
|
||||
self.position_size = 0
|
||||
self.position_entry_time = None
|
||||
self.bars_held = 0
|
||||
|
||||
def check_position_exit(self, df: pd.DataFrame):
|
||||
if not self.in_position:
|
||||
return
|
||||
|
||||
current_ts = df["timestamp"].iloc[-1]
|
||||
if current_ts > self.last_bar_ts:
|
||||
self.bars_held += 1
|
||||
self.last_bar_ts = current_ts
|
||||
|
||||
if self.bars_held >= HOLD_BARS:
|
||||
self.close_current_position("hold_limit")
|
||||
return
|
||||
|
||||
price = df["close"].iloc[-1]
|
||||
if self.position_direction == "buy":
|
||||
pnl_pct = (price - self.position_entry_price) / self.position_entry_price
|
||||
else:
|
||||
pnl_pct = (self.position_entry_price - price) / self.position_entry_price
|
||||
|
||||
if pnl_pct <= -0.02:
|
||||
self.close_current_position("stop_loss_2pct")
|
||||
|
||||
def run_once(self) -> str:
|
||||
"""Esegui un singolo ciclo. Ritorna lo stato."""
|
||||
df = self.fetch_candles(LOOKBACK_DAYS, TRAIN_INSTRUMENT)
|
||||
if df.empty:
|
||||
return "no_data"
|
||||
|
||||
if self.in_position:
|
||||
self.check_position_exit(df)
|
||||
self.save_status()
|
||||
if self.in_position:
|
||||
return f"in_position_{self.position_direction}_bar{self.bars_held}"
|
||||
return "position_closed"
|
||||
|
||||
signal = self.engine.check_signal(df)
|
||||
if signal:
|
||||
self.log("SIGNAL", signal)
|
||||
self.open_position(signal["direction"], signal)
|
||||
self.save_status()
|
||||
return f"signal_{signal['direction']}"
|
||||
|
||||
self.save_status()
|
||||
return "watching"
|
||||
|
||||
def run(self, retrain_hours: int = 24):
|
||||
"""Loop principale."""
|
||||
print("=" * 60)
|
||||
print(f" PAPER TRADER — {INSTRUMENT} (margine {CURRENCY})")
|
||||
print(f" Segnali da: {TRAIN_INSTRUMENT} {RESOLUTION}m")
|
||||
print(f" Leva: {LEVERAGE}x, Position: {POSITION_PCT*100:.0f}%, Hold: {HOLD_BARS} barre")
|
||||
print(f" Poll: ogni {POLL_SECONDS}s")
|
||||
print(f" Log: {self.log_path}")
|
||||
print("=" * 60)
|
||||
|
||||
account = self.client.get_account_summary()
|
||||
self.log("STARTUP", {
|
||||
"virtual_capital": self.virtual_capital,
|
||||
"testnet_equity": account["equity"],
|
||||
"testnet": account.get("testnet", True),
|
||||
})
|
||||
|
||||
if not self.train_model():
|
||||
print("Training fallito. Uscita.")
|
||||
return
|
||||
|
||||
last_train = datetime.now(timezone.utc)
|
||||
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
if (now - last_train).total_seconds() > retrain_hours * 3600:
|
||||
self.train_model()
|
||||
last_train = now
|
||||
|
||||
status = self.run_once()
|
||||
if status != "watching":
|
||||
print(f" → {status}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.log("SHUTDOWN", {"reason": "keyboard"})
|
||||
if self.in_position:
|
||||
self.close_current_position("shutdown")
|
||||
break
|
||||
except Exception as e:
|
||||
self.log("ERROR", {"error": str(e)})
|
||||
print(f" ERRORE: {e}")
|
||||
|
||||
time.sleep(POLL_SECONDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
trader = PaperTrader()
|
||||
trader.run()
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Motore segnali: squeeze detection + ML confirmation su dati live."""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.ensemble import GradientBoostingClassifier
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
|
||||
def keltner_ratio(close: np.ndarray, high: np.ndarray, low: np.ndarray, window: int = 14) -> np.ndarray:
|
||||
n = len(close)
|
||||
result = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc = close[i - window : i]
|
||||
wh = high[i - window : i]
|
||||
wl = low[i - window : i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(wh - wl, np.maximum(np.abs(wh - np.roll(wc, 1)), np.abs(wl - np.roll(wc, 1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc_r = (ma + 1.5 * atr) - (ma - 1.5 * atr)
|
||||
bb_r = (ma + 2 * bb_std) - (ma - 2 * bb_std)
|
||||
if kc_r > 0:
|
||||
result[i] = bb_r / kc_r
|
||||
return result
|
||||
|
||||
|
||||
def build_features(df: pd.DataFrame, i: int, squeeze_duration: int, squeeze_avg_vol: float, kcr_val: float) -> np.ndarray | None:
|
||||
if i < 100 or i >= len(df):
|
||||
return None
|
||||
|
||||
o = df["open"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
c = df["close"].values
|
||||
v = df["volume"].values
|
||||
|
||||
feats = []
|
||||
for w in [12, 24, 48]:
|
||||
if i < w:
|
||||
feats.extend([0] * 12)
|
||||
continue
|
||||
|
||||
win_c = c[i - w : i]
|
||||
win_o = o[i - w : i]
|
||||
win_h = h[i - w : i]
|
||||
win_l = l[i - w : i]
|
||||
win_v = v[i - w : i]
|
||||
|
||||
mn, mx = win_l.min(), max(win_h.max(), win_c.max())
|
||||
rng = mx - mn if mx - mn > 0 else 1e-10
|
||||
total = win_h - win_l
|
||||
total = np.where(total == 0, 1e-10, total)
|
||||
body = np.abs(win_c - win_o) / total
|
||||
direction = np.sign(win_c - win_o)
|
||||
log_c = np.log(np.where(win_c == 0, 1e-10, win_c))
|
||||
rets = np.diff(log_c)
|
||||
v_mean = np.mean(win_v)
|
||||
|
||||
feats.extend([
|
||||
np.mean(rets) if len(rets) > 0 else 0,
|
||||
np.std(rets) if len(rets) > 0 else 0,
|
||||
np.sum(rets) if len(rets) > 0 else 0,
|
||||
float(pd.Series(rets).skew()) if len(rets) > 2 else 0,
|
||||
float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0,
|
||||
np.mean(body),
|
||||
np.std(body),
|
||||
np.mean(direction),
|
||||
np.mean(direction[-min(3, w):]),
|
||||
(win_c[-1] - mn) / rng,
|
||||
win_v[-1] / v_mean if v_mean > 0 else 1,
|
||||
np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0,
|
||||
])
|
||||
|
||||
feats.extend([
|
||||
squeeze_duration,
|
||||
squeeze_duration / (24 * 4),
|
||||
kcr_val,
|
||||
v[i - 1] / squeeze_avg_vol if squeeze_avg_vol > 0 else 1,
|
||||
np.mean(v[max(0, i - 3) : i]) / squeeze_avg_vol if squeeze_avg_vol > 0 else 1,
|
||||
])
|
||||
|
||||
h48 = np.max(h[max(0, i - 48) : i])
|
||||
l48 = np.min(l[max(0, i - 48) : i])
|
||||
r48 = h48 - l48
|
||||
feats.append((c[i - 1] - l48) / r48 if r48 > 0 else 0.5)
|
||||
|
||||
tr = np.maximum(h[i - 14 : i] - l[i - 14 : i],
|
||||
np.maximum(np.abs(h[i - 14 : i] - np.roll(c[i - 14 : i], 1)),
|
||||
np.abs(l[i - 14 : i] - np.roll(c[i - 14 : i], 1))))
|
||||
atr = np.mean(tr[1:])
|
||||
feats.append(atr / c[i - 1] if c[i - 1] > 0 else 0)
|
||||
|
||||
first_ret = (c[i - 1] - c[i - 2]) / c[i - 2] if i >= 2 and c[i - 2] > 0 else 0
|
||||
feats.append(first_ret)
|
||||
|
||||
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
|
||||
|
||||
|
||||
class SignalEngine:
|
||||
"""Rileva squeeze e genera segnali ML in real-time."""
|
||||
|
||||
def __init__(self, bb_w: int = 14, sq_thr: float = 0.8, ml_thr: float = 0.70, min_squeeze_bars: int = 5):
|
||||
self.bb_w = bb_w
|
||||
self.sq_thr = sq_thr
|
||||
self.ml_thr = ml_thr
|
||||
self.min_squeeze_bars = min_squeeze_bars
|
||||
|
||||
self.model: GradientBoostingClassifier | None = None
|
||||
self.scaler: StandardScaler | None = None
|
||||
self.in_squeeze = False
|
||||
self.squeeze_start_idx = 0
|
||||
self.trained = False
|
||||
|
||||
def train(self, df: pd.DataFrame, lookahead: int = 3) -> dict:
|
||||
"""Addestra il modello su dati storici."""
|
||||
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, self.bb_w)
|
||||
|
||||
X_all, y_all = [], []
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
|
||||
for i in range(self.bb_w + 1, n - lookahead):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < self.sq_thr
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
duration = i - sq_start
|
||||
if duration < self.min_squeeze_bars:
|
||||
continue
|
||||
|
||||
avg_vol = np.mean(volume[sq_start:i])
|
||||
feats = build_features(df, i, duration, avg_vol, kcr[i])
|
||||
if feats is None:
|
||||
continue
|
||||
|
||||
actual = (close[i + lookahead - 1] - close[i - 1]) / close[i - 1]
|
||||
X_all.append(feats)
|
||||
y_all.append(1 if actual > 0 else 0)
|
||||
|
||||
if len(X_all) < 30:
|
||||
return {"error": "not enough training samples", "samples": len(X_all)}
|
||||
|
||||
X = np.array(X_all)
|
||||
y = np.array(y_all)
|
||||
|
||||
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.fit(X_s, y)
|
||||
self.trained = True
|
||||
|
||||
preds = self.model.predict(X_s)
|
||||
train_acc = np.mean(preds == y) * 100
|
||||
|
||||
return {"samples": len(X), "up_ratio": np.mean(y) * 100, "train_accuracy": train_acc}
|
||||
|
||||
def check_signal(self, df: pd.DataFrame) -> dict | None:
|
||||
"""Controlla se c'è un segnale sulle ultime candele.
|
||||
Ritorna dict con direzione e probabilità, oppure None.
|
||||
"""
|
||||
if not self.trained:
|
||||
return None
|
||||
|
||||
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, self.bb_w)
|
||||
|
||||
if n < self.bb_w + 10:
|
||||
return None
|
||||
|
||||
last_kcr = kcr[-1]
|
||||
prev_kcr = kcr[-2] if n > 1 else np.nan
|
||||
|
||||
if np.isnan(last_kcr) or np.isnan(prev_kcr):
|
||||
return None
|
||||
|
||||
was_squeeze = prev_kcr < self.sq_thr
|
||||
is_released = last_kcr >= self.sq_thr
|
||||
|
||||
if not (was_squeeze and is_released):
|
||||
self.in_squeeze = prev_kcr < self.sq_thr
|
||||
if self.in_squeeze and not hasattr(self, '_sq_start_tracking'):
|
||||
self._sq_start_tracking = n - 1
|
||||
if not self.in_squeeze:
|
||||
self._sq_start_tracking = None
|
||||
return None
|
||||
|
||||
sq_start = getattr(self, '_sq_start_tracking', n - 10)
|
||||
if sq_start is None:
|
||||
sq_start = n - 10
|
||||
duration = (n - 1) - sq_start
|
||||
if duration < self.min_squeeze_bars:
|
||||
self._sq_start_tracking = None
|
||||
return None
|
||||
|
||||
avg_vol = np.mean(volume[max(0, sq_start) : n - 1])
|
||||
feats = build_features(df, n - 1, duration, avg_vol, last_kcr)
|
||||
self._sq_start_tracking = None
|
||||
|
||||
if feats is None:
|
||||
return None
|
||||
|
||||
feats_s = self.scaler.transform(feats.reshape(1, -1))
|
||||
proba = self.model.predict_proba(feats_s)[0]
|
||||
up_idx = list(self.model.classes_).index(1)
|
||||
p_up = proba[up_idx]
|
||||
|
||||
if p_up >= self.ml_thr:
|
||||
return {"direction": "buy", "probability": p_up, "squeeze_duration": duration}
|
||||
elif p_up <= (1 - self.ml_thr):
|
||||
return {"direction": "sell", "probability": 1 - p_up, "squeeze_duration": duration}
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Notifiche Telegram per il paper trader."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import json
|
||||
|
||||
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||
|
||||
NOTIFY_EVENTS = {
|
||||
"SIGNAL", "OPENED", "CLOSED", "OPEN_FAILED", "CLOSE_FAILED",
|
||||
"ERROR", "STARTUP", "SHUTDOWN", "TRAINING_FAILED",
|
||||
}
|
||||
|
||||
|
||||
def send_telegram(text: str) -> bool:
|
||||
if not BOT_TOKEN or not CHAT_ID:
|
||||
return False
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
|
||||
data = urllib.parse.urlencode({"chat_id": CHAT_ID, "text": text, "parse_mode": "HTML"}).encode()
|
||||
urllib.request.urlopen(url, data, timeout=10)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def notify_event(event: str, data: dict | None = None):
|
||||
if event not in NOTIFY_EVENTS:
|
||||
return
|
||||
lines = [f"📊 <b>{event}</b>"]
|
||||
if data:
|
||||
for k, v in data.items():
|
||||
if k in ("signal",):
|
||||
continue
|
||||
lines.append(f" {k}: {v}")
|
||||
send_telegram("\n".join(lines))
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Strategie di trading — classe base e indicatori condivisi."""
|
||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats
|
||||
from src.strategies.indicators import (
|
||||
keltner_ratio, detect_squeezes, ema, atr, rv_annualized, rolling_correlation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Strategy", "Signal", "BacktestResult", "YearlyStats",
|
||||
"keltner_ratio", "detect_squeezes", "ema", "atr",
|
||||
"rv_annualized", "rolling_correlation",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Classe base astratta per tutte le strategie di trading."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
@dataclass
|
||||
class Signal:
|
||||
"""Segnale di trading generato da una strategia."""
|
||||
idx: int
|
||||
direction: int # +1 long, -1 short
|
||||
entry_price: float
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class YearlyStats:
|
||||
year: int
|
||||
trades: int
|
||||
wins: int
|
||||
pnl: float
|
||||
|
||||
@property
|
||||
def accuracy(self) -> float:
|
||||
return self.wins / self.trades * 100 if self.trades > 0 else 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestResult:
|
||||
"""Risultato completo di un backtest."""
|
||||
strategy_name: str
|
||||
asset: str
|
||||
timeframe: str
|
||||
params: dict
|
||||
|
||||
trades: int
|
||||
wins: int
|
||||
pnl: float
|
||||
capital: float
|
||||
initial_capital: float
|
||||
max_dd: float
|
||||
time_in_market_pct: float
|
||||
avg_trade_duration_h: float
|
||||
years_active: int
|
||||
yearly: list[YearlyStats]
|
||||
|
||||
@property
|
||||
def accuracy(self) -> float:
|
||||
return self.wins / self.trades * 100 if self.trades > 0 else 0.0
|
||||
|
||||
@property
|
||||
def sharpe(self) -> float:
|
||||
pnls = []
|
||||
for ys in self.yearly:
|
||||
pnls.append(ys.pnl)
|
||||
if len(pnls) < 2 or np.std(pnls) == 0:
|
||||
return 0.0
|
||||
return float(np.mean(pnls) / np.std(pnls) * np.sqrt(len(pnls)))
|
||||
|
||||
@property
|
||||
def daily_pnl(self) -> float:
|
||||
return self.pnl / (self.years_active * 365) if self.years_active > 0 else 0.0
|
||||
|
||||
@property
|
||||
def worst_year(self) -> YearlyStats | None:
|
||||
valid = [y for y in self.yearly if y.trades >= 10]
|
||||
if not valid:
|
||||
valid = self.yearly
|
||||
return min(valid, key=lambda y: y.accuracy) if valid else None
|
||||
|
||||
def print_summary(self):
|
||||
worst = self.worst_year
|
||||
worst_str = f"{worst.year}({worst.accuracy:.0f}%)" if worst else "N/A"
|
||||
dur = f"{self.avg_trade_duration_h:.0f}h" if self.avg_trade_duration_h >= 1 else f"{self.avg_trade_duration_h * 60:.0f}m"
|
||||
print(f" {self.strategy_name:<30s} {self.asset:>3s} {self.timeframe:>3s} "
|
||||
f"{self.trades:>5d}t {self.accuracy:>5.1f}% "
|
||||
f"€{self.pnl:>+9.0f} DD {self.max_dd:>4.1f}% "
|
||||
f"€/d {self.daily_pnl:>+6.2f} "
|
||||
f"Mkt {self.time_in_market_pct:>4.1f}% {dur:>5s} "
|
||||
f"worst={worst_str} {self.years_active}y")
|
||||
|
||||
def print_yearly(self):
|
||||
print(f"\n {self.strategy_name} [{self.asset} {self.timeframe}] — per anno:")
|
||||
print(f" {'Anno':>6s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s}")
|
||||
for ys in sorted(self.yearly, key=lambda y: y.year):
|
||||
print(f" {ys.year:>6d} {ys.trades:>7d} {ys.accuracy:>5.1f}% €{ys.pnl:>+8.0f}")
|
||||
|
||||
|
||||
TF_MINUTES = {"1m": 1, "5m": 5, "15m": 15, "1h": 60, "4h": 240, "1d": 1440}
|
||||
|
||||
|
||||
class Strategy(ABC):
|
||||
"""Classe base per tutte le strategie.
|
||||
|
||||
Sottoclassi devono implementare:
|
||||
- name, description, default_assets, default_timeframes
|
||||
- generate_signals(df, timestamps, **params) -> list[Signal]
|
||||
"""
|
||||
|
||||
name: str = "unnamed"
|
||||
description: str = ""
|
||||
default_assets: list[str] = ["BTC", "ETH"]
|
||||
default_timeframes: list[str] = ["15m", "1h"]
|
||||
|
||||
# Parametri di backtest
|
||||
fee_rt: float = 0.002
|
||||
leverage: float = 3.0
|
||||
position_size: float = 0.15
|
||||
initial_capital: float = 1000.0
|
||||
|
||||
@abstractmethod
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
"""Genera segnali di trading dal dataframe OHLCV.
|
||||
|
||||
Args:
|
||||
df: DataFrame con colonne open, high, low, close, volume, timestamp
|
||||
ts: DatetimeIndex UTC dei timestamp
|
||||
**params: parametri specifici della strategia
|
||||
|
||||
Returns:
|
||||
Lista di Signal con idx, direction, entry_price
|
||||
"""
|
||||
...
|
||||
|
||||
def backtest(self, asset: str, tf: str, hold: int = 3,
|
||||
**params) -> BacktestResult | None:
|
||||
"""Esegue backtest su un asset/timeframe."""
|
||||
df = load_data(asset, tf)
|
||||
c = df["close"].values
|
||||
n = len(c)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
sig_params = {**params, "asset": asset, "tf": tf}
|
||||
signals = self.generate_signals(df, ts, **sig_params)
|
||||
if not signals:
|
||||
return None
|
||||
|
||||
yearly: dict[int, dict] = {}
|
||||
capital = float(self.initial_capital)
|
||||
peak = capital
|
||||
max_dd = 0.0
|
||||
total_bars = 0
|
||||
|
||||
for sig in signals:
|
||||
i = sig.idx
|
||||
if i + hold >= n or i < 1:
|
||||
continue
|
||||
|
||||
entry = sig.entry_price
|
||||
exit_price = c[min(i + hold - 1, n - 1)]
|
||||
actual = (exit_price - entry) / entry * sig.direction
|
||||
net = actual * self.leverage - self.fee_rt * self.leverage
|
||||
|
||||
capital += capital * self.position_size * net
|
||||
capital = max(capital, 10)
|
||||
if capital > peak:
|
||||
peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
total_bars += hold
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0:
|
||||
yearly[year]["w"] += 1
|
||||
yearly[year]["pnl"] += net * self.initial_capital
|
||||
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t == 0:
|
||||
return None
|
||||
|
||||
yearly_stats = [
|
||||
YearlyStats(year=y, trades=d["t"], wins=d["w"], pnl=d["pnl"])
|
||||
for y, d in sorted(yearly.items())
|
||||
]
|
||||
|
||||
return BacktestResult(
|
||||
strategy_name=self.name,
|
||||
asset=asset,
|
||||
timeframe=tf,
|
||||
params=params,
|
||||
trades=all_t,
|
||||
wins=all_w,
|
||||
pnl=sum(d["pnl"] for d in yearly.values()),
|
||||
capital=capital,
|
||||
initial_capital=self.initial_capital,
|
||||
max_dd=max_dd * 100,
|
||||
time_in_market_pct=total_bars / n * 100,
|
||||
avg_trade_duration_h=hold * TF_MINUTES.get(tf, 60) / 60,
|
||||
years_active=len(yearly),
|
||||
yearly=yearly_stats,
|
||||
)
|
||||
|
||||
def run_all(self, assets: list[str] | None = None,
|
||||
timeframes: list[str] | None = None,
|
||||
hold: int = 3, **params) -> list[BacktestResult]:
|
||||
"""Esegue backtest su tutte le combinazioni asset/timeframe."""
|
||||
assets = assets or self.default_assets
|
||||
timeframes = timeframes or self.default_timeframes
|
||||
results = []
|
||||
for asset in assets:
|
||||
for tf in timeframes:
|
||||
r = self.backtest(asset, tf, hold=hold, **params)
|
||||
if r and r.trades >= 20:
|
||||
results.append(r)
|
||||
results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
return results
|
||||
|
||||
def report(self, results: list[BacktestResult] | None = None,
|
||||
assets: list[str] | None = None,
|
||||
timeframes: list[str] | None = None,
|
||||
hold: int = 3, **params):
|
||||
"""Esegue e stampa report completo."""
|
||||
if results is None:
|
||||
results = self.run_all(assets, timeframes, hold, **params)
|
||||
|
||||
print(f"\n{'=' * 120}")
|
||||
print(f" {self.name} — {self.description}")
|
||||
print(f" Fee: {self.fee_rt*100:.1f}% RT | Leva: {self.leverage:.0f}x | Pos: {self.position_size*100:.0f}%")
|
||||
print(f"{'=' * 120}")
|
||||
print(f" {'Nome':<30s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
|
||||
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
|
||||
f"{'Mkt%':>5s} {'Dur':>5s} {'Worst':>12s} {'Anni':>4s}")
|
||||
print(f" {'─' * 110}")
|
||||
|
||||
for r in results:
|
||||
r.print_summary()
|
||||
|
||||
if results:
|
||||
best = results[0]
|
||||
best.print_yearly()
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Indicatori tecnici condivisi tra tutte le strategie."""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def keltner_ratio(close: np.ndarray, high: np.ndarray, low: np.ndarray,
|
||||
window: int = 14) -> np.ndarray:
|
||||
"""Rapporto Bollinger / Keltner. Sotto 1 = squeeze (BB dentro KC)."""
|
||||
n = len(close)
|
||||
r = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc = close[i - window:i]
|
||||
wh = high[i - window:i]
|
||||
wl = low[i - window:i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(
|
||||
wh - wl,
|
||||
np.maximum(np.abs(wh - np.roll(wc, 1)), np.abs(wl - np.roll(wc, 1))),
|
||||
)
|
||||
atr = np.mean(tr[1:])
|
||||
kc = (ma + 1.5 * atr) - (ma - 1.5 * atr)
|
||||
bb = (ma + 2 * bb_std) - (ma - 2 * bb_std)
|
||||
if kc > 0:
|
||||
r[i] = bb / kc
|
||||
return r
|
||||
|
||||
|
||||
def detect_squeezes(close: np.ndarray, high: np.ndarray, low: np.ndarray,
|
||||
kcr: np.ndarray, sq_thr: float = 0.8,
|
||||
min_dur: int = 5) -> list[dict]:
|
||||
"""Rileva squeeze events: periodi dove BB sta dentro KC."""
|
||||
events: list[dict] = []
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
for i in range(1, len(close)):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < sq_thr
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
dur = i - sq_start
|
||||
if dur < min_dur:
|
||||
continue
|
||||
events.append({
|
||||
"idx": i, "dur": dur, "sq_start": sq_start,
|
||||
"kcr_at_release": kcr[i],
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
def ema(arr: np.ndarray, period: int) -> np.ndarray:
|
||||
"""Exponential Moving Average."""
|
||||
r = np.full(len(arr), np.nan)
|
||||
k = 2 / (period + 1)
|
||||
r[period - 1] = np.mean(arr[:period])
|
||||
for i in range(period, len(arr)):
|
||||
r[i] = arr[i] * k + r[i - 1] * (1 - k)
|
||||
return r
|
||||
|
||||
|
||||
def atr(high: np.ndarray, low: np.ndarray, close: np.ndarray,
|
||||
period: int = 14) -> np.ndarray:
|
||||
"""Average True Range (EMA-smoothed)."""
|
||||
tr = np.maximum(
|
||||
high - low,
|
||||
np.maximum(np.abs(high - np.roll(close, 1)), np.abs(low - np.roll(close, 1))),
|
||||
)
|
||||
tr[0] = high[0] - low[0]
|
||||
r = np.full(len(close), np.nan)
|
||||
r[period - 1] = np.mean(tr[:period])
|
||||
k = 2 / (period + 1)
|
||||
for i in range(period, len(close)):
|
||||
r[i] = tr[i] * k + r[i - 1] * (1 - k)
|
||||
return r
|
||||
|
||||
|
||||
def rv_annualized(close: np.ndarray, window: int) -> np.ndarray:
|
||||
"""Realized volatility annualizzata (hourly data assumed)."""
|
||||
lr = np.diff(np.log(np.where(close == 0, 1e-10, close)))
|
||||
r = np.full(len(close), np.nan)
|
||||
for i in range(window, len(lr)):
|
||||
r[i + 1] = np.std(lr[i - window:i]) * np.sqrt(24 * 365)
|
||||
return r
|
||||
|
||||
|
||||
def rolling_correlation(close_a: np.ndarray, close_b: np.ndarray,
|
||||
window: int = 48) -> np.ndarray:
|
||||
"""Correlazione rolling tra rendimenti logaritmici di due asset."""
|
||||
n = max(len(close_a), len(close_b))
|
||||
ret_a = np.diff(np.log(np.where(close_a == 0, 1e-10, close_a)))
|
||||
ret_b = np.diff(np.log(np.where(close_b[:len(close_a)] == 0, 1e-10, close_b[:len(close_a)])))
|
||||
min_len = min(len(ret_a), len(ret_b))
|
||||
corr = np.full(n, np.nan)
|
||||
for i in range(window, min_len):
|
||||
cv = np.corrcoef(ret_a[i - window:i], ret_b[i - window:i])[0, 1]
|
||||
corr[i + 1] = cv if np.isfinite(cv) else 0
|
||||
return corr
|
||||
Reference in New Issue
Block a user