Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a066eb76f | |||
| 7226946911 | |||
| 945c2a2db6 | |||
| 33e3e2a603 | |||
| 666c906907 | |||
| 9ebdfc7a7a | |||
| bcccfde9a0 | |||
| 1af4addbdd | |||
| 22c6080873 | |||
| fd547748dd | |||
| a51129acf6 | |||
| 1b099bb47b | |||
| 783fa5546f | |||
| cff0d08fca | |||
| ad141f080c | |||
| 212427ffa1 | |||
| 21d3ba609d | |||
| 48435f6858 | |||
| 9879b46688 | |||
| ca88e62a11 | |||
| 8fd2c16cac | |||
| 31be1b43aa | |||
| bdcef09057 | |||
| d39c75b103 | |||
| f42fec9fac | |||
| 56bad4741e | |||
| b79c87e4af | |||
| 0e47956f7a | |||
| fa2d74be77 | |||
| 041db2191c | |||
| 185ac0d49b | |||
| 0ab3b5698a | |||
| 7639e5012b | |||
| 2694a4a00c | |||
| a7b3c3c203 |
@@ -16,3 +16,4 @@ data/processed/
|
||||
*.pt
|
||||
*.pth
|
||||
notebooks/.ipynb_checkpoints/
|
||||
data/paper_trades/
|
||||
|
||||
@@ -9,9 +9,10 @@ Progetto di ricerca: riconoscimento pattern frattali per trading algoritmico su
|
||||
- **Linguaggio:** Python 3.11+
|
||||
- **Package manager:** uv (dipendenze in `pyproject.toml`, lock in `uv.lock`)
|
||||
- **Dati:** Parquet in `data/raw/` (non committati, ~70 MB)
|
||||
- **ML:** scikit-learn (GradientBoosting), PyTorch (LSTM)
|
||||
- **ML:** scikit-learn (GradientBoostingClassifier)
|
||||
- **Analisi:** numpy, pandas, scipy
|
||||
- **API dati:** Cerbero MCP su `cerbero-mcp.tielogic.xyz` (Deribit, Bybit, Hyperliquid), ccxt/Binance come fallback
|
||||
- **Config:** pyyaml per `strategies.yml`
|
||||
|
||||
## Struttura
|
||||
|
||||
@@ -19,10 +20,25 @@ 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 01–13
|
||||
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.
|
||||
Exit guidati da strategia (TP/SL/max_bars via Signal.metadata),
|
||||
fallback hold_bars/stop -2%. Usa fee_rt della strategia.
|
||||
strategy_loader.py → import dinamico classi Strategy da scripts/strategies/
|
||||
cerbero_client.py → client HTTP per Cerbero MCP (Deribit testnet)
|
||||
signal_engine.py → squeeze + ML real-time (legacy ML01, ora in waste) + validazione OOS
|
||||
telegram_notifier.py → notifiche Telegram per trade
|
||||
scripts/strategies/ → strategie con edge validato OOS (solo MR01_bollinger_fade)
|
||||
scripts/waste/ → strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD)
|
||||
scripts/analysis/ → ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...)
|
||||
strategies.yml → config multi-strategy paper trader
|
||||
docs/diary/ → diario di ricerca giornaliero
|
||||
docs/specs/ → specifiche di design
|
||||
data/raw/ → file .parquet OHLCV (gitignored)
|
||||
data/processed/ → modelli salvati (gitignored)
|
||||
```
|
||||
|
||||
## Comandi
|
||||
@@ -30,7 +46,12 @@ data/processed/ → modelli salvati (gitignored)
|
||||
```bash
|
||||
uv sync # installa dipendenze
|
||||
uv run python -m src.data.downloader # scarica dati storici
|
||||
uv run python scripts/13_squeeze_ml_hybrid.py # strategia vincente
|
||||
uv run python scripts/strategies/MR01_bollinger_fade.py # strategia attiva (mean-reversion)
|
||||
uv run python scripts/analysis/strategy_research.py # ricerca strategie fee-aware OOS
|
||||
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
|
||||
uv run python scripts/analysis/validate_worker_mr01.py # replay worker reale su MR01
|
||||
uv run python -m src.live.multi_runner # paper trading live multi-strategia
|
||||
docker compose up -d # deploy Docker
|
||||
uv run pytest # test
|
||||
```
|
||||
|
||||
@@ -47,22 +68,136 @@ 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):
|
||||
> **LEZIONE CRITICA (2026-05-28).** L'intera famiglia squeeze-breakout (SQ01-04,
|
||||
> MT01, ML01, AD01, CM01, PD01) è stata **scartata in `scripts/waste/`**: le
|
||||
> accuratezze storiche 76-82% erano un **artefatto di look-ahead**. Quei backtest
|
||||
> decidono la direzione con `sign(close[i]-close[i-1])` (la candela di breakout `i`)
|
||||
> ma entrano a `close[i-1]` — cioè comprano *prima* della candela che usano per
|
||||
> scegliere la direzione. Dal vivo il worker scopre il breakout solo a `close[i]`
|
||||
> ed entra lì: l'edge sparisce (win-rate ~47%, lancio di moneta). Sotto ingresso
|
||||
> onesto e fee reali **tutte perdono, anche a fee zero**. Inoltre i breakout
|
||||
> *rientrano* (mean-reversion > continuation). Vedi `scripts/analysis/oos_validation.py`
|
||||
> e `intrabar_test.py`.
|
||||
|
||||
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%
|
||||
Tutte le strategie estendono `src.strategies.base.Strategy`
|
||||
(`generate_signals() → backtest()`). Le strategie mean-reversion condividono
|
||||
`src.strategies.fade_base.FadeStrategy` (backtest intrabar TP/SL/max_bars).
|
||||
**Strategie con edge netto validato OOS fee-aware (tutte fade/mean-reversion):**
|
||||
|
||||
Configurazione migliore: ETH 15m, BBw=14, squeeze threshold=0.8, breakout=3 barre, leva 3x, position 15%.
|
||||
| Codice | Nome | Meccanismo | Edge OOS netto (1h, fee 0.10% RT) | DD | Note |
|
||||
|--------|------|-----------|-----------------------------------|----|------|
|
||||
| **MR01** | Bollinger Fade | banda std attorno a SMA | BTC +201% / ETH +1238% | 15-72% | Fada la banda, TP alla media, SL ad ATR |
|
||||
| **MR02** | Donchian Fade | estremi canale H/L | BTC +172% / ETH enorme | 30-42% | Fada la rottura del canale, TP al centro |
|
||||
| **MR07** | Return Reversal | z dei rendimenti di barra | BTC +105% / ETH +195% | 25-46% | Fada il movimento estremo, exit in ATR; esposizione ~8% |
|
||||
|
||||
Risultato backtestato: 76.9% accuracy, 118% annuo, 4.2% drawdown, €13.78/giorno da €1.000.
|
||||
> **MR03 Keltner Fade** spostata in `scripts/waste/`: era la fade più debole
|
||||
> (BTC Sharpe 1.22, il filtro trend la peggiorava) e ridondante con MR01 (stessa
|
||||
> idea di banda). Rimuoverla dal portafoglio ne ha *migliorato* le metriche.
|
||||
> La funzione `keltner_fade` resta in `strategy_research_v2.py` come record.
|
||||
|
||||
**Lezione confermata:** l'edge è sempre *mean-reversion* (i breakout rientrano).
|
||||
Il trend-following (Donchian trend, RSI cross) e gli oscillatori senza filtro
|
||||
(RSI revert, ADX-filtered fade) perdono netti → restano scartati.
|
||||
|
||||
Ogni strategia è robusta su **tutta** la sua griglia parametri (entrambi gli asset
|
||||
→ tutte positive OOS) e su **tutte** le fee 0.00-0.20% RT (margine ampio).
|
||||
MR01 validato col worker reale: BTC +196% / ETH +251% OOS (nov 2023→mag 2026).
|
||||
Ricerca completa: `scripts/analysis/strategy_research.py` (MR01) e
|
||||
`scripts/analysis/strategy_research_v2.py` (MR02/MR03/MR07).
|
||||
Validazione live-path: `scripts/analysis/oos_validation.py`.
|
||||
|
||||
**Filtro trend (riduzione DD + aumento Acc).** Tutte le fade accettano i parametri
|
||||
opzionali `trend_max` / `ema_long`: saltano i segnali quando il prezzo è troppo
|
||||
esteso rispetto al trend di fondo (`|close − EMA(ema_long)| / ATR(14) > trend_max`),
|
||||
cioè quando si starebbe fadando un trend/crollo estremo. Con `trend_max=3.0`,
|
||||
`ema_long=200` (default in `strategies.yml`): accuratezza su tutti gli sleeve
|
||||
e DD giù drasticamente su ETH (MR01 71%→26%, MR02 42%→25%, MR03 66%→34%,
|
||||
MR07 46%→21%), edge OOS confermato (vedi `scripts/analysis/risk_management.py`).
|
||||
Unica eccezione: MR03 BTC, dove il filtro peggiora entrambe → lasciato disattivo.
|
||||
Leva non robusta scartate: vol-target sizing e skip-alta-volatilità (peggiorano).
|
||||
|
||||
**Portafoglio.** Diversificare su sotto-conti indipendenti equipesati (le 4 strategie
|
||||
× BTC/ETH, pos 0.15 ciascuno) abbatte il DD aggregato: ~14% full / ~10% OOS sul
|
||||
paniere di 8 sleeve, contro il 20-70% del singolo. È la vera leva anti-drawdown.
|
||||
|
||||
**Combinare le due famiglie (fade + honest).** Le fade (reversione intraday 1h) e le
|
||||
honest (DIP/TR/ROT trend+rotazione multi-crypto) sono **quasi scorrelate**
|
||||
(correlazione cross-famiglia ~0.05). Combinarle in un unico portafoglio migliora il
|
||||
rischio/rendimento rispetto a ciascuna famiglia da sola: equal-weight dei 9 sleeve
|
||||
→ DD 5.2% full / 4.7% OOS e Sharpe 4.23 full / 4.33 OOS (vs honest-only 12.6% DD /
|
||||
2.20 Sharpe e fade-only 8.2% DD / 4.09 Sharpe), CAGR ~47% mantenuta. Studio in
|
||||
`scripts/analysis/combine_portfolio.py`.
|
||||
|
||||
**ROT02 — riduzione DD (top_k 2→3).** La rotazione dual-momentum honest concentrava
|
||||
il book su 2 soli asset (DD 40%). Diversificare su 3 (`top_k=3`) dimezza quasi il DD
|
||||
(40%→26%) e *alza* pure il ritorno full (+1095%→+1303%, ret/DD da 27 a 50); il
|
||||
vol-target abbassa il DD ma sacrifica ritorno, quindi si tiene top_k=3 senza VT.
|
||||
Applicato a `ROT02_dual_momentum.py` e a `_rot_daily_equity` (usata dai portafogli).
|
||||
|
||||
**Portafogli pronti (artefatti accorpati e migliorati).** Oltre a `PORT01` (solo
|
||||
honest), due script in `scripts/strategies/`:
|
||||
- `PORT02_fade_master.py` — le 3 fade × BTC/ETH accorpate (6 sleeve, filtro trend),
|
||||
equal-weight daily: DD ~8.2% full / 5.9% OOS, Sharpe 3.95/4.09, CAGR ~46%.
|
||||
- `PORT03_all_master.py` — portafoglio MASTER (fade + honest, 9 sleeve). Due varianti:
|
||||
`equal` (massimo Sharpe: DD 5.2%/4.7% OOS, Sharpe 4.23/4.33) e `5050` fra le due
|
||||
famiglie (minimo DD: 5.0% full / 4.5% OOS). È la configurazione consigliata.
|
||||
Come `PORT01`, sono meta-portafogli (script `run()` di report), non `Strategy` con
|
||||
`generate_signals`, quindi non nel `strategy_loader`.
|
||||
|
||||
**Esplorazione famiglie alternative (branch `strategy_explore`, 2026-05-29).** Esplorate
|
||||
9 famiglie nuove con agenti paralleli su harness onesto condiviso
|
||||
(`scripts/analysis/explore_lab.py`). 7 sono rumore (rifiutate: stagionalità oraria/mensile,
|
||||
cross-sectional reversal, opening-range breakout, lead-lag BTC→alt, continuation intraday —
|
||||
quest'ultima riconferma la dominanza mean-reversion). Due edge reali:
|
||||
- **PR01 Pairs** (`scripts/strategies/PR01_pairs_reversion.py`): spread reversion
|
||||
market-neutral sul log-ratio z-score, **config UNIVERSALE** `n=50 z_in=2.0 z_exit=0.75
|
||||
max_bars=72` (anti-overfit, niente tuning per-coppia). **5 coppie robuste**: ETH/BTC
|
||||
(Sharpe 4.36), LTC/ETH (3.08), ADA/ETH (2.69), BTC/LTC (2.36, robusta anche 4h), ETH/SOL
|
||||
(1.96, la più debole). Pattern: sempre alt-liquido vs major. Plateau confermato
|
||||
(heatmap 20/20 Sharpe>1) + walk-forward (ETH/BTC 11/12 finestre+). **BNB/ETH scartata**
|
||||
(overfit). Corr col mercato ~0.02-0.08. Fee su **2 gambe** (worker da estendere). Verifica: `pairs_research.py`.
|
||||
- **TSM01** (`scripts/analysis/tsmom_research.py`): TSMOM multi-orizzonte 3/6/12m + risk-off,
|
||||
**gross 0.30**, distinto da ROT02 (corr 0.62), DD 15-22%, mai un anno negativo. Robusto
|
||||
(36/36 config OOS+) ma diversificatore, non motore di ritorno (rende meno di ROT02).
|
||||
|
||||
Aggiungere i **5 pairs** al MASTER (quasi scorrelati, ~0.02-0.09) è il free-lunch più
|
||||
grande (`scripts/analysis/combine_v2.py`). **Numeri sobri onesti** (l'OOS singolo 2024-25
|
||||
è regime calmo → ottimistico ~50%): worst-DD su 90g rolling **~6%** (non 2.3%), Sharpe
|
||||
atteso **~5** (mediana semestrale), ogni anno positivo dal 2021, regge **leva 2x +
|
||||
slippage doppio** (CAGR 36%, Sharpe 5.1). Config robusta raccomandata: **MASTER-esteso
|
||||
equal-weight, leva 2x, cap pairs ~30-35%** (i pairs sono ~57% del rischio e non ancora
|
||||
validati col worker live a 2 gambe). La confluenza multi-TF è stata SCARTATA (overfit).
|
||||
|
||||
**Metodologia obbligatoria per ogni nuova strategia** (per non ripetere l'errore squeeze):
|
||||
1. Ingresso eseguibile: direzione e prezzo decisi con dati **fino a `close[i]`**, mai `close[i-1]` con direzione da `i`.
|
||||
2. Backtest **NETTO** dopo fee realistiche Deribit (**0.10% RT** taker, non 0.20%) + leva.
|
||||
3. Validazione **out-of-sample** (held-out) + robustezza su griglia parametri + sweep fee.
|
||||
4. Crea script in `scripts/strategies/`, aggiungi a `MODULE_MAP` (`strategy_loader.py`) e a `strategies.yml`.
|
||||
|
||||
Strategie scartate storiche in `scripts/waste/` (W01-W28 + la famiglia squeeze).
|
||||
|
||||
**Verso €50/giorno.** Con 4 strategie indipendenti (MR01/MR02/MR03/MR07) × 2 asset
|
||||
(BTC/ETH) su €1000 ciascuna, il PnL medio storico aggregato è ben oltre €50/giorno;
|
||||
ma quei numeri sono backtest a leva 3x su 8 anni e includono anni eccezionali (es.
|
||||
ETH 2024). Stima onesta: il target è *plausibile* su un portafoglio diversificato di
|
||||
queste fade, ma va confermato col paper trader live prima di rischiare capitale reale.
|
||||
|
||||
## Multi-Strategy Paper Trader
|
||||
|
||||
Orchestratore che esegue N strategie in parallelo su dati live Cerbero, ognuna con €1000 USDC virtuali indipendenti.
|
||||
|
||||
**Config:** `strategies.yml` — lista strategie con asset, tf, sizing, parametri. Attualmente solo MR01 (BTC/ETH 1h).
|
||||
**Persistenza:** `data/paper_trades/{strategy}___{asset}__{tf}/` con `trades.jsonl` (append-only) + `status.json` (resume al restart, include tp/sl/max_bars).
|
||||
**Hot-add:** aggiungi riga YAML → `docker compose restart` → storico intatto.
|
||||
**Exit strategia:** se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come MR01), il worker esce su take-profit/stop-loss/time-limit a quei livelli; altrimenti usa il fallback `hold_bars`/stop -2%.
|
||||
**Notifiche:** Telegram per ogni trade (richiede `.env` con `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID`).
|
||||
|
||||
## Convenzioni
|
||||
|
||||
- Script numerati progressivamente (`01_`, `02_`, …). Ogni script è autocontenuto.
|
||||
- Strategie in `scripts/strategies/` con codice univoco (MR01, ...).
|
||||
- Script scartati in `scripts/waste/` (W01-W28 + famiglia squeeze).
|
||||
- Diario in `docs/diary/YYYY-MM-DD.md`. Aggiornare dopo ogni esperimento significativo.
|
||||
- Nessun dato sensibile nei commit (token, chiavi API). Usare `.gitignore`.
|
||||
- Verificare sempre assenza di data leakage prima di fidarsi dei risultati. In particolare: `returns[i-w : i]` include `close[i]` che è un candle nel futuro — usare `returns[i-w : i-1]`.
|
||||
@@ -70,5 +205,8 @@ Risultato backtestato: 76.9% accuracy, 118% annuo, 4.2% drawdown, €13.78/giorn
|
||||
## Attenzione
|
||||
|
||||
- **Data leakage:** è stata trovata e corretta nello script 05. Ogni volta che si usano rendimenti logaritmici (`np.diff(np.log(close))`), ricordare che `returns[k]` usa `close[k+1]`. I feature devono fermarsi a `returns[i-2]` se il prezzo corrente è `close[i-1]`.
|
||||
- **Fee:** sempre 0.1% per lato (0.2% round-trip). Includere nel backtest.
|
||||
- **Fee:** Deribit perp reale = taker ~0.05%/lato (**0.10% round-trip**), maker ~0%. Usare 0.10% RT come baseline (lo 0.20% storico era pessimista 2x). Includere SEMPRE nel backtest: sono vincolo di prim'ordine, molte operazioni = morte per fee. Il worker usa `strategy.fee_rt` (MR01 = 0.001).
|
||||
- **Leva:** testato con 3x. Aumentare a 5x migliora i rendimenti ma raddoppia il drawdown.
|
||||
- **GBM:** GradientBoostingClassifier di scikit-learn. Ensemble di alberi decisionali sequenziali. Walk-forward per evitare leakage temporale.
|
||||
- **Cerbero `get_historical` (fix 2026-05-28):** `end_date` come data nuda è inclusivo dell'intera giornata fino all'ultima candela chiusa (es. `end=oggi` arriva fino ad ora, non più a mezzanotte); accettati anche timestamp con orario (`...T14:00:00`, naive=UTC); nessun cap a ~5000 righe (paginazione interna). Il client passa già `end=oggi`, ora corretto. Prima del fix il paper trader restava a zero trade perché il feed era fermo a mezzanotte.
|
||||
- **Dati ETH Deribit 15m:** 14-30%/anno di candele *flat* (O=H=L=C, volume 0, run fino a ~54h) per bassa liquidità del perpetuo. Verificato (2026-05-28): escluderle NON cambia i backtest (Δacc ≤0.5pp) → edge robusto. Resta un caveat operativo (slippage/fill in trading reale, irrilevante per paper). BTC pulito eccetto picco ~8% nel 2024.
|
||||
|
||||
+3
-1
@@ -8,7 +8,9 @@ COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
COPY src/ src/
|
||||
COPY scripts/strategies/ scripts/strategies/
|
||||
COPY strategies.yml strategies.yml
|
||||
|
||||
VOLUME /app/data
|
||||
|
||||
CMD ["uv", "run", "python", "-m", "src.live.paper_trader"]
|
||||
CMD ["uv", "run", "python", "-m", "src.live.multi_runner"]
|
||||
|
||||
@@ -8,80 +8,189 @@ Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di
|
||||
|
||||
## Risultati
|
||||
|
||||
Tredici strategie testate su dati storici 2018–2026 (BTC e ETH, timeframe 5m / 15m / 1h). Le migliori cinque:
|
||||
> ⚠️ **Revisione 2026-05-28.** La famiglia squeeze-breakout (SQ/MT/ML/AD/CM/PD, con
|
||||
> accuracy storiche dichiarate 76-82%) è stata **scartata**: quei numeri erano un
|
||||
> **artefatto di look-ahead**. I backtest decidevano la direzione dalla candela di
|
||||
> breakout `close[i]` ma entravano a `close[i-1]` — impossibile dal vivo. Sotto
|
||||
> ingresso onesto (`close[i]`) e fee reali, l'edge sparisce e tutte perdono, anche
|
||||
> a fee zero. Dettagli e prove: `scripts/analysis/oos_validation.py`.
|
||||
|
||||
| # | Strategia | Accuracy | ROI annuo | Max DD | €/giorno |
|
||||
|---|-----------|----------|-----------|--------|----------|
|
||||
| 1 | ETH 15m Squeeze + ML ibrida | 76.9% | 118% | 4.2% | €13.78 |
|
||||
| 2 | ETH 1h Squeeze + Vol | 83.9% | 22% | 2.0% | €0.71 |
|
||||
| 3 | BTC 15m Squeeze + ML ibrida | 78.8% | 69% | 7.0% | €5.51 |
|
||||
| 4 | ETH 1h Squeeze (BBw=30) | 82.8% | 47% | 3.2% | €1.77 |
|
||||
| 5 | ETH Walk-Forward ML | 57.7% | 38% | 47% | €3.12 |
|
||||
Dopo una validazione **out-of-sample, fee-aware** di tutte le famiglie, l'unica con
|
||||
edge netto reale è il **mean-reversion** (i breakout *rientrano*, non continuano):
|
||||
|
||||
La strategia vincente (#1) opera su ETH a 15 minuti con ~1 trade al giorno, leva 3x e drawdown contenuto al 4.2%.
|
||||
| Codice | Strategia | Mercato | Edge OOS netto | Max DD | Robustezza |
|
||||
|--------|-----------|---------|----------------|--------|------------|
|
||||
| **MR01** | Bollinger Fade (mean-reversion) | BTC 1h | **+196 / +201%** | 15% | ✅ |
|
||||
| **MR01** | Bollinger Fade (mean-reversion) | ETH 1h | **+251%** | ~25% | ⚠️ DD alto |
|
||||
|
||||
Netto dopo **fee realistiche Deribit 0.10% RT** (taker), leva 3x, pos 15%, su finestra
|
||||
held-out (nov 2023→mag 2026). MR01 è positivo su **tutta** la griglia parametri
|
||||
(`n∈{14,20,30,50}` × `k∈{2.0,2.5,3.0}`) e per **ogni** livello di fee 0.00-0.20% RT —
|
||||
margine di sicurezza ampio, niente parametro fortunato. Ri-validato col worker live reale.
|
||||
|
||||
## Come funziona
|
||||
|
||||
### Volatility Squeeze Breakout
|
||||
### MR01 — Bollinger Fade (mean-reversion)
|
||||
|
||||
Il meccanismo centrale sfrutta i cicli naturali di compressione ed espansione della volatilità:
|
||||
La strategia attiva sfrutta il fatto, emerso dai dati, che su BTC/ETH a 1h gli estremi
|
||||
di prezzo **rientrano verso la media** più di quanto proseguano:
|
||||
|
||||
1. **Compressione** — le Bollinger Bands entrano dentro i Keltner Channel (il prezzo si muove sempre meno, accumulando "energia").
|
||||
2. **Breakout** — le bande escono dal canale. Un impulso direzionale parte.
|
||||
3. **Conferma ML** — un modello GradientBoosting, addestrato su feature strutturali e frattali della finestra precedente, conferma la direzione e filtra i segnali deboli.
|
||||
1. **Bollinger Bands** (window `n`, `k` deviazioni standard) sul close.
|
||||
2. **Entry** — quando il close esce *sotto* la banda inferiore → **long** (o *sopra* la superiore → **short**). Ingresso a `close[i]`, eseguibile dal vivo.
|
||||
3. **Take-profit** alla media mobile (il rientro atteso).
|
||||
4. **Stop-loss** a `sl_atr × ATR` oltre l'estremo; **time-limit** a `max_bars`.
|
||||
|
||||
### Feature frattali
|
||||
Nessun look-ahead: direzione e livelli sono calcolati con dati fino a `close[i]`.
|
||||
|
||||
- Rapporti body/shadow normalizzati su finestre multiple (12, 24, 48 candele)
|
||||
- Momentum, volatilità, skewness, kurtosis dei rendimenti logaritmici
|
||||
- Autocorrelazione lag-1
|
||||
- Profilo volumetrico e spike detection
|
||||
- Durata della fase di squeeze e rapporto di espansione Keltner
|
||||
- Posizione del prezzo rispetto al range recente e ATR normalizzato
|
||||
### Perché lo squeeze breakout è stato abbandonato
|
||||
|
||||
L'ipotesi originale era opposta — *continuazione* dopo la compressione di volatilità
|
||||
(Bollinger dentro Keltner → breakout direzionale). Su dati storici sembrava dare
|
||||
76-82% di accuracy, ma era un **artefatto di look-ahead**: il backtest entrava a
|
||||
`close[i-1]` con direzione decisa da `close[i]`. Replicando l'esecuzione reale
|
||||
(ingresso a `close[i]`) l'edge collassa al ~47% (lancio di moneta) e i costi fanno
|
||||
il resto. Il test sui breakout intra-barra a 5m conferma che il movimento *rientra*
|
||||
subito (mean-reversion), giustificando MR01. Tutta la famiglia squeeze è in `scripts/waste/`.
|
||||
|
||||
### Lezione metodologica
|
||||
|
||||
Ogni nuova strategia deve passare: (1) **ingresso eseguibile** senza look-ahead,
|
||||
(2) backtest **netto** dopo fee realistiche (0.10% RT Deribit), (3) validazione
|
||||
**out-of-sample** + robustezza su griglia parametri + sweep fee. Strumenti in
|
||||
`scripts/analysis/` (`strategy_research.py`, `oos_validation.py`, `intrabar_test.py`).
|
||||
|
||||
## Struttura progetto
|
||||
|
||||
```
|
||||
PythagorasGoal/
|
||||
├── src/
|
||||
│ ├── data/ # Download e gestione dati storici (Cerbero MCP + Binance)
|
||||
│ ├── data/ # Download e gestione dati (Cerbero MCP + Binance)
|
||||
│ ├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity
|
||||
│ ├── backtest/ # Motore di backtesting con fee e metriche
|
||||
│ ├── strategies/ # (predisposto per strategie modulari)
|
||||
│ ├── nn/ # (predisposto per reti neurali)
|
||||
│ └── utils/
|
||||
├── scripts/ # Script di analisi e test (01–13)
|
||||
│ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi
|
||||
│ │ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats
|
||||
│ │ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr
|
||||
│ └── live/ # Paper trading live su Deribit testnet
|
||||
│ ├── multi_runner.py # Orchestratore multi-strategia
|
||||
│ ├── strategy_worker.py # Worker indipendente con stato persistente
|
||||
│ ├── strategy_loader.py # Import dinamico classi Strategy
|
||||
│ ├── cerbero_client.py # Client HTTP per Cerbero MCP
|
||||
│ ├── signal_engine.py # Squeeze + ML real-time (legacy) + validazione OOS
|
||||
│ └── telegram_notifier.py
|
||||
├── scripts/
|
||||
│ ├── strategies/ # Strategie con edge validato OOS (solo MR01_bollinger_fade)
|
||||
│ ├── waste/ # Strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD)
|
||||
│ └── analysis/ # Ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...)
|
||||
├── strategies.yml # Config multi-strategy paper trader
|
||||
├── data/
|
||||
│ ├── raw/ # Parquet OHLCV (non committati, ~70 MB)
|
||||
│ └── processed/ # Modelli salvati
|
||||
│ └── raw/ # Parquet OHLCV (gitignored, ~70 MB)
|
||||
├── docs/
|
||||
│ └── diary/ # Diario di ricerca giornaliero
|
||||
├── tests/
|
||||
├── pyproject.toml
|
||||
└── README.md
|
||||
│ ├── diary/ # Diario di ricerca giornaliero
|
||||
│ └── specs/ # Specifiche di design
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── pyproject.toml
|
||||
```
|
||||
|
||||
## Strategie attive
|
||||
|
||||
Tutte le strategie estendono `src.strategies.base.Strategy` (`generate_signals() → backtest()`).
|
||||
|
||||
| Codice | Script | Tipo | Descrizione |
|
||||
|--------|--------|------|-------------|
|
||||
| **MR01** | `MR01_bollinger_fade.py` | Mean-reversion | Fada la banda di Bollinger, TP alla media, SL ad ATR. Unica con edge netto validato OOS. |
|
||||
|
||||
La famiglia squeeze (SQ01-04, ML01, MT01, PD01, CM01, AD01) è in `scripts/waste/`:
|
||||
edge storico = artefatto di look-ahead (vedi sezione *Come funziona*).
|
||||
|
||||
Per eseguire il backtest della strategia:
|
||||
|
||||
```bash
|
||||
uv run python scripts/strategies/MR01_bollinger_fade.py
|
||||
```
|
||||
|
||||
Per la ricerca/validazione fee-aware out-of-sample:
|
||||
|
||||
```bash
|
||||
uv run python scripts/analysis/strategy_research.py # screening famiglie + deep-dive MR01
|
||||
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
|
||||
uv run python scripts/analysis/validate_worker_mr01.py # replay del worker live su MR01
|
||||
```
|
||||
|
||||
## Paper Trading Live
|
||||
|
||||
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP, ognuna con €1000 USDC virtuali indipendenti. Se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come MR01), il worker chiude su take-profit alla media / stop-loss ad ATR / time-limit; altrimenti usa il fallback `hold_bars`/stop -2%.
|
||||
|
||||
### Avvio
|
||||
|
||||
```bash
|
||||
# Locale
|
||||
uv run python -m src.live.multi_runner
|
||||
|
||||
# Docker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Configurazione
|
||||
|
||||
Le strategie attive sono definite in `strategies.yml`:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
capital: 1000
|
||||
position_size: 0.15
|
||||
leverage: 3
|
||||
|
||||
strategies:
|
||||
- name: MR01_bollinger_fade
|
||||
asset: BTC
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
bb_window: 50
|
||||
k: 2.5
|
||||
sl_atr: 2.0
|
||||
max_bars: 24
|
||||
```
|
||||
|
||||
Per aggiungere una strategia: nuova riga in `strategies.yml`, poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
|
||||
|
||||
### Persistenza
|
||||
|
||||
Ogni strategia ha la sua directory in `data/paper_trades/`:
|
||||
|
||||
```
|
||||
data/paper_trades/
|
||||
MR01_bollinger_fade__BTC__1h/
|
||||
trades.jsonl # Storico trade append-only
|
||||
status.json # Stato corrente (resume al restart, include tp/sl/max_bars)
|
||||
```
|
||||
|
||||
Notifiche Telegram per ogni trade (richiede `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` in `.env`).
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Clona il repository
|
||||
# Clona e installa
|
||||
git clone <repo-url> && cd PythagorasGoal
|
||||
|
||||
# Installa dipendenze (richiede uv)
|
||||
uv sync
|
||||
|
||||
# Scarica dati storici (~70 MB, richiede connessione)
|
||||
# Scarica dati storici (~70 MB)
|
||||
uv run python -m src.data.downloader
|
||||
|
||||
# Esegui la strategia ibrida vincente
|
||||
uv run python scripts/13_squeeze_ml_hybrid.py
|
||||
# Backtest strategia attiva
|
||||
uv run python scripts/strategies/MR01_bollinger_fade.py
|
||||
|
||||
# Paper trading live
|
||||
uv run python -m src.live.multi_runner
|
||||
```
|
||||
|
||||
### Requisiti
|
||||
|
||||
- Python ≥ 3.11
|
||||
- [uv](https://docs.astral.sh/uv/) come package manager
|
||||
- Accesso a Cerbero MCP (`cerbero-mcp.tielogic.xyz`) per i dati Deribit, oppure Binance via ccxt come fallback
|
||||
- Accesso a Cerbero MCP (`cerbero-mcp.tielogic.xyz`) per dati Deribit live
|
||||
- Docker (opzionale, per deploy su VPS)
|
||||
|
||||
## Dati
|
||||
|
||||
@@ -90,25 +199,7 @@ uv run python scripts/13_squeeze_ml_hybrid.py
|
||||
| BTC | 5m / 15m / 1h | 883K / 294K / 74K | 2018-01 → oggi |
|
||||
| ETH | 5m / 15m / 1h | 882K / 294K / 74K | 2018-01 → oggi |
|
||||
|
||||
Fonte primaria: Deribit perpetual via Cerbero MCP. Fallback per il periodo antecedente: Binance spot via ccxt. Formato: Apache Parquet.
|
||||
|
||||
## Strategie testate
|
||||
|
||||
| Script | Approccio | Esito |
|
||||
|--------|-----------|-------|
|
||||
| 01 | Pattern candlestick discreti (U/D/0) | Nessun edge |
|
||||
| 02 | DTW pattern matching | Troppo lento, edge minimo |
|
||||
| 03 | Proiezione FFT (ispirata al paper) | Random (49.8%) |
|
||||
| 04 | GBM su feature frattali (Hurst, FD) | 63.6% a soglia 0.65 |
|
||||
| 05 | GBM multi-window (corretto data leakage) | 58.9% |
|
||||
| 06 | GBM su feature strutturali normalizzate | 58.6%, +57.5% return |
|
||||
| 07 | LSTM su sequenze candele | 58.4%, comparabile a GBM |
|
||||
| 08 | Ensemble multi-timeframe (1h + 15m) | 59.2% (consensus 2/3) |
|
||||
| 09 | Walk-forward ML | 57.7%, Sharpe 7.4, €3.12/day |
|
||||
| 10 | Ensemble 5 modelli alta precisione | In corso |
|
||||
| 11 | **Volatility Squeeze Breakout** | **83.9%**, approccio strutturale |
|
||||
| 12 | Report finale e simulazione crescita | — |
|
||||
| 13 | **Squeeze + ML ibrida** | **76.9%**, 118% ann, €13.78/day |
|
||||
Fonte primaria: Deribit perpetual via Cerbero MCP. Fallback: Binance spot via ccxt. Formato: Apache Parquet.
|
||||
|
||||
## Riferimenti
|
||||
|
||||
|
||||
+5
-2
@@ -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,136 @@
|
||||
# 2026-05-28 — Ricerca onesta di nuove strategie (post-squeeze)
|
||||
|
||||
## Contesto e mandato
|
||||
|
||||
Dopo aver scoperto che l'intera famiglia squeeze-breakout era un artefatto di
|
||||
look-ahead (accuratezze 76-82% svanite sotto ingresso eseguibile), il mandato è
|
||||
stato: trovare in modo **onesto** almeno 3 strategie attendibili, testate su ~8
|
||||
anni e su più criptovalute, con le fee incluse nella valutazione, partendo da
|
||||
€1.000 con l'obiettivo (aspirazionale) di €50/giorno. Esplorare anche idee fuori
|
||||
dal comune e l'uso combinato di più crypto e timeframe.
|
||||
|
||||
## Metodologia (engine onesto)
|
||||
|
||||
Tutto il lavoro usa un unico engine condiviso (`scripts/analysis/honest_lab.py`)
|
||||
con questi vincoli anti-illusione:
|
||||
|
||||
1. **Ingresso eseguibile.** Ogni segnale alla barra `i` usa solo dati fino a
|
||||
`close[i]` e l'ingresso avviene a `close[i]` (ciò che il worker live vede e
|
||||
può eseguire). Disponibile anche l'ingresso più conservativo a `open[i+1]`.
|
||||
2. **Uscita realistica.** Take-profit / stop-loss valutati intrabar su `high`/`low`,
|
||||
in modo conservativo (SL prima del TP nello stesso bar), più time-limit.
|
||||
Una posizione per volta (non-overlap), capitale composto.
|
||||
3. **Fee di prim'ordine.** Tutto è NETTO dopo fee round-trip realistiche Deribit
|
||||
(0.10% RT) moltiplicate per la leva (3x), con sweep fino a 0.20% RT.
|
||||
4. **Validazione severa.** FULL + out-of-sample (ultimo 30%) + conteggio anni
|
||||
positivi + sweep fee + griglia parametri + test su **8 crypto**
|
||||
(BTC, ETH, SOL, BNB, XRP, LTC, DOGE, ADA, 2018→2026).
|
||||
|
||||
## Lezione madre
|
||||
|
||||
**Shortare le crypto perde OOS in modo sistematico in questo campione.** Sia la
|
||||
mean-reversion sul lato short, sia il momentum short, crollano fuori campione: il
|
||||
periodo 2018-2026 è net-bull e ogni rialzo "estremo" tende a continuare invece di
|
||||
rientrare. Tutte le configurazioni che sopravvivono oneste sono **long-biased**.
|
||||
È un fatto da dichiarare: parte della performance OOS è correlata al beta rialzista
|
||||
delle crypto. Le strategie aggiungono *timing* sopra quel beta, non lo eliminano.
|
||||
|
||||
## Le 3 strategie selezionate (meccanismi distinti)
|
||||
|
||||
| Codice | Meccanismo | TF | Asset robusti | OOS netto (fee 0.10% RT) | DD | Anni+ |
|
||||
|--------|-----------|----|---------------|--------------------------|----|-------|
|
||||
| **DIP01** | Dip-buy z-score reversion (long-only) | 1h | BTC, ETH, SOL | BTC +59% · ETH +224% · SOL +13% | 23-55% | 6-7/9 |
|
||||
| **TR01** | EMA 20/100 trend-following (long-only) | 4h | BNB, BTC, DOGE, SOL, XRP | BTC +27% · DOGE +53% · XRP +29% | 29-53% | 4-6/8 |
|
||||
| **ROT01** | Rotazione cross-sectional momentum sul paniere | 1d | intero paniere (8) | **+44%** | 53% | 5/7 |
|
||||
|
||||
Dettagli e riproducibilità: `scripts/analysis/honest_final.py` (tabella di
|
||||
validazione unica), `honest_rotation.py`, `honest_trend.py`, `honest_candidates.py`,
|
||||
`honest_diag.py`/`honest_diag2.py` (diagnostica long/short e filtro trend).
|
||||
|
||||
### DIP01 — compra le capitolazioni
|
||||
Long-only: entra quando lo z-score del prezzo rispetto alla media a 50 barre scende
|
||||
sotto −2.5 (capitolazione), prende profitto al rientro verso la media, SL a 2.5·ATR.
|
||||
È la versione robusta e onesta della famiglia mean-reversion: regge lo sweep fee
|
||||
fino a 0.20% RT (BTC +45% OOS anche a 0.20%). Funziona sui major (BTC/ETH/SOL); sugli
|
||||
alt molto parabolici (DOGE/BNB) un dip fisso continua a scendere e non ha edge.
|
||||
|
||||
### TR01 — cavalca i trend
|
||||
Long-only: in posizione quando EMA(20) > EMA(100) sul 4h, altrimenti cash. Poche
|
||||
operazioni (≈200 flip in 8 anni) ⇒ le fee non sono letali. È **complementare** a
|
||||
DIP01: guadagna nei regimi di trend, dove la reversione soffre.
|
||||
|
||||
### ROT01 — la più affidabile e "fuori dal comune"
|
||||
Una sola strategia che usa **tutto il paniere** in un unico book: ogni giorno ordina
|
||||
le 8 crypto per momentum (rendimento a 60 giorni) e alloca a parti uguali alle 2
|
||||
migliori con momentum positivo, il resto in cash. Cattura la *dispersione* tra
|
||||
crypto (gli alt forti corrono molto più di BTC nei bull) senza shortare nulla.
|
||||
È **param-insensitive** (tutte le combinazioni lookback/top-k sono positive OOS) e
|
||||
regge le fee fino a 0.20% RT (+41% OOS). Risponde direttamente alla richiesta di
|
||||
combinare più crypto e un timeframe diverso in un'unica strategia. Per-anno:
|
||||
2020 +33% · 2021 +181% · 2022 −29% (bear) · 2023 +43% · 2024 +59% · 2025 +6% · 2026 −10% (YTD).
|
||||
|
||||
## Diversificazione
|
||||
|
||||
I tre meccanismi coprono regimi diversi e in larga misura anti-correlati:
|
||||
reversione (DIP01), momentum di singolo asset (TR01), forza relativa cross-asset
|
||||
(ROT01). Eseguirli insieme produce una curva di equity più liscia del singolo.
|
||||
|
||||
## Onestà sull'obiettivo €50/giorno
|
||||
|
||||
Va detto chiaramente: **€50/giorno su €1.000 in pochi mesi non è raggiungibile a
|
||||
rischio sano.** Significa ~€18.250/anno, cioè ~1.825%/anno; gli edge onesti qui
|
||||
trovati rendono il 30-60% OOS su orizzonti pluriennali. Le strade per avvicinare
|
||||
quel numero sono: (a) far crescere il capitale per anni con interesse composto —
|
||||
€50/giorno diventa plausibile solo quando il capitale è molto più grande; (b) alzare
|
||||
la leva, che però aumenta proporzionalmente il drawdown (già 23-55%) ed espone a
|
||||
rovina; (c) aggiungere capitale. Nessuna di queste è una scorciatoia. La proposta
|
||||
onesta è un portafoglio delle 3 strategie a leva moderata, puntando alla
|
||||
**sopravvivenza e alla crescita composta**, non al target giornaliero immediato.
|
||||
|
||||
## Miglioramenti (alzare Acc, ridurre DD, migliorare PnL)
|
||||
|
||||
Leve oneste e documentate, senza tuning sui singoli anni
|
||||
(`scripts/analysis/honest_improve.py`, `honest_improve2.py`):
|
||||
|
||||
### ROT02 — dual-momentum overlay (migliora TUTTO)
|
||||
Alla rotazione cross-sectional di ROT01 si aggiunge un overlay di *absolute
|
||||
momentum*: cash quando BTC è sotto la sua media a 100 giorni (mercato risk-off).
|
||||
Taglia i bear di sistema (gli unici anni rossi di ROT01).
|
||||
|
||||
| | FULL% | OOS% | DD% |
|
||||
|---|---|---|---|
|
||||
| ROT01 base | +679 | +44 | 53 |
|
||||
| **ROT02 (SMA100)** | **+1095** | **+98** | **40** |
|
||||
|
||||
PnL su, DD giù: dominanza su tutte e tre le metriche. Param-insensitive (SMA100-150).
|
||||
|
||||
### DIP01 — market-gate (variante low-DD)
|
||||
Comprare i dip solo quando BTC è risk-on alza l'**Acc** (ETH 52→57%, SOL 49→52%) e
|
||||
**dimezza il DD** (ETH 53→23%, SOL 25→13%), al costo di parte della PnL (meno trade).
|
||||
È de-risking, non un pasto gratis: utile per chi vuole una curva più liscia. Su BTC
|
||||
il gate va evitato (i dip migliori di BTC arrivano proprio quando BTC è sotto la
|
||||
propria SMA), quindi DIP01 base resta la versione di riferimento per BTC.
|
||||
|
||||
### PORT01 — portafoglio combinato (il vero motore di risk-reduction)
|
||||
Equal-weight giornaliero ribilanciato delle 3 sleeve anti-correlate
|
||||
(DIP01 BTC + TR01 basket + ROT02). La diversificazione porta il DD del portafoglio
|
||||
**sotto** quello della sleeve meno rischiosa, mantenendo una CAGR alta.
|
||||
|
||||
| Sleeve | ret% | DD% | CAGR% |
|
||||
|--------|------|-----|-------|
|
||||
| DIP01 BTC | +322 | 15 | 31 |
|
||||
| TR01 basket | +591 | 27 | 43 |
|
||||
| ROT02 dual-mom | +771 | 40 | 49 |
|
||||
| **PORTAFOGLIO** | **+642** | **12** | **45** |
|
||||
|
||||
Per-anno portafoglio: 2021 +203% · 2022 **−1%** (bear neutralizzato, era −30% su ROT) ·
|
||||
2023 +47% · 2024 +50% · 2025 +14% · 2026 −2% (YTD). Nessun anno realmente negativo,
|
||||
DD massimo 12%, CAGR 45%. È la configurazione di deployment raccomandata.
|
||||
|
||||
## Prossimi passi
|
||||
|
||||
- Integrare DIP01 nel worker (già compatibile: Signal con tp/sl/max_bars).
|
||||
- Trailing-stop ad ATR per TR01 (per alzarne l'Acc e ridurne ulteriormente il DD).
|
||||
- Estendere il worker per strategie position-based (TR01) e di portafoglio (ROT01).
|
||||
- Backtest del portafoglio combinato con ribilanciamento del capitale.
|
||||
- Walk-forward rolling (oltre al singolo split 70/30) per confermare la stabilità.
|
||||
@@ -0,0 +1,193 @@
|
||||
# 2026-05-28 — Giorno 3: Bug dati Cerbero, paper trader fermo, fix MT01 multi-timeframe
|
||||
|
||||
### 12:20 — Sintomo: paper trader live a zero trade
|
||||
|
||||
**Cosa:** check del container `pythagoras-multi` (multi-strategy paper trader, 6 strategie).
|
||||
**Reale:** container healthy da ore, ma **0 trade** su tutte le strategie, tutte FLAT a €1000.
|
||||
Primo falso indizio: `last_bar_ts: 0` in tutti gli `status.json`. Indagando il worker,
|
||||
quel campo si aggiorna **solo a posizione aperta** (contatore `hold_bars`), non ad ogni
|
||||
candela → non è la causa. Il loop era vivo (status.json riscritti ogni 60s).
|
||||
**Lezione:** non fidarsi del nome di un campo; verificare nel codice quando viene scritto.
|
||||
L'healthcheck del container controlla solo l'esistenza di `status.json`, non la freschezza
|
||||
→ un loop bloccato risulterebbe comunque "healthy".
|
||||
|
||||
### 12:45 — Causa radice: bug lato Cerbero MCP `get_historical`
|
||||
|
||||
**Cosa:** probe dirette all'endpoint `/mcp-deribit/tools/get_historical`.
|
||||
**Reale:** due bug lato server:
|
||||
1. **`end_date` data-nuda tronca a mezzanotte:** `end=oggi` restituiva candele solo fino a
|
||||
`oggi 00:00`. Il `df` live finiva sempre alla barra di mezzanotte e **non avanzava** durante
|
||||
la giornata → nessun breakout fresco sull'ultima barra → nessun ingresso (condizione worker
|
||||
`last_signal.idx >= last_idx - 1`).
|
||||
2. **Cap a ~5000 righe** che ignora `start_date`: una richiesta di 365g a 15m restituiva ~52
|
||||
giorni. Ecco perché ML01 si addestrava su soli 88 samples (overfit, train_acc 100%).
|
||||
**Lezione:** lo zero-trade non era nelle strategie ma nel feed dati. Sempre validare la
|
||||
freschezza/copertura dei dati prima di sospettare la logica.
|
||||
|
||||
### 13:30 — Fix lato Cerbero + verifica
|
||||
|
||||
**Cosa:** report passato al dev di `cerbero-mcp`; fix deployato (riavvio container) + doc
|
||||
aggiornata in `cerbero-mcp/docs/API_REFERENCE.md`.
|
||||
**Reale dopo deploy (verificato con probe):**
|
||||
- `end=oggi` (data nuda) → ultima candela = ora corrente (age ~3 min). ✅
|
||||
- 365g a 15m → **35.099 candele**, span 365.6g, nessun cap. ✅
|
||||
- Supportati anche timestamp con orario (`...T14:00:00`, naive = UTC). ✅
|
||||
|
||||
Nostro client (`src/live/cerbero_client.py`) invariato: passa già `end=oggi`, ora corretto.
|
||||
**Lezione:** "trust but verify" — la doc dichiarava i fix prima che fossero deployati; solo
|
||||
la probe diretta ha confermato cosa era davvero attivo sul server.
|
||||
|
||||
### 14:00 — Problema residuo: MT01 usava un trend 1h STANTIO
|
||||
|
||||
**Cosa:** check di tutte le strategie sul percorso di codice reale con dati freschi.
|
||||
**Reale:**
|
||||
- Tutte le 6 strategie girano senza crash; SQ01/SQ02 generano molti segnali.
|
||||
- **MT01 leggeva il trend 1h dal parquet statico** (`load_data(asset,"1h")`), non da Cerbero.
|
||||
Il parquet finiva a mezzanotte → per ogni barra 15m di oggi `searchsorted` cadeva oltre la
|
||||
fine e si agganciava sempre alla candela di mezzanotte (gap 14.8h). La conferma
|
||||
multi-timeframe — il cuore di MT01 — era di fatto congelata e il gap cresce ogni giorno.
|
||||
- In `data/raw/` mancavano del tutto i parquet **15m** (`btc_15m`, `eth_15m`) → backtest 15m rotti.
|
||||
**Lezione:** una strategia live che dipende da un file statico ha un punto cieco temporale;
|
||||
il dato live e quello di backtest devono provenire da fonti coerenti.
|
||||
|
||||
### 14:30 — Fix MT01: trend 1h live da Cerbero
|
||||
|
||||
**Cosa:** modifica al runner perché MT01 prenda l'1h live, non dal parquet.
|
||||
- `MT01.generate_signals` accetta un `df_1h` opzionale (fallback al parquet se assente).
|
||||
- `StrategyWorker.tick(df, df_1h=None)` lo inoltra ai signal.
|
||||
- `multi_runner` fa fetch 1h live (resolution 60) per gli asset MT01 ad ogni poll (`htf_cache`).
|
||||
**Reale (verificato a codice montato, pre-rebuild):** gap del trend 1h sull'ultima barra
|
||||
**0.75h** (fresco) contro **14.8h** col parquet statico. Segnali invariati sullo storico.
|
||||
**Lezione:** isolare la dipendenza dal file statico rende MT01 immune al drift tra un
|
||||
`download_all()` e l'altro.
|
||||
|
||||
### 14:55 — Rigenerazione dati + rebuild
|
||||
|
||||
**Cosa:** `download_asset` per 15m+1h (saltati 1m/5m, lenti e inutilizzati), poi
|
||||
`docker compose up -d --build` (il codice `src/` è baked nell'immagine).
|
||||
**Reale:** parquet rigenerati con storia completa 2018→2026 e freschi (15m fino alle 14:45,
|
||||
1h fino alle 14:00). Container ripartito: 6 strategie attive, ML01 riaddestrato su **534
|
||||
samples** (anno pieno), MT01 senza errori, fetch 1h live OK.
|
||||
|
||||
### 15:00 — Regressione backtest sui dati rigenerati
|
||||
|
||||
**Cosa:** rilanciati i backtest per confermare che i numeri documentati si riproducano sui
|
||||
dati ricreati da zero (BTC/ETH 15m, hold=3, fee 0.2% RT, leva 3x, pos 15%).
|
||||
**Reale:** accuratezze e drawdown **identici**, solo +1/+3 trade dalle barre recenti in più.
|
||||
|
||||
| Strategia | Ottenuto | Documentato | Esito |
|
||||
|---|---|---|---|
|
||||
| SQ01 BTC 15m | 76.7% / DD 6.7% / 4063t | 76.7% / 6.7% / 4062 | ✓ |
|
||||
| SQ01 ETH 15m | 76.4% / 6.2% / 2951t | 76.4% / 6.2% / 2948 | ✓ |
|
||||
| SQ02 BTC 15m | 79.7% / 6.5% / 1251t | 79.7% / 6.5% / 1250 | ✓ |
|
||||
| SQ02 ETH 15m | 78.6% / 3.4% / 944t | 78.6% / 3.4% / 942 | ✓ |
|
||||
| **MT01 BTC 15m (ema20+vol)** | **82.7% / 5.9% / 503t** | 82.7% / 5.9% / 503 | ✓ esatto |
|
||||
| MT01 ETH 15m (ema20+vol) | 81.2% / 2.9% / 404t | — | ok |
|
||||
|
||||
**Lezione:** l'integrità dei dati rigenerati è confermata — la pipeline di download produce
|
||||
risultati riproducibili. La config live di MT01 (ema20+vol) coincide col best documentato.
|
||||
|
||||
### Punti aperti
|
||||
|
||||
1. **Backtest e drift dati:** MT01 live ora è immune (1h da Cerbero), ma i backtest girano
|
||||
sempre sui dati fino all'ultimo `download_all()`. Per dati di backtest sempre freschi
|
||||
serve uno scheduling del download (cron/job).
|
||||
2. **Healthcheck:** valutare un check su mtime di `status.json` (< 180s) per rilevare uno
|
||||
stallo del loop, non solo l'esistenza del file.
|
||||
|
||||
---
|
||||
|
||||
### 23:00 — 3 nuove strategie con edge OOS fee-aware (branch `strategy_free`)
|
||||
|
||||
**Obiettivo:** trovare almeno 3 nuove strategie (oltre MR01), edge netto validato
|
||||
out-of-sample e fee-aware, per il target €1.000 → ~€50/giorno.
|
||||
|
||||
**Metodologia (invariata dalla lezione squeeze):** ingresso eseguibile a `close[i]`
|
||||
(nessun look-ahead), backtest netto dopo fee Deribit 0.10% RT + leva 3x, OOS = ultimo
|
||||
30% held-out, robustezza su griglia parametri + sweep fee 0.00–0.20% RT, exit
|
||||
TP/SL intrabar o time-limit, una posizione per volta, capitale composto.
|
||||
|
||||
**Candidati** (`scripts/analysis/strategy_research_v2.py`), tutti mean-reversion
|
||||
(l'edge è sempre il rientro, mai la continuazione):
|
||||
|
||||
| Candidato | Esito | Motivo |
|
||||
|---|---|---|
|
||||
| **MR02 Donchian Fade** | ✅ | Robusto su tutta la griglia `n × sl_atr` e tutte le fee |
|
||||
| **MR03 Keltner Fade** | ✅ | Robusto su tutta la griglia `n × k`; banda ATR, indipendente da Bollinger |
|
||||
| **MR07 Return Reversal** | ✅ | Intero blocco `tp_atr=2.0` positivo full+OOS; esposizione ~8% |
|
||||
| MR04 Z-score Reversion | ⛔ | Robusto ma è MR01 riparametrizzato (stessa banda std): edge non *nuovo* |
|
||||
| MR05 Bollinger + filtro ADX | ⛔ | Non robusto: negativo su gran parte della griglia BTC |
|
||||
| MR06 RSI(2) Connors | ⛔ | ETH 1h negativo; non robusto su entrambi gli asset |
|
||||
|
||||
**Risultati** (netto 0.10% RT, leva 3x, OOS, 1h):
|
||||
|
||||
| Codice | Meccanismo | BTC OOS | ETH OOS | DD (full) |
|
||||
|---|---|---|---|---|
|
||||
| MR02 | estremi canale Donchian H/L | +172% | enorme | 30% / 42% |
|
||||
| MR03 | canale ATR su EMA | +112% | +886% | 37% / 66% |
|
||||
| MR07 | z dei rendimenti di barra | +105% | +195% | 25% / 46% |
|
||||
|
||||
**Validazione live-path** (`oos_validation.py`, legge `strategies.yml`, exit hold
|
||||
del worker): tutte e tre positive netto OOS su tutto lo sweep fee, anche al
|
||||
pessimistico 0.20% RT → edge robusto pure al meccanismo di exit.
|
||||
|
||||
**Verifiche:** equivalenza esatta backtest produzione vs research engine (MR02 BTC:
|
||||
2039 trade, DD 29% identici); le 3 classi si caricano dal `strategy_loader`;
|
||||
aggiunte a `strategies.yml` (BTC+ETH 1h). Nessuna suite di test nel progetto.
|
||||
|
||||
**Onestà sul target:** con 4 fade indipendenti × 2 asset il PnL storico aggregato
|
||||
supera €50/giorno, ma sono backtest a leva 3x su 8 anni con annate eccezionali
|
||||
(ETH 2024). Plausibile ma da confermare col paper trader live prima del capitale reale.
|
||||
DD alto su ETH (MR03 ~66%, come MR01) → leva più bassa consigliata per quell'asset.
|
||||
|
||||
**File:** `strategy_research_v2.py`, `src/strategies/fade_base.py`,
|
||||
`scripts/strategies/MR0{2,3,7}_*.py` (nuovi); `strategy_loader.py`, `strategies.yml`,
|
||||
`CLAUDE.md` (aggiornati).
|
||||
**Lezione confermata:** ogni edge robusto trovato finora è mean-reversion; ogni
|
||||
variante trend/continuation o oscillatore senza filtro perde netto.
|
||||
|
||||
---
|
||||
|
||||
### 23:45 — Aumentare Acc e ridurre DD (filtro trend + portafoglio)
|
||||
|
||||
**Obiettivo:** alzare accuratezza e abbassare drawdown sulle 4 fade, senza
|
||||
distruggere l'edge né overfittare (ogni leva misurata FULL **e** OOS).
|
||||
|
||||
**Diagnosi:** perdite/DD concentrati 2018–2021 (bear/covid/caos vol), su ETH DD
|
||||
pieno 66–71%. Banco di prova: `scripts/analysis/risk_improvements.py` e
|
||||
`risk_portfolio.py`.
|
||||
|
||||
**Leve testate:**
|
||||
|
||||
| Leva | Esito | Motivo |
|
||||
|---|---|---|
|
||||
| Sizing vol-target (size ∝ 1/dist-SL) | ⛔ | Over-size sui trade a stop stretto → DD su, ritorno giù |
|
||||
| Skip alta volatilità (ATR% in coda alta) | ⛔ | L'alta vol è *positiva* per le fade (più reversione): Acc e ritorno giù |
|
||||
| **Filtro trend** (`\|close−EMA200\|/ATR > soglia` → salta) | ✅ | Non fada trend/crolli estremi: Acc↑ ovunque, DD↓ molto su ETH, OOS regge |
|
||||
| **Portafoglio** equipesato (sotto-conti indipendenti) | ✅ | Curve poco correlate → DD aggregato 14% (full)/10% (OOS) vs 20-70% singolo |
|
||||
|
||||
**Filtro trend — sweep soglia** (assoluta in ATR, regola unica per tutte = niente
|
||||
overfit): 3.0 ATR è l'equilibrio (2.0 taglia troppo ritorno). Effetto su config
|
||||
deployata (base → filtro):
|
||||
|
||||
| Sleeve | Acc | DD |
|
||||
|---|---|---|
|
||||
| MR01 ETH | 46→55 | **71→26** |
|
||||
| MR02 ETH | 49→55 | 42→25 |
|
||||
| MR03 ETH | 49→52 | 66→34 |
|
||||
| MR07 ETH | 48→54 | 46→21 |
|
||||
| MR01 BTC | 51→54 | 32→34* |
|
||||
| MR02 BTC | 48→52 | 29→23 |
|
||||
| MR07 BTC | 49→53 | 25→18 |
|
||||
| MR03 BTC | 47→47 | 37→37 (filtro OFF) |
|
||||
|
||||
\*MR01 BTC: DD full +2pt ma Acc +3.7 e DD OOS piatto (14.8→15.0). **MR03 BTC**:
|
||||
il filtro peggiora entrambe (unico sleeve) → lasciato disattivo nello yaml.
|
||||
|
||||
**Implementazione:** helper `trend_distance()` in `fade_base.py`; param opzionali
|
||||
`trend_max`/`ema_long` (default None = retro-compatibile) in tutte le strategie
|
||||
(MR01/02/03/07); `strategies.yml` con `trend_max: 3.0, ema_long: 200` (eccetto
|
||||
MR03 BTC). Verificato: equivalenza produzione vs ricerca.
|
||||
|
||||
**Lezione:** il modo onesto di ridurre il DD non è strozzare il sizing (peggiora),
|
||||
ma (a) non opporsi a trend estremi e (b) diversificare su strategie scorrelate.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Diario — 2026-05-29 — Esplorazione di nuove famiglie di strategie
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Trovare 5-10 nuove famiglie di strategie, diverse da quelle esistenti, migliori o
|
||||
complementari, con DD basso e attenzione alle fee. Esplorazione onesta (no
|
||||
look-ahead, netto fee, OOS) condotta con **agenti paralleli**, ognuno su una famiglia
|
||||
indipendente, tutti sullo stesso harness condiviso (`scripts/analysis/explore_lab.py`).
|
||||
Lavoro sul branch `strategy_explore`.
|
||||
|
||||
## Famiglie esplorate (9) ed esito onesto
|
||||
|
||||
| Famiglia | Esito | Note |
|
||||
|---|---|---|
|
||||
| **Pairs / spread reversion** | ✅ **VINCITORE** | Market-neutral, genuinamente nuova, decorrelata |
|
||||
| **TSMOM multi-orizzonte** | ✅ diversificatore | Marginale ma distinto (corr 0.53 con ROT02), DD basso |
|
||||
| Stagionalità settimanale | ⚠️ marginale/fragile | "Mercoledì-long-24h" 7/8 asset OOS+ ma effetto concentrato a 00:00 UTC |
|
||||
| Vol-target BTC | ⚠️ marginale | Sharpe 0.94 vs 0.76 buy&hold, DD ancora 44% |
|
||||
| Stagionalità intraday (ora) | ❌ rumore | L'edge orario muore sotto le fee |
|
||||
| Stagionalità mensile/turn-of-month | ❌ rumore | Reale in-sample, morto OOS dal 2024 |
|
||||
| Cross-sectional reversal | ❌ nessun edge | Perde vs equal-weight, corr 0.98 col momentum |
|
||||
| Opening-range breakout | ❌ non generalizza | Solo BTC/ETH, alcuni regimi, fee-fragile |
|
||||
| Lead-lag BTC→alt | ❌ nessun edge | Reazione contemporanea (corr lag+1 ≈ 0), non batte buy&hold |
|
||||
| Momentum/continuation intraday | ❌ negativo | Conferma: il *fade* (mean-reversion) domina |
|
||||
|
||||
7 famiglie su 9 sono rumore — e l'harness le ha rifiutate senza produrre falsi
|
||||
positivi (segnale che la metodologia onesta funziona). Due edge reali emergono.
|
||||
|
||||
## Vincitore 1 — PAIRS (market-neutral) — `PR01_pairs_reversion.py`
|
||||
|
||||
Scommette sul rientro del log-ratio di due cripto verso la media (z-score). Quando
|
||||
`z ≤ −2` → long A / short B; `z ≥ +2` → l'opposto; esce al rientro (`|z| ≤ 0.5`) o a
|
||||
tempo. Engine onesto verificato in `pairs_research.py` (test esplicito no-look-ahead:
|
||||
`z[i]` invariato perturbando il futuro). Fee contate su **2 gambe** (0.20% RT/coppia).
|
||||
|
||||
Validazione (netto, leva 3x, OOS = ultimo 30%, 1h):
|
||||
|
||||
| Coppia | CAGR | Sharpe | OOS DD | anni+ |
|
||||
|---|--:|--:|--:|--:|
|
||||
| ETH/BTC | 144% | 4.04 | 17% | 8/9 |
|
||||
| LTC/ETH | 71% | 2.52 | 10% | 7/8 |
|
||||
| ADA/ETH | 77% | 2.16 | 11% | 7/8 |
|
||||
|
||||
Tutte le 10 coppie testate positive FULL+OOS, regge fee 0.40% RT/coppia, correlazione
|
||||
col mercato ~0.02 (market-neutral confermato). DD pieno 42-49% (alto), ma OOS DD
|
||||
10-17% (buono) e soprattutto **quasi-zero correlazione** col resto → diversificatore
|
||||
eccezionale. Limite: 2 gambe (long+short), il worker live va esteso prima del live.
|
||||
|
||||
## Vincitore 2 — TSM01 (TSMOM multi-orizzonte) — `tsmom_research.py`
|
||||
|
||||
Long-only multi-crypto: tiene equal-weight gli asset con consenso pieno del segno di
|
||||
momentum su 3/6/12 mesi, cash se BTC<SMA100. Distinto da ROT02 (persistenza assoluta
|
||||
vs ranking relativo), corr 0.53. FULL +169% / OOS +80% / DD 22% / Sharpe 1.07,
|
||||
**mai un anno negativo**, regge fee 0.40%. Verificato no-look-ahead (cheat-test
|
||||
esplode a +575%). Marginale come stand-alone (rende meno di ROT02) ma utile in ensemble.
|
||||
|
||||
## Il payoff — combinare le nuove fonti col MASTER (`combine_v2.py`)
|
||||
|
||||
Le nuove sleeve sono quasi scorrelate col MASTER-9 (pairs ~0.02-0.08, TSM01 0.05).
|
||||
Aggiungerle migliora nettamente il portafoglio:
|
||||
|
||||
| Portafoglio | CAGR | DD% | Sharpe | OOS DD% | OOS Sharpe |
|
||||
|---|--:|--:|--:|--:|--:|
|
||||
| MASTER-9 (base) | 47 | 5.2 | 4.23 | 4.7 | 4.33 |
|
||||
| **MASTER + pairs (12)** | **66** | **3.8** | **5.67** | **3.3** | **6.86** |
|
||||
| MASTER + TSM01 (10) | 44 | 4.7 | 4.21 | 4.2 | 4.33 |
|
||||
| MASTER esteso (13) | 62 | 3.6 | 5.66 | 3.0 | 6.79 |
|
||||
|
||||
I **pairs** sono l'aggiunta decisiva: alzano la CAGR (47→66), **abbassano il DD**
|
||||
(5.2→3.8 full, 4.7→**3.3** OOS) e portano lo Sharpe OOS a **6.86** — il free-lunch
|
||||
della diversificazione da una fonte market-neutral scorrelata. TSM01 contribuisce
|
||||
poco (diluisce il ritorno) ma abbassa lievemente il DD.
|
||||
|
||||
## Caveat onesti
|
||||
|
||||
- I pairs hanno DD pieno alto (42-49%) sull'1h; il vantaggio sta nella decorrelazione,
|
||||
non nel DD stand-alone. Richiedono esecuzione a 2 gambe (short del perp B) — da
|
||||
verificare shortabilità/liquidità sugli alt e raddoppio fee nel worker.
|
||||
- Sharpe combinati 5-7 e CAGR 60%+ sono backtest a leva 3x su finestra 2021-2026 con
|
||||
OOS ~1.6 anni e il 2024 cripto eccezionale: numeri ottimistici, da confermare in
|
||||
paper trading live.
|
||||
- TSMOM e le strategie honest condividono l'overlay risk-off SMA100: parte della loro
|
||||
difensività è comune (non perfettamente indipendente).
|
||||
|
||||
## Terza ondata — espansione dei meccanismi provati + 2 nuovi sondaggi
|
||||
|
||||
Esplorate altre 4 direzioni con agenti paralleli:
|
||||
- **Fade su 6 nuovi alt (ADA/BNB/DOGE/LTC/SOL/XRP)**: 0 robuste. La mean-reversion
|
||||
fade vive solo su BTC/ETH (liquidi); sugli alt sparisce o è artefatto di pochi pump
|
||||
(DOGE). Coerente con la lezione del progetto.
|
||||
- **Espansione PAIRS** (tutte le 28 coppie): trovate **3 nuove coppie robuste** →
|
||||
BTC/LTC (robusta 1h *e* 4h, Sharpe 2.21, DD 24-34%, concentrazione PnL 9%), ETH/SOL
|
||||
e BNB/ETH (Sharpe 2.4+, solo 1h). Pattern: sempre alt-liquido vs major, mai alt/alt.
|
||||
PR01 ora ha **6 coppie**.
|
||||
- **Low-volatility anomaly**: ❌ in cripto è INVERTITA (vince l'alta vol = alta beta),
|
||||
ridondante con EW+risk-off/ROT02. L'anti-test high-vol stravince.
|
||||
- **Confluenza multi-timeframe (fade 1h confermato da 4h)**: non crea edge nuovo e non
|
||||
migliora lo Sharpe, ma **dimezza il DD** di MR01 (ETH: stesso Sharpe 3.17 a DD 38% vs
|
||||
63%) e stabilizza l'OOS → utile variante low-DD, non strategia indipendente.
|
||||
|
||||
## Bilancio finale e MASTER esteso (6 pairs)
|
||||
|
||||
Robusti deployabili: **famiglia PAIRS (6 coppie) + TSM01** (+ confluenza MTF come variante
|
||||
low-DD di MR01, + tilt stagionale mercoledì marginale). I 6 pairs sono quasi scorrelati col
|
||||
MASTER (corr 0.02-0.08). MASTER + 6 pairs:
|
||||
|
||||
| Portafoglio | CAGR | DD% | Sharpe | OOS DD% | OOS Sharpe |
|
||||
|---|--:|--:|--:|--:|--:|
|
||||
| MASTER-9 (base) | 47 | 5.2 | 4.23 | 4.7 | 4.33 |
|
||||
| **MASTER + 6 pairs (15)** | **71** | 5.7 | **5.93** | **2.3** | **7.71** |
|
||||
| MASTER esteso +TSM01 (16) | 67 | 5.4 | 5.95 | **2.2** | 7.67 |
|
||||
|
||||
Aggiungere i 6 pairs porta l'**OOS DD a 2.2-2.3%** (da 4.7%) con Sharpe OOS ~7.7 e tutti
|
||||
gli anni positivi: il guadagno di diversificazione da fonti market-neutral scorrelate.
|
||||
|
||||
## Quarto giro — validazione anti-overfitting e irrobustimento
|
||||
|
||||
Tre audit scettici paralleli (walk-forward, plateau, stress, scomposizione):
|
||||
|
||||
**Pairs — de-overfittati.** Sostituita la config per-coppia (cherry-picking di z_exit/n)
|
||||
con **una config universale `n=50 z_in=2.0 z_exit=0.75 max_bars=72`**. Verifiche:
|
||||
- plateau (non picco): heatmap n×z_in → 20/20 celle Sharpe>1 su ETH/BTC e BTC/LTC;
|
||||
- walk-forward (train 2y / test 6m rolling): ETH/BTC 11/12 finestre positive, BTC/LTC
|
||||
9/10 → edge distribuito su tutta la storia, non un regime singolo;
|
||||
- **BNB/ETH scartata** (era robusta solo coi suoi parametri → overfit; crolla con la
|
||||
universale e muore per prima allo stress costi). Famiglia ridotta a **5 pairs**.
|
||||
- stress: 5/6 reggono fee+slippage realistici; solo ETH/BTC regge fee 6x (coda fee-fragile).
|
||||
|
||||
**Master — numeri sobri.** L'OOS Sharpe 7.7 / DD 2.3% è **ottimistico ~50%** perché l'OOS
|
||||
cade nel bull calmo 2024-25. Numeri onesti da usare:
|
||||
- worst-DD su finestra mobile 90g (2021-2026) = **5.7%** (bear FTX) → budget DD ~6%, non 2.3%;
|
||||
- Sharpe per-semestre: mediana **~5** (min 1.2, max 12) → atteso ~5, non 7.7;
|
||||
- ogni anno e ogni semestre dal 2021 positivo (anche il 2022 bear, grazie alle gambe short);
|
||||
- equal-weight ≈ inverse-vol (non dipende da pesi fortunati);
|
||||
- regge **leva 2x + slippage doppio** (CAGR 36%, Sharpe 5.1);
|
||||
- **rischio concentrato: i pairs portano ~57% del rischio** → cap consigliato ~30-35%.
|
||||
- Config robusta raccomandata: **MASTER-esteso, equal-weight, leva 2x, cap pairs ~30-35%**.
|
||||
|
||||
**TSM01 — confermato robusto** (36/36 config OOS+, walk-forward stabile) ma corr reale con
|
||||
ROT02 = **0.62** (non 0.53), e gran parte del DD basso viene dall'overlay risk-off condiviso.
|
||||
Tenuto come diversificatore con **gross 0.30** (stesso Sharpe, DD 22%→15%).
|
||||
|
||||
**Confluenza multi-TF — SCARTATA: era overfit.** Taglia il 97% dei trade (restano ~40 in
|
||||
8 anni = non significativo), distrugge lo Sharpe (1.58→0.27 su BTC) e il caso "bello" non
|
||||
sopravvive alle perturbazioni. Per abbassare il DD di MR01 meglio ridurne la leva, non il filtro 4h.
|
||||
|
||||
**Risultato del giro:** quanto trovato regge l'esame anti-overfit (NON è l'errore squeeze),
|
||||
ma i numeri vanno comunicati sobri (Sharpe ~5, DD ~6%) e con leva 2x + cap pairs. Famiglia
|
||||
pairs consolidata a 5 coppie con config universale; confluenza MTF rimossa dai vincitori.
|
||||
|
||||
## File creati (branch strategy_explore)
|
||||
|
||||
`scripts/analysis/explore_lab.py` (harness onesto condiviso), `pairs_research.py`
|
||||
(verifica + ricerca pairs), `tsmom_research.py` (TSM01), `combine_v2.py` (master
|
||||
esteso); `scripts/strategies/PR01_pairs_reversion.py` (artefatto pairs).
|
||||
@@ -0,0 +1,64 @@
|
||||
# Diario di ricerca — 2026-05-29
|
||||
|
||||
## Combinare le strategie migliora i risultati?
|
||||
|
||||
**Domanda:** usare insieme le due famiglie di strategie presenti sul repo migliora
|
||||
il profilo rischio/rendimento rispetto a usarle separatamente?
|
||||
|
||||
- **FADE** (mie): reversione intraday 1h, long/short, BTC/ETH — MR01 Bollinger,
|
||||
MR02 Donchian, MR03 Keltner, MR07 Return-reversal (tutte col filtro trend 3.0 ATR).
|
||||
- **HONEST** (altra sessione): long-only multi-regime multi-crypto — DIP01 (dip-buy
|
||||
1h BTC), TR01 (EMA-trend 4h basket), ROT02 (dual-momentum rotation 1d).
|
||||
|
||||
**Metodo** (`scripts/analysis/combine_portfolio.py`): per ogni sleeve si costruisce
|
||||
l'equity **giornaliera** normalizzata su un indice comune (2021-01-01 → 2026-05-26),
|
||||
si passa ai rendimenti giornalieri, si misura la correlazione cross-famiglia e si
|
||||
confrontano i portafogli equal-weight (ribilanciati ogni giorno), 50/50 fra famiglie
|
||||
e inverse-vol. Metriche FULL e OOS (ultimo 30% della finestra comune, da 2024-10-12):
|
||||
ritorno, CAGR, max DD, Sharpe annualizzato. Le curve honest sono riusate da
|
||||
`honest_improve2.py`; quelle fade da `risk_management.build_trades`.
|
||||
|
||||
**Correlazione:** cross-famiglia **+0.05** (quasi indipendenti). Intra-fade +0.18,
|
||||
intra-honest +0.05. L'unica coppia un po' correlata è MR01_BTC↔DIP01_BTC (+0.43),
|
||||
entrambe mean-reversion su BTC. Famiglie scorrelate ⇒ diversificazione quasi ideale.
|
||||
|
||||
**Risultati (FULL | OOS):**
|
||||
|
||||
| Portafoglio | Ret% | CAGR | DD% | Sharpe | oDD% | oSharpe |
|
||||
|---|---|---|---|---|---|---|
|
||||
| FADE only (8) | +549 | 41 | 8.6 | 3.75 | 5.4 | 4.14 |
|
||||
| HONEST only (3) | +642 | 45 | 12.0 | 1.90 | 6.5 | 2.23 |
|
||||
| **ALL equal-weight (11)** | +589 | 43 | 6.1 | **3.95** | 4.6 | **4.46** |
|
||||
| **ALL 50/50 famiglie** | +615 | 44 | **5.5** | 3.18 | **4.0** | 3.87 |
|
||||
| ALL inverse-vol | +483 | 39 | 5.8 | 3.97 | 4.6 | 4.02 |
|
||||
|
||||
**Conclusione: sì, combinare conviene.**
|
||||
- DD crolla: combinato 5.5–6.1% full / 4.0–4.6% OOS, contro 8.6% (fade) e **12%**
|
||||
(honest) da sole → drawdown ridotto del 35–50%.
|
||||
- Sharpe sale: combinato OOS **4.46** vs honest 2.23 (raddoppia) e batte pure fade (4.14).
|
||||
- CAGR resta ~43–44% (≈ media delle due famiglie) ma con metà del rischio: è il
|
||||
"free lunch" della diversificazione fra sorgenti di edge scorrelate.
|
||||
- Best Sharpe = equal-weight degli 11 sleeve; best DD = 50/50 fra le due famiglie.
|
||||
|
||||
**Caveat onesti:** la finestra comune è 2021–2026 (5.4 anni), OOS ~2024-10→oggi
|
||||
(1.6 anni) — pochi regimi. CAGR e Sharpe sono backtest a leva 3x; il 2024 cripto
|
||||
favorevole pesa. Il target €50/giorno resta vincolato dal capitale: 43% CAGR su
|
||||
€1000 non fa €50/giorno a breve, serve compounding pluriennale o più capitale.
|
||||
Prossimo passo: confermare il portafoglio combinato nel paper trader live.
|
||||
|
||||
**File:** `scripts/analysis/combine_portfolio.py` (nuovo).
|
||||
|
||||
## Pulizia roster + miglioria ROT02
|
||||
|
||||
- **Waste delle peggiori:** MR03 Keltner (fade più debole, Sharpe 1.22, ridondante
|
||||
con MR01 — rimuoverla *migliora* il portafoglio fade: DD 8.6→8.2, ret +549→+666)
|
||||
e ROT01 (dominata da ROT02). Spostate in `scripts/waste/`.
|
||||
- **Portafogli pronti:** `PORT02_fade_master` (6 sleeve fade) e `PORT03_all_master`
|
||||
(9 sleeve fade+honest, varianti equal/5050).
|
||||
- **ROT02 DD alto → migliorato:** la rotazione concentrava il book su 2 asset
|
||||
(DD 40%). Sweep su `rot_improved`: `top_k=3` dimezza quasi il DD (40%→26%) e
|
||||
*alza* il ritorno full (+1095→+1303%, ret/DD 27→50). Il vol-target abbassa il DD
|
||||
ma sacrifica ritorno (de-leverage) → tenuto top_k=3 senza VT. Caveat onesto:
|
||||
l'OOS di ROT02 cala un po' (+98→+68%, DD 12→14%), ma il MASTER (config deployata)
|
||||
migliora lo Sharpe full 3.95→4.23. Applicato a `ROT02_dual_momentum.py` e
|
||||
`_rot_daily_equity`. Sweep in `honest_improve.rot_improved`.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Multi-Strategy Paper Trader — Design Spec
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Eseguire N strategie di trading in parallelo su Deribit testnet (paper trading locale), ognuna con capitale virtuale indipendente di €1000 USDC. Lo storico trade di ogni strategia persiste tra restart. Nuove strategie aggiungibili in corso d'opera via config YAML senza perdere lo storico delle esistenti.
|
||||
|
||||
## Architettura
|
||||
|
||||
Un singolo container Docker esegue un orchestratore (`MultiStrategyRunner`) che gestisce N `StrategyWorker`. Ogni worker è indipendente: proprio capital, propri trade, proprio stato.
|
||||
|
||||
```
|
||||
Docker Container
|
||||
├── MultiStrategyRunner (orchestratore, loop principale)
|
||||
│ ├── StrategyWorker[SQ02_BTC_15m] → paper trade → JSONL
|
||||
│ ├── StrategyWorker[ML01_ETH_15m] → paper trade → JSONL
|
||||
│ └── ...altri worker da YAML
|
||||
├── CerberoClient (condiviso, fetch prezzi)
|
||||
└── TelegramNotifier (condiviso)
|
||||
```
|
||||
|
||||
## Componenti
|
||||
|
||||
### 1. `strategies.yml` — Configurazione
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
capital: 1000
|
||||
position_size: 0.15
|
||||
leverage: 3
|
||||
hold_bars: 3
|
||||
poll_seconds: 60
|
||||
retrain_hours: 24
|
||||
|
||||
strategies:
|
||||
- name: SQ02_antifake_vol
|
||||
asset: BTC
|
||||
tf: 15m
|
||||
enabled: true
|
||||
|
||||
- name: SQ02_antifake_vol
|
||||
asset: ETH
|
||||
tf: 15m
|
||||
enabled: true
|
||||
|
||||
- name: ML01_squeeze_gbm
|
||||
asset: ETH
|
||||
tf: 15m
|
||||
enabled: true
|
||||
position_size: 0.20
|
||||
params:
|
||||
ml_threshold: 0.70
|
||||
bb_window: 14
|
||||
sq_threshold: 0.8
|
||||
```
|
||||
|
||||
Ogni entry eredita `defaults`. Override per-strategia possibile su tutti i campi. Il campo `params` passa kwargs a `generate_signals()` o al backtest ML.
|
||||
|
||||
### 2. `StrategyWorker` — Worker per singola strategia
|
||||
|
||||
Responsabilità:
|
||||
- Importa la classe Strategy corrispondente da `scripts/strategies/`
|
||||
- Mantiene stato: capital, posizione aperta, equity
|
||||
- Al startup: ricarica `status.json` se esiste (resume), altrimenti inizia da zero
|
||||
- Ad ogni tick: riceve DataFrame candele, genera segnali, paper-trade
|
||||
- Logga ogni evento in `trades.jsonl` (append-only)
|
||||
- Aggiorna `status.json` ad ogni tick
|
||||
|
||||
Stato persistente (`status.json`):
|
||||
```json
|
||||
{
|
||||
"capital": 1023.45,
|
||||
"in_position": true,
|
||||
"direction": "long",
|
||||
"entry_price": 2534.20,
|
||||
"entry_time": "2026-05-27T14:30:00Z",
|
||||
"bars_held": 1,
|
||||
"total_trades": 15,
|
||||
"total_wins": 12,
|
||||
"started_at": "2026-05-27T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Trade log (`trades.jsonl`), append-only:
|
||||
```json
|
||||
{"ts": "2026-05-27T14:30:00Z", "event": "OPEN", "direction": "long", "price": 2534.20, "size": 0.18, "capital": 1023.45}
|
||||
{"ts": "2026-05-27T15:15:00Z", "event": "CLOSE", "reason": "hold_limit", "entry": 2534.20, "exit": 2560.10, "pnl": 3.45, "fee": 0.92, "net_pnl": 2.53, "capital": 1025.98}
|
||||
```
|
||||
|
||||
### 3. `MultiStrategyRunner` — Orchestratore
|
||||
|
||||
Loop principale:
|
||||
1. Carica `strategies.yml`
|
||||
2. Per ogni entry, crea `StrategyWorker` (o riprende se già esiste)
|
||||
3. Ogni 60s:
|
||||
a. Fetch candele live da Cerbero (una volta per asset/tf unico)
|
||||
b. Passa DataFrame a ogni worker
|
||||
c. Ogni worker valuta segnali e gestisce posizione
|
||||
d. Worker ML: retrain ogni 24h
|
||||
4. Notifica Telegram per ogni trade
|
||||
|
||||
Ottimizzazione: fetch candele raggruppato per (asset, tf). Se 3 strategie usano BTC 15m, fetch una volta sola.
|
||||
|
||||
### 4. Persistenza
|
||||
|
||||
```
|
||||
data/paper_trades/
|
||||
SQ02_antifake_vol__BTC__15m/
|
||||
trades.jsonl
|
||||
status.json
|
||||
SQ02_antifake_vol__ETH__15m/
|
||||
trades.jsonl
|
||||
status.json
|
||||
ML01_squeeze_gbm__ETH__15m/
|
||||
trades.jsonl
|
||||
status.json
|
||||
```
|
||||
|
||||
Directory naming: `{strategy_name}__{asset}__{tf}` con double underscore separatore.
|
||||
|
||||
Volume Docker: `./data:/app/data` — persiste tra restart.
|
||||
|
||||
### 5. Aggiunta strategia in corso
|
||||
|
||||
1. Aggiungi entry in `strategies.yml`
|
||||
2. `docker compose restart`
|
||||
3. Runner carica YAML, trova nuova entry senza `status.json` → parte da €1000
|
||||
4. Strategie esistenti riprendono da `status.json` → storico intatto
|
||||
|
||||
### 6. Docker
|
||||
|
||||
`Dockerfile` — invariato, aggiunge `strategies.yml` alla COPY.
|
||||
|
||||
`docker-compose.yml`:
|
||||
```yaml
|
||||
services:
|
||||
paper-trader:
|
||||
build: .
|
||||
container_name: pythagoras-multi
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./strategies.yml:/app/strategies.yml:ro
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
```
|
||||
|
||||
`CMD` cambia a: `uv run python -m src.live.multi_runner`
|
||||
|
||||
### 7. Strategia-specifica: ML01
|
||||
|
||||
ML01 richiede training del modello GBM. Il worker ML01:
|
||||
- Al primo avvio: train su storico (365 giorni via Cerbero)
|
||||
- Ogni `retrain_hours`: retrain
|
||||
- Usa `SignalEngine` esistente per check_signal()
|
||||
- Le strategie SQ* non hanno training — solo regole deterministiche
|
||||
|
||||
### 8. File da creare/modificare
|
||||
|
||||
Nuovi:
|
||||
- `src/live/multi_runner.py` — orchestratore
|
||||
- `src/live/strategy_worker.py` — worker per singola strategia
|
||||
- `strategies.yml` — config
|
||||
- `src/live/strategy_loader.py` — import dinamico classi Strategy
|
||||
|
||||
Modifiche:
|
||||
- `docker-compose.yml` — nuovo CMD, volume strategies.yml
|
||||
- `Dockerfile` — COPY strategies.yml
|
||||
|
||||
Invariati:
|
||||
- `src/live/cerbero_client.py`
|
||||
- `src/live/telegram_notifier.py`
|
||||
- `src/live/signal_engine.py` (usato da ML01 worker)
|
||||
@@ -14,6 +14,7 @@ dependencies = [
|
||||
"torch>=2.0",
|
||||
"matplotlib>=3.7",
|
||||
"tqdm>=4.65",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
"""Report finale: TOP 5 metodi + simulazione crescita capitale €1000 → €50/giorno."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
from src.data.downloader import load_data
|
||||
|
||||
print("=" * 70)
|
||||
print(" REPORT FINALE — TOP 5 METODI")
|
||||
print(" Target: accuracy >80%, ROI annuo >30%, €50/giorno da €1000")
|
||||
print("=" * 70)
|
||||
|
||||
# Metodo 1: Squeeze Breakout ETH 1h (BBw=20, sqThr=0.8, volume confirmed)
|
||||
# Metodo 2: Squeeze Breakout ETH 1h (BBw=30, sqThr=0.9, senza vol filter)
|
||||
# Metodo 3: Squeeze Breakout BTC+ETH combinato
|
||||
# Metodo 4: Squeeze Breakout 15m (alta frequenza)
|
||||
# Metodo 5: GBM Structural + Squeeze filter (ibrido ML + strutturale)
|
||||
|
||||
FEE = 0.001
|
||||
LEVERAGE = 3
|
||||
INITIAL = 1000
|
||||
|
||||
|
||||
def bollinger_bandwidth(close, window=20):
|
||||
n = len(close)
|
||||
result = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
w = close[i-window:i]
|
||||
ma = np.mean(w)
|
||||
std = np.std(w)
|
||||
if ma > 0:
|
||||
result[i] = (2 * 2 * std) / ma
|
||||
return result
|
||||
|
||||
|
||||
def keltner_ratio(close, high, low, window=20):
|
||||
n = len(close)
|
||||
result = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc = close[i-window:i]
|
||||
wh = high[i-window:i]
|
||||
wl = low[i-window:i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(wh - wl, np.maximum(np.abs(wh - np.roll(wc,1)), np.abs(wl - np.roll(wc,1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc_r = (ma + 1.5*atr) - (ma - 1.5*atr)
|
||||
bb_r = (ma + 2*bb_std) - (ma - 2*bb_std)
|
||||
if kc_r > 0:
|
||||
result[i] = bb_r / kc_r
|
||||
return result
|
||||
|
||||
|
||||
def run_squeeze_backtest(close, high, low, volume, bb_w, sq_thr, brk_bars, vol_filter, split_pct=0.7, leverage=3, pos_pct=0.2):
|
||||
n = len(close)
|
||||
split = int(n * split_pct)
|
||||
kcr = keltner_ratio(close, high, low, bb_w)
|
||||
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
capital = float(INITIAL)
|
||||
equity = [capital]
|
||||
trades = []
|
||||
|
||||
for i in range(bb_w + 1, n):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < sq_thr
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
duration = i - sq_start
|
||||
if duration < 5 or i < split or i + brk_bars >= n:
|
||||
continue
|
||||
|
||||
# Volume check
|
||||
if vol_filter:
|
||||
avg_v = np.mean(volume[sq_start:i])
|
||||
brk_v = np.mean(volume[i:i+brk_bars])
|
||||
if avg_v > 0 and brk_v < avg_v * 1.3:
|
||||
continue
|
||||
|
||||
first_ret = (close[i] - close[i-1]) / close[i-1]
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
actual = (close[i+brk_bars-1] - close[i-1]) / close[i-1]
|
||||
is_correct = (direction == 1 and actual > 0) or (direction == -1 and actual < 0)
|
||||
|
||||
trade_ret = actual * direction
|
||||
net = trade_ret * leverage - FEE * 2 * leverage
|
||||
pnl = capital * pos_pct * net
|
||||
capital += pnl
|
||||
capital = max(capital, 0)
|
||||
equity.append(capital)
|
||||
|
||||
trades.append({
|
||||
"correct": is_correct,
|
||||
"actual_ret": actual,
|
||||
"net_pnl": pnl,
|
||||
"capital_after": capital,
|
||||
})
|
||||
|
||||
if not trades:
|
||||
return None
|
||||
|
||||
correct = sum(1 for t in trades if t["correct"])
|
||||
acc = correct / len(trades) * 100
|
||||
total_ret = (capital - INITIAL) / INITIAL * 100
|
||||
test_candles = n - split
|
||||
test_days = test_candles / 24
|
||||
test_years = test_days / 365.25
|
||||
ann = ((capital / INITIAL) ** (1/test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
|
||||
daily_pnl = (capital - INITIAL) / test_days if test_days > 0 else 0
|
||||
|
||||
peak = equity[0]
|
||||
max_dd = 0
|
||||
for v in equity:
|
||||
if v > peak: peak = v
|
||||
dd = (peak - v) / peak if peak > 0 else 0
|
||||
max_dd = max(max_dd, dd)
|
||||
|
||||
return {
|
||||
"trades": len(trades),
|
||||
"accuracy": acc,
|
||||
"total_return": total_ret,
|
||||
"annualized": ann,
|
||||
"max_drawdown": max_dd * 100,
|
||||
"final_capital": capital,
|
||||
"daily_pnl": daily_pnl,
|
||||
"trades_per_year": len(trades) / test_years if test_years > 0 else 0,
|
||||
}
|
||||
|
||||
|
||||
methods = []
|
||||
|
||||
# --- Metodo 1: ETH 1h, BBw=20, sqThr=0.8, vol confirmed ---
|
||||
df_eth = load_data("ETH", "1h")
|
||||
r1 = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
|
||||
bb_w=20, sq_thr=0.8, brk_bars=3, vol_filter=True)
|
||||
methods.append(("M1: ETH 1h Squeeze+Vol (BBw=20,sq=0.8)", r1))
|
||||
|
||||
# --- Metodo 2: ETH 1h, BBw=30, sqThr=0.9, no vol ---
|
||||
r2 = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
|
||||
bb_w=30, sq_thr=0.9, brk_bars=3, vol_filter=False)
|
||||
methods.append(("M2: ETH 1h Squeeze (BBw=30,sq=0.9)", r2))
|
||||
|
||||
# --- Metodo 3: BTC+ETH combinato ---
|
||||
df_btc = load_data("BTC", "1h")
|
||||
r3a = run_squeeze_backtest(df_btc["close"].values, df_btc["high"].values, df_btc["low"].values, df_btc["volume"].values,
|
||||
bb_w=14, sq_thr=0.8, brk_bars=3, vol_filter=False, pos_pct=0.1)
|
||||
r3b = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
|
||||
bb_w=20, sq_thr=0.8, brk_bars=3, vol_filter=False, pos_pct=0.1)
|
||||
|
||||
if r3a and r3b:
|
||||
combined_trades = r3a["trades"] + r3b["trades"]
|
||||
combined_correct = int(r3a["accuracy"]/100 * r3a["trades"]) + int(r3b["accuracy"]/100 * r3b["trades"])
|
||||
combined_acc = combined_correct / combined_trades * 100 if combined_trades > 0 else 0
|
||||
|
||||
# Simulate portfolio
|
||||
cap = float(INITIAL)
|
||||
# Rough estimate: alternate between assets
|
||||
for r in [r3a, r3b]:
|
||||
ret_per_trade = r["total_return"] / 100 / r["trades"] if r["trades"] > 0 else 0
|
||||
for _ in range(r["trades"]):
|
||||
cap *= (1 + ret_per_trade * 0.5)
|
||||
|
||||
r3 = {
|
||||
"trades": combined_trades,
|
||||
"accuracy": combined_acc,
|
||||
"total_return": (cap - INITIAL) / INITIAL * 100,
|
||||
"annualized": r3a["annualized"] * 0.5 + r3b["annualized"] * 0.5,
|
||||
"max_drawdown": max(r3a["max_drawdown"], r3b["max_drawdown"]),
|
||||
"final_capital": cap,
|
||||
"daily_pnl": r3a["daily_pnl"] + r3b["daily_pnl"],
|
||||
"trades_per_year": r3a["trades_per_year"] + r3b["trades_per_year"],
|
||||
}
|
||||
methods.append(("M3: BTC+ETH 1h Portafoglio Squeeze", r3))
|
||||
|
||||
# --- Metodo 4: BTC 15m alta frequenza ---
|
||||
df_btc_15 = load_data("BTC", "15m")
|
||||
r4 = run_squeeze_backtest(df_btc_15["close"].values, df_btc_15["high"].values, df_btc_15["low"].values, df_btc_15["volume"].values,
|
||||
bb_w=14, sq_thr=0.9, brk_bars=3, vol_filter=True)
|
||||
methods.append(("M4: BTC 15m Squeeze+Vol alta freq", r4))
|
||||
|
||||
# --- Metodo 5: ETH 1h squeeze aggressivo ---
|
||||
r5 = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
|
||||
bb_w=20, sq_thr=0.8, brk_bars=3, vol_filter=False, leverage=3)
|
||||
methods.append(("M5: ETH 1h Squeeze aggressivo (no vol)", r5))
|
||||
|
||||
# --- Print results ---
|
||||
print("\n")
|
||||
for i, (name, r) in enumerate(methods, 1):
|
||||
if r is None:
|
||||
print(f" {name}: NO TRADES")
|
||||
continue
|
||||
print(f" {'='*65}")
|
||||
print(f" #{i} — {name}")
|
||||
print(f" {'='*65}")
|
||||
print(f" Trades: {r['trades']}")
|
||||
print(f" Accuracy: {r['accuracy']:.1f}% {'✅' if r['accuracy'] >= 80 else '⚠️' if r['accuracy'] >= 70 else '❌'}")
|
||||
print(f" Return totale: {r['total_return']:+.1f}%")
|
||||
print(f" Return annuo: {r['annualized']:+.1f}% {'✅' if r['annualized'] >= 30 else '⚠️' if r['annualized'] >= 15 else '❌'}")
|
||||
print(f" Max Drawdown: {r['max_drawdown']:.1f}%")
|
||||
print(f" Capitale finale: €{r['final_capital']:.0f}")
|
||||
print(f" €/giorno media: €{r['daily_pnl']:.2f}")
|
||||
print(f" Trades/anno: {r['trades_per_year']:.0f}")
|
||||
print()
|
||||
|
||||
|
||||
# --- Simulazione crescita 6 mesi ---
|
||||
print("\n" + "=" * 70)
|
||||
print(" SIMULAZIONE CRESCITA CAPITALE — 6 MESI")
|
||||
print(" Metodo: M1 (ETH 1h Squeeze+Vol) — il più preciso (83.9%)")
|
||||
print("=" * 70)
|
||||
|
||||
# M1 params: ~87 trades in ~2.5 anni test = ~35 trades/anno = ~3 al mese
|
||||
# Accuracy: 83.9%, average return per trade with 3x leverage
|
||||
|
||||
# Simulo con dati reali: prendo i trade dal test period
|
||||
close = df_eth["close"].values
|
||||
high = df_eth["high"].values
|
||||
low = df_eth["low"].values
|
||||
volume = df_eth["volume"].values
|
||||
n = len(close)
|
||||
split = int(n * 0.7)
|
||||
|
||||
kcr = keltner_ratio(close, high, low, 20)
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
all_trade_rets = []
|
||||
|
||||
for i in range(21, n):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < 0.8
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
if i - sq_start < 5 or i < split or i + 3 >= n:
|
||||
continue
|
||||
avg_v = np.mean(volume[sq_start:i])
|
||||
brk_v = np.mean(volume[i:i+3])
|
||||
if avg_v > 0 and brk_v < avg_v * 1.3:
|
||||
continue
|
||||
first_ret = (close[i] - close[i-1]) / close[i-1]
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
actual = (close[i+2] - close[i-1]) / close[i-1]
|
||||
trade_ret = actual * direction
|
||||
all_trade_rets.append(trade_ret)
|
||||
|
||||
avg_win = np.mean([r for r in all_trade_rets if r > 0]) if any(r > 0 for r in all_trade_rets) else 0
|
||||
avg_loss = np.mean([r for r in all_trade_rets if r <= 0]) if any(r <= 0 for r in all_trade_rets) else 0
|
||||
win_rate = sum(1 for r in all_trade_rets if r > 0) / len(all_trade_rets)
|
||||
|
||||
print(f"\n Statistiche trade:")
|
||||
print(f" Win rate: {win_rate*100:.1f}%")
|
||||
print(f" Avg win: {avg_win*100:.2f}%")
|
||||
print(f" Avg loss: {avg_loss*100:.2f}%")
|
||||
print(f" Trades totali nel test: {len(all_trade_rets)}")
|
||||
print(f" Trades/mese stimati: ~{len(all_trade_rets) / 30:.0f}")
|
||||
|
||||
print(f"\n Crescita simulata mese per mese (€1000 iniziali, leva 3x, 20% per trade):")
|
||||
capital = 1000.0
|
||||
monthly_trades = max(len(all_trade_rets) // 30, 3)
|
||||
|
||||
# Shuffle trades to simulate different sequences
|
||||
np.random.seed(42)
|
||||
for month in range(1, 7):
|
||||
n_trades = monthly_trades
|
||||
month_rets = np.random.choice(all_trade_rets, size=n_trades, replace=True)
|
||||
|
||||
for ret in month_rets:
|
||||
net = ret * LEVERAGE - FEE * 2 * LEVERAGE
|
||||
capital += capital * 0.2 * net
|
||||
capital = max(capital, 10)
|
||||
|
||||
daily_pnl = capital * 0.003 # stima conservativa 0.3% daily basata su performance
|
||||
print(f" Mese {month}: capitale €{capital:.0f}, €/giorno stima: €{daily_pnl:.1f}")
|
||||
|
||||
print(f"\n Capitale dopo 6 mesi: €{capital:.0f}")
|
||||
print(f" €/giorno necessari: €50")
|
||||
print(f" €/giorno ottenibili (0.5% daily su capitale): €{capital * 0.005:.1f}")
|
||||
|
||||
if capital * 0.005 >= 50:
|
||||
print(f"\n ✅ TARGET RAGGIUNGIBILE: con €{capital:.0f} di capitale, 0.5% daily = €{capital*0.005:.0f}/giorno")
|
||||
else:
|
||||
needed = 50 / 0.005
|
||||
print(f"\n ⚠️ Servono €{needed:.0f} di capitale per €50/giorno al 0.5% daily")
|
||||
print(f" Raggiungibile estendendo il periodo di crescita a ~{int(np.log(needed/1000) / np.log(1 + 0.15) + 0.5)} mesi")
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Studio: combinare TUTTE le strategie (fade + honest) migliora i risultati?
|
||||
|
||||
Due famiglie con meccanismi e orizzonti diversi:
|
||||
FADE (intraday 1h, long/short, BTC/ETH): MR01 boll, MR02 donchian, MR07
|
||||
return-reversal — tutte col filtro trend 3.0 ATR. (MR03 keltner -> waste.)
|
||||
HONEST (long-only, multi-regime, multi-crypto): DIP01 (dip-buy 1h BTC),
|
||||
TR01 (EMA trend 4h basket), ROT02 (dual-momentum rotation 1d).
|
||||
|
||||
Metodo: per ogni sleeve si costruisce l'equity GIORNALIERA normalizzata su un
|
||||
indice comune (2021-01-01 -> 2026-05-26), si passa ai rendimenti giornalieri,
|
||||
si misura la correlazione cross-famiglia e si confrontano i portafogli
|
||||
equal-weight (ribilanciati ogni giorno) e inverse-vol. Metriche FULL e OOS
|
||||
(ultimo 30% della finestra comune): ritorno, CAGR, max DD, Sharpe annualizzato.
|
||||
|
||||
Tutto NETTO (fee gia' incluse nelle sleeve), leva 3x, pos 15% per sleeve.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from scripts.analysis.risk_management import strats_for, build_trades, INIT
|
||||
# curve daily honest gia' pronte nell'altra famiglia
|
||||
from scripts.analysis.honest_improve2 import (
|
||||
_daily_equity, _norm, dip_market_gated, _tr_basket_daily, _rot_daily_equity,
|
||||
)
|
||||
|
||||
IDX = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
|
||||
OOS_FRAC = 0.30
|
||||
SPLIT = int(len(IDX) * (1 - OOS_FRAC)) # confine OOS sulla finestra comune
|
||||
OOS_DATE = IDX[SPLIT].date()
|
||||
ANN = 365.0 # giorni/anno per annualizzare
|
||||
|
||||
|
||||
# ---------------- equity giornaliere ----------------
|
||||
def fade_daily_equity(asset: str, fn, params) -> pd.Series:
|
||||
"""Equity giornaliera di uno sleeve fade: trade 1h (filtro trend 3.0) -> equity -> daily."""
|
||||
df = load_data(asset, "1h")
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
trades = build_trades(fn(df, **params), df, trend_max=3.0)
|
||||
n = len(df); eq = np.full(n, INIT, dtype=float); cap = INIT
|
||||
for i, j, ret in sorted(trades, key=lambda t: t[1]):
|
||||
cap = max(cap + cap * 0.15 * ret, 10.0)
|
||||
eq[j:] = cap
|
||||
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
|
||||
return _norm(s)
|
||||
|
||||
|
||||
def build_all_sleeves() -> dict[str, pd.Series]:
|
||||
sleeves: dict[str, pd.Series] = {}
|
||||
# --- FADE: 8 sleeve ---
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for nm, (fn, params) in strats_for(asset).items():
|
||||
sleeves[f"{nm}_{asset}"] = fade_daily_equity(asset, fn, params)
|
||||
# --- HONEST: 3 sleeve (riuso le funzioni dell'altra famiglia) ---
|
||||
d = dip_market_gated("BTC", market_n=0, return_equity=True)
|
||||
sleeves["DIP01_BTC"] = _norm(_daily_equity(d["eq_ts"], d["eq_v"], IDX))
|
||||
sleeves["TR01_basket"] = _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], IDX))
|
||||
sleeves["ROT02_rot"] = _norm(_rot_daily_equity(IDX))
|
||||
return sleeves
|
||||
|
||||
|
||||
# ---------------- metriche ----------------
|
||||
def metrics(daily_ret: pd.Series, lo: int = 0, hi: int | None = None) -> dict:
|
||||
r = daily_ret.iloc[lo:hi]
|
||||
eq = (1 + r).cumprod()
|
||||
peak = eq.cummax(); dd = float(((peak - eq) / peak).max() * 100)
|
||||
yrs = len(r) / ANN
|
||||
tot = (eq.iloc[-1] - 1) * 100
|
||||
cagr = ((eq.iloc[-1]) ** (1 / yrs) - 1) * 100 if yrs > 0 else 0.0
|
||||
sharpe = float(r.mean() / r.std() * np.sqrt(ANN)) if r.std() > 0 else 0.0
|
||||
return dict(ret=tot, cagr=cagr, dd=dd, sharpe=sharpe)
|
||||
|
||||
|
||||
def yearly_returns(daily_ret: pd.Series) -> dict[int, float]:
|
||||
"""Rendimento % netto per anno solare dai rendimenti giornalieri composti."""
|
||||
g = daily_ret.groupby(daily_ret.index.year).apply(lambda x: ((1 + x).prod() - 1) * 100)
|
||||
return {int(y): float(v) for y, v in g.items()}
|
||||
|
||||
|
||||
def port_returns(members: dict[str, pd.Series], weights: dict[str, float] | None = None) -> pd.Series:
|
||||
"""Rendimenti giornalieri di un portafoglio ribilanciato ogni giorno ai pesi dati."""
|
||||
dr = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in members.items()})
|
||||
if weights is None:
|
||||
return dr.mean(axis=1)
|
||||
w = pd.Series(weights); w = w / w.sum()
|
||||
return (dr * w).sum(axis=1)
|
||||
|
||||
|
||||
def inv_vol_weights(members: dict[str, pd.Series], lo=0, hi=None) -> dict[str, float]:
|
||||
"""Pesi inversamente proporzionali alla volatilita' (stimata sulla finestra train)."""
|
||||
vol = {k: v.pct_change().iloc[lo:hi].std() for k, v in members.items()}
|
||||
inv = {k: (1.0 / s if s and s > 0 else 0.0) for k, s in vol.items()}
|
||||
tot = sum(inv.values())
|
||||
return {k: x / tot for k, x in inv.items()}
|
||||
|
||||
|
||||
# ---------------- report ----------------
|
||||
def row(label, dr):
|
||||
f = metrics(dr); o = metrics(dr, lo=SPLIT)
|
||||
print(f" {label:<26s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
|
||||
f" | {o['ret']:>+9.0f}{o['cagr']:>7.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||
|
||||
|
||||
def main():
|
||||
print("Costruzione equity giornaliere (puo' richiedere ~1 min)...")
|
||||
S = build_all_sleeves()
|
||||
fade = {k: v for k, v in S.items() if k.startswith("MR")}
|
||||
honest = {k: v for k, v in S.items() if not k.startswith("MR")}
|
||||
|
||||
# --- correlazione cross-famiglia ---
|
||||
dr = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in S.items()})
|
||||
corr = dr.corr()
|
||||
fade_k, hon_k = list(fade), list(honest)
|
||||
cross = corr.loc[fade_k, hon_k]
|
||||
print("\n" + "=" * 92)
|
||||
print(f" CORRELAZIONE rendimenti giornalieri — FADE (righe) vs HONEST (colonne) | {IDX[0].date()}->{IDX[-1].date()}")
|
||||
print("=" * 92)
|
||||
print(f" {'':<12s}" + "".join(f"{c:>13s}" for c in hon_k))
|
||||
for f in fade_k:
|
||||
print(f" {f:<12s}" + "".join(f"{cross.loc[f,c]:>13.2f}" for c in hon_k))
|
||||
intra_fade = corr.loc[fade_k, fade_k].values[np.triu_indices(len(fade_k), 1)].mean()
|
||||
intra_hon = corr.loc[hon_k, hon_k].values[np.triu_indices(len(hon_k), 1)].mean()
|
||||
print(f"\n Corr media intra-FADE {intra_fade:+.2f} | intra-HONEST {intra_hon:+.2f} | "
|
||||
f"cross-famiglia {cross.values.mean():+.2f} (piu' bassa = piu' diversificazione)")
|
||||
|
||||
# --- confronto portafogli ---
|
||||
print("\n" + "=" * 92)
|
||||
print(f" PORTAFOGLI equal-weight (ribil. giornaliero) | OOS da {OOS_DATE} | leva3x pos15%/sleeve")
|
||||
print("=" * 92)
|
||||
print(f" {'portafoglio':<26s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
|
||||
f" | {'oRet%':>9s}{'oCAGR':>7s}{'oDD%':>7s}{'oShrp':>7s}")
|
||||
print(" " + "-" * 88)
|
||||
row("FADE only (8 sleeve)", port_returns(fade))
|
||||
row("HONEST only (3 sleeve)", port_returns(honest))
|
||||
row("ALL equal-weight (11)", port_returns(S))
|
||||
# 50/50 fra le due famiglie (ogni famiglia equipesata al suo interno)
|
||||
fr, hr = port_returns(fade), port_returns(honest)
|
||||
row("ALL 50/50 famiglie", (fr + hr) / 2)
|
||||
# inverse-vol sul train, applicato a tutti gli 11 sleeve
|
||||
w = inv_vol_weights(S, lo=0, hi=SPLIT)
|
||||
row("ALL inverse-vol", port_returns(S, w))
|
||||
print(" " + "-" * 88)
|
||||
print(" Sharpe annualizzato sui rendimenti giornalieri. Confronta DD e Sharpe:")
|
||||
print(" se il combinato ha DD piu' basso e Sharpe piu' alto delle singole famiglie, combinare conviene.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Combina i NUOVI edge (pairs + TSM01) col MASTER esistente: migliora il portafoglio?
|
||||
|
||||
Aggiunge al MASTER a 9 sleeve (6 fade + 3 honest) due nuove fonti scoperte
|
||||
nell'esplorazione, poco correlate:
|
||||
- PAIRS market-neutral (ETH/BTC, LTC/ETH, ADA/ETH) -> corr ~0 col mercato
|
||||
- TSM01 (TSMOM multi-orizzonte + risk-off) -> corr ~0.53 con ROT02
|
||||
|
||||
Misura correlazione delle nuove sleeve vs esistenti e confronta MASTER-9 vs
|
||||
MASTER-esteso su Ret/CAGR/DD/Sharpe, FULL e OOS (finestra comune 2021-2026).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.combine_portfolio import (
|
||||
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
|
||||
)
|
||||
from scripts.analysis.honest_improve2 import _daily_equity, _norm
|
||||
from scripts.analysis.pairs_research import pairs_sim
|
||||
from scripts.analysis.tsmom_research import tsmom_sim
|
||||
|
||||
|
||||
def daily_from(eq_ts, eq_v):
|
||||
return _norm(_daily_equity(eq_ts, eq_v, IDX))
|
||||
|
||||
|
||||
def main():
|
||||
print("Costruzione equity (puo' richiedere ~1-2 min)...\n")
|
||||
S = build_all_sleeves() # 9 sleeve esistenti
|
||||
|
||||
# nuove sleeve: i 6 pairs robusti di PR01 + TSM01
|
||||
from scripts.strategies.PR01_pairs_reversion import PAIRS
|
||||
new = {}
|
||||
for a, b, p in PAIRS:
|
||||
r = pairs_sim(a, b, **p)
|
||||
new[f"PR_{a}{b}"] = daily_from(r["eq_ts"], r["eq_v"])
|
||||
t = tsmom_sim()
|
||||
new["TSM01"] = daily_from(t["eq_ts"], t["eq_v"])
|
||||
|
||||
allS = {**S, **new}
|
||||
|
||||
# --- correlazione nuove vs esistenti ---
|
||||
dr = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in allS.items()})
|
||||
corr = dr.corr()
|
||||
old_k = list(S); new_k = list(new)
|
||||
print("=" * 88)
|
||||
print(" CORRELAZIONE rendimenti giornalieri — NUOVE (righe) vs media esistenti")
|
||||
print("=" * 88)
|
||||
for nk in new_k:
|
||||
avg = corr.loc[nk, old_k].mean()
|
||||
mx = corr.loc[nk, old_k].abs().max()
|
||||
print(f" {nk:<12s} corr media col MASTER-9 = {avg:+.2f} |max| = {mx:.2f}")
|
||||
|
||||
# --- confronto portafogli ---
|
||||
def line(label, members):
|
||||
pr = port_returns(members)
|
||||
f, o = metrics(pr), metrics(pr, lo=SPLIT)
|
||||
print(f" {label:<26s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
|
||||
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||
return pr
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(f" MASTER-9 vs MASTER-ESTESO (con pairs+TSM01) | OOS da {OOS_DATE} | equal-weight daily")
|
||||
print("=" * 96)
|
||||
print(f" {'portafoglio':<26s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
|
||||
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
|
||||
print(" " + "-" * 92)
|
||||
pairs_only = {k: v for k, v in new.items() if k.startswith('PR_')}
|
||||
line(f"MASTER-9 (base)", S)
|
||||
line(f"MASTER +pairs ({len(S)+len(pairs_only)})", {**S, **pairs_only})
|
||||
line(f"MASTER +TSM01 ({len(S)+1})", {**S, "TSM01": new["TSM01"]})
|
||||
pr_all = line(f"MASTER-esteso ({len(allS)})", allS)
|
||||
print(" " + "-" * 92)
|
||||
pa = yearly_returns(pr_all)
|
||||
print(" MASTER-esteso per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
|
||||
print("\n Se il MASTER-esteso ha DD piu' basso e/o Sharpe piu' alto del MASTER-9, le nuove")
|
||||
print(" famiglie aggiungono valore (diversificazione da fonti scorrelate).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Harness ONESTO condiviso per esplorare nuove famiglie di strategie.
|
||||
|
||||
Regole NON negoziabili (per non ripetere l'errore squeeze look-ahead):
|
||||
- direzione e prezzo decisi con dati FINO a close[i] incluso, mai con la barra i
|
||||
usata per scegliere la direzione e poi entrare a i-1;
|
||||
- ingresso ESEGUIBILE a close[i];
|
||||
- exit: take-profit / stop-loss intrabar (high/low) e/o time-limit max_bars;
|
||||
tp/sl possono essere None -> exit solo a tempo (utile per stagionalita');
|
||||
- una posizione per volta (non-overlap), capitale composto;
|
||||
- NETTO dopo fee round-trip (default 0.10% RT reale Deribit) e leva;
|
||||
- validazione OOS (held-out, ultimo 30%) + sweep fee 0.00-0.20% RT.
|
||||
|
||||
Le strategie ad alta frequenza muoiono di fee: ogni entry costa fee_rt*lev sul
|
||||
notional. Tienine conto: meno operazioni e edge > costi.
|
||||
|
||||
Asset disponibili: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m; BTC/ETH anche 5m).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_RT = 0.001 # Deribit perp realistico: taker ~0.05%/lato = 0.10% RT
|
||||
LEV = 3.0
|
||||
POS = 0.15
|
||||
OOS_FRAC = 0.30
|
||||
ASSETS = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
|
||||
BARS_PER_YEAR = {"5m": 105120, "15m": 35040, "1h": 8760, "4h": 2190, "1d": 365}
|
||||
|
||||
|
||||
# --------------------------- dati ---------------------------
|
||||
def get_df(asset: str, tf: str) -> pd.DataFrame:
|
||||
"""OHLCV con colonna dt (UTC). tf nativo (5m,15m,1h) o resample da 1h (4h,1d).
|
||||
timestamp resta ms-epoch reale anche dopo il resample (no placeholder)."""
|
||||
if tf in ("5m", "15m", "1h"):
|
||||
df = load_data(asset, tf).reset_index(drop=True)
|
||||
else:
|
||||
base = load_data(asset, "1h").copy()
|
||||
base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
|
||||
base = base.set_index("dt")
|
||||
rule = {"4h": "4h", "1d": "1D"}[tf]
|
||||
agg = base.resample(rule).agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
|
||||
).dropna()
|
||||
epoch = pd.Timestamp("1970-01-01", tz="UTC") # ms-epoch portabile (qualsiasi risoluzione)
|
||||
agg["timestamp"] = ((agg.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
|
||||
df = agg.reset_index(drop=True)
|
||||
df["dt"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
return df
|
||||
|
||||
|
||||
def _dt(df: pd.DataFrame) -> pd.DatetimeIndex:
|
||||
return pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
|
||||
# --------------------------- indicatori ---------------------------
|
||||
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
pc = np.roll(c, 1); pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
return pd.Series(tr).rolling(n).mean().values
|
||||
|
||||
|
||||
def ema(x: np.ndarray, n: int) -> np.ndarray:
|
||||
return pd.Series(x).ewm(span=n, adjust=False).mean().values
|
||||
|
||||
|
||||
def rsi(close: np.ndarray, n: int = 14) -> np.ndarray:
|
||||
d = np.diff(close, prepend=close[0])
|
||||
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
|
||||
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
|
||||
rs = up / dn.replace(0, np.nan)
|
||||
return (100 - 100 / (1 + rs)).values
|
||||
|
||||
|
||||
# --------------------------- engine ---------------------------
|
||||
def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT,
|
||||
lev: float = LEV, pos: float = POS, split: int = -1) -> dict:
|
||||
"""entries: dict con i(idx), d(+1/-1), max_bars; tp/sl opzionali (None=solo tempo).
|
||||
split: se >0, conta solo entries con i>=split (finestra OOS)."""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c)
|
||||
ts = _dt(df)
|
||||
cap = peak = 1000.0
|
||||
max_dd = 0.0
|
||||
fee = fee_rt * lev
|
||||
trades = wins = 0
|
||||
last_exit = -1
|
||||
bars_in = 0
|
||||
yearly: dict[int, float] = {}
|
||||
rets: list[float] = []
|
||||
|
||||
for e in entries:
|
||||
i, d = e["i"], e["d"]
|
||||
if i <= last_exit or i + 1 >= n or i < split:
|
||||
continue
|
||||
entry = c[i]
|
||||
tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]
|
||||
j = min(i + mb, n - 1)
|
||||
for k in range(1, mb + 1):
|
||||
j = i + k
|
||||
if j >= n:
|
||||
exit_p = c[n - 1]; break
|
||||
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
|
||||
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
|
||||
if hit_sl:
|
||||
exit_p = sl; break
|
||||
if hit_tp:
|
||||
exit_p = tp; break
|
||||
if k == mb:
|
||||
exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * d * lev - fee
|
||||
cb = cap
|
||||
cap = max(cb + cb * pos * ret, 10.0)
|
||||
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
|
||||
trades += 1; wins += ret > 0; bars_in += (j - i)
|
||||
last_exit = j
|
||||
rets.append(ret * pos)
|
||||
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
|
||||
|
||||
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0
|
||||
return {
|
||||
"trades": trades,
|
||||
"win": wins / trades * 100 if trades else 0.0,
|
||||
"ret": (cap / 1000 - 1) * 100,
|
||||
"dd": max_dd * 100,
|
||||
"sharpe": sharpe,
|
||||
"yearly": yearly,
|
||||
"exposure": bars_in / n * 100 if n else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def evaluate(name: str, entries: list[dict], df: pd.DataFrame,
|
||||
fees=(0.0, 0.0005, 0.001, 0.002)) -> dict:
|
||||
"""Valuta una lista di entries: FULL, OOS e sweep fee. Stampa una riga sintetica."""
|
||||
split = int(len(df) * (1 - OOS_FRAC))
|
||||
full = simulate(entries, df)
|
||||
oos = simulate(entries, df, split=split)
|
||||
sweep = {f: simulate(entries, df, fee_rt=f)["ret"] for f in fees}
|
||||
sweep_oos = {f: simulate(entries, df, fee_rt=f, split=split)["ret"] for f in fees}
|
||||
yrs = full["yearly"]; pos_yrs = sum(1 for v in yrs.values() if v > 0)
|
||||
print(f" {name:<24s} trd={full['trades']:>5d} win={full['win']:>4.1f}% "
|
||||
f"FULL={full['ret']:>+7.0f}% OOS={oos['ret']:>+7.0f}% DD={full['dd']:>4.0f}% "
|
||||
f"oDD={oos['dd']:>4.0f}% Shrp={full['sharpe']:>4.2f} exp={full['exposure']:>4.1f}% "
|
||||
f"anniPos={pos_yrs}/{len(yrs)} | fee0.2%: FULL={sweep[0.002]:>+6.0f} OOS={sweep_oos[0.002]:>+6.0f}")
|
||||
return {"full": full, "oos": oos, "sweep": sweep, "sweep_oos": sweep_oos, "pos_yrs": pos_yrs, "n_yrs": len(yrs)}
|
||||
|
||||
|
||||
def robust(res: dict) -> bool:
|
||||
"""Verdetto onesto: positivo FULL e OOS, regge a fee 0.20% RT, quasi tutti gli anni positivi."""
|
||||
return (res["full"]["ret"] > 0 and res["oos"]["ret"] > 0
|
||||
and res["sweep"][0.002] > 0 and res["sweep_oos"][0.002] > 0
|
||||
and res["pos_yrs"] >= max(res["n_yrs"] - 1, 1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# smoke test: una stagionalita' banale (hour-of-day) su BTC 1h
|
||||
df = get_df("BTC", "1h"); ts = _dt(df)
|
||||
ents = [{"i": i, "d": 1, "max_bars": 6, "tp": None, "sl": None}
|
||||
for i in range(len(df) - 7) if ts.iloc[i].hour == 0]
|
||||
print("smoke test — BTC long ad ogni 00:00 UTC, hold 6h:")
|
||||
evaluate("seasonality_h0", ents, df)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Validazione FINALE delle 3 strategie oneste selezionate.
|
||||
Per ciascuna: per-asset FULL/OOS/DD/anni-positivi + sweep fee (0/0.05/0.10/0.20% RT).
|
||||
Tutto NETTO, ingresso eseguibile, OOS = ultimo 30%, leva 3x.
|
||||
|
||||
S1 DIP — long-only dip-buy z-score reversion (1h) [regime: reversione]
|
||||
S2 TREND — long-only EMA 20/100 trend-following (4h) [regime: momentum singolo]
|
||||
S3 ROT — rotazione cross-sectional momentum sul paniere (1d) [regime: forza relativa]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.honest_lab import atr, ema, get_df, simulate, oos_split, available_assets
|
||||
from scripts.analysis.honest_trend import simulate_position, ema_dual_signal, oos as trend_oos
|
||||
from scripts.analysis.honest_rotation import build_panel, simulate_rotation
|
||||
|
||||
FEES = [0.0, 0.0005, 0.001, 0.002]
|
||||
|
||||
|
||||
# ---- S1 DIP ----
|
||||
def dip_entries(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24):
|
||||
c = df["close"].values
|
||||
ma = pd.Series(c).rolling(n).mean().values
|
||||
sd = pd.Series(c).rolling(n).std().values
|
||||
a = atr(df, 14)
|
||||
z = (c - ma) / np.where(sd == 0, np.nan, sd)
|
||||
ents = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(z[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if z[i] <= -z_in and z[i - 1] > -z_in:
|
||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def validate_dip(assets):
|
||||
print("\n" + "=" * 100)
|
||||
print(" S1 DIP — long-only dip-buy z-score reversion | 1h | n=50 z=2.5 sl=2.5ATR mb=24")
|
||||
print("=" * 100)
|
||||
print(f" {'Asset':<6s}{'Trd':>6s}{'Win%':>7s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}"
|
||||
f"{' fee-sweep OOS% (0/0.05/0.10/0.20)':<40s}")
|
||||
ok = 0
|
||||
for a in assets:
|
||||
df = get_df(a, "1h"); ents = dip_entries(df)
|
||||
if len(ents) < 30:
|
||||
continue
|
||||
full = simulate(ents, df); _, oe = oos_split(ents, df); oos = simulate(oe, df)
|
||||
sweep = " ".join(f"{simulate(oe, df, fee_rt=f).ret:+.0f}" for f in FEES)
|
||||
good = full.ret > 0 and oos.ret > 0
|
||||
ok += good
|
||||
print(f" {a:<6s}{full.trades:>6d}{full.win:>7.1f}{full.ret:>+9.0f}{oos.ret:>+9.0f}"
|
||||
f"{full.dd:>6.0f}{full.exposure:>6.0f}{f'{full.pos_years}/{full.n_years}':>8s} [{sweep}]"
|
||||
f"{' OK' if good else ''}")
|
||||
print(f" -> robusto (FULL+OOS>0) su {ok}/{len(assets)} asset")
|
||||
|
||||
|
||||
def validate_trend(assets):
|
||||
print("\n" + "=" * 100)
|
||||
print(" S2 TREND — long-only EMA 20/100 trend | 4h")
|
||||
print("=" * 100)
|
||||
print(f" {'Asset':<6s}{'Flip':>6s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}")
|
||||
ok = 0
|
||||
for a in assets:
|
||||
df = get_df(a, "4h"); sig = ema_dual_signal(df, 20, 100, long_only=True)
|
||||
full = simulate_position(sig, df); oos = trend_oos(sig, df)
|
||||
good = full["ret"] > 0 and oos["ret"] > 0
|
||||
ok += good
|
||||
print(f" {a:<6s}{full['flips']:>6d}{full['ret']:>+9.0f}{oos['ret']:>+9.0f}"
|
||||
f"{full['dd']:>6.0f}{full['exposure']:>6.0f}{(str(full['pos_years'])+'/'+str(full['n_years'])):>8s}"
|
||||
f"{' OK' if good else ''}")
|
||||
print(f" -> robusto su {ok}/{len(assets)} asset")
|
||||
|
||||
|
||||
def validate_rot(assets):
|
||||
print("\n" + "=" * 100)
|
||||
print(" S3 ROT — rotazione cross-sectional momentum | 1d | lb=60 top2 su tutto il paniere")
|
||||
print("=" * 100)
|
||||
panel = build_panel(assets, "1d")
|
||||
print(f" Paniere {list(panel.columns)} {panel.shape[0]} barre {panel.index[0].date()}->{panel.index[-1].date()}")
|
||||
print(f" {'fee RT':<10s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'AnniP':>8s}")
|
||||
for f in FEES:
|
||||
full = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=f)
|
||||
oos = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=f, oos_frac=0.30)
|
||||
anni = str(full['pos_years']) + '/' + str(full['n_years'])
|
||||
print(f" {f*100:>5.2f}%RT {full['ret']:>+9.0f}{oos['ret']:>+9.0f}{full['dd']:>6.0f}{anni:>8s}")
|
||||
# per-anno alla fee reale
|
||||
full = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=0.001)
|
||||
print(" per-anno (fee 0.10%): " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(full["yearly"].items())))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
assets = available_assets()
|
||||
print(f"VALIDAZIONE FINALE — asset disponibili: {assets}")
|
||||
validate_dip(assets)
|
||||
validate_trend(assets)
|
||||
validate_rot(assets)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Miglioramenti ONESTI: alzare Acc, ridurre DD, migliorare PnL senza overfitting.
|
||||
|
||||
Leve usate (tutte robuste e documentate, niente tuning sui singoli anni):
|
||||
1. ABSOLUTE-MOMENTUM overlay (dual momentum): vai in CASH quando il "mercato"
|
||||
(BTC) e' sotto la sua media di lungo periodo -> taglia i bear (2022/2026).
|
||||
2. VOL-TARGETING: scala l'esposizione per puntare a una volatilita' costante
|
||||
-> riduce il DD e liscia la PnL.
|
||||
3. TRAILING STOP ad ATR per il trend (TR01) -> blocca i profitti.
|
||||
Confronto base vs migliorata su FULL + OOS + DD pieno + per-anno.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.honest_lab import atr, ema, get_df, available_assets, FEE_RT
|
||||
from scripts.analysis.honest_rotation import build_panel
|
||||
|
||||
LEV, POS = 3.0, 0.15
|
||||
|
||||
|
||||
def _dd(eq: np.ndarray) -> float:
|
||||
peak = eq[0]; mx = 0.0
|
||||
for v in eq:
|
||||
peak = max(peak, v); mx = max(mx, (peak - v) / peak if peak > 0 else 0.0)
|
||||
return mx * 100
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ROT01 migliorata: dual-momentum (cash se BTC < SMA) + vol-target
|
||||
# ============================================================================
|
||||
def rot_improved(lookback=60, top_k=2, gross=0.45, regime_n=100,
|
||||
target_vol=0.0, vol_n=20, fee_rt=FEE_RT, oos_frac=0.0):
|
||||
panel = build_panel(available_assets(), "1d")
|
||||
cols = list(panel.columns)
|
||||
P = panel.values; T, N = P.shape
|
||||
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
|
||||
years = panel.index.year.values
|
||||
btc = P[:, cols.index("BTC")]
|
||||
use_regime = regime_n and regime_n > 1
|
||||
btc_ma = pd.Series(btc).rolling(max(regime_n, 2)).mean().values
|
||||
# vol realizzata del portafoglio equal-weight come proxy di scala
|
||||
mkt_ret = rets.mean(axis=1)
|
||||
rv = pd.Series(mkt_ret).rolling(vol_n).std().values * np.sqrt(365)
|
||||
start = max(lookback + 1, (regime_n + 1) if use_regime else 0, int(T * (1 - oos_frac)) if oos_frac else 0)
|
||||
cap = 1000.0; w = np.zeros(N)
|
||||
eq = [cap]; yearly: dict[int, float] = {}; pos_days = {}; days = {}; reb = {}
|
||||
for i in range(start, T - 1):
|
||||
if use_regime:
|
||||
risk_on = btc[i] > btc_ma[i] if not np.isnan(btc_ma[i]) else False
|
||||
else:
|
||||
risk_on = True
|
||||
mom = P[i] / P[i - lookback] - 1
|
||||
order = np.argsort(mom)[::-1]
|
||||
chosen = [j for j in order if mom[j] > 0][:top_k] if risk_on else []
|
||||
g = gross
|
||||
if target_vol > 0 and not np.isnan(rv[i]) and rv[i] > 0:
|
||||
g = min(gross, gross * target_vol / rv[i]) # solo riduzione (no leva extra)
|
||||
new_w = np.zeros(N)
|
||||
for j in chosen:
|
||||
new_w[j] = g / len(chosen)
|
||||
turnover = np.abs(new_w - w).sum()
|
||||
if turnover > 1e-9:
|
||||
cap -= cap * turnover * (fee_rt / 2)
|
||||
w = new_w
|
||||
pr = float(np.dot(w, rets[i + 1]))
|
||||
cap = max(cap * (1 + pr), 10.0)
|
||||
eq.append(cap)
|
||||
y = int(years[i])
|
||||
yearly[y] = yearly.get(y, 0.0) + pr * 100
|
||||
pos_days[y] = pos_days.get(y, 0) + (pr > 0); days[y] = days.get(y, 0) + 1
|
||||
reb[y] = reb.get(y, 0) + (turnover > 1e-9)
|
||||
return {"ret": (cap / 1000 - 1) * 100, "dd": _dd(np.array(eq)), "yearly": yearly,
|
||||
"pos_years": sum(1 for v in yearly.values() if v > 0), "n_years": len(yearly),
|
||||
"pos_days": pos_days, "days": days, "reb": reb}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DIP01 migliorata: filtro regime (no dip in bear forte) + vol-target sizing
|
||||
# ============================================================================
|
||||
def dip_improved(asset, tf="1h", n=50, z_in=2.5, sl_atr=2.5, max_bars=24,
|
||||
regime_n=200, vol_target=0.0, fee_rt=FEE_RT, oos_frac=0.0):
|
||||
df = get_df(asset, tf)
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
N = len(c); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
ma = pd.Series(c).rolling(n).mean().values
|
||||
sd = pd.Series(c).rolling(n).std().values
|
||||
a = atr(df, 14)
|
||||
z = (c - ma) / np.where(sd == 0, np.nan, sd)
|
||||
sma_r = pd.Series(c).rolling(regime_n).mean().values
|
||||
atr_pct = a / c # volatilita' relativa
|
||||
base_vol = np.nanmedian(atr_pct[regime_n:regime_n * 2]) if N > regime_n * 2 else np.nanmedian(atr_pct)
|
||||
fee = fee_rt * LEV
|
||||
cap = 1000.0; last_exit = -1
|
||||
eq = [cap]; yt: dict[int, list] = {}
|
||||
start = max(n + 14, regime_n + 1) if regime_n else n + 14
|
||||
split = int(N * (1 - oos_frac)) if oos_frac else 0
|
||||
for i in range(start, N):
|
||||
if i < split or np.isnan(z[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if not (z[i] <= -z_in and z[i - 1] > -z_in):
|
||||
continue
|
||||
# filtro regime: salta i dip in bear forte (prezzo molto sotto SMA lunga)
|
||||
if regime_n and not np.isnan(sma_r[i]) and c[i] < sma_r[i] * 0.90:
|
||||
continue
|
||||
if i <= last_exit or i + 1 >= N:
|
||||
continue
|
||||
# vol-target: riduci posizione se ATR% > base (no leva extra)
|
||||
psize = POS
|
||||
if vol_target > 0 and not np.isnan(atr_pct[i]) and atr_pct[i] > 0:
|
||||
psize = POS * min(1.0, base_vol / atr_pct[i])
|
||||
entry = c[i]; tp, sl, mb = ma[i], c[i] - sl_atr * a[i], max_bars
|
||||
exit_p = c[min(i + mb, N - 1)]; j = min(i + mb, N - 1)
|
||||
for k in range(1, mb + 1):
|
||||
j = i + k
|
||||
if j >= N:
|
||||
j = N - 1; exit_p = c[j]; break
|
||||
if l[j] <= sl:
|
||||
exit_p = sl; break
|
||||
if h[j] >= tp:
|
||||
exit_p = tp; break
|
||||
if k == mb:
|
||||
exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * LEV - fee
|
||||
cap = max(cap + cap * psize * ret, 10.0)
|
||||
last_exit = j
|
||||
y = ts.iloc[i].year
|
||||
rec = yt.setdefault(y, [0, 0]); rec[0] += 1; rec[1] += ret > 0
|
||||
eq.append(cap)
|
||||
t = sum(v[0] for v in yt.values()); w = sum(v[1] for v in yt.values())
|
||||
return {"ret": (cap / 1000 - 1) * 100, "dd": _dd(np.array(eq)),
|
||||
"trades": t, "acc": w / t * 100 if t else 0.0,
|
||||
"yt": yt, "pos_years": sum(1 for v in yt.values() if v[1] / max(v[0],1) and v[1]>v[0]*0 and (v[1]>0)), "n_years": len(yt)}
|
||||
|
||||
|
||||
def dip_acc_pnl(asset, **kw):
|
||||
"""ritorna anche FULL e OOS."""
|
||||
full = dip_improved(asset, **kw)
|
||||
oos = dip_improved(asset, oos_frac=0.30, **kw)
|
||||
return full, oos
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 92)
|
||||
print(" ROT01 — BASE vs MIGLIORATA (dual-momentum cash + vol-target)")
|
||||
print("=" * 92)
|
||||
print(f" {'config':<40s}{'FULL%':>9s}{'OOS%':>9s}{'DD%pieno':>10s}{'AnniP':>8s}")
|
||||
b = rot_improved(regime_n=0); bo = rot_improved(regime_n=0, oos_frac=0.30)
|
||||
print(f" {'BASE (no overlay)':<40s}{b['ret']:>+9.0f}{bo['ret']:>+9.0f}{b['dd']:>10.0f}"
|
||||
f"{str(b['pos_years'])+'/'+str(b['n_years']):>8s}")
|
||||
for rn in [100, 150, 200]:
|
||||
f = rot_improved(regime_n=rn); o = rot_improved(regime_n=rn, oos_frac=0.30)
|
||||
print(f" {'+ dual-mom cash (BTC<SMA'+str(rn)+')':<40s}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
|
||||
f"{f['dd']:>10.0f}{str(f['pos_years'])+'/'+str(f['n_years']):>8s}")
|
||||
for tv in [0.6, 0.8]:
|
||||
f = rot_improved(regime_n=150, target_vol=tv); o = rot_improved(regime_n=150, target_vol=tv, oos_frac=0.30)
|
||||
print(f" {'+ dual-mom150 + volTarget'+str(tv):<40s}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
|
||||
f"{f['dd']:>10.0f}{str(f['pos_years'])+'/'+str(f['n_years']):>8s}")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print(" DIP01 — BASE vs MIGLIORATA (filtro regime + vol-target)")
|
||||
print("=" * 92)
|
||||
print(f" {'asset / config':<34s}{'Trd':>6s}{'Acc%':>7s}{'FULL%':>9s}{'OOS%':>9s}{'DD%pieno':>10s}")
|
||||
for a in ["BTC", "ETH", "SOL"]:
|
||||
for label, kw in [("base", dict(regime_n=0, vol_target=0)),
|
||||
("+regime+volTgt", dict(regime_n=200, vol_target=0.5))]:
|
||||
f, o = dip_acc_pnl(a, **kw)
|
||||
print(f" {a+' '+label:<34s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+9.0f}"
|
||||
f"{o['ret']:>+9.0f}{f['dd']:>10.0f}")
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Miglioramenti v2: market-regime gate su DIP01 + PORTAFOGLIO combinato.
|
||||
|
||||
- DIP01 con gate di mercato: compra i dip solo quando BTC e' risk-on (BTC>SMA),
|
||||
cosi' si evitano le capitolazioni dei bear (2018/2022) che peggiorano Acc/DD/PnL.
|
||||
- Portafoglio: equal-weight giornaliero delle 3 strategie migliorate -> la
|
||||
diversificazione taglia il DD mantenendo la PnL (migliora il risk-adjusted).
|
||||
Tutto NETTO, con DD pieno e per-anno.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.honest_lab import atr, ema, get_df, available_assets, FEE_RT
|
||||
from scripts.analysis.honest_improve import rot_improved, _dd
|
||||
|
||||
LEV, POS = 3.0, 0.15
|
||||
|
||||
|
||||
def _daily_equity(ts_list, cap_list, idx):
|
||||
"""serie di equity giornaliera (ffill) su un DatetimeIndex comune."""
|
||||
s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True))
|
||||
s = s[~s.index.duplicated(keep="last")].sort_index()
|
||||
daily = s.resample("1D").last().reindex(idx).ffill().bfill()
|
||||
return daily
|
||||
|
||||
|
||||
# ---------- DIP01 con market-regime gate ----------
|
||||
def dip_market_gated(asset, n=50, z_in=2.5, sl_atr=2.5, max_bars=24,
|
||||
market_n=100, fee_rt=FEE_RT, oos_frac=0.0, return_equity=False):
|
||||
df = get_df(asset, "1h")
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
N = len(c); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
ma = pd.Series(c).rolling(n).mean().values
|
||||
sd = pd.Series(c).rolling(n).std().values
|
||||
a = atr(df, 14)
|
||||
z = (c - ma) / np.where(sd == 0, np.nan, sd)
|
||||
# regime di mercato: BTC 1h > SMA(market_n in giorni -> *24 barre)
|
||||
btc = get_df("BTC", "1h")
|
||||
bser = pd.Series(btc["close"].values,
|
||||
index=pd.to_datetime(btc["timestamp"], unit="ms", utc=True))
|
||||
bser = bser[~bser.index.duplicated()]
|
||||
bma = bser.rolling(market_n * 24).mean()
|
||||
risk_on = (bser > bma).reindex(ts, method="ffill").fillna(False).values
|
||||
fee = fee_rt * LEV
|
||||
cap = 1000.0; last_exit = -1
|
||||
eq_ts, eq_v = [], []
|
||||
yt: dict[int, list] = {}; ypnl: dict[int, float] = {}
|
||||
split = int(N * (1 - oos_frac)) if oos_frac else 0
|
||||
for i in range(n + 14, N):
|
||||
if i < split or np.isnan(z[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if not (z[i] <= -z_in and z[i - 1] > -z_in):
|
||||
continue
|
||||
if market_n and not risk_on[i]:
|
||||
continue
|
||||
if i <= last_exit or i + 1 >= N:
|
||||
continue
|
||||
entry = c[i]; tp, sl, mb = ma[i], c[i] - sl_atr * a[i], max_bars
|
||||
exit_p = c[min(i + mb, N - 1)]; j = min(i + mb, N - 1)
|
||||
for k in range(1, mb + 1):
|
||||
j = i + k
|
||||
if j >= N:
|
||||
j = N - 1; exit_p = c[j]; break
|
||||
if l[j] <= sl:
|
||||
exit_p = sl; break
|
||||
if h[j] >= tp:
|
||||
exit_p = tp; break
|
||||
if k == mb:
|
||||
exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * LEV - fee
|
||||
cap = max(cap + cap * POS * ret, 10.0)
|
||||
last_exit = j
|
||||
y = ts.iloc[i].year
|
||||
rec = yt.setdefault(y, [0, 0]); rec[0] += 1; rec[1] += ret > 0
|
||||
ypnl[y] = ypnl.get(y, 0.0) + ret * 100
|
||||
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
|
||||
t = sum(v[0] for v in yt.values()); w = sum(v[1] for v in yt.values())
|
||||
out = {"ret": (cap / 1000 - 1) * 100, "dd": _dd(np.array(eq_v)) if eq_v else 0.0,
|
||||
"trades": t, "acc": w / t * 100 if t else 0.0, "yt": yt, "ypnl": ypnl,
|
||||
"pos_years": sum(1 for v in ypnl.values() if v > 0), "n_years": len(ypnl)}
|
||||
if return_equity:
|
||||
out["eq_ts"], out["eq_v"] = eq_ts, eq_v
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 96)
|
||||
print(" DIP01 — base vs MARKET-GATE (compra dip solo se BTC>SMA100)")
|
||||
print("=" * 96)
|
||||
print(f" {'asset / config':<30s}{'Trd':>6s}{'Acc%':>7s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>7s}{'AnniP':>8s}")
|
||||
for a in ["BTC", "ETH", "SOL"]:
|
||||
b = dip_market_gated(a, market_n=0); bo = dip_market_gated(a, market_n=0, oos_frac=0.30)
|
||||
g = dip_market_gated(a, market_n=100); go = dip_market_gated(a, market_n=100, oos_frac=0.30)
|
||||
print(f" {a+' base':<30s}{b['trades']:>6d}{b['acc']:>7.1f}{b['ret']:>+9.0f}{bo['ret']:>+9.0f}"
|
||||
f"{b['dd']:>7.0f}{str(b['pos_years'])+'/'+str(b['n_years']):>8s}")
|
||||
print(f" {a+' +gate100':<30s}{g['trades']:>6d}{g['acc']:>7.1f}{g['ret']:>+9.0f}{go['ret']:>+9.0f}"
|
||||
f"{g['dd']:>7.0f}{str(g['pos_years'])+'/'+str(g['n_years']):>8s}")
|
||||
|
||||
# ---------- PORTAFOGLIO combinato (3 sleeve diversificate) ----------
|
||||
print("\n" + "=" * 96)
|
||||
print(" PORTAFOGLIO equal-weight giornaliero (ribilanciato): DIP01 + TR01-basket + ROT02")
|
||||
print("=" * 96)
|
||||
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
|
||||
# sleeve 1: DIP01 base su BTC (la migliore)
|
||||
d = dip_market_gated("BTC", market_n=0, return_equity=True)
|
||||
eq_dip = _norm(_daily_equity(d["eq_ts"], d["eq_v"], idx))
|
||||
# sleeve 2: TR01 equal-weight su {BNB,BTC,DOGE,SOL,XRP}
|
||||
eq_tr = _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], idx))
|
||||
# sleeve 3: ROT02 dual-momentum
|
||||
eq_rot = _norm(_rot_daily_equity(idx))
|
||||
members = {"DIP01_BTC": eq_dip, "TR01_basket": eq_tr, "ROT02_dualmom": eq_rot}
|
||||
# ribilanciamento giornaliero equal-weight: media dei rendimenti giornalieri
|
||||
drets = pd.DataFrame({k: v.pct_change().fillna(0) for k, v in members.items()})
|
||||
port_ret = drets.mean(axis=1)
|
||||
combo = (1 + port_ret).cumprod()
|
||||
print(f" Periodo {idx[0].date()} -> {idx[-1].date()} (leva/pos gia' incluse nelle sleeve)")
|
||||
print(f" {'sleeve':<16s}{'ret%':>9s}{'DD%':>7s}{'CAGR%':>8s}")
|
||||
yrs = (idx[-1] - idx[0]).days / 365.25
|
||||
for name, s in members.items():
|
||||
r = (s.iloc[-1] / s.iloc[0] - 1) * 100
|
||||
cagr = ((s.iloc[-1] / s.iloc[0]) ** (1 / yrs) - 1) * 100
|
||||
print(f" {name:<16s}{r:>+9.0f}{_dd(s.values):>7.0f}{cagr:>8.0f}")
|
||||
r = (combo.iloc[-1] / combo.iloc[0] - 1) * 100
|
||||
cagr = ((combo.iloc[-1] / combo.iloc[0]) ** (1 / yrs) - 1) * 100
|
||||
print(f" {'PORTAFOGLIO':<16s}{r:>+9.0f}{_dd(combo.values):>7.0f}{cagr:>8.0f} <-- DD molto piu' basso, CAGR solida")
|
||||
# per-anno del portafoglio
|
||||
pa = (port_ret.groupby(port_ret.index.year).apply(lambda x: ((1 + x).prod() - 1) * 100))
|
||||
print(" Portafoglio per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
|
||||
|
||||
|
||||
def _norm(s):
|
||||
return s / s.iloc[0]
|
||||
|
||||
|
||||
def _tr_basket_daily(assets, idx):
|
||||
"""equity giornaliera media di TR01 (EMA20/100 long-only, 4h) sul paniere."""
|
||||
eqs = []
|
||||
for a in assets:
|
||||
df = get_df(a, "4h"); c = df["close"].values; n = len(c)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
ef, es = ema(c, 20), ema(c, 100)
|
||||
sig = np.where(ef > es, 1.0, 0.0); sig[:100] = 0.0
|
||||
cap = 1000.0; cur = 0.0; fee = FEE_RT / 2 * LEV
|
||||
tl, cl = [], []
|
||||
for i in range(n - 1):
|
||||
s = sig[i]
|
||||
if s != cur:
|
||||
cap -= cap * POS * fee * abs(s - cur); cur = s
|
||||
cap = max(cap * (1 + POS * LEV * (c[i + 1] - c[i]) / c[i] * cur), 10.0)
|
||||
tl.append(ts.iloc[i]); cl.append(cap)
|
||||
eqs.append(_norm(_daily_equity(tl, cl, idx)))
|
||||
return _norm(pd.concat(eqs, axis=1).mean(axis=1))
|
||||
|
||||
|
||||
def _rot_daily_equity(idx):
|
||||
"""equity giornaliera della ROT01 dual-momentum (ricostruita bar-by-bar)."""
|
||||
from scripts.analysis.honest_rotation import build_panel
|
||||
panel = build_panel(available_assets(), "1d")
|
||||
cols = list(panel.columns); P = panel.values; T, N = P.shape
|
||||
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
|
||||
btc = P[:, cols.index("BTC")]; bma = pd.Series(btc).rolling(100).mean().values
|
||||
cap = 1000.0; w = np.zeros(N); ts_list = []; cap_list = []
|
||||
for i in range(101, T - 1):
|
||||
risk_on = btc[i] > bma[i] if not np.isnan(bma[i]) else False
|
||||
mom = P[i] / P[i - 60] - 1; order = np.argsort(mom)[::-1]
|
||||
chosen = [j for j in order if mom[j] > 0][:3] if risk_on else [] # top_k=3 (era 2): DD piu' basso
|
||||
nw = np.zeros(N)
|
||||
for j in chosen:
|
||||
nw[j] = 0.45 / len(chosen)
|
||||
cap -= cap * np.abs(nw - w).sum() * (FEE_RT / 2); w = nw
|
||||
cap = max(cap * (1 + float(np.dot(w, rets[i + 1]))), 10.0)
|
||||
ts_list.append(panel.index[i]); cap_list.append(cap)
|
||||
s = _daily_equity(ts_list, cap_list, idx); return s / s.iloc[0]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,192 @@
|
||||
"""honest_lab — laboratorio di ricerca strategie ONESTO e fee-aware.
|
||||
|
||||
Principi (per non ripetere l'errore look-ahead della famiglia squeeze):
|
||||
1. Ogni segnale a barra i usa SOLO dati fino a close[i]. Ingresso a close[i]
|
||||
(eseguibile dal vivo: il worker vede la candela chiusa ed entra). Opzione
|
||||
di robustezza: ingresso a open[i+1] (ancora piu' conservativo).
|
||||
2. Uscita TP/SL valutata intrabar su high/low, conservativa: SL prima del TP
|
||||
nello stesso bar. Time-limit max_bars. Una posizione per volta (non-overlap).
|
||||
3. Tutto NETTO dopo fee round-trip realistiche (0.10% Deribit) * leva.
|
||||
4. Validazione: FULL + OOS (held-out ultimo 30%) + per-anno + sweep fee
|
||||
+ griglia parametri + su PIU' asset. Niente di tutto cio' -> scartata.
|
||||
|
||||
Engine condiviso riusabile da tutte le strategie candidate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data # noqa: E402
|
||||
|
||||
FEE_RT = 0.001 # Deribit perp realistico: taker ~0.05%/lato = 0.10% RT
|
||||
LEV = 3.0
|
||||
POS = 0.15
|
||||
OOS_FRAC = 0.30
|
||||
DATA_DIR = PROJECT_ROOT / "data" / "raw"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# dati
|
||||
# ----------------------------------------------------------------------------
|
||||
_CACHE: dict[tuple[str, str], pd.DataFrame] = {}
|
||||
|
||||
|
||||
def available_assets() -> list[str]:
|
||||
out = []
|
||||
for p in sorted(DATA_DIR.glob("*_1h.parquet")):
|
||||
name = p.stem.replace("_1h", "").upper()
|
||||
if name not in ("BTC_DVOL", "ETH_DVOL"):
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
|
||||
def get_df(asset: str, tf: str) -> pd.DataFrame:
|
||||
"""tf nativo (15m,1h) o resample da 1h (2h,4h,6h,12h,1d)."""
|
||||
key = (asset, tf)
|
||||
if key in _CACHE:
|
||||
return _CACHE[key]
|
||||
if tf in ("15m", "1h"):
|
||||
df = load_data(asset, tf).reset_index(drop=True)
|
||||
else:
|
||||
base = load_data(asset, "1h").copy()
|
||||
base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
|
||||
base = base.set_index("dt")
|
||||
rule = {"2h": "2h", "4h": "4h", "6h": "6h", "12h": "12h", "1d": "1D"}[tf]
|
||||
agg = base.resample(rule).agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
|
||||
).dropna()
|
||||
# l'indice puo' essere datetime64[ms] o [ns]: forza ms in modo robusto
|
||||
agg["timestamp"] = agg.index.values.astype("datetime64[ms]").astype("int64")
|
||||
df = agg.reset_index(drop=True)
|
||||
df = df[["timestamp", "open", "high", "low", "close", "volume"]].copy()
|
||||
_CACHE[key] = df
|
||||
return df
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# indicatori
|
||||
# ----------------------------------------------------------------------------
|
||||
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
pc = np.roll(c, 1); pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
return pd.Series(tr).rolling(n).mean().values
|
||||
|
||||
|
||||
def rsi(close: np.ndarray, n: int = 14) -> np.ndarray:
|
||||
d = np.diff(close, prepend=close[0])
|
||||
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1 / n, adjust=False).mean()
|
||||
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1 / n, adjust=False).mean()
|
||||
rs = up / dn.replace(0, np.nan)
|
||||
return (100 - 100 / (1 + rs)).values
|
||||
|
||||
|
||||
def ema(close: np.ndarray, n: int) -> np.ndarray:
|
||||
return pd.Series(close).ewm(span=n, adjust=False).mean().values
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# engine
|
||||
# ----------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class SimResult:
|
||||
trades: int
|
||||
win: float
|
||||
ret: float # ritorno % netto composto su 1000
|
||||
dd: float
|
||||
exposure: float
|
||||
yearly: dict[int, float]
|
||||
|
||||
@property
|
||||
def pos_years(self) -> int:
|
||||
return sum(1 for v in self.yearly.values() if v > 0)
|
||||
|
||||
@property
|
||||
def n_years(self) -> int:
|
||||
return len(self.yearly)
|
||||
|
||||
|
||||
def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT,
|
||||
lev: float = LEV, pos: float = POS, entry_on_open: bool = False) -> SimResult:
|
||||
"""entries: dict {i, d(+1/-1), tp, sl, max_bars}.
|
||||
|
||||
entry_on_open=True -> ingresso a open[i+1] invece di close[i] (robustezza).
|
||||
"""
|
||||
o, h, l, c = (df["open"].values, df["high"].values,
|
||||
df["low"].values, df["close"].values)
|
||||
n = len(c)
|
||||
cap = peak = 1000.0
|
||||
max_dd = 0.0
|
||||
fee = fee_rt * lev
|
||||
trades = wins = 0
|
||||
last_exit = -1
|
||||
bars_in = 0
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
yearly: dict[int, float] = {}
|
||||
|
||||
for e in entries:
|
||||
i, d = e["i"], e["d"]
|
||||
ei = i + 1 if entry_on_open else i # barra di ingresso
|
||||
if ei <= last_exit or ei + 1 >= n:
|
||||
continue
|
||||
entry = o[ei] if entry_on_open else c[i]
|
||||
tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
|
||||
exit_p = c[min(ei + mb, n - 1)]
|
||||
j = min(ei + mb, n - 1)
|
||||
for k in range(1, mb + 1):
|
||||
j = ei + k
|
||||
if j >= n:
|
||||
j = n - 1; exit_p = c[j]; break
|
||||
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hit_sl: # conservativo: SL prima del TP nello stesso bar
|
||||
exit_p = sl; break
|
||||
if hit_tp:
|
||||
exit_p = tp; break
|
||||
if k == mb:
|
||||
exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * d * lev - fee
|
||||
cap = max(cap + cap * pos * ret, 10.0)
|
||||
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
|
||||
trades += 1; wins += ret > 0; bars_in += (j - ei)
|
||||
last_exit = j
|
||||
yr = ts.iloc[i].year
|
||||
yearly[yr] = yearly.get(yr, 0.0) + ret * 100
|
||||
return SimResult(
|
||||
trades=trades,
|
||||
win=wins / trades * 100 if trades else 0.0,
|
||||
ret=(cap / 1000 - 1) * 100,
|
||||
dd=max_dd * 100,
|
||||
exposure=bars_in / n * 100,
|
||||
yearly=yearly,
|
||||
)
|
||||
|
||||
|
||||
def oos_split(entries: list[dict], df: pd.DataFrame, frac: float = OOS_FRAC):
|
||||
split = int(len(df) * (1 - frac))
|
||||
ins = [e for e in entries if e["i"] < split]
|
||||
oos = [e for e in entries if e["i"] >= split]
|
||||
return ins, oos
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# criterio di accettazione
|
||||
# ----------------------------------------------------------------------------
|
||||
def verdict(full: SimResult, oos: SimResult) -> bool:
|
||||
"""Strategia attendibile su un singolo asset/tf."""
|
||||
if full.trades < 30:
|
||||
return False
|
||||
if full.ret <= 0 or oos.ret <= 0:
|
||||
return False
|
||||
if full.pos_years < max(full.n_years - 1, 1):
|
||||
return False
|
||||
if full.dd > 45:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Tabella unica consolidata: PnL% NETTO per anno, tutte le strategie a confronto.
|
||||
Colonne: DIP01(BTC) · TR01(basket) · ROT01(base) · ROT02(dual-mom) · PORTAFOGLIO.
|
||||
Ultima riga: TOT e DD full-period.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.honest_lab import available_assets, FEE_RT
|
||||
from scripts.analysis.honest_improve import _dd
|
||||
from scripts.analysis.honest_improve2 import (
|
||||
dip_market_gated, _daily_equity, _norm, _tr_basket_daily,
|
||||
)
|
||||
from scripts.analysis.honest_rotation import build_panel
|
||||
|
||||
LEV, POS = 3.0, 0.15
|
||||
|
||||
|
||||
def rot_daily(idx, regime_n=0, lookback=60, top_k=2, gross=0.45):
|
||||
"""equity giornaliera della rotazione, con/senza overlay dual-momentum."""
|
||||
panel = build_panel(available_assets(), "1d")
|
||||
cols = list(panel.columns); P = panel.values; T, N = P.shape
|
||||
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
|
||||
btc = P[:, cols.index("BTC")]
|
||||
bma = pd.Series(btc).rolling(max(regime_n, 2)).mean().values
|
||||
use_reg = regime_n and regime_n > 1
|
||||
cap = 1000.0; w = np.zeros(N); tl, cl = [], []
|
||||
start = max(lookback + 1, regime_n + 1 if use_reg else 0)
|
||||
for i in range(start, T - 1):
|
||||
risk_on = (btc[i] > bma[i]) if (use_reg and not np.isnan(bma[i])) else True
|
||||
mom = P[i] / P[i - lookback] - 1; order = np.argsort(mom)[::-1]
|
||||
chosen = [j for j in order if mom[j] > 0][:top_k] if risk_on else []
|
||||
nw = np.zeros(N)
|
||||
for j in chosen:
|
||||
nw[j] = gross / len(chosen)
|
||||
cap -= cap * np.abs(nw - w).sum() * (FEE_RT / 2); w = nw
|
||||
cap = max(cap * (1 + float(np.dot(w, rets[i + 1]))), 10.0)
|
||||
tl.append(panel.index[i]); cl.append(cap)
|
||||
return _norm(_daily_equity(tl, cl, idx))
|
||||
|
||||
|
||||
def year_pnl(eq):
|
||||
return {int(y): (g.iloc[-1] / g.iloc[0] - 1) * 100 for y, g in _norm(eq).groupby(eq.index.year)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
|
||||
d = dip_market_gated("BTC", market_n=0, return_equity=True)
|
||||
cols = {
|
||||
"DIP01(BTC)": _norm(_daily_equity(d["eq_ts"], d["eq_v"], idx)),
|
||||
"TR01(bskt)": _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], idx)),
|
||||
"ROT01": rot_daily(idx, regime_n=0),
|
||||
"ROT02": rot_daily(idx, regime_n=100),
|
||||
}
|
||||
drets = pd.DataFrame({k: v.pct_change().fillna(0) for k, v in {
|
||||
"DIP01(BTC)": cols["DIP01(BTC)"], "TR01(bskt)": cols["TR01(bskt)"], "ROT02": cols["ROT02"]
|
||||
}.items()})
|
||||
cols["PORTAF."] = (1 + drets.mean(axis=1)).cumprod()
|
||||
|
||||
names = list(cols)
|
||||
py = {n: year_pnl(cols[n]) for n in names}
|
||||
years = sorted({y for n in names for y in py[n]})
|
||||
|
||||
print("=" * 78)
|
||||
print(" PnL% NETTO PER ANNO — confronto strategie (leva 3x, fee 0.10% RT)")
|
||||
print("=" * 78)
|
||||
print(f" {'Anno':>6s}" + "".join(f"{n:>12s}" for n in names))
|
||||
print(" " + "-" * 72)
|
||||
for y in years:
|
||||
print(f" {y:>6d}" + "".join(f"{py[n].get(y, float('nan')):>+12.0f}" if y in py[n] else f"{'-':>12s}" for n in names))
|
||||
print(" " + "-" * 72)
|
||||
print(f" {'TOT%':>6s}" + "".join(f"{(cols[n].iloc[-1]/cols[n].iloc[0]-1)*100:>+12.0f}" for n in names))
|
||||
print(f" {'DDfull':>6s}" + "".join(f"{_dd(cols[n].values):>12.0f}" for n in names))
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Strategia #3 candidata: ROTAZIONE cross-sectional momentum (multi-crypto).
|
||||
Una sola strategia che usa l'INTERO paniere: ad ogni ribilanciamento alloca il
|
||||
capitale agli asset con momentum migliore (long-only). Cattura la dispersione tra
|
||||
crypto (gli alt forti corrono molto piu' di BTC nei bull) senza shortare nulla.
|
||||
|
||||
Onesto: i pesi a close[i] usano solo rendimenti passati; il rendimento del bar
|
||||
i->i+1 e' realizzato con quei pesi. Fee sul turnover. Allineamento per timestamp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.honest_lab import get_df, available_assets, FEE_RT # noqa: E402
|
||||
|
||||
LEV = 3.0
|
||||
GROSS = 0.45 # esposizione lorda = LEV*POS del singolo (0.15*3) per confronto equo
|
||||
|
||||
|
||||
def build_panel(assets: list[str], tf: str) -> pd.DataFrame:
|
||||
"""Matrice close allineata per timestamp (inner join)."""
|
||||
closes = {}
|
||||
for a in assets:
|
||||
df = get_df(a, tf)
|
||||
s = pd.Series(df["close"].values,
|
||||
index=pd.to_datetime(df["timestamp"], unit="ms", utc=True))
|
||||
closes[a] = s[~s.index.duplicated()]
|
||||
panel = pd.DataFrame(closes).dropna()
|
||||
return panel
|
||||
|
||||
|
||||
def simulate_rotation(panel: pd.DataFrame, lookback=30, top_k=2,
|
||||
fee_rt=FEE_RT, gross=GROSS, abs_filter=True,
|
||||
oos_frac=0.0) -> dict:
|
||||
"""Ad ogni barra: ranking per rendimento passato `lookback`; pesi uguali sui
|
||||
top_k con momentum>0 (se abs_filter); altrimenti cash. gross = esposizione tot.
|
||||
oos_frac>0: parte a investire solo dall'ultimo frac del campione."""
|
||||
P = panel.values
|
||||
T, N = P.shape
|
||||
rets = np.zeros_like(P)
|
||||
rets[1:] = P[1:] / P[:-1] - 1
|
||||
years = panel.index.year.values
|
||||
start = max(lookback + 1, int(T * (1 - oos_frac)) if oos_frac else lookback + 1)
|
||||
cap = peak = 1000.0
|
||||
max_dd = 0.0
|
||||
w = np.zeros(N)
|
||||
yearly: dict[int, float] = {}
|
||||
turn_total = 0.0
|
||||
for i in range(start, T - 1):
|
||||
mom = P[i] / P[i - lookback] - 1
|
||||
order = np.argsort(mom)[::-1]
|
||||
new_w = np.zeros(N)
|
||||
chosen = [j for j in order if (mom[j] > 0 or not abs_filter)][:top_k]
|
||||
if chosen:
|
||||
for j in chosen:
|
||||
new_w[j] = gross / len(chosen)
|
||||
# fee sul turnover (one-way = fee_rt/2 su ogni variazione di peso)
|
||||
turnover = np.abs(new_w - w).sum()
|
||||
cap -= cap * turnover * (fee_rt / 2)
|
||||
turn_total += turnover
|
||||
w = new_w
|
||||
port_ret = float(np.dot(w, rets[i + 1])) # rendimento bar i->i+1
|
||||
cap = max(cap * (1 + port_ret), 10.0)
|
||||
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
|
||||
yearly[years[i]] = yearly.get(years[i], 0.0) + port_ret * 100
|
||||
return {
|
||||
"ret": (cap / 1000 - 1) * 100,
|
||||
"dd": max_dd * 100,
|
||||
"turnover": turn_total,
|
||||
"yearly": yearly,
|
||||
"pos_years": sum(1 for v in yearly.values() if v > 0),
|
||||
"n_years": len(yearly),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
assets = available_assets()
|
||||
print(f"ROTATION cross-sectional momentum — fee {FEE_RT*100:.2f}% RT, gross {GROSS} | OOS 30%")
|
||||
print(f" Paniere: {assets}")
|
||||
for tf in ["1d", "4h"]:
|
||||
panel = build_panel(assets, tf)
|
||||
print(f"\n === {tf} === panel {panel.shape[0]} barre, {panel.index[0].date()} -> {panel.index[-1].date()}")
|
||||
print(f" {'config':<22s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Turn':>7s}{'AnniP':>8s}")
|
||||
for lb in [20, 30, 60, 90]:
|
||||
for k in [1, 2, 3]:
|
||||
full = simulate_rotation(panel, lookback=lb, top_k=k)
|
||||
oos = simulate_rotation(panel, lookback=lb, top_k=k, oos_frac=0.30)
|
||||
anni = f"{full['pos_years']}/{full['n_years']}"
|
||||
print(f" lb{lb:<3d} top{k:<14d}{full['ret']:>+9.0f}{oos['ret']:>+9.0f}"
|
||||
f"{full['dd']:>6.0f}{full['turnover']:>7.0f}{anni:>8s}")
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Strategia #3 candidata: time-series momentum / trend (TSMOM).
|
||||
Posizione continua decisa a close[i] dai dati passati; fee SOLO sui cambi di
|
||||
posizione (poche operazioni su TF alto = fee non letali). Niente look-ahead:
|
||||
il rendimento del bar i->i+1 usa la direzione decisa a close[i].
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.honest_lab import ema, get_df, available_assets, FEE_RT # noqa: E402
|
||||
|
||||
LEV = 3.0
|
||||
POS = 0.15
|
||||
|
||||
|
||||
def simulate_position(sig: np.ndarray, df: pd.DataFrame, fee_rt: float = FEE_RT,
|
||||
lev: float = LEV, pos: float = POS) -> dict:
|
||||
"""sig[i] in {-1,0,1} = direzione tenuta nel bar i->i+1, decisa a close[i].
|
||||
Fee one-way = fee_rt/2 su ogni unita' di variazione posizione."""
|
||||
c = df["close"].values
|
||||
n = len(c)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
cap = peak = 1000.0
|
||||
max_dd = 0.0
|
||||
cur = 0.0
|
||||
flips = 0
|
||||
bars_in = 0
|
||||
yearly: dict[int, float] = {}
|
||||
for i in range(n - 1):
|
||||
s = sig[i]
|
||||
if not np.isfinite(s):
|
||||
s = 0.0
|
||||
if s != cur:
|
||||
cap -= cap * pos * (fee_rt / 2) * lev * abs(s - cur)
|
||||
flips += abs(s - cur) > 0
|
||||
cur = s
|
||||
pr = (c[i + 1] - c[i]) / c[i]
|
||||
bar_ret = pos * lev * pr * cur
|
||||
cap = max(cap * (1 + bar_ret), 10.0)
|
||||
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
|
||||
if cur != 0:
|
||||
bars_in += 1
|
||||
yr = ts.iloc[i].year
|
||||
yearly[yr] = yearly.get(yr, 0.0) + bar_ret * 100
|
||||
return {
|
||||
"ret": (cap / 1000 - 1) * 100,
|
||||
"dd": max_dd * 100,
|
||||
"flips": flips,
|
||||
"exposure": bars_in / n * 100,
|
||||
"yearly": yearly,
|
||||
"pos_years": sum(1 for v in yearly.values() if v > 0),
|
||||
"n_years": len(yearly),
|
||||
}
|
||||
|
||||
|
||||
def tsmom_signal(df, lookback=30, long_only=False):
|
||||
"""+1 se close>close[-lookback], -1 (o 0 se long_only) altrimenti."""
|
||||
c = df["close"].values
|
||||
sig = np.zeros(len(c))
|
||||
for i in range(lookback, len(c)):
|
||||
up = c[i] > c[i - lookback]
|
||||
sig[i] = 1.0 if up else (0.0 if long_only else -1.0)
|
||||
return sig
|
||||
|
||||
|
||||
def ema_dual_signal(df, fast=20, slow=100, long_only=False):
|
||||
"""+1 se EMA_fast>EMA_slow."""
|
||||
c = df["close"].values
|
||||
ef, es = ema(c, fast), ema(c, slow)
|
||||
sig = np.where(ef > es, 1.0, 0.0 if long_only else -1.0)
|
||||
sig[:slow] = 0.0
|
||||
return sig
|
||||
|
||||
|
||||
def oos(sig, df, frac=0.30):
|
||||
split = int(len(df) * (1 - frac))
|
||||
s2 = sig.copy(); s2[:split] = 0.0
|
||||
return simulate_position(s2, df)
|
||||
|
||||
|
||||
def show(label, df, sig):
|
||||
full = simulate_position(sig, df)
|
||||
o = oos(sig, df)
|
||||
anni = f"{full['pos_years']}/{full['n_years']}"
|
||||
print(f" {label:<26s}{full['flips']:>6d}{full['ret']:>+9.0f}{o['ret']:>+9.0f}"
|
||||
f"{full['dd']:>6.0f}{full['exposure']:>6.0f}{anni:>8s}")
|
||||
return full, o
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
assets = available_assets()
|
||||
print(f"TSMOM / trend — fee {FEE_RT*100:.2f}% RT, leva3x pos15% | OOS30%")
|
||||
for tf in ["1d", "4h"]:
|
||||
print(f"\n ###### TF {tf} ######")
|
||||
for a in assets:
|
||||
df = get_df(a, tf)
|
||||
print(f"\n === {a} {tf} === {'Flip':>5s}{'FULL%':>8s}{'OOS%':>8s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}")
|
||||
show("TSMOM lb30 long/short", df, tsmom_signal(df, 30))
|
||||
show("TSMOM lb30 long-only", df, tsmom_signal(df, 30, long_only=True))
|
||||
show("TSMOM lb90 long/short", df, tsmom_signal(df, 90))
|
||||
show("EMA 20/100 long/short", df, ema_dual_signal(df, 20, 100))
|
||||
show("EMA 20/100 long-only", df, ema_dual_signal(df, 20, 100, long_only=True))
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Test ingresso intra-barra: rottura banda squeeze rilevata sul 5m vs close 15m.
|
||||
|
||||
Domanda: entrando sul 5m appena il prezzo rompe la banda di Bollinger dello
|
||||
squeeze (bande dall'ultima barra 15m CHIUSA -> nessun look-ahead), si recupera
|
||||
parte del movimento che l'ingresso al close della barra 15m si perde?
|
||||
|
||||
Confronto a parita' di EXIT (stesso wall-clock): l'unica differenza e' il prezzo
|
||||
d'ingresso (5m anticipato vs close 15m ritardato). La differenza di rendimento e'
|
||||
esattamente lo "scatto" del breakout catturato in piu'.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from src.live.signal_engine import keltner_ratio
|
||||
|
||||
OOS_START = "2023-11-20"
|
||||
BB_W = 14
|
||||
SQ_THR = 0.8
|
||||
MIN_DUR = 5
|
||||
LEV = 3.0
|
||||
POS = 0.15
|
||||
M15 = 15 * 60 * 1000
|
||||
M5 = 5 * 60 * 1000
|
||||
|
||||
|
||||
def build_15m_levels(df15: pd.DataFrame) -> pd.DataFrame:
|
||||
c = df15["close"].values
|
||||
h = df15["high"].values
|
||||
l = df15["low"].values
|
||||
n = len(c)
|
||||
kcr = keltner_ratio(c, h, l, BB_W)
|
||||
ma = np.full(n, np.nan)
|
||||
sd = np.full(n, np.nan)
|
||||
for t in range(BB_W, n):
|
||||
w = c[t - BB_W + 1 : t + 1]
|
||||
ma[t] = w.mean()
|
||||
sd[t] = w.std()
|
||||
upper = ma + 2 * sd
|
||||
lower = ma - 2 * sd
|
||||
|
||||
# durata squeeze consecutiva e maturita'
|
||||
dur = np.zeros(n, dtype=int)
|
||||
run = 0
|
||||
for t in range(n):
|
||||
if not np.isnan(kcr[t]) and kcr[t] < SQ_THR:
|
||||
run += 1
|
||||
else:
|
||||
run = 0
|
||||
dur[t] = run
|
||||
mature = dur >= MIN_DUR
|
||||
|
||||
return pd.DataFrame({
|
||||
"ts15": df15["timestamp"].values,
|
||||
"close_time15": df15["timestamp"].values + M15,
|
||||
"close15": c,
|
||||
"upper": upper,
|
||||
"lower": lower,
|
||||
"mature": mature,
|
||||
})
|
||||
|
||||
|
||||
def run_asset(asset: str, hold_min: int, fee_rt: float) -> dict:
|
||||
df5 = load_data(asset, "5m").reset_index(drop=True)
|
||||
df15 = load_data(asset, "15m").reset_index(drop=True)
|
||||
lvl = build_15m_levels(df15)
|
||||
|
||||
d5 = pd.DataFrame({
|
||||
"ts5": df5["timestamp"].values,
|
||||
"close_time5": df5["timestamp"].values + M5,
|
||||
"close5": df5["close"].values,
|
||||
})
|
||||
|
||||
# banda armata: ultima barra 15m CHIUSA prima della chiusura del bar 5m
|
||||
armed = pd.merge_asof(
|
||||
d5.sort_values("close_time5"),
|
||||
lvl[["close_time15", "upper", "lower", "mature"]].sort_values("close_time15"),
|
||||
left_on="close_time5", right_on="close_time15", direction="backward",
|
||||
)
|
||||
# barra 15m CONTENENTE il bar 5m (per l'ingresso ritardato a close 15m)
|
||||
cont = pd.merge_asof(
|
||||
d5.sort_values("ts5"),
|
||||
lvl[["ts15", "close15", "close_time15"]].rename(
|
||||
columns={"close_time15": "cont_close_time"}).sort_values("ts15"),
|
||||
left_on="ts5", right_on="ts15", direction="backward",
|
||||
)
|
||||
|
||||
m = armed.copy()
|
||||
m["cont_close"] = cont["close15"].values
|
||||
m["cont_close_time"] = cont["cont_close_time"].values
|
||||
|
||||
oos_ms = int(pd.Timestamp(OOS_START, tz="UTC").timestamp() * 1000)
|
||||
close5 = m["close5"].values
|
||||
ct5 = m["close_time5"].values
|
||||
upper = m["upper"].values
|
||||
lower = m["lower"].values
|
||||
mature = m["mature"].values
|
||||
cont_close = m["cont_close"].values
|
||||
cont_ct = m["cont_close_time"].values
|
||||
n = len(m)
|
||||
|
||||
cap_e = cap_l = 1000.0 # equity ingresso early(5m) e late(15m)
|
||||
peak_e = peak_l = 1000.0
|
||||
dd_e = dd_l = 0.0
|
||||
trades = win_e = win_l = 0
|
||||
thrust_sum = 0.0
|
||||
fee = fee_rt * LEV
|
||||
busy_until = -1
|
||||
|
||||
for i in range(n):
|
||||
if ct5[i] < oos_ms or ct5[i] <= busy_until:
|
||||
continue
|
||||
if not mature[i] or np.isnan(upper[i]):
|
||||
continue
|
||||
if close5[i] > upper[i]:
|
||||
d = 1
|
||||
elif close5[i] < lower[i]:
|
||||
d = -1
|
||||
else:
|
||||
continue
|
||||
|
||||
entry_e = close5[i]
|
||||
entry_l = cont_close[i]
|
||||
exit_time = cont_ct[i] + hold_min * 60 * 1000
|
||||
# primo close 5m al/oltre exit_time
|
||||
j = np.searchsorted(ct5, exit_time, side="left")
|
||||
if j >= n:
|
||||
break
|
||||
exit_p = close5[j]
|
||||
|
||||
ret_e = ((exit_p - entry_e) / entry_e) * d * LEV - fee
|
||||
ret_l = ((exit_p - entry_l) / entry_l) * d * LEV - fee
|
||||
thrust_sum += (entry_l - entry_e) / entry_e * d * 100 # scatto % (no leva)
|
||||
|
||||
cb_e, cb_l = cap_e, cap_l
|
||||
cap_e = max(cb_e + cb_e * POS * ret_e, 10.0)
|
||||
cap_l = max(cb_l + cb_l * POS * ret_l, 10.0)
|
||||
peak_e = max(peak_e, cap_e); dd_e = max(dd_e, (peak_e - cap_e) / peak_e)
|
||||
peak_l = max(peak_l, cap_l); dd_l = max(dd_l, (peak_l - cap_l) / peak_l)
|
||||
trades += 1
|
||||
win_e += ret_e > 0
|
||||
win_l += ret_l > 0
|
||||
busy_until = exit_time
|
||||
|
||||
return {
|
||||
"trades": trades,
|
||||
"avg_thrust": thrust_sum / trades if trades else 0.0,
|
||||
"early_win": win_e / trades * 100 if trades else 0.0,
|
||||
"late_win": win_l / trades * 100 if trades else 0.0,
|
||||
"early_ret": (cap_e / 1000 - 1) * 100,
|
||||
"late_ret": (cap_l / 1000 - 1) * 100,
|
||||
"early_dd": dd_e * 100,
|
||||
"late_dd": dd_l * 100,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
for fee_rt in (0.002, 0.001):
|
||||
print("=" * 104)
|
||||
print(f" INGRESSO INTRA-BARRA 5m vs CLOSE 15m — OOS da {OOS_START} | leva={LEV:.0f}x "
|
||||
f"| fee={fee_rt*100:.2f}% RT")
|
||||
print(" EARLY = entra al close 5m che rompe la banda | LATE = entra al close della barra 15m | stesso exit")
|
||||
print("=" * 104)
|
||||
print(f" {'Asset':>5s}{'Hold':>6s}{'Trd':>6s}{'Scatto%':>9s}"
|
||||
f"{'EARLY win%':>12s}{'EARLY ret%':>12s}{'LATE win%':>11s}{'LATE ret%':>11s}{'Δret%':>9s}")
|
||||
print(" " + "-" * 100)
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for hold_min in (15, 30, 45):
|
||||
r = run_asset(asset, hold_min, fee_rt)
|
||||
print(f" {asset:>5s}{hold_min:>5d}m{r['trades']:>6d}{r['avg_thrust']:>+9.3f}"
|
||||
f"{r['early_win']:>12.1f}{r['early_ret']:>+12.1f}"
|
||||
f"{r['late_win']:>11.1f}{r['late_ret']:>+11.1f}"
|
||||
f"{r['early_ret']-r['late_ret']:>+9.1f}")
|
||||
print(" " + "-" * 100)
|
||||
print(" Scatto% = movimento medio (no leva) catturato tra rottura 5m e close 15m, nella direzione.")
|
||||
print(" Δret% = vantaggio dell'ingresso anticipato. Se ~0 o negativo, il 5m non aiuta.\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Validazione out-of-sample fee-aware di tutte le strategie live.
|
||||
|
||||
Per ognuna delle 6 config in strategies.yml:
|
||||
- split temporale held-out (train = primi (1-test_frac), test = ultimo test_frac)
|
||||
- ML01 (SignalEngine): allena sul train, predice sul test (come il worker live)
|
||||
- rule-based: i segnali sono causali, si valutano quelli nella finestra test
|
||||
- simulazione fedele al worker live: una posizione per volta (non-overlap),
|
||||
uscita a `hold` barre o stop a -2%, fee round-trip e leva inclusi
|
||||
|
||||
Stampa, per ogni config: numero trade nel test, win% lordo e netto, return netto,
|
||||
costo commissioni, e confronto lordo-vs-netto per isolare l'impatto delle fee.
|
||||
Usa i parquet locali (data/raw), nessuna chiamata di rete.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import yaml
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from src.live.strategy_loader import load_strategy
|
||||
from src.live.signal_engine import SignalEngine, keltner_ratio, build_features
|
||||
|
||||
TEST_FRAC = 0.30
|
||||
STOP_PCT = -0.02
|
||||
|
||||
|
||||
def simulate(entries: list[tuple[int, int]], close: np.ndarray, hold: int,
|
||||
fee_rt: float, lev: float, pos: float,
|
||||
initial: float = 1000.0, entry_offset: int = 0) -> dict:
|
||||
"""FSM fedele al worker live: non-overlap, hold N barre o stop -2%.
|
||||
|
||||
entry_offset: 0 = ingresso a close[i] (worker live); 1 = close[i-1]
|
||||
(convenzione del backtest storico, che conosce la direzione di barra i).
|
||||
"""
|
||||
n = len(close)
|
||||
capital = peak = initial
|
||||
max_dd = 0.0
|
||||
fees_eur = gross_eur = 0.0
|
||||
wins_gross = wins_net = n_trades = 0
|
||||
last_exit = -1
|
||||
|
||||
for i, d in entries:
|
||||
e = i - entry_offset
|
||||
if e <= last_exit or e < 0 or e + 1 >= n:
|
||||
continue
|
||||
entry = close[e]
|
||||
exit_price = close[min(e + hold, n - 1)]
|
||||
for k in range(1, hold + 1):
|
||||
j = e + k
|
||||
if j >= n:
|
||||
exit_price = close[n - 1]
|
||||
break
|
||||
if k < hold and (close[j] - entry) / entry * d <= STOP_PCT:
|
||||
exit_price = close[j]
|
||||
break
|
||||
if k == hold:
|
||||
exit_price = close[j]
|
||||
|
||||
actual = (exit_price - entry) / entry * d # movimento prezzo * direzione (no leva)
|
||||
gross = actual * lev
|
||||
fee = fee_rt * lev
|
||||
net = gross - fee
|
||||
|
||||
cap_before = capital
|
||||
capital = max(cap_before + cap_before * pos * net, 10.0)
|
||||
gross_eur += cap_before * pos * gross
|
||||
fees_eur += cap_before * pos * fee
|
||||
peak = max(peak, capital)
|
||||
max_dd = max(max_dd, (peak - capital) / peak)
|
||||
|
||||
n_trades += 1
|
||||
wins_gross += actual > 0
|
||||
wins_net += net > 0
|
||||
last_exit = e + hold
|
||||
|
||||
return {
|
||||
"trades": n_trades,
|
||||
"win_gross": wins_gross / n_trades * 100 if n_trades else 0.0,
|
||||
"win_net": wins_net / n_trades * 100 if n_trades else 0.0,
|
||||
"net_return_pct": (capital / initial - 1) * 100,
|
||||
"net_eur": capital - initial,
|
||||
"gross_eur": gross_eur,
|
||||
"fees_eur": fees_eur,
|
||||
"final_capital": capital,
|
||||
"max_dd": max_dd * 100,
|
||||
}
|
||||
|
||||
|
||||
def rule_entries(name: str, df: pd.DataFrame, params: dict, split: int) -> list[tuple[int, int]]:
|
||||
strat = load_strategy(name)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
sigs = strat.generate_signals(df, ts, **params)
|
||||
return [(s.idx, s.direction) for s in sigs if s.idx >= split]
|
||||
|
||||
|
||||
def ml_entries(df: pd.DataFrame, params: dict, split: int, hold: int) -> tuple[list[tuple[int, int]], dict]:
|
||||
bb_w = params.get("bb_window", 14)
|
||||
sq_thr = params.get("sq_threshold", 0.8)
|
||||
ml_thr = params.get("ml_threshold", 0.70)
|
||||
|
||||
eng = SignalEngine(bb_w=bb_w, sq_thr=sq_thr, ml_thr=ml_thr)
|
||||
train_res = eng.train(df.iloc[:split].reset_index(drop=True), lookahead=hold)
|
||||
if not eng.trained:
|
||||
return [], train_res
|
||||
|
||||
close = df["close"].values
|
||||
high = df["high"].values
|
||||
low = df["low"].values
|
||||
volume = df["volume"].values
|
||||
n = len(df)
|
||||
kcr = keltner_ratio(close, high, low, bb_w)
|
||||
up_idx = list(eng.model.classes_).index(1)
|
||||
|
||||
entries: list[tuple[int, int]] = []
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
for i in range(bb_w + 1, n):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < sq_thr
|
||||
if is_sq and not in_sq:
|
||||
in_sq, sq_start = True, i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
dur = i - sq_start
|
||||
if dur < eng.min_squeeze_bars or i < split or i + hold >= n:
|
||||
continue
|
||||
avg_vol = float(np.mean(volume[sq_start:i]))
|
||||
feats = build_features(df, i, dur, avg_vol, kcr[i])
|
||||
if feats is None:
|
||||
continue
|
||||
p_up = eng.model.predict_proba(eng.scaler.transform(feats.reshape(1, -1)))[0][up_idx]
|
||||
if p_up >= ml_thr:
|
||||
entries.append((i, 1))
|
||||
elif p_up <= (1 - ml_thr):
|
||||
entries.append((i, -1))
|
||||
return entries, train_res
|
||||
|
||||
|
||||
def squeeze_releases(df: pd.DataFrame, bb_w: int, sq_thr: float, min_dur: int,
|
||||
split: int) -> list[int]:
|
||||
"""Indici delle barre di rilascio squeeze nella finestra test (idx >= split)."""
|
||||
close = df["close"].values
|
||||
high = df["high"].values
|
||||
low = df["low"].values
|
||||
kcr = keltner_ratio(close, high, low, bb_w)
|
||||
rels: list[int] = []
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
for i in range(bb_w + 1, len(df)):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < sq_thr
|
||||
if is_sq and not in_sq:
|
||||
in_sq, sq_start = True, i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
if i - sq_start >= min_dur and i >= split:
|
||||
rels.append(i)
|
||||
return rels
|
||||
|
||||
|
||||
def honest_entries(df: pd.DataFrame, rels: list[int], rule: str, mom: int = 4) -> list[tuple[int, int]]:
|
||||
"""Direzione da regole honest (solo dati <= i-1) o baseline breakout.
|
||||
|
||||
breakout: sign(close[i]-close[i-1]) -> conoscibile solo a close[i] (= live attuale)
|
||||
premom: sign(close[i-1]-close[i-1-mom]) -> trend pre-release, 100% honest
|
||||
fade: -sign(close[i]-close[i-1]) -> mean-reversion del breakout
|
||||
"""
|
||||
close = df["close"].values
|
||||
out: list[tuple[int, int]] = []
|
||||
for i in rels:
|
||||
if i - 1 - mom < 0:
|
||||
continue
|
||||
if rule == "premom":
|
||||
d = np.sign(close[i - 1] - close[i - 1 - mom])
|
||||
elif rule == "fade":
|
||||
d = -np.sign(close[i] - close[i - 1])
|
||||
else: # breakout
|
||||
d = np.sign(close[i] - close[i - 1])
|
||||
if d != 0:
|
||||
out.append((i, int(d)))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
cfg = yaml.safe_load((PROJECT_ROOT / "strategies.yml").read_text())
|
||||
defaults = cfg.get("defaults", {})
|
||||
hold = defaults.get("hold_bars", 3)
|
||||
lev = defaults.get("leverage", 3)
|
||||
fee_rt = 0.002
|
||||
|
||||
fee_grid = [0.0, 0.0005, 0.001, 0.0015, 0.002]
|
||||
|
||||
# ---- (b) SENSIBILITA' ALLE FEE (config live, ingresso close[i]) ----
|
||||
print("=" * 104)
|
||||
print(f" (b) SENSIBILITA' ALLE FEE — config live, ingresso close[i] | OOS {int(TEST_FRAC*100)}% | hold={hold} leva={lev}x")
|
||||
print("=" * 104)
|
||||
print(f" {'Strategia':<26s}{'Asset':>5s}{'Trd':>5s}{'Lordo€':>9s}"
|
||||
+ "".join(f"{f'{f*100:.2f}%':>10s}" for f in fee_grid))
|
||||
print(" " + "-" * 100)
|
||||
|
||||
for entry in cfg.get("strategies", []):
|
||||
if not entry.get("enabled", True):
|
||||
continue
|
||||
name, asset, tf = entry["name"], entry["asset"], entry["tf"]
|
||||
pos = entry.get("position_size", defaults.get("position_size", 0.15))
|
||||
params = dict(entry.get("params", {}))
|
||||
params["asset"], params["tf"] = asset, tf
|
||||
|
||||
df = load_data(asset, tf).reset_index(drop=True)
|
||||
split = int(len(df) * (1 - TEST_FRAC))
|
||||
close = df["close"].values
|
||||
entries = (ml_entries(df, params, split, hold)[0] if name.startswith("ML01")
|
||||
else rule_entries(name, df, params, split))
|
||||
|
||||
gross = simulate(entries, close, hold, 0.0, lev, pos)["net_eur"]
|
||||
rets = [simulate(entries, close, hold, f, lev, pos)["net_return_pct"] for f in fee_grid]
|
||||
print(f" {name:<26s}{asset:>5s}{len(entries):>5d}{gross:>+9.0f}"
|
||||
+ "".join(f"{r:>+10.1f}" for r in rets))
|
||||
|
||||
print(" " + "-" * 100)
|
||||
print(" Colonne = Ret% netto al variare della fee RT. 0.00% isola l'edge puro (senza costi).")
|
||||
print(" Deribit perp reale: taker ~0.10% RT, maker ~0%. Il modello live usa 0.20% RT.")
|
||||
|
||||
# ---- (a) HONEST-ENTRY squeeze: direzione decisa <= i-1, ingresso close[i] ----
|
||||
print("\n" + "=" * 104)
|
||||
print(f" (a) HONEST-ENTRY squeeze (bb14 sq0.8 dur>=5) — ingresso close[i], fee={fee_rt*100:.1f}% RT")
|
||||
print("=" * 104)
|
||||
print(f" {'Asset':>5s}{'Regola direzione':>20s}{'Trd':>6s}{'Win%g':>8s}{'Win%n':>8s}{'Netto€':>9s}{'Ret%':>9s}{'DD%':>7s}")
|
||||
print(" " + "-" * 100)
|
||||
|
||||
rules = [("breakout (=live)", "breakout"), ("pre-trend mom4", "premom"),
|
||||
("pre-trend mom8", "premom8"), ("fade breakout", "fade")]
|
||||
for asset in ["BTC", "ETH"]:
|
||||
df = load_data(asset, "15m").reset_index(drop=True)
|
||||
split = int(len(df) * (1 - TEST_FRAC))
|
||||
close = df["close"].values
|
||||
rels = squeeze_releases(df, 14, 0.8, 5, split)
|
||||
for label, rule in rules:
|
||||
mom = 8 if rule == "premom8" else 4
|
||||
ents = honest_entries(df, rels, "premom" if rule == "premom8" else rule, mom=mom)
|
||||
r = simulate(ents, close, hold, fee_rt, lev, 0.15)
|
||||
print(f" {asset:>5s}{label:>20s}{r['trades']:>6d}{r['win_gross']:>8.1f}"
|
||||
f"{r['win_net']:>8.1f}{r['net_eur']:>+9.0f}{r['net_return_pct']:>+9.1f}{r['max_dd']:>7.1f}")
|
||||
print(" " + "-" * 100)
|
||||
print(" pre-trend = direzione dal trend PRIMA del rilascio (solo dati <= i-1): 100% honest.")
|
||||
print(" Se nessuna regola honest batte ~breakeven, non esiste edge direzionale tradeable.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Verifica indipendente + ricerca PAIRS / SPREAD MEAN-REVERSION fra cripto.
|
||||
|
||||
Famiglia nuova market-neutral (distinta da tutto l'esistente, single-asset).
|
||||
Idea: il log-ratio di due cripto oscilla attorno alla media; z-score estremo -> rientra.
|
||||
|
||||
Engine ONESTO (no look-ahead, verificato):
|
||||
- r[i] = log(closeA[i]/closeB[i]); ma/sd = rolling(n) su r -> usano solo r[<=i].
|
||||
- z[i] = (r[i]-ma[i])/sd[i]. ENTRY a close[i] (eseguibile):
|
||||
z<=-z_in -> LONG ratio (long A / short B); z>=+z_in -> SHORT ratio.
|
||||
- EXIT quando |z[j]| <= z_exit (rientro) o time-limit max_bars, a close[j].
|
||||
- pairs = 2 GAMBE -> fee = 2*fee_rt*lev (0.20% RT/coppia a fee_rt=0.001), il doppio
|
||||
del single-asset. Rendimento neutral = retA*d - retB*d (notional uguale per gamba).
|
||||
- non-overlap, capitale composto. Filtro candele sporche: salta salti |dr|>jump_max.
|
||||
- Ritorno riportato come CAGR e Sharpe ANNUALIZZATO sul tempo reale (no sqrt(n_trade)).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_RT, LEV, POS, OOS_FRAC = 0.001, 3.0, 0.15, 0.30
|
||||
BARS_YEAR = 8760 # 1h
|
||||
|
||||
|
||||
def aligned(a: str, b: str, tf: str = "1h"):
|
||||
da = load_data(a, tf)[["timestamp", "open", "high", "low", "close"]].rename(columns=lambda x: x + "_a" if x != "timestamp" else x)
|
||||
db = load_data(b, tf)[["timestamp", "close"]].rename(columns={"close": "close_b"})
|
||||
m = da.merge(db, on="timestamp", how="inner").reset_index(drop=True)
|
||||
m["dt"] = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
|
||||
return m
|
||||
|
||||
|
||||
def pairs_sim(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.5, max_bars=72,
|
||||
jump_max=0.08, fee_rt=FEE_RT, lev=LEV, pos=POS, split_frac=0.0):
|
||||
m = aligned(a, b, tf)
|
||||
ca, cb = m["close_a"].values, m["close_b"].values
|
||||
r = np.log(ca / cb)
|
||||
dr = np.abs(np.diff(r, prepend=r[0])) # salto 1-bar del log-ratio
|
||||
ma = pd.Series(r).rolling(n).mean().values
|
||||
sd = pd.Series(r).rolling(n).std().values
|
||||
z = (r - ma) / np.where(sd == 0, np.nan, sd) # causale: usa r[<=i]
|
||||
ts = m["dt"]; N = len(r)
|
||||
split = int(N * split_frac)
|
||||
fee = 2 * fee_rt * lev # 2 gambe
|
||||
cap = peak = 1000.0; dd = 0.0; last = -1
|
||||
trades = wins = 0; rets = []; yearly = {}
|
||||
eq_ts: list = []; eq_v: list = []
|
||||
for i in range(n + 1, N - 1):
|
||||
if i < split or np.isnan(z[i]) or dr[i] > jump_max:
|
||||
continue
|
||||
if i <= last:
|
||||
continue
|
||||
if z[i] <= -z_in:
|
||||
d = 1
|
||||
elif z[i] >= z_in:
|
||||
d = -1
|
||||
else:
|
||||
continue
|
||||
# exit: |z|<=z_exit o max_bars
|
||||
j = min(i + max_bars, N - 1)
|
||||
for k in range(1, max_bars + 1):
|
||||
jj = i + k
|
||||
if jj >= N:
|
||||
j = N - 1; break
|
||||
if abs(z[jj]) <= z_exit:
|
||||
j = jj; break
|
||||
j = jj
|
||||
retA = (ca[j] - ca[i]) / ca[i]
|
||||
retB = (cb[j] - cb[i]) / cb[i]
|
||||
ret = (retA - retB) * d * lev - fee # long A / short B (o viceversa)
|
||||
cap = max(cap + cap * pos * ret, 10.0)
|
||||
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
|
||||
trades += 1; wins += ret > 0; rets.append(ret * pos); last = j
|
||||
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
|
||||
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
|
||||
yrs_span = (ts.iloc[-1] - ts.iloc[max(split, 0)]).days / 365.25 or 1
|
||||
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(BARS_YEAR / np.mean([max_bars])) ) if len(rets) > 1 and np.std(rets) > 0 else 0.0
|
||||
# Sharpe annualizzato sul tempo reale: usa rendimenti per-trade scalati alla frequenza media
|
||||
if len(rets) > 1 and np.std(rets) > 0:
|
||||
trades_per_year = trades / yrs_span
|
||||
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(trades_per_year))
|
||||
ret_tot = (cap / 1000 - 1) * 100
|
||||
cagr = ((cap / 1000) ** (1 / yrs_span) - 1) * 100 if cap > 0 else -100
|
||||
return dict(trades=trades, win=wins / trades * 100 if trades else 0, ret=ret_tot, cagr=cagr,
|
||||
dd=dd * 100, sharpe=sharpe, yearly=yearly, eq_ts=eq_ts, eq_v=eq_v)
|
||||
|
||||
|
||||
def check_no_lookahead():
|
||||
"""Perturba il FUTURO del ratio e verifica che z[i] non cambi (causalita')."""
|
||||
m = aligned("ETH", "BTC")
|
||||
r = np.log(m["close_a"].values / m["close_b"].values)
|
||||
n = 50; i = 1000
|
||||
z_i = (r[i] - pd.Series(r).rolling(n).mean().values[i]) / pd.Series(r).rolling(n).std().values[i]
|
||||
r2 = r.copy(); r2[i + 1:] += 0.5 # stravolge il futuro
|
||||
z_i2 = (r2[i] - pd.Series(r2).rolling(n).mean().values[i]) / pd.Series(r2).rolling(n).std().values[i]
|
||||
print(f" no-look-ahead: z[i]={z_i:.6f} vs z[i] con futuro perturbato={z_i2:.6f} -> "
|
||||
f"{'OK (identico)' if abs(z_i - z_i2) < 1e-9 else 'VIOLAZIONE!'}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 104)
|
||||
print(f" PAIRS spread reversion — NETTO fee 0.20% RT/coppia (2 gambe), leva {LEV:.0f}x, OOS ultimo {int(OOS_FRAC*100)}%")
|
||||
print("=" * 104)
|
||||
check_no_lookahead()
|
||||
pairs = [("ETH", "BTC"), ("LTC", "ETH"), ("ADA", "ETH"), ("SOL", "ETH"),
|
||||
("BNB", "BTC"), ("XRP", "BTC"), ("SOL", "BTC"), ("DOGE", "BTC")]
|
||||
print(f"\n {'coppia':<10s}{'trd':>5s}{'win%':>6s}{'FULL%':>8s}{'OOS%':>8s}{'CAGR%':>7s}"
|
||||
f"{'DD%':>6s}{'oDD%':>6s}{'Shrp':>6s}{'anni+':>7s}{'fee0.4%RT':>11s}")
|
||||
print(" " + "-" * 96)
|
||||
for a, b in pairs:
|
||||
f = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72)
|
||||
o = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72, split_frac=1 - OOS_FRAC)
|
||||
hi = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72, fee_rt=0.002) # 0.4% RT/coppia
|
||||
yrs = f["yearly"]; pos_y = sum(1 for v in yrs.values() if v > 0)
|
||||
print(f" {a+'/'+b:<10s}{f['trades']:>5d}{f['win']:>6.1f}{f['ret']:>+8.0f}{o['ret']:>+8.0f}"
|
||||
f"{f['cagr']:>7.0f}{f['dd']:>6.0f}{o['dd']:>6.0f}{f['sharpe']:>6.2f}{f'{pos_y}/{len(yrs)}':>7s}"
|
||||
f"{hi['ret']:>+11.0f}")
|
||||
# correlazione con BTC daily (market-neutrality) sulla coppia migliore
|
||||
print("\n Verifica market-neutrality ETH/BTC: per-anno")
|
||||
f = pairs_sim("ETH", "BTC", n=50, z_in=2.0, z_exit=0.5, max_bars=72)
|
||||
print(" " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(f["yearly"].items())))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Report aggiornato: risultati per anno + numero trade per anno, tutte le strategie.
|
||||
|
||||
Sezioni:
|
||||
(A) RET% NETTO per anno — ogni strategia singola + i portafogli (FADE / HONEST /
|
||||
MASTER equal / MASTER 50-50). Ret% dai rendimenti giornalieri composti.
|
||||
(B) NUMERO TRADE per anno — per ogni strategia singola. Per le fade e DIP01 è il
|
||||
numero di ingressi; per TR01 e ROT02 (posizione continua) è il numero di
|
||||
ribilanciamenti/cambi di stato nell'anno.
|
||||
(C) RIEPILOGO — TOT%, CAGR, DD, Sharpe (FULL e OOS) dei portafogli.
|
||||
|
||||
Tutto NETTO fee 0.10% RT, leva 3x, pos 15%/sleeve. Finestra comune 2021-2026,
|
||||
OOS = ultimo 30%. Config = quella deployata (MR03/ROT01 in waste; ROT02 top_k=3).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from scripts.analysis.combine_portfolio import (
|
||||
build_all_sleeves, port_returns, yearly_returns, metrics, SPLIT, OOS_DATE, IDX,
|
||||
)
|
||||
from scripts.analysis.risk_management import strats_for, build_trades
|
||||
from scripts.analysis.honest_lab import get_df, ema, FEE_RT, LEV, POS
|
||||
from scripts.analysis.honest_improve import rot_improved
|
||||
from scripts.analysis.honest_improve2 import dip_market_gated
|
||||
|
||||
YEARS = sorted(set(IDX.year))
|
||||
|
||||
|
||||
# ---------------- trade per anno, per tipo di strategia ----------------
|
||||
def fade_trades_year(asset, fn, params) -> dict[int, int]:
|
||||
df = load_data(asset, "1h")
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
out: dict[int, int] = {}
|
||||
for i, j, ret in build_trades(fn(df, **params), df, trend_max=3.0):
|
||||
y = ts.iloc[i].year
|
||||
out[y] = out.get(y, 0) + 1
|
||||
return out
|
||||
|
||||
|
||||
def dip_trades_year() -> dict[int, int]:
|
||||
d = dip_market_gated("BTC", market_n=0)
|
||||
# yt[anno] = lista dei trade dell'anno -> il conteggio e' la lunghezza
|
||||
return {int(y): (len(v) if isinstance(v, (list, tuple)) else int(v)) for y, v in d["yt"].items()}
|
||||
|
||||
|
||||
def tr_rebalances_year(assets) -> dict[int, int]:
|
||||
"""Cambi di stato (entra/esce dal trend) per anno, sommati sul paniere TR01."""
|
||||
out: dict[int, int] = {}
|
||||
for a in assets:
|
||||
df = get_df(a, "4h"); c = df["close"].values
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
ef, es = ema(c, 20), ema(c, 100)
|
||||
sig = np.where(ef > es, 1.0, 0.0); sig[:100] = 0.0
|
||||
for i in range(1, len(c)):
|
||||
if sig[i] != sig[i - 1]:
|
||||
y = ts.iloc[i].year
|
||||
out[y] = out.get(y, 0) + 1
|
||||
return out
|
||||
|
||||
|
||||
def rot_rebalances_year() -> dict[int, int]:
|
||||
r = rot_improved(lookback=60, top_k=3, regime_n=100)
|
||||
return {int(y): int(n) for y, n in r["reb"].items()}
|
||||
|
||||
|
||||
def main():
|
||||
print("Costruzione equity e conteggi (puo' richiedere ~1 min)...\n")
|
||||
S = build_all_sleeves()
|
||||
fade = {k: v for k, v in S.items() if k.startswith("MR")}
|
||||
honest = {k: v for k, v in S.items() if not k.startswith("MR")}
|
||||
|
||||
# rendimenti giornalieri per Ret%/anno
|
||||
sleeve_ret = {k: v.pct_change().fillna(0.0) for k, v in S.items()}
|
||||
ports = {
|
||||
"FADE": port_returns(fade),
|
||||
"HONEST": port_returns(honest),
|
||||
"MASTEReq": port_returns(S),
|
||||
"MAST5050": (port_returns(fade) + port_returns(honest)) / 2,
|
||||
}
|
||||
|
||||
# ---- (A) RET% per anno ----
|
||||
cols_A = list(S) + list(ports)
|
||||
rety = {**{k: yearly_returns(v) for k, v in sleeve_ret.items()},
|
||||
**{k: yearly_returns(v) for k, v in ports.items()}}
|
||||
print("=" * 132)
|
||||
print(" (A) RET% NETTO PER ANNO — strategie singole e portafogli | leva 3x pos 15% fee 0.10% RT")
|
||||
print("=" * 132)
|
||||
print(f" {'Anno':>5s}" + "".join(f"{c.replace('_',''):>11s}" for c in cols_A))
|
||||
print(" " + "-" * 126)
|
||||
for y in YEARS:
|
||||
print(f" {y:>5d}" + "".join(f"{rety[c].get(y, 0):>+11.0f}" for c in cols_A))
|
||||
|
||||
# ---- (B) NUMERO TRADE per anno ----
|
||||
tcounts = {}
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for nm, (fn, params) in strats_for(asset).items():
|
||||
tcounts[f"{nm}_{asset}"] = fade_trades_year(asset, fn, params)
|
||||
tcounts["DIP01_BTC"] = dip_trades_year()
|
||||
tcounts["TR01_basket*"] = tr_rebalances_year(["BNB", "BTC", "DOGE", "SOL", "XRP"])
|
||||
tcounts["ROT02_rot*"] = rot_rebalances_year()
|
||||
cols_B = list(tcounts)
|
||||
print("\n" + "=" * 132)
|
||||
print(" (B) NUMERO TRADE PER ANNO — fade/DIP01 = ingressi; TR01/ROT02 (*) = ribilanciamenti")
|
||||
print("=" * 132)
|
||||
print(f" {'Anno':>5s}" + "".join(f"{c.replace('_',''):>13s}" for c in cols_B))
|
||||
print(" " + "-" * 126)
|
||||
for y in YEARS:
|
||||
print(f" {y:>5d}" + "".join(f"{tcounts[c].get(y, 0):>13d}" for c in cols_B))
|
||||
print(" " + "-" * 126)
|
||||
print(f" {'TOT':>5s}" + "".join(f"{sum(tcounts[c].values()):>13d}" for c in cols_B))
|
||||
|
||||
# ---- (C) riepilogo portafogli ----
|
||||
print("\n" + "=" * 92)
|
||||
print(f" (C) RIEPILOGO PORTAFOGLI | OOS da {OOS_DATE}")
|
||||
print("=" * 92)
|
||||
print(f" {'portafoglio':<14s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
|
||||
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
|
||||
print(" " + "-" * 74)
|
||||
for name, pr in ports.items():
|
||||
f, o = metrics(pr), metrics(pr, lo=SPLIT)
|
||||
print(f" {name:<14s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
|
||||
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||
print("\n MASTEReq (9 sleeve) = configurazione consigliata. (*) TR01/ROT02 = posizione")
|
||||
print(" continua: il conteggio e' il numero di ribilanciamenti/cambi di stato, non di trade discreti.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Report riassuntivo: tutte le strategie/famiglie per anno + analisi di integrazione.
|
||||
|
||||
Consolida in un solo posto:
|
||||
(A) RET% NETTO per anno per FAMIGLIA (FADE / HONEST / PAIRS / TSM01) e per i portafogli.
|
||||
(B) RET% NETTO per anno per ogni STRATEGIA singola (tutti gli sleeve).
|
||||
(C) INTEGRAZIONE: cosa succede al MASTER aggiungendo le nuove famiglie (pairs, TSM01).
|
||||
(D) Numeri SOBRI (worst-case) e raccomandazione operativa.
|
||||
|
||||
Famiglie:
|
||||
FADE (reversione intraday 1h, long/short, BTC/ETH): MR01, MR02, MR07
|
||||
HONEST (long-only multi-regime multi-crypto): DIP01, TR01, ROT02
|
||||
PAIRS (market-neutral spread reversion, config universale): 5 coppie
|
||||
TSM01 (TSMOM multi-orizzonte, diversificatore)
|
||||
|
||||
Tutto NETTO fee, leva 3x (vedi nota sobria leva 2x), finestra comune 2021-2026, OOS=ultimo 30%.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.combine_portfolio import (
|
||||
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
|
||||
)
|
||||
from scripts.analysis.honest_improve2 import _daily_equity, _norm
|
||||
from scripts.analysis.pairs_research import pairs_sim
|
||||
from scripts.analysis.tsmom_research import tsmom_sim
|
||||
from scripts.strategies.PR01_pairs_reversion import PAIRS
|
||||
|
||||
YEARS = sorted(set(IDX.year))
|
||||
|
||||
|
||||
def daily_from(eq_ts, eq_v):
|
||||
return _norm(_daily_equity(eq_ts, eq_v, IDX))
|
||||
|
||||
|
||||
def build_everything():
|
||||
S = build_all_sleeves() # 9 sleeve (FADE 6 + HONEST 3)
|
||||
pairs = {}
|
||||
for a, b, p in PAIRS:
|
||||
r = pairs_sim(a, b, **p)
|
||||
pairs[f"PR_{a}{b}"] = daily_from(r["eq_ts"], r["eq_v"])
|
||||
t = tsmom_sim()
|
||||
tsm = {"TSM01": daily_from(t["eq_ts"], t["eq_v"])}
|
||||
return S, pairs, tsm
|
||||
|
||||
|
||||
def yrow(label, dr):
|
||||
yr = yearly_returns(dr)
|
||||
return f" {label:<14s}" + "".join(f"{yr.get(y, 0):>+9.0f}" for y in YEARS)
|
||||
|
||||
|
||||
def metric_block(label, dr):
|
||||
f, o = metrics(dr), metrics(dr, lo=SPLIT)
|
||||
return (f" {label:<16s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
|
||||
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||
|
||||
|
||||
def main():
|
||||
print("Costruzione (puo' richiedere ~1-2 min)...\n")
|
||||
S, pairs, tsm = build_everything()
|
||||
fade = {k: v for k, v in S.items() if k.startswith("MR")}
|
||||
honest = {k: v for k, v in S.items() if not k.startswith("MR")}
|
||||
|
||||
fam = {
|
||||
"FADE": port_returns(fade),
|
||||
"HONEST": port_returns(honest),
|
||||
"PAIRS": port_returns(pairs),
|
||||
"TSM01": tsm["TSM01"].pct_change().fillna(0.0),
|
||||
}
|
||||
master9 = port_returns(S)
|
||||
master_p = port_returns({**S, **pairs})
|
||||
master_x = port_returns({**S, **pairs, **tsm})
|
||||
|
||||
# ---------- (A) per anno, per FAMIGLIA + portafogli ----------
|
||||
print("=" * 110)
|
||||
print(" (A) RET% NETTO PER ANNO — per FAMIGLIA e per PORTAFOGLIO | leva 3x, fee netta")
|
||||
print("=" * 110)
|
||||
print(f" {'':<14s}" + "".join(f"{y:>9d}" for y in YEARS))
|
||||
print(" " + "-" * 104)
|
||||
for k, dr in fam.items():
|
||||
print(yrow(k, dr))
|
||||
print(" " + "-" * 104)
|
||||
print(yrow("MASTER-9", master9))
|
||||
print(yrow("MASTER+pairs", master_p))
|
||||
print(yrow("MASTER-esteso", master_x))
|
||||
|
||||
# ---------- (B) per anno, per STRATEGIA singola ----------
|
||||
print("\n" + "=" * 130)
|
||||
print(" (B) RET% NETTO PER ANNO — per STRATEGIA singola (tutti gli sleeve)")
|
||||
print("=" * 130)
|
||||
allsl = {**S, **pairs, **tsm}
|
||||
cols = list(allsl)
|
||||
print(f" {'Anno':>5s}" + "".join(f"{c.replace('_',''):>11s}" for c in cols))
|
||||
print(" " + "-" * 124)
|
||||
yr_each = {k: yearly_returns(v.pct_change().fillna(0.0)) for k, v in allsl.items()}
|
||||
for y in YEARS:
|
||||
print(f" {y:>5d}" + "".join(f"{yr_each[c].get(y, 0):>+11.0f}" for c in cols))
|
||||
|
||||
# ---------- (C) integrazione ----------
|
||||
print("\n" + "=" * 96)
|
||||
print(f" (C) INTEGRAZIONE delle nuove famiglie nel MASTER | OOS da {OOS_DATE} | equal-weight daily")
|
||||
print("=" * 96)
|
||||
print(f" {'portafoglio':<16s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
|
||||
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
|
||||
print(" " + "-" * 80)
|
||||
print(metric_block("MASTER-9", master9))
|
||||
print(metric_block("+pairs", master_p))
|
||||
print(metric_block("+TSM01", port_returns({**S, **tsm})))
|
||||
print(metric_block("MASTER-esteso", master_x))
|
||||
# correlazione media nuove vs master-9
|
||||
dr_all = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in {**S, **pairs, **tsm}.items()})
|
||||
corr = dr_all.corr(); old = list(S)
|
||||
print(" " + "-" * 80)
|
||||
for k in list(pairs) + list(tsm):
|
||||
print(f" corr {k:<11s} vs MASTER-9 = {corr.loc[k, old].mean():+.2f}")
|
||||
|
||||
# ---------- (D) numeri sobri ----------
|
||||
print("\n" + "=" * 96)
|
||||
print(" (D) NUMERI SOBRI / RACCOMANDAZIONE (anti-overfit)")
|
||||
print("=" * 96)
|
||||
print(" - L'OOS singolo (2024-25) e' regime calmo -> Sharpe/DD OOS ottimistici ~50%.")
|
||||
print(" - Numeri onesti del MASTER-esteso: worst-DD 90g ~6%, Sharpe atteso ~5, ogni anno positivo dal 2021.")
|
||||
print(" - Regge leva 2x + slippage doppio (CAGR ~36%, Sharpe ~5).")
|
||||
print(" - Rischio concentrato sui PAIRS (~57%) -> cap allocazione pairs ~30-35%.")
|
||||
print(" - I pairs sono a 2 gambe (long/short): il worker live va esteso prima del trading reale.")
|
||||
print(" - CONFIG RACCOMANDATA: MASTER-esteso, equal-weight, leva 2x, cap pairs 30-35%.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Gestione del rischio sulle fade (MR01/MR02/MR03/MR07): alzare Acc, ridurre DD.
|
||||
|
||||
Due analisi, ognuna misurata FULL e OOS (ultimo 30%) per non illudersi:
|
||||
|
||||
(A) SCREENING LEVE — confronta su ogni strategia le leve di rischio:
|
||||
- vol-target sizing (size ~ 1/distanza-SL) -> SCARTATA (peggiora)
|
||||
- skip alta volatilita' (ATR% in coda alta) -> SCARTATA (peggiora)
|
||||
- filtro trend (|close-EMA200|/ATR oltre soglia) -> ADOTTATA (Acc+ DD-)
|
||||
- combinazione di tutte
|
||||
|
||||
(B) FILTRO TREND + PORTAFOGLIO:
|
||||
- sweep della soglia trend (assoluta in ATR, regola unica = no overfit)
|
||||
- portafoglio equipesato su sotto-conti indipendenti: curve poco correlate
|
||||
-> DD aggregato << DD del singolo sleeve (vera leva anti-drawdown)
|
||||
|
||||
Engine fedele: ingresso close[i], exit TP/SL intrabar (high/low) o time-limit,
|
||||
non-overlap, capitale composto. Numeri NETTI fee 0.10% RT, leva 3x.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from scripts.analysis.strategy_research import bollinger_fade, atr
|
||||
from scripts.analysis.strategy_research_v2 import donchian_fade, return_reversal
|
||||
|
||||
FEE_RT, LEV, POS, INIT, OOS_FRAC = 0.001, 3.0, 0.15, 1000.0, 0.30
|
||||
|
||||
# config base di ogni strategia (come strategies.yml).
|
||||
# NB: MR03 keltner_fade spostata in scripts/waste/ (fade piu' debole, ridondante
|
||||
# con MR01); la funzione keltner_fade resta in strategy_research_v2 come record.
|
||||
STRATS = {
|
||||
"MR01": (bollinger_fade, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24)),
|
||||
"MR02": (donchian_fade, dict(n=20, sl_atr=2.0, max_bars=24)),
|
||||
"MR07": (return_reversal,dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
|
||||
}
|
||||
STRATS_ETH = dict(STRATS)
|
||||
|
||||
|
||||
def strats_for(asset: str) -> dict:
|
||||
return STRATS_ETH if asset == "ETH" else STRATS
|
||||
|
||||
|
||||
# ============================ (A) SCREENING LEVE ============================
|
||||
def add_context(ents, df, ema_long=200):
|
||||
"""Aggiunge a ogni entry: sl_dist, atr_pct, trend_dist (|close-EMA|/ATR)."""
|
||||
c = df["close"].values
|
||||
a = atr(df, 14)
|
||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
||||
apct = a / c
|
||||
for e in ents:
|
||||
i = e["i"]
|
||||
e["sl_dist"] = abs(c[i] - e["sl"]) / c[i]
|
||||
e["atr_pct"] = apct[i]
|
||||
e["trend_dist"] = abs(c[i] - el[i]) / a[i] if a[i] else 0.0
|
||||
return ents
|
||||
|
||||
|
||||
def simulate(ents, df, fee_rt=FEE_RT, lev=LEV, split=-1,
|
||||
sizer=None, vol_skip=None, trend_skip=None, max_size=0.30):
|
||||
"""sizer: funzione(entry)->frazione capitale; default POS fisso.
|
||||
vol_skip: soglia atr_pct sopra cui salto. trend_skip: soglia trend_dist sopra cui salto."""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
cap = peak = INIT
|
||||
dd = 0.0; last = -1; trd = wins = 0
|
||||
fee = fee_rt * lev
|
||||
yearly = {}; rets = []
|
||||
for e in ents:
|
||||
i, d = e["i"], e["d"]
|
||||
if i <= last or i + 1 >= n or i < split:
|
||||
continue
|
||||
if vol_skip is not None and e["atr_pct"] > vol_skip:
|
||||
continue
|
||||
if trend_skip is not None and e["trend_dist"] > trend_skip:
|
||||
continue
|
||||
entry = c[i]; tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1)
|
||||
for k in range(1, mb + 1):
|
||||
j = i + k
|
||||
if j >= n:
|
||||
exit_p = c[n - 1]; break
|
||||
hs = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hs: exit_p = sl; break
|
||||
if ht: exit_p = tp; break
|
||||
if k == mb: exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * d * lev - fee
|
||||
size = POS if sizer is None else min(sizer(e), max_size)
|
||||
cap = max(cap + cap * size * ret, 10.0)
|
||||
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
|
||||
trd += 1; wins += ret > 0; last = j; rets.append(ret * size)
|
||||
y = ts.iloc[i].year; yearly[y] = yearly.get(y, 0.0) + ret * size * INIT
|
||||
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0
|
||||
return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0,
|
||||
ret=(cap / INIT - 1) * 100, dd=dd * 100, yearly=yearly, sharpe=sharpe)
|
||||
|
||||
|
||||
def vol_target_sizer(target=0.015):
|
||||
"""size t.c. rischio (size*lev*sl_dist) ~ target; piu' largo lo stop, meno size."""
|
||||
return lambda e: target / (LEV * max(e["sl_dist"], 1e-4))
|
||||
|
||||
|
||||
def _line(label, full, oos):
|
||||
print(f" {label:<28s}{full['trades']:>6d}{full['acc']:>7.1f}{full['ret']:>+10.0f}{full['dd']:>7.1f}{full['sharpe']:>7.2f}"
|
||||
f" | {oos['trades']:>5d}{oos['acc']:>7.1f}{oos['ret']:>+9.0f}{oos['dd']:>7.1f}{oos['sharpe']:>7.2f}")
|
||||
|
||||
|
||||
def screen_levers():
|
||||
print("=" * 110)
|
||||
print(" (A) SCREENING LEVE — vol-target / vol-skip / filtro-trend | NETTO fee 0.10% RT, leva 3x")
|
||||
print("=" * 110)
|
||||
for asset in ["BTC", "ETH"]:
|
||||
df = load_data(asset, "1h")
|
||||
split = int(len(df) * (1 - OOS_FRAC))
|
||||
print(f"\n {asset} 1h")
|
||||
print(f" {'config':<28s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>10s}{'DD%':>7s}{'Shrp':>7s}"
|
||||
f" | {'oTrd':>5s}{'oAcc':>7s}{'oRet':>9s}{'oDD':>7s}{'oShrp':>7s}")
|
||||
print(" " + "-" * 106)
|
||||
for nm, (fn, params) in strats_for(asset).items():
|
||||
ents = add_context(fn(df, **params), df)
|
||||
p85 = float(np.quantile([e["atr_pct"] for e in ents], 0.85))
|
||||
t90 = float(np.quantile([e["trend_dist"] for e in ents], 0.90))
|
||||
_line(f"{nm} base", simulate(ents, df), simulate(ents, df, split=split))
|
||||
_line(f"{nm} +volTarget", simulate(ents, df, sizer=vol_target_sizer()),
|
||||
simulate(ents, df, split=split, sizer=vol_target_sizer()))
|
||||
_line(f"{nm} +volSkip(p85)", simulate(ents, df, vol_skip=p85),
|
||||
simulate(ents, df, split=split, vol_skip=p85))
|
||||
_line(f"{nm} +trendSkip(p90)", simulate(ents, df, trend_skip=t90),
|
||||
simulate(ents, df, split=split, trend_skip=t90))
|
||||
_line(f"{nm} +ALL", simulate(ents, df, sizer=vol_target_sizer(), vol_skip=p85, trend_skip=t90),
|
||||
simulate(ents, df, split=split, sizer=vol_target_sizer(), vol_skip=p85, trend_skip=t90))
|
||||
print(" " + "-" * 106)
|
||||
print("\n Esito: vol-target e vol-skip PEGGIORANO; il filtro trend e' l'unica leva utile.")
|
||||
|
||||
|
||||
# ===================== (B) FILTRO TREND + PORTAFOGLIO =====================
|
||||
def build_trades(ents, df, lev=LEV, fee_rt=FEE_RT, trend_max=None, ema_long=200):
|
||||
"""Lista trade non-overlap: (entry_idx, exit_idx, ret_netto). Filtro trend opzionale."""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c); a = atr(df, 14)
|
||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
||||
fee = fee_rt * lev
|
||||
out = []; last = -1
|
||||
for e in ents:
|
||||
i, d = e["i"], e["d"]
|
||||
if i <= last or i + 1 >= n:
|
||||
continue
|
||||
if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max:
|
||||
continue
|
||||
entry = c[i]; tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1)
|
||||
for k in range(1, mb + 1):
|
||||
j = i + k
|
||||
if j >= n:
|
||||
exit_p = c[n - 1]; break
|
||||
hs = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hs: exit_p = sl; break
|
||||
if ht: exit_p = tp; break
|
||||
if k == mb: exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * d * lev - fee
|
||||
out.append((i, j, ret)); last = j
|
||||
return out
|
||||
|
||||
|
||||
def metrics_single(trades, pos=POS, split=-1):
|
||||
cap = peak = INIT; dd = 0.0; trd = wins = 0; rets = []
|
||||
for i, j, ret in trades:
|
||||
if i < split:
|
||||
continue
|
||||
cap = max(cap + cap * pos * ret, 10.0)
|
||||
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
|
||||
trd += 1; wins += ret > 0; rets.append(ret * pos)
|
||||
sh = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0
|
||||
return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0,
|
||||
ret=(cap / INIT - 1) * 100, dd=dd * 100, sharpe=sh)
|
||||
|
||||
|
||||
def sleeve_equity(trades, n_bars, pos=POS, split=-1):
|
||||
"""Equity di uno sleeve su sotto-conto indipendente (capitale INIT, pos fissa)."""
|
||||
eq = np.full(n_bars, INIT, dtype=float)
|
||||
cap = INIT
|
||||
for i, j, ret in sorted(trades, key=lambda t: t[1]):
|
||||
if i < split:
|
||||
continue
|
||||
cap = max(cap + cap * pos * ret, 10.0)
|
||||
eq[j:] = cap
|
||||
return eq
|
||||
|
||||
|
||||
def metrics_portfolio(strat_trades, n_bars, pos=POS, split=-1):
|
||||
"""Portafoglio equipesato: media di N sotto-conti indipendenti. DD sull'aggregata."""
|
||||
sleeves = [sleeve_equity(tr, n_bars, pos=pos, split=split) for tr in strat_trades.values()]
|
||||
agg = np.mean(sleeves, axis=0)
|
||||
agg = agg[max(split, 0):]
|
||||
peak = np.maximum.accumulate(agg)
|
||||
dd = float(np.max((peak - agg) / peak) * 100)
|
||||
trd = sum(1 for tr in strat_trades.values() for i, _, _ in tr if i >= split)
|
||||
wins = sum(1 for tr in strat_trades.values() for i, _, r in tr if i >= split and r > 0)
|
||||
return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0, ret=(agg[-1] / INIT - 1) * 100, dd=dd)
|
||||
|
||||
|
||||
def trend_and_portfolio():
|
||||
# --- sweep soglia trend ---
|
||||
print("\n" + "=" * 104)
|
||||
print(" (B1) FILTRO TREND |close-EMA200|/ATR > soglia -> SALTA | NETTO fee 0.10% RT, leva 3x")
|
||||
print("=" * 104)
|
||||
print(f" {'Strat/Asset':<14s}{'soglia':>8s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>9s}{'DD%':>7s}"
|
||||
f" | {'oAcc':>6s}{'oRet':>9s}{'oDD':>7s}{'oShrp':>7s}")
|
||||
print(" " + "-" * 100)
|
||||
for asset in ["BTC", "ETH"]:
|
||||
df = load_data(asset, "1h"); split = int(len(df) * (1 - OOS_FRAC))
|
||||
for nm, (fn, params) in strats_for(asset).items():
|
||||
ents = fn(df, **params)
|
||||
for thr in [None, 4.0, 3.0, 2.5, 2.0]:
|
||||
tr = build_trades(ents, df, trend_max=thr)
|
||||
f = metrics_single(tr); o = metrics_single(tr, split=split)
|
||||
lab = "base" if thr is None else f"{thr}ATR"
|
||||
print(f" {nm+' '+asset:<14s}{lab:>8s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+9.0f}{f['dd']:>7.1f}"
|
||||
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||
print(" " + "-" * 100)
|
||||
|
||||
# --- portafoglio equipesato (filtro trend 3.0 ATR) ---
|
||||
print("\n" + "=" * 104)
|
||||
print(" (B2) PORTAFOGLIO equipesato: N sotto-conti indipendenti (pos 0.15, filtro trend 3.0 ATR)")
|
||||
print("=" * 104)
|
||||
print(f" {'Universo':<26s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>10s}{'DD%':>7s}"
|
||||
f" | {'oAcc':>6s}{'oRet':>9s}{'oDD':>7s}")
|
||||
print(" " + "-" * 100)
|
||||
all_trades = {}
|
||||
for asset in ["BTC", "ETH"]:
|
||||
df = load_data(asset, "1h"); split = int(len(df) * (1 - OOS_FRAC)); n = len(df)
|
||||
st = {f"{nm}_{asset}": build_trades(fn(df, **p), df, trend_max=3.0) for nm, (fn, p) in strats_for(asset).items()}
|
||||
all_trades.update(st)
|
||||
f = metrics_portfolio(st, n); o = metrics_portfolio(st, n, split=split)
|
||||
print(f" {'Portafoglio '+asset+' (4 strat)':<26s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+10.0f}{f['dd']:>7.1f}"
|
||||
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}")
|
||||
df0 = load_data("BTC", "1h"); split0 = int(len(df0) * (1 - OOS_FRAC))
|
||||
f = metrics_portfolio(all_trades, len(df0)); o = metrics_portfolio(all_trades, len(df0), split=split0)
|
||||
print(" " + "-" * 100)
|
||||
print(f" {'GLOBALE BTC+ETH (8 sleeve)':<26s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+10.0f}{f['dd']:>7.1f}"
|
||||
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}")
|
||||
print("\n Curve poco correlate => DD aggregato molto piu' basso del singolo sleeve.")
|
||||
|
||||
|
||||
def main():
|
||||
screen_levers()
|
||||
trend_and_portfolio()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Ricerca strategie fee-aware, OOS, oltre la famiglia squeeze.
|
||||
|
||||
Lezioni apprese (squeeze breakout = nessun edge):
|
||||
- le FEE sono vincolo di prim'ordine -> default fee realistica Deribit 0.10% RT
|
||||
(taker 0.05%/lato, maker ~0%); poche operazioni meglio di molte
|
||||
- i breakout RIENTRANO -> si esplora mean-reversion, non continuation
|
||||
- ogni numero e' NETTO dopo fee+leva, su finestra held-out + per anno
|
||||
|
||||
Engine realistico: ingresso a close[i] (eseguibile), uscita su TP/SL intrabar
|
||||
(high/low) o time-limit, una posizione per volta (non-overlap), capitale composto.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_RT = 0.001 # Deribit perp realistico: taker 0.05%/lato
|
||||
LEV = 3.0
|
||||
POS = 0.15
|
||||
OOS_FRAC = 0.30
|
||||
BARS_PER_YEAR = {"15m": 35040, "1h": 8760, "4h": 2190, "1d": 365}
|
||||
|
||||
|
||||
# ----------------------------- dati -----------------------------
|
||||
def get_df(asset: str, tf: str) -> pd.DataFrame:
|
||||
"""tf nativo (15m,1h) o resample da 1h (4h,1d)."""
|
||||
if tf in ("15m", "1h"):
|
||||
return load_data(asset, tf).reset_index(drop=True)
|
||||
base = load_data(asset, "1h").copy()
|
||||
base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
|
||||
base = base.set_index("dt")
|
||||
rule = {"4h": "4h", "1d": "1D"}[tf]
|
||||
agg = base.resample(rule).agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
|
||||
).dropna()
|
||||
agg["timestamp"] = agg.index.asi8 // 10**6
|
||||
return agg.reset_index(drop=True)
|
||||
|
||||
|
||||
# --------------------------- indicatori ---------------------------
|
||||
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
pc = np.roll(c, 1); pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
return pd.Series(tr).rolling(n).mean().values
|
||||
|
||||
|
||||
def rsi(close: np.ndarray, n: int = 14) -> np.ndarray:
|
||||
d = np.diff(close, prepend=close[0])
|
||||
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
|
||||
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
|
||||
rs = up / dn.replace(0, np.nan)
|
||||
return (100 - 100 / (1 + rs)).values
|
||||
|
||||
|
||||
# --------------------------- engine ---------------------------
|
||||
def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT,
|
||||
lev: float = LEV, pos: float = POS) -> dict:
|
||||
"""entries: dict con i(idx), d(+1/-1), tp(prezzo), sl(prezzo), max_bars."""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c)
|
||||
cap = peak = 1000.0
|
||||
max_dd = 0.0
|
||||
fee = fee_rt * lev
|
||||
trades = wins = 0
|
||||
last_exit = -1
|
||||
bars_in = 0
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
yearly: dict[int, float] = {}
|
||||
|
||||
for e in entries:
|
||||
i, d = e["i"], e["d"]
|
||||
if i <= last_exit or i + 1 >= n:
|
||||
continue
|
||||
entry = c[i]
|
||||
tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]
|
||||
for k in range(1, mb + 1):
|
||||
j = i + k
|
||||
if j >= n:
|
||||
exit_p = c[n - 1]; break
|
||||
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hit_sl: # conservativo: SL prima del TP nello stesso bar
|
||||
exit_p = sl; break
|
||||
if hit_tp:
|
||||
exit_p = tp; break
|
||||
if k == mb:
|
||||
exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * d * lev - fee
|
||||
cb = cap
|
||||
cap = max(cb + cb * pos * ret, 10.0)
|
||||
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
|
||||
trades += 1; wins += ret > 0; bars_in += min(mb, j - i)
|
||||
last_exit = j
|
||||
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
|
||||
return {
|
||||
"trades": trades,
|
||||
"win": wins / trades * 100 if trades else 0.0,
|
||||
"ret": (cap / 1000 - 1) * 100,
|
||||
"dd": max_dd * 100,
|
||||
"yearly": yearly,
|
||||
"exposure": bars_in / n * 100,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------- strategie ---------------------------
|
||||
def bollinger_fade(df, n=20, k=2.0, sl_atr=2.0, max_bars=24):
|
||||
"""Mean-reversion: fada il close oltre la banda, TP alla media, SL = k_atr*ATR."""
|
||||
c = df["close"].values
|
||||
ma = pd.Series(c).rolling(n).mean().values
|
||||
sd = pd.Series(c).rolling(n).std().values
|
||||
a = atr(df, 14)
|
||||
up, lo = ma + k * sd, ma - k * sd
|
||||
ents = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]: # appena sotto la banda
|
||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def rsi_revert(df, n=14, lo=30, hi=70, sl_atr=2.0, max_bars=24, ma_n=20):
|
||||
"""RSI mean-reversion: long su RSI<lo che risale, TP alla media mobile."""
|
||||
c = df["close"].values
|
||||
r = rsi(c, n)
|
||||
ma = pd.Series(c).rolling(ma_n).mean().values
|
||||
a = atr(df, 14)
|
||||
ents = []
|
||||
for i in range(max(n, ma_n) + 1, len(c)):
|
||||
if np.isnan(r[i]) or np.isnan(ma[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if r[i - 1] < lo <= r[i]:
|
||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif r[i - 1] > hi >= r[i]:
|
||||
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def donchian_trend(df, n=20, sl_atr=2.0, tp_atr=6.0, max_bars=120):
|
||||
"""Trend-following: breakout canale Donchian, TP/SL in multipli di ATR."""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
hh = pd.Series(h).rolling(n).max().shift(1).values
|
||||
ll = pd.Series(l).rolling(n).min().shift(1).values
|
||||
a = atr(df, 14)
|
||||
ents = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(hh[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if c[i] > hh[i]:
|
||||
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif c[i] < ll[i]:
|
||||
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
STRATS = {
|
||||
"BOLL_fade k2 m24": (bollinger_fade, dict(n=20, k=2.0, sl_atr=2.0, max_bars=24)),
|
||||
"BOLL_fade k2.5 m24": (bollinger_fade, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)),
|
||||
"RSI_revert 30/70": (rsi_revert, dict(n=14, lo=30, hi=70, sl_atr=2.0, max_bars=24)),
|
||||
"RSI_revert 25/75": (rsi_revert, dict(n=14, lo=25, hi=75, sl_atr=2.0, max_bars=24)),
|
||||
"DONCH_trend n20": (donchian_trend, dict(n=20, sl_atr=2.0, tp_atr=6.0, max_bars=120)),
|
||||
"DONCH_trend n50": (donchian_trend, dict(n=50, sl_atr=2.0, tp_atr=8.0, max_bars=200)),
|
||||
}
|
||||
|
||||
|
||||
def deep_dive():
|
||||
print("\n" + "#" * 120)
|
||||
print(" APPROFONDIMENTO BOLLINGER FADE (mean-reversion) — l'unica famiglia con edge netto")
|
||||
print("#" * 120)
|
||||
|
||||
cases = [("BTC", "1h"), ("ETH", "1h"), ("BTC", "4h"), ("ETH", "4h")]
|
||||
base = dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)
|
||||
|
||||
# --- per anno (config base k2.5/n20) ---
|
||||
print(f"\n [1] PnL NETTO per anno — n=20 k=2.5 sl=2ATR | fee {FEE_RT*100:.2f}% RT")
|
||||
all_years = sorted({y for a, tf in cases for y in simulate(bollinger_fade(get_df(a, tf), **base), get_df(a, tf))["yearly"]})
|
||||
print(f" {'Asset/TF':<10s}" + "".join(f"{y:>8d}" for y in all_years) + f"{'TOT%':>9s}{'DD%':>6s}")
|
||||
for a, tf in cases:
|
||||
df = get_df(a, tf)
|
||||
r = simulate(bollinger_fade(df, **base), df)
|
||||
row = "".join(f"{r['yearly'].get(y, 0):>+8.0f}" for y in all_years)
|
||||
print(f" {a+' '+tf:<10s}{row}{r['ret']:>+9.0f}{r['dd']:>6.0f}")
|
||||
|
||||
# --- sensibilita' fee ---
|
||||
print(f"\n [2] SENSIBILITA' FEE — Ret% FULL / OOS (n=20 k=2.5)")
|
||||
fees = [0.0, 0.0005, 0.001, 0.002]
|
||||
print(f" {'Asset/TF':<10s}" + "".join(f"{f'{f*100:.2f}%RT':>22s}" for f in fees))
|
||||
print(f" {'':<10s}" + "".join(f"{'full':>11s}{'oos':>11s}" for _ in fees))
|
||||
for a, tf in cases:
|
||||
df = get_df(a, tf)
|
||||
ents = bollinger_fade(df, **base)
|
||||
split = int(len(df) * (1 - OOS_FRAC))
|
||||
oents = [e for e in ents if e["i"] >= split]
|
||||
cells = ""
|
||||
for f in fees:
|
||||
cells += f"{simulate(ents, df, fee_rt=f)['ret']:>+11.0f}{simulate(oents, df, fee_rt=f)['ret']:>+11.0f}"
|
||||
print(f" {a+' '+tf:<10s}{cells}")
|
||||
|
||||
# --- griglia parametri (robustezza) su BTC/ETH 1h ---
|
||||
print(f"\n [3] GRIGLIA PARAMETRI — Ret%OOS (DD%) | fee {FEE_RT*100:.2f}% RT, deve essere stabile")
|
||||
for a in ["BTC", "ETH"]:
|
||||
df = get_df(a, "1h")
|
||||
split = int(len(df) * (1 - OOS_FRAC))
|
||||
print(f"\n {a} 1h " + "".join(f"{f'k={k}':>16s}" for k in [2.0, 2.5, 3.0]))
|
||||
for n in [14, 20, 30, 50]:
|
||||
cells = ""
|
||||
for k in [2.0, 2.5, 3.0]:
|
||||
ents = [e for e in bollinger_fade(df, n=n, k=k, sl_atr=2.0, max_bars=24) if e["i"] >= split]
|
||||
r = simulate(ents, df)
|
||||
cell = f"{r['ret']:+.0f}({r['dd']:.0f})"
|
||||
cells += f"{cell:>16s}"
|
||||
print(f" n={n:<4d}{cells}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 120)
|
||||
print(f" RICERCA STRATEGIE — NETTO dopo fee {FEE_RT*100:.2f}% RT | leva {LEV:.0f}x | pos {POS*100:.0f}% "
|
||||
f"| OOS = ultimo {int(OOS_FRAC*100)}%")
|
||||
print("=" * 120)
|
||||
print(f" {'Strategia':<20s}{'Asset':>5s}{'TF':>5s}{'Trd':>6s}{'Tr/yr':>7s}{'Win%':>7s}"
|
||||
f"{'Ret%FULL':>10s}{'Ret%OOS':>10s}{'DD%':>7s}{'Exp%':>7s}{'AnniPos':>9s}")
|
||||
print(" " + "-" * 116)
|
||||
|
||||
for label, (fn, params) in STRATS.items():
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["1h", "4h"]:
|
||||
df = get_df(asset, tf)
|
||||
ents = fn(df, **params)
|
||||
full = simulate(ents, df)
|
||||
split = int(len(df) * (1 - OOS_FRAC))
|
||||
oos = simulate([e for e in ents if e["i"] >= split], df)
|
||||
yrs = full["yearly"]
|
||||
pos_yrs = sum(1 for v in yrs.values() if v > 0)
|
||||
tr_yr = full["trades"] / max(len(yrs), 1)
|
||||
flag = " <<<" if oos["ret"] > 0 and full["ret"] > 0 and pos_yrs >= max(len(yrs) - 1, 1) else ""
|
||||
print(f" {label:<20s}{asset:>5s}{tf:>5s}{full['trades']:>6d}{tr_yr:>7.0f}{full['win']:>7.1f}"
|
||||
f"{full['ret']:>+10.1f}{oos['ret']:>+10.1f}{full['dd']:>7.1f}{full['exposure']:>7.1f}"
|
||||
f"{f'{pos_yrs}/{len(yrs)}':>9s}{flag}")
|
||||
print(" " + "-" * 116)
|
||||
print(" Ret%FULL/OOS = ritorno NETTO composto su €1000. AnniPos = anni con PnL netto>0.")
|
||||
print(" <<< = positivo full+OOS e robusto (quasi tutti gli anni positivi).")
|
||||
deep_dive()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Ricerca v2 — nuove strategie oltre MR01, stessa metodologia fee-aware OOS.
|
||||
|
||||
Lezioni ereditate (vedi strategy_research.py / oos_validation.py):
|
||||
- mean-reversion ha edge, continuation/trend NO (i breakout rientrano)
|
||||
- fee = vincolo di prim'ordine -> default Deribit 0.10% RT, poche operazioni meglio
|
||||
- ingresso ESEGUIBILE a close[i] (mai look-ahead con direzione da barra i)
|
||||
- ogni numero NETTO dopo fee+leva, su finestra held-out (OOS=ultimo 30%) + per anno
|
||||
|
||||
Nuovi candidati (tutti fade/mean-reversion con ingresso onesto):
|
||||
MR02 donchian_fade - fade rottura canale Donchian (opposto del trend che muore)
|
||||
MR03 keltner_fade - fade canale Keltner (ATR), TP alla EMA media
|
||||
MR04 zscore_revert - fade deviazione z-score estrema, TP alla media
|
||||
MR05 boll_fade_adx - Bollinger fade con filtro regime ADX (solo mercato laterale)
|
||||
|
||||
Engine identico a strategy_research.simulate (ingresso close[i], exit TP/SL intrabar
|
||||
high/low o time-limit, non-overlap, capitale composto).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# riusa engine, dati e indicatori gia' validati
|
||||
from scripts.analysis.strategy_research import (
|
||||
FEE_RT, LEV, POS, OOS_FRAC, get_df, atr, rsi, simulate,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------- indicatori extra ---------------------------
|
||||
def ema(x: np.ndarray, n: int) -> np.ndarray:
|
||||
return pd.Series(x).ewm(span=n, adjust=False).mean().values
|
||||
|
||||
|
||||
def adx(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||
"""Average Directional Index: misura la forza del trend (alto=trend, basso=range)."""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
up = h - np.roll(h, 1)
|
||||
dn = np.roll(l, 1) - l
|
||||
up[0] = dn[0] = 0.0
|
||||
plus_dm = np.where((up > dn) & (up > 0), up, 0.0)
|
||||
minus_dm = np.where((dn > up) & (dn > 0), dn, 0.0)
|
||||
pc = np.roll(c, 1); pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
atr_n = pd.Series(tr).ewm(alpha=1/n, adjust=False).mean().values
|
||||
pdi = 100 * pd.Series(plus_dm).ewm(alpha=1/n, adjust=False).mean().values / np.where(atr_n == 0, np.nan, atr_n)
|
||||
mdi = 100 * pd.Series(minus_dm).ewm(alpha=1/n, adjust=False).mean().values / np.where(atr_n == 0, np.nan, atr_n)
|
||||
dx = 100 * np.abs(pdi - mdi) / np.where((pdi + mdi) == 0, np.nan, pdi + mdi)
|
||||
return pd.Series(dx).ewm(alpha=1/n, adjust=False).mean().values
|
||||
|
||||
|
||||
# --------------------------- strategie nuove ---------------------------
|
||||
def donchian_fade(df, n=20, sl_atr=2.0, max_bars=24):
|
||||
"""MR02 — fade rottura canale Donchian: rompe sopra max-N => short verso il mid.
|
||||
|
||||
Coerente con 'i breakout rientrano': l'opposto di donchian_trend (che fallisce).
|
||||
Ingresso a close[i] sulla barra che chiude oltre il canale precedente.
|
||||
TP al centro del canale, SL = sl_atr*ATR oltre l'estremo.
|
||||
"""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
hh = pd.Series(h).rolling(n).max().shift(1).values
|
||||
ll = pd.Series(l).rolling(n).min().shift(1).values
|
||||
a = atr(df, 14)
|
||||
ents = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(hh[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
mid = (hh[i] + ll[i]) / 2.0
|
||||
if c[i] > hh[i] and c[i - 1] <= hh[i - 1]: # rottura rialzista => fade short
|
||||
ents.append({"i": i, "d": -1, "tp": mid, "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
elif c[i] < ll[i] and c[i - 1] >= ll[i - 1]: # rottura ribassista => fade long
|
||||
ents.append({"i": i, "d": 1, "tp": mid, "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def keltner_fade(df, n=20, k=2.0, sl_atr=2.0, max_bars=24):
|
||||
"""MR03 — fade canale Keltner (EMA +/- k*ATR), TP alla EMA media.
|
||||
|
||||
Come Bollinger ma banda basata su ATR (volatilita' di range) invece che std:
|
||||
reagisce diversamente ai gap. Ingresso quando close esce dalla banda.
|
||||
"""
|
||||
c = df["close"].values
|
||||
e = ema(c, n)
|
||||
a = atr(df, n)
|
||||
up, lo = e + k * a, e - k * a
|
||||
ents = []
|
||||
for i in range(n + 1, len(c)):
|
||||
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||
ents.append({"i": i, "d": 1, "tp": e[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||
ents.append({"i": i, "d": -1, "tp": e[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def zscore_revert(df, n=50, z=2.0, sl_atr=2.5, max_bars=24):
|
||||
"""MR04 — fade deviazione z-score estrema dalla media, TP alla media.
|
||||
|
||||
z = (close-ma)/std. Entra quando |z| supera la soglia (close fuori); chiude
|
||||
quando torna alla media. Banda di Bollinger riparametrizzata in z (equivalente
|
||||
a k=z) ma con SL piu' largo e finestra lunga: poche operazioni, alta selettivita'.
|
||||
"""
|
||||
c = df["close"].values
|
||||
ma = pd.Series(c).rolling(n).mean().values
|
||||
sd = pd.Series(c).rolling(n).std().values
|
||||
a = atr(df, 14)
|
||||
ents = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(ma[i]) or sd[i] == 0 or np.isnan(a[i]):
|
||||
continue
|
||||
zi = (c[i] - ma[i]) / sd[i]
|
||||
zp = (c[i - 1] - ma[i - 1]) / sd[i - 1] if sd[i - 1] else 0.0
|
||||
if zi <= -z and zp > -z:
|
||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif zi >= z and zp < z:
|
||||
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def boll_fade_adx(df, n=50, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25.0):
|
||||
"""MR05 — Bollinger fade SOLO in regime laterale (ADX < adx_max).
|
||||
|
||||
Il fade soffre quando c'e' trend forte (il prezzo continua oltre la banda).
|
||||
Filtro ADX: opera solo quando la forza del trend e' bassa -> meno trade, edge piu' pulito.
|
||||
"""
|
||||
c = df["close"].values
|
||||
ma = pd.Series(c).rolling(n).mean().values
|
||||
sd = pd.Series(c).rolling(n).std().values
|
||||
a = atr(df, 14)
|
||||
ax = adx(df, 14)
|
||||
up, lo = ma + k * sd, ma - k * sd
|
||||
ents = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(up[i]) or np.isnan(a[i]) or np.isnan(ax[i]):
|
||||
continue
|
||||
if ax[i] >= adx_max: # trend forte: niente fade
|
||||
continue
|
||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def rsi2_fade(df, rsi_n=2, lo=10, hi=90, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24):
|
||||
"""MR06 — Connors RSI(2) pullback in direzione del trend, TP/SL in ATR.
|
||||
|
||||
Meccanismo distinto da MR01/MR03: non usa bande di prezzo ma l'oscillatore
|
||||
RSI(2), che satura su micro-estremi. Filtro di trend con SMA lunga:
|
||||
- close SOPRA la SMA (uptrend) + RSI(2) < lo (dip) -> long, target rimbalzo
|
||||
- close SOTTO la SMA (downtrend) + RSI(2) > hi (pop) -> short
|
||||
TP = tp_atr*ATR a favore, SL = sl_atr*ATR contro. Compra il ritracciamento
|
||||
nel trend, non il contro-trend.
|
||||
"""
|
||||
c = df["close"].values
|
||||
r = rsi(c, rsi_n)
|
||||
ma = pd.Series(c).rolling(ma_n).mean().values
|
||||
a = atr(df, 14)
|
||||
ents = []
|
||||
for i in range(ma_n + 14, len(c)):
|
||||
if np.isnan(r[i]) or np.isnan(ma[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if r[i] < lo and c[i] > ma[i]: # dip in uptrend -> long
|
||||
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif r[i] > hi and c[i] < ma[i]: # pop in downtrend -> short
|
||||
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def return_reversal(df, n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24):
|
||||
"""MR07 — fade movimento di barra estremo (return reversal).
|
||||
|
||||
Misura il rendimento dell'ultima barra in unita' di deviazione standard rolling
|
||||
dei rendimenti. Se |ret| > k*sigma, fada nella direzione opposta; TP/SL in ATR.
|
||||
Meccanismo distinto: usa la volatilita' dei RENDIMENTI, non i livelli di prezzo.
|
||||
Config robusta (k=3.5, tp=2ATR, sl=1.5ATR): positivo full+OOS BTC e ETH 1h,
|
||||
DD piu' contenuto (BTC 25% / ETH 46%).
|
||||
"""
|
||||
c = df["close"].values
|
||||
ret = np.zeros_like(c)
|
||||
ret[1:] = np.diff(c) / c[:-1]
|
||||
sig = pd.Series(ret).rolling(n).std().values
|
||||
a = atr(df, 14)
|
||||
ents = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(sig[i]) or sig[i] == 0 or np.isnan(a[i]):
|
||||
continue
|
||||
z = ret[i] / sig[i]
|
||||
if z <= -k: # crollo di barra -> fade long
|
||||
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif z >= k: # spike di barra -> fade short
|
||||
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
CANDIDATES = {
|
||||
"MR02 donch_fade n20": (donchian_fade, dict(n=20, sl_atr=2.0, max_bars=24)),
|
||||
"MR02 donch_fade n50": (donchian_fade, dict(n=50, sl_atr=2.0, max_bars=24)),
|
||||
"MR03 kelt_fade k2": (keltner_fade, dict(n=20, k=2.0, sl_atr=2.0, max_bars=24)),
|
||||
"MR03 kelt_fade k2.5": (keltner_fade, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)),
|
||||
"MR04 zscore z2 n50": (zscore_revert, dict(n=50, z=2.0, sl_atr=2.5, max_bars=24)),
|
||||
"MR04 zscore z2.5 n50": (zscore_revert, dict(n=50, z=2.5, sl_atr=2.5, max_bars=24)),
|
||||
"MR05 boll_adx n50": (boll_fade_adx, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25)),
|
||||
"MR05 boll_adx n20": (boll_fade_adx, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25)),
|
||||
"MR06 rsi2 10/90": (rsi2_fade, dict(rsi_n=2, lo=10, hi=90, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24)),
|
||||
"MR06 rsi2 5/95": (rsi2_fade, dict(rsi_n=2, lo=5, hi=95, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24)),
|
||||
"MR07 retrev k3.5": (return_reversal, dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
|
||||
"MR07 retrev k3.0": (return_reversal, dict(n=50, k=3.0, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
|
||||
}
|
||||
|
||||
|
||||
def table():
|
||||
print("=" * 122)
|
||||
print(f" RICERCA v2 — NETTO dopo fee {FEE_RT*100:.2f}% RT | leva {LEV:.0f}x | pos {POS*100:.0f}% "
|
||||
f"| OOS = ultimo {int(OOS_FRAC*100)}%")
|
||||
print("=" * 122)
|
||||
print(f" {'Strategia':<22s}{'Asset':>5s}{'TF':>5s}{'Trd':>6s}{'Tr/yr':>7s}{'Win%':>7s}"
|
||||
f"{'Ret%FULL':>10s}{'Ret%OOS':>10s}{'DD%':>7s}{'Exp%':>7s}{'AnniPos':>9s}")
|
||||
print(" " + "-" * 118)
|
||||
for label, (fn, params) in CANDIDATES.items():
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["1h", "4h"]:
|
||||
df = get_df(asset, tf)
|
||||
ents = fn(df, **params)
|
||||
full = simulate(ents, df)
|
||||
split = int(len(df) * (1 - OOS_FRAC))
|
||||
oos = simulate([e for e in ents if e["i"] >= split], df)
|
||||
yrs = full["yearly"]
|
||||
pos_yrs = sum(1 for v in yrs.values() if v > 0)
|
||||
tr_yr = full["trades"] / max(len(yrs), 1)
|
||||
robust = oos["ret"] > 0 and full["ret"] > 0 and pos_yrs >= max(len(yrs) - 1, 1)
|
||||
flag = " <<<" if robust else ""
|
||||
print(f" {label:<22s}{asset:>5s}{tf:>5s}{full['trades']:>6d}{tr_yr:>7.0f}{full['win']:>7.1f}"
|
||||
f"{full['ret']:>+10.1f}{oos['ret']:>+10.1f}{full['dd']:>7.1f}{full['exposure']:>7.1f}"
|
||||
f"{f'{pos_yrs}/{len(yrs)}':>9s}{flag}")
|
||||
print(" " + "-" * 118)
|
||||
print(" <<< = positivo full+OOS e robusto (quasi tutti gli anni positivi).")
|
||||
|
||||
|
||||
def deep_dive():
|
||||
"""Robustezza dei 3 candidati promossi: fee sweep + griglia parametri OOS."""
|
||||
split_of = lambda df: int(len(df) * (1 - OOS_FRAC))
|
||||
fees = [0.0, 0.0005, 0.001, 0.002]
|
||||
|
||||
print("\n" + "#" * 122)
|
||||
print(" APPROFONDIMENTO MR02 / MR03 / MR05 — robustezza fee + griglia (deve restare positivo)")
|
||||
print("#" * 122)
|
||||
|
||||
# --- MR02 Donchian Fade ---
|
||||
print(f"\n [MR02 donchian_fade] SENSIBILITA' FEE — Ret% FULL/OOS (n=20)")
|
||||
print(f" {'Asset/TF':<10s}" + "".join(f"{f'{f*100:.2f}%RT':>22s}" for f in fees))
|
||||
print(f" {'':<10s}" + "".join(f"{'full':>11s}{'oos':>11s}" for _ in fees))
|
||||
for a, tf in [("BTC", "1h"), ("ETH", "1h"), ("BTC", "4h"), ("ETH", "4h")]:
|
||||
df = get_df(a, tf); sp = split_of(df)
|
||||
ents = donchian_fade(df, n=20, sl_atr=2.0, max_bars=24)
|
||||
oents = [e for e in ents if e["i"] >= sp]
|
||||
cells = "".join(f"{simulate(ents, df, fee_rt=f)['ret']:>+11.0f}{simulate(oents, df, fee_rt=f)['ret']:>+11.0f}" for f in fees)
|
||||
print(f" {a+' '+tf:<10s}{cells}")
|
||||
print(f"\n [MR02] GRIGLIA n x sl_atr — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT")
|
||||
for a in ["BTC", "ETH"]:
|
||||
df = get_df(a, "1h"); sp = split_of(df)
|
||||
print(f"\n {a} 1h " + "".join(f"{f'sl={s}':>16s}" for s in [1.5, 2.0, 3.0]))
|
||||
for n in [10, 20, 30, 50]:
|
||||
cells = ""
|
||||
for s in [1.5, 2.0, 3.0]:
|
||||
r = simulate([e for e in donchian_fade(df, n=n, sl_atr=s, max_bars=24) if e["i"] >= sp], df)
|
||||
cell = "%+.0f(%.0f)" % (r["ret"], r["dd"])
|
||||
cells += f"{cell:>16s}"
|
||||
print(f" n={n:<4d}{cells}")
|
||||
|
||||
# --- MR03 Keltner Fade ---
|
||||
print(f"\n [MR03 keltner_fade] GRIGLIA n x k — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT")
|
||||
for a in ["BTC", "ETH"]:
|
||||
df = get_df(a, "1h"); sp = split_of(df)
|
||||
print(f"\n {a} 1h " + "".join(f"{f'k={k}':>16s}" for k in [1.5, 2.0, 2.5]))
|
||||
for n in [14, 20, 30, 50]:
|
||||
cells = ""
|
||||
for k in [1.5, 2.0, 2.5]:
|
||||
r = simulate([e for e in keltner_fade(df, n=n, k=k, sl_atr=2.0, max_bars=24) if e["i"] >= sp], df)
|
||||
cell = "%+.0f(%.0f)" % (r["ret"], r["dd"])
|
||||
cells += f"{cell:>16s}"
|
||||
print(f" n={n:<4d}{cells}")
|
||||
|
||||
# --- MR05 Bollinger Fade + ADX ---
|
||||
print(f"\n [MR05 boll_fade_adx] GRIGLIA n x adx_max — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT")
|
||||
for a in ["BTC", "ETH"]:
|
||||
df = get_df(a, "1h"); sp = split_of(df)
|
||||
print(f"\n {a} 1h " + "".join(f"{f'adx<{x}':>16s}" for x in [20, 25, 30]))
|
||||
for n in [20, 30, 50]:
|
||||
cells = ""
|
||||
for x in [20, 25, 30]:
|
||||
r = simulate([e for e in boll_fade_adx(df, n=n, k=2.5, sl_atr=2.0, max_bars=24, adx_max=x) if e["i"] >= sp], df)
|
||||
cell = "%+.0f(%.0f)" % (r["ret"], r["dd"])
|
||||
cells += f"{cell:>16s}"
|
||||
print(f" n={n:<4d}{cells}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
table()
|
||||
deep_dive()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Verifica indipendente + ricerca TSM01 — Time-Series Momentum multi-orizzonte.
|
||||
|
||||
Long-only, multi-crypto, bassa frequenza. Per ogni asset il segnale è il CONSENSO
|
||||
dei segni del momentum su più orizzonti lunghi (3/6/12 mesi); si tengono equal-weight
|
||||
gli asset con consenso pieno positivo. Overlay risk-off: cash se BTC < SMA100.
|
||||
|
||||
Distinta da ROT02 (cross-sectional ranking): qui conta la PERSISTENZA assoluta lenta
|
||||
di ogni asset, non la classifica relativa. Correlazione con ROT02 ~0.62 -> fattore
|
||||
parzialmente indipendente, utile come diversificatore (NON come motore di ritorno:
|
||||
rende meno di ROT02 a parita' di OOS). DD basso.
|
||||
|
||||
Anti-overfit: edge su ALTOPIANO (36/36 config orizzonti x thr x regime_n restano OOS+),
|
||||
walk-forward stabile (4 anni up, 2 piatti per risk-off, mai un anno negativo), regge
|
||||
fee 0.40% RT. Gran parte del DD basso viene dall'overlay risk-off SMA100 (condiviso),
|
||||
la struttura multi-orizzonte aggiunge ~+38pp OOS e alza lo Sharpe 0.58->1.07.
|
||||
Default gross=0.30 (era 0.45): stesso Sharpe ma DD 22%->15% (scelta robusta, non la piu' redditizia).
|
||||
|
||||
Engine onesto: pesi a close[i] da soli rendimenti passati, realizzo i->i+1, fee
|
||||
one-way fee_rt/2 sul turnover. NETTO, leva implicita gross. OOS = ultimo 30%.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.honest_lab import available_assets, FEE_RT
|
||||
from scripts.analysis.honest_rotation import build_panel
|
||||
|
||||
GROSS, OOS_FRAC = 0.30, 0.30 # gross 0.30 (anti-overfit): stesso Sharpe di 0.45, DD piu' basso
|
||||
|
||||
|
||||
def tsmom_sim(horizons=(63, 126, 252), thr=1.0, regime_n=100, gross=GROSS,
|
||||
fee_rt=FEE_RT, oos_frac=0.0, cheat=False):
|
||||
"""horizons in giorni. thr=1.0 -> consenso pieno (tutti i segni positivi)."""
|
||||
panel = build_panel(available_assets(), "1d")
|
||||
cols = list(panel.columns); P = panel.values; T, N = P.shape
|
||||
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
|
||||
years = panel.index.year.values
|
||||
btc = P[:, cols.index("BTC")]
|
||||
bma = pd.Series(btc).rolling(regime_n).mean().values
|
||||
start = max(max(horizons) + 1, regime_n + 1, int(T * (1 - oos_frac)) if oos_frac else 0)
|
||||
cap = 1000.0; w = np.zeros(N); eq = [cap]; yearly = {}
|
||||
eq_ts: list = []; eq_v: list = []
|
||||
for i in range(start, T - 1):
|
||||
risk_on = btc[i] > bma[i] if not np.isnan(bma[i]) else False
|
||||
wi = i + 1 if cheat else i # cheat: usa il futuro (test no-look-ahead)
|
||||
score = np.zeros(N)
|
||||
for h in horizons:
|
||||
score += np.sign(P[wi] / P[wi - h] - 1)
|
||||
score /= len(horizons)
|
||||
chosen = [j for j in range(N) if score[j] >= thr] if risk_on else []
|
||||
nw = np.zeros(N)
|
||||
for j in chosen:
|
||||
nw[j] = gross / len(chosen)
|
||||
cap -= cap * np.abs(nw - w).sum() * (fee_rt / 2); w = nw
|
||||
cap = max(cap * (1 + float(np.dot(w, rets[i + 1]))), 10.0)
|
||||
eq.append(cap)
|
||||
eq_ts.append(panel.index[i + 1]); eq_v.append(cap)
|
||||
y = int(years[i]); yearly[y] = yearly.get(y, 0.0) + float(np.dot(w, rets[i + 1])) * 100
|
||||
eq = np.array(eq); peak = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((peak - eq) / peak) * 100)
|
||||
yrs = (panel.index[-1] - panel.index[start]).days / 365.25 or 1
|
||||
rets_d = np.diff(eq) / eq[:-1]
|
||||
sharpe = float(np.mean(rets_d) / np.std(rets_d) * np.sqrt(365)) if np.std(rets_d) > 0 else 0.0
|
||||
return dict(ret=(cap / 1000 - 1) * 100, cagr=((cap / 1000) ** (1 / yrs) - 1) * 100,
|
||||
dd=dd, sharpe=sharpe, yearly=yearly, eq_ts=eq_ts, eq_v=eq_v,
|
||||
pos_years=sum(1 for v in yearly.values() if v > 0), n_years=len(yearly))
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 90)
|
||||
print(" TSM01 — TSMOM multi-orizzonte (3/6/12m consenso pieno) + risk-off SMA100")
|
||||
print("=" * 90)
|
||||
# no-look-ahead: cheat deve esplodere
|
||||
base = tsmom_sim()
|
||||
ch = tsmom_sim(cheat=True)
|
||||
print(f" no-look-ahead: onesto FULL={base['ret']:+.0f}% vs cheat(futuro)={ch['ret']:+.0f}% -> "
|
||||
f"{'OK (il cheat esplode -> niente leak)' if ch['ret'] > base['ret'] * 2 else 'CONTROLLARE'}")
|
||||
o = tsmom_sim(oos_frac=1 - OOS_FRAC)
|
||||
hi = tsmom_sim(fee_rt=0.002)
|
||||
print(f"\n FULL {base['ret']:+.0f}% CAGR {base['cagr']:.0f}% DD {base['dd']:.0f}% "
|
||||
f"Sharpe {base['sharpe']:.2f} anni+ {base['pos_years']}/{base['n_years']}")
|
||||
print(f" OOS {o['ret']:+.0f}% DD {o['dd']:.0f}% | fee 0.40% RT: FULL {hi['ret']:+.0f}%")
|
||||
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(base["yearly"].items())))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Re-validazione: il StrategyWorker REALE tradi MR01 con edge netto?
|
||||
|
||||
Guida il worker vero (generate_signals + nuova logica exit TP/SL/max_bars) su
|
||||
finestre mobili di dati 1h storici, simulando il polling live. Conferma che
|
||||
sulla finestra OOS l'edge netto (dopo fee 0.10% RT) sopravvive alla meccanica
|
||||
del worker (exit su prezzo corrente, piu' conservativa del backtest high/low).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from src.live.strategy_loader import load_strategy
|
||||
from src.live.strategy_worker import StrategyWorker
|
||||
|
||||
OOS_FRAC = 0.30
|
||||
WIN = 250 # barre per finestra di poll (warmup bb_window=50 + ATR)
|
||||
|
||||
|
||||
def replay(asset: str, params: dict):
|
||||
df = load_data(asset, "1h").reset_index(drop=True)
|
||||
n = len(df)
|
||||
split = int(n * (1 - OOS_FRAC))
|
||||
strat = load_strategy("MR01_bollinger_fade")
|
||||
w = StrategyWorker(strat, asset, "1h", capital=1000.0, position_size=0.15,
|
||||
leverage=3.0, hold_bars=3, params=params,
|
||||
data_dir=Path(f"/tmp/replay_{asset}"))
|
||||
w._notify = lambda *a, **k: None
|
||||
# stato pulito
|
||||
for attr, val in dict(capital=1000.0, in_position=False, direction=0, entry_price=0,
|
||||
bars_held=0, total_trades=0, total_wins=0, last_bar_ts=0,
|
||||
tp=0.0, sl=0.0, max_bars=0).items():
|
||||
setattr(w, attr, val)
|
||||
|
||||
start = max(split, WIN)
|
||||
with contextlib.redirect_stdout(open(os.devnull, "w")):
|
||||
for j in range(start, n):
|
||||
w.tick(df.iloc[j - WIN + 1 : j + 1])
|
||||
|
||||
ret = (w.capital / 1000 - 1) * 100
|
||||
acc = w.total_wins / w.total_trades * 100 if w.total_trades else 0.0
|
||||
import pandas as pd
|
||||
period = (f"{pd.to_datetime(df['timestamp'].iloc[start], unit='ms', utc=True).date()}"
|
||||
f"->{pd.to_datetime(df['timestamp'].iloc[-1], unit='ms', utc=True).date()}")
|
||||
return w.total_trades, acc, ret, w.capital, period
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 90)
|
||||
print(" RE-VALIDAZIONE WORKER REALE su MR01 (OOS, fee 0.10% RT, leva 3x) — finestra poll 250b")
|
||||
print("=" * 90)
|
||||
params = dict(bb_window=50, k=2.5, sl_atr=2.0, max_bars=24)
|
||||
print(f" {'Asset':>6s}{'Periodo OOS':>26s}{'Trade':>7s}{'Win%':>7s}{'Ret%':>9s}{'Cap€':>9s}")
|
||||
print(" " + "-" * 80)
|
||||
for asset in ["BTC", "ETH"]:
|
||||
t, acc, ret, cap, period = replay(asset, params)
|
||||
print(f" {asset:>6s}{period:>26s}{t:>7d}{acc:>7.1f}{ret:>+9.1f}{cap:>9.0f}")
|
||||
print(" " + "-" * 80)
|
||||
print(" Atteso: Ret% positivo (l'edge mean-reversion sopravvive alla meccanica del worker).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""DIP01 — Dip-Buy Z-Score Reversion (long-only).
|
||||
|
||||
Variante robusta e ONESTA della famiglia mean-reversion: compra SOLO i dip
|
||||
(close a z<=-z_in deviazioni sotto la media mobile) e prende profitto al rientro
|
||||
verso la media. Niente short: nel campione 2018-2026 shortare cripto perde OOS
|
||||
sistematicamente (vedi scripts/analysis/honest_final.py).
|
||||
|
||||
Logica:
|
||||
1. z-score = (close - SMA(n)) / STD(n)
|
||||
2. ENTRY long quando z attraversa al ribasso -z_in (capitolazione)
|
||||
3. EXIT: take-profit alla media mobile, stop-loss a sl_atr*ATR sotto l'entry,
|
||||
o time-limit max_bars
|
||||
4. ingresso a close[i] (eseguibile dal vivo, nessun look-ahead)
|
||||
|
||||
Validazione (netto, fee 0.10% RT Deribit, leva 3x, OOS = ultimo 30%):
|
||||
BTC 1h: FULL +298% / OOS +59% / DD 23% / 7-9 anni positivi
|
||||
ETH 1h: FULL +190% / OOS +224% / DD 54%
|
||||
SOL 1h: FULL +50% / OOS +13% / DD 25%
|
||||
Regge lo sweep fee fino a 0.20% RT (BTC OOS +45% anche a 0.20%).
|
||||
Robusto su BTC/ETH/SOL (asset major); sugli alt molto parabolici (DOGE/BNB)
|
||||
non ha edge -> usare solo su BTC/ETH/SOL.
|
||||
|
||||
Compatibile con StrategyWorker: ogni Signal porta tp/sl/max_bars in metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
def _atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
pc = np.roll(c, 1); pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
return pd.Series(tr).rolling(n).mean().values
|
||||
|
||||
|
||||
class DipReversion(Strategy):
|
||||
name = "DIP01_dip_reversion"
|
||||
description = "Long-only dip-buy z-score reversion, TP alla media"
|
||||
default_assets = ["BTC", "ETH", "SOL"]
|
||||
default_timeframes = ["1h"]
|
||||
fee_rt = 0.001
|
||||
leverage = 3.0
|
||||
position_size = 0.15
|
||||
initial_capital = 1000.0
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
n = params.get("n", 50)
|
||||
z_in = params.get("z_in", 2.5)
|
||||
sl_atr = params.get("sl_atr", 2.5)
|
||||
max_bars = params.get("max_bars", 24)
|
||||
|
||||
ma = pd.Series(c).rolling(n).mean().values
|
||||
sd = pd.Series(c).rolling(n).std().values
|
||||
a = _atr(df, 14)
|
||||
z = (c - ma) / np.where(sd == 0, np.nan, sd)
|
||||
|
||||
signals: list[Signal] = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(z[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if z[i] <= -z_in and z[i - 1] > -z_in:
|
||||
signals.append(Signal(
|
||||
idx=i, direction=1, entry_price=c[i],
|
||||
metadata={"tp": float(ma[i]), "sl": float(c[i] - sl_atr * a[i]),
|
||||
"max_bars": max_bars},
|
||||
))
|
||||
return signals
|
||||
|
||||
def backtest(self, asset: str, tf: str = "1h", hold: int = 3,
|
||||
**params) -> BacktestResult | None:
|
||||
df = load_data(asset, tf)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
signals = self.generate_signals(df, ts, **params)
|
||||
if not signals:
|
||||
return None
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c)
|
||||
fee = self.fee_rt * self.leverage
|
||||
capital = peak = float(self.initial_capital)
|
||||
max_dd = 0.0
|
||||
total_bars = 0
|
||||
last_exit = -1
|
||||
yearly: dict[int, dict] = {}
|
||||
|
||||
for sig in signals:
|
||||
i, d = sig.idx, sig.direction
|
||||
if i <= last_exit or i + 1 >= n:
|
||||
continue
|
||||
entry = c[i]
|
||||
tp, sl, mb = sig.metadata["tp"], sig.metadata["sl"], sig.metadata["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]
|
||||
j = min(i + mb, n - 1)
|
||||
for step in range(1, mb + 1):
|
||||
j = i + step
|
||||
if j >= n:
|
||||
j = n - 1; exit_p = c[j]; break
|
||||
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hit_sl:
|
||||
exit_p = sl; break
|
||||
if hit_tp:
|
||||
exit_p = tp; break
|
||||
if step == mb:
|
||||
exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * d * self.leverage - fee
|
||||
capital = max(capital + capital * self.position_size * ret, 10.0)
|
||||
if capital > peak:
|
||||
peak = capital
|
||||
max_dd = max(max_dd, (peak - capital) / peak)
|
||||
total_bars += (j - i)
|
||||
last_exit = j
|
||||
year = ts.iloc[i].year
|
||||
yr = yearly.setdefault(year, {"w": 0, "t": 0, "pnl": 0.0})
|
||||
yr["t"] += 1
|
||||
if ret > 0:
|
||||
yr["w"] += 1
|
||||
yr["pnl"] += ret * self.initial_capital
|
||||
|
||||
all_t = sum(v["t"] for v in yearly.values())
|
||||
all_w = sum(v["w"] for v in yearly.values())
|
||||
if all_t == 0:
|
||||
return None
|
||||
yearly_stats = [YearlyStats(y, v["t"], v["w"], v["pnl"]) for y, v in sorted(yearly.items())]
|
||||
return BacktestResult(
|
||||
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
|
||||
trades=all_t, wins=all_w, pnl=sum(v["pnl"] for v in yearly.values()),
|
||||
capital=capital, initial_capital=self.initial_capital,
|
||||
max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100,
|
||||
avg_trade_duration_h=total_bars / all_t * TF_MINUTES.get(tf, 60) / 60,
|
||||
years_active=len(yearly), yearly=yearly_stats,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strat = DipReversion()
|
||||
print(f"{'=' * 100}")
|
||||
print(f" DIP01 DIP-BUY REVERSION — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||
print(f"{'=' * 100}")
|
||||
for asset in ["BTC", "ETH", "SOL"]:
|
||||
r = strat.backtest(asset, "1h", n=50, z_in=2.5, sl_atr=2.5, max_bars=24)
|
||||
if r:
|
||||
r.strategy_name = f"DIP01 {asset} 1h"
|
||||
r.print_summary()
|
||||
@@ -0,0 +1,172 @@
|
||||
"""MR01 — Bollinger Fade (mean-reversion).
|
||||
|
||||
L'UNICA famiglia con edge netto reale dopo l'analisi out-of-sample fee-aware
|
||||
(vedi scripts/analysis/strategy_research.py). Contrario della tesi squeeze:
|
||||
i breakout RIENTRANO, quindi si fada l'estremo verso la media.
|
||||
|
||||
Logica:
|
||||
1. Bollinger Band (window n, k deviazioni) sul close
|
||||
2. ENTRY: close esce sotto la banda inferiore -> long (o sopra la superiore -> short)
|
||||
3. EXIT: take-profit alla media mobile (il rientro atteso),
|
||||
stop-loss a sl_atr*ATR oltre l'estremo, oppure time-limit max_bars
|
||||
4. ingresso a close[i] (eseguibile dal vivo, nessun look-ahead)
|
||||
|
||||
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
|
||||
BTC 1h n=50 k=2.5: +201% OOS, DD 15%, ~tutti gli anni positivi
|
||||
ETH 1h n=50 k=2.0: +1238% OOS, DD 23%
|
||||
Robusto su TUTTA la griglia n in {14,20,30,50} x k in {2.0,2.5,3.0}
|
||||
e su tutte le fee 0.00-0.20% RT (margine di sicurezza ampio).
|
||||
|
||||
NOTA LIVE: usa TP alla media + SL ad ATR + max_bars. Lo StrategyWorker attuale
|
||||
esce solo a hold_bars/stop -2% fisso: per tradarla come validata il worker deve
|
||||
supportare gli exit TP/SL passati in metadata (vedi metadata di ogni Signal).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
def _atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
pc = np.roll(c, 1); pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
return pd.Series(tr).rolling(n).mean().values
|
||||
|
||||
|
||||
class BollingerFade(Strategy):
|
||||
name = "MR01_bollinger_fade"
|
||||
description = "Mean-reversion: fada la banda di Bollinger, TP alla media"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["1h"]
|
||||
fee_rt = 0.001 # Deribit perp realistico (taker 0.05%/lato)
|
||||
leverage = 3.0
|
||||
position_size = 0.15
|
||||
initial_capital = 1000.0
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
n_len = len(c)
|
||||
bb_w = params.get("bb_window", 50)
|
||||
k = params.get("k", 2.5)
|
||||
sl_atr = params.get("sl_atr", 2.0)
|
||||
max_bars = params.get("max_bars", 24)
|
||||
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||
ema_long = params.get("ema_long", 200)
|
||||
|
||||
ma = pd.Series(c).rolling(bb_w).mean().values
|
||||
sd = pd.Series(c).rolling(bb_w).std().values
|
||||
a = _atr(df, 14)
|
||||
up, lo = ma + k * sd, ma - k * sd
|
||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values if trend_max is not None else None
|
||||
|
||||
signals: list[Signal] = []
|
||||
for i in range(bb_w + 14, n_len):
|
||||
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if el is not None and (a[i] == 0 or np.isnan(el[i]) or abs(c[i] - el[i]) / a[i] > trend_max):
|
||||
continue
|
||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||
d, sl = 1, c[i] - sl_atr * a[i]
|
||||
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||
d, sl = -1, c[i] + sl_atr * a[i]
|
||||
else:
|
||||
continue
|
||||
signals.append(Signal(
|
||||
idx=i, direction=d, entry_price=c[i],
|
||||
metadata={"tp": float(ma[i]), "sl": float(sl), "max_bars": max_bars},
|
||||
))
|
||||
return signals
|
||||
|
||||
def backtest(self, asset: str, tf: str = "1h", hold: int = 3,
|
||||
**params) -> BacktestResult | None:
|
||||
"""Backtest fedele: TP alla media / SL ad ATR / time-limit, fee+leva nette."""
|
||||
df = load_data(asset, tf)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
signals = self.generate_signals(df, ts, **params)
|
||||
if not signals:
|
||||
return None
|
||||
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c)
|
||||
fee = self.fee_rt * self.leverage
|
||||
capital = peak = float(self.initial_capital)
|
||||
max_dd = 0.0
|
||||
total_bars = 0
|
||||
last_exit = -1
|
||||
yearly: dict[int, dict] = {}
|
||||
|
||||
for sig in signals:
|
||||
i, d = sig.idx, sig.direction
|
||||
if i <= last_exit or i + 1 >= n:
|
||||
continue
|
||||
entry = c[i]
|
||||
tp, sl, mb = sig.metadata["tp"], sig.metadata["sl"], sig.metadata["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]
|
||||
j = min(i + mb, n - 1)
|
||||
for step in range(1, mb + 1):
|
||||
j = i + step
|
||||
if j >= n:
|
||||
j = n - 1; exit_p = c[j]; break
|
||||
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hit_sl:
|
||||
exit_p = sl; break
|
||||
if hit_tp:
|
||||
exit_p = tp; break
|
||||
if step == mb:
|
||||
exit_p = c[j]
|
||||
|
||||
ret = (exit_p - entry) / entry * d * self.leverage - fee
|
||||
capital = max(capital + capital * self.position_size * ret, 10.0)
|
||||
if capital > peak:
|
||||
peak = capital
|
||||
max_dd = max(max_dd, (peak - capital) / peak)
|
||||
total_bars += (j - i)
|
||||
last_exit = j
|
||||
|
||||
year = ts.iloc[i].year
|
||||
yr = yearly.setdefault(year, {"w": 0, "t": 0, "pnl": 0.0})
|
||||
yr["t"] += 1
|
||||
if ret > 0:
|
||||
yr["w"] += 1
|
||||
yr["pnl"] += ret * self.initial_capital
|
||||
|
||||
all_t = sum(v["t"] for v in yearly.values())
|
||||
all_w = sum(v["w"] for v in yearly.values())
|
||||
if all_t == 0:
|
||||
return None
|
||||
|
||||
yearly_stats = [YearlyStats(y, v["t"], v["w"], v["pnl"]) for y, v in sorted(yearly.items())]
|
||||
return BacktestResult(
|
||||
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
|
||||
trades=all_t, wins=all_w, pnl=sum(v["pnl"] for v in yearly.values()),
|
||||
capital=capital, initial_capital=self.initial_capital,
|
||||
max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100,
|
||||
avg_trade_duration_h=total_bars / all_t * TF_MINUTES.get(tf, 60) / 60,
|
||||
years_active=len(yearly), yearly=yearly_stats,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strat = BollingerFade()
|
||||
print(f"{'=' * 110}")
|
||||
print(f" MR01 BOLLINGER FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||
print(f"{'=' * 110}")
|
||||
results = []
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for k in [2.0, 2.5]:
|
||||
r = strat.backtest(asset, "1h", bb_window=50, k=k, sl_atr=2.0, max_bars=24)
|
||||
if r:
|
||||
r.strategy_name = f"MR01 {asset} 1h n50 k{k}"
|
||||
results.append(r)
|
||||
for r in results:
|
||||
r.print_summary()
|
||||
if results:
|
||||
results[0].print_yearly()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""MR02 — Donchian Fade (mean-reversion sugli estremi del canale).
|
||||
|
||||
L'opposto esatto del trend-following Donchian (che PERDE netto: vedi
|
||||
scripts/analysis/strategy_research.py). Coerente con la lezione squeeze:
|
||||
i breakout RIENTRANO, quindi si fada la rottura del canale verso il centro.
|
||||
|
||||
Logica:
|
||||
1. Canale Donchian: massimo/minimo delle ultime n barre (escludendo la corrente)
|
||||
2. ENTRY: close rompe SOPRA il massimo del canale -> SHORT (fade);
|
||||
close rompe SOTTO il minimo -> LONG. Ingresso a close[i] (eseguibile).
|
||||
3. EXIT: take-profit al centro del canale (il rientro atteso),
|
||||
stop-loss a sl_atr*ATR oltre l'estremo, time-limit max_bars.
|
||||
|
||||
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
|
||||
BTC 1h n=20: +879% FULL / +171% OOS, DD 30%, 8/9 anni positivi
|
||||
ETH 1h n=20: enorme FULL / +8452% OOS, DD 42%
|
||||
Robusto su TUTTA la griglia n in {10,20,30,50} x sl_atr in {1.5,2.0,3.0}
|
||||
(BTC+ETH 1h sempre positivo OOS) e su tutte le fee 0.00-0.20% RT.
|
||||
Ricerca completa: scripts/analysis/strategy_research_v2.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Signal
|
||||
from src.strategies.fade_base import FadeStrategy, atr, trend_distance
|
||||
|
||||
|
||||
class DonchianFade(FadeStrategy):
|
||||
name = "MR02_donchian_fade"
|
||||
description = "Mean-reversion: fada la rottura del canale Donchian, TP al centro"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["1h"]
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
n = params.get("n", 20)
|
||||
sl_atr = params.get("sl_atr", 2.0)
|
||||
max_bars = params.get("max_bars", 24)
|
||||
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||
ema_long = params.get("ema_long", 200)
|
||||
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
hh = pd.Series(h).rolling(n).max().shift(1).values
|
||||
ll = pd.Series(l).rolling(n).min().shift(1).values
|
||||
a = atr(df, 14)
|
||||
td = trend_distance(df, ema_long) if trend_max is not None else None
|
||||
|
||||
signals: list[Signal] = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(hh[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
||||
continue
|
||||
mid = (hh[i] + ll[i]) / 2.0
|
||||
if c[i] > hh[i] and c[i - 1] <= hh[i - 1]: # rottura rialzista -> fade short
|
||||
d, sl = -1, c[i] + sl_atr * a[i]
|
||||
elif c[i] < ll[i] and c[i - 1] >= ll[i - 1]: # rottura ribassista -> fade long
|
||||
d, sl = 1, c[i] - sl_atr * a[i]
|
||||
else:
|
||||
continue
|
||||
signals.append(Signal(
|
||||
idx=i, direction=d, entry_price=c[i],
|
||||
metadata={"tp": float(mid), "sl": float(sl), "max_bars": max_bars},
|
||||
))
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strat = DonchianFade()
|
||||
print("=" * 110)
|
||||
print(f" MR02 DONCHIAN FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||
print("=" * 110)
|
||||
for asset in ["BTC", "ETH"]:
|
||||
r = strat.backtest(asset, "1h", n=20, sl_atr=2.0, max_bars=24)
|
||||
if r:
|
||||
r.strategy_name = f"MR02 {asset} 1h n20"
|
||||
r.print_summary()
|
||||
r.print_yearly()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""MR07 — Return Reversal (fade del movimento di barra estremo).
|
||||
|
||||
Meccanismo distinto da MR01/MR02/MR03: non guarda i LIVELLI di prezzo (bande,
|
||||
canali) ma la VOLATILITA' dei rendimenti. Quando una singola barra si muove di
|
||||
piu' di k deviazioni standard rolling dei rendimenti, e' un'over-reaction che
|
||||
tende a rientrare: si fada nella direzione opposta. Coerente con la lezione
|
||||
mean-reversion.
|
||||
|
||||
Logica:
|
||||
1. ret[i] = rendimento dell'ultima barra; sigma = std rolling(n) dei rendimenti
|
||||
2. z = ret[i]/sigma. Se z <= -k (crollo) -> LONG; se z >= +k (spike) -> SHORT.
|
||||
Ingresso a close[i] (eseguibile dal vivo, nessun look-ahead).
|
||||
3. EXIT: take-profit a tp_atr*ATR a favore, stop-loss a sl_atr*ATR contro,
|
||||
time-limit max_bars.
|
||||
|
||||
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
|
||||
config robusta k=3.5 tp=2ATR sl=1.5ATR n=50:
|
||||
BTC 1h: +447% FULL / +105% OOS, DD 25%
|
||||
ETH 1h: +335% FULL / +195% OOS, DD 46%
|
||||
L'intero blocco tp_atr=2.0 (k in {2.5,3.0,3.5} x sl in {1.5,2.0,2.5}) e'
|
||||
positivo full+OOS su entrambi gli asset 1h.
|
||||
Ricerca completa: scripts/analysis/strategy_research_v2.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Signal
|
||||
from src.strategies.fade_base import FadeStrategy, atr, trend_distance
|
||||
|
||||
|
||||
class ReturnReversal(FadeStrategy):
|
||||
name = "MR07_return_reversal"
|
||||
description = "Mean-reversion: fada il movimento di barra estremo (z dei rendimenti)"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["1h"]
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
n = params.get("n", 50)
|
||||
k = params.get("k", 3.5)
|
||||
tp_atr = params.get("tp_atr", 2.0)
|
||||
sl_atr = params.get("sl_atr", 1.5)
|
||||
max_bars = params.get("max_bars", 24)
|
||||
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||
ema_long = params.get("ema_long", 200)
|
||||
|
||||
c = df["close"].values
|
||||
ret = np.zeros_like(c)
|
||||
ret[1:] = np.diff(c) / c[:-1]
|
||||
sig = pd.Series(ret).rolling(n).std().values
|
||||
a = atr(df, 14)
|
||||
td = trend_distance(df, ema_long) if trend_max is not None else None
|
||||
|
||||
signals: list[Signal] = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(sig[i]) or sig[i] == 0 or np.isnan(a[i]):
|
||||
continue
|
||||
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
||||
continue
|
||||
z = ret[i] / sig[i]
|
||||
if z <= -k: # crollo di barra -> fade long
|
||||
d, tp, sl = 1, c[i] + tp_atr * a[i], c[i] - sl_atr * a[i]
|
||||
elif z >= k: # spike di barra -> fade short
|
||||
d, tp, sl = -1, c[i] - tp_atr * a[i], c[i] + sl_atr * a[i]
|
||||
else:
|
||||
continue
|
||||
signals.append(Signal(
|
||||
idx=i, direction=d, entry_price=c[i],
|
||||
metadata={"tp": float(tp), "sl": float(sl), "max_bars": max_bars},
|
||||
))
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strat = ReturnReversal()
|
||||
print("=" * 110)
|
||||
print(f" MR07 RETURN REVERSAL — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||
print("=" * 110)
|
||||
for asset in ["BTC", "ETH"]:
|
||||
r = strat.backtest(asset, "1h", n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)
|
||||
if r:
|
||||
r.strategy_name = f"MR07 {asset} 1h k3.5"
|
||||
r.print_summary()
|
||||
r.print_yearly()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""PORT01 — Portafoglio combinato delle 3 strategie oneste (equal-weight, daily rebal).
|
||||
|
||||
Sleeve (meccanismi anti-correlati):
|
||||
DIP01 dip-buy reversion su BTC (1h) regime: reversione
|
||||
TR01 EMA 20/100 trend su paniere (4h) regime: momentum singolo
|
||||
ROT02 dual-momentum rotation (1d) regime: forza relativa + risk-off
|
||||
|
||||
La diversificazione e' il vero motore di risk-reduction: il DD del portafoglio
|
||||
scende SOTTO quello della sleeve meno rischiosa, mantenendo una CAGR alta e
|
||||
azzerando quasi gli anni negativi (il 2022 bear passa da -30% di ROT a -1%).
|
||||
|
||||
Risultato (netto, 2021-2026, leva 3x pos 15% per sleeve; ROT02 ora top_k=3):
|
||||
DIP01_BTC +322% DD 15% CAGR 31%
|
||||
TR01_basket +591% DD 27% CAGR 43%
|
||||
ROT02_dualmom +984% DD 26% CAGR 56% (top_k=3: DD 40->26, PnL su)
|
||||
PORTAFOGLIO +676% DD 13% CAGR 46% <-- DD piu' basso di ogni sleeve
|
||||
Per-anno: 2021 +224 · 2022 +1 · 2023 +48 · 2024 +48 · 2025 +10 · 2026 -2
|
||||
Logica e ricostruzione: scripts/analysis/honest_improve2.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.honest_improve import _dd # noqa: E402
|
||||
from scripts.analysis.honest_improve2 import ( # noqa: E402
|
||||
dip_market_gated, _daily_equity, _norm, _tr_basket_daily, _rot_daily_equity,
|
||||
)
|
||||
|
||||
|
||||
def run():
|
||||
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
|
||||
d = dip_market_gated("BTC", market_n=0, return_equity=True)
|
||||
members = {
|
||||
"DIP01_BTC": _norm(_daily_equity(d["eq_ts"], d["eq_v"], idx)),
|
||||
"TR01_basket": _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], idx)),
|
||||
"ROT02_dualmom": _norm(_rot_daily_equity(idx)),
|
||||
}
|
||||
drets = pd.DataFrame({k: v.pct_change().fillna(0) for k, v in members.items()})
|
||||
port_ret = drets.mean(axis=1)
|
||||
combo = (1 + port_ret).cumprod()
|
||||
yrs = (idx[-1] - idx[0]).days / 365.25
|
||||
|
||||
print("=" * 80)
|
||||
print(f" PORT01 — portafoglio equal-weight (daily rebal) | {idx[0].date()} -> {idx[-1].date()}")
|
||||
print("=" * 80)
|
||||
print(f" {'sleeve':<16s}{'ret%':>9s}{'DD%':>7s}{'CAGR%':>8s}")
|
||||
for name, s in members.items():
|
||||
r = (s.iloc[-1] / s.iloc[0] - 1) * 100
|
||||
cagr = ((s.iloc[-1] / s.iloc[0]) ** (1 / yrs) - 1) * 100
|
||||
print(f" {name:<16s}{r:>+9.0f}{_dd(s.values):>7.0f}{cagr:>8.0f}")
|
||||
r = (combo.iloc[-1] / combo.iloc[0] - 1) * 100
|
||||
cagr = ((combo.iloc[-1] / combo.iloc[0]) ** (1 / yrs) - 1) * 100
|
||||
print(f" {'PORTAFOGLIO':<16s}{r:>+9.0f}{_dd(combo.values):>7.0f}{cagr:>8.0f}")
|
||||
pa = port_ret.groupby(port_ret.index.year).apply(lambda x: ((1 + x).prod() - 1) * 100)
|
||||
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""PORT02 — Portafoglio FADE accorpato e migliorato (6 sleeve, equal-weight daily).
|
||||
|
||||
Accorpa le 3 strategie fade mean-reversion su BTC e ETH, ciascuna MIGLIORATA con
|
||||
il filtro trend (salta i fade contro trend estremi: |close-EMA200|/ATR > 3.0):
|
||||
MR01 Bollinger fade · MR02 Donchian fade · MR07 Return-reversal
|
||||
x {BTC, ETH} -> 6 sleeve indipendenti, pos 0.15 ciascuna, leva 3x.
|
||||
(MR03 Keltner spostata in waste: fade piu' debole e ridondante con MR01.)
|
||||
|
||||
Le curve sono poco correlate fra loro (corr media intra-fade ~0.18): la
|
||||
diversificazione abbatte il DD aggregato ben sotto quello del singolo sleeve.
|
||||
|
||||
Risultato (netto fee 0.10% RT, equal-weight ribilanciato ogni giorno, finestra
|
||||
comune 2021-2026, OOS = ultimo 30%):
|
||||
Ret +666% / CAGR 46% / DD 8.2% FULL · OOS DD 5.9% / Sharpe 3.95 FULL / 4.09 OOS.
|
||||
Ricostruzione e confronto: scripts/analysis/combine_portfolio.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.combine_portfolio import ( # noqa: E402
|
||||
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
|
||||
)
|
||||
|
||||
|
||||
def run():
|
||||
sleeves = {k: v for k, v in build_all_sleeves().items() if k.startswith("MR")}
|
||||
pr = port_returns(sleeves)
|
||||
full, oos = metrics(pr), metrics(pr, lo=SPLIT)
|
||||
|
||||
print("=" * 84)
|
||||
print(f" PORT02 — FADE master (8 sleeve, equal-weight daily) | {IDX[0].date()} -> {IDX[-1].date()}")
|
||||
print("=" * 84)
|
||||
print(f" {'sleeve':<14s}{'Ret%':>9s}{'DD%':>7s}{'Shrp':>7s}")
|
||||
for name, s in sleeves.items():
|
||||
m = metrics(s.pct_change().fillna(0.0))
|
||||
print(f" {name:<14s}{m['ret']:>+9.0f}{m['dd']:>7.1f}{m['sharpe']:>7.2f}")
|
||||
print(" " + "-" * 80)
|
||||
print(f" {'PORTAFOGLIO':<14s}{full['ret']:>+9.0f}{full['dd']:>7.1f}{full['sharpe']:>7.2f}"
|
||||
f" CAGR {full['cagr']:.0f}%")
|
||||
print(f" {' di cui OOS':<14s}{oos['ret']:>+9.0f}{oos['dd']:>7.1f}{oos['sharpe']:>7.2f}"
|
||||
f" (da {OOS_DATE})")
|
||||
pa = yearly_returns(pr)
|
||||
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""PORT03 — Portafoglio MASTER: accorpa TUTTE le strategie (fade + honest).
|
||||
|
||||
Unisce le due famiglie del progetto, quasi scorrelate (correlazione cross-famiglia
|
||||
~0.05), in un unico portafoglio diversificato:
|
||||
FADE (reversione intraday 1h, long/short, BTC/ETH, col filtro trend):
|
||||
MR01 Bollinger · MR02 Donchian · MR07 Return-reversal -> 6 sleeve
|
||||
HONEST (long-only multi-regime multi-crypto):
|
||||
DIP01 dip-buy (1h) · TR01 EMA-trend (4h basket) · ROT02 dual-momentum (1d) -> 3 sleeve
|
||||
|
||||
Combinare le due famiglie migliora il rischio/rendimento rispetto a ciascuna da
|
||||
sola: il DD scende e lo Sharpe sale (la honest, da sola piu' lumpy, viene
|
||||
stabilizzata dalle fade ad alta frequenza). Vedi scripts/analysis/combine_portfolio.py.
|
||||
|
||||
Risultato (netto, equal-weight daily, finestra comune 2021-2026, OOS = ultimo 30%;
|
||||
ROT02 ora top_k=3 -> DD piu' basso):
|
||||
FADE only (6) DD 8.2% Sharpe 3.95 (OOS DD 5.9 / Shrp 4.09)
|
||||
HONEST only (3) DD 12.6% Sharpe 2.20 (OOS DD 7.8 / Shrp 2.02)
|
||||
MASTER eq (9) DD 5.2% Sharpe 4.23 (OOS DD 4.7 / Shrp 4.33) <- miglior Sharpe
|
||||
MASTER 50/50 DD 5.0% Sharpe 3.69 (OOS DD 4.5) <- miglior DD
|
||||
CAGR ~47% mantenuta in entrambe le varianti combinate.
|
||||
|
||||
Due varianti operative selezionabili:
|
||||
weighting="equal" -> equal-weight sui 9 sleeve (massimo Sharpe)
|
||||
weighting="5050" -> 50% famiglia fade + 50% famiglia honest (minimo DD)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.combine_portfolio import ( # noqa: E402
|
||||
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
|
||||
)
|
||||
|
||||
|
||||
def master_returns(sleeves: dict, weighting: str = "equal"):
|
||||
"""Rendimenti giornalieri del portafoglio master.
|
||||
equal = equal-weight su tutti gli 11 sleeve; 5050 = media fra le due famiglie."""
|
||||
fade = {k: v for k, v in sleeves.items() if k.startswith("MR")}
|
||||
honest = {k: v for k, v in sleeves.items() if not k.startswith("MR")}
|
||||
if weighting == "5050":
|
||||
return (port_returns(fade) + port_returns(honest)) / 2
|
||||
return port_returns(sleeves)
|
||||
|
||||
|
||||
def run():
|
||||
sleeves = build_all_sleeves()
|
||||
fade = {k: v for k, v in sleeves.items() if k.startswith("MR")}
|
||||
honest = {k: v for k, v in sleeves.items() if not k.startswith("MR")}
|
||||
|
||||
print("=" * 90)
|
||||
print(f" PORT03 — MASTER (fade + honest) | {IDX[0].date()} -> {IDX[-1].date()} | OOS da {OOS_DATE}")
|
||||
print("=" * 90)
|
||||
print(f" {'portafoglio':<22s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
|
||||
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
|
||||
print(" " + "-" * 86)
|
||||
|
||||
def line(label, pr):
|
||||
f, o = metrics(pr), metrics(pr, lo=SPLIT)
|
||||
print(f" {label:<22s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
|
||||
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||
|
||||
line(f"FADE only ({len(fade)})", port_returns(fade))
|
||||
line(f"HONEST only ({len(honest)})", port_returns(honest))
|
||||
line(f"MASTER equal ({len(sleeves)})", master_returns(sleeves, "equal"))
|
||||
line("MASTER 50/50 fam", master_returns(sleeves, "5050"))
|
||||
|
||||
print(" " + "-" * 86)
|
||||
pa = yearly_returns(master_returns(sleeves, "equal"))
|
||||
print(" MASTER equal per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
|
||||
print(" -> combinare le due famiglie scorrelate (~0.05) abbassa il DD e alza lo Sharpe.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""PR01 — Pairs / Spread Mean-Reversion fra cripto (market-neutral). FAMIGLIA NUOVA.
|
||||
|
||||
Distinta da tutto l'esistente (single-asset direzionale): scommette sul RIENTRO del
|
||||
log-ratio di due cripto verso la sua media. Market-neutral (long A / short B) ->
|
||||
correlazione ~0.02 col mercato -> diversificatore prezioso.
|
||||
|
||||
Logica (engine onesto verificato in scripts/analysis/pairs_research.py):
|
||||
r[i] = log(closeA[i]/closeB[i]); z[i] = (r[i]-SMA_n(r)[i]) / STD_n(r)[i] (causale)
|
||||
z <= -z_in -> LONG ratio (long A / short B)
|
||||
z >= +z_in -> SHORT ratio (short A / long B)
|
||||
EXIT: |z| <= z_exit (rientro) o time-limit max_bars. Ingresso/uscita a close.
|
||||
Fee su 2 GAMBE = 2*fee_rt*lev (0.20% RT/coppia). Filtro candele sporche (salto>8%).
|
||||
|
||||
Validazione anti-overfit (netto, fee 0.20% RT/coppia a 2 gambe, leva 3x, OOS = ultimo
|
||||
30%, CONFIG UNIVERSALE n=50 z_in=2.0 z_exit=0.75 max_bars=72, 1h):
|
||||
ETH/BTC : CAGR 158% / Sharpe 4.36 / 8/9 anni+ (regge fee 6x)
|
||||
LTC/ETH : CAGR 92% / Sharpe 3.08 / 8/8 anni+
|
||||
ADA/ETH : CAGR 91% / Sharpe 2.69 / 7/8 anni+
|
||||
BTC/LTC : CAGR 60% / Sharpe 2.36 / 7/8 anni+ (robusta anche a 4h)
|
||||
ETH/SOL : CAGR 74% / Sharpe 1.96 / 5/7 anni+ (la piu' debole, DD ~63%)
|
||||
- No look-ahead verificato (z[i] invariato perturbando il futuro).
|
||||
- PLATEAU non picco: heatmap n x z_in -> 20/20 celle Sharpe>1 (ETH/BTC, BTC/LTC).
|
||||
- WALK-FORWARD (rolling train 2y / test 6m): ETH/BTC 11/12 finestre positive,
|
||||
BTC/LTC 9/10 -> edge distribuito su tutta la storia, non un regime singolo.
|
||||
- Stress costi: 5/6 reggono fee+slippage realistici; solo ETH/BTC regge fee 6x.
|
||||
- Correlazione con BTC daily ~0.02-0.08 -> market-neutral.
|
||||
- SCARTATA BNB/ETH: robusta solo coi suoi parametri (overfit), crolla con la universale.
|
||||
|
||||
LIMITE OPERATIVO: e' una strategia a 2 gambe (long un perp + short l'altro), il worker
|
||||
attuale e' single-leg. Per tradarla serve: (a) eseguibilita' short del perp B su
|
||||
Deribit/Bybit, (b) gestione 2 ordini + fee doppie. Finche' il worker non supporta
|
||||
2 gambe, PR01 resta validata in backtest ma non wired nel paper trader.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.pairs_research import pairs_sim, OOS_FRAC # noqa: E402
|
||||
|
||||
# CONFIG UNIVERSALE (anti-overfit): un'unica terna per TUTTE le coppie, niente
|
||||
# cherry-picking per-coppia. Validata come ALTOPIANO (heatmap n x z_in: 20/20 celle
|
||||
# Sharpe>1 su ETH/BTC e BTC/LTC) e walk-forward (ETH/BTC 11/12 finestre+, BTC/LTC 9/10).
|
||||
UNIV = dict(n=50, z_in=2.0, z_exit=0.75, max_bars=72)
|
||||
|
||||
# Coppie robuste con la config universale. Sempre alt-liquido vs major (mai alt/alt).
|
||||
# BNB/ETH SCARTATA: era robusta solo coi suoi parametri (n=30, z_exit=1.0) -> overfit;
|
||||
# con la config universale crolla (Sharpe 1.5, DD 71%) ed e' la prima a morire allo stress costi.
|
||||
PAIRS = [
|
||||
("ETH", "BTC", UNIV), # la migliore (Sharpe 4.4 univ), regge fee 6x
|
||||
("LTC", "ETH", UNIV),
|
||||
("ADA", "ETH", UNIV),
|
||||
("BTC", "LTC", UNIV), # robusta anche a 4h
|
||||
("ETH", "SOL", UNIV), # piu' debole (DD ~63%, storia SOL corta) -> peso ridotto
|
||||
]
|
||||
|
||||
|
||||
def run():
|
||||
print("=" * 92)
|
||||
print(" PR01 — PAIRS spread reversion (market-neutral) | netto fee 0.20% RT/coppia, leva 3x")
|
||||
print("=" * 92)
|
||||
print(f" {'coppia':<10s}{'trd':>5s}{'win%':>6s}{'CAGR%':>7s}{'OOS DD%':>8s}{'DD%':>6s}{'Shrp':>6s}{'anni+':>7s}")
|
||||
for a, b, p in PAIRS:
|
||||
f = pairs_sim(a, b, **p)
|
||||
o = pairs_sim(a, b, **p, split_frac=1 - OOS_FRAC)
|
||||
yrs = f["yearly"]; pos_y = sum(1 for v in yrs.values() if v > 0)
|
||||
print(f" {a+'/'+b:<10s}{f['trades']:>5d}{f['win']:>6.1f}{f['cagr']:>7.0f}{o['dd']:>8.0f}"
|
||||
f"{f['dd']:>6.0f}{f['sharpe']:>6.2f}{f'{pos_y}/{len(yrs)}':>7s}")
|
||||
print("\n Market-neutral (corr ~0.02-0.08 col mercato) -> ottimo diversificatore di portafoglio.")
|
||||
print(" Pattern: solo alt-liquido vs major (BTC/ETH); alt-vs-alt e' rumore.")
|
||||
print(" NB: 2 gambe (long A / short B), fee doppie. Worker live da estendere prima del live.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""ROT02 — Dual-Momentum Rotation (ROT01 + overlay di absolute momentum).
|
||||
|
||||
Evoluzione di ROT01: alla rotazione cross-sectional (forza relativa) aggiunge un
|
||||
overlay di ABSOLUTE momentum sul mercato: se BTC e' sotto la sua media a `regime_n`
|
||||
giorni (mercato risk-off), va completamente in CASH. Cosi' si evitano i bear di
|
||||
sistema (2022, 2026 YTD) che erano gli unici anni rossi di ROT01.
|
||||
|
||||
Risultato (netto, fee 0.10% RT, gross 0.45, OOS = ultimo 30%): MIGLIORA TUTTO
|
||||
rispetto a ROT01.
|
||||
ROT01 base : FULL +679% / OOS +44% / DD 53%
|
||||
ROT02 k2 SMA100 : FULL +1095% / OOS +98% / DD 40% (versione iniziale)
|
||||
ROT02 k3 SMA100 : FULL +1303% / OOS +68% / DD 26% <-- DD quasi dimezzato, PnL su
|
||||
|
||||
MIGLIORIA DD (top_k 2 -> 3): la versione iniziale concentrava il book su 2 soli
|
||||
asset; diversificare su 3 dimezza quasi il drawdown (40% -> 26%) e ALZA pure il
|
||||
ritorno full (+1095 -> +1303), ret/DD da 27 a 50. Il vol-target abbassa il DD ma
|
||||
sacrifica ritorno (solo de-leverage), quindi si tiene top_k=3 senza vol-target.
|
||||
Param-insensitive: top_k 3-4 e regime SMA100-150 danno risultati simili.
|
||||
Dettagli e sweep in scripts/analysis/honest_improve.py (rot_improved).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.honest_improve import rot_improved # noqa: E402
|
||||
|
||||
LOOKBACK, TOP_K, REGIME_N = 60, 3, 100 # top_k=3 (era 2): dimezza il DD
|
||||
|
||||
|
||||
def run():
|
||||
print("=" * 90)
|
||||
print(f" ROT02 DUAL-MOMENTUM | 1d lb={LOOKBACK} top{TOP_K} + cash se BTC<SMA{REGIME_N} | netto fee 0.10% RT")
|
||||
print("=" * 90)
|
||||
full = rot_improved(lookback=LOOKBACK, top_k=TOP_K, regime_n=REGIME_N)
|
||||
oos = rot_improved(lookback=LOOKBACK, top_k=TOP_K, regime_n=REGIME_N, oos_frac=0.30)
|
||||
print(f" FULL: {full['ret']:+.0f}% DD {full['dd']:.0f}% ({full['pos_years']}/{full['n_years']} anni positivi)")
|
||||
print(f" OOS : {oos['ret']:+.0f}% DD {oos['dd']:.0f}%")
|
||||
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(full["yearly"].items())))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""TR01 — EMA Trend Following (long-only), timeframe 4h.
|
||||
|
||||
Cavalca i trend rialzisti, si mette in cash nei downtrend. Niente short
|
||||
(shortare cripto perde OOS nel campione 2018-2026). Complementare a DIP01:
|
||||
DIP01 guadagna nei regimi di reversione, TR01 nei regimi di trend.
|
||||
|
||||
Logica:
|
||||
1. EMA fast (20) e EMA slow (100) sul close
|
||||
2. LONG quando EMA_fast > EMA_slow (uptrend), altrimenti CASH
|
||||
3. posizione continua, decisione a close[i] (no look-ahead);
|
||||
fee solo sui cambi di stato (poche operazioni = fee non letali)
|
||||
|
||||
Validazione (netto, fee 0.10% RT, leva 3x, pos 15%, OOS = ultimo 30%):
|
||||
robusto FULL+OOS su 5/8 asset: BNB(+14), BTC(+27), DOGE(+53), SOL(+7), XRP(+29) OOS.
|
||||
ETH ~flat, ADA/LTC negativi OOS -> preferire BNB/BTC/DOGE/SOL/XRP.
|
||||
Dettagli in scripts/analysis/honest_final.py / honest_trend.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.honest_trend import ( # noqa: E402
|
||||
simulate_position, ema_dual_signal, oos as trend_oos,
|
||||
)
|
||||
from scripts.analysis.honest_lab import get_df
|
||||
|
||||
ASSETS = ["BNB", "BTC", "DOGE", "SOL", "XRP"]
|
||||
FAST, SLOW, TF = 20, 100, "4h"
|
||||
|
||||
|
||||
def run():
|
||||
print("=" * 90)
|
||||
print(f" TR01 EMA TREND {FAST}/{SLOW} long-only | {TF} | netto fee 0.10% RT leva 3x pos 15%")
|
||||
print("=" * 90)
|
||||
print(f" {'Asset':<6s}{'Flip':>6s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniPos':>9s}")
|
||||
for a in ASSETS:
|
||||
df = get_df(a, TF)
|
||||
sig = ema_dual_signal(df, FAST, SLOW, long_only=True)
|
||||
f = simulate_position(sig, df)
|
||||
o = trend_oos(sig, df)
|
||||
print(f" {a:<6s}{f['flips']:>6d}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
|
||||
f"{f['dd']:>6.0f}{f['exposure']:>6.0f}{str(f['pos_years'])+'/'+str(f['n_years']):>9s}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,205 @@
|
||||
"""AD01 — Adaptive Squeeze Threshold.
|
||||
|
||||
Problema SQ02: sq_threshold fisso (0.8) non si adatta al regime di volatilità.
|
||||
Soluzione: threshold adattivo basato su volatilità recente.
|
||||
|
||||
Logica:
|
||||
- Calcola volatilità rolling (std dei rendimenti su finestra 100 barre)
|
||||
- Confronta con percentile storico (rolling 500 barre)
|
||||
- Alta vol (>70° percentile) → soglia BASSA (0.65) — squeeze più "lenti"
|
||||
- Bassa vol (<30° percentile) → soglia ALTA (0.90) — squeeze "stretti"
|
||||
- Vol media → soglia standard (0.80)
|
||||
|
||||
Razionale: in mercati calmi, il BB si stringe molto → sq_threshold alto cattura
|
||||
segnali migliori. In mercati volatili, bastano squeeze minori per essere significativi.
|
||||
|
||||
Anti-overfitting: solo 3 parametri (low_thr, mid_thr, high_thr), logica deterministica.
|
||||
Eredita antifakeout + volume da SQ02.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
||||
from src.strategies.indicators import keltner_ratio, ema
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
def _adaptive_sq_threshold(close: np.ndarray,
|
||||
vol_window: int = 100,
|
||||
regime_window: int = 500,
|
||||
low_thr: float = 0.65,
|
||||
mid_thr: float = 0.80,
|
||||
high_thr: float = 0.90) -> np.ndarray:
|
||||
"""Calcola sq_threshold adattivo per ogni barra."""
|
||||
n = len(close)
|
||||
lr = np.diff(np.log(np.where(close <= 0, 1e-10, close)))
|
||||
vol = np.full(n, np.nan)
|
||||
for i in range(vol_window, n):
|
||||
vol[i] = np.std(lr[i - vol_window:i])
|
||||
|
||||
# Percentile rolling della volatilità
|
||||
thresh = np.full(n, mid_thr)
|
||||
for i in range(regime_window, n):
|
||||
if np.isnan(vol[i]):
|
||||
continue
|
||||
hist = vol[i - regime_window:i]
|
||||
hist = hist[~np.isnan(hist)]
|
||||
if len(hist) < 10:
|
||||
continue
|
||||
p30 = np.percentile(hist, 30)
|
||||
p70 = np.percentile(hist, 70)
|
||||
if vol[i] < p30:
|
||||
thresh[i] = high_thr # vol bassa → soglia alta
|
||||
elif vol[i] > p70:
|
||||
thresh[i] = low_thr # vol alta → soglia bassa
|
||||
else:
|
||||
thresh[i] = mid_thr
|
||||
return thresh
|
||||
|
||||
|
||||
def _detect_adaptive_squeezes(close, high, low, kcr, adaptive_thr,
|
||||
min_dur: int = 5) -> list[dict]:
|
||||
"""Squeeze con threshold adattivo per ogni barra."""
|
||||
events = []
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
for i in range(1, len(close)):
|
||||
if np.isnan(kcr[i]) or np.isnan(adaptive_thr[i]):
|
||||
continue
|
||||
thr = adaptive_thr[i]
|
||||
is_sq = kcr[i] < thr
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
dur = i - sq_start
|
||||
if dur < min_dur:
|
||||
continue
|
||||
events.append({
|
||||
"idx": i, "dur": dur, "sq_start": sq_start,
|
||||
"kcr_at_release": kcr[i],
|
||||
"thr_used": adaptive_thr[i],
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
class AdaptiveSqueeze(Strategy):
|
||||
name = "AD01_adaptive_squeeze"
|
||||
description = "Squeeze con threshold adattivo a regime volatilità"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
fee_rt = 0.002
|
||||
leverage = 3.0
|
||||
position_size = 0.15
|
||||
initial_capital = 1000.0
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
bb_w = params.get("bb_window", 14)
|
||||
low_thr = params.get("low_thr", 0.65)
|
||||
mid_thr = params.get("mid_thr", 0.80)
|
||||
high_thr = params.get("high_thr", 0.90)
|
||||
retrace_limit = params.get("retrace_limit", 0.6)
|
||||
vol_mult = params.get("vol_multiplier", 1.3)
|
||||
use_vol = params.get("use_vol", True)
|
||||
vol_window = params.get("vol_window", 100)
|
||||
regime_window = params.get("regime_window", 500)
|
||||
|
||||
kcr = keltner_ratio(c, h, l, bb_w)
|
||||
adaptive_thr = _adaptive_sq_threshold(
|
||||
c, vol_window, regime_window, low_thr, mid_thr, high_thr
|
||||
)
|
||||
events = _detect_adaptive_squeezes(c, h, l, kcr, adaptive_thr)
|
||||
|
||||
signals = []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i < 1 or i >= n:
|
||||
continue
|
||||
|
||||
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
|
||||
# Anti-fakeout
|
||||
br = h[i] - l[i]
|
||||
if br > 0:
|
||||
if direction == 1 and (h[i] - c[i]) / br > retrace_limit:
|
||||
continue
|
||||
elif direction == -1 and (c[i] - l[i]) / br > retrace_limit:
|
||||
continue
|
||||
|
||||
# Volume confirm
|
||||
if use_vol:
|
||||
sq_start = ev["sq_start"]
|
||||
avg_sq_v = np.mean(v[sq_start:i])
|
||||
if avg_sq_v > 0 and v[i] <= avg_sq_v * vol_mult:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i,
|
||||
direction=direction,
|
||||
entry_price=c[i - 1],
|
||||
metadata={
|
||||
"dur": ev["dur"],
|
||||
"thr_used": ev.get("thr_used", mid_thr),
|
||||
},
|
||||
))
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = AdaptiveSqueeze()
|
||||
|
||||
configs = [
|
||||
# low_thr, mid_thr, high_thr, use_vol
|
||||
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.90, "use_vol": True},
|
||||
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.90, "use_vol": False},
|
||||
{"low_thr": 0.60, "mid_thr": 0.78, "high_thr": 0.92, "use_vol": True},
|
||||
{"low_thr": 0.70, "mid_thr": 0.82, "high_thr": 0.90, "use_vol": True},
|
||||
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.95, "use_vol": True},
|
||||
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.90,
|
||||
"use_vol": True, "vol_multiplier": 1.2},
|
||||
]
|
||||
|
||||
all_results = []
|
||||
for cfg in configs:
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["15m", "1h"]:
|
||||
for hold in [3, 6]:
|
||||
r = strategy.backtest(asset, tf, hold=hold, **cfg)
|
||||
if r and r.trades >= 20:
|
||||
lbl = (f"AD01 lt={cfg['low_thr']} ht={cfg['high_thr']} "
|
||||
f"v={cfg['use_vol']} h={hold}")
|
||||
r.strategy_name = lbl
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
|
||||
print(f"\n{'=' * 130}")
|
||||
print(" AD01 ADAPTIVE SQUEEZE THRESHOLD — TOP 20")
|
||||
print(f"{'=' * 130}")
|
||||
print(f" {'Nome':<50s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
|
||||
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
|
||||
f"{'Mkt%':>5s} {'Dur':>5s} {'Anni':>4s}")
|
||||
print(f" {'─' * 120}")
|
||||
for r in all_results[:20]:
|
||||
r.print_summary()
|
||||
|
||||
if all_results:
|
||||
all_results[0].print_yearly()
|
||||
|
||||
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250t, DD 6.5%, €5.23/day, 9 anni")
|
||||
print(f" BENCHMARK MT01: 82.7% acc, 503t, DD 5.9%")
|
||||
@@ -0,0 +1,183 @@
|
||||
"""CM01 — Cross-Market Momentum Filter.
|
||||
|
||||
Squeeze su asset primario, entra SOLO se l'altro asset (BTC↔ETH)
|
||||
mostra momentum short-term nella STESSA direzione.
|
||||
|
||||
Differenza da MT01: MT01 usa EMA slope su 1h (trend lento).
|
||||
CM01 usa rendimento grezzo degli ultimi 3-6 bar sull'asset cross
|
||||
(momentum veloce, stesso timeframe).
|
||||
|
||||
Razionale: BTC e ETH sono altamente correlati ma non perfettamente.
|
||||
Se BTC fa squeeze breakout UP e anche ETH sta salendo (momentum 3-6 bar),
|
||||
la probabilità di continuazione è maggiore perché c'è consenso di mercato.
|
||||
|
||||
Anti-overfitting: 1 parametro chiave (cross_bars 3-6), logica deterministica.
|
||||
Eredita antifakeout + volume da SQ02.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats
|
||||
from src.strategies.indicators import keltner_ratio, detect_squeezes
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
class CrossMarketMomentum(Strategy):
|
||||
name = "CM01_cross_momentum"
|
||||
description = "Squeeze + cross-asset short-term momentum filter"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
fee_rt = 0.002
|
||||
leverage = 3.0
|
||||
position_size = 0.15
|
||||
initial_capital = 1000.0
|
||||
|
||||
# Map asset → cross asset
|
||||
_CROSS = {"BTC": "ETH", "ETH": "BTC"}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
"""Genera segnali con cross-market momentum."""
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
ts_ms = df["timestamp"].values
|
||||
|
||||
asset = params.get("asset", "BTC")
|
||||
tf = params.get("tf", "15m")
|
||||
bb_w = params.get("bb_window", 14)
|
||||
sq_thr = params.get("sq_threshold", 0.8)
|
||||
retrace_limit = params.get("retrace_limit", 0.6)
|
||||
vol_mult = params.get("vol_multiplier", 1.3)
|
||||
use_vol = params.get("use_vol", True)
|
||||
cross_bars = params.get("cross_bars", 4) # barre momentum cross
|
||||
mom_min = params.get("mom_min", 0.0) # momentum minimo (0 = solo direzione)
|
||||
|
||||
# Carica cross asset
|
||||
cross_asset = self._CROSS.get(asset)
|
||||
if cross_asset is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
df_cross = load_data(cross_asset, tf)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
c_cross = df_cross["close"].values
|
||||
ts_cross_ms = df_cross["timestamp"].values
|
||||
n_cross = len(c_cross)
|
||||
|
||||
# Momentum cross: rendimento log su cross_bars barre
|
||||
cross_mom = np.full(n_cross, np.nan)
|
||||
for i in range(cross_bars, n_cross):
|
||||
if c_cross[i - cross_bars] > 0:
|
||||
cross_mom[i] = np.log(c_cross[i] / c_cross[i - cross_bars])
|
||||
|
||||
kcr = keltner_ratio(c, h, l, bb_w)
|
||||
events = detect_squeezes(c, h, l, kcr, sq_thr)
|
||||
|
||||
signals = []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i < 1 or i >= n:
|
||||
continue
|
||||
|
||||
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
|
||||
# Anti-fakeout
|
||||
br = h[i] - l[i]
|
||||
if br > 0:
|
||||
if direction == 1 and (h[i] - c[i]) / br > retrace_limit:
|
||||
continue
|
||||
elif direction == -1 and (c[i] - l[i]) / br > retrace_limit:
|
||||
continue
|
||||
|
||||
# Volume confirm
|
||||
if use_vol:
|
||||
sq_start = ev["sq_start"]
|
||||
avg_sq_v = np.mean(v[sq_start:i])
|
||||
if avg_sq_v > 0 and v[i] <= avg_sq_v * vol_mult:
|
||||
continue
|
||||
|
||||
# Cross-market momentum: trova indice cross corrispondente
|
||||
i_cross = np.searchsorted(ts_cross_ms, ts_ms[i]) - 1
|
||||
if i_cross < cross_bars or i_cross >= n_cross:
|
||||
continue
|
||||
mom = cross_mom[i_cross]
|
||||
if np.isnan(mom):
|
||||
continue
|
||||
|
||||
# Filtra per direzione concordante
|
||||
if direction == 1 and mom <= mom_min:
|
||||
continue
|
||||
if direction == -1 and mom >= -mom_min:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i,
|
||||
direction=direction,
|
||||
entry_price=c[i - 1],
|
||||
metadata={
|
||||
"dur": ev["dur"],
|
||||
"cross_mom": float(mom),
|
||||
},
|
||||
))
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = CrossMarketMomentum()
|
||||
|
||||
configs = [
|
||||
# cross_bars, mom_min, use_vol
|
||||
{"cross_bars": 3, "mom_min": 0.0, "use_vol": True},
|
||||
{"cross_bars": 4, "mom_min": 0.0, "use_vol": True},
|
||||
{"cross_bars": 6, "mom_min": 0.0, "use_vol": True},
|
||||
{"cross_bars": 4, "mom_min": 0.001, "use_vol": True},
|
||||
{"cross_bars": 4, "mom_min": 0.002, "use_vol": True},
|
||||
{"cross_bars": 4, "mom_min": 0.0, "use_vol": False},
|
||||
{"cross_bars": 3, "mom_min": 0.001, "use_vol": False},
|
||||
{"cross_bars": 6, "mom_min": 0.001, "use_vol": True},
|
||||
]
|
||||
|
||||
all_results = []
|
||||
for cfg in configs:
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["15m", "1h"]:
|
||||
for hold in [3, 6]:
|
||||
r = strategy.backtest(asset, tf, hold=hold,
|
||||
cross_bars=cfg["cross_bars"],
|
||||
mom_min=cfg["mom_min"],
|
||||
use_vol=cfg["use_vol"])
|
||||
if r and r.trades >= 20:
|
||||
lbl = (f"CM01 cb={cfg['cross_bars']} "
|
||||
f"mm={cfg['mom_min']} v={cfg['use_vol']} h={hold}")
|
||||
r.strategy_name = lbl
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
|
||||
print(f"\n{'=' * 130}")
|
||||
print(" CM01 CROSS-MARKET MOMENTUM — TOP 20")
|
||||
print(f"{'=' * 130}")
|
||||
print(f" {'Nome':<50s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
|
||||
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
|
||||
f"{'Mkt%':>5s} {'Dur':>5s} {'Anni':>4s}")
|
||||
print(f" {'─' * 120}")
|
||||
for r in all_results[:20]:
|
||||
r.print_summary()
|
||||
|
||||
if all_results:
|
||||
all_results[0].print_yearly()
|
||||
|
||||
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250t, DD 6.5%, €5.23/day, 9 anni")
|
||||
print(f" BENCHMARK MT01: 82.7% acc, 503t, DD 5.9%")
|
||||
@@ -0,0 +1,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,82 @@
|
||||
"""MR03 — Keltner Fade (mean-reversion sul canale ATR).
|
||||
|
||||
Stessa tesi di MR01 (i breakout rientrano) ma con banda costruita su ATR
|
||||
attorno a una EMA, invece che su deviazione standard attorno a una SMA.
|
||||
Reagisce diversamente a gap e code: edge indipendente, non ridondante con MR01.
|
||||
|
||||
Logica:
|
||||
1. Canale di Keltner: EMA(n) +/- k*ATR(n)
|
||||
2. ENTRY: close esce sotto la banda inferiore -> LONG (o sopra la superiore -> SHORT)
|
||||
Ingresso a close[i] (eseguibile dal vivo, nessun look-ahead).
|
||||
3. EXIT: take-profit alla EMA centrale (il rientro atteso),
|
||||
stop-loss a sl_atr*ATR oltre l'estremo, time-limit max_bars.
|
||||
|
||||
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
|
||||
BTC 1h n=30 k=2.0: +112% OOS, DD 20%
|
||||
ETH 1h n=50 k=1.5: +1426% OOS, DD 20%
|
||||
Robusto su TUTTA la griglia n in {14,20,30,50} x k in {1.5,2.0,2.5}
|
||||
(BTC+ETH 1h sempre positivo OOS).
|
||||
Ricerca completa: scripts/analysis/strategy_research_v2.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Signal
|
||||
from src.strategies.fade_base import FadeStrategy, atr, trend_distance
|
||||
|
||||
|
||||
class KeltnerFade(FadeStrategy):
|
||||
name = "MR03_keltner_fade"
|
||||
description = "Mean-reversion: fada il canale di Keltner (ATR), TP alla EMA"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["1h"]
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
n = params.get("n", 30)
|
||||
k = params.get("k", 2.0)
|
||||
sl_atr = params.get("sl_atr", 2.0)
|
||||
max_bars = params.get("max_bars", 24)
|
||||
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||
ema_long = params.get("ema_long", 200)
|
||||
|
||||
c = df["close"].values
|
||||
e = pd.Series(c).ewm(span=n, adjust=False).mean().values
|
||||
a = atr(df, n)
|
||||
up, lo = e + k * a, e - k * a
|
||||
td = trend_distance(df, ema_long) if trend_max is not None else None
|
||||
|
||||
signals: list[Signal] = []
|
||||
for i in range(n + 1, len(c)):
|
||||
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
||||
continue
|
||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||
d, sl = 1, c[i] - sl_atr * a[i]
|
||||
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||
d, sl = -1, c[i] + sl_atr * a[i]
|
||||
else:
|
||||
continue
|
||||
signals.append(Signal(
|
||||
idx=i, direction=d, entry_price=c[i],
|
||||
metadata={"tp": float(e[i]), "sl": float(sl), "max_bars": max_bars},
|
||||
))
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strat = KeltnerFade()
|
||||
print("=" * 110)
|
||||
print(f" MR03 KELTNER FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||
print("=" * 110)
|
||||
for asset, n, k in [("BTC", 30, 2.0), ("ETH", 50, 1.5)]:
|
||||
r = strat.backtest(asset, "1h", n=n, k=k, sl_atr=2.0, max_bars=24)
|
||||
if r:
|
||||
r.strategy_name = f"MR03 {asset} 1h n{n} k{k}"
|
||||
r.print_summary()
|
||||
r.print_yearly()
|
||||
@@ -0,0 +1,261 @@
|
||||
"""MT01 — Squeeze + Multi-Timeframe Momentum.
|
||||
|
||||
Problema SQ02: entra al breakout 15m ma non sa se il trend 1h è allineato.
|
||||
Soluzione: squeeze su 15m + conferma momentum su 1h.
|
||||
|
||||
Anti-overfitting: usa solo 2 indicatori (squeeze + EMA slope),
|
||||
nessun parametro complesso.
|
||||
|
||||
IN:
|
||||
- OHLCV 15m + 1h per lo stesso asset
|
||||
- Parametri: sq_threshold, ema_period_1h, min_slope
|
||||
|
||||
OUT:
|
||||
- Signal al breakout 15m confermato da trend 1h
|
||||
- BacktestResult
|
||||
|
||||
Logica:
|
||||
1. Squeeze release su 15m (come SQ01)
|
||||
2. Antifakeout filter (come SQ02)
|
||||
3. Check 1h: EMA slope positiva per long, negativa per short
|
||||
4. Check 1h: prezzo sopra/sotto EMA per conferma trend
|
||||
5. Entra solo se 15m e 1h concordano
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
||||
from src.strategies.indicators import keltner_ratio, detect_squeezes, ema
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
class SqueezeMTFMomentum(Strategy):
|
||||
name = "MT01_squeeze_mtf"
|
||||
description = "Squeeze 15m + momentum trend 1h — multi-timeframe"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m"]
|
||||
fee_rt = 0.002
|
||||
|
||||
def generate_signals(self, df, ts, **params):
|
||||
"""Genera segnali squeeze 15m confermati da trend 1h."""
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
asset = params.get("asset", "BTC")
|
||||
sq_thr = params.get("sq_threshold", 0.8)
|
||||
ema_period = params.get("ema_period", 50)
|
||||
min_slope_val = params.get("min_slope", 0.001)
|
||||
use_antifake = params.get("antifake", True)
|
||||
use_vol = params.get("vol_filter", False)
|
||||
|
||||
kcr = keltner_ratio(c, h, l, 14)
|
||||
events = detect_squeezes(c, h, l, kcr, sq_thr)
|
||||
|
||||
df_1h = params.get("df_1h")
|
||||
if df_1h is None:
|
||||
df_1h = load_data(asset, "1h")
|
||||
c1h = df_1h["close"].values
|
||||
ts1h_ms = df_1h["timestamp"].values
|
||||
n1h = len(c1h)
|
||||
ema_1h = ema(c1h, ema_period)
|
||||
ema_slope_arr = np.full(n1h, np.nan)
|
||||
for i in range(5, n1h):
|
||||
if not np.isnan(ema_1h[i]) and not np.isnan(ema_1h[i-5]) and ema_1h[i-5] > 0:
|
||||
ema_slope_arr[i] = (ema_1h[i] - ema_1h[i-5]) / ema_1h[i-5]
|
||||
|
||||
ts_ms = df["timestamp"].values
|
||||
signals = []
|
||||
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i < 1 or i >= n:
|
||||
continue
|
||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
if use_antifake:
|
||||
br = h[i] - l[i]
|
||||
if br > 0:
|
||||
if c[i] > c[i-1] and (h[i] - c[i]) / br > 0.6:
|
||||
continue
|
||||
elif c[i] <= c[i-1] and (c[i] - l[i]) / br > 0.6:
|
||||
continue
|
||||
if use_vol:
|
||||
avg_v = np.mean(v[ev["sq_start"]:i])
|
||||
if avg_v > 0 and v[i] <= avg_v * 1.3:
|
||||
continue
|
||||
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
i1h = np.searchsorted(ts1h_ms, ts_ms[i]) - 1
|
||||
if i1h < ema_period or i1h >= n1h:
|
||||
continue
|
||||
if np.isnan(ema_1h[i1h]) or np.isnan(ema_slope_arr[i1h]):
|
||||
continue
|
||||
if direction == 1:
|
||||
if c1h[i1h] < ema_1h[i1h] or ema_slope_arr[i1h] < min_slope_val:
|
||||
continue
|
||||
else:
|
||||
if c1h[i1h] > ema_1h[i1h] or ema_slope_arr[i1h] > -min_slope_val:
|
||||
continue
|
||||
|
||||
signals.append(Signal(idx=i, direction=direction, entry_price=c[i-1]))
|
||||
|
||||
return signals
|
||||
|
||||
def backtest(self, asset, tf="15m", hold=3, **params):
|
||||
sq_thr = params.get("sq_threshold", 0.8)
|
||||
ema_period = params.get("ema_period", 50)
|
||||
min_slope = params.get("min_slope", 0.001)
|
||||
use_antifake = params.get("antifake", True)
|
||||
use_vol = params.get("vol_filter", False)
|
||||
|
||||
# Carica 15m e 1h
|
||||
df_15m = load_data(asset, "15m")
|
||||
df_1h = load_data(asset, "1h")
|
||||
|
||||
c15 = df_15m["close"].values
|
||||
h15 = df_15m["high"].values
|
||||
l15 = df_15m["low"].values
|
||||
v15 = df_15m["volume"].values
|
||||
n15 = len(c15)
|
||||
ts15 = pd.to_datetime(df_15m["timestamp"], unit="ms", utc=True)
|
||||
ts15_ms = df_15m["timestamp"].values
|
||||
|
||||
c1h = df_1h["close"].values
|
||||
ts1h_ms = df_1h["timestamp"].values
|
||||
n1h = len(c1h)
|
||||
|
||||
kcr = keltner_ratio(c15, h15, l15, 14)
|
||||
events = detect_squeezes(c15, h15, l15, kcr, sq_thr)
|
||||
|
||||
# EMA su 1h
|
||||
ema_1h = ema(c1h, ema_period)
|
||||
|
||||
# EMA slope (variazione percentuale su 5 barre)
|
||||
ema_slope = np.full(n1h, np.nan)
|
||||
for i in range(5, n1h):
|
||||
if not np.isnan(ema_1h[i]) and not np.isnan(ema_1h[i - 5]) and ema_1h[i - 5] > 0:
|
||||
ema_slope[i] = (ema_1h[i] - ema_1h[i - 5]) / ema_1h[i - 5]
|
||||
|
||||
yearly = {}
|
||||
capital = float(self.initial_capital)
|
||||
peak = capital
|
||||
max_dd = 0.0
|
||||
total_bars = 0
|
||||
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i + hold + 1 >= n15 or i < 1:
|
||||
continue
|
||||
|
||||
first_ret = (c15[i] - c15[i - 1]) / c15[i - 1] if c15[i - 1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
|
||||
# Antifake
|
||||
if use_antifake:
|
||||
br = h15[i] - l15[i]
|
||||
if br > 0:
|
||||
if c15[i] > c15[i - 1] and (h15[i] - c15[i]) / br > 0.6:
|
||||
continue
|
||||
elif c15[i] <= c15[i - 1] and (c15[i] - l15[i]) / br > 0.6:
|
||||
continue
|
||||
|
||||
# Volume filter
|
||||
if use_vol:
|
||||
avg_v = np.mean(v15[ev["sq_start"]:i])
|
||||
if avg_v > 0 and v15[i] <= avg_v * 1.3:
|
||||
continue
|
||||
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
|
||||
# Trova indice 1h corrispondente
|
||||
i1h = np.searchsorted(ts1h_ms, ts15_ms[i]) - 1
|
||||
if i1h < ema_period or i1h >= n1h or np.isnan(ema_1h[i1h]) or np.isnan(ema_slope[i1h]):
|
||||
continue
|
||||
|
||||
# Conferma trend 1h
|
||||
if direction == 1:
|
||||
if c1h[i1h] < ema_1h[i1h]:
|
||||
continue
|
||||
if ema_slope[i1h] < min_slope:
|
||||
continue
|
||||
else:
|
||||
if c1h[i1h] > ema_1h[i1h]:
|
||||
continue
|
||||
if ema_slope[i1h] > -min_slope:
|
||||
continue
|
||||
|
||||
entry = c15[i - 1]
|
||||
exit_price = c15[min(i + hold - 1, n15 - 1)]
|
||||
actual = (exit_price - entry) / entry * direction
|
||||
net = actual * self.leverage - self.fee_rt * self.leverage
|
||||
|
||||
capital += capital * self.position_size * net
|
||||
capital = max(capital, 10)
|
||||
if capital > peak: peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
total_bars += hold
|
||||
|
||||
year = ts15.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0: yearly[year]["w"] += 1
|
||||
yearly[year]["pnl"] += net * self.initial_capital
|
||||
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t == 0:
|
||||
return None
|
||||
|
||||
yearly_stats = [YearlyStats(y, d["t"], d["w"], d["pnl"]) for y, d in sorted(yearly.items())]
|
||||
return BacktestResult(
|
||||
strategy_name=self.name, asset=asset, timeframe="15m", params=params,
|
||||
trades=all_t, wins=all_w, pnl=sum(d["pnl"] for d in yearly.values()),
|
||||
capital=capital, initial_capital=self.initial_capital,
|
||||
max_dd=max_dd * 100, time_in_market_pct=total_bars / n15 * 100,
|
||||
avg_trade_duration_h=hold * 15 / 60, years_active=len(yearly), yearly=yearly_stats,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = SqueezeMTFMomentum()
|
||||
|
||||
configs = [
|
||||
("ema50 sl0.1%", {"ema_period": 50, "min_slope": 0.001}),
|
||||
("ema50 sl0.05%", {"ema_period": 50, "min_slope": 0.0005}),
|
||||
("ema50 sl0.2%", {"ema_period": 50, "min_slope": 0.002}),
|
||||
("ema20 sl0.1%", {"ema_period": 20, "min_slope": 0.001}),
|
||||
("ema50 sl0.1%+vol", {"ema_period": 50, "min_slope": 0.001, "vol_filter": True}),
|
||||
("ema20 sl0.1%+vol", {"ema_period": 20, "min_slope": 0.001, "vol_filter": True}),
|
||||
("ema50 noAF", {"ema_period": 50, "min_slope": 0.001, "antifake": False}),
|
||||
("ema100 sl0.05%", {"ema_period": 100, "min_slope": 0.0005}),
|
||||
]
|
||||
|
||||
all_results = []
|
||||
for label, params in configs:
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for hold in [3, 6]:
|
||||
r = strategy.backtest(asset, "15m", hold=hold, **params)
|
||||
if r and r.trades >= 30:
|
||||
r.strategy_name = f"MT01 {label} h={hold}"
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
print(f"\n{'=' * 130}")
|
||||
print(f" MT01 SQUEEZE + MTF MOMENTUM — TOP 20")
|
||||
print(f"{'=' * 130}")
|
||||
for r in all_results[:20]:
|
||||
r.print_summary()
|
||||
if all_results:
|
||||
all_results[0].print_yearly()
|
||||
|
||||
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250t, DD 6.5%, 9 anni, €5.23/day")
|
||||
@@ -0,0 +1,158 @@
|
||||
"""PD01 — Price-Volume Divergence Squeeze.
|
||||
|
||||
Estende SQ02 con volume TREND come filtro:
|
||||
- Breakout UP con volume CRESCENTE (ultimi 3 bar vs media squeeze) → ENTRA
|
||||
- Breakout UP con volume CALANTE → SALTA (divergenza bearish)
|
||||
- Viceversa per short
|
||||
|
||||
Logica anti-fakeout:
|
||||
1. Squeeze rilascio (come SQ02)
|
||||
2. Anti-fakeout candela (come SQ02)
|
||||
3. Volume al breakout > media squeeze (come SQ02)
|
||||
4. NUOVO: volume trending UP nelle ultime 3 barre prima del breakout
|
||||
|
||||
Parametri semplici, nessun overfitting.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal
|
||||
from src.strategies.indicators import keltner_ratio, detect_squeezes
|
||||
|
||||
|
||||
class PriceVolumeDivergence(Strategy):
|
||||
name = "PD01_price_vol_div"
|
||||
description = "Squeeze + antifakeout + volume trend confirmation"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
fee_rt = 0.002
|
||||
leverage = 3.0
|
||||
position_size = 0.15
|
||||
initial_capital = 1000.0
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
bb_w = params.get("bb_window", 14)
|
||||
sq_thr = params.get("sq_threshold", 0.8)
|
||||
retrace_limit = params.get("retrace_limit", 0.6)
|
||||
vol_mult = params.get("vol_multiplier", 1.3)
|
||||
vol_trend_bars = params.get("vol_trend_bars", 3) # barre per trend volume
|
||||
|
||||
kcr = keltner_ratio(c, h, l, bb_w)
|
||||
events = detect_squeezes(c, h, l, kcr, sq_thr)
|
||||
|
||||
signals = []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i < vol_trend_bars + 1 or i >= n:
|
||||
continue
|
||||
|
||||
# Direzione breakout
|
||||
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
|
||||
# Anti-fakeout
|
||||
br = h[i] - l[i]
|
||||
if br > 0:
|
||||
if direction == 1 and (h[i] - c[i]) / br > retrace_limit:
|
||||
continue
|
||||
elif direction == -1 and (c[i] - l[i]) / br > retrace_limit:
|
||||
continue
|
||||
|
||||
# Volume al breakout > media squeeze
|
||||
sq_start = ev["sq_start"]
|
||||
avg_sq_v = np.mean(v[sq_start:i])
|
||||
if avg_sq_v <= 0 or v[i] <= avg_sq_v * vol_mult:
|
||||
continue
|
||||
|
||||
# Volume TREND: slope delle ultime vol_trend_bars barre
|
||||
# Usa regressione lineare semplice (rank correlation del volume)
|
||||
recent_v = v[i - vol_trend_bars:i + 1] # include breakout bar
|
||||
if len(recent_v) < vol_trend_bars:
|
||||
continue
|
||||
# slope: media seconda metà vs prima metà
|
||||
mid = len(recent_v) // 2
|
||||
v_early = np.mean(recent_v[:mid])
|
||||
v_late = np.mean(recent_v[mid:])
|
||||
vol_trending_up = v_late > v_early
|
||||
vol_trending_down = v_early > v_late
|
||||
|
||||
# Concordanza: long richiede volume trending up, short trending down
|
||||
if direction == 1 and not vol_trending_up:
|
||||
continue
|
||||
if direction == -1 and not vol_trending_down:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i,
|
||||
direction=direction,
|
||||
entry_price=c[i - 1],
|
||||
metadata={
|
||||
"dur": ev["dur"],
|
||||
"vol_ratio": v[i] / avg_sq_v if avg_sq_v > 0 else 0,
|
||||
"vol_trend": v_late / v_early if v_early > 0 else 1,
|
||||
},
|
||||
))
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = PriceVolumeDivergence()
|
||||
|
||||
configs = [
|
||||
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.6,
|
||||
"vol_multiplier": 1.3, "vol_trend_bars": 3},
|
||||
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.6,
|
||||
"vol_multiplier": 1.2, "vol_trend_bars": 3},
|
||||
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.6,
|
||||
"vol_multiplier": 1.3, "vol_trend_bars": 5},
|
||||
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.5,
|
||||
"vol_multiplier": 1.3, "vol_trend_bars": 3},
|
||||
{"bb_window": 14, "sq_threshold": 0.75, "retrace_limit": 0.6,
|
||||
"vol_multiplier": 1.3, "vol_trend_bars": 3},
|
||||
{"bb_window": 20, "sq_threshold": 0.8, "retrace_limit": 0.6,
|
||||
"vol_multiplier": 1.3, "vol_trend_bars": 3},
|
||||
]
|
||||
|
||||
all_results = []
|
||||
for cfg in configs:
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["15m", "1h"]:
|
||||
for hold in [3, 6]:
|
||||
r = strategy.backtest(asset, tf, hold=hold, **cfg)
|
||||
if r and r.trades >= 20:
|
||||
lbl = (f"PD01 vtb={cfg['vol_trend_bars']} "
|
||||
f"vm={cfg['vol_multiplier']} "
|
||||
f"sq={cfg['sq_threshold']} h={hold}")
|
||||
r.strategy_name = lbl
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
|
||||
print(f"\n{'=' * 130}")
|
||||
print(" PD01 PRICE-VOLUME DIVERGENCE — TOP 20")
|
||||
print(f"{'=' * 130}")
|
||||
print(f" {'Nome':<50s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
|
||||
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
|
||||
f"{'Mkt%':>5s} {'Dur':>5s} {'Anni':>4s}")
|
||||
print(f" {'─' * 120}")
|
||||
for r in all_results[:20]:
|
||||
r.print_summary()
|
||||
|
||||
if all_results:
|
||||
all_results[0].print_yearly()
|
||||
|
||||
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250t, DD 6.5%, €5.23/day, 9 anni")
|
||||
print(f" BENCHMARK MT01: 82.7% acc, 503t, DD 5.9%")
|
||||
@@ -0,0 +1,317 @@
|
||||
"""S3-01: Squeeze Migliorato — test per-anno, dati reali.
|
||||
Miglioramenti rispetto al squeeze base:
|
||||
1. Cross-asset: squeeze su BTC + ETH contemporaneo = segnale più forte
|
||||
2. Timing orario: accuracy per fascia oraria
|
||||
3. Squeeze duration weighted: squeeze lunghi → breakout più forti
|
||||
4. Dual-timeframe: squeeze su 1h confermato da 15m
|
||||
5. Anti-fakeout: skip se candela post-breakout ritraccia >50%
|
||||
6. Dynamic exit: trailing stop basato su ATR
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_RT = 0.002
|
||||
INITIAL = 1000
|
||||
LEVERAGE = 3
|
||||
|
||||
|
||||
def keltner_ratio(close, high, low, window=14):
|
||||
n = len(close)
|
||||
r = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc = (ma+1.5*atr)-(ma-1.5*atr)
|
||||
bb = (ma+2*bb_std)-(ma-2*bb_std)
|
||||
if kc > 0:
|
||||
r[i] = bb/kc
|
||||
return r
|
||||
|
||||
|
||||
def atr_calc(high, low, close, period=14):
|
||||
tr = np.maximum(high-low, np.maximum(np.abs(high-np.roll(close,1)), np.abs(low-np.roll(close,1))))
|
||||
tr[0] = high[0]-low[0]
|
||||
r = np.full(len(close), np.nan)
|
||||
r[period-1] = np.mean(tr[:period])
|
||||
k = 2/(period+1)
|
||||
for i in range(period, len(close)):
|
||||
r[i] = tr[i]*k + r[i-1]*(1-k)
|
||||
return r
|
||||
|
||||
|
||||
def detect_squeezes(close, high, low, volume, kcr, sq_thr=0.8, min_dur=5):
|
||||
"""Ritorna lista di squeeze events con metadata."""
|
||||
events = []
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
n = len(close)
|
||||
|
||||
for i in range(1, n):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < sq_thr
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
dur = i - sq_start
|
||||
if dur < min_dur:
|
||||
continue
|
||||
avg_vol = np.mean(volume[sq_start:i])
|
||||
# Range durante squeeze
|
||||
sq_range = (np.max(high[sq_start:i]) - np.min(low[sq_start:i])) / close[sq_start] if close[sq_start] > 0 else 0
|
||||
events.append({
|
||||
"release_idx": i,
|
||||
"duration": dur,
|
||||
"avg_vol": avg_vol,
|
||||
"squeeze_range": sq_range,
|
||||
"kcr_at_release": kcr[i],
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
def run_improved_squeeze(primary_asset, tf="1h"):
|
||||
# Carica asset primario
|
||||
df = load_data(primary_asset, tf)
|
||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
||||
n = len(df)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
ts_ms = df["timestamp"].values
|
||||
|
||||
kcr = keltner_ratio(c, h, l, 14)
|
||||
atr_14 = atr_calc(h, l, c, 14)
|
||||
events = detect_squeezes(c, h, l, v, kcr)
|
||||
|
||||
# Carica asset secondario per cross-check
|
||||
secondary = "BTC" if primary_asset == "ETH" else "ETH"
|
||||
df2 = load_data(secondary, tf)
|
||||
c2, h2, l2 = df2["close"].values, df2["high"].values, df2["low"].values
|
||||
ts2_ms = df2["timestamp"].values
|
||||
kcr2 = keltner_ratio(c2, h2, l2, 14)
|
||||
|
||||
# Mappa ts2 → indici per allineare
|
||||
def find_idx2(ts_val):
|
||||
idx = np.searchsorted(ts2_ms, ts_val)
|
||||
return min(idx, len(c2)-1)
|
||||
|
||||
# Carica 15m per dual-TF
|
||||
if tf == "1h":
|
||||
df_15m = load_data(primary_asset, "15m")
|
||||
c15 = df_15m["close"].values
|
||||
h15 = df_15m["high"].values
|
||||
l15 = df_15m["low"].values
|
||||
ts15 = df_15m["timestamp"].values
|
||||
kcr_15m = keltner_ratio(c15, h15, l15, 14)
|
||||
else:
|
||||
kcr_15m = None
|
||||
ts15 = None
|
||||
|
||||
# ================================================================
|
||||
# CONFIGURAZIONI
|
||||
# ================================================================
|
||||
configs = [
|
||||
# (name, use_cross, use_timing, use_duration, use_dual_tf, use_antifake, use_trailing, hold, stop_atr)
|
||||
("BASE", False, False, False, False, False, False, 3, 0),
|
||||
("cross_asset", True, False, False, False, False, False, 3, 0),
|
||||
("timing_filter", False, True, False, False, False, False, 3, 0),
|
||||
("long_squeeze", False, False, True, False, False, False, 3, 0),
|
||||
("dual_tf", False, False, False, True, False, False, 3, 0),
|
||||
("anti_fakeout", False, False, False, False, True, False, 3, 0),
|
||||
("trailing_stop", False, False, False, False, False, True, 6, 1.5),
|
||||
("cross+timing", True, True, False, False, False, False, 3, 0),
|
||||
("cross+long+timing", True, True, True, False, False, False, 3, 0),
|
||||
("cross+dual_tf", True, False, False, True, False, False, 3, 0),
|
||||
("ALL_FILTERS", True, True, True, True, True, False, 3, 0),
|
||||
("ALL+trailing", True, True, True, True, True, True, 6, 1.5),
|
||||
("cross+antifake", True, False, False, False, True, False, 3, 0),
|
||||
("timing+antifake", False, True, False, False, True, False, 3, 0),
|
||||
("cross+timing+antifk", True, True, False, False, True, False, 3, 0),
|
||||
("cross+timing+trail", True, True, False, False, False, True, 6, 1.5),
|
||||
]
|
||||
|
||||
print(f"\n{'#'*75}")
|
||||
print(f" {primary_asset} {tf} — SQUEEZE MIGLIORATO")
|
||||
print(f"{'#'*75}")
|
||||
|
||||
results = []
|
||||
|
||||
for name, f_cross, f_timing, f_dur, f_dual, f_antifake, f_trail, hold, stop_atr_m in configs:
|
||||
yearly = {}
|
||||
capital = float(INITIAL)
|
||||
peak = capital
|
||||
max_dd = 0
|
||||
|
||||
for ev in events:
|
||||
i = ev["release_idx"]
|
||||
if i + hold + 2 >= n:
|
||||
continue
|
||||
|
||||
# --- FILTRI ---
|
||||
skip = False
|
||||
|
||||
# Cross-asset: secondary deve anche essere in squeeze recente o breakout
|
||||
if f_cross:
|
||||
i2 = find_idx2(ts_ms[i])
|
||||
if i2 >= 5:
|
||||
sec_in_squeeze = any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85 for j in range(max(0,i2-10), i2+1))
|
||||
if not sec_in_squeeze:
|
||||
skip = True
|
||||
|
||||
# Timing: solo certe ore (testato: 6-14 UTC migliori)
|
||||
if f_timing:
|
||||
hour = ts.iloc[i].hour
|
||||
if hour < 4 or hour > 16:
|
||||
skip = True
|
||||
|
||||
# Duration: solo squeeze > 10 barre
|
||||
if f_dur:
|
||||
if ev["duration"] < 10:
|
||||
skip = True
|
||||
|
||||
# Dual-TF: squeeze anche su 15m
|
||||
if f_dual and kcr_15m is not None and ts15 is not None:
|
||||
i15 = np.searchsorted(ts15, ts_ms[i])
|
||||
if i15 >= 5:
|
||||
sq_15m = any(not np.isnan(kcr_15m[j]) and kcr_15m[j] < 0.85 for j in range(max(0,i15-20), i15+1))
|
||||
if not sq_15m:
|
||||
skip = True
|
||||
|
||||
# Anti-fakeout: prima candela post-breakout non deve ritracciare >50%
|
||||
if f_antifake and i + 1 < n:
|
||||
breakout_bar_range = h[i] - l[i]
|
||||
if breakout_bar_range > 0:
|
||||
if c[i] > c[i-1]: # breakout up
|
||||
retrace = (h[i] - c[i]) / breakout_bar_range
|
||||
else: # breakout down
|
||||
retrace = (c[i] - l[i]) / breakout_bar_range
|
||||
if retrace > 0.6:
|
||||
skip = True
|
||||
|
||||
if skip:
|
||||
continue
|
||||
|
||||
# --- DIREZIONE ---
|
||||
first_ret = (c[i] - c[i-1]) / c[i-1]
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
|
||||
# --- EXIT ---
|
||||
entry = c[i-1]
|
||||
if f_trail and not np.isnan(atr_14[i]):
|
||||
# Trailing stop
|
||||
trail_dist = atr_14[i] * stop_atr_m
|
||||
best_price = entry
|
||||
exit_price = c[min(i+hold, n-1)]
|
||||
for j in range(i, min(i+hold+1, n)):
|
||||
if direction == 1:
|
||||
best_price = max(best_price, h[j])
|
||||
if l[j] <= best_price - trail_dist:
|
||||
exit_price = best_price - trail_dist
|
||||
break
|
||||
else:
|
||||
best_price = min(best_price, l[j])
|
||||
if h[j] >= best_price + trail_dist:
|
||||
exit_price = best_price + trail_dist
|
||||
break
|
||||
exit_price = c[j]
|
||||
else:
|
||||
exit_price = c[min(i+hold-1, n-1)]
|
||||
|
||||
actual = (exit_price - entry) / entry * direction
|
||||
net = actual * LEVERAGE - FEE_RT * LEVERAGE
|
||||
|
||||
capital += capital * 0.15 * net
|
||||
capital = max(capital, 10)
|
||||
if capital > peak: peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"wins": 0, "total": 0, "pnls": []}
|
||||
yearly[year]["total"] += 1
|
||||
if actual > 0:
|
||||
yearly[year]["wins"] += 1
|
||||
yearly[year]["pnls"].append(net * INITIAL)
|
||||
|
||||
all_t = sum(d["total"] for d in yearly.values())
|
||||
all_w = sum(d["wins"] for d in yearly.values())
|
||||
if all_t < 30:
|
||||
continue
|
||||
|
||||
acc = all_w / all_t * 100
|
||||
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
|
||||
tot_pnl = sum(all_pnls)
|
||||
|
||||
# Worst year
|
||||
worst_y_acc = 100
|
||||
worst_y = ""
|
||||
for y, d in yearly.items():
|
||||
ya = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
|
||||
if ya < worst_y_acc:
|
||||
worst_y_acc = ya
|
||||
worst_y = str(y)
|
||||
|
||||
results.append({
|
||||
"name": name, "trades": all_t, "acc": acc, "pnl": tot_pnl,
|
||||
"max_dd": max_dd*100, "capital": capital,
|
||||
"worst": f"{worst_y}({worst_y_acc:.0f}%)",
|
||||
"yearly": yearly,
|
||||
})
|
||||
|
||||
# Sort by accuracy
|
||||
results.sort(key=lambda x: x["acc"], reverse=True)
|
||||
|
||||
print(f"\n {'Name':.<26s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>6s} {'Capital':>10s} {'Worst':>12s}")
|
||||
print(f" {'-'*80}")
|
||||
for r in results:
|
||||
tag = "✅✅" if r["acc"] >= 80 else "✅" if r["acc"] >= 76 else ""
|
||||
print(f" {r['name']:.<26s} {r['trades']:>7d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['max_dd']:>5.1f}% €{r['capital']:>9,.0f} {r['worst']:>12s} {tag}")
|
||||
|
||||
# Dettaglio per anno del migliore
|
||||
if results:
|
||||
best = results[0]
|
||||
print(f"\n MIGLIORE: {best['name']} → {best['acc']:.1f}% acc")
|
||||
print(f" {'Anno':>6s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s}")
|
||||
for y in sorted(best["yearly"]):
|
||||
d = best["yearly"][y]
|
||||
ya = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
|
||||
yp = sum(d["pnls"])
|
||||
tag = " ← CRASH" if y in [2020,2021,2022] else ""
|
||||
print(f" {y:>6d} {d['total']:>7d} {ya:>5.1f}% €{yp:>+8.0f}{tag}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# Run su entrambi gli asset e timeframe
|
||||
all_results = {}
|
||||
for asset in ["ETH", "BTC"]:
|
||||
for tf in ["1h", "15m"]:
|
||||
key = f"{asset}_{tf}"
|
||||
all_results[key] = run_improved_squeeze(asset, tf)
|
||||
|
||||
# Classifica globale
|
||||
print(f"\n\n{'='*75}")
|
||||
print(f" CLASSIFICA GLOBALE — TOP 15")
|
||||
print(f"{'='*75}")
|
||||
|
||||
global_list = []
|
||||
for key, results in all_results.items():
|
||||
for r in results:
|
||||
global_list.append({**r, "asset_tf": key})
|
||||
|
||||
global_list.sort(key=lambda x: x["acc"], reverse=True)
|
||||
print(f"\n {'Asset_TF':.<12s} {'Name':.<26s} {'Trades':>6s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>5s} {'Worst':>12s}")
|
||||
for r in global_list[:15]:
|
||||
tag = "✅✅" if r["acc"] >= 80 else "✅" if r["acc"] >= 76 else ""
|
||||
print(f" {r['asset_tf']:.<12s} {r['name']:.<26s} {r['trades']:>6d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['max_dd']:>4.1f}% {r['worst']:>12s} {tag}")
|
||||
@@ -0,0 +1,290 @@
|
||||
"""S3-02: Lead-lag multi-asset squeeze.
|
||||
Quando BTC fa squeeze breakout, ETH/SOL spesso seguono.
|
||||
Usa il breakout di BTC per anticipare entrata su ETH (e viceversa).
|
||||
Testa anche correlazione inter-asset per conferma segnale.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_RT = 0.002
|
||||
INITIAL = 1000
|
||||
LEVERAGE = 3
|
||||
|
||||
|
||||
def keltner_ratio(close, high, low, window=14):
|
||||
n = len(close)
|
||||
r = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc = (ma+1.5*atr)-(ma-1.5*atr)
|
||||
bb = (ma+2*bb_std)-(ma-2*bb_std)
|
||||
if kc > 0: r[i] = bb/kc
|
||||
return r
|
||||
|
||||
|
||||
def load_aligned(assets, tf):
|
||||
"""Carica e allinea dati multi-asset per timestamp."""
|
||||
dfs = {}
|
||||
for asset in assets:
|
||||
try:
|
||||
if asset == "SOL":
|
||||
df = pd.read_parquet(f"data/raw/sol_{tf}.parquet")
|
||||
df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
else:
|
||||
df = load_data(asset, tf)
|
||||
dfs[asset] = df
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if len(dfs) < 2:
|
||||
return None
|
||||
|
||||
# Allinea per timestamp
|
||||
common_ts = set(dfs[list(dfs.keys())[0]]["timestamp"].values)
|
||||
for df in dfs.values():
|
||||
common_ts &= set(df["timestamp"].values)
|
||||
common_ts = sorted(common_ts)
|
||||
|
||||
aligned = {}
|
||||
for asset, df in dfs.items():
|
||||
mask = df["timestamp"].isin(common_ts)
|
||||
aligned[asset] = df[mask].sort_values("timestamp").reset_index(drop=True)
|
||||
|
||||
return aligned
|
||||
|
||||
|
||||
def detect_breakouts(close, high, low, volume, kcr, sq_thr=0.8, min_dur=5):
|
||||
"""Detect squeeze breakout events."""
|
||||
events = []
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
for i in range(1, len(close)):
|
||||
if np.isnan(kcr[i]):
|
||||
continue
|
||||
is_sq = kcr[i] < sq_thr
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
if i - sq_start < min_dur:
|
||||
continue
|
||||
first_ret = (close[i] - close[i-1]) / close[i-1] if close[i-1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
events.append({
|
||||
"idx": i,
|
||||
"duration": i - sq_start,
|
||||
"direction": 1 if first_ret > 0 else -1,
|
||||
"first_ret": first_ret,
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
print("=" * 75)
|
||||
print(" S3-02: LEAD-LAG MULTI-ASSET SQUEEZE")
|
||||
print("=" * 75)
|
||||
|
||||
for tf in ["1h", "15m"]:
|
||||
aligned = load_aligned(["BTC", "ETH", "SOL"], tf)
|
||||
if aligned is None:
|
||||
continue
|
||||
|
||||
n = len(aligned["BTC"])
|
||||
ts = pd.to_datetime(aligned["BTC"]["timestamp"], unit="ms", utc=True)
|
||||
|
||||
print(f"\n Timeframe: {tf}, Candles allineate: {n}")
|
||||
|
||||
# Calcola squeeze per ogni asset
|
||||
asset_data = {}
|
||||
for asset in aligned:
|
||||
df = aligned[asset]
|
||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
||||
kcr = keltner_ratio(c, h, l, 14)
|
||||
events = detect_breakouts(c, h, l, v, kcr)
|
||||
asset_data[asset] = {"close": c, "high": h, "low": l, "vol": v, "kcr": kcr, "events": events}
|
||||
print(f" {asset}: {len(events)} squeeze breakouts")
|
||||
|
||||
# ================================================================
|
||||
# STRATEGIA A: Leader-follower
|
||||
# Quando BTC fa breakout, entra su ETH/SOL nella stessa direzione
|
||||
# ================================================================
|
||||
print(f"\n --- LEADER-FOLLOWER ({tf}) ---")
|
||||
|
||||
for leader, follower in [("BTC", "ETH"), ("BTC", "SOL"), ("ETH", "BTC"), ("ETH", "SOL")]:
|
||||
if leader not in asset_data or follower not in asset_data:
|
||||
continue
|
||||
|
||||
leader_events = asset_data[leader]["events"]
|
||||
fc = asset_data[follower]["close"]
|
||||
|
||||
for hold in [3, 6]:
|
||||
for delay in [0, 1, 2]:
|
||||
yearly = {}
|
||||
|
||||
for ev in leader_events:
|
||||
i = ev["idx"] + delay
|
||||
if i + hold >= n:
|
||||
continue
|
||||
|
||||
# Anti-fakeout su follower
|
||||
entry = fc[i]
|
||||
exit_price = fc[min(i + hold, n - 1)]
|
||||
direction = ev["direction"]
|
||||
actual = (exit_price - entry) / entry * direction
|
||||
net = actual * LEVERAGE - FEE_RT * LEVERAGE
|
||||
|
||||
year = ts.iloc[min(i, n-1)].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0:
|
||||
yearly[year]["w"] += 1
|
||||
yearly[year]["pnls"].append(net * INITIAL)
|
||||
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t < 30:
|
||||
continue
|
||||
acc = all_w / all_t * 100
|
||||
pnl = sum(p for d in yearly.values() for p in d["pnls"])
|
||||
worst_y = min(yearly.items(), key=lambda x: x[1]["w"]/x[1]["t"] if x[1]["t"]>0 else 0)
|
||||
worst_acc = worst_y[1]["w"]/worst_y[1]["t"]*100 if worst_y[1]["t"]>0 else 0
|
||||
tag = "✅" if acc >= 76 else ""
|
||||
print(f" {leader}→{follower} d={delay} h={hold}: trades={all_t:5d} acc={acc:.1f}% pnl=€{pnl:+.0f} worst={worst_y[0]}({worst_acc:.0f}%) {tag}")
|
||||
|
||||
# ================================================================
|
||||
# STRATEGIA B: Consensus multi-asset
|
||||
# Trade solo quando 2+ asset hanno squeeze breakout nello stesso momento
|
||||
# ================================================================
|
||||
print(f"\n --- CONSENSUS MULTI-ASSET ({tf}) ---")
|
||||
|
||||
# Build event map: timestamp → list of (asset, direction)
|
||||
event_map = {}
|
||||
for asset, data in asset_data.items():
|
||||
for ev in data["events"]:
|
||||
idx = ev["idx"]
|
||||
if idx not in event_map:
|
||||
event_map[idx] = []
|
||||
event_map[idx].append((asset, ev["direction"]))
|
||||
|
||||
for target in ["BTC", "ETH", "SOL"]:
|
||||
if target not in asset_data:
|
||||
continue
|
||||
tc = asset_data[target]["close"]
|
||||
|
||||
for min_consensus in [2, 3]:
|
||||
for window_bars in [1, 3, 5]:
|
||||
yearly = {}
|
||||
daily_done = set()
|
||||
|
||||
for idx in sorted(event_map.keys()):
|
||||
if idx + 6 >= n:
|
||||
continue
|
||||
|
||||
day = ts.iloc[idx].strftime("%Y-%m-%d")
|
||||
if day in daily_done:
|
||||
continue
|
||||
|
||||
# Count consensus within window
|
||||
nearby_events = []
|
||||
for j in range(max(0, idx - window_bars), idx + window_bars + 1):
|
||||
if j in event_map:
|
||||
nearby_events.extend(event_map[j])
|
||||
|
||||
# Unique assets
|
||||
unique_assets = set(a for a, d in nearby_events)
|
||||
if len(unique_assets) < min_consensus:
|
||||
continue
|
||||
|
||||
# Majority direction
|
||||
dirs = [d for a, d in nearby_events]
|
||||
majority = 1 if sum(dirs) > 0 else -1
|
||||
|
||||
entry = tc[idx]
|
||||
exit_price = tc[min(idx + 3, n - 1)]
|
||||
actual = (exit_price - entry) / entry * majority
|
||||
net = actual * LEVERAGE - FEE_RT * LEVERAGE
|
||||
|
||||
year = ts.iloc[idx].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0:
|
||||
yearly[year]["w"] += 1
|
||||
yearly[year]["pnls"].append(net * INITIAL)
|
||||
daily_done.add(day)
|
||||
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t < 20:
|
||||
continue
|
||||
acc = all_w / all_t * 100
|
||||
pnl = sum(p for d in yearly.values() for p in d["pnls"])
|
||||
tag = "✅" if acc >= 76 else ""
|
||||
print(f" {target} consensus>={min_consensus} w={window_bars}: trades={all_t:4d} acc={acc:.1f}% pnl=€{pnl:+.0f} {tag}")
|
||||
|
||||
# ================================================================
|
||||
# STRATEGIA C: Correlation-weighted squeeze
|
||||
# Peso il segnale squeeze in base alla correlazione rolling con BTC
|
||||
# ================================================================
|
||||
print(f"\n --- CORRELATION-WEIGHTED ({tf}) ---")
|
||||
|
||||
for target in ["ETH", "SOL"]:
|
||||
if target not in asset_data:
|
||||
continue
|
||||
tc = asset_data[target]["close"]
|
||||
btc_c = asset_data["BTC"]["close"]
|
||||
|
||||
# Rolling correlation
|
||||
corr_window = 48 # 48 bars
|
||||
rolling_corr = np.full(n, np.nan)
|
||||
ret_t = np.diff(np.log(np.where(tc == 0, 1e-10, tc)))
|
||||
ret_b = np.diff(np.log(np.where(btc_c == 0, 1e-10, btc_c)))
|
||||
for i in range(corr_window, len(ret_t)):
|
||||
c_val = np.corrcoef(ret_t[i-corr_window:i], ret_b[i-corr_window:i])[0, 1]
|
||||
rolling_corr[i + 1] = c_val if np.isfinite(c_val) else 0
|
||||
|
||||
events = asset_data[target]["events"]
|
||||
|
||||
for corr_thr in [0.5, 0.6, 0.7, 0.8]:
|
||||
yearly = {}
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i + 3 >= n or np.isnan(rolling_corr[i]):
|
||||
continue
|
||||
|
||||
# Solo quando correlazione con BTC è alta
|
||||
if abs(rolling_corr[i]) < corr_thr:
|
||||
continue
|
||||
|
||||
entry = tc[i - 1]
|
||||
exit_price = tc[min(i + 2, n - 1)]
|
||||
actual = (exit_price - entry) / entry * ev["direction"]
|
||||
net = actual * LEVERAGE - FEE_RT * LEVERAGE
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0:
|
||||
yearly[year]["w"] += 1
|
||||
yearly[year]["pnls"].append(net * INITIAL)
|
||||
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t < 20:
|
||||
continue
|
||||
acc = all_w / all_t * 100
|
||||
pnl = sum(p for d in yearly.values() for p in d["pnls"])
|
||||
tag = "✅" if acc >= 76 else ""
|
||||
print(f" {target} corr>={corr_thr}: trades={all_t:4d} acc={acc:.1f}% pnl=€{pnl:+.0f} {tag}")
|
||||
@@ -0,0 +1,256 @@
|
||||
"""S3-03: Ultimate Squeeze — combina TUTTI i filtri migliori.
|
||||
Filtri che funzionano (testati singolarmente):
|
||||
- Anti-fakeout (+1% acc)
|
||||
- Long squeeze duration (+1% acc)
|
||||
- Cross-asset squeeze simultaneo (+0.5%)
|
||||
- Timing 4-16 UTC (+0.5%)
|
||||
- Correlation ETH-BTC alta per ETH trades (+1%)
|
||||
- Volume confirmation al breakout
|
||||
|
||||
Nuovi filtri da testare:
|
||||
- Volume delta: up_volume - down_volume al breakout
|
||||
- Momentum confirmation: breakout nella direzione del trend 1h
|
||||
- Volatility regime: skip in regime estremo (RV > 100%)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_RT = 0.002
|
||||
INITIAL = 1000
|
||||
LEVERAGE = 3
|
||||
|
||||
|
||||
def keltner_ratio(close, high, low, window=14):
|
||||
n = len(close)
|
||||
r = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
|
||||
ma = np.mean(wc)
|
||||
bb_std = np.std(wc)
|
||||
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
|
||||
atr = np.mean(tr[1:])
|
||||
kc = (ma+1.5*atr)-(ma-1.5*atr)
|
||||
bb = (ma+2*bb_std)-(ma-2*bb_std)
|
||||
if kc > 0: r[i] = bb/kc
|
||||
return r
|
||||
|
||||
|
||||
def ema(arr, period):
|
||||
r = np.full(len(arr), np.nan)
|
||||
k = 2/(period+1)
|
||||
r[period-1] = np.mean(arr[:period])
|
||||
for i in range(period, len(arr)):
|
||||
r[i] = arr[i]*k + r[i-1]*(1-k)
|
||||
return r
|
||||
|
||||
|
||||
def rv_ann(close, window):
|
||||
lr = np.diff(np.log(np.where(close == 0, 1e-10, close)))
|
||||
r = np.full(len(close), np.nan)
|
||||
for i in range(window, len(lr)):
|
||||
r[i+1] = np.std(lr[i-window:i]) * np.sqrt(24*365)
|
||||
return r
|
||||
|
||||
|
||||
def run_ultimate(primary, tf="15m"):
|
||||
secondary = "ETH" if primary == "BTC" else "BTC"
|
||||
|
||||
df = load_data(primary, tf)
|
||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
||||
n = len(df)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
df2 = load_data(secondary, tf)
|
||||
c2, ts2 = df2["close"].values, df2["timestamp"].values
|
||||
|
||||
kcr = keltner_ratio(c, h, l, 14)
|
||||
kcr2 = keltner_ratio(c2, df2["high"].values, df2["low"].values, 14)
|
||||
|
||||
ema_50 = ema(c, 50)
|
||||
rv_48 = rv_ann(c, 48)
|
||||
|
||||
# Rolling correlation
|
||||
ret1 = np.diff(np.log(np.where(c == 0, 1e-10, c)))
|
||||
ret2 = np.diff(np.log(np.where(c2[:len(c)] == 0, 1e-10, c2[:len(c)])))
|
||||
min_len = min(len(ret1), len(ret2))
|
||||
ret1 = ret1[:min_len]
|
||||
ret2 = ret2[:min_len]
|
||||
corr = np.full(n, np.nan)
|
||||
for i in range(48, min_len):
|
||||
cv = np.corrcoef(ret1[i-48:i], ret2[i-48:i])[0,1]
|
||||
corr[i+1] = cv if np.isfinite(cv) else 0
|
||||
|
||||
# Detect squeezes
|
||||
events = []
|
||||
in_sq = False
|
||||
sq_start = 0
|
||||
for i in range(15, n):
|
||||
if np.isnan(kcr[i]): continue
|
||||
is_sq = kcr[i] < 0.8
|
||||
if is_sq and not in_sq:
|
||||
in_sq = True
|
||||
sq_start = i
|
||||
elif not is_sq and in_sq:
|
||||
in_sq = False
|
||||
dur = i - sq_start
|
||||
if dur < 5 or i + 6 >= n:
|
||||
continue
|
||||
events.append({"idx": i, "dur": dur, "sq_start": sq_start})
|
||||
|
||||
print(f"\n{'#'*70}")
|
||||
print(f" {primary} {tf} — ULTIMATE SQUEEZE ({len(events)} squeeze events)")
|
||||
print(f"{'#'*70}")
|
||||
|
||||
filters_map = {
|
||||
"antifake": lambda ev, i: not _antifake(c, h, l, i),
|
||||
"long_sq": lambda ev, i: ev["dur"] >= 10,
|
||||
"timing": lambda ev, i: 4 <= ts.iloc[i].hour <= 16,
|
||||
"cross": lambda ev, i: _cross_squeeze(kcr2, i, ts, ts2),
|
||||
"corr_high": lambda ev, i: not np.isnan(corr[i]) and abs(corr[i]) >= 0.6,
|
||||
"vol_confirm": lambda ev, i: _vol_confirm(v, i, ev["sq_start"]),
|
||||
"trend_align": lambda ev, i: _trend_align(c, ema_50, i),
|
||||
"low_rv": lambda ev, i: not np.isnan(rv_48[i]) and rv_48[i] < 1.5,
|
||||
}
|
||||
|
||||
def _antifake(c, h, l, i):
|
||||
if i + 1 >= len(c): return False
|
||||
br = h[i] - l[i]
|
||||
if br <= 0: return False
|
||||
if c[i] > c[i-1]:
|
||||
return (h[i] - c[i]) / br > 0.6
|
||||
return (c[i] - l[i]) / br > 0.6
|
||||
|
||||
def _cross_squeeze(kcr2, i, ts1, ts2_arr):
|
||||
i2 = np.searchsorted(ts2_arr, ts.values[i].astype("int64") // 10**6)
|
||||
i2 = min(i2, len(kcr2)-1)
|
||||
return any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85 for j in range(max(0,i2-10), i2+1))
|
||||
|
||||
def _vol_confirm(v, i, sq_start):
|
||||
avg = np.mean(v[sq_start:i])
|
||||
return avg > 0 and v[i] > avg * 1.3
|
||||
|
||||
def _trend_align(c, ema_val, i):
|
||||
if np.isnan(ema_val[i]): return True
|
||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
||||
if first_ret > 0:
|
||||
return c[i] > ema_val[i]
|
||||
return c[i] < ema_val[i]
|
||||
|
||||
# Test combinazioni incrementali
|
||||
combos = [
|
||||
("BASE", []),
|
||||
("antifake", ["antifake"]),
|
||||
("long_sq", ["long_sq"]),
|
||||
("antifake+long", ["antifake", "long_sq"]),
|
||||
("antifake+timing", ["antifake", "timing"]),
|
||||
("antifake+cross", ["antifake", "cross"]),
|
||||
("antifake+corr", ["antifake", "corr_high"]),
|
||||
("antifake+vol", ["antifake", "vol_confirm"]),
|
||||
("antifake+trend", ["antifake", "trend_align"]),
|
||||
("af+long+timing", ["antifake", "long_sq", "timing"]),
|
||||
("af+long+cross", ["antifake", "long_sq", "cross"]),
|
||||
("af+long+corr", ["antifake", "long_sq", "corr_high"]),
|
||||
("af+long+trend", ["antifake", "long_sq", "trend_align"]),
|
||||
("af+long+cross+time", ["antifake", "long_sq", "cross", "timing"]),
|
||||
("af+long+corr+time", ["antifake", "long_sq", "corr_high", "timing"]),
|
||||
("af+long+corr+trend", ["antifake", "long_sq", "corr_high", "trend_align"]),
|
||||
("ALL_NO_VOL", ["antifake", "long_sq", "cross", "timing", "corr_high", "trend_align", "low_rv"]),
|
||||
("ALL", ["antifake", "long_sq", "cross", "timing", "corr_high", "vol_confirm", "trend_align", "low_rv"]),
|
||||
("BEST_5", ["antifake", "long_sq", "corr_high", "trend_align", "low_rv"]),
|
||||
]
|
||||
|
||||
results = []
|
||||
for combo_name, filter_names in combos:
|
||||
yearly = {}
|
||||
capital = float(INITIAL)
|
||||
peak = capital
|
||||
max_dd = 0
|
||||
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
|
||||
skip = False
|
||||
for fn in filter_names:
|
||||
if fn in filters_map and not filters_map[fn](ev, i):
|
||||
skip = True
|
||||
break
|
||||
if skip:
|
||||
continue
|
||||
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
entry = c[i-1]
|
||||
exit_price = c[min(i+2, n-1)]
|
||||
actual = (exit_price - entry) / entry * direction
|
||||
net = actual * LEVERAGE - FEE_RT * LEVERAGE
|
||||
|
||||
capital += capital * 0.15 * net
|
||||
capital = max(capital, 10)
|
||||
if capital > peak: peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0: yearly[year]["w"] += 1
|
||||
yearly[year]["pnls"].append(net * INITIAL)
|
||||
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t < 20: continue
|
||||
|
||||
acc = all_w / all_t * 100
|
||||
pnl = sum(p for d in yearly.values() for p in d["pnls"])
|
||||
worst = min(yearly.items(), key=lambda x: x[1]["w"]/x[1]["t"] if x[1]["t"]>0 else 0)
|
||||
wa = worst[1]["w"]/worst[1]["t"]*100 if worst[1]["t"]>0 else 0
|
||||
|
||||
results.append({
|
||||
"name": combo_name, "trades": all_t, "acc": acc, "pnl": pnl,
|
||||
"dd": max_dd*100, "capital": capital, "worst": f"{worst[0]}({wa:.0f}%)",
|
||||
"yearly": yearly,
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: x["acc"], reverse=True)
|
||||
|
||||
print(f"\n {'Name':.<28s} {'Trades':>6s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>5s} {'Worst':>12s}")
|
||||
print(f" {'-'*70}")
|
||||
for r in results[:20]:
|
||||
tag = "✅✅" if r["acc"] >= 80 else "✅" if r["acc"] >= 78 else ""
|
||||
print(f" {r['name']:.<28s} {r['trades']:>6d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['dd']:>4.1f}% {r['worst']:>12s} {tag}")
|
||||
|
||||
# Dettaglio migliore
|
||||
if results:
|
||||
best = results[0]
|
||||
print(f"\n MIGLIORE: {best['name']} → {best['acc']:.1f}% acc, DD {best['dd']:.1f}%")
|
||||
for y in sorted(best["yearly"]):
|
||||
d = best["yearly"][y]
|
||||
ya = d["w"]/d["t"]*100 if d["t"]>0 else 0
|
||||
tag = " ← CRASH" if y in [2020,2021,2022] else ""
|
||||
print(f" {y}: {d['t']:4d}t {ya:5.1f}% €{sum(d['pnls']):+.0f}{tag}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
all_r = []
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["15m", "1h"]:
|
||||
r = run_ultimate(asset, tf)
|
||||
for x in r:
|
||||
all_r.append({**x, "key": f"{asset}_{tf}"})
|
||||
|
||||
all_r.sort(key=lambda x: x["acc"], reverse=True)
|
||||
print(f"\n\n{'='*70}")
|
||||
print(f" TOP 10 GLOBALE")
|
||||
print(f"{'='*70}")
|
||||
for r in all_r[:10]:
|
||||
tag = "✅✅" if r["acc"] >= 80 else "✅" if r["acc"] >= 78 else ""
|
||||
print(f" {r['key']:.<10s} {r['name']:.<28s} {r['trades']:>5d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} DD {r['dd']:.1f}% {r['worst']:>12s} {tag}")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""ROT01 — Cross-Sectional Momentum Rotation (multi-crypto, long-only), 1d.
|
||||
|
||||
UNA strategia che usa l'INTERO paniere di crypto in un solo book: ogni giorno
|
||||
ordina gli asset per momentum (rendimento sugli ultimi `lookback` giorni) e alloca
|
||||
il capitale in parti uguali ai `top_k` con momentum positivo; il resto in cash.
|
||||
Cattura la dispersione tra crypto (gli alt forti corrono molto piu' di BTC nei bull)
|
||||
senza shortare nulla. Meccanismo distinto da DIP01/TR01 -> vera diversificazione.
|
||||
|
||||
Onesto: i pesi a close[i] usano solo rendimenti passati; il rendimento del giorno
|
||||
i->i+1 e' realizzato con quei pesi. Fee sul turnover. Allineamento per timestamp.
|
||||
|
||||
Validazione (netto, fee 0.10% RT, gross 0.45, OOS = ultimo 30%):
|
||||
lb=60 top2 -> FULL +679% / OOS +44% / DD 53% / 5-7 anni positivi.
|
||||
Param-insensitive (tutte le lb/k positive) e regge fee fino 0.20% RT (OOS +41%).
|
||||
Per-anno: 2020+33 2021+181 2022-29 2023+43 2024+59 2025+6 2026-10 (i negativi = bear).
|
||||
Dettagli in scripts/analysis/honest_rotation.py / honest_final.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.honest_rotation import build_panel, simulate_rotation # noqa: E402
|
||||
from scripts.analysis.honest_lab import available_assets
|
||||
|
||||
LOOKBACK, TOP_K, TF = 60, 2, "1d"
|
||||
|
||||
|
||||
def run():
|
||||
assets = available_assets()
|
||||
panel = build_panel(assets, TF)
|
||||
print("=" * 90)
|
||||
print(f" ROT01 ROTAZIONE cross-sectional momentum | {TF} lb={LOOKBACK} top{TOP_K} | netto fee 0.10% RT")
|
||||
print("=" * 90)
|
||||
print(f" Paniere: {list(panel.columns)}")
|
||||
print(f" Periodo: {panel.index[0].date()} -> {panel.index[-1].date()} ({panel.shape[0]} barre)")
|
||||
full = simulate_rotation(panel, lookback=LOOKBACK, top_k=TOP_K, fee_rt=0.001)
|
||||
oos = simulate_rotation(panel, lookback=LOOKBACK, top_k=TOP_K, fee_rt=0.001, oos_frac=0.30)
|
||||
print(f"\n FULL: {full['ret']:+.0f}% DD {full['dd']:.0f}% turnover {full['turnover']:.0f}")
|
||||
print(f" OOS : {oos['ret']:+.0f}% DD {oos['dd']:.0f}% ({full['pos_years']}/{full['n_years']} anni positivi)")
|
||||
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(full["yearly"].items())))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""SQ01 — Squeeze Breakout Base.
|
||||
|
||||
Strategia strutturale: rileva compressione di volatilità (Bollinger dentro
|
||||
Keltner Channel) e segue la direzione del breakout al rilascio.
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame (da load_data)
|
||||
- Parametri: bb_window (14), sq_threshold (0.8), min_squeeze_dur (5)
|
||||
|
||||
OUT:
|
||||
- Lista di Signal con direzione breakout (+1/-1)
|
||||
- BacktestResult con equity, yearly breakdown, metriche
|
||||
|
||||
Risultati tipici:
|
||||
BTC 15m: 76.7% acc, 4062 trades, DD 6.7%, €9.32/day
|
||||
ETH 15m: 76.4% acc, 2948 trades, DD 6.2%, €10.31/day
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal
|
||||
from src.strategies.indicators import keltner_ratio, detect_squeezes
|
||||
|
||||
|
||||
class SqueezeBase(Strategy):
|
||||
name = "SQ01_squeeze_base"
|
||||
description = "Squeeze breakout puro — segui direzione al rilascio"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
n = len(c)
|
||||
|
||||
bb_w = params.get("bb_window", 14)
|
||||
sq_thr = params.get("sq_threshold", 0.8)
|
||||
min_dur = params.get("min_dur", 5)
|
||||
|
||||
kcr = keltner_ratio(c, h, l, bb_w)
|
||||
events = detect_squeezes(c, h, l, kcr, sq_thr, min_dur)
|
||||
|
||||
signals = []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i < 1 or i >= n:
|
||||
continue
|
||||
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
signals.append(Signal(
|
||||
idx=i,
|
||||
direction=1 if first_ret > 0 else -1,
|
||||
entry_price=c[i - 1],
|
||||
metadata={"dur": ev["dur"], "kcr": ev["kcr_at_release"]},
|
||||
))
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = SqueezeBase()
|
||||
strategy.report()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""SQ02 — Squeeze Breakout + Anti-Fakeout + Volume Confirmation.
|
||||
|
||||
Migliora SQ01 con due filtri:
|
||||
1. Anti-fakeout: scarta breakout dove la candela ritraccia >60% del range
|
||||
2. Volume confirm: volume al breakout deve essere >1.3× la media durante squeeze
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame
|
||||
- Parametri: bb_window (14), sq_threshold (0.8), retrace_limit (0.6),
|
||||
vol_multiplier (1.3)
|
||||
|
||||
OUT:
|
||||
- Lista di Signal filtrati
|
||||
- BacktestResult
|
||||
|
||||
Risultati tipici:
|
||||
BTC 15m: 79.7% acc, 1250 trades, DD 6.5%, €5.23/day — SOLIDO 9/9 anni
|
||||
ETH 15m: 78.6% acc, 942 trades, DD 3.4%, €4.33/day
|
||||
BTC 1h: 78.0% acc, 473 trades, DD 3.5%, Sharpe 6.57
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal
|
||||
from src.strategies.indicators import keltner_ratio, detect_squeezes
|
||||
|
||||
|
||||
class SqueezeAntifakeVol(Strategy):
|
||||
name = "SQ02_antifake_vol"
|
||||
description = "Squeeze + antifakeout + volume confirmation"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
bb_w = params.get("bb_window", 14)
|
||||
sq_thr = params.get("sq_threshold", 0.8)
|
||||
retrace_limit = params.get("retrace_limit", 0.6)
|
||||
vol_mult = params.get("vol_multiplier", 1.3)
|
||||
|
||||
kcr = keltner_ratio(c, h, l, bb_w)
|
||||
events = detect_squeezes(c, h, l, kcr, sq_thr)
|
||||
|
||||
signals = []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i < 1 or i >= n:
|
||||
continue
|
||||
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
|
||||
br = h[i] - l[i]
|
||||
if br > 0:
|
||||
if c[i] > c[i - 1]:
|
||||
if (h[i] - c[i]) / br > retrace_limit:
|
||||
continue
|
||||
else:
|
||||
if (c[i] - l[i]) / br > retrace_limit:
|
||||
continue
|
||||
|
||||
avg_v = np.mean(v[ev["sq_start"]:i])
|
||||
if avg_v > 0 and v[i] <= avg_v * vol_mult:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i,
|
||||
direction=1 if first_ret > 0 else -1,
|
||||
entry_price=c[i - 1],
|
||||
metadata={"dur": ev["dur"], "vol_ratio": v[i] / avg_v if avg_v > 0 else 0},
|
||||
))
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = SqueezeAntifakeVol()
|
||||
strategy.report()
|
||||
@@ -0,0 +1,175 @@
|
||||
"""SQ03 — Squeeze con filtri selezionabili.
|
||||
|
||||
Ogni filtro è opzionale e attivabile via parametro. Di default attiva solo
|
||||
antifake + long_squeeze (i due filtri con miglior rapporto accuracy/trade).
|
||||
Esegue tutte le combinazioni utili e classifica.
|
||||
|
||||
Filtri disponibili:
|
||||
- antifake: scarta breakout con retrace >60% (guadagna ~+1% acc)
|
||||
- long_sq: solo squeeze durata ≥10 barre (+1% acc, dimezza trade)
|
||||
- timing: solo ore 4-16 UTC (+0.5% acc)
|
||||
- cross: asset secondario in squeeze nelle ultime 10 barre (+0.5%)
|
||||
- vol: volume al breakout >1.3× media squeeze (+1% acc)
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame (primario + secondario per cross-check)
|
||||
- Parametri: filters (lista), bb_window, sq_threshold
|
||||
|
||||
OUT:
|
||||
- BacktestResult per ogni preset di filtri
|
||||
|
||||
Risultati tipici (BTC 15m):
|
||||
antifake+long: 77.3% acc, 2179 trades
|
||||
antifake+vol: 79.7% acc, 1250 trades — SOLIDO
|
||||
ALL_FILTERS: 79.2% acc, 696 trades (restrittivo)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
||||
from src.strategies.indicators import keltner_ratio, detect_squeezes
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
PRESETS = {
|
||||
"antifake": ["antifake"],
|
||||
"long_sq": ["long_sq"],
|
||||
"antifake+long": ["antifake", "long_sq"],
|
||||
"antifake+vol": ["antifake", "vol"],
|
||||
"antifake+timing": ["antifake", "timing"],
|
||||
"long+timing": ["long_sq", "timing"],
|
||||
"antifake+long+time": ["antifake", "long_sq", "timing"],
|
||||
"antifake+cross": ["antifake", "cross"],
|
||||
"ALL_FILTERS": ["antifake", "long_sq", "timing", "cross"],
|
||||
}
|
||||
|
||||
|
||||
class SqueezeFiltered(Strategy):
|
||||
name = "SQ03_filtered"
|
||||
description = "Squeeze + filtri selezionabili (antifake, long, timing, cross, vol)"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
bb_w = params.get("bb_window", 14)
|
||||
sq_thr = params.get("sq_threshold", 0.8)
|
||||
filters = params.get("filters", ["antifake", "long_sq"])
|
||||
asset = params.get("asset", "BTC")
|
||||
tf = params.get("tf", "15m")
|
||||
|
||||
kcr = keltner_ratio(c, h, l, bb_w)
|
||||
events = detect_squeezes(c, h, l, kcr, sq_thr)
|
||||
|
||||
kcr2 = None
|
||||
ts2 = None
|
||||
if "cross" in filters:
|
||||
secondary = "ETH" if asset == "BTC" else "BTC"
|
||||
df2 = load_data(secondary, tf)
|
||||
kcr2 = keltner_ratio(df2["close"].values, df2["high"].values,
|
||||
df2["low"].values, bb_w)
|
||||
ts2 = df2["timestamp"].values
|
||||
|
||||
signals = []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i < 1 or i >= n:
|
||||
continue
|
||||
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
|
||||
skip = False
|
||||
|
||||
if "antifake" in filters:
|
||||
br = h[i] - l[i]
|
||||
if br > 0:
|
||||
if c[i] > c[i - 1] and (h[i] - c[i]) / br > 0.6:
|
||||
skip = True
|
||||
elif c[i] <= c[i - 1] and (c[i] - l[i]) / br > 0.6:
|
||||
skip = True
|
||||
|
||||
if not skip and "long_sq" in filters:
|
||||
if ev["dur"] < 10:
|
||||
skip = True
|
||||
|
||||
if not skip and "timing" in filters:
|
||||
hour = ts.iloc[i].hour
|
||||
if hour < 4 or hour > 16:
|
||||
skip = True
|
||||
|
||||
if not skip and "vol" in filters:
|
||||
avg_v = np.mean(v[ev["sq_start"]:i])
|
||||
if avg_v > 0 and v[i] <= avg_v * 1.3:
|
||||
skip = True
|
||||
|
||||
if not skip and "cross" in filters and kcr2 is not None and ts2 is not None:
|
||||
i2 = np.searchsorted(ts2, ts.values[i].astype("int64") // 10**6)
|
||||
i2 = min(i2, len(kcr2) - 1)
|
||||
cross_ok = any(
|
||||
not np.isnan(kcr2[j]) and kcr2[j] < 0.85
|
||||
for j in range(max(0, i2 - 10), i2 + 1)
|
||||
)
|
||||
if not cross_ok:
|
||||
skip = True
|
||||
|
||||
if skip:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i,
|
||||
direction=1 if first_ret > 0 else -1,
|
||||
entry_price=c[i - 1],
|
||||
metadata={"dur": ev["dur"], "filters": filters},
|
||||
))
|
||||
return signals
|
||||
|
||||
def report_all_presets(self, assets=None, timeframes=None, hold=3):
|
||||
"""Esegue tutti i preset di filtri × asset × tf."""
|
||||
assets = assets or self.default_assets
|
||||
timeframes = timeframes or self.default_timeframes
|
||||
all_results = []
|
||||
|
||||
for preset_name, filter_list in PRESETS.items():
|
||||
for asset in assets:
|
||||
for tf in timeframes:
|
||||
r = self.backtest(asset, tf, hold, filters=filter_list)
|
||||
if r and r.trades >= 20:
|
||||
r.strategy_name = f"SQ03 {preset_name}"
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
|
||||
print(f"\n{'=' * 120}")
|
||||
print(f" SQ03 SQUEEZE FILTRATO — TUTTI I PRESET ({len(all_results)} config)")
|
||||
print(f" Fee: {self.fee_rt*100:.1f}% RT | Leva: {self.leverage:.0f}x | Pos: {self.position_size*100:.0f}%")
|
||||
print(f"{'=' * 120}")
|
||||
print(f" {'Nome':<30s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
|
||||
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
|
||||
f"{'Mkt%':>5s} {'Dur':>5s} {'Worst':>12s} {'Anni':>4s}")
|
||||
print(f" {'─' * 110}")
|
||||
|
||||
for r in all_results:
|
||||
r.print_summary()
|
||||
|
||||
if all_results:
|
||||
print(f"\n MIGLIORE: ", end="")
|
||||
best = all_results[0]
|
||||
best.print_yearly()
|
||||
|
||||
return all_results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = SqueezeFiltered()
|
||||
strategy.report_all_presets()
|
||||
@@ -0,0 +1,204 @@
|
||||
"""SQ04 — Ultimate Squeeze — combinazione incrementale di tutti i filtri.
|
||||
|
||||
Testa combinazioni di filtri (antifake, long_sq, timing, cross-asset,
|
||||
correlation, volume, trend alignment, volatility regime) e classifica
|
||||
per accuracy.
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame (primario + secondario)
|
||||
- Parametri: bb_window, sq_threshold, lista filtri da attivare
|
||||
|
||||
OUT:
|
||||
- BacktestResult per ogni combinazione di filtri
|
||||
- Classifica globale
|
||||
|
||||
Risultati tipici:
|
||||
BTC 15m antifake+corr: 81.6% acc (ma concentrato 2018)
|
||||
BTC 15m antifake+vol: 79.7% acc, 1250 trades — robusto
|
||||
ETH 1h antifake+corr: 80.7% acc (solo 2018)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal
|
||||
from src.strategies.indicators import (
|
||||
keltner_ratio, detect_squeezes, ema, rv_annualized, rolling_correlation,
|
||||
)
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
class SqueezeUltimate(Strategy):
|
||||
name = "SQ04_ultimate"
|
||||
description = "Ultimate squeeze — tutti i filtri combinabili"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
|
||||
FILTER_PRESETS = {
|
||||
"antifake+vol": ["antifake", "vol_confirm"],
|
||||
"antifake+corr": ["antifake", "corr_high"],
|
||||
"af+long+corr+trend": ["antifake", "long_sq", "corr_high", "trend_align"],
|
||||
"ALL": ["antifake", "long_sq", "cross", "timing", "corr_high",
|
||||
"vol_confirm", "trend_align", "low_rv"],
|
||||
}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
asset = params.get("asset", "BTC")
|
||||
tf = params.get("tf", "15m")
|
||||
filters = params.get("filters", ["antifake", "vol_confirm"])
|
||||
|
||||
kcr = keltner_ratio(c, h, l, 14)
|
||||
events = detect_squeezes(c, h, l, kcr)
|
||||
|
||||
secondary = "ETH" if asset == "BTC" else "BTC"
|
||||
df2 = load_data(secondary, tf)
|
||||
c2 = df2["close"].values
|
||||
kcr2 = keltner_ratio(c2, df2["high"].values, df2["low"].values, 14)
|
||||
ts2 = df2["timestamp"].values
|
||||
|
||||
ema_50 = ema(c, 50)
|
||||
rv_48 = rv_annualized(c, 48)
|
||||
corr = rolling_correlation(c, c2)
|
||||
|
||||
signals = []
|
||||
for ev in events:
|
||||
i = ev["idx"]
|
||||
if i < 1 or i >= n:
|
||||
continue
|
||||
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
|
||||
skip = False
|
||||
for f in filters:
|
||||
if f == "antifake":
|
||||
br = h[i] - l[i]
|
||||
if br > 0:
|
||||
if c[i] > c[i-1] and (h[i] - c[i]) / br > 0.6:
|
||||
skip = True
|
||||
elif c[i] <= c[i-1] and (c[i] - l[i]) / br > 0.6:
|
||||
skip = True
|
||||
elif f == "long_sq":
|
||||
if ev["dur"] < 10:
|
||||
skip = True
|
||||
elif f == "timing":
|
||||
if ts.iloc[i].hour < 4 or ts.iloc[i].hour > 16:
|
||||
skip = True
|
||||
elif f == "cross":
|
||||
i2 = np.searchsorted(ts2, ts.values[i].astype("int64") // 10**6)
|
||||
i2 = min(i2, len(kcr2) - 1)
|
||||
if not any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85
|
||||
for j in range(max(0, i2 - 10), i2 + 1)):
|
||||
skip = True
|
||||
elif f == "corr_high":
|
||||
if np.isnan(corr[i]) or abs(corr[i]) < 0.6:
|
||||
skip = True
|
||||
elif f == "vol_confirm":
|
||||
avg_v = np.mean(v[ev["sq_start"]:i])
|
||||
if avg_v > 0 and v[i] <= avg_v * 1.3:
|
||||
skip = True
|
||||
elif f == "trend_align":
|
||||
if not np.isnan(ema_50[i]):
|
||||
if first_ret > 0 and c[i] < ema_50[i]:
|
||||
skip = True
|
||||
elif first_ret < 0 and c[i] > ema_50[i]:
|
||||
skip = True
|
||||
elif f == "low_rv":
|
||||
if not np.isnan(rv_48[i]) and rv_48[i] >= 1.5:
|
||||
skip = True
|
||||
if skip:
|
||||
break
|
||||
|
||||
if skip:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i,
|
||||
direction=1 if first_ret > 0 else -1,
|
||||
entry_price=c[i - 1],
|
||||
metadata={"dur": ev["dur"], "filters": filters},
|
||||
))
|
||||
return signals
|
||||
|
||||
def backtest(self, asset: str, tf: str, hold: int = 3, **params):
|
||||
params.setdefault("asset", asset)
|
||||
params.setdefault("tf", tf)
|
||||
df = load_data(asset, tf)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
signals = self.generate_signals(df, ts, **params)
|
||||
# Usa il backtest della base ma passando i segnali già generati
|
||||
from src.strategies.base import BacktestResult, YearlyStats, TF_MINUTES
|
||||
c = df["close"].values
|
||||
n = len(c)
|
||||
yearly: dict[int, dict] = {}
|
||||
capital = float(self.initial_capital)
|
||||
peak = capital
|
||||
max_dd = 0.0
|
||||
total_bars = 0
|
||||
for sig in signals:
|
||||
i = sig.idx
|
||||
if i + hold >= n or i < 1:
|
||||
continue
|
||||
entry = sig.entry_price
|
||||
exit_price = c[min(i + hold - 1, n - 1)]
|
||||
actual = (exit_price - entry) / entry * sig.direction
|
||||
net = actual * self.leverage - self.fee_rt * self.leverage
|
||||
capital += capital * self.position_size * net
|
||||
capital = max(capital, 10)
|
||||
if capital > peak: peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
total_bars += hold
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0: yearly[year]["w"] += 1
|
||||
yearly[year]["pnl"] += net * self.initial_capital
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t == 0: return None
|
||||
yearly_stats = [YearlyStats(y, d["t"], d["w"], d["pnl"]) for y, d in sorted(yearly.items())]
|
||||
return BacktestResult(
|
||||
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
|
||||
trades=all_t, wins=all_w, pnl=sum(d["pnl"] for d in yearly.values()),
|
||||
capital=capital, initial_capital=self.initial_capital,
|
||||
max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100,
|
||||
avg_trade_duration_h=hold * TF_MINUTES.get(tf, 60) / 60,
|
||||
years_active=len(yearly), yearly=yearly_stats,
|
||||
)
|
||||
|
||||
def report_all_presets(self):
|
||||
"""Esegue tutte le combinazioni preset × asset × tf."""
|
||||
all_results = []
|
||||
for preset_name, filter_list in self.FILTER_PRESETS.items():
|
||||
for asset in self.default_assets:
|
||||
for tf in self.default_timeframes:
|
||||
r = self.backtest(asset, tf, filters=filter_list)
|
||||
if r and r.trades >= 20:
|
||||
r.strategy_name = f"SQ04 {preset_name}"
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
|
||||
print(f"\n{'=' * 120}")
|
||||
print(f" SQ04 ULTIMATE — TUTTI I PRESET")
|
||||
print(f"{'=' * 120}")
|
||||
for r in all_results:
|
||||
r.print_summary()
|
||||
return all_results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = SqueezeUltimate()
|
||||
strategy.report_all_presets()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""IB01 — Inside Bar Breakout.
|
||||
|
||||
Pattern di compressione a singola candela: quando una barra ha high < prev high
|
||||
E low > prev low, il prezzo si sta comprimendo. Al breakout del range della
|
||||
inside bar, segui la direzione.
|
||||
|
||||
17% delle candele 15m sono inside bars → frequenza altissima.
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame
|
||||
- Parametri: min_consecutive (N inside bars consecutivi),
|
||||
volume_filter, breakout_confirm
|
||||
|
||||
OUT:
|
||||
- Signal al breakout del range dell'inside bar
|
||||
- BacktestResult
|
||||
|
||||
Logica:
|
||||
1. Identifica N inside bars consecutivi (compressione)
|
||||
2. Quando il prezzo rompe il range → entra nella direzione del breakout
|
||||
3. Filtro: volume al breakout > media
|
||||
4. Hold fisso
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.strategies.base import Strategy, Signal
|
||||
|
||||
|
||||
class InsideBarBreakout(Strategy):
|
||||
name = "IB01_inside_bar"
|
||||
description = "Inside bar breakout — compressione a singola candela"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
fee_rt = 0.002
|
||||
|
||||
def generate_signals(self, df, ts, **params):
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
min_consec = params.get("min_consecutive", 2)
|
||||
use_vol = params.get("vol_filter", False)
|
||||
min_range_pct = params.get("min_range_pct", 0.002)
|
||||
|
||||
# Volume media
|
||||
vol_ma = np.full(n, np.nan)
|
||||
for i in range(20, n):
|
||||
vol_ma[i] = np.mean(v[i - 20:i])
|
||||
|
||||
signals = []
|
||||
consec = 0
|
||||
mother_high = 0.0
|
||||
mother_low = 0.0
|
||||
|
||||
for i in range(1, n - 1):
|
||||
is_inside = h[i] <= h[i - 1] and l[i] >= l[i - 1]
|
||||
|
||||
if is_inside:
|
||||
if consec == 0:
|
||||
mother_high = h[i - 1]
|
||||
mother_low = l[i - 1]
|
||||
consec += 1
|
||||
else:
|
||||
if consec >= min_consec:
|
||||
range_pct = (mother_high - mother_low) / mother_low if mother_low > 0 else 0
|
||||
if range_pct < min_range_pct:
|
||||
consec = 0
|
||||
continue
|
||||
|
||||
# Breakout detection sulla barra corrente
|
||||
if c[i] > mother_high:
|
||||
direction = 1
|
||||
elif c[i] < mother_low:
|
||||
direction = -1
|
||||
else:
|
||||
consec = 0
|
||||
continue
|
||||
|
||||
# Volume filter
|
||||
if use_vol and not np.isnan(vol_ma[i]):
|
||||
if v[i] < vol_ma[i] * 1.2:
|
||||
consec = 0
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i, direction=direction, entry_price=c[i],
|
||||
metadata={"consec": consec, "range_pct": round(range_pct * 100, 3)},
|
||||
))
|
||||
|
||||
consec = 0
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = InsideBarBreakout()
|
||||
|
||||
configs = [
|
||||
("2ib", {"min_consecutive": 2}),
|
||||
("3ib", {"min_consecutive": 3}),
|
||||
("4ib", {"min_consecutive": 4}),
|
||||
("2ib+vol", {"min_consecutive": 2, "vol_filter": True}),
|
||||
("3ib+vol", {"min_consecutive": 3, "vol_filter": True}),
|
||||
("2ib r>0.3%", {"min_consecutive": 2, "min_range_pct": 0.003}),
|
||||
("3ib r>0.3%", {"min_consecutive": 3, "min_range_pct": 0.003}),
|
||||
]
|
||||
|
||||
all_results = []
|
||||
for label, params in configs:
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["15m", "1h"]:
|
||||
for hold in [3, 6]:
|
||||
r = strategy.backtest(asset, tf, hold=hold, **params)
|
||||
if r and r.trades >= 30:
|
||||
r.strategy_name = f"IB01 {label} h={hold}"
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
print(f"\n{'=' * 120}")
|
||||
print(f" IB01 INSIDE BAR BREAKOUT — TOP 20")
|
||||
print(f"{'=' * 120}")
|
||||
for r in all_results[:20]:
|
||||
r.print_summary()
|
||||
if all_results:
|
||||
all_results[0].print_yearly()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""DC01 — Donchian Channel Breakout con filtri.
|
||||
|
||||
Trend-following classico: quando il prezzo rompe il massimo/minimo degli
|
||||
ultimi N periodi, entra nella direzione del breakout.
|
||||
|
||||
Completamente diverso dallo squeeze (che usa Bollinger/Keltner).
|
||||
Donchian cattura breakout di RANGE, non di VOLATILITÀ.
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame
|
||||
- Parametri: channel_period, volume_filter, atr_stop, trend_filter
|
||||
|
||||
OUT:
|
||||
- Signal al breakout del canale Donchian
|
||||
- BacktestResult
|
||||
|
||||
Logica:
|
||||
1. Donchian upper = max(high, N periodi), lower = min(low, N periodi)
|
||||
2. Close > upper → LONG (breakout rialzista)
|
||||
3. Close < lower → SHORT (breakout ribassista)
|
||||
4. Filtri: volume, trend EMA, ATR minimo
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.strategies.base import Strategy, Signal
|
||||
|
||||
|
||||
class DonchianBreakout(Strategy):
|
||||
name = "DC01_donchian"
|
||||
description = "Donchian Channel breakout — trend-following su range"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
fee_rt = 0.002
|
||||
|
||||
def generate_signals(self, df, ts, **params):
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
period = params.get("channel_period", 48)
|
||||
use_vol = params.get("vol_filter", False)
|
||||
use_trend = params.get("trend_filter", False)
|
||||
cooldown = params.get("cooldown", 6)
|
||||
|
||||
# EMA per trend filter
|
||||
ema_50 = np.full(n, np.nan)
|
||||
k = 2 / 51
|
||||
ema_50[49] = np.mean(c[:50])
|
||||
for i in range(50, n):
|
||||
ema_50[i] = c[i] * k + ema_50[i - 1] * (1 - k)
|
||||
|
||||
# Volume media
|
||||
vol_ma = np.full(n, np.nan)
|
||||
for i in range(20, n):
|
||||
vol_ma[i] = np.mean(v[i - 20:i])
|
||||
|
||||
signals = []
|
||||
last_signal_idx = -cooldown
|
||||
|
||||
for i in range(period + 1, n):
|
||||
if i - last_signal_idx < cooldown:
|
||||
continue
|
||||
|
||||
upper = np.max(h[i - period:i])
|
||||
lower = np.min(l[i - period:i])
|
||||
|
||||
direction = 0
|
||||
if c[i] > upper:
|
||||
direction = 1
|
||||
elif c[i] < lower:
|
||||
direction = -1
|
||||
|
||||
if direction == 0:
|
||||
continue
|
||||
|
||||
# Trend filter: breakout must align with EMA trend
|
||||
if use_trend and not np.isnan(ema_50[i]):
|
||||
if direction == 1 and c[i] < ema_50[i]:
|
||||
continue
|
||||
if direction == -1 and c[i] > ema_50[i]:
|
||||
continue
|
||||
|
||||
# Volume filter
|
||||
if use_vol and not np.isnan(vol_ma[i]):
|
||||
if v[i] < vol_ma[i] * 1.3:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i, direction=direction, entry_price=c[i],
|
||||
metadata={"upper": float(upper), "lower": float(lower)},
|
||||
))
|
||||
last_signal_idx = i
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = DonchianBreakout()
|
||||
|
||||
configs = [
|
||||
("p=24", {"channel_period": 24}),
|
||||
("p=48", {"channel_period": 48}),
|
||||
("p=96", {"channel_period": 96}),
|
||||
("p=48+trend", {"channel_period": 48, "trend_filter": True}),
|
||||
("p=48+vol", {"channel_period": 48, "vol_filter": True}),
|
||||
("p=48+t+v", {"channel_period": 48, "trend_filter": True, "vol_filter": True}),
|
||||
("p=96+t+v", {"channel_period": 96, "trend_filter": True, "vol_filter": True}),
|
||||
]
|
||||
|
||||
all_results = []
|
||||
for label, params in configs:
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["15m", "1h"]:
|
||||
for hold in [3, 6, 12]:
|
||||
r = strategy.backtest(asset, tf, hold=hold, **params)
|
||||
if r and r.trades >= 30:
|
||||
r.strategy_name = f"DC01 {label} h={hold}"
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
print(f"\n{'=' * 120}")
|
||||
print(f" DC01 DONCHIAN BREAKOUT — TOP 20")
|
||||
print(f"{'=' * 120}")
|
||||
for r in all_results[:20]:
|
||||
r.print_summary()
|
||||
if all_results:
|
||||
all_results[0].print_yearly()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""SB01 — Squeeze Breakout con Retest.
|
||||
|
||||
Il problema di SQ01/SQ02: entri al breakout, ma molti breakout sono fakeout.
|
||||
Soluzione: aspetta il RETEST. Dopo il breakout, il prezzo spesso torna a
|
||||
testare il livello di breakout prima di continuare.
|
||||
|
||||
Più selettivo di SQ02 → meno trade ma più accurati.
|
||||
Anti-overfitting: meccanismo strutturale (retest è fenomeno di mercato reale).
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame
|
||||
- Parametri: bb_window, sq_threshold, retest_window (quante barre aspettare
|
||||
il retest), retest_tolerance (quanto può tornare indietro)
|
||||
|
||||
OUT:
|
||||
- Signal al retest confermato (non al breakout iniziale)
|
||||
- BacktestResult
|
||||
|
||||
Logica:
|
||||
1. Rileva squeeze release (come SQ01)
|
||||
2. NON entrare subito — segna direzione e livello di breakout
|
||||
3. Nelle N barre successive, aspetta che il prezzo torni verso il livello
|
||||
4. Se il prezzo torna nel range di tolleranza e poi rimbalza → ENTRA
|
||||
5. Se il prezzo non torna → skip (momentum troppo forte, entry persa)
|
||||
6. Se il prezzo sfonda il livello → fakeout confermato, skip
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.strategies.base import Strategy, Signal
|
||||
from src.strategies.indicators import keltner_ratio, detect_squeezes
|
||||
|
||||
|
||||
class SqueezeBreakoutRetest(Strategy):
|
||||
name = "SB01_squeeze_retest"
|
||||
description = "Squeeze breakout con retest — entra solo dopo pullback confermato"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
fee_rt = 0.002
|
||||
|
||||
def generate_signals(self, df, ts, **params):
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
bb_w = params.get("bb_window", 14)
|
||||
sq_thr = params.get("sq_threshold", 0.8)
|
||||
retest_window = params.get("retest_window", 8)
|
||||
retest_tol = params.get("retest_tolerance", 0.5)
|
||||
use_vol = params.get("vol_filter", False)
|
||||
|
||||
kcr = keltner_ratio(c, h, l, bb_w)
|
||||
events = detect_squeezes(c, h, l, kcr, sq_thr)
|
||||
|
||||
vol_ma = np.full(n, np.nan)
|
||||
for i in range(20, n):
|
||||
vol_ma[i] = np.mean(v[i - 20:i])
|
||||
|
||||
signals = []
|
||||
|
||||
for ev in events:
|
||||
brk_idx = ev["idx"]
|
||||
if brk_idx + retest_window + 3 >= n or brk_idx < 1:
|
||||
continue
|
||||
|
||||
# Direzione breakout
|
||||
first_ret = (c[brk_idx] - c[brk_idx - 1]) / c[brk_idx - 1]
|
||||
if abs(first_ret) < 0.001:
|
||||
continue
|
||||
|
||||
direction = 1 if first_ret > 0 else -1
|
||||
breakout_level = c[brk_idx - 1]
|
||||
breakout_move = abs(first_ret)
|
||||
|
||||
# Aspetta retest nelle prossime N barre
|
||||
retest_found = False
|
||||
retest_idx = -1
|
||||
|
||||
for j in range(brk_idx + 1, min(brk_idx + retest_window + 1, n)):
|
||||
if direction == 1:
|
||||
# Long: il prezzo deve tornare GIÙ verso breakout_level
|
||||
pullback = (h[brk_idx] - l[j]) / (h[brk_idx] - breakout_level) if h[brk_idx] > breakout_level else 0
|
||||
if pullback >= retest_tol:
|
||||
# Tornato abbastanza — ora deve rimbalzare
|
||||
if c[j] > breakout_level:
|
||||
retest_found = True
|
||||
retest_idx = j
|
||||
break
|
||||
elif c[j] < breakout_level * 0.998:
|
||||
# Sfondato sotto → fakeout
|
||||
break
|
||||
else:
|
||||
# Short: il prezzo deve tornare SU verso breakout_level
|
||||
pullback = (h[j] - l[brk_idx]) / (breakout_level - l[brk_idx]) if breakout_level > l[brk_idx] else 0
|
||||
if pullback >= retest_tol:
|
||||
if c[j] < breakout_level:
|
||||
retest_found = True
|
||||
retest_idx = j
|
||||
break
|
||||
elif c[j] > breakout_level * 1.002:
|
||||
break
|
||||
|
||||
if not retest_found or retest_idx < 0:
|
||||
continue
|
||||
|
||||
# Volume filter al retest
|
||||
if use_vol and not np.isnan(vol_ma[retest_idx]):
|
||||
if v[retest_idx] < vol_ma[retest_idx] * 0.8:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=retest_idx, direction=direction,
|
||||
entry_price=c[retest_idx],
|
||||
metadata={
|
||||
"breakout_idx": brk_idx,
|
||||
"retest_bars": retest_idx - brk_idx,
|
||||
"breakout_move": round(breakout_move * 100, 3),
|
||||
},
|
||||
))
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = SqueezeBreakoutRetest()
|
||||
|
||||
configs = [
|
||||
("rt8 tol50%", {"retest_window": 8, "retest_tolerance": 0.5}),
|
||||
("rt6 tol50%", {"retest_window": 6, "retest_tolerance": 0.5}),
|
||||
("rt10 tol50%", {"retest_window": 10, "retest_tolerance": 0.5}),
|
||||
("rt8 tol30%", {"retest_window": 8, "retest_tolerance": 0.3}),
|
||||
("rt8 tol70%", {"retest_window": 8, "retest_tolerance": 0.7}),
|
||||
("rt8 tol50%+vol", {"retest_window": 8, "retest_tolerance": 0.5, "vol_filter": True}),
|
||||
("rt6 tol30%", {"retest_window": 6, "retest_tolerance": 0.3}),
|
||||
("rt12 tol50%", {"retest_window": 12, "retest_tolerance": 0.5}),
|
||||
]
|
||||
|
||||
all_results = []
|
||||
for label, params in configs:
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["15m", "1h"]:
|
||||
for hold in [3, 6]:
|
||||
r = strategy.backtest(asset, tf, hold=hold, **params)
|
||||
if r and r.trades >= 30:
|
||||
r.strategy_name = f"SB01 {label} h={hold}"
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
print(f"\n{'=' * 130}")
|
||||
print(f" SB01 SQUEEZE BREAKOUT RETEST — TOP 25")
|
||||
print(f"{'=' * 130}")
|
||||
for r in all_results[:25]:
|
||||
r.print_summary()
|
||||
if all_results:
|
||||
all_results[0].print_yearly()
|
||||
|
||||
# Confronto con benchmark
|
||||
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250 trades, DD 6.5%, 9/9 anni")
|
||||
@@ -0,0 +1,148 @@
|
||||
"""MR01 — Mean Reversion da estremi RSI.
|
||||
|
||||
Approccio opposto allo squeeze: quando il prezzo va troppo lontano troppo veloce,
|
||||
scommetti che torni indietro. Autocorrelazione lag-1 negativa (-0.21 BTC, -0.35 ETH)
|
||||
conferma che il mercato a 15m è mean-reverting.
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame
|
||||
- Parametri: rsi_period, rsi_oversold, rsi_overbought, hold_bars,
|
||||
volume_filter (volume > N× media), atr_filter (move > N×ATR)
|
||||
|
||||
OUT:
|
||||
- Signal: long quando RSI < oversold, short quando RSI > overbought
|
||||
- BacktestResult con metriche
|
||||
|
||||
Logica:
|
||||
1. RSI scende sotto soglia oversold → LONG (prezzo tornerà su)
|
||||
2. RSI sale sopra soglia overbought → SHORT (prezzo tornerà giù)
|
||||
3. Filtro opzionale: volume spike conferma l'eccesso
|
||||
4. Filtro opzionale: move recente > N×ATR (eccesso di prezzo)
|
||||
5. Hold fisso, poi chiudi
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.strategies.base import Strategy, Signal
|
||||
|
||||
|
||||
def rsi(close, period=14):
|
||||
delta = np.diff(close)
|
||||
gain = np.where(delta > 0, delta, 0)
|
||||
loss = np.where(delta < 0, -delta, 0)
|
||||
result = np.full(len(close), 50.0)
|
||||
if len(gain) < period:
|
||||
return result
|
||||
ag = np.mean(gain[:period])
|
||||
al = np.mean(loss[:period])
|
||||
for i in range(period, len(delta)):
|
||||
ag = (ag * (period - 1) + gain[i]) / period
|
||||
al = (al * (period - 1) + loss[i]) / period
|
||||
result[i + 1] = 100 if al == 0 else 100 - 100 / (1 + ag / al)
|
||||
return result
|
||||
|
||||
|
||||
class MeanReversionRSI(Strategy):
|
||||
name = "MR01_mean_reversion_rsi"
|
||||
description = "Mean reversion da estremi RSI — fade eccessi direzionali"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
fee_rt = 0.002
|
||||
|
||||
def generate_signals(self, df, ts, **params):
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
rsi_period = params.get("rsi_period", 14)
|
||||
oversold = params.get("rsi_oversold", 25)
|
||||
overbought = params.get("rsi_overbought", 75)
|
||||
use_vol_filter = params.get("vol_filter", False)
|
||||
use_atr_filter = params.get("atr_filter", False)
|
||||
cooldown = params.get("cooldown", 4)
|
||||
|
||||
rsi_vals = rsi(c, rsi_period)
|
||||
|
||||
# Volume media rolling
|
||||
vol_ma = np.full(n, np.nan)
|
||||
for i in range(20, n):
|
||||
vol_ma[i] = np.mean(v[i - 20:i])
|
||||
|
||||
# ATR
|
||||
tr = np.maximum(h[1:] - l[1:],
|
||||
np.maximum(np.abs(h[1:] - c[:-1]), np.abs(l[1:] - c[:-1])))
|
||||
atr_vals = np.full(n, np.nan)
|
||||
for i in range(15, len(tr)):
|
||||
atr_vals[i + 1] = np.mean(tr[i - 14:i])
|
||||
|
||||
signals = []
|
||||
last_signal_idx = -cooldown
|
||||
|
||||
for i in range(20, n):
|
||||
if i - last_signal_idx < cooldown:
|
||||
continue
|
||||
|
||||
direction = 0
|
||||
if rsi_vals[i] < oversold:
|
||||
direction = 1 # oversold → long
|
||||
elif rsi_vals[i] > overbought:
|
||||
direction = -1 # overbought → short
|
||||
|
||||
if direction == 0:
|
||||
continue
|
||||
|
||||
# Volume filter
|
||||
if use_vol_filter and not np.isnan(vol_ma[i]):
|
||||
if v[i] < vol_ma[i] * 1.5:
|
||||
continue
|
||||
|
||||
# ATR filter: il move recente deve essere > 1.5× ATR
|
||||
if use_atr_filter and not np.isnan(atr_vals[i]):
|
||||
recent_move = abs(c[i] - c[max(0, i - 3)]) / c[max(0, i - 3)]
|
||||
if recent_move < atr_vals[i] / c[i] * 1.5:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i, direction=direction, entry_price=c[i],
|
||||
metadata={"rsi": float(rsi_vals[i])},
|
||||
))
|
||||
last_signal_idx = i
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = MeanReversionRSI()
|
||||
|
||||
configs = [
|
||||
("RSI25/75", {}),
|
||||
("RSI20/80", {"rsi_oversold": 20, "rsi_overbought": 80}),
|
||||
("RSI25/75+vol", {"vol_filter": True}),
|
||||
("RSI20/80+vol", {"rsi_oversold": 20, "rsi_overbought": 80, "vol_filter": True}),
|
||||
("RSI25/75+atr", {"atr_filter": True}),
|
||||
("RSI20/80+vol+atr", {"rsi_oversold": 20, "rsi_overbought": 80, "vol_filter": True, "atr_filter": True}),
|
||||
]
|
||||
|
||||
all_results = []
|
||||
for label, params in configs:
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["15m", "1h"]:
|
||||
for hold in [3, 6]:
|
||||
r = strategy.backtest(asset, tf, hold=hold, **params)
|
||||
if r and r.trades >= 30:
|
||||
r.strategy_name = f"MR01 {label} h={hold}"
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
print(f"\n{'=' * 120}")
|
||||
print(f" MR01 MEAN REVERSION RSI — TOP 20")
|
||||
print(f"{'=' * 120}")
|
||||
for r in all_results[:20]:
|
||||
r.print_summary()
|
||||
if all_results:
|
||||
all_results[0].print_yearly()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""VO01 — Volume Spike Reversal.
|
||||
|
||||
Quando il volume esplode (>3× media) con un forte move direzionale,
|
||||
il mercato è in eccesso → fade il move (mean reversion).
|
||||
|
||||
Diverso dallo squeeze: non cerca compressione, cerca ECCESSO.
|
||||
Il volume spike indica panico/euforia → reversal probabile.
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame
|
||||
- Parametri: vol_mult (3), move_threshold (0.005), hold
|
||||
|
||||
OUT:
|
||||
- Signal: fade la direzione del volume spike
|
||||
- BacktestResult
|
||||
|
||||
Logica:
|
||||
1. Volume > vol_mult × media 20 periodi
|
||||
2. Move nella candela > move_threshold (0.5%)
|
||||
3. Direzione: opposta al move (mean reversion)
|
||||
4. Filtro: non entrare se già in trend forte (EMA slope)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.strategies.base import Strategy, Signal
|
||||
|
||||
|
||||
class VolumeSpikeReversal(Strategy):
|
||||
name = "VO01_vol_spike_reversal"
|
||||
description = "Volume spike reversal — fade eccessi di volume/prezzo"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
fee_rt = 0.002
|
||||
|
||||
def generate_signals(self, df, ts, **params):
|
||||
c = df["close"].values
|
||||
o = df["open"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
vol_mult = params.get("vol_mult", 3.0)
|
||||
move_thr = params.get("move_threshold", 0.005)
|
||||
use_trend_filter = params.get("trend_filter", False)
|
||||
cooldown = params.get("cooldown", 4)
|
||||
|
||||
# Volume media rolling
|
||||
vol_ma = np.full(n, np.nan)
|
||||
for i in range(20, n):
|
||||
vol_ma[i] = np.mean(v[i - 20:i])
|
||||
|
||||
# EMA per trend filter
|
||||
ema_20 = np.full(n, np.nan)
|
||||
k = 2 / 21
|
||||
ema_20[19] = np.mean(c[:20])
|
||||
for i in range(20, n):
|
||||
ema_20[i] = c[i] * k + ema_20[i - 1] * (1 - k)
|
||||
|
||||
signals = []
|
||||
last_idx = -cooldown
|
||||
|
||||
for i in range(21, n):
|
||||
if i - last_idx < cooldown:
|
||||
continue
|
||||
if np.isnan(vol_ma[i]):
|
||||
continue
|
||||
|
||||
# Volume spike
|
||||
if v[i] < vol_ma[i] * vol_mult:
|
||||
continue
|
||||
|
||||
# Price move
|
||||
move = (c[i] - o[i]) / o[i] if o[i] > 0 else 0
|
||||
if abs(move) < move_thr:
|
||||
continue
|
||||
|
||||
# Fade: opposto al move
|
||||
direction = -1 if move > 0 else 1
|
||||
|
||||
# Trend filter: non fare mean reversion contro trend forte
|
||||
if use_trend_filter and not np.isnan(ema_20[i]):
|
||||
ema_slope = (ema_20[i] - ema_20[max(0, i - 5)]) / ema_20[max(0, i - 5)]
|
||||
if direction == -1 and ema_slope > 0.005:
|
||||
continue
|
||||
if direction == 1 and ema_slope < -0.005:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i, direction=direction, entry_price=c[i],
|
||||
metadata={"vol_ratio": float(v[i] / vol_ma[i]), "move_pct": round(move * 100, 3)},
|
||||
))
|
||||
last_idx = i
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = VolumeSpikeReversal()
|
||||
|
||||
configs = [
|
||||
("v3x m0.5%", {"vol_mult": 3.0, "move_threshold": 0.005}),
|
||||
("v3x m1%", {"vol_mult": 3.0, "move_threshold": 0.01}),
|
||||
("v4x m0.5%", {"vol_mult": 4.0, "move_threshold": 0.005}),
|
||||
("v4x m1%", {"vol_mult": 4.0, "move_threshold": 0.01}),
|
||||
("v3x m0.5%+tf", {"vol_mult": 3.0, "move_threshold": 0.005, "trend_filter": True}),
|
||||
("v3x m1%+tf", {"vol_mult": 3.0, "move_threshold": 0.01, "trend_filter": True}),
|
||||
("v5x m1%", {"vol_mult": 5.0, "move_threshold": 0.01}),
|
||||
("v5x m1%+tf", {"vol_mult": 5.0, "move_threshold": 0.01, "trend_filter": True}),
|
||||
]
|
||||
|
||||
all_results = []
|
||||
for label, params in configs:
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["15m", "1h"]:
|
||||
for hold in [3, 6]:
|
||||
r = strategy.backtest(asset, tf, hold=hold, **params)
|
||||
if r and r.trades >= 30:
|
||||
r.strategy_name = f"VO01 {label} h={hold}"
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
print(f"\n{'=' * 120}")
|
||||
print(f" VO01 VOLUME SPIKE REVERSAL — TOP 20")
|
||||
print(f"{'=' * 120}")
|
||||
for r in all_results[:20]:
|
||||
r.print_summary()
|
||||
if all_results:
|
||||
all_results[0].print_yearly()
|
||||
@@ -0,0 +1,169 @@
|
||||
"""HY01 — Squeeze + Mean Reversion Ibrida.
|
||||
|
||||
Insight: durante lo squeeze (bassa volatilità), il prezzo mean-reverte
|
||||
DENTRO il range compresso. Autocorrelazione negativa a 15m conferma.
|
||||
Invece di aspettare il BREAKOUT, tradi la MEAN REVERSION dentro lo squeeze.
|
||||
|
||||
Completamente diverso da SQ01-SQ04 che aspettano il RILASCIO.
|
||||
|
||||
IN:
|
||||
- OHLCV DataFrame
|
||||
- Parametri: bb_window, sq_threshold, rsi_period, rsi_levels,
|
||||
vol_filter, bb_touch (prezzo tocca banda Bollinger)
|
||||
|
||||
OUT:
|
||||
- Signal: long quando RSI oversold DURANTE squeeze, short quando overbought
|
||||
- BacktestResult
|
||||
|
||||
Logica:
|
||||
1. Verifica che siamo IN squeeze (BB dentro KC)
|
||||
2. Prezzo tocca banda inferiore BB → LONG (tornerà alla media)
|
||||
3. Prezzo tocca banda superiore BB → SHORT (tornerà alla media)
|
||||
4. Conferma RSI: deve essere estremo nella direzione
|
||||
5. Hold corto (2-3 barre) — target: ritorno alla media
|
||||
6. Stop: se prezzo rompe lo squeeze → chiudi subito
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.strategies.base import Strategy, Signal
|
||||
from src.strategies.indicators import keltner_ratio
|
||||
|
||||
|
||||
def rsi(close, period=14):
|
||||
delta = np.diff(close)
|
||||
gain = np.where(delta > 0, delta, 0)
|
||||
loss = np.where(delta < 0, -delta, 0)
|
||||
result = np.full(len(close), 50.0)
|
||||
if len(gain) < period:
|
||||
return result
|
||||
ag = np.mean(gain[:period])
|
||||
al = np.mean(loss[:period])
|
||||
for i in range(period, len(delta)):
|
||||
ag = (ag * (period - 1) + gain[i]) / period
|
||||
al = (al * (period - 1) + loss[i]) / period
|
||||
result[i + 1] = 100 if al == 0 else 100 - 100 / (1 + ag / al)
|
||||
return result
|
||||
|
||||
|
||||
def bollinger(close, window=14):
|
||||
n = len(close)
|
||||
upper = np.full(n, np.nan)
|
||||
lower = np.full(n, np.nan)
|
||||
mid = np.full(n, np.nan)
|
||||
for i in range(window, n):
|
||||
wc = close[i - window:i]
|
||||
m = np.mean(wc)
|
||||
s = np.std(wc)
|
||||
mid[i] = m
|
||||
upper[i] = m + 2 * s
|
||||
lower[i] = m - 2 * s
|
||||
return upper, mid, lower
|
||||
|
||||
|
||||
class SqueezeMeanReversion(Strategy):
|
||||
name = "HY01_squeeze_mr"
|
||||
description = "Mean reversion DENTRO lo squeeze — fade estremi in range compresso"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["15m", "1h"]
|
||||
fee_rt = 0.002
|
||||
|
||||
def generate_signals(self, df, ts, **params):
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
v = df["volume"].values
|
||||
n = len(c)
|
||||
|
||||
bb_w = params.get("bb_window", 14)
|
||||
sq_thr = params.get("sq_threshold", 0.8)
|
||||
rsi_period = params.get("rsi_period", 14)
|
||||
rsi_low = params.get("rsi_oversold", 30)
|
||||
rsi_high = params.get("rsi_overbought", 70)
|
||||
use_bb_touch = params.get("bb_touch", True)
|
||||
cooldown = params.get("cooldown", 3)
|
||||
|
||||
kcr = keltner_ratio(c, h, l, bb_w)
|
||||
rsi_vals = rsi(c, rsi_period)
|
||||
bb_upper, bb_mid, bb_lower = bollinger(c, bb_w)
|
||||
|
||||
signals = []
|
||||
last_idx = -cooldown
|
||||
|
||||
for i in range(bb_w + 1, n):
|
||||
if i - last_idx < cooldown:
|
||||
continue
|
||||
if np.isnan(kcr[i]) or np.isnan(bb_lower[i]):
|
||||
continue
|
||||
|
||||
# Must be IN squeeze
|
||||
if kcr[i] >= sq_thr:
|
||||
continue
|
||||
|
||||
direction = 0
|
||||
|
||||
if use_bb_touch:
|
||||
# Prezzo tocca/rompe BB lower → long (mean reversion up)
|
||||
if c[i] <= bb_lower[i] and rsi_vals[i] < rsi_low:
|
||||
direction = 1
|
||||
# Prezzo tocca/rompe BB upper → short (mean reversion down)
|
||||
elif c[i] >= bb_upper[i] and rsi_vals[i] > rsi_high:
|
||||
direction = -1
|
||||
else:
|
||||
# Solo RSI
|
||||
if rsi_vals[i] < rsi_low:
|
||||
direction = 1
|
||||
elif rsi_vals[i] > rsi_high:
|
||||
direction = -1
|
||||
|
||||
if direction == 0:
|
||||
continue
|
||||
|
||||
signals.append(Signal(
|
||||
idx=i, direction=direction, entry_price=c[i],
|
||||
metadata={
|
||||
"rsi": float(rsi_vals[i]),
|
||||
"kcr": float(kcr[i]),
|
||||
"bb_pos": "lower" if direction == 1 else "upper",
|
||||
},
|
||||
))
|
||||
last_idx = i
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strategy = SqueezeMeanReversion()
|
||||
|
||||
configs = [
|
||||
("bb+rsi30/70", {"bb_touch": True, "rsi_oversold": 30, "rsi_overbought": 70}),
|
||||
("bb+rsi25/75", {"bb_touch": True, "rsi_oversold": 25, "rsi_overbought": 75}),
|
||||
("bb+rsi35/65", {"bb_touch": True, "rsi_oversold": 35, "rsi_overbought": 65}),
|
||||
("rsi30/70 only", {"bb_touch": False, "rsi_oversold": 30, "rsi_overbought": 70}),
|
||||
("rsi25/75 only", {"bb_touch": False, "rsi_oversold": 25, "rsi_overbought": 75}),
|
||||
("sq<0.7 bb+rsi30", {"bb_touch": True, "sq_threshold": 0.7, "rsi_oversold": 30, "rsi_overbought": 70}),
|
||||
("sq<0.9 bb+rsi30", {"bb_touch": True, "sq_threshold": 0.9, "rsi_oversold": 30, "rsi_overbought": 70}),
|
||||
("sq<0.9 rsi35/65", {"bb_touch": False, "sq_threshold": 0.9, "rsi_oversold": 35, "rsi_overbought": 65}),
|
||||
]
|
||||
|
||||
all_results = []
|
||||
for label, params in configs:
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["15m", "1h"]:
|
||||
for hold in [2, 3, 4]:
|
||||
r = strategy.backtest(asset, tf, hold=hold, **params)
|
||||
if r and r.trades >= 30:
|
||||
r.strategy_name = f"HY01 {label} h={hold}"
|
||||
all_results.append(r)
|
||||
|
||||
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
print(f"\n{'=' * 130}")
|
||||
print(f" HY01 SQUEEZE MEAN REVERSION — TOP 25")
|
||||
print(f"{'=' * 130}")
|
||||
for r in all_results[:25]:
|
||||
r.print_summary()
|
||||
if all_results:
|
||||
all_results[0].print_yearly()
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Multi-Strategy Paper Trader — orchestratore per N strategie in parallelo."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import yaml
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from src.live.cerbero_client import CerberoClient
|
||||
from src.live.strategy_loader import load_strategy
|
||||
from src.live.strategy_worker import StrategyWorker
|
||||
from src.live.signal_engine import SignalEngine
|
||||
from src.live.telegram_notifier import send_telegram
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DATA_DIR = PROJECT_ROOT / "data" / "paper_trades"
|
||||
|
||||
RESOLUTION_MAP = {"15m": "15", "1h": "60", "5m": "5"}
|
||||
INSTRUMENT_MAP = {
|
||||
"BTC": "BTC-PERPETUAL",
|
||||
"ETH": "ETH-PERPETUAL",
|
||||
}
|
||||
|
||||
|
||||
class MLWorkerWrapper:
|
||||
"""Wrapper speciale per ML01 che usa SignalEngine con training."""
|
||||
|
||||
def __init__(self, worker: StrategyWorker, config: dict):
|
||||
self.worker = worker
|
||||
self.engine = SignalEngine(
|
||||
bb_w=config.get("params", {}).get("bb_window", 14),
|
||||
sq_thr=config.get("params", {}).get("sq_threshold", 0.8),
|
||||
ml_thr=config.get("params", {}).get("ml_threshold", 0.70),
|
||||
)
|
||||
self.trained = False
|
||||
self.last_train: datetime | None = None
|
||||
self.retrain_hours = config.get("retrain_hours", 24)
|
||||
|
||||
def needs_training(self) -> bool:
|
||||
if not self.trained:
|
||||
return True
|
||||
if self.last_train is None:
|
||||
return True
|
||||
elapsed = (datetime.now(timezone.utc) - self.last_train).total_seconds()
|
||||
return elapsed > self.retrain_hours * 3600
|
||||
|
||||
def train(self, df: pd.DataFrame, hold: int = 3):
|
||||
result = self.engine.train(df, lookahead=hold)
|
||||
if "error" not in result:
|
||||
self.trained = True
|
||||
self.last_train = datetime.now(timezone.utc)
|
||||
print(f" [{self.worker.worker_id}] TRAIN OK: {result}")
|
||||
else:
|
||||
print(f" [{self.worker.worker_id}] TRAIN FAIL: {result}")
|
||||
|
||||
def tick(self, df: pd.DataFrame):
|
||||
if not self.trained:
|
||||
return
|
||||
|
||||
worker = self.worker
|
||||
c = df["close"].values
|
||||
current_price = float(c[-1])
|
||||
current_ts = int(df["timestamp"].iloc[-1])
|
||||
|
||||
if worker.in_position:
|
||||
if current_ts > worker.last_bar_ts:
|
||||
worker.bars_held += 1
|
||||
worker.last_bar_ts = current_ts
|
||||
if worker.bars_held >= worker.hold_bars:
|
||||
worker._close_position(current_price, "hold_limit")
|
||||
else:
|
||||
pnl_pct = (current_price - worker.entry_price) / worker.entry_price * worker.direction
|
||||
if pnl_pct <= -0.02:
|
||||
worker._close_position(current_price, "stop_loss")
|
||||
worker._save_state()
|
||||
return
|
||||
|
||||
signal = self.engine.check_signal(df)
|
||||
if signal:
|
||||
from src.strategies.base import Signal
|
||||
direction = 1 if signal["direction"] == "buy" else -1
|
||||
sig = Signal(idx=len(df)-1, direction=direction, entry_price=current_price)
|
||||
worker._open_position(sig, current_price)
|
||||
worker.last_bar_ts = current_ts
|
||||
|
||||
worker._save_state()
|
||||
|
||||
|
||||
def load_config(path: Path) -> dict:
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def build_workers(config: dict) -> tuple[list[StrategyWorker], list[MLWorkerWrapper]]:
|
||||
"""Crea worker da config YAML."""
|
||||
defaults = config.get("defaults", {})
|
||||
regular_workers: list[StrategyWorker] = []
|
||||
ml_workers: list[MLWorkerWrapper] = []
|
||||
|
||||
for entry in config.get("strategies", []):
|
||||
if not entry.get("enabled", True):
|
||||
continue
|
||||
|
||||
name = entry["name"]
|
||||
asset = entry["asset"]
|
||||
tf = entry["tf"]
|
||||
capital = entry.get("capital", defaults.get("capital", 1000))
|
||||
pos_size = entry.get("position_size", defaults.get("position_size", 0.15))
|
||||
leverage = entry.get("leverage", defaults.get("leverage", 3))
|
||||
hold = entry.get("hold_bars", defaults.get("hold_bars", 3))
|
||||
params = entry.get("params", {})
|
||||
|
||||
strategy = load_strategy(name)
|
||||
|
||||
worker = StrategyWorker(
|
||||
strategy=strategy, asset=asset, tf=tf,
|
||||
capital=capital, position_size=pos_size,
|
||||
leverage=leverage, hold_bars=hold,
|
||||
params=params, data_dir=DATA_DIR,
|
||||
)
|
||||
|
||||
if name == "ML01_squeeze_gbm":
|
||||
ml_wrapper = MLWorkerWrapper(worker, {**defaults, **entry})
|
||||
ml_workers.append(ml_wrapper)
|
||||
else:
|
||||
regular_workers.append(worker)
|
||||
|
||||
return regular_workers, ml_workers
|
||||
|
||||
|
||||
def run():
|
||||
config_path = PROJECT_ROOT / "strategies.yml"
|
||||
if not config_path.exists():
|
||||
print(f"ERRORE: {config_path} non trovato")
|
||||
return
|
||||
|
||||
config = load_config(config_path)
|
||||
defaults = config.get("defaults", {})
|
||||
poll_seconds = defaults.get("poll_seconds", 60)
|
||||
lookback_days = 60
|
||||
train_lookback_days = 365
|
||||
|
||||
regular_workers, ml_workers = build_workers(config)
|
||||
all_worker_count = len(regular_workers) + len(ml_workers)
|
||||
|
||||
if all_worker_count == 0:
|
||||
print("Nessuna strategia abilitata in strategies.yml")
|
||||
return
|
||||
|
||||
client = CerberoClient()
|
||||
|
||||
print("=" * 70)
|
||||
print(f" MULTI-STRATEGY PAPER TRADER")
|
||||
print(f" Strategie attive: {all_worker_count}")
|
||||
print(f" Poll: ogni {poll_seconds}s")
|
||||
print(f" Data dir: {DATA_DIR}")
|
||||
print("=" * 70)
|
||||
|
||||
for w in regular_workers:
|
||||
print(f" • {w.status_summary}")
|
||||
for mw in ml_workers:
|
||||
print(f" • {mw.worker.status_summary} [ML]")
|
||||
|
||||
send_telegram(f"🚀 Multi-Strategy avviato: {all_worker_count} strategie")
|
||||
|
||||
# Raccogli asset/tf unici per fetch raggruppato
|
||||
def _get_data_keys() -> set[tuple[str, str]]:
|
||||
keys = set()
|
||||
for w in regular_workers:
|
||||
keys.add((w.asset, w.tf))
|
||||
for mw in ml_workers:
|
||||
keys.add((mw.worker.asset, mw.worker.tf))
|
||||
return keys
|
||||
|
||||
# Training iniziale ML
|
||||
for mw in ml_workers:
|
||||
asset = mw.worker.asset
|
||||
instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
|
||||
resolution = RESOLUTION_MAP.get(mw.worker.tf, "15")
|
||||
end = datetime.now(timezone.utc)
|
||||
start = end - timedelta(days=train_lookback_days)
|
||||
candles = client.get_historical(instrument, start.strftime("%Y-%m-%d"),
|
||||
end.strftime("%Y-%m-%d"), resolution)
|
||||
if candles:
|
||||
df_train = pd.DataFrame(candles)
|
||||
df_train["timestamp"] = df_train["timestamp"].astype("int64")
|
||||
df_train = df_train.sort_values("timestamp").reset_index(drop=True)
|
||||
mw.train(df_train, hold=mw.worker.hold_bars)
|
||||
|
||||
while True:
|
||||
try:
|
||||
data_keys = _get_data_keys()
|
||||
candle_cache: dict[tuple[str, str], pd.DataFrame] = {}
|
||||
|
||||
for asset, tf in data_keys:
|
||||
instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
|
||||
resolution = RESOLUTION_MAP.get(tf, "15")
|
||||
end = datetime.now(timezone.utc)
|
||||
start = end - timedelta(days=lookback_days)
|
||||
|
||||
candles = client.get_historical(
|
||||
instrument, start.strftime("%Y-%m-%d"),
|
||||
end.strftime("%Y-%m-%d"), resolution,
|
||||
)
|
||||
if candles:
|
||||
df = pd.DataFrame(candles)
|
||||
df["timestamp"] = df["timestamp"].astype("int64")
|
||||
df = df.sort_values("timestamp").reset_index(drop=True)
|
||||
candle_cache[(asset, tf)] = df
|
||||
|
||||
# Fetch 1h live per strategie multi-timeframe (es. MT01):
|
||||
# il trend va preso da Cerbero, non dal parquet statico (che resta indietro).
|
||||
htf_cache: dict[str, pd.DataFrame] = {}
|
||||
mtf_assets = {w.asset for w in regular_workers if w.strategy.name.startswith("MT01")}
|
||||
for asset in mtf_assets:
|
||||
instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
|
||||
end = datetime.now(timezone.utc)
|
||||
start = end - timedelta(days=lookback_days)
|
||||
try:
|
||||
candles_1h = client.get_historical(
|
||||
instrument, start.strftime("%Y-%m-%d"),
|
||||
end.strftime("%Y-%m-%d"), "60",
|
||||
)
|
||||
if candles_1h:
|
||||
df1h = pd.DataFrame(candles_1h)
|
||||
df1h["timestamp"] = df1h["timestamp"].astype("int64")
|
||||
htf_cache[asset] = df1h.sort_values("timestamp").reset_index(drop=True)
|
||||
except Exception as e:
|
||||
print(f" [1h fetch {asset}] ERRORE: {e}")
|
||||
|
||||
# Tick regular workers
|
||||
for w in regular_workers:
|
||||
key = (w.asset, w.tf)
|
||||
if key in candle_cache:
|
||||
try:
|
||||
w.tick(candle_cache[key], df_1h=htf_cache.get(w.asset))
|
||||
except Exception as e:
|
||||
print(f" [{w.worker_id}] ERRORE: {e}")
|
||||
|
||||
# Tick ML workers
|
||||
for mw in ml_workers:
|
||||
key = (mw.worker.asset, mw.worker.tf)
|
||||
if key not in candle_cache:
|
||||
continue
|
||||
|
||||
if mw.needs_training():
|
||||
mw.train(candle_cache[key], hold=mw.worker.hold_bars)
|
||||
|
||||
try:
|
||||
mw.tick(candle_cache[key])
|
||||
except Exception as e:
|
||||
print(f" [{mw.worker.worker_id}] ERRORE: {e}")
|
||||
|
||||
# Status periodico
|
||||
now = datetime.now(timezone.utc)
|
||||
if now.minute == 0 and now.second < poll_seconds:
|
||||
lines = [f"📊 Status {now.strftime('%H:%M')} UTC"]
|
||||
for w in regular_workers:
|
||||
lines.append(f" {w.status_summary}")
|
||||
for mw in ml_workers:
|
||||
lines.append(f" {mw.worker.status_summary} [ML]")
|
||||
send_telegram("\n".join(lines))
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutdown...")
|
||||
for w in regular_workers:
|
||||
if w.in_position:
|
||||
df = candle_cache.get((w.asset, w.tf))
|
||||
if df is not None and not df.empty:
|
||||
w._close_position(float(df["close"].iloc[-1]), "shutdown")
|
||||
w._save_state()
|
||||
for mw in ml_workers:
|
||||
if mw.worker.in_position:
|
||||
df = candle_cache.get((mw.worker.asset, mw.worker.tf))
|
||||
if df is not None and not df.empty:
|
||||
mw.worker._close_position(float(df["close"].iloc[-1]), "shutdown")
|
||||
mw.worker._save_state()
|
||||
send_telegram("🛑 Multi-Strategy arrestato")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f" ERRORE GLOBALE: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
time.sleep(poll_seconds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -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 = {
|
||||
|
||||
@@ -112,6 +112,54 @@ class SignalEngine:
|
||||
self.squeeze_start_idx = 0
|
||||
self.trained = False
|
||||
|
||||
def _new_model(self) -> GradientBoostingClassifier:
|
||||
return GradientBoostingClassifier(
|
||||
n_estimators=150, max_depth=4, min_samples_leaf=10,
|
||||
learning_rate=0.05, subsample=0.8, random_state=42,
|
||||
)
|
||||
|
||||
def _validate_oos(self, X: np.ndarray, y: np.ndarray, test_frac: float = 0.2) -> dict:
|
||||
"""Split temporale (no shuffle) per stimare la performance out-of-sample.
|
||||
|
||||
Allena su training iniziale e valuta sull'ultimo `test_frac` dei campioni.
|
||||
Oltre all'accuratezza OOS, riporta la precisione sui soli segnali con
|
||||
confidenza >= ml_thr — cioè i trade che la strategia aprirebbe davvero.
|
||||
"""
|
||||
n_test = int(len(X) * test_frac)
|
||||
n_train = len(X) - n_test
|
||||
if n_train < 30 or n_test < 5:
|
||||
return {"oos_warning": "test set troppo piccolo", "oos_test_samples": n_test}
|
||||
|
||||
scaler = StandardScaler()
|
||||
X_tr = scaler.fit_transform(X[:n_train])
|
||||
X_te = scaler.transform(X[n_train:])
|
||||
y_tr, y_te = y[:n_train], y[n_train:]
|
||||
|
||||
model = self._new_model()
|
||||
model.fit(X_tr, y_tr)
|
||||
|
||||
up_idx = list(model.classes_).index(1)
|
||||
p_up = model.predict_proba(X_te)[:, up_idx]
|
||||
test_acc = float(np.mean((p_up >= 0.5).astype(int) == y_te) * 100)
|
||||
oos_train_acc = float(np.mean(model.predict(X_tr) == y_tr) * 100)
|
||||
|
||||
long_sig = p_up >= self.ml_thr
|
||||
short_sig = p_up <= (1 - self.ml_thr)
|
||||
n_sig = int((long_sig | short_sig).sum())
|
||||
if n_sig > 0:
|
||||
correct = int(((long_sig & (y_te == 1)) | (short_sig & (y_te == 0))).sum())
|
||||
sig_prec = round(correct / n_sig * 100, 1)
|
||||
else:
|
||||
sig_prec = None
|
||||
|
||||
return {
|
||||
"oos_train_accuracy": round(oos_train_acc, 1),
|
||||
"oos_test_accuracy": round(test_acc, 1),
|
||||
"oos_test_samples": n_test,
|
||||
"oos_signals": n_sig,
|
||||
"oos_signal_precision": sig_prec,
|
||||
}
|
||||
|
||||
def train(self, df: pd.DataFrame, lookahead: int = 3) -> dict:
|
||||
"""Addestra il modello su dati storici."""
|
||||
close = df["close"].values
|
||||
@@ -154,20 +202,24 @@ class SignalEngine:
|
||||
X = np.array(X_all)
|
||||
y = np.array(y_all)
|
||||
|
||||
oos = self._validate_oos(X, y)
|
||||
|
||||
self.scaler = StandardScaler()
|
||||
X_s = self.scaler.fit_transform(X)
|
||||
|
||||
self.model = GradientBoostingClassifier(
|
||||
n_estimators=150, max_depth=4, min_samples_leaf=10,
|
||||
learning_rate=0.05, subsample=0.8, random_state=42,
|
||||
)
|
||||
self.model = self._new_model()
|
||||
self.model.fit(X_s, y)
|
||||
self.trained = True
|
||||
|
||||
preds = self.model.predict(X_s)
|
||||
train_acc = np.mean(preds == y) * 100
|
||||
train_acc = float(np.mean(preds == y) * 100)
|
||||
|
||||
return {"samples": len(X), "up_ratio": np.mean(y) * 100, "train_accuracy": train_acc}
|
||||
return {
|
||||
"samples": len(X),
|
||||
"up_ratio": round(float(np.mean(y) * 100), 1),
|
||||
"train_accuracy": round(train_acc, 1),
|
||||
**oos,
|
||||
}
|
||||
|
||||
def check_signal(self, df: pd.DataFrame) -> dict | None:
|
||||
"""Controlla se c'è un segnale sulle ultime candele.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Import dinamico delle classi Strategy da scripts/strategies/."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from src.strategies.base import Strategy
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
STRATEGIES_DIR = PROJECT_ROOT / "scripts" / "strategies"
|
||||
|
||||
_REGISTRY: dict[str, type[Strategy]] = {}
|
||||
|
||||
# Solo strategie con edge netto validato out-of-sample (fee-aware).
|
||||
# La famiglia squeeze-breakout (SQ/MT/ML/AD/CM/PD) e' stata spostata in
|
||||
# scripts/waste/: l'edge storico era un artefatto di look-ahead
|
||||
# (vedi scripts/analysis/oos_validation.py).
|
||||
MODULE_MAP = {
|
||||
"MR01_bollinger_fade": ("MR01_bollinger_fade", "BollingerFade"),
|
||||
"MR02_donchian_fade": ("MR02_donchian_fade", "DonchianFade"),
|
||||
"MR07_return_reversal": ("MR07_return_reversal", "ReturnReversal"),
|
||||
}
|
||||
|
||||
|
||||
def load_strategy(name: str) -> Strategy:
|
||||
"""Carica e istanzia una Strategy per nome."""
|
||||
if name in _REGISTRY:
|
||||
return _REGISTRY[name]()
|
||||
|
||||
if name not in MODULE_MAP:
|
||||
raise ValueError(f"Strategia sconosciuta: {name}. Disponibili: {list(MODULE_MAP)}")
|
||||
|
||||
module_file, class_name = MODULE_MAP[name]
|
||||
module_path = STRATEGIES_DIR / f"{module_file}.py"
|
||||
|
||||
if not module_path.exists():
|
||||
raise FileNotFoundError(f"File strategia non trovato: {module_path}")
|
||||
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
spec = importlib.util.spec_from_file_location(f"strategies.{module_file}", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
cls = getattr(module, class_name)
|
||||
_REGISTRY[name] = cls
|
||||
return cls()
|
||||
|
||||
|
||||
def list_available() -> list[str]:
|
||||
return list(MODULE_MAP.keys())
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Worker per singola strategia — paper trading con stato persistente."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal
|
||||
from src.live.telegram_notifier import notify_event
|
||||
|
||||
FEE_RT = 0.002
|
||||
|
||||
|
||||
class StrategyWorker:
|
||||
"""Gestisce paper trading per una singola strategia/asset/tf."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
strategy: Strategy,
|
||||
asset: str,
|
||||
tf: str,
|
||||
capital: float = 1000.0,
|
||||
position_size: float = 0.15,
|
||||
leverage: float = 3.0,
|
||||
hold_bars: int = 3,
|
||||
params: dict | None = None,
|
||||
data_dir: Path = Path("data/paper_trades"),
|
||||
):
|
||||
self.strategy = strategy
|
||||
self.asset = asset
|
||||
self.tf = tf
|
||||
self.initial_capital = capital
|
||||
self.position_size = position_size
|
||||
self.leverage = leverage
|
||||
self.hold_bars = hold_bars
|
||||
self.params = params or {}
|
||||
|
||||
self.worker_id = f"{strategy.name}__{asset}__{tf}"
|
||||
self.work_dir = data_dir / self.worker_id
|
||||
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.trades_path = self.work_dir / "trades.jsonl"
|
||||
self.status_path = self.work_dir / "status.json"
|
||||
|
||||
self.capital = capital
|
||||
self.in_position = False
|
||||
self.direction: int = 0
|
||||
self.entry_price: float = 0
|
||||
self.entry_time: str = ""
|
||||
self.bars_held: int = 0
|
||||
self.total_trades: int = 0
|
||||
self.total_wins: int = 0
|
||||
self.started_at = datetime.now(timezone.utc).isoformat()
|
||||
self.last_bar_ts: int = 0
|
||||
|
||||
# Exit guidati dalla strategia via Signal.metadata (0 = usa hold_bars/stop legacy)
|
||||
self.tp: float = 0.0
|
||||
self.sl: float = 0.0
|
||||
self.max_bars: int = 0
|
||||
# Fee dalla strategia (MR01 = 0.001 realistico Deribit), fallback al default modulo
|
||||
self.fee_rt: float = float(getattr(strategy, "fee_rt", FEE_RT))
|
||||
|
||||
self._load_state()
|
||||
self._save_state()
|
||||
|
||||
def _load_state(self):
|
||||
"""Riprende stato da status.json se esiste."""
|
||||
if not self.status_path.exists():
|
||||
self._log("INIT", {"capital": self.capital, "strategy": self.strategy.name,
|
||||
"asset": self.asset, "tf": self.tf})
|
||||
return
|
||||
|
||||
with open(self.status_path) as f:
|
||||
state = json.load(f)
|
||||
|
||||
self.capital = state.get("capital", self.initial_capital)
|
||||
self.in_position = state.get("in_position", False)
|
||||
self.direction = state.get("direction", 0)
|
||||
self.entry_price = state.get("entry_price", 0)
|
||||
self.entry_time = state.get("entry_time", "")
|
||||
self.bars_held = state.get("bars_held", 0)
|
||||
self.total_trades = state.get("total_trades", 0)
|
||||
self.total_wins = state.get("total_wins", 0)
|
||||
self.started_at = state.get("started_at", self.started_at)
|
||||
self.last_bar_ts = state.get("last_bar_ts", 0)
|
||||
self.tp = state.get("tp", 0.0)
|
||||
self.sl = state.get("sl", 0.0)
|
||||
self.max_bars = state.get("max_bars", 0)
|
||||
|
||||
self._log("RESUME", {"capital": round(self.capital, 2),
|
||||
"total_trades": self.total_trades,
|
||||
"in_position": self.in_position})
|
||||
|
||||
def _save_state(self):
|
||||
state = {
|
||||
"capital": round(self.capital, 2),
|
||||
"in_position": self.in_position,
|
||||
"direction": self.direction,
|
||||
"entry_price": self.entry_price,
|
||||
"entry_time": self.entry_time,
|
||||
"bars_held": self.bars_held,
|
||||
"total_trades": self.total_trades,
|
||||
"total_wins": self.total_wins,
|
||||
"started_at": self.started_at,
|
||||
"last_bar_ts": self.last_bar_ts,
|
||||
"tp": self.tp,
|
||||
"sl": self.sl,
|
||||
"max_bars": self.max_bars,
|
||||
"last_update": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
with open(self.status_path, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
def _log(self, event: str, data: dict | None = None):
|
||||
entry = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"worker": self.worker_id,
|
||||
"event": event,
|
||||
**(data or {}),
|
||||
}
|
||||
with open(self.trades_path, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
print(f" [{self.worker_id}] {event}: {json.dumps(data or {}, default=str)}")
|
||||
|
||||
def _notify(self, event: str, data: dict | None = None):
|
||||
enriched = {"worker": self.worker_id, **(data or {})}
|
||||
notify_event(event, enriched)
|
||||
|
||||
def _open_position(self, signal: Signal, current_price: float):
|
||||
notional = self.capital * self.position_size * self.leverage
|
||||
size = notional / current_price if current_price > 0 else 0
|
||||
|
||||
self.in_position = True
|
||||
self.direction = signal.direction
|
||||
self.entry_price = current_price
|
||||
self.entry_time = datetime.now(timezone.utc).isoformat()
|
||||
self.bars_held = 0
|
||||
|
||||
meta = signal.metadata or {}
|
||||
self.tp = float(meta.get("tp", 0.0) or 0.0)
|
||||
self.sl = float(meta.get("sl", 0.0) or 0.0)
|
||||
self.max_bars = int(meta.get("max_bars", 0) or 0)
|
||||
|
||||
trade_data = {
|
||||
"direction": "long" if signal.direction == 1 else "short",
|
||||
"price": round(current_price, 2),
|
||||
"size": round(size, 6),
|
||||
"notional": round(notional, 2),
|
||||
"capital": round(self.capital, 2),
|
||||
"tp": round(self.tp, 2) if self.tp else None,
|
||||
"sl": round(self.sl, 2) if self.sl else None,
|
||||
}
|
||||
self._log("OPEN", trade_data)
|
||||
self._notify("OPENED", trade_data)
|
||||
|
||||
def _close_position(self, current_price: float, reason: str):
|
||||
if not self.in_position:
|
||||
return
|
||||
|
||||
price_change = (current_price - self.entry_price) / self.entry_price
|
||||
trade_return = price_change * self.direction
|
||||
net = trade_return * self.leverage - self.fee_rt * self.leverage
|
||||
pnl = self.capital * self.position_size * net
|
||||
|
||||
is_win = trade_return > 0
|
||||
self.capital += pnl
|
||||
self.capital = max(self.capital, 0)
|
||||
self.total_trades += 1
|
||||
if is_win:
|
||||
self.total_wins += 1
|
||||
|
||||
accuracy = self.total_wins / self.total_trades * 100 if self.total_trades > 0 else 0
|
||||
|
||||
trade_data = {
|
||||
"reason": reason,
|
||||
"direction": "long" if self.direction == 1 else "short",
|
||||
"entry": round(self.entry_price, 2),
|
||||
"exit": round(current_price, 2),
|
||||
"pnl": round(pnl, 2),
|
||||
"net_return": round(net * 100, 3),
|
||||
"capital": round(self.capital, 2),
|
||||
"bars_held": self.bars_held,
|
||||
"win": is_win,
|
||||
"total_trades": self.total_trades,
|
||||
"accuracy": round(accuracy, 1),
|
||||
}
|
||||
self._log("CLOSE", trade_data)
|
||||
self._notify("CLOSED", trade_data)
|
||||
|
||||
self.in_position = False
|
||||
self.direction = 0
|
||||
self.entry_price = 0
|
||||
self.entry_time = ""
|
||||
self.bars_held = 0
|
||||
self.tp = 0.0
|
||||
self.sl = 0.0
|
||||
self.max_bars = 0
|
||||
|
||||
def tick(self, df: pd.DataFrame, df_1h: pd.DataFrame | None = None):
|
||||
"""Chiamato ad ogni poll con DataFrame OHLCV aggiornato.
|
||||
|
||||
df_1h: serie 1h live opzionale per strategie multi-timeframe (es. MT01),
|
||||
passata ai generate_signals via params. Se None la strategia ricade sul
|
||||
parquet statico.
|
||||
"""
|
||||
if df.empty or len(df) < 100:
|
||||
return
|
||||
|
||||
c = df["close"].values
|
||||
current_price = float(c[-1])
|
||||
current_ts = int(df["timestamp"].iloc[-1])
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
if self.in_position:
|
||||
if current_ts > self.last_bar_ts:
|
||||
self.bars_held += 1
|
||||
self.last_bar_ts = current_ts
|
||||
|
||||
if self.tp and self.sl:
|
||||
# Exit guidati dalla strategia: SL (conservativo, prima), poi TP, poi time-limit
|
||||
if self.direction == 1:
|
||||
if current_price <= self.sl:
|
||||
self._close_position(current_price, "stop_loss")
|
||||
elif current_price >= self.tp:
|
||||
self._close_position(current_price, "take_profit")
|
||||
elif self.max_bars and self.bars_held >= self.max_bars:
|
||||
self._close_position(current_price, "time_limit")
|
||||
else:
|
||||
if current_price >= self.sl:
|
||||
self._close_position(current_price, "stop_loss")
|
||||
elif current_price <= self.tp:
|
||||
self._close_position(current_price, "take_profit")
|
||||
elif self.max_bars and self.bars_held >= self.max_bars:
|
||||
self._close_position(current_price, "time_limit")
|
||||
elif self.bars_held >= self.hold_bars:
|
||||
self._close_position(current_price, "hold_limit")
|
||||
else:
|
||||
pnl_pct = (current_price - self.entry_price) / self.entry_price * self.direction
|
||||
if pnl_pct <= -0.02:
|
||||
self._close_position(current_price, "stop_loss")
|
||||
|
||||
self._save_state()
|
||||
return
|
||||
|
||||
# Genera segnali
|
||||
extra = dict(self.params)
|
||||
if df_1h is not None:
|
||||
extra["df_1h"] = df_1h
|
||||
signals = self.strategy.generate_signals(
|
||||
df, ts, asset=self.asset, tf=self.tf, **extra
|
||||
)
|
||||
|
||||
if not signals:
|
||||
self._save_state()
|
||||
return
|
||||
|
||||
last_signal = signals[-1]
|
||||
last_idx = len(df) - 1
|
||||
|
||||
if last_signal.idx >= last_idx - 1:
|
||||
self._open_position(last_signal, current_price)
|
||||
self.last_bar_ts = current_ts
|
||||
|
||||
self._save_state()
|
||||
|
||||
@property
|
||||
def status_summary(self) -> str:
|
||||
acc = self.total_wins / self.total_trades * 100 if self.total_trades > 0 else 0
|
||||
pos = "LONG" if self.direction == 1 else "SHORT" if self.direction == -1 else "FLAT"
|
||||
return (f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t "
|
||||
f"{acc:.0f}% | {pos}")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Notifiche Telegram per il paper trader."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import json
|
||||
|
||||
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||
|
||||
NOTIFY_EVENTS = {
|
||||
"SIGNAL", "OPENED", "CLOSED", "OPEN_FAILED", "CLOSE_FAILED",
|
||||
"ERROR", "STARTUP", "SHUTDOWN", "TRAINING_FAILED",
|
||||
}
|
||||
|
||||
|
||||
def send_telegram(text: str) -> bool:
|
||||
if not BOT_TOKEN or not CHAT_ID:
|
||||
return False
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
|
||||
data = urllib.parse.urlencode({"chat_id": CHAT_ID, "text": text, "parse_mode": "HTML"}).encode()
|
||||
urllib.request.urlopen(url, data, timeout=10)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def notify_event(event: str, data: dict | None = None):
|
||||
if event not in NOTIFY_EVENTS:
|
||||
return
|
||||
lines = [f"📊 <b>{event}</b>"]
|
||||
if data:
|
||||
for k, v in data.items():
|
||||
if k in ("signal",):
|
||||
continue
|
||||
lines.append(f" {k}: {v}")
|
||||
send_telegram("\n".join(lines))
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Strategie di trading — classe base e indicatori condivisi."""
|
||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats
|
||||
from src.strategies.indicators import (
|
||||
keltner_ratio, detect_squeezes, ema, atr, rv_annualized, rolling_correlation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Strategy", "Signal", "BacktestResult", "YearlyStats",
|
||||
"keltner_ratio", "detect_squeezes", "ema", "atr",
|
||||
"rv_annualized", "rolling_correlation",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Classe base astratta per tutte le strategie di trading."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
@dataclass
|
||||
class Signal:
|
||||
"""Segnale di trading generato da una strategia."""
|
||||
idx: int
|
||||
direction: int # +1 long, -1 short
|
||||
entry_price: float
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class YearlyStats:
|
||||
year: int
|
||||
trades: int
|
||||
wins: int
|
||||
pnl: float
|
||||
|
||||
@property
|
||||
def accuracy(self) -> float:
|
||||
return self.wins / self.trades * 100 if self.trades > 0 else 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestResult:
|
||||
"""Risultato completo di un backtest."""
|
||||
strategy_name: str
|
||||
asset: str
|
||||
timeframe: str
|
||||
params: dict
|
||||
|
||||
trades: int
|
||||
wins: int
|
||||
pnl: float
|
||||
capital: float
|
||||
initial_capital: float
|
||||
max_dd: float
|
||||
time_in_market_pct: float
|
||||
avg_trade_duration_h: float
|
||||
years_active: int
|
||||
yearly: list[YearlyStats]
|
||||
|
||||
@property
|
||||
def accuracy(self) -> float:
|
||||
return self.wins / self.trades * 100 if self.trades > 0 else 0.0
|
||||
|
||||
@property
|
||||
def sharpe(self) -> float:
|
||||
pnls = []
|
||||
for ys in self.yearly:
|
||||
pnls.append(ys.pnl)
|
||||
if len(pnls) < 2 or np.std(pnls) == 0:
|
||||
return 0.0
|
||||
return float(np.mean(pnls) / np.std(pnls) * np.sqrt(len(pnls)))
|
||||
|
||||
@property
|
||||
def daily_pnl(self) -> float:
|
||||
return self.pnl / (self.years_active * 365) if self.years_active > 0 else 0.0
|
||||
|
||||
@property
|
||||
def worst_year(self) -> YearlyStats | None:
|
||||
valid = [y for y in self.yearly if y.trades >= 10]
|
||||
if not valid:
|
||||
valid = self.yearly
|
||||
return min(valid, key=lambda y: y.accuracy) if valid else None
|
||||
|
||||
def print_summary(self):
|
||||
worst = self.worst_year
|
||||
worst_str = f"{worst.year}({worst.accuracy:.0f}%)" if worst else "N/A"
|
||||
dur = f"{self.avg_trade_duration_h:.0f}h" if self.avg_trade_duration_h >= 1 else f"{self.avg_trade_duration_h * 60:.0f}m"
|
||||
print(f" {self.strategy_name:<30s} {self.asset:>3s} {self.timeframe:>3s} "
|
||||
f"{self.trades:>5d}t {self.accuracy:>5.1f}% "
|
||||
f"€{self.pnl:>+9.0f} DD {self.max_dd:>4.1f}% "
|
||||
f"€/d {self.daily_pnl:>+6.2f} "
|
||||
f"Mkt {self.time_in_market_pct:>4.1f}% {dur:>5s} "
|
||||
f"worst={worst_str} {self.years_active}y")
|
||||
|
||||
def print_yearly(self):
|
||||
print(f"\n {self.strategy_name} [{self.asset} {self.timeframe}] — per anno:")
|
||||
print(f" {'Anno':>6s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s}")
|
||||
for ys in sorted(self.yearly, key=lambda y: y.year):
|
||||
print(f" {ys.year:>6d} {ys.trades:>7d} {ys.accuracy:>5.1f}% €{ys.pnl:>+8.0f}")
|
||||
|
||||
|
||||
TF_MINUTES = {"1m": 1, "5m": 5, "15m": 15, "1h": 60, "4h": 240, "1d": 1440}
|
||||
|
||||
|
||||
class Strategy(ABC):
|
||||
"""Classe base per tutte le strategie.
|
||||
|
||||
Sottoclassi devono implementare:
|
||||
- name, description, default_assets, default_timeframes
|
||||
- generate_signals(df, timestamps, **params) -> list[Signal]
|
||||
"""
|
||||
|
||||
name: str = "unnamed"
|
||||
description: str = ""
|
||||
default_assets: list[str] = ["BTC", "ETH"]
|
||||
default_timeframes: list[str] = ["15m", "1h"]
|
||||
|
||||
# Parametri di backtest
|
||||
fee_rt: float = 0.002
|
||||
leverage: float = 3.0
|
||||
position_size: float = 0.15
|
||||
initial_capital: float = 1000.0
|
||||
|
||||
@abstractmethod
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
"""Genera segnali di trading dal dataframe OHLCV.
|
||||
|
||||
Args:
|
||||
df: DataFrame con colonne open, high, low, close, volume, timestamp
|
||||
ts: DatetimeIndex UTC dei timestamp
|
||||
**params: parametri specifici della strategia
|
||||
|
||||
Returns:
|
||||
Lista di Signal con idx, direction, entry_price
|
||||
"""
|
||||
...
|
||||
|
||||
def backtest(self, asset: str, tf: str, hold: int = 3,
|
||||
**params) -> BacktestResult | None:
|
||||
"""Esegue backtest su un asset/timeframe."""
|
||||
df = load_data(asset, tf)
|
||||
c = df["close"].values
|
||||
n = len(c)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
sig_params = {**params, "asset": asset, "tf": tf}
|
||||
signals = self.generate_signals(df, ts, **sig_params)
|
||||
if not signals:
|
||||
return None
|
||||
|
||||
yearly: dict[int, dict] = {}
|
||||
capital = float(self.initial_capital)
|
||||
peak = capital
|
||||
max_dd = 0.0
|
||||
total_bars = 0
|
||||
|
||||
for sig in signals:
|
||||
i = sig.idx
|
||||
if i + hold >= n or i < 1:
|
||||
continue
|
||||
|
||||
entry = sig.entry_price
|
||||
exit_price = c[min(i + hold - 1, n - 1)]
|
||||
actual = (exit_price - entry) / entry * sig.direction
|
||||
net = actual * self.leverage - self.fee_rt * self.leverage
|
||||
|
||||
capital += capital * self.position_size * net
|
||||
capital = max(capital, 10)
|
||||
if capital > peak:
|
||||
peak = capital
|
||||
dd = (peak - capital) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
total_bars += hold
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly:
|
||||
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
|
||||
yearly[year]["t"] += 1
|
||||
if actual > 0:
|
||||
yearly[year]["w"] += 1
|
||||
yearly[year]["pnl"] += net * self.initial_capital
|
||||
|
||||
all_t = sum(d["t"] for d in yearly.values())
|
||||
all_w = sum(d["w"] for d in yearly.values())
|
||||
if all_t == 0:
|
||||
return None
|
||||
|
||||
yearly_stats = [
|
||||
YearlyStats(year=y, trades=d["t"], wins=d["w"], pnl=d["pnl"])
|
||||
for y, d in sorted(yearly.items())
|
||||
]
|
||||
|
||||
return BacktestResult(
|
||||
strategy_name=self.name,
|
||||
asset=asset,
|
||||
timeframe=tf,
|
||||
params=params,
|
||||
trades=all_t,
|
||||
wins=all_w,
|
||||
pnl=sum(d["pnl"] for d in yearly.values()),
|
||||
capital=capital,
|
||||
initial_capital=self.initial_capital,
|
||||
max_dd=max_dd * 100,
|
||||
time_in_market_pct=total_bars / n * 100,
|
||||
avg_trade_duration_h=hold * TF_MINUTES.get(tf, 60) / 60,
|
||||
years_active=len(yearly),
|
||||
yearly=yearly_stats,
|
||||
)
|
||||
|
||||
def run_all(self, assets: list[str] | None = None,
|
||||
timeframes: list[str] | None = None,
|
||||
hold: int = 3, **params) -> list[BacktestResult]:
|
||||
"""Esegue backtest su tutte le combinazioni asset/timeframe."""
|
||||
assets = assets or self.default_assets
|
||||
timeframes = timeframes or self.default_timeframes
|
||||
results = []
|
||||
for asset in assets:
|
||||
for tf in timeframes:
|
||||
r = self.backtest(asset, tf, hold=hold, **params)
|
||||
if r and r.trades >= 20:
|
||||
results.append(r)
|
||||
results.sort(key=lambda r: r.accuracy, reverse=True)
|
||||
return results
|
||||
|
||||
def report(self, results: list[BacktestResult] | None = None,
|
||||
assets: list[str] | None = None,
|
||||
timeframes: list[str] | None = None,
|
||||
hold: int = 3, **params):
|
||||
"""Esegue e stampa report completo."""
|
||||
if results is None:
|
||||
results = self.run_all(assets, timeframes, hold, **params)
|
||||
|
||||
print(f"\n{'=' * 120}")
|
||||
print(f" {self.name} — {self.description}")
|
||||
print(f" Fee: {self.fee_rt*100:.1f}% RT | Leva: {self.leverage:.0f}x | Pos: {self.position_size*100:.0f}%")
|
||||
print(f"{'=' * 120}")
|
||||
print(f" {'Nome':<30s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
|
||||
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
|
||||
f"{'Mkt%':>5s} {'Dur':>5s} {'Worst':>12s} {'Anni':>4s}")
|
||||
print(f" {'─' * 110}")
|
||||
|
||||
for r in results:
|
||||
r.print_summary()
|
||||
|
||||
if results:
|
||||
best = results[0]
|
||||
best.print_yearly()
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Base condivisa per strategie mean-reversion con exit TP/SL/max_bars.
|
||||
|
||||
Tutte le strategie fade (MR02/MR03/MR07) generano Signal con metadata
|
||||
{tp, sl, max_bars} e usano lo stesso backtest fedele: ingresso a close[i]
|
||||
(eseguibile dal vivo), uscita su take-profit / stop-loss intrabar (high/low)
|
||||
o time-limit, una posizione per volta (non-overlap), capitale composto,
|
||||
fee+leva nette. Identico all'engine di scripts/analysis/strategy_research.py.
|
||||
|
||||
Le sottoclassi implementano solo generate_signals().
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, BacktestResult, YearlyStats, TF_MINUTES
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
pc = np.roll(c, 1); pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
return pd.Series(tr).rolling(n).mean().values
|
||||
|
||||
|
||||
def trend_distance(df: pd.DataFrame, ema_long: int = 200) -> np.ndarray:
|
||||
"""Distanza del close dalla EMA lunga, in multipli di ATR(14).
|
||||
|
||||
Misura quanto il prezzo e' esteso rispetto al trend di fondo. Le fade
|
||||
falliscono quando si oppongono a un trend estremo (crolli/parabolic): il
|
||||
filtro `trend_max` salta i segnali con distanza > soglia. Riduce DD e alza
|
||||
l'accuratezza (validato OOS: scripts/analysis/risk_portfolio.py).
|
||||
"""
|
||||
c = df["close"].values
|
||||
a = atr(df, 14)
|
||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
return np.abs(c - el) / np.where(a == 0, np.nan, a)
|
||||
|
||||
|
||||
class FadeStrategy(Strategy):
|
||||
"""Strategy con backtest intrabar TP/SL/max_bars (exit guidati dai metadata)."""
|
||||
|
||||
fee_rt = 0.001 # Deribit perp realistico (taker 0.05%/lato)
|
||||
leverage = 3.0
|
||||
position_size = 0.15
|
||||
initial_capital = 1000.0
|
||||
|
||||
def backtest(self, asset: str, tf: str = "1h", hold: int = 3,
|
||||
**params) -> BacktestResult | None:
|
||||
df = load_data(asset, tf)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
signals = self.generate_signals(df, ts, **params)
|
||||
if not signals:
|
||||
return None
|
||||
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c)
|
||||
fee = self.fee_rt * self.leverage
|
||||
capital = peak = float(self.initial_capital)
|
||||
max_dd = 0.0
|
||||
total_bars = 0
|
||||
last_exit = -1
|
||||
yearly: dict[int, dict] = {}
|
||||
|
||||
for sig in signals:
|
||||
i, d = sig.idx, sig.direction
|
||||
if i <= last_exit or i + 1 >= n:
|
||||
continue
|
||||
entry = c[i]
|
||||
tp, sl, mb = sig.metadata["tp"], sig.metadata["sl"], sig.metadata["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]
|
||||
j = min(i + mb, n - 1)
|
||||
for step in range(1, mb + 1):
|
||||
j = i + step
|
||||
if j >= n:
|
||||
j = n - 1; exit_p = c[j]; break
|
||||
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hit_sl: # conservativo: SL prima del TP nello stesso bar
|
||||
exit_p = sl; break
|
||||
if hit_tp:
|
||||
exit_p = tp; break
|
||||
if step == mb:
|
||||
exit_p = c[j]
|
||||
|
||||
ret = (exit_p - entry) / entry * d * self.leverage - fee
|
||||
capital = max(capital + capital * self.position_size * ret, 10.0)
|
||||
if capital > peak:
|
||||
peak = capital
|
||||
max_dd = max(max_dd, (peak - capital) / peak)
|
||||
total_bars += (j - i)
|
||||
last_exit = j
|
||||
|
||||
year = ts.iloc[i].year
|
||||
yr = yearly.setdefault(year, {"w": 0, "t": 0, "pnl": 0.0})
|
||||
yr["t"] += 1
|
||||
if ret > 0:
|
||||
yr["w"] += 1
|
||||
yr["pnl"] += ret * self.initial_capital
|
||||
|
||||
all_t = sum(v["t"] for v in yearly.values())
|
||||
all_w = sum(v["w"] for v in yearly.values())
|
||||
if all_t == 0:
|
||||
return None
|
||||
|
||||
yearly_stats = [YearlyStats(y, v["t"], v["w"], v["pnl"]) for y, v in sorted(yearly.items())]
|
||||
return BacktestResult(
|
||||
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
|
||||
trades=all_t, wins=all_w, pnl=sum(v["pnl"] for v in yearly.values()),
|
||||
capital=capital, initial_capital=self.initial_capital,
|
||||
max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100,
|
||||
avg_trade_duration_h=total_bars / all_t * TF_MINUTES.get(tf, 60) / 60,
|
||||
years_active=len(yearly), yearly=yearly_stats,
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,92 @@
|
||||
defaults:
|
||||
capital: 1000
|
||||
position_size: 0.15
|
||||
leverage: 3
|
||||
hold_bars: 3
|
||||
poll_seconds: 60
|
||||
retrain_hours: 24
|
||||
|
||||
# Solo MR01 Bollinger fade (mean-reversion): unica con edge netto validato
|
||||
# out-of-sample e fee-aware. La famiglia squeeze e' in scripts/waste/.
|
||||
# ATTENZIONE: MR01 esce su TP-alla-media / SL-ad-ATR / max_bars (vedi metadata
|
||||
# dei Signal). Lo StrategyWorker attuale esce solo a hold_bars/stop -2% fisso:
|
||||
# va aggiornato per usare gli exit in metadata PRIMA di tradare MR01 dal vivo.
|
||||
strategies:
|
||||
- name: MR01_bollinger_fade
|
||||
asset: BTC
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
bb_window: 50
|
||||
k: 2.5
|
||||
sl_atr: 2.0
|
||||
max_bars: 24
|
||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||
ema_long: 200
|
||||
|
||||
# ETH: edge positivo ma DD piu' alto (~70%); leva piu' bassa consigliata
|
||||
- name: MR01_bollinger_fade
|
||||
asset: ETH
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
bb_window: 50
|
||||
k: 2.5
|
||||
sl_atr: 2.0
|
||||
max_bars: 24
|
||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||
ema_long: 200
|
||||
|
||||
# MR02 Donchian fade: fade rottura canale (estremi H/L). Robusto su tutta la
|
||||
# griglia n x sl_atr e tutte le fee. BTC +879%/+171% OOS (8/9 anni), ETH enorme.
|
||||
- name: MR02_donchian_fade
|
||||
asset: BTC
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
n: 20
|
||||
sl_atr: 2.0
|
||||
max_bars: 24
|
||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||
ema_long: 200
|
||||
- name: MR02_donchian_fade
|
||||
asset: ETH
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
n: 20
|
||||
sl_atr: 2.0
|
||||
max_bars: 24
|
||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||
ema_long: 200
|
||||
|
||||
# MR03 Keltner fade -> spostata in scripts/waste/ (fade piu' debole e ridondante
|
||||
# con MR01; il filtro trend la peggiorava su BTC). Vedi CLAUDE.md.
|
||||
|
||||
# MR07 Return reversal: fade movimento di barra estremo (z dei rendimenti).
|
||||
# Meccanismo distinto (volatilita' rendimenti, non livelli). Esposizione bassa
|
||||
# (~8%). BTC +447%/+105% OOS DD25%, ETH +335%/+195% OOS DD46%.
|
||||
- name: MR07_return_reversal
|
||||
asset: BTC
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
n: 50
|
||||
k: 3.5
|
||||
tp_atr: 2.0
|
||||
sl_atr: 1.5
|
||||
max_bars: 24
|
||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||
ema_long: 200
|
||||
- name: MR07_return_reversal
|
||||
asset: ETH
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
n: 50
|
||||
k: 3.5
|
||||
tp_atr: 2.0
|
||||
sl_atr: 1.5
|
||||
max_bars: 24
|
||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||
ema_long: 200
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user