12 Commits

Author SHA1 Message Date
Adriano d39c75b103 feat(strategy4): PD01 82.5%/DD2.9%, AD01 81.2%, CM01 81.9% — tutte battono SQ02
Nuove strategie che battono SQ02 (79.7% acc, DD 6.5%):
- PD01 price-volume divergence: 82.5% acc, DD 2.9%, worst year 80%
- CM01 cross-market momentum: 81.9% acc, DD 2.7%
- AD01 adaptive squeeze threshold: 81.2% acc, DD 3.4%
- MT01 (già committato): 82.7% acc, DD 5.9%

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:20:44 +02:00
Adriano 0ab3b5698a docs: confronto migliori strategie S1/S2 per anno, dati reali
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:12:47 +02:00
Adriano 7639e5012b Merge branch 'main' of ssh://git.tielogic.xyz:222/Adriano/PythagorasGoal
# Conflicts:
#	uv.lock
2026-05-27 11:09:52 +02:00
Adriano Dal Pastro 2694a4a00c feat: notifiche Telegram dal paper trader via bot
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:52:11 +00:00
Adriano Dal Pastro a7b3c3c203 infra: add uv.lock per build Docker riproducibili
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:19:15 +00:00
74 changed files with 5704 additions and 89 deletions
+46 -13
View File
@@ -9,9 +9,10 @@ Progetto di ricerca: riconoscimento pattern frattali per trading algoritmico su
- **Linguaggio:** Python 3.11+
- **Package manager:** uv (dipendenze in `pyproject.toml`, lock in `uv.lock`)
- **Dati:** Parquet in `data/raw/` (non committati, ~70 MB)
- **ML:** scikit-learn (GradientBoosting), PyTorch (LSTM)
- **ML:** scikit-learn (GradientBoostingClassifier)
- **Analisi:** numpy, pandas, scipy
- **API dati:** Cerbero MCP su `cerbero-mcp.tielogic.xyz` (Deribit, Bybit, Hyperliquid), ccxt/Binance come fallback
- **Config:** pyyaml per `strategies.yml`
## Struttura
@@ -19,10 +20,23 @@ Progetto di ricerca: riconoscimento pattern frattali per trading algoritmico su
src/data/ → download e caricamento dati (downloader.py)
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
src/backtest/ → engine di backtesting (engine.py)
scripts/ → analisi e strategie numerate 0113
src/strategies/ → classe base Strategy ABC + indicatori condivisi
base.py → Strategy, Signal, BacktestResult, YearlyStats
indicators.py → keltner_ratio, detect_squeezes, ema, atr, rv, correlation
src/live/ → paper trading live multi-strategia
multi_runner.py → orchestratore: carica YAML, fetch candele, tick worker
strategy_worker.py → worker indipendente: capital, trade log, stato persistente
strategy_loader.py → import dinamico classi Strategy da scripts/strategies/
cerbero_client.py → client HTTP per Cerbero MCP (Deribit testnet)
signal_engine.py → squeeze + ML real-time (per ML01)
telegram_notifier.py → notifiche Telegram per trade
scripts/strategies/ → strategie attive (SQ01-SQ04, ML01)
scripts/waste/ → strategie scartate (W01-W22 + REF originali)
scripts/analysis/ → script di confronto e report
strategies.yml → config multi-strategy paper trader
docs/diary/ → diario di ricerca giornaliero
docs/specs/ → specifiche di design
data/raw/ → file .parquet OHLCV (gitignored)
data/processed/ → modelli salvati (gitignored)
```
## Comandi
@@ -30,7 +44,10 @@ data/processed/ → modelli salvati (gitignored)
```bash
uv sync # installa dipendenze
uv run python -m src.data.downloader # scarica dati storici
uv run python scripts/13_squeeze_ml_hybrid.py # strategia vincente
uv run python scripts/strategies/SQ02_squeeze_antifake_vol.py # miglior strategia robusta
uv run python scripts/strategies/ML01_squeeze_gbm.py # squeeze + ML (GBM)
uv run python -m src.live.multi_runner # paper trading live multi-strategia
docker compose up -d # deploy Docker
uv run pytest # test
```
@@ -47,22 +64,37 @@ df = load_data("ETH", "15m") # carica un asset/timeframe
Fonte primaria: Cerbero MCP (endpoint `/mcp-deribit/tools/get_historical`).
Token observer: nel file `secrets/observer.token` del progetto CerberoSuite.
## Strategia vincente
## Strategie attive
**Squeeze + ML ibrida** (script 13):
Tutte le strategie estendono `src.strategies.base.Strategy` con interfaccia comune:
`generate_signals() → backtest() → report()`.
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%
| 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 | Filtered | Regole | 79.2% | Filtri selezionabili (9 preset) |
| SQ04 | Ultimate | Regole | 81.6% | Max accuracy ma concentrato 2018 |
| ML01 | Squeeze+GBM | ML | 78.8% | Walk-forward, €8-12/day, DD basso |
Configurazione migliore: ETH 15m, BBw=14, squeeze threshold=0.8, breakout=3 barre, leva 3x, position 15%.
Per aggiungere una strategia:
1. Crea script in `scripts/strategies/` che estende `Strategy`
2. Aggiungi mapping in `src/live/strategy_loader.py``MODULE_MAP`
3. Aggiungi entry in `strategies.yml` per paper trading
Risultato backtestato: 76.9% accuracy, 118% annuo, 4.2% drawdown, €13.78/giorno da €1.000.
## Multi-Strategy Paper Trader
Orchestratore che esegue N strategie in parallelo su dati live Cerbero, ognuna con €1000 USDC virtuali indipendenti.
**Config:** `strategies.yml` — lista strategie con asset, tf, sizing, parametri.
**Persistenza:** `data/paper_trades/{strategy}___{asset}__{tf}/` con `trades.jsonl` (append-only) + `status.json` (resume al restart).
**Hot-add:** aggiungi riga YAML → `docker compose restart` → storico intatto.
**Notifiche:** Telegram per ogni trade (richiede `.env` con `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID`).
## Convenzioni
- Script numerati progressivamente (`01_`, `02_`, …). Ogni script è autocontenuto.
- Strategie in `scripts/strategies/` con codice univoco (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]`.
@@ -72,3 +104,4 @@ Risultato backtestato: 76.9% accuracy, 118% annuo, 4.2% drawdown, €13.78/giorn
- **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.
- **GBM:** GradientBoostingClassifier di scikit-learn. Ensemble di alberi decisionali sequenziali. Walk-forward per evitare leakage temporale.
+3 -1
View File
@@ -8,7 +8,9 @@ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY src/ src/
COPY scripts/strategies/ scripts/strategies/
COPY strategies.yml strategies.yml
VOLUME /app/data
CMD ["uv", "run", "python", "-m", "src.live.paper_trader"]
CMD ["uv", "run", "python", "-m", "src.live.multi_runner"]
+110 -50
View File
@@ -8,17 +8,18 @@ Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di
## Risultati
Tredici strategie testate su dati storici 20182026 (BTC e ETH, timeframe 5m / 15m / 1h). Le migliori cinque:
Oltre 30 strategie testate su dati storici 20182026 (BTC e ETH, timeframe 15m / 1h). Le migliori:
| # | 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 |
| Codice | Strategia | Accuracy | Trades | Max DD | €/giorno | Robustezza |
|--------|-----------|----------|--------|--------|----------|------------|
| SQ02 | Antifake+Vol BTC 15m | **79.7%** | 1250 | 6.5% | €5.23 | ✅ 9/9 anni |
| ML01 | Squeeze+GBM BTC 15m | 79.1% | 1929 | 5.5% | €8.45 | ✅ 5/5 anni |
| SQ02 | Antifake+Vol ETH 15m | 78.6% | 942 | 3.4% | €4.33 | 8/9 anni |
| SQ02 | Antifake+Vol BTC 1h | 78.0% | 473 | 3.5% | €3.85 | ✅ 9/9 anni |
| SQ01 | Squeeze Base ETH 15m | 76.4% | 2948 | 6.2% | €10.31 | 9/9 anni |
| ML01 | Squeeze+GBM ETH 15m | 76.7% | 1210 | 4.2% | €11.12 | 5/5 anni |
La strategia vincente (#1) opera su ETH a 15 minuti con ~1 trade al giorno, leva 3x e drawdown contenuto al 4.2%.
La strategia più robusta (SQ02 BTC 15m) mantiene accuracy ≥73% ogni anno dal 2018 con Sharpe 5.01.
## Come funziona
@@ -28,14 +29,14 @@ Il meccanismo centrale sfrutta i cicli naturali di compressione ed espansione de
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.
3. **Filtri** — anti-fakeout (scarta breakout con retrace >60%) + volume confirmation (breakout >1.3× media).
4. **ML opzionale** — un modello GradientBoosting (GBM), addestrato walk-forward su feature strutturali, conferma la direzione e filtra i segnali deboli.
### Feature frattali
### Feature ML (44 dimensioni)
- 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
- Autocorrelazione lag-1 e profilo volumetrico
- Durata della fase di squeeze e rapporto di espansione Keltner
- Posizione del prezzo rispetto al range recente e ATR normalizzato
@@ -44,44 +45,121 @@ Il meccanismo centrale sfrutta i cicli naturali di compressione ed espansione de
```
PythagorasGoal/
├── src/
│ ├── data/ # Download e gestione dati storici (Cerbero MCP + Binance)
│ ├── data/ # Download e gestione dati (Cerbero MCP + Binance)
│ ├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity
│ ├── backtest/ # Motore di backtesting con fee e metriche
│ ├── strategies/ # (predisposto per strategie modulari)
├── nn/ # (predisposto per reti neurali)
│ └── utils/
├── scripts/ # Script di analisi e test (0113)
│ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi
│ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats
│ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr
│ └── live/ # Paper trading live su Deribit testnet
│ ├── multi_runner.py # Orchestratore multi-strategia
│ ├── strategy_worker.py # Worker indipendente con stato persistente
│ ├── strategy_loader.py # Import dinamico classi Strategy
│ ├── cerbero_client.py # Client HTTP per Cerbero MCP
│ ├── signal_engine.py # Squeeze + ML real-time (per ML01)
│ └── telegram_notifier.py
├── scripts/
│ ├── strategies/ # Strategie attive (SQ01-SQ04, ML01)
│ ├── waste/ # Strategie scartate (W01-W22)
│ └── analysis/ # Script di confronto e report
├── strategies.yml # Config multi-strategy paper trader
├── data/
── raw/ # Parquet OHLCV (non committati, ~70 MB)
│ └── processed/ # Modelli salvati
── raw/ # Parquet OHLCV (gitignored, ~70 MB)
├── docs/
── diary/ # Diario di ricerca giornaliero
├── tests/
├── pyproject.toml
── README.md
── diary/ # Diario di ricerca giornaliero
│ └── specs/ # Specifiche di design
├── Dockerfile
── docker-compose.yml
└── pyproject.toml
```
## Strategie attive
Tutte le strategie estendono `src.strategies.base.Strategy` con interfaccia comune: `generate_signals() → backtest() → report()`.
| Codice | Script | Tipo | Descrizione |
|--------|--------|------|-------------|
| SQ01 | `SQ01_squeeze_base.py` | Regole | Squeeze breakout puro |
| SQ02 | `SQ02_squeeze_antifake_vol.py` | Regole | Squeeze + antifakeout + volume confirm |
| SQ03 | `SQ03_squeeze_all_filters.py` | Regole | Squeeze + filtri selezionabili (9 preset) |
| SQ04 | `SQ04_squeeze_ultimate.py` | Regole | Combo incrementali con correlazione/trend |
| ML01 | `ML01_squeeze_gbm.py` | ML | Squeeze + GBM walk-forward |
Per eseguire il backtest di una strategia:
```bash
uv run python scripts/strategies/SQ02_squeeze_antifake_vol.py
```
## Paper Trading Live
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP, ognuna con €1000 USDC virtuali indipendenti.
### Avvio
```bash
# Locale
uv run python -m src.live.multi_runner
# Docker
docker compose up -d
```
### Configurazione
Le strategie attive sono definite in `strategies.yml`:
```yaml
defaults:
capital: 1000
position_size: 0.15
leverage: 3
strategies:
- name: SQ02_antifake_vol
asset: BTC
tf: 15m
enabled: true
```
Per aggiungere una strategia: nuova riga in `strategies.yml`, poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
### Persistenza
Ogni strategia ha la sua directory in `data/paper_trades/`:
```
data/paper_trades/
SQ02_antifake_vol__BTC__15m/
trades.jsonl # Storico trade append-only
status.json # Stato corrente (resume al restart)
```
Notifiche Telegram per ogni trade (richiede `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` in `.env`).
## Setup
```bash
# Clona il repository
# Clona e installa
git clone <repo-url> && cd PythagorasGoal
# Installa dipendenze (richiede uv)
uv sync
# Scarica dati storici (~70 MB, richiede connessione)
# Scarica dati storici (~70 MB)
uv run python -m src.data.downloader
# Esegui la strategia ibrida vincente
uv run python scripts/13_squeeze_ml_hybrid.py
# Backtest strategia migliore
uv run python scripts/strategies/SQ02_squeeze_antifake_vol.py
# Paper trading live
uv run python -m src.live.multi_runner
```
### Requisiti
- Python ≥ 3.11
- [uv](https://docs.astral.sh/uv/) come package manager
- Accesso a Cerbero MCP (`cerbero-mcp.tielogic.xyz`) per i dati Deribit, oppure Binance via ccxt come fallback
- Accesso a Cerbero MCP (`cerbero-mcp.tielogic.xyz`) per dati Deribit live
- Docker (opzionale, per deploy su VPS)
## Dati
@@ -90,25 +168,7 @@ uv run python scripts/13_squeeze_ml_hybrid.py
| BTC | 5m / 15m / 1h | 883K / 294K / 74K | 2018-01 → oggi |
| ETH | 5m / 15m / 1h | 882K / 294K / 74K | 2018-01 → oggi |
Fonte primaria: Deribit perpetual via Cerbero MCP. Fallback per il periodo antecedente: Binance spot via ccxt. Formato: Apache Parquet.
## Strategie testate
| Script | Approccio | Esito |
|--------|-----------|-------|
| 01 | Pattern candlestick discreti (U/D/0) | Nessun edge |
| 02 | DTW pattern matching | Troppo lento, edge minimo |
| 03 | Proiezione FFT (ispirata al paper) | Random (49.8%) |
| 04 | GBM su feature frattali (Hurst, FD) | 63.6% a soglia 0.65 |
| 05 | GBM multi-window (corretto data leakage) | 58.9% |
| 06 | GBM su feature strutturali normalizzate | 58.6%, +57.5% return |
| 07 | LSTM su sequenze candele | 58.4%, comparabile a GBM |
| 08 | Ensemble multi-timeframe (1h + 15m) | 59.2% (consensus 2/3) |
| 09 | Walk-forward ML | 57.7%, Sharpe 7.4, €3.12/day |
| 10 | Ensemble 5 modelli alta precisione | In corso |
| 11 | **Volatility Squeeze Breakout** | **83.9%**, approccio strutturale |
| 12 | Report finale e simulazione crescita | — |
| 13 | **Squeeze + ML ibrida** | **76.9%**, 118% ann, €13.78/day |
Fonte primaria: Deribit perpetual via Cerbero MCP. Fallback: Binance spot via ccxt. Formato: Apache Parquet.
## Riferimenti
@@ -0,0 +1,13 @@
{
"capital": 1000,
"in_position": false,
"direction": 0,
"entry_price": 0,
"entry_time": "",
"bars_held": 0,
"total_trades": 0,
"total_wins": 0,
"started_at": "2026-05-27T21:16:02.087963+00:00",
"last_bar_ts": 0,
"last_update": "2026-05-27T21:16:04.705726+00:00"
}
@@ -0,0 +1 @@
{"ts": "2026-05-27T21:16:02.087975+00:00", "worker": "ML01_squeeze_gbm__ETH__15m", "event": "INIT", "capital": 1000, "strategy": "ML01_squeeze_gbm", "asset": "ETH", "tf": "15m"}
@@ -0,0 +1,13 @@
{
"capital": 1000,
"in_position": false,
"direction": 0,
"entry_price": 0,
"entry_time": "",
"bars_held": 0,
"total_trades": 0,
"total_wins": 0,
"started_at": "2026-05-27T21:16:02.087646+00:00",
"last_bar_ts": 0,
"last_update": "2026-05-27T21:16:04.584685+00:00"
}
@@ -0,0 +1 @@
{"ts": "2026-05-27T21:16:02.087660+00:00", "worker": "SQ01_squeeze_base__BTC__15m", "event": "INIT", "capital": 1000, "strategy": "SQ01_squeeze_base", "asset": "BTC", "tf": "15m"}
@@ -0,0 +1,13 @@
{
"capital": 1000,
"in_position": false,
"direction": 0,
"entry_price": 0,
"entry_time": "",
"bars_held": 0,
"total_trades": 0,
"total_wins": 0,
"started_at": "2026-05-27T21:16:02.087214+00:00",
"last_bar_ts": 0,
"last_update": "2026-05-27T21:16:04.339917+00:00"
}
@@ -0,0 +1 @@
{"ts": "2026-05-27T21:16:02.087241+00:00", "worker": "SQ02_antifake_vol__BTC__15m", "event": "INIT", "capital": 1000, "strategy": "SQ02_antifake_vol", "asset": "BTC", "tf": "15m"}
@@ -0,0 +1,13 @@
{
"capital": 1000,
"in_position": false,
"direction": 0,
"entry_price": 0,
"entry_time": "",
"bars_held": 0,
"total_trades": 0,
"total_wins": 0,
"started_at": "2026-05-27T21:16:02.087438+00:00",
"last_bar_ts": 0,
"last_update": "2026-05-27T21:16:04.463602+00:00"
}
@@ -0,0 +1 @@
{"ts": "2026-05-27T21:16:02.087448+00:00", "worker": "SQ02_antifake_vol__ETH__15m", "event": "INIT", "capital": 1000, "strategy": "SQ02_antifake_vol", "asset": "ETH", "tf": "15m"}
+8
View File
@@ -0,0 +1,8 @@
{
"in_position": false,
"direction": null,
"entry_price": 0,
"entry_time": null,
"bars_held": 0,
"last_update": "2026-05-27T07:40:09.196718+00:00"
}
@@ -0,0 +1,2 @@
{"timestamp": "2026-05-27T07:35:10.715321+00:00", "event": "TRAINING", "lookback_days": 365}
{"timestamp": "2026-05-27T07:35:11.967644+00:00", "event": "TRAINING_DONE", "samples": 90, "up_ratio": 48.888888888888886, "train_accuracy": 100.0}
@@ -0,0 +1,3 @@
{"timestamp": "2026-05-27T07:36:03.120802+00:00", "event": "STARTUP", "equity": 101459.276155, "testnet": true}
{"timestamp": "2026-05-27T07:36:03.121518+00:00", "event": "TRAINING", "lookback_days": 365}
{"timestamp": "2026-05-27T07:36:04.249123+00:00", "event": "TRAINING_DONE", "samples": 90, "up_ratio": 48.888888888888886, "train_accuracy": 100.0}
@@ -0,0 +1,6 @@
{"timestamp": "2026-05-27T08:04:41.544464+00:00", "event": "TRAINING", "lookback_days": 365, "instrument": "ETH-PERPETUAL"}
{"timestamp": "2026-05-27T08:04:42.704464+00:00", "event": "TRAINING_DONE", "samples": 90, "up_ratio": 48.888888888888886, "train_accuracy": 100.0}
{"timestamp": "2026-05-27T08:04:42.918237+00:00", "event": "OPENING", "side": "buy", "amount": 0.216, "price": 2083.75, "virtual_capital": 1000.0, "notional": 450.0, "signal": {"direction": "buy", "probability": 0.75, "squeeze_duration": 10}}
{"timestamp": "2026-05-27T08:04:43.143718+00:00", "event": "OPENED", "order_result": {"order": {"label": "pythagoras-squeeze", "price": 2292.25, "order_id": "USDC-209283595178", "user_id": 81070, "amount": 0.216, "instrument_name": "ETH_USDC-PERPETUAL", "direction": "buy", "time_in_force": "good_til_cancelled", "web": false, "api": true, "creation_timestamp": 1779869083116, "mmp": false, "replaced": false, "post_only": false, "reduce_only": false, "filled_amount": 0.216, "last_update_timestamp": 1779869083116, "average_price": 2083.9, "contracts": 216.0, "order_state": "filled", "order_type": "market", "is_liquidation": false, "risk_reducing": false}, "trades": [{"label": "pythagoras-squeeze", "timestamp": 1779869083116, "state": "filled", "price": 2083.9, "order_id": "USDC-209283595178", "user_id": 81070, "amount": 0.216, "instrument_name": "ETH_USDC-PERPETUAL", "direction": "buy", "index_price": 2083.37, "trade_seq": 6674514, "api": true, "mark_price": 2083.86, "matching_id": null, "tick_direction": 0, "profit_loss": 0.0, "mmp": false, "post_only": false, "reduce_only": false, "self_trade": false, "contracts": 216.0, "trade_id": "USDC-32731729", "fee_currency": "USDC", "order_type": "market", "fee": 0.2250612, "liquidity": "T", "risk_reducing": false}], "data_timestamp": "2026-05-27T08:04:43.126155+00:00"}}
{"timestamp": "2026-05-27T08:04:46.361078+00:00", "event": "CLOSING", "reason": "test", "entry_price": 2083.75, "exit_price": 2083.95, "size": 0.216, "trade_pnl": 0.04, "fee": 0.9, "net_pnl": -0.86, "pnl_pct": -0.086, "bars_held": 0, "capital_before": 1000.0}
{"timestamp": "2026-05-27T08:04:46.574322+00:00", "event": "CLOSED", "result": {"order_id": "USDC-209283608601", "state": "filled", "data_timestamp": "2026-05-27T08:04:46.555823+00:00"}, "net_pnl": -0.86, "pnl_pct": -0.086, "virtual_capital": 999.14}
+5 -2
View File
@@ -1,14 +1,17 @@
services:
paper-trader:
build: .
container_name: pythagoras-paper
container_name: pythagoras-multi
restart: unless-stopped
volumes:
- ./data:/app/data
- ./strategies.yml:/app/strategies.yml:ro
env_file:
- .env
environment:
- PYTHONUNBUFFERED=1
healthcheck:
test: ["CMD", "python", "-c", "import json; s=json.load(open('/app/data/paper_trades/status.json')); assert s['last_update']"]
test: ["CMD", "python", "-c", "import os; assert any(f.endswith('status.json') for r,d,fs in os.walk('/app/data/paper_trades') for f in fs)"]
interval: 120s
timeout: 10s
retries: 3
@@ -0,0 +1,174 @@
# Multi-Strategy Paper Trader — Design Spec
## Obiettivo
Eseguire N strategie di trading in parallelo su Deribit testnet (paper trading locale), ognuna con capitale virtuale indipendente di €1000 USDC. Lo storico trade di ogni strategia persiste tra restart. Nuove strategie aggiungibili in corso d'opera via config YAML senza perdere lo storico delle esistenti.
## Architettura
Un singolo container Docker esegue un orchestratore (`MultiStrategyRunner`) che gestisce N `StrategyWorker`. Ogni worker è indipendente: proprio capital, propri trade, proprio stato.
```
Docker Container
├── MultiStrategyRunner (orchestratore, loop principale)
│ ├── StrategyWorker[SQ02_BTC_15m] → paper trade → JSONL
│ ├── StrategyWorker[ML01_ETH_15m] → paper trade → JSONL
│ └── ...altri worker da YAML
├── CerberoClient (condiviso, fetch prezzi)
└── TelegramNotifier (condiviso)
```
## Componenti
### 1. `strategies.yml` — Configurazione
```yaml
defaults:
capital: 1000
position_size: 0.15
leverage: 3
hold_bars: 3
poll_seconds: 60
retrain_hours: 24
strategies:
- name: SQ02_antifake_vol
asset: BTC
tf: 15m
enabled: true
- name: SQ02_antifake_vol
asset: ETH
tf: 15m
enabled: true
- name: ML01_squeeze_gbm
asset: ETH
tf: 15m
enabled: true
position_size: 0.20
params:
ml_threshold: 0.70
bb_window: 14
sq_threshold: 0.8
```
Ogni entry eredita `defaults`. Override per-strategia possibile su tutti i campi. Il campo `params` passa kwargs a `generate_signals()` o al backtest ML.
### 2. `StrategyWorker` — Worker per singola strategia
Responsabilità:
- Importa la classe Strategy corrispondente da `scripts/strategies/`
- Mantiene stato: capital, posizione aperta, equity
- Al startup: ricarica `status.json` se esiste (resume), altrimenti inizia da zero
- Ad ogni tick: riceve DataFrame candele, genera segnali, paper-trade
- Logga ogni evento in `trades.jsonl` (append-only)
- Aggiorna `status.json` ad ogni tick
Stato persistente (`status.json`):
```json
{
"capital": 1023.45,
"in_position": true,
"direction": "long",
"entry_price": 2534.20,
"entry_time": "2026-05-27T14:30:00Z",
"bars_held": 1,
"total_trades": 15,
"total_wins": 12,
"started_at": "2026-05-27T10:00:00Z"
}
```
Trade log (`trades.jsonl`), append-only:
```json
{"ts": "2026-05-27T14:30:00Z", "event": "OPEN", "direction": "long", "price": 2534.20, "size": 0.18, "capital": 1023.45}
{"ts": "2026-05-27T15:15:00Z", "event": "CLOSE", "reason": "hold_limit", "entry": 2534.20, "exit": 2560.10, "pnl": 3.45, "fee": 0.92, "net_pnl": 2.53, "capital": 1025.98}
```
### 3. `MultiStrategyRunner` — Orchestratore
Loop principale:
1. Carica `strategies.yml`
2. Per ogni entry, crea `StrategyWorker` (o riprende se già esiste)
3. Ogni 60s:
a. Fetch candele live da Cerbero (una volta per asset/tf unico)
b. Passa DataFrame a ogni worker
c. Ogni worker valuta segnali e gestisce posizione
d. Worker ML: retrain ogni 24h
4. Notifica Telegram per ogni trade
Ottimizzazione: fetch candele raggruppato per (asset, tf). Se 3 strategie usano BTC 15m, fetch una volta sola.
### 4. Persistenza
```
data/paper_trades/
SQ02_antifake_vol__BTC__15m/
trades.jsonl
status.json
SQ02_antifake_vol__ETH__15m/
trades.jsonl
status.json
ML01_squeeze_gbm__ETH__15m/
trades.jsonl
status.json
```
Directory naming: `{strategy_name}__{asset}__{tf}` con double underscore separatore.
Volume Docker: `./data:/app/data` — persiste tra restart.
### 5. Aggiunta strategia in corso
1. Aggiungi entry in `strategies.yml`
2. `docker compose restart`
3. Runner carica YAML, trova nuova entry senza `status.json` → parte da €1000
4. Strategie esistenti riprendono da `status.json` → storico intatto
### 6. Docker
`Dockerfile` — invariato, aggiunge `strategies.yml` alla COPY.
`docker-compose.yml`:
```yaml
services:
paper-trader:
build: .
container_name: pythagoras-multi
restart: unless-stopped
volumes:
- ./data:/app/data
- ./strategies.yml:/app/strategies.yml:ro
env_file:
- .env
environment:
- PYTHONUNBUFFERED=1
```
`CMD` cambia a: `uv run python -m src.live.multi_runner`
### 7. Strategia-specifica: ML01
ML01 richiede training del modello GBM. Il worker ML01:
- Al primo avvio: train su storico (365 giorni via Cerbero)
- Ogni `retrain_hours`: retrain
- Usa `SignalEngine` esistente per check_signal()
- Le strategie SQ* non hanno training — solo regole deterministiche
### 8. File da creare/modificare
Nuovi:
- `src/live/multi_runner.py` — orchestratore
- `src/live/strategy_worker.py` — worker per singola strategia
- `strategies.yml` — config
- `src/live/strategy_loader.py` — import dinamico classi Strategy
Modifiche:
- `docker-compose.yml` — nuovo CMD, volume strategies.yml
- `Dockerfile` — COPY strategies.yml
Invariati:
- `src/live/cerbero_client.py`
- `src/live/telegram_notifier.py`
- `src/live/signal_engine.py` (usato da ML01 worker)
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
"torch>=2.0",
"matplotlib>=3.7",
"tqdm>=4.65",
"pyyaml>=6.0",
]
[project.optional-dependencies]
+309
View File
@@ -0,0 +1,309 @@
"""Confronto migliori strategie S1 e S2 — andamento per anno."""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.data.downloader import load_data
from src.fractal.patterns import encode_candles
FEE_PERP = 0.002 # 0.1% taker roundtrip perpetual
FEE_OPT = 0.0052 # options roundtrip
INITIAL = 1000
LEVERAGE = 3
def keltner_ratio(close, high, low, window=14):
n = len(close)
r = np.full(n, np.nan)
for i in range(window, n):
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
atr = np.mean(tr[1:])
kc = (ma+1.5*atr)-(ma-1.5*atr)
bb = (ma+2*bb_std)-(ma-2*bb_std)
if kc > 0:
r[i] = bb/kc
return r
def rv_ann(close, window):
lr = np.diff(np.log(np.where(close==0, 1e-10, close)))
r = np.full(len(close), np.nan)
for i in range(window, len(lr)):
r[i+1] = np.std(lr[i-window:i]) * np.sqrt(24*365)
return r
def rsi(close, period=14):
delta = np.diff(close)
gain = np.where(delta>0, delta, 0)
loss = np.where(delta<0, -delta, 0)
result = np.full(len(close), 50.0)
if len(gain) < period:
return result
ag = np.mean(gain[:period])
al = np.mean(loss[:period])
for i in range(period, len(delta)):
ag = (ag*(period-1)+gain[i])/period
al = (al*(period-1)+loss[i])/period
result[i+1] = 100 if al == 0 else 100-100/(1+ag/al)
return result
def ema(arr, period):
r = np.full(len(arr), np.nan)
k = 2/(period+1)
r[period-1] = np.mean(arr[:period])
for i in range(period, len(arr)):
r[i] = arr[i]*k + r[i-1]*(1-k)
return r
# =====================================================================
# S1 BEST: Squeeze Breakout ETH 1h (BBw=14, sq=0.8, brk=3)
# =====================================================================
def run_s1_squeeze(asset, tf):
df = load_data(asset, tf)
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
kcr = keltner_ratio(c, h, l, 14)
yearly = {}
in_sq = False
sq_start = 0
for i in range(15, n):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < 0.8
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
if i - sq_start < 5 or i + 3 >= n:
continue
first_ret = (c[i] - c[i-1]) / c[i-1]
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
actual = (c[i+2] - c[i-1]) / c[i-1]
trade_ret = actual * direction
net = trade_ret * LEVERAGE - FEE_PERP * LEVERAGE
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"pnls": [], "wins": 0, "total": 0}
yearly[year]["pnls"].append(net)
yearly[year]["total"] += 1
if trade_ret > 0:
yearly[year]["wins"] += 1
return yearly
# =====================================================================
# S1 BEST ALT: Squeeze+ML hybrid ETH 15m
# =====================================================================
# Troppo complesso da ricalcolare (serve ML training). Uso i dati S1 squeeze puro.
# =====================================================================
# S2 BEST: VRP ETH 48h (con IV stimata, unico disponibile su 8 anni)
# =====================================================================
def run_s2_vrp(asset, dte=48):
df = load_data(asset, "1h")
c = df["close"].values
n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
rv_24 = rv_ann(c, 24)
rv_168 = rv_ann(c, 168)
yearly = {}
for i in range(170, n - dte):
if ts.iloc[i].hour != 8:
continue
rv_s, rv_l = rv_24[i], rv_168[i]
if np.isnan(rv_s) or np.isnan(rv_l) or rv_s < 0.05 or rv_l < 0.05:
continue
regime = rv_s / rv_l
iv_pf = 0.9 if regime > 2 else (1.0 if regime > 1.5 else (1.1 if regime > 1 else 1.2))
iv = rv_l * iv_pf
prem = iv * np.sqrt(dte/(24*365)) * 0.8
spot = c[i]
move = abs(c[min(i+dte, n-1)] - spot) / spot
pos = 0.10
raw = (prem - move) * pos if move <= prem else max(-(move-prem)*pos, -pos*0.05)
net = raw - FEE_OPT * pos
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"pnls": [], "wins": 0, "total": 0}
yearly[year]["pnls"].append(net)
yearly[year]["total"] += 1
if raw > 0:
yearly[year]["wins"] += 1
return yearly
# =====================================================================
# S2 BEST PERPETUAL: Multi-TF 15m+1h BTC
# =====================================================================
def run_s2_multitf(asset):
df_1h = load_data(asset, "1h")
df_15m = load_data(asset, "15m")
c1h = df_1h["close"].values
ts1h = pd.to_datetime(df_1h["timestamp"], unit="ms", utc=True)
c15 = df_15m["close"].values
ts15 = df_15m["timestamp"].values
n15 = len(c15)
ema_50 = ema(c1h, 50)
rsi_15m = rsi(c15, 14)
yearly = {}
daily_done = set()
for i in range(100, n15 - 12):
ts_dt = pd.Timestamp(ts15[i], unit="ms", tz="UTC")
day = ts_dt.strftime("%Y-%m-%d")
if day in daily_done:
continue
if rsi_15m[i] > 35 and rsi_15m[i] < 65:
continue
h_idx = np.searchsorted(ts1h.values.astype("int64"), ts15[i]) - 1
if h_idx < 50 or h_idx >= len(c1h) or np.isnan(ema_50[h_idx]):
continue
direction = None
if rsi_15m[i] < 30 and c1h[h_idx] > ema_50[h_idx]:
direction = "long"
elif rsi_15m[i] > 70 and c1h[h_idx] < ema_50[h_idx]:
direction = "short"
if direction is None:
continue
entry = c15[i]
exit_price = c15[min(i+12, n15-1)]
trade_ret = (exit_price-entry)/entry if direction == "long" else (entry-exit_price)/entry
net = trade_ret * LEVERAGE - FEE_PERP * LEVERAGE
year = ts_dt.year
if year not in yearly:
yearly[year] = {"pnls": [], "wins": 0, "total": 0}
yearly[year]["pnls"].append(net)
yearly[year]["total"] += 1
if trade_ret > 0:
yearly[year]["wins"] += 1
daily_done.add(day)
return yearly
# =====================================================================
# REPORT
# =====================================================================
strategies = {
"S1: Squeeze BTC 1h": run_s1_squeeze("BTC", "1h"),
"S1: Squeeze ETH 1h": run_s1_squeeze("ETH", "1h"),
"S1: Squeeze ETH 15m": run_s1_squeeze("ETH", "15m"),
"S2: VRP ETH 48h (IV est)": run_s2_vrp("ETH", 48),
"S2: VRP BTC 48h (IV est)": run_s2_vrp("BTC", 48),
"S2: MultiTF BTC 15m+1h": run_s2_multitf("BTC"),
"S2: MultiTF ETH 15m+1h": run_s2_multitf("ETH"),
}
all_years = sorted(set(y for v in strategies.values() for y in v))
print("=" * 120)
print(" MIGLIORI STRATEGIE — ANDAMENTO PER ANNO")
print(" Fee reali. PnL su €1000 flat (no compounding). Dati OHLCV reali 2018-2026.")
print(" ⚠ VRP usa IV STIMATA (non reale) — fidarsi solo dei dati perpetual per backtest lungo")
print("=" * 120)
# Header
hdr = f" {'Anno':>6s}"
for name in strategies:
short = name.split(": ")[1][:18]
hdr += f" | {short:>18s}"
print(hdr)
print(f" {'-' * (len(hdr) - 2)}")
# Per anno: accuracy / PnL totale
for year in all_years:
row_acc = f" {year:>6d}"
row_pnl = f" {'':>6s}"
for name, yearly in strategies.items():
if year in yearly:
d = yearly[year]
acc = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
pnl = sum(d["pnls"]) * INITIAL
tag = "" if acc >= 75 else "" if acc >= 65 else "" if acc >= 55 else " "
row_acc += f" | {acc:>5.1f}% {tag} {d['total']:>3d}t"
row_pnl += f" | €{pnl:>+8.0f} "
else:
row_acc += f" | {'':>18s}"
row_pnl += f" | {'':>18s}"
print(row_acc)
print(row_pnl)
# Totali
print(f" {'-' * (len(hdr) - 2)}")
row_tot = f" {'TOT':>6s}"
for name, yearly in strategies.items():
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
all_wins = sum(d["wins"] for d in yearly.values())
all_total = sum(d["total"] for d in yearly.values())
acc = all_wins/all_total*100 if all_total > 0 else 0
pnl = sum(all_pnls) * INITIAL
row_tot += f" | {acc:>5.1f}% {all_total:>4d}t"
print(row_tot)
row_pnl_tot = f" {'€TOT':>6s}"
for name, yearly in strategies.items():
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
pnl = sum(all_pnls) * INITIAL
row_pnl_tot += f" | €{pnl:>+8.0f} "
print(row_pnl_tot)
# Compounding
print(f"\n {'':>6s}", end="")
for name in strategies:
short = name.split(": ")[1][:18]
print(f" | {short:>18s}", end="")
print()
row_comp = f" {'COMP':>6s}"
for name, yearly in strategies.items():
cap = float(INITIAL)
for year in sorted(yearly):
for pnl in yearly[year]["pnls"]:
cap += cap * pnl
cap = max(cap, 10)
row_comp += f" | €{cap:>12,.0f} "
print(row_comp)
# Drawdown
row_dd = f" {'MAXDD':>6s}"
for name, yearly in strategies.items():
cap = float(INITIAL)
peak = cap
mdd = 0
for year in sorted(yearly):
for pnl in yearly[year]["pnls"]:
cap += cap * pnl
cap = max(cap, 10)
if cap > peak: peak = cap
dd = (peak - cap) / peak
mdd = max(mdd, dd)
row_dd += f" | {mdd*100:>12.1f}% "
print(row_dd)
# Legenda
print(f"\n Legenda: ▓ ≥75% acc ▒ ≥65% acc ░ ≥55% acc")
print(f" ⚠ S2 VRP: IV stimata (rv_long × 1.0-1.2), NON dati reali opzioni")
print(f" S1 Squeeze e S2 MultiTF: dati OHLCV reali al 100%")
+559
View File
@@ -0,0 +1,559 @@
"""Analisi finale — S1 (squeeze puro) vs Script 13 (squeeze+ML GBM).
Metriche: PnL, num trades, DD max, tempo medio a mercato, descrizione.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.preprocessing import StandardScaler
from src.data.downloader import load_data
from src.fractal.patterns import encode_candles
FEE_PERP = 0.002
FEE_ML = 0.001
INITIAL = 1000
LEVERAGE = 3
TF_MINUTES = {"1m": 1, "5m": 5, "15m": 15, "1h": 60, "4h": 240, "1d": 1440}
# ── helpers ──────────────────────────────────────────────────────────
def keltner_ratio(close, high, low, window=14):
n = len(close)
r = np.full(n, np.nan)
for i in range(window, n):
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
atr = np.mean(tr[1:])
kc = (ma+1.5*atr)-(ma-1.5*atr)
bb = (ma+2*bb_std)-(ma-2*bb_std)
if kc > 0:
r[i] = bb/kc
return r
def detect_squeezes(close, high, low, kcr, sq_thr=0.8, min_dur=5):
events = []
in_sq = False
sq_start = 0
for i in range(1, len(close)):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
dur = i - sq_start
if dur < min_dur:
continue
events.append({"idx": i, "dur": dur, "sq_start": sq_start,
"avg_vol_squeeze": np.mean(close[sq_start:i]),
"kcr_at_release": kcr[i]})
return events
def _build_result(yearly, capital, max_dd, all_t, all_w, time_pct, avg_dur_h):
acc = all_w / all_t * 100
tot_pnl = sum(p for d in yearly.values() for p in d["pnls"])
years_active = len(yearly)
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
sharpe = np.mean(all_pnls) / np.std(all_pnls) * np.sqrt(252) if len(all_pnls) > 1 and np.std(all_pnls) > 0 else 0
year_details = {}
for y in sorted(yearly):
d = yearly[y]
ya = d["w"] / d["t"] * 100 if d["t"] > 0 else 0
yp = sum(d["pnls"])
year_details[y] = {"trades": d["t"], "acc": ya, "pnl": yp}
valid_years = {y: d for y, d in year_details.items() if d["trades"] >= 10}
if valid_years:
worst_y = min(valid_years, key=lambda y: valid_years[y]["acc"])
worst_acc = valid_years[worst_y]["acc"]
elif year_details:
worst_y = min(year_details, key=lambda y: year_details[y]["acc"])
worst_acc = year_details[worst_y]["acc"]
else:
worst_y = "N/A"
worst_acc = 0
daily_pnl = tot_pnl / (years_active * 365) if years_active > 0 else 0
return {
"trades": all_t, "acc": acc, "pnl": tot_pnl, "capital": capital,
"max_dd": max_dd * 100, "sharpe": sharpe, "daily_pnl": daily_pnl,
"time_in_market_pct": time_pct, "avg_dur_h": avg_dur_h,
"years_active": years_active, "worst_year": str(worst_y),
"worst_acc": worst_acc, "year_details": year_details,
}
# ── S1: Squeeze breakout puro ────────────────────────────────────────
def run_s1_squeeze(asset, tf, hold=3):
df = load_data(asset, tf)
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
kcr = keltner_ratio(c, h, l, 14)
events = detect_squeezes(c, h, l, kcr)
yearly = {}
capital = float(INITIAL)
peak = capital
max_dd = 0
total_bars = 0
for ev in events:
i = ev["idx"]
if i + hold + 1 >= n:
continue
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
entry = c[i-1]
exit_price = c[min(i + hold - 1, n - 1)]
actual = (exit_price - entry) / entry * direction
net = actual * LEVERAGE - FEE_PERP * LEVERAGE
capital += capital * 0.15 * net
capital = max(capital, 10)
if capital > peak: peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
total_bars += hold
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnls": []}
yearly[year]["t"] += 1
if actual > 0: yearly[year]["w"] += 1
yearly[year]["pnls"].append(net * INITIAL)
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t == 0: return None
return _build_result(yearly, capital, max_dd, all_t, all_w,
total_bars / n * 100, hold * TF_MINUTES.get(tf, 60) / 60)
def run_s1_antifake_vol(asset, tf, hold=3):
df = load_data(asset, tf)
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
kcr = keltner_ratio(c, h, l, 14)
events = detect_squeezes(c, h, l, kcr)
yearly = {}
capital = float(INITIAL)
peak = capital
max_dd = 0
total_bars = 0
for ev in events:
i = ev["idx"]
if i + hold + 1 >= n:
continue
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
if abs(first_ret) < 0.001:
continue
br = h[i] - l[i]
if br > 0:
if c[i] > c[i-1]:
if (h[i] - c[i]) / br > 0.6:
continue
else:
if (c[i] - l[i]) / br > 0.6:
continue
avg_v = np.mean(v[ev["sq_start"]:i])
if avg_v > 0 and v[i] <= avg_v * 1.3:
continue
direction = 1 if first_ret > 0 else -1
entry = c[i-1]
exit_price = c[min(i + hold - 1, n - 1)]
actual = (exit_price - entry) / entry * direction
net = actual * LEVERAGE - FEE_PERP * LEVERAGE
capital += capital * 0.15 * net
capital = max(capital, 10)
if capital > peak: peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
total_bars += hold
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnls": []}
yearly[year]["t"] += 1
if actual > 0: yearly[year]["w"] += 1
yearly[year]["pnls"].append(net * INITIAL)
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t == 0: return None
return _build_result(yearly, capital, max_dd, all_t, all_w,
total_bars / n * 100, hold * TF_MINUTES.get(tf, 60) / 60)
# ── Script 13: Squeeze + ML ibrida (GBM walk-forward) ────────────────
def build_features_at(df, i, squeeze_info):
if i < 100:
return None
o = df["open"].values
h = df["high"].values
l = df["low"].values
c = df["close"].values
v = df["volume"].values
feats = []
for w in [12, 24, 48]:
win_c = c[i-w:i]
win_o = o[i-w:i]
win_h = h[i-w:i]
win_l = l[i-w:i]
win_v = v[i-w:i]
mn, mx = win_l.min(), max(win_h.max(), win_c.max())
rng = mx - mn if mx - mn > 0 else 1e-10
total = win_h - win_l
total = np.where(total == 0, 1e-10, total)
body = np.abs(win_c - win_o) / total
direction = np.sign(win_c - win_o)
log_c = np.log(np.where(win_c == 0, 1e-10, win_c))
rets = np.diff(log_c)
v_mean = np.mean(win_v)
feats.extend([
np.mean(rets) if len(rets) > 0 else 0,
np.std(rets) if len(rets) > 0 else 0,
np.sum(rets) if len(rets) > 0 else 0,
float(pd.Series(rets).skew()) if len(rets) > 2 else 0,
float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0,
np.mean(body), np.std(body),
np.mean(direction), np.mean(direction[-min(3, w):]),
(win_c[-1] - mn) / rng,
win_v[-1] / v_mean if v_mean > 0 else 1,
np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0,
])
sq = squeeze_info
feats.extend([
sq["dur"], sq["dur"] / 24, sq["kcr_at_release"],
v[i-1] / sq["avg_vol_squeeze"] if sq["avg_vol_squeeze"] > 0 else 1,
np.mean(v[i:min(i+3, len(v))]) / sq["avg_vol_squeeze"] if sq["avg_vol_squeeze"] > 0 else 1,
])
h48 = np.max(h[max(0, i-48):i])
l48 = np.min(l[max(0, i-48):i])
r48 = h48 - l48
feats.append((c[i-1] - l48) / r48 if r48 > 0 else 0.5)
tr = np.maximum(h[i-14:i] - l[i-14:i],
np.maximum(np.abs(h[i-14:i] - np.roll(c[i-14:i], 1)),
np.abs(l[i-14:i] - np.roll(c[i-14:i], 1))))
atr = np.mean(tr[1:])
feats.append(atr / c[i-1] if c[i-1] > 0 else 0)
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
feats.append(first_ret)
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
def run_s13_hybrid(asset, tf, bb_w, sq_thr, brk_bars, leverage, pos_pct, ml_thr):
df = load_data(asset, tf)
close = df["close"].values
high = df["high"].values
low = df["low"].values
volume = df["volume"].values
n = len(df)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
kcr = keltner_ratio(close, high, low, bb_w)
events = detect_squeezes(close, high, low, kcr, sq_thr)
X_all, y_all, ev_all = [], [], []
for ev in events:
i = ev["idx"]
if i + brk_bars >= n or i < 100:
continue
feats = build_features_at(df, i, ev)
if feats is None:
continue
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
X_all.append(feats)
y_all.append(1 if actual_ret > 0 else 0)
ev_all.append(ev)
if len(X_all) < 50:
return None
X = np.array(X_all)
y = np.array(y_all)
TRAIN_SIZE = max(int(len(X) * 0.5), 50)
STEP_SIZE = max(int(len(X) * 0.1), 10)
yearly = {}
capital = float(INITIAL)
peak = capital
max_dd = 0
total_bars = 0
all_t = 0
all_w = 0
start = 0
while start + TRAIN_SIZE + STEP_SIZE <= len(X):
train_end = start + TRAIN_SIZE
test_end = min(train_end + STEP_SIZE, len(X))
X_tr, y_tr = X[start:train_end], y[start:train_end]
X_te = X[train_end:test_end]
if len(np.unique(y_tr)) < 2:
start += STEP_SIZE
continue
scaler = StandardScaler()
X_tr_s = scaler.fit_transform(X_tr)
X_te_s = scaler.transform(X_te)
model = GradientBoostingClassifier(
n_estimators=150, max_depth=4, min_samples_leaf=10,
learning_rate=0.05, subsample=0.8, random_state=42,
)
model.fit(X_tr_s, y_tr)
up_idx = list(model.classes_).index(1) if 1 in model.classes_ else -1
if up_idx < 0:
start += STEP_SIZE
continue
for j in range(len(X_te)):
proba = model.predict_proba(X_te_s[j:j+1])[0]
p_up = proba[up_idx]
ev = ev_all[train_end + j]
i = ev["idx"]
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
direction = None
if p_up >= ml_thr:
direction = 1
elif p_up <= (1 - ml_thr):
direction = -1
if direction is None:
continue
is_correct = (direction == 1 and actual_ret > 0) or (direction == -1 and actual_ret < 0)
trade_ret = actual_ret * direction
net = trade_ret * leverage - FEE_ML * 2 * leverage
capital += capital * pos_pct * net
capital = max(capital, 10)
if capital > peak: peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
total_bars += brk_bars
all_t += 1
if is_correct: all_w += 1
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnls": []}
yearly[year]["t"] += 1
if is_correct: yearly[year]["w"] += 1
yearly[year]["pnls"].append(net * INITIAL)
start += STEP_SIZE
if all_t == 0:
return None
return _build_result(yearly, capital, max_dd, all_t, all_w,
total_bars / n * 100, brk_bars * TF_MINUTES.get(tf, 60) / 60)
# ═══════════════════════════════════════════════════════════════════
# ESECUZIONE
# ═══════════════════════════════════════════════════════════════════
print("Calcolo in corso...\n")
strategies = []
def add(name, desc, cat, result):
if result and result["trades"] >= 20:
strategies.append({"name": name, "desc": desc, "cat": cat, **result})
# ── S1: Squeeze puro ────────────────────────────────────────────
add("S1 Squeeze BTC 15m", "Squeeze breakout puro, BBw=14, hold 3×15m, leva 3x",
"S1", run_s1_squeeze("BTC", "15m"))
add("S1 Squeeze ETH 15m", "Squeeze breakout puro, BBw=14, hold 3×15m, leva 3x",
"S1", run_s1_squeeze("ETH", "15m"))
add("S1 Squeeze BTC 1h", "Squeeze breakout puro, BBw=14, hold 3×1h, leva 3x",
"S1", run_s1_squeeze("BTC", "1h"))
add("S1 Squeeze ETH 1h", "Squeeze breakout puro, BBw=14, hold 3×1h, leva 3x",
"S1", run_s1_squeeze("ETH", "1h"))
add("S1 AF+Vol BTC 15m", "Squeeze + antifakeout + volume confirm >1.3× media",
"S1", run_s1_antifake_vol("BTC", "15m"))
add("S1 AF+Vol ETH 15m", "Squeeze + antifakeout + volume confirm >1.3× media",
"S1", run_s1_antifake_vol("ETH", "15m"))
add("S1 AF+Vol BTC 1h", "Squeeze + antifakeout + volume confirm >1.3× media",
"S1", run_s1_antifake_vol("BTC", "1h"))
add("S1 AF+Vol ETH 1h", "Squeeze + antifakeout + volume confirm >1.3× media",
"S1", run_s1_antifake_vol("ETH", "1h"))
# ── Script 13: Squeeze + ML (GBM walk-forward) ─────────────────
print(" Training ML models...")
add("S13 ETH 15m bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.70, 3x leva 15% pos",
"S13", run_s13_hybrid("ETH", "15m", 14, 0.8, 3, 3, 0.15, 0.70))
add("S13 ETH 15m bb14 ml65", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.65, 3x leva 15% pos",
"S13", run_s13_hybrid("ETH", "15m", 14, 0.8, 3, 3, 0.15, 0.65))
add("S13 ETH 15m bb20 ml70", "Squeeze+GBM walk-forward, BBw=20 sq=0.9 ml≥0.70, 3x leva 15% pos",
"S13", run_s13_hybrid("ETH", "15m", 20, 0.9, 3, 3, 0.15, 0.70))
add("S13 BTC 15m bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.9 ml≥0.70, 3x leva 15% pos",
"S13", run_s13_hybrid("BTC", "15m", 14, 0.9, 3, 3, 0.15, 0.70))
add("S13 BTC 15m bb14 ml65", "Squeeze+GBM walk-forward, BBw=14 sq=0.9 ml≥0.65, 3x leva 15% pos",
"S13", run_s13_hybrid("BTC", "15m", 14, 0.9, 3, 3, 0.15, 0.65))
add("S13 BTC 1h bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.70, 3x leva 20% pos",
"S13", run_s13_hybrid("BTC", "1h", 14, 0.8, 3, 3, 0.20, 0.70))
add("S13 BTC 1h bb14 ml65", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.65, 3x leva 20% pos",
"S13", run_s13_hybrid("BTC", "1h", 14, 0.8, 3, 3, 0.20, 0.65))
add("S13 ETH 1h bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.70, 3x leva 20% pos",
"S13", run_s13_hybrid("ETH", "1h", 14, 0.8, 3, 3, 0.20, 0.70))
strategies.sort(key=lambda x: x["acc"], reverse=True)
# ═══════════════════════════════════════════════════════════════════
# TABELLA 1: Classifica
# ═══════════════════════════════════════════════════════════════════
W = 150
print("=" * W)
print(" S1 (SQUEEZE PURO) vs S13 (SQUEEZE + GBM) — CLASSIFICA FINALE")
print(f" Fee: 0.2% RT. Dati OHLCV reali 2018-2026. Position 15%. Leva 3x.")
print("=" * W)
hdr = (f" {'#':>2s} {'Cat':>3s} {'Nome':<26s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>9s} {'DD%':>6s} {'€/day':>7s} {'Sharpe':>7s} "
f"{'Mkt%':>5s} {'Dur':>6s} {'Worst':>12s} {'Anni':>4s}")
print(hdr)
print(f" {''*(W-4)}")
for idx, s in enumerate(strategies, 1):
worst = f"{s['worst_year']}({s['worst_acc']:.0f}%)"
dur_str = f"{s['avg_dur_h']:.0f}h" if s['avg_dur_h'] >= 1 else f"{s['avg_dur_h']*60:.0f}m"
tag = " ★★" if s["acc"] >= 78 else "" if s["acc"] >= 76 else ""
print(f" {idx:>2d} {s['cat']:>3s} {s['name']:<26s} {s['trades']:>6d} {s['acc']:>5.1f}% "
f"{s['pnl']:>+8.0f} {s['max_dd']:>5.1f}% {s['daily_pnl']:>+6.2f} {s['sharpe']:>7.2f} "
f"{s['time_in_market_pct']:>4.1f}% {dur_str:>6s} {worst:>12s} {s['years_active']:>4d}{tag}")
# ═══════════════════════════════════════════════════════════════════
# TABELLA 2: Descrizione
# ═══════════════════════════════════════════════════════════════════
print(f"\n\n{'=' * W}")
print(" DESCRIZIONE")
print(f"{'=' * W}")
print(f" {'#':>2s} {'Nome':<26s} {'Descrizione'}")
print(f" {''*(W-4)}")
for idx, s in enumerate(strategies, 1):
print(f" {idx:>2d} {s['name']:<26s} {s['desc']}")
# ═══════════════════════════════════════════════════════════════════
# TABELLA 3: Breakdown per anno
# ═══════════════════════════════════════════════════════════════════
top_n = min(12, len(strategies))
top = strategies[:top_n]
all_years = sorted(set(y for s in top for y in s["year_details"]))
print(f"\n\n{'=' * W}")
print(f" BREAKDOWN PER ANNO — TOP {top_n} (accuracy% / trades)")
print(f"{'=' * W}")
header = f" {'Nome':<26s}"
for y in all_years:
header += f" {y:>10d}"
print(header)
print(f" {''*(W-4)}")
for s in top:
line = f" {s['name']:<26s}"
for y in all_years:
if y in s["year_details"]:
d = s["year_details"][y]
line += f" {d['acc']:>4.0f}%/{d['trades']:<4d}"
else:
line += f" {'':>10s}"
print(line)
# ═══════════════════════════════════════════════════════════════════
# TABELLA 4: Robustezza
# ═══════════════════════════════════════════════════════════════════
print(f"\n\n{'=' * W}")
print(f" ANALISI ROBUSTEZZA")
print(f"{'=' * W}")
print(f" {'#':>2s} {'Nome':<26s} {'MinAcc':>7s} {'MaxAcc':>7s} {'Spread':>7s} "
f"{'AnniOK':>7s} {'€/trade':>8s} {'Verdict':<12s}")
print(f" {''*90}")
for idx, s in enumerate(strategies, 1):
yd = s["year_details"]
valid = {y: d for y, d in yd.items() if d["trades"] >= 10}
accs = [d["acc"] for d in (valid if valid else yd).values()]
if not accs:
continue
min_a, max_a = min(accs), max(accs)
spread = max_a - min_a
years_ok = sum(1 for a in accs if a >= 70)
avg_pnl = s["pnl"] / s["trades"] if s["trades"] > 0 else 0
n_valid = len(valid if valid else yd)
if n_valid < 4:
verdict = "⚠ CORTO"
elif min_a < 60:
verdict = "⚠ FRAGILE"
elif min_a >= 72 and s["acc"] >= 77:
verdict = "✅ SOLIDO"
elif min_a >= 65 and s["acc"] >= 74:
verdict = "~ BUONO"
else:
verdict = "~ OK"
print(f" {idx:>2d} {s['name']:<26s} {min_a:>6.1f}% {max_a:>6.1f}% {spread:>6.1f}% "
f"{years_ok:>3d}/{n_valid:<3d}{avg_pnl:>+7.1f} {verdict:<12s}")
# ═══════════════════════════════════════════════════════════════════
# VERDETTO
# ═══════════════════════════════════════════════════════════════════
print(f"\n\n{'=' * W}")
print(f" VERDETTO FINALE")
print(f"{'=' * W}")
solidi = [s for s in strategies if s["trades"] >= 200 and s["years_active"] >= 5 and s["worst_acc"] >= 65]
solidi_s1 = [s for s in solidi if s["cat"] == "S1"]
solidi_ml = [s for s in solidi if s["cat"] == "S13"]
solidi_s1.sort(key=lambda x: x["acc"], reverse=True)
solidi_ml.sort(key=lambda x: x["daily_pnl"], reverse=True)
if solidi_s1:
b = solidi_s1[0]
print(f"\n MIGLIORE S1 (regole pure, facile da deployare):")
print(f" {b['name']}{b['acc']:.1f}% acc, {b['trades']} trades, DD {b['max_dd']:.1f}%, €{b['daily_pnl']:+.2f}/day, Sharpe {b['sharpe']:.2f}")
if solidi_ml:
m = solidi_ml[0]
print(f"\n MIGLIORE S13 (squeeze+GBM, più complesso):")
print(f" {m['name']}{m['acc']:.1f}% acc, {m['trades']} trades, DD {m['max_dd']:.1f}%, €{m['daily_pnl']:+.2f}/day, Sharpe {m['sharpe']:.2f}")
max_pnl = max(strategies, key=lambda x: x["pnl"])
print(f"\n MAX PnL: {max_pnl['name']} — €{max_pnl['pnl']:+,.0f}")
+205
View File
@@ -0,0 +1,205 @@
"""AD01 — Adaptive Squeeze Threshold.
Problema SQ02: sq_threshold fisso (0.8) non si adatta al regime di volatilità.
Soluzione: threshold adattivo basato su volatilità recente.
Logica:
- Calcola volatilità rolling (std dei rendimenti su finestra 100 barre)
- Confronta con percentile storico (rolling 500 barre)
- Alta vol (>70° percentile) → soglia BASSA (0.65) — squeeze più "lenti"
- Bassa vol (<30° percentile) → soglia ALTA (0.90) — squeeze "stretti"
- Vol media → soglia standard (0.80)
Razionale: in mercati calmi, il BB si stringe molto → sq_threshold alto cattura
segnali migliori. In mercati volatili, bastano squeeze minori per essere significativi.
Anti-overfitting: solo 3 parametri (low_thr, mid_thr, high_thr), logica deterministica.
Eredita antifakeout + volume da SQ02.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
from src.strategies.indicators import keltner_ratio, ema
from src.data.downloader import load_data
def _adaptive_sq_threshold(close: np.ndarray,
vol_window: int = 100,
regime_window: int = 500,
low_thr: float = 0.65,
mid_thr: float = 0.80,
high_thr: float = 0.90) -> np.ndarray:
"""Calcola sq_threshold adattivo per ogni barra."""
n = len(close)
lr = np.diff(np.log(np.where(close <= 0, 1e-10, close)))
vol = np.full(n, np.nan)
for i in range(vol_window, n):
vol[i] = np.std(lr[i - vol_window:i])
# Percentile rolling della volatilità
thresh = np.full(n, mid_thr)
for i in range(regime_window, n):
if np.isnan(vol[i]):
continue
hist = vol[i - regime_window:i]
hist = hist[~np.isnan(hist)]
if len(hist) < 10:
continue
p30 = np.percentile(hist, 30)
p70 = np.percentile(hist, 70)
if vol[i] < p30:
thresh[i] = high_thr # vol bassa → soglia alta
elif vol[i] > p70:
thresh[i] = low_thr # vol alta → soglia bassa
else:
thresh[i] = mid_thr
return thresh
def _detect_adaptive_squeezes(close, high, low, kcr, adaptive_thr,
min_dur: int = 5) -> list[dict]:
"""Squeeze con threshold adattivo per ogni barra."""
events = []
in_sq = False
sq_start = 0
for i in range(1, len(close)):
if np.isnan(kcr[i]) or np.isnan(adaptive_thr[i]):
continue
thr = adaptive_thr[i]
is_sq = kcr[i] < thr
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
dur = i - sq_start
if dur < min_dur:
continue
events.append({
"idx": i, "dur": dur, "sq_start": sq_start,
"kcr_at_release": kcr[i],
"thr_used": adaptive_thr[i],
})
return events
class AdaptiveSqueeze(Strategy):
name = "AD01_adaptive_squeeze"
description = "Squeeze con threshold adattivo a regime volatilità"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
leverage = 3.0
position_size = 0.15
initial_capital = 1000.0
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
low_thr = params.get("low_thr", 0.65)
mid_thr = params.get("mid_thr", 0.80)
high_thr = params.get("high_thr", 0.90)
retrace_limit = params.get("retrace_limit", 0.6)
vol_mult = params.get("vol_multiplier", 1.3)
use_vol = params.get("use_vol", True)
vol_window = params.get("vol_window", 100)
regime_window = params.get("regime_window", 500)
kcr = keltner_ratio(c, h, l, bb_w)
adaptive_thr = _adaptive_sq_threshold(
c, vol_window, regime_window, low_thr, mid_thr, high_thr
)
events = _detect_adaptive_squeezes(c, h, l, kcr, adaptive_thr)
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= n:
continue
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
# Anti-fakeout
br = h[i] - l[i]
if br > 0:
if direction == 1 and (h[i] - c[i]) / br > retrace_limit:
continue
elif direction == -1 and (c[i] - l[i]) / br > retrace_limit:
continue
# Volume confirm
if use_vol:
sq_start = ev["sq_start"]
avg_sq_v = np.mean(v[sq_start:i])
if avg_sq_v > 0 and v[i] <= avg_sq_v * vol_mult:
continue
signals.append(Signal(
idx=i,
direction=direction,
entry_price=c[i - 1],
metadata={
"dur": ev["dur"],
"thr_used": ev.get("thr_used", mid_thr),
},
))
return signals
if __name__ == "__main__":
strategy = AdaptiveSqueeze()
configs = [
# low_thr, mid_thr, high_thr, use_vol
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.90, "use_vol": True},
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.90, "use_vol": False},
{"low_thr": 0.60, "mid_thr": 0.78, "high_thr": 0.92, "use_vol": True},
{"low_thr": 0.70, "mid_thr": 0.82, "high_thr": 0.90, "use_vol": True},
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.95, "use_vol": True},
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.90,
"use_vol": True, "vol_multiplier": 1.2},
]
all_results = []
for cfg in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **cfg)
if r and r.trades >= 20:
lbl = (f"AD01 lt={cfg['low_thr']} ht={cfg['high_thr']} "
f"v={cfg['use_vol']} h={hold}")
r.strategy_name = lbl
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(" AD01 ADAPTIVE SQUEEZE THRESHOLD — TOP 20")
print(f"{'=' * 130}")
print(f" {'Nome':<50s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Anni':>4s}")
print(f" {'' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250t, DD 6.5%, €5.23/day, 9 anni")
print(f" BENCHMARK MT01: 82.7% acc, 503t, DD 5.9%")
@@ -0,0 +1,183 @@
"""CM01 — Cross-Market Momentum Filter.
Squeeze su asset primario, entra SOLO se l'altro asset (BTC↔ETH)
mostra momentum short-term nella STESSA direzione.
Differenza da MT01: MT01 usa EMA slope su 1h (trend lento).
CM01 usa rendimento grezzo degli ultimi 3-6 bar sull'asset cross
(momentum veloce, stesso timeframe).
Razionale: BTC e ETH sono altamente correlati ma non perfettamente.
Se BTC fa squeeze breakout UP e anche ETH sta salendo (momentum 3-6 bar),
la probabilità di continuazione è maggiore perché c'è consenso di mercato.
Anti-overfitting: 1 parametro chiave (cross_bars 3-6), logica deterministica.
Eredita antifakeout + volume da SQ02.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats
from src.strategies.indicators import keltner_ratio, detect_squeezes
from src.data.downloader import load_data
class CrossMarketMomentum(Strategy):
name = "CM01_cross_momentum"
description = "Squeeze + cross-asset short-term momentum filter"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
leverage = 3.0
position_size = 0.15
initial_capital = 1000.0
# Map asset → cross asset
_CROSS = {"BTC": "ETH", "ETH": "BTC"}
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
"""Genera segnali con cross-market momentum."""
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
ts_ms = df["timestamp"].values
asset = params.get("asset", "BTC")
tf = params.get("tf", "15m")
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
retrace_limit = params.get("retrace_limit", 0.6)
vol_mult = params.get("vol_multiplier", 1.3)
use_vol = params.get("use_vol", True)
cross_bars = params.get("cross_bars", 4) # barre momentum cross
mom_min = params.get("mom_min", 0.0) # momentum minimo (0 = solo direzione)
# Carica cross asset
cross_asset = self._CROSS.get(asset)
if cross_asset is None:
return []
try:
df_cross = load_data(cross_asset, tf)
except Exception:
return []
c_cross = df_cross["close"].values
ts_cross_ms = df_cross["timestamp"].values
n_cross = len(c_cross)
# Momentum cross: rendimento log su cross_bars barre
cross_mom = np.full(n_cross, np.nan)
for i in range(cross_bars, n_cross):
if c_cross[i - cross_bars] > 0:
cross_mom[i] = np.log(c_cross[i] / c_cross[i - cross_bars])
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= n:
continue
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
# Anti-fakeout
br = h[i] - l[i]
if br > 0:
if direction == 1 and (h[i] - c[i]) / br > retrace_limit:
continue
elif direction == -1 and (c[i] - l[i]) / br > retrace_limit:
continue
# Volume confirm
if use_vol:
sq_start = ev["sq_start"]
avg_sq_v = np.mean(v[sq_start:i])
if avg_sq_v > 0 and v[i] <= avg_sq_v * vol_mult:
continue
# Cross-market momentum: trova indice cross corrispondente
i_cross = np.searchsorted(ts_cross_ms, ts_ms[i]) - 1
if i_cross < cross_bars or i_cross >= n_cross:
continue
mom = cross_mom[i_cross]
if np.isnan(mom):
continue
# Filtra per direzione concordante
if direction == 1 and mom <= mom_min:
continue
if direction == -1 and mom >= -mom_min:
continue
signals.append(Signal(
idx=i,
direction=direction,
entry_price=c[i - 1],
metadata={
"dur": ev["dur"],
"cross_mom": float(mom),
},
))
return signals
if __name__ == "__main__":
strategy = CrossMarketMomentum()
configs = [
# cross_bars, mom_min, use_vol
{"cross_bars": 3, "mom_min": 0.0, "use_vol": True},
{"cross_bars": 4, "mom_min": 0.0, "use_vol": True},
{"cross_bars": 6, "mom_min": 0.0, "use_vol": True},
{"cross_bars": 4, "mom_min": 0.001, "use_vol": True},
{"cross_bars": 4, "mom_min": 0.002, "use_vol": True},
{"cross_bars": 4, "mom_min": 0.0, "use_vol": False},
{"cross_bars": 3, "mom_min": 0.001, "use_vol": False},
{"cross_bars": 6, "mom_min": 0.001, "use_vol": True},
]
all_results = []
for cfg in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold,
cross_bars=cfg["cross_bars"],
mom_min=cfg["mom_min"],
use_vol=cfg["use_vol"])
if r and r.trades >= 20:
lbl = (f"CM01 cb={cfg['cross_bars']} "
f"mm={cfg['mom_min']} v={cfg['use_vol']} h={hold}")
r.strategy_name = lbl
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(" CM01 CROSS-MARKET MOMENTUM — TOP 20")
print(f"{'=' * 130}")
print(f" {'Nome':<50s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Anni':>4s}")
print(f" {'' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250t, DD 6.5%, €5.23/day, 9 anni")
print(f" BENCHMARK MT01: 82.7% acc, 503t, DD 5.9%")
+266
View File
@@ -0,0 +1,266 @@
"""ML01 — Squeeze + GBM (Gradient Boosting Machine) Walk-Forward.
Strategia ibrida: squeeze breakout come pre-filtro (QUANDO tradare),
GradientBoosting su features strutturali come conferma (QUALE direzione).
Pipeline:
1. Rileva squeeze release (Bollinger esce da Keltner)
2. Estrai 44 features dalla finestra (structural multi-window + squeeze
metadata + price position + ATR + momentum breakout)
3. GBM walk-forward: train su 50% rolling, step 10%, predice direzione
4. Trade solo se ML ha confidenza ≥ ml_threshold
IN:
- OHLCV DataFrame
- Parametri: bb_window (14), sq_threshold (0.8), brk_bars (3),
ml_threshold (0.70), leverage (3), position_pct (0.15)
OUT:
- BacktestResult con metriche walk-forward (no data leakage)
- Solo periodo di test (seconda metà dati)
Risultati tipici:
ETH 15m bb14 ml=0.70: 76.9% acc, 1213 trades, DD 4.2%, €13.78/day
BTC 15m bb14 ml=0.70: 78.8% acc, 1964 trades, DD 7.0%, €5.51/day
BTC 1h bb14 ml=0.70: 77.3% acc, 617 trades, DD 6.7%, €3.85/day
Note:
- GBM = GradientBoostingClassifier di scikit-learn
- Walk-forward: nessun look-ahead, train sempre prima di test
- Il baseline squeeze puro ha accuracy più alta (~79.5%) ma DD peggiore
- Il valore del ML è filtrare breakout deboli → DD ridotto
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.preprocessing import StandardScaler
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
from src.strategies.indicators import keltner_ratio, detect_squeezes
from src.data.downloader import load_data
def _build_features(df: pd.DataFrame, i: int, squeeze_info: dict) -> np.ndarray | None:
"""44 features per il punto di squeeze release."""
if i < 100:
return None
o, h, l, c, v = (df["open"].values, df["high"].values, df["low"].values,
df["close"].values, df["volume"].values)
feats = []
for w in [12, 24, 48]:
wc, wo = c[i-w:i], o[i-w:i]
wh, wl, wv = h[i-w:i], l[i-w:i], v[i-w:i]
mn, mx = wl.min(), max(wh.max(), wc.max())
rng = mx - mn if mx - mn > 0 else 1e-10
total = np.where(wh - wl == 0, 1e-10, wh - wl)
body = np.abs(wc - wo) / total
direction = np.sign(wc - wo)
log_c = np.log(np.where(wc == 0, 1e-10, wc))
rets = np.diff(log_c)
v_mean = np.mean(wv)
feats.extend([
np.mean(rets) if len(rets) > 0 else 0,
np.std(rets) if len(rets) > 0 else 0,
np.sum(rets) if len(rets) > 0 else 0,
float(pd.Series(rets).skew()) if len(rets) > 2 else 0,
float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0,
np.mean(body), np.std(body),
np.mean(direction), np.mean(direction[-min(3, w):]),
(wc[-1] - mn) / rng,
wv[-1] / v_mean if v_mean > 0 else 1,
np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0,
])
sq = squeeze_info
feats.extend([
sq["dur"], sq["dur"] / 24, sq["kcr_at_release"],
v[i-1] / sq.get("avg_vol", 1) if sq.get("avg_vol", 0) > 0 else 1,
np.mean(v[i:min(i+3, len(v))]) / sq.get("avg_vol", 1) if sq.get("avg_vol", 0) > 0 else 1,
])
h48, l48 = np.max(h[max(0, i-48):i]), np.min(l[max(0, i-48):i])
r48 = h48 - l48
feats.append((c[i-1] - l48) / r48 if r48 > 0 else 0.5)
tr = np.maximum(h[i-14:i] - l[i-14:i],
np.maximum(np.abs(h[i-14:i] - np.roll(c[i-14:i], 1)),
np.abs(l[i-14:i] - np.roll(c[i-14:i], 1))))
feats.append(np.mean(tr[1:]) / c[i-1] if c[i-1] > 0 else 0)
feats.append((c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0)
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
class SqueezeGBM(Strategy):
name = "ML01_squeeze_gbm"
description = "Squeeze + GBM walk-forward — ML filtra breakout deboli"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_ml = 0.001
def generate_signals(self, df, ts, **params):
raise NotImplementedError("ML01 usa backtest custom con walk-forward")
def backtest(self, asset: str, tf: str, hold: int = 3, **params) -> BacktestResult | None:
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8 if tf == "1h" else 0.9)
brk = params.get("brk_bars", hold)
ml_thr = params.get("ml_threshold", 0.70)
lev = params.get("leverage", self.leverage)
pos = params.get("position_pct", self.position_size)
df = load_data(asset, tf)
close = df["close"].values
high = df["high"].values
low = df["low"].values
volume = df["volume"].values
n = len(df)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
kcr = keltner_ratio(close, high, low, bb_w)
raw_events = detect_squeezes(close, high, low, kcr, sq_thr)
# Aggiungi avg_vol a ogni evento
events = []
for ev in raw_events:
ev["avg_vol"] = float(np.mean(volume[ev["sq_start"]:ev["idx"]]))
events.append(ev)
X_all, y_all, ev_all = [], [], []
for ev in events:
i = ev["idx"]
if i + brk >= n or i < 100:
continue
feats = _build_features(df, i, ev)
if feats is None:
continue
actual_ret = (close[i + brk - 1] - close[i - 1]) / close[i - 1]
X_all.append(feats)
y_all.append(1 if actual_ret > 0 else 0)
ev_all.append(ev)
if len(X_all) < 50:
return None
X, y = np.array(X_all), np.array(y_all)
TRAIN_SIZE = max(int(len(X) * 0.5), 50)
STEP_SIZE = max(int(len(X) * 0.1), 10)
yearly: dict[int, dict] = {}
capital = float(self.initial_capital)
peak = capital
max_dd = 0.0
total_bars = 0
all_t = all_w = 0
start = 0
while start + TRAIN_SIZE + STEP_SIZE <= len(X):
train_end = start + TRAIN_SIZE
test_end = min(train_end + STEP_SIZE, len(X))
X_tr, y_tr = X[start:train_end], y[start:train_end]
X_te = X[train_end:test_end]
if len(np.unique(y_tr)) < 2:
start += STEP_SIZE
continue
scaler = StandardScaler()
X_tr_s = scaler.fit_transform(X_tr)
X_te_s = scaler.transform(X_te)
model = GradientBoostingClassifier(
n_estimators=150, max_depth=4, min_samples_leaf=10,
learning_rate=0.05, subsample=0.8, random_state=42,
)
model.fit(X_tr_s, y_tr)
up_idx = list(model.classes_).index(1) if 1 in model.classes_ else -1
if up_idx < 0:
start += STEP_SIZE
continue
for j in range(len(X_te)):
proba = model.predict_proba(X_te_s[j:j+1])[0]
p_up = proba[up_idx]
ev = ev_all[train_end + j]
i = ev["idx"]
actual_ret = (close[i + brk - 1] - close[i - 1]) / close[i - 1]
if p_up >= ml_thr:
direction = 1
elif p_up <= (1 - ml_thr):
direction = -1
else:
continue
is_correct = (direction == 1 and actual_ret > 0) or (direction == -1 and actual_ret < 0)
trade_ret = actual_ret * direction
net = trade_ret * lev - self.fee_ml * 2 * lev
capital += capital * pos * net
capital = max(capital, 10)
if capital > peak:
peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
total_bars += brk
all_t += 1
if is_correct:
all_w += 1
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
yearly[year]["t"] += 1
if is_correct:
yearly[year]["w"] += 1
yearly[year]["pnl"] += net * self.initial_capital
start += STEP_SIZE
if all_t == 0:
return None
yearly_stats = [
YearlyStats(year=y, trades=d["t"], wins=d["w"], pnl=d["pnl"])
for y, d in sorted(yearly.items())
]
return BacktestResult(
strategy_name=self.name,
asset=asset,
timeframe=tf,
params={"bb_w": bb_w, "sq_thr": sq_thr, "ml_thr": ml_thr,
"brk": brk, "lev": lev, "pos": pos},
trades=all_t,
wins=all_w,
pnl=sum(d["pnl"] for d in yearly.values()),
capital=capital,
initial_capital=self.initial_capital,
max_dd=max_dd * 100,
time_in_market_pct=total_bars / n * 100,
avg_trade_duration_h=brk * TF_MINUTES.get(tf, 60) / 60,
years_active=len(yearly),
yearly=yearly_stats,
)
if __name__ == "__main__":
strategy = SqueezeGBM()
print("Training ML models...\n")
results = []
for asset in ["ETH", "BTC"]:
for tf in ["15m", "1h"]:
for ml_thr in [0.65, 0.70]:
r = strategy.backtest(asset, tf, ml_threshold=ml_thr)
if r and r.trades >= 20:
r.strategy_name = f"ML01 {asset} {tf} ml={ml_thr}"
results.append(r)
results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"{'=' * 120}")
print(f" ML01 SQUEEZE+GBM — RISULTATI")
print(f"{'=' * 120}")
for r in results:
r.print_summary()
if results:
results[0].print_yearly()
@@ -0,0 +1,259 @@
"""MT01 — Squeeze + Multi-Timeframe Momentum.
Problema SQ02: entra al breakout 15m ma non sa se il trend 1h è allineato.
Soluzione: squeeze su 15m + conferma momentum su 1h.
Anti-overfitting: usa solo 2 indicatori (squeeze + EMA slope),
nessun parametro complesso.
IN:
- OHLCV 15m + 1h per lo stesso asset
- Parametri: sq_threshold, ema_period_1h, min_slope
OUT:
- Signal al breakout 15m confermato da trend 1h
- BacktestResult
Logica:
1. Squeeze release su 15m (come SQ01)
2. Antifakeout filter (come SQ02)
3. Check 1h: EMA slope positiva per long, negativa per short
4. Check 1h: prezzo sopra/sotto EMA per conferma trend
5. Entra solo se 15m e 1h concordano
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
from src.strategies.indicators import keltner_ratio, detect_squeezes, ema
from src.data.downloader import load_data
class SqueezeMTFMomentum(Strategy):
name = "MT01_squeeze_mtf"
description = "Squeeze 15m + momentum trend 1h — multi-timeframe"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
"""Genera segnali squeeze 15m confermati da trend 1h."""
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
asset = params.get("asset", "BTC")
sq_thr = params.get("sq_threshold", 0.8)
ema_period = params.get("ema_period", 50)
min_slope_val = params.get("min_slope", 0.001)
use_antifake = params.get("antifake", True)
use_vol = params.get("vol_filter", False)
kcr = keltner_ratio(c, h, l, 14)
events = detect_squeezes(c, h, l, kcr, sq_thr)
df_1h = load_data(asset, "1h")
c1h = df_1h["close"].values
ts1h_ms = df_1h["timestamp"].values
n1h = len(c1h)
ema_1h = ema(c1h, ema_period)
ema_slope_arr = np.full(n1h, np.nan)
for i in range(5, n1h):
if not np.isnan(ema_1h[i]) and not np.isnan(ema_1h[i-5]) and ema_1h[i-5] > 0:
ema_slope_arr[i] = (ema_1h[i] - ema_1h[i-5]) / ema_1h[i-5]
ts_ms = df["timestamp"].values
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= n:
continue
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
if abs(first_ret) < 0.001:
continue
if use_antifake:
br = h[i] - l[i]
if br > 0:
if c[i] > c[i-1] and (h[i] - c[i]) / br > 0.6:
continue
elif c[i] <= c[i-1] and (c[i] - l[i]) / br > 0.6:
continue
if use_vol:
avg_v = np.mean(v[ev["sq_start"]:i])
if avg_v > 0 and v[i] <= avg_v * 1.3:
continue
direction = 1 if first_ret > 0 else -1
i1h = np.searchsorted(ts1h_ms, ts_ms[i]) - 1
if i1h < ema_period or i1h >= n1h:
continue
if np.isnan(ema_1h[i1h]) or np.isnan(ema_slope_arr[i1h]):
continue
if direction == 1:
if c1h[i1h] < ema_1h[i1h] or ema_slope_arr[i1h] < min_slope_val:
continue
else:
if c1h[i1h] > ema_1h[i1h] or ema_slope_arr[i1h] > -min_slope_val:
continue
signals.append(Signal(idx=i, direction=direction, entry_price=c[i-1]))
return signals
def backtest(self, asset, tf="15m", hold=3, **params):
sq_thr = params.get("sq_threshold", 0.8)
ema_period = params.get("ema_period", 50)
min_slope = params.get("min_slope", 0.001)
use_antifake = params.get("antifake", True)
use_vol = params.get("vol_filter", False)
# Carica 15m e 1h
df_15m = load_data(asset, "15m")
df_1h = load_data(asset, "1h")
c15 = df_15m["close"].values
h15 = df_15m["high"].values
l15 = df_15m["low"].values
v15 = df_15m["volume"].values
n15 = len(c15)
ts15 = pd.to_datetime(df_15m["timestamp"], unit="ms", utc=True)
ts15_ms = df_15m["timestamp"].values
c1h = df_1h["close"].values
ts1h_ms = df_1h["timestamp"].values
n1h = len(c1h)
kcr = keltner_ratio(c15, h15, l15, 14)
events = detect_squeezes(c15, h15, l15, kcr, sq_thr)
# EMA su 1h
ema_1h = ema(c1h, ema_period)
# EMA slope (variazione percentuale su 5 barre)
ema_slope = np.full(n1h, np.nan)
for i in range(5, n1h):
if not np.isnan(ema_1h[i]) and not np.isnan(ema_1h[i - 5]) and ema_1h[i - 5] > 0:
ema_slope[i] = (ema_1h[i] - ema_1h[i - 5]) / ema_1h[i - 5]
yearly = {}
capital = float(self.initial_capital)
peak = capital
max_dd = 0.0
total_bars = 0
for ev in events:
i = ev["idx"]
if i + hold + 1 >= n15 or i < 1:
continue
first_ret = (c15[i] - c15[i - 1]) / c15[i - 1] if c15[i - 1] > 0 else 0
if abs(first_ret) < 0.001:
continue
# Antifake
if use_antifake:
br = h15[i] - l15[i]
if br > 0:
if c15[i] > c15[i - 1] and (h15[i] - c15[i]) / br > 0.6:
continue
elif c15[i] <= c15[i - 1] and (c15[i] - l15[i]) / br > 0.6:
continue
# Volume filter
if use_vol:
avg_v = np.mean(v15[ev["sq_start"]:i])
if avg_v > 0 and v15[i] <= avg_v * 1.3:
continue
direction = 1 if first_ret > 0 else -1
# Trova indice 1h corrispondente
i1h = np.searchsorted(ts1h_ms, ts15_ms[i]) - 1
if i1h < ema_period or i1h >= n1h or np.isnan(ema_1h[i1h]) or np.isnan(ema_slope[i1h]):
continue
# Conferma trend 1h
if direction == 1:
if c1h[i1h] < ema_1h[i1h]:
continue
if ema_slope[i1h] < min_slope:
continue
else:
if c1h[i1h] > ema_1h[i1h]:
continue
if ema_slope[i1h] > -min_slope:
continue
entry = c15[i - 1]
exit_price = c15[min(i + hold - 1, n15 - 1)]
actual = (exit_price - entry) / entry * direction
net = actual * self.leverage - self.fee_rt * self.leverage
capital += capital * self.position_size * net
capital = max(capital, 10)
if capital > peak: peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
total_bars += hold
year = ts15.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
yearly[year]["t"] += 1
if actual > 0: yearly[year]["w"] += 1
yearly[year]["pnl"] += net * self.initial_capital
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t == 0:
return None
yearly_stats = [YearlyStats(y, d["t"], d["w"], d["pnl"]) for y, d in sorted(yearly.items())]
return BacktestResult(
strategy_name=self.name, asset=asset, timeframe="15m", params=params,
trades=all_t, wins=all_w, pnl=sum(d["pnl"] for d in yearly.values()),
capital=capital, initial_capital=self.initial_capital,
max_dd=max_dd * 100, time_in_market_pct=total_bars / n15 * 100,
avg_trade_duration_h=hold * 15 / 60, years_active=len(yearly), yearly=yearly_stats,
)
if __name__ == "__main__":
strategy = SqueezeMTFMomentum()
configs = [
("ema50 sl0.1%", {"ema_period": 50, "min_slope": 0.001}),
("ema50 sl0.05%", {"ema_period": 50, "min_slope": 0.0005}),
("ema50 sl0.2%", {"ema_period": 50, "min_slope": 0.002}),
("ema20 sl0.1%", {"ema_period": 20, "min_slope": 0.001}),
("ema50 sl0.1%+vol", {"ema_period": 50, "min_slope": 0.001, "vol_filter": True}),
("ema20 sl0.1%+vol", {"ema_period": 20, "min_slope": 0.001, "vol_filter": True}),
("ema50 noAF", {"ema_period": 50, "min_slope": 0.001, "antifake": False}),
("ema100 sl0.05%", {"ema_period": 100, "min_slope": 0.0005}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for hold in [3, 6]:
r = strategy.backtest(asset, "15m", hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"MT01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(f" MT01 SQUEEZE + MTF MOMENTUM — TOP 20")
print(f"{'=' * 130}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250t, DD 6.5%, 9 anni, €5.23/day")
@@ -0,0 +1,158 @@
"""PD01 — Price-Volume Divergence Squeeze.
Estende SQ02 con volume TREND come filtro:
- Breakout UP con volume CRESCENTE (ultimi 3 bar vs media squeeze) → ENTRA
- Breakout UP con volume CALANTE → SALTA (divergenza bearish)
- Viceversa per short
Logica anti-fakeout:
1. Squeeze rilascio (come SQ02)
2. Anti-fakeout candela (come SQ02)
3. Volume al breakout > media squeeze (come SQ02)
4. NUOVO: volume trending UP nelle ultime 3 barre prima del breakout
Parametri semplici, nessun overfitting.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.strategies.indicators import keltner_ratio, detect_squeezes
class PriceVolumeDivergence(Strategy):
name = "PD01_price_vol_div"
description = "Squeeze + antifakeout + volume trend confirmation"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
leverage = 3.0
position_size = 0.15
initial_capital = 1000.0
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
retrace_limit = params.get("retrace_limit", 0.6)
vol_mult = params.get("vol_multiplier", 1.3)
vol_trend_bars = params.get("vol_trend_bars", 3) # barre per trend volume
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
signals = []
for ev in events:
i = ev["idx"]
if i < vol_trend_bars + 1 or i >= n:
continue
# Direzione breakout
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
# Anti-fakeout
br = h[i] - l[i]
if br > 0:
if direction == 1 and (h[i] - c[i]) / br > retrace_limit:
continue
elif direction == -1 and (c[i] - l[i]) / br > retrace_limit:
continue
# Volume al breakout > media squeeze
sq_start = ev["sq_start"]
avg_sq_v = np.mean(v[sq_start:i])
if avg_sq_v <= 0 or v[i] <= avg_sq_v * vol_mult:
continue
# Volume TREND: slope delle ultime vol_trend_bars barre
# Usa regressione lineare semplice (rank correlation del volume)
recent_v = v[i - vol_trend_bars:i + 1] # include breakout bar
if len(recent_v) < vol_trend_bars:
continue
# slope: media seconda metà vs prima metà
mid = len(recent_v) // 2
v_early = np.mean(recent_v[:mid])
v_late = np.mean(recent_v[mid:])
vol_trending_up = v_late > v_early
vol_trending_down = v_early > v_late
# Concordanza: long richiede volume trending up, short trending down
if direction == 1 and not vol_trending_up:
continue
if direction == -1 and not vol_trending_down:
continue
signals.append(Signal(
idx=i,
direction=direction,
entry_price=c[i - 1],
metadata={
"dur": ev["dur"],
"vol_ratio": v[i] / avg_sq_v if avg_sq_v > 0 else 0,
"vol_trend": v_late / v_early if v_early > 0 else 1,
},
))
return signals
if __name__ == "__main__":
strategy = PriceVolumeDivergence()
configs = [
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.6,
"vol_multiplier": 1.3, "vol_trend_bars": 3},
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.6,
"vol_multiplier": 1.2, "vol_trend_bars": 3},
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.6,
"vol_multiplier": 1.3, "vol_trend_bars": 5},
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.5,
"vol_multiplier": 1.3, "vol_trend_bars": 3},
{"bb_window": 14, "sq_threshold": 0.75, "retrace_limit": 0.6,
"vol_multiplier": 1.3, "vol_trend_bars": 3},
{"bb_window": 20, "sq_threshold": 0.8, "retrace_limit": 0.6,
"vol_multiplier": 1.3, "vol_trend_bars": 3},
]
all_results = []
for cfg in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **cfg)
if r and r.trades >= 20:
lbl = (f"PD01 vtb={cfg['vol_trend_bars']} "
f"vm={cfg['vol_multiplier']} "
f"sq={cfg['sq_threshold']} h={hold}")
r.strategy_name = lbl
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(" PD01 PRICE-VOLUME DIVERGENCE — TOP 20")
print(f"{'=' * 130}")
print(f" {'Nome':<50s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Anni':>4s}")
print(f" {'' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250t, DD 6.5%, €5.23/day, 9 anni")
print(f" BENCHMARK MT01: 82.7% acc, 503t, DD 5.9%")
+68
View File
@@ -0,0 +1,68 @@
"""SQ01 — Squeeze Breakout Base.
Strategia strutturale: rileva compressione di volatilità (Bollinger dentro
Keltner Channel) e segue la direzione del breakout al rilascio.
IN:
- OHLCV DataFrame (da load_data)
- Parametri: bb_window (14), sq_threshold (0.8), min_squeeze_dur (5)
OUT:
- Lista di Signal con direzione breakout (+1/-1)
- BacktestResult con equity, yearly breakdown, metriche
Risultati tipici:
BTC 15m: 76.7% acc, 4062 trades, DD 6.7%, €9.32/day
ETH 15m: 76.4% acc, 2948 trades, DD 6.2%, €10.31/day
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.strategies.indicators import keltner_ratio, detect_squeezes
class SqueezeBase(Strategy):
name = "SQ01_squeeze_base"
description = "Squeeze breakout puro — segui direzione al rilascio"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
min_dur = params.get("min_dur", 5)
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr, min_dur)
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= n:
continue
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
if abs(first_ret) < 0.001:
continue
signals.append(Signal(
idx=i,
direction=1 if first_ret > 0 else -1,
entry_price=c[i - 1],
metadata={"dur": ev["dur"], "kcr": ev["kcr_at_release"]},
))
return signals
if __name__ == "__main__":
strategy = SqueezeBase()
strategy.report()
@@ -0,0 +1,87 @@
"""SQ02 — Squeeze Breakout + Anti-Fakeout + Volume Confirmation.
Migliora SQ01 con due filtri:
1. Anti-fakeout: scarta breakout dove la candela ritraccia >60% del range
2. Volume confirm: volume al breakout deve essere >1.3× la media durante squeeze
IN:
- OHLCV DataFrame
- Parametri: bb_window (14), sq_threshold (0.8), retrace_limit (0.6),
vol_multiplier (1.3)
OUT:
- Lista di Signal filtrati
- BacktestResult
Risultati tipici:
BTC 15m: 79.7% acc, 1250 trades, DD 6.5%, €5.23/day — SOLIDO 9/9 anni
ETH 15m: 78.6% acc, 942 trades, DD 3.4%, €4.33/day
BTC 1h: 78.0% acc, 473 trades, DD 3.5%, Sharpe 6.57
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.strategies.indicators import keltner_ratio, detect_squeezes
class SqueezeAntifakeVol(Strategy):
name = "SQ02_antifake_vol"
description = "Squeeze + antifakeout + volume confirmation"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
retrace_limit = params.get("retrace_limit", 0.6)
vol_mult = params.get("vol_multiplier", 1.3)
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= n:
continue
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
if abs(first_ret) < 0.001:
continue
br = h[i] - l[i]
if br > 0:
if c[i] > c[i - 1]:
if (h[i] - c[i]) / br > retrace_limit:
continue
else:
if (c[i] - l[i]) / br > retrace_limit:
continue
avg_v = np.mean(v[ev["sq_start"]:i])
if avg_v > 0 and v[i] <= avg_v * vol_mult:
continue
signals.append(Signal(
idx=i,
direction=1 if first_ret > 0 else -1,
entry_price=c[i - 1],
metadata={"dur": ev["dur"], "vol_ratio": v[i] / avg_v if avg_v > 0 else 0},
))
return signals
if __name__ == "__main__":
strategy = SqueezeAntifakeVol()
strategy.report()
@@ -0,0 +1,175 @@
"""SQ03 — Squeeze con filtri selezionabili.
Ogni filtro è opzionale e attivabile via parametro. Di default attiva solo
antifake + long_squeeze (i due filtri con miglior rapporto accuracy/trade).
Esegue tutte le combinazioni utili e classifica.
Filtri disponibili:
- antifake: scarta breakout con retrace >60% (guadagna ~+1% acc)
- long_sq: solo squeeze durata ≥10 barre (+1% acc, dimezza trade)
- timing: solo ore 4-16 UTC (+0.5% acc)
- cross: asset secondario in squeeze nelle ultime 10 barre (+0.5%)
- vol: volume al breakout >1.3× media squeeze (+1% acc)
IN:
- OHLCV DataFrame (primario + secondario per cross-check)
- Parametri: filters (lista), bb_window, sq_threshold
OUT:
- BacktestResult per ogni preset di filtri
Risultati tipici (BTC 15m):
antifake+long: 77.3% acc, 2179 trades
antifake+vol: 79.7% acc, 1250 trades — SOLIDO
ALL_FILTERS: 79.2% acc, 696 trades (restrittivo)
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
from src.strategies.indicators import keltner_ratio, detect_squeezes
from src.data.downloader import load_data
PRESETS = {
"antifake": ["antifake"],
"long_sq": ["long_sq"],
"antifake+long": ["antifake", "long_sq"],
"antifake+vol": ["antifake", "vol"],
"antifake+timing": ["antifake", "timing"],
"long+timing": ["long_sq", "timing"],
"antifake+long+time": ["antifake", "long_sq", "timing"],
"antifake+cross": ["antifake", "cross"],
"ALL_FILTERS": ["antifake", "long_sq", "timing", "cross"],
}
class SqueezeFiltered(Strategy):
name = "SQ03_filtered"
description = "Squeeze + filtri selezionabili (antifake, long, timing, cross, vol)"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
filters = params.get("filters", ["antifake", "long_sq"])
asset = params.get("asset", "BTC")
tf = params.get("tf", "15m")
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
kcr2 = None
ts2 = None
if "cross" in filters:
secondary = "ETH" if asset == "BTC" else "BTC"
df2 = load_data(secondary, tf)
kcr2 = keltner_ratio(df2["close"].values, df2["high"].values,
df2["low"].values, bb_w)
ts2 = df2["timestamp"].values
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= n:
continue
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
if abs(first_ret) < 0.001:
continue
skip = False
if "antifake" in filters:
br = h[i] - l[i]
if br > 0:
if c[i] > c[i - 1] and (h[i] - c[i]) / br > 0.6:
skip = True
elif c[i] <= c[i - 1] and (c[i] - l[i]) / br > 0.6:
skip = True
if not skip and "long_sq" in filters:
if ev["dur"] < 10:
skip = True
if not skip and "timing" in filters:
hour = ts.iloc[i].hour
if hour < 4 or hour > 16:
skip = True
if not skip and "vol" in filters:
avg_v = np.mean(v[ev["sq_start"]:i])
if avg_v > 0 and v[i] <= avg_v * 1.3:
skip = True
if not skip and "cross" in filters and kcr2 is not None and ts2 is not None:
i2 = np.searchsorted(ts2, ts.values[i].astype("int64") // 10**6)
i2 = min(i2, len(kcr2) - 1)
cross_ok = any(
not np.isnan(kcr2[j]) and kcr2[j] < 0.85
for j in range(max(0, i2 - 10), i2 + 1)
)
if not cross_ok:
skip = True
if skip:
continue
signals.append(Signal(
idx=i,
direction=1 if first_ret > 0 else -1,
entry_price=c[i - 1],
metadata={"dur": ev["dur"], "filters": filters},
))
return signals
def report_all_presets(self, assets=None, timeframes=None, hold=3):
"""Esegue tutti i preset di filtri × asset × tf."""
assets = assets or self.default_assets
timeframes = timeframes or self.default_timeframes
all_results = []
for preset_name, filter_list in PRESETS.items():
for asset in assets:
for tf in timeframes:
r = self.backtest(asset, tf, hold, filters=filter_list)
if r and r.trades >= 20:
r.strategy_name = f"SQ03 {preset_name}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" SQ03 SQUEEZE FILTRATO — TUTTI I PRESET ({len(all_results)} config)")
print(f" Fee: {self.fee_rt*100:.1f}% RT | Leva: {self.leverage:.0f}x | Pos: {self.position_size*100:.0f}%")
print(f"{'=' * 120}")
print(f" {'Nome':<30s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Worst':>12s} {'Anni':>4s}")
print(f" {'' * 110}")
for r in all_results:
r.print_summary()
if all_results:
print(f"\n MIGLIORE: ", end="")
best = all_results[0]
best.print_yearly()
return all_results
if __name__ == "__main__":
strategy = SqueezeFiltered()
strategy.report_all_presets()
+204
View File
@@ -0,0 +1,204 @@
"""SQ04 — Ultimate Squeeze — combinazione incrementale di tutti i filtri.
Testa combinazioni di filtri (antifake, long_sq, timing, cross-asset,
correlation, volume, trend alignment, volatility regime) e classifica
per accuracy.
IN:
- OHLCV DataFrame (primario + secondario)
- Parametri: bb_window, sq_threshold, lista filtri da attivare
OUT:
- BacktestResult per ogni combinazione di filtri
- Classifica globale
Risultati tipici:
BTC 15m antifake+corr: 81.6% acc (ma concentrato 2018)
BTC 15m antifake+vol: 79.7% acc, 1250 trades — robusto
ETH 1h antifake+corr: 80.7% acc (solo 2018)
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.strategies.indicators import (
keltner_ratio, detect_squeezes, ema, rv_annualized, rolling_correlation,
)
from src.data.downloader import load_data
class SqueezeUltimate(Strategy):
name = "SQ04_ultimate"
description = "Ultimate squeeze — tutti i filtri combinabili"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
FILTER_PRESETS = {
"antifake+vol": ["antifake", "vol_confirm"],
"antifake+corr": ["antifake", "corr_high"],
"af+long+corr+trend": ["antifake", "long_sq", "corr_high", "trend_align"],
"ALL": ["antifake", "long_sq", "cross", "timing", "corr_high",
"vol_confirm", "trend_align", "low_rv"],
}
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
asset = params.get("asset", "BTC")
tf = params.get("tf", "15m")
filters = params.get("filters", ["antifake", "vol_confirm"])
kcr = keltner_ratio(c, h, l, 14)
events = detect_squeezes(c, h, l, kcr)
secondary = "ETH" if asset == "BTC" else "BTC"
df2 = load_data(secondary, tf)
c2 = df2["close"].values
kcr2 = keltner_ratio(c2, df2["high"].values, df2["low"].values, 14)
ts2 = df2["timestamp"].values
ema_50 = ema(c, 50)
rv_48 = rv_annualized(c, 48)
corr = rolling_correlation(c, c2)
signals = []
for ev in events:
i = ev["idx"]
if i < 1 or i >= n:
continue
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
if abs(first_ret) < 0.001:
continue
skip = False
for f in filters:
if f == "antifake":
br = h[i] - l[i]
if br > 0:
if c[i] > c[i-1] and (h[i] - c[i]) / br > 0.6:
skip = True
elif c[i] <= c[i-1] and (c[i] - l[i]) / br > 0.6:
skip = True
elif f == "long_sq":
if ev["dur"] < 10:
skip = True
elif f == "timing":
if ts.iloc[i].hour < 4 or ts.iloc[i].hour > 16:
skip = True
elif f == "cross":
i2 = np.searchsorted(ts2, ts.values[i].astype("int64") // 10**6)
i2 = min(i2, len(kcr2) - 1)
if not any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85
for j in range(max(0, i2 - 10), i2 + 1)):
skip = True
elif f == "corr_high":
if np.isnan(corr[i]) or abs(corr[i]) < 0.6:
skip = True
elif f == "vol_confirm":
avg_v = np.mean(v[ev["sq_start"]:i])
if avg_v > 0 and v[i] <= avg_v * 1.3:
skip = True
elif f == "trend_align":
if not np.isnan(ema_50[i]):
if first_ret > 0 and c[i] < ema_50[i]:
skip = True
elif first_ret < 0 and c[i] > ema_50[i]:
skip = True
elif f == "low_rv":
if not np.isnan(rv_48[i]) and rv_48[i] >= 1.5:
skip = True
if skip:
break
if skip:
continue
signals.append(Signal(
idx=i,
direction=1 if first_ret > 0 else -1,
entry_price=c[i - 1],
metadata={"dur": ev["dur"], "filters": filters},
))
return signals
def backtest(self, asset: str, tf: str, hold: int = 3, **params):
params.setdefault("asset", asset)
params.setdefault("tf", tf)
df = load_data(asset, tf)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
signals = self.generate_signals(df, ts, **params)
# Usa il backtest della base ma passando i segnali già generati
from src.strategies.base import BacktestResult, YearlyStats, TF_MINUTES
c = df["close"].values
n = len(c)
yearly: dict[int, dict] = {}
capital = float(self.initial_capital)
peak = capital
max_dd = 0.0
total_bars = 0
for sig in signals:
i = sig.idx
if i + hold >= n or i < 1:
continue
entry = sig.entry_price
exit_price = c[min(i + hold - 1, n - 1)]
actual = (exit_price - entry) / entry * sig.direction
net = actual * self.leverage - self.fee_rt * self.leverage
capital += capital * self.position_size * net
capital = max(capital, 10)
if capital > peak: peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
total_bars += hold
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
yearly[year]["t"] += 1
if actual > 0: yearly[year]["w"] += 1
yearly[year]["pnl"] += net * self.initial_capital
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t == 0: return None
yearly_stats = [YearlyStats(y, d["t"], d["w"], d["pnl"]) for y, d in sorted(yearly.items())]
return BacktestResult(
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
trades=all_t, wins=all_w, pnl=sum(d["pnl"] for d in yearly.values()),
capital=capital, initial_capital=self.initial_capital,
max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100,
avg_trade_duration_h=hold * TF_MINUTES.get(tf, 60) / 60,
years_active=len(yearly), yearly=yearly_stats,
)
def report_all_presets(self):
"""Esegue tutte le combinazioni preset × asset × tf."""
all_results = []
for preset_name, filter_list in self.FILTER_PRESETS.items():
for asset in self.default_assets:
for tf in self.default_timeframes:
r = self.backtest(asset, tf, filters=filter_list)
if r and r.trades >= 20:
r.strategy_name = f"SQ04 {preset_name}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" SQ04 ULTIMATE — TUTTI I PRESET")
print(f"{'=' * 120}")
for r in all_results:
r.print_summary()
return all_results
if __name__ == "__main__":
strategy = SqueezeUltimate()
strategy.report_all_presets()
+317
View File
@@ -0,0 +1,317 @@
"""S3-01: Squeeze Migliorato — test per-anno, dati reali.
Miglioramenti rispetto al squeeze base:
1. Cross-asset: squeeze su BTC + ETH contemporaneo = segnale più forte
2. Timing orario: accuracy per fascia oraria
3. Squeeze duration weighted: squeeze lunghi → breakout più forti
4. Dual-timeframe: squeeze su 1h confermato da 15m
5. Anti-fakeout: skip se candela post-breakout ritraccia >50%
6. Dynamic exit: trailing stop basato su ATR
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.data.downloader import load_data
FEE_RT = 0.002
INITIAL = 1000
LEVERAGE = 3
def keltner_ratio(close, high, low, window=14):
n = len(close)
r = np.full(n, np.nan)
for i in range(window, n):
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
atr = np.mean(tr[1:])
kc = (ma+1.5*atr)-(ma-1.5*atr)
bb = (ma+2*bb_std)-(ma-2*bb_std)
if kc > 0:
r[i] = bb/kc
return r
def atr_calc(high, low, close, period=14):
tr = np.maximum(high-low, np.maximum(np.abs(high-np.roll(close,1)), np.abs(low-np.roll(close,1))))
tr[0] = high[0]-low[0]
r = np.full(len(close), np.nan)
r[period-1] = np.mean(tr[:period])
k = 2/(period+1)
for i in range(period, len(close)):
r[i] = tr[i]*k + r[i-1]*(1-k)
return r
def detect_squeezes(close, high, low, volume, kcr, sq_thr=0.8, min_dur=5):
"""Ritorna lista di squeeze events con metadata."""
events = []
in_sq = False
sq_start = 0
n = len(close)
for i in range(1, n):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
dur = i - sq_start
if dur < min_dur:
continue
avg_vol = np.mean(volume[sq_start:i])
# Range durante squeeze
sq_range = (np.max(high[sq_start:i]) - np.min(low[sq_start:i])) / close[sq_start] if close[sq_start] > 0 else 0
events.append({
"release_idx": i,
"duration": dur,
"avg_vol": avg_vol,
"squeeze_range": sq_range,
"kcr_at_release": kcr[i],
})
return events
def run_improved_squeeze(primary_asset, tf="1h"):
# Carica asset primario
df = load_data(primary_asset, tf)
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
n = len(df)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ts_ms = df["timestamp"].values
kcr = keltner_ratio(c, h, l, 14)
atr_14 = atr_calc(h, l, c, 14)
events = detect_squeezes(c, h, l, v, kcr)
# Carica asset secondario per cross-check
secondary = "BTC" if primary_asset == "ETH" else "ETH"
df2 = load_data(secondary, tf)
c2, h2, l2 = df2["close"].values, df2["high"].values, df2["low"].values
ts2_ms = df2["timestamp"].values
kcr2 = keltner_ratio(c2, h2, l2, 14)
# Mappa ts2 → indici per allineare
def find_idx2(ts_val):
idx = np.searchsorted(ts2_ms, ts_val)
return min(idx, len(c2)-1)
# Carica 15m per dual-TF
if tf == "1h":
df_15m = load_data(primary_asset, "15m")
c15 = df_15m["close"].values
h15 = df_15m["high"].values
l15 = df_15m["low"].values
ts15 = df_15m["timestamp"].values
kcr_15m = keltner_ratio(c15, h15, l15, 14)
else:
kcr_15m = None
ts15 = None
# ================================================================
# CONFIGURAZIONI
# ================================================================
configs = [
# (name, use_cross, use_timing, use_duration, use_dual_tf, use_antifake, use_trailing, hold, stop_atr)
("BASE", False, False, False, False, False, False, 3, 0),
("cross_asset", True, False, False, False, False, False, 3, 0),
("timing_filter", False, True, False, False, False, False, 3, 0),
("long_squeeze", False, False, True, False, False, False, 3, 0),
("dual_tf", False, False, False, True, False, False, 3, 0),
("anti_fakeout", False, False, False, False, True, False, 3, 0),
("trailing_stop", False, False, False, False, False, True, 6, 1.5),
("cross+timing", True, True, False, False, False, False, 3, 0),
("cross+long+timing", True, True, True, False, False, False, 3, 0),
("cross+dual_tf", True, False, False, True, False, False, 3, 0),
("ALL_FILTERS", True, True, True, True, True, False, 3, 0),
("ALL+trailing", True, True, True, True, True, True, 6, 1.5),
("cross+antifake", True, False, False, False, True, False, 3, 0),
("timing+antifake", False, True, False, False, True, False, 3, 0),
("cross+timing+antifk", True, True, False, False, True, False, 3, 0),
("cross+timing+trail", True, True, False, False, False, True, 6, 1.5),
]
print(f"\n{'#'*75}")
print(f" {primary_asset} {tf} — SQUEEZE MIGLIORATO")
print(f"{'#'*75}")
results = []
for name, f_cross, f_timing, f_dur, f_dual, f_antifake, f_trail, hold, stop_atr_m in configs:
yearly = {}
capital = float(INITIAL)
peak = capital
max_dd = 0
for ev in events:
i = ev["release_idx"]
if i + hold + 2 >= n:
continue
# --- FILTRI ---
skip = False
# Cross-asset: secondary deve anche essere in squeeze recente o breakout
if f_cross:
i2 = find_idx2(ts_ms[i])
if i2 >= 5:
sec_in_squeeze = any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85 for j in range(max(0,i2-10), i2+1))
if not sec_in_squeeze:
skip = True
# Timing: solo certe ore (testato: 6-14 UTC migliori)
if f_timing:
hour = ts.iloc[i].hour
if hour < 4 or hour > 16:
skip = True
# Duration: solo squeeze > 10 barre
if f_dur:
if ev["duration"] < 10:
skip = True
# Dual-TF: squeeze anche su 15m
if f_dual and kcr_15m is not None and ts15 is not None:
i15 = np.searchsorted(ts15, ts_ms[i])
if i15 >= 5:
sq_15m = any(not np.isnan(kcr_15m[j]) and kcr_15m[j] < 0.85 for j in range(max(0,i15-20), i15+1))
if not sq_15m:
skip = True
# Anti-fakeout: prima candela post-breakout non deve ritracciare >50%
if f_antifake and i + 1 < n:
breakout_bar_range = h[i] - l[i]
if breakout_bar_range > 0:
if c[i] > c[i-1]: # breakout up
retrace = (h[i] - c[i]) / breakout_bar_range
else: # breakout down
retrace = (c[i] - l[i]) / breakout_bar_range
if retrace > 0.6:
skip = True
if skip:
continue
# --- DIREZIONE ---
first_ret = (c[i] - c[i-1]) / c[i-1]
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
# --- EXIT ---
entry = c[i-1]
if f_trail and not np.isnan(atr_14[i]):
# Trailing stop
trail_dist = atr_14[i] * stop_atr_m
best_price = entry
exit_price = c[min(i+hold, n-1)]
for j in range(i, min(i+hold+1, n)):
if direction == 1:
best_price = max(best_price, h[j])
if l[j] <= best_price - trail_dist:
exit_price = best_price - trail_dist
break
else:
best_price = min(best_price, l[j])
if h[j] >= best_price + trail_dist:
exit_price = best_price + trail_dist
break
exit_price = c[j]
else:
exit_price = c[min(i+hold-1, n-1)]
actual = (exit_price - entry) / entry * direction
net = actual * LEVERAGE - FEE_RT * LEVERAGE
capital += capital * 0.15 * net
capital = max(capital, 10)
if capital > peak: peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"wins": 0, "total": 0, "pnls": []}
yearly[year]["total"] += 1
if actual > 0:
yearly[year]["wins"] += 1
yearly[year]["pnls"].append(net * INITIAL)
all_t = sum(d["total"] for d in yearly.values())
all_w = sum(d["wins"] for d in yearly.values())
if all_t < 30:
continue
acc = all_w / all_t * 100
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
tot_pnl = sum(all_pnls)
# Worst year
worst_y_acc = 100
worst_y = ""
for y, d in yearly.items():
ya = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
if ya < worst_y_acc:
worst_y_acc = ya
worst_y = str(y)
results.append({
"name": name, "trades": all_t, "acc": acc, "pnl": tot_pnl,
"max_dd": max_dd*100, "capital": capital,
"worst": f"{worst_y}({worst_y_acc:.0f}%)",
"yearly": yearly,
})
# Sort by accuracy
results.sort(key=lambda x: x["acc"], reverse=True)
print(f"\n {'Name':.<26s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>6s} {'Capital':>10s} {'Worst':>12s}")
print(f" {'-'*80}")
for r in results:
tag = "✅✅" if r["acc"] >= 80 else "" if r["acc"] >= 76 else ""
print(f" {r['name']:.<26s} {r['trades']:>7d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['max_dd']:>5.1f}% €{r['capital']:>9,.0f} {r['worst']:>12s} {tag}")
# Dettaglio per anno del migliore
if results:
best = results[0]
print(f"\n MIGLIORE: {best['name']}{best['acc']:.1f}% acc")
print(f" {'Anno':>6s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s}")
for y in sorted(best["yearly"]):
d = best["yearly"][y]
ya = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
yp = sum(d["pnls"])
tag = " ← CRASH" if y in [2020,2021,2022] else ""
print(f" {y:>6d} {d['total']:>7d} {ya:>5.1f}% €{yp:>+8.0f}{tag}")
return results
# Run su entrambi gli asset e timeframe
all_results = {}
for asset in ["ETH", "BTC"]:
for tf in ["1h", "15m"]:
key = f"{asset}_{tf}"
all_results[key] = run_improved_squeeze(asset, tf)
# Classifica globale
print(f"\n\n{'='*75}")
print(f" CLASSIFICA GLOBALE — TOP 15")
print(f"{'='*75}")
global_list = []
for key, results in all_results.items():
for r in results:
global_list.append({**r, "asset_tf": key})
global_list.sort(key=lambda x: x["acc"], reverse=True)
print(f"\n {'Asset_TF':.<12s} {'Name':.<26s} {'Trades':>6s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>5s} {'Worst':>12s}")
for r in global_list[:15]:
tag = "✅✅" if r["acc"] >= 80 else "" if r["acc"] >= 76 else ""
print(f" {r['asset_tf']:.<12s} {r['name']:.<26s} {r['trades']:>6d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['max_dd']:>4.1f}% {r['worst']:>12s} {tag}")
+290
View File
@@ -0,0 +1,290 @@
"""S3-02: Lead-lag multi-asset squeeze.
Quando BTC fa squeeze breakout, ETH/SOL spesso seguono.
Usa il breakout di BTC per anticipare entrata su ETH (e viceversa).
Testa anche correlazione inter-asset per conferma segnale.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.data.downloader import load_data
FEE_RT = 0.002
INITIAL = 1000
LEVERAGE = 3
def keltner_ratio(close, high, low, window=14):
n = len(close)
r = np.full(n, np.nan)
for i in range(window, n):
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
atr = np.mean(tr[1:])
kc = (ma+1.5*atr)-(ma-1.5*atr)
bb = (ma+2*bb_std)-(ma-2*bb_std)
if kc > 0: r[i] = bb/kc
return r
def load_aligned(assets, tf):
"""Carica e allinea dati multi-asset per timestamp."""
dfs = {}
for asset in assets:
try:
if asset == "SOL":
df = pd.read_parquet(f"data/raw/sol_{tf}.parquet")
df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
else:
df = load_data(asset, tf)
dfs[asset] = df
except Exception:
pass
if len(dfs) < 2:
return None
# Allinea per timestamp
common_ts = set(dfs[list(dfs.keys())[0]]["timestamp"].values)
for df in dfs.values():
common_ts &= set(df["timestamp"].values)
common_ts = sorted(common_ts)
aligned = {}
for asset, df in dfs.items():
mask = df["timestamp"].isin(common_ts)
aligned[asset] = df[mask].sort_values("timestamp").reset_index(drop=True)
return aligned
def detect_breakouts(close, high, low, volume, kcr, sq_thr=0.8, min_dur=5):
"""Detect squeeze breakout events."""
events = []
in_sq = False
sq_start = 0
for i in range(1, len(close)):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
if i - sq_start < min_dur:
continue
first_ret = (close[i] - close[i-1]) / close[i-1] if close[i-1] > 0 else 0
if abs(first_ret) < 0.001:
continue
events.append({
"idx": i,
"duration": i - sq_start,
"direction": 1 if first_ret > 0 else -1,
"first_ret": first_ret,
})
return events
print("=" * 75)
print(" S3-02: LEAD-LAG MULTI-ASSET SQUEEZE")
print("=" * 75)
for tf in ["1h", "15m"]:
aligned = load_aligned(["BTC", "ETH", "SOL"], tf)
if aligned is None:
continue
n = len(aligned["BTC"])
ts = pd.to_datetime(aligned["BTC"]["timestamp"], unit="ms", utc=True)
print(f"\n Timeframe: {tf}, Candles allineate: {n}")
# Calcola squeeze per ogni asset
asset_data = {}
for asset in aligned:
df = aligned[asset]
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
kcr = keltner_ratio(c, h, l, 14)
events = detect_breakouts(c, h, l, v, kcr)
asset_data[asset] = {"close": c, "high": h, "low": l, "vol": v, "kcr": kcr, "events": events}
print(f" {asset}: {len(events)} squeeze breakouts")
# ================================================================
# STRATEGIA A: Leader-follower
# Quando BTC fa breakout, entra su ETH/SOL nella stessa direzione
# ================================================================
print(f"\n --- LEADER-FOLLOWER ({tf}) ---")
for leader, follower in [("BTC", "ETH"), ("BTC", "SOL"), ("ETH", "BTC"), ("ETH", "SOL")]:
if leader not in asset_data or follower not in asset_data:
continue
leader_events = asset_data[leader]["events"]
fc = asset_data[follower]["close"]
for hold in [3, 6]:
for delay in [0, 1, 2]:
yearly = {}
for ev in leader_events:
i = ev["idx"] + delay
if i + hold >= n:
continue
# Anti-fakeout su follower
entry = fc[i]
exit_price = fc[min(i + hold, n - 1)]
direction = ev["direction"]
actual = (exit_price - entry) / entry * direction
net = actual * LEVERAGE - FEE_RT * LEVERAGE
year = ts.iloc[min(i, n-1)].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnls": []}
yearly[year]["t"] += 1
if actual > 0:
yearly[year]["w"] += 1
yearly[year]["pnls"].append(net * INITIAL)
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t < 30:
continue
acc = all_w / all_t * 100
pnl = sum(p for d in yearly.values() for p in d["pnls"])
worst_y = min(yearly.items(), key=lambda x: x[1]["w"]/x[1]["t"] if x[1]["t"]>0 else 0)
worst_acc = worst_y[1]["w"]/worst_y[1]["t"]*100 if worst_y[1]["t"]>0 else 0
tag = "" if acc >= 76 else ""
print(f" {leader}{follower} d={delay} h={hold}: trades={all_t:5d} acc={acc:.1f}% pnl=€{pnl:+.0f} worst={worst_y[0]}({worst_acc:.0f}%) {tag}")
# ================================================================
# STRATEGIA B: Consensus multi-asset
# Trade solo quando 2+ asset hanno squeeze breakout nello stesso momento
# ================================================================
print(f"\n --- CONSENSUS MULTI-ASSET ({tf}) ---")
# Build event map: timestamp → list of (asset, direction)
event_map = {}
for asset, data in asset_data.items():
for ev in data["events"]:
idx = ev["idx"]
if idx not in event_map:
event_map[idx] = []
event_map[idx].append((asset, ev["direction"]))
for target in ["BTC", "ETH", "SOL"]:
if target not in asset_data:
continue
tc = asset_data[target]["close"]
for min_consensus in [2, 3]:
for window_bars in [1, 3, 5]:
yearly = {}
daily_done = set()
for idx in sorted(event_map.keys()):
if idx + 6 >= n:
continue
day = ts.iloc[idx].strftime("%Y-%m-%d")
if day in daily_done:
continue
# Count consensus within window
nearby_events = []
for j in range(max(0, idx - window_bars), idx + window_bars + 1):
if j in event_map:
nearby_events.extend(event_map[j])
# Unique assets
unique_assets = set(a for a, d in nearby_events)
if len(unique_assets) < min_consensus:
continue
# Majority direction
dirs = [d for a, d in nearby_events]
majority = 1 if sum(dirs) > 0 else -1
entry = tc[idx]
exit_price = tc[min(idx + 3, n - 1)]
actual = (exit_price - entry) / entry * majority
net = actual * LEVERAGE - FEE_RT * LEVERAGE
year = ts.iloc[idx].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnls": []}
yearly[year]["t"] += 1
if actual > 0:
yearly[year]["w"] += 1
yearly[year]["pnls"].append(net * INITIAL)
daily_done.add(day)
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t < 20:
continue
acc = all_w / all_t * 100
pnl = sum(p for d in yearly.values() for p in d["pnls"])
tag = "" if acc >= 76 else ""
print(f" {target} consensus>={min_consensus} w={window_bars}: trades={all_t:4d} acc={acc:.1f}% pnl=€{pnl:+.0f} {tag}")
# ================================================================
# STRATEGIA C: Correlation-weighted squeeze
# Peso il segnale squeeze in base alla correlazione rolling con BTC
# ================================================================
print(f"\n --- CORRELATION-WEIGHTED ({tf}) ---")
for target in ["ETH", "SOL"]:
if target not in asset_data:
continue
tc = asset_data[target]["close"]
btc_c = asset_data["BTC"]["close"]
# Rolling correlation
corr_window = 48 # 48 bars
rolling_corr = np.full(n, np.nan)
ret_t = np.diff(np.log(np.where(tc == 0, 1e-10, tc)))
ret_b = np.diff(np.log(np.where(btc_c == 0, 1e-10, btc_c)))
for i in range(corr_window, len(ret_t)):
c_val = np.corrcoef(ret_t[i-corr_window:i], ret_b[i-corr_window:i])[0, 1]
rolling_corr[i + 1] = c_val if np.isfinite(c_val) else 0
events = asset_data[target]["events"]
for corr_thr in [0.5, 0.6, 0.7, 0.8]:
yearly = {}
for ev in events:
i = ev["idx"]
if i + 3 >= n or np.isnan(rolling_corr[i]):
continue
# Solo quando correlazione con BTC è alta
if abs(rolling_corr[i]) < corr_thr:
continue
entry = tc[i - 1]
exit_price = tc[min(i + 2, n - 1)]
actual = (exit_price - entry) / entry * ev["direction"]
net = actual * LEVERAGE - FEE_RT * LEVERAGE
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnls": []}
yearly[year]["t"] += 1
if actual > 0:
yearly[year]["w"] += 1
yearly[year]["pnls"].append(net * INITIAL)
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t < 20:
continue
acc = all_w / all_t * 100
pnl = sum(p for d in yearly.values() for p in d["pnls"])
tag = "" if acc >= 76 else ""
print(f" {target} corr>={corr_thr}: trades={all_t:4d} acc={acc:.1f}% pnl=€{pnl:+.0f} {tag}")
+256
View File
@@ -0,0 +1,256 @@
"""S3-03: Ultimate Squeeze — combina TUTTI i filtri migliori.
Filtri che funzionano (testati singolarmente):
- Anti-fakeout (+1% acc)
- Long squeeze duration (+1% acc)
- Cross-asset squeeze simultaneo (+0.5%)
- Timing 4-16 UTC (+0.5%)
- Correlation ETH-BTC alta per ETH trades (+1%)
- Volume confirmation al breakout
Nuovi filtri da testare:
- Volume delta: up_volume - down_volume al breakout
- Momentum confirmation: breakout nella direzione del trend 1h
- Volatility regime: skip in regime estremo (RV > 100%)
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.data.downloader import load_data
FEE_RT = 0.002
INITIAL = 1000
LEVERAGE = 3
def keltner_ratio(close, high, low, window=14):
n = len(close)
r = np.full(n, np.nan)
for i in range(window, n):
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
atr = np.mean(tr[1:])
kc = (ma+1.5*atr)-(ma-1.5*atr)
bb = (ma+2*bb_std)-(ma-2*bb_std)
if kc > 0: r[i] = bb/kc
return r
def ema(arr, period):
r = np.full(len(arr), np.nan)
k = 2/(period+1)
r[period-1] = np.mean(arr[:period])
for i in range(period, len(arr)):
r[i] = arr[i]*k + r[i-1]*(1-k)
return r
def rv_ann(close, window):
lr = np.diff(np.log(np.where(close == 0, 1e-10, close)))
r = np.full(len(close), np.nan)
for i in range(window, len(lr)):
r[i+1] = np.std(lr[i-window:i]) * np.sqrt(24*365)
return r
def run_ultimate(primary, tf="15m"):
secondary = "ETH" if primary == "BTC" else "BTC"
df = load_data(primary, tf)
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
n = len(df)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df2 = load_data(secondary, tf)
c2, ts2 = df2["close"].values, df2["timestamp"].values
kcr = keltner_ratio(c, h, l, 14)
kcr2 = keltner_ratio(c2, df2["high"].values, df2["low"].values, 14)
ema_50 = ema(c, 50)
rv_48 = rv_ann(c, 48)
# Rolling correlation
ret1 = np.diff(np.log(np.where(c == 0, 1e-10, c)))
ret2 = np.diff(np.log(np.where(c2[:len(c)] == 0, 1e-10, c2[:len(c)])))
min_len = min(len(ret1), len(ret2))
ret1 = ret1[:min_len]
ret2 = ret2[:min_len]
corr = np.full(n, np.nan)
for i in range(48, min_len):
cv = np.corrcoef(ret1[i-48:i], ret2[i-48:i])[0,1]
corr[i+1] = cv if np.isfinite(cv) else 0
# Detect squeezes
events = []
in_sq = False
sq_start = 0
for i in range(15, n):
if np.isnan(kcr[i]): continue
is_sq = kcr[i] < 0.8
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
dur = i - sq_start
if dur < 5 or i + 6 >= n:
continue
events.append({"idx": i, "dur": dur, "sq_start": sq_start})
print(f"\n{'#'*70}")
print(f" {primary} {tf} — ULTIMATE SQUEEZE ({len(events)} squeeze events)")
print(f"{'#'*70}")
filters_map = {
"antifake": lambda ev, i: not _antifake(c, h, l, i),
"long_sq": lambda ev, i: ev["dur"] >= 10,
"timing": lambda ev, i: 4 <= ts.iloc[i].hour <= 16,
"cross": lambda ev, i: _cross_squeeze(kcr2, i, ts, ts2),
"corr_high": lambda ev, i: not np.isnan(corr[i]) and abs(corr[i]) >= 0.6,
"vol_confirm": lambda ev, i: _vol_confirm(v, i, ev["sq_start"]),
"trend_align": lambda ev, i: _trend_align(c, ema_50, i),
"low_rv": lambda ev, i: not np.isnan(rv_48[i]) and rv_48[i] < 1.5,
}
def _antifake(c, h, l, i):
if i + 1 >= len(c): return False
br = h[i] - l[i]
if br <= 0: return False
if c[i] > c[i-1]:
return (h[i] - c[i]) / br > 0.6
return (c[i] - l[i]) / br > 0.6
def _cross_squeeze(kcr2, i, ts1, ts2_arr):
i2 = np.searchsorted(ts2_arr, ts.values[i].astype("int64") // 10**6)
i2 = min(i2, len(kcr2)-1)
return any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85 for j in range(max(0,i2-10), i2+1))
def _vol_confirm(v, i, sq_start):
avg = np.mean(v[sq_start:i])
return avg > 0 and v[i] > avg * 1.3
def _trend_align(c, ema_val, i):
if np.isnan(ema_val[i]): return True
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
if first_ret > 0:
return c[i] > ema_val[i]
return c[i] < ema_val[i]
# Test combinazioni incrementali
combos = [
("BASE", []),
("antifake", ["antifake"]),
("long_sq", ["long_sq"]),
("antifake+long", ["antifake", "long_sq"]),
("antifake+timing", ["antifake", "timing"]),
("antifake+cross", ["antifake", "cross"]),
("antifake+corr", ["antifake", "corr_high"]),
("antifake+vol", ["antifake", "vol_confirm"]),
("antifake+trend", ["antifake", "trend_align"]),
("af+long+timing", ["antifake", "long_sq", "timing"]),
("af+long+cross", ["antifake", "long_sq", "cross"]),
("af+long+corr", ["antifake", "long_sq", "corr_high"]),
("af+long+trend", ["antifake", "long_sq", "trend_align"]),
("af+long+cross+time", ["antifake", "long_sq", "cross", "timing"]),
("af+long+corr+time", ["antifake", "long_sq", "corr_high", "timing"]),
("af+long+corr+trend", ["antifake", "long_sq", "corr_high", "trend_align"]),
("ALL_NO_VOL", ["antifake", "long_sq", "cross", "timing", "corr_high", "trend_align", "low_rv"]),
("ALL", ["antifake", "long_sq", "cross", "timing", "corr_high", "vol_confirm", "trend_align", "low_rv"]),
("BEST_5", ["antifake", "long_sq", "corr_high", "trend_align", "low_rv"]),
]
results = []
for combo_name, filter_names in combos:
yearly = {}
capital = float(INITIAL)
peak = capital
max_dd = 0
for ev in events:
i = ev["idx"]
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
if abs(first_ret) < 0.001:
continue
skip = False
for fn in filter_names:
if fn in filters_map and not filters_map[fn](ev, i):
skip = True
break
if skip:
continue
direction = 1 if first_ret > 0 else -1
entry = c[i-1]
exit_price = c[min(i+2, n-1)]
actual = (exit_price - entry) / entry * direction
net = actual * LEVERAGE - FEE_RT * LEVERAGE
capital += capital * 0.15 * net
capital = max(capital, 10)
if capital > peak: peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnls": []}
yearly[year]["t"] += 1
if actual > 0: yearly[year]["w"] += 1
yearly[year]["pnls"].append(net * INITIAL)
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t < 20: continue
acc = all_w / all_t * 100
pnl = sum(p for d in yearly.values() for p in d["pnls"])
worst = min(yearly.items(), key=lambda x: x[1]["w"]/x[1]["t"] if x[1]["t"]>0 else 0)
wa = worst[1]["w"]/worst[1]["t"]*100 if worst[1]["t"]>0 else 0
results.append({
"name": combo_name, "trades": all_t, "acc": acc, "pnl": pnl,
"dd": max_dd*100, "capital": capital, "worst": f"{worst[0]}({wa:.0f}%)",
"yearly": yearly,
})
results.sort(key=lambda x: x["acc"], reverse=True)
print(f"\n {'Name':.<28s} {'Trades':>6s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>5s} {'Worst':>12s}")
print(f" {'-'*70}")
for r in results[:20]:
tag = "✅✅" if r["acc"] >= 80 else "" if r["acc"] >= 78 else ""
print(f" {r['name']:.<28s} {r['trades']:>6d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['dd']:>4.1f}% {r['worst']:>12s} {tag}")
# Dettaglio migliore
if results:
best = results[0]
print(f"\n MIGLIORE: {best['name']}{best['acc']:.1f}% acc, DD {best['dd']:.1f}%")
for y in sorted(best["yearly"]):
d = best["yearly"][y]
ya = d["w"]/d["t"]*100 if d["t"]>0 else 0
tag = " ← CRASH" if y in [2020,2021,2022] else ""
print(f" {y}: {d['t']:4d}t {ya:5.1f}% €{sum(d['pnls']):+.0f}{tag}")
return results
all_r = []
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
r = run_ultimate(asset, tf)
for x in r:
all_r.append({**x, "key": f"{asset}_{tf}"})
all_r.sort(key=lambda x: x["acc"], reverse=True)
print(f"\n\n{'='*70}")
print(f" TOP 10 GLOBALE")
print(f"{'='*70}")
for r in all_r[:10]:
tag = "✅✅" if r["acc"] >= 80 else "" if r["acc"] >= 78 else ""
print(f" {r['key']:.<10s} {r['name']:.<28s} {r['trades']:>5d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} DD {r['dd']:.1f}% {r['worst']:>12s} {tag}")
+131
View File
@@ -0,0 +1,131 @@
"""IB01 — Inside Bar Breakout.
Pattern di compressione a singola candela: quando una barra ha high < prev high
E low > prev low, il prezzo si sta comprimendo. Al breakout del range della
inside bar, segui la direzione.
17% delle candele 15m sono inside bars → frequenza altissima.
IN:
- OHLCV DataFrame
- Parametri: min_consecutive (N inside bars consecutivi),
volume_filter, breakout_confirm
OUT:
- Signal al breakout del range dell'inside bar
- BacktestResult
Logica:
1. Identifica N inside bars consecutivi (compressione)
2. Quando il prezzo rompe il range → entra nella direzione del breakout
3. Filtro: volume al breakout > media
4. Hold fisso
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
class InsideBarBreakout(Strategy):
name = "IB01_inside_bar"
description = "Inside bar breakout — compressione a singola candela"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
min_consec = params.get("min_consecutive", 2)
use_vol = params.get("vol_filter", False)
min_range_pct = params.get("min_range_pct", 0.002)
# Volume media
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
signals = []
consec = 0
mother_high = 0.0
mother_low = 0.0
for i in range(1, n - 1):
is_inside = h[i] <= h[i - 1] and l[i] >= l[i - 1]
if is_inside:
if consec == 0:
mother_high = h[i - 1]
mother_low = l[i - 1]
consec += 1
else:
if consec >= min_consec:
range_pct = (mother_high - mother_low) / mother_low if mother_low > 0 else 0
if range_pct < min_range_pct:
consec = 0
continue
# Breakout detection sulla barra corrente
if c[i] > mother_high:
direction = 1
elif c[i] < mother_low:
direction = -1
else:
consec = 0
continue
# Volume filter
if use_vol and not np.isnan(vol_ma[i]):
if v[i] < vol_ma[i] * 1.2:
consec = 0
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={"consec": consec, "range_pct": round(range_pct * 100, 3)},
))
consec = 0
return signals
if __name__ == "__main__":
strategy = InsideBarBreakout()
configs = [
("2ib", {"min_consecutive": 2}),
("3ib", {"min_consecutive": 3}),
("4ib", {"min_consecutive": 4}),
("2ib+vol", {"min_consecutive": 2, "vol_filter": True}),
("3ib+vol", {"min_consecutive": 3, "vol_filter": True}),
("2ib r>0.3%", {"min_consecutive": 2, "min_range_pct": 0.003}),
("3ib r>0.3%", {"min_consecutive": 3, "min_range_pct": 0.003}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"IB01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" IB01 INSIDE BAR BREAKOUT — TOP 20")
print(f"{'=' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+133
View File
@@ -0,0 +1,133 @@
"""DC01 — Donchian Channel Breakout con filtri.
Trend-following classico: quando il prezzo rompe il massimo/minimo degli
ultimi N periodi, entra nella direzione del breakout.
Completamente diverso dallo squeeze (che usa Bollinger/Keltner).
Donchian cattura breakout di RANGE, non di VOLATILITÀ.
IN:
- OHLCV DataFrame
- Parametri: channel_period, volume_filter, atr_stop, trend_filter
OUT:
- Signal al breakout del canale Donchian
- BacktestResult
Logica:
1. Donchian upper = max(high, N periodi), lower = min(low, N periodi)
2. Close > upper → LONG (breakout rialzista)
3. Close < lower → SHORT (breakout ribassista)
4. Filtri: volume, trend EMA, ATR minimo
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
class DonchianBreakout(Strategy):
name = "DC01_donchian"
description = "Donchian Channel breakout — trend-following su range"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
period = params.get("channel_period", 48)
use_vol = params.get("vol_filter", False)
use_trend = params.get("trend_filter", False)
cooldown = params.get("cooldown", 6)
# EMA per trend filter
ema_50 = np.full(n, np.nan)
k = 2 / 51
ema_50[49] = np.mean(c[:50])
for i in range(50, n):
ema_50[i] = c[i] * k + ema_50[i - 1] * (1 - k)
# Volume media
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
signals = []
last_signal_idx = -cooldown
for i in range(period + 1, n):
if i - last_signal_idx < cooldown:
continue
upper = np.max(h[i - period:i])
lower = np.min(l[i - period:i])
direction = 0
if c[i] > upper:
direction = 1
elif c[i] < lower:
direction = -1
if direction == 0:
continue
# Trend filter: breakout must align with EMA trend
if use_trend and not np.isnan(ema_50[i]):
if direction == 1 and c[i] < ema_50[i]:
continue
if direction == -1 and c[i] > ema_50[i]:
continue
# Volume filter
if use_vol and not np.isnan(vol_ma[i]):
if v[i] < vol_ma[i] * 1.3:
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={"upper": float(upper), "lower": float(lower)},
))
last_signal_idx = i
return signals
if __name__ == "__main__":
strategy = DonchianBreakout()
configs = [
("p=24", {"channel_period": 24}),
("p=48", {"channel_period": 48}),
("p=96", {"channel_period": 96}),
("p=48+trend", {"channel_period": 48, "trend_filter": True}),
("p=48+vol", {"channel_period": 48, "vol_filter": True}),
("p=48+t+v", {"channel_period": 48, "trend_filter": True, "vol_filter": True}),
("p=96+t+v", {"channel_period": 96, "trend_filter": True, "vol_filter": True}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6, 12]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"DC01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" DC01 DONCHIAN BREAKOUT — TOP 20")
print(f"{'=' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+163
View File
@@ -0,0 +1,163 @@
"""SB01 — Squeeze Breakout con Retest.
Il problema di SQ01/SQ02: entri al breakout, ma molti breakout sono fakeout.
Soluzione: aspetta il RETEST. Dopo il breakout, il prezzo spesso torna a
testare il livello di breakout prima di continuare.
Più selettivo di SQ02 → meno trade ma più accurati.
Anti-overfitting: meccanismo strutturale (retest è fenomeno di mercato reale).
IN:
- OHLCV DataFrame
- Parametri: bb_window, sq_threshold, retest_window (quante barre aspettare
il retest), retest_tolerance (quanto può tornare indietro)
OUT:
- Signal al retest confermato (non al breakout iniziale)
- BacktestResult
Logica:
1. Rileva squeeze release (come SQ01)
2. NON entrare subito — segna direzione e livello di breakout
3. Nelle N barre successive, aspetta che il prezzo torni verso il livello
4. Se il prezzo torna nel range di tolleranza e poi rimbalza → ENTRA
5. Se il prezzo non torna → skip (momentum troppo forte, entry persa)
6. Se il prezzo sfonda il livello → fakeout confermato, skip
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.strategies.indicators import keltner_ratio, detect_squeezes
class SqueezeBreakoutRetest(Strategy):
name = "SB01_squeeze_retest"
description = "Squeeze breakout con retest — entra solo dopo pullback confermato"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
retest_window = params.get("retest_window", 8)
retest_tol = params.get("retest_tolerance", 0.5)
use_vol = params.get("vol_filter", False)
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
signals = []
for ev in events:
brk_idx = ev["idx"]
if brk_idx + retest_window + 3 >= n or brk_idx < 1:
continue
# Direzione breakout
first_ret = (c[brk_idx] - c[brk_idx - 1]) / c[brk_idx - 1]
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
breakout_level = c[brk_idx - 1]
breakout_move = abs(first_ret)
# Aspetta retest nelle prossime N barre
retest_found = False
retest_idx = -1
for j in range(brk_idx + 1, min(brk_idx + retest_window + 1, n)):
if direction == 1:
# Long: il prezzo deve tornare GIÙ verso breakout_level
pullback = (h[brk_idx] - l[j]) / (h[brk_idx] - breakout_level) if h[brk_idx] > breakout_level else 0
if pullback >= retest_tol:
# Tornato abbastanza — ora deve rimbalzare
if c[j] > breakout_level:
retest_found = True
retest_idx = j
break
elif c[j] < breakout_level * 0.998:
# Sfondato sotto → fakeout
break
else:
# Short: il prezzo deve tornare SU verso breakout_level
pullback = (h[j] - l[brk_idx]) / (breakout_level - l[brk_idx]) if breakout_level > l[brk_idx] else 0
if pullback >= retest_tol:
if c[j] < breakout_level:
retest_found = True
retest_idx = j
break
elif c[j] > breakout_level * 1.002:
break
if not retest_found or retest_idx < 0:
continue
# Volume filter al retest
if use_vol and not np.isnan(vol_ma[retest_idx]):
if v[retest_idx] < vol_ma[retest_idx] * 0.8:
continue
signals.append(Signal(
idx=retest_idx, direction=direction,
entry_price=c[retest_idx],
metadata={
"breakout_idx": brk_idx,
"retest_bars": retest_idx - brk_idx,
"breakout_move": round(breakout_move * 100, 3),
},
))
return signals
if __name__ == "__main__":
strategy = SqueezeBreakoutRetest()
configs = [
("rt8 tol50%", {"retest_window": 8, "retest_tolerance": 0.5}),
("rt6 tol50%", {"retest_window": 6, "retest_tolerance": 0.5}),
("rt10 tol50%", {"retest_window": 10, "retest_tolerance": 0.5}),
("rt8 tol30%", {"retest_window": 8, "retest_tolerance": 0.3}),
("rt8 tol70%", {"retest_window": 8, "retest_tolerance": 0.7}),
("rt8 tol50%+vol", {"retest_window": 8, "retest_tolerance": 0.5, "vol_filter": True}),
("rt6 tol30%", {"retest_window": 6, "retest_tolerance": 0.3}),
("rt12 tol50%", {"retest_window": 12, "retest_tolerance": 0.5}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"SB01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(f" SB01 SQUEEZE BREAKOUT RETEST — TOP 25")
print(f"{'=' * 130}")
for r in all_results[:25]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
# Confronto con benchmark
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250 trades, DD 6.5%, 9/9 anni")
+148
View File
@@ -0,0 +1,148 @@
"""MR01 — Mean Reversion da estremi RSI.
Approccio opposto allo squeeze: quando il prezzo va troppo lontano troppo veloce,
scommetti che torni indietro. Autocorrelazione lag-1 negativa (-0.21 BTC, -0.35 ETH)
conferma che il mercato a 15m è mean-reverting.
IN:
- OHLCV DataFrame
- Parametri: rsi_period, rsi_oversold, rsi_overbought, hold_bars,
volume_filter (volume > N× media), atr_filter (move > N×ATR)
OUT:
- Signal: long quando RSI < oversold, short quando RSI > overbought
- BacktestResult con metriche
Logica:
1. RSI scende sotto soglia oversold → LONG (prezzo tornerà su)
2. RSI sale sopra soglia overbought → SHORT (prezzo tornerà giù)
3. Filtro opzionale: volume spike conferma l'eccesso
4. Filtro opzionale: move recente > N×ATR (eccesso di prezzo)
5. Hold fisso, poi chiudi
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
def rsi(close, period=14):
delta = np.diff(close)
gain = np.where(delta > 0, delta, 0)
loss = np.where(delta < 0, -delta, 0)
result = np.full(len(close), 50.0)
if len(gain) < period:
return result
ag = np.mean(gain[:period])
al = np.mean(loss[:period])
for i in range(period, len(delta)):
ag = (ag * (period - 1) + gain[i]) / period
al = (al * (period - 1) + loss[i]) / period
result[i + 1] = 100 if al == 0 else 100 - 100 / (1 + ag / al)
return result
class MeanReversionRSI(Strategy):
name = "MR01_mean_reversion_rsi"
description = "Mean reversion da estremi RSI — fade eccessi direzionali"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
rsi_period = params.get("rsi_period", 14)
oversold = params.get("rsi_oversold", 25)
overbought = params.get("rsi_overbought", 75)
use_vol_filter = params.get("vol_filter", False)
use_atr_filter = params.get("atr_filter", False)
cooldown = params.get("cooldown", 4)
rsi_vals = rsi(c, rsi_period)
# Volume media rolling
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
# ATR
tr = np.maximum(h[1:] - l[1:],
np.maximum(np.abs(h[1:] - c[:-1]), np.abs(l[1:] - c[:-1])))
atr_vals = np.full(n, np.nan)
for i in range(15, len(tr)):
atr_vals[i + 1] = np.mean(tr[i - 14:i])
signals = []
last_signal_idx = -cooldown
for i in range(20, n):
if i - last_signal_idx < cooldown:
continue
direction = 0
if rsi_vals[i] < oversold:
direction = 1 # oversold → long
elif rsi_vals[i] > overbought:
direction = -1 # overbought → short
if direction == 0:
continue
# Volume filter
if use_vol_filter and not np.isnan(vol_ma[i]):
if v[i] < vol_ma[i] * 1.5:
continue
# ATR filter: il move recente deve essere > 1.5× ATR
if use_atr_filter and not np.isnan(atr_vals[i]):
recent_move = abs(c[i] - c[max(0, i - 3)]) / c[max(0, i - 3)]
if recent_move < atr_vals[i] / c[i] * 1.5:
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={"rsi": float(rsi_vals[i])},
))
last_signal_idx = i
return signals
if __name__ == "__main__":
strategy = MeanReversionRSI()
configs = [
("RSI25/75", {}),
("RSI20/80", {"rsi_oversold": 20, "rsi_overbought": 80}),
("RSI25/75+vol", {"vol_filter": True}),
("RSI20/80+vol", {"rsi_oversold": 20, "rsi_overbought": 80, "vol_filter": True}),
("RSI25/75+atr", {"atr_filter": True}),
("RSI20/80+vol+atr", {"rsi_oversold": 20, "rsi_overbought": 80, "vol_filter": True, "atr_filter": True}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"MR01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" MR01 MEAN REVERSION RSI — TOP 20")
print(f"{'=' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+133
View File
@@ -0,0 +1,133 @@
"""VO01 — Volume Spike Reversal.
Quando il volume esplode (>3× media) con un forte move direzionale,
il mercato è in eccesso → fade il move (mean reversion).
Diverso dallo squeeze: non cerca compressione, cerca ECCESSO.
Il volume spike indica panico/euforia → reversal probabile.
IN:
- OHLCV DataFrame
- Parametri: vol_mult (3), move_threshold (0.005), hold
OUT:
- Signal: fade la direzione del volume spike
- BacktestResult
Logica:
1. Volume > vol_mult × media 20 periodi
2. Move nella candela > move_threshold (0.5%)
3. Direzione: opposta al move (mean reversion)
4. Filtro: non entrare se già in trend forte (EMA slope)
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
class VolumeSpikeReversal(Strategy):
name = "VO01_vol_spike_reversal"
description = "Volume spike reversal — fade eccessi di volume/prezzo"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
o = df["open"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
vol_mult = params.get("vol_mult", 3.0)
move_thr = params.get("move_threshold", 0.005)
use_trend_filter = params.get("trend_filter", False)
cooldown = params.get("cooldown", 4)
# Volume media rolling
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
# EMA per trend filter
ema_20 = np.full(n, np.nan)
k = 2 / 21
ema_20[19] = np.mean(c[:20])
for i in range(20, n):
ema_20[i] = c[i] * k + ema_20[i - 1] * (1 - k)
signals = []
last_idx = -cooldown
for i in range(21, n):
if i - last_idx < cooldown:
continue
if np.isnan(vol_ma[i]):
continue
# Volume spike
if v[i] < vol_ma[i] * vol_mult:
continue
# Price move
move = (c[i] - o[i]) / o[i] if o[i] > 0 else 0
if abs(move) < move_thr:
continue
# Fade: opposto al move
direction = -1 if move > 0 else 1
# Trend filter: non fare mean reversion contro trend forte
if use_trend_filter and not np.isnan(ema_20[i]):
ema_slope = (ema_20[i] - ema_20[max(0, i - 5)]) / ema_20[max(0, i - 5)]
if direction == -1 and ema_slope > 0.005:
continue
if direction == 1 and ema_slope < -0.005:
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={"vol_ratio": float(v[i] / vol_ma[i]), "move_pct": round(move * 100, 3)},
))
last_idx = i
return signals
if __name__ == "__main__":
strategy = VolumeSpikeReversal()
configs = [
("v3x m0.5%", {"vol_mult": 3.0, "move_threshold": 0.005}),
("v3x m1%", {"vol_mult": 3.0, "move_threshold": 0.01}),
("v4x m0.5%", {"vol_mult": 4.0, "move_threshold": 0.005}),
("v4x m1%", {"vol_mult": 4.0, "move_threshold": 0.01}),
("v3x m0.5%+tf", {"vol_mult": 3.0, "move_threshold": 0.005, "trend_filter": True}),
("v3x m1%+tf", {"vol_mult": 3.0, "move_threshold": 0.01, "trend_filter": True}),
("v5x m1%", {"vol_mult": 5.0, "move_threshold": 0.01}),
("v5x m1%+tf", {"vol_mult": 5.0, "move_threshold": 0.01, "trend_filter": True}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"VO01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" VO01 VOLUME SPIKE REVERSAL — TOP 20")
print(f"{'=' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+169
View File
@@ -0,0 +1,169 @@
"""HY01 — Squeeze + Mean Reversion Ibrida.
Insight: durante lo squeeze (bassa volatilità), il prezzo mean-reverte
DENTRO il range compresso. Autocorrelazione negativa a 15m conferma.
Invece di aspettare il BREAKOUT, tradi la MEAN REVERSION dentro lo squeeze.
Completamente diverso da SQ01-SQ04 che aspettano il RILASCIO.
IN:
- OHLCV DataFrame
- Parametri: bb_window, sq_threshold, rsi_period, rsi_levels,
vol_filter, bb_touch (prezzo tocca banda Bollinger)
OUT:
- Signal: long quando RSI oversold DURANTE squeeze, short quando overbought
- BacktestResult
Logica:
1. Verifica che siamo IN squeeze (BB dentro KC)
2. Prezzo tocca banda inferiore BB → LONG (tornerà alla media)
3. Prezzo tocca banda superiore BB → SHORT (tornerà alla media)
4. Conferma RSI: deve essere estremo nella direzione
5. Hold corto (2-3 barre) — target: ritorno alla media
6. Stop: se prezzo rompe lo squeeze → chiudi subito
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.strategies.indicators import keltner_ratio
def rsi(close, period=14):
delta = np.diff(close)
gain = np.where(delta > 0, delta, 0)
loss = np.where(delta < 0, -delta, 0)
result = np.full(len(close), 50.0)
if len(gain) < period:
return result
ag = np.mean(gain[:period])
al = np.mean(loss[:period])
for i in range(period, len(delta)):
ag = (ag * (period - 1) + gain[i]) / period
al = (al * (period - 1) + loss[i]) / period
result[i + 1] = 100 if al == 0 else 100 - 100 / (1 + ag / al)
return result
def bollinger(close, window=14):
n = len(close)
upper = np.full(n, np.nan)
lower = np.full(n, np.nan)
mid = np.full(n, np.nan)
for i in range(window, n):
wc = close[i - window:i]
m = np.mean(wc)
s = np.std(wc)
mid[i] = m
upper[i] = m + 2 * s
lower[i] = m - 2 * s
return upper, mid, lower
class SqueezeMeanReversion(Strategy):
name = "HY01_squeeze_mr"
description = "Mean reversion DENTRO lo squeeze — fade estremi in range compresso"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
rsi_period = params.get("rsi_period", 14)
rsi_low = params.get("rsi_oversold", 30)
rsi_high = params.get("rsi_overbought", 70)
use_bb_touch = params.get("bb_touch", True)
cooldown = params.get("cooldown", 3)
kcr = keltner_ratio(c, h, l, bb_w)
rsi_vals = rsi(c, rsi_period)
bb_upper, bb_mid, bb_lower = bollinger(c, bb_w)
signals = []
last_idx = -cooldown
for i in range(bb_w + 1, n):
if i - last_idx < cooldown:
continue
if np.isnan(kcr[i]) or np.isnan(bb_lower[i]):
continue
# Must be IN squeeze
if kcr[i] >= sq_thr:
continue
direction = 0
if use_bb_touch:
# Prezzo tocca/rompe BB lower → long (mean reversion up)
if c[i] <= bb_lower[i] and rsi_vals[i] < rsi_low:
direction = 1
# Prezzo tocca/rompe BB upper → short (mean reversion down)
elif c[i] >= bb_upper[i] and rsi_vals[i] > rsi_high:
direction = -1
else:
# Solo RSI
if rsi_vals[i] < rsi_low:
direction = 1
elif rsi_vals[i] > rsi_high:
direction = -1
if direction == 0:
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={
"rsi": float(rsi_vals[i]),
"kcr": float(kcr[i]),
"bb_pos": "lower" if direction == 1 else "upper",
},
))
last_idx = i
return signals
if __name__ == "__main__":
strategy = SqueezeMeanReversion()
configs = [
("bb+rsi30/70", {"bb_touch": True, "rsi_oversold": 30, "rsi_overbought": 70}),
("bb+rsi25/75", {"bb_touch": True, "rsi_oversold": 25, "rsi_overbought": 75}),
("bb+rsi35/65", {"bb_touch": True, "rsi_oversold": 35, "rsi_overbought": 65}),
("rsi30/70 only", {"bb_touch": False, "rsi_oversold": 30, "rsi_overbought": 70}),
("rsi25/75 only", {"bb_touch": False, "rsi_oversold": 25, "rsi_overbought": 75}),
("sq<0.7 bb+rsi30", {"bb_touch": True, "sq_threshold": 0.7, "rsi_oversold": 30, "rsi_overbought": 70}),
("sq<0.9 bb+rsi30", {"bb_touch": True, "sq_threshold": 0.9, "rsi_oversold": 30, "rsi_overbought": 70}),
("sq<0.9 rsi35/65", {"bb_touch": False, "sq_threshold": 0.9, "rsi_oversold": 35, "rsi_overbought": 65}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [2, 3, 4]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"HY01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(f" HY01 SQUEEZE MEAN REVERSION — TOP 25")
print(f"{'=' * 130}")
for r in all_results[:25]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+271
View File
@@ -0,0 +1,271 @@
"""Multi-Strategy Paper Trader — orchestratore per N strategie in parallelo."""
from __future__ import annotations
import time
import yaml
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pandas as pd
from src.live.cerbero_client import CerberoClient
from src.live.strategy_loader import load_strategy
from src.live.strategy_worker import StrategyWorker
from src.live.signal_engine import SignalEngine
from src.live.telegram_notifier import send_telegram
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = PROJECT_ROOT / "data" / "paper_trades"
RESOLUTION_MAP = {"15m": "15", "1h": "60", "5m": "5"}
INSTRUMENT_MAP = {
"BTC": "BTC-PERPETUAL",
"ETH": "ETH-PERPETUAL",
}
class MLWorkerWrapper:
"""Wrapper speciale per ML01 che usa SignalEngine con training."""
def __init__(self, worker: StrategyWorker, config: dict):
self.worker = worker
self.engine = SignalEngine(
bb_w=config.get("params", {}).get("bb_window", 14),
sq_thr=config.get("params", {}).get("sq_threshold", 0.8),
ml_thr=config.get("params", {}).get("ml_threshold", 0.70),
)
self.trained = False
self.last_train: datetime | None = None
self.retrain_hours = config.get("retrain_hours", 24)
def needs_training(self) -> bool:
if not self.trained:
return True
if self.last_train is None:
return True
elapsed = (datetime.now(timezone.utc) - self.last_train).total_seconds()
return elapsed > self.retrain_hours * 3600
def train(self, df: pd.DataFrame, hold: int = 3):
result = self.engine.train(df, lookahead=hold)
if "error" not in result:
self.trained = True
self.last_train = datetime.now(timezone.utc)
print(f" [{self.worker.worker_id}] TRAIN OK: {result}")
else:
print(f" [{self.worker.worker_id}] TRAIN FAIL: {result}")
def tick(self, df: pd.DataFrame):
if not self.trained:
return
worker = self.worker
c = df["close"].values
current_price = float(c[-1])
current_ts = int(df["timestamp"].iloc[-1])
if worker.in_position:
if current_ts > worker.last_bar_ts:
worker.bars_held += 1
worker.last_bar_ts = current_ts
if worker.bars_held >= worker.hold_bars:
worker._close_position(current_price, "hold_limit")
else:
pnl_pct = (current_price - worker.entry_price) / worker.entry_price * worker.direction
if pnl_pct <= -0.02:
worker._close_position(current_price, "stop_loss")
worker._save_state()
return
signal = self.engine.check_signal(df)
if signal:
from src.strategies.base import Signal
direction = 1 if signal["direction"] == "buy" else -1
sig = Signal(idx=len(df)-1, direction=direction, entry_price=current_price)
worker._open_position(sig, current_price)
worker.last_bar_ts = current_ts
worker._save_state()
def load_config(path: Path) -> dict:
with open(path) as f:
return yaml.safe_load(f)
def build_workers(config: dict) -> tuple[list[StrategyWorker], list[MLWorkerWrapper]]:
"""Crea worker da config YAML."""
defaults = config.get("defaults", {})
regular_workers: list[StrategyWorker] = []
ml_workers: list[MLWorkerWrapper] = []
for entry in config.get("strategies", []):
if not entry.get("enabled", True):
continue
name = entry["name"]
asset = entry["asset"]
tf = entry["tf"]
capital = entry.get("capital", defaults.get("capital", 1000))
pos_size = entry.get("position_size", defaults.get("position_size", 0.15))
leverage = entry.get("leverage", defaults.get("leverage", 3))
hold = entry.get("hold_bars", defaults.get("hold_bars", 3))
params = entry.get("params", {})
strategy = load_strategy(name)
worker = StrategyWorker(
strategy=strategy, asset=asset, tf=tf,
capital=capital, position_size=pos_size,
leverage=leverage, hold_bars=hold,
params=params, data_dir=DATA_DIR,
)
if name == "ML01_squeeze_gbm":
ml_wrapper = MLWorkerWrapper(worker, {**defaults, **entry})
ml_workers.append(ml_wrapper)
else:
regular_workers.append(worker)
return regular_workers, ml_workers
def run():
config_path = PROJECT_ROOT / "strategies.yml"
if not config_path.exists():
print(f"ERRORE: {config_path} non trovato")
return
config = load_config(config_path)
defaults = config.get("defaults", {})
poll_seconds = defaults.get("poll_seconds", 60)
lookback_days = 60
train_lookback_days = 365
regular_workers, ml_workers = build_workers(config)
all_worker_count = len(regular_workers) + len(ml_workers)
if all_worker_count == 0:
print("Nessuna strategia abilitata in strategies.yml")
return
client = CerberoClient()
print("=" * 70)
print(f" MULTI-STRATEGY PAPER TRADER")
print(f" Strategie attive: {all_worker_count}")
print(f" Poll: ogni {poll_seconds}s")
print(f" Data dir: {DATA_DIR}")
print("=" * 70)
for w in regular_workers:
print(f"{w.status_summary}")
for mw in ml_workers:
print(f"{mw.worker.status_summary} [ML]")
send_telegram(f"🚀 Multi-Strategy avviato: {all_worker_count} strategie")
# Raccogli asset/tf unici per fetch raggruppato
def _get_data_keys() -> set[tuple[str, str]]:
keys = set()
for w in regular_workers:
keys.add((w.asset, w.tf))
for mw in ml_workers:
keys.add((mw.worker.asset, mw.worker.tf))
return keys
# Training iniziale ML
for mw in ml_workers:
asset = mw.worker.asset
instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
resolution = RESOLUTION_MAP.get(mw.worker.tf, "15")
end = datetime.now(timezone.utc)
start = end - timedelta(days=train_lookback_days)
candles = client.get_historical(instrument, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), resolution)
if candles:
df_train = pd.DataFrame(candles)
df_train["timestamp"] = df_train["timestamp"].astype("int64")
df_train = df_train.sort_values("timestamp").reset_index(drop=True)
mw.train(df_train, hold=mw.worker.hold_bars)
while True:
try:
data_keys = _get_data_keys()
candle_cache: dict[tuple[str, str], pd.DataFrame] = {}
for asset, tf in data_keys:
instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
resolution = RESOLUTION_MAP.get(tf, "15")
end = datetime.now(timezone.utc)
start = end - timedelta(days=lookback_days)
candles = client.get_historical(
instrument, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), resolution,
)
if candles:
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
df = df.sort_values("timestamp").reset_index(drop=True)
candle_cache[(asset, tf)] = df
# Tick regular workers
for w in regular_workers:
key = (w.asset, w.tf)
if key in candle_cache:
try:
w.tick(candle_cache[key])
except Exception as e:
print(f" [{w.worker_id}] ERRORE: {e}")
# Tick ML workers
for mw in ml_workers:
key = (mw.worker.asset, mw.worker.tf)
if key not in candle_cache:
continue
if mw.needs_training():
mw.train(candle_cache[key], hold=mw.worker.hold_bars)
try:
mw.tick(candle_cache[key])
except Exception as e:
print(f" [{mw.worker.worker_id}] ERRORE: {e}")
# Status periodico
now = datetime.now(timezone.utc)
if now.minute == 0 and now.second < poll_seconds:
lines = [f"📊 Status {now.strftime('%H:%M')} UTC"]
for w in regular_workers:
lines.append(f" {w.status_summary}")
for mw in ml_workers:
lines.append(f" {mw.worker.status_summary} [ML]")
send_telegram("\n".join(lines))
except KeyboardInterrupt:
print("\nShutdown...")
for w in regular_workers:
if w.in_position:
df = candle_cache.get((w.asset, w.tf))
if df is not None and not df.empty:
w._close_position(float(df["close"].iloc[-1]), "shutdown")
w._save_state()
for mw in ml_workers:
if mw.worker.in_position:
df = candle_cache.get((mw.worker.asset, mw.worker.tf))
if df is not None and not df.empty:
mw.worker._close_position(float(df["close"].iloc[-1]), "shutdown")
mw.worker._save_state()
send_telegram("🛑 Multi-Strategy arrestato")
break
except Exception as e:
print(f" ERRORE GLOBALE: {e}")
import traceback
traceback.print_exc()
time.sleep(poll_seconds)
if __name__ == "__main__":
run()
+2
View File
@@ -10,6 +10,7 @@ import pandas as pd
from src.live.cerbero_client import CerberoClient
from src.live.signal_engine import SignalEngine
from src.live.telegram_notifier import notify_event
LOG_DIR = Path(__file__).resolve().parents[2] / "data" / "paper_trades"
INSTRUMENT = "ETH_USDC-PERPETUAL"
@@ -52,6 +53,7 @@ class PaperTrader:
with open(self.log_path, "a") as f:
f.write(json.dumps(entry) + "\n")
print(f" [{entry['timestamp'][:19]}] {event}: {json.dumps(data or {})}")
notify_event(event, data)
def save_status(self):
status = {
+52
View File
@@ -0,0 +1,52 @@
"""Import dinamico delle classi Strategy da scripts/strategies/."""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
from src.strategies.base import Strategy
PROJECT_ROOT = Path(__file__).resolve().parents[2]
STRATEGIES_DIR = PROJECT_ROOT / "scripts" / "strategies"
_REGISTRY: dict[str, type[Strategy]] = {}
MODULE_MAP = {
"SQ01_squeeze_base": ("SQ01_squeeze_base", "SqueezeBase"),
"SQ02_antifake_vol": ("SQ02_squeeze_antifake_vol", "SqueezeAntifakeVol"),
"SQ03_filtered": ("SQ03_squeeze_all_filters", "SqueezeFiltered"),
"SQ04_ultimate": ("SQ04_squeeze_ultimate", "SqueezeUltimate"),
"ML01_squeeze_gbm": ("ML01_squeeze_gbm", "SqueezeGBM"),
"MT01_squeeze_mtf": ("MT01_squeeze_mtf_momentum", "SqueezeMTFMomentum"),
}
def load_strategy(name: str) -> Strategy:
"""Carica e istanzia una Strategy per nome."""
if name in _REGISTRY:
return _REGISTRY[name]()
if name not in MODULE_MAP:
raise ValueError(f"Strategia sconosciuta: {name}. Disponibili: {list(MODULE_MAP)}")
module_file, class_name = MODULE_MAP[name]
module_path = STRATEGIES_DIR / f"{module_file}.py"
if not module_path.exists():
raise FileNotFoundError(f"File strategia non trovato: {module_path}")
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
spec = importlib.util.spec_from_file_location(f"strategies.{module_file}", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
cls = getattr(module, class_name)
_REGISTRY[name] = cls
return cls()
def list_available() -> list[str]:
return list(MODULE_MAP.keys())
+226
View File
@@ -0,0 +1,226 @@
"""Worker per singola strategia — paper trading con stato persistente."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.live.telegram_notifier import notify_event
FEE_RT = 0.002
class StrategyWorker:
"""Gestisce paper trading per una singola strategia/asset/tf."""
def __init__(
self,
strategy: Strategy,
asset: str,
tf: str,
capital: float = 1000.0,
position_size: float = 0.15,
leverage: float = 3.0,
hold_bars: int = 3,
params: dict | None = None,
data_dir: Path = Path("data/paper_trades"),
):
self.strategy = strategy
self.asset = asset
self.tf = tf
self.initial_capital = capital
self.position_size = position_size
self.leverage = leverage
self.hold_bars = hold_bars
self.params = params or {}
self.worker_id = f"{strategy.name}__{asset}__{tf}"
self.work_dir = data_dir / self.worker_id
self.work_dir.mkdir(parents=True, exist_ok=True)
self.trades_path = self.work_dir / "trades.jsonl"
self.status_path = self.work_dir / "status.json"
self.capital = capital
self.in_position = False
self.direction: int = 0
self.entry_price: float = 0
self.entry_time: str = ""
self.bars_held: int = 0
self.total_trades: int = 0
self.total_wins: int = 0
self.started_at = datetime.now(timezone.utc).isoformat()
self.last_bar_ts: int = 0
self._load_state()
self._save_state()
def _load_state(self):
"""Riprende stato da status.json se esiste."""
if not self.status_path.exists():
self._log("INIT", {"capital": self.capital, "strategy": self.strategy.name,
"asset": self.asset, "tf": self.tf})
return
with open(self.status_path) as f:
state = json.load(f)
self.capital = state.get("capital", self.initial_capital)
self.in_position = state.get("in_position", False)
self.direction = state.get("direction", 0)
self.entry_price = state.get("entry_price", 0)
self.entry_time = state.get("entry_time", "")
self.bars_held = state.get("bars_held", 0)
self.total_trades = state.get("total_trades", 0)
self.total_wins = state.get("total_wins", 0)
self.started_at = state.get("started_at", self.started_at)
self.last_bar_ts = state.get("last_bar_ts", 0)
self._log("RESUME", {"capital": round(self.capital, 2),
"total_trades": self.total_trades,
"in_position": self.in_position})
def _save_state(self):
state = {
"capital": round(self.capital, 2),
"in_position": self.in_position,
"direction": self.direction,
"entry_price": self.entry_price,
"entry_time": self.entry_time,
"bars_held": self.bars_held,
"total_trades": self.total_trades,
"total_wins": self.total_wins,
"started_at": self.started_at,
"last_bar_ts": self.last_bar_ts,
"last_update": datetime.now(timezone.utc).isoformat(),
}
with open(self.status_path, "w") as f:
json.dump(state, f, indent=2)
def _log(self, event: str, data: dict | None = None):
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"worker": self.worker_id,
"event": event,
**(data or {}),
}
with open(self.trades_path, "a") as f:
f.write(json.dumps(entry) + "\n")
print(f" [{self.worker_id}] {event}: {json.dumps(data or {}, default=str)}")
def _notify(self, event: str, data: dict | None = None):
enriched = {"worker": self.worker_id, **(data or {})}
notify_event(event, enriched)
def _open_position(self, signal: Signal, current_price: float):
notional = self.capital * self.position_size * self.leverage
size = notional / current_price if current_price > 0 else 0
self.in_position = True
self.direction = signal.direction
self.entry_price = current_price
self.entry_time = datetime.now(timezone.utc).isoformat()
self.bars_held = 0
trade_data = {
"direction": "long" if signal.direction == 1 else "short",
"price": round(current_price, 2),
"size": round(size, 6),
"notional": round(notional, 2),
"capital": round(self.capital, 2),
}
self._log("OPEN", trade_data)
self._notify("OPENED", trade_data)
def _close_position(self, current_price: float, reason: str):
if not self.in_position:
return
price_change = (current_price - self.entry_price) / self.entry_price
trade_return = price_change * self.direction
net = trade_return * self.leverage - FEE_RT * self.leverage
pnl = self.capital * self.position_size * net
is_win = trade_return > 0
self.capital += pnl
self.capital = max(self.capital, 0)
self.total_trades += 1
if is_win:
self.total_wins += 1
accuracy = self.total_wins / self.total_trades * 100 if self.total_trades > 0 else 0
trade_data = {
"reason": reason,
"direction": "long" if self.direction == 1 else "short",
"entry": round(self.entry_price, 2),
"exit": round(current_price, 2),
"pnl": round(pnl, 2),
"net_return": round(net * 100, 3),
"capital": round(self.capital, 2),
"bars_held": self.bars_held,
"win": is_win,
"total_trades": self.total_trades,
"accuracy": round(accuracy, 1),
}
self._log("CLOSE", trade_data)
self._notify("CLOSED", trade_data)
self.in_position = False
self.direction = 0
self.entry_price = 0
self.entry_time = ""
self.bars_held = 0
def tick(self, df: pd.DataFrame):
"""Chiamato ad ogni poll con DataFrame OHLCV aggiornato."""
if df.empty or len(df) < 100:
return
c = df["close"].values
current_price = float(c[-1])
current_ts = int(df["timestamp"].iloc[-1])
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
if self.in_position:
if current_ts > self.last_bar_ts:
self.bars_held += 1
self.last_bar_ts = current_ts
if self.bars_held >= self.hold_bars:
self._close_position(current_price, "hold_limit")
else:
pnl_pct = (current_price - self.entry_price) / self.entry_price * self.direction
if pnl_pct <= -0.02:
self._close_position(current_price, "stop_loss")
self._save_state()
return
# Genera segnali
signals = self.strategy.generate_signals(
df, ts, asset=self.asset, tf=self.tf, **self.params
)
if not signals:
self._save_state()
return
last_signal = signals[-1]
last_idx = len(df) - 1
if last_signal.idx >= last_idx - 1:
self._open_position(last_signal, current_price)
self.last_bar_ts = current_ts
self._save_state()
@property
def status_summary(self) -> str:
acc = self.total_wins / self.total_trades * 100 if self.total_trades > 0 else 0
pos = "LONG" if self.direction == 1 else "SHORT" if self.direction == -1 else "FLAT"
return (f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t "
f"{acc:.0f}% | {pos}")
+39
View File
@@ -0,0 +1,39 @@
"""Notifiche Telegram per il paper trader."""
from __future__ import annotations
import os
import urllib.request
import urllib.parse
import json
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
NOTIFY_EVENTS = {
"SIGNAL", "OPENED", "CLOSED", "OPEN_FAILED", "CLOSE_FAILED",
"ERROR", "STARTUP", "SHUTDOWN", "TRAINING_FAILED",
}
def send_telegram(text: str) -> bool:
if not BOT_TOKEN or not CHAT_ID:
return False
try:
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
data = urllib.parse.urlencode({"chat_id": CHAT_ID, "text": text, "parse_mode": "HTML"}).encode()
urllib.request.urlopen(url, data, timeout=10)
return True
except Exception:
return False
def notify_event(event: str, data: dict | None = None):
if event not in NOTIFY_EVENTS:
return
lines = [f"📊 <b>{event}</b>"]
if data:
for k, v in data.items():
if k in ("signal",):
continue
lines.append(f" {k}: {v}")
send_telegram("\n".join(lines))
+11
View File
@@ -0,0 +1,11 @@
"""Strategie di trading — classe base e indicatori condivisi."""
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats
from src.strategies.indicators import (
keltner_ratio, detect_squeezes, ema, atr, rv_annualized, rolling_correlation,
)
__all__ = [
"Strategy", "Signal", "BacktestResult", "YearlyStats",
"keltner_ratio", "detect_squeezes", "ema", "atr",
"rv_annualized", "rolling_correlation",
]
+243
View File
@@ -0,0 +1,243 @@
"""Classe base astratta per tutte le strategie di trading."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
from src.data.downloader import load_data
@dataclass
class Signal:
"""Segnale di trading generato da una strategia."""
idx: int
direction: int # +1 long, -1 short
entry_price: float
metadata: dict = field(default_factory=dict)
@dataclass
class YearlyStats:
year: int
trades: int
wins: int
pnl: float
@property
def accuracy(self) -> float:
return self.wins / self.trades * 100 if self.trades > 0 else 0.0
@dataclass
class BacktestResult:
"""Risultato completo di un backtest."""
strategy_name: str
asset: str
timeframe: str
params: dict
trades: int
wins: int
pnl: float
capital: float
initial_capital: float
max_dd: float
time_in_market_pct: float
avg_trade_duration_h: float
years_active: int
yearly: list[YearlyStats]
@property
def accuracy(self) -> float:
return self.wins / self.trades * 100 if self.trades > 0 else 0.0
@property
def sharpe(self) -> float:
pnls = []
for ys in self.yearly:
pnls.append(ys.pnl)
if len(pnls) < 2 or np.std(pnls) == 0:
return 0.0
return float(np.mean(pnls) / np.std(pnls) * np.sqrt(len(pnls)))
@property
def daily_pnl(self) -> float:
return self.pnl / (self.years_active * 365) if self.years_active > 0 else 0.0
@property
def worst_year(self) -> YearlyStats | None:
valid = [y for y in self.yearly if y.trades >= 10]
if not valid:
valid = self.yearly
return min(valid, key=lambda y: y.accuracy) if valid else None
def print_summary(self):
worst = self.worst_year
worst_str = f"{worst.year}({worst.accuracy:.0f}%)" if worst else "N/A"
dur = f"{self.avg_trade_duration_h:.0f}h" if self.avg_trade_duration_h >= 1 else f"{self.avg_trade_duration_h * 60:.0f}m"
print(f" {self.strategy_name:<30s} {self.asset:>3s} {self.timeframe:>3s} "
f"{self.trades:>5d}t {self.accuracy:>5.1f}% "
f"{self.pnl:>+9.0f} DD {self.max_dd:>4.1f}% "
f"€/d {self.daily_pnl:>+6.2f} "
f"Mkt {self.time_in_market_pct:>4.1f}% {dur:>5s} "
f"worst={worst_str} {self.years_active}y")
def print_yearly(self):
print(f"\n {self.strategy_name} [{self.asset} {self.timeframe}] — per anno:")
print(f" {'Anno':>6s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s}")
for ys in sorted(self.yearly, key=lambda y: y.year):
print(f" {ys.year:>6d} {ys.trades:>7d} {ys.accuracy:>5.1f}% €{ys.pnl:>+8.0f}")
TF_MINUTES = {"1m": 1, "5m": 5, "15m": 15, "1h": 60, "4h": 240, "1d": 1440}
class Strategy(ABC):
"""Classe base per tutte le strategie.
Sottoclassi devono implementare:
- name, description, default_assets, default_timeframes
- generate_signals(df, timestamps, **params) -> list[Signal]
"""
name: str = "unnamed"
description: str = ""
default_assets: list[str] = ["BTC", "ETH"]
default_timeframes: list[str] = ["15m", "1h"]
# Parametri di backtest
fee_rt: float = 0.002
leverage: float = 3.0
position_size: float = 0.15
initial_capital: float = 1000.0
@abstractmethod
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
"""Genera segnali di trading dal dataframe OHLCV.
Args:
df: DataFrame con colonne open, high, low, close, volume, timestamp
ts: DatetimeIndex UTC dei timestamp
**params: parametri specifici della strategia
Returns:
Lista di Signal con idx, direction, entry_price
"""
...
def backtest(self, asset: str, tf: str, hold: int = 3,
**params) -> BacktestResult | None:
"""Esegue backtest su un asset/timeframe."""
df = load_data(asset, tf)
c = df["close"].values
n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sig_params = {**params, "asset": asset, "tf": tf}
signals = self.generate_signals(df, ts, **sig_params)
if not signals:
return None
yearly: dict[int, dict] = {}
capital = float(self.initial_capital)
peak = capital
max_dd = 0.0
total_bars = 0
for sig in signals:
i = sig.idx
if i + hold >= n or i < 1:
continue
entry = sig.entry_price
exit_price = c[min(i + hold - 1, n - 1)]
actual = (exit_price - entry) / entry * sig.direction
net = actual * self.leverage - self.fee_rt * self.leverage
capital += capital * self.position_size * net
capital = max(capital, 10)
if capital > peak:
peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
total_bars += hold
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
yearly[year]["t"] += 1
if actual > 0:
yearly[year]["w"] += 1
yearly[year]["pnl"] += net * self.initial_capital
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t == 0:
return None
yearly_stats = [
YearlyStats(year=y, trades=d["t"], wins=d["w"], pnl=d["pnl"])
for y, d in sorted(yearly.items())
]
return BacktestResult(
strategy_name=self.name,
asset=asset,
timeframe=tf,
params=params,
trades=all_t,
wins=all_w,
pnl=sum(d["pnl"] for d in yearly.values()),
capital=capital,
initial_capital=self.initial_capital,
max_dd=max_dd * 100,
time_in_market_pct=total_bars / n * 100,
avg_trade_duration_h=hold * TF_MINUTES.get(tf, 60) / 60,
years_active=len(yearly),
yearly=yearly_stats,
)
def run_all(self, assets: list[str] | None = None,
timeframes: list[str] | None = None,
hold: int = 3, **params) -> list[BacktestResult]:
"""Esegue backtest su tutte le combinazioni asset/timeframe."""
assets = assets or self.default_assets
timeframes = timeframes or self.default_timeframes
results = []
for asset in assets:
for tf in timeframes:
r = self.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 20:
results.append(r)
results.sort(key=lambda r: r.accuracy, reverse=True)
return results
def report(self, results: list[BacktestResult] | None = None,
assets: list[str] | None = None,
timeframes: list[str] | None = None,
hold: int = 3, **params):
"""Esegue e stampa report completo."""
if results is None:
results = self.run_all(assets, timeframes, hold, **params)
print(f"\n{'=' * 120}")
print(f" {self.name}{self.description}")
print(f" Fee: {self.fee_rt*100:.1f}% RT | Leva: {self.leverage:.0f}x | Pos: {self.position_size*100:.0f}%")
print(f"{'=' * 120}")
print(f" {'Nome':<30s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Worst':>12s} {'Anni':>4s}")
print(f" {'' * 110}")
for r in results:
r.print_summary()
if results:
best = results[0]
best.print_yearly()
return results
+102
View File
@@ -0,0 +1,102 @@
"""Indicatori tecnici condivisi tra tutte le strategie."""
from __future__ import annotations
import numpy as np
def keltner_ratio(close: np.ndarray, high: np.ndarray, low: np.ndarray,
window: int = 14) -> np.ndarray:
"""Rapporto Bollinger / Keltner. Sotto 1 = squeeze (BB dentro KC)."""
n = len(close)
r = np.full(n, np.nan)
for i in range(window, n):
wc = close[i - window:i]
wh = high[i - window:i]
wl = low[i - window:i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(
wh - wl,
np.maximum(np.abs(wh - np.roll(wc, 1)), np.abs(wl - np.roll(wc, 1))),
)
atr = np.mean(tr[1:])
kc = (ma + 1.5 * atr) - (ma - 1.5 * atr)
bb = (ma + 2 * bb_std) - (ma - 2 * bb_std)
if kc > 0:
r[i] = bb / kc
return r
def detect_squeezes(close: np.ndarray, high: np.ndarray, low: np.ndarray,
kcr: np.ndarray, sq_thr: float = 0.8,
min_dur: int = 5) -> list[dict]:
"""Rileva squeeze events: periodi dove BB sta dentro KC."""
events: list[dict] = []
in_sq = False
sq_start = 0
for i in range(1, len(close)):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
dur = i - sq_start
if dur < min_dur:
continue
events.append({
"idx": i, "dur": dur, "sq_start": sq_start,
"kcr_at_release": kcr[i],
})
return events
def ema(arr: np.ndarray, period: int) -> np.ndarray:
"""Exponential Moving Average."""
r = np.full(len(arr), np.nan)
k = 2 / (period + 1)
r[period - 1] = np.mean(arr[:period])
for i in range(period, len(arr)):
r[i] = arr[i] * k + r[i - 1] * (1 - k)
return r
def atr(high: np.ndarray, low: np.ndarray, close: np.ndarray,
period: int = 14) -> np.ndarray:
"""Average True Range (EMA-smoothed)."""
tr = np.maximum(
high - low,
np.maximum(np.abs(high - np.roll(close, 1)), np.abs(low - np.roll(close, 1))),
)
tr[0] = high[0] - low[0]
r = np.full(len(close), np.nan)
r[period - 1] = np.mean(tr[:period])
k = 2 / (period + 1)
for i in range(period, len(close)):
r[i] = tr[i] * k + r[i - 1] * (1 - k)
return r
def rv_annualized(close: np.ndarray, window: int) -> np.ndarray:
"""Realized volatility annualizzata (hourly data assumed)."""
lr = np.diff(np.log(np.where(close == 0, 1e-10, close)))
r = np.full(len(close), np.nan)
for i in range(window, len(lr)):
r[i + 1] = np.std(lr[i - window:i]) * np.sqrt(24 * 365)
return r
def rolling_correlation(close_a: np.ndarray, close_b: np.ndarray,
window: int = 48) -> np.ndarray:
"""Correlazione rolling tra rendimenti logaritmici di due asset."""
n = max(len(close_a), len(close_b))
ret_a = np.diff(np.log(np.where(close_a == 0, 1e-10, close_a)))
ret_b = np.diff(np.log(np.where(close_b[:len(close_a)] == 0, 1e-10, close_b[:len(close_a)])))
min_len = min(len(ret_a), len(ret_b))
corr = np.full(n, np.nan)
for i in range(window, min_len):
cv = np.corrcoef(ret_a[i - window:i], ret_b[i - window:i])[0, 1]
corr[i + 1] = cv if np.isfinite(cv) else 0
return corr
+51
View File
@@ -0,0 +1,51 @@
defaults:
capital: 1000
position_size: 0.15
leverage: 3
hold_bars: 3
poll_seconds: 60
retrain_hours: 24
strategies:
- name: SQ02_antifake_vol
asset: BTC
tf: 15m
enabled: true
- name: SQ02_antifake_vol
asset: ETH
tf: 15m
enabled: true
- name: SQ01_squeeze_base
asset: BTC
tf: 15m
enabled: true
- name: ML01_squeeze_gbm
asset: ETH
tf: 15m
enabled: true
position_size: 0.15
params:
ml_threshold: 0.70
bb_window: 14
sq_threshold: 0.8
- name: MT01_squeeze_mtf
asset: BTC
tf: 15m
enabled: true
params:
ema_period: 20
min_slope: 0.001
vol_filter: true
- name: MT01_squeeze_mtf
asset: ETH
tf: 15m
enabled: true
params:
ema_period: 20
min_slope: 0.001
vol_filter: true
Generated
+70 -13
View File
@@ -542,30 +542,30 @@ wheels = [
[[package]]
name = "cuda-bindings"
version = "13.2.0"
version = "13.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/a9/3a8241c6e19483ac1f1dcf5c10238205dcb8a6e9d0d4d4709240dff28ff4/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:721104c603f059780d287969be3d194a18d0cc3b713ed9049065a1107706759d", size = 5730273, upload-time = "2026-03-11T00:12:37.18Z" },
{ url = "https://files.pythonhosted.org/packages/e9/94/2748597f47bb1600cd466b20cab4159f1530a3a33fe7f70fee199b3abb9e/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1eba9504ac70667dd48313395fe05157518fd6371b532790e96fbb31bbb5a5e1", size = 6313924, upload-time = "2026-03-11T00:12:39.462Z" },
{ url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" },
{ url = "https://files.pythonhosted.org/packages/1f/92/f899f7bbb5617bb65ec52a6eac1e9a1447a86b916c4194f8a5001b8cde0c/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46d8776a55d6d5da9dd6e9858fba2efcda2abe6743871dee47dd06eb8cb6d955", size = 6320619, upload-time = "2026-03-11T00:12:45.939Z" },
{ url = "https://files.pythonhosted.org/packages/df/93/eef988860a3ca985f82c4f3174fc0cdd94e07331ba9a92e8e064c260337f/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6629ca2df6f795b784752409bcaedbd22a7a651b74b56a165ebc0c9dcbd504d0", size = 5614610, upload-time = "2026-03-11T00:12:50.337Z" },
{ url = "https://files.pythonhosted.org/packages/18/23/6db3aba46864aee357ab2415135b3fe3da7e9f1fa0221fa2a86a5968099c/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dca0da053d3b4cc4869eff49c61c03f3c5dbaa0bcd712317a358d5b8f3f385d", size = 6149914, upload-time = "2026-03-11T00:12:52.374Z" },
{ url = "https://files.pythonhosted.org/packages/c0/87/87a014f045b77c6de5c8527b0757fe644417b184e5367db977236a141602/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6464b30f46692d6c7f65d4a0e0450d81dd29de3afc1bb515653973d01c2cd6e", size = 5685673, upload-time = "2026-03-11T00:12:56.371Z" },
{ url = "https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4af9f3e1be603fa12d5ad6cfca7844c9d230befa9792b5abdf7dd79979c3626", size = 6191386, upload-time = "2026-03-11T00:12:58.965Z" },
{ url = "https://files.pythonhosted.org/packages/5f/58/ed2c3b39c8dd5f96aa7a4abef0d47a73932c7a988e30f5fa428f00ed0da1/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df850a1ff8ce1b3385257b08e47b70e959932f5f432d0a4e46a355962b4e4771", size = 5507469, upload-time = "2026-03-11T00:13:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/0c941b112ceeb21439b05895eace78ca1aa2eaaf695c8521a068fd9b4c00/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8a16384c6494e5485f39314b0b4afb04bee48d49edb16d5d8593fd35bbd231b", size = 6059693, upload-time = "2026-03-11T00:13:06.003Z" },
{ url = "https://files.pythonhosted.org/packages/f9/52/50673d25e46d199556f827514bf646a49471d50538c5e577201245b348a9/cuda_bindings-13.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:244a167d81a9d07d3209d02c8e02e848c2b7c38f4d01e8e4d1f9620b173ae006", size = 6051409, upload-time = "2026-05-27T03:59:01.648Z" },
{ url = "https://files.pythonhosted.org/packages/88/ee/e8f4bdfb808c3689539b7c035d63b6dac9f236b2d6f807f18c7f5f3ef879/cuda_bindings-13.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:820ab45be3f39a088f39c4a04eb8b852d0b339bff8c518da5c258882b8a4e21b", size = 6671833, upload-time = "2026-05-27T03:59:03.761Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e0/4b3fdba08ff177e9451f376a4ba2df18d76f9158e6a16cdc062bd83db9fa/cuda_bindings-13.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f72f67f5790e7fa51e3f893e007e49567573ccdf4ed1ca988fbbbd36cd77847c", size = 6020531, upload-time = "2026-05-27T03:59:07.942Z" },
{ url = "https://files.pythonhosted.org/packages/04/40/a2ea4d8f032bfd6c220d50b6f92cd61f33d48f31959da39ed1b178cfee54/cuda_bindings-13.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a99c3b8d584f266c616bd0f30c7cd83e33553e3ef2abad41ff5a74fbc033a69a", size = 6653764, upload-time = "2026-05-27T03:59:09.981Z" },
{ url = "https://files.pythonhosted.org/packages/ae/a0/156efe7816699c2de1ea2395031db7d010b7af23c243563a3ee6f0ecc1de/cuda_bindings-13.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7698fcc4577aa96372866f4d0c9a6cf686cd5c90eab94581c29d37fe6600542", size = 5914803, upload-time = "2026-05-27T03:59:14.011Z" },
{ url = "https://files.pythonhosted.org/packages/51/91/510aae64d53227b5b36db6bfaea41514b66d92cd65ddc43aa49566f18313/cuda_bindings-13.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abd908f651160d12c45c5714a38ee102a1173a55433c0d1509ec0e8293beb4a6", size = 6472506, upload-time = "2026-05-27T03:59:16.551Z" },
{ url = "https://files.pythonhosted.org/packages/01/53/2ef49e5b3734a5531b2ba5d726cba724d9cbb262404e586ed61070604826/cuda_bindings-13.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a801fa30e75d25b74252123aefc746b6c4275624d2b8640632dd1dfeeaa1f88", size = 6008814, upload-time = "2026-05-27T03:59:20.921Z" },
{ url = "https://files.pythonhosted.org/packages/2f/cb/3a9fcf0651e0a49b4d0f1955837ce079245b27086c22fb2f253039bdf324/cuda_bindings-13.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94d40ef7b4bdd9dce0244a1baa132e0e538f1eb2c0d162fb3648a15e48515365", size = 6531477, upload-time = "2026-05-27T03:59:23.391Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/6987c5ee98f117317a85650ddc79480a3fa59a573ae1c923d0722b56ae71/cuda_bindings-13.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5911bea15810b749a8077f8c45423ed785d51618b8e8664dea1fc8f5a2a76c8", size = 5807073, upload-time = "2026-05-27T03:59:28.218Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/46ceee07dc19f18a5d1c28d592750ed9dbdc803077eb083576a442c9938c/cuda_bindings-13.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2870fed7707a37f8af0c02364b05f355ebe8921604e8c68eb56cf66867e0798", size = 6354325, upload-time = "2026-05-27T03:59:30.715Z" },
]
[[package]]
name = "cuda-pathfinder"
version = "1.5.4"
version = "1.5.5"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/d0/c177e29701cf1d3008d7d2b16b5fc626592ce13bd535f8795c5f57187e0e/cuda_pathfinder-1.5.4-py3-none-any.whl", hash = "sha256:9563d3175ce1828531acf4b94e1c1c7d67208c347ca002493e2654878b26f4b7", size = 51657, upload-time = "2026-04-27T22:42:07.712Z" },
{ url = "https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl", hash = "sha256:0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689", size = 51671, upload-time = "2026-05-27T01:21:25.413Z" },
]
[[package]]
@@ -2057,6 +2057,7 @@ dependencies = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pyarrow" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "scikit-learn" },
{ name = "scipy" },
@@ -2081,6 +2082,7 @@ requires-dist = [
{ name = "pyarrow", specifier = ">=15.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "requests", specifier = ">=2.31" },
{ name = "scikit-learn", specifier = ">=1.3" },
{ name = "scipy", specifier = ">=1.11" },
@@ -2101,6 +2103,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.34.2"