Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9275eab4af | |||
| 2a97294d67 | |||
| bc0ad3e86f | |||
| cc9a380a63 | |||
| 8c5372c487 | |||
| be85a454e0 | |||
| ed2a9013aa | |||
| bf1cada33f | |||
| f5173fba06 | |||
| 7bf8b197e0 | |||
| 6097205694 | |||
| 0aaab08612 | |||
| e3bb622b90 | |||
| 11ace196c7 | |||
| 62e272255b | |||
| bd5f1a6ea0 | |||
| 3909646028 | |||
| 9ce43031ee | |||
| 8c4e1cdf13 | |||
| 35c0133e63 | |||
| 2da3457ca7 | |||
| ff2ff6de7d | |||
| bd6232dc00 | |||
| 6f86c644bf | |||
| 2e9ec81567 | |||
| 3bc42517b0 | |||
| 59609a535a | |||
| a2579d21bc | |||
| ad65a0b344 | |||
| 3accc91f84 | |||
| 7707cc17a0 | |||
| abd396fa54 | |||
| e8075b6292 | |||
| a26397b1bf | |||
| 74670dae05 | |||
| 4f5bd32c38 | |||
| 74c79aa6c1 | |||
| 092e809946 | |||
| 92de7c9867 | |||
| 719509fb2f | |||
| fec15c07bb | |||
| cb1b6ea46a | |||
| 1f0c1ab02a | |||
| b5de241ebc | |||
| 5157cd544d | |||
| 722837e599 | |||
| 8e72d7ad02 | |||
| 87b420f459 | |||
| 6605008f49 | |||
| b5f644e52d | |||
| 7da9dc36e4 | |||
| 4770a8368f | |||
| d624fe74c9 | |||
| 29c2ea488f | |||
| ac6f3766b0 | |||
| 4d9f2af0c0 | |||
| 26de94e57d | |||
| 8fd8f64368 | |||
| 7005763517 | |||
| 5f229cd66e | |||
| 7169614506 | |||
| 69619df4c7 | |||
| 056d18a3fd | |||
| 99efc7a042 | |||
| e44a310a3b | |||
| 83c4e7a334 | |||
| 56565f6f73 | |||
| ab4f706057 | |||
| 05ebd6754b | |||
| b5d277478c | |||
| 53e0965c4b | |||
| aaf0221957 | |||
| 1177da33ea | |||
| 03e8938a18 | |||
| c13773e762 | |||
| 5a219ca8e5 | |||
| 2a11728384 | |||
| 7b2e0049eb | |||
| fb65df7861 | |||
| 49039ac286 | |||
| 9e91ad6335 | |||
| 924ed8eeff | |||
| fe8c272460 | |||
| a7ada9f36c | |||
| 1e60835612 | |||
| a40315563e | |||
| e7e8041dae | |||
| ce601c4507 | |||
| dc63399cc7 | |||
| e374cca103 | |||
| 2749553577 | |||
| 0bb14d1c6e | |||
| 04f64c8f89 | |||
| 0f582db265 | |||
| d02bc10ab5 | |||
| a5547fb3d2 | |||
| 2b3d3e3ff8 | |||
| 169819fe31 | |||
| 7a4bdb74f0 | |||
| eaf4800b6d | |||
| 3f6b0ccf91 | |||
| 9ff469cb8e | |||
| d99c9895bb | |||
| ea04dcd9d1 | |||
| 753d786bb5 | |||
| 602c46e5bf | |||
| 9e1be75444 | |||
| e002968914 | |||
| 2596687679 | |||
| 4ac87ab385 | |||
| 2b01443efe | |||
| 5ac9ebed5b | |||
| 1561005d41 | |||
| b9e5176a5b | |||
| a60ad30ac0 | |||
| bd31a15548 | |||
| 4dc0e77ee5 | |||
| 9a066eb76f | |||
| 7226946911 | |||
| 945c2a2db6 | |||
| 33e3e2a603 | |||
| 666c906907 | |||
| 9ebdfc7a7a | |||
| bcccfde9a0 | |||
| 1af4addbdd | |||
| 22c6080873 | |||
| fd547748dd | |||
| a51129acf6 | |||
| 1b099bb47b | |||
| 783fa5546f | |||
| cff0d08fca | |||
| ad141f080c | |||
| 212427ffa1 | |||
| 21d3ba609d | |||
| 48435f6858 | |||
| 9879b46688 | |||
| ca88e62a11 | |||
| 8fd2c16cac | |||
| 31be1b43aa | |||
| bdcef09057 |
+10
@@ -16,3 +16,13 @@ data/processed/
|
|||||||
*.pt
|
*.pt
|
||||||
*.pth
|
*.pth
|
||||||
notebooks/.ipynb_checkpoints/
|
notebooks/.ipynb_checkpoints/
|
||||||
|
data/paper_trades/
|
||||||
|
data/portfolio_paper/
|
||||||
|
data/portfolios/
|
||||||
|
|
||||||
|
# stato locale di tooling (non condiviso)
|
||||||
|
.claude/
|
||||||
|
.omc/
|
||||||
|
|
||||||
|
# dati regime (DVOL/funding/feature cache, rigenerabili)
|
||||||
|
data/regime/
|
||||||
|
|||||||
@@ -292,3 +292,40 @@ curl -X POST http://localhost:9000/mcp-bybit/tools/get_ticker \
|
|||||||
|
|
||||||
Per lo schema completo dei body di richiesta e risposta:
|
Per lo schema completo dei body di richiesta e risposta:
|
||||||
<http://localhost:9000/apidocs>.
|
<http://localhost:9000/apidocs>.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Discovery strumenti — schemi `get_instruments` / `get_markets` / `get_historical`
|
||||||
|
|
||||||
|
Schemi dei body verificati sull'OpenAPI live (usati da `src/data/instruments.py`).
|
||||||
|
|
||||||
|
### Lista strumenti
|
||||||
|
| Exchange | Tool | Body | Risposta (campi utili) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Deribit | `get_instruments` | `{currency:"any", kind:"future", offset:int, limit:100}` (paginato, `has_more`) | `instruments[].name` (es. `BTC-PERPETUAL`, `SOL_USDC-PERPETUAL`), `expiry`, `tick_size` |
|
||||||
|
| Bybit | `get_instruments` | `{category:"linear", symbol?}` | `instruments[]`: `symbol`, `status`, `base_coin`, `quote_coin` |
|
||||||
|
| Hyperliquid | `get_markets` | `{}` | lista `{asset, mark_price, funding_rate, open_interest, volume_24h, max_leverage}` |
|
||||||
|
|
||||||
|
### Storico OHLCV (`get_historical`, chiave `candles` uniforme `{timestamp(ms),open,high,low,close,volume}`)
|
||||||
|
| Exchange | Body |
|
||||||
|
|---|---|
|
||||||
|
| Deribit | `{instrument, start_date:"YYYY-MM-DD", end_date, resolution}` — resolution `1/5/15/60/1D` |
|
||||||
|
| Bybit | `{symbol, category:"linear", interval:"1/5/15/60/D", start:int_ms, end:int_ms, limit}` |
|
||||||
|
| Hyperliquid | `{asset|instrument, start_date, end_date, resolution:"1m/5m/15m/1h/1d", limit}` |
|
||||||
|
|
||||||
|
### Simboli Deribit
|
||||||
|
- BTC/ETH → perpetui **inverse**: `BTC-PERPETUAL`, `ETH-PERPETUAL`
|
||||||
|
- Altcoin → perpetui **lineari USDC**: `<COIN>_USDC-PERPETUAL` (es. `SOL_USDC-PERPETUAL`)
|
||||||
|
- Trappola: `LTC-PERPETUAL`/`ADA-PERPETUAL` non esistono; `SOL-PERPETUAL` esiste ma è un contratto sbagliato (prezzo ~9.6 vs SOL reale ~82).
|
||||||
|
|
||||||
|
### Validazione (lato progetto)
|
||||||
|
`src/data/instruments.py` valida ogni strumento sui dati storici realmente
|
||||||
|
raccoglibili — esistenza, congruenza OHLC, not-flat, liquidità (volume daily) e
|
||||||
|
**congruenza prezzo cross-exchange** (scostamento dalla mediana del base-coin ≤5%).
|
||||||
|
Solo gli exchange con feed affidabile sono inclusi: **Deribit** e **Hyperliquid**
|
||||||
|
(esclusi Alpaca/stocks e **Bybit**, il cui feed testnet è farlocco). Output in
|
||||||
|
`data/instruments_registry.json`; il downloader scarica **solo** strumenti validati.
|
||||||
|
|
||||||
|
> **Testnet.** Il token osservatore punta a testnet (`"testnet": true` nei ticker):
|
||||||
|
> i prezzi possono divergere dal mainnet. La congruenza cross-exchange via mediana
|
||||||
|
> è il filtro che scarta i feed incongrui prima di usarli per backtest/trading.
|
||||||
|
|||||||
@@ -17,40 +17,85 @@ Progetto di ricerca: riconoscimento pattern frattali per trading algoritmico su
|
|||||||
## Struttura
|
## Struttura
|
||||||
|
|
||||||
```
|
```
|
||||||
src/data/ → download e caricamento dati (downloader.py)
|
src/data/ → download e caricamento dati
|
||||||
|
downloader.py → download/caricamento parquet (gate: solo strumenti validati)
|
||||||
|
instruments.py → discovery + validazione strumenti per exchange, registry
|
||||||
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
|
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
|
||||||
src/backtest/ → engine di backtesting (engine.py)
|
src/backtest/ → engine di backtesting (engine.py)
|
||||||
src/strategies/ → classe base Strategy ABC + indicatori condivisi
|
src/strategies/ → classe base Strategy ABC + indicatori condivisi
|
||||||
base.py → Strategy, Signal, BacktestResult, YearlyStats
|
base.py → Strategy, Signal, BacktestResult, YearlyStats
|
||||||
indicators.py → keltner_ratio, detect_squeezes, ema, atr, rv, correlation
|
indicators.py → keltner_ratio, detect_squeezes, ema, atr, rv, correlation
|
||||||
src/live/ → paper trading live multi-strategia
|
src/live/ → paper trading live multi-strategia
|
||||||
multi_runner.py → orchestratore: carica YAML, fetch candele, tick worker
|
multi_runner.py → orchestratore: carica YAML (strategies + pairs), fetch candele, tick worker
|
||||||
strategy_worker.py → worker indipendente: capital, trade log, stato persistente
|
strategy_worker.py → worker single-leg: 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.
|
||||||
|
pairs_worker.py → worker a 2 GAMBE per PR01 (market-neutral): long A / short B sullo
|
||||||
|
z-score del log-ratio, exit |z|<=z_exit o max_bars, fee su 2 gambe.
|
||||||
strategy_loader.py → import dinamico classi Strategy da scripts/strategies/
|
strategy_loader.py → import dinamico classi Strategy da scripts/strategies/
|
||||||
cerbero_client.py → client HTTP per Cerbero MCP (Deribit testnet)
|
cerbero_client.py → client HTTP per Cerbero MCP (Deribit testnet): dati + ordini
|
||||||
signal_engine.py → squeeze + ML real-time (per ML01)
|
(place_order market/reduce_only, get_trade_history, get_positions)
|
||||||
|
execution.py → ExecutionClient: esecuzione REALE su Deribit (shadow). notional→
|
||||||
|
amount (lineari USDC + inverse), open/close_amount reduce-only,
|
||||||
|
verifica sul trade (order_id), fee reali dai trades[]
|
||||||
|
signal_engine.py → squeeze + ML real-time (legacy ML01, ora in waste) + validazione OOS
|
||||||
telegram_notifier.py → notifiche Telegram per trade
|
telegram_notifier.py → notifiche Telegram per trade
|
||||||
scripts/strategies/ → strategie attive (SQ01-SQ04, ML01)
|
src/portfolio/ → portafogli di prima classe (capitale-pool, backtest+live)
|
||||||
scripts/waste/ → strategie scartate (W01-W22 + REF originali)
|
base.py → SleeveSpec, Portfolio (.backtest), load_active_portfolio
|
||||||
scripts/analysis/ → script di confronto e report
|
weighting.py → schemi pesi: equal/cap/inverse_vol/cluster_rp/manual
|
||||||
|
sleeves.py → builder unificato equity-per-sleeve (fonte unica, parità report)
|
||||||
|
ledger.py → PortfolioLedger: capitale/PnL/DD/persistenza+resume
|
||||||
|
runner.py → PortfolioRunner live (data Cerbero v2, sizing, ribilancio)
|
||||||
|
src/version.py → APP_VERSION (legge il file VERSION) — mostrato nei msg Telegram
|
||||||
|
src/strategies/fade_base.py → FadeStrategy + helper: atr, trend_distance, hurst_skip_mask (loss-guard),
|
||||||
|
exit close-confirm SL via param sl_confirm_atr (EXIT-16)
|
||||||
|
scripts/strategies/ → strategie con edge validato OOS: FADE (MR01/MR02/MR07),
|
||||||
|
HONEST (DIP01/TR01/ROT02), PAIRS (PR01), TSMOM + portafogli (PORT01/02/03);
|
||||||
|
FR01_hurst_calm_fade.py = record ricerca (robusto ma NON deployato)
|
||||||
|
scripts/portfolios/ → definizioni PORT01-06 (_defs.py) + report run() + hourly_report.py (Telegram)
|
||||||
|
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, ...);
|
||||||
|
regime_fetcher.py + regime_lab.py (DVOL/funding/feature regime per la ricerca);
|
||||||
|
exit_lab.py + exit_policies/ (harness ricerca exit: cache segnali, train/OOS)
|
||||||
|
scripts/bump_version.py, scripts/deploy.sh → versionamento e deploy (bump+commit+rebuild)
|
||||||
|
VERSION → versione semver (cotta nell'immagine, +1 ad ogni deploy)
|
||||||
strategies.yml → config multi-strategy paper trader
|
strategies.yml → config multi-strategy paper trader
|
||||||
docs/diary/ → diario di ricerca giornaliero
|
docs/diary/ → diario di ricerca giornaliero
|
||||||
docs/specs/ → specifiche di design
|
docs/specs/ → specifiche di design
|
||||||
data/raw/ → file .parquet OHLCV (gitignored)
|
data/raw/ → file .parquet OHLCV (gitignored) | data/regime/ → DVOL+funding+feature (gitignored)
|
||||||
|
data/instruments_registry.json → allowlist strumenti validati (gate del downloader)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Comandi
|
## Comandi
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # installa dipendenze
|
uv sync # installa dipendenze
|
||||||
uv run python -m src.data.downloader # scarica dati storici
|
uv run python -m src.data.downloader # scarica dati storici (solo strumenti validati)
|
||||||
uv run python scripts/strategies/SQ02_squeeze_antifake_vol.py # miglior strategia robusta
|
uv run python -m src.data.instruments # (ri)costruisci il registry strumenti validati
|
||||||
uv run python scripts/strategies/ML01_squeeze_gbm.py # squeeze + ML (GBM)
|
uv run python scripts/strategies/MR01_bollinger_fade.py # backtest una strategia (es. fade)
|
||||||
uv run python -m src.live.multi_runner # paper trading live multi-strategia
|
uv run python scripts/strategies/PR01_pairs_reversion.py # backtest pairs market-neutral
|
||||||
docker compose up -d # deploy Docker
|
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/report_families.py # report per anno di tutte le famiglie
|
||||||
|
uv run python scripts/analysis/validate_worker_pairs.py # replay worker 2 gambe == backtest
|
||||||
|
uv run python -m src.live.multi_runner # paper trading live multi-strategia (strategie + pairs)
|
||||||
|
uv run python scripts/portfolios/PORT06_master_shape.py # report backtest portafoglio (default)
|
||||||
|
uv run python -m src.portfolio.runner # paper trading a PORTAFOGLIO (capitale pool)
|
||||||
|
uv run python scripts/analysis/smoke_portfolio.py # smoke live data layer Cerbero v2
|
||||||
|
uv run python scripts/analysis/live_exec_smoke.py # smoke ESECUZIONE reale (ordine→verifica→fee, testnet)
|
||||||
|
uv run python scripts/analysis/live_shadow_smoke.py # smoke catena shadow nel worker (open/close reali)
|
||||||
|
uv run python scripts/analysis/regime_fetcher.py # fetch DVOL+funding (Deribit mainnet) -> data/regime/
|
||||||
|
uv run python scripts/analysis/exit_lab.py # (ri)costruisci cache segnali exit-lab + parity check
|
||||||
|
./scripts/deploy.sh [patch|minor|major] # DEPLOY: bump versione + commit + rebuild Docker
|
||||||
uv run pytest # test
|
uv run pytest # test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Deploy.** Il sorgente è **COPY nell'immagine Docker** (non montato) → `docker compose restart`
|
||||||
|
> NON ricarica il codice: serve **`docker compose up -d --build`** (o `./scripts/deploy.sh`, che bumpa
|
||||||
|
> la versione, committa e rebuilda). Il volume `data/` persiste → i worker fanno RESUME dello stato.
|
||||||
|
> La **versione** (file `VERSION`, semver, +1 ad ogni deploy via `deploy.sh`) compare nei messaggi
|
||||||
|
> Telegram (notifiche trade + report orario) → correli ogni msg al codice che l'ha generato.
|
||||||
|
|
||||||
## Dati storici
|
## Dati storici
|
||||||
|
|
||||||
Scaricati e salvati localmente in Parquet. Per rigenerarli:
|
Scaricati e salvati localmente in Parquet. Per rigenerarli:
|
||||||
@@ -64,37 +109,306 @@ df = load_data("ETH", "15m") # carica un asset/timeframe
|
|||||||
Fonte primaria: Cerbero MCP (endpoint `/mcp-deribit/tools/get_historical`).
|
Fonte primaria: Cerbero MCP (endpoint `/mcp-deribit/tools/get_historical`).
|
||||||
Token observer: nel file `secrets/observer.token` del progetto CerberoSuite.
|
Token observer: nel file `secrets/observer.token` del progetto CerberoSuite.
|
||||||
|
|
||||||
|
### Strumenti & validazione (gate raccolta dati)
|
||||||
|
|
||||||
|
`src/data/instruments.py` scopre e **valida** gli strumenti per ogni exchange
|
||||||
|
implementato — **Deribit** e **Hyperliquid** (esclusi Alpaca/stocks e **Bybit**,
|
||||||
|
il cui feed testnet è farlocco). Per ogni perpetuo enumera via `get_instruments`
|
||||||
|
/`get_markets` e verifica sui **dati storici realmente raccoglibili**:
|
||||||
|
esistenza, congruenza OHLC, not-flat (scarta contratti morti), liquidità (volume
|
||||||
|
daily) e **congruenza prezzo cross-exchange** (scostamento dalla mediana del
|
||||||
|
base-coin ≤ 5% → scarta outlier come `SOL-PERPETUAL`=9.6 vs SOL reale ~82).
|
||||||
|
|
||||||
|
Output: `data/instruments_registry.json` (strumenti validi, timeframe, start-date).
|
||||||
|
**Gate:** `_download_cerbero_range` rifiuta gli strumenti non validati (override
|
||||||
|
`allow_unvalidated=True` solo per casi eccezionali). Rigenera con
|
||||||
|
`python -m src.data.instruments`.
|
||||||
|
|
||||||
|
> **NB testnet.** Il token Cerbero punta a testnet; la congruenza cross-exchange
|
||||||
|
> è il filtro che distingue i feed realistici (Deribit, Hyperliquid) da quelli
|
||||||
|
> farlocchi (Bybit). Simboli Deribit: BTC/ETH = `<COIN>-PERPETUAL` (inverse);
|
||||||
|
> alt = `<COIN>_USDC-PERPETUAL` (lineari USDC). Registry attuale: Deribit 18/106,
|
||||||
|
> Hyperliquid 66/74 validi (major liquidi: BTC dal 2018, alt dal 2022).
|
||||||
|
|
||||||
## Strategie attive
|
## Strategie attive
|
||||||
|
|
||||||
Tutte le strategie estendono `src.strategies.base.Strategy` con interfaccia comune:
|
> **LEZIONE CRITICA (2026-05-28).** L'intera famiglia squeeze-breakout (SQ01-04,
|
||||||
`generate_signals() → backtest() → report()`.
|
> 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`.
|
||||||
|
|
||||||
| Codice | Nome | Tipo | Accuracy | Note |
|
Tutte le strategie estendono `src.strategies.base.Strategy`
|
||||||
|--------|------|------|----------|------|
|
(`generate_signals() → backtest()`). Le strategie mean-reversion condividono
|
||||||
| SQ01 | Squeeze Base | Regole | 76.7% | Squeeze breakout puro, baseline |
|
`src.strategies.fade_base.FadeStrategy` (backtest intrabar TP/SL/max_bars).
|
||||||
| SQ02 | Antifake+Vol | Regole | **79.7%** | **Miglior robusto** — 9 anni, Sharpe 5.01 |
|
**Strategie con edge netto validato OOS fee-aware (tutte fade/mean-reversion):**
|
||||||
| SQ03 | Filtered | Regole | 79.2% | Filtri selezionabili (9 preset) |
|
|
||||||
| SQ04 | Ultimate | Regole | 81.6% | Max accuracy ma concentrato 2018 |
|
|
||||||
| ML01 | Squeeze+GBM | ML | 78.8% | Walk-forward, €8-12/day, DD basso |
|
|
||||||
|
|
||||||
Per aggiungere una strategia:
|
| Codice | Nome | Meccanismo | Edge OOS netto (1h, fee 0.10% RT) | DD | Note |
|
||||||
1. Crea script in `scripts/strategies/` che estende `Strategy`
|
|--------|------|-----------|-----------------------------------|----|------|
|
||||||
2. Aggiungi mapping in `src/live/strategy_loader.py` → `MODULE_MAP`
|
| **MR01** | Bollinger Fade | banda std attorno a SMA | BTC +201% / ETH +1238% | 15-72% | Fada la banda, TP alla media, SL ad ATR |
|
||||||
3. Aggiungi entry in `strategies.yml` per paper trading
|
| **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% |
|
||||||
|
|
||||||
|
> **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).
|
||||||
|
|
||||||
|
**SWAP filtri fade: hurst→trend (2026-06-07).** Il gate PORT06 sul path live
|
||||||
|
(`scripts/analysis/trendmax_port06_impact.py`, parità 1.00000 col canonico) ha mostrato che
|
||||||
|
**post-EXIT-16 il loss-guard Hurst è ridondante-dannoso**: EXIT-16 ha eliminato i wick-stop che
|
||||||
|
hurst evitava → gli ingressi saltati (66% delle barre!) sono in maggioranza tornati vincenti.
|
||||||
|
Su PORT06: LIVE hurst-only FULL Sh 7.23 / OOS 9.35-DD 1.68 vs **TREND-ONLY 7.89 / 9.91-DD 1.20**
|
||||||
|
(domina su tutte le metriche; hurst+trend insieme over-filtra: 7.11, metà dei trade; plateau
|
||||||
|
trend_max 2.5/3.0/3.5 robusto). TREND-ONLY è la config che la ricerca EXIT-16 aveva davvero
|
||||||
|
promosso (entries trend-filtrate, no hurst) e che il live non aveva mai eseguito. **Live ora:
|
||||||
|
`trend_max=3.0, ema_long=200` nelle 6 fade di `_defs.py`, `hurst_max` rimosso** (la maschera resta
|
||||||
|
in `fade_base`). Monitor: `hourly_report` traccia lo stop-rate per epoca PRE→HURST→TREND.
|
||||||
|
Lezione: ri-gateare ogni filtro quando cambia l'exit engine. Diario
|
||||||
|
`docs/diary/2026-06-07-trendmax-gate.md`. Il paragrafo sotto resta come record storico:
|
||||||
|
|
||||||
|
**Loss-guard Hurst (storico: live dal 2026-06-02 al 2026-06-07, poi sostituito dal filtro trend).** Le fade accettano `hurst_max`: saltano i segnali in
|
||||||
|
regime PERSISTENTE/trending (rolling-Hurst ≥ soglia), dove si concentrano stop-loss e perdite
|
||||||
|
(diagnosi: stop-rate 43% per hurst>0.55 vs 21% anti-persistente; i peggiori 1% trade hanno hurst
|
||||||
|
medio 0.61). Helper `src.strategies.fade_base.hurst_skip_mask` (rolling-Hurst causale **dalle sole
|
||||||
|
close** → nessun feed esterno; step=6 per velocità live). **`hurst_max=0.55` attivo sulle 6 fade in
|
||||||
|
`_defs.py`**: il test decisivo a livello PORT06 lo conferma — **FULL DD 4.10%→2.39% (quasi dimezzato),
|
||||||
|
Sharpe 6.62→6.76, OOS Sharpe 8.89→9.15**. È l'UNICO meccanismo anti-perdite che supera il gate (ADX,
|
||||||
|
vol-expansion/vratio, efficiency-ratio, time-stop, vol-target FALLISCONO: tagliano i winner insieme ai
|
||||||
|
loser; i claim esterni ADX/ATR-ratio non si replicano su queste fade crypto). NB: il filtro agisce solo
|
||||||
|
sul path LIVE (`spec.params`); il backtest canonico (`build_everything`/regression-lock) NON è filtrato
|
||||||
|
→ il live farà MEGLIO del backtest sul DD. Ricerca: `scripts/analysis/fade_lossguard_workflow.js`,
|
||||||
|
diagnosi `fade_loss_by_regime.py`, diario `docs/diary/2026-06-02-fade-lossguard.md`.
|
||||||
|
**Effetto misurato (backtest):** stop-loss fade −67% in numero (1881→621), perdite totali −68%, coda
|
||||||
|
−61%→−48% (lo stop-RATE per-trade scende poco, 42→38%: il guard lavora riducendo l'ESPOSIZIONE nel
|
||||||
|
regime tossico, non rendendo sicuro ogni trade). **Monitor live:** `hourly_report.py` traccia lo
|
||||||
|
stop-rate fade PRIMA/DOPO l'attivazione (14:34 UTC del 2026-06-02) e dà il verdetto su Telegram quando
|
||||||
|
il campione DOPO ≥30 (già confermato: stop-rate live PRIMA 42% == backtest 42.1%).
|
||||||
|
|
||||||
|
**FIX EXIT-16 live — confirm su barra COMPLETATA (2026-06-05).** Il worker valutava il confirm-SL
|
||||||
|
sul prezzo della candela IN FORMAZIONE ad ogni poll → reintroduceva la wick-sensitivity che EXIT-16
|
||||||
|
elimina (audit: 2 stop su 3 del crash ETH erano wick-stop che il backtest non avrebbe preso in quel
|
||||||
|
momento). Ora `tick` valuta il confirm SOLO sul close dell'ultima barra completata (detection: la
|
||||||
|
riga -1 del df è la candela in corso finché `now < ts[-1]+bar_ms`), buf dall'ATR della stessa barra;
|
||||||
|
fill al prezzo corrente (≈ stress lag_close_exit, OK in exit-lab); TP intrabar invariato. La concausa
|
||||||
|
feed-gap NON è mitigabile lato exit (fill reali ≈ sim) e l'entry-guard post-flat è BOCCIATA (skippare
|
||||||
|
i segnali dopo barre flat PEGGIORA tutti gli sleeve ETH: la candela-gap è l'overshoot che la fade
|
||||||
|
fada). Aggiunto alert Telegram `STALE_FEED` (≥2 barre 1h flat → notifica + gap % al risveglio, solo
|
||||||
|
osservabilità). Diario `docs/diary/2026-06-05-confirm-sl-forming-bar.md`.
|
||||||
|
|
||||||
|
**EXIT-16 close-confirm SL (ATTIVO LIVE, 2026-06-04; esteso a DIP01 il 2026-06-07).** Le fade E
|
||||||
|
DIP01 accettano `sl_confirm_atr` (live: 0.5 in `_defs.py`): lo SL **intrabar è disattivato** e lo stop scatta solo se il **CLOSE** della barra
|
||||||
|
sfonda `sl ∓ 0.5·ATR14`, con uscita al close (TP intrabar al livello e max_bars invariati; in modalità
|
||||||
|
confirm il TP ha priorità nel bar). Scoperta della ricerca exit-lab (34 agenti, 23 famiglie esplorate +
|
||||||
|
10 verifiche avversariali + test PORT06): **gli stop intrabar da wick sono falsi negativi** — l'overshoot
|
||||||
|
che buca lo stop e rientra è esattamente il movimento che la fade fada. Verificato: indipendente dal
|
||||||
|
loss-guard Hurst, plateau buffer 0.4-1.0, regge fee 2x/lag/slippage, coda ≈ base nei crash veri (FTX:
|
||||||
|
+2.4% vs −39% del no-SL puro). **PORT06: FULL Sharpe 6.47→7.84 DD 4.10→2.60, OOS Sharpe 8.82→10.06 DD
|
||||||
|
1.30→1.15.** La famiglia "cavalca il prezzo" (trailing/ride/partial-runner, 15 varianti) è invece tutta
|
||||||
|
SCARTATA: oltre il TP=media non c'è coda (4ª conferma). Collaterali: l'engine intrabar filla gli stop
|
||||||
|
"al livello" anche su gap-through (54% dei casi per stop tight) → bias PRO stop-stretti nelle ricerche
|
||||||
|
future; mai deployare strategie con `sl=0` (il fallback −2% del worker non si applicherebbe). Harness
|
||||||
|
riusabile `scripts/analysis/exit_lab.py` + policy in `exit_policies/`. Implementazione: `fade_base.backtest`
|
||||||
|
+ `StrategyWorker.tick` (param `sl_confirm_atr`, None = comportamento storico; il backtest canonico
|
||||||
|
`build_everything` resta NON filtrato → il live farà meglio del backtest, come per il loss-guard).
|
||||||
|
Diario `docs/diary/2026-06-04-exit-lab.md`.
|
||||||
|
|
||||||
|
**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 live implementato
|
||||||
|
(`src/live/pairs_worker.py`, sezione `pairs:` in `strategies.yml`). LOGICA validata
|
||||||
|
(`validate_worker_pairs.py`: replay == backtest ESATTO). LIVE (`live_smoke_pairs.py`,
|
||||||
|
smoke reale Cerbero): **tutte e 5 le coppie con feed live fresco**. Naming Deribit:
|
||||||
|
BTC/ETH = `<COIN>-PERPETUAL` (inverse); alt = `<COIN>_USDC-PERPETUAL` (lineari USDC,
|
||||||
|
storia dal 2022). Trappola: `LTC-PERPETUAL`/`SOL-PERPETUAL` danno vuoto/dati errati →
|
||||||
|
usare sempre `_USDC-PERPETUAL`. Resta da verificare solo liquidità/fill in esecuzione.
|
||||||
|
Verifica edge: `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; worker live a
|
||||||
|
2 gambe implementato, validato e con feed live su tutte e 5 le coppie — resta da
|
||||||
|
verificare liquidità/fill in esecuzione reale). La confluenza multi-TF è stata SCARTATA (overfit).
|
||||||
|
|
||||||
|
**Pattern del segnale per FORMA (branch `shape_patterns`, 2026-05-29).** Esplorate 5 famiglie
|
||||||
|
di *shape forecasting* con agenti paralleli su harness onesto (`scripts/analysis/shape_lab.py`:
|
||||||
|
analog kNN causale, no-look-ahead verificato). **4/5 sono RUMORE** (riconfermano la dominanza
|
||||||
|
mean-reversion): analog kNN sulla forma grezza (solo BTC-overfit), encoding candele
|
||||||
|
UP/DOWN/DOJI+body/shadow (hit-rate ~50%), DTW+template geometrici (DTW *peggiora* l'euclidea;
|
||||||
|
template overfit), PIP/pivot/zig-zag (0/48 robuste). Vedi `scripts/analysis/shape_*_research.py`.
|
||||||
|
- **SH01 Shape-ML** (`scripts/strategies/SH01_shape_ml.py`): UNICO edge. Una LogisticRegression
|
||||||
|
legge 17 feature di forma (body/shadow, rendimenti, pendenza/curvatura, pos max/min, RSI,
|
||||||
|
estensione) e predice il segno del rendimento a H barre in **walk-forward** (scaler+modello
|
||||||
|
solo sul passato, no leakage). Config **W24 H12 th0.58**. A differenza dello squeeze
|
||||||
|
**regge fee 0.20% RT**. Win-rate ~50% → l'edge è nell'**asimmetria**, non nella frequenza.
|
||||||
|
Validazione (`scripts/analysis/shape_ml_validate.py`): BTC robusto OVUNQUE (expanding +219%/
|
||||||
|
OOS +42% Sharpe 2.72 8-9 anni; rolling 2y +166%/+96%; stress 2x+slippage OK), ETH/ADA
|
||||||
|
robusti solo expanding (secondari), LTC/SOL/XRP scartati. Griglia: **5/27 celle robuste su
|
||||||
|
cresta stretta W24/H8-12** → overfit moderato, scelta la config conservativa. **Valore vero:
|
||||||
|
diversificatore** (corr +0.08 col MASTER); aggiungerlo migliora l'OOS del MASTER (Sharpe
|
||||||
|
4.33→5.10, DD 4.7%→4.2%). NON motore standalone. **LIVE (2026-06-01): gira come StrategyWorker
|
||||||
|
reale** (vedi fix wiring sotto in SCOPE LIVE). Diario: `docs/diary/2026-05-29-shape.md`.
|
||||||
|
|
||||||
|
**ARGO / GEX opzioni (analisi 2026-06-01, SCARTATO).** Valutato ARGO (lettura del gamma-exposure dei
|
||||||
|
dealer Deribit) come filtro di regime. Esito **NO-GO**: il net-GEX si calcola live (Deribit mainnet
|
||||||
|
public API, OI reale ~368k contratti, DVOL/funding storici gratis) ma **lo storico per-strike dell'OI
|
||||||
|
non è gratuito → non backtestabile OOS** (stesso muro delle opzioni W18/19/21). Niente evidenza crypto,
|
||||||
|
segno fragile, mercato dominato dai perp. Diario `docs/diary/2026-06-01-argo-gex-feasibility.md`.
|
||||||
|
|
||||||
|
**Frattali del segnale × Regime ARGO (ricerca 100 agenti, 2026-06-02, RECORD).** Cercata una strategia
|
||||||
|
che combini un segnale frattale (Hurst/Higuchi/Williams/analog) con un gate di regime (DVOL/VRP/funding).
|
||||||
|
Infrastruttura riusabile: `scripts/analysis/regime_fetcher.py` (DVOL+funding da Deribit mainnet →
|
||||||
|
**`data/regime/`**, NON `data/raw/` che è solo OHLCV) e `regime_lab.py` (feature regime+frattali causali,
|
||||||
|
cache, harness netto-OOS). Esito: **15 strategie robuste e causali, ma NESSUNA batte/migliora il PORT06**
|
||||||
|
(diversificatori sovrapposti alle fade). **Finding: il prior ARGO "VRP>0=range=fade" è SMENTITO** —
|
||||||
|
l'edge è su **VRP<0 + DVOL bassa**. Il vincitore `FR01_hurst_calm_fade.py` è robusto ma DILUISCE il
|
||||||
|
PORT06 (OOS Sharpe 8.89→8.72) → **non deployato** (in `scripts/strategies/` ma NON in MODULE_MAP/yml).
|
||||||
|
Il sottoprodotto utile è stato il **loss-guard Hurst** (vedi sopra), che invece MIGLIORA il PORT06.
|
||||||
|
Diario `docs/diary/2026-06-02-fractal-argo-search.md`.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## Portafogli
|
||||||
|
|
||||||
|
- Un `Portfolio` è un oggetto di prima classe (`src/portfolio/`) con definizione (sleeve + schema pesi) e due facce sulla **STESSA** definizione: `.backtest()` (riusa il builder unico di `sleeves.py` → parità esatta con `report_families`) e live (`PortfolioRunner`: capitale pool condiviso, sizing per peso, ribilancio giornaliero, ledger aggregato in `data/portfolios/{code}/`).
|
||||||
|
- **Schemi peso:** `equal` (default), `cap` (tetto per famiglia, es. pairs 33% — config raccomandata), `inverse_vol`, `cluster_rp` (equal fra cluster naturali poi inverse-vol intra-cluster), `manual`. Definiti in `weighting.py`; la chiave cap è la famiglia (PAIRS/FADE/HONEST/SHAPE/TSM).
|
||||||
|
- **Default `portfolios.yml`:** PORT06 (master+shape), `weighting=cap pairs 0.33 + shape 0.0588`, leva 2x, ribilancio 1D. Backtest PORT06 canonico (dati al 2026-05-28, pre-cap-shape): FULL Sharpe 6.47 DD 4.10% / OOS Sharpe 8.82 DD 1.30%; **con EXIT-16 close-confirm (config live attuale): FULL 7.84 / 2.60%, OOS 10.06 / 1.15%** (i vecchi 6.07/8.19 erano pre-loss-guard/pre-refresh dati). Col cap SHAPE (2026-06-05): FULL 6.43 / 3.96%, OOS 8.58 / 1.36% — assicurazione sulla coda SH01, vedi sotto.
|
||||||
|
- **SH01 SENZA STOP-LOSS — by design, CONFERMATO da ricerca (2026-06-05).** Dopo il crash ETH (−15.6% su un trade SH01 live), ricerca multi-agente con harness dedicato `scripts/analysis/sh01_exit_lab.py` (cache segnali walk-forward, engine con **fill gap-aware** `worse(livello, open)`, parity esatta con explore_lab, protocollo train≤2023-11-01/OOS): **11 famiglie di stop testate (ATR intrabar/close-confirm, %, chandelier, breakeven, giveback, loser-timestop, disaster-cap close+intrabar, swing, vol-regime), 0 sopravvissute** al gate (ETH migliorato senza degradare BTC, train E oos, plateau). Pattern: ogni stop stretto abbastanza da toccare la coda ETH rompe BTC; ogni stop largo non arriva alla coda; nei crash il fill è al gap, non al livello (lo stop "protettivo" PEGGIORA la coda OOS). Mitigazione adottata: **cap famiglia SHAPE a 0.0588 in PORT06** (≈ dimezzata; costo OOS Sharpe −0.24, FULL DD −0.14pp) — la prossima coda impatta il conto per metà. NON impostare mai `sl`/`sl_confirm_atr` su SH_BTC/SH_ETH. Direzione futura: liquidity-gate sull'entry (skip dopo feed flat). Diario `docs/diary/2026-06-05-sh01-sl-research.md`.
|
||||||
|
- **Data layer Cerbero v2:** `get_historical_v2` unificato + `get_instruments` (naming robusto) + `get_ticker_batch`. Trading su Deribit.
|
||||||
|
- **SCOPE LIVE (fase 2 completata):** il runner esegue TUTTI gli sleeve di PORT06. Worker: single `StrategyWorker` (fade MR01/02/07, DIP01, **e SH01**), `PairsWorker` (PR01 2 gambe), e i multi-asset dedicati `BasketTrendWorker` (TR01 4h), `RotationWorker` (ROT02 1d), `TsmomWorker` (TSM01 1d). Il runner fetcha 1h da Cerbero v2 e **resampla a 4h/1d** (lookback dimensionato sui daily: TSM01 usa 252g). Validazione: runner pool/ribilancio/ledger == backtest (`validate_portfolio_runner.py`, identico); worker multi-asset == reference (`validate_honest_workers.py`: TSM01 esatto, ROT02 +1303% canonico, TR01 stesso ordine — differenza di convenzione capitale-unico vs media-equity).
|
||||||
|
- **SH01 bootstrap full-history (punto-10, 2026-06-07).** La ri-validazione col train-window del
|
||||||
|
regime live (`sh01_trainwindow_validate.py`) ha mostrato che SH01 a train 365g **NON è robusto**
|
||||||
|
(BTC fee-2x FULL −42%, ETH Sharpe −0.02, trade-rate 22-26% vs 10% validato: LogReg over-confident,
|
||||||
|
soglia inerte — la diagnosi sweep era esatta). L'edge è MONOTONO nella memoria: solo l'expanding
|
||||||
|
full-history passa il gate. Fix live: il runner passa agli sleeve `ml` la storia FULL
|
||||||
|
(`_with_history`: parquet locale + feed, gap-guard con WARN) e `ml_wf_entries(last_block_only=True)`
|
||||||
|
fitta SOLO l'ultimo blocco del walk-forward → segnali **identici per costruzione** al WF completo
|
||||||
|
(parity test esatto), 0.6 s/tick su 73k barre. Manutenzione: tenere fresco il parquet
|
||||||
|
(`download_all()`). MAI ri-tunare la soglia nel regime corto (instabile/incoerente fra asset).
|
||||||
|
Diario `docs/diary/2026-06-07-sh01-trainwindow.md`.
|
||||||
|
- **FIX SH01 wiring (2026-06-01).** SH01 gira come **`StrategyWorker` NORMALE** (NON il vecchio `MLWorkerWrapper` di `multi_runner`, che usava il `SignalEngine` **squeeze SCARTATO**: apriva senza metadata ed usciva a `hold_bars=3`, ignorando del tutto SH01_shape_ml). `SH01_shape_ml.generate_signals` fa il walk-forward (retraining) internamente ad ogni tick ed emette `metadata.max_bars=12` → exit a orizzonte via `StrategyWorker.tick`. Serve ≥4000 barre 1h (`_ML_LOOKBACK_DAYS=365`). Vedi `docs/diary/2026-06-01-sh01-wiring-squeeze-bug.md`.
|
||||||
|
- **Altri fix StrategyWorker (2026-06-01).** Exit a orizzonte puro per strategie senza TP/SL (`elif self.max_bars`, SH01 esce a H=12 non hold_bars=3); `is_win = net > 0` (win NETTO fee, non lordo); filtro `min_tp_frac` (salta micro-scalp col TP entro le fee); loss-guard `hurst_max=0.55` sulle fade (vedi sopra).
|
||||||
|
- **Exit intrabar (fase 3, risolto):** lo `StrategyWorker` ora esce sui TP/SL toccati INTRABAR (high/low della barra, al livello, SL prioritario) come il backtest — non più solo sul close. Allinea fade/DIP01 live al backtest intrabar (`tests/portfolio/test_intrabar_exit.py`). Caveat residuo onesto: nel paper trading l'high/low usato è quello della barra in corso al poll; su un fill reale conterebbe il momento del tocco.
|
||||||
|
- **ESECUZIONE REALE — shadow (v1.0.3, 2026-06-03).** I **6 fade** (MR01/MR02/MR07 × BTC/ETH) eseguono ordini **REALI su Deribit testnet** accanto al fill simulato (*shadow*: sim + reale in parallelo, il sim resta la verità che guida le decisioni). `src/live/execution.py` `ExecutionClient`: `open` (market) + `close_amount` (market **reduce-only della SOLA quota del worker** — i 3 fade BTC condividono lo strumento e le posizioni si nettano per conto, quindi NON si usa `close_position` che flatterebbe le quote altrui); **verifica l'esecuzione sul TRADE** (order_id in `get_trade_history`, non sulla size netta aggregata); **fee REALI lette dai `trades[]`**. Strumenti = **lineari USDC** (`BTC_USDC`/`ETH_USDC-PERPETUAL`, amount nel base-coin, step 0.0001/0.001): scelti perché il **payoff lineare == matematica del backtest** (l'inverse `*-PERPETUAL` introdurrebbe una base 1/prezzo) e fee/PnL sono in USDC. Lo `StrategyWorker` tiene un **ledger reale parallelo** (`real_capital`, persistito) e logga `REAL_OPEN`/`REAL_CLOSE` col confronto **slippage** (prezzo sim vs eseguito) e **fee** (assunta 0.10% vs reale). Config: `portfolios.yml` → `overrides.execution {enabled, sleeves:[MR01,MR02,MR07,DIP01], instruments:{BTC:BTC_USDC-PERPETUAL, ETH:ETH_USDC-PERPETUAL}}` (**DIP01 aggiunto il 2026-06-04**: stesso wiring single-leg, TP resting incluso); pairs/rotation/tsmom/shape restano **simulati** (pairs richiede un executor a 2 gambe con gestione leg-risk; shape non ha TP). **Fee reali misurate = 0.05%/lato = 0.10% RT** (== assunto del backtest, su ETH; BTC inverse era ~0.094%). **Alert Telegram:** `REAL_EXEC_LIVE` (primo ordine reale verificato per worker) + `REAL_OPEN_FAIL`. Smoke (testnet, €0): `scripts/analysis/live_exec_smoke.py` (layer: ordine→verifica→fee) e `live_shadow_smoke.py` (catena worker open/close). **Capitale live portato a 2000** (notional fade ~$35) per ridurre il rumore di arrotondamento su BTC (step lineare ~$6.7). NB: ledger reale ≠ ledger sim — i worker già in posizione sim a un restart non hanno quota reale corrispondente; lo shadow reale parte pulito dalla prossima apertura.
|
||||||
|
- **Disaster-bracket on-book + alert outage (v1.1.4, 2026-06-07).** A ogni `REAL_OPEN` dei fade
|
||||||
|
eseguiti il worker piazza uno **STOP_MARKET reduce-only a ~−30%** dall'ingresso (trigger sul mark,
|
||||||
|
`ExecutionClient.place_disaster_sl`, cancellato in `_real_close`): assicurazione per gli outage
|
||||||
|
(poll-loop fermo = exit non valutati), in operatività normale non scatta mai → 0 costo Sharpe.
|
||||||
|
Config `overrides.execution.disaster_sl_pct` (0.30; 0=off). NB: il `set_stop_loss` di cerbero-mcp
|
||||||
|
è un `private/edit` (solo ordini APERTI) → inutilizzabile su market fillati; la cancel di un
|
||||||
|
trigger order risponde `untriggered` (= successo, verificato testnet). Alert Telegram `FEED_OUTAGE`
|
||||||
|
dopo 5 poll falliti consecutivi (con elenco posizioni reali aperte) + notifica di ripresa. I fix
|
||||||
|
di parità della stessa tornata (v1.1.3): TR01 fee×leva + forming-bar TR01/Pairs + WARN
|
||||||
|
`PANEL_SHORT` su TSM01/ROT02; `hourly_report` ora mostra i multi-asset (sezione MULTI-ASSET).
|
||||||
|
Diario `docs/diary/2026-06-07-sweep-fixes.md`.
|
||||||
|
- **TP reale = LIMIT reduce-only AL LIVELLO (2026-06-04).** Misurati +235 bps di slippage medio sulle uscite take-profit market-on-poll (sim esce al livello intrabar, il reale chiudeva al poll post-rimbalzo: sim +11.85 vs reale +0.62 USD sui primi 7 close). Fix: a ogni `REAL_OPEN` il worker piazza un **limit reduce-only al TP** (`ExecutionClient.place_tp_limit`, prezzo quantizzato al tick, SOLA quota del worker) → `REAL_TP_RESTING`; a ogni chiusura sim `_real_close` **cancella il resting → riconcilia i fill (anche parziali) via `get_trade_history` per order_id → market reduce-only solo del residuo** → ledger su prezzo combinato. `real_tp_order_id` persistito in `status.json` (resume-safe). Lo **SL resta market-on-poll** (deliberato: i trigger Deribit generano un nuovo order_id al trigger → fill non verificabile per order_id; e sul SL il rimbalzo lavora a favore). Fill da resting = fee **maker ~0%**. Smoke: `live_shadow_smoke.py` (2 scenari, testnet). Diario `docs/diary/2026-06-04-shadow-divergence.md`.
|
||||||
|
- **position_size per-famiglia (2026-06-07).** `portfolios.yml` accetta `position_size_family`
|
||||||
|
(chiave = `weighting.family_of`); plumbing `runner.pos_for_spec`. **PAIRS a 0.20** (esposizione
|
||||||
|
0.40 ≈ il validato 0.45): la famiglia è senza stop e col globale 0.5×lev2 girava a ~2.2x il
|
||||||
|
validato (ETH/BTC DD grezzo 78% a quella taglia). PORT06 OOS DD 3.40→1.26% al costo di OOS
|
||||||
|
Sharpe 9.05→8.43 — assicurazione come il cap SHAPE. Gate `pairspos_port06_impact.py`,
|
||||||
|
diario `docs/diary/2026-06-07-pairspos-gate.md`.
|
||||||
|
- **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate); fedele al backtest daily-rebalanced entro il turnover infragiornaliero.
|
||||||
|
|
||||||
## Multi-Strategy Paper Trader
|
## Multi-Strategy Paper Trader
|
||||||
|
|
||||||
Orchestratore che esegue N strategie in parallelo su dati live Cerbero, ognuna con €1000 USDC virtuali indipendenti.
|
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.
|
**Config:** `strategies.yml` — due sezioni: `strategies` (single-leg: fade/honest) e
|
||||||
**Persistenza:** `data/paper_trades/{strategy}___{asset}__{tf}/` con `trades.jsonl` (append-only) + `status.json` (resume al restart).
|
`pairs` (a 2 gambe). Attive: 6 fade (MR01/MR02/MR07 × BTC/ETH) + 5 coppie PR01.
|
||||||
|
**Due worker:** `strategy_worker.py` (single-leg) e `pairs_worker.py` (2 gambe,
|
||||||
|
long A / short B sullo z-score del log-ratio, fee su 2 gambe).
|
||||||
|
**Persistenza:** `data/paper_trades/{worker_id}/` con `trades.jsonl` (append-only) + `status.json` (resume al restart).
|
||||||
**Hot-add:** aggiungi riga YAML → `docker compose restart` → storico intatto.
|
**Hot-add:** aggiungi riga YAML → `docker compose restart` → storico intatto.
|
||||||
|
**Exit strategia:** se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come le fade), il worker esce su take-profit/stop-loss/time-limit; i pairs escono su |z|≤z_exit o max_bars.
|
||||||
|
**Naming Deribit (feed live):** major = `<COIN>-PERPETUAL` (inverse); alt = `<COIN>_USDC-PERPETUAL` (lineari USDC). Vedi INSTRUMENT_MAP in `multi_runner.py`.
|
||||||
**Notifiche:** Telegram per ogni trade (richiede `.env` con `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID`).
|
**Notifiche:** Telegram per ogni trade (richiede `.env` con `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID`).
|
||||||
|
|
||||||
## Convenzioni
|
## Convenzioni
|
||||||
|
|
||||||
- Strategie in `scripts/strategies/` con codice univoco (SQ01, ML01, ...).
|
- Strategie in `scripts/strategies/` con codice univoco (MR01, ...).
|
||||||
- Script scartati in `scripts/waste/` con prefisso W01-W22.
|
- Script scartati in `scripts/waste/` (W01-W28 + famiglia squeeze).
|
||||||
- Diario in `docs/diary/YYYY-MM-DD.md`. Aggiornare dopo ogni esperimento significativo.
|
- Diario in `docs/diary/YYYY-MM-DD.md`. Aggiornare dopo ogni esperimento significativo.
|
||||||
- Nessun dato sensibile nei commit (token, chiavi API). Usare `.gitignore`.
|
- 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]`.
|
- 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]`.
|
||||||
@@ -102,6 +416,8 @@ Orchestratore che esegue N strategie in parallelo su dati live Cerbero, ognuna c
|
|||||||
## Attenzione
|
## 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]`.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
+4
-2
@@ -8,9 +8,11 @@ COPY pyproject.toml uv.lock ./
|
|||||||
RUN uv sync --frozen --no-dev
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
COPY scripts/strategies/ scripts/strategies/
|
COPY scripts/ scripts/
|
||||||
COPY strategies.yml strategies.yml
|
COPY strategies.yml portfolios.yml VERSION ./
|
||||||
|
|
||||||
VOLUME /app/data
|
VOLUME /app/data
|
||||||
|
|
||||||
|
# Default: paper trader multi-strategia. Il servizio "portfolio" in docker-compose
|
||||||
|
# sovrascrive il command per il runner a portafoglio (src.portfolio.runner).
|
||||||
CMD ["uv", "run", "python", "-m", "src.live.multi_runner"]
|
CMD ["uv", "run", "python", "-m", "src.live.multi_runner"]
|
||||||
|
|||||||
@@ -4,41 +4,99 @@ Sistema di riconoscimento pattern frattali e predizione per il trading di cripto
|
|||||||
|
|
||||||
## Obiettivo
|
## Obiettivo
|
||||||
|
|
||||||
Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di €50 al giorno entro 6–8 mesi, tramite strategie algoritmiche che combinano analisi frattale, squeeze di volatilità e machine learning.
|
Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di €50 al giorno entro 6–8 mesi, tramite un portafoglio di strategie algoritmiche poco correlate fra loro — mean-reversion, trend/rotazione e spread market-neutral — validate out-of-sample e fee-aware.
|
||||||
|
|
||||||
## Risultati
|
## Risultati
|
||||||
|
|
||||||
Oltre 30 strategie testate su dati storici 2018–2026 (BTC e ETH, timeframe 15m / 1h). Le migliori:
|
> ⚠️ **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`.
|
||||||
|
|
||||||
| Codice | Strategia | Accuracy | Trades | Max DD | €/giorno | Robustezza |
|
Dopo una validazione **out-of-sample, fee-aware** di molte famiglie di strategie,
|
||||||
|--------|-----------|----------|--------|--------|----------|------------|
|
emergono cinque famiglie con edge netto reale, tutte radicate nella stessa lezione
|
||||||
| SQ02 | Antifake+Vol BTC 15m | **79.7%** | 1250 | 6.5% | €5.23 | ✅ 9/9 anni |
|
(in cripto la **mean-reversion** funziona, la continuazione no) o nella diversificazione:
|
||||||
| ML01 | Squeeze+GBM BTC 15m | 79.1% | 1929 | 5.5% | €8.45 | ✅ 5/5 anni |
|
|
||||||
| SQ02 | Antifake+Vol ETH 15m | 78.6% | 942 | 3.4% | €4.33 | 8/9 anni |
|
|
||||||
| SQ02 | Antifake+Vol BTC 1h | 78.0% | 473 | 3.5% | €3.85 | ✅ 9/9 anni |
|
|
||||||
| SQ01 | Squeeze Base ETH 15m | 76.4% | 2948 | 6.2% | €10.31 | 9/9 anni |
|
|
||||||
| ML01 | Squeeze+GBM ETH 15m | 76.7% | 1210 | 4.2% | €11.12 | 5/5 anni |
|
|
||||||
|
|
||||||
La strategia più robusta (SQ02 BTC 15m) mantiene accuracy ≥73% ogni anno dal 2018 con Sharpe 5.01.
|
| Famiglia | Meccanismo | Strategie | Profilo (netto OOS) |
|
||||||
|
|----------|-----------|-----------|---------------------|
|
||||||
|
| **FADE** | mean-reversion intraday 1h (long/short, BTC/ETH) | MR01 Bollinger, MR02 Donchian, MR07 Return-reversal | Acc 52-55%, DD 18-34% |
|
||||||
|
| **HONEST** | long-only multi-regime multi-crypto | DIP01 dip-buy, TR01 EMA-trend, ROT02 dual-momentum | CAGR 31-56%, DD 15-27% |
|
||||||
|
| **PAIRS** | spread reversion *market-neutral* (2 gambe) | PR01 ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL | Sharpe 2.0-4.4, corr col mercato ~0.05 |
|
||||||
|
| **TSMOM** | time-series momentum multi-orizzonte | TSM01 (3/6/12m + risk-off) | diversificatore, DD 15-22% |
|
||||||
|
| **SHAPE** | ML walk-forward su feature di *forma* del prezzo | SH01 (LogisticRegression, orizzonte 12 barre) | diversificatore, corr +0.08 col resto |
|
||||||
|
|
||||||
|
Tutti i numeri sono **netti** dopo fee realistiche (Deribit 0.10% RT single-leg, 0.20%
|
||||||
|
RT/coppia sui pairs), leva 3x, su finestra held-out. Le strategie sono robuste su griglia
|
||||||
|
parametri, sweep fee 0.00-0.20% RT e — per i pairs — validate con **walk-forward** e
|
||||||
|
config universale (niente cherry-picking).
|
||||||
|
|
||||||
|
### Portafoglio combinato (la vera leva anti-drawdown)
|
||||||
|
|
||||||
|
Le famiglie sono **quasi scorrelate fra loro** (~0.05). Combinandole in un unico
|
||||||
|
portafoglio equipesato il drawdown crolla sotto quello di ogni singola sleeve:
|
||||||
|
|
||||||
|
| Portafoglio | CAGR | Max DD | Sharpe |
|
||||||
|
|-------------|------|--------|--------|
|
||||||
|
| FADE (6 sleeve) | ~46% | 8% | 3.9 |
|
||||||
|
| HONEST (3 sleeve) | ~46% | 13% | 2.2 |
|
||||||
|
| **MASTER** (FADE + HONEST, 9) | ~47% | **5%** | 4.2 |
|
||||||
|
| **MASTER + PAIRS + TSM01** (15) | ~67% | ~5% | ~6 |
|
||||||
|
| **PORT06 live** (17 sleeve, cap pairs 33%, leva 2×, config EXIT-16) | ~79% | **2.6%** | 7.8 (FULL) / 10.1 (OOS) |
|
||||||
|
|
||||||
|
> 🔎 **Numeri sobri (anti-overfit).** L'OOS singolo cade nel regime favorevole 2024-25:
|
||||||
|
> i valori di Sharpe/DD sopra sono ottimistici di circa il 50%. Da pianificare per le
|
||||||
|
> decisioni: **Sharpe atteso ~5**, **worst-drawdown su 90 giorni ~6%**, profilo che regge
|
||||||
|
> a leva 2x con slippage raddoppiato. Configurazione raccomandata: equal-weight, leva 2x,
|
||||||
|
> con un cap sull'allocazione ai pairs (~30-35%, poiché concentrano ~57% del rischio).
|
||||||
|
> Tutto resta da confermare nel paper trading live.
|
||||||
|
|
||||||
## Come funziona
|
## 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").
|
1. **Bollinger Bands** (window `n`, `k` deviazioni standard) sul close.
|
||||||
2. **Breakout** — le bande escono dal canale. Un impulso direzionale parte.
|
2. **Entry** — quando il close esce *sotto* la banda inferiore → **long** (o *sopra* la superiore → **short**). Ingresso a `close[i]`, eseguibile dal vivo.
|
||||||
3. **Filtri** — anti-fakeout (scarta breakout con retrace >60%) + volume confirmation (breakout >1.3× media).
|
3. **Take-profit** alla media mobile (il rientro atteso).
|
||||||
4. **ML opzionale** — un modello GradientBoosting (GBM), addestrato walk-forward su feature strutturali, conferma la direzione e filtra i segnali deboli.
|
4. **Stop-loss** a `sl_atr × ATR` oltre l'estremo; **time-limit** a `max_bars`.
|
||||||
|
|
||||||
### Feature ML (44 dimensioni)
|
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)
|
### Le altre famiglie
|
||||||
- Momentum, volatilità, skewness, kurtosis dei rendimenti logaritmici
|
|
||||||
- Autocorrelazione lag-1 e profilo volumetrico
|
- **FADE** (oltre MR01): MR02 fada la rottura del canale Donchian verso il centro;
|
||||||
- Durata della fase di squeeze e rapporto di espansione Keltner
|
MR07 fada il movimento di barra estremo misurato in deviazioni standard dei
|
||||||
- Posizione del prezzo rispetto al range recente e ATR normalizzato
|
rendimenti. Stessa logica di reversione, indicatori indipendenti.
|
||||||
|
- **HONEST** (long-only, multi-crypto): DIP01 compra i dip estremi e rivende al
|
||||||
|
recupero; TR01 segue il trend con incrocio di EMA su un paniere; ROT02 ruota ogni
|
||||||
|
giorno sui tre asset col momentum più forte, andando in cash quando BTC è sotto la
|
||||||
|
sua media (risk-off). Coprono i regimi di trend e rotazione, complementari alle fade.
|
||||||
|
- **PAIRS** (market-neutral): scommette sul rientro verso la media del log-ratio fra
|
||||||
|
due cripto (z-score). Long su una, short sull'altra: l'esposizione netta al mercato è
|
||||||
|
quasi nulla (correlazione ~0.02), il che la rende un diversificatore eccellente.
|
||||||
|
- **TSMOM**: tiene gli asset con momentum positivo persistente su più orizzonti
|
||||||
|
(3/6/12 mesi), con overlay risk-off. Rende meno ma è poco correlato, utile in ensemble.
|
||||||
|
|
||||||
|
### 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
|
## Struttura progetto
|
||||||
|
|
||||||
@@ -51,20 +109,30 @@ PythagorasGoal/
|
|||||||
│ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi
|
│ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi
|
||||||
│ │ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats
|
│ │ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats
|
||||||
│ │ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr
|
│ │ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr
|
||||||
│ └── live/ # Paper trading live su Deribit testnet
|
│ ├── live/ # Paper trading live su Deribit testnet
|
||||||
│ ├── multi_runner.py # Orchestratore multi-strategia
|
│ │ ├── multi_runner.py # Orchestratore multi-strategia (strategie + pairs)
|
||||||
│ ├── strategy_worker.py # Worker indipendente con stato persistente
|
│ │ ├── strategy_worker.py # Worker single-leg con stato persistente
|
||||||
│ ├── strategy_loader.py # Import dinamico classi Strategy
|
│ │ ├── pairs_worker.py # Worker a 2 gambe per i pairs (market-neutral)
|
||||||
│ ├── cerbero_client.py # Client HTTP per Cerbero MCP
|
│ │ ├── strategy_loader.py # Import dinamico classi Strategy
|
||||||
│ ├── signal_engine.py # Squeeze + ML real-time (per ML01)
|
│ │ ├── cerbero_client.py # Client HTTP per Cerbero MCP
|
||||||
│ └── telegram_notifier.py
|
│ │ ├── signal_engine.py # Squeeze + ML real-time (legacy) + validazione OOS
|
||||||
|
│ │ └── telegram_notifier.py
|
||||||
|
│ └── portfolio/ # Portafogli di prima classe (capitale condiviso, backtest + live)
|
||||||
|
│ ├── base.py # SleeveSpec, Portfolio (.backtest), load_active_portfolio
|
||||||
|
│ ├── weighting.py # Schemi di ponderazione: equal, cap, inverse_vol, cluster_rp, manual
|
||||||
|
│ ├── sleeves.py # Builder unificato equity-per-sleeve (fonte unica, parità report)
|
||||||
|
│ ├── ledger.py # PortfolioLedger: PnL/DD aggregati, persistenza e resume
|
||||||
|
│ └── runner.py # PortfolioRunner live (Cerbero v2, sizing, ribilancio giornaliero)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── strategies/ # Strategie attive (SQ01-SQ04, ML01)
|
│ ├── strategies/ # Strategie con edge validato OOS (FADE, HONEST, PAIRS, TSMOM + portafogli)
|
||||||
│ ├── waste/ # Strategie scartate (W01-W22)
|
│ ├── portfolios/ # Definizioni PORT01-06 e report run() dei portafogli di prima classe
|
||||||
│ └── analysis/ # Script di confronto e report
|
│ ├── waste/ # Strategie scartate (squeeze SQ/MT/ML/AD/CM/PD, MR03, ROT01, W01-W28)
|
||||||
|
│ └── analysis/ # Ricerca/validazione OOS fee-aware, gestione rischio, report
|
||||||
├── strategies.yml # Config multi-strategy paper trader
|
├── strategies.yml # Config multi-strategy paper trader
|
||||||
├── data/
|
├── data/
|
||||||
│ └── raw/ # Parquet OHLCV (gitignored, ~70 MB)
|
│ ├── raw/ # Parquet OHLCV (gitignored, ~70 MB)
|
||||||
|
│ └── regime/ # DVOL + funding (Deribit mainnet) + cache feature regime (gitignored)
|
||||||
|
├── VERSION # versione semver (cotta nell'immagine, mostrata nei msg Telegram)
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── diary/ # Diario di ricerca giornaliero
|
│ ├── diary/ # Diario di ricerca giornaliero
|
||||||
│ └── specs/ # Specifiche di design
|
│ └── specs/ # Specifiche di design
|
||||||
@@ -75,25 +143,73 @@ PythagorasGoal/
|
|||||||
|
|
||||||
## Strategie attive
|
## Strategie attive
|
||||||
|
|
||||||
Tutte le strategie estendono `src.strategies.base.Strategy` con interfaccia comune: `generate_signals() → backtest() → report()`.
|
Le strategie single-asset estendono `src.strategies.base.Strategy`
|
||||||
|
(`generate_signals() → backtest()`); i pairs hanno un worker dedicato a 2 gambe.
|
||||||
|
|
||||||
| Codice | Script | Tipo | Descrizione |
|
| Codice | Script | Famiglia | Descrizione |
|
||||||
|--------|--------|------|-------------|
|
|--------|--------|----------|-------------|
|
||||||
| SQ01 | `SQ01_squeeze_base.py` | Regole | Squeeze breakout puro |
|
| **MR01** | `MR01_bollinger_fade.py` | FADE | Fada la banda di Bollinger, TP alla media, SL ad ATR |
|
||||||
| SQ02 | `SQ02_squeeze_antifake_vol.py` | Regole | Squeeze + antifakeout + volume confirm |
|
| **MR02** | `MR02_donchian_fade.py` | FADE | Fada la rottura del canale Donchian, TP al centro |
|
||||||
| SQ03 | `SQ03_squeeze_all_filters.py` | Regole | Squeeze + filtri selezionabili (9 preset) |
|
| **MR07** | `MR07_return_reversal.py` | FADE | Fada il movimento di barra estremo (z dei rendimenti) |
|
||||||
| SQ04 | `SQ04_squeeze_ultimate.py` | Regole | Combo incrementali con correlazione/trend |
|
| **DIP01** | `DIP01_dip_reversion.py` | HONEST | Dip-buy long-only su z-score estremo |
|
||||||
| ML01 | `ML01_squeeze_gbm.py` | ML | Squeeze + GBM walk-forward |
|
| **TR01** | `TR01_ema_trend.py` | HONEST | EMA 20/100 trend-following su paniere cripto (4h) |
|
||||||
|
| **ROT02** | `ROT02_dual_momentum.py` | HONEST | Rotazione cross-sectional top-3 + risk-off (1d) |
|
||||||
|
| **PR01** | `PR01_pairs_reversion.py` | PAIRS | Spread reversion market-neutral su 5 coppie |
|
||||||
|
| **TSM01** | `tsmom_research.py` | TSMOM | Time-series momentum multi-orizzonte + risk-off |
|
||||||
|
| **SH01** | `SH01_shape_ml.py` | SHAPE | LogisticRegression walk-forward su 17 feature di forma, orizzonte 12 barre (diversificatore) |
|
||||||
|
|
||||||
Per eseguire il backtest di una strategia:
|
Le fade applicano tre protezioni live: un **filtro trend** (`trend_max`/`ema_long`,
|
||||||
|
salta i segnali col prezzo troppo esteso rispetto alla EMA200), un **loss-guard Hurst**
|
||||||
|
(`hurst_max=0.55`, salta i segnali in regime persistente/trending dove si concentrano gli stop-loss
|
||||||
|
— dimezza il drawdown del portafoglio, calcolato dalle sole close) e l'**EXIT-16 close-confirm SL**
|
||||||
|
(`sl_confirm_atr=0.5`, 2026-06-04: lo stop scatta solo se la barra *chiude* oltre `sl ∓ 0.5·ATR14` —
|
||||||
|
gli stop intrabar da wick erano falsi negativi, l'overshoot che buca lo stop è proprio il movimento
|
||||||
|
che la fade fada; a livello PORT06 porta l'OOS Sharpe da 8.82 a 10.06). Più un filtro `min_tp_frac`
|
||||||
|
che scarta i micro-scalp col take-profit entro il costo delle fee. Le tre protezioni sono
|
||||||
|
complementari: Hurst toglie il regime tossico, il trend-filter gli ingressi sovra-estesi, il
|
||||||
|
close-confirm i falsi stop. Portafogli pronti: `PORT01`
|
||||||
|
(honest), `PORT02` (fade), `PORT03` (master fade+honest), **`PORT06`** (master esteso, default live).
|
||||||
|
|
||||||
|
**Scartate** (in `scripts/waste/`): la famiglia squeeze (SQ01-04, ML01, MT01, PD01,
|
||||||
|
CM01, AD01 — artefatto di look-ahead), MR03 Keltner (debole/ridondante con MR01) e
|
||||||
|
ROT01 (dominata da ROT02).
|
||||||
|
|
||||||
|
### Comandi utili
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run python scripts/strategies/SQ02_squeeze_antifake_vol.py
|
# Backtest di una strategia
|
||||||
|
uv run python scripts/strategies/MR01_bollinger_fade.py
|
||||||
|
uv run python scripts/strategies/PR01_pairs_reversion.py
|
||||||
|
|
||||||
|
# Ricerca e validazione fee-aware out-of-sample
|
||||||
|
uv run python scripts/analysis/strategy_research.py # screening famiglie + deep-dive fade
|
||||||
|
uv run python scripts/analysis/strategy_research_v2.py # MR02 / MR03 / MR07
|
||||||
|
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
|
||||||
|
uv run python scripts/analysis/pairs_research.py # ricerca + verifica no-look-ahead dei pairs
|
||||||
|
|
||||||
|
# Gestione rischio, combinazione, report
|
||||||
|
uv run python scripts/analysis/risk_management.py # filtro trend + portafoglio fade
|
||||||
|
uv run python scripts/analysis/combine_portfolio.py # combinare fade + honest
|
||||||
|
uv run python scripts/analysis/combine_v2.py # master esteso con pairs + TSM01
|
||||||
|
uv run python scripts/analysis/report_families.py # report per anno di tutte le famiglie
|
||||||
|
|
||||||
|
# Validazione dei worker live (replay == backtest)
|
||||||
|
uv run python scripts/analysis/validate_worker_mr01.py # worker single-leg su MR01
|
||||||
|
uv run python scripts/analysis/validate_worker_pairs.py # worker a 2 gambe sui pairs
|
||||||
|
uv run python scripts/analysis/live_smoke_pairs.py # smoke test feed live reale dei pairs
|
||||||
```
|
```
|
||||||
|
|
||||||
## Paper Trading Live
|
## Paper Trading Live
|
||||||
|
|
||||||
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP, ognuna con €1000 USDC virtuali indipendenti.
|
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP,
|
||||||
|
ognuna con €1000 USDC virtuali indipendenti. Gestisce due tipi di worker:
|
||||||
|
|
||||||
|
- **Single-leg** (`strategy_worker.py`): per le strategie direzionali. Se un `Signal`
|
||||||
|
porta `tp`/`sl`/`max_bars` in `metadata` (come le fade), chiude su take-profit /
|
||||||
|
stop-loss / time-limit; altrimenti usa il fallback `hold_bars`/stop -2%.
|
||||||
|
- **Due gambe** (`pairs_worker.py`): per i pairs market-neutral. Apre long su una gamba
|
||||||
|
e short sull'altra, esce sul rientro dello z-score o per time-limit, conta le fee su
|
||||||
|
entrambe le gambe. Validato: il replay storico coincide *esattamente* col backtest.
|
||||||
|
|
||||||
### Avvio
|
### Avvio
|
||||||
|
|
||||||
@@ -115,14 +231,24 @@ defaults:
|
|||||||
position_size: 0.15
|
position_size: 0.15
|
||||||
leverage: 3
|
leverage: 3
|
||||||
|
|
||||||
strategies:
|
strategies: # strategie single-leg
|
||||||
- name: SQ02_antifake_vol
|
- name: MR01_bollinger_fade
|
||||||
asset: BTC
|
asset: BTC
|
||||||
tf: 15m
|
tf: 1h
|
||||||
enabled: true
|
enabled: true
|
||||||
|
params: { bb_window: 50, k: 2.5, sl_atr: 2.0, max_bars: 24, trend_max: 3.0, ema_long: 200 }
|
||||||
|
|
||||||
|
pairs: # strategie a 2 gambe (market-neutral)
|
||||||
|
- name: PR01_pairs_reversion
|
||||||
|
a: ETH
|
||||||
|
b: BTC
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params: { n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08 }
|
||||||
```
|
```
|
||||||
|
|
||||||
Per aggiungere una strategia: nuova riga in `strategies.yml`, poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
|
Per aggiungere una strategia: nuova riga in `strategies.yml` (sezione `strategies` o
|
||||||
|
`pairs`), poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
|
||||||
|
|
||||||
### Persistenza
|
### Persistenza
|
||||||
|
|
||||||
@@ -130,13 +256,101 @@ Ogni strategia ha la sua directory in `data/paper_trades/`:
|
|||||||
|
|
||||||
```
|
```
|
||||||
data/paper_trades/
|
data/paper_trades/
|
||||||
SQ02_antifake_vol__BTC__15m/
|
MR01_bollinger_fade__BTC__1h/
|
||||||
trades.jsonl # Storico trade append-only
|
trades.jsonl # Storico trade append-only
|
||||||
status.json # Stato corrente (resume al restart)
|
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`).
|
Notifiche Telegram per ogni trade (richiede `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` in `.env`).
|
||||||
|
|
||||||
|
## Paper Trading a Portafoglio
|
||||||
|
|
||||||
|
Accanto al multi-strategy runner originale — in cui ogni strategia gestisce autonomamente il proprio conto virtuale da €1.000 — il progetto dispone ora di un **paper trader a portafoglio** (`src/portfolio/`) che tratta l'insieme delle strategie come un unico organismo con un capitale condiviso.
|
||||||
|
|
||||||
|
### Come funziona
|
||||||
|
|
||||||
|
La definizione di un portafoglio (`SleeveSpec` + schema di peso) ha due facce sulla stessa sorgente dati:
|
||||||
|
|
||||||
|
- **Backtest** (`.backtest()`): ricostruisce le equity-curve di ogni sleeve tramite il builder unificato in `sleeves.py`, le pondera secondo lo schema scelto e calcola le metriche aggregate (CAGR, Sharpe, max DD). La parità con i report prodotti da `report_families.py` è garantita dalla fonte unica.
|
||||||
|
- **Live** (`PortfolioRunner`): ogni ora il runner scarica le candele aggiornate via Cerbero v2, calcola i pesi correnti, avvia i worker appropriati per ogni sleeve attiva e registra il PnL aggregato nel ledger (`data/portfolios/{code}/`). Il ledger persiste tra i riavvii.
|
||||||
|
|
||||||
|
### Schemi di ponderazione
|
||||||
|
|
||||||
|
Il modulo `weighting.py` mette a disposizione cinque schemi: `equal` (default), `cap` (tetto per famiglia — p.es. `pairs: 0.33` per limitare la concentrazione), `inverse_vol` (pesi inversamente proporzionali alla volatilità storica), `cluster_rp` (equal tra cluster naturali poi inverse-vol all'interno del cluster) e `manual` (pesi liberi). Lo schema si specifica in `portfolios.yml` insieme al codice portafoglio e alla leva.
|
||||||
|
|
||||||
|
### Portafoglio di default: PORT06
|
||||||
|
|
||||||
|
La configurazione raccomandata è **PORT06** (`scripts/portfolios/PORT06_master_shape.py`): portafoglio master esteso che include tutte e sei le famiglie (FADE, HONEST, PAIRS, TSMOM, SHAPE), con schema `cap` che limita i pairs al 33% del capitale per moderare la loro concentrazione di rischio. Backtest canonico (dati al 2026-05-28): Sharpe 6.47 (FULL) / 8.82 (OOS), drawdown massimo 4.10% (FULL) / 1.30% (OOS), leva 2×; **con la config live attuale (EXIT-16 close-confirm): Sharpe 7.84 / 10.06, DD 2.60% / 1.15%**.
|
||||||
|
|
||||||
|
### Scope live
|
||||||
|
|
||||||
|
Il runner esegue **tutti e 17 gli sleeve** di PORT06: **fade** (MR01, MR02, MR07 × BTC/ETH),
|
||||||
|
**honest** (DIP01, TR01-basket 4h, ROT02-rotation 1d), **pairs** (PR01, cinque coppie),
|
||||||
|
**TSMOM** (TSM01 1d) e **shape** (SH01 × BTC/ETH). Worker dedicati: `StrategyWorker` (single-leg, fade/
|
||||||
|
dip/**shape**), `PairsWorker` (2 gambe), `BasketTrendWorker`, `RotationWorker`, `TsmomWorker`. Il runner
|
||||||
|
fetcha 1h da Cerbero v2 e resampla a 4h/1d; il pool di capitale, il ribilancio giornaliero e il ledger
|
||||||
|
sono validati == backtest.
|
||||||
|
|
||||||
|
> **SH01 (2026-06-01):** gira come `StrategyWorker` normale (il walk-forward è interno a
|
||||||
|
> `generate_signals`). Il vecchio `MLWorkerWrapper` usava il `SignalEngine` **squeeze scartato** —
|
||||||
|
> rimosso. **Loss-guard Hurst (2026-06-02):** le fade saltano i segnali in regime persistente
|
||||||
|
> (rolling-Hurst ≥ 0.55), dove si concentrano gli stop-loss — dimezza il drawdown del portafoglio
|
||||||
|
> (FULL 4.1%→2.4%; stop-loss fade −67% in numero, perdite totali −68%). Calcolato dalle sole close,
|
||||||
|
> attivo live (`hurst_max` nei params). Il report orario su Telegram **monitora lo stop-rate fade
|
||||||
|
> prima/dopo l'attivazione** e dà il verdetto automatico quando il campione è sufficiente.
|
||||||
|
|
||||||
|
### Esecuzione reale (shadow, Deribit testnet)
|
||||||
|
|
||||||
|
Sette sleeve single-leg — le **6 fade** (MR01/MR02/MR07 × BTC/ETH) e **DIP01** (dal 2026-06-04) —
|
||||||
|
eseguono ordini **reali su Deribit testnet** accanto al fill simulato (*shadow*: il sim resta la
|
||||||
|
verità che guida le decisioni; il reale misura la fattibilità). Punti chiave:
|
||||||
|
|
||||||
|
- **Strumenti lineari USDC** (`BTC_USDC`/`ETH_USDC-PERPETUAL`): payoff lineare = matematica del
|
||||||
|
backtest; fee e PnL in USDC. Quantizzazione `Decimal` di amount (step) e prezzi (tick).
|
||||||
|
- **Take-profit reale = limit reduce-only AL livello** (v1.0.7): piazzato all'apertura, copre la
|
||||||
|
sola quota del worker (gli strumenti sono condivisi fra worker e nettati per conto); alla
|
||||||
|
chiusura il worker cancella il resting, riconcilia i fill dal trade history per `order_id` e
|
||||||
|
chiude a market solo il residuo. Fix della divergenza misurata: il market-on-poll usciva
|
||||||
|
+235 bps oltre il livello TP. Fill da resting = fee maker (~0%).
|
||||||
|
- **Stop-loss close-confirm** (v1.1.0): uscita al close che sfonda il livello → market
|
||||||
|
reduce-only al poll (nessun ordine stop sul book, per scelta: i trigger Deribit generano un
|
||||||
|
nuovo order_id allo scatto, non verificabile, e i wick non devono stoppare).
|
||||||
|
- **Verifica sul trade** (order_id in `get_trade_history`), fee reali dai `trades[]`, ledger
|
||||||
|
reale parallelo persistito (`real_capital`), eventi `REAL_OPEN`/`REAL_TP_RESTING`/`REAL_CLOSE`
|
||||||
|
nel log + alert Telegram (`REAL_EXEC_LIVE`, `REAL_OPEN_FAIL`).
|
||||||
|
- Config in `portfolios.yml` → `overrides.execution {enabled, sleeves, instruments}`.
|
||||||
|
**Pairs/rotation/TSMOM/shape restano simulati**: i pairs richiedono un executor a 2 gambe
|
||||||
|
(leg-risk), i multi-asset un rebalance-to-target; roadmap nel diario.
|
||||||
|
|
||||||
|
### Versione & deploy
|
||||||
|
|
||||||
|
Ogni deploy ha una **versione** (file `VERSION`, semver) che compare nei messaggi Telegram (notifiche
|
||||||
|
trade + report orario), così correli ogni messaggio al codice che l'ha generato. Il sorgente è **cotto
|
||||||
|
nell'immagine** → per aggiornare il live serve un **rebuild**, non un semplice restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh # bump patch (1.0.0 → 1.0.1) + commit + rebuild + ricrea container
|
||||||
|
./scripts/deploy.sh minor # 1.0.x → 1.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Il volume `data/` persiste tra i deploy → i worker fanno RESUME dello stato (capitale, posizioni aperte).
|
||||||
|
|
||||||
|
### Avvio del paper trader a portafoglio
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backtest del portafoglio di default (PORT06)
|
||||||
|
uv run python scripts/portfolios/PORT06_master_shape.py
|
||||||
|
|
||||||
|
# Paper trading live a portafoglio
|
||||||
|
uv run python -m src.portfolio.runner
|
||||||
|
|
||||||
|
# Report orario su Telegram (stato + stop-rate fade prima/dopo loss-guard) — via cron
|
||||||
|
uv run python scripts/portfolios/hourly_report.py
|
||||||
|
|
||||||
|
# Smoke test del data layer Cerbero v2
|
||||||
|
uv run python scripts/analysis/smoke_portfolio.py
|
||||||
|
```
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -147,8 +361,8 @@ uv sync
|
|||||||
# Scarica dati storici (~70 MB)
|
# Scarica dati storici (~70 MB)
|
||||||
uv run python -m src.data.downloader
|
uv run python -m src.data.downloader
|
||||||
|
|
||||||
# Backtest strategia migliore
|
# Backtest strategia attiva
|
||||||
uv run python scripts/strategies/SQ02_squeeze_antifake_vol.py
|
uv run python scripts/strategies/MR01_bollinger_fade.py
|
||||||
|
|
||||||
# Paper trading live
|
# Paper trading live
|
||||||
uv run python -m src.live.multi_runner
|
uv run python -m src.live.multi_runner
|
||||||
@@ -163,12 +377,41 @@ uv run python -m src.live.multi_runner
|
|||||||
|
|
||||||
## Dati
|
## Dati
|
||||||
|
|
||||||
| Asset | Timeframe | Candele | Copertura |
|
| Asset | Timeframe | Copertura |
|
||||||
|-------|-----------|---------|-----------|
|
|-------|-----------|-----------|
|
||||||
| BTC | 5m / 15m / 1h | 883K / 294K / 74K | 2018-01 → oggi |
|
| BTC, ETH | 5m / 15m / 1h | 2018-01 → oggi |
|
||||||
| ETH | 5m / 15m / 1h | 882K / 294K / 74K | 2018-01 → oggi |
|
| SOL, LTC, ADA, XRP, BNB, DOGE | 15m / 1h | 2019-2022 → oggi (variabile per asset) |
|
||||||
|
|
||||||
Fonte primaria: Deribit perpetual via Cerbero MCP. Fallback: Binance spot via ccxt. Formato: Apache Parquet.
|
Fonte primaria: perpetual Deribit via Cerbero MCP. Fallback: Binance spot via ccxt.
|
||||||
|
Formato: Apache Parquet (in `data/raw/`, gitignored).
|
||||||
|
|
||||||
|
> **Nota sul naming Deribit (per il feed live).** I major sono perpetui *inverse*
|
||||||
|
> (`BTC-PERPETUAL`, `ETH-PERPETUAL`); gli altcoin sono perpetui *lineari USDC*
|
||||||
|
> (`SOL_USDC-PERPETUAL`, `LTC_USDC-PERPETUAL`, …) con storia dal 2022. Attenzione:
|
||||||
|
> `LTC-PERPETUAL`/`ADA-PERPETUAL` non esistono e `SOL-PERPETUAL` restituisce dati
|
||||||
|
> errati — per gli altcoin usare sempre la forma `_USDC-PERPETUAL`.
|
||||||
|
|
||||||
|
### Discovery & validazione strumenti
|
||||||
|
|
||||||
|
`src/data/instruments.py` scopre e **valida** gli strumenti disponibili sugli
|
||||||
|
exchange implementati — **Deribit** e **Hyperliquid** (esclusi Alpaca/stocks e
|
||||||
|
**Bybit**, feed testnet inaffidabile). Ogni perpetuo viene testato sui dati
|
||||||
|
storici realmente raccoglibili: esistenza, congruenza OHLC, contratto non-morto,
|
||||||
|
liquidità e **congruenza prezzo cross-exchange** (mediana per base-coin, tolleranza
|
||||||
|
5%) — così feed farlocchi e contratti sbagliati (es. `SOL-PERPETUAL`=9.6) vengono
|
||||||
|
scartati. Il risultato è `data/instruments_registry.json` (strumenti validi +
|
||||||
|
timeframe + data d'inizio).
|
||||||
|
|
||||||
|
**Solo gli strumenti validati possono essere scaricati**: il downloader ha un gate
|
||||||
|
(`_download_cerbero_range`) che rifiuta quelli non nel registry. Rigenera con:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m src.data.instruments
|
||||||
|
```
|
||||||
|
|
||||||
|
Simboli Deribit: BTC/ETH = `<COIN>-PERPETUAL` (inverse); altcoin =
|
||||||
|
`<COIN>_USDC-PERPETUAL` (lineari USDC). Registry attuale (testnet): Deribit 18/106
|
||||||
|
validi (major liquidi, BTC dal 2018), Hyperliquid 66/74.
|
||||||
|
|
||||||
## Riferimenti
|
## Riferimenti
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"capital": 1000,
|
|
||||||
"in_position": false,
|
|
||||||
"direction": 0,
|
|
||||||
"entry_price": 0,
|
|
||||||
"entry_time": "",
|
|
||||||
"bars_held": 0,
|
|
||||||
"total_trades": 0,
|
|
||||||
"total_wins": 0,
|
|
||||||
"started_at": "2026-05-27T21:16:02.087963+00:00",
|
|
||||||
"last_bar_ts": 0,
|
|
||||||
"last_update": "2026-05-27T21:16:04.705726+00:00"
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"ts": "2026-05-27T21:16:02.087975+00:00", "worker": "ML01_squeeze_gbm__ETH__15m", "event": "INIT", "capital": 1000, "strategy": "ML01_squeeze_gbm", "asset": "ETH", "tf": "15m"}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"capital": 1000,
|
|
||||||
"in_position": false,
|
|
||||||
"direction": 0,
|
|
||||||
"entry_price": 0,
|
|
||||||
"entry_time": "",
|
|
||||||
"bars_held": 0,
|
|
||||||
"total_trades": 0,
|
|
||||||
"total_wins": 0,
|
|
||||||
"started_at": "2026-05-27T21:16:02.087646+00:00",
|
|
||||||
"last_bar_ts": 0,
|
|
||||||
"last_update": "2026-05-27T21:16:04.584685+00:00"
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"ts": "2026-05-27T21:16:02.087660+00:00", "worker": "SQ01_squeeze_base__BTC__15m", "event": "INIT", "capital": 1000, "strategy": "SQ01_squeeze_base", "asset": "BTC", "tf": "15m"}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"capital": 1000,
|
|
||||||
"in_position": false,
|
|
||||||
"direction": 0,
|
|
||||||
"entry_price": 0,
|
|
||||||
"entry_time": "",
|
|
||||||
"bars_held": 0,
|
|
||||||
"total_trades": 0,
|
|
||||||
"total_wins": 0,
|
|
||||||
"started_at": "2026-05-27T21:16:02.087214+00:00",
|
|
||||||
"last_bar_ts": 0,
|
|
||||||
"last_update": "2026-05-27T21:16:04.339917+00:00"
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"ts": "2026-05-27T21:16:02.087241+00:00", "worker": "SQ02_antifake_vol__BTC__15m", "event": "INIT", "capital": 1000, "strategy": "SQ02_antifake_vol", "asset": "BTC", "tf": "15m"}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"capital": 1000,
|
|
||||||
"in_position": false,
|
|
||||||
"direction": 0,
|
|
||||||
"entry_price": 0,
|
|
||||||
"entry_time": "",
|
|
||||||
"bars_held": 0,
|
|
||||||
"total_trades": 0,
|
|
||||||
"total_wins": 0,
|
|
||||||
"started_at": "2026-05-27T21:16:02.087438+00:00",
|
|
||||||
"last_bar_ts": 0,
|
|
||||||
"last_update": "2026-05-27T21:16:04.463602+00:00"
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"ts": "2026-05-27T21:16:02.087448+00:00", "worker": "SQ02_antifake_vol__ETH__15m", "event": "INIT", "capital": 1000, "strategy": "SQ02_antifake_vol", "asset": "ETH", "tf": "15m"}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"in_position": false,
|
|
||||||
"direction": null,
|
|
||||||
"entry_price": 0,
|
|
||||||
"entry_time": null,
|
|
||||||
"bars_held": 0,
|
|
||||||
"last_update": "2026-05-27T07:40:09.196718+00:00"
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
{"timestamp": "2026-05-27T07:35:10.715321+00:00", "event": "TRAINING", "lookback_days": 365}
|
|
||||||
{"timestamp": "2026-05-27T07:35:11.967644+00:00", "event": "TRAINING_DONE", "samples": 90, "up_ratio": 48.888888888888886, "train_accuracy": 100.0}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{"timestamp": "2026-05-27T07:36:03.120802+00:00", "event": "STARTUP", "equity": 101459.276155, "testnet": true}
|
|
||||||
{"timestamp": "2026-05-27T07:36:03.121518+00:00", "event": "TRAINING", "lookback_days": 365}
|
|
||||||
{"timestamp": "2026-05-27T07:36:04.249123+00:00", "event": "TRAINING_DONE", "samples": 90, "up_ratio": 48.888888888888886, "train_accuracy": 100.0}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{"timestamp": "2026-05-27T08:04:41.544464+00:00", "event": "TRAINING", "lookback_days": 365, "instrument": "ETH-PERPETUAL"}
|
|
||||||
{"timestamp": "2026-05-27T08:04:42.704464+00:00", "event": "TRAINING_DONE", "samples": 90, "up_ratio": 48.888888888888886, "train_accuracy": 100.0}
|
|
||||||
{"timestamp": "2026-05-27T08:04:42.918237+00:00", "event": "OPENING", "side": "buy", "amount": 0.216, "price": 2083.75, "virtual_capital": 1000.0, "notional": 450.0, "signal": {"direction": "buy", "probability": 0.75, "squeeze_duration": 10}}
|
|
||||||
{"timestamp": "2026-05-27T08:04:43.143718+00:00", "event": "OPENED", "order_result": {"order": {"label": "pythagoras-squeeze", "price": 2292.25, "order_id": "USDC-209283595178", "user_id": 81070, "amount": 0.216, "instrument_name": "ETH_USDC-PERPETUAL", "direction": "buy", "time_in_force": "good_til_cancelled", "web": false, "api": true, "creation_timestamp": 1779869083116, "mmp": false, "replaced": false, "post_only": false, "reduce_only": false, "filled_amount": 0.216, "last_update_timestamp": 1779869083116, "average_price": 2083.9, "contracts": 216.0, "order_state": "filled", "order_type": "market", "is_liquidation": false, "risk_reducing": false}, "trades": [{"label": "pythagoras-squeeze", "timestamp": 1779869083116, "state": "filled", "price": 2083.9, "order_id": "USDC-209283595178", "user_id": 81070, "amount": 0.216, "instrument_name": "ETH_USDC-PERPETUAL", "direction": "buy", "index_price": 2083.37, "trade_seq": 6674514, "api": true, "mark_price": 2083.86, "matching_id": null, "tick_direction": 0, "profit_loss": 0.0, "mmp": false, "post_only": false, "reduce_only": false, "self_trade": false, "contracts": 216.0, "trade_id": "USDC-32731729", "fee_currency": "USDC", "order_type": "market", "fee": 0.2250612, "liquidity": "T", "risk_reducing": false}], "data_timestamp": "2026-05-27T08:04:43.126155+00:00"}}
|
|
||||||
{"timestamp": "2026-05-27T08:04:46.361078+00:00", "event": "CLOSING", "reason": "test", "entry_price": 2083.75, "exit_price": 2083.95, "size": 0.216, "trade_pnl": 0.04, "fee": 0.9, "net_pnl": -0.86, "pnl_pct": -0.086, "bars_held": 0, "capital_before": 1000.0}
|
|
||||||
{"timestamp": "2026-05-27T08:04:46.574322+00:00", "event": "CLOSED", "result": {"order_id": "USDC-209283608601", "state": "filled", "data_timestamp": "2026-05-27T08:04:46.555823+00:00"}, "net_pnl": -0.86, "pnl_pct": -0.086, "virtual_capital": 999.14}
|
|
||||||
+7
-4
@@ -1,17 +1,20 @@
|
|||||||
services:
|
services:
|
||||||
paper-trader:
|
portfolio:
|
||||||
build: .
|
build: .
|
||||||
container_name: pythagoras-multi
|
container_name: pythagoras-portfolio
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command: ["uv", "run", "python", "-m", "src.portfolio.runner"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./strategies.yml:/app/strategies.yml:ro
|
- ./portfolios.yml:/app/portfolios.yml:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
healthcheck:
|
healthcheck:
|
||||||
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)"]
|
test: ["CMD", "python", "-c", "import os; assert any(f.endswith('status.json') for r,d,fs in os.walk('/app/data/portfolios') for f in fs)"]
|
||||||
interval: 120s
|
interval: 120s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
labels:
|
||||||
|
- com.centurylinklabs.watchtower.enable=false
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# TODO — prossimi passi
|
||||||
|
|
||||||
|
> Raccolta dei follow-up aperti. Aggiornato 2026-06-07 (fine sessione: roadmap
|
||||||
|
> improvement-sweep 10/10 completata, v1.1.9 live).
|
||||||
|
|
||||||
|
## Esecuzione reale — i pezzi grossi mancanti
|
||||||
|
|
||||||
|
- [ ] **Executor a 2 gambe per i PAIRS** (`PairsExecutionClient`). È la fetta reale-non-eseguita
|
||||||
|
più grande (29% del portafoglio). Serve: piazzamento il più possibile atomico delle 2 gambe,
|
||||||
|
fallback di **unwind** se una sola filla (leg-risk), dimensionamento congiunto col doppio
|
||||||
|
arrotondamento ai lotti minimi (BTC/ETH/LTC/ADA/SOL hanno step diversi), riconciliazione e
|
||||||
|
chiusura a 2 gambe. Progetto a sé, il più impegnativo rimasto.
|
||||||
|
- [ ] **SH01 in reale**: oggi escluso perché non ha TP/SL (esce a orizzonte H=12) → manca il livello
|
||||||
|
a cui agganciare il limit resting + disaster-bracket. Due strade: (a) nuovo percorso
|
||||||
|
"market reduce-only a scadenza orizzonte" nell'executor; (b) valutare TP/SL su SH01 — MA la
|
||||||
|
ricerca dedicata (`sh01_exit_lab`, 11 famiglie di stop, 0 sopravvissute) dice che ogni stop
|
||||||
|
rompe l'edge → andrebbe ri-gateato. Prima conviene osservare il PnL live post-fix di oggi.
|
||||||
|
|
||||||
|
## Capitale (decisione utente, non codice)
|
||||||
|
|
||||||
|
- [ ] **Valutare aumento capitale a €5.000-10.000** per togliere il rumore di arrotondamento.
|
||||||
|
A €2.000 il rumore BTC per trade è ~2.4% (morde); soglie: ~€4.800 (<1%), ~€9.500 (<0.5%,
|
||||||
|
"punto dolce"), ~€19.000 (trascurabile). Le commissioni invece sono % e neutre al capitale.
|
||||||
|
Riguarda solo i 7 single-leg che eseguono reale; pairs/SH01 sono simulati.
|
||||||
|
|
||||||
|
## Follow-up dal code-review (bassa priorità, non urgenti)
|
||||||
|
|
||||||
|
- [ ] **forming-bar su ROT02/TSM01**: i worker daily valutano la candela 1d IN FORMAZIONE al primo
|
||||||
|
poll dopo mezzanotte UTC (stessa classe del fix TR01/Pairs, pre-esistente). Ora è banale:
|
||||||
|
usare `src.live.bars.last_settled_idx` in `_panel` / nei due tick. Impatto basso (1d) ma
|
||||||
|
stessa famiglia di bug.
|
||||||
|
- [ ] **ancora bfill di `_daily_equity`**: la serie a punti-trade reindexata su IDX àncora il primo
|
||||||
|
valore della finestra al PRIMO trade in-finestra, non al capitale portato avanti. Tocca le
|
||||||
|
metriche canoniche di TUTTI gli sleeve a punti-trade (honest/pairs/tsm). Valutare se
|
||||||
|
correggere OVUNQUE in un colpo (cambierebbe i numeri canonici di riferimento). Scoperto nel
|
||||||
|
gate DIP01 (parità inizialmente fallita per questo).
|
||||||
|
- [ ] **convenzione TR01 worker vs reference**: il replay `validate_honest_workers` dà TR01 worker
|
||||||
|
-44% vs reference +42% (identico pre/post fix di oggi). È la divergenza capitale-unico
|
||||||
|
(`mean(rets)` solo sugli asset in posizione) vs media-equity 1/N del reference. Indagare:
|
||||||
|
sospetto che `mean(rets)` sovrappesi gli asset in posizione quando il paniere è parzialmente
|
||||||
|
investito.
|
||||||
|
- [ ] **engine duplicato nei 3 gate** `*_port06_impact.py` (exit16/trendmax/dip01): `build_trades_variant`
|
||||||
|
/ `port_metrics` copiati quasi verbatim. Sono regression-lock per decisioni live → la
|
||||||
|
copy-drift corrompe i verdetti. Fattorizzare in un modulo condiviso.
|
||||||
|
- [ ] **epoche hardcoded in `hourly_report.lossguard_section`** (LOSSGUARD_SINCE, TRENDSWAP_SINCE):
|
||||||
|
ogni nuova epoca-filtro richiede di editare la funzione. Derivarle da deploy history/config.
|
||||||
|
|
||||||
|
## Monitoraggio (osservare, non agire subito)
|
||||||
|
|
||||||
|
- [ ] **PnL live post-swap trend** (epoca TREND in `hourly_report`): verdetto stop-rate fade a n>=30.
|
||||||
|
- [ ] **SH01 post-bootstrap**: il trade-rate live deve scendere da ~25% a ~10% delle barre
|
||||||
|
(selettività della soglia ripristinata). Controllare nel report orario.
|
||||||
|
- [ ] **MR02_ETH**: ultima posizione aperta SENZA disaster-SL (aperta pre-v1.1.4). Si chiuderà
|
||||||
|
normalmente; le prossime aperture avranno il bracket.
|
||||||
|
- [ ] **parquet fresco per SH01 bootstrap**: oggi al 2026-05-28, il feed copre 365g → margine ~11
|
||||||
|
mesi. Rilanciare `download_all()` periodicamente (WARN automatico se il gap supera il lookback).
|
||||||
@@ -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,84 @@
|
|||||||
|
# Diario — 2026-05-29 — Pattern del segnale per FORMA (analog/shape forecasting)
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
|
||||||
|
Verificare se la **forma** del segnale (la morfologia recente del prezzo) permette di
|
||||||
|
prevedere l'andamento successivo, e ricavarne edge verso il target €1000 → €50/giorno.
|
||||||
|
Esplorazione onesta (no look-ahead, netto fee, OOS) con **agenti paralleli**, ognuno su
|
||||||
|
una famiglia di forma indipendente, tutti sullo stesso harness shape (`scripts/analysis/
|
||||||
|
shape_lab.py`, che riusa l'engine netto-fee+OOS di `explore_lab.py`). Branch `shape_patterns`.
|
||||||
|
|
||||||
|
## Harness
|
||||||
|
|
||||||
|
`shape_lab.py` — analog forecasting causale: a ogni barra `i` si guarda la forma recente
|
||||||
|
`W` (closes z-normalizzati fino a `close[i]`), si cercano nel passato le `K` finestre più
|
||||||
|
simili **il cui esito a `H` barre era già noto prima di `i`** (KDTree ricostruito ogni
|
||||||
|
`rebuild` barre → niente O(N²)), si prevede la direzione = segno del rendimento medio degli
|
||||||
|
analoghi. **No-look-ahead verificato** (perturbare il futuro non cambia la forma a `i`,
|
||||||
|
max diff 0.0). Baseline forma grezza: marginale e **muore sulle fee** (W24H12K50: FULL
|
||||||
|
+112% / OOS +48% ma a 0.20% RT → −72%; troppi trade, exp 74%, win 49.5%).
|
||||||
|
|
||||||
|
## Famiglie esplorate (5) ed esito onesto
|
||||||
|
|
||||||
|
| Famiglia | Esito | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| Analog kNN (forma grezza, selettività) | ❌ RUMORE | Solo BTC-overfit, non robusto ≥2 asset |
|
||||||
|
| Encoding candele (UP/DOWN/DOJI + body/shadow) | ❌ RUMORE | Hit-rate condizionale ~50%, segno incoerente fra asset |
|
||||||
|
| DTW + template geometrici (M/W, testa-spalle, V, U) | ❌ RUMORE | DTW *peggiora* l'euclidea; template overfit (FULL ok, OOS crolla) |
|
||||||
|
| PIP / pivot / zig-zag (geometria svolte) | ❌ RUMORE | 0/48 config robuste; le rotture S/R rientrano (riconferma MR) |
|
||||||
|
| **Feature-vector + ML walk-forward** | ✅ **EDGE REALE** | LogisticRegression sulla forma, fee-robusto |
|
||||||
|
|
||||||
|
4 famiglie su 5 sono rumore: riconfermano che la forma grezza non contiene edge
|
||||||
|
direzionale eseguibile e che l'unico edge "classico" resta la mean-reversion (fade/pairs).
|
||||||
|
|
||||||
|
## L'edge: SH01 — Shape-ML
|
||||||
|
|
||||||
|
Una **LogisticRegression** legge 17 feature di forma (body/shadow ratio, rendimenti,
|
||||||
|
pendenza/curvatura del path, posizione di max/min, RSI, estensione) e predice il segno del
|
||||||
|
rendimento a `H` barre. **Walk-forward rigoroso**: scaler+modello fittati solo sul passato
|
||||||
|
con esito noto, poi predicono il blocco corrente; si entra a `close[i]` se la probabilità
|
||||||
|
≥ soglia. Causalità verificata con check espliciti (feature e predizioni invarianti al
|
||||||
|
futuro). Il GradientBoosting dà edge equivalente ma è ~60× più lento → si usa il logit.
|
||||||
|
|
||||||
|
A differenza della famiglia squeeze (che moriva anche a fee zero), **questo edge
|
||||||
|
sopravvive a fee 0.20% RT**. Win-rate ~50% → l'edge è nell'**asimmetria** (quando indovina
|
||||||
|
la direzione i moti sono più grandi), non nella frequenza.
|
||||||
|
|
||||||
|
### Validazione dura (config W24 H12 th0.58, netto fee, leva 3x, pos 0.15, OOS 30%)
|
||||||
|
|
||||||
|
- **Multi-asset expanding**: robusti **BTC** (FULL +219% / OOS +42% / Sharpe 2.72 / DD 23%
|
||||||
|
/ 8-9 anni+ / accOOS 56%), **ETH** (+80% / +144% / Sharpe 1.21, più volatile), **ADA**
|
||||||
|
(+707% / +57% / Sharpe 3.22). Scartati LTC/SOL/XRP (perdono netti).
|
||||||
|
- **Walk-forward rolling (train fisso 2 anni)**: regge **solo BTC** (+166% / +96% / Sharpe
|
||||||
|
2.05). L'edge si appoggia in parte alla memoria lunga → BTC è il più solido.
|
||||||
|
- **Stress leva 2x + slippage doppio (0.20% RT)**: BTC OK (+40% / +17% / Sharpe 1.24),
|
||||||
|
ETH marginale (+7% / +73% / Sharpe 0.37).
|
||||||
|
- **Griglia (W,H,thresh) su BTC**: **5/27 celle robuste**, su una **cresta** stretta (W24,
|
||||||
|
H8-12), non altopiano largo → rischio overfit moderato. Per prudenza si sceglie la config
|
||||||
|
robusta sul maggior numero di test (W24 H12 th0.58), non il PnL massimo (W24 H8 rende di
|
||||||
|
più ma accOOS ~49% = più drift che segnale).
|
||||||
|
|
||||||
|
### Il valore vero: diversificatore di portafoglio
|
||||||
|
|
||||||
|
Correlazione daily col MASTER **+0.08** (quasi scorrelato). Aggiungere lo sleeve shape
|
||||||
|
(BTC+ETH) al MASTER migliora l'OOS: **Sharpe 4.33 → 5.10, DD 4.7% → 4.2%** (FULL: Sharpe
|
||||||
|
4.23 → 4.37, DD 5.2% → 4.3%). Non è un motore standalone (per-asset troppo stretto fuori
|
||||||
|
da BTC), ma un **free-lunch** da aggiungere al paniere.
|
||||||
|
|
||||||
|
## Artefatti
|
||||||
|
|
||||||
|
- `scripts/analysis/shape_lab.py` — harness analog/forma causale.
|
||||||
|
- `scripts/analysis/shape_{analog,candle,template,pivot,ml}_research.py` — le 5 ricerche.
|
||||||
|
- `scripts/analysis/shape_ml_validate.py` — validazione dura del candidato ML.
|
||||||
|
- `scripts/strategies/SH01_shape_ml.py` — la strategia (Strategy + run() riproducibile).
|
||||||
|
- Aggiunta a `MODULE_MAP` (caricabile per backtest).
|
||||||
|
|
||||||
|
## Conclusione e prossimi passi
|
||||||
|
|
||||||
|
La forma del segnale **non** predice in modo grezzo (4/5 famiglie rumore), ma un modello
|
||||||
|
lineare sulle feature di forma in walk-forward onesto **sì**, soprattutto su BTC, e vale
|
||||||
|
come diversificatore quasi-scorrelato del MASTER. Da fare prima del live:
|
||||||
|
1. **Worker con retraining periodico** (lo StrategyWorker attuale è a regola fissa; SH01
|
||||||
|
riallena il modello → serve un loop tipo legacy signal_engine).
|
||||||
|
2. Validazione live-path (replay worker == backtest) come fatto per i pairs.
|
||||||
|
3. Decidere il peso nel MASTER-esteso (cap, leva) col paper trader.
|
||||||
@@ -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,68 @@
|
|||||||
|
# 2026-05-31 — Studio sugli EXIT delle fade: scalping, TP dinamico, TP-ATR
|
||||||
|
|
||||||
|
> Innescato da una domanda operativa ("un TP è stato raggiunto, non si poteva
|
||||||
|
> scalpare / fare un TP dinamico?"). Studio fee-aware su MR02 (Donchian fade,
|
||||||
|
> segnali invariati `n=20 sl_atr=2.0 max_bars=24`, fee 0.10% RT, leva 3x). Tre
|
||||||
|
> alternative di uscita misurate contro il baseline attuale (**TP = centro del
|
||||||
|
> canale**). Verdetto: **il design attuale è già ottimale; nessuna alternativa lo batte.**
|
||||||
|
|
||||||
|
## 1. "Scalping" = timeframe più veloce (15m vs 1h)
|
||||||
|
A fee 0.10% il 15m rende di più in lordo (~4× più trade), MA è molto più **fragile**:
|
||||||
|
|
||||||
|
| | trade | PnL @0% | @0.10% | @0.20% | DD @0.10% |
|
||||||
|
|---|--:|--:|--:|--:|--:|
|
||||||
|
| BTC 1h | 2041 | +22.768 | +16.645 | +10.522 | 29% |
|
||||||
|
| BTC 15m | 8251 | +65.286 | +40.533 | +15.780 | 29% |
|
||||||
|
| ETH 15m | 9388 | +120.103 | +91.939 | +63.775 | **62%** |
|
||||||
|
|
||||||
|
Da 0% a 0.20% il 15m perde **~76%** del profitto (vs 54% del 1h) e il DD esplode
|
||||||
|
(ETH 15m → 93% a 0.20%). 4× più trade = 4× più fee + slippage (non modellato, ma
|
||||||
|
peggiore su book sottili). **L'1h è scelto per il margine di sicurezza, non per il PnL
|
||||||
|
lordo.** Lo scalping vero (<0.3% target) è in pieno territorio "morte da fee".
|
||||||
|
|
||||||
|
## 2. TP dinamico / trailing ("lascia correre il vincitore")
|
||||||
|
Stessi segnali, exit per trailing a k·ATR dal massimo favorevole invece del TP fisso:
|
||||||
|
|
||||||
|
| policy | BTC win% | ETH win% | equity |
|
||||||
|
|--------|---------:|---------:|--------|
|
||||||
|
| FIXED (centro, attuale) | **48%** | **49%** | 🟢 di gran lunga il migliore |
|
||||||
|
| TRAIL (lascia correre) | 36% | 36% | 🔴 azzerato |
|
||||||
|
| MID+TRAIL | 47% | 47% | 🔴 peggio |
|
||||||
|
|
||||||
|
Il win-rate crolla 48%→36%: i trade che avrebbero incassato il TP fanno andata-e-ritorno
|
||||||
|
e stoppano fuori. **Concettuale:** l'edge della fade è la reversione *fino* alla media;
|
||||||
|
una volta toccata, l'edge è esaurito. Lasciar correre *oltre* = scommettere sulla
|
||||||
|
continuazione, che sui perp crypto NON ha edge (rientra). È la stessa logica per cui
|
||||||
|
SMA/ORB/WR (continuazione) hanno fallito: **let-it-run = trend = il lato perdente.**
|
||||||
|
|
||||||
|
## 3. TP scalato all'ATR (TP = entry + dir·m·ATR, SL fisso 2 ATR → R:R = m/2)
|
||||||
|
| Config | win% | avg %/trade | Sharpe | sumRet% |
|
||||||
|
|--------|-----:|-----------:|-------:|--------:|
|
||||||
|
| **BTC MID (attuale)** | 48% | **0.816** | **3.8** | **1664** |
|
||||||
|
| BTC ATR m=0.5 (RR0.25) | **77%** | −0.081 | −1.0 | −217 |
|
||||||
|
| BTC ATR m=1.0 | 67% | 0.192 | 1.6 | 465 |
|
||||||
|
| BTC ATR m=2.0 | 53% | 0.563 | 3.0 | 1199 |
|
||||||
|
| BTC ATR m=3.0 | 46% | 0.679 | 3.0 | 1331 |
|
||||||
|
| **ETH MID (attuale)** | 49% | **1.738** | **7.5** | **4169** |
|
||||||
|
| ETH ATR m=0.5 | 77% | 0.041 | 0.5 | 134 |
|
||||||
|
| ETH ATR m=3.0 | 46% | 1.082 | 4.7 | 2515 |
|
||||||
|
|
||||||
|
OOS (ultimo 30%) identico: **MID** batte ogni `m` (BTC MID avg 1.14/Sh 3.2; ETH MID avg
|
||||||
|
4.43/Sh 10.9). Due lezioni:
|
||||||
|
- **TP stretto (m=0.5) = trappola dello scalping quantificata:** win-rate **77%** ma edge
|
||||||
|
**zero/negativo** (BTC −0.08%/trade). I rari stop a 2 ATR spazzano via le micro-vincite,
|
||||||
|
la fee mangia il resto. **Win-rate alto ≠ edge.**
|
||||||
|
- **Nessun multiplo ATR fisso batte il centro del canale**, su avg/trade E Sharpe, FULL e OOS,
|
||||||
|
entrambi gli asset.
|
||||||
|
|
||||||
|
## Verdetto unificato
|
||||||
|
Il **TP al centro del canale è ottimale** perché è un target *adattivo alla struttura*: un
|
||||||
|
multiplo fisso di ATR misura solo *quanta* vol c'è, ma ignora *dove* sta la media; il centro
|
||||||
|
adatta al punto reale di reversione **ed è già scalato alla volatilità** (il canale si allarga
|
||||||
|
in regime volatile). Per una mean-reversion il punto giusto dove chiudere è **la media — niente
|
||||||
|
prima, niente dopo.** Tre alternative escluse coi numeri (15m, trailing, TP-ATR) → la scelta
|
||||||
|
di design corrente è blindata.
|
||||||
|
|
||||||
|
> Nota metodologica ricorrente: diffidare del **win-rate alto**. Il segnale vero è
|
||||||
|
> rendimento-medio-per-trade × Sharpe; un TP stretto regala win-rate e nasconde l'assenza
|
||||||
|
> di edge. (Stesso tranello dei guru: backtest cherry-picked ad alta % di vincite.)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 2026-05-31 — Stato trade LIVE PORT06 (paper trading)
|
||||||
|
|
||||||
|
> Snapshot verificato del paper trader a portafoglio (`src.portfolio.runner`, Docker
|
||||||
|
> `pythagoras-portfolio`). Dati da `data/portfolios/PORT06/` + log del container.
|
||||||
|
> Avvio container: 2026-05-29 18:37 UTC. Snapshot: 2026-05-31 13:20 UTC (~43h).
|
||||||
|
|
||||||
|
## Riepilogo capitale
|
||||||
|
| Metrica | Valore |
|
||||||
|
|---------|--------|
|
||||||
|
| Capitale iniziale | €1000.00 (17 sleeve equal-weight, ~€58.82 ciascuno) |
|
||||||
|
| `total_capital` (realizzato, ultimo rebal 00:00) | **€1000.09** (+0.09) |
|
||||||
|
| Equity mark-to-market (live) | **€1000.36** (+0.036%) |
|
||||||
|
| Peggior punto toccato | −€0.01 |
|
||||||
|
| **Max DD** | **0.40%** |
|
||||||
|
| Container | running, healthy, 0 restart |
|
||||||
|
|
||||||
|
## Trade chiusi (storia completa dallo startup: 10 trade, 9W/1L)
|
||||||
|
| # | Sleeve | Uscita | Net % | PnL € | Esito |
|
||||||
|
|---|--------|--------|------:|------:|:---:|
|
||||||
|
| 1 | PR01 ETH/SOL | mean_revert | +0.503 | +0.040 | W |
|
||||||
|
| 2 | PR01 ETH/SOL | mean_revert | +0.683 | +0.060 | W |
|
||||||
|
| 3 | SH01 BTC (ML) | hold_limit | −0.462 | −0.040 | L |
|
||||||
|
| 4 | SH01 BTC (ML) | hold_limit | +0.017 | +0.000 | W |
|
||||||
|
| 5 | PR01 ETH/SOL | mean_revert | +0.488 | +0.040 | W |
|
||||||
|
| 6 | PR01 ETH/SOL | mean_revert | +0.284 | +0.030 | W |
|
||||||
|
| 7 | PR01 LTC/ETH | mean_revert | +0.745 | +0.070 | W |
|
||||||
|
| 8 | PR01 BTC/LTC | mean_revert | +0.434 | +0.040 | W |
|
||||||
|
| 9 | MR02 ETH fade | take_profit | +0.995 | +0.090 | W |
|
||||||
|
| 10 | SH01 ETH (ML) | hold_limit | +0.742 | +0.070 | W |
|
||||||
|
| | **TOTALE** | | | **+0.400** | **90% win** |
|
||||||
|
|
||||||
|
### Aggregato per sleeve (trade chiusi)
|
||||||
|
| Sleeve | n | win | acc% | PnL € |
|
||||||
|
|--------|--:|----:|----:|------:|
|
||||||
|
| PR01 ETH/SOL | 4 | 4 | 100 | +0.170 |
|
||||||
|
| MR02 ETH fade | 1 | 1 | 100 | +0.090 |
|
||||||
|
| PR01 LTC/ETH | 1 | 1 | 100 | +0.070 |
|
||||||
|
| SH01 ETH (ML) | 1 | 1 | 100 | +0.070 |
|
||||||
|
| PR01 BTC/LTC | 1 | 1 | 100 | +0.040 |
|
||||||
|
| SH01 BTC (ML) | 2 | 1 | 50 | −0.040 |
|
||||||
|
|
||||||
|
Motore del PnL finora: **pairs PR01** (market-neutral, mean_revert rapidi 1-6 barre) +
|
||||||
|
una fade **MR02** su take_profit. Unica perdita: SH01 BTC (ML) su hold_limit (fisiologico,
|
||||||
|
edge nell'asimmetria, win-rate ~50%). Sleeve daily (ROT02/TSM01/TR01) e diverse fade non
|
||||||
|
hanno ancora chiuso trade (orizzonte più lungo / pochi segnali in ~2 giorni).
|
||||||
|
|
||||||
|
## Posizioni aperte (3)
|
||||||
|
| Sleeve | Dir | Entry | Capitale |
|
||||||
|
|--------|-----|------:|---------:|
|
||||||
|
| MR02 BTC fade | short | 73969.0 | €58.83 |
|
||||||
|
| MR02 ETH fade | long | 2016.15 | €58.92 |
|
||||||
|
| SH01 BTC (ML) | long | 73811.5 | €58.83 |
|
||||||
|
|
||||||
|
## Verifica (check 2026-05-31)
|
||||||
|
- **0 anomalie** sui 10 CLOSE: `net = gross − fee` rispettato, flag `win` coerente col
|
||||||
|
PnL, fee sempre presente (pairs 0.4% su 2 gambe, fade 0.10% RT).
|
||||||
|
- **Uscite = backtest**: tutti i CLOSE pairs sono `mean_revert` con **|z| ≤ 0.75** al close
|
||||||
|
(0.363/0.605/0.684/0.619/0.656) = esattamente `z_exit=0.75` di PR01; MR02 esce a TP al
|
||||||
|
livello. Il worker live replica la regola del backtest.
|
||||||
|
- **Riconciliazione**: +0.40 realizzato vs +0.09 `total_capital` NON è un errore — è il
|
||||||
|
timing del ribilancio giornaliero (`total_capital` snapshotta a 00:00, le posizioni
|
||||||
|
aperte restano sul notional fino al rebal; CLAUDE.md). L'equity MtM live (+0.36) è il
|
||||||
|
numero corrente, confermato da `equity.jsonl`.
|
||||||
|
|
||||||
|
## Lettura onesta
|
||||||
|
Campione minuscolo (**~2 giorni, 10 trade**) → il PnL (+€0.40 realizzato, +€0.36 MtM) è a
|
||||||
|
livello di **rumore**: non se ne deduce performance. Quello che il check conferma a questo
|
||||||
|
stadio è che il sistema è **sano e fedele**: esecuzione corretta, costi reali inclusi,
|
||||||
|
uscite conformi al backtest, DD trascurabile (0.40%), 0 errori/restart. L'edge si
|
||||||
|
manifesterà solo su orizzonte settimane/mesi. Monitor Docker attivo per down/unhealthy/restart.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 2026-05-31 — Copertura opzioni: idee testate e SCARTATE (record anti-ripetizione)
|
||||||
|
|
||||||
|
> Record delle conclusioni. Il **codice** di queste prove è stato testato e poi
|
||||||
|
> scartato (non conservato nel repo): qui restano i numeri e il *perché*, così da
|
||||||
|
> non ri-testare le stesse idee in futuro. Motore di pricing usato: Black-Scholes
|
||||||
|
> r=0 + IV stimata onestamente = RV × moltiplicatore VRP ≥ 1 (il compratore
|
||||||
|
> SOVRAPPAGA, come in W18-W21), fee Deribit reali (0.03%/gamba + ~0.10% slippage).
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
**La copertura opzioni non genera edge nuovo per questo progetto.** I due edge
|
||||||
|
disponibili (trend e mean-reversion) sono già catturati **50-100× più a buon
|
||||||
|
mercato dai perp** (fee 0.10% RT) di quanto facciano le opzioni (premio + VRP +
|
||||||
|
asimmetria coda). Comprare premio perde contro il VRP crypto; venderlo paga le
|
||||||
|
code grasse. Cappare la perdita su una strategia senza expectancy positiva limita
|
||||||
|
solo *quanto* perdi: **non esiste il pasto gratis "leva alta + perdite coperte".**
|
||||||
|
|
||||||
|
## 1. Overlay opzioni su PORT06 — non fattibile
|
||||||
|
Mismatch di orizzonte: l'edge di PORT06 è intraday (hold fade ~9h). Carry ATM 9h
|
||||||
|
≈ **0.96%** del notional vs edge fade per-trade **0.10-0.30%** → costo 3-10× l'edge.
|
||||||
|
La coda di PORT06 è già piccola (DD ~5%) e market-neutral (pairs ~57% del rischio):
|
||||||
|
poco da assicurare. La copertura giusta era già lì (diversificazione + stop), gratis.
|
||||||
|
|
||||||
|
## 2. Strategie nuove a copertura opzioni
|
||||||
|
- **OH01 — direzionale (TSMOM) + opzione protettiva / sola opzione.** Frontiera
|
||||||
|
iso-rischio: il **perp NON coperto domina a ogni livello di rischio** (Sharpe 0.90
|
||||||
|
vs 0.33-0.57; CAGR +33% vs negativo). Comprare protezione su un trend perde per il
|
||||||
|
carry/VRP (il trend-following è "long vol" nel *payoff*, non comprando opzioni).
|
||||||
|
- **OH02 — spread di credito su mean-reversion (vendi premio = VRP a favore).**
|
||||||
|
La copertura funziona (perdita cappata, DD basso, win-rate 73-80%: la reversione è
|
||||||
|
reale). Ma **expectancy ~0/leggermente negativa**: il 27% di trade dove il movimento
|
||||||
|
*continua* (code grasse) costa ~5× ogni vincita. Un trend filter porta solo *singole
|
||||||
|
celle* a +1-2% (overfit: config diversa per asset). Non robusto.
|
||||||
|
|
||||||
|
## 3. V5 — Bull Call Spread / debit spread (stile Casario)
|
||||||
|
È **la migliore struttura long-premium**: rischio definito funziona (worstRoll −13%
|
||||||
|
vs −64% della call secca, DD 54% vs 94%). **Ma net-negativo in crypto** (BTC −2.2%
|
||||||
|
full / −13.5% OOS) e il perp non coperto lo batte. Sweep larghezza: spread più larghi
|
||||||
|
rendono di più → **cappare l'upside toglie le code grasse che pagano il premio**.
|
||||||
|
**Verdetto:** valido **sulle AZIONI** (vol/VRP bassi, uptrend puliti da screener), NON
|
||||||
|
in crypto. Casario ha ragione nel suo dominio (equity), non trasferibile ai perp crypto.
|
||||||
|
|
||||||
|
## 4. V4 — Box strategy (max/min giorno prima, supply/demand) → SKIP
|
||||||
|
Core tradabile = **fadare gli estremi del canale = MR02** (già live). La candela di
|
||||||
|
conferma (doji/hammer/rejection) = pattern di rigetto = rumore (vedi diario TA). Nessun
|
||||||
|
edge nuovo: costruirlo ri-deriverebbe solo MR02.
|
||||||
|
|
||||||
|
## Cosa servirebbe per un vero edge a opzioni (fuori scope attuale)
|
||||||
|
Non direzione né reversione (già coperte dai perp), ma un edge *specifico delle opzioni*:
|
||||||
|
dislocazioni della superficie IV/skew, o gestione attiva (chiusura al 50% del credito,
|
||||||
|
roll). Richiede storico prezzi opzioni reale (qui assente, prezzi sintetici da BS) e un
|
||||||
|
feed greche/IV che il `CerberoClient` oggi non espone.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 2026-05-31 — 3 strategie TA "classiche": testate e SCARTATE (record anti-ripetizione)
|
||||||
|
|
||||||
|
> Record delle conclusioni. Codice testato e poi scartato (non conservato nel repo).
|
||||||
|
> Strategie da contenuti trading-guru: (1) SMA20/200 trend+pullback, (2) Opening Range
|
||||||
|
> Breakout "ironclad", (3) "Weakness rectangle" reversal (ICT). Testate con la
|
||||||
|
> metodologia onesta del progetto: ingresso eseguibile a `close[i]`, SL/TP intrabar,
|
||||||
|
> fee Deribit 0.10% RT, leva 3x, OOS(ultimo 30%), griglia robustezza, sweep fee.
|
||||||
|
|
||||||
|
## TL;DR — tutte e 3 NO EDGE (negative anche a fee ZERO)
|
||||||
|
Tutte e tre **direzionali/continuazione**, tutte negative su BTC/ETH, su tutta la
|
||||||
|
griglia, **anche a fee 0%** → il problema è il *segnale* (avg_R per-trade ≤ 0), non i
|
||||||
|
costi. Riconfermano la lezione centrale: *sui perp crypto i breakout/continuazione
|
||||||
|
rientrano; l'unico edge robusto è la mean-reversion.*
|
||||||
|
|
||||||
|
| Strategia | Tipo | avg_R @ fee0 | Motivo |
|
||||||
|
|-----------|------|--------------|--------|
|
||||||
|
| **SMA01** MA-pullback | continuazione | −0.15 BTC / −0.07 ETH | win ~30% (serve ~40% a R:R 2) |
|
||||||
|
| **ORB01** opening-range breakout | breakout | −0.10…−0.19 | crypto 24/7: manca l'asta d'apertura, ragione d'essere dell'ORB |
|
||||||
|
| **WR01** weakness rectangle | reversal→continuazione | ≈ −0.05/−0.00 | R:R "5:1" illusorio (win cala in proporzione); le weakness vengono travolte |
|
||||||
|
|
||||||
|
> Verificato indipendentemente (reimplementazione minima SMA01): a fee 0 avg_R
|
||||||
|
> −0.15/−0.07. Il −100% di CAGR è solo l'edge negativo composto a leva 3x su migliaia
|
||||||
|
> di trade, non un bug.
|
||||||
|
|
||||||
|
## Tentativo di MIGLIORAMENTO — ribaltarle sul lato fade
|
||||||
|
Miglioramento *di principio* (non tuning): visto che perdono perché sono continuazione,
|
||||||
|
ribaltate sul **fade** (l'unico lato con edge in crypto).
|
||||||
|
|
||||||
|
| versione fade | edge? (avg_R@fee0) | verdetto |
|
||||||
|
|---------------|--------------------|----------|
|
||||||
|
| **SMA02** fade dell'estensione→SMA20 | **+** (0.04…0.36) | = **MR01 inferiore** (FULL 1h negativo, Sharpe 0.4-0.9 vs 2.7+) |
|
||||||
|
| **ORB02** fade del breakout del range | **+** (win 35%→50-66%) | = **MR02/MR07** senza controlli di rischio (DD 90-100%) |
|
||||||
|
| **WR02** weakness come reversione | **≈0** | **rumore**, non una fade-family nascosta |
|
||||||
|
|
||||||
|
- Il flip restituisce segno positivo a 2/3 (riconferma *fade > continuazione*) **ma nulla
|
||||||
|
di additivo**: SMA02/ORB02 sono ri-scoperte inferiori di strategie già live; WR02 è rumore.
|
||||||
|
- **Ipotesi "SMA200 piatta = meglio fadare" SMENTITA**: il regime *range* non batte il fade
|
||||||
|
semplice; semmai il regime *trend* dà avg_R migliore ma con time-in-market 0.5-9%.
|
||||||
|
|
||||||
|
## Lezione metodologica
|
||||||
|
La prova del nove è l'**avg_R a fee 0**: se una strategia perde anche senza costi, il
|
||||||
|
problema è il segnale e nessun tuning la salva. Le strategie che funzionano restano
|
||||||
|
MR01/MR02/MR07 (fade) + PR01 (pairs) + PORT06 — l'edge è mean-reversion + diversificazione.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 2026-06-01 — Bugfix: SH01 usciva a 3 barre invece di H=12 (exit a orizzonte)
|
||||||
|
|
||||||
|
> Diagnosi partita da un check sulla debolezza apparente di **SH01_BTC** nel paper
|
||||||
|
> trading live PORT06 (accuratezza 33,3% su 3 trade). Non era sfortuna statistica:
|
||||||
|
> era un bug di exit nello `StrategyWorker`.
|
||||||
|
|
||||||
|
## Sintomo
|
||||||
|
|
||||||
|
Nel live PORT06 (Docker `pythagoras-portfolio`), SH01_BTC mostrava 3 trade tutti
|
||||||
|
`long`, **tutti chiusi con `reason: "hold_limit"` a `bars_held: 3`**, con `tp: null
|
||||||
|
sl: null`:
|
||||||
|
|
||||||
|
| # | entry | exit | bars | net % | esito |
|
||||||
|
|---|-------|------|------|------:|:---:|
|
||||||
|
| 1 | 73529.5 | 73433.0 | 3 | −0,46% | ❌ |
|
||||||
|
| 2 | 73759.5 | 73839.5 | 3 | +0,02% | ✅ |
|
||||||
|
| 3 | 73811.5 | 73766.0 | 3 | −0,32% | ❌ |
|
||||||
|
|
||||||
|
`oos_signal_precision` nei log di TRAIN scendeva 55,6% → 50,0% → 43,3%.
|
||||||
|
|
||||||
|
## Causa
|
||||||
|
|
||||||
|
SH01 (`scripts/strategies/SH01_shape_ml.py`, config **W24 H12 th0.58**) è una
|
||||||
|
strategia **horizon-only**: predice il segno del rendimento a **H=12 barre** ed esce a
|
||||||
|
H barre. I suoi Signal portano `metadata={"max_bars": H}` (=12) e **nessun TP/SL**.
|
||||||
|
|
||||||
|
Nello `StrategyWorker.tick()` la logica di uscita era:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self.tp and self.sl: # SH01: False (tp=sl=0)
|
||||||
|
... usa self.max_bars ... # -> max_bars=12 consultato SOLO qui
|
||||||
|
elif self.bars_held >= self.hold_bars: # fallback legacy hold_bars=3
|
||||||
|
self._close_position(..., "hold_limit") # SH01 finiva QUI
|
||||||
|
```
|
||||||
|
|
||||||
|
`self.max_bars` (=12, settato correttamente in `_open_position`) era onorato **solo
|
||||||
|
dentro il ramo `tp and sl`**. Senza TP/SL, SH01 cadeva sul fallback `hold_bars=3` e
|
||||||
|
chiudeva a 3 barre. L'edge di SH01 — per CLAUDE.md è nell'**asimmetria sull'orizzonte
|
||||||
|
H, non nella frequenza** (win-rate ~50%) — non aveva tempo di realizzarsi: tagliato a
|
||||||
|
3/12, degenera in rumore.
|
||||||
|
|
||||||
|
Solo SH01 (BTC+ETH) era colpito: tutte le fade (MR01/MR02/MR07, DIP01) portano
|
||||||
|
tp+sl+max_bars e usano il ramo intrabar corretto.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
`src/live/strategy_worker.py`: aggiunto un ramo per l'exit a orizzonte puro, prima del
|
||||||
|
fallback `hold_bars`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
elif self.max_bars:
|
||||||
|
# Exit puro a orizzonte (strategie senza TP/SL, es. SH01 shape-ML H=12):
|
||||||
|
# onora max_bars dalla metadata del Signal, non il fallback hold_bars=3.
|
||||||
|
if self.bars_held >= self.max_bars:
|
||||||
|
self._close_position(current_price, "time_limit")
|
||||||
|
```
|
||||||
|
|
||||||
|
Le fade restano invariate (entrano nel ramo `tp and sl`).
|
||||||
|
|
||||||
|
## Verifica
|
||||||
|
|
||||||
|
- Nuovo test `tests/portfolio/test_horizon_exit.py` (2 casi): con `max_bars=12` resta
|
||||||
|
in posizione a 3 barre; esce a 12 con `reason: "time_limit"` e `bars_held: 12`.
|
||||||
|
- Suite completa: **43 passed**.
|
||||||
|
- Container riavviato: **tutti i 17 sleeve RESUME puliti**, inclusa una posizione
|
||||||
|
SH01_ETH short aperta che ora seguirà l'exit a 12 barre.
|
||||||
|
|
||||||
|
## Atteso d'ora in poi
|
||||||
|
|
||||||
|
I trade SH01 nei log mostreranno `reason: "time_limit"` con `bars_held: 12` invece di
|
||||||
|
`hold_limit / 3`. Il 33% di accuratezza era un artefatto dell'exit prematuro; ora la
|
||||||
|
strategia gira sull'orizzonte su cui è validata (BTC OOS Sharpe 2,72, expanding).
|
||||||
|
Resta comunque un **diversificatore** del MASTER, non un motore di ritorno standalone.
|
||||||
|
|
||||||
|
## Lezione
|
||||||
|
|
||||||
|
Il backtest di SH01 (`fade_base`/engine onesto) esce a H barre via `max_bars`; il
|
||||||
|
worker live deve replicarlo. Quando una strategia non porta TP/SL ma solo un
|
||||||
|
orizzonte, il fallback `hold_bars` del worker la **falsa silenziosamente**. Verificare
|
||||||
|
sempre che la convenzione di exit del worker live coincida con quella del backtest
|
||||||
|
validato — non solo l'ingresso.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 2026-06-01 — SH01 live eseguiva la strategia SBAGLIATA (squeeze scartato), non shape-ML
|
||||||
|
|
||||||
|
> Scoperto verificando perché SH01 continuava a chiudere a `hold_limit/3` **anche dopo**
|
||||||
|
> il rebuild col fix horizon-exit. Il fix era corretto ma in un **ramo morto**: SH01 live
|
||||||
|
> non passava da `StrategyWorker.tick()`.
|
||||||
|
|
||||||
|
## Sintomo
|
||||||
|
|
||||||
|
Dopo il deploy del fix SH01 (exit a H=12), un close SH01_BTC delle 12:00 era ancora
|
||||||
|
`reason=hold_limit bars=3` (perdita −1,27%). Il fix non aveva effetto sul path reale.
|
||||||
|
|
||||||
|
## Causa (bug di wiring, più grave del previsto)
|
||||||
|
|
||||||
|
`src/portfolio/runner.py` importava `MLWorkerWrapper` da **`src/live/multi_runner.py`** e
|
||||||
|
ci avvolgeva lo sleeve SH01:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if spec.kind == "ml":
|
||||||
|
return MLWorkerWrapper(worker, {"retrain_hours": 24})
|
||||||
|
```
|
||||||
|
|
||||||
|
Ma quel wrapper è **legacy, per la famiglia squeeze ML01** (scartata, vedi CLAUDE.md):
|
||||||
|
- usa `SignalEngine` = squeeze-detection + GradientBoosting (NON SH01_shape_ml);
|
||||||
|
- ha una `tick()` propria che apre con un `Signal` **nudo** (niente tp/sl/max_bars) ed
|
||||||
|
esce con `if bars_held >= hold_bars: close("hold_limit")` → ignora del tutto la
|
||||||
|
strategia caricata e il fix horizon.
|
||||||
|
|
||||||
|
Quindi lo sleeve "SH01" del portafoglio live **non eseguiva shape-ML**: eseguiva il
|
||||||
|
motore squeeze scartato. I log `TRAIN OK / oos_signal_precision` venivano da lì. Il
|
||||||
|
`worker` con strategy=SH01_shape_ml era costruito ma la sua `generate_signals` non
|
||||||
|
veniva **mai** chiamata.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
SH01 (`kind="ml"`) ora gira come **StrategyWorker normale**: `SH01_shape_ml.generate_signals`
|
||||||
|
fa il walk-forward (retraining) **internamente** ad ogni tick (`ml_wf_entries`) ed emette
|
||||||
|
`metadata.max_bars=H=12` → gli exit passano per `StrategyWorker.tick()` e il fix horizon
|
||||||
|
si applica davvero.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# runner.py: niente più MLWorkerWrapper per kind="ml"
|
||||||
|
return StrategyWorker(strategy=strategy, asset=spec.asset, tf=spec.tf, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lookback dati.** `ml_wf_entries` ha `train_min=4000` → servono ≥4000 barre 1h prima di
|
||||||
|
produrre segnali (con 90g/2160 barre → 0 segnali, runtime 0.01s — il falso "muto"). Le
|
||||||
|
candele 1h di BTC/ETH già arrivano a 440g (le richiede TSM01/ROT02 a 1d), ma per non
|
||||||
|
dipendere da quella coincidenza ho aggiunto `_ML_LOOKBACK_DAYS=365`: gli asset usati da
|
||||||
|
sleeve ml fetchano ≥365g (~8760 barre). Costo `generate_signals` su 365g: **0,17–0,24s**
|
||||||
|
(modello logit) → trascurabile sul poll 60s.
|
||||||
|
|
||||||
|
**Verifica.** Build SH01 → `StrategyWorker` con `strategy.name=="SH01_shape_ml"`, niente
|
||||||
|
attributo `engine` (regression test `test_build_ml_sh01_is_plain_strategyworker`). Smoke
|
||||||
|
su 365g: 766–1786 segnali, tutti `max_bars=12`; tick live 0,17s. `ml_wf_entries` non
|
||||||
|
predice mai l'ultima barra (`n-1`) ma fino a `n-2` = esattamente la condizione di apertura
|
||||||
|
del worker (`idx >= last_idx-1`) → apre quando il segnale è fresco. Suite: 51 passed.
|
||||||
|
|
||||||
|
**Stato live.** SH01 BTC/ETH erano flat: contatori resettati a 0 (capitale preservato
|
||||||
|
58,76/58,78), vecchi trade squeeze archiviati in `trades_squeeze_archive.jsonl`. Rebuild
|
||||||
|
+ recreate: 14 worker RESUME puliti, container healthy, nessun log TRAIN/squeeze, zero
|
||||||
|
errori.
|
||||||
|
|
||||||
|
## Lezione
|
||||||
|
|
||||||
|
1. **Verificare il path REALE, non solo il codice del fix.** Il fix horizon era giusto ma
|
||||||
|
SH01 non lo attraversava. Un fix non testato end-to-end sul percorso vivo è un fix
|
||||||
|
presunto. (Mi ero fidato del rebuild senza confermare il reason dei close SH01.)
|
||||||
|
2. Riusare un wrapper legacy "perché c'è" è un rischio: `MLWorkerWrapper` di multi_runner
|
||||||
|
era per la famiglia squeeze scartata, non per shape-ML.
|
||||||
|
3. Un modello ML "muto" può essere solo **fame di dati** (train_min), non un bug logico:
|
||||||
|
controllare sempre la dimensione della finestra prima di concludere.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 2026-06-01 — "Win" che perdono: metrica netto-fee + filtro TP edge-minimo
|
||||||
|
|
||||||
|
> Partito da un'osservazione dell'utente sui trade live PORT06: **ci sono close con
|
||||||
|
> `win=True` ma `pnl` negativo**. Due problemi distinti, entrambi risolti.
|
||||||
|
|
||||||
|
## Problema 1 — la metrica `win` mentiva (lordo invece di netto)
|
||||||
|
|
||||||
|
In `strategy_worker.py::_close_position`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
trade_return = price_change * direction # LORDO, prima delle fee
|
||||||
|
net = trade_return * leverage - fee_rt * leverage
|
||||||
|
pnl = capital * position_size * net # corretto (netto)
|
||||||
|
is_win = trade_return > 0 # BUG: usa il LORDO
|
||||||
|
```
|
||||||
|
|
||||||
|
`is_win` scattava appena il prezzo si muoveva di un soffio a favore, **prima delle
|
||||||
|
fee**. Capitale e PnL erano giusti (netti); solo la metrica `win`/`accuracy` era
|
||||||
|
gonfiata.
|
||||||
|
|
||||||
|
**Quantificazione (51 close live):** 39 win dichiarate (76,5%) → **13 falsi win**
|
||||||
|
(`win=True` ma `pnl≤0`) → accuratezza **netta reale 52,9%**. PnL realizzato +€0,77
|
||||||
|
(resta positivo: lo trascinano i pairs).
|
||||||
|
|
||||||
|
**Fix:** `is_win = net > 0`. + `tests/portfolio/test_win_net_of_fees.py` (mossa
|
||||||
|
sotto-fee = non win; oltre-fee = win; perdita = non win).
|
||||||
|
|
||||||
|
**Riconciliazione contatori persistiti:** i `total_wins` su disco erano gonfiati dal
|
||||||
|
vecchio conteggio lordo. Ricalcolati come `net_return>0` dai `trades.jsonl`:
|
||||||
|
**MR01_BTC 7→1, DIP01_BTC 7→1** (gli unici toccati; tutti gli altri già coerenti).
|
||||||
|
Capitale invariato.
|
||||||
|
|
||||||
|
## Problema 2 — i 13 falsi win erano tutti MR01_BTC / DIP01_BTC in take_profit
|
||||||
|
|
||||||
|
Causa: in `MR01_bollinger_fade` e `DIP01_dip_buy` il **TP è la media** (`tp = ma[i]`)
|
||||||
|
e l'entry è a `close[i]` appena fuori banda. Nel regime BTC **piatto** (inchiodato
|
||||||
|
~73.700 per ore, vol bassissima) la media è a pochi dollari dall'entry → il TP cade
|
||||||
|
**dentro** il costo round-trip (0,10%): colpire il TP = perdita netta garantita.
|
||||||
|
|
||||||
|
**Meccanismo del fix (importante).** "Spostare il TP più in là" NON garantisce di non
|
||||||
|
perdere: il prezzo rientra solo fino alla media, non oltre → si finirebbe su SL/time-
|
||||||
|
limit, perdendo di più. La mossa provabilmente non-perdente è un **filtro di edge
|
||||||
|
minimo**: se `|tp − entry|/entry ≤ min_tp_frac` non si apre la trade. Break-even
|
||||||
|
esatto = `fee_rt` (= 0,10%, indipendente dalla leva, perché
|
||||||
|
`ret = mossa·lev − fee_rt·lev > 0 ⇔ mossa > fee_rt`).
|
||||||
|
|
||||||
|
**Implementazione:** parametro `min_tp_frac` (default 0.0 = off) in **tutte le 4 fade**
|
||||||
|
(MR01 banda, MR02 midpoint canale, MR07 ATR-scaled) e DIP01; salta i segnali sotto
|
||||||
|
soglia. Cablato negli sleeve live a **0.0015 (1,5× fee)** in `_defs.py` (`MIN_TP_FRAC`).
|
||||||
|
|
||||||
|
**Validazione backtest (BTC+ETH 1h, config sleeve, min_tp_frac ∈ {0,.001,.0015,.002}):**
|
||||||
|
neutro su tutte e 4 le fade.
|
||||||
|
- MR01: 0 trade rimossi (BTC +8028€, ETH +10395€) — metriche identiche.
|
||||||
|
- DIP01 BTC: −1 trade a 0.002, **migliora** (+7492→+7522€, DD 26,3→25,9%).
|
||||||
|
- MR02 BTC: −1 trade a 0.0015 (pnl invariato +12198€), ETH 0 rimossi.
|
||||||
|
- MR07 BTC/ETH: 0 rimossi (TP ATR-scaled sempre ben oltre le fee nello storico).
|
||||||
|
|
||||||
|
Conclusione: i micro-scalp sotto-fee **non esistono nel campione storico** — sono un
|
||||||
|
artefatto del regime attuale. Il filtro è **puro upside**: neutro sul backtest validato,
|
||||||
|
protettivo dal vivo. (Le 12 trade live incriminate, tutte MR01/DIP01 BTC, avevano gap
|
||||||
|
~0,026%, ben sotto 0,15% → tutte bloccate.)
|
||||||
|
|
||||||
|
+ `tests/portfolio/test_min_tp_frac.py` (monotonia + ogni superstite ha gap > soglia
|
||||||
|
+ default-off invariato).
|
||||||
|
|
||||||
|
## Nota deploy — il codice è COTTO nell'immagine, non montato
|
||||||
|
|
||||||
|
Scoperta durante il deploy: `docker-compose.yml` monta solo `./data` e
|
||||||
|
`./portfolios.yml`; il sorgente (`src/`, `scripts/`) è `COPY` nel Dockerfile. Quindi
|
||||||
|
**`docker compose restart` NON ricarica le modifiche al codice** — serve
|
||||||
|
`docker compose up -d --build`. Conseguenza retroattiva: anche il fix SH01
|
||||||
|
horizon-exit di stamattina è andato live solo con questo rebuild. Da ricordare per ogni
|
||||||
|
futura modifica ai worker. Il volume `./data` persiste → i 14 worker fanno RESUME
|
||||||
|
puliti dopo il rebuild (capitale e posizioni intatti).
|
||||||
|
|
||||||
|
## Stato finale
|
||||||
|
|
||||||
|
- `is_win = net > 0` live; contatori riconciliati (MR01/DIP01 BTC 1/9).
|
||||||
|
- Filtro `min_tp_frac=0.0015` live su tutti i fade + DIP01 (attivo solo MR01/DIP01).
|
||||||
|
- Fix SH01 horizon-exit ora **effettivamente** live (rebuild).
|
||||||
|
- Suite: 49 passed. Container ricostruito, healthy, 14 sleeve in RESUME.
|
||||||
|
|
||||||
|
## Lezione
|
||||||
|
|
||||||
|
1. Una metrica di "win" deve essere **netto fee**, altrimenti l'accuracy è teatro.
|
||||||
|
2. Quando il TP è dentro il costo di transazione, la trade è persa in partenza: meglio
|
||||||
|
**non prenderla** che ritoccare il TP.
|
||||||
|
3. Per i worker live in Docker: **rebuild**, non restart. Il restart ricarica solo lo
|
||||||
|
stato dal volume, non il codice.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 2026-06-02 — Loss-guard per le fade: filtro Hurst (regime persistente)
|
||||||
|
|
||||||
|
> Goal: limitare le perdite delle fade in "bassa vol". Diagnosi empirica + ricerca web + workflow
|
||||||
|
> 11 agenti + test decisivo a livello PORT06. Branch `feat/fade-lossguard`.
|
||||||
|
|
||||||
|
## Riformulazione del problema (la premessa era imprecisa)
|
||||||
|
|
||||||
|
Diagnosi su 3022 trade fade (MR01/MR02/MR07 × BTC/ETH, 2021+): **le perdite NON si concentrano in
|
||||||
|
bassa vol** — anzi il terzile low-DVOL è net positivo (+2,30%/trade). Il vero driver è il **regime
|
||||||
|
PERSISTENTE/trending**, misurato dall'Hurst:
|
||||||
|
- somma perdite peggiore: **hurst>0,55** (−2695% in low-vol, dominante in ogni terzile vol)
|
||||||
|
- **stop-rate 43% per hurst>0,55 vs 21% per hurst<0,45** (anti-persistente) — 2x
|
||||||
|
- peggiori 1% trade: Hurst medio 0,61 (77% con hurst>0,55, solo 13% in bassa-DVOL)
|
||||||
|
|
||||||
|
## Ricerca web (confermata e smentita dai dati reali)
|
||||||
|
- **Hurst regime filter** (MR solo H<0,45, evitare H>0,55): **CONFERMATO** sui dati reali. ✅
|
||||||
|
- **ADX** (PF 1,62 sotto 20 vs −0,74 sopra 30): **NON si replica** — ADX-skip uccide l'edge
|
||||||
|
(Sharpe 4,82→0,99) e lo stop-rate non scende. ❌
|
||||||
|
- **vol-expansion ATR-ratio>1,5 (−72% perdite)**: **NON si replica** — alza DD e stop-rate. ❌
|
||||||
|
- **time-stop ~15 barre**: riduce stop-rate ma alza il DD full → non passa standalone. ❌
|
||||||
|
|
||||||
|
## Workflow 11 agenti — meccanismi testati
|
||||||
|
| Meccanismo | OOS Sharpe (base→filt) | DD full | Buon loss-guard? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Hurst-SKIP h<0,55** | 4,82→4,96 ↑ | 24,3→13,8% ↓ | **SÌ** |
|
||||||
|
| **Hurst-SIZE 1/0,5/0,25** | 4,65→5,32 ↑ (full) | 33,6→11,3% maxDD ↓ | **SÌ** |
|
||||||
|
| ADX-skip | 4,82→0,99 ✗ | — | NO (uccide edge) |
|
||||||
|
| vol-expansion vratio | 4,82→4,04 | 24,3→27,5% ✗ | NO |
|
||||||
|
| Kaufman ER, time-stop, vol-target, DVOL-rising, combo | tutti ↓ o DD↑ | — | NO |
|
||||||
|
|
||||||
|
**Solo l'Hurst** isola chirurgicamente il regime tossico; gli altri sono "dimmer uniformi" che
|
||||||
|
tagliano winner insieme ai loser (gate FR01 fallito).
|
||||||
|
|
||||||
|
## TEST DECISIVO a livello PORT06 — SUPERATO ✅
|
||||||
|
|
||||||
|
Applicato l'Hurst-skip alle 6 fade dentro il PORT06 intero (equal-weight, le altre 11 sleeve
|
||||||
|
invariate):
|
||||||
|
|
||||||
|
| Portafoglio | FULL Sharpe | FULL DD | OOS Sharpe | OOS DD | OOS ret |
|
||||||
|
|-------------|:--:|:--:|:--:|:--:|:--:|
|
||||||
|
| PORT06 baseline | 6,62 | 4,10% | 8,89 | 1,22% | +175% |
|
||||||
|
| **+ Hurst-skip h<0,55** | **6,76** | **2,39%** | **9,15** | 1,54% | +158% |
|
||||||
|
| + Hurst-skip h<0,50 | 6,61 | 2,08% | 9,02 | 1,54% | +150% |
|
||||||
|
|
||||||
|
**A differenza di FR01 (che diluiva), il filtro Hurst MIGLIORA il PORT06**: FULL Sharpe ↑, **FULL
|
||||||
|
DD quasi dimezzato (4,10→2,39%)**, OOS Sharpe ↑ (8,89→9,15). Costo: OOS DD +0,3pp (resta minuscolo),
|
||||||
|
OOS ret −17pp. **h<0,55 è il pick** (0,50 taglia più ritorno). Non aumenta il profitto: è puro
|
||||||
|
**rischio** — dimezza il DD mantenendo/alzando lo Sharpe.
|
||||||
|
|
||||||
|
## Implementazione
|
||||||
|
Aggiunto `hurst_skip_mask` in `src/strategies/fade_base.py` (rolling-Hurst causale dalle SOLE close)
|
||||||
|
+ parametro `hurst_max` (default None=off) in MR01/MR02/MR07. Test: `test_hurst_lossguard.py`.
|
||||||
|
|
||||||
|
**Vantaggio operativo decisivo vs FR01:** l'Hurst si calcola **dalle sole close** → nessun feed
|
||||||
|
DVOL/regime live necessario. Lo `StrategyWorker` lo computa inline dai dati che già ha → **deployabile
|
||||||
|
senza nuova infrastruttura**, basta settare `hurst_max: 0.55` nei params degli sleeve fade.
|
||||||
|
|
||||||
|
## Da fare per attivarlo live (deploy)
|
||||||
|
1. Settare `hurst_max: 0.55` nei params delle fade in `_defs.py` (sleeve live) + aggiornare i params
|
||||||
|
fade del backtest (`combine_portfolio`/`report_families`) per PARITÀ + rigenerare il
|
||||||
|
regression-lock PORT06 (i numeri canonici cambiano: DD 4,9→~2,4%).
|
||||||
|
2. Verificare che il rolling-Hurst live nel worker coincida col backtest (stessa finestra 100,
|
||||||
|
stesso stepping causale).
|
||||||
|
3. Rebuild immagine Docker (`up -d --build`, non restart) + verifica RESUME.
|
||||||
|
|
||||||
|
Default attuale: `hurst_max` OFF → zero impatto su backtest/parità/live finché non lo si attiva
|
||||||
|
esplicitamente. Il SISTEMA è trovato e validato; l'attivazione è una decisione di deploy.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 2026-06-02 — Ricerca a 100 agenti: Frattali del segnale × Regime ARGO
|
||||||
|
|
||||||
|
> Workflow multi-agente (171 agenti, 8,4M token, ~7h) per cercare una strategia che combini
|
||||||
|
> un SEGNALE FRATTALE con un GATE/INTERAZIONE DI REGIME ARGO (DVOL/funding/VRP), validata OOS.
|
||||||
|
> Branch: `feat/fractal-argo-search`. Substrato e codice sul branch, niente impatto su main/live.
|
||||||
|
|
||||||
|
## Substrato costruito
|
||||||
|
- `regime_fetcher.py`: DVOL (2021-03→oggi) + funding (2019→oggi) BTC/ETH da **Deribit mainnet
|
||||||
|
public** (no-auth, OI/IV reali — non il testnet farlocco di Cerbero).
|
||||||
|
- `regime_lab.py`: allineamento regime↔prezzo **causale no-look-ahead** (merge_asof backward),
|
||||||
|
feature regime (dvol_pct, **vrp=dvol−rv**, funding_z, dvol_chg) + frattali (rolling Hurst,
|
||||||
|
Higuchi FD, vol-ratio, Williams pivot), cache feature precalcolate, validazione netto-fee OOS
|
||||||
|
via `explore_lab`. Bug corretto in corsa: `vrp` annualizzava la realized-vol sempre come 1h →
|
||||||
|
rotta su 4h/1d (sempre negativa); fix per timeframe.
|
||||||
|
- `fractal_argo_workflow.js`: 84 agenti griglia (7 famiglie frattali × 6 angoli regime × BTC/ETH)
|
||||||
|
+ 8 wildcard + verifica avversariale dei survivor + sintesi.
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
|
||||||
|
**Esistono edge frattale×regime reali, causali, robusti** (15 confermati dalla batteria
|
||||||
|
avversariale: audit look-ahead bit-esatto, cross-asset, split alternativo, fee 0.2% RT, plateau).
|
||||||
|
**MA nessuno batte PORT06 standalone** (OOS Sharpe 8,19 / DD 2,3%): sono **diversificatori a
|
||||||
|
bassa esposizione** (1,5-8%, ~100-460 trade), profilo SH01/pairs.
|
||||||
|
|
||||||
|
### Top candidati confermati
|
||||||
|
| Strategia | Asset/TF | OOS Sharpe | OOS DD | trade |
|
||||||
|
|-----------|----------|:--:|:--:|:--:|
|
||||||
|
| FRAC-VRP multiscala (chop × VRP<0) | ETH 1h | 5,55 | 11% | 184 |
|
||||||
|
| HigVRP-Fade (Higuchi alto × VRP<0) | BTC 1h | 4,55 | 7,2% | 286 |
|
||||||
|
| **HurstCalmFade (hurst<0,55 × dvol<0,4)** | BTC 1h | **3,73** | 5,1% | 198 |
|
||||||
|
| WILD8 Pivot-Hurst | BTC 4h | 3,87 | 10% | 482 |
|
||||||
|
| AnalogFundingFade (kNN forma × funding) | ETH 4h | 2,15 | 9,3% | 229 |
|
||||||
|
|
||||||
|
## Il finding chiave (controintuitivo): il prior ARGO è SMENTITO
|
||||||
|
|
||||||
|
La tesi naïve **"VRP>0 = GEX+ = range = fade" FALLISCE** sistematicamente. L'edge robusto e
|
||||||
|
ripetuto è su **VRP<0** (vol implicita *sottoprezzata* vs realizzata → mean-reversion whippy) e su
|
||||||
|
**DVOL bassa**, l'opposto del brief. Gate invertito VRP>0 → Sharpe −2,08/−1,30 su entrambi gli
|
||||||
|
asset. È il risultato più solido di tutta la ricerca, look-ahead-clean (lag-1/3/6 robusto).
|
||||||
|
|
||||||
|
## Cosa aggiunge valore vs cosa è decorativo
|
||||||
|
- **Load-bearing (confermato per ablazione):** VRP<0; hurst-low × dvol-low (HurstCalmFade taglia
|
||||||
|
DD OOS 17%→5%); funding_z estremo |fz|≥1,8 (analog ETH 4h).
|
||||||
|
- **Decorativo (DD-reducer, non interazione):** dvol_chg, dvol_high/low e funding come gate spesso
|
||||||
|
riducono solo esposizione senza migliorare il segno.
|
||||||
|
|
||||||
|
## Cosa è RUMORE (conferma i priori del progetto)
|
||||||
|
- Frattali da soli (angolo=none): hurst/Higuchi/vratio/chop/candle/analog intraday → non robusti
|
||||||
|
(DD 30-90%, muoiono di fee). Conferma `shape_lab`.
|
||||||
|
- Momentum/breakout gateato (hurst>0,6, dvol-rising): catastrofico (Sharpe −2…−7) → riconferma
|
||||||
|
dominanza mean-reversion, i breakout rientrano.
|
||||||
|
- ARGO-GEX nella direzione attesa (VRP>0): perde. Coerente con W18/19/21 scartate.
|
||||||
|
- Pivot-fade non-laggato (frac_up[i]): artefatto squeeze-like, va sempre laggato a i−2.
|
||||||
|
|
||||||
|
## Vincitore selezionato + test decisivo
|
||||||
|
|
||||||
|
**FR01 HurstCalmFade BTC 1h** (`scripts/strategies/FR01_hurst_calm_fade.py`): il più verificato,
|
||||||
|
DD più basso (5,1% OOS), generalizza a ETH. **Test di correlazione decisivo** (la domanda che
|
||||||
|
conta: aggiungerlo migliora il PORT06 o è ridondante?): correlazione daily-returns coi fade
|
||||||
|
esistenti **MR01 +0,17 / MR02 +0,08 / MR07 −0,03** → **BASSA, quasi-ortogonale**, NON ridondante.
|
||||||
|
Passa il gate → vale l'inserimento come diversificatore.
|
||||||
|
|
||||||
|
## Onestà finale
|
||||||
|
L'edge frattale×regime è **reale, causale, robusto** ma è sempre **mean-reversion già nota
|
||||||
|
condizionata dal regime (VRP<0 / hurst-low / dvol-low)**, non un motore ortogonale nuovo. Valore =
|
||||||
|
**riduzione DD aggregato come sleeve a bassa esposizione**. La correlazione bassa lo qualifica come
|
||||||
|
diversificatore reale.
|
||||||
|
|
||||||
|
## TEST DECISIVO SUL MASTER — VERDETTO FINALE: NON deployare
|
||||||
|
|
||||||
|
Misurato il contributo marginale di FR01 al PORT06 intero (equal-weight, `master_corr`):
|
||||||
|
|
||||||
|
| Portafoglio | FULL Sharpe | OOS Sharpe | OOS DD | OOS ret |
|
||||||
|
|-------------|:--:|:--:|:--:|:--:|
|
||||||
|
| PORT06 (17 sleeve) | 6,62 | **8,89** | 1,2% | +175% |
|
||||||
|
| PORT06 + FR01 (19) | 6,55 | **8,72** | 1,1% | +156% |
|
||||||
|
|
||||||
|
**FR01 NON migliora il PORT06: lo DILUISCE** (OOS Sharpe 8,89→8,72, OOS ret +175%→+156%; DD
|
||||||
|
marginalmente meglio 1,2→1,1% ma a costo di Sharpe). Corr FR01 vs MASTER +0,18 (BTC)/+0,23 (ETH).
|
||||||
|
|
||||||
|
**Causa + nota di onestà metrica:** lo Sharpe "3,73" dei report del workflow è **per-trade/annuale**
|
||||||
|
(`explore_lab`); quello rilevante per il portafoglio è lo **Sharpe daily-return** (`combine`), che per
|
||||||
|
FR01 è solo **~1,85/1,53** — troppo basso per muovere un PORT06 a 8,89. È "ridondanza robusta":
|
||||||
|
mean-reversion regime-gated che si sovrappone a ciò che il MASTER già fa.
|
||||||
|
|
||||||
|
**ESITO: il search a 100 agenti ha trovato strategie robuste e causali, ma NESSUNA migliora il
|
||||||
|
PORT06.** Non deployare FR01 né i candidati gemelli. Valore del progetto resta nell'estendere
|
||||||
|
fade/pairs validati. Tutto resta come RECORD DI RICERCA sul branch (non si merge in produzione).
|
||||||
|
Wiring DVOL live e walk-forward: non necessari, deploy abbandonato.
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# 2026-06-04 — EXIT LAB: ricerca massiva exit dinamiche (34 agenti) → EXIT-16 close-confirm SL PROMOSSO
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
|
||||||
|
Cercare exit migliori per le fade attive: TP dinamici, SL dinamici/protettivi,
|
||||||
|
meccanismi per "cavalcare il prezzo". Mandato: ≥20 agenti con specifiche
|
||||||
|
differenti, guardia anti-overfitting, test su tutto lo storico disponibile.
|
||||||
|
|
||||||
|
**Esito in una riga: "cavalcare" non esiste (17ª conferma), ma la ricerca ha
|
||||||
|
trovato una cosa più grossa — LO STOP-LOSS INTRABAR FISSO È IL COMPONENTE CHE
|
||||||
|
DISTRUGGE PIÙ VALORE NELLE FADE, e la forma robusta del fix (SL confermato in
|
||||||
|
chiusura, EXIT-16) è PROMOSSA a livello PORT06: OOS Sharpe 8.82→10.06,
|
||||||
|
FULL DD 4.10→2.60.**
|
||||||
|
|
||||||
|
## Infrastruttura (riusabile)
|
||||||
|
|
||||||
|
- `scripts/analysis/exit_lab.py` — harness CONDIVISO: cache segnali live-params
|
||||||
|
delle 3 fade (`data/cache/exit_lab_signals.pkl`, 73.6k barre 1h 2018→2026-05,
|
||||||
|
BTC+ETH), engine intrabar identico a `fade_base` (parità verificata al punto
|
||||||
|
percentuale su tutti e 6 gli sleeve), contratto anti-look-ahead esplicito
|
||||||
|
(`levels(j)` solo dati ≤ j-1; `after_bar(j)` sul close = eseguibile al poll),
|
||||||
|
protocollo train/OOS (selezione parametri SOLO su pre-2023-11; OOS letto una
|
||||||
|
volta), fill parziali, `ExitPolicy` pluggable.
|
||||||
|
- `scripts/analysis/exit_policies/` — 23 policy + 10 script di verifica.
|
||||||
|
- `scripts/analysis/exit16_port06_impact.py` — replay canonico `build_trades`/
|
||||||
|
`fade_daily_equity` con parità ESATTA (corr 1.00000) + innesto EXIT-16 +
|
||||||
|
ricalcolo PORT06 (cap pairs 0.33, leva 2x).
|
||||||
|
|
||||||
|
## Fase 1 — Explore: 23 agenti, una famiglia ciascuno
|
||||||
|
|
||||||
|
Tally: **3 PROMISING, 5 NEUTRAL, 15 REJECT.**
|
||||||
|
|
||||||
|
REJECT (la famiglia "cavalca il prezzo" è stata falcidiata): trail_atr_ride,
|
||||||
|
trail_pct, breakeven, sar_trail, tp_decay, tp_extend_momentum, tp_moving_mean,
|
||||||
|
partial_tp_trail, hurst_exit, giveback, loser_timestop, donchian_trail,
|
||||||
|
ema_cross_exit, vol_rescale, sl_tp_ride. Autopsia ricorrente: **il TP delle
|
||||||
|
fade sta ALLA MEDIA; lì il movimento è esaurito per costruzione → oltre quel
|
||||||
|
punto non c'è coda da cavalcare** (il runner finiva oltre il TP ~3% delle
|
||||||
|
volte). Caso da manuale `trail_atr_ride`: train 5/6 a favore, **OOS 0/6**
|
||||||
|
(MR01 ETH ret 134%→1%) — senza l'embargo train/OOS l'avremmo promosso.
|
||||||
|
|
||||||
|
NEUTRAL: ratchet, sl_tighten, z_overshoot, wide_sl_trail, swing_stop
|
||||||
|
(miglioramenti misti, mai 5/6).
|
||||||
|
|
||||||
|
PROMISING — tre meccanismi diversi, UNA stessa tesi ("gli stop intrabar da
|
||||||
|
wick sono falsi negativi: l'overshoot che buca lo stop è esattamente ciò che
|
||||||
|
la fade vuole fadare, e rientra"):
|
||||||
|
- **EXIT-02 trail_atr_keep_tp** (chandelier k=1.5 + TP fisso): 6/6 train, 5/6 OOS.
|
||||||
|
- **EXIT-16 close_confirm_sl** (SL solo se la barra CHIUDE oltre sl0±0.5·ATR):
|
||||||
|
**6/6 train E 6/6 OOS**, plateau.
|
||||||
|
- **EXIT-22 no_sl** (diagnostica: SL rimosso): 6/6+6/6, plateau monotono
|
||||||
|
perfetto `SL stretto < base < SL 2x < niente SL`.
|
||||||
|
|
||||||
|
## Fase 2 — Verify: 10 avversari (leakage / overfit / stress × 3 + tail-risk)
|
||||||
|
|
||||||
|
- **EXIT-02 CONFUTATO (2/3).** (a) Overfit: è **Pareto-dominato da no_sl a
|
||||||
|
ZERO parametri** su tutti i 6 sleeve OOS (Sharpe base→trail→noSL: MR01 BTC
|
||||||
|
2.77→3.08→5.67; MR02 ETH 12.35→11.41→17.24 — il trail PEGGIORA lo sleeve di
|
||||||
|
punta) → overfit di selezione: la forma parametrizzata sbagliata di una tesi
|
||||||
|
giusta. (b) Leakage: codice pulito MA **scoperta di metodo: il 54% degli
|
||||||
|
stop del trail è GAP-THROUGH** (l'open del bar è già oltre il livello;
|
||||||
|
gap mediano 62-174 bps): l'engine che filla gli stop "al livello" SOVRASTIMA
|
||||||
|
ogni policy a stop stretti; col fill realistico `worse(level, open)` il
|
||||||
|
confronto si ribalta. **Da oggi ogni ricerca con stop tight deve modellare
|
||||||
|
il gap-through.** Lo SL largo della base ne è quasi immune.
|
||||||
|
- **EXIT-16 SUPERA 4/4.** Leakage: contratto pulito, robusto a 1-3 bar di lag
|
||||||
|
e al fill a `open[j+1]` (esce deliberatamente tardi → il timing fine è
|
||||||
|
irrilevante per costruzione). Overfit: plateau monotono su buffer 0.4→1.0
|
||||||
|
FUORI griglia, il ponte SL-largo k=1.5→4 converge, 4 finestre temporali ok,
|
||||||
|
e **indipendente dal loss-guard Hurst** (segnali rigenerati senza guard:
|
||||||
|
la tesi regge anche nel regime trending). Stress: fee 2x 6/6, slippage
|
||||||
|
20 bps simmetrico 6/6, bear 2021-22 ok.
|
||||||
|
- **EXIT-22 invalidato SENZA mitigazione (tail-risk).** p99 MAE ~raddoppia
|
||||||
|
(MR01 ETH −38.8→−59.7%); finestra FTX 2022-11: MR02 ETH **−39% (worst −48%,
|
||||||
|
MAE −70%)** dove la base faceva +2.7% uscendo in 2-3 barre. E un fatto di
|
||||||
|
wiring: con `sl=0` il worker cade nel ramo `elif max_bars` → **il fallback
|
||||||
|
−2% è codice morto: no_sl live girerebbe SENZA ALCUNO stop**. Il disaster-SL
|
||||||
|
4× tiene ~95% del ritorno ma il worst pooled resta −47%.
|
||||||
|
- **Raccomandazione convergente del tail-auditor: EXIT-16** — neutralizza
|
||||||
|
quasi tutta la coda DA SOLO (p99/worst ≈ base; FTX: +2.4%) mantenendo
|
||||||
|
l'immunità ai wick. Mai usare uno stop stretto −2% come fallback (cappa
|
||||||
|
l'edge insieme alla coda).
|
||||||
|
|
||||||
|
## Fase 3 — Test decisivo a livello PORT06 (path canonico)
|
||||||
|
|
||||||
|
Replay di `build_trades`/`fade_daily_equity` con **parità ESATTA** (corr
|
||||||
|
1.00000, ritorni identici, PORT06 base == `Portfolio.backtest()` al centesimo:
|
||||||
|
FULL 6.47/4.10%, OOS 8.82/1.30% — i numeri 6.07/8.19 citati in CLAUDE.md sono
|
||||||
|
pre-refresh-dati). Poi SOLO il ramo SL sostituito col close-confirm 0.5·ATR:
|
||||||
|
|
||||||
|
| PORT06 | FULL Sharpe | FULL DD | FULL CAGR | OOS Sharpe | OOS DD | OOS CAGR |
|
||||||
|
|--------|------------|---------|-----------|-----------|--------|----------|
|
||||||
|
| base | 6.47 | 4.10% | 61% | 8.82 | 1.30% | 86% |
|
||||||
|
| **EXIT-16** | **7.84** | **2.60%** | **79%** | **10.06** | **1.15%** | **102%** |
|
||||||
|
|
||||||
|
Gate (lo stesso del loss-guard Hurst): **PROMOSSO** — migliora OGNI metrica,
|
||||||
|
FULL e OOS. Per-sleeve: ritorni FULL ~4-10x (MR01 BTC 299→1963%) e DD giù su
|
||||||
|
5/6 (eccezione MR02 ETH +3.4pp, assorbita in aggregato).
|
||||||
|
|
||||||
|
## Verdetto e piano di deploy (DA FARE, non ancora implementato)
|
||||||
|
|
||||||
|
**EXIT-16 close_confirm_sl, buffer 0.5·ATR14, SOLO sulle 6 fade.**
|
||||||
|
1. Param `sl_confirm_atr=0.5` nelle fade (default None = comportamento
|
||||||
|
attuale, per non rompere i backtest canonici/regression-lock).
|
||||||
|
2. `FadeStrategy.backtest` + `StrategyWorker.tick`: disattivare l'uscita
|
||||||
|
intrabar a sl0; aggiungere il ramo sul CLOSE del bar (long: esci se
|
||||||
|
`close < sl0 − 0.5·ATR14`; short speculare). TP intrabar e max_bars
|
||||||
|
INVARIATI. ATR14 causale già disponibile.
|
||||||
|
3. Shadow reale: nessun nuovo tipo d'ordine (il close-confirm esce al close
|
||||||
|
del worker → market reduce-only come oggi; il limit resting al TP di
|
||||||
|
v1.0.7 resta identico).
|
||||||
|
4. DIP01/pairs/rotation/tsmom/shape NON toccati.
|
||||||
|
|
||||||
|
## Caveat onesti
|
||||||
|
|
||||||
|
- L'uscita al close può essere PEGGIO di sl0 sui veri sfondamenti (già nei
|
||||||
|
numeri del backtest, ma la coda dipende dai gap: campione 1h crypto,
|
||||||
|
stress estremi sottorappresentati).
|
||||||
|
- Niente stop fisico sul book → rischio gap/disconnessione fra i poll
|
||||||
|
(mitigazione possibile in futuro: disaster-SL intrabar a 4× come cintura,
|
||||||
|
costa ~5% del guadagno).
|
||||||
|
- avg_bars +~18% (esposizione su; già nei numeri via non-overlap).
|
||||||
|
- I numeri per-sleeve FULL (ret 4-10x) vanno letti col solito sconto del
|
||||||
|
compounding su 8 anni; la decisione è presa sul gate PORT06, non su quelli.
|
||||||
|
|
||||||
|
## Scoperte collaterali da NON perdere
|
||||||
|
|
||||||
|
1. **Gap-through fill**: l'engine intrabar filla gli stop al livello anche
|
||||||
|
quando l'open è già oltre → bias sistematico PRO stop-stretti. Aggiungere
|
||||||
|
il fill `worse(level, open)` all'harness prima di future ricerche su stop.
|
||||||
|
2. **Wiring worker**: una strategia con `sl=0`+`max_bars` gira nel ramo
|
||||||
|
`elif max_bars` → il fallback −2% non si applica. Oggi nessuna strategia
|
||||||
|
attiva emette sl=0, ma il ramo va reso esplicito se mai si deployasse
|
||||||
|
una policy senza SL.
|
||||||
|
3. Il prior ladder di stamattina è confermato e generalizzato: 4 conferme
|
||||||
|
indipendenti che oltre il TP non c'è nulla da cavalcare.
|
||||||
|
|
||||||
|
## Costi della ricerca
|
||||||
|
|
||||||
|
34 agenti (23 explore + 10 verify + 1 PORT06), ~1.65M token subagente,
|
||||||
|
~70 minuti wall-clock su 2 core. Workflow: `exit-policy-explore`
|
||||||
|
(wf_4b23e922-f48) + `exit-policy-verify` (wf_f2bb98df-c43).
|
||||||
|
|
||||||
|
## IMPLEMENTATO E DEPLOYATO (sera stessa, v1.1.0)
|
||||||
|
|
||||||
|
Param `sl_confirm_atr` (default None = comportamento storico):
|
||||||
|
- `src/strategies/fade_base.py` `FadeStrategy.backtest` + `scripts/strategies/
|
||||||
|
MR01_bollinger_fade.py` (ha un backtest proprio, duplicato storico — patch
|
||||||
|
identica): ramo close-confirm al posto dell'SL intrabar; TP intrabar e
|
||||||
|
max_bars invariati; in modalità confirm il TP ha priorità nel bar (come la
|
||||||
|
policy validata in exit_lab).
|
||||||
|
- `src/live/strategy_worker.py` `tick`: con `sl_confirm_atr` nei params dello
|
||||||
|
sleeve, TP intrabar al livello come prima; stop SOLO se `close` sfonda
|
||||||
|
`sl ∓ buf·ATR14(df)`, uscita al prezzo corrente (reason `stop_loss`
|
||||||
|
invariata → il monitor stop-rate di `hourly_report` continua a funzionare).
|
||||||
|
- `scripts/portfolios/_defs.py`: `SL_CONFIRM_ATR = 0.5` sulle 6 fade (path
|
||||||
|
live, come `hurst_max`). Il backtest canonico resta NON filtrato.
|
||||||
|
- Test: `tests/portfolio/test_close_confirm_sl.py` (wick non stoppa; breach
|
||||||
|
sul close stoppa al close; TP intrabar resta; buffer regge; regressione
|
||||||
|
senza param). 59/59 verdi.
|
||||||
|
|
||||||
|
**Nota onesta dal sanity-check sul path GREZZO (nessun filtro):** il ritorno
|
||||||
|
esplode (MR02 ETH cap 366k→3.0M) ma il **DD per-sleeve SALE** (MR01 BTC
|
||||||
|
32→52%): senza trend-filter/hurst il close-confirm tiene posizione anche nei
|
||||||
|
trend profondi. La riduzione di DD vive nella config live (trend_max 3.0 +
|
||||||
|
hurst 0.55) e nella diversificazione — esattamente ciò che il test PORT06
|
||||||
|
canonico ha misurato (DD 4.10→2.60). Le tre protezioni sono COMPLEMENTARI:
|
||||||
|
hurst toglie l'esposizione al regime tossico, il trend-filter gli ingressi
|
||||||
|
sovra-estesi, il close-confirm i falsi stop da wick.
|
||||||
|
|
||||||
|
**Monitoraggio live:** lo stop-rate fade su `hourly_report` dovrebbe scendere
|
||||||
|
sotto il ~38% atteso post-hurst; gli stop avranno exit leggermente oltre il
|
||||||
|
livello (uscita al close), i TP restano al livello (e nello shadow reale al
|
||||||
|
limit reduce-only di v1.0.7).
|
||||||
|
|
||||||
|
Deploy: commit `a2579d2` (+ research `ad65a0b`), release v1.1.0.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 2026-06-04 — Exit ladder (80% del TP → esci 80%, runner con stop dinamico): SCARTATO
|
||||||
|
|
||||||
|
## La proposta
|
||||||
|
|
||||||
|
"E se all'80% del TP usciamo con l'80% della posizione, mettiamo uno SL dinamico
|
||||||
|
su quella soglia (profitto lockato) e lasciamo correre il 20% restante?"
|
||||||
|
|
||||||
|
Idea: uscita scalare — bancare prima la maggior parte del profitto (la soglia
|
||||||
|
all'80% viene toccata più spesso del TP pieno) e tenere un runner free-ride con
|
||||||
|
worst-case alla soglia, best-case oltre il TP.
|
||||||
|
|
||||||
|
## Il test (onesto, stessi segnali)
|
||||||
|
|
||||||
|
`scripts/analysis/partial_tp_ladder.py`: replay intrabar degli STESSI segnali
|
||||||
|
delle 3 fade (params live: `trend_max=3.0, ema_long=200, hurst_max=0.55,
|
||||||
|
min_tp_frac=0.0015`), engine identico a `fade_base` (ingresso a `close[i]`,
|
||||||
|
SL pieno prioritario nello stesso bar, fee 0.10% RT × leva 3 — il ladder NON
|
||||||
|
paga fee extra: due uscite ma stesso notional). Convenzioni conservative: se il
|
||||||
|
bar che tocca la soglia chiude oltre (rientro), il runner è stoppato subito.
|
||||||
|
Griglia: base vs ladder (f=0.8 q=0.8 — la proposta —, f=0.8 q=0.5, f=0.5 q=0.5),
|
||||||
|
FULL + OOS (nov 2023→), BTC+ETH.
|
||||||
|
|
||||||
|
## Risultati (proposta f0.8 q0.8)
|
||||||
|
|
||||||
|
| Sleeve | ret% FULL base→ladder | DD% | ret% OOS | win% OOS |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| MR01 BTC | 92 → 92 | 13.8 → 10.0 | 41 → 39 | 60 → 65 |
|
||||||
|
| MR01 ETH | 194 → 195 | 16.5 → 15.3 | 134 → 147 | 70 → 75 |
|
||||||
|
| MR02 BTC | 129 → 112 | 19.0 → 17.2 | 66 → 54 | 55 → 59 |
|
||||||
|
| **MR02 ETH** | **2135 → 1319** | 16.2 → 12.7 | **893 → 651** | 75 → 80 |
|
||||||
|
| MR07 BTC | 78 → 78 | 6.8 → 5.2 | 43 → 40 | 64 → 66 |
|
||||||
|
| MR07 ETH | 115 → 96 | 10.6 → 10.6 | 68 → 64 | 66 → 70 |
|
||||||
|
|
||||||
|
## Verdetto: NO-GO (gate fallito)
|
||||||
|
|
||||||
|
1. **Il runner non corre.** Su centinaia di trade finisce oltre il TP quasi mai
|
||||||
|
(~3% dei partial; 0-15 casi per sleeve) e viene **stoppato alla soglia nel
|
||||||
|
~60% dei tocchi**. È la fisica mean-reversion: il TP delle fade sta ALLA
|
||||||
|
MEDIA — lì il movimento è esaurito per costruzione. Il runner è un
|
||||||
|
trend-follow della coda, e i breakout rientrano (lezione squeeze).
|
||||||
|
2. **Il costo è concentrato sui winner migliori**: ogni TP pieno ora esce
|
||||||
|
all'80% della strada sulla quota grossa → MR02 ETH (lo sleeve più forte)
|
||||||
|
−38% ret FULL / −27% OOS. Sharpe per-trade ~piatto: non migliora il
|
||||||
|
rischio/rendimento, scambia ritorno con DD.
|
||||||
|
3. **Quello che compra** (win-rate +4-5pp, DD −1-4pp) è lo stesso profilo del
|
||||||
|
**vol-target sizing già scartato** ("abbassa il DD ma sacrifica ritorno");
|
||||||
|
il lavoro anti-DD lo fa già il loss-guard Hurst, che il gate l'ha passato
|
||||||
|
perché taglia SOLO i loser.
|
||||||
|
|
||||||
|
Nota dal grid: `f0.5 q0.5` su MR07 ETH fa Sharpe OOS 6.58 / win 82%, ma
|
||||||
|
distrugge MR02 (2135→531) → nessuna variante robusta su tutti gli sleeve,
|
||||||
|
niente cherry-picking per-sleeve.
|
||||||
|
|
||||||
|
Argomento esecutivo in più: il ladder raddoppierebbe la complessità dello
|
||||||
|
shadow appena deployato (v1.0.7: limit reduce-only al TP) — due resting order
|
||||||
|
per posizione, due riconciliazioni, e lo stop dinamico avrebbe il problema dei
|
||||||
|
trigger Deribit (nuovo order_id al trigger → fill non verificabile per
|
||||||
|
order_id).
|
||||||
|
|
||||||
|
**Conferma della lezione di famiglia:** i tweak d'exit che toccano i winner
|
||||||
|
falliscono il gate (time-stop, vol-target, ADX, vratio… e ora il ladder);
|
||||||
|
l'unico anti-perdite che passa resta il loss-guard Hurst, che riduce
|
||||||
|
l'esposizione nel regime tossico senza toccare le uscite.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 2026-06-04 — Shadow execution: divergenza sim/reale misurata + TODO limit reduce-only
|
||||||
|
|
||||||
|
## Deploy v1.0.6
|
||||||
|
|
||||||
|
Fix float deployata (`74670da` + release): `_quantize_step` con `Decimal` in
|
||||||
|
`src/live/execution.py` — `72*0.001 = 0.07200000000000001` causava `Invalid params`
|
||||||
|
su Deribit (1 `REAL_OPEN_FAIL` su MR07 il 2026-06-03). Resume pulito al restart.
|
||||||
|
|
||||||
|
## Misura della divergenza sim vs reale (primi 7 close shadow)
|
||||||
|
|
||||||
|
**Bilancio: PnL sim +11.85 USD vs reale +0.62 USD → delta −11.22 USD.**
|
||||||
|
Le fee NON sono il problema ($0.63 totali = 0.05%/lato, esattamente l'assunto del
|
||||||
|
backtest). Il problema è tutto nel **timing delle uscite**.
|
||||||
|
|
||||||
|
- **Aperture allineate**: slippage da −8 a +27 bps, mediana ~±7 bps. OK.
|
||||||
|
- **Chiusure take-profit: +235 bps di slippage medio** (MR07 ETH short: +257/+267/+283/+333 bps).
|
||||||
|
Causa strutturale, non liquidità: il sim esce *al livello TP* intrabar (come il
|
||||||
|
backtest), il reale chiude **a mercato solo al poll del tick**, quando il prezzo è
|
||||||
|
già rientrato (TP toccato a ~1790, fill reale a ~1840). Sim +3 USD a trade, reale ~0.
|
||||||
|
- **Stop-loss: stessa meccanica ma a favore** (MR02 SL: −0.68 reale vs −2.87 sim).
|
||||||
|
Le fade però hanno più TP che SL → netto negativo.
|
||||||
|
- **2 `REAL_CLOSE` non verificate** (order_id null, fallback su stima sim). Venue flat
|
||||||
|
(`get_positions()=[]`), nessuna posizione orfana — ma sorvegliare i prossimi close.
|
||||||
|
|
||||||
|
**Implicazione onesta: l'edge di MR07 non sopravvive all'esecuzione market-on-poll.**
|
||||||
|
Il backtest assume il fill al livello TP; il reale incassa il prezzo post-rimbalzo.
|
||||||
|
|
||||||
|
## TODO (richiesto, da implementare)
|
||||||
|
|
||||||
|
Nello shadow-execution (`src/live/execution.py` + `StrategyWorker`): all'apertura
|
||||||
|
della posizione reale piazzare su Deribit un **LIMIT reduce-only appoggiato al
|
||||||
|
livello TP** (e idealmente stop-market per lo SL), invece di chiudere a mercato al
|
||||||
|
poll — così il fill reale replica l'assunzione intrabar del backtest.
|
||||||
|
|
||||||
|
Dettagli da considerare:
|
||||||
|
- cancellare/aggiornare l'ordine resting se il sim esce per `max_bars` o al restart (resume);
|
||||||
|
- i 3 fade BTC condividono lo strumento (posizioni nettate per conto): il limit
|
||||||
|
reduce-only deve coprire la SOLA quota del worker, come già fa `close_amount`;
|
||||||
|
- verificare il fill del resting order via `get_trade_history` (order_id), come per i market.
|
||||||
|
|
||||||
|
## IMPLEMENTATO (sera): LIMIT reduce-only al TP
|
||||||
|
|
||||||
|
**Flusso nuovo.** A ogni `REAL_OPEN` verificato il worker piazza subito un
|
||||||
|
**limit reduce-only al livello TP** (lato opposto, stesso `amount` della SOLA
|
||||||
|
quota del worker — strumento condiviso fra i 3 fade per coin) → evento
|
||||||
|
`REAL_TP_RESTING` (o `REAL_TP_FAIL` → si resta sul vecchio market-on-poll).
|
||||||
|
Alla chiusura sim, qualunque sia la ragione, `_real_close` ora:
|
||||||
|
1. **cancella** il resting (innocuo se già fillato — e dopo il cancel nessun
|
||||||
|
fill può più arrivare, niente race);
|
||||||
|
2. **riconcilia i fill** (anche parziali) dal `get_trade_history` per order_id
|
||||||
|
(stessa fonte autorevole dei market), con retry se il cancel risulta
|
||||||
|
"già fillato" ma la history è in ritardo;
|
||||||
|
3. chiude a **market reduce-only solo la quota residua** (exit non-TP:
|
||||||
|
stop_loss/time_limit, o TP non raggiunto sullo strumento d'esecuzione);
|
||||||
|
4. ledger reale su **prezzo d'uscita combinato** (media pesata fill-TP +
|
||||||
|
market) e fee sommate. `REAL_CLOSE` logga `tp_order_id`,
|
||||||
|
`tp_filled_amount`, `market_amount`.
|
||||||
|
|
||||||
|
**Dettagli del TODO coperti:**
|
||||||
|
- `max_bars`/`stop_loss` → il cancel è il passo 1 di ogni chiusura;
|
||||||
|
- restart → `real_tp_order_id` è **persistito** in `status.json`: al resume il
|
||||||
|
resting resta in book e la prima chiusura lo riconcilia normalmente;
|
||||||
|
- quota del worker → il limit usa lo stesso `amount` dell'apertura (mai
|
||||||
|
`close_position`); il residuo è ri-quantizzato allo step (Decimal) dentro
|
||||||
|
`close_amount` (niente artefatti float tipo `0.072-0.024`);
|
||||||
|
- prezzi limit quantizzati al **tick** dello strumento (`quantize_price`,
|
||||||
|
BTC_USDC 0.5 / ETH_USDC 0.05 — Deribit rifiuta prezzi off-tick).
|
||||||
|
|
||||||
|
**SL: niente stop-market, decisione deliberata.** (a) Su Deribit un trigger
|
||||||
|
order, quando scatta, genera l'ordine eseguito con un **nuovo order_id** → il
|
||||||
|
fill non sarebbe verificabile via `get_trade_history` per order_id (il
|
||||||
|
requisito di verifica); (b) la misura di stamattina mostra che sul SL il
|
||||||
|
market-on-poll lavora **a favore** (il rimbalzo riduce la perdita). Lo SL resta
|
||||||
|
quindi market-on-poll.
|
||||||
|
|
||||||
|
**Bonus fee.** Quando il TP filla da resting il fill è **maker** (~0% su
|
||||||
|
Deribit) invece di taker 0.05% — il reale farà meglio dell'assunto 0.10% RT
|
||||||
|
sulle uscite TP.
|
||||||
|
|
||||||
|
**Verifica.** `live_shadow_smoke.py` esteso ai due percorsi e passato su
|
||||||
|
testnet: (A) resting in book → exit `time_limit` → cancel + market fallback,
|
||||||
|
conto flat; (B) TP già crossato → fill immediato del limit, chiusura
|
||||||
|
riconciliata dalla history **senza ordine market** (`market_amount=0`),
|
||||||
|
prezzo persino migliore del TP (+48 bps, taker sul cross). 54 test pytest OK.
|
||||||
|
|
||||||
|
**Caveat residuo onesto.** Se il processo muore DOPO il fill del resting e
|
||||||
|
PRIMA della chiusura sim, il worker al resume riconcilia al prossimo exit sim
|
||||||
|
(corretto); ma nell'intervallo la quota reale è già chiusa mentre il sim la
|
||||||
|
crede aperta — finestra breve e solo contabile. Inoltre il TP del sim è sul
|
||||||
|
feed (`BTC-PERPETUAL` inverse) mentre il limit vive sul lineare USDC: la base
|
||||||
|
fra i due può far fillare/non fillare il limit a cavallo del livello — il
|
||||||
|
fallback market copre il caso.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Diario — 2026-06-05 — Fix EXIT-16 live: confirm sul close di barra COMPLETATA + alert STALE_FEED
|
||||||
|
|
||||||
|
## Innesco
|
||||||
|
|
||||||
|
Crash ETH 2026-06-05: gli stop MR01/MR02 ETH sono scattati molto sotto il livello
|
||||||
|
nominale (MR02: sl 1636.28 → exit 1600.45; MR01: sl 1610.75 → exit 1594.35).
|
||||||
|
Analisi delle due concause dichiarate:
|
||||||
|
|
||||||
|
1. **EXIT-16 close-confirm** (by design): uscita al close oltre `sl ∓ 0.5·ATR14`,
|
||||||
|
non al livello — costo accettato e MODELLATO nel backtest validato (7.84/10.06).
|
||||||
|
2. **Feed gappato**: ETH testnet flat ~2h (1656.80/1655.55, 13:00–14:50 UTC), poi
|
||||||
|
una candela 14:55 da 1634 a 1600 — nessun prezzo esisteva tra SL ed exit. I fill
|
||||||
|
REALI confermano (1600.2 / 1589.7, slippage −1.6/−29 bps): non è un bug, è il mercato.
|
||||||
|
|
||||||
|
## Scoperta: divergenza implementativa REALE nel worker
|
||||||
|
|
||||||
|
Audit dei 3 stop ETH del 2026-06-04/05 contro le candele:
|
||||||
|
|
||||||
|
| Stop live | Close barra COMPLETATA al momento | Confirm | Verdetto |
|
||||||
|
|---|---|---|---|
|
||||||
|
| MR02 06:03 @1672.9 | 1682.25 (05:00) | ~1675.4 | **wick-stop**: il backtest NON avrebbe stoppato lì |
|
||||||
|
| MR02 15:00 @1600.45 | 1600.05 (14:00) | ~1621 | legittimo, fill ≈ backtest |
|
||||||
|
| MR01 15:01 @1594.35 | 1600.05 (14:00) | ~1594.4 | **wick-stop**: il backtest NON avrebbe stoppato lì |
|
||||||
|
|
||||||
|
**2 su 3 erano valutazioni sulla barra IN FORMAZIONE**: `tick` confrontava
|
||||||
|
`current_price` (close istantaneo della candela in corso al poll) con `sl − buf`.
|
||||||
|
Il backtest (`fade_base`) valuta il confirm SOLO sul close di barra completata
|
||||||
|
`c[j]`. La differenza reintroduce live esattamente la wick-sensitivity che EXIT-16
|
||||||
|
elimina: un dip infrabarra sotto il confirm stoppava anche se la barra chiudeva
|
||||||
|
sopra (e nei dip-che-rientrano la fade vince — è il suo edge). Live sarebbe stato
|
||||||
|
strutturalmente PIÙ stop-prone del backtest validato.
|
||||||
|
|
||||||
|
## Fix (`strategy_worker.py`, ramo sl_confirm_atr)
|
||||||
|
|
||||||
|
- Il confirm è valutato sul **close dell'ultima barra COMPLETATA**: l'ultima riga
|
||||||
|
del df è la candela in corso finché non è trascorsa la sua durata
|
||||||
|
(`now_ms < ts[-1] + bar_ms`, `bar_ms` = mediana dei delta timestamp → regge 1h/4h/1d).
|
||||||
|
- Il `buf` usa l'ATR14 alla stessa barra completata (prima: barra in formazione).
|
||||||
|
- Il **fill resta al prezzo corrente** al poll (lag ≈ stress `lag_close_exit`,
|
||||||
|
superato in exit-lab). TP intrabar sulla barra in corso INVARIATO (= backtest).
|
||||||
|
- Test nuovi in `test_close_confirm_sl.py` (sezione FIX 2026-06-05): il dip della
|
||||||
|
candela in corso non stoppa; il breach della completata stoppa anche se rimbalzato;
|
||||||
|
TP intrabar invariato. NB fixture: timestamp in ms ESPLICITI
|
||||||
|
(`Timestamp.now()` è risoluzione µs → `astype int64 // 10**6` darebbe secondi).
|
||||||
|
|
||||||
|
## Concausa feed: entry-guard TESTATA e BOCCIATA, solo osservabilità
|
||||||
|
|
||||||
|
Quantificato (1h, storico completo): segnali fade subito dopo barre flat = BTC
|
||||||
|
0.2–0.9%, **ETH 3.4–11.8%**. Backtest con skip di quei segnali (config live,
|
||||||
|
sl_confirm 0.5): **PEGGIORA tutti gli sleeve ETH** (MR01 +1344→+1256, MR02
|
||||||
|
+2372→+2250, MR07 +740→+621; BTC ~invariato). La candela-gap post-flat è
|
||||||
|
l'overshoot che la fade fada con profitto — e i fill reali post-crash lo
|
||||||
|
confermano (riaperture a −1.9 bps di slippage). **Niente skip degli ingressi.**
|
||||||
|
|
||||||
|
Aggiunto invece **alert Telegram `STALE_FEED`** (`runner._check_stale_feed`):
|
||||||
|
notifica quando un asset ha ≥2 barre 1h complete flat (worker ciechi) e al
|
||||||
|
risveglio col **gap %** del primo prezzo reale. Una notifica per episodio
|
||||||
|
(dedup per asset). Solo osservabilità, zero effetto sulle decisioni.
|
||||||
|
|
||||||
|
## Cosa NON è stato toccato (e perché)
|
||||||
|
|
||||||
|
- Il **costo del confirm** (uscita al close, non al livello nominale) resta: è il
|
||||||
|
trade-off validato di EXIT-16 (FULL 6.47→7.84, OOS 8.82→10.06) — i wick-stop
|
||||||
|
evitati pagano molto più dei close-stop più profondi.
|
||||||
|
- Il **gap-through** non è mitigabile lato exit: nessun prezzo esisteva tra SL ed
|
||||||
|
exit (fill reali ≈ sim). L'unica protezione strutturale resta la diversificazione
|
||||||
|
(17 sleeve) + i cap di famiglia.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Diario — 2026-06-05 — SL su SH01: ricerca multi-agente (verdetto: NO-SL, cap del peso)
|
||||||
|
|
||||||
|
## Innesco
|
||||||
|
|
||||||
|
Crash ETH del 2026-06-05 (1742→1546, −11%, con feed testnet FLAT ~2h e gap 1655→1600):
|
||||||
|
SH01 ETH si è preso la coda intera, **−15.6% in un trade** (long 1727.8 → time_limit 1594.35,
|
||||||
|
leva 2x) — il singolo trade peggiore del portafoglio live. SH01 non ha TP/SL per design
|
||||||
|
(exit solo a orizzonte H=12). Domanda: esiste uno SL che taglia le code senza distruggere
|
||||||
|
l'edge (win ~50%, edge tutto nell'asimmetria dei winner)?
|
||||||
|
|
||||||
|
## Numeri storici per anno (config live W24 H12 th0.58, netto fee 0.10% RT, leva 3x)
|
||||||
|
|
||||||
|
Somma per-trade in pp (leva 3x); equity-level ≈ ×0.15.
|
||||||
|
|
||||||
|
| Anno | BTC | ETH |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 2018 | −65.8 | +73.7 |
|
||||||
|
| 2019 | +87.7 | −19.3 |
|
||||||
|
| 2020 | +194.0 | **−293.1** |
|
||||||
|
| 2021 | +301.3 | +67.4 |
|
||||||
|
| 2022 | +64.3 | +79.1 |
|
||||||
|
| 2023 | +17.4 | +20.6 |
|
||||||
|
| 2024 | +110.0 | +108.1 |
|
||||||
|
| 2025 | +76.8 | +539.7 |
|
||||||
|
| 2026 | +59.0 | −29.5 |
|
||||||
|
|
||||||
|
BTC = motore robusto (8/9 anni+). ETH = fragile (DD 61%, win 48.8%): la coda non
|
||||||
|
protetta è un problema STRUTTURALE di ETH, non solo dell'episodio live.
|
||||||
|
|
||||||
|
## Infrastruttura (riusabile)
|
||||||
|
|
||||||
|
`scripts/analysis/sh01_exit_lab.py` — harness onesto stile exit-lab:
|
||||||
|
- cache segnali walk-forward (`data/cache/sh01_exit_lab.pkl`, 7248 BTC + 7386 ETH entries);
|
||||||
|
- engine intrabar con **fill GAP-AWARE**: stop fillato a `worse(livello, open[j])`, non
|
||||||
|
"al livello" — chiude il bias PRO-stop-stretti documentato il 2026-06-04 (54% di fill
|
||||||
|
ottimisti) e modella il caso reale del crash (gap 1655→1600 senza scambi intermedi);
|
||||||
|
- modalità stop `intrabar` e `close` (close-confirm stile EXIT-16), `after_bar` per
|
||||||
|
policy discrezionali, stress `lag_close_exit` (poll in ritardo);
|
||||||
|
- parity ESATTA con `explore_lab.simulate` (BTC +218.50%/1531 trade, ETH +80.13%/1257);
|
||||||
|
- protocollo: TRAIN fino 2023-11-01 (selezione SOLO lì, plateau ≥3 celle), OOS una volta.
|
||||||
|
|
||||||
|
Baseline (orizzonte puro): BTC TRAIN +127%/dd23/shrp2.09/worst−5.5 | OOS +41%/8/2.18/−3.1.
|
||||||
|
ETH TRAIN **−26%/dd61/shrp−0.16/worst−14.9** | OOS +143%/7/**3.60**/−4.6.
|
||||||
|
|
||||||
|
## Esito: 11 famiglie testate, 0 sopravvissute
|
||||||
|
|
||||||
|
Policy in `scripts/analysis/sh01_exit_policies/` (01-10 dagli agenti + 11 dal
|
||||||
|
completeness-probe della sintesi):
|
||||||
|
|
||||||
|
| # | Famiglia | Verdetto |
|
||||||
|
|---|---|---|
|
||||||
|
| 01 | ATR fisso intrabar (k 1–5) | NO: 0/7 celle migliorative sul train |
|
||||||
|
| 02 | ATR fisso close-confirm (k 1–5) | NO: k=4 migliora ETH train (shrp −0.16→+0.62, DD 61→41, worst −8.4) MA ETH-OOS peggiora (shrp 3.60→2.32, worst −6.6) |
|
||||||
|
| 03 | % fisso (1–5%, 2 modi) | NO: BTC↓, ETH-OOS ret 52% del baseline |
|
||||||
|
| 04 | Chandelier trailing | NO: cella isolata; ETH-OOS tutto peggio (4ª bocciatura della famiglia trailing) |
|
||||||
|
| 05 | Breakeven ratchet | NO: 0 celle migliorative |
|
||||||
|
| 06 | Giveback (profit-protection) | NO: dormiente sulle code (worst invariato), taglia winner BTC |
|
||||||
|
| 07 | Loser time-stop (m,x) | NO: ETH-OOS shrp↓ DD↑, BTC↓ |
|
||||||
|
| 08 | Disaster-cap LARGO close-confirm (k 3–6) | NO: k=4 plateau sul train ma ETH-OOS DD 7→11-15%, ret 59-74%; FABBRICA short da stop a −44/−54% |
|
||||||
|
| 09 | Swing strutturale | NO: 0/18 celle |
|
||||||
|
| 10 | Stop condizionale vol-regime | NO: il migliore dei falliti (ETH-OOS shrp 3.28 vs 3.60) ma BTC train <95% |
|
||||||
|
| 11 | Disaster-cap LARGO **intrabar** (k 5–12) | NO: k=10 cella isolata; BTC-OOS shrp 2.18→**0.92**, worst −3.1→−11.4 (il fill gap-aware rende lo stop più tossico del no-stop) |
|
||||||
|
|
||||||
|
**Pattern (5ª conferma della lezione exit-lab):** ogni stop abbastanza stretto da toccare
|
||||||
|
la coda ETH intacca il motore BTC; ogni stop abbastanza largo da risparmiare BTC non
|
||||||
|
arriva mai alla coda ETH. Per un segnale win~50% con edge nell'asimmetria dei winner,
|
||||||
|
OGNI SL taglia i winner-in-drawdown insieme ai loser. In più il fill gap-aware mostra
|
||||||
|
che proprio nei crash (quando lo stop servirebbe) il fill è al gap, non al livello:
|
||||||
|
lo stop intrabar largo PEGGIORA la coda OOS di BTC invece di proteggerla.
|
||||||
|
|
||||||
|
## Decisione
|
||||||
|
|
||||||
|
- **NO SL su SH01** (né `sl` né `sl_confirm_atr` per SH_BTC/SH_ETH: corretto E sicuro —
|
||||||
|
il fallback −2% del worker si applica solo se `sl` è presente).
|
||||||
|
- **La leva giusta è il PESO**: cap della famiglia SHAPE nel weighting di PORT06
|
||||||
|
(`weighting.py` supporta già `caps` per famiglia, come PAIRS 0.33). Dimezzare la quota
|
||||||
|
SHAPE ≈ dimezza l'impatto della prossima coda −15% sul conto, a costo ~nullo di Sharpe
|
||||||
|
(SH01 è un diversificatore, corr +0.08 col MASTER).
|
||||||
|
- **APPLICATO E DEPLOYATO (stesso giorno):** `caps={"PAIRS": 0.33, "SHAPE": 0.0588}` in
|
||||||
|
`_defs.py` (SHAPE 11.8%→5.9%, ≈ mezzo sleeve equal; SH_BTC=SH_ETH=0.0294, somma pesi 1.0,
|
||||||
|
verificato). Quantificato sul backtest PORT06:
|
||||||
|
|
||||||
|
| Config | FULL Sharpe | FULL DD | CAGR | OOS Sharpe | OOS DD |
|
||||||
|
|---|---:|---:|---:|---:|---:|
|
||||||
|
| precedente (SHAPE 11.8%) | 6.47 | 4.10% | 61.2% | 8.82 | 1.30% |
|
||||||
|
| **cap SHAPE 5.9% (scelto)** | 6.43 | 3.96% | 62.1% | 8.58 | 1.36% |
|
||||||
|
| SHAPE rimosso | 6.30 | 3.88% | 63.0% | 8.26 | 1.41% |
|
||||||
|
|
||||||
|
Il costo (−0.24 OOS Sharpe) è il premio dell'assicurazione su una coda che il backtest
|
||||||
|
daily NON modella (gap di feed, flat testnet). Rimuovere SHAPE costa troppo (8.26).
|
||||||
|
|
||||||
|
## Direzioni future (fuori scope SL, annotate dalla sintesi)
|
||||||
|
|
||||||
|
1. **Liquidity/staleness-gate sull'ENTRY** (skip ingressi dopo finestre di feed flat —
|
||||||
|
il flat 2h ha preceduto il gap del 2026-06-05): la direzione più promettente per il
|
||||||
|
gap-through specifico, merita uno studio dedicato.
|
||||||
|
2. Vol-target sizing per-trade su SH01 (sulle fade fallì, su SH01 mai testato).
|
||||||
|
3. SL solo-ETH: scartato anche concettualmente (il beneficio coda ETH è marginale/nullo
|
||||||
|
OOS in TUTTE le famiglie — non è un problema di uniformità del meccanismo).
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 2026-06-06 — Improvement sweep multi-agente (review live PORT06)
|
||||||
|
|
||||||
|
> Ricerca multi-agente del 2026-06-06 (106 agenti, 196 finding grezzi, 60 verificati avversarialmente, 37 validi / 23 uccisi).
|
||||||
|
> Workflow: port06-improvement-sweep (wf_56b7d4de-a06).
|
||||||
|
|
||||||
|
|
||||||
|
## 1. Quadro d'insieme
|
||||||
|
|
||||||
|
Il sistema PORT06 gira correttamente nella struttura (17 sleeve, runner pool/ledger == backtest), ma la review ha trovato **due classi sistematiche di problemi**, non bug isolati.
|
||||||
|
|
||||||
|
**(a) Divergenze live-vs-backtest per omissione di protezione.** Il pattern documentato del progetto è "il live AGGIUNGE protezione che il backtest canonico non ha" (Hurst, EXIT-16) → il live fa meglio del backtest sul DD. Qui si trova il contrario in più punti: il filtro `trend_max=3.0` è ON nel backtest canonico ma **OFF in produzione su tutte e 6 le fade** (base.py:91 / _defs.py:35-38); il cap `SHAPE=0.0588` v1.1.2 è **inerte** (sovrascritto da portfolios.yml) → SH01 gira a **2x peso** senza stop-loss; EXIT-16 non è esteso a DIP01 (l'unico sleeve BTC che esegue davvero); la forming-bar lesson non è portata a TR01 né a PairsWorker. Diversi diari (2026-06-04-partial-tp-ladder.md:15, 2026-06-04-exit-lab.md) documentano config che il codice NON implementa.
|
||||||
|
|
||||||
|
**(b) Calibrazione/sizing che diverge dal validato.** SH01 fa fuoco sul 25% delle barre vs 2.9% validato (th0.58 inerte per train-window troppo corto); i pairs girano a `pos=0.5` mentre tutta la validazione PR01 è `pos=0.15` (DD ~3.3x); TR01 sotto-carica le fee 2x (manca il fattore leva).
|
||||||
|
|
||||||
|
Tema ricorrente onesto: **la maggior parte delle "scoperte" sono ripristini di config già validate, non nuovo alpha.** I bug di config (cap, trend_max) vanno corretti subito; ogni cambio di *strategia* deve passare il gate PORT06/OOS (precedente FR01: robusto-individualmente ≠ migliora PORT06).
|
||||||
|
|
||||||
|
## 2. P0 — Protezione capitale / gap live
|
||||||
|
|
||||||
|
| Titolo | Cosa fare | Evidenza | Effort |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Cap SHAPE 0.0588 inerte → SH01 a 2x peso** | `load_active_portfolio` deve fare **deep-merge** dei caps (partire da `p.caps` e aggiornare con `ov["caps"]`), oppure ridichiarare il dict completo in `portfolios.yml:7` come `{PAIRS:0.33, SHAPE:0.0588}`. Ri-snapshot pesi, confermare sum=1.0. SH01 non ha SL by-design e ha già subìto un −15.6% live: il cap è l'UNICA protezione coda. | `src/portfolio/base.py:91-93` setattr sovrascrive caps; `portfolios.yml:7` `{PAIRS:0.33}`; `scripts/portfolios/_defs.py:75` `{PAIRS:0.33,SHAPE:0.0588}`; live `data/portfolios/PORT06/status.json` SH_BTC=SH_ETH=0.0588 (totale 0.1176 = 2.0x intended). Backtest canonico usa il cap giusto (FULL 6.43/OOS 8.58) → è una rottura di parità. | low |
|
||||||
|
|
||||||
|
> Nota: la stessa correzione risolve anche il sotto-peso ~9% di tutti gli altri non-pairs (0.0588 live vs 0.0647 intended); il rischio materiale è solo SHAPE.
|
||||||
|
|
||||||
|
## 3. P1 — Alto valore, testabile subito
|
||||||
|
|
||||||
|
| Titolo | Cosa fare | Evidenza | Effort |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **`trend_max=3.0` OFF in produzione su tutte le 6 fade** | Aggiungere `"trend_max":3.0, "ema_long":200` alla SleeveSpec FADE `_defs.py:36-37`. Ripristina la config del backtest canonico (look-ahead-free, EMA200 causale). MR02/MR07 lo onorano (non lo ignorano). Ri-validare a livello PORT06 (template `exit16_port06_impact.py`) perché Hurst+trend si sovrappongono. | `_defs.py:35-38` (no trend_max); `MR01_bollinger_fade.py:61` `get('trend_max')`→None; `combine_portfolio.py:47` canonico usa trend_max=3.0; `risk_management.py:242` lo applica a tutte e 3. Live: MR01_ETH/MR02_ETH hanno fadato il crash ETH 06-05 più volte (long@1658.9 poi long@1592.8 in caduta 1727→1517), tutti stoppati (trades.jsonl). Misurato (live-hurst-on, 3x): MR02_ETH DD 27.3%→16.2%, WR +8pp, Sharpe +1.34 (return ~dimezzato — trade-off DD/return). | low |
|
||||||
|
| **Disaster-bracket on-book durante outage feed** | Il poll-loop intero è in un try/except: in un outage le posizioni REALI restano **senza stop sul book e senza valutazione exit**. Piazzare un bracket lontano (~−30%) su Deribit per i fade eseguiti [MR01,MR02,MR07,DIP01] via `cerbero_client.py:137 set_stop_loss` (esiste, non cablato). Non spara mai in operatività normale → 0 costo Sharpe; pura assicurazione. | `runner.py:246-298` (try/except unico, sleep su errore); ETH gap p99.9=996bps, max=3301bps, peggior 3h −3492bps (`data/raw/eth_1h.parquet`); REAL_OPEN 06-05 verificati; `docs/diary/2026-06-04-exit-lab.md:119-121` lo segnala, mai implementato. Caveat: nei crash il fill è al gap, non al livello → cappa ma non al trigger. | low-med |
|
||||||
|
| **SH01 th0.58 inerte: fa fuoco sul 25% delle barre vs 2.9% validato** | **Ri-validare SH01 OOS con train-window pari al regime live (~8760 barre)** invece dell'expanding full-history. La LogReg su train piccolo è over-confident (mean max-proba 0.582 vs 0.536). NON limitarsi ad alzare th (la calibrazione resta sbagliata). Se l'edge non sopravvive nel regime live, riconsiderare SH01. | misurato: small-train frac≥0.58 = 44.8% vs full 3.8%; live-window 1197 entry (49.9% long) vs 139 (71.9% long), trade-rate 25.1% vs 2.9%. `SH01_shape_ml.py:60` th=0.58; tutta la validazione (`shape_ml_validate.py`) usa training expanding. È la causa meccanica del WR live 1/9. Blast radius limitato dal cap SHAPE (vedi P0). | low (test) |
|
||||||
|
| **Pairs a pos=0.5 vs validazione pos=0.15 (DD ~3.3x)** | Introdurre un `position_size` per-famiglia (plumb `SleeveSpec`/`build_worker_for`, runner.py:43-89) e portare i pairs a **0.15-0.25**. Ri-eseguire `combine` alla pos live prima del deploy. I pairs sono la famiglia a DD-grezzo più alto (ETH/BTC 47.6%) e **senza stop** (exit solo |z|≤z_exit o max_bars). | `portfolios.yml:14` pos=0.5; `pairs_worker.py:157` pnl=cap*pos*net; `pairs_research.py:29` POS=0.15; live ADA/ETH 06-05 net −8.52% = −4.26% sleeve (sarebbe −1.28% a 0.15). Choice deliberata (commento yaml), ma diverge dal validato. | low-med |
|
||||||
|
| **TR01: fee sotto-caricate 2x (manca leva)** | Moltiplicare il termine fee per `self.leverage` (basket_trend_worker.py:69) per allinearlo al return leveraged (riga 67) e al reference `fee=FEE_RT/2*LEV` (honest_improve2.py:150). | worker fee/flip 1.5e-05 vs corretto 3.0e-05 (POS0.15/LEV2/N5) = 2.0x. tsmom/rotation_worker sono consistenti → TR01 è l'unico difettoso. Drag assoluto piccolo (low turnover) ma bias ottimistico mono-direzionale che gonfia total_capital nel rebalance. | low |
|
||||||
|
| **PairsWorker agisce sulla barra IN FORMAZIONE** | Portare la EXIT-16 forming-bar logic ai pairs: calcolare `bar_ms` da `median(diff(ts))` e valutare z sull'ultima barra COMPLETA (entry+exit), come `strategy_worker.py:445-447`. | `pairs_worker.py:185` i=len(m)-1, :195 exit, :204-207 entry su barra in formazione; backtest `pairs_research.py:69-75` solo close settled; `validate_worker_pairs.py` non esercita il path → divergenza non testata. | low |
|
||||||
|
| **TR01 valuta crossover EMA sulla barra 4h in formazione** | In `tick` scartare la barra in formazione (`k=-1 if now_ms≥ts[-1]+bar_ms else -2`) e valutare il segnale sull'ultima 4h COMPLETA; correggere ANCHE il booking del return (riga 66 legge la finestra in formazione). | `basket_trend_worker.py:60-72` legge `[-1]` (forming); reference `honest_improve2.py:152` `range(n-1)` solo completi. Evidenza: SOL flip 0→1@85.66 poi 1→0@77.72 a 59min, stessa finestra 08-12 UTC (−9.3% intra-window glitch). Fix incompleto se si tocca solo il segnale. | low |
|
||||||
|
| **DIP01 senza EXIT-16 (ed è l'unico sleeve BTC che esegue reale)** | Aggiungere `"sl_confirm_atr":0.5` (e valutare `hurst_max`) ai params DIP01 `_defs.py:41-42` (il worker lo legge già, zero codice). **Ri-validare sul grid OOS proprio di DIP01 + a livello PORT06 con engine gap-aware** prima del deploy (DD standalone sale BTC 23→37%). | `_defs.py:41-42` DIP01 ha solo {min_tp_frac}; worker gira il branch intrabar wick-sensitive (`strategy_worker.py:466`) invece del close-confirm (:429). Riproducibile: BTC FULL 299→726/OOS 60→168, ETH FULL 187→898/OOS 221→255. I 3 fade BTC sono flat (0 ordini reali); DIP01_BTC è l'UNICO BTC con round-trip reale → gap non teorico. | low (fix) / med (validazione) |
|
||||||
|
| **TSM01 252d lookback: shortfall su un asset tronca l'inner-join sotto need=253, tick() esce in silenzio** | Aggiungere un WARN/Telegram quando `panel<need` (gated su "era già operativo" per evitare falsi positivi al cold-start). Coprire anche RotationWorker (stesso silent return). | `runner.py:37` 1d=440; `tsmom_worker.py:53-55` need=253, return muto; `rotation_worker._panel how='inner'` tronca al più corto; live margine 188 (OK ora) → latente, non attivo. | low |
|
||||||
|
| **Osservabilità: TR01/ROT02/TSM01 invisibili in hourly_report** | `hourly_report.collect()` filtra su chiavi (`event`, `in_position`) che i worker multi-asset non emettono → 100% delle loro righe scartate. Aggiungere `in_position` ai `_save()` dei 3 worker e/o esporre `status_summary` (long correnti + giorni-dall'ultimo-flip). Distingue "flat in chop" da "wiring rotto". | TR01 2 record (entrambi 06-03, lo SOL whipsaw), flat da allora; ROT02/TSM01 0 trade (rispettivamente cash / risk-off da 06-01, by-design). `hourly_report.py:105,111`; worker `_log`/`_save` senza `event`/`in_position`. | low |
|
||||||
|
| **MR07 reale: ripristino-fill non esercitato (short-side TP-resting mai testato)** | Smoke testnet `live_shadow_smoke.py` con `direction=-1` per esercitare il limit reduce-only BUY sotto mercato (può crossare subito → path `resting_fills`). MR07 (+ tutti i worker) ha 0 TP-resting filled finora → reconciliation completamente non testata in produzione. | MR07_ETH 5-6 REAL_OPEN tutti 06-03 PRE-fix (TP-resting 2026-06-04); +257/267/283/333bps slippage legacy (sunk, non ricorrente). Tutti i REAL_TP_RESTING verificati sono side=buy (long); il path short non è mai stato esercitato né in ledger né negli smoke. `strategy_worker.py:253` `_place_real_tp`. | low |
|
||||||
|
|
||||||
|
## 4. P2 — Filoni di ricerca
|
||||||
|
|
||||||
|
- **MR01 ETH k=2.0 vs k=2.5 deployato.** k=2.0 è uniformemente meglio OOS su ETH (no-filtro +1238% vs +487%; trend-filtrato +569% vs +196%) e neutro su BTC sotto filtro live. Non è nuovo overfit: è il config per-asset *documentato* (MR01:15-16) che il k=2.5 universale ha silenziosamente abbandonato. Da confermare a livello PORT06 con i param live (hurst/sl_confirm). `risk_management.py:40,44`.
|
||||||
|
- **Limit-order maker entry sui fade — NO-GO confermato.** Strutturalmente infattibile: il segnale fade fa fuoco quando il close ha già bucato la banda (mediana 33bps sotto); un buy-limit al livello è marketable→taker; un limit più profondo manca i winner che rimbalzano in 1-4 min. Negativo-EV, non implementare. `MR01_bollinger_fade.py:84,93`; live MR07_ETH 5 open→TP in ~1.4min.
|
||||||
|
|
||||||
|
## 5. P3 / Scartati in verifica
|
||||||
|
|
||||||
|
- **Commento errato in `_defs.py:17-18`** ("MR02/MR07 ignorano min_tp_frac") — falso (lo leggono e applicano, MR02:46,72), ma il filtro è di fatto inerte su MR02/MR07 nel campione → solo igiene doc, no PnL.
|
||||||
|
- **TSM01 inerte (0 trade da 06-03)** — corretto by-design (BTC sotto SMA100 dal 06-01 + tutti gli asset consensus negativo). Solo diagnostica; NON redistribuire il capitale (riduce la diversificazione quando il regime gira).
|
||||||
|
- **Time-limit fade NON sono un leak** — myth-busting: il bucket max_bars è net-positivo OOS (+118÷+195bps, +154%/+335% totale); il vero loser è lo SL (già gestito). NON accorciare max_bars.
|
||||||
|
- **Fill max_bars next-open vs close** — wash misurato (5/6 sleeve identici); il worker già filla ~next-open. Nessuna azione.
|
||||||
|
- **SH01 H=12 fuori scope `exit_lab.py`** — routing: usare `sh01_exit_lab.py` (gap-aware) per qualsiasi studio orizzonte SH01. Clarificazione, non bug.
|
||||||
|
- **PairsWorker bars_held conta poll-con-nuovo-ts non barre reali** — latente (0 time_limit su 9 close), bite solo dopo outage >2h con z ancora oltre soglia; rischio di introdurre exit-anticipato se bar_ms via median-diff su feed testnet flaky → usare bar_ms fisso. Igiene, bassa priorità.
|
||||||
|
|
||||||
|
## 6. Piano consigliato (2 settimane)
|
||||||
|
|
||||||
|
**Settimana 1 — protezione capitale e parità (config-only, niente cambio di strategia):**
|
||||||
|
|
||||||
|
1. **Giorno 1 — P0 cap SHAPE.** Deep-merge dei caps in `base.py:91`; ri-snapshot pesi PORT06, confermare SH totale=0.0588, sum=1.0, nessuno sleeve affamato. Verificare contro il backtest canonico (FULL 6.43/OOS 8.58). Questo dimezza l'esposizione SH01-no-SL già colpito dalla coda.
|
||||||
|
2. **Giorno 1-2 — TR01 fee 2x + forming-bar (TR01 e Pairs).** Fix puramente di parità/contabilità, nessun gate OOS necessario (avvicinano il live al backtest validato). Coprire il silent-return TSM01/Rotation con WARN.
|
||||||
|
3. **Giorno 2-3 — disaster-bracket on-book + osservabilità outage.** Cablare `set_stop_loss` lontano (~−30%) sui 4 fade eseguiti; aggiungere alert su N poll falliti consecutivi con `real_in_position=True` e log del gap% al risveglio. Smoke testnet (€0). Pura assicurazione coda, 0 costo Sharpe.
|
||||||
|
4. **Giorno 3 — osservabilità report** (TR01/ROT02/TSM01 in hourly_report) + smoke MR07 short-side TP-resting.
|
||||||
|
|
||||||
|
**Settimana 2 — ripristini di config validata, ognuno dietro il gate PORT06/OOS:**
|
||||||
|
|
||||||
|
5. **Giorno 4-6 — `trend_max=3.0` sulle 6 fade.** Implementare in `_defs.py`, poi **ri-eseguire il gate PORT06** (template `exit16_port06_impact.py`) misurando trend-ON+hurst+EXIT-16 vs attuale trend-OFF: deploy SOLO se Sharpe OOS non peggiora e DD scende (sovrapposizione hurst+trend va quantificata).
|
||||||
|
6. **Giorno 6-8 — pos pairs per-famiglia.** Plumbing `SleeveSpec.position_size`; ri-eseguire `combine` a pos live (target 0.15-0.25); scegliere il punto sul trade-off DD/ritorno; deploy.
|
||||||
|
7. **Giorno 8-10 — DIP01 EXIT-16+hurst.** Validare sul grid OOS proprio di DIP01 (famiglia honest, meccanismi tarati su fade) **con engine gap-aware**, poi gate PORT06. Deploy solo se passa entrambi.
|
||||||
|
8. **Giorno 10-12 — SH01 ri-validazione train-window.** Ri-eseguire validazione con train cap ~8760 barre (regime live). Se l'edge sopravvive → tarare th/calibrazione su slice held-out; se non sopravvive → riconsiderare SH01 (NON ri-tunare il th a forza). **Mai impostare sl/sl_confirm_atr su SH.**
|
||||||
|
|
||||||
|
Filoni P2 (MR01 k ETH) e cleanup P3 in coda, opportunistici. Regola trasversale: i punti 1-4 sono fix di bug/parità e vanno per primi; 5-8 toccano il comportamento di trading e **nessuno va in produzione senza superare il gate OOS + PORT06** (precedente FR01: robusto individualmente ≠ migliora il portafoglio).
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 2026-06-07 — DIP01 EXIT-16: PROMOSSO (gate gap-aware, il più netto della serie)
|
||||||
|
|
||||||
|
Punto 9 della roadmap sweep: DIP01 era l'unico sleeve BTC con esecuzione REALE
|
||||||
|
round-trip e girava ancora sul branch SL intrabar wick-sensitive. EXIT-16 era
|
||||||
|
validato SOLO sulle fade: estensione a una strategia honest dietro doppio gate
|
||||||
|
(grid proprio di DIP01 + PORT06), con engine GAP-AWARE come da lezione exit-lab.
|
||||||
|
|
||||||
|
## Metodo (`scripts/analysis/dip01_exit16_impact.py`)
|
||||||
|
|
||||||
|
- Parità con la equity canonica: corr **1.00000**, ret +322% identico.
|
||||||
|
- **Engine gap-aware**: nel ramo orig lo SL filla a `worse(livello, open[j])` —
|
||||||
|
l'engine canonico filla "al livello" anche su gap-through (54% dei casi per stop
|
||||||
|
tight) e regalerebbe al ramo intrabar un vantaggio fittizio.
|
||||||
|
- Grid 3×3×2 (z_in × sl_atr × max_bars) su BTC (deployato) ed ETH (robustezza);
|
||||||
|
train (pre 2024-10-12) e OOS separati. Buffer plateau {0.4, 0.5, 0.75, 1.0}.
|
||||||
|
- NB `hurst_max` NON valutato su DIP01: il gate trendmax dello stesso giorno ha
|
||||||
|
mostrato che è ridondante-dannoso post-EXIT-16.
|
||||||
|
|
||||||
|
### Trappola di metodo trovata: l'ancora bfill di `_daily_equity`
|
||||||
|
|
||||||
|
La prima parità falliva (corr 0.9956, −16pp) con **engine identici** (662 trade,
|
||||||
|
stessi return, stesso capitale finale). Causa: la convenzione canonica
|
||||||
|
`_daily_equity` (serie a punti trade-exit → `reindex(IDX).ffill().bfill()`)
|
||||||
|
**ancora il primo valore della finestra al PRIMO trade dentro IDX** (es. 942.54
|
||||||
|
dopo le perdite di gennaio 2021), non al capitale portato avanti dall'ultimo trade
|
||||||
|
del 2020 (979.23). Il mio replay hourly era più "corretto" ma per parità va
|
||||||
|
replicata la convenzione. **Finding aperto**: questo bias tocca le metriche
|
||||||
|
canoniche di TUTTI gli sleeve a punti-trade (honest/pairs/tsm) — l'ancoraggio
|
||||||
|
distorce leggermente il punto di partenza della finestra. Da valutare a parte se
|
||||||
|
correggerlo OVUNQUE in un colpo solo (cambierebbe i numeri canonici di riferimento).
|
||||||
|
|
||||||
|
## Risultati
|
||||||
|
|
||||||
|
- **Grid BTC: 36/36** (EXIT-16 ≥ orig gap-aware in train E OOS in tutte le 18
|
||||||
|
celle). OOS Sharpe ~2-4x (cella canonica 1.47→3.48), DD giù in 14/18 celle.
|
||||||
|
- **Grid ETH: 35/36** (unica eccezione la cella estrema z3.0/sl3.0/mb48, marginale).
|
||||||
|
- **Plateau buffer piatto**: OOS DD 6.4% identico da 0.4 a 1.0; scelto 0.5 (== fade).
|
||||||
|
- **PORT06**: FULL Sharpe 6.43→6.61, DD 3.96→3.58 | OOS Sharpe 8.58→8.77, DD
|
||||||
|
1.36→1.34. Migliora tutto. **PROMOSSO**.
|
||||||
|
|
||||||
|
È la 5ª conferma indipendente del principio EXIT-16: gli stop intrabar da wick
|
||||||
|
sono falsi negativi per le strategie mean-reversion — l'overshoot che buca lo
|
||||||
|
stop è il movimento che si sta comprando.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
`_defs.py`: `"sl_confirm_atr": 0.5` nei params DIP01_BTC (il worker legge già il
|
||||||
|
param — zero codice). Live path: TP intrabar invariato, SL solo su close confermato,
|
||||||
|
disaster-bracket on-book già attivo per gli outage.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 2026-06-07 — Gate position_size PAIRS: 0.5 → 0.20 (assicurazione, come il cap SHAPE)
|
||||||
|
|
||||||
|
Punto 8 della roadmap improvement-sweep: i pairs giravano col `position_size`
|
||||||
|
GLOBALE 0.5 a leva 2 (esposizione 1.0 della fetta) mentre TUTTA la validazione PR01
|
||||||
|
è a pos 0.15 lev 3 (esposizione 0.45) — ~2.2x il validato su una famiglia **senza
|
||||||
|
stop** (exit solo |z|≤z_exit o max_bars). Scelta deliberata (commento yml) ma mai
|
||||||
|
gateata. Evidenza live: ADA/ETH 2026-06-05 net −8.52% = −4.26% di sleeve in un trade.
|
||||||
|
|
||||||
|
## Metodo
|
||||||
|
|
||||||
|
`scripts/analysis/pairspos_port06_impact.py`: le 5 equity pairs ricostruite con
|
||||||
|
`pairs_sim` alla **leva live (2x)** su griglia pos {0.50, 0.25, 0.20, 0.15},
|
||||||
|
innestate nel PORT06 canonico (pesi cap PAIRS 0.33 + SHAPE 0.0588). Parità
|
||||||
|
verificata: `pairs_sim(0.15, lev3)` == equity canoniche (corr 1.00000, 5/5).
|
||||||
|
|
||||||
|
## Risultati
|
||||||
|
|
||||||
|
| pos | esp. | FULL Sh / DD | CAGR | OOS Sh / DD | fam DD | worst pair DD grezzo |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 0.50 (live) | 1.00 | 6.69 / 4.77% | 130% | 9.05 / 3.40% | 14.0% | ETH/BTC 78% |
|
||||||
|
| 0.25 | 0.50 | 6.50 / 4.04% | 67% | 8.69 / 1.54% | 7.2% | 51% |
|
||||||
|
| **0.20** | **0.40** | 6.33 / 3.90% | 57% | 8.43 / **1.26%** | 5.8% | 44% |
|
||||||
|
| 0.15 | 0.30 | 6.03 / 3.78% | 47% | 7.99 / 1.33% | 4.3% | 35% |
|
||||||
|
|
||||||
|
Letture:
|
||||||
|
- Il gate meccanico (OOS Sharpe non peggiora) **boccia ogni riduzione**: ovvio —
|
||||||
|
ridurre l'esposizione di una famiglia che in-sample non è mai esplosa costa sempre
|
||||||
|
Sharpe. Ma il +130% CAGR / +2.6e9% compounded di ETH/BTC a pos 0.5 è fantasia da
|
||||||
|
compounding: dice solo che il tail event non è ancora successo in-sample.
|
||||||
|
- **0.20 è il ginocchio**: OOS DD 1.26% batte anche 0.15 (1.33% — oltre il ginocchio
|
||||||
|
si taglia ritorno senza guadagnare DD); esposizione 0.40 ≈ il validato 0.45.
|
||||||
|
- Decisione di tipo assicurativo, identica al precedente cap SHAPE (adottato pagando
|
||||||
|
−0.24 OOS Sharpe).
|
||||||
|
|
||||||
|
## Decisione (utente): pos PAIRS = 0.20
|
||||||
|
|
||||||
|
Implementazione:
|
||||||
|
- `runner.pos_for_spec(sid, global_ps, family_overrides)` — override per-famiglia
|
||||||
|
(chiave = `weighting.family_of`), plumbato in `build_worker_for`.
|
||||||
|
- `portfolios.yml`: `position_size_family: {PAIRS: 0.20}` (globale 0.5 invariato
|
||||||
|
per fade/dip/shape).
|
||||||
|
- Test: `test_pos_for_spec_family_override`, `test_build_pairs_worker_position_size`.
|
||||||
|
|
||||||
|
Nota di transizione: la posizione LTC/ETH aperta al deploy (entrata a sizing 0.5)
|
||||||
|
verrà chiusa col pos nuovo 0.20 → il pnl % sleeve di quel singolo trade è bookato
|
||||||
|
alla taglia nuova (one-time, conservativo).
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 2026-06-07 — SH01 punto-10: il regime live (train 365g) NON è robusto → bootstrap full-history
|
||||||
|
|
||||||
|
Punto 10 della roadmap sweep: ri-validare SH01 col train-window del regime live
|
||||||
|
(~8760 barre, il `_ML_LOOKBACK_DAYS=365` del runner) invece dell'expanding
|
||||||
|
full-history usato da tutta la validazione storica.
|
||||||
|
|
||||||
|
## Ri-validazione (`scripts/analysis/sh01_trainwindow_validate.py`)
|
||||||
|
|
||||||
|
`ml_wf_entries(train_window=...)` rolling su storia piena ≈ il regime live.
|
||||||
|
Protocollo explore_lab (FULL / OOS 30% / sweep fee / anni positivi), W24 H12 th0.58.
|
||||||
|
|
||||||
|
| BTC train | trade-rate | FULL / OOS | Sharpe | fee 0.2%: FULL/OOS | anni+ | robusto |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 8760 (live) | **21.7%** | +82 / +74 | 1.31 | **−42** / +20 | 6/9 | ❌ |
|
||||||
|
| 17520 (2y) | 15.4% | +166 / +96 | 2.05 | +9 / +47 | 6/9 | ❌ |
|
||||||
|
| 26280 (3y) | 11.8% | +348 / +95 | 3.09 | +109 / +61 | 7/9 | ❌ |
|
||||||
|
| EXPANDING | 9.8% | +219 / +42 | 2.72 | +60 / +26 | 8/9 | ✅ |
|
||||||
|
|
||||||
|
ETH a tw=8760: **FULL −33%, Sharpe −0.02** (lancio di moneta che paga fee).
|
||||||
|
|
||||||
|
Conferme: (a) la diagnosi del sweep era esatta (trade-rate 22-26% live vs 10%
|
||||||
|
validato — soglia inerte per over-confidence su train piccolo); (b) la
|
||||||
|
progressione è MONOTONA: l'edge di SH01 *è* la memoria lunga; (c) lo sweep della
|
||||||
|
soglia nel regime corto dà risultati instabili e incoerenti fra asset → ri-tunare
|
||||||
|
th sarebbe curve-fitting sul rumore di calibrazione (come da warning del piano).
|
||||||
|
|
||||||
|
## Decisione (utente): bootstrap full-history
|
||||||
|
|
||||||
|
Ripristinare in live ESATTAMENTE il regime validato (expanding full-history):
|
||||||
|
|
||||||
|
1. **`ml_wf_entries(last_block_only=True)`** — fitta/predice SOLO l'ultimo blocco
|
||||||
|
del walk-forward. I confini dei blocchi sono deterministici (start + k·retrain)
|
||||||
|
e al worker servono solo i segnali recenti → le entries dell'ultimo blocco sono
|
||||||
|
**identiche per costruzione** al tail del WF completo (parity test esatto in
|
||||||
|
`tests/portfolio/test_sh01_last_block.py`). Costo live: **0.6 s/tick** su 73k
|
||||||
|
barre (1 fit LogReg) vs ~140 fit del WF completo.
|
||||||
|
2. **Bootstrap storia nel runner**: per gli sleeve `ml` il tick riceve
|
||||||
|
`concat(parquet_locale < feed_start, feed_live)` (`runner._with_history`).
|
||||||
|
Gap-guard: se il parquet è stantio oltre il lookback del feed → solo feed +
|
||||||
|
WARN una-tantum (meglio il regime corto dichiarato che una serie col buco).
|
||||||
|
Manutenzione: tenere fresco il parquet con `download_all()` (oggi al 2026-05-28,
|
||||||
|
il feed copre 365g → margine ~11 mesi).
|
||||||
|
3. `_defs.py`: `params={"last_block_only": True}` sugli sleeve SHAPE (solo path
|
||||||
|
live; il backtest canonico resta WF completo).
|
||||||
|
|
||||||
|
Effetto atteso live: trade-rate SH01 da ~25% a ~10% delle barre, selettività
|
||||||
|
della soglia ripristinata, WR atteso ≈ validazione (acc OOS ~56% BTC).
|
||||||
|
Il monitor naturale è il report orario (SH01 farà MENO trade).
|
||||||
|
|
||||||
|
Nota: `_ML_LOOKBACK_DAYS` resta 365 (il feed serve comunque per le barre recenti
|
||||||
|
e come fallback se il parquet è stantio).
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# 2026-06-07 — Implementazione fix improvement-sweep (P0 + punti 2-6, settimana 1)
|
||||||
|
|
||||||
|
Implementati e deployati i fix "settimana 1" del piano dello sweep 2026-06-06
|
||||||
|
(tutti fix di parità/protezione/osservabilità, nessun cambio di strategia → nessun
|
||||||
|
gate OOS richiesto). Versioni: v1.1.3 (punti 1-4), v1.1.4 (punti 5-6).
|
||||||
|
|
||||||
|
## P0 — cap SHAPE 0.0588 ripristinato (v1.1.3)
|
||||||
|
|
||||||
|
`portfolios.yml` ridichiara il dict caps COMPLETO `{PAIRS: 0.33, SHAPE: 0.0588}`
|
||||||
|
(l'override sostituisce interamente i caps di `_defs.py` via setattr in
|
||||||
|
`base.py:load_active_portfolio` — footgun ora commentato nel yml). Verificato live
|
||||||
|
post-restart: SH_BTC+SH_ETH = 0.0588 totale (prima 0.1176 = 2x intended), non-cappati
|
||||||
|
risaliti a 0.0647, sum = 1.0 — parità col backtest canonico (FULL 6.43 / OOS 8.58).
|
||||||
|
|
||||||
|
## Punti 2-4 — parità worker multi-asset (v1.1.3)
|
||||||
|
|
||||||
|
- **TR01 fee × leva**: `POS · LEV · fee_rt/2` per flip come il reference
|
||||||
|
`honest_improve2._tr_basket_daily:150` (prima sotto-caricata 2x).
|
||||||
|
- **TR01 forming-bar**: crossover EMA E booking del return solo su barre 4h COMPLETE
|
||||||
|
(riga -1 = candela in corso finché non è trascorsa la sua durata, pattern EXIT-16).
|
||||||
|
Evidenza live che non può più ripetersi: flip SOL 0→1→0 in 59min stessa finestra 4h.
|
||||||
|
- **PairsWorker forming-bar**: entry ED exit sul close di barra COMPLETA, come il
|
||||||
|
backtest `pairs_research` (close settled).
|
||||||
|
- **TSM01/ROT02 silent-return**: WARN log + Telegram `PANEL_SHORT` quando il panel
|
||||||
|
inner-join è sotto il lookback (helper condiviso `_warn_panel_short` in
|
||||||
|
rotation_worker), gated su "era già operativo", una notifica per episodio.
|
||||||
|
|
||||||
|
Verifiche: 72 test (7 nuovi), `validate_worker_pairs` ESATTO, `validate_honest_workers`
|
||||||
|
invariato. **Nota onesta**: TR01 replay worker −44% vs reference +42% è IDENTICO
|
||||||
|
pre/post fix → divergenza di convenzione pre-esistente (capitale-unico con
|
||||||
|
`mean(rets)` solo sugli asset in posizione vs media-equity 1/N del reference).
|
||||||
|
Da rivisitare a parte: "stesso ordine di grandezza" oggi non regge più.
|
||||||
|
|
||||||
|
## Punto 5 — disaster-bracket on-book + alert outage (v1.1.4)
|
||||||
|
|
||||||
|
Durante un outage (poll-loop in except) le posizioni REALI restavano senza stop sul
|
||||||
|
book e senza valutazione exit. Ora:
|
||||||
|
|
||||||
|
- **`ExecutionClient.place_disaster_sl`**: STOP_MARKET reduce-only a ~−30%
|
||||||
|
dall'ingresso (trigger sul mark), piazzato a ogni REAL_OPEN sui fade eseguiti
|
||||||
|
(MR01/MR02/MR07/DIP01) e cancellato in `_real_close` (prima del TP-reconcile).
|
||||||
|
In operatività normale non scatta mai → 0 costo Sharpe; nei crash il fill è al
|
||||||
|
gap (cappa la coda, non la elimina). Config: `overrides.execution.disaster_sl_pct`
|
||||||
|
(default 0.30, 0 = off). `real_dsl_order_id` persistito (resume-safe).
|
||||||
|
- **SCOPERTA**: il `set_stop_loss` di cerbero-mcp è un `private/edit` Deribit (solo
|
||||||
|
ordini APERTI) → inutilizzabile su market già fillati; il bracket va piazzato come
|
||||||
|
trigger order autonomo via `place_order(type="stop_market")` (già supportato).
|
||||||
|
- **Semantica cancel trigger order** (verificata su testnet): la cancel risponde con
|
||||||
|
lo stato AL MOMENTO della cancel (`untriggered` = successo; il re-cancel dà
|
||||||
|
`order_not_found`); `error` = non più in book (probabile trigger scattato → il
|
||||||
|
market a valle filla 0 e REAL_CLOSE esce verified=False).
|
||||||
|
- **Alert `FEED_OUTAGE`**: dopo 5 poll falliti consecutivi (~5 min) Telegram con
|
||||||
|
l'elenco delle posizioni reali aperte; notifica di ripresa con durata.
|
||||||
|
|
||||||
|
Smoke testnet (`live_shadow_smoke.py`, esteso): 4 scenari long+short — il path
|
||||||
|
**short-side TP-resting** (BUY limit reduce-only) non era MAI stato esercitato.
|
||||||
|
Tutti verdi: resting/cross-immediato due lati, DSL piazzato/cancellato (verificato
|
||||||
|
zero ordini orfani sul book), conto flat a fine smoke.
|
||||||
|
|
||||||
|
## Punto 6 — osservabilità multi-asset (v1.1.4)
|
||||||
|
|
||||||
|
- `in_position` aggiunto ai `_save()` di TR01/ROT02/TSM01.
|
||||||
|
- `hourly_report`: nuova sezione **MULTI-ASSET** (book corrente | ultimo flip |
|
||||||
|
freschezza status) — prima i 3 worker erano invisibili nel report (collect()
|
||||||
|
filtra su event/in_position che non emettevano): impossibile distinguere
|
||||||
|
"flat/risk-off by-design" da "wiring rotto". I multi-asset sono esclusi dalla
|
||||||
|
tabella IN CORSO (assume entry/bars single-leg).
|
||||||
|
|
||||||
|
## Code review multi-agente del giorno stesso (v1.1.7)
|
||||||
|
|
||||||
|
Review a 7 angoli su `8c4e1cd..HEAD` + check trades live. Il candidato top dei finder
|
||||||
|
("stop_market senza trigger") è REFUTATO: cerbero-mcp traduce `price→trigger_price +
|
||||||
|
trigger=mark_price` e in produzione 2 DSL erano armati + 1 ciclo completo pulito.
|
||||||
|
Quattro fix applicati:
|
||||||
|
|
||||||
|
1. **Alert `REAL_DIVERGENCE`** (|slippage sim/reale| ≥ 100bps a open/close). Scoperta
|
||||||
|
dal check trades: alle 10:37 uno **spike print testnet** (candela 10:00 H=65618 con
|
||||||
|
O/C~62400) ha fatto shortare alle 3 fade BTC un close fantasma a 65266.5 — il reale
|
||||||
|
ha fillato correttamente a ~62395 (−440bps) ma il sim ha bookato +2.26 mai esistiti
|
||||||
|
(MR07 reale −0.13). Prima passava in silenzio.
|
||||||
|
2. **`FEED_OUTAGE` anche su feed degradato senza eccezione** (HTTP 200 con candles
|
||||||
|
vuote → i worker saltavano il tick in silenzio e lo streak restava 0). Helper unico
|
||||||
|
`_outage_tick` (fix anche dell'incoerenza chiavi minuti/durata_min).
|
||||||
|
3. **`src/live/bars.py`**: detection forming-bar UNIFICATA (era copiata in 4 punti,
|
||||||
|
con `_check_stale_feed` che hardcodava 1h). È l'invariante di sicurezza di EXIT-16:
|
||||||
|
ora vive in un posto solo, testato (`test_bars.py`).
|
||||||
|
4. **DSL cancel hardening**: retry su errore transitorio + alert Telegram
|
||||||
|
`REAL_DSL_CANCEL_FAIL` se lo stop resta forse ORFANO sul book (prima l'id veniva
|
||||||
|
dimenticato in silenzio → lo stop stantio poteva colpire la posizione successiva);
|
||||||
|
`order_not_found` = probabile trigger durante outage → solo log (il close a valle
|
||||||
|
esce già verified=False). Test con executor finto (`test_real_close_dsl.py`).
|
||||||
|
|
||||||
|
Finding noti NON ancora fixati (in coda): ROT02/TSM01 valutano la candela 1d in
|
||||||
|
formazione al primo poll dopo mezzanotte (stessa classe del fix TR01/Pairs, pre-esistente);
|
||||||
|
engine dei gate copiato fra i 3 script `*_port06_impact.py`; epoche hourly_report hardcoded.
|
||||||
|
|
||||||
|
## Resta in roadmap (settimana 2, OGNUNO dietro gate PORT06/OOS)
|
||||||
|
|
||||||
|
trend_max=3.0 sulle 6 fade → pos pairs 0.15-0.25 per-famiglia → DIP01 EXIT-16
|
||||||
|
(validazione gap-aware) → SH01 ri-validazione train-window ~8760 barre.
|
||||||
|
Follow-up tecnico: divergenza convenzione TR01 worker vs reference (sopra).
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 2026-06-07 — Gate trend_max sulle fade live: BOCCIATA l'aggiunta, PROMOSSO lo swap hurst→trend
|
||||||
|
|
||||||
|
Punto 7 della roadmap improvement-sweep: ripristinare `trend_max=3.0`/`ema_long=200`
|
||||||
|
sulle 6 fade live (ON nel backtest canonico, OFF in produzione). Il piano imponeva il
|
||||||
|
gate PORT06 perché hurst e trend si sovrappongono. Esito sorprendente ma coerente.
|
||||||
|
|
||||||
|
## Metodo
|
||||||
|
|
||||||
|
`scripts/analysis/trendmax_port06_impact.py` — engine riusato da `exit16_port06_impact`
|
||||||
|
(replay esatto del path canonico, parità verificata: corr 1.00000 e diff 0.00% su tutte
|
||||||
|
e 6 le fade) + maschera Hurst IDENTICA al live (`fade_base.hurst_skip_mask`, close-only,
|
||||||
|
w=100 step=6 — non la cache regime_lab). Tutte le varianti girano sul **path live**
|
||||||
|
(EXIT-16 close-confirm 0.5 attivo); PORT06 con pesi cap canonici (PAIRS 0.33,
|
||||||
|
SHAPE 0.0588), OOS da 2024-10-12.
|
||||||
|
|
||||||
|
Nota di metodo: il test PORT06 del loss-guard Hurst (`fade_lossguard_port_test`,
|
||||||
|
2026-06-02) era su entries GIÀ trend-filtrate E sull'engine PRE-EXIT-16 (SL intrabar).
|
||||||
|
La config live reale (hurst senza trend, con EXIT-16) non era MAI stata gateata.
|
||||||
|
|
||||||
|
## Risultati (PORT06, fade in path live EXIT-16)
|
||||||
|
|
||||||
|
| Variante | FULL Sh | FULL DD | FULL CAGR | OOS Sh | OOS DD | OOS CAGR |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| LIVE (hurst, no trend) | 7.23 | 2.68% | 68% | 9.35 | 1.68% | 92% |
|
||||||
|
| CANDIDATO (hurst+trend) | 7.11 | 2.06% | 59% | 9.36 | 1.62% | 83% |
|
||||||
|
| **TREND-ONLY 3.0** | 7.89 | 2.46% | 82% | **9.91** | **1.20%** | 103% |
|
||||||
|
| NESSUNO (solo EXIT-16) | **8.07** | 3.35% | **105%** | 9.72 | 1.38% | 119% |
|
||||||
|
| TREND-2.5 | 7.75 | 2.46% | 76% | 9.76 | 1.20% | 96% |
|
||||||
|
| TREND-3.5 | 7.87 | 2.46% | 84% | 9.64 | 1.20% | 103% |
|
||||||
|
|
||||||
|
## Letture
|
||||||
|
|
||||||
|
1. **CANDIDATO BOCCIATO** (gate formale): il DD scende (2.68→2.06) ma FULL Sharpe
|
||||||
|
−0.12 e CAGR −9pp — over-filtering, i due filtri tagliano lo stesso regime
|
||||||
|
(n trade −50%, es. MR02_ETH 911→436).
|
||||||
|
2. **Il loss-guard Hurst è ridondante-dannoso post-EXIT-16**: NESSUNO batte LIVE su
|
||||||
|
Sharpe e ritorno ovunque. Spiegazione meccanica: hurst evitava i regimi stop-heavy
|
||||||
|
saltando gli ingressi; EXIT-16 ha eliminato i wick-stop alla radice → gli ingressi
|
||||||
|
che hurst salta sono in maggioranza tornati vincenti. Il 66% delle barre è oltre
|
||||||
|
soglia hurst → il filtro toglieva metà dell'esposizione per un beneficio che ormai
|
||||||
|
non c'è.
|
||||||
|
3. **TREND-ONLY domina LIVE su tutte e 4 le metriche** ed è ESATTAMENTE la config che
|
||||||
|
la ricerca EXIT-16 del 2026-06-04 aveva promosso (entries trend-filtrate, no hurst:
|
||||||
|
FULL 7.84/2.60, OOS 10.06/1.15 — i 7.89/2.46 e 9.91/1.20 di oggi combaciano al
|
||||||
|
netto di refresh dati e cap SHAPE). Il live non aveva mai eseguito quella config.
|
||||||
|
4. **Plateau robusto**: trend_max 2.5/3.0/3.5 quasi indistinguibili (OOS DD 1.20
|
||||||
|
identico su tutte) → non è una soglia tunata.
|
||||||
|
5. Il trend filter vs NESSUNO è un trade-off deliberato: −23pp CAGR FULL per
|
||||||
|
FULL DD 3.35→2.46 e OOS Sharpe 9.72→9.91. Coerente con la filosofia del progetto
|
||||||
|
(DD control > ritorno marginale).
|
||||||
|
|
||||||
|
## Decisione (utente, 2026-06-07)
|
||||||
|
|
||||||
|
**SWAP hurst→trend nelle 6 fade live** (`_defs.py`): `trend_max=3.0, ema_long=200`,
|
||||||
|
`hurst_max` rimosso. `hurst_skip_mask` resta in `fade_base` (param opzionale).
|
||||||
|
Monitor live aggiornato: `hourly_report` ora traccia lo stop-rate per epoca
|
||||||
|
PRE (nessun filtro) → HURST → TREND, verdetto a n≥30 nell'epoca TREND.
|
||||||
|
|
||||||
|
## Lezione
|
||||||
|
|
||||||
|
Ogni meccanismo anti-perdite va RI-gateato quando cambia l'exit engine: hurst era
|
||||||
|
genuinamente il migliore sull'engine a SL intrabar (unico su ~10 candidati), ma
|
||||||
|
EXIT-16 ne ha assorbito il lavoro. I filtri si valutano sul path live corrente,
|
||||||
|
non sui numeri storici di promozione.
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,377 @@
|
|||||||
|
# Fase 2-B — Worker live honest/TSM01 (dedicati) — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development o executing-plans. Steps con checkbox `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** Costruire i worker live mancanti perché PORT06 giri live al completo (oltre a fade+pairs+shape già pronti): DIP01, TR01 (basket), ROT02 (rotation), TSM01 (tsmom rotation), e integrarli nel `PortfolioRunner`.
|
||||||
|
|
||||||
|
**Architecture:** Worker DEDICATI per ogni strategia (scelta utente). DIP01 è single-asset → Strategy subclass + `StrategyWorker` esistente. TR01/ROT02/TSM01 sono multi-asset/rotation → tre classi worker nuove in `src/live/` con stato per-asset persistente, ciascuna fedele alla rispettiva funzione di backtest in `scripts/analysis/{honest_improve2,tsmom_research}.py`. Integrazione in `src/portfolio/runner.py::build_worker_for` + tick.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11, pandas/numpy, pytest. Riusa CerberoClient v2 (multi-asset fetch), PortfolioLedger, e le funzioni di riferimento honest/tsm.
|
||||||
|
|
||||||
|
**Branch:** `portfolio_phase2`. **Spec madre:** `docs/superpowers/specs/2026-05-29-portfolios-design.md` (§ scope live, fase 2).
|
||||||
|
|
||||||
|
**Riferimenti di logica (NON modificare, sono la verità del backtest):**
|
||||||
|
- DIP01 → `honest_improve2.dip_market_gated` (z-score dip, gate BTC>SMA, TP=SMA/SL=ATR/max_bars, intrabar).
|
||||||
|
- TR01 → `honest_improve2._tr_basket_daily` (per asset 4h: EMA20>EMA100 long/flat; basket equal-weight).
|
||||||
|
- ROT02 → `honest_improve2._rot_daily_equity` (panel 1d, mom 60g, top-3 se mom>0 e BTC>SMA100, gross 0.45 split, ribilancio giornaliero).
|
||||||
|
- TSM01 → `tsmom_research.tsmom_sim` (panel 1d, Σ sign(P/P[-h]) h∈{63,126,252} ≥ thr=1.0, gate BTC>SMA100, gross 0.30 split).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
| File | Responsabilità |
|
||||||
|
|------|----------------|
|
||||||
|
| `scripts/strategies/DIP01_dip_buy.py` | Strategy `Dip01DipBuy` (single-asset; metadata tp/sl/max_bars + gate) |
|
||||||
|
| `src/live/basket_trend_worker.py` | `BasketTrendWorker` (TR01): N asset 4h, EMA cross, long/flat per asset |
|
||||||
|
| `src/live/rotation_worker.py` | `RotationWorker` (ROT02): panel 1d, dual-momentum top-k, gross split |
|
||||||
|
| `src/live/tsmom_worker.py` | `TsmomWorker` (TSM01): panel 1d, consenso segni multi-orizzonte |
|
||||||
|
| `src/live/strategy_loader.py` | **mod**: aggiungi `DIP01_dip_buy` a MODULE_MAP |
|
||||||
|
| `src/portfolio/runner.py` | **mod**: `build_worker_for` gestisce kind "basket"/"rotation"/"tsmom"; tick multi-asset |
|
||||||
|
| `src/portfolio/base.py` (`_defs.py`) | **mod**: SleeveSpec degli honest/tsm con `kind` e `universe` corretti |
|
||||||
|
| `tests/portfolio/test_honest_workers.py` | unit per ciascun worker + replay==backtest su finestra |
|
||||||
|
|
||||||
|
**Universi:** TR01 = [BNB,BTC,DOGE,SOL,XRP] (4h); ROT02/TSM01 = `available_assets()` (1d). I worker multi-asset ricevono il dict {asset: df} dal runner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: DIP01 come Strategy single-asset
|
||||||
|
|
||||||
|
**Files:** Create `scripts/strategies/DIP01_dip_buy.py`; Modify `src/live/strategy_loader.py`; Test `tests/portfolio/test_dip01.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Test (fallisce)** — `tests/portfolio/test_dip01.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
from scripts.strategies.DIP01_dip_buy import Dip01DipBuy
|
||||||
|
|
||||||
|
|
||||||
|
def test_dip01_generates_long_signals_with_exits():
|
||||||
|
df = load_data("BTC", "1h").iloc[-5000:].reset_index(drop=True)
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
sigs = Dip01DipBuy().generate_signals(df, ts, asset="BTC", tf="1h")
|
||||||
|
assert len(sigs) > 0
|
||||||
|
s = sigs[0]
|
||||||
|
assert s.direction == 1 # dip-buy è solo long
|
||||||
|
assert {"tp", "sl", "max_bars"} <= set(s.metadata)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_dip01.py -v` → FAIL (ModuleNotFoundError).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implementa `scripts/strategies/DIP01_dip_buy.py`.** Replica ESATTA della logica di `dip_market_gated` (default `market_n=0` = senza gate, come lo sleeve DIP01_BTC del portafoglio: vedi combine_portfolio che usa `market_n=0`). Genera Signal long quando `z[i] <= -z_in and z[i-1] > -z_in`, con metadata `tp=SMA[i]`, `sl=c[i]-sl_atr*atr[i]`, `max_bars`. fee_rt=0.001, leverage 3, position 0.15.
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""DIP01 — Dip-buy mean-reversion single-asset (z-score sotto-banda). Honest family.
|
||||||
|
|
||||||
|
Replica live della logica validata in scripts/analysis/honest_improve2.dip_market_gated
|
||||||
|
(con market_n=0, come lo sleeve DIP01_BTC del portafoglio): compra quando lo z-score del
|
||||||
|
prezzo rispetto a SMA(n) incrocia sotto -z_in; esce a TP=SMA, SL=close-sl_atr*ATR, o max_bars.
|
||||||
|
"""
|
||||||
|
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.strategies.base import Strategy, Signal # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _atr(df, n=14):
|
||||||
|
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 Dip01DipBuy(Strategy):
|
||||||
|
name = "DIP01_dip_buy"
|
||||||
|
description = "Dip-buy mean-reversion single-asset (z-score), exit TP=SMA/SL=ATR/max_bars"
|
||||||
|
default_assets = ["BTC"]
|
||||||
|
default_timeframes = ["1h"]
|
||||||
|
fee_rt = 0.001
|
||||||
|
leverage = 3.0
|
||||||
|
position_size = 0.15
|
||||||
|
|
||||||
|
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||||
|
n: int = 50, z_in: float = 2.5, sl_atr: float = 2.5,
|
||||||
|
max_bars: int = 24, **params) -> list[Signal]:
|
||||||
|
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)
|
||||||
|
out: list[Signal] = []
|
||||||
|
for i in range(n + 14, len(c)):
|
||||||
|
if np.isnan(z[i]) or np.isnan(a[i]) or np.isnan(ma[i]):
|
||||||
|
continue
|
||||||
|
if z[i] <= -z_in and z[i - 1] > -z_in:
|
||||||
|
out.append(Signal(idx=i, direction=1, entry_price=float(c[i]),
|
||||||
|
metadata={"tp": float(ma[i]),
|
||||||
|
"sl": float(c[i] - sl_atr * a[i]),
|
||||||
|
"max_bars": int(max_bars)}))
|
||||||
|
return out
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Registra nel loader.** In `src/live/strategy_loader.py` MODULE_MAP aggiungi:
|
||||||
|
```python
|
||||||
|
"DIP01_dip_buy": ("DIP01_dip_buy", "Dip01DipBuy"),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5:** `uv run pytest tests/portfolio/test_dip01.py -v` → 1 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add scripts/strategies/DIP01_dip_buy.py src/live/strategy_loader.py tests/portfolio/test_dip01.py
|
||||||
|
git commit -m "feat(live): DIP01 dip-buy come Strategy single-asset (worker via StrategyWorker)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nota:** DIP01 nel runner usa lo StrategyWorker esistente (kind="single", name="DIP01"). Aggiorna `_STRAT_MODULE` in `runner.py` con `"DIP01": "DIP01_dip_buy"` e in `_defs.py` lo SleeveSpec DIP01_BTC resta kind="single". Il backtest dello sleeve DIP01_BTC continua a venire da `build_everything` (parità invariata).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `BasketTrendWorker` (TR01)
|
||||||
|
|
||||||
|
**Files:** Create `src/live/basket_trend_worker.py`; Test `tests/portfolio/test_basket_worker.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Test (fallisce)** — verifica che, dato un dict {asset: df 4h}, il worker calcoli posizione long/flat per asset secondo EMA20>EMA100 e aggiorni il capitale equal-weight:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from src.live.basket_trend_worker import BasketTrendWorker
|
||||||
|
|
||||||
|
|
||||||
|
def _ramp_df(n=300, slope=1.0):
|
||||||
|
c = np.linspace(100, 100 + slope * n, n)
|
||||||
|
ts = (pd.date_range("2024-01-01", periods=n, freq="4h", tz="UTC").astype("int64") // 10**6)
|
||||||
|
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
|
||||||
|
|
||||||
|
|
||||||
|
def test_basket_goes_long_in_uptrend(tmp_path):
|
||||||
|
w = BasketTrendWorker(universe=["AAA", "BBB"], tf="4h", capital=1000.0, data_dir=tmp_path)
|
||||||
|
data = {"AAA": _ramp_df(slope=1.0), "BBB": _ramp_df(slope=1.0)}
|
||||||
|
w.tick(data)
|
||||||
|
assert w.positions["AAA"] == 1.0 and w.positions["BBB"] == 1.0 # EMA20>EMA100 in salita
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_basket_worker.py -v` → FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implementa `src/live/basket_trend_worker.py`.** Stato: capitale totale + dict `positions` (asset→0/1) + persistenza. `tick(data: dict[str,df])`: per ogni asset calcola EMA20/EMA100 sull'ultima barra; target = 1.0 se ef>es else 0.0; applica fee `FEE_RT/2*LEV` sul turnover |Δpos|; aggiorna capitale equal-weight col rendimento di barra di ogni asset attivo (`POS*LEV*ret*pos/len(universe)`... mantieni la convenzione di `_tr_basket_daily`: ogni asset è uno sleeve normalizzato, equal-weight → applica `mean` dei rendimenti per-asset). Persisti `status.json` (capitale, positions, last_bar_ts per asset) e logga `trades.jsonl`. fee_rt=0.001, leverage 3, position 0.15.
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""BasketTrendWorker (TR01): EMA20>EMA100 long/flat su un paniere, equal-weight.
|
||||||
|
Replica live di honest_improve2._tr_basket_daily."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
FEE_RT, LEV, POS = 0.001, 3.0, 0.15
|
||||||
|
|
||||||
|
|
||||||
|
def _ema(x, n):
|
||||||
|
return pd.Series(x).ewm(span=n, adjust=False).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
class BasketTrendWorker:
|
||||||
|
def __init__(self, universe, tf="4h", capital=1000.0, position_size=POS,
|
||||||
|
leverage=LEV, fee_rt=FEE_RT, name="TR01_basket",
|
||||||
|
data_dir=Path("data/portfolio_paper")):
|
||||||
|
self.universe = list(universe)
|
||||||
|
self.tf = tf
|
||||||
|
self.initial_capital = capital
|
||||||
|
self.capital = capital
|
||||||
|
self.position_size = position_size
|
||||||
|
self.leverage = leverage
|
||||||
|
self.fee_rt = fee_rt
|
||||||
|
self.worker_id = f"{name}__{'-'.join(self.universe)}__{tf}"
|
||||||
|
self.work_dir = Path(data_dir) / self.worker_id
|
||||||
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.status_path = self.work_dir / "status.json"
|
||||||
|
self.trades_path = self.work_dir / "trades.jsonl"
|
||||||
|
self.positions = {a: 0.0 for a in self.universe}
|
||||||
|
self.last_bar_ts = {a: 0 for a in self.universe}
|
||||||
|
self.in_position = False # per il ribilancio del runner (skip se True)
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
if self.status_path.exists():
|
||||||
|
s = json.loads(self.status_path.read_text())
|
||||||
|
self.capital = s.get("capital", self.capital)
|
||||||
|
self.positions = {**self.positions, **s.get("positions", {})}
|
||||||
|
self.last_bar_ts = {**self.last_bar_ts, **s.get("last_bar_ts", {})}
|
||||||
|
self.in_position = any(v > 0 for v in self.positions.values())
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
self.status_path.write_text(json.dumps({
|
||||||
|
"capital": round(self.capital, 2), "positions": self.positions,
|
||||||
|
"last_bar_ts": self.last_bar_ts,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
|
||||||
|
|
||||||
|
def tick(self, data: dict):
|
||||||
|
rets = []
|
||||||
|
for a in self.universe:
|
||||||
|
df = data.get(a)
|
||||||
|
if df is None or len(df) < 110:
|
||||||
|
continue
|
||||||
|
c = df["close"].values
|
||||||
|
ef, es = _ema(c, 20)[-1], _ema(c, 100)[-1]
|
||||||
|
target = 1.0 if ef > es else 0.0
|
||||||
|
bar_ts = int(df["timestamp"].iloc[-1])
|
||||||
|
prev = self.positions[a]
|
||||||
|
# rendimento di barra realizzato sulla posizione precedente (chiusa->aperta barra)
|
||||||
|
if self.last_bar_ts[a] and bar_ts > self.last_bar_ts[a] and prev > 0:
|
||||||
|
r = (c[-1] - c[-2]) / c[-2]
|
||||||
|
rets.append(self.position_size * self.leverage * r * prev)
|
||||||
|
if target != prev:
|
||||||
|
self.capital -= self.capital * self.position_size * (self.fee_rt / 2) * abs(target - prev) / len(self.universe)
|
||||||
|
self._log(a, prev, target, float(c[-1]))
|
||||||
|
self.positions[a] = target
|
||||||
|
self.last_bar_ts[a] = bar_ts
|
||||||
|
if rets:
|
||||||
|
self.capital = max(self.capital * (1 + float(np.mean(rets))), 10.0)
|
||||||
|
self.in_position = any(v > 0 for v in self.positions.values())
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _log(self, asset, frm, to, price):
|
||||||
|
with open(self.trades_path, "a") as f:
|
||||||
|
f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"asset": asset, "from": frm, "to": to,
|
||||||
|
"price": round(price, 6), "capital": round(self.capital, 2)}) + "\n")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_summary(self):
|
||||||
|
longs = [a for a, v in self.positions.items() if v > 0]
|
||||||
|
return f"{self.worker_id}: cap={self.capital:.0f} long={longs}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4:** `uv run pytest tests/portfolio/test_basket_worker.py -v` → 1 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/live/basket_trend_worker.py tests/portfolio/test_basket_worker.py
|
||||||
|
git commit -m "feat(live): BasketTrendWorker (TR01) EMA-cross long/flat multi-asset"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `RotationWorker` (ROT02)
|
||||||
|
|
||||||
|
**Files:** Create `src/live/rotation_worker.py`; Test `tests/portfolio/test_rotation_worker.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Test (fallisce)** — dato {asset: df 1d}, sceglie i top-k per momentum 60g con gate BTC>SMA100 e imposta i pesi gross/k:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from src.live.rotation_worker import RotationWorker
|
||||||
|
|
||||||
|
|
||||||
|
def _df(n=200, slope=1.0):
|
||||||
|
c = np.linspace(100, 100 + slope * n, n)
|
||||||
|
ts = (pd.date_range("2023-01-01", periods=n, freq="1D", tz="UTC").astype("int64") // 10**6)
|
||||||
|
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_picks_top_momentum_when_risk_on(tmp_path):
|
||||||
|
w = RotationWorker(universe=["BTC", "AAA", "BBB"], top_k=2, gross=0.45, data_dir=tmp_path)
|
||||||
|
data = {"BTC": _df(slope=1.0), "AAA": _df(slope=3.0), "BBB": _df(slope=0.1)}
|
||||||
|
w.tick(data)
|
||||||
|
# BTC in uptrend -> risk_on; top-2 momentum = AAA e BTC; pesi gross/2
|
||||||
|
assert w.weights["AAA"] > 0 and abs(sum(w.weights.values()) - 0.45) < 1e-9
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_rotation_worker.py -v` → FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implementa `src/live/rotation_worker.py`.** Replica di `_rot_daily_equity`: panel di close 1d allineato; `risk_on = BTC[-1] > SMA100(BTC)[-1]`; `mom = P[-1]/P[-61]-1`; `chosen = [top_k per mom con mom>0] se risk_on else []`; pesi `gross/len(chosen)`; turnover fee `FEE_RT/2 * Σ|Δw|`; capitale aggiornato col rendimento di portafoglio del giorno successivo (live: al tick si realizza il rendimento dell'ultima barra sui pesi correnti, poi si ricalcolano i pesi). Persisti capitale+weights+last_ts. `in_position = bool(weights)`.
|
||||||
|
|
||||||
|
(Implementazione analoga a BasketTrendWorker: stato persistente, `tick(data)` allinea i panel per timestamp comune, calcola momentum/gate, applica fee sul turnover e rendimento di barra. Mantieni `top_k=3, gross=0.45` come default — i valori dello sleeve ROT02_rot del portafoglio.)
|
||||||
|
|
||||||
|
- [ ] **Step 4:** test → 1 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/live/rotation_worker.py tests/portfolio/test_rotation_worker.py
|
||||||
|
git commit -m "feat(live): RotationWorker (ROT02) dual-momentum top-k risk-gated"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `TsmomWorker` (TSM01)
|
||||||
|
|
||||||
|
**Files:** Create `src/live/tsmom_worker.py`; Test `tests/portfolio/test_tsmom_worker.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Test (fallisce)** — consenso segni multi-orizzonte: sceglie gli asset con `Σ sign(P/P[-h]) ≥ thr` (h∈{63,126,252}) sotto gate, pesi gross/k.
|
||||||
|
|
||||||
|
- [ ] **Step 2-3: Implementa `src/live/tsmom_worker.py`** replicando `tsmom_sim`: `score[j] = mean_h sign(P[-1,j]/P[-1-h,j]-1)`; `chosen = [j: score>=thr] se risk_on`; pesi `gross/len(chosen)` con `gross=0.30`. Stessa struttura di RotationWorker (panel 1d, fee turnover, rendimento di barra, persistenza). Default `horizons=(63,126,252), thr=1.0, regime_n=100, gross=0.30`.
|
||||||
|
|
||||||
|
- [ ] **Step 4:** test → passed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/live/tsmom_worker.py tests/portfolio/test_tsmom_worker.py
|
||||||
|
git commit -m "feat(live): TsmomWorker (TSM01) consenso TSMOM multi-orizzonte risk-gated"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Integrazione nel PortfolioRunner
|
||||||
|
|
||||||
|
**Files:** Modify `src/portfolio/runner.py`, `scripts/portfolios/_defs.py`, `src/portfolio/base.py`; Test `tests/portfolio/test_runner_honest.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** In `_defs.py`, marca gli SleeveSpec multi-asset col `kind` giusto e l'universo:
|
||||||
|
- DIP01 → `kind="single", name="DIP01"` (resta StrategyWorker via _STRAT_MODULE["DIP01"]="DIP01_dip_buy").
|
||||||
|
- TR01 → `kind="basket"`, aggiungi campo universo (riusa `params={"universe": ["BNB","BTC","DOGE","SOL","XRP"], "tf": "4h"}`).
|
||||||
|
- ROT02 → `kind="rotation"`, `params={"top_k":3, "gross":0.45, "tf":"1d"}`.
|
||||||
|
- TSM01 → `kind="tsmom"`, `params={"horizons":[63,126,252], "thr":1.0, "gross":0.30, "tf":"1d"}`.
|
||||||
|
(Aggiungi `universe`/campi a SleeveSpec se serve, default None.)
|
||||||
|
|
||||||
|
- [ ] **Step 2:** In `runner.py::build_worker_for` aggiungi i rami `kind in ("basket","rotation","tsmom")` che costruiscono i rispettivi worker con `capital=alloc_capital` e `data_dir=DATA_DIR`. Aggiorna `_STRAT_MODULE` con `"DIP01": "DIP01_dip_buy"`. Rimuovi DIP01/TR01/ROT02/TSM01 dalla lista "saltati": ora sono supportati.
|
||||||
|
|
||||||
|
- [ ] **Step 3:** In `runner.run()` il tick deve passare ai worker multi-asset un dict {asset: df} (fetch di tutti gli asset dell'universo). Estendi la raccolta `keys` e il dispatch del tick: per kind basket/rotation/tsmom costruisci `data = {a: cache[(a, tf)] for a in universe}` e chiama `w.tick(data)`. Per `_worker_equity` i nuovi worker espongono `.capital` (già ok). Per il ribilancio, espongono `.in_position` (skip se True).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test** `tests/portfolio/test_runner_honest.py`: `build_worker_for` ritorna il tipo giusto per ogni kind con capitale = alloc; e `run()` con PORT06 non lascia più sleeve "saltati" (mocka il fetch o testa solo build).
|
||||||
|
|
||||||
|
- [ ] **Step 5:** `uv run pytest tests/portfolio/ -m "not network" -v` → tutti verdi.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/portfolio/runner.py scripts/portfolios/_defs.py src/portfolio/base.py tests/portfolio/test_runner_honest.py
|
||||||
|
git commit -m "feat(portfolio): integra worker honest/TSM01 nel runner (PORT06 live completo)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Validazione replay==backtest per i worker multi-asset
|
||||||
|
|
||||||
|
**Files:** Modify `scripts/analysis/validate_portfolio_runner.py` (o nuovo `validate_honest_workers.py`).
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Per ogni worker multi-asset, replay bar-by-bar su dati storici (load_data) e confronto dell'equity finale con la funzione di riferimento (`_tr_basket_daily`, `_rot_daily_equity`, `tsmom_sim`) entro tolleranza. ROT02/TSM01 sono daily → replay veloce (poche migliaia di barre). TR01 4h → medio. Atteso: match stretto (differenze solo da bar-timing/cadenza). DIP01 ha il gap intrabar noto come le fade (documenta, non assert esatto).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
```bash
|
||||||
|
git add scripts/analysis/validate_honest_workers.py
|
||||||
|
git commit -m "test(portfolio): replay worker honest/TSM01 == backtest di riferimento"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
- **Copertura:** i 4 worker (DIP01 single via Strategy; TR01/ROT02/TSM01 dedicati) + integrazione runner + validazione → PORT06 gira live completo (niente più sleeve saltati).
|
||||||
|
- **Parità backtest:** invariata (gli sleeve del backtest vengono ancora da `build_everything`; i worker sono il path LIVE). La validazione replay==backtest (Task 6) certifica i worker live.
|
||||||
|
- **Gap noto:** DIP01, come le fade, ha exit intrabar nel backtest ma close-based nel live → gap strutturale documentato (non un bug). TR01/ROT02/TSM01 non hanno TP/SL intrabar (entry/exit a chiusura barra/giorno) → replay atteso stretto.
|
||||||
|
- **Tipi:** i nuovi worker espongono `.capital` e `.in_position` (richiesti da `_worker_equity`/`rebalance_allocations`); `tick(data: dict)` per i multi-asset vs `tick(df)`/`tick(dfa,dfb)` esistenti → il runner dispatcha per `kind`.
|
||||||
|
- **Rischio:** la convenzione di capitale/rendimento dei worker multi-asset deve combaciare con le funzioni di riferimento; la validazione Task 6 è il gate che lo verifica — se diverge, allineare la formula (non la reference).
|
||||||
|
|
||||||
|
> **Punto aperto:** verificare la disponibilità su Cerbero v2 dei timeframe 4h/1d per tutti gli asset dell'universo (TR01 usa 4h; ROT02/TSM01 usano 1d, oggi resample da 1h in get_df). Il runner live dovrà resamplare 1h→4h/1d dal feed v2 o fetchare nativamente — da decidere in Task 5/Step 3.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
|||||||
|
# Design — Cartella `portfolios/`: portafogli come oggetti di prima classe
|
||||||
|
|
||||||
|
**Data:** 2026-05-29
|
||||||
|
**Stato:** approvato in brainstorming, pronto per il piano di implementazione
|
||||||
|
**Branch:** `shape_patterns` (o branch dedicato `portfolios`)
|
||||||
|
|
||||||
|
## 1. Obiettivo e contesto
|
||||||
|
|
||||||
|
Oggi le strategie del progetto vivono come *sleeve* indipendenti: ogni worker del paper
|
||||||
|
trader (`StrategyWorker`, `PairsWorker`) gestisce un conto autonomo da €1000, con capitale
|
||||||
|
e stato propri in `data/paper_trades/{worker_id}/`. I "portafogli" `PORT01-03` esistenti
|
||||||
|
sono soltanto script di **report offline**: normalizzano le equity storiche dei singoli
|
||||||
|
sleeve e ne calcolano metriche equipesate. Non esiste un livello che gestisca davvero un
|
||||||
|
capitale condiviso, i pesi, il ribilanciamento e il PnL aggregato in tempo reale.
|
||||||
|
|
||||||
|
Questo design introduce una cartella `portfolios/` in cui il **portafoglio è un oggetto di
|
||||||
|
prima classe** che gestirà il trading e lo stato PnL. Un portafoglio possiede un capitale
|
||||||
|
totale, lo alloca ai propri sleeve secondo uno schema di pesi, dimensiona le posizioni,
|
||||||
|
ribilancia periodicamente e mantiene il ledger aggregato. La stessa definizione serve sia
|
||||||
|
al backtest sia al live, garantendo coerenza fra ciò che si misura e ciò che si tradia.
|
||||||
|
|
||||||
|
L'obiettivo strategico resta invariato: partire da €1000 e arrivare verso €50/giorno con un
|
||||||
|
paniere diversificato delle famiglie validate (fade, honest, pairs, TSMOM, shape-ML).
|
||||||
|
|
||||||
|
## 2. Decisioni di brainstorming
|
||||||
|
|
||||||
|
1. **Modello di capitale: pool condiviso.** Il portafoglio possiede il capitale totale, lo
|
||||||
|
alloca ai sleeve secondo i pesi, ridimensiona le posizioni e tiene lo stato/PnL
|
||||||
|
aggregato. I worker diventano esecutori.
|
||||||
|
2. **Scope: backtest + live unificati.** Un'unica classe `Portfolio` come fonte di verità,
|
||||||
|
capace sia di backtest/report storico sia di gestione live.
|
||||||
|
3. **Ribilanciamento periodico.** Il capitale viene riallocato ai pesi target a cadenza
|
||||||
|
fissa (giornaliera di default, configurabile), coerente con tutte le metriche misurate
|
||||||
|
finora.
|
||||||
|
4. **Schemi di peso supportati (tutti):** `equal` (default), `cap` (tetto per
|
||||||
|
famiglia/cluster, es. pairs 33% — configurazione sobria raccomandata), `inverse_vol`,
|
||||||
|
`cluster_rp` (equal fra cluster naturali poi inverse-vol dentro), `manual`.
|
||||||
|
5. **Scope live v1: tutti gli sleeve** — fade, honest, pairs (2 gambe) e shape-ML (SH01 via
|
||||||
|
worker con retraining periodico, sfruttando il `MLWorkerWrapper` esistente).
|
||||||
|
6. **Data layer Cerbero v2.** Il runner live adotta gli endpoint unificati v2: `get_historical`
|
||||||
|
unificato, `get_instruments` (naming robusto, niente `INSTRUMENT_MAP` hardcoded),
|
||||||
|
`get_ticker_batch` (fetch multi-gamba efficiente). Venue di trading = Deribit come ora.
|
||||||
|
|
||||||
|
### Analisi di accorpamento (a supporto delle decisioni)
|
||||||
|
|
||||||
|
`scripts/analysis/sleeve_clustering.py` ha mostrato che:
|
||||||
|
- i **cluster naturali** delle 17 sleeve non coincidono con le famiglie ma con
|
||||||
|
asset/regime: BTC-reversion, ETH-reversion, trend (TR01+TSM01), shape (SH_BTC+SH_ETH),
|
||||||
|
rotation (ROT02);
|
||||||
|
- la **ridondanza è lieve** (correlazione massima 0.43 MR01_BTC↔DIP01_BTC, 0.37 TR01↔TSM01):
|
||||||
|
nessuno sleeve è davvero fondibile, ognuno aggiunge diversificazione;
|
||||||
|
- a equal-weight i **pairs pesano il 47% del rischio** → giustifica lo schema `cap`;
|
||||||
|
- in OOS calmo equal-weight batte inverse-vol e risk-parity (i pairs ad alto rischio/ritorno
|
||||||
|
corrono liberi), ma è un risultato di regime → il cap resta la scelta prudente.
|
||||||
|
|
||||||
|
Il campo `cluster` di `SleeveSpec` codifica questi gruppi naturali per gli schemi `cap` e
|
||||||
|
`cluster_rp`.
|
||||||
|
|
||||||
|
## 3. Architettura e layout
|
||||||
|
|
||||||
|
Si rispecchia la struttura delle strategie (`src/strategies/` base + `scripts/strategies/`
|
||||||
|
concrete):
|
||||||
|
|
||||||
|
```
|
||||||
|
src/portfolio/
|
||||||
|
__init__.py
|
||||||
|
base.py # Portfolio (definizione + .backtest()), SleeveSpec, PortfolioResult
|
||||||
|
sleeves.py # costruzione UNIFICATA delle equity-per-sleeve (backtest);
|
||||||
|
# centralizza la logica oggi in combine_portfolio + report_families
|
||||||
|
weighting.py # schemi pesi: equal, cap, inverse_vol, cluster_rp, manual
|
||||||
|
ledger.py # PortfolioLedger: capitale, allocazioni, equity, PnL, peak/DD, persistenza
|
||||||
|
runner.py # PortfolioRunner (live): pool capital, sizing, ribilancio, aggregazione
|
||||||
|
|
||||||
|
scripts/portfolios/
|
||||||
|
PORT01_honest.py PORT02_fade.py PORT03_master.py
|
||||||
|
PORT04_master_pairs.py PORT05_master_esteso.py PORT06_master_shape.py
|
||||||
|
# definizioni concrete (lista SleeveSpec + schema pesi); run() = report backtest
|
||||||
|
|
||||||
|
portfolios.yml # config LIVE: portafoglio attivo, capitale, schema pesi, cap, cadenza, leva
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integrazione col codice esistente:**
|
||||||
|
- Il backtest riusa i builder di equity-per-sleeve (`build_all_sleeves`, `pairs_sim`,
|
||||||
|
`shape_daily_equity`), centralizzati in `src/portfolio/sleeves.py`; `combine_portfolio.py`
|
||||||
|
e `report_families.py` diventano consumer sottili (niente duplicazione).
|
||||||
|
- Il live riusa da `multi_runner`: il fetch candele, `build_workers`,
|
||||||
|
`build_pairs_workers`, `MLWorkerWrapper`. `multi_runner` resta entrypoint legacy
|
||||||
|
single-sleeve finché `PortfolioRunner` non lo sostituisce.
|
||||||
|
- I vecchi `PORT01-03` di `scripts/strategies/` vengono migrati in `scripts/portfolios/`
|
||||||
|
come definizioni della nuova classe.
|
||||||
|
|
||||||
|
## 4. Definizione del portafoglio (schema)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class SleeveSpec:
|
||||||
|
kind: str # "single" | "pairs" | "ml"
|
||||||
|
name: str # "MR01_bollinger_fade" | "PR01_pairs_reversion" | "SH01_shape_ml"
|
||||||
|
asset: str | None = None # single/ml
|
||||||
|
a: str | None = None # pairs: gamba long
|
||||||
|
b: str | None = None # pairs: gamba short
|
||||||
|
tf: str = "1h"
|
||||||
|
params: dict = field(default_factory=dict)
|
||||||
|
cluster: str = "" # BTC-rev | ETH-rev | trend | shape | rotation
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Portfolio:
|
||||||
|
code: str # "PORT06"
|
||||||
|
label: str # "Master + shape"
|
||||||
|
sleeves: list[SleeveSpec]
|
||||||
|
weighting: str = "equal" # equal | cap | inverse_vol | cluster_rp | manual
|
||||||
|
weights: dict | None = None # solo manual (sleeve-id -> peso)
|
||||||
|
caps: dict | None = None # solo cap: chiave = FAMIGLIA (derivata da kind/name:
|
||||||
|
# PAIRS/FADE/HONEST/SHAPE/TSM), es. {"PAIRS": 0.33}.
|
||||||
|
# cluster_rp usa invece il campo `cluster` degli sleeve.
|
||||||
|
total_capital: float = 1000.0
|
||||||
|
leverage: float = 3.0 # nota: 2x raccomandata per il live reale
|
||||||
|
rebalance: str = "1D"
|
||||||
|
vol_lookback: int = 90 # giorni per inverse_vol / cluster_rp
|
||||||
|
|
||||||
|
def backtest(self, ...) -> PortfolioResult: ...
|
||||||
|
def weight_vector(self, sleeve_returns) -> dict[str, float]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Gli schemi di peso (in `weighting.py`) restituiscono un dict `sleeve-id -> peso` che somma a
|
||||||
|
1. `equal/cap/manual` sono statici; `inverse_vol/cluster_rp` si ricalcolano a ogni ribilancio
|
||||||
|
sulla finestra trailing `vol_lookback`, identicamente in backtest e live.
|
||||||
|
|
||||||
|
## 5. Faccia backtest
|
||||||
|
|
||||||
|
`Portfolio.backtest()` riusa la macchina che ha prodotto tutte le metriche viste finora,
|
||||||
|
centralizzata in `src/portfolio/sleeves.py`:
|
||||||
|
|
||||||
|
```
|
||||||
|
build_sleeve_equity(spec) -> pd.Series # equity daily normalizzata su IDX comune
|
||||||
|
kind="single" -> fade/honest daily equity builders
|
||||||
|
kind="pairs" -> pairs_sim -> daily
|
||||||
|
kind="ml" -> shape_daily_equity
|
||||||
|
```
|
||||||
|
|
||||||
|
Poi: `weight_vector()` → pesi → `port_returns()` con ribilancio giornaliero → `metrics()`
|
||||||
|
FULL/OOS + `yearly_returns()`. Restituisce un `PortfolioResult` con ret/CAGR/DD/Sharpe
|
||||||
|
(FULL e OOS), tabella per-anno e contributo al rischio per sleeve e per cluster. Lo `run()`
|
||||||
|
di ogni `scripts/portfolios/PORTxx.py` stampa questo report.
|
||||||
|
|
||||||
|
## 6. Faccia live (`PortfolioRunner`)
|
||||||
|
|
||||||
|
Loop a poll:
|
||||||
|
|
||||||
|
1. **Data layer v2.** All'avvio `get_instruments` risolve i nomi reali di ogni asset/coppia
|
||||||
|
(fallback a una mappa statica se l'endpoint non risponde). Per tick: `get_historical`
|
||||||
|
unificato per le candele + `get_ticker_batch` per i prezzi correnti di tutte le gambe in
|
||||||
|
un'unica chiamata.
|
||||||
|
2. **Costruzione sleeve→worker.** Riusa `build_workers` / `build_pairs_workers` /
|
||||||
|
`MLWorkerWrapper` (SH01). I worker sono esecutori, non possiedono più €1000 fissi.
|
||||||
|
3. **Capitale pool + sizing.** Il `PortfolioLedger` tiene `total_capital`. A ogni worker
|
||||||
|
viene assegnato `alloc_i = peso_i × total_capital`; il worker dimensiona il notional come
|
||||||
|
`alloc_i × position_size × leverage` (si riusa il campo `capital` del worker come base di
|
||||||
|
allocazione).
|
||||||
|
4. **Ribilancio (cadenza `rebalance`, default giornaliera).** `total_capital = Σ equity_sleeve`
|
||||||
|
(capitale + PnL realizzato); ricalcolo dei pesi (vol-based sulla finestra trailing o
|
||||||
|
statici); riallineo `alloc_i`.
|
||||||
|
5. **Aggregazione.** Dopo ogni tick il ledger aggiorna equity totale, peak, max_dd, PnL
|
||||||
|
aggregato e per-sleeve/cluster.
|
||||||
|
|
||||||
|
### Approssimazione dichiarata (limite noto)
|
||||||
|
|
||||||
|
Il ribilancio cambia la base di sizing delle posizioni **future**; le posizioni già aperte
|
||||||
|
restano sul notional con cui sono nate (nessun travaso forzato a metà trade). Per il paper
|
||||||
|
trading questo è fedele al backtest daily-rebalanced entro lo scarto dovuto al turnover
|
||||||
|
infragiornaliero. È un compromesso accettato per non introdurre la contabilità a ledger
|
||||||
|
unico (approccio C scartato in brainstorming), rimandata a quando si passerà a capitale
|
||||||
|
reale su un singolo conto-margine.
|
||||||
|
|
||||||
|
## 7. Persistenza e stato PnL
|
||||||
|
|
||||||
|
Stato del portafoglio separato dai singoli worker, in `data/portfolios/{code}/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
data/portfolios/PORT06/
|
||||||
|
status.json # resume: total_capital, equity, peak, max_dd, pesi correnti,
|
||||||
|
# alloc+capitale+PnL per sleeve, ultimo ribilancio, ts
|
||||||
|
equity.jsonl # append-only: una riga per tick/giorno (ts, equity, dd, pnl_day) -> curva live
|
||||||
|
events.jsonl # append-only: ribilanci (pesi prima/dopo), milestone, errori
|
||||||
|
```
|
||||||
|
|
||||||
|
- I worker continuano a scrivere il proprio `trades.jsonl`/`status.json` in
|
||||||
|
`data/paper_trades/{worker_id}/` (storico per-sleeve intatto). Il portafoglio aggrega
|
||||||
|
sopra, non duplica i trade.
|
||||||
|
- **Resume:** al restart il runner ricarica lo `status.json` del portafoglio e gli stati
|
||||||
|
dei worker → riprende capitale, pesi e posizioni senza perdere storico.
|
||||||
|
- **Indicatori target:** il ledger espone `pnl_total`, `pnl_today`, `€/day` medio e DD
|
||||||
|
corrente.
|
||||||
|
- **Notifiche Telegram:** riepilogo a livello portafoglio (equity, PnL giorno, DD, ribilanci)
|
||||||
|
oltre alle notifiche per-trade dei worker.
|
||||||
|
|
||||||
|
## 8. Portafogli forniti e default
|
||||||
|
|
||||||
|
| Codice | Label | Sleeve | Pesi |
|
||||||
|
|--------|-------|--------|------|
|
||||||
|
| PORT01 | Honest | DIP01·TR01·ROT02 | equal |
|
||||||
|
| PORT02 | Fade master | MR01/02/07 × BTC/ETH (6) | equal |
|
||||||
|
| PORT03 | Master | fade+honest (9) | equal / manual 50-50 |
|
||||||
|
| PORT04 | Master + pairs | 9 + 5 pairs | equal · cap pairs 0.33 |
|
||||||
|
| PORT05 | Master esteso | 9 + pairs + TSM01 | equal · cap pairs |
|
||||||
|
| **PORT06** | **Master + shape** *(default)* | 9 + pairs + TSM01 + SH01 (BTC/ETH) | **cap pairs 0.33** |
|
||||||
|
|
||||||
|
**Default raccomandato:** PORT06 con `weighting="cap"` (pairs ~33%), `leverage=2` (sobrio),
|
||||||
|
`rebalance="1D"`. È la combinazione col miglior profilo OOS dell'analisi (Sharpe più alto,
|
||||||
|
DD più basso) e contiene tutte le famiglie validate. `portfolios.yml` seleziona il
|
||||||
|
portafoglio attivo e i suoi override.
|
||||||
|
|
||||||
|
## 9. Test
|
||||||
|
|
||||||
|
- **Unit** — `weighting.py` (somma pesi = 1, cap rispettato e ridistribuito,
|
||||||
|
inverse-vol/cluster corretti); `ledger.py` (capitale/PnL/DD, resume da status.json).
|
||||||
|
- **Parità backtest↔report** — `Portfolio.backtest()` di PORT03/04/05/06 riproduce
|
||||||
|
*esattamente* i numeri di `report_families.py` (regressione, stessa fonte).
|
||||||
|
- **Parità live↔backtest** — replay del `PortfolioRunner` su dati storici con ribilancio
|
||||||
|
giornaliero ≈ `Portfolio.backtest()` entro tolleranza (lo scarto è il turnover
|
||||||
|
infragiornaliero dichiarato), sullo stesso schema della validazione dei pairs.
|
||||||
|
- **Smoke live** — un tick reale end-to-end via Cerbero v2 (get_instruments +
|
||||||
|
get_historical + ticker_batch), nessun ordine reale, verifica ledger/persistenza/resume.
|
||||||
|
|
||||||
|
## 10. Fuori scope (note per il futuro)
|
||||||
|
|
||||||
|
- **Ledger unico / conto-margine reale** (approccio C): rinviato al passaggio a capitale
|
||||||
|
reale.
|
||||||
|
- **Hyperliquid come venue per gli alt** dei pairs (perp lineari nativi, evita i trap di
|
||||||
|
naming Deribit) — opzione abilitata dal data layer v2, non in v1.
|
||||||
|
- **Validazione pairs live via `get_cointegration_pairs`** e feature da macro/sentiment
|
||||||
|
(funding, liquidation, OI) per strategie future.
|
||||||
|
- **`run_backtest` server-side** di Cerbero come check incrociato.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Config LIVE del paper trader a portafoglio. Seleziona UN portafoglio attivo
|
||||||
|
# (definito in scripts/portfolios/_defs.py) e ne fa l'override dei parametri operativi.
|
||||||
|
active: PORT06 # default raccomandato: master + shape
|
||||||
|
overrides:
|
||||||
|
total_capital: 2000
|
||||||
|
weighting: cap # equal | cap | inverse_vol | cluster_rp | manual
|
||||||
|
# NB: questo dict SOSTITUISCE interamente i caps di _defs.py (setattr in
|
||||||
|
# base.py:load_active_portfolio) → va ridichiarato COMPLETO. Il cap SHAPE
|
||||||
|
# 0.0588 (mitigazione coda SH01, 2026-06-05) era stato perso per questo.
|
||||||
|
caps: {PAIRS: 0.33, SHAPE: 0.0588}
|
||||||
|
leverage: 2 # sobrio per il live reale
|
||||||
|
rebalance: 1D
|
||||||
|
poll_seconds: 60
|
||||||
|
# Frazione di capitale-sleeve per posizione (canonico backtest = 0.15).
|
||||||
|
# 0.5 con leva 2x = 100% della fetta impegnata quando in posizione (max impiego
|
||||||
|
# dei 2K senza debito di margine). NB: il DD scala ~lineare (~×3.3 vs validato).
|
||||||
|
position_size: 0.5
|
||||||
|
# Override per-famiglia (chiave = weighting.family_of). PAIRS 0.20 (2026-06-07):
|
||||||
|
# famiglia SENZA stop, validata a esposizione 0.45 (pos 0.15 lev 3) — a 0.5×2=1.0
|
||||||
|
# girava a ~2.2x il validato (ETH/BTC DD grezzo 78%; ADA/ETH live -4.26% sleeve in
|
||||||
|
# un trade). A 0.20×2=0.40 ≈ validato: PORT06 OOS DD 3.40→1.26%, costo OOS Sharpe
|
||||||
|
# 9.05→8.43 (assicurazione, come il cap SHAPE). Gate: pairspos_port06_impact.py.
|
||||||
|
position_size_family: {PAIRS: 0.20}
|
||||||
|
# Esecuzione REALE su Deribit testnet, in SHADOW (sim + reale in parallelo).
|
||||||
|
# I 7 single-leg con TP/SL in metadata: 6 fade (MR01/MR02/MR07 x BTC/ETH) +
|
||||||
|
# DIP01 BTC (attivato 2026-06-04: stesso wiring StrategyWorker, TP limit resting
|
||||||
|
# incluso). Ordini sui LINEARI USDC (payoff lineare = matematica del backtest;
|
||||||
|
# fee/PnL in USDC). Gli altri sleeve (pairs/rotation/tsmom/shape) restano
|
||||||
|
# simulati: pairs richiede executor a 2 gambe, shape non ha TP (orizzonte puro).
|
||||||
|
execution:
|
||||||
|
enabled: true
|
||||||
|
sleeves: [MR01, MR02, MR07, DIP01]
|
||||||
|
instruments:
|
||||||
|
BTC: BTC_USDC-PERPETUAL
|
||||||
|
ETH: ETH_USDC-PERPETUAL
|
||||||
|
# Disaster-bracket on-book (2026-06-07): STOP_MARKET reduce-only a ~-30%
|
||||||
|
# dall'ingresso, piazzato a ogni REAL_OPEN e cancellato alla chiusura.
|
||||||
|
# Assicurazione per gli outage (runner fermo = exit non valutati); in
|
||||||
|
# operativita' normale non scatta mai -> 0 costo Sharpe. 0 = disattivo.
|
||||||
|
disaster_sl_pct: 0.30
|
||||||
@@ -27,3 +27,4 @@ dev = [
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
markers = ["network: test che richiede Cerbero MCP (rete+token)"]
|
||||||
|
|||||||
@@ -1,309 +0,0 @@
|
|||||||
"""Confronto migliori strategie S1 e S2 — andamento per anno."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, ".")
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
from src.data.downloader import load_data
|
|
||||||
from src.fractal.patterns import encode_candles
|
|
||||||
|
|
||||||
FEE_PERP = 0.002 # 0.1% taker roundtrip perpetual
|
|
||||||
FEE_OPT = 0.0052 # options roundtrip
|
|
||||||
INITIAL = 1000
|
|
||||||
LEVERAGE = 3
|
|
||||||
|
|
||||||
|
|
||||||
def keltner_ratio(close, high, low, window=14):
|
|
||||||
n = len(close)
|
|
||||||
r = np.full(n, np.nan)
|
|
||||||
for i in range(window, n):
|
|
||||||
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
|
|
||||||
ma = np.mean(wc)
|
|
||||||
bb_std = np.std(wc)
|
|
||||||
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
|
|
||||||
atr = np.mean(tr[1:])
|
|
||||||
kc = (ma+1.5*atr)-(ma-1.5*atr)
|
|
||||||
bb = (ma+2*bb_std)-(ma-2*bb_std)
|
|
||||||
if kc > 0:
|
|
||||||
r[i] = bb/kc
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def rv_ann(close, window):
|
|
||||||
lr = np.diff(np.log(np.where(close==0, 1e-10, close)))
|
|
||||||
r = np.full(len(close), np.nan)
|
|
||||||
for i in range(window, len(lr)):
|
|
||||||
r[i+1] = np.std(lr[i-window:i]) * np.sqrt(24*365)
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def rsi(close, period=14):
|
|
||||||
delta = np.diff(close)
|
|
||||||
gain = np.where(delta>0, delta, 0)
|
|
||||||
loss = np.where(delta<0, -delta, 0)
|
|
||||||
result = np.full(len(close), 50.0)
|
|
||||||
if len(gain) < period:
|
|
||||||
return result
|
|
||||||
ag = np.mean(gain[:period])
|
|
||||||
al = np.mean(loss[:period])
|
|
||||||
for i in range(period, len(delta)):
|
|
||||||
ag = (ag*(period-1)+gain[i])/period
|
|
||||||
al = (al*(period-1)+loss[i])/period
|
|
||||||
result[i+1] = 100 if al == 0 else 100-100/(1+ag/al)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def ema(arr, period):
|
|
||||||
r = np.full(len(arr), np.nan)
|
|
||||||
k = 2/(period+1)
|
|
||||||
r[period-1] = np.mean(arr[:period])
|
|
||||||
for i in range(period, len(arr)):
|
|
||||||
r[i] = arr[i]*k + r[i-1]*(1-k)
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# S1 BEST: Squeeze Breakout ETH 1h (BBw=14, sq=0.8, brk=3)
|
|
||||||
# =====================================================================
|
|
||||||
def run_s1_squeeze(asset, tf):
|
|
||||||
df = load_data(asset, tf)
|
|
||||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
|
||||||
n = len(c)
|
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
kcr = keltner_ratio(c, h, l, 14)
|
|
||||||
|
|
||||||
yearly = {}
|
|
||||||
in_sq = False
|
|
||||||
sq_start = 0
|
|
||||||
|
|
||||||
for i in range(15, n):
|
|
||||||
if np.isnan(kcr[i]):
|
|
||||||
continue
|
|
||||||
is_sq = kcr[i] < 0.8
|
|
||||||
if is_sq and not in_sq:
|
|
||||||
in_sq = True
|
|
||||||
sq_start = i
|
|
||||||
elif not is_sq and in_sq:
|
|
||||||
in_sq = False
|
|
||||||
if i - sq_start < 5 or i + 3 >= n:
|
|
||||||
continue
|
|
||||||
first_ret = (c[i] - c[i-1]) / c[i-1]
|
|
||||||
if abs(first_ret) < 0.001:
|
|
||||||
continue
|
|
||||||
direction = 1 if first_ret > 0 else -1
|
|
||||||
actual = (c[i+2] - c[i-1]) / c[i-1]
|
|
||||||
trade_ret = actual * direction
|
|
||||||
net = trade_ret * LEVERAGE - FEE_PERP * LEVERAGE
|
|
||||||
|
|
||||||
year = ts.iloc[i].year
|
|
||||||
if year not in yearly:
|
|
||||||
yearly[year] = {"pnls": [], "wins": 0, "total": 0}
|
|
||||||
yearly[year]["pnls"].append(net)
|
|
||||||
yearly[year]["total"] += 1
|
|
||||||
if trade_ret > 0:
|
|
||||||
yearly[year]["wins"] += 1
|
|
||||||
|
|
||||||
return yearly
|
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# S1 BEST ALT: Squeeze+ML hybrid ETH 15m
|
|
||||||
# =====================================================================
|
|
||||||
# Troppo complesso da ricalcolare (serve ML training). Uso i dati S1 squeeze puro.
|
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# S2 BEST: VRP ETH 48h (con IV stimata, unico disponibile su 8 anni)
|
|
||||||
# =====================================================================
|
|
||||||
def run_s2_vrp(asset, dte=48):
|
|
||||||
df = load_data(asset, "1h")
|
|
||||||
c = df["close"].values
|
|
||||||
n = len(c)
|
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
rv_24 = rv_ann(c, 24)
|
|
||||||
rv_168 = rv_ann(c, 168)
|
|
||||||
|
|
||||||
yearly = {}
|
|
||||||
for i in range(170, n - dte):
|
|
||||||
if ts.iloc[i].hour != 8:
|
|
||||||
continue
|
|
||||||
rv_s, rv_l = rv_24[i], rv_168[i]
|
|
||||||
if np.isnan(rv_s) or np.isnan(rv_l) or rv_s < 0.05 or rv_l < 0.05:
|
|
||||||
continue
|
|
||||||
regime = rv_s / rv_l
|
|
||||||
iv_pf = 0.9 if regime > 2 else (1.0 if regime > 1.5 else (1.1 if regime > 1 else 1.2))
|
|
||||||
iv = rv_l * iv_pf
|
|
||||||
prem = iv * np.sqrt(dte/(24*365)) * 0.8
|
|
||||||
spot = c[i]
|
|
||||||
move = abs(c[min(i+dte, n-1)] - spot) / spot
|
|
||||||
pos = 0.10
|
|
||||||
raw = (prem - move) * pos if move <= prem else max(-(move-prem)*pos, -pos*0.05)
|
|
||||||
net = raw - FEE_OPT * pos
|
|
||||||
|
|
||||||
year = ts.iloc[i].year
|
|
||||||
if year not in yearly:
|
|
||||||
yearly[year] = {"pnls": [], "wins": 0, "total": 0}
|
|
||||||
yearly[year]["pnls"].append(net)
|
|
||||||
yearly[year]["total"] += 1
|
|
||||||
if raw > 0:
|
|
||||||
yearly[year]["wins"] += 1
|
|
||||||
return yearly
|
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# S2 BEST PERPETUAL: Multi-TF 15m+1h BTC
|
|
||||||
# =====================================================================
|
|
||||||
def run_s2_multitf(asset):
|
|
||||||
df_1h = load_data(asset, "1h")
|
|
||||||
df_15m = load_data(asset, "15m")
|
|
||||||
c1h = df_1h["close"].values
|
|
||||||
ts1h = pd.to_datetime(df_1h["timestamp"], unit="ms", utc=True)
|
|
||||||
c15 = df_15m["close"].values
|
|
||||||
ts15 = df_15m["timestamp"].values
|
|
||||||
n15 = len(c15)
|
|
||||||
|
|
||||||
ema_50 = ema(c1h, 50)
|
|
||||||
rsi_15m = rsi(c15, 14)
|
|
||||||
|
|
||||||
yearly = {}
|
|
||||||
daily_done = set()
|
|
||||||
|
|
||||||
for i in range(100, n15 - 12):
|
|
||||||
ts_dt = pd.Timestamp(ts15[i], unit="ms", tz="UTC")
|
|
||||||
day = ts_dt.strftime("%Y-%m-%d")
|
|
||||||
if day in daily_done:
|
|
||||||
continue
|
|
||||||
if rsi_15m[i] > 35 and rsi_15m[i] < 65:
|
|
||||||
continue
|
|
||||||
h_idx = np.searchsorted(ts1h.values.astype("int64"), ts15[i]) - 1
|
|
||||||
if h_idx < 50 or h_idx >= len(c1h) or np.isnan(ema_50[h_idx]):
|
|
||||||
continue
|
|
||||||
|
|
||||||
direction = None
|
|
||||||
if rsi_15m[i] < 30 and c1h[h_idx] > ema_50[h_idx]:
|
|
||||||
direction = "long"
|
|
||||||
elif rsi_15m[i] > 70 and c1h[h_idx] < ema_50[h_idx]:
|
|
||||||
direction = "short"
|
|
||||||
if direction is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
entry = c15[i]
|
|
||||||
exit_price = c15[min(i+12, n15-1)]
|
|
||||||
trade_ret = (exit_price-entry)/entry if direction == "long" else (entry-exit_price)/entry
|
|
||||||
net = trade_ret * LEVERAGE - FEE_PERP * LEVERAGE
|
|
||||||
|
|
||||||
year = ts_dt.year
|
|
||||||
if year not in yearly:
|
|
||||||
yearly[year] = {"pnls": [], "wins": 0, "total": 0}
|
|
||||||
yearly[year]["pnls"].append(net)
|
|
||||||
yearly[year]["total"] += 1
|
|
||||||
if trade_ret > 0:
|
|
||||||
yearly[year]["wins"] += 1
|
|
||||||
daily_done.add(day)
|
|
||||||
return yearly
|
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# REPORT
|
|
||||||
# =====================================================================
|
|
||||||
strategies = {
|
|
||||||
"S1: Squeeze BTC 1h": run_s1_squeeze("BTC", "1h"),
|
|
||||||
"S1: Squeeze ETH 1h": run_s1_squeeze("ETH", "1h"),
|
|
||||||
"S1: Squeeze ETH 15m": run_s1_squeeze("ETH", "15m"),
|
|
||||||
"S2: VRP ETH 48h (IV est)": run_s2_vrp("ETH", 48),
|
|
||||||
"S2: VRP BTC 48h (IV est)": run_s2_vrp("BTC", 48),
|
|
||||||
"S2: MultiTF BTC 15m+1h": run_s2_multitf("BTC"),
|
|
||||||
"S2: MultiTF ETH 15m+1h": run_s2_multitf("ETH"),
|
|
||||||
}
|
|
||||||
|
|
||||||
all_years = sorted(set(y for v in strategies.values() for y in v))
|
|
||||||
|
|
||||||
print("=" * 120)
|
|
||||||
print(" MIGLIORI STRATEGIE — ANDAMENTO PER ANNO")
|
|
||||||
print(" Fee reali. PnL su €1000 flat (no compounding). Dati OHLCV reali 2018-2026.")
|
|
||||||
print(" ⚠ VRP usa IV STIMATA (non reale) — fidarsi solo dei dati perpetual per backtest lungo")
|
|
||||||
print("=" * 120)
|
|
||||||
|
|
||||||
# Header
|
|
||||||
hdr = f" {'Anno':>6s}"
|
|
||||||
for name in strategies:
|
|
||||||
short = name.split(": ")[1][:18]
|
|
||||||
hdr += f" | {short:>18s}"
|
|
||||||
print(hdr)
|
|
||||||
print(f" {'-' * (len(hdr) - 2)}")
|
|
||||||
|
|
||||||
# Per anno: accuracy / PnL totale
|
|
||||||
for year in all_years:
|
|
||||||
row_acc = f" {year:>6d}"
|
|
||||||
row_pnl = f" {'':>6s}"
|
|
||||||
for name, yearly in strategies.items():
|
|
||||||
if year in yearly:
|
|
||||||
d = yearly[year]
|
|
||||||
acc = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
|
|
||||||
pnl = sum(d["pnls"]) * INITIAL
|
|
||||||
tag = "▓" if acc >= 75 else "▒" if acc >= 65 else "░" if acc >= 55 else " "
|
|
||||||
row_acc += f" | {acc:>5.1f}% {tag} {d['total']:>3d}t"
|
|
||||||
row_pnl += f" | €{pnl:>+8.0f} "
|
|
||||||
else:
|
|
||||||
row_acc += f" | {'—':>18s}"
|
|
||||||
row_pnl += f" | {'':>18s}"
|
|
||||||
print(row_acc)
|
|
||||||
print(row_pnl)
|
|
||||||
|
|
||||||
# Totali
|
|
||||||
print(f" {'-' * (len(hdr) - 2)}")
|
|
||||||
row_tot = f" {'TOT':>6s}"
|
|
||||||
for name, yearly in strategies.items():
|
|
||||||
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
|
|
||||||
all_wins = sum(d["wins"] for d in yearly.values())
|
|
||||||
all_total = sum(d["total"] for d in yearly.values())
|
|
||||||
acc = all_wins/all_total*100 if all_total > 0 else 0
|
|
||||||
pnl = sum(all_pnls) * INITIAL
|
|
||||||
row_tot += f" | {acc:>5.1f}% {all_total:>4d}t"
|
|
||||||
print(row_tot)
|
|
||||||
|
|
||||||
row_pnl_tot = f" {'€TOT':>6s}"
|
|
||||||
for name, yearly in strategies.items():
|
|
||||||
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
|
|
||||||
pnl = sum(all_pnls) * INITIAL
|
|
||||||
row_pnl_tot += f" | €{pnl:>+8.0f} "
|
|
||||||
print(row_pnl_tot)
|
|
||||||
|
|
||||||
# Compounding
|
|
||||||
print(f"\n {'':>6s}", end="")
|
|
||||||
for name in strategies:
|
|
||||||
short = name.split(": ")[1][:18]
|
|
||||||
print(f" | {short:>18s}", end="")
|
|
||||||
print()
|
|
||||||
|
|
||||||
row_comp = f" {'COMP':>6s}"
|
|
||||||
for name, yearly in strategies.items():
|
|
||||||
cap = float(INITIAL)
|
|
||||||
for year in sorted(yearly):
|
|
||||||
for pnl in yearly[year]["pnls"]:
|
|
||||||
cap += cap * pnl
|
|
||||||
cap = max(cap, 10)
|
|
||||||
row_comp += f" | €{cap:>12,.0f} "
|
|
||||||
print(row_comp)
|
|
||||||
|
|
||||||
# Drawdown
|
|
||||||
row_dd = f" {'MAXDD':>6s}"
|
|
||||||
for name, yearly in strategies.items():
|
|
||||||
cap = float(INITIAL)
|
|
||||||
peak = cap
|
|
||||||
mdd = 0
|
|
||||||
for year in sorted(yearly):
|
|
||||||
for pnl in yearly[year]["pnls"]:
|
|
||||||
cap += cap * pnl
|
|
||||||
cap = max(cap, 10)
|
|
||||||
if cap > peak: peak = cap
|
|
||||||
dd = (peak - cap) / peak
|
|
||||||
mdd = max(mdd, dd)
|
|
||||||
row_dd += f" | {mdd*100:>12.1f}% "
|
|
||||||
print(row_dd)
|
|
||||||
|
|
||||||
# Legenda
|
|
||||||
print(f"\n Legenda: ▓ ≥75% acc ▒ ≥65% acc ░ ≥55% acc")
|
|
||||||
print(f" ⚠ S2 VRP: IV stimata (rv_long × 1.0-1.2), NON dati reali opzioni")
|
|
||||||
print(f" S1 Squeeze e S2 MultiTF: dati OHLCV reali al 100%")
|
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -1,559 +0,0 @@
|
|||||||
"""Analisi finale — S1 (squeeze puro) vs Script 13 (squeeze+ML GBM).
|
|
||||||
Metriche: PnL, num trades, DD max, tempo medio a mercato, descrizione.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, ".")
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
from sklearn.ensemble import GradientBoostingClassifier
|
|
||||||
from sklearn.preprocessing import StandardScaler
|
|
||||||
from src.data.downloader import load_data
|
|
||||||
from src.fractal.patterns import encode_candles
|
|
||||||
|
|
||||||
FEE_PERP = 0.002
|
|
||||||
FEE_ML = 0.001
|
|
||||||
INITIAL = 1000
|
|
||||||
LEVERAGE = 3
|
|
||||||
|
|
||||||
TF_MINUTES = {"1m": 1, "5m": 5, "15m": 15, "1h": 60, "4h": 240, "1d": 1440}
|
|
||||||
|
|
||||||
|
|
||||||
# ── helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def keltner_ratio(close, high, low, window=14):
|
|
||||||
n = len(close)
|
|
||||||
r = np.full(n, np.nan)
|
|
||||||
for i in range(window, n):
|
|
||||||
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
|
|
||||||
ma = np.mean(wc)
|
|
||||||
bb_std = np.std(wc)
|
|
||||||
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
|
|
||||||
atr = np.mean(tr[1:])
|
|
||||||
kc = (ma+1.5*atr)-(ma-1.5*atr)
|
|
||||||
bb = (ma+2*bb_std)-(ma-2*bb_std)
|
|
||||||
if kc > 0:
|
|
||||||
r[i] = bb/kc
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def detect_squeezes(close, high, low, kcr, sq_thr=0.8, min_dur=5):
|
|
||||||
events = []
|
|
||||||
in_sq = False
|
|
||||||
sq_start = 0
|
|
||||||
for i in range(1, len(close)):
|
|
||||||
if np.isnan(kcr[i]):
|
|
||||||
continue
|
|
||||||
is_sq = kcr[i] < sq_thr
|
|
||||||
if is_sq and not in_sq:
|
|
||||||
in_sq = True
|
|
||||||
sq_start = i
|
|
||||||
elif not is_sq and in_sq:
|
|
||||||
in_sq = False
|
|
||||||
dur = i - sq_start
|
|
||||||
if dur < min_dur:
|
|
||||||
continue
|
|
||||||
events.append({"idx": i, "dur": dur, "sq_start": sq_start,
|
|
||||||
"avg_vol_squeeze": np.mean(close[sq_start:i]),
|
|
||||||
"kcr_at_release": kcr[i]})
|
|
||||||
return events
|
|
||||||
|
|
||||||
|
|
||||||
def _build_result(yearly, capital, max_dd, all_t, all_w, time_pct, avg_dur_h):
|
|
||||||
acc = all_w / all_t * 100
|
|
||||||
tot_pnl = sum(p for d in yearly.values() for p in d["pnls"])
|
|
||||||
years_active = len(yearly)
|
|
||||||
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
|
|
||||||
sharpe = np.mean(all_pnls) / np.std(all_pnls) * np.sqrt(252) if len(all_pnls) > 1 and np.std(all_pnls) > 0 else 0
|
|
||||||
|
|
||||||
year_details = {}
|
|
||||||
for y in sorted(yearly):
|
|
||||||
d = yearly[y]
|
|
||||||
ya = d["w"] / d["t"] * 100 if d["t"] > 0 else 0
|
|
||||||
yp = sum(d["pnls"])
|
|
||||||
year_details[y] = {"trades": d["t"], "acc": ya, "pnl": yp}
|
|
||||||
|
|
||||||
valid_years = {y: d for y, d in year_details.items() if d["trades"] >= 10}
|
|
||||||
if valid_years:
|
|
||||||
worst_y = min(valid_years, key=lambda y: valid_years[y]["acc"])
|
|
||||||
worst_acc = valid_years[worst_y]["acc"]
|
|
||||||
elif year_details:
|
|
||||||
worst_y = min(year_details, key=lambda y: year_details[y]["acc"])
|
|
||||||
worst_acc = year_details[worst_y]["acc"]
|
|
||||||
else:
|
|
||||||
worst_y = "N/A"
|
|
||||||
worst_acc = 0
|
|
||||||
|
|
||||||
daily_pnl = tot_pnl / (years_active * 365) if years_active > 0 else 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"trades": all_t, "acc": acc, "pnl": tot_pnl, "capital": capital,
|
|
||||||
"max_dd": max_dd * 100, "sharpe": sharpe, "daily_pnl": daily_pnl,
|
|
||||||
"time_in_market_pct": time_pct, "avg_dur_h": avg_dur_h,
|
|
||||||
"years_active": years_active, "worst_year": str(worst_y),
|
|
||||||
"worst_acc": worst_acc, "year_details": year_details,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── S1: Squeeze breakout puro ────────────────────────────────────────
|
|
||||||
|
|
||||||
def run_s1_squeeze(asset, tf, hold=3):
|
|
||||||
df = load_data(asset, tf)
|
|
||||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
|
||||||
n = len(c)
|
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
kcr = keltner_ratio(c, h, l, 14)
|
|
||||||
events = detect_squeezes(c, h, l, kcr)
|
|
||||||
|
|
||||||
yearly = {}
|
|
||||||
capital = float(INITIAL)
|
|
||||||
peak = capital
|
|
||||||
max_dd = 0
|
|
||||||
total_bars = 0
|
|
||||||
|
|
||||||
for ev in events:
|
|
||||||
i = ev["idx"]
|
|
||||||
if i + hold + 1 >= n:
|
|
||||||
continue
|
|
||||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
|
||||||
if abs(first_ret) < 0.001:
|
|
||||||
continue
|
|
||||||
direction = 1 if first_ret > 0 else -1
|
|
||||||
entry = c[i-1]
|
|
||||||
exit_price = c[min(i + hold - 1, n - 1)]
|
|
||||||
actual = (exit_price - entry) / entry * direction
|
|
||||||
net = actual * LEVERAGE - FEE_PERP * LEVERAGE
|
|
||||||
|
|
||||||
capital += capital * 0.15 * net
|
|
||||||
capital = max(capital, 10)
|
|
||||||
if capital > peak: peak = capital
|
|
||||||
dd = (peak - capital) / peak
|
|
||||||
max_dd = max(max_dd, dd)
|
|
||||||
total_bars += hold
|
|
||||||
|
|
||||||
year = ts.iloc[i].year
|
|
||||||
if year not in yearly:
|
|
||||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
|
||||||
yearly[year]["t"] += 1
|
|
||||||
if actual > 0: yearly[year]["w"] += 1
|
|
||||||
yearly[year]["pnls"].append(net * INITIAL)
|
|
||||||
|
|
||||||
all_t = sum(d["t"] for d in yearly.values())
|
|
||||||
all_w = sum(d["w"] for d in yearly.values())
|
|
||||||
if all_t == 0: return None
|
|
||||||
return _build_result(yearly, capital, max_dd, all_t, all_w,
|
|
||||||
total_bars / n * 100, hold * TF_MINUTES.get(tf, 60) / 60)
|
|
||||||
|
|
||||||
|
|
||||||
def run_s1_antifake_vol(asset, tf, hold=3):
|
|
||||||
df = load_data(asset, tf)
|
|
||||||
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
|
|
||||||
n = len(c)
|
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
kcr = keltner_ratio(c, h, l, 14)
|
|
||||||
events = detect_squeezes(c, h, l, kcr)
|
|
||||||
|
|
||||||
yearly = {}
|
|
||||||
capital = float(INITIAL)
|
|
||||||
peak = capital
|
|
||||||
max_dd = 0
|
|
||||||
total_bars = 0
|
|
||||||
|
|
||||||
for ev in events:
|
|
||||||
i = ev["idx"]
|
|
||||||
if i + hold + 1 >= n:
|
|
||||||
continue
|
|
||||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
|
||||||
if abs(first_ret) < 0.001:
|
|
||||||
continue
|
|
||||||
br = h[i] - l[i]
|
|
||||||
if br > 0:
|
|
||||||
if c[i] > c[i-1]:
|
|
||||||
if (h[i] - c[i]) / br > 0.6:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
if (c[i] - l[i]) / br > 0.6:
|
|
||||||
continue
|
|
||||||
avg_v = np.mean(v[ev["sq_start"]:i])
|
|
||||||
if avg_v > 0 and v[i] <= avg_v * 1.3:
|
|
||||||
continue
|
|
||||||
|
|
||||||
direction = 1 if first_ret > 0 else -1
|
|
||||||
entry = c[i-1]
|
|
||||||
exit_price = c[min(i + hold - 1, n - 1)]
|
|
||||||
actual = (exit_price - entry) / entry * direction
|
|
||||||
net = actual * LEVERAGE - FEE_PERP * LEVERAGE
|
|
||||||
capital += capital * 0.15 * net
|
|
||||||
capital = max(capital, 10)
|
|
||||||
if capital > peak: peak = capital
|
|
||||||
dd = (peak - capital) / peak
|
|
||||||
max_dd = max(max_dd, dd)
|
|
||||||
total_bars += hold
|
|
||||||
|
|
||||||
year = ts.iloc[i].year
|
|
||||||
if year not in yearly:
|
|
||||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
|
||||||
yearly[year]["t"] += 1
|
|
||||||
if actual > 0: yearly[year]["w"] += 1
|
|
||||||
yearly[year]["pnls"].append(net * INITIAL)
|
|
||||||
|
|
||||||
all_t = sum(d["t"] for d in yearly.values())
|
|
||||||
all_w = sum(d["w"] for d in yearly.values())
|
|
||||||
if all_t == 0: return None
|
|
||||||
return _build_result(yearly, capital, max_dd, all_t, all_w,
|
|
||||||
total_bars / n * 100, hold * TF_MINUTES.get(tf, 60) / 60)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Script 13: Squeeze + ML ibrida (GBM walk-forward) ────────────────
|
|
||||||
|
|
||||||
def build_features_at(df, i, squeeze_info):
|
|
||||||
if i < 100:
|
|
||||||
return None
|
|
||||||
o = df["open"].values
|
|
||||||
h = df["high"].values
|
|
||||||
l = df["low"].values
|
|
||||||
c = df["close"].values
|
|
||||||
v = df["volume"].values
|
|
||||||
feats = []
|
|
||||||
for w in [12, 24, 48]:
|
|
||||||
win_c = c[i-w:i]
|
|
||||||
win_o = o[i-w:i]
|
|
||||||
win_h = h[i-w:i]
|
|
||||||
win_l = l[i-w:i]
|
|
||||||
win_v = v[i-w:i]
|
|
||||||
mn, mx = win_l.min(), max(win_h.max(), win_c.max())
|
|
||||||
rng = mx - mn if mx - mn > 0 else 1e-10
|
|
||||||
total = win_h - win_l
|
|
||||||
total = np.where(total == 0, 1e-10, total)
|
|
||||||
body = np.abs(win_c - win_o) / total
|
|
||||||
direction = np.sign(win_c - win_o)
|
|
||||||
log_c = np.log(np.where(win_c == 0, 1e-10, win_c))
|
|
||||||
rets = np.diff(log_c)
|
|
||||||
v_mean = np.mean(win_v)
|
|
||||||
feats.extend([
|
|
||||||
np.mean(rets) if len(rets) > 0 else 0,
|
|
||||||
np.std(rets) if len(rets) > 0 else 0,
|
|
||||||
np.sum(rets) if len(rets) > 0 else 0,
|
|
||||||
float(pd.Series(rets).skew()) if len(rets) > 2 else 0,
|
|
||||||
float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0,
|
|
||||||
np.mean(body), np.std(body),
|
|
||||||
np.mean(direction), np.mean(direction[-min(3, w):]),
|
|
||||||
(win_c[-1] - mn) / rng,
|
|
||||||
win_v[-1] / v_mean if v_mean > 0 else 1,
|
|
||||||
np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0,
|
|
||||||
])
|
|
||||||
sq = squeeze_info
|
|
||||||
feats.extend([
|
|
||||||
sq["dur"], sq["dur"] / 24, sq["kcr_at_release"],
|
|
||||||
v[i-1] / sq["avg_vol_squeeze"] if sq["avg_vol_squeeze"] > 0 else 1,
|
|
||||||
np.mean(v[i:min(i+3, len(v))]) / sq["avg_vol_squeeze"] if sq["avg_vol_squeeze"] > 0 else 1,
|
|
||||||
])
|
|
||||||
h48 = np.max(h[max(0, i-48):i])
|
|
||||||
l48 = np.min(l[max(0, i-48):i])
|
|
||||||
r48 = h48 - l48
|
|
||||||
feats.append((c[i-1] - l48) / r48 if r48 > 0 else 0.5)
|
|
||||||
tr = np.maximum(h[i-14:i] - l[i-14:i],
|
|
||||||
np.maximum(np.abs(h[i-14:i] - np.roll(c[i-14:i], 1)),
|
|
||||||
np.abs(l[i-14:i] - np.roll(c[i-14:i], 1))))
|
|
||||||
atr = np.mean(tr[1:])
|
|
||||||
feats.append(atr / c[i-1] if c[i-1] > 0 else 0)
|
|
||||||
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
|
||||||
feats.append(first_ret)
|
|
||||||
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
|
|
||||||
|
|
||||||
|
|
||||||
def run_s13_hybrid(asset, tf, bb_w, sq_thr, brk_bars, leverage, pos_pct, ml_thr):
|
|
||||||
df = load_data(asset, tf)
|
|
||||||
close = df["close"].values
|
|
||||||
high = df["high"].values
|
|
||||||
low = df["low"].values
|
|
||||||
volume = df["volume"].values
|
|
||||||
n = len(df)
|
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
||||||
|
|
||||||
kcr = keltner_ratio(close, high, low, bb_w)
|
|
||||||
events = detect_squeezes(close, high, low, kcr, sq_thr)
|
|
||||||
|
|
||||||
X_all, y_all, ev_all = [], [], []
|
|
||||||
for ev in events:
|
|
||||||
i = ev["idx"]
|
|
||||||
if i + brk_bars >= n or i < 100:
|
|
||||||
continue
|
|
||||||
feats = build_features_at(df, i, ev)
|
|
||||||
if feats is None:
|
|
||||||
continue
|
|
||||||
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
|
|
||||||
X_all.append(feats)
|
|
||||||
y_all.append(1 if actual_ret > 0 else 0)
|
|
||||||
ev_all.append(ev)
|
|
||||||
|
|
||||||
if len(X_all) < 50:
|
|
||||||
return None
|
|
||||||
|
|
||||||
X = np.array(X_all)
|
|
||||||
y = np.array(y_all)
|
|
||||||
|
|
||||||
TRAIN_SIZE = max(int(len(X) * 0.5), 50)
|
|
||||||
STEP_SIZE = max(int(len(X) * 0.1), 10)
|
|
||||||
|
|
||||||
yearly = {}
|
|
||||||
capital = float(INITIAL)
|
|
||||||
peak = capital
|
|
||||||
max_dd = 0
|
|
||||||
total_bars = 0
|
|
||||||
all_t = 0
|
|
||||||
all_w = 0
|
|
||||||
|
|
||||||
start = 0
|
|
||||||
while start + TRAIN_SIZE + STEP_SIZE <= len(X):
|
|
||||||
train_end = start + TRAIN_SIZE
|
|
||||||
test_end = min(train_end + STEP_SIZE, len(X))
|
|
||||||
X_tr, y_tr = X[start:train_end], y[start:train_end]
|
|
||||||
X_te = X[train_end:test_end]
|
|
||||||
|
|
||||||
if len(np.unique(y_tr)) < 2:
|
|
||||||
start += STEP_SIZE
|
|
||||||
continue
|
|
||||||
|
|
||||||
scaler = StandardScaler()
|
|
||||||
X_tr_s = scaler.fit_transform(X_tr)
|
|
||||||
X_te_s = scaler.transform(X_te)
|
|
||||||
|
|
||||||
model = GradientBoostingClassifier(
|
|
||||||
n_estimators=150, max_depth=4, min_samples_leaf=10,
|
|
||||||
learning_rate=0.05, subsample=0.8, random_state=42,
|
|
||||||
)
|
|
||||||
model.fit(X_tr_s, y_tr)
|
|
||||||
|
|
||||||
up_idx = list(model.classes_).index(1) if 1 in model.classes_ else -1
|
|
||||||
if up_idx < 0:
|
|
||||||
start += STEP_SIZE
|
|
||||||
continue
|
|
||||||
|
|
||||||
for j in range(len(X_te)):
|
|
||||||
proba = model.predict_proba(X_te_s[j:j+1])[0]
|
|
||||||
p_up = proba[up_idx]
|
|
||||||
|
|
||||||
ev = ev_all[train_end + j]
|
|
||||||
i = ev["idx"]
|
|
||||||
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
|
|
||||||
|
|
||||||
direction = None
|
|
||||||
if p_up >= ml_thr:
|
|
||||||
direction = 1
|
|
||||||
elif p_up <= (1 - ml_thr):
|
|
||||||
direction = -1
|
|
||||||
if direction is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
is_correct = (direction == 1 and actual_ret > 0) or (direction == -1 and actual_ret < 0)
|
|
||||||
trade_ret = actual_ret * direction
|
|
||||||
net = trade_ret * leverage - FEE_ML * 2 * leverage
|
|
||||||
capital += capital * pos_pct * net
|
|
||||||
capital = max(capital, 10)
|
|
||||||
if capital > peak: peak = capital
|
|
||||||
dd = (peak - capital) / peak
|
|
||||||
max_dd = max(max_dd, dd)
|
|
||||||
total_bars += brk_bars
|
|
||||||
|
|
||||||
all_t += 1
|
|
||||||
if is_correct: all_w += 1
|
|
||||||
|
|
||||||
year = ts.iloc[i].year
|
|
||||||
if year not in yearly:
|
|
||||||
yearly[year] = {"w": 0, "t": 0, "pnls": []}
|
|
||||||
yearly[year]["t"] += 1
|
|
||||||
if is_correct: yearly[year]["w"] += 1
|
|
||||||
yearly[year]["pnls"].append(net * INITIAL)
|
|
||||||
|
|
||||||
start += STEP_SIZE
|
|
||||||
|
|
||||||
if all_t == 0:
|
|
||||||
return None
|
|
||||||
return _build_result(yearly, capital, max_dd, all_t, all_w,
|
|
||||||
total_bars / n * 100, brk_bars * TF_MINUTES.get(tf, 60) / 60)
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
# ESECUZIONE
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print("Calcolo in corso...\n")
|
|
||||||
|
|
||||||
strategies = []
|
|
||||||
|
|
||||||
def add(name, desc, cat, result):
|
|
||||||
if result and result["trades"] >= 20:
|
|
||||||
strategies.append({"name": name, "desc": desc, "cat": cat, **result})
|
|
||||||
|
|
||||||
# ── S1: Squeeze puro ────────────────────────────────────────────
|
|
||||||
add("S1 Squeeze BTC 15m", "Squeeze breakout puro, BBw=14, hold 3×15m, leva 3x",
|
|
||||||
"S1", run_s1_squeeze("BTC", "15m"))
|
|
||||||
add("S1 Squeeze ETH 15m", "Squeeze breakout puro, BBw=14, hold 3×15m, leva 3x",
|
|
||||||
"S1", run_s1_squeeze("ETH", "15m"))
|
|
||||||
add("S1 Squeeze BTC 1h", "Squeeze breakout puro, BBw=14, hold 3×1h, leva 3x",
|
|
||||||
"S1", run_s1_squeeze("BTC", "1h"))
|
|
||||||
add("S1 Squeeze ETH 1h", "Squeeze breakout puro, BBw=14, hold 3×1h, leva 3x",
|
|
||||||
"S1", run_s1_squeeze("ETH", "1h"))
|
|
||||||
add("S1 AF+Vol BTC 15m", "Squeeze + antifakeout + volume confirm >1.3× media",
|
|
||||||
"S1", run_s1_antifake_vol("BTC", "15m"))
|
|
||||||
add("S1 AF+Vol ETH 15m", "Squeeze + antifakeout + volume confirm >1.3× media",
|
|
||||||
"S1", run_s1_antifake_vol("ETH", "15m"))
|
|
||||||
add("S1 AF+Vol BTC 1h", "Squeeze + antifakeout + volume confirm >1.3× media",
|
|
||||||
"S1", run_s1_antifake_vol("BTC", "1h"))
|
|
||||||
add("S1 AF+Vol ETH 1h", "Squeeze + antifakeout + volume confirm >1.3× media",
|
|
||||||
"S1", run_s1_antifake_vol("ETH", "1h"))
|
|
||||||
|
|
||||||
# ── Script 13: Squeeze + ML (GBM walk-forward) ─────────────────
|
|
||||||
print(" Training ML models...")
|
|
||||||
add("S13 ETH 15m bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.70, 3x leva 15% pos",
|
|
||||||
"S13", run_s13_hybrid("ETH", "15m", 14, 0.8, 3, 3, 0.15, 0.70))
|
|
||||||
add("S13 ETH 15m bb14 ml65", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.65, 3x leva 15% pos",
|
|
||||||
"S13", run_s13_hybrid("ETH", "15m", 14, 0.8, 3, 3, 0.15, 0.65))
|
|
||||||
add("S13 ETH 15m bb20 ml70", "Squeeze+GBM walk-forward, BBw=20 sq=0.9 ml≥0.70, 3x leva 15% pos",
|
|
||||||
"S13", run_s13_hybrid("ETH", "15m", 20, 0.9, 3, 3, 0.15, 0.70))
|
|
||||||
add("S13 BTC 15m bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.9 ml≥0.70, 3x leva 15% pos",
|
|
||||||
"S13", run_s13_hybrid("BTC", "15m", 14, 0.9, 3, 3, 0.15, 0.70))
|
|
||||||
add("S13 BTC 15m bb14 ml65", "Squeeze+GBM walk-forward, BBw=14 sq=0.9 ml≥0.65, 3x leva 15% pos",
|
|
||||||
"S13", run_s13_hybrid("BTC", "15m", 14, 0.9, 3, 3, 0.15, 0.65))
|
|
||||||
add("S13 BTC 1h bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.70, 3x leva 20% pos",
|
|
||||||
"S13", run_s13_hybrid("BTC", "1h", 14, 0.8, 3, 3, 0.20, 0.70))
|
|
||||||
add("S13 BTC 1h bb14 ml65", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.65, 3x leva 20% pos",
|
|
||||||
"S13", run_s13_hybrid("BTC", "1h", 14, 0.8, 3, 3, 0.20, 0.65))
|
|
||||||
add("S13 ETH 1h bb14 ml70", "Squeeze+GBM walk-forward, BBw=14 sq=0.8 ml≥0.70, 3x leva 20% pos",
|
|
||||||
"S13", run_s13_hybrid("ETH", "1h", 14, 0.8, 3, 3, 0.20, 0.70))
|
|
||||||
|
|
||||||
strategies.sort(key=lambda x: x["acc"], reverse=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
# TABELLA 1: Classifica
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
W = 150
|
|
||||||
print("=" * W)
|
|
||||||
print(" S1 (SQUEEZE PURO) vs S13 (SQUEEZE + GBM) — CLASSIFICA FINALE")
|
|
||||||
print(f" Fee: 0.2% RT. Dati OHLCV reali 2018-2026. Position 15%. Leva 3x.")
|
|
||||||
print("=" * W)
|
|
||||||
hdr = (f" {'#':>2s} {'Cat':>3s} {'Nome':<26s} {'Trades':>6s} {'Acc':>6s} "
|
|
||||||
f"{'PnL€':>9s} {'DD%':>6s} {'€/day':>7s} {'Sharpe':>7s} "
|
|
||||||
f"{'Mkt%':>5s} {'Dur':>6s} {'Worst':>12s} {'Anni':>4s}")
|
|
||||||
print(hdr)
|
|
||||||
print(f" {'─'*(W-4)}")
|
|
||||||
|
|
||||||
for idx, s in enumerate(strategies, 1):
|
|
||||||
worst = f"{s['worst_year']}({s['worst_acc']:.0f}%)"
|
|
||||||
dur_str = f"{s['avg_dur_h']:.0f}h" if s['avg_dur_h'] >= 1 else f"{s['avg_dur_h']*60:.0f}m"
|
|
||||||
tag = " ★★" if s["acc"] >= 78 else " ★" if s["acc"] >= 76 else ""
|
|
||||||
print(f" {idx:>2d} {s['cat']:>3s} {s['name']:<26s} {s['trades']:>6d} {s['acc']:>5.1f}% "
|
|
||||||
f"€{s['pnl']:>+8.0f} {s['max_dd']:>5.1f}% {s['daily_pnl']:>+6.2f} {s['sharpe']:>7.2f} "
|
|
||||||
f"{s['time_in_market_pct']:>4.1f}% {dur_str:>6s} {worst:>12s} {s['years_active']:>4d}{tag}")
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
# TABELLA 2: Descrizione
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print(f"\n\n{'=' * W}")
|
|
||||||
print(" DESCRIZIONE")
|
|
||||||
print(f"{'=' * W}")
|
|
||||||
print(f" {'#':>2s} {'Nome':<26s} {'Descrizione'}")
|
|
||||||
print(f" {'─'*(W-4)}")
|
|
||||||
for idx, s in enumerate(strategies, 1):
|
|
||||||
print(f" {idx:>2d} {s['name']:<26s} {s['desc']}")
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
# TABELLA 3: Breakdown per anno
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
top_n = min(12, len(strategies))
|
|
||||||
top = strategies[:top_n]
|
|
||||||
all_years = sorted(set(y for s in top for y in s["year_details"]))
|
|
||||||
|
|
||||||
print(f"\n\n{'=' * W}")
|
|
||||||
print(f" BREAKDOWN PER ANNO — TOP {top_n} (accuracy% / trades)")
|
|
||||||
print(f"{'=' * W}")
|
|
||||||
|
|
||||||
header = f" {'Nome':<26s}"
|
|
||||||
for y in all_years:
|
|
||||||
header += f" {y:>10d}"
|
|
||||||
print(header)
|
|
||||||
print(f" {'─'*(W-4)}")
|
|
||||||
|
|
||||||
for s in top:
|
|
||||||
line = f" {s['name']:<26s}"
|
|
||||||
for y in all_years:
|
|
||||||
if y in s["year_details"]:
|
|
||||||
d = s["year_details"][y]
|
|
||||||
line += f" {d['acc']:>4.0f}%/{d['trades']:<4d}"
|
|
||||||
else:
|
|
||||||
line += f" {'—':>10s}"
|
|
||||||
print(line)
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
# TABELLA 4: Robustezza
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print(f"\n\n{'=' * W}")
|
|
||||||
print(f" ANALISI ROBUSTEZZA")
|
|
||||||
print(f"{'=' * W}")
|
|
||||||
print(f" {'#':>2s} {'Nome':<26s} {'MinAcc':>7s} {'MaxAcc':>7s} {'Spread':>7s} "
|
|
||||||
f"{'AnniOK':>7s} {'€/trade':>8s} {'Verdict':<12s}")
|
|
||||||
print(f" {'─'*90}")
|
|
||||||
|
|
||||||
for idx, s in enumerate(strategies, 1):
|
|
||||||
yd = s["year_details"]
|
|
||||||
valid = {y: d for y, d in yd.items() if d["trades"] >= 10}
|
|
||||||
accs = [d["acc"] for d in (valid if valid else yd).values()]
|
|
||||||
if not accs:
|
|
||||||
continue
|
|
||||||
min_a, max_a = min(accs), max(accs)
|
|
||||||
spread = max_a - min_a
|
|
||||||
years_ok = sum(1 for a in accs if a >= 70)
|
|
||||||
avg_pnl = s["pnl"] / s["trades"] if s["trades"] > 0 else 0
|
|
||||||
n_valid = len(valid if valid else yd)
|
|
||||||
|
|
||||||
if n_valid < 4:
|
|
||||||
verdict = "⚠ CORTO"
|
|
||||||
elif min_a < 60:
|
|
||||||
verdict = "⚠ FRAGILE"
|
|
||||||
elif min_a >= 72 and s["acc"] >= 77:
|
|
||||||
verdict = "✅ SOLIDO"
|
|
||||||
elif min_a >= 65 and s["acc"] >= 74:
|
|
||||||
verdict = "~ BUONO"
|
|
||||||
else:
|
|
||||||
verdict = "~ OK"
|
|
||||||
|
|
||||||
print(f" {idx:>2d} {s['name']:<26s} {min_a:>6.1f}% {max_a:>6.1f}% {spread:>6.1f}% "
|
|
||||||
f"{years_ok:>3d}/{n_valid:<3d} €{avg_pnl:>+7.1f} {verdict:<12s}")
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
# VERDETTO
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print(f"\n\n{'=' * W}")
|
|
||||||
print(f" VERDETTO FINALE")
|
|
||||||
print(f"{'=' * W}")
|
|
||||||
|
|
||||||
solidi = [s for s in strategies if s["trades"] >= 200 and s["years_active"] >= 5 and s["worst_acc"] >= 65]
|
|
||||||
solidi_s1 = [s for s in solidi if s["cat"] == "S1"]
|
|
||||||
solidi_ml = [s for s in solidi if s["cat"] == "S13"]
|
|
||||||
solidi_s1.sort(key=lambda x: x["acc"], reverse=True)
|
|
||||||
solidi_ml.sort(key=lambda x: x["daily_pnl"], reverse=True)
|
|
||||||
|
|
||||||
if solidi_s1:
|
|
||||||
b = solidi_s1[0]
|
|
||||||
print(f"\n MIGLIORE S1 (regole pure, facile da deployare):")
|
|
||||||
print(f" {b['name']} — {b['acc']:.1f}% acc, {b['trades']} trades, DD {b['max_dd']:.1f}%, €{b['daily_pnl']:+.2f}/day, Sharpe {b['sharpe']:.2f}")
|
|
||||||
|
|
||||||
if solidi_ml:
|
|
||||||
m = solidi_ml[0]
|
|
||||||
print(f"\n MIGLIORE S13 (squeeze+GBM, più complesso):")
|
|
||||||
print(f" {m['name']} — {m['acc']:.1f}% acc, {m['trades']} trades, DD {m['max_dd']:.1f}%, €{m['daily_pnl']:+.2f}/day, Sharpe {m['sharpe']:.2f}")
|
|
||||||
|
|
||||||
max_pnl = max(strategies, key=lambda x: x["pnl"])
|
|
||||||
print(f"\n MAX PnL: {max_pnl['name']} — €{max_pnl['pnl']:+,.0f}")
|
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""GATE DIP01 + PORT06: estendere EXIT-16 (close-confirm SL) a DIP01 (sweep punto 9).
|
||||||
|
|
||||||
|
DIP01 e' l'unico sleeve BTC con esecuzione REALE round-trip, e gira ancora col
|
||||||
|
branch SL intrabar wick-sensitive. EXIT-16 e' stato validato SULLE FADE: estenderlo
|
||||||
|
a una strategia honest richiede la validazione sul grid proprio di DIP01, con
|
||||||
|
engine GAP-AWARE (lezione exit-lab: l'engine canonico filla gli stop "al livello"
|
||||||
|
anche su gap-through -> bias PRO stop intrabar stretti; il confronto onesto filla
|
||||||
|
lo SL a worse(livello, open)).
|
||||||
|
|
||||||
|
Protocollo:
|
||||||
|
[1] parita': replay engine 'orig' (fill al livello) == equity canonica DIP01_BTC
|
||||||
|
[2] grid 3x3x2 (z_in x sl_atr x max_bars) su BTC (deployato) ed ETH (robustezza):
|
||||||
|
orig GAP-AWARE vs EXIT-16(buf 0.5), ret/DD/Sharpe train (pre-OOS) e OOS
|
||||||
|
[3] plateau buffer {0.4, 0.5, 0.75, 1.0} sulla cella canonica
|
||||||
|
[4] gate PORT06: DIP01_BTC exit16 innestato nel canonico, pesi cap
|
||||||
|
-> PROMOSSO se OOS Sharpe non peggiora E FULL/DD non degradano materialmente.
|
||||||
|
|
||||||
|
NB hurst_max NON valutato: il gate trendmax (2026-06-07) ha mostrato che il
|
||||||
|
loss-guard Hurst e' ridondante-dannoso POST-EXIT-16 (stesso regime target).
|
||||||
|
|
||||||
|
uv run python scripts/analysis/dip01_exit16_impact.py
|
||||||
|
"""
|
||||||
|
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 atr
|
||||||
|
from scripts.analysis.combine_portfolio import (
|
||||||
|
_norm, IDX, port_returns, metrics, SPLIT, OOS_DATE,
|
||||||
|
)
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS
|
||||||
|
from src.portfolio import weighting as W
|
||||||
|
|
||||||
|
FEE_RT, LEV, POS, INIT = 0.001, 3.0, 0.15, 1000.0
|
||||||
|
BUFFER = 0.5
|
||||||
|
GRID_Z = (2.0, 2.5, 3.0)
|
||||||
|
GRID_SL = (2.0, 2.5, 3.0)
|
||||||
|
GRID_MB = (24, 48)
|
||||||
|
CANON = dict(n=50, z_in=2.5, sl_atr=2.5, max_bars=24)
|
||||||
|
|
||||||
|
|
||||||
|
def dip_entries(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24):
|
||||||
|
"""Entries DIP01 == honest_improve2.dip_market_gated (market_n=0): crossing
|
||||||
|
di z sotto -z_in. Ritorna [{i, tp, sl, mb}] (long-only)."""
|
||||||
|
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)
|
||||||
|
out = []
|
||||||
|
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:
|
||||||
|
out.append({"i": i, "tp": ma[i], "sl": c[i] - sl_atr * a[i],
|
||||||
|
"mb": max_bars})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def dip_trades(ents, df, mode, buffer=BUFFER):
|
||||||
|
"""Engine exit DIP01 (long-only), non-overlap come il canonico.
|
||||||
|
|
||||||
|
mode="orig" : SL intrabar fill AL LIVELLO (== canonico, per la parita')
|
||||||
|
mode="orig_gap" : SL intrabar fill a worse(livello, open[j]) — gap-aware
|
||||||
|
mode="exit16" : SL intrabar OFF; TP intrabar al livello (priorita' nel bar);
|
||||||
|
stop solo se close[j] < sl - buffer*ATR14[j], fill a close[j]
|
||||||
|
"""
|
||||||
|
h, l, c, o = df["high"].values, df["low"].values, df["close"].values, df["open"].values
|
||||||
|
n = len(c)
|
||||||
|
a = atr(df, 14)
|
||||||
|
fee = FEE_RT * LEV
|
||||||
|
out = []
|
||||||
|
last = -1
|
||||||
|
for e in ents:
|
||||||
|
i = e["i"]
|
||||||
|
if i <= last or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
tp, sl, mb = e["tp"], e["sl"], e["mb"]
|
||||||
|
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 mode in ("orig", "orig_gap"):
|
||||||
|
if l[j] <= sl:
|
||||||
|
exit_p = sl if mode == "orig" else min(sl, o[j])
|
||||||
|
break
|
||||||
|
if h[j] >= tp:
|
||||||
|
exit_p = tp
|
||||||
|
break
|
||||||
|
if k == mb:
|
||||||
|
exit_p = c[j]
|
||||||
|
else: # exit16
|
||||||
|
if h[j] >= tp:
|
||||||
|
exit_p = tp
|
||||||
|
break
|
||||||
|
aj = a[j] if np.isfinite(a[j]) else 0.0
|
||||||
|
if c[j] < sl - buffer * aj:
|
||||||
|
exit_p = c[j]
|
||||||
|
break
|
||||||
|
if k == mb:
|
||||||
|
exit_p = c[j]
|
||||||
|
ret = (exit_p - c[i]) / c[i] * LEV - fee
|
||||||
|
out.append((i, j, ret))
|
||||||
|
last = j
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def daily_equity(df, trades):
|
||||||
|
"""Equity giornaliera con la convenzione CANONICA honest (_daily_equity su punti
|
||||||
|
trade-exit). NB: la serie a punti-trade reindexata su IDX ancora il primo valore
|
||||||
|
al PRIMO trade dentro IDX (bfill), non al capitale portato avanti da prima —
|
||||||
|
convenzione discutibile ma e' quella di build_everything: per la parita' (e il
|
||||||
|
confronto col PORT06 canonico) va replicata esattamente."""
|
||||||
|
from scripts.analysis.honest_improve2 import _daily_equity
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
cap = INIT
|
||||||
|
eq_ts, eq_v = [], []
|
||||||
|
for i, j, ret in sorted(trades, key=lambda t: t[1]):
|
||||||
|
cap = max(cap + cap * POS * ret, 10.0)
|
||||||
|
eq_ts.append(ts.iloc[j])
|
||||||
|
eq_v.append(cap)
|
||||||
|
return _norm(_daily_equity(eq_ts, eq_v, IDX))
|
||||||
|
|
||||||
|
|
||||||
|
def cell_metrics(eq):
|
||||||
|
dr = eq.pct_change().fillna(0.0)
|
||||||
|
return metrics(dr), metrics(dr, lo=SPLIT)
|
||||||
|
|
||||||
|
|
||||||
|
def port_metrics(members, p):
|
||||||
|
ids = p.sleeve_ids
|
||||||
|
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
|
||||||
|
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
|
||||||
|
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
||||||
|
drp = port_returns({i: members[i] for i in ids}, w)
|
||||||
|
return metrics(drp), metrics(drp, lo=SPLIT)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = PORTFOLIOS["PORT06"]
|
||||||
|
print("=" * 104)
|
||||||
|
print(" GATE DIP01 EXIT-16 (close-confirm 0.5 ATR) — grid gap-aware + PORT06")
|
||||||
|
print(f" OOS da {OOS_DATE} | fee {FEE_RT*100:.2f}%RT x lev{LEV:.0f} | pos {POS}")
|
||||||
|
print("=" * 104)
|
||||||
|
|
||||||
|
print("\n[1] build_everything() canonico (cache)...")
|
||||||
|
from src.portfolio.sleeves import all_sleeve_equities
|
||||||
|
eq_base = dict(all_sleeve_equities())
|
||||||
|
|
||||||
|
dfs = {a: load_data(a, "1h") for a in ("BTC", "ETH")}
|
||||||
|
|
||||||
|
# --- parita' ---
|
||||||
|
ents = dip_entries(dfs["BTC"], **CANON)
|
||||||
|
rep = daily_equity(dfs["BTC"], dip_trades(ents, dfs["BTC"], "orig"))
|
||||||
|
base = eq_base["DIP01_BTC"]
|
||||||
|
corr = base.pct_change().fillna(0).corr(rep.pct_change().fillna(0))
|
||||||
|
rb = (base.iloc[-1] / base.iloc[0] - 1) * 100
|
||||||
|
rr = (rep.iloc[-1] / rep.iloc[0] - 1) * 100
|
||||||
|
print(f"\n[1] PARITA' orig vs canonico: corr={corr:.5f} ret {rb:+.0f}% vs {rr:+.0f}%")
|
||||||
|
if not (corr > 0.999 and abs(rr - rb) <= max(1.0, abs(rb) * 0.01)):
|
||||||
|
print(" >>> PARITA' FALLITA: STOP.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- [2] grid gap-aware ---
|
||||||
|
for asset in ("BTC", "ETH"):
|
||||||
|
df = dfs[asset]
|
||||||
|
print(f"\n[2] GRID {asset} — orig GAP-AWARE vs EXIT-16 (train | OOS: ret% e Sharpe)")
|
||||||
|
print(f" {'cella':<16s}{'tr retO':>9s}{'tr retE':>9s} {'oos retO':>9s}{'oos retE':>9s}"
|
||||||
|
f" {'oos ShO':>8s}{'oos ShE':>8s} {'ddO':>6s}{'ddE':>6s} esito")
|
||||||
|
wins_tr = wins_oos = cells = 0
|
||||||
|
for z in GRID_Z:
|
||||||
|
for slm in GRID_SL:
|
||||||
|
for mb in GRID_MB:
|
||||||
|
ents = dip_entries(df, n=50, z_in=z, sl_atr=slm, max_bars=mb)
|
||||||
|
eo = daily_equity(df, dip_trades(ents, df, "orig_gap"))
|
||||||
|
ee = daily_equity(df, dip_trades(ents, df, "exit16"))
|
||||||
|
fo, oo = cell_metrics(eo)
|
||||||
|
fe, oe = cell_metrics(ee)
|
||||||
|
tr_o = fo["ret"] - oo["ret"]; tr_e = fe["ret"] - oe["ret"] # ~train (full-oos, approssimato su ret composti: usare segni)
|
||||||
|
# train ret esatto: equity al SPLIT
|
||||||
|
tr_o = (eo.iloc[SPLIT] / eo.iloc[0] - 1) * 100
|
||||||
|
tr_e = (ee.iloc[SPLIT] / ee.iloc[0] - 1) * 100
|
||||||
|
cells += 1
|
||||||
|
w_tr = tr_e >= tr_o
|
||||||
|
w_oos = oe["ret"] >= oo["ret"]
|
||||||
|
wins_tr += w_tr
|
||||||
|
wins_oos += w_oos
|
||||||
|
tag = ("OK" if (w_tr and w_oos) else "tr-" if w_oos else "oos-" if w_tr else "KO")
|
||||||
|
print(f" z{z} sl{slm} mb{mb:<3d}{tr_o:>9.0f}{tr_e:>9.0f} "
|
||||||
|
f"{oo['ret']:>9.0f}{oe['ret']:>9.0f} {oo['sharpe']:>8.2f}{oe['sharpe']:>8.2f}"
|
||||||
|
f" {fo['dd']:>6.1f}{fe['dd']:>6.1f} {tag}")
|
||||||
|
print(f" -> EXIT-16 >= orig-gap: train {wins_tr}/{cells}, OOS {wins_oos}/{cells}")
|
||||||
|
|
||||||
|
# --- [3] plateau buffer (BTC, cella canonica) ---
|
||||||
|
print("\n[3] Plateau buffer EXIT-16 (BTC, cella canonica):")
|
||||||
|
ents = dip_entries(dfs["BTC"], **CANON)
|
||||||
|
for buf in (0.4, 0.5, 0.75, 1.0):
|
||||||
|
ee = daily_equity(dfs["BTC"], dip_trades(ents, dfs["BTC"], "exit16", buffer=buf))
|
||||||
|
fe, oe = cell_metrics(ee)
|
||||||
|
print(f" buf {buf:<5}FULL ret {fe['ret']:>+7.0f}% DD {fe['dd']:>5.1f} Sh {fe['sharpe']:>5.2f}"
|
||||||
|
f" | OOS ret {oe['ret']:>+6.0f}% DD {oe['dd']:>5.1f} Sh {oe['sharpe']:>5.2f}")
|
||||||
|
|
||||||
|
# --- [4] gate PORT06 ---
|
||||||
|
ee = daily_equity(dfs["BTC"], dip_trades(ents, dfs["BTC"], "exit16"))
|
||||||
|
members_b = dict(eq_base)
|
||||||
|
members_e = dict(eq_base)
|
||||||
|
members_e["DIP01_BTC"] = ee
|
||||||
|
f_b, o_b = port_metrics(members_b, p)
|
||||||
|
f_e, o_e = port_metrics(members_e, p)
|
||||||
|
print("\n" + "=" * 104)
|
||||||
|
print(f" [4] PORT06 (pesi cap {p.caps}) — DIP01_BTC orig vs EXIT-16")
|
||||||
|
print("=" * 104)
|
||||||
|
print(f" {'variante':<10s}{'FULL Sh':>9s}{'FULL DD%':>10s}{'CAGR':>6s} | {'OOS Sh':>7s}{'OOS DD%':>8s}{'CAGR':>6s}")
|
||||||
|
for nm, (f, o) in (("BASE", (f_b, o_b)), ("EXIT-16", (f_e, o_e))):
|
||||||
|
print(f" {nm:<10s}{f['sharpe']:>9.2f}{f['dd']:>10.2f}{f['cagr']:>5.0f}% | "
|
||||||
|
f"{o['sharpe']:>7.2f}{o['dd']:>8.2f}{o['cagr']:>5.0f}%")
|
||||||
|
|
||||||
|
oos_ok = o_e["sharpe"] >= o_b["sharpe"] - 0.02 and o_e["dd"] <= o_b["dd"] + 0.20
|
||||||
|
full_ok = f_e["sharpe"] >= f_b["sharpe"] - 0.02 and f_e["dd"] <= f_b["dd"] + 0.20
|
||||||
|
promoted = oos_ok and full_ok
|
||||||
|
print(f"\n GATE: OOS {'OK' if oos_ok else 'KO'} | FULL {'OK' if full_ok else 'KO'}")
|
||||||
|
print(" VERDETTO: " + (">>> PROMOSSO <<<" if promoted else ">>> BOCCIATO <<<"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""TEST DECISIVO: impatto di EXIT-16 (close_confirm_sl, buffer=0.5 ATR) sul PORT06,
|
||||||
|
nel PATH CANONICO del backtest (NON exit_lab).
|
||||||
|
|
||||||
|
EXIT-16: lo SL intrabar e' DISATTIVATO; si esce al close del bar j solo se il close
|
||||||
|
ha sfondato il livello di buffer*ATR14:
|
||||||
|
long (d=1): esci a close[j] se close[j] < sl0 - 0.5*atr14[j]
|
||||||
|
short (d=-1): esci a close[j] se close[j] > sl0 + 0.5*atr14[j]
|
||||||
|
TP intrabar al livello e max_bars al close restano INVARIATI.
|
||||||
|
|
||||||
|
Metodo (come fu fatto per il loss-guard Hurst):
|
||||||
|
1. build_everything() canonico -> equity giornaliere di TUTTI gli sleeve (cache intatta).
|
||||||
|
2. ricostruisco le 6 equity fade in variante EXIT-16 replicando ESATTAMENTE
|
||||||
|
fade_daily_equity/build_trades (stessi segnali fn(df,**params), trend_max=3.0,
|
||||||
|
fee 0.10%RT*lev3, pos 0.15, compounding, non-overlap), cambiando SOLO il ramo SL.
|
||||||
|
3. PARITA': con la SL-rule originale il replay deve riprodurre le equity canoniche.
|
||||||
|
4. PORT06 base vs EXIT-16 con la STESSA matematica dei pesi (Portfolio.backtest):
|
||||||
|
weighting cap, caps PAIRS 0.33, ribilancio 1D, metriche FULL e OOS.
|
||||||
|
|
||||||
|
NB: la leva 2x del portfolios.yml NON entra nel backtest (Portfolio.backtest la ignora;
|
||||||
|
e' un knob live). Le equity fade gia' includono lev=3 dentro build_trades.
|
||||||
|
"""
|
||||||
|
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 atr
|
||||||
|
from scripts.analysis.risk_management import strats_for, FEE_RT, LEV, POS, INIT
|
||||||
|
from scripts.analysis.combine_portfolio import (
|
||||||
|
fade_daily_equity, _norm, IDX, port_returns, metrics, SPLIT, OOS_DATE,
|
||||||
|
)
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS
|
||||||
|
from src.portfolio import weighting as W
|
||||||
|
|
||||||
|
BUFFER = 0.5 # EXIT-16 promossa: close-confirm con buffer 0.5 ATR
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- engine replay
|
||||||
|
def build_trades_variant(ents, df, mode, buffer=BUFFER,
|
||||||
|
lev=LEV, fee_rt=FEE_RT, trend_max=3.0, ema_long=200):
|
||||||
|
"""Replica ESATTA di risk_management.build_trades, cambiando SOLO il ramo SL.
|
||||||
|
|
||||||
|
mode="orig" : SL intrabar al livello (SL prima del TP) == canonico.
|
||||||
|
mode="exit16" : SL intrabar DISATTIVATO; close-confirm sul close[j]:
|
||||||
|
long esci a close[j] se close[j] < sl0 - buffer*atr14[j]
|
||||||
|
short esci a close[j] se close[j] > sl0 + buffer*atr14[j]
|
||||||
|
TP intrabar al livello e max_bars al close INVARIATI.
|
||||||
|
"""
|
||||||
|
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, sl0, 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
|
||||||
|
if mode == "orig":
|
||||||
|
hs = (d == 1 and l[j] <= sl0) or (d == -1 and h[j] >= sl0)
|
||||||
|
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||||
|
if hs:
|
||||||
|
exit_p = sl0
|
||||||
|
break
|
||||||
|
if ht:
|
||||||
|
exit_p = tp
|
||||||
|
break
|
||||||
|
if k == mb:
|
||||||
|
exit_p = c[j]
|
||||||
|
else: # exit16: no SL intrabar; TP intrabar; poi close-confirm SL al close[j]
|
||||||
|
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||||
|
if ht:
|
||||||
|
exit_p = tp
|
||||||
|
break
|
||||||
|
aj = a[j] if np.isfinite(a[j]) else 0.0
|
||||||
|
confirm = (d == 1 and c[j] < sl0 - buffer * aj) or \
|
||||||
|
(d == -1 and c[j] > sl0 + buffer * aj)
|
||||||
|
if confirm:
|
||||||
|
exit_p = c[j]
|
||||||
|
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 fade_equity_variant(asset, fn, params, mode):
|
||||||
|
"""Stesso flusso di combine_portfolio.fade_daily_equity ma con build_trades_variant."""
|
||||||
|
df = load_data(asset, "1h")
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
trades = build_trades_variant(fn(df, **params), df, mode=mode, 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 * POS * ret, 10.0)
|
||||||
|
eq[j:] = cap
|
||||||
|
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
|
||||||
|
return _norm(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- pesi PORT06
|
||||||
|
def port_metrics(members: dict[str, pd.Series], weights: dict[str, float]):
|
||||||
|
dr = port_returns(members, weights)
|
||||||
|
return metrics(dr), metrics(dr, lo=SPLIT)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = PORTFOLIOS["PORT06"]
|
||||||
|
fade_ids = [s.sid for s in p.sleeves if s.sid.startswith("MR")]
|
||||||
|
print("=" * 96)
|
||||||
|
print(" TEST DECISIVO EXIT-16 (close_confirm_sl buffer=0.5 ATR) su PORT06 — path canonico")
|
||||||
|
print(f" fade sleeve: {fade_ids}")
|
||||||
|
print("=" * 96)
|
||||||
|
|
||||||
|
# --- 1. equity canoniche di TUTTI gli sleeve (cache intatta) ---
|
||||||
|
print("\n[1] build_everything() canonico (pesante, ~2-3 min)...")
|
||||||
|
from src.portfolio.sleeves import all_sleeve_equities
|
||||||
|
eq_base = dict(all_sleeve_equities()) # {sid: equity giornaliera}
|
||||||
|
print(f" sleeve totali: {len(eq_base)}")
|
||||||
|
|
||||||
|
# --- 2. PARITA': replay 'orig' deve riprodurre le equity canoniche ---
|
||||||
|
print("\n[2] PARITA' replay (mode=orig) vs canonico (fade_daily_equity):")
|
||||||
|
print(f" {'sleeve':<10s}{'corr':>10s}{'ret_canon%':>14s}{'ret_replay%':>14s}{'diff%':>9s}")
|
||||||
|
parity_ok = True
|
||||||
|
eq_orig, eq_e16 = {}, {}
|
||||||
|
for asset in ("BTC", "ETH"):
|
||||||
|
for nm, (fn, params) in strats_for(asset).items():
|
||||||
|
sid = f"{nm}_{asset}"
|
||||||
|
if sid not in fade_ids:
|
||||||
|
continue
|
||||||
|
eq_orig[sid] = fade_equity_variant(asset, fn, params, mode="orig")
|
||||||
|
eq_e16[sid] = fade_equity_variant(asset, fn, params, mode="exit16")
|
||||||
|
base = eq_base[sid]
|
||||||
|
rep = eq_orig[sid]
|
||||||
|
corr = base.pct_change().fillna(0).corr(rep.pct_change().fillna(0))
|
||||||
|
rb = (base.iloc[-1] / base.iloc[0] - 1) * 100
|
||||||
|
rr = (rep.iloc[-1] / rep.iloc[0] - 1) * 100
|
||||||
|
diff = rr - rb
|
||||||
|
flag = "" if (corr > 0.999 and abs(diff) <= max(1.0, abs(rb) * 0.01)) else " <-- MISMATCH"
|
||||||
|
if flag:
|
||||||
|
parity_ok = False
|
||||||
|
print(f" {sid:<10s}{corr:>10.5f}{rb:>14.1f}{rr:>14.1f}{diff:>+9.2f}{flag}")
|
||||||
|
print(f"\n PARITA' {'OK' if parity_ok else 'FALLITA'} "
|
||||||
|
f"(corr>0.999 e ret finale entro 1%).")
|
||||||
|
if not parity_ok:
|
||||||
|
print("\n >>> Parita' non raggiunta: NON forzo. Diagnostico sopra. STOP.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- 3. PORT06 base vs EXIT-16: stessi pesi cap, stessa matematica ---
|
||||||
|
members_base = dict(eq_base)
|
||||||
|
members_e16 = dict(eq_base)
|
||||||
|
for sid in fade_ids:
|
||||||
|
members_e16[sid] = eq_e16[sid] # sostituisco SOLO le 6 colonne fade
|
||||||
|
|
||||||
|
ids = p.sleeve_ids
|
||||||
|
# pesi cap canonici (gli stessi che usa Portfolio.backtest)
|
||||||
|
dr_base = pd.DataFrame({i: members_base[i].pct_change().fillna(0.0) for i in ids})
|
||||||
|
w_base = W.weight_vector(p.weighting, ids, dr_base, weights=p.weights,
|
||||||
|
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
||||||
|
dr_e16 = pd.DataFrame({i: members_e16[i].pct_change().fillna(0.0) for i in ids})
|
||||||
|
w_e16 = W.weight_vector(p.weighting, ids, dr_e16, weights=p.weights,
|
||||||
|
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
||||||
|
|
||||||
|
f_b, o_b = port_metrics({i: members_base[i] for i in ids}, w_base)
|
||||||
|
f_e, o_e = port_metrics({i: members_e16[i] for i in ids}, w_e16)
|
||||||
|
|
||||||
|
print("\n" + "=" * 96)
|
||||||
|
print(f" [3] PORT06 — pesi={p.weighting} caps={p.caps} | OOS da {OOS_DATE} | leva3x interna fade, pos0.15")
|
||||||
|
print("=" * 96)
|
||||||
|
print(f" {'variante':<14s}{'FULL Sh':>9s}{'FULL DD%':>10s}{'FULL CAGR':>11s}"
|
||||||
|
f" | {'OOS Sh':>8s}{'OOS DD%':>9s}{'OOS CAGR':>10s}")
|
||||||
|
print(" " + "-" * 90)
|
||||||
|
print(f" {'BASE':<14s}{f_b['sharpe']:>9.2f}{f_b['dd']:>10.2f}{f_b['cagr']:>10.0f}%"
|
||||||
|
f" | {o_b['sharpe']:>8.2f}{o_b['dd']:>9.2f}{o_b['cagr']:>9.0f}%")
|
||||||
|
print(f" {'EXIT-16':<14s}{f_e['sharpe']:>9.2f}{f_e['dd']:>10.2f}{f_e['cagr']:>10.0f}%"
|
||||||
|
f" | {o_e['sharpe']:>8.2f}{o_e['dd']:>9.2f}{o_e['cagr']:>9.0f}%")
|
||||||
|
print(" " + "-" * 90)
|
||||||
|
print(f" {'DELTA':<14s}{f_e['sharpe']-f_b['sharpe']:>+9.2f}{f_e['dd']-f_b['dd']:>+10.2f}"
|
||||||
|
f"{f_e['cagr']-f_b['cagr']:>+10.0f}% | {o_e['sharpe']-o_b['sharpe']:>+8.2f}"
|
||||||
|
f"{o_e['dd']-o_b['dd']:>+9.2f}{o_e['cagr']-o_b['cagr']:>+9.0f}%")
|
||||||
|
|
||||||
|
# --- per-sleeve fade: differenze principali ---
|
||||||
|
print("\n Per-sleeve fade (equity FULL ret%, EXIT-16 vs orig-replay):")
|
||||||
|
print(f" {'sleeve':<10s}{'orig ret%':>12s}{'exit16 ret%':>14s}{'delta%':>10s}"
|
||||||
|
f"{'orig DD%':>10s}{'e16 DD%':>10s}")
|
||||||
|
for sid in fade_ids:
|
||||||
|
ro = eq_orig[sid]; re = eq_e16[sid]
|
||||||
|
def _dd(s):
|
||||||
|
pk = s.cummax(); return float(((pk - s) / pk).max() * 100)
|
||||||
|
rro = (ro.iloc[-1] / ro.iloc[0] - 1) * 100
|
||||||
|
rre = (re.iloc[-1] / re.iloc[0] - 1) * 100
|
||||||
|
print(f" {sid:<10s}{rro:>12.1f}{rre:>14.1f}{rre-rro:>+10.1f}"
|
||||||
|
f"{_dd(ro):>10.1f}{_dd(re):>10.1f}")
|
||||||
|
|
||||||
|
# --- GATE ---
|
||||||
|
print("\n" + "=" * 96)
|
||||||
|
print(" GATE (stesso del loss-guard): PROMOSSO se OOS Sharpe migliora/pari E DD non peggiora")
|
||||||
|
print(" materialmente, E in FULL non degrada.")
|
||||||
|
print("=" * 96)
|
||||||
|
oos_sh_ok = o_e['sharpe'] >= o_b['sharpe'] - 0.02
|
||||||
|
oos_dd_ok = o_e['dd'] <= o_b['dd'] + 0.20 # no peggioramento materiale DD
|
||||||
|
full_ok = f_e['sharpe'] >= f_b['sharpe'] - 0.02 and f_e['dd'] <= f_b['dd'] + 0.20
|
||||||
|
promoted = oos_sh_ok and oos_dd_ok and full_ok
|
||||||
|
print(f" OOS Sharpe {o_b['sharpe']:.2f} -> {o_e['sharpe']:.2f} "
|
||||||
|
f"({'OK' if oos_sh_ok else 'KO'})")
|
||||||
|
print(f" OOS DD% {o_b['dd']:.2f} -> {o_e['dd']:.2f} "
|
||||||
|
f"({'OK' if oos_dd_ok else 'KO'})")
|
||||||
|
print(f" FULL Sharpe {f_b['sharpe']:.2f} -> {f_e['sharpe']:.2f} | "
|
||||||
|
f"FULL DD {f_b['dd']:.2f} -> {f_e['dd']:.2f} ({'OK' if full_ok else 'KO'})")
|
||||||
|
print("\n VERDETTO: " + (">>> PROMOSSO <<<" if promoted else ">>> BOCCIATO <<<"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""EXIT LAB — harness onesto e CONDIVISO per la ricerca di policy di uscita
|
||||||
|
(TP dinamico, SL dinamico/trailing, partial, ride) sulle fade attive.
|
||||||
|
|
||||||
|
Ricerca 2026-06-04 (>=20 agenti): ogni agente implementa una ExitPolicy in
|
||||||
|
scripts/analysis/exit_policies/<id>_<nome>.py e la valuta QUI, sugli STESSI
|
||||||
|
segnali (cache su disco) e con lo stesso engine intrabar di fade_base.
|
||||||
|
|
||||||
|
CONTRATTO ANTI-LOOK-AHEAD (vincolante, verra' verificato da agenti avversari):
|
||||||
|
- i livelli attivi nel bar j (`levels(j)`) possono usare SOLO dati <= j-1
|
||||||
|
(il worker live li fissa al close del bar precedente, poi il bar j li tocca);
|
||||||
|
- `after_bar(j)` decide sul CLOSE del bar j (eseguibile al poll del tick);
|
||||||
|
- indicatori: usare l'indice j-1 degli array causali (es. ctx["atr14"][j-1]).
|
||||||
|
|
||||||
|
PROTOCOLLO ANTI-OVERFIT (vincolante):
|
||||||
|
- TRAIN = storico fino al 2023-11-01, OOS = dopo. La SELEZIONE dei parametri
|
||||||
|
si fa SOLO sul train; l'OOS si guarda una volta, per il verdetto.
|
||||||
|
- gate: il miglioramento deve tenere su ENTRAMBI gli asset e su TUTTE e 3 le
|
||||||
|
strategie (train E oos), con plateau sulla griglia (non una cella isolata).
|
||||||
|
- fee 0.10% RT x leva su tutto il notional; nessuna fee scontata sui limit.
|
||||||
|
|
||||||
|
Baseline = exit attuale (TP/SL fissi dall'entrata + max_bars): la parita' con
|
||||||
|
`partial_tp_ladder.py --base` e' verificata da `parity_check()`.
|
||||||
|
|
||||||
|
uv run python scripts/analysis/exit_lab.py # build cache + parity check
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pickle
|
||||||
|
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 # noqa: E402
|
||||||
|
from src.live.strategy_loader import load_strategy # noqa: E402
|
||||||
|
|
||||||
|
LIVE_PARAMS = dict(trend_max=3.0, ema_long=200, hurst_max=0.55, min_tp_frac=0.0015)
|
||||||
|
OOS_START_MS = int(pd.Timestamp("2023-11-01", tz="UTC").value // 1e6)
|
||||||
|
LEV, POS, FEE_RT = 3.0, 0.15, 0.001
|
||||||
|
CODES = ["MR01_bollinger_fade", "MR02_donchian_fade", "MR07_return_reversal"]
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
CACHE = PROJECT_ROOT / "data" / "cache" / "exit_lab_signals.pkl"
|
||||||
|
HARD_CAP = 240 # bound assoluto ai bar in posizione (policy "ride" comprese)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- dati
|
||||||
|
|
||||||
|
def _atr14(h: np.ndarray, l: np.ndarray, c: np.ndarray) -> np.ndarray:
|
||||||
|
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(14).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
def load_sleeves(refresh: bool = False) -> dict:
|
||||||
|
"""{(code, asset): sleeve} con cache. sleeve = {signals, open, high, low,
|
||||||
|
close, ts_ms, atr14}. signals = [(i, d, tp0, sl0, mb), ...] dai params LIVE."""
|
||||||
|
if CACHE.exists() and not refresh:
|
||||||
|
with open(CACHE, "rb") as f:
|
||||||
|
return pickle.load(f)
|
||||||
|
out = {}
|
||||||
|
for code in CODES:
|
||||||
|
strat = load_strategy(code)
|
||||||
|
for asset in ASSETS:
|
||||||
|
df = load_data(asset, "1h")
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
sigs = strat.generate_signals(df, ts, **LIVE_PARAMS)
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
out[(code, asset)] = {
|
||||||
|
"signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]),
|
||||||
|
float(s.metadata["sl"]), int(s.metadata["max_bars"]))
|
||||||
|
for s in sigs],
|
||||||
|
"open": df["open"].values.astype(float),
|
||||||
|
"high": h, "low": l, "close": c,
|
||||||
|
"ts_ms": df["timestamp"].values.astype(np.int64),
|
||||||
|
"atr14": _atr14(h, l, c),
|
||||||
|
}
|
||||||
|
print(f" cache {code} {asset}: {len(sigs)} segnali, {len(c)} barre "
|
||||||
|
f"({ts.iloc[0].date()} -> {ts.iloc[-1].date()})")
|
||||||
|
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(CACHE, "wb") as f:
|
||||||
|
pickle.dump(out, f)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- policy
|
||||||
|
|
||||||
|
class ExitPolicy:
|
||||||
|
"""Baseline = exit live attuale. Le sottoclassi ridefinisco levels/after_bar.
|
||||||
|
|
||||||
|
Una ISTANZA per trade. `ctx` e' il dict sleeve (array completi + indicatori
|
||||||
|
aggiunti da prepare()): per contratto si legge SOLO fino a j-1 in levels(j)
|
||||||
|
e fino a j in after_bar(j)/on_partial(j).
|
||||||
|
"""
|
||||||
|
name = "base"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def prepare(cls, ctx: dict, **params) -> None:
|
||||||
|
"""Pre-calcola array causali per-sleeve (una volta), es. SMA/EMA."""
|
||||||
|
|
||||||
|
def __init__(self, ctx: dict, i: int, d: int, entry: float,
|
||||||
|
tp0: float, sl0: float, mb: int, **params):
|
||||||
|
self.ctx, self.i, self.d, self.entry = ctx, i, d, entry
|
||||||
|
self.tp0, self.sl0, self.mb = tp0, sl0, mb
|
||||||
|
self.horizon = mb # le sottoclassi possono estendere (cap HARD_CAP)
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
"""Livelli ATTIVI nel bar j -> (tp, sl, tp_frac). None = livello assente.
|
||||||
|
tp_frac = quota del RESIDUO che esce al tocco del TP (1.0 = tutta)."""
|
||||||
|
return self.tp0, self.sl0, 1.0
|
||||||
|
|
||||||
|
def on_partial(self, j: int, price: float, remaining: float) -> None:
|
||||||
|
"""Notifica del fill parziale al TP nel bar j (aggiorna lo stato qui)."""
|
||||||
|
|
||||||
|
def after_bar(self, j: int) -> bool:
|
||||||
|
"""True = chiudi il residuo al close[j] (decisione sul close, eseguibile)."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- engine
|
||||||
|
|
||||||
|
def simulate(policy_cls, sleeve: dict, params: dict | None = None,
|
||||||
|
start_ms: int | None = None, end_ms: int | None = None) -> dict:
|
||||||
|
"""Replay intrabar dei segnali dello sleeve con la policy. SL prioritario
|
||||||
|
sul TP nello stesso bar (conservativo); fill parziali pesati; max_bars/
|
||||||
|
horizon esce al close; non-overlap (una posizione per volta)."""
|
||||||
|
params = params or {}
|
||||||
|
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
|
||||||
|
n = len(c)
|
||||||
|
ctx = dict(sleeve)
|
||||||
|
policy_cls.prepare(ctx, **params)
|
||||||
|
fee = FEE_RT * LEV
|
||||||
|
capital = peak = 1000.0
|
||||||
|
max_dd = 0.0
|
||||||
|
last_exit = -1
|
||||||
|
trades = wins = 0
|
||||||
|
bars_tot = 0
|
||||||
|
rets = []
|
||||||
|
|
||||||
|
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
|
||||||
|
if start_ms is not None and ts[i] < start_ms:
|
||||||
|
continue
|
||||||
|
if end_ms is not None and ts[i] >= end_ms:
|
||||||
|
continue
|
||||||
|
if i <= last_exit or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
horizon = min(int(pol.horizon), HARD_CAP)
|
||||||
|
fills: list[tuple[float, float]] = []
|
||||||
|
remaining = 1.0
|
||||||
|
j = i
|
||||||
|
for step in range(1, horizon + 1):
|
||||||
|
j = i + step
|
||||||
|
if j >= n:
|
||||||
|
j = n - 1
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
break
|
||||||
|
tp, sl, tpfrac = pol.levels(j)
|
||||||
|
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: # conservativo: SL prima del TP
|
||||||
|
fills.append((remaining, sl)); remaining = 0.0
|
||||||
|
break
|
||||||
|
if hit_tp:
|
||||||
|
f = min(max(tpfrac, 0.0), 1.0) * remaining
|
||||||
|
if f > 0:
|
||||||
|
fills.append((f, tp)); remaining -= f
|
||||||
|
if remaining <= 1e-9:
|
||||||
|
break
|
||||||
|
pol.on_partial(j, tp, remaining)
|
||||||
|
if pol.after_bar(j):
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
break
|
||||||
|
if step == horizon:
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
if remaining > 1e-9: # safety (non dovrebbe accadere)
|
||||||
|
fills.append((remaining, c[j]))
|
||||||
|
|
||||||
|
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
|
||||||
|
capital = max(capital + capital * POS * ret, 10.0)
|
||||||
|
peak = max(peak, capital)
|
||||||
|
max_dd = max(max_dd, (peak - capital) / peak)
|
||||||
|
last_exit = j
|
||||||
|
trades += 1
|
||||||
|
wins += ret > 0
|
||||||
|
bars_tot += j - i
|
||||||
|
rets.append(ret)
|
||||||
|
|
||||||
|
if trades == 0:
|
||||||
|
return {}
|
||||||
|
r = np.array(rets)
|
||||||
|
return {
|
||||||
|
"ret_pct": (capital / 1000.0 - 1) * 100,
|
||||||
|
"dd_pct": max_dd * 100,
|
||||||
|
"trades": trades,
|
||||||
|
"win_pct": wins / trades * 100,
|
||||||
|
"avg_ret_bps": r.mean() * 1e4,
|
||||||
|
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
|
||||||
|
"avg_bars": bars_tot / trades,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- report
|
||||||
|
|
||||||
|
def evaluate(policy_cls, grid: list[dict], data: dict | None = None,
|
||||||
|
quiet: bool = False) -> dict:
|
||||||
|
"""Protocollo train/OOS su tutta la griglia. La selezione dei parametri va
|
||||||
|
fatta SUL TRAIN (l'OOS si riporta, non si ottimizza). Ritorna dict
|
||||||
|
{params_str: {sleeve: {train: {...}, oos: {...}}}} + baseline."""
|
||||||
|
data = data or load_sleeves()
|
||||||
|
out: dict = {}
|
||||||
|
rows = [("base", ExitPolicy, {})] + [
|
||||||
|
(", ".join(f"{k}={v}" for k, v in g.items()) or "default", policy_cls, g)
|
||||||
|
for g in grid]
|
||||||
|
for tag, cls, g in rows:
|
||||||
|
out[tag] = {}
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
tr = simulate(cls, sleeve, g, end_ms=OOS_START_MS)
|
||||||
|
oo = simulate(cls, sleeve, g, start_ms=OOS_START_MS)
|
||||||
|
out[tag][key] = {"train": tr, "oos": oo}
|
||||||
|
if not quiet:
|
||||||
|
print(f"{tag:<28}{key:<10}"
|
||||||
|
f"TRAIN ret{tr.get('ret_pct', 0):>7.0f}% dd{tr.get('dd_pct', 0):>5.1f} "
|
||||||
|
f"sh{tr.get('sharpe_t', 0):>5.2f} n{tr.get('trades', 0):>4} | "
|
||||||
|
f"OOS ret{oo.get('ret_pct', 0):>6.0f}% dd{oo.get('dd_pct', 0):>5.1f} "
|
||||||
|
f"sh{oo.get('sharpe_t', 0):>5.2f} n{oo.get('trades', 0):>4} "
|
||||||
|
f"bars{oo.get('avg_bars', 0):>5.1f}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parity_check() -> None:
|
||||||
|
"""La baseline qui deve riprodurre i numeri FULL di partial_tp_ladder (base):
|
||||||
|
MR01 BTC ~92%/13.8dd, MR01 ETH ~194%/16.5dd, MR02 ETH ~2135%/16.2dd..."""
|
||||||
|
data = load_sleeves()
|
||||||
|
print("\nParity check baseline (FULL, atteso = partial_tp_ladder base):")
|
||||||
|
expected = {("MR01_bollinger_fade", "BTC"): 92, ("MR01_bollinger_fade", "ETH"): 194,
|
||||||
|
("MR02_donchian_fade", "BTC"): 129, ("MR02_donchian_fade", "ETH"): 2135,
|
||||||
|
("MR07_return_reversal", "BTC"): 78, ("MR07_return_reversal", "ETH"): 115}
|
||||||
|
ok = True
|
||||||
|
for key, sleeve in data.items():
|
||||||
|
r = simulate(ExitPolicy, sleeve)
|
||||||
|
exp = expected[key]
|
||||||
|
match = abs(r["ret_pct"] - exp) < 1.0
|
||||||
|
ok &= match
|
||||||
|
print(f" {key[0].split('_')[0]} {key[1]}: ret {r['ret_pct']:.0f}% "
|
||||||
|
f"(atteso ~{exp}) {'OK' if match else 'MISMATCH'}")
|
||||||
|
print("PARITY", "OK" if ok else "FAILED")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
load_sleeves(refresh="--refresh" in sys.argv)
|
||||||
|
parity_check()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""EXIT-01 — trail_atr_ride: TP RIMOSSO, cavalcata pura con SL trailing chandelier.
|
||||||
|
|
||||||
|
Idea: le fade mean-reversion escono oggi al TP fisso (alla media) + SL + max_bars.
|
||||||
|
Qui togliamo il TP e lasciamo correre il trade, proteggendolo con un SL trailing
|
||||||
|
"chandelier" a k*ATR dal massimo favorevole raggiunto. Lo stop puo' solo stringersi
|
||||||
|
(mai allargarsi). Orizzonte esteso (cap HARD_CAP=240) per dare spazio al runner.
|
||||||
|
|
||||||
|
Long: stop(j) = max( sl0, max(high[i..j-1]) - k*atr14[j-1] ) (sale, mai scende)
|
||||||
|
Short: stop(j) = min( sl0, min(low[i..j-1]) + k*atr14[j-1] ) (scende, mai sale)
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
|
||||||
|
- massimo/minimo favorevole sullo slice [i .. j-1] (mantenuto incrementalmente,
|
||||||
|
aggiornato col bar j-1 prima di calcolare lo stop attivo nel bar j);
|
||||||
|
- atr14[j-1] (indice causale).
|
||||||
|
Nessun TP -> nessun fill parziale. after_bar non usato (chiusura solo a orizzonte/SL).
|
||||||
|
|
||||||
|
GRID: k in {2.0, 3.0, 4.0} x horizon_mult in {2, 4} (6 celle). horizon = mult*mb cap 240.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from exit_lab import ExitPolicy, evaluate, HARD_CAP
|
||||||
|
|
||||||
|
|
||||||
|
class TrailAtrRide(ExitPolicy):
|
||||||
|
name = "trail_atr_ride"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, k=3.0, horizon_mult=4, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.k = float(k)
|
||||||
|
self.horizon = min(int(horizon_mult) * mb, HARD_CAP)
|
||||||
|
# estremo favorevole sullo slice [i..j-1]; inizializzato al bar di entrata i
|
||||||
|
# (il primo bar valutato e' j=i+1, dove lo slice [i..j-1]=[i..i] e' noto).
|
||||||
|
self.fav_high = ctx["high"][i]
|
||||||
|
self.fav_low = ctx["low"][i]
|
||||||
|
self._last_seen = i # ultimo indice gia' incorporato nell'estremo
|
||||||
|
# stop trailing monotono: parte da sl0 e puo' solo stringersi
|
||||||
|
self.cur_stop = sl0
|
||||||
|
|
||||||
|
def levels(self, j):
|
||||||
|
h = self.ctx["high"]
|
||||||
|
l = self.ctx["low"]
|
||||||
|
atr = self.ctx["atr14"]
|
||||||
|
# incorpora i bar fino a j-1 (dati causali, gia' chiusi al poll del bar j)
|
||||||
|
while self._last_seen < j - 1:
|
||||||
|
self._last_seen += 1
|
||||||
|
if h[self._last_seen] > self.fav_high:
|
||||||
|
self.fav_high = h[self._last_seen]
|
||||||
|
if l[self._last_seen] < self.fav_low:
|
||||||
|
self.fav_low = l[self._last_seen]
|
||||||
|
a = atr[j - 1]
|
||||||
|
if a != a: # NaN nei primi 14 bar -> resta sullo stop corrente
|
||||||
|
return None, self.cur_stop, 1.0
|
||||||
|
if self.d == 1:
|
||||||
|
cand = self.fav_high - self.k * a
|
||||||
|
if cand > self.cur_stop: # lo stop long puo' solo SALIRE (stringersi)
|
||||||
|
self.cur_stop = cand
|
||||||
|
else:
|
||||||
|
cand = self.fav_low + self.k * a
|
||||||
|
if cand < self.cur_stop: # lo stop short puo' solo SCENDERE (stringersi)
|
||||||
|
self.cur_stop = cand
|
||||||
|
return None, self.cur_stop, 1.0 # TP rimosso
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"k": k, "horizon_mult": m}
|
||||||
|
for k in (2.0, 3.0, 4.0)
|
||||||
|
for m in (2, 4)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(TrailAtrRide, GRID)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""EXIT-02 — trail_atr_keep_tp.
|
||||||
|
|
||||||
|
Chandelier trailing stop a k*ATR dall'estremo favorevole RAGGIUNTO dall'entrata,
|
||||||
|
MA il TP fisso tp0 dall'entrata RESTA attivo: si esce al PRIMO dei due (il TP al
|
||||||
|
livello, oppure il trail). horizon = max_bars (invariato).
|
||||||
|
|
||||||
|
Stop attivo nel bar j (solo dati <= j-1, anti-look-ahead):
|
||||||
|
long : chand = max(high[i..j-1]) - k*atr14[j-1]; sl = max(sl0, chand)
|
||||||
|
short: chand = min(low[i..j-1]) + k*atr14[j-1]; sl = min(sl0, chand)
|
||||||
|
|
||||||
|
Il max(sl0, chand) (per il long) tiene la protezione iniziale a sl0 e lascia che
|
||||||
|
il trail TIGHTEN solo quando il prezzo corre a favore -> "ride" controllato che
|
||||||
|
non allenta mai il rischio iniziale. Il TP non viene toccato: una fade che torna
|
||||||
|
alla media esce comunque al TP come la base; il trail morde solo quando il TP non
|
||||||
|
viene raggiunto e il prezzo ha prima corso a favore e poi ritracciato.
|
||||||
|
|
||||||
|
GRID: k in {1.5, 2.0, 3.0, 4.0} (4 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TrailATRKeepTP(ExitPolicy):
|
||||||
|
name = "trail_atr_keep_tp"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.k = float(params.get("k", 2.0))
|
||||||
|
self.high = ctx["high"]
|
||||||
|
self.low = ctx["low"]
|
||||||
|
self.atr = ctx["atr14"]
|
||||||
|
# estremo favorevole running (solo barre <= j-1); init = barra d'entrata i
|
||||||
|
self.run_hi = self.high[i]
|
||||||
|
self.run_lo = self.low[i]
|
||||||
|
self.last_seen = i # ultimo indice gia' incorporato nel running extremum
|
||||||
|
|
||||||
|
def _update_running(self, upto: int) -> None:
|
||||||
|
"""Incorpora le barre (last_seen, upto] nell'estremo running. upto = j-1,
|
||||||
|
quindi NON tocca il bar j (anti-look-ahead)."""
|
||||||
|
while self.last_seen < upto:
|
||||||
|
self.last_seen += 1
|
||||||
|
if self.high[self.last_seen] > self.run_hi:
|
||||||
|
self.run_hi = self.high[self.last_seen]
|
||||||
|
if self.low[self.last_seen] < self.run_lo:
|
||||||
|
self.run_lo = self.low[self.last_seen]
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
self._update_running(j - 1) # solo dati <= j-1
|
||||||
|
a = self.atr[j - 1]
|
||||||
|
if a is None or a != a: # NaN -> nessun trail, usa sl0
|
||||||
|
return self.tp0, self.sl0, 1.0
|
||||||
|
if self.d == 1:
|
||||||
|
chand = self.run_hi - self.k * a
|
||||||
|
sl = max(self.sl0, chand) # piu' protettivo (stop piu' alto)
|
||||||
|
else:
|
||||||
|
chand = self.run_lo + self.k * a
|
||||||
|
sl = min(self.sl0, chand) # piu' protettivo (stop piu' basso)
|
||||||
|
return self.tp0, sl, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [{"k": 1.5}, {"k": 2.0}, {"k": 3.0}, {"k": 4.0}]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(TrailATRKeepTP, GRID)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""EXIT-03 — trail_pct: trailing percentuale dall'high-water-mark favorevole (sui CLOSE).
|
||||||
|
|
||||||
|
Idea: invece di un trail ATR (EXIT-01/02), lo stop segue l'estremo favorevole
|
||||||
|
RAGGIUNTO dai CLOSE a una distanza percentuale fissa p. Lo stop puo' solo
|
||||||
|
stringersi (mai allentarsi sotto sl0 lato rischio).
|
||||||
|
|
||||||
|
Long : hwm = max(close[i..j-1]); stop(j) = max(sl0, hwm*(1-p)) (sale, mai scende)
|
||||||
|
Short: lwm = min(close[i..j-1]); stop(j) = min(sl0, lwm*(1+p)) (scende, mai sale)
|
||||||
|
|
||||||
|
Due varianti:
|
||||||
|
keep_tp=True -> il TP fisso tp0 dall'entrata RESTA attivo (esci al primo dei due),
|
||||||
|
horizon = max_bars (invariato). Il trail morde solo se il TP non
|
||||||
|
viene raggiunto e il prezzo ha prima corso a favore e poi ritracciato.
|
||||||
|
keep_tp=False -> TP RIMOSSO (cavalcata pura), horizon = 2*max_bars (cap HARD_CAP).
|
||||||
|
Si esce solo sul trailing-stop o a orizzonte.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
|
||||||
|
- hwm/lwm sullo slice [i..j-1] dei CLOSE (mantenuto incrementalmente col bar j-1);
|
||||||
|
- p e' una costante -> nessuna dipendenza da indicatori del bar j.
|
||||||
|
L'hwm sui close (non sugli high) e' deliberato: il close e' il dato su cui il
|
||||||
|
worker live decide al poll del tick; un hwm sugli high anticiperebbe un livello
|
||||||
|
che il close non ha ancora confermato.
|
||||||
|
|
||||||
|
GRID: p in {0.005, 0.01, 0.02} x keep_tp in {True, False} (6 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TrailPct(ExitPolicy):
|
||||||
|
name = "trail_pct"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb,
|
||||||
|
p=0.01, keep_tp=True, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.p = float(p)
|
||||||
|
self.keep_tp = bool(keep_tp)
|
||||||
|
if not self.keep_tp:
|
||||||
|
self.horizon = min(2 * mb, HARD_CAP)
|
||||||
|
self.close = ctx["close"]
|
||||||
|
# high/low-water-mark sui CLOSE, solo barre <= j-1; init = close d'entrata i
|
||||||
|
self.hwm = self.close[i]
|
||||||
|
self.lwm = self.close[i]
|
||||||
|
self.last_seen = i # ultimo indice gia' incorporato
|
||||||
|
# stop trailing monotono: parte da sl0 e puo' solo stringersi
|
||||||
|
self.cur_stop = sl0
|
||||||
|
|
||||||
|
def _update_wm(self, upto: int) -> None:
|
||||||
|
"""Incorpora le barre (last_seen, upto] nell'estremo sui close. upto = j-1
|
||||||
|
-> NON tocca il bar j (anti-look-ahead)."""
|
||||||
|
while self.last_seen < upto:
|
||||||
|
self.last_seen += 1
|
||||||
|
cv = self.close[self.last_seen]
|
||||||
|
if cv > self.hwm:
|
||||||
|
self.hwm = cv
|
||||||
|
if cv < self.lwm:
|
||||||
|
self.lwm = cv
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
self._update_wm(j - 1) # solo dati <= j-1
|
||||||
|
if self.d == 1:
|
||||||
|
cand = self.hwm * (1.0 - self.p)
|
||||||
|
if cand > self.cur_stop: # lo stop long puo' solo SALIRE
|
||||||
|
self.cur_stop = cand
|
||||||
|
else:
|
||||||
|
cand = self.lwm * (1.0 + self.p)
|
||||||
|
if cand < self.cur_stop: # lo stop short puo' solo SCENDERE
|
||||||
|
self.cur_stop = cand
|
||||||
|
tp = self.tp0 if self.keep_tp else None
|
||||||
|
return tp, self.cur_stop, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"p": p, "keep_tp": kt}
|
||||||
|
for p in (0.005, 0.01, 0.02)
|
||||||
|
for kt in (True, False)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(TrailPct, GRID)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""EXIT-04 — breakeven (protezione).
|
||||||
|
|
||||||
|
Quando il profitto su CLOSE supera k*ATR(entry) (ATR fissato all'entrata,
|
||||||
|
atr14[i]), sposta lo SL a BREAKEVEN = entry +/- buffer. Il buffer e' a favore
|
||||||
|
(0.2% = 2*fee_rt*entry) cosi' l'uscita a BE non e' in perdita per le fee. Il TP
|
||||||
|
fisso tp0 RESTA invariato. horizon = max_bars (invariato).
|
||||||
|
|
||||||
|
Una volta armato il breakeven, lo SL non torna mai indietro (cliquet): protegge
|
||||||
|
il profitto gia' maturato senza allentare il rischio.
|
||||||
|
|
||||||
|
Stop attivo nel bar j (solo dati <= j-1, anti-look-ahead):
|
||||||
|
- trigger: si arma quando close[j-1] e' a favore di >= k*atr14[i] dall'entrata
|
||||||
|
(atr14[i] = ATR all'entrata, costante; close[j-1] = ultimo close noto).
|
||||||
|
- prima dell'arm: sl = sl0 (protezione iniziale invariata).
|
||||||
|
- dopo l'arm:
|
||||||
|
long : be = entry + buffer; sl = max(sl0, be)
|
||||||
|
short: be = entry - buffer; sl = min(sl0, be)
|
||||||
|
NB max/min con sl0 fa si' che lo SL non venga mai ALLENTATO: se sl0 fosse
|
||||||
|
gia' oltre il BE (raro), resta sl0. Tipicamente sl0 e' sotto entry (long) e
|
||||||
|
il BE lo alza -> stop piu' protettivo.
|
||||||
|
|
||||||
|
GRID: k in {0.5, 1.0, 1.5} x buffer_frac in {0.0, 0.002} (6 celle).
|
||||||
|
buffer = buffer_frac * entry. buffer_frac 0.002 = 0.2% = 2*fee_rt a favore.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class Breakeven(ExitPolicy):
|
||||||
|
name = "breakeven"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.k = float(params.get("k", 1.0))
|
||||||
|
self.buffer_frac = float(params.get("buffer_frac", 0.002))
|
||||||
|
self.close = ctx["close"]
|
||||||
|
self.atr = ctx["atr14"]
|
||||||
|
self.atr_entry = self.atr[i] # ATR all'entrata, costante
|
||||||
|
self.buffer = self.buffer_frac * entry
|
||||||
|
self.armed = False
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
# arma il breakeven usando SOLO close[j-1] (ultimo close noto) e atr14[i]
|
||||||
|
if not self.armed and self.atr_entry is not None and self.atr_entry == self.atr_entry:
|
||||||
|
cprev = self.close[j - 1]
|
||||||
|
profit = (cprev - self.entry) * self.d # >0 = a favore
|
||||||
|
if profit >= self.k * self.atr_entry:
|
||||||
|
self.armed = True
|
||||||
|
if not self.armed:
|
||||||
|
return self.tp0, self.sl0, 1.0
|
||||||
|
if self.d == 1:
|
||||||
|
be = self.entry + self.buffer
|
||||||
|
sl = max(self.sl0, be) # non allenta mai
|
||||||
|
else:
|
||||||
|
be = self.entry - self.buffer
|
||||||
|
sl = min(self.sl0, be)
|
||||||
|
return self.tp0, sl, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"k": 0.5, "buffer_frac": 0.0},
|
||||||
|
{"k": 0.5, "buffer_frac": 0.002},
|
||||||
|
{"k": 1.0, "buffer_frac": 0.0},
|
||||||
|
{"k": 1.0, "buffer_frac": 0.002},
|
||||||
|
{"k": 1.5, "buffer_frac": 0.0},
|
||||||
|
{"k": 1.5, "buffer_frac": 0.002},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(Breakeven, GRID)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""EXIT-05 — ratchet: stop a cricchetto sul profitto su close (high-water-mark).
|
||||||
|
|
||||||
|
Idea: una volta che il trade ha messo a segno profitto (su CLOSE), proteggiamo
|
||||||
|
una FRAZIONE f del profitto migliore raggiunto. hwm = miglior close a favore visto
|
||||||
|
finora; quando hwm e' a favore dell'entrata, lo stop si porta a
|
||||||
|
long : stop = entry + f*(hwm - entry)
|
||||||
|
short: stop = entry - f*(entry - hwm)
|
||||||
|
Lo stop e' SOLO-STRINGENTE (cricchetto): puo' solo avvicinarsi al prezzo, mai
|
||||||
|
allentarsi. Parte da sl0 (protezione iniziale invariata finche' il ratchet non
|
||||||
|
supera sl0).
|
||||||
|
|
||||||
|
Differenza dal trailing chandelier (EXIT-01): qui lo stop e' ancorato all'ENTRY
|
||||||
|
+ frazione del profitto su close (non al massimo high - k*ATR). Cattura profitto
|
||||||
|
in % del guadagno realizzato; con f<1 lascia un cuscinetto sotto l'hwm.
|
||||||
|
|
||||||
|
VARIANTI:
|
||||||
|
- keep_tp=True : il TP fisso tp0 resta (exit standard alla media) + ratchet di
|
||||||
|
backup sullo SL. horizon = max_bars.
|
||||||
|
- keep_tp=False: TP RIMOSSO -> ride puro protetto dal cricchetto, horizon=2*mb
|
||||||
|
(cap HARD_CAP). Qui il ratchet e' l'unica uscita oltre l'orizzonte.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
|
||||||
|
- hwm calcolato sui close[i .. j-1] (mantenuto incrementalmente, aggiornato col
|
||||||
|
bar j-1 prima di calcolare lo stop attivo nel bar j; close[i]=entry e' incluso
|
||||||
|
cosi' hwm>=entry sempre, lo stop non scende mai sotto entry una volta armato).
|
||||||
|
Nessun TP nella variante ride -> nessun fill parziale. after_bar non usato.
|
||||||
|
|
||||||
|
GRID: f in {0.3, 0.5, 0.7} x keep_tp in {True, False} (6 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class Ratchet(ExitPolicy):
|
||||||
|
name = "ratchet"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, f=0.5, keep_tp=True, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.f = float(f)
|
||||||
|
self.keep_tp = bool(keep_tp)
|
||||||
|
self.close = ctx["close"]
|
||||||
|
if not self.keep_tp:
|
||||||
|
self.horizon = min(2 * mb, HARD_CAP)
|
||||||
|
# high-water-mark del close a favore sullo slice [i..j-1]; il primo bar
|
||||||
|
# valutato e' j=i+1 -> slice [i..i] = close[i] = entry (gia' incorporato).
|
||||||
|
self.hwm = self.close[i] # = entry
|
||||||
|
self._last_seen = i
|
||||||
|
self.armed = False # diventa True quando hwm > entry (a favore)
|
||||||
|
# stop a cricchetto: parte da sl0, puo' solo stringersi
|
||||||
|
self.cur_stop = sl0
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
c = self.close
|
||||||
|
d = self.d
|
||||||
|
# incorpora i close fino a j-1 (causali, gia' chiusi al poll del bar j)
|
||||||
|
while self._last_seen < j - 1:
|
||||||
|
self._last_seen += 1
|
||||||
|
cv = c[self._last_seen]
|
||||||
|
if d == 1:
|
||||||
|
if cv > self.hwm:
|
||||||
|
self.hwm = cv
|
||||||
|
else:
|
||||||
|
if cv < self.hwm:
|
||||||
|
self.hwm = cv
|
||||||
|
tp = self.tp0 if self.keep_tp else None
|
||||||
|
# profitto su close del miglior bar a favore (>=0 perche' hwm parte da entry)
|
||||||
|
prof = (self.hwm - self.entry) * d
|
||||||
|
if prof > 0: # il trade e' andato a favore: arma il ratchet
|
||||||
|
self.armed = True
|
||||||
|
if d == 1:
|
||||||
|
cand = self.entry + self.f * (self.hwm - self.entry)
|
||||||
|
if cand > self.cur_stop: # cricchetto: lo stop long solo SALE
|
||||||
|
self.cur_stop = cand
|
||||||
|
else:
|
||||||
|
cand = self.entry - self.f * (self.entry - self.hwm)
|
||||||
|
if cand < self.cur_stop: # cricchetto: lo stop short solo SCENDE
|
||||||
|
self.cur_stop = cand
|
||||||
|
return tp, self.cur_stop, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"f": f, "keep_tp": keep_tp}
|
||||||
|
for f in (0.3, 0.5, 0.7)
|
||||||
|
for keep_tp in (True, False)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(Ratchet, GRID)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""EXIT-06 — sar_trail: Parabolic SAR semplificato come trailing stop, TP rimosso.
|
||||||
|
|
||||||
|
Idea: come EXIT-01 (cavalcata pura senza TP), ma lo stop trailing non e' un
|
||||||
|
chandelier a k*ATR fisso bensi' un Parabolic SAR semplificato che ACCELERA: il
|
||||||
|
fattore af parte a 0.02 e cresce di af_step ad ogni NUOVO estremo favorevole
|
||||||
|
(cap af_max). Lo stop si avvicina sempre piu' in fretta al prezzo man mano che il
|
||||||
|
trade corre nella direzione giusta -> protegge i runner stringendo aggressivamente.
|
||||||
|
|
||||||
|
sar(j) = sar(j-1) + af * (ep - sar(j-1)) con ep = estremo favorevole (high long / low short)
|
||||||
|
stop attivo nel bar j = max(sl0, sar) long (min(sl0, sar) short)
|
||||||
|
af: 0.02 -> +af_step ad ogni nuovo ep -> cap af_max
|
||||||
|
|
||||||
|
TP RIMOSSO, horizon = 4*mb (cap HARD_CAP=240).
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1. L'estremo favorevole `ep` e lo
|
||||||
|
stato SAR vengono aggiornati incorporando i bar fino a j-1 (gia' chiusi al poll
|
||||||
|
del bar j). sar(j-1) -> sar(j) usa ep aggiornato a j-1. Nessun dato del bar j.
|
||||||
|
Nessun TP -> nessun fill parziale; after_bar non usato (uscita solo a SL/orizzonte).
|
||||||
|
|
||||||
|
GRID: af_max in {0.1, 0.2} x af_step in {0.02, 0.04} (4 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from exit_lab import ExitPolicy, evaluate, HARD_CAP
|
||||||
|
|
||||||
|
|
||||||
|
class SarTrail(ExitPolicy):
|
||||||
|
name = "sar_trail"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, af_max=0.2, af_step=0.02, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.af_max = float(af_max)
|
||||||
|
self.af_step = float(af_step)
|
||||||
|
self.horizon = min(4 * mb, HARD_CAP)
|
||||||
|
# estremo favorevole (ep) sullo slice [i..j-1]; inizializzato al bar di entrata
|
||||||
|
self.ep = ctx["high"][i] if d == 1 else ctx["low"][i]
|
||||||
|
self.af = 0.02
|
||||||
|
# SAR iniziale: lo stop di partenza (sl0). Il SAR converge verso ep dall'sl0.
|
||||||
|
self.sar = sl0
|
||||||
|
self._last_seen = i # ultimo indice gia' incorporato nello stato SAR
|
||||||
|
self.cur_stop = sl0 # stop monotono (solo si stringe)
|
||||||
|
|
||||||
|
def _step(self, idx):
|
||||||
|
"""Incorpora il bar `idx` (causale, <= j-1) aggiornando ep, af e sar."""
|
||||||
|
h = self.ctx["high"][idx]
|
||||||
|
l = self.ctx["low"][idx]
|
||||||
|
# avanza il SAR di un passo verso l'estremo favorevole corrente
|
||||||
|
self.sar = self.sar + self.af * (self.ep - self.sar)
|
||||||
|
# nuovo estremo favorevole? -> accelera
|
||||||
|
if self.d == 1:
|
||||||
|
if h > self.ep:
|
||||||
|
self.ep = h
|
||||||
|
self.af = min(self.af + self.af_step, self.af_max)
|
||||||
|
else:
|
||||||
|
if l < self.ep:
|
||||||
|
self.ep = l
|
||||||
|
self.af = min(self.af + self.af_step, self.af_max)
|
||||||
|
|
||||||
|
def levels(self, j):
|
||||||
|
# incorpora i bar fino a j-1 (gia' chiusi al poll del bar j)
|
||||||
|
while self._last_seen < j - 1:
|
||||||
|
self._last_seen += 1
|
||||||
|
self._step(self._last_seen)
|
||||||
|
if self.d == 1:
|
||||||
|
if self.sar > self.cur_stop: # lo stop long puo' solo SALIRE (stringersi)
|
||||||
|
self.cur_stop = self.sar
|
||||||
|
else:
|
||||||
|
if self.sar < self.cur_stop: # lo stop short puo' solo SCENDERE
|
||||||
|
self.cur_stop = self.sar
|
||||||
|
return None, self.cur_stop, 1.0 # TP rimosso
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"af_max": am, "af_step": st}
|
||||||
|
for am in (0.1, 0.2)
|
||||||
|
for st in (0.02, 0.04)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(SarTrail, GRID)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""EXIT-07 — TP dinamico che DECADE col tempo (verso breakeven).
|
||||||
|
|
||||||
|
PRIOR (vincolante, ieri SCARTATO un ladder 80/20): il TP delle fade sta alla
|
||||||
|
MEDIA, e li' il movimento e' esaurito -> il prezzo spesso NON arriva al TP e il
|
||||||
|
trade muore a max_bars (close) o sullo SL. Idea di questa famiglia: invece di
|
||||||
|
tenere il TP fisso al target ambizioso (la media), farlo DECADERE col passare
|
||||||
|
delle barre verso un target piu' modesto (breakeven che copre le fee, o meta'
|
||||||
|
strada). Cosi' un trade che s'e' avvicinato ma non ha toccato il TP pieno puo'
|
||||||
|
essere chiuso in profitto/pareggio prima di scadere a max_bars potenzialmente
|
||||||
|
in perdita. SL FISSO (sl0) invariato. horizon = max_bars invariato.
|
||||||
|
|
||||||
|
TP attivo nel bar j:
|
||||||
|
frac(j) = clamp( ((j - i) / mb) * speed , 0, 1 )
|
||||||
|
tp(j) = tp0 + frac(j) * (target_fin - tp0)
|
||||||
|
- frac dipende SOLO da j (indice di tempo) e da costanti note all'entrata
|
||||||
|
(i, mb, speed) -> NESSUN dato > j-1 -> anti-look-ahead OK per costruzione.
|
||||||
|
- long (d=+1): tp0 > entry; target_fin <= tp0 -> il TP SCENDE verso entry.
|
||||||
|
- short (d=-1): tp0 < entry; target_fin >= tp0 -> il TP SALE verso entry.
|
||||||
|
- speed>1: il TP raggiunge il target finale prima della fine (clamp a 1).
|
||||||
|
|
||||||
|
target_fin (param "target"):
|
||||||
|
- "breakeven": entry*(1 + d*0.003) -> copre 0.3% (~ fee 0.10%RT + margine).
|
||||||
|
Per long sta sopra entry, per short sotto: chiudere li' NON e' in perdita.
|
||||||
|
- "halfway" : (entry + tp0)/2 -> meta' strada fra entry e il TP pieno.
|
||||||
|
|
||||||
|
GRID: speed in {0.5, 1.0, 1.5} x target in {breakeven, halfway} (6 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TpDecay(ExitPolicy):
|
||||||
|
name = "tp_decay"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.speed = float(params.get("speed", 1.0))
|
||||||
|
target = str(params.get("target", "breakeven"))
|
||||||
|
if target == "breakeven":
|
||||||
|
self.target_fin = entry * (1.0 + d * 0.003)
|
||||||
|
elif target == "halfway":
|
||||||
|
self.target_fin = 0.5 * (entry + tp0)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"target sconosciuto: {target}")
|
||||||
|
# mb puo' essere 0 in teoria; protezione
|
||||||
|
self.mb_eff = max(int(mb), 1)
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
# frac dipende SOLO dal tempo (j - i), mb, speed: costanti note all'entrata
|
||||||
|
frac = ((j - self.i) / self.mb_eff) * self.speed
|
||||||
|
if frac < 0.0:
|
||||||
|
frac = 0.0
|
||||||
|
elif frac > 1.0:
|
||||||
|
frac = 1.0
|
||||||
|
tp = self.tp0 + frac * (self.target_fin - self.tp0)
|
||||||
|
return tp, self.sl0, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"speed": 0.5, "target": "breakeven"},
|
||||||
|
{"speed": 1.0, "target": "breakeven"},
|
||||||
|
{"speed": 1.5, "target": "breakeven"},
|
||||||
|
{"speed": 0.5, "target": "halfway"},
|
||||||
|
{"speed": 1.0, "target": "halfway"},
|
||||||
|
{"speed": 1.5, "target": "halfway"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(TpDecay, GRID)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""EXIT-08 — SL che si STRINGE linearmente col tempo (time-based stop tighten).
|
||||||
|
|
||||||
|
Lo stop iniziale sl0 si avvicina linearmente a un livello d'arrivo `target`
|
||||||
|
man mano che il trade invecchia. TP fisso (tp0) e horizon=max_bars invariati.
|
||||||
|
|
||||||
|
frac(j) = clamp( (j - i) / (mb * stretch), 0, 1 )
|
||||||
|
sl(j) = sl0 + frac(j) * (target - sl0)
|
||||||
|
|
||||||
|
target ("arrivo"):
|
||||||
|
- "entry" -> entry (a fine corsa lo stop e' a breakeven)
|
||||||
|
- "midpoint" -> (entry + sl0)/2 (a fine corsa lo stop e' a meta' strada)
|
||||||
|
|
||||||
|
Direzioni: il segno e' gestito dalla geometria di sl0 vs entry.
|
||||||
|
long (d=1): sl0 < entry -> target >= sl0 -> lo stop SALE verso entry
|
||||||
|
short (d=-1): sl0 > entry -> target <= sl0 -> lo stop SCENDE verso entry
|
||||||
|
In entrambi i casi lo stop si stringe (avvicina al prezzo d'ingresso) col tempo.
|
||||||
|
|
||||||
|
stretch:
|
||||||
|
- 1.0 : lo stop raggiunge `target` a max_bars (fine horizon).
|
||||||
|
- 2.0 : lo stop raggiunge `target` a 2*max_bars -> stringe a META' velocita',
|
||||||
|
quindi a max_bars e' solo a meta' del cammino sl0->target (piu' lasco).
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD: sl(j) dipende SOLO da {i, j, mb, stretch, entry, sl0}, tutti
|
||||||
|
noti all'ENTRATA (close[i]). Nessun dato del bar j o successivi entra nel livello
|
||||||
|
attivo nel bar j. Il TP resta tp0. -> contratto rispettato per costruzione.
|
||||||
|
|
||||||
|
MECCANISMO ATTESO: stringere lo stop col tempo taglia prima i trade che ristagnano
|
||||||
|
(non vanno al TP ne' allo SL iniziale e scadrebbero a max_bars vicino al BE/in
|
||||||
|
perdita). Rischio: stoppa fuori trade che sarebbero rientrati verso il TP (la fade
|
||||||
|
e' mean-reversion: il movimento contrario e' spesso transitorio) -> puo' alzare lo
|
||||||
|
stop-rate effettivo e tagliare winner ritardatari.
|
||||||
|
|
||||||
|
GRID: stretch in {1.0, 2.0} x target in {entry, midpoint} (4 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class SlTighten(ExitPolicy):
|
||||||
|
name = "sl_tighten"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.stretch = float(params.get("stretch", 1.0))
|
||||||
|
self.target_kind = str(params.get("target", "entry"))
|
||||||
|
if self.target_kind == "entry":
|
||||||
|
self.target = entry
|
||||||
|
elif self.target_kind == "midpoint":
|
||||||
|
self.target = 0.5 * (entry + sl0)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"target sconosciuto: {self.target_kind}")
|
||||||
|
self.denom = max(self.mb * self.stretch, 1e-9)
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
# frac dipende solo da i, j, mb, stretch -> noti all'entrata. No look-ahead.
|
||||||
|
frac = (j - self.i) / self.denom
|
||||||
|
if frac < 0.0:
|
||||||
|
frac = 0.0
|
||||||
|
elif frac > 1.0:
|
||||||
|
frac = 1.0
|
||||||
|
sl = self.sl0 + frac * (self.target - self.sl0)
|
||||||
|
return self.tp0, sl, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"stretch": 1.0, "target": "entry"},
|
||||||
|
{"stretch": 1.0, "target": "midpoint"},
|
||||||
|
{"stretch": 2.0, "target": "entry"},
|
||||||
|
{"stretch": 2.0, "target": "midpoint"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(SlTighten, GRID)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""EXIT-09 — tp_extend_momentum: cavalca il momentum OLTRE il TP.
|
||||||
|
|
||||||
|
Idea: le fade escono al TP fisso (alla media). Ma quando il movimento NON e'
|
||||||
|
esaurito e sfonda il TP in chiusura, vogliamo provare a cavalcarlo invece di
|
||||||
|
prendere solo il TP. State machine a 2 fasi:
|
||||||
|
|
||||||
|
FASE A (default, TP intatto): tp = tp0, sl = sl0. Exit standard alla media.
|
||||||
|
Finche' close[j-1] NON ha superato tp0 in chiusura, ci si comporta come base.
|
||||||
|
|
||||||
|
FASE B (armata quando close[j-1] supera tp0 a favore):
|
||||||
|
- TP RIMOSSO (None): non si esce piu' al livello fisso;
|
||||||
|
- attiva un trail "chandelier" a k*ATR ancorato all'ESTREMO favorevole visto
|
||||||
|
DAL bar di superamento in poi (high per long, low per short);
|
||||||
|
- FLOOR a tp0: lo stop trailing non scende mai sotto tp0 (long) / non sale mai
|
||||||
|
sopra tp0 (short), cosi' NON si esce MAI peggio del TP originale. Lo stop e'
|
||||||
|
inoltre solo-stringente (cricchetto): puo' solo avvicinarsi al prezzo.
|
||||||
|
|
||||||
|
Trigger di arma (su close[j-1], a favore):
|
||||||
|
long : close[j-1] > tp0 -> arma
|
||||||
|
short: close[j-1] < tp0 -> arma
|
||||||
|
|
||||||
|
Trail attivo (FASE B), con floor a tp0:
|
||||||
|
long : stop = max( tp0, fav_high_post - k*atr14[j-1] ) (cricchetto: solo sale)
|
||||||
|
short: stop = min( tp0, fav_low_post + k*atr14[j-1] ) (cricchetto: solo scende)
|
||||||
|
|
||||||
|
Orizzonte esteso a 3*mb (cap HARD_CAP=240) per dare spazio al runner post-TP.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
|
||||||
|
- il trigger di arma legge close[j-1] (gia' chiuso al poll del bar j);
|
||||||
|
- l'estremo favorevole post-superamento e' sullo slice [arm_idx .. j-1]
|
||||||
|
(mantenuto incrementalmente, incorporando i bar fino a j-1);
|
||||||
|
- atr14[j-1] (indice causale).
|
||||||
|
Nessun fill parziale (tp_frac sempre 1.0). after_bar non usato.
|
||||||
|
|
||||||
|
GRID: k in {1.0, 2.0, 3.0} (3 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TpExtendMomentum(ExitPolicy):
|
||||||
|
name = "tp_extend_momentum"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, k=2.0, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.k = float(k)
|
||||||
|
self.horizon = min(3 * mb, HARD_CAP)
|
||||||
|
self.close = ctx["close"]
|
||||||
|
self.high = ctx["high"]
|
||||||
|
self.low = ctx["low"]
|
||||||
|
self.atr = ctx["atr14"]
|
||||||
|
# FASE A finche' armed=False. Si arma quando close[j-1] supera tp0 a favore.
|
||||||
|
self.armed = False
|
||||||
|
self.arm_idx = None # bar dal quale si ancora l'estremo favorevole
|
||||||
|
self.fav_high = None # estremo favorevole post-superamento (long)
|
||||||
|
self.fav_low = None # estremo favorevole post-superamento (short)
|
||||||
|
self._last_seen = i # ultimo close gia' esaminato per il trigger
|
||||||
|
self._fav_seen = None # ultimo bar gia' incorporato nell'estremo
|
||||||
|
# stop trailing monotono in FASE B: parte dal floor tp0, solo si stringe
|
||||||
|
self.cur_stop = None
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
c = self.close
|
||||||
|
d = self.d
|
||||||
|
# ---- FASE A -> controlla l'arma sui close fino a j-1 (causali) -----------
|
||||||
|
if not self.armed:
|
||||||
|
while self._last_seen < j - 1:
|
||||||
|
self._last_seen += 1
|
||||||
|
cv = c[self._last_seen]
|
||||||
|
crossed = (cv > self.tp0) if d == 1 else (cv < self.tp0)
|
||||||
|
if crossed:
|
||||||
|
# arma: ancora l'estremo favorevole DAL bar di superamento
|
||||||
|
self.armed = True
|
||||||
|
self.arm_idx = self._last_seen
|
||||||
|
self.fav_high = self.high[self.arm_idx]
|
||||||
|
self.fav_low = self.low[self.arm_idx]
|
||||||
|
self._fav_seen = self.arm_idx
|
||||||
|
self.cur_stop = self.tp0 # floor di partenza = tp0
|
||||||
|
break
|
||||||
|
if not self.armed:
|
||||||
|
return self.tp0, self.sl0, 1.0 # ancora FASE A: TP/SL fissi
|
||||||
|
|
||||||
|
# ---- FASE B -> TP rimosso, trail chandelier ancorato post-superamento -----
|
||||||
|
# incorpora i bar [arm_idx .. j-1] nell'estremo favorevole (dati causali)
|
||||||
|
while self._fav_seen < j - 1:
|
||||||
|
self._fav_seen += 1
|
||||||
|
if self.high[self._fav_seen] > self.fav_high:
|
||||||
|
self.fav_high = self.high[self._fav_seen]
|
||||||
|
if self.low[self._fav_seen] < self.fav_low:
|
||||||
|
self.fav_low = self.low[self._fav_seen]
|
||||||
|
a = self.atr[j - 1]
|
||||||
|
if a == a: # non-NaN
|
||||||
|
if d == 1:
|
||||||
|
cand = self.fav_high - self.k * a
|
||||||
|
cand = max(cand, self.tp0) # floor: mai sotto il TP
|
||||||
|
if cand > self.cur_stop: # cricchetto: solo sale
|
||||||
|
self.cur_stop = cand
|
||||||
|
else:
|
||||||
|
cand = self.fav_low + self.k * a
|
||||||
|
cand = min(cand, self.tp0) # floor: mai sopra il TP
|
||||||
|
if cand < self.cur_stop: # cricchetto: solo scende
|
||||||
|
self.cur_stop = cand
|
||||||
|
return None, self.cur_stop, 1.0 # TP rimosso in FASE B
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [{"k": k} for k in (1.0, 2.0, 3.0)]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(TpExtendMomentum, GRID)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""EXIT-10 — tp_moving_mean: il TP INSEGUE la media corrente (non quello fisso).
|
||||||
|
|
||||||
|
PRIOR (vincolante): le fade puntano alla MEDIA. Oggi il TP e' CONGELATO all'entrata
|
||||||
|
(tp0 = SMA_n(close) al close[i-1]). Ma la media SI MUOVE durante il trade: se il
|
||||||
|
prezzo ci mette qualche barra a rientrare, la media si e' gia' spostata. Idea:
|
||||||
|
ridefinire il TP come la SMA_n corrente -> tp(j) = SMA_n(close)[j-1]. Il target
|
||||||
|
"segue" la media verso cui la fade punta, invece di mirare a una media stantia.
|
||||||
|
|
||||||
|
long (d=+1): si compra sotto la media -> tp(j) = SMA_n[j-1] (sopra entry, di norma).
|
||||||
|
short (d=-1): si vende sopra la media -> tp(j) = SMA_n[j-1] (sotto entry, di norma).
|
||||||
|
|
||||||
|
CAP ONESTO (anti exit-in-perdita): se la media si muove CONTRO (long: media scende
|
||||||
|
sotto entry; short: media sale sopra entry), il TP diventerebbe un'uscita in perdita
|
||||||
|
mascherata. Lo evitiamo:
|
||||||
|
long : tp(j) = max(tp(j), entry*(1+0.002))
|
||||||
|
short: tp(j) = min(tp(j), entry*(1-0.002))
|
||||||
|
0.002 (~ 0.10% RT fee + margine) garantisce che il tocco del TP sia >= breakeven.
|
||||||
|
|
||||||
|
SL FISSO (sl0) invariato; horizon = max_bars invariato.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD: prepare() precalcola sma_n = rolling(n).mean() su close (causale,
|
||||||
|
ogni valore dipende solo da close <= quel-indice). In levels(j) si legge sma_n[j-1]
|
||||||
|
-> solo dati <= j-1, mai il bar j. SL e horizon invariati. OK per costruzione.
|
||||||
|
|
||||||
|
GRID: n in {20, 50, 100} (3 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TpMovingMean(ExitPolicy):
|
||||||
|
name = "tp_moving_mean"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def prepare(cls, ctx, **params):
|
||||||
|
n = int(params.get("n", 50))
|
||||||
|
key = f"sma_{n}"
|
||||||
|
if key not in ctx:
|
||||||
|
c = ctx["close"]
|
||||||
|
ctx[key] = pd.Series(c).rolling(n).mean().values
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
n = int(params.get("n", 50))
|
||||||
|
self.sma = ctx[f"sma_{n}"]
|
||||||
|
# cap onesto: il TP non puo' diventare un'uscita in perdita
|
||||||
|
if d == 1:
|
||||||
|
self.cap = entry * (1.0 + 0.002)
|
||||||
|
else:
|
||||||
|
self.cap = entry * (1.0 - 0.002)
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
# SOLO dati <= j-1
|
||||||
|
m = self.sma[j - 1]
|
||||||
|
if not np.isfinite(m):
|
||||||
|
# warmup della SMA non ancora pronto -> ricadi sul TP fisso d'entrata
|
||||||
|
return self.tp0, self.sl0, 1.0
|
||||||
|
if self.d == 1:
|
||||||
|
tp = max(m, self.cap)
|
||||||
|
else:
|
||||||
|
tp = min(m, self.cap)
|
||||||
|
return tp, self.sl0, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"n": 20},
|
||||||
|
{"n": 50},
|
||||||
|
{"n": 100},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(TpMovingMean, GRID)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""EXIT-11 — z_overshoot: TP OLTRE la media (overshoot di z_off deviazioni std).
|
||||||
|
|
||||||
|
PRIOR (vincolante): le fade puntano alla MEDIA. Oggi il TP e' la SMA_n al close[i-1].
|
||||||
|
Ipotesi di questa famiglia: la reversione spesso NON si ferma esattamente alla media,
|
||||||
|
ma la attraversa (overshoot). Spostando il TP un po' OLTRE la media nella direzione
|
||||||
|
del trade catturiamo l'overshoot quando c'e', al prezzo di mancare alcuni TP che si
|
||||||
|
fermano alla media (che poi rientrano o vengono presi da max_bars/SL).
|
||||||
|
|
||||||
|
tp(j) = SMA_n[j-1] + d * z_off * STD_n[j-1]
|
||||||
|
|
||||||
|
long (d=+1): si compra sotto la media -> TP = media + z_off*std (sopra la media: piu' lontano).
|
||||||
|
short (d=-1): si vende sopra la media -> TP = media - z_off*std (sotto la media: piu' lontano).
|
||||||
|
|
||||||
|
In ENTRAMBI i casi il TP si allontana di z_off*std OLTRE la media nella direzione
|
||||||
|
del profitto -> target piu' ambizioso. NB: lo z e' un overshoot ADDIZIONALE rispetto
|
||||||
|
alla media corrente; la media stessa si muove (come EXIT-10) quindi il target insegue
|
||||||
|
la media + overshoot.
|
||||||
|
|
||||||
|
CAP ONESTO (anti exit-in-perdita): se la media + overshoot finisce contro l'entry
|
||||||
|
(media e' gia' scappata oltre entry contro di noi), il TP non deve diventare un'uscita
|
||||||
|
in perdita mascherata. Floor a breakeven+fee come EXIT-10:
|
||||||
|
long : tp(j) = max(tp(j), entry*(1+0.002))
|
||||||
|
short: tp(j) = min(tp(j), entry*(1-0.002))
|
||||||
|
|
||||||
|
SL FISSO (sl0) invariato; horizon = max_bars invariato.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD: prepare() precalcola sma_n = rolling(n).mean() e std_n =
|
||||||
|
rolling(n).std(ddof=0) su close (causali: ogni valore dipende solo da close <= indice).
|
||||||
|
In levels(j) si legge sma_n[j-1] e std_n[j-1] -> solo dati <= j-1, mai il bar j.
|
||||||
|
SL e horizon invariati. OK per costruzione.
|
||||||
|
|
||||||
|
GRID: n in {20, 50} x z_off in {0.25, 0.5} (4 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ZOvershoot(ExitPolicy):
|
||||||
|
name = "z_overshoot"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def prepare(cls, ctx, **params):
|
||||||
|
n = int(params.get("n", 50))
|
||||||
|
kmean = f"sma_{n}"
|
||||||
|
kstd = f"std_{n}"
|
||||||
|
if kmean not in ctx or kstd not in ctx:
|
||||||
|
c = pd.Series(ctx["close"])
|
||||||
|
ctx[kmean] = c.rolling(n).mean().values
|
||||||
|
ctx[kstd] = c.rolling(n).std(ddof=0).values
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
n = int(params.get("n", 50))
|
||||||
|
self.z_off = float(params.get("z_off", 0.5))
|
||||||
|
self.sma = ctx[f"sma_{n}"]
|
||||||
|
self.std = ctx[f"std_{n}"]
|
||||||
|
# cap onesto: il TP non puo' diventare un'uscita in perdita
|
||||||
|
if d == 1:
|
||||||
|
self.cap = entry * (1.0 + 0.002)
|
||||||
|
else:
|
||||||
|
self.cap = entry * (1.0 - 0.002)
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
# SOLO dati <= j-1
|
||||||
|
m = self.sma[j - 1]
|
||||||
|
s = self.std[j - 1]
|
||||||
|
if not (np.isfinite(m) and np.isfinite(s)):
|
||||||
|
# warmup non pronto -> ricadi sul TP fisso d'entrata
|
||||||
|
return self.tp0, self.sl0, 1.0
|
||||||
|
tp = m + self.d * self.z_off * s
|
||||||
|
if self.d == 1:
|
||||||
|
tp = max(tp, self.cap)
|
||||||
|
else:
|
||||||
|
tp = min(tp, self.cap)
|
||||||
|
return tp, self.sl0, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"n": 20, "z_off": 0.25},
|
||||||
|
{"n": 20, "z_off": 0.5},
|
||||||
|
{"n": 50, "z_off": 0.25},
|
||||||
|
{"n": 50, "z_off": 0.5},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(ZOvershoot, GRID)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""EXIT-12 — partial_tp_trail: partial AL TP PIENO, poi il runner CORRE col trail.
|
||||||
|
|
||||||
|
Idea (diversa dal ladder 80/20 gia' SCARTATO, che metteva uno stop FISSO alla
|
||||||
|
soglia 80% del TP): qui il partial avviene AL TP PIENO (tp0, alla media), esce una
|
||||||
|
frazione q del trade, e il RESIDUO resta SENZA TP, protetto da un trailing
|
||||||
|
chandelier k*ATR ancorato all'estremo favorevole raggiunto DOPO il partial; il
|
||||||
|
floor dello stop e' tp0 -> il profitto al livello TP e' LOCKATO (il runner non
|
||||||
|
puo' mai chiudere peggio di tp0). horizon esteso a 3*mb (cap HARD_CAP) per dare
|
||||||
|
spazio al runner.
|
||||||
|
|
||||||
|
Fase 1 (pre-partial): exit standard = (tp0, sl0). Al tocco di tp0 esce q.
|
||||||
|
Fase 2 (post-partial): TP rimosso. Trail sul residuo:
|
||||||
|
Long : stop(j) = max( tp0, max(high[part..j-1]) - k*atr14[j-1] ) (solo sale)
|
||||||
|
Short: stop(j) = min( tp0, min(low [part..j-1]) + k*atr14[j-1] ) (solo scende)
|
||||||
|
floor = tp0 -> profitto lockato al livello TP, MAI peggio.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
|
||||||
|
- estremo favorevole post-partial sullo slice [part .. j-1] (mantenuto
|
||||||
|
incrementalmente, aggiornato col bar j-1 prima di calcolare lo stop in j);
|
||||||
|
- atr14[j-1] (indice causale).
|
||||||
|
on_partial(j) registra solo l'indice del bar di partial (j) e il prezzo (tp0):
|
||||||
|
l'estremo della fase 2 parte da high/low del bar di partial j (<= j, ma il primo
|
||||||
|
bar valutato in fase 2 e' j+1, slice [j..j] noto al poll di j+1 -> causale).
|
||||||
|
|
||||||
|
GRID: q in {0.5, 0.7} x k in {2.0, 3.0} (4 celle). tp_frac=q.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class PartialTpTrail(ExitPolicy):
|
||||||
|
name = "partial_tp_trail"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, q=0.5, k=3.0, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.q = float(q)
|
||||||
|
self.k = float(k)
|
||||||
|
self.horizon = min(3 * mb, HARD_CAP)
|
||||||
|
self.high = ctx["high"]
|
||||||
|
self.low = ctx["low"]
|
||||||
|
self.atr = ctx["atr14"]
|
||||||
|
# stato fase 2 (post-partial)
|
||||||
|
self.partial_idx = None # bar in cui e' avvenuto il partial (None = fase 1)
|
||||||
|
self.fav_high = None # estremo favorevole sullo slice [partial..j-1]
|
||||||
|
self.fav_low = None
|
||||||
|
self._last_seen = None # ultimo indice incorporato nell'estremo
|
||||||
|
self.cur_stop = None # stop trailing fase 2, floor=tp0, monotono
|
||||||
|
|
||||||
|
def levels(self, j):
|
||||||
|
# ---- Fase 1: pre-partial -> exit standard (tp0, sl0), esce frazione q al TP
|
||||||
|
if self.partial_idx is None:
|
||||||
|
return self.tp0, self.sl0, self.q
|
||||||
|
|
||||||
|
# ---- Fase 2: post-partial -> TP rimosso, trail chandelier floor=tp0
|
||||||
|
h, l, atr, d = self.high, self.low, self.atr, self.d
|
||||||
|
# incorpora i bar fino a j-1 (dati causali, gia' chiusi al poll del bar j)
|
||||||
|
while self._last_seen < j - 1:
|
||||||
|
self._last_seen += 1
|
||||||
|
if h[self._last_seen] > self.fav_high:
|
||||||
|
self.fav_high = h[self._last_seen]
|
||||||
|
if l[self._last_seen] < self.fav_low:
|
||||||
|
self.fav_low = l[self._last_seen]
|
||||||
|
a = atr[j - 1]
|
||||||
|
if a != a: # NaN -> resta sullo stop corrente
|
||||||
|
return None, self.cur_stop, 1.0
|
||||||
|
if d == 1:
|
||||||
|
cand = self.fav_high - self.k * a
|
||||||
|
if cand > self.cur_stop: # lo stop long puo' solo SALIRE (stringersi)
|
||||||
|
self.cur_stop = cand
|
||||||
|
else:
|
||||||
|
cand = self.fav_low + self.k * a
|
||||||
|
if cand < self.cur_stop: # lo stop short puo' solo SCENDERE
|
||||||
|
self.cur_stop = cand
|
||||||
|
return None, self.cur_stop, 1.0
|
||||||
|
|
||||||
|
def on_partial(self, j, price, remaining):
|
||||||
|
# entra in fase 2: ancora l'estremo al bar di partial j (high/low[j] sono
|
||||||
|
# noti al poll del bar j+1, primo bar valutato in fase 2). floor=tp0.
|
||||||
|
self.partial_idx = j
|
||||||
|
self.fav_high = self.high[j]
|
||||||
|
self.fav_low = self.low[j]
|
||||||
|
self._last_seen = j
|
||||||
|
self.cur_stop = self.tp0 # profitto lockato al livello TP, MAI peggio
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"q": q, "k": k}
|
||||||
|
for q in (0.5, 0.7)
|
||||||
|
for k in (2.0, 3.0)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(PartialTpTrail, GRID)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""EXIT-13 — hurst_exit: TP condizionato al REGIME (rolling-Hurst causale).
|
||||||
|
|
||||||
|
IDEA. Le fade puntano alla MEDIA: in regime ANTI-PERSISTENTE (Hurst basso) la
|
||||||
|
reversione tende a completarsi -> ha senso tenere il TP pieno tp0. In regime
|
||||||
|
PERSISTENTE/trending (Hurst alto) la reversione spesso NON arriva fino in fondo
|
||||||
|
(il movimento continua, lo stop-loss si concentra li': vedi loss-guard Hurst) ->
|
||||||
|
conviene "prendere quel che c'e'": TP anticipato a meta' strada (entry+tp0)/2.
|
||||||
|
|
||||||
|
- H[j-1] < h_lo (anti-persistente): tp(j) = tp0 (reversione completa)
|
||||||
|
- H[j-1] >= h_lo (persistente): tp(j) = (entry+tp0)/2 (TP a meta')
|
||||||
|
|
||||||
|
SL FISSO (sl0) e horizon (max_bars) invariati.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD. prepare() precalcola H = rolling_hurst(close, window=100, step=6)
|
||||||
|
una volta sullo sleeve. rolling_hurst e' causale: H[k] usa returns[k-window:k] e
|
||||||
|
returns[m]=diff(log(close))[m] dipende da close[m+1], quindi H[k] dipende solo da
|
||||||
|
close <= k. In levels(j) leggo H[j-1] -> solo dati <= j-1. SL/horizon invariati.
|
||||||
|
La decisione del regime e' fissata col bar precedente, il bar j tocca i livelli.
|
||||||
|
|
||||||
|
NB sul TP a meta'. Lo "step=6" significa che H e' costante a tratti di 6 barre; il
|
||||||
|
regime e' ri-letto ogni bar (j-1) ma cambia valore ogni 6. Il TP a meta' strada
|
||||||
|
e' SEMPRE >= breakeven per costruzione (e' fra entry e tp0, e tp0 e' gia' oltre il
|
||||||
|
margine fee per come la fade lo fissa), quindi non maschera uscite in perdita.
|
||||||
|
|
||||||
|
GRID: h_lo in {0.45, 0.50, 0.55} (3 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||||
|
from src.fractal.indicators import rolling_hurst # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class HurstExit(ExitPolicy):
|
||||||
|
name = "hurst_exit"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def prepare(cls, ctx, **params):
|
||||||
|
if "hurst100" not in ctx:
|
||||||
|
c = ctx["close"]
|
||||||
|
ctx["hurst100"] = rolling_hurst(c, window=100, step=6)
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.h = ctx["hurst100"]
|
||||||
|
self.h_lo = float(params.get("h_lo", 0.50))
|
||||||
|
self.tp_half = (entry + tp0) / 2.0 # TP a meta' strada (persistente)
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
# SOLO dati <= j-1
|
||||||
|
hv = self.h[j - 1]
|
||||||
|
if not np.isfinite(hv):
|
||||||
|
return self.tp0, self.sl0, 1.0
|
||||||
|
if hv < self.h_lo:
|
||||||
|
tp = self.tp0 # anti-persistente: reversione completa
|
||||||
|
else:
|
||||||
|
tp = self.tp_half # persistente: prendi la meta'
|
||||||
|
return tp, self.sl0, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"h_lo": 0.45},
|
||||||
|
{"h_lo": 0.50},
|
||||||
|
{"h_lo": 0.55},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(HurstExit, GRID)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""EXIT-14 — protezione GIVEBACK dal picco (trailing sul PROFITTO, non sul prezzo).
|
||||||
|
|
||||||
|
Idea: una fade puo' correre a favore quasi fino al TP (alla media) e poi
|
||||||
|
ritracciare prima di toccarlo, restituendo gran parte del guadagno gia' maturato.
|
||||||
|
Questa policy traccia l'high-water-mark (hwm) del PROFITTO non realizzato sul
|
||||||
|
CLOSE; quando il profitto corrente scende sotto (1-g)*hwm_profit -- cioe' si e'
|
||||||
|
restituita una frazione g del picco -- e il picco era gia' "significativo"
|
||||||
|
(hwm_profit > soglia * dist(entry, tp0)), esce al close del bar (after_bar).
|
||||||
|
Il TP fisso tp0 e lo SL fisso sl0 RESTANO invariati e prioritari intrabar.
|
||||||
|
|
||||||
|
Razionale: protegge il profitto maturato senza toccare i livelli; non e' un
|
||||||
|
ladder (ieri scartato perche' al TP/media il movimento e' esaurito) ma un
|
||||||
|
trailing sul drawdown del trade quando ha gia' fatto buona parte del lavoro.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD:
|
||||||
|
- levels(j) NON e' toccato (TP/SL fissi): nessun dato futuro.
|
||||||
|
- after_bar(j) decide sul CLOSE del bar j (eseguibile al poll). Aggiorna l'hwm
|
||||||
|
con il profitto a close[j] e poi confronta: tutto con dati <= j. Il bar j e'
|
||||||
|
lecito in after_bar per contratto. L'hwm e' un cliquet (non scende mai).
|
||||||
|
|
||||||
|
GRID: g in {0.4, 0.6} x arm_frac in {0.3, 0.5} della distanza al TP (4 celle).
|
||||||
|
- g = frazione del picco di profitto restituita che fa scattare l'uscita.
|
||||||
|
- arm_frac = il giveback e' armato solo quando hwm_profit ha superato
|
||||||
|
arm_frac * dist(entry, tp0): finche' il trade non ha "fatto strada"
|
||||||
|
(frazione del cammino verso la media) non interviene -> non taglia i trade
|
||||||
|
che partono storti, solo quelli che hanno gia' guadagnato e poi mollano.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class Giveback(ExitPolicy):
|
||||||
|
name = "giveback"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.g = float(params.get("g", 0.4))
|
||||||
|
self.arm_frac = float(params.get("arm_frac", 0.3))
|
||||||
|
self.close = ctx["close"]
|
||||||
|
# distanza (in prezzo, sempre positiva) dall'entrata al TP iniziale
|
||||||
|
self.dist_tp = abs(self.tp0 - entry) if self.tp0 is not None else 0.0
|
||||||
|
self.arm_level = self.arm_frac * self.dist_tp
|
||||||
|
self.hwm_profit = 0.0 # high-water-mark del profitto a favore (cliquet)
|
||||||
|
|
||||||
|
def after_bar(self, j: int) -> bool:
|
||||||
|
if self.dist_tp <= 0.0:
|
||||||
|
return False
|
||||||
|
# profitto NON realizzato a favore, valutato sul close del bar j (lecito qui)
|
||||||
|
profit = (self.close[j] - self.entry) * self.d
|
||||||
|
if profit > self.hwm_profit:
|
||||||
|
self.hwm_profit = profit # aggiorna il picco (non scende mai)
|
||||||
|
# arma solo se il picco ha superato la soglia (trade gia' "in cammino")
|
||||||
|
if self.hwm_profit < self.arm_level:
|
||||||
|
return False
|
||||||
|
# esci se il profitto corrente ha restituito >= g del picco
|
||||||
|
if profit < (1.0 - self.g) * self.hwm_profit:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"g": 0.4, "arm_frac": 0.3},
|
||||||
|
{"g": 0.4, "arm_frac": 0.5},
|
||||||
|
{"g": 0.6, "arm_frac": 0.3},
|
||||||
|
{"g": 0.6, "arm_frac": 0.5},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(Giveback, GRID)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""EXIT-15 — loser time-stop ("i loser muoiono giovani?").
|
||||||
|
|
||||||
|
Le fade tengono il trade fino a TP/SL fissi o max_bars. Qui aggiungiamo un
|
||||||
|
solo extra-exit CONDIZIONATO AL SEGNO della PnL: al k-esimo bar dopo l'entrata,
|
||||||
|
se il trade e' ancora in PERDITA sul CLOSE (unrealized < 0), chiudi il residuo
|
||||||
|
al close[j]. Se a quel bar il trade e' in profitto (o piatto), NON tocca nulla
|
||||||
|
e lascia correre verso TP/SL/max_bars come la base.
|
||||||
|
|
||||||
|
TP/SL fissi (tp0, sl0) INVARIATI; horizon = max_bars INVARIATO.
|
||||||
|
|
||||||
|
Differenza col time-stop semplice (gia' FALLITO il 2026-06-02, tagliava i
|
||||||
|
winner): qui non si esce per il solo passare del tempo, ma SOLO se la PnL e'
|
||||||
|
negativa. L'ipotesi e' che un trade ancora in rosso dopo k bar abbia bassa
|
||||||
|
probabilita' di recuperare e finisca per colpire lo SL (peggio) o scadere a
|
||||||
|
max_bars in perdita -> uscire prima limita la coda di perdite e libera capitale.
|
||||||
|
|
||||||
|
Anti-look-ahead: la decisione e' in `after_bar(j)`, che per contratto puo'
|
||||||
|
leggere il CLOSE del bar j (eseguibile al poll del tick). Confronto:
|
||||||
|
unrealized(j) = (close[j] - entry) * d (>0 = a favore)
|
||||||
|
Esce SOLO quando j - i == k (controllo una tantum al k-esimo bar). I livelli
|
||||||
|
TP/SL restano quelli base (nessun uso di dati futuri in levels()).
|
||||||
|
|
||||||
|
GRID: k in {4, 8, 12, 16} (4 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class LoserTimestop(ExitPolicy):
|
||||||
|
name = "loser_timestop"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.k = int(params.get("k", 8))
|
||||||
|
self.close = ctx["close"]
|
||||||
|
|
||||||
|
# livelli base, invariati (nessun look-ahead)
|
||||||
|
def levels(self, j: int):
|
||||||
|
return self.tp0, self.sl0, 1.0
|
||||||
|
|
||||||
|
def after_bar(self, j: int) -> bool:
|
||||||
|
# controllo una tantum al k-esimo bar dopo l'entrata
|
||||||
|
if j - self.i != self.k:
|
||||||
|
return False
|
||||||
|
unrealized = (self.close[j] - self.entry) * self.d
|
||||||
|
return unrealized < 0.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"k": 4},
|
||||||
|
{"k": 8},
|
||||||
|
{"k": 12},
|
||||||
|
{"k": 16},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(LoserTimestop, GRID)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""EXIT-16 — close_confirm_sl: STOP-LOSS confermato in CHIUSURA (immune ai wick).
|
||||||
|
|
||||||
|
IDEA. Lo SL intrabar fisso stoppa anche su un wick transitorio che buca sl0 e
|
||||||
|
poi rientra. Le fade sono mean-reversion: il movimento contrario e' spesso un
|
||||||
|
overshoot momentaneo. Qui lo SL NON e' piu' un livello toccato intrabar
|
||||||
|
(disattivato: sl=None nei livelli, l'engine non lo testa MAI sui low/high) ma
|
||||||
|
una CONFERMA sul CLOSE del bar: si esce solo se il prezzo CHIUDE oltre sl0.
|
||||||
|
|
||||||
|
in levels(j): sl = None (no stop intrabar -> immune ai wick), TP fisso tp0.
|
||||||
|
in after_bar(j):
|
||||||
|
long (d=1): esci al close[j] se close[j] < sl0 - buffer*atr14[j]
|
||||||
|
short (d=-1): esci al close[j] se close[j] > sl0 + buffer*atr14[j]
|
||||||
|
|
||||||
|
ONESTO. L'uscita avviene al CLOSE[j], che puo' essere PEGGIO di sl0 quando il
|
||||||
|
bar gappa o sfonda di slancio (il backtest paga il close reale, non sl0). Questo
|
||||||
|
e' il costo del "confermare": si rinuncia a fermarsi esatti a sl0 in cambio di
|
||||||
|
non farsi cacciare dai wick. Il buffer*atr richiede uno sfondamento ulteriore in
|
||||||
|
chiusura -> meno uscite ma piu' lontane da sl0 quando scattano.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD. levels(j) ritorna sl=None e tp0 (nessun dato del bar j). La
|
||||||
|
decisione di uscita e' in after_bar(j), che PER CONTRATTO puo' leggere il bar j:
|
||||||
|
close[j] e atr14[j] sono entrambi noti al close del bar j (atr14[j] = rolling
|
||||||
|
mean di TR fino a j; TR[j] usa high/low/close[j] e close[j-1], tutti chiusi a j).
|
||||||
|
Nessun dato > j entra nella decisione. -> contratto rispettato.
|
||||||
|
|
||||||
|
NB il HARD_CAP/horizon=max_bars resta: se ne' TP ne' close-confirm scattano, il
|
||||||
|
trade scade al close a max_bars come la base. Quindi un trade puo' restare in
|
||||||
|
posizione PIU' a lungo della base (la base poteva stopparlo intrabar prima):
|
||||||
|
l'avg_bars puo' SALIRE -> il ret va pesato per l'esposizione.
|
||||||
|
|
||||||
|
GRID: buffer in {0.0, 0.25, 0.5} ATR (3 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class CloseConfirmSl(ExitPolicy):
|
||||||
|
name = "close_confirm_sl"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.buffer = float(params.get("buffer", 0.0))
|
||||||
|
self.close = ctx["close"]
|
||||||
|
self.atr = ctx["atr14"]
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
# SL intrabar DISATTIVATO (immune ai wick): l'engine non testa low/high
|
||||||
|
# contro lo stop. TP fisso tp0 intrabar invariato.
|
||||||
|
return self.tp0, None, 1.0
|
||||||
|
|
||||||
|
def after_bar(self, j: int) -> bool:
|
||||||
|
# Decisione sul CLOSE del bar j -> per contratto j e' leggibile.
|
||||||
|
a = self.atr[j]
|
||||||
|
if not np.isfinite(a):
|
||||||
|
a = 0.0
|
||||||
|
cj = self.close[j]
|
||||||
|
if self.d == 1:
|
||||||
|
# long: stop confermato se chiude SOTTO sl0 - buffer*atr
|
||||||
|
return cj < self.sl0 - self.buffer * a
|
||||||
|
else:
|
||||||
|
# short: stop confermato se chiude SOPRA sl0 + buffer*atr
|
||||||
|
return cj > self.sl0 + self.buffer * a
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"buffer": 0.0},
|
||||||
|
{"buffer": 0.25},
|
||||||
|
{"buffer": 0.5},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(CloseConfirmSl, GRID)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""EXIT-17 — wide_sl_trail: RESPIRA POI PROTEGGI.
|
||||||
|
|
||||||
|
Idea: l'SL fisso all'entrata (sl0) puo' essere troppo stretto -> stoppa fade che
|
||||||
|
poi sarebbero rientrate alla media. Qui ALLARGHIAMO l'SL iniziale a
|
||||||
|
sl_w = entry - w*(entry - sl0) (long, w>1 -> stop piu' lontano = piu' respiro)
|
||||||
|
sl_w = entry + w*(sl0 - entry) (short)
|
||||||
|
ma attiviamo SUBITO un trailing chandelier a k*ATR dall'estremo favorevole. Lo stop
|
||||||
|
attivo nel bar j e' il PIU' PROTETTIVO fra l'SL allargato e il chandelier:
|
||||||
|
long : sl = max(sl_w, max(high[i..j-1]) - k*ATR[j-1])
|
||||||
|
short: sl = min(sl_w, min(low[i..j-1]) + k*ATR[j-1])
|
||||||
|
TP fisso tp0 invariato; horizon = max_bars invariato.
|
||||||
|
|
||||||
|
Logica: finche' il prezzo NON corre a favore, il rischio e' l'SL allargato (respiro:
|
||||||
|
si tollera un ritracciamento iniziale piu' ampio nella speranza che la fade rientri).
|
||||||
|
Appena il prezzo corre a favore, il chandelier sale sopra sl_w e protegge il
|
||||||
|
profitto come un trail normale. Differenza da EXIT-02 (che usa max(sl0, chand)):
|
||||||
|
qui il floor iniziale e' PIU' LARGO di sl0, quindi early il rischio per-trade e'
|
||||||
|
maggiore (peggiora i loser veloci) ma si salvano fade che con sl0 sarebbero state
|
||||||
|
stoppate giusto prima del rientro.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1 (estremo favorevole su [i..j-1]
|
||||||
|
mantenuto incrementalmente fino a j-1; atr14[j-1]). after_bar non usato.
|
||||||
|
|
||||||
|
GRID: w in {1.5, 2.0} x k in {2.0, 3.0} (4 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class WideSLTrail(ExitPolicy):
|
||||||
|
name = "wide_sl_trail"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, w=1.5, k=2.0, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.w = float(w)
|
||||||
|
self.k = float(k)
|
||||||
|
self.high = ctx["high"]
|
||||||
|
self.low = ctx["low"]
|
||||||
|
self.atr = ctx["atr14"]
|
||||||
|
# SL iniziale ALLARGATO: piu' lontano dall'entrata (w>1)
|
||||||
|
if d == 1:
|
||||||
|
self.sl_w = entry - self.w * (entry - sl0)
|
||||||
|
else:
|
||||||
|
self.sl_w = entry + self.w * (sl0 - entry)
|
||||||
|
# estremo favorevole running (solo barre <= j-1); init = barra d'entrata i
|
||||||
|
self.run_hi = self.high[i]
|
||||||
|
self.run_lo = self.low[i]
|
||||||
|
self.last_seen = i
|
||||||
|
|
||||||
|
def _update_running(self, upto: int) -> None:
|
||||||
|
"""Incorpora le barre (last_seen, upto] nell'estremo favorevole. upto = j-1
|
||||||
|
-> NON tocca il bar j (anti-look-ahead)."""
|
||||||
|
while self.last_seen < upto:
|
||||||
|
self.last_seen += 1
|
||||||
|
if self.high[self.last_seen] > self.run_hi:
|
||||||
|
self.run_hi = self.high[self.last_seen]
|
||||||
|
if self.low[self.last_seen] < self.run_lo:
|
||||||
|
self.run_lo = self.low[self.last_seen]
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
self._update_running(j - 1) # solo dati <= j-1
|
||||||
|
a = self.atr[j - 1]
|
||||||
|
if a is None or a != a: # NaN nei primi 14 bar -> usa l'SL allargato
|
||||||
|
return self.tp0, self.sl_w, 1.0
|
||||||
|
if self.d == 1:
|
||||||
|
chand = self.run_hi - self.k * a
|
||||||
|
sl = max(self.sl_w, chand) # il piu' protettivo (stop piu' alto)
|
||||||
|
else:
|
||||||
|
chand = self.run_lo + self.k * a
|
||||||
|
sl = min(self.sl_w, chand) # il piu' protettivo (stop piu' basso)
|
||||||
|
return self.tp0, sl, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"w": w, "k": k}
|
||||||
|
for w in (1.5, 2.0)
|
||||||
|
for k in (2.0, 3.0)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(WideSLTrail, GRID)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""EXIT-18 — SL STRUTTURALE su swing low/high (structural / chandelier-by-structure).
|
||||||
|
|
||||||
|
Idea: invece di uno stop FISSO a sl0 (ATR dall'entrata), uno stop ancorato alla
|
||||||
|
STRUTTURA recente del prezzo. Per un long: il livello "naturale" sotto cui la tesi
|
||||||
|
mean-reversion e' invalidata e' il minimo dello swing recente; se il prezzo rompe
|
||||||
|
sotto il minimo degli ultimi n bar la fade ha torto. Simmetrico per lo short sul
|
||||||
|
massimo recente.
|
||||||
|
|
||||||
|
long (d=1): level(j) = min(low[j-n .. j-1]) - buf
|
||||||
|
short (d=-1): level(j) = max(high[j-n .. j-1]) + buf
|
||||||
|
buf = 0.25 * atr14[j-1]
|
||||||
|
|
||||||
|
SOLO-STRINGENTE: lo stop parte da sl0 (protezione iniziale invariata) e si aggiorna
|
||||||
|
SOLO se il livello swing e' PIU' PROTETTIVO (cricchetto):
|
||||||
|
long : cur_stop = max(cur_stop, level) (lo stop puo' solo SALIRE)
|
||||||
|
short: cur_stop = min(cur_stop, level) (lo stop puo' solo SCENDERE)
|
||||||
|
Cosi' man mano che il prezzo (per una fade vincente) torna verso la media, lo swing
|
||||||
|
low/high sale/scende e lo stop segue, bloccando profitto. Non si allenta MAI.
|
||||||
|
|
||||||
|
TP fisso (tp0) e horizon=max_bars invariati: questa famiglia cambia SOLO lo stop.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD (contratto): levels(j) usa SOLO dati con indice <= j-1:
|
||||||
|
- massimi/minimi sullo slice [j-n .. j-1] (lookback chiuso a j-1);
|
||||||
|
- buffer da atr14[j-1] (indicatore causale, valore del bar precedente).
|
||||||
|
Mantengo cur_stop in modo incrementale ma lo aggiorno con i bar fino a j-1, mai j.
|
||||||
|
Nessun dato del bar j o successivi entra nel livello attivo nel bar j.
|
||||||
|
|
||||||
|
MECCANISMO ATTESO: lo stop strutturale e' tipicamente PIU' LASCO di sl0 all'inizio
|
||||||
|
(il minimo recente puo' stare oltre sl0): in quel caso il cricchetto lo IGNORA e
|
||||||
|
resta a sl0 (non allentiamo mai). Quando invece la struttura si stringe (lo swing
|
||||||
|
low risale verso il prezzo dopo che la fade comincia a funzionare) lo stop SALE,
|
||||||
|
proteggendo i guadagni di un trade che poi rintraccia prima del TP.
|
||||||
|
Prior dal repo (ladder scartato): il TP della fade sta alla MEDIA, dove il movimento
|
||||||
|
e' esaurito -> oltre il TP non c'e' runner. Qui non tocchiamo il TP, quindi non
|
||||||
|
puntiamo a cavalcare oltre: cerchiamo SOLO di tagliare meglio i loser/ristagni
|
||||||
|
proteggendo profitto. RISCHIO (fade = mean-reversion): stringere lo stop su un
|
||||||
|
pullback transitorio stoppa fuori trade che sarebbero rientrati al TP -> winner
|
||||||
|
tagliati e stop-rate su. Lo misuriamo.
|
||||||
|
|
||||||
|
GRID: n in {5, 10, 20} (3 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class SwingStop(ExitPolicy):
|
||||||
|
name = "swing_stop"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, n=10, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.n = int(n)
|
||||||
|
self.low = ctx["low"]
|
||||||
|
self.high = ctx["high"]
|
||||||
|
self.atr14 = ctx["atr14"]
|
||||||
|
# stop a cricchetto: parte dalla protezione iniziale, puo' solo stringersi
|
||||||
|
self.cur_stop = sl0
|
||||||
|
# indice fino a cui ho gia' incorporato i bar nel cricchetto (escluso)
|
||||||
|
self._last_seen = i
|
||||||
|
|
||||||
|
def _swing_level(self, j: int):
|
||||||
|
"""Livello swing causale usando SOLO low/high su [j-n .. j-1] e atr14[j-1]."""
|
||||||
|
lo = max(self.i, j - self.n) # non andare prima dell'entrata
|
||||||
|
hi = j # slice [lo:hi] => indici <= j-1
|
||||||
|
if hi <= lo:
|
||||||
|
return None
|
||||||
|
a = self.atr14[j - 1]
|
||||||
|
if not np.isfinite(a):
|
||||||
|
a = 0.0
|
||||||
|
buf = 0.25 * a
|
||||||
|
if self.d == 1:
|
||||||
|
return float(np.min(self.low[lo:hi])) - buf
|
||||||
|
else:
|
||||||
|
return float(np.max(self.high[lo:hi])) + buf
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
d = self.d
|
||||||
|
# aggiorna il cricchetto bar-per-bar fino a j-1 (causale). Per ogni bar k
|
||||||
|
# passato calcolo il livello swing attivo "a quel momento" e stringo lo stop.
|
||||||
|
while self._last_seen < j:
|
||||||
|
self._last_seen += 1
|
||||||
|
k = self._last_seen
|
||||||
|
lvl = self._swing_level(k)
|
||||||
|
if lvl is None:
|
||||||
|
continue
|
||||||
|
if d == 1:
|
||||||
|
if lvl > self.cur_stop: # cricchetto long: lo stop solo SALE
|
||||||
|
self.cur_stop = lvl
|
||||||
|
else:
|
||||||
|
if lvl < self.cur_stop: # cricchetto short: lo stop solo SCENDE
|
||||||
|
self.cur_stop = lvl
|
||||||
|
return self.tp0, self.cur_stop, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"n": 5},
|
||||||
|
{"n": 10},
|
||||||
|
{"n": 20},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(SwingStop, GRID)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""EXIT-19 — TP RIMOSSO, exit al CANALE DONCHIAN OPPOSTO (donchian_trail).
|
||||||
|
|
||||||
|
Idea: la fade attuale esce al TP (sulla media) + SL fisso (ATR dall'entrata) +
|
||||||
|
max_bars. Qui TOGLIAMO il TP e usiamo come unica uscita di prezzo uno STOP
|
||||||
|
DINAMICO ancorato al canale Donchian OPPOSTO alla direzione del trade:
|
||||||
|
|
||||||
|
long (d=1): stop(j) = min(low[j-n .. j-1]) (canale inferiore)
|
||||||
|
short (d=-1): stop(j) = max(high[j-n .. j-1]) (canale superiore)
|
||||||
|
|
||||||
|
Per un long che funziona (il prezzo risale verso la media) il canale inferiore
|
||||||
|
SALE bar dopo bar -> lo stop segue e blocca profitto; usciamo quando il prezzo
|
||||||
|
ritraccia sotto il minimo recente. Simmetrico short.
|
||||||
|
|
||||||
|
FLOOR a sl0 (mai PEGGIO dello SL originale): il livello attivo e' floorato alla
|
||||||
|
protezione iniziale -> non si allenta mai oltre sl0.
|
||||||
|
long : stop = max(channel_low, sl0)
|
||||||
|
short: stop = min(channel_high, sl0)
|
||||||
|
|
||||||
|
HORIZON = 4*mb (cap HARD_CAP=240): senza TP la posizione puo' restare a lungo,
|
||||||
|
quindi diamo molto piu' respiro al time-stop; l'uscita "naturale" e' il canale.
|
||||||
|
|
||||||
|
DIFFERENZA da EXIT-18 (swing_stop): qui (a) NON c'e' TP affatto (li' tp0 restava),
|
||||||
|
(b) niente cricchetto persistente: lo stop e' il canale RICALCOLATO ogni bar (puo'
|
||||||
|
anche allentarsi rispetto al bar prima, ma mai sotto sl0 grazie al floor),
|
||||||
|
(c) horizon esteso 4x. E' una uscita puramente trend-following/Donchian innestata
|
||||||
|
su un ingresso mean-reversion.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD (contratto): levels(j) usa SOLO dati con indice <= j-1:
|
||||||
|
- min/max sullo slice [j-n .. j-1] (lookback chiuso a j-1, lo[lo:hi] con hi=j);
|
||||||
|
- nessun dato del bar j entra nel livello attivo nel bar j;
|
||||||
|
- non si guarda mai high/low[j] per decidere lo stop attivo nel bar j.
|
||||||
|
|
||||||
|
PRIOR dal repo (ladder scartato): il TP della fade sta alla MEDIA, dove il
|
||||||
|
movimento e' esaurito; "il runner non corre". Quindi togliendo il TP rischiamo di
|
||||||
|
restare in posizione MENTRE il prezzo ristagna/rientra, pagando giveback e fee.
|
||||||
|
Il canale opposto dovrebbe limitare il giveback, ma la mean-reversion fa rientrare
|
||||||
|
il prezzo prima che il canale si stringa -> probabile uscita PEGGIORE del TP.
|
||||||
|
Lo misuriamo senza pregiudizio.
|
||||||
|
|
||||||
|
GRID: n in {10, 20, 30} (3 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class DonchianTrail(ExitPolicy):
|
||||||
|
name = "donchian_trail"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, n=20, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.n = int(n)
|
||||||
|
self.low = ctx["low"]
|
||||||
|
self.high = ctx["high"]
|
||||||
|
# TP rimosso, horizon esteso 4x (il cap a HARD_CAP lo applica l'engine)
|
||||||
|
self.horizon = 4 * mb
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
d = self.d
|
||||||
|
# canale opposto causale: slice [lo : j] => indici <= j-1
|
||||||
|
lo = max(self.i, j - self.n)
|
||||||
|
hi = j
|
||||||
|
if hi <= lo:
|
||||||
|
# primo bar dopo l'entrata: nessuna finestra -> usa solo sl0 (no TP)
|
||||||
|
return None, self.sl0, 1.0
|
||||||
|
if d == 1:
|
||||||
|
ch = float(np.min(self.low[lo:hi]))
|
||||||
|
stop = max(ch, self.sl0) # floor: mai sotto sl0
|
||||||
|
else:
|
||||||
|
ch = float(np.max(self.high[lo:hi]))
|
||||||
|
stop = min(ch, self.sl0) # floor: mai sopra sl0
|
||||||
|
return None, stop, 1.0 # TP = None (rimosso)
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"n": 10},
|
||||||
|
{"n": 20},
|
||||||
|
{"n": 30},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(DonchianTrail, GRID)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""EXIT-20 — ema_cross_exit: esci quando il close attraversa contro-posizione la EMA_m.
|
||||||
|
|
||||||
|
IDEA. Le fade comprano sotto / vendono sopra la media e oggi escono al TP fisso (la
|
||||||
|
media congelata all'entrata) o a max_bars/SL. Qui usiamo la EMA_m come "linea della
|
||||||
|
media corrente": finche' il prezzo non l'ha attraversata, la reversione e' ancora in
|
||||||
|
corso; quando il close la attraversa CONTRO la posizione, la mean-reversion ha
|
||||||
|
esaurito il suo percorso -> si chiude al close.
|
||||||
|
|
||||||
|
long (d=+1): la fade ha comprato sotto la media; esce quando close[j] < ema_m[j]
|
||||||
|
(il prezzo e' risalito ABOVE la media e ora la ri-perfora al ribasso,
|
||||||
|
ovvero il close torna sotto la media -> reversione finita/overshoot).
|
||||||
|
short (d=-1): esce quando close[j] > ema_m[j].
|
||||||
|
|
||||||
|
NB sul segno. La condizione "long: close < ema" e' la cross CONTRO la posizione: la
|
||||||
|
fade long scommette sul rientro VERSO/OLTRE la media; quando il close ricade sotto la
|
||||||
|
EMA dopo che la reversione e' avvenuta, il segnale di reversione e' consumato. E' un
|
||||||
|
exit "alla media mobile" che insegue la media invece del target congelato.
|
||||||
|
|
||||||
|
VARIANTI keep_tp:
|
||||||
|
- keep_tp=True : il TP fisso tp0 RESTA attivo (si esce al primo fra TP-al-livello,
|
||||||
|
SL, ema-cross, max_bars). horizon = max_bars (invariato).
|
||||||
|
- keep_tp=False: si RIMUOVE il TP fisso (tp=None) e si lascia che sia la ema-cross a
|
||||||
|
chiudere il vincente; horizon = 2*max_bars (cap HARD_CAP) per dare
|
||||||
|
spazio alla cross. SL fisso resta SEMPRE.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD. prepare() precalcola ema_m = EMA(close, span=m) UNA volta: causale,
|
||||||
|
ema[k] dipende solo da close <= k. La decisione di uscita e' in after_bar(j), che per
|
||||||
|
contratto puo' leggere il bar j (close[j], ema_m[j]) ed e' eseguibile al close del
|
||||||
|
poll. levels(j) usa solo sl0/tp0 (costanti) -> nessun dato > j-1. OK per costruzione.
|
||||||
|
|
||||||
|
GRID: m in {5, 10, 20} x keep_tp in {True, False} (6 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class EmaCrossExit(ExitPolicy):
|
||||||
|
name = "ema_cross_exit"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def prepare(cls, ctx, **params):
|
||||||
|
m = int(params.get("m", 10))
|
||||||
|
key = f"ema_{m}"
|
||||||
|
if key not in ctx:
|
||||||
|
c = ctx["close"]
|
||||||
|
ctx[key] = pd.Series(c).ewm(span=m, adjust=False).mean().values
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
m = int(params.get("m", 10))
|
||||||
|
self.ema = ctx[f"ema_{m}"]
|
||||||
|
self.keep_tp = bool(params.get("keep_tp", True))
|
||||||
|
if not self.keep_tp:
|
||||||
|
# senza TP la cross deve avere spazio: raddoppia l'orizzonte (cap HARD_CAP)
|
||||||
|
self.horizon = min(2 * mb, HARD_CAP)
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
# SL fisso sempre; TP fisso solo se keep_tp (altrimenti None -> lo gestisce la cross)
|
||||||
|
tp = self.tp0 if self.keep_tp else None
|
||||||
|
return tp, self.sl0, 1.0
|
||||||
|
|
||||||
|
def after_bar(self, j: int) -> bool:
|
||||||
|
# after_bar puo' usare il bar j (close[j], ema[j]) -> eseguibile al close del poll
|
||||||
|
e = self.ema[j]
|
||||||
|
if not np.isfinite(e):
|
||||||
|
return False
|
||||||
|
c = self.ctx["close"][j]
|
||||||
|
if self.d == 1:
|
||||||
|
return c < e # long: close ricade SOTTO la media -> reversione finita
|
||||||
|
return c > e # short: close risale SOPRA la media
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"m": 5, "keep_tp": True},
|
||||||
|
{"m": 10, "keep_tp": True},
|
||||||
|
{"m": 20, "keep_tp": True},
|
||||||
|
{"m": 5, "keep_tp": False},
|
||||||
|
{"m": 10, "keep_tp": False},
|
||||||
|
{"m": 20, "keep_tp": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(EmaCrossExit, GRID)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""EXIT-21 — vol_rescale (livelli che RESPIRANO con la volatilita').
|
||||||
|
|
||||||
|
I TP/SL fissi della base nascono come multipli dell'ATR all'entrata:
|
||||||
|
m_tp = |tp0 - entry| / atr14[i] (quanti ATR dista il TP)
|
||||||
|
m_sl = |sl0 - entry| / atr14[i] (quanti ATR dista lo SL)
|
||||||
|
Invece di congelare la DISTANZA in prezzo (tp0, sl0), congeliamo il MULTIPLO e
|
||||||
|
lasciamo che la distanza respiri con l'ATR corrente:
|
||||||
|
tp(j) = entry + d * m_tp * atr14[j-1]
|
||||||
|
sl(j) = entry - d * m_sl * atr14[j-1]
|
||||||
|
Cosi' se la vol si comprime mentre la fade torna alla media, il TP si AVVICINA
|
||||||
|
(prende profitto prima, coerente col fatto che il movimento residuo si esaurisce);
|
||||||
|
se la vol esplode, lo SL si ALLARGA (meno stop-out su spike), e viceversa.
|
||||||
|
|
||||||
|
Anti-look-ahead: m_tp/m_sl usano atr14[i] (noto al close del bar d'entrata, dove
|
||||||
|
il worker fissa i livelli); levels(j) usa SOLO atr14[j-1]. Se atr14[j-1] e' NaN
|
||||||
|
(warmup), si ricade sui livelli fissi base (tp0/sl0).
|
||||||
|
|
||||||
|
Varianti (mode):
|
||||||
|
- tp_only : TP respira, SL resta fisso a sl0
|
||||||
|
- sl_only : SL respira, TP resta fisso a tp0
|
||||||
|
- both : entrambi respirano
|
||||||
|
|
||||||
|
GRID: mode in {tp_only, sl_only, both} (3 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class VolRescale(ExitPolicy):
|
||||||
|
name = "vol_rescale"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.mode = params.get("mode", "both")
|
||||||
|
self.atr = ctx["atr14"]
|
||||||
|
# ATR all'entrata: noto al close[i] (il worker fissa i livelli qui).
|
||||||
|
a_i = self.atr[i]
|
||||||
|
if a_i is None or a_i != a_i or a_i <= 0:
|
||||||
|
# nessun ATR valido all'entrata -> impossibile derivare i multipli:
|
||||||
|
# la policy degenera nei livelli fissi base.
|
||||||
|
self.valid = False
|
||||||
|
self.m_tp = self.m_sl = 0.0
|
||||||
|
else:
|
||||||
|
self.valid = True
|
||||||
|
self.m_tp = abs(tp0 - entry) / a_i
|
||||||
|
self.m_sl = abs(sl0 - entry) / a_i
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
if not self.valid:
|
||||||
|
return self.tp0, self.sl0, 1.0
|
||||||
|
a = self.atr[j - 1] # solo dati <= j-1
|
||||||
|
if a is None or a != a: # NaN -> ricadi sui fissi base
|
||||||
|
return self.tp0, self.sl0, 1.0
|
||||||
|
d = self.d
|
||||||
|
tp = self.tp0
|
||||||
|
sl = self.sl0
|
||||||
|
if self.mode in ("tp_only", "both"):
|
||||||
|
tp = self.entry + d * self.m_tp * a
|
||||||
|
if self.mode in ("sl_only", "both"):
|
||||||
|
sl = self.entry - d * self.m_sl * a
|
||||||
|
return tp, sl, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [{"mode": "tp_only"}, {"mode": "sl_only"}, {"mode": "both"}]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(VolRescale, GRID)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""EXIT-22 — DIAGNOSTICA DEL VALORE DELLO SL sulle fade (MR01/MR02/MR07).
|
||||||
|
|
||||||
|
Le fade live escono con TP/SL FISSI decisi all'entrata (al close[i]) + max_bars.
|
||||||
|
Lo SL e' sl0 = entry -/+ k*ATR (per costruzione della strategia). Questa policy
|
||||||
|
NON cambia TP ne' horizon: tocca SOLO lo SL per misurare quanto lo SL ATTUALE
|
||||||
|
aggiunge/toglie a ret/DD/Sharpe per sleeve. Tre regimi:
|
||||||
|
|
||||||
|
mode = "none" -> sl = None (SL RIMOSSO: restano TP + max_bars). Il trade
|
||||||
|
puo' chiudere solo al TP o a scadenza horizon. Esposizione
|
||||||
|
al rischio di coda massima (nessun taglio della perdita).
|
||||||
|
mode = "wide2x" -> sl spostato a 2x la distanza entry->sl0 (stop piu' lasco):
|
||||||
|
sl = entry + 2*(sl0 - entry). Stoppa meno spesso.
|
||||||
|
mode = "tight05x" -> sl spostato a 0.5x la distanza (stop piu' stretto):
|
||||||
|
sl = entry + 0.5*(sl0 - entry). Stoppa piu' spesso, ma
|
||||||
|
perde di meno per stop.
|
||||||
|
|
||||||
|
La geometria col segno e' automatica: per long sl0<entry, per short sl0>entry;
|
||||||
|
scalare (sl0-entry) preserva il lato corretto in entrambi i casi.
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD: il livello sl(j) dipende SOLO da {entry, sl0, mode}, tutti noti
|
||||||
|
all'ENTRATA (close[i]). Costante per tutto il trade, identico al baseline tranne
|
||||||
|
il fattore di scala. Nessun dato del bar j o futuro entra. TP = tp0 invariato.
|
||||||
|
-> contratto rispettato per costruzione (livello statico, come il baseline).
|
||||||
|
|
||||||
|
INTERPRETAZIONE (diagnostica, non per forza deployabile):
|
||||||
|
- se "none" >= base su ret E DD -> lo SL toglie valore (taglia winner che
|
||||||
|
sarebbero rientrati: la fade e' mean-reversion, il movimento avverso e'
|
||||||
|
spesso transitorio).
|
||||||
|
- se "tight05x" peggiora molto -> lo SL e' gia' sul filo: stringerlo morde i
|
||||||
|
rientri. se "wide2x" ~ "none" -> lo SL attuale e' quasi inerte (raramente
|
||||||
|
toccato) e il rischio di coda e' contenuto da max_bars/TP.
|
||||||
|
- DD e' la metrica chiave: lo SL serve a contenere la coda. Se "none" ha DD
|
||||||
|
simile o piu' basso, lo SL non sta proteggendo nulla di utile.
|
||||||
|
|
||||||
|
GRID: mode in {none, wide2x, tight05x} (3 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exit_lab import ExitPolicy, evaluate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
_SCALE = {"none": None, "wide2x": 2.0, "tight05x": 0.5}
|
||||||
|
|
||||||
|
|
||||||
|
class NoSl(ExitPolicy):
|
||||||
|
name = "no_sl"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.mode = str(params.get("mode", "none"))
|
||||||
|
if self.mode not in _SCALE:
|
||||||
|
raise ValueError(f"mode sconosciuto: {self.mode}")
|
||||||
|
scale = _SCALE[self.mode]
|
||||||
|
if scale is None:
|
||||||
|
self.sl = None
|
||||||
|
else:
|
||||||
|
# sl = entry + scale*(sl0 - entry): preserva il lato per long/short.
|
||||||
|
self.sl = entry + scale * (sl0 - entry)
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
return self.tp0, self.sl, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [
|
||||||
|
{"mode": "none"},
|
||||||
|
{"mode": "wide2x"},
|
||||||
|
{"mode": "tight05x"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(NoSl, GRID)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""EXIT-23 — sl_tp_ride: LOCK AL TP E CAVALCA.
|
||||||
|
|
||||||
|
Idea (variante stretta di EXIT-09). Le fade escono al TP fisso (alla media). Qui
|
||||||
|
si tiene il TP fisso ATTIVO come exit normale finche' il prezzo non lo SUPERA in
|
||||||
|
chiusura. Quando close[j-1] supera tp0 a favore:
|
||||||
|
- TP RIMOSSO (None): non si esce piu' al livello fisso;
|
||||||
|
- SL spostato esattamente a tp0 (il profitto del TP e' LOCKATO: non si esce mai
|
||||||
|
peggio del TP originale);
|
||||||
|
- da tp0 in poi un trail chandelier k*ATR ancorato all'estremo favorevole visto
|
||||||
|
dal bar di superamento in poi, SOLO-STRINGENTE (cricchetto, mai si allenta).
|
||||||
|
|
||||||
|
Differenza dal 09: qui il floor e' ESATTAMENTE tp0 (cosi' come in 09, ma 09 ha
|
||||||
|
horizon 3*mb fisso e griglia su k 1/2/3); qui il lock scatta subito al primo
|
||||||
|
superamento in chiusura e la griglia esplora ANCHE l'horizon. State machine:
|
||||||
|
|
||||||
|
FASE A (armed=False): tp=tp0, sl=sl0 -> identica a base.
|
||||||
|
FASE B (armed): tp=None, sl = max(tp0, fav_high - k*atr) (long, cricchetto)
|
||||||
|
= min(tp0, fav_low + k*atr) (short, cricchetto)
|
||||||
|
|
||||||
|
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
|
||||||
|
- trigger di arma su close[j-1] (gia' chiuso al poll del bar j);
|
||||||
|
- estremo favorevole post-superamento su slice [arm_idx .. j-1] (incrementale);
|
||||||
|
- atr14[j-1] (indice causale).
|
||||||
|
Nessun fill parziale (tp_frac sempre 1.0). after_bar non usato.
|
||||||
|
|
||||||
|
GRID: k in {1.5, 2.5} x horizon in {2*mb, 4*mb} (4 celle).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class SlTpRide(ExitPolicy):
|
||||||
|
name = "sl_tp_ride"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, k=2.5, hmult=4, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.k = float(k)
|
||||||
|
self.horizon = min(int(hmult) * mb, HARD_CAP)
|
||||||
|
self.close = ctx["close"]
|
||||||
|
self.high = ctx["high"]
|
||||||
|
self.low = ctx["low"]
|
||||||
|
self.atr = ctx["atr14"]
|
||||||
|
self.armed = False
|
||||||
|
self.arm_idx = None
|
||||||
|
self.fav_high = None
|
||||||
|
self.fav_low = None
|
||||||
|
self._last_seen = i # ultimo close esaminato per il trigger di arma
|
||||||
|
self._fav_seen = None # ultimo bar incorporato nell'estremo favorevole
|
||||||
|
self.cur_stop = None # stop trailing monotono in FASE B (floor tp0)
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
c = self.close
|
||||||
|
d = self.d
|
||||||
|
# ---- FASE A: controlla l'arma sui close fino a j-1 (causali) -------------
|
||||||
|
if not self.armed:
|
||||||
|
while self._last_seen < j - 1:
|
||||||
|
self._last_seen += 1
|
||||||
|
cv = c[self._last_seen]
|
||||||
|
crossed = (cv > self.tp0) if d == 1 else (cv < self.tp0)
|
||||||
|
if crossed:
|
||||||
|
self.armed = True
|
||||||
|
self.arm_idx = self._last_seen
|
||||||
|
self.fav_high = self.high[self.arm_idx]
|
||||||
|
self.fav_low = self.low[self.arm_idx]
|
||||||
|
self._fav_seen = self.arm_idx
|
||||||
|
self.cur_stop = self.tp0 # SL spostato a tp0: profitto lockato
|
||||||
|
break
|
||||||
|
if not self.armed:
|
||||||
|
return self.tp0, self.sl0, 1.0 # FASE A: TP/SL fissi
|
||||||
|
|
||||||
|
# ---- FASE B: TP rimosso, trail chandelier con floor a tp0 ----------------
|
||||||
|
while self._fav_seen < j - 1:
|
||||||
|
self._fav_seen += 1
|
||||||
|
if self.high[self._fav_seen] > self.fav_high:
|
||||||
|
self.fav_high = self.high[self._fav_seen]
|
||||||
|
if self.low[self._fav_seen] < self.fav_low:
|
||||||
|
self.fav_low = self.low[self._fav_seen]
|
||||||
|
a = self.atr[j - 1]
|
||||||
|
if a == a: # non-NaN
|
||||||
|
if d == 1:
|
||||||
|
cand = max(self.fav_high - self.k * a, self.tp0)
|
||||||
|
if cand > self.cur_stop: # cricchetto: solo sale
|
||||||
|
self.cur_stop = cand
|
||||||
|
else:
|
||||||
|
cand = min(self.fav_low + self.k * a, self.tp0)
|
||||||
|
if cand < self.cur_stop: # cricchetto: solo scende
|
||||||
|
self.cur_stop = cand
|
||||||
|
return None, self.cur_stop, 1.0 # TP rimosso in FASE B
|
||||||
|
|
||||||
|
|
||||||
|
GRID = [{"k": k, "hmult": h} for k in (1.5, 2.5) for h in (2, 4)]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
evaluate(SlTpRide, GRID)
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""ADVERSARIAL VERIFY — EXIT-02 trail_atr_keep_tp, LENTE OVERFIT/ROBUSTEZZA.
|
||||||
|
|
||||||
|
Tesi del sopravvissuto: lo SL intrabar fisso distrugge valore nelle fade; il
|
||||||
|
Chandelier trail (k=1.5) + TP fisso migliora Sharpe/DD ovunque (6/6 train, 5/6 OOS).
|
||||||
|
|
||||||
|
Ipotesi nulla del verificatore: e' un artefatto. Tre attacchi:
|
||||||
|
(1) JITTER parametri: k vicini non provati (1.25/1.75) + ponte SL fisso a 3x/4x
|
||||||
|
ATR (no_sl). Il plateau tiene o e' una cresta?
|
||||||
|
(2) STABILITA' TEMPORALE: train 2018-20 vs 21-22, OOS 23-11/25-01 vs 25-01/26-05.
|
||||||
|
Il miglioramento c'e' in OGNI finestra o concentrato in un regime?
|
||||||
|
(3) DIPENDENZA HURST (decisivo): i segnali in cache hanno hurst_max=0.55 (toglie
|
||||||
|
il regime trending). Rigenero i segnali SENZA hurst (hurst_max=None) IN MEMORIA
|
||||||
|
(non tocco la cache) e ripeto base-vs-policy: la tesi "SL dannoso" regge anche
|
||||||
|
dove gli stop servivano (regime persistente)?
|
||||||
|
|
||||||
|
cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
|
||||||
|
scripts/analysis/exit_policies/verify_02_overfit.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve()
|
||||||
|
sys.path.insert(0, str(HERE.parents[1])) # scripts/analysis
|
||||||
|
sys.path.insert(0, str(HERE.parents[3])) # project root
|
||||||
|
|
||||||
|
import exit_lab # noqa: E402
|
||||||
|
from exit_lab import (ExitPolicy, simulate, load_sleeves, OOS_START_MS, # noqa: E402
|
||||||
|
CODES, ASSETS, LIVE_PARAMS, _atr14)
|
||||||
|
from importlib import import_module # noqa: E402
|
||||||
|
|
||||||
|
mod = import_module("exit_policies.02_trail_atr_keep_tp")
|
||||||
|
TrailATRKeepTP = mod.TrailATRKeepTP
|
||||||
|
|
||||||
|
from src.data.downloader import load_data # noqa: E402
|
||||||
|
from src.live.strategy_loader import load_strategy # noqa: E402
|
||||||
|
|
||||||
|
SLEEVE_KEYS = [(c, a) for c in CODES for a in ASSETS]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- fixed-SL bridge
|
||||||
|
class FixedSLmultATR(ExitPolicy):
|
||||||
|
"""Ponte fra base (SL=sl0) e no_sl: SL fisso a m*ATR(entry) dall'entrata,
|
||||||
|
TP fisso. Se il trail (k piccolo) batte uno SL fisso GIA' largo (3x/4x),
|
||||||
|
allora il guadagno e' nel trailing, non solo nell'allontanare lo SL."""
|
||||||
|
name = "fixed_sl_mult_atr"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
m = float(params.get("m", 3.0))
|
||||||
|
a = ctx["atr14"][i]
|
||||||
|
if a is None or a != a:
|
||||||
|
self.sl = sl0
|
||||||
|
else:
|
||||||
|
self.sl = entry - m * a if d == 1 else entry + m * a
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
return self.tp0, self.sl, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- no-SL bridge
|
||||||
|
class NoSL(ExitPolicy):
|
||||||
|
"""Solo TP fisso + horizon, NESSUNO stop. Isola: il valore e' nel TOGLIERE
|
||||||
|
lo stop (qualsiasi) o nel TRAIL dinamico? Se NoSL ~ trail, il driver e'
|
||||||
|
'niente SL'; se il trail batte NoSL, il trail aggiunge."""
|
||||||
|
name = "no_sl"
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
return self.tp0, None, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(r):
|
||||||
|
if not r:
|
||||||
|
return " (no trades)"
|
||||||
|
return (f"ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>6.2f} "
|
||||||
|
f"n{r['trades']:>4} bars{r['avg_bars']:>5.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(rows):
|
||||||
|
"""rows: list of (sleeve_key, base_dict, pol_dict). Ritorna conteggi miglioramento."""
|
||||||
|
sh_up = dd_dn = ret_up = n = 0
|
||||||
|
for _, b, p in rows:
|
||||||
|
if not b or not p:
|
||||||
|
continue
|
||||||
|
n += 1
|
||||||
|
sh_up += p["sharpe_t"] > b["sharpe_t"]
|
||||||
|
dd_dn += p["dd_pct"] < b["dd_pct"]
|
||||||
|
ret_up += p["ret_pct"] > b["ret_pct"]
|
||||||
|
return sh_up, dd_dn, ret_up, n
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================= TEST 1: JITTER
|
||||||
|
def test_jitter(data):
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("TEST 1 — JITTER: k vicini (1.25/1.75) + ponte SL fisso 3x/4x ATR + NoSL")
|
||||||
|
print("=" * 78)
|
||||||
|
print("\n[1a] Trail k in {1.25, 1.5, 1.75} — plateau o cresta? (OOS)")
|
||||||
|
for k in (1.25, 1.5, 1.75):
|
||||||
|
rows = []
|
||||||
|
for key in SLEEVE_KEYS:
|
||||||
|
sl = data[key]
|
||||||
|
b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS)
|
||||||
|
p = simulate(TrailATRKeepTP, sl, {"k": k}, start_ms=OOS_START_MS)
|
||||||
|
rows.append((key, b, p))
|
||||||
|
sh, dd, ret, n = _summary(rows)
|
||||||
|
print(f" k={k:<5} OOS: Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
|
||||||
|
|
||||||
|
print("\n[1b] SL fisso a m*ATR dall'entrata (m=3,4) — uno stop largo basta? (OOS)")
|
||||||
|
for m in (3.0, 4.0):
|
||||||
|
rows = []
|
||||||
|
for key in SLEEVE_KEYS:
|
||||||
|
sl = data[key]
|
||||||
|
b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS)
|
||||||
|
p = simulate(FixedSLmultATR, sl, {"m": m}, start_ms=OOS_START_MS)
|
||||||
|
rows.append((key, b, p))
|
||||||
|
sh, dd, ret, n = _summary(rows)
|
||||||
|
print(f" m={m:<5} OOS: Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
|
||||||
|
|
||||||
|
print("\n[1c] NoSL (solo TP+horizon) — il driver e' 'togliere lo SL'? (OOS)")
|
||||||
|
rows = []
|
||||||
|
for key in SLEEVE_KEYS:
|
||||||
|
sl = data[key]
|
||||||
|
b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS)
|
||||||
|
p = simulate(NoSL, sl, start_ms=OOS_START_MS)
|
||||||
|
rows.append((key, b, p))
|
||||||
|
sh, dd, ret, n = _summary(rows)
|
||||||
|
print(f" NoSL OOS: Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
|
||||||
|
|
||||||
|
print("\n[1d] dettaglio per sleeve: base vs k=1.5 vs NoSL vs SLx3 (OOS)")
|
||||||
|
for key in SLEEVE_KEYS:
|
||||||
|
sl = data[key]
|
||||||
|
b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS)
|
||||||
|
t = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=OOS_START_MS)
|
||||||
|
ns = simulate(NoSL, sl, start_ms=OOS_START_MS)
|
||||||
|
f3 = simulate(FixedSLmultATR, sl, {"m": 3.0}, start_ms=OOS_START_MS)
|
||||||
|
tag = f"{key[0].split('_')[0]} {key[1]}"
|
||||||
|
print(f" {tag:<10} base sh{b.get('sharpe_t',0):>6.2f} | trail1.5 sh{t.get('sharpe_t',0):>6.2f} "
|
||||||
|
f"| NoSL sh{ns.get('sharpe_t',0):>6.2f} | SLx3 sh{f3.get('sharpe_t',0):>6.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================= TEST 2: TEMPORAL
|
||||||
|
def test_temporal(data):
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("TEST 2 — STABILITA' TEMPORALE (Sharpe base -> trail k=1.5)")
|
||||||
|
print("=" * 78)
|
||||||
|
W = [
|
||||||
|
("train 2018-20", None, int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6)),
|
||||||
|
("train 2021-22", int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6), OOS_START_MS),
|
||||||
|
("OOS 23-11/25-01", OOS_START_MS, int(pd.Timestamp("2025-01-01", tz="UTC").value // 1e6)),
|
||||||
|
("OOS 25-01/26-05", int(pd.Timestamp("2025-01-01", tz="UTC").value // 1e6), None),
|
||||||
|
]
|
||||||
|
for label, s, e in W:
|
||||||
|
rows = []
|
||||||
|
for key in SLEEVE_KEYS:
|
||||||
|
sl = data[key]
|
||||||
|
b = simulate(ExitPolicy, sl, start_ms=s, end_ms=e)
|
||||||
|
p = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=s, end_ms=e)
|
||||||
|
rows.append((key, b, p))
|
||||||
|
sh, dd, ret, n = _summary(rows)
|
||||||
|
# mediana del delta-Sharpe
|
||||||
|
deltas = [p["sharpe_t"] - b["sharpe_t"] for _, b, p in rows if b and p]
|
||||||
|
med = float(np.median(deltas)) if deltas else 0.0
|
||||||
|
print(f" {label:<20} Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n} "
|
||||||
|
f"median dSharpe {med:+.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================= TEST 3: HURST
|
||||||
|
def _build_sleeves_no_hurst():
|
||||||
|
"""Rigenera i segnali SENZA il loss-guard Hurst (hurst_max=None), IN MEMORIA.
|
||||||
|
Replica esattamente load_sleeves() ma con LIVE_PARAMS modificati."""
|
||||||
|
params = dict(LIVE_PARAMS)
|
||||||
|
params["hurst_max"] = None
|
||||||
|
out = {}
|
||||||
|
for code in CODES:
|
||||||
|
strat = load_strategy(code)
|
||||||
|
for asset in ASSETS:
|
||||||
|
df = load_data(asset, "1h")
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
sigs = strat.generate_signals(df, ts, **params)
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
out[(code, asset)] = {
|
||||||
|
"signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]),
|
||||||
|
float(s.metadata["sl"]), int(s.metadata["max_bars"]))
|
||||||
|
for s in sigs],
|
||||||
|
"open": df["open"].values.astype(float),
|
||||||
|
"high": h, "low": l, "close": c,
|
||||||
|
"ts_ms": df["timestamp"].values.astype(np.int64),
|
||||||
|
"atr14": _atr14(h, l, c),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_hurst(data):
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("TEST 3 — DIPENDENZA DAL FILTRO HURST (decisivo)")
|
||||||
|
print("=" * 78)
|
||||||
|
print("Rigenero segnali con hurst_max=None (loss-guard OFF -> include il regime")
|
||||||
|
print("trending/persistente dove gli stop dovrebbero servire). Confronto base->trail.")
|
||||||
|
nh = _build_sleeves_no_hurst()
|
||||||
|
|
||||||
|
# quanti segnali in piu' (il guard ne toglieva)
|
||||||
|
print("\n segnali: con-guard -> senza-guard")
|
||||||
|
for key in SLEEVE_KEYS:
|
||||||
|
ng = len(data[key]["signals"])
|
||||||
|
nn = len(nh[key]["signals"])
|
||||||
|
tag = f"{key[0].split('_')[0]} {key[1]}"
|
||||||
|
print(f" {tag:<10} {ng:>4} -> {nn:>4} (+{nn-ng})")
|
||||||
|
|
||||||
|
for scope, s, e in [("TRAIN", None, OOS_START_MS), ("OOS", OOS_START_MS, None)]:
|
||||||
|
print(f"\n [{scope}] base vs trail k=1.5 — SENZA hurst guard")
|
||||||
|
rows = []
|
||||||
|
for key in SLEEVE_KEYS:
|
||||||
|
sl = nh[key]
|
||||||
|
b = simulate(ExitPolicy, sl, start_ms=s, end_ms=e)
|
||||||
|
p = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=s, end_ms=e)
|
||||||
|
rows.append((key, b, p))
|
||||||
|
tag = f"{key[0].split('_')[0]} {key[1]}"
|
||||||
|
print(f" {tag:<10} base {_fmt(b)}")
|
||||||
|
print(f" {'':<10} trail{_fmt(p)}")
|
||||||
|
sh, dd, ret, n = _summary(rows)
|
||||||
|
print(f" --> Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
|
||||||
|
|
||||||
|
# contro-prova: con-guard sugli STESSI scope, per isolare l'effetto guard
|
||||||
|
print("\n [CONTROLLO] stesso confronto CON hurst guard (cache):")
|
||||||
|
for scope, s, e in [("TRAIN", None, OOS_START_MS), ("OOS", OOS_START_MS, None)]:
|
||||||
|
rows = []
|
||||||
|
for key in SLEEVE_KEYS:
|
||||||
|
sl = data[key]
|
||||||
|
b = simulate(ExitPolicy, sl, start_ms=s, end_ms=e)
|
||||||
|
p = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=s, end_ms=e)
|
||||||
|
rows.append((key, b, p))
|
||||||
|
sh, dd, ret, n = _summary(rows)
|
||||||
|
print(f" [{scope}] con-guard --> Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
data = load_sleeves()
|
||||||
|
test_jitter(data)
|
||||||
|
test_temporal(data)
|
||||||
|
test_hurst(data)
|
||||||
|
print("\nDONE")
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""VERIFICA AVVERSARIALE — lente LOOK-AHEAD/ESEGUIBILITA' su EXIT-02 (k=1.5).
|
||||||
|
|
||||||
|
Ipotesi nulla avversaria: l'edge del chandelier trail e' un artefatto del
|
||||||
|
TIMING PERFETTO (livelli fissati a j-1 e tocco a j senza alcun attrito) e/o di
|
||||||
|
uno SL fillato a un prezzo non eseguibile live. Provo a confutarla.
|
||||||
|
|
||||||
|
Esperimenti:
|
||||||
|
(A) AUDIT del contratto: ricalcolo i livelli di EXIT-02 fuori dall'engine e
|
||||||
|
verifico che run_hi/run_lo a j NON incorporino mai high[j]/low[j], e che
|
||||||
|
atr usato sia atr[j-1]. (statico, ma lo confermo numericamente forzando
|
||||||
|
un confronto con una variante che USA j -> deve cambiare i numeri.)
|
||||||
|
|
||||||
|
(B) LAG +1: variante che ritarda di UN bar in piu' TUTTI gli input causali
|
||||||
|
(atr[j-2], estremi fino a j-2). Se l'edge collassa -> appeso al timing.
|
||||||
|
|
||||||
|
(C) ESEGUIBILITA' SL: lo SL fillato a `sl` (prezzo del livello) e' ottimistico?
|
||||||
|
Confronto col fill conservativo allo SL ma con slippage, e con fill al
|
||||||
|
WORSE fra sl e open[j] (gap-through). Stima costo.
|
||||||
|
|
||||||
|
(D) ESEGUIBILITA' HORIZON/CLOSE: gli exit a max_bars escono a close[j]. Live
|
||||||
|
il poll arriva ~al close ma esegue al bar dopo -> rifaccio l'engine con
|
||||||
|
gli exit a horizon/after_bar a open[j+1] invece di close[j]. Costo?
|
||||||
|
(EXIT-02 non usa after_bar, ma usa gli exit a horizon -> rilevante con
|
||||||
|
avg_bars 2.5: il turnover alto AMPLIFICA il costo per-exit.)
|
||||||
|
|
||||||
|
Tutto a leva 3, fee 0.10% RT, OOS_START 2023-11-01.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
import exit_lab as EL # noqa: E402
|
||||||
|
from exit_lab import ExitPolicy, load_sleeves, simulate, OOS_START_MS, LEV, POS, FEE_RT # noqa: E402
|
||||||
|
from importlib import import_module # noqa: E402
|
||||||
|
|
||||||
|
mod = import_module("02_trail_atr_keep_tp")
|
||||||
|
TrailATRKeepTP = mod.TrailATRKeepTP
|
||||||
|
|
||||||
|
KPICK = 1.5
|
||||||
|
DATA = load_sleeves()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (A) AUDIT statico
|
||||||
|
class TrailLeak(TrailATRKeepTP):
|
||||||
|
"""VARIANTE SPORCA: usa run_hi/run_lo fino a j (incl. bar j) e atr[j].
|
||||||
|
Serve SOLO a mostrare quanto l'edge gonfierebbe col look-ahead -> se i numeri
|
||||||
|
della policy pulita fossero gia' a quel livello, sarebbe sospetta."""
|
||||||
|
name = "trail_LEAK"
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
self._update_running(j) # SPORCO: incorpora bar j
|
||||||
|
a = self.atr[j] # SPORCO: atr del bar j
|
||||||
|
if a is None or a != a:
|
||||||
|
return self.tp0, self.sl0, 1.0
|
||||||
|
if self.d == 1:
|
||||||
|
chand = self.run_hi - self.k * a
|
||||||
|
sl = max(self.sl0, chand)
|
||||||
|
else:
|
||||||
|
chand = self.run_lo + self.k * a
|
||||||
|
sl = min(self.sl0, chand)
|
||||||
|
return self.tp0, sl, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (B) LAG +1
|
||||||
|
class TrailLag1(TrailATRKeepTP):
|
||||||
|
"""Ritarda di UN bar in piu': estremi fino a j-2, atr[j-2]."""
|
||||||
|
name = "trail_LAG1"
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
self._update_running(j - 2) # estremi solo <= j-2
|
||||||
|
idx = j - 2
|
||||||
|
a = self.atr[idx] if idx >= 0 else None
|
||||||
|
if a is None or a != a:
|
||||||
|
return self.tp0, self.sl0, 1.0
|
||||||
|
if self.d == 1:
|
||||||
|
chand = self.run_hi - self.k * a
|
||||||
|
sl = max(self.sl0, chand)
|
||||||
|
else:
|
||||||
|
chand = self.run_lo + self.k * a
|
||||||
|
sl = min(self.sl0, chand)
|
||||||
|
return self.tp0, sl, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------- (C)/(D) engine alternativo con attriti exec
|
||||||
|
def simulate_exec(policy_cls, sleeve, params, *, start_ms=None, end_ms=None,
|
||||||
|
sl_slip_bps=0.0, sl_gap=False, horizon_open=False):
|
||||||
|
"""Clone di EL.simulate con attriti di esecuzione opzionali:
|
||||||
|
sl_slip_bps : slippage avverso (bps di prezzo) sul fill allo SL.
|
||||||
|
sl_gap : fill allo SL = worse(sl, open[j]) (gap-through realistico).
|
||||||
|
horizon_open: exit a horizon/after_bar a open[j+1] invece di close[j].
|
||||||
|
"""
|
||||||
|
params = params or {}
|
||||||
|
o = sleeve["open"]; h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
|
||||||
|
n = len(c)
|
||||||
|
ctx = dict(sleeve)
|
||||||
|
policy_cls.prepare(ctx, **params)
|
||||||
|
fee = FEE_RT * LEV
|
||||||
|
capital = peak = 1000.0
|
||||||
|
max_dd = 0.0
|
||||||
|
last_exit = -1
|
||||||
|
trades = wins = 0
|
||||||
|
bars_tot = 0
|
||||||
|
rets = []
|
||||||
|
|
||||||
|
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
|
||||||
|
if start_ms is not None and ts[i] < start_ms:
|
||||||
|
continue
|
||||||
|
if end_ms is not None and ts[i] >= end_ms:
|
||||||
|
continue
|
||||||
|
if i <= last_exit or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
horizon = min(int(pol.horizon), EL.HARD_CAP)
|
||||||
|
fills = []
|
||||||
|
remaining = 1.0
|
||||||
|
j = i
|
||||||
|
for step in range(1, horizon + 1):
|
||||||
|
j = i + step
|
||||||
|
if j >= n:
|
||||||
|
j = n - 1
|
||||||
|
px = o[min(j + 1, n - 1)] if horizon_open else c[j]
|
||||||
|
fills.append((remaining, px)); remaining = 0.0
|
||||||
|
break
|
||||||
|
tp, sl, tpfrac = pol.levels(j)
|
||||||
|
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:
|
||||||
|
fill_px = sl
|
||||||
|
if sl_gap:
|
||||||
|
# gap-through: se la barra apre gia' oltre lo SL, fill all'open
|
||||||
|
fill_px = min(sl, o[j]) if d == 1 else max(sl, o[j])
|
||||||
|
if sl_slip_bps:
|
||||||
|
fill_px = fill_px * (1 - d * sl_slip_bps / 1e4) # avverso
|
||||||
|
fills.append((remaining, fill_px)); remaining = 0.0
|
||||||
|
break
|
||||||
|
if hit_tp:
|
||||||
|
f = min(max(tpfrac, 0.0), 1.0) * remaining
|
||||||
|
if f > 0:
|
||||||
|
fills.append((f, tp)); remaining -= f
|
||||||
|
if remaining <= 1e-9:
|
||||||
|
break
|
||||||
|
pol.on_partial(j, tp, remaining)
|
||||||
|
if pol.after_bar(j):
|
||||||
|
px = o[min(j + 1, n - 1)] if horizon_open else c[j]
|
||||||
|
fills.append((remaining, px)); remaining = 0.0
|
||||||
|
break
|
||||||
|
if step == horizon:
|
||||||
|
px = o[min(j + 1, n - 1)] if horizon_open else c[j]
|
||||||
|
fills.append((remaining, px)); remaining = 0.0
|
||||||
|
if remaining > 1e-9:
|
||||||
|
fills.append((remaining, c[j]))
|
||||||
|
|
||||||
|
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
|
||||||
|
capital = max(capital + capital * POS * ret, 10.0)
|
||||||
|
peak = max(peak, capital)
|
||||||
|
max_dd = max(max_dd, (peak - capital) / peak)
|
||||||
|
last_exit = j
|
||||||
|
trades += 1
|
||||||
|
wins += ret > 0
|
||||||
|
bars_tot += j - i
|
||||||
|
rets.append(ret)
|
||||||
|
|
||||||
|
if trades == 0:
|
||||||
|
return {}
|
||||||
|
r = np.array(rets)
|
||||||
|
return {
|
||||||
|
"ret_pct": (capital / 1000.0 - 1) * 100,
|
||||||
|
"dd_pct": max_dd * 100,
|
||||||
|
"trades": trades,
|
||||||
|
"win_pct": wins / trades * 100,
|
||||||
|
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
|
||||||
|
"avg_bars": bars_tot / trades,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def oos(cls, sleeve, sim=simulate, **kw):
|
||||||
|
return sim(cls, sleeve, {"k": KPICK}, start_ms=OOS_START_MS, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def line(tag, r):
|
||||||
|
return (f"{tag:<26} ret{r.get('ret_pct',0):>7.0f}% dd{r.get('dd_pct',0):>5.1f} "
|
||||||
|
f"sh{r.get('sharpe_t',0):>5.2f} n{r.get('trades',0):>4} "
|
||||||
|
f"win{r.get('win_pct',0):>4.0f}% bars{r.get('avg_bars',0):>4.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
print("=" * 90)
|
||||||
|
print("OOS (2023-11-01+) k=1.5 — clean / LEAK(look-ahead) / LAG1(+1 bar) per sleeve")
|
||||||
|
print("=" * 90)
|
||||||
|
agg = {"clean": [], "leak": [], "lag1": []}
|
||||||
|
for (code, asset), sleeve in DATA.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
rc = oos(TrailATRKeepTP, sleeve)
|
||||||
|
rk = oos(TrailLeak, sleeve)
|
||||||
|
rl = oos(TrailLag1, sleeve)
|
||||||
|
agg["clean"].append(rc.get("ret_pct", 0))
|
||||||
|
agg["leak"].append(rk.get("ret_pct", 0))
|
||||||
|
agg["lag1"].append(rl.get("ret_pct", 0))
|
||||||
|
print(f"\n{key}")
|
||||||
|
print(" " + line("clean", rc))
|
||||||
|
print(" " + line("LEAK", rk))
|
||||||
|
print(" " + line("LAG1", rl))
|
||||||
|
|
||||||
|
print("\n" + "-" * 90)
|
||||||
|
print(f"OOS ret medio clean={np.mean(agg['clean']):.0f}% "
|
||||||
|
f"LEAK={np.mean(agg['leak']):.0f}% LAG1={np.mean(agg['lag1']):.0f}%")
|
||||||
|
print(f"LAG1/clean ratio per sleeve: "
|
||||||
|
f"{[f'{a/ b:.2f}' if b else 'na' for a, b in zip(agg['lag1'], agg['clean'])]}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 90)
|
||||||
|
print("ESECUZIONE — attriti su OOS k=1.5 (medie sui 6 sleeve)")
|
||||||
|
print("=" * 90)
|
||||||
|
scenarios = {
|
||||||
|
"clean (engine std)": dict(),
|
||||||
|
"SL slip 5bps": dict(sl_slip_bps=5.0),
|
||||||
|
"SL slip 10bps": dict(sl_slip_bps=10.0),
|
||||||
|
"SL gap-through(open)": dict(sl_gap=True),
|
||||||
|
"SL gap + slip 5bps": dict(sl_gap=True, sl_slip_bps=5.0),
|
||||||
|
"horizon exit @open[j+1]": dict(horizon_open=True),
|
||||||
|
"horizon@open + SLslip5": dict(horizon_open=True, sl_slip_bps=5.0),
|
||||||
|
}
|
||||||
|
for tag, kw in scenarios.items():
|
||||||
|
rets, dds, shs = [], [], []
|
||||||
|
for (code, asset), sleeve in DATA.items():
|
||||||
|
r = oos(TrailATRKeepTP, sleeve, sim=simulate_exec, **kw)
|
||||||
|
rets.append(r.get("ret_pct", 0)); dds.append(r.get("dd_pct", 0)); shs.append(r.get("sharpe_t", 0))
|
||||||
|
print(f"{tag:<28} OOS ret medio {np.mean(rets):>6.0f}% dd {np.mean(dds):>4.1f} "
|
||||||
|
f"sh {np.mean(shs):>4.2f} (min ret {min(rets):>5.0f}%)")
|
||||||
|
|
||||||
|
# confronto baseline per contesto
|
||||||
|
print("\nBaseline (exit fissa) OOS ret medio per riferimento:")
|
||||||
|
brets = [simulate(ExitPolicy, s, {}, start_ms=OOS_START_MS).get("ret_pct", 0)
|
||||||
|
for s in DATA.values()]
|
||||||
|
print(f" base OOS ret medio {np.mean(brets):.0f}%")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- (E) frequenza gap-through SL
|
||||||
|
print("\n" + "=" * 90)
|
||||||
|
print("FREQUENZA gap-through allo SL (OOS k=1.5): quanti fill SL aprono OLTRE il livello?")
|
||||||
|
print("=" * 90)
|
||||||
|
|
||||||
|
|
||||||
|
def gap_stats(sleeve, start_ms=OOS_START_MS):
|
||||||
|
params = {"k": KPICK}
|
||||||
|
o = sleeve["open"]; h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
|
||||||
|
n = len(c)
|
||||||
|
ctx = dict(sleeve); TrailATRKeepTP.prepare(ctx, **params)
|
||||||
|
last_exit = -1
|
||||||
|
sl_hits = gaps = 0
|
||||||
|
gap_bps = []
|
||||||
|
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
|
||||||
|
if ts[i] < start_ms or i <= last_exit or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
pol = TrailATRKeepTP(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
horizon = min(int(pol.horizon), EL.HARD_CAP)
|
||||||
|
j = i
|
||||||
|
for step in range(1, horizon + 1):
|
||||||
|
j = i + step
|
||||||
|
if j >= n:
|
||||||
|
break
|
||||||
|
tp, sl, _ = pol.levels(j)
|
||||||
|
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:
|
||||||
|
sl_hits += 1
|
||||||
|
# gap-through: l'open e' gia' oltre lo SL (peggio del livello)?
|
||||||
|
worse = (d == 1 and o[j] < sl) or (d == -1 and o[j] > sl)
|
||||||
|
if worse:
|
||||||
|
gaps += 1
|
||||||
|
gap_bps.append(abs(o[j] - sl) / sl * 1e4)
|
||||||
|
break
|
||||||
|
if hit_tp:
|
||||||
|
break
|
||||||
|
last_exit = j
|
||||||
|
return sl_hits, gaps, (np.mean(gap_bps) if gap_bps else 0.0), (np.median(gap_bps) if gap_bps else 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
tot_h = tot_g = 0
|
||||||
|
for (code, asset), sleeve in DATA.items():
|
||||||
|
sh_, g_, m_, md_ = gap_stats(sleeve)
|
||||||
|
tot_h += sh_; tot_g += g_
|
||||||
|
print(f" {code.split('_')[0]} {asset}: SL hits {sh_:>3} gap-through {g_:>3} "
|
||||||
|
f"({100*g_/max(sh_,1):>4.0f}%) gap medio {m_:>5.1f}bps mediano {md_:>5.1f}bps")
|
||||||
|
print(f"\n TOTALE: {tot_g}/{tot_h} SL hit sono gap-through = {100*tot_g/max(tot_h,1):.0f}%")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- (F) gap pessimista vs baseline per sleeve
|
||||||
|
print("\n" + "=" * 90)
|
||||||
|
print("VERDETTO ESEGUIBILITA': EXIT-02 con SL gap-through vs BASELINE (exit fissa), OOS, per sleeve")
|
||||||
|
print("=" * 90)
|
||||||
|
print(f"{'sleeve':<10}{'base ret':>10}{'base dd':>9}{'base sh':>9} "
|
||||||
|
f"{'trail gap ret':>14}{'gap dd':>8}{'gap sh':>8} verdetto")
|
||||||
|
n_better = 0
|
||||||
|
for (code, asset), sleeve in DATA.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
b = simulate(ExitPolicy, sleeve, {}, start_ms=OOS_START_MS)
|
||||||
|
g = oos(TrailATRKeepTP, sleeve, sim=simulate_exec, sl_gap=True, sl_slip_bps=5.0)
|
||||||
|
better = g.get("sharpe_t", 0) >= b.get("sharpe_t", 0)
|
||||||
|
dd_better = g.get("dd_pct", 99) <= b.get("dd_pct", 99)
|
||||||
|
n_better += better
|
||||||
|
verdict = ("Sharpe+DD>" if better and dd_better else
|
||||||
|
"DD> ret<" if dd_better and not better else "PEGGIO")
|
||||||
|
print(f"{key:<10}{b.get('ret_pct',0):>9.0f}%{b.get('dd_pct',0):>8.1f}{b.get('sharpe_t',0):>9.2f} "
|
||||||
|
f"{g.get('ret_pct',0):>13.0f}%{g.get('dd_pct',0):>7.1f}{g.get('sharpe_t',0):>8.2f} {verdict}")
|
||||||
|
print(f"\n sleeve con Sharpe trail-gap >= baseline: {n_better}/6")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------- (G) CONFRONTO EQUO: baseline E trail entrambi con gap-through SL
|
||||||
|
print("\n" + "=" * 90)
|
||||||
|
print("CONFRONTO EQUO (gap-through SL su ENTRAMBI) — la tesi 'SL fisso distrugge valore' regge?")
|
||||||
|
print("=" * 90)
|
||||||
|
print(f"{'sleeve':<10}{'BASE-gap ret':>13}{'dd':>6}{'sh':>6} {'TRAIL-gap ret':>14}{'dd':>6}{'sh':>6} verdetto")
|
||||||
|
trail_wins = 0
|
||||||
|
agg_b = []; agg_t = []
|
||||||
|
for (code, asset), sleeve in DATA.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
b = oos(ExitPolicy, sleeve, sim=simulate_exec, sl_gap=True, sl_slip_bps=5.0)
|
||||||
|
t = oos(TrailATRKeepTP, sleeve, sim=simulate_exec, sl_gap=True, sl_slip_bps=5.0)
|
||||||
|
agg_b.append(b.get("sharpe_t", 0)); agg_t.append(t.get("sharpe_t", 0))
|
||||||
|
win = t.get("sharpe_t", 0) >= b.get("sharpe_t", 0)
|
||||||
|
trail_wins += win
|
||||||
|
print(f"{key:<10}{b.get('ret_pct',0):>12.0f}%{b.get('dd_pct',0):>6.1f}{b.get('sharpe_t',0):>6.2f} "
|
||||||
|
f"{t.get('ret_pct',0):>13.0f}%{t.get('dd_pct',0):>6.1f}{t.get('sharpe_t',0):>6.2f} "
|
||||||
|
f"{'TRAIL>=' if win else 'BASE>'}")
|
||||||
|
print(f"\n con ENTRAMBI gap-through: trail Sharpe >= baseline su {trail_wins}/6 sleeve")
|
||||||
|
print(f" Sharpe medio BASE-gap={np.mean(agg_b):.2f} TRAIL-gap={np.mean(agg_t):.2f}")
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""STRESS verifier for EXIT-02 trail_atr_keep_tp (train-pick k=1.5).
|
||||||
|
|
||||||
|
Adversarial lens = STRESS. Hypothesis to try to REFUTE the survivor:
|
||||||
|
(1) Fee 2x (FEE_RT=0.002): the policy raises turnover (avg_bars 9->2.5) =>
|
||||||
|
it should be disproportionately hurt by doubling fees.
|
||||||
|
(2) Bear/crash subperiod 2021-01..2022-12 (2021-05-19 crash, LUNA, FTX):
|
||||||
|
does the DD/tail of the policy survive? compare worst trade + 5 worst.
|
||||||
|
(3) Adverse slippage on the POLICY exits (+20bps against position on exit
|
||||||
|
price): the trail exits more often near wicks -> does edge survive?
|
||||||
|
(4) Turnover / capital-churn: the policy turns capital ~3.6x faster. Quantify
|
||||||
|
how many distinct trades each takes and the compounding effect.
|
||||||
|
|
||||||
|
We monkeypatch exit_lab.FEE_RT and re-implement a thin per-trade collector that
|
||||||
|
mirrors exit_lab.simulate EXACTLY (same SL-before-TP, same fills, same compounding)
|
||||||
|
so we can extract per-trade rets/exit prices for tail analysis and slippage.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
import exit_lab # noqa: E402
|
||||||
|
from exit_lab import ExitPolicy, load_sleeves, OOS_START_MS, HARD_CAP # noqa: E402
|
||||||
|
|
||||||
|
# import the survivor policy
|
||||||
|
import importlib.util
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"p02", str(Path(__file__).resolve().parent / "02_trail_atr_keep_tp.py"))
|
||||||
|
p02 = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(p02)
|
||||||
|
TrailATRKeepTP = p02.TrailATRKeepTP
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
BEAR_START = int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6)
|
||||||
|
BEAR_END = int(pd.Timestamp("2023-01-01", tz="UTC").value // 1e6)
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_detailed(policy_cls, sleeve, params=None, start_ms=None, end_ms=None,
|
||||||
|
exit_slip_bps=0.0):
|
||||||
|
"""Mirror of exit_lab.simulate but returns per-trade detail and supports an
|
||||||
|
adverse slippage applied to every fill price (against the position)."""
|
||||||
|
params = params or {}
|
||||||
|
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
|
||||||
|
n = len(c)
|
||||||
|
ctx = dict(sleeve)
|
||||||
|
policy_cls.prepare(ctx, **params)
|
||||||
|
fee = exit_lab.FEE_RT * exit_lab.LEV
|
||||||
|
LEV, POS = exit_lab.LEV, exit_lab.POS
|
||||||
|
slip = exit_slip_bps * 1e-4
|
||||||
|
capital = peak = 1000.0
|
||||||
|
max_dd = 0.0
|
||||||
|
last_exit = -1
|
||||||
|
rets = []
|
||||||
|
tdetail = []
|
||||||
|
|
||||||
|
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
|
||||||
|
if start_ms is not None and ts[i] < start_ms:
|
||||||
|
continue
|
||||||
|
if end_ms is not None and ts[i] >= end_ms:
|
||||||
|
continue
|
||||||
|
if i <= last_exit or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
horizon = min(int(pol.horizon), HARD_CAP)
|
||||||
|
fills = []
|
||||||
|
remaining = 1.0
|
||||||
|
j = i
|
||||||
|
for step in range(1, horizon + 1):
|
||||||
|
j = i + step
|
||||||
|
if j >= n:
|
||||||
|
j = n - 1
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
break
|
||||||
|
tp, sl, tpfrac = pol.levels(j)
|
||||||
|
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:
|
||||||
|
fills.append((remaining, sl)); remaining = 0.0
|
||||||
|
break
|
||||||
|
if hit_tp:
|
||||||
|
f = min(max(tpfrac, 0.0), 1.0) * remaining
|
||||||
|
if f > 0:
|
||||||
|
fills.append((f, tp)); remaining -= f
|
||||||
|
if remaining <= 1e-9:
|
||||||
|
break
|
||||||
|
pol.on_partial(j, tp, remaining)
|
||||||
|
if pol.after_bar(j):
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
break
|
||||||
|
if step == horizon:
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
if remaining > 1e-9:
|
||||||
|
fills.append((remaining, c[j]))
|
||||||
|
|
||||||
|
# adverse slippage: every exit fill price moves AGAINST the position
|
||||||
|
# long (d=1) sells lower -> p*(1-slip); short (d=-1) buys back higher -> p*(1+slip)
|
||||||
|
adj_fills = [(f, p * (1.0 - d * slip)) for f, p in fills]
|
||||||
|
ret = sum(f * (p - entry) for f, p in adj_fills) / entry * d * LEV - fee
|
||||||
|
capital = max(capital + capital * POS * ret, 10.0)
|
||||||
|
peak = max(peak, capital)
|
||||||
|
max_dd = max(max_dd, (peak - capital) / peak)
|
||||||
|
last_exit = j
|
||||||
|
rets.append(ret)
|
||||||
|
tdetail.append({"i": i, "j": j, "d": d, "ret": ret, "bars": j - i, "ts": int(ts[i])})
|
||||||
|
|
||||||
|
if not rets:
|
||||||
|
return {}
|
||||||
|
r = np.array(rets)
|
||||||
|
return {
|
||||||
|
"ret_pct": (capital / 1000.0 - 1) * 100,
|
||||||
|
"dd_pct": max_dd * 100,
|
||||||
|
"trades": len(r),
|
||||||
|
"win_pct": (r > 0).mean() * 100,
|
||||||
|
"avg_ret_bps": r.mean() * 1e4,
|
||||||
|
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
|
||||||
|
"avg_bars": np.mean([t["bars"] for t in tdetail]),
|
||||||
|
"detail": tdetail,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_grid(data, fee_rt, slip_bps, start_ms, end_ms, label):
|
||||||
|
orig = exit_lab.FEE_RT
|
||||||
|
exit_lab.FEE_RT = fee_rt
|
||||||
|
print(f"\n===== {label} (FEE_RT={fee_rt}, slip={slip_bps}bps, "
|
||||||
|
f"start={start_ms}, end={end_ms}) =====")
|
||||||
|
agg = {}
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
base = simulate_detailed(ExitPolicy, sleeve, {}, start_ms, end_ms, slip_bps)
|
||||||
|
pol = simulate_detailed(TrailATRKeepTP, sleeve, {"k": 1.5}, start_ms, end_ms, slip_bps)
|
||||||
|
if not base or not pol:
|
||||||
|
continue
|
||||||
|
agg[key] = (base, pol)
|
||||||
|
print(f"{key:<10} BASE ret{base['ret_pct']:>8.0f}% dd{base['dd_pct']:>5.1f} "
|
||||||
|
f"sh{base['sharpe_t']:>5.2f} n{base['trades']:>4} bars{base['avg_bars']:>4.1f} "
|
||||||
|
f"| POL ret{pol['ret_pct']:>8.0f}% dd{pol['dd_pct']:>5.1f} "
|
||||||
|
f"sh{pol['sharpe_t']:>5.2f} n{pol['trades']:>4} bars{pol['avg_bars']:>4.1f}")
|
||||||
|
exit_lab.FEE_RT = orig
|
||||||
|
return agg
|
||||||
|
|
||||||
|
|
||||||
|
def policy_better(agg, metric="sharpe_t"):
|
||||||
|
"""count sleeves where policy >= base on metric (and ret not collapsed)."""
|
||||||
|
n_better_sh = n_better_dd = n_ret_ok = 0
|
||||||
|
for key, (base, pol) in agg.items():
|
||||||
|
if pol["sharpe_t"] >= base["sharpe_t"]:
|
||||||
|
n_better_sh += 1
|
||||||
|
if pol["dd_pct"] <= base["dd_pct"]:
|
||||||
|
n_better_dd += 1
|
||||||
|
if pol["ret_pct"] >= base["ret_pct"] * 0.5 or pol["ret_pct"] >= base["ret_pct"]:
|
||||||
|
n_ret_ok += 1
|
||||||
|
return n_better_sh, n_better_dd, n_ret_ok, len(agg)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
data = load_sleeves()
|
||||||
|
|
||||||
|
# ---- LENS 1: fee 2x on full OOS
|
||||||
|
a_base_oos = run_grid(data, 0.001, 0.0, OOS_START_MS, None, "L0 OOS baseline fee (sanity)")
|
||||||
|
a_fee2_oos = run_grid(data, 0.002, 0.0, OOS_START_MS, None, "L1 OOS FEE 2x")
|
||||||
|
|
||||||
|
# ---- LENS 3: adverse slippage 20bps on exits (OOS, normal fee)
|
||||||
|
a_slip_oos = run_grid(data, 0.001, 20.0, OOS_START_MS, None, "L3 OOS +20bps adverse exit slippage")
|
||||||
|
|
||||||
|
# ---- LENS 1+3 combined (worst case): fee 2x AND slippage
|
||||||
|
a_both = run_grid(data, 0.002, 20.0, OOS_START_MS, None, "L1+3 OOS fee2x + 20bps slip")
|
||||||
|
|
||||||
|
# ---- LENS 2: bear/crash subperiod 2021-2022
|
||||||
|
a_bear = run_grid(data, 0.001, 0.0, BEAR_START, BEAR_END, "L2 BEAR 2021-2022")
|
||||||
|
|
||||||
|
# ---- LENS 2 tail: worst trade + 5 worst, base vs policy, on bear window
|
||||||
|
print("\n===== L2 TAIL: worst trades in 2021-2022 (base vs policy) =====")
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
base = simulate_detailed(ExitPolicy, sleeve, {}, BEAR_START, BEAR_END)
|
||||||
|
pol = simulate_detailed(TrailATRKeepTP, sleeve, {"k": 1.5}, BEAR_START, BEAR_END)
|
||||||
|
if not base or not pol:
|
||||||
|
continue
|
||||||
|
bw = sorted([t["ret"] for t in base["detail"]])[:5]
|
||||||
|
pw = sorted([t["ret"] for t in pol["detail"]])[:5]
|
||||||
|
print(f"{key:<10} base 5-worst(bps) {[f'{x*1e4:.0f}' for x in bw]} "
|
||||||
|
f"| pol 5-worst(bps) {[f'{x*1e4:.0f}' for x in pw]}")
|
||||||
|
print(f"{'':<10} base worst {bw[0]*1e4:.0f}bps pol worst {pw[0]*1e4:.0f}bps "
|
||||||
|
f"base n={base['trades']} pol n={pol['trades']}")
|
||||||
|
|
||||||
|
# ---- LENS 4: turnover / capital churn quantification (OOS)
|
||||||
|
print("\n===== L4 TURNOVER / capital churn (OOS) =====")
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
base = simulate_detailed(ExitPolicy, sleeve, {}, OOS_START_MS, None)
|
||||||
|
pol = simulate_detailed(TrailATRKeepTP, sleeve, {"k": 1.5}, OOS_START_MS, None)
|
||||||
|
if not base or not pol:
|
||||||
|
continue
|
||||||
|
# bars in market total, fraction of time deployed
|
||||||
|
n = len(sleeve["close"])
|
||||||
|
base_bars = sum(t["bars"] for t in base["detail"])
|
||||||
|
pol_bars = sum(t["bars"] for t in pol["detail"])
|
||||||
|
churn = (pol["trades"] / max(base["trades"], 1))
|
||||||
|
print(f"{key:<10} trades base{base['trades']:>4} pol{pol['trades']:>4} "
|
||||||
|
f"(x{churn:.2f}) | bars-in-mkt base{base_bars:>5} pol{pol_bars:>5} "
|
||||||
|
f"| avg_bars base{base['avg_bars']:.1f} pol{pol['avg_bars']:.1f} "
|
||||||
|
f"| win% base{base['win_pct']:.0f} pol{pol['win_pct']:.0f}")
|
||||||
|
|
||||||
|
# ---- VERDICT helpers
|
||||||
|
print("\n===== VERDICT TALLIES =====")
|
||||||
|
for lbl, agg in [("OOS baseline-fee", a_base_oos), ("OOS fee2x", a_fee2_oos),
|
||||||
|
("OOS slip20", a_slip_oos), ("OOS fee2x+slip", a_both),
|
||||||
|
("BEAR 2021-22", a_bear)]:
|
||||||
|
nsh, ndd, nret, tot = policy_better(agg)
|
||||||
|
print(f"{lbl:<20} policy>=base: sharpe {nsh}/{tot} dd-better {ndd}/{tot} "
|
||||||
|
f"ret>=50%base {nret}/{tot}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Verifica avversariale LEAKAGE/ESEGUIBILITA' per EXIT-16 close_confirm_sl.
|
||||||
|
|
||||||
|
Tre attacchi:
|
||||||
|
A) CONTRATTO: dump statico di cosa legge la policy (close[j], atr[j]) e prova
|
||||||
|
che nessun indice > j entra nella decisione. Replica esatta del numero
|
||||||
|
headline (MR02 BTC/ETH OOS) per ancorare.
|
||||||
|
B) LAG: variante con UN bar di ritardo in piu' sugli input causali della
|
||||||
|
soglia (atr14[j-1] e confronto su close[j-1] invece di close[j]). Se l'edge
|
||||||
|
collassa -> appeso al timing perfetto. La decisione resta eseguibile
|
||||||
|
(close[j-1] noto a j-1), ma sposta il momento dello stop di un bar.
|
||||||
|
C) ESEGUIBILITA' LIVE: il worker esce al POLL successivo, non al close[j]
|
||||||
|
esatto. Stima del costo eseguendo l'uscita a open[j+1] invece di close[j].
|
||||||
|
|
||||||
|
Esegui: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
|
||||||
|
scripts/analysis/exit_policies/verify_16_leakage.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
sys.path.insert(0, str(HERE.parent)) # scripts/analysis
|
||||||
|
sys.path.insert(0, str(HERE.parents[2])) # project root
|
||||||
|
|
||||||
|
import exit_lab # noqa: E402
|
||||||
|
from exit_lab import (ExitPolicy, load_sleeves, simulate, OOS_START_MS) # noqa: E402
|
||||||
|
|
||||||
|
# import the survivor policy directly from its file
|
||||||
|
import importlib.util # noqa: E402
|
||||||
|
spec = importlib.util.spec_from_file_location("p16", HERE / "16_close_confirm_sl.py")
|
||||||
|
p16 = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(p16)
|
||||||
|
CloseConfirmSl = p16.CloseConfirmSl
|
||||||
|
|
||||||
|
BUF = 0.5 # train-pick buffer
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- B) LAG variant
|
||||||
|
class CloseConfirmSlLag(ExitPolicy):
|
||||||
|
"""Identica a EXIT-16 ma con 1 bar di ritardo sugli input della soglia:
|
||||||
|
decisione su close[j-1] e atr[j-1] (eseguibile gia' a j-1). Se l'edge
|
||||||
|
dipendeva dal close[j] esatto del bar di sfondamento, qui collassa."""
|
||||||
|
name = "close_confirm_sl_lag"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.buffer = float(params.get("buffer", 0.0))
|
||||||
|
self.close = ctx["close"]
|
||||||
|
self.atr = ctx["atr14"]
|
||||||
|
|
||||||
|
def levels(self, j):
|
||||||
|
return self.tp0, None, 1.0
|
||||||
|
|
||||||
|
def after_bar(self, j):
|
||||||
|
jj = j - 1
|
||||||
|
if jj <= self.i:
|
||||||
|
return False
|
||||||
|
a = self.atr[jj]
|
||||||
|
if not np.isfinite(a):
|
||||||
|
a = 0.0
|
||||||
|
cj = self.close[jj]
|
||||||
|
if self.d == 1:
|
||||||
|
return cj < self.sl0 - self.buffer * a
|
||||||
|
return cj > self.sl0 + self.buffer * a
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------- C) execution-delay (open[j+1]) variant
|
||||||
|
def simulate_open_next(sleeve, params, start_ms=None, end_ms=None):
|
||||||
|
"""Come exit_lab.simulate ma quando la policy esce sul CLOSE (after_bar o
|
||||||
|
horizon) il FILL avviene a open[j+1] (poll successivo), non a close[j].
|
||||||
|
I TP/SL intrabar restano al livello (limit). Stima il costo del ritardo
|
||||||
|
di un poll per un'exit market al prossimo bar."""
|
||||||
|
h = sleeve["high"]; l = sleeve["low"]; c = sleeve["close"]
|
||||||
|
o = sleeve["open"]; ts = sleeve["ts_ms"]
|
||||||
|
n = len(c)
|
||||||
|
ctx = dict(sleeve)
|
||||||
|
CloseConfirmSl.prepare(ctx, **params)
|
||||||
|
fee = exit_lab.FEE_RT * exit_lab.LEV
|
||||||
|
POS = exit_lab.POS; LEV = exit_lab.LEV
|
||||||
|
capital = peak = 1000.0
|
||||||
|
max_dd = 0.0
|
||||||
|
last_exit = -1
|
||||||
|
trades = wins = 0
|
||||||
|
bars_tot = 0
|
||||||
|
rets = []
|
||||||
|
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
|
||||||
|
if start_ms is not None and ts[i] < start_ms:
|
||||||
|
continue
|
||||||
|
if end_ms is not None and ts[i] >= end_ms:
|
||||||
|
continue
|
||||||
|
if i <= last_exit or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
pol = CloseConfirmSl(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
horizon = min(int(pol.horizon), exit_lab.HARD_CAP)
|
||||||
|
fills = []
|
||||||
|
remaining = 1.0
|
||||||
|
j = i
|
||||||
|
for step in range(1, horizon + 1):
|
||||||
|
j = i + step
|
||||||
|
if j >= n:
|
||||||
|
j = n - 1
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
break
|
||||||
|
tp, sl, tpfrac = pol.levels(j)
|
||||||
|
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:
|
||||||
|
fills.append((remaining, sl)); remaining = 0.0
|
||||||
|
break
|
||||||
|
if hit_tp:
|
||||||
|
f = min(max(tpfrac, 0.0), 1.0) * remaining
|
||||||
|
if f > 0:
|
||||||
|
fills.append((f, tp)); remaining -= f
|
||||||
|
if remaining <= 1e-9:
|
||||||
|
break
|
||||||
|
pol.on_partial(j, tp, remaining)
|
||||||
|
if pol.after_bar(j):
|
||||||
|
# EXECUTION DELAY: fill al prossimo open invece di close[j]
|
||||||
|
px = o[j + 1] if j + 1 < n else c[j]
|
||||||
|
fills.append((remaining, px)); remaining = 0.0
|
||||||
|
break
|
||||||
|
if step == horizon:
|
||||||
|
px = o[j + 1] if j + 1 < n else c[j]
|
||||||
|
fills.append((remaining, px)); remaining = 0.0
|
||||||
|
if remaining > 1e-9:
|
||||||
|
fills.append((remaining, c[j]))
|
||||||
|
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
|
||||||
|
capital = max(capital + capital * POS * ret, 10.0)
|
||||||
|
peak = max(peak, capital)
|
||||||
|
max_dd = max(max_dd, (peak - capital) / peak)
|
||||||
|
last_exit = j
|
||||||
|
trades += 1
|
||||||
|
wins += ret > 0
|
||||||
|
bars_tot += j - i
|
||||||
|
rets.append(ret)
|
||||||
|
if trades == 0:
|
||||||
|
return {}
|
||||||
|
r = np.array(rets)
|
||||||
|
return {"ret_pct": (capital / 1000.0 - 1) * 100, "dd_pct": max_dd * 100,
|
||||||
|
"trades": trades, "win_pct": wins / trades * 100,
|
||||||
|
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
|
||||||
|
"avg_bars": bars_tot / trades}
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(r):
|
||||||
|
if not r:
|
||||||
|
return "(no trades)"
|
||||||
|
return (f"ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} "
|
||||||
|
f"n{r['trades']:>4} bars{r['avg_bars']:>5.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
data = load_sleeves()
|
||||||
|
params = {"buffer": BUF}
|
||||||
|
keys = list(data.keys())
|
||||||
|
|
||||||
|
# ---------------------------------------- A) contratto / ancoraggio headline
|
||||||
|
print("=" * 96)
|
||||||
|
print("A) ANCORAGGIO (OOS) base vs EXIT-16(buf=0.5) vs LAG(+1 bar) vs OPEN[j+1] delay")
|
||||||
|
print("=" * 96)
|
||||||
|
survive_base = survive_lag = survive_delay = 0
|
||||||
|
agg = {}
|
||||||
|
for key in keys:
|
||||||
|
sl = data[key]
|
||||||
|
b_oos = simulate(ExitPolicy, sl, {}, start_ms=OOS_START_MS)
|
||||||
|
s_oos = simulate(CloseConfirmSl, sl, params, start_ms=OOS_START_MS)
|
||||||
|
lag_oos = simulate(CloseConfirmSlLag, sl, params, start_ms=OOS_START_MS)
|
||||||
|
del_oos = simulate_open_next(sl, params, start_ms=OOS_START_MS)
|
||||||
|
name = f"{key[0].split('_')[0]} {key[1]}"
|
||||||
|
print(f"\n{name}")
|
||||||
|
print(f" base {fmt(b_oos)}")
|
||||||
|
print(f" EXIT16 {fmt(s_oos)}")
|
||||||
|
print(f" LAG+1 {fmt(lag_oos)}")
|
||||||
|
print(f" DELAY {fmt(del_oos)}")
|
||||||
|
# survivorship: EXIT16 sharpe >= base sharpe?
|
||||||
|
if s_oos and b_oos and s_oos["sharpe_t"] >= b_oos["sharpe_t"]:
|
||||||
|
survive_base += 1
|
||||||
|
if lag_oos and b_oos and lag_oos["sharpe_t"] >= b_oos["sharpe_t"]:
|
||||||
|
survive_lag += 1
|
||||||
|
if del_oos and b_oos and del_oos["sharpe_t"] >= b_oos["sharpe_t"]:
|
||||||
|
survive_delay += 1
|
||||||
|
agg[name] = dict(base=b_oos, exit16=s_oos, lag=lag_oos, delay=del_oos)
|
||||||
|
|
||||||
|
print("\n" + "=" * 96)
|
||||||
|
print(f"GATE OOS (sharpe >= base): EXIT16 {survive_base}/6 | LAG+1 {survive_lag}/6 "
|
||||||
|
f"| DELAY(open[j+1]) {survive_delay}/6")
|
||||||
|
|
||||||
|
# ---------------------------------------- quantify lag/delay damage on headline
|
||||||
|
print("\nDanno relativo su sharpe OOS (EXIT16 = 100%):")
|
||||||
|
for name, a in agg.items():
|
||||||
|
s = a["exit16"]["sharpe_t"] if a["exit16"] else 0
|
||||||
|
lg = a["lag"]["sharpe_t"] if a["lag"] else 0
|
||||||
|
dl = a["delay"]["sharpe_t"] if a["delay"] else 0
|
||||||
|
ls = f"{100*lg/s:5.0f}%" if s else " n/a"
|
||||||
|
ds = f"{100*dl/s:5.0f}%" if s else " n/a"
|
||||||
|
print(f" {name:<10} sh{s:5.2f} LAG->{ls} DELAY->{ds}")
|
||||||
|
|
||||||
|
# ---------------------------------------- B) per-trade audit of decision indices
|
||||||
|
print("\n" + "=" * 96)
|
||||||
|
print("B) AUDIT INDICI: la decisione after_bar(j) legge close[j], atr[j]. "
|
||||||
|
"Verifico\n che simulate() chiami after_bar SOLO con j = i+step (mai > j corrente).")
|
||||||
|
# static guarantee from code; demonstrate atr[j] is causal (rolling mean to j)
|
||||||
|
sl = data[keys[0]]
|
||||||
|
print(f" atr14[k] = rolling(14).mean(TR) -> usa TR[k-13..k], tutti chiusi a k. OK")
|
||||||
|
print(f" close[j] noto al close del bar j. Nessun indice > j nella decisione. OK")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""VERIFY EXIT-16 close_confirm_sl — lente OVERFIT/ROBUSTEZZA (avversariale).
|
||||||
|
|
||||||
|
Ipotesi nulla: il risultato e' un artefatto (overfit di cella / di regime / di
|
||||||
|
dipendenza dal loss-guard Hurst gia' applicato in cache). Tre test:
|
||||||
|
|
||||||
|
(1) JITTER PARAMETRI: buffer fuori griglia {0.4, 0.6, 0.75, 1.0} + ponte verso la
|
||||||
|
base con SL fisso a 3x/4x ATR (no_sl come limite). Il plateau tiene?
|
||||||
|
(2) STABILITA' TEMPORALE: train 2018-20 vs 21-22; OOS 2023-11/2025-01 vs
|
||||||
|
2025-01/2026-05. Il miglioramento e' in OGNI finestra o concentrato?
|
||||||
|
(3) DIPENDENZA HURST (decisivo): rigenero i segnali con hurst_max=None (NESSUN
|
||||||
|
loss-guard, NON tocco la cache) e ripeto base-vs-policy. Se senza il guard la
|
||||||
|
policy crolla, funziona SOLO grazie al guard -> condizione di validita'.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||||
|
|
||||||
|
import exit_lab # noqa: E402
|
||||||
|
from exit_lab import ExitPolicy, simulate, OOS_START_MS, _atr14 # noqa: E402
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"cc16", str(Path(__file__).resolve().parent / "16_close_confirm_sl.py"))
|
||||||
|
cc16 = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(cc16)
|
||||||
|
CloseConfirmSl = cc16.CloseConfirmSl
|
||||||
|
|
||||||
|
from src.data.downloader import load_data # noqa: E402
|
||||||
|
from src.live.strategy_loader import load_strategy # noqa: E402
|
||||||
|
|
||||||
|
CODES = ["MR01_bollinger_fade", "MR02_donchian_fade", "MR07_return_reversal"]
|
||||||
|
ASSETS = ("BTC", "ETH")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- policy ponte: SL fisso a multiplo di ATR (no_sl come limite) ----
|
||||||
|
class WideSlPolicy(ExitPolicy):
|
||||||
|
"""SL intrabar spostato a k*ATR oltre sl0 (ponte tra base e no-sl)."""
|
||||||
|
name = "wide_sl"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
self.k = float(params.get("k_atr", 2.0))
|
||||||
|
self.atr = ctx["atr14"]
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
a = self.atr[j - 1] if np.isfinite(self.atr[j - 1]) else 0.0
|
||||||
|
# sl0 e' sotto (long) / sopra (short) l'entry; allarga di k*atr
|
||||||
|
sl = self.sl0 - self.k * a if self.d == 1 else self.sl0 + self.k * a
|
||||||
|
return self.tp0, sl, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def sub(cls, sleeve, g, s, e):
|
||||||
|
return simulate(cls, sleeve, g, start_ms=s, end_ms=e)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(r):
|
||||||
|
if not r:
|
||||||
|
return " n/a"
|
||||||
|
return (f"ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} "
|
||||||
|
f"n{r['trades']:>4} bars{r['avg_bars']:>5.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_signals(hurst_max):
|
||||||
|
"""Rigenera sleeve in memoria con hurst_max dato (None = no guard). NON tocca cache."""
|
||||||
|
out = {}
|
||||||
|
params = dict(trend_max=3.0, ema_long=200, hurst_max=hurst_max, min_tp_frac=0.0015)
|
||||||
|
for code in CODES:
|
||||||
|
strat = load_strategy(code)
|
||||||
|
for asset in ASSETS:
|
||||||
|
df = load_data(asset, "1h")
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
sigs = strat.generate_signals(df, ts, **params)
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
out[(code, asset)] = {
|
||||||
|
"signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]),
|
||||||
|
float(s.metadata["sl"]), int(s.metadata["max_bars"]))
|
||||||
|
for s in sigs],
|
||||||
|
"open": df["open"].values.astype(float),
|
||||||
|
"high": h, "low": l, "close": c,
|
||||||
|
"ts_ms": df["timestamp"].values.astype(np.int64),
|
||||||
|
"atr14": _atr14(h, l, c),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
data = exit_lab.load_sleeves()
|
||||||
|
keys = list(data.keys())
|
||||||
|
|
||||||
|
# ===== TEST 1: JITTER PARAMETRI =====
|
||||||
|
print("=" * 100)
|
||||||
|
print("TEST 1 — JITTER buffer fuori griglia + ponte WIDE-SL (OOS, dopo 2023-11)")
|
||||||
|
print("=" * 100)
|
||||||
|
jit_buffers = [0.4, 0.6, 0.75, 1.0]
|
||||||
|
all_pos = True
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
base = sub(ExitPolicy, sleeve, {}, OOS_START_MS, None)
|
||||||
|
line = f"{key:<10} BASE {fmt(base)}"
|
||||||
|
print(line)
|
||||||
|
for b in jit_buffers:
|
||||||
|
r = sub(CloseConfirmSl, sleeve, {"buffer": b}, OOS_START_MS, None)
|
||||||
|
better = r and base and r["sharpe_t"] >= base["sharpe_t"] - 0.10
|
||||||
|
all_pos &= bool(better)
|
||||||
|
print(f" buf={b:<4} {fmt(r)} {'OK' if better else 'WORSE'}")
|
||||||
|
print()
|
||||||
|
print(f"JITTER buffer: tutte >= base-0.10 sharpe? {all_pos}\n")
|
||||||
|
|
||||||
|
print("-" * 100)
|
||||||
|
print("PONTE WIDE-SL: SL intrabar fisso allargato a k*ATR (k grande -> verso no-sl)")
|
||||||
|
print("-" * 100)
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
base = sub(ExitPolicy, sleeve, {}, OOS_START_MS, None)
|
||||||
|
print(f"{key:<10} BASE(k=0) {fmt(base)}")
|
||||||
|
for k in [1.5, 3.0, 4.0]:
|
||||||
|
r = sub(WideSlPolicy, sleeve, {"k_atr": k}, OOS_START_MS, None)
|
||||||
|
print(f" k={k:<4} {fmt(r)}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ===== TEST 2: STABILITA' TEMPORALE =====
|
||||||
|
print("=" * 100)
|
||||||
|
print("TEST 2 — STABILITA' TEMPORALE (base vs policy buffer=0.5)")
|
||||||
|
print("=" * 100)
|
||||||
|
ms = lambda d: int(pd.Timestamp(d, tz="UTC").value // 1e6)
|
||||||
|
windows = [
|
||||||
|
("TRAIN 2018-20", None, ms("2021-01-01")),
|
||||||
|
("TRAIN 2021-22", ms("2021-01-01"), OOS_START_MS),
|
||||||
|
("OOS 23/11-25/01", OOS_START_MS, ms("2025-01-01")),
|
||||||
|
("OOS 25/01-26/05", ms("2025-01-01"), None),
|
||||||
|
]
|
||||||
|
win_verdict = {w[0]: 0 for w in windows}
|
||||||
|
win_total = {w[0]: 0 for w in windows}
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
print(f"\n{key}")
|
||||||
|
for wname, s, e in windows:
|
||||||
|
b = sub(ExitPolicy, sleeve, {}, s, e)
|
||||||
|
p = sub(CloseConfirmSl, sleeve, {"buffer": 0.5}, s, e)
|
||||||
|
if b and p:
|
||||||
|
win_total[wname] += 1
|
||||||
|
# criterio: policy non peggio della base su sharpe (tol 0.15)
|
||||||
|
imp = p["sharpe_t"] >= b["sharpe_t"] - 0.15
|
||||||
|
win_verdict[wname] += int(imp)
|
||||||
|
tag = "OK " if imp else "BAD"
|
||||||
|
else:
|
||||||
|
tag = "n/a"
|
||||||
|
print(f" {wname:<18} base {fmt(b)}")
|
||||||
|
print(f" {'':<18} pol {fmt(p)} -> {tag}")
|
||||||
|
print("\nPer-finestra (policy >= base-0.15 sharpe):")
|
||||||
|
for w in windows:
|
||||||
|
wn = w[0]
|
||||||
|
print(f" {wn:<18} {win_verdict[wn]}/{win_total[wn]} sleeve OK")
|
||||||
|
|
||||||
|
# ===== TEST 3: DIPENDENZA HURST (DECISIVO) =====
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("TEST 3 — DIPENDENZA dal loss-guard HURST (DECISIVO)")
|
||||||
|
print("Rigenero i segnali con hurst_max=None (NO guard, regime trending incluso).")
|
||||||
|
print("Se la policy crolla -> funziona SOLO grazie al guard.")
|
||||||
|
print("=" * 100)
|
||||||
|
print("Generazione segnali SENZA hurst (puo' richiedere ~1-2 min)...")
|
||||||
|
data_nohurst = build_signals(hurst_max=None)
|
||||||
|
|
||||||
|
n_guard = sum(len(s["signals"]) for s in data.values())
|
||||||
|
n_nohurst = sum(len(s["signals"]) for s in data_nohurst.values())
|
||||||
|
print(f"Segnali totali: con guard {n_guard}, senza guard {n_nohurst} "
|
||||||
|
f"(+{n_nohurst - n_guard} segnali in regime trending)\n")
|
||||||
|
|
||||||
|
holds = True
|
||||||
|
for region_name, s, e in [("TRAIN", None, OOS_START_MS), ("OOS", OOS_START_MS, None)]:
|
||||||
|
print(f"--- {region_name} (segnali SENZA hurst guard) ---")
|
||||||
|
for (code, asset), sleeve in data_nohurst.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
b = sub(ExitPolicy, sleeve, {}, s, e)
|
||||||
|
p = sub(CloseConfirmSl, sleeve, {"buffer": 0.5}, s, e)
|
||||||
|
if b and p:
|
||||||
|
imp = p["sharpe_t"] >= b["sharpe_t"] - 0.15
|
||||||
|
ddimp = p["dd_pct"] <= b["dd_pct"] + 1.0
|
||||||
|
holds &= bool(imp)
|
||||||
|
tag = "OK " if imp else "POLICY WORSE"
|
||||||
|
else:
|
||||||
|
tag = "n/a"
|
||||||
|
print(f" {key:<10} base {fmt(b)}")
|
||||||
|
print(f" {'':<10} pol {fmt(p)} -> {tag}")
|
||||||
|
print()
|
||||||
|
print(f"TEST 3 verdict: policy regge SENZA il guard (>= base-0.15 sharpe ovunque)? {holds}")
|
||||||
|
print("Se False -> la tesi 'SL dannoso' dipende dal guard (condizione di validita').")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""VERIFY EXIT-16 close_confirm_sl — lente STRESS (avversariale).
|
||||||
|
|
||||||
|
Ipotesi nulla: l'edge della close-confirm-SL e' fragile a frizioni reali.
|
||||||
|
Quattro stress, tutti su segnali cache (params LIVE, hurst_max=0.55):
|
||||||
|
|
||||||
|
(1) FEE 2x: FEE_RT=0.002 (vs 0.001). Penalizza le policy che girano piu' capitale.
|
||||||
|
(2) BEAR/CRASH 2021-01..2022-12 (LUNA/FTX/19-mag-21): worst-trade + 5 peggiori
|
||||||
|
trade della policy vs base. Lo SL disattivato lascia correre le perdite?
|
||||||
|
(3) SLIPPAGE AVVERSO 20bps sulle uscite della policy: ogni fill di USCITA paga
|
||||||
|
+20bps contro la posizione (prezzo di uscita peggiorato). L'edge regge?
|
||||||
|
NB: lo applico SOLO alle uscite della POLICY (la sua tesi e' "esco al close":
|
||||||
|
il close-fill e' market, paga slippage; la base esce a livelli limite sl0/tp0).
|
||||||
|
(4) OVERLAP/TURNOVER: la policy allunga la permanenza (no stop intrabar). Conto
|
||||||
|
i segnali SALTATI per non-overlap (i <= last_exit) base vs policy, e quanto
|
||||||
|
capitale-tempo (somma bars in posizione) gira in piu'.
|
||||||
|
|
||||||
|
Tutto via simulate() con monkeypatch di FEE_RT e una sottoclasse engine per lo
|
||||||
|
slippage. Niente modifiche ad altri file.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||||
|
|
||||||
|
import exit_lab # noqa: E402
|
||||||
|
from exit_lab import ExitPolicy, simulate, OOS_START_MS, HARD_CAP, LEV, POS # noqa: E402
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"cc16", str(Path(__file__).resolve().parent / "16_close_confirm_sl.py"))
|
||||||
|
cc16 = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(cc16)
|
||||||
|
CloseConfirmSl = cc16.CloseConfirmSl
|
||||||
|
|
||||||
|
BUF = 0.5 # train-pick
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(r):
|
||||||
|
if not r:
|
||||||
|
return " n/a"
|
||||||
|
return (f"ret{r['ret_pct']:>8.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} "
|
||||||
|
f"n{r['trades']:>4} win{r['win_pct']:>4.0f} bars{r['avg_bars']:>5.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
def sub(cls, sleeve, g, s, e):
|
||||||
|
return simulate(cls, sleeve, g, start_ms=s, end_ms=e)
|
||||||
|
|
||||||
|
|
||||||
|
def ms(d):
|
||||||
|
return int(pd.Timestamp(d, tz="UTC").value // 1e6)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Engine "instrumented" che riproduce simulate() ma:
|
||||||
|
# - applica uno slippage avverso (bps) su OGNI fill di USCITA (solo se policy)
|
||||||
|
# - raccoglie la lista dei ret per-trade e i segnali SALTATI per non-overlap
|
||||||
|
# - raccoglie capital-time (somma bars)
|
||||||
|
# Lo tengo allineato 1:1 con exit_lab.simulate (stesso ordine SL-prima-di-TP).
|
||||||
|
# ===========================================================================
|
||||||
|
def simulate_instr(policy_cls, sleeve, params=None, start_ms=None, end_ms=None,
|
||||||
|
exit_slip_bps=0.0):
|
||||||
|
params = params or {}
|
||||||
|
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
|
||||||
|
n = len(c)
|
||||||
|
ctx = dict(sleeve)
|
||||||
|
policy_cls.prepare(ctx, **params)
|
||||||
|
fee = exit_lab.FEE_RT * LEV
|
||||||
|
slip = exit_slip_bps * 1e-4
|
||||||
|
capital = peak = 1000.0
|
||||||
|
max_dd = 0.0
|
||||||
|
last_exit = -1
|
||||||
|
trades = wins = 0
|
||||||
|
bars_tot = 0
|
||||||
|
skipped_overlap = 0
|
||||||
|
rets = [] # (ret, ts_entry, bars)
|
||||||
|
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
|
||||||
|
if start_ms is not None and ts[i] < start_ms:
|
||||||
|
continue
|
||||||
|
if end_ms is not None and ts[i] >= end_ms:
|
||||||
|
continue
|
||||||
|
if i + 1 >= n:
|
||||||
|
continue
|
||||||
|
if i <= last_exit:
|
||||||
|
skipped_overlap += 1
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
horizon = min(int(pol.horizon), HARD_CAP)
|
||||||
|
fills = []
|
||||||
|
remaining = 1.0
|
||||||
|
j = i
|
||||||
|
for step in range(1, horizon + 1):
|
||||||
|
j = i + step
|
||||||
|
if j >= n:
|
||||||
|
j = n - 1
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
break
|
||||||
|
tp, sl, tpfrac = pol.levels(j)
|
||||||
|
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:
|
||||||
|
fills.append((remaining, sl)); remaining = 0.0
|
||||||
|
break
|
||||||
|
if hit_tp:
|
||||||
|
f = min(max(tpfrac, 0.0), 1.0) * remaining
|
||||||
|
if f > 0:
|
||||||
|
fills.append((f, tp)); remaining -= f
|
||||||
|
if remaining <= 1e-9:
|
||||||
|
break
|
||||||
|
pol.on_partial(j, tp, remaining)
|
||||||
|
if pol.after_bar(j):
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
break
|
||||||
|
if step == horizon:
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
if remaining > 1e-9:
|
||||||
|
fills.append((remaining, c[j]))
|
||||||
|
# slippage avverso sull'uscita: il prezzo di uscita peggiora di slip,
|
||||||
|
# cioe' si vende piu' basso (long) / si ricompra piu' alto (short).
|
||||||
|
def adj(p):
|
||||||
|
return p * (1.0 - slip) if d == 1 else p * (1.0 + slip)
|
||||||
|
ret = sum(f * (adj(p) - entry) for f, p in fills) / entry * d * LEV - fee
|
||||||
|
capital = max(capital + capital * POS * ret, 10.0)
|
||||||
|
peak = max(peak, capital)
|
||||||
|
max_dd = max(max_dd, (peak - capital) / peak)
|
||||||
|
last_exit = j
|
||||||
|
trades += 1
|
||||||
|
wins += ret > 0
|
||||||
|
bars_tot += j - i
|
||||||
|
rets.append((ret, int(ts[i]), j - i))
|
||||||
|
if trades == 0:
|
||||||
|
return {}
|
||||||
|
r = np.array([x[0] for x in rets])
|
||||||
|
return {
|
||||||
|
"ret_pct": (capital / 1000.0 - 1) * 100,
|
||||||
|
"dd_pct": max_dd * 100,
|
||||||
|
"trades": trades,
|
||||||
|
"win_pct": wins / trades * 100,
|
||||||
|
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
|
||||||
|
"avg_bars": bars_tot / trades,
|
||||||
|
"bars_tot": bars_tot,
|
||||||
|
"skipped_overlap": skipped_overlap,
|
||||||
|
"rets": rets,
|
||||||
|
"worst5": sorted(r.tolist())[:5],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
data = exit_lab.load_sleeves()
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
print("=" * 104)
|
||||||
|
print("TEST 1 — FEE 2x (FEE_RT 0.001 -> 0.002). base vs policy buffer=0.5 (OOS, dopo 2023-11)")
|
||||||
|
print("=" * 104)
|
||||||
|
orig_fee = exit_lab.FEE_RT
|
||||||
|
survive_fee = True
|
||||||
|
for fee in (0.001, 0.002):
|
||||||
|
exit_lab.FEE_RT = fee
|
||||||
|
print(f"\n--- FEE_RT={fee} ---")
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
b = sub(ExitPolicy, sleeve, {}, OOS_START_MS, None)
|
||||||
|
p = sub(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None)
|
||||||
|
tag = ""
|
||||||
|
if fee == 0.002 and b and p:
|
||||||
|
# regge se sharpe policy >= base (la tesi e' che migliora)
|
||||||
|
ok = p["sharpe_t"] >= b["sharpe_t"] - 0.10
|
||||||
|
survive_fee &= ok
|
||||||
|
tag = "OK" if ok else "WORSE"
|
||||||
|
print(f" {key:<10} base {fmt(b)}")
|
||||||
|
print(f" {'':<10} pol {fmt(p)} {tag}")
|
||||||
|
exit_lab.FEE_RT = orig_fee
|
||||||
|
print(f"\nFEE 2x: policy regge (>= base-0.10 sh su tutti gli sleeve OOS)? {survive_fee}")
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
print("\n" + "=" * 104)
|
||||||
|
print("TEST 2 — BEAR/CRASH 2021-01..2022-12 (LUNA/FTX/19-mag): worst-trade + 5 peggiori")
|
||||||
|
print("=" * 104)
|
||||||
|
s2, e2 = ms("2021-01-01"), ms("2023-01-01")
|
||||||
|
tail_worse = 0
|
||||||
|
tail_total = 0
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
b = simulate_instr(ExitPolicy, sleeve, {}, s2, e2)
|
||||||
|
p = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, s2, e2)
|
||||||
|
print(f"\n{key}")
|
||||||
|
print(f" base {fmt(b)}")
|
||||||
|
print(f" pol {fmt(p)}")
|
||||||
|
if b and p:
|
||||||
|
bw = [f"{x*100:+.1f}%" for x in b["worst5"]]
|
||||||
|
pw = [f"{x*100:+.1f}%" for x in p["worst5"]]
|
||||||
|
print(f" base 5 peggiori (ret netto): {bw}")
|
||||||
|
print(f" pol 5 peggiori (ret netto): {pw}")
|
||||||
|
tail_total += 1
|
||||||
|
# la policy peggiora la coda se il worst-trade e' piu' negativo
|
||||||
|
if p["worst5"][0] < b["worst5"][0] - 0.005:
|
||||||
|
tail_worse += 1
|
||||||
|
print(f" -> CODA PEGGIORE: worst {p['worst5'][0]*100:+.1f}% < base {b['worst5'][0]*100:+.1f}%")
|
||||||
|
else:
|
||||||
|
print(f" -> coda OK: worst {p['worst5'][0]*100:+.1f}% vs base {b['worst5'][0]*100:+.1f}%")
|
||||||
|
print(f" DD bear: base {b['dd_pct']:.1f}% pol {p['dd_pct']:.1f}%")
|
||||||
|
print(f"\nBEAR: sleeve con coda PEGGIORE (worst-trade > 0.5pt sotto base): {tail_worse}/{tail_total}")
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
print("\n" + "=" * 104)
|
||||||
|
print("TEST 3 — SLIPPAGE AVVERSO 20bps sulle uscite della POLICY (OOS). base senza slippage")
|
||||||
|
print("(la tesi della policy e' 'esco al close' = market fill -> paga slippage)")
|
||||||
|
print("=" * 104)
|
||||||
|
survive_slip = True
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
b = simulate_instr(ExitPolicy, sleeve, {}, OOS_START_MS, None, exit_slip_bps=0.0)
|
||||||
|
p0 = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None, exit_slip_bps=0.0)
|
||||||
|
p20 = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None, exit_slip_bps=20.0)
|
||||||
|
ok = p20 and b and p20["sharpe_t"] >= b["sharpe_t"] - 0.10
|
||||||
|
survive_slip &= bool(ok)
|
||||||
|
print(f"\n{key}")
|
||||||
|
print(f" base (no slip) {fmt(b)}")
|
||||||
|
print(f" pol (no slip) {fmt(p0)}")
|
||||||
|
print(f" pol (+20bps exit) {fmt(p20)} {'OK' if ok else 'WORSE vs base'}")
|
||||||
|
print(f"\nSLIPPAGE 20bps: policy ancora >= base-0.10 sh su tutti? {survive_slip}")
|
||||||
|
print("(test severo: lo slippage colpisce la policy ma NON la base — asimmetria pessimistica)")
|
||||||
|
|
||||||
|
# severita' extra: slippage anche sulla base (entrambe market) per fairness
|
||||||
|
print("\n--- fairness: 20bps anche sulle uscite della BASE ---")
|
||||||
|
fair = True
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
b20 = simulate_instr(ExitPolicy, sleeve, {}, OOS_START_MS, None, exit_slip_bps=20.0)
|
||||||
|
p20 = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None, exit_slip_bps=20.0)
|
||||||
|
ok = p20 and b20 and p20["sharpe_t"] >= b20["sharpe_t"] - 0.10
|
||||||
|
fair &= bool(ok)
|
||||||
|
print(f" {key:<10} base+20 {fmt(b20)}")
|
||||||
|
print(f" {'':<10} pol +20 {fmt(p20)} {'OK' if ok else 'WORSE'}")
|
||||||
|
print(f"fairness (entrambe +20bps): policy >= base-0.10 sh? {fair}")
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
print("\n" + "=" * 104)
|
||||||
|
print("TEST 4 — OVERLAP/TURNOVER: segnali saltati per non-overlap + capital-time (OOS)")
|
||||||
|
print("=" * 104)
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
b = simulate_instr(ExitPolicy, sleeve, {}, OOS_START_MS, None)
|
||||||
|
p = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None)
|
||||||
|
if b and p:
|
||||||
|
dskip = p["skipped_overlap"] - b["skipped_overlap"]
|
||||||
|
dbars = p["bars_tot"] - b["bars_tot"]
|
||||||
|
print(f" {key:<10} base: trades {b['trades']:>4} skip-overlap {b['skipped_overlap']:>4} "
|
||||||
|
f"bars_tot {b['bars_tot']:>6} avg {b['avg_bars']:.1f}")
|
||||||
|
print(f" {'':<10} pol : trades {p['trades']:>4} skip-overlap {p['skipped_overlap']:>4} "
|
||||||
|
f"bars_tot {p['bars_tot']:>6} avg {p['avg_bars']:.1f}")
|
||||||
|
print(f" {'':<10} -> +{dskip} segnali persi per overlap, "
|
||||||
|
f"+{dbars} bars in posizione ({dbars/max(b['bars_tot'],1)*100:+.0f}% capital-time)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""VERIFICA AVVERSARIALE — lente LOOK-AHEAD/ESEGUIBILITA' per EXIT-22 (no_sl).
|
||||||
|
|
||||||
|
Ipotesi da confutare: "rimuovere lo SL e' un free-lunch" potrebbe essere un
|
||||||
|
artefatto di (a) look-ahead nel contratto livelli/engine, (b) dipendenza dal
|
||||||
|
timing perfetto dell'uscita, (c) non-replicabilita' del fill live (il worker
|
||||||
|
ticka ogni ora ed esce al poll successivo, non al close esatto del bar).
|
||||||
|
|
||||||
|
Esperimenti (tutti riusano simulate() e la policy importata):
|
||||||
|
E1 CONTRATTO: la policy NoSl ha livelli STATICI dall'entrata. Confermo che
|
||||||
|
l'output non cambia se "rumorizzo" gli array > i (futuro) post-entrata, e
|
||||||
|
che non cambia se rumorizzo atr14 ovunque (la policy non lo usa).
|
||||||
|
E2 LAG: variante che ritarda l'uscita a horizon (e i tocchi) di 1 bar — ma
|
||||||
|
siccome NoSl non ha SL e non usa indicatori a j-1, il vero lag rilevante
|
||||||
|
e' SUL FILL. Implemento NoSlLagExit: l'uscita al close del bar j viene
|
||||||
|
eseguita al close[j+1] (un tick dopo) e il TP intrabar viene fillato al
|
||||||
|
WORST fra tp e close[j] (slippage avverso). Misuro il collasso dell'edge.
|
||||||
|
E3 ESEGUIBILITA' open[j+1]: orizzonte/uscite al close[j] rieseguite a
|
||||||
|
open[j+1] (il poll successivo del worker). Quanto costa il gap di apertura?
|
||||||
|
E4 TP-FILL pessimistico: TP fillato a close[j] (non al livello tp) quando il
|
||||||
|
bar tocca il TP -> stima il caso in cui il worker scopre il tocco solo al
|
||||||
|
poll e chiude al prezzo corrente, peggiore del livello.
|
||||||
|
E5 SOTTOPERIODI OOS: l'edge di 'none' regge nei sotto-intervalli OOS o e'
|
||||||
|
tutto in una coda fortunata? (2023-11..2024-08 vs 2024-08..fine).
|
||||||
|
|
||||||
|
Verdetto: refuted=True solo se un test ESEGUIBILE realistico (E2/E3/E4) cancella
|
||||||
|
il vantaggio di 'none' su 'base' (ret E dd) su entrambi gli asset / tutte le fade.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
sys.path.insert(0, str(HERE.parent)) # scripts/analysis
|
||||||
|
|
||||||
|
import exit_lab # noqa: E402
|
||||||
|
from exit_lab import ExitPolicy, simulate, load_sleeves, OOS_START_MS, HARD_CAP, LEV, POS # noqa: E402
|
||||||
|
|
||||||
|
sys.path.insert(0, str(HERE))
|
||||||
|
import importlib.util # noqa: E402
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location("_no_sl", HERE / "22_no_sl.py")
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
NoSl = _mod.NoSl
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(r):
|
||||||
|
if not r:
|
||||||
|
return " (no trades)"
|
||||||
|
return (f"ret{r['ret_pct']:>8.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} "
|
||||||
|
f"n{r['trades']:>4} win{r['win_pct']:>4.0f}% bars{r['avg_bars']:>5.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- E2/E3/E4 engines
|
||||||
|
|
||||||
|
def simulate_exec(sleeve, mode, *, exit_at, tp_fill, start_ms=None, end_ms=None,
|
||||||
|
with_sl=False):
|
||||||
|
"""Clone di simulate() con NoSl(mode), ma con esecuzione LIVE-realistica:
|
||||||
|
exit_at : 'close' -> uscita orizzonte al close[j] (baseline harness)
|
||||||
|
'open1' -> uscita orizzonte al open[j+1] (poll successivo)
|
||||||
|
tp_fill : 'level' -> TP fillato al livello tp (ottimistico, harness)
|
||||||
|
'close' -> TP fillato al close[j] del bar che tocca (worker
|
||||||
|
scopre il tocco solo al poll: prezzo corrente)
|
||||||
|
'open1' -> TP fillato al open[j+1]
|
||||||
|
with_sl=False -> NoSl (mode='none'); with_sl tramite mode='base' usa lo SL
|
||||||
|
della strategia (per il confronto base vs none nelle STESSE condizioni exec).
|
||||||
|
"""
|
||||||
|
h, l, c, o, ts = (sleeve["high"], sleeve["low"], sleeve["close"],
|
||||||
|
sleeve["open"], sleeve["ts_ms"])
|
||||||
|
n = len(c)
|
||||||
|
fee = exit_lab.FEE_RT * LEV
|
||||||
|
capital = peak = 1000.0
|
||||||
|
max_dd = 0.0
|
||||||
|
last_exit = -1
|
||||||
|
trades = wins = bars_tot = 0
|
||||||
|
rets = []
|
||||||
|
|
||||||
|
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
|
||||||
|
if start_ms is not None and ts[i] < start_ms:
|
||||||
|
continue
|
||||||
|
if end_ms is not None and ts[i] >= end_ms:
|
||||||
|
continue
|
||||||
|
if i <= last_exit or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
if mode == "base":
|
||||||
|
sl = sl0
|
||||||
|
else:
|
||||||
|
sl = None # 'none'
|
||||||
|
tp = tp0
|
||||||
|
horizon = min(int(mb), HARD_CAP)
|
||||||
|
exit_price = None
|
||||||
|
j = i
|
||||||
|
for step in range(1, horizon + 1):
|
||||||
|
j = i + step
|
||||||
|
if j >= n:
|
||||||
|
j = n - 1
|
||||||
|
exit_price = c[j]
|
||||||
|
break
|
||||||
|
hit_sl = sl is not None and ((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_price = sl # SL fill al livello (favorevole alla tesi 'base')
|
||||||
|
break
|
||||||
|
if hit_tp:
|
||||||
|
if tp_fill == "level":
|
||||||
|
exit_price = tp
|
||||||
|
elif tp_fill == "close":
|
||||||
|
exit_price = c[j]
|
||||||
|
elif tp_fill == "open1":
|
||||||
|
exit_price = o[j + 1] if j + 1 < n else c[j]
|
||||||
|
break
|
||||||
|
if step == horizon:
|
||||||
|
if exit_at == "close":
|
||||||
|
exit_price = c[j]
|
||||||
|
elif exit_at == "open1":
|
||||||
|
exit_price = o[j + 1] if j + 1 < n else c[j]
|
||||||
|
break
|
||||||
|
if exit_price is None:
|
||||||
|
exit_price = c[j]
|
||||||
|
|
||||||
|
ret = (exit_price - entry) / entry * d * LEV - fee
|
||||||
|
capital = max(capital + capital * POS * ret, 10.0)
|
||||||
|
peak = max(peak, capital)
|
||||||
|
max_dd = max(max_dd, (peak - capital) / peak)
|
||||||
|
last_exit = j
|
||||||
|
trades += 1
|
||||||
|
wins += ret > 0
|
||||||
|
bars_tot += j - i
|
||||||
|
rets.append(ret)
|
||||||
|
|
||||||
|
if trades == 0:
|
||||||
|
return {}
|
||||||
|
r = np.array(rets)
|
||||||
|
return {"ret_pct": (capital / 1000.0 - 1) * 100, "dd_pct": max_dd * 100,
|
||||||
|
"trades": trades, "win_pct": wins / trades * 100,
|
||||||
|
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
|
||||||
|
"avg_bars": bars_tot / trades}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
data = load_sleeves()
|
||||||
|
keys = list(data.keys())
|
||||||
|
|
||||||
|
# ---- E1: contratto look-ahead (la policy ha livelli statici) -------------
|
||||||
|
print("=" * 100)
|
||||||
|
print("E1 CONTRATTO LOOK-AHEAD: rumorizzo gli array DOPO l'entrata e atr14 (la")
|
||||||
|
print(" policy NoSl non deve cambiare: livelli statici, non usa indicatori).")
|
||||||
|
print("=" * 100)
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
code, asset = "MR02_donchian_fade", "ETH"
|
||||||
|
base_sleeve = data[(code, asset)]
|
||||||
|
clean = simulate(NoSl, base_sleeve, {"mode": "none"}, start_ms=OOS_START_MS)
|
||||||
|
# rumorizzo atr14 interamente (policy non lo usa)
|
||||||
|
noisy = dict(base_sleeve)
|
||||||
|
noisy["atr14"] = base_sleeve["atr14"] * (1 + rng.normal(0, 0.5, len(base_sleeve["atr14"])))
|
||||||
|
res_noisy_atr = simulate(NoSl, noisy, {"mode": "none"}, start_ms=OOS_START_MS)
|
||||||
|
print(f" {code.split('_')[0]} {asset} clean : {_fmt(clean)}")
|
||||||
|
print(f" {code.split('_')[0]} {asset} atr14 noised : {_fmt(res_noisy_atr)} "
|
||||||
|
f"(identico => NoSl non legge atr14)")
|
||||||
|
|
||||||
|
# ---- E5: sottoperiodi OOS ------------------------------------------------
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("E5 SOTTOPERIODI OOS: l'edge di 'none' vs 'base' regge in 2 meta'?")
|
||||||
|
print("=" * 100)
|
||||||
|
mid = int(pd.Timestamp("2024-09-01", tz="UTC").value // 1e6)
|
||||||
|
for (code, asset) in keys:
|
||||||
|
sl_name = code.split("_")[0]
|
||||||
|
s = data[(code, asset)]
|
||||||
|
for lab, a, b in [("H1 23-11..24-09", OOS_START_MS, mid),
|
||||||
|
("H2 24-09..fine ", mid, None)]:
|
||||||
|
bse = simulate(ExitPolicy, s, {}, start_ms=a, end_ms=b)
|
||||||
|
non = simulate(NoSl, s, {"mode": "none"}, start_ms=a, end_ms=b)
|
||||||
|
if bse and non:
|
||||||
|
dret = non["ret_pct"] - bse["ret_pct"]
|
||||||
|
ddd = non["dd_pct"] - bse["dd_pct"]
|
||||||
|
flag = "OK" if (dret > -1 and ddd < 1) else "FAIL"
|
||||||
|
print(f" {sl_name} {asset} {lab}: base {_fmt(bse)}")
|
||||||
|
print(f" {sl_name} {asset} {lab}: none {_fmt(non)} "
|
||||||
|
f"[dret{dret:+.0f} ddd{ddd:+.1f} {flag}]")
|
||||||
|
|
||||||
|
# ---- E2/E3/E4: esecuzione realistica, base vs none nelle STESSE condizioni
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("E2/E3/E4 ESEGUIBILITA' LIVE (OOS). Confronto base vs none sotto:")
|
||||||
|
print(" IDEAL : exit close[j], TP@level (= harness)")
|
||||||
|
print(" OPEN1 : exit open[j+1], TP@open[j+1] (worker esce al poll successivo)")
|
||||||
|
print(" TPCLOSE: exit close[j], TP@close[j] (TP scoperto al poll, fill peggiore)")
|
||||||
|
print("=" * 100)
|
||||||
|
scenarios = [("IDEAL ", dict(exit_at="close", tp_fill="level")),
|
||||||
|
("OPEN1 ", dict(exit_at="open1", tp_fill="open1")),
|
||||||
|
("TPCLOSE", dict(exit_at="close", tp_fill="close"))]
|
||||||
|
summary = {sc[0]: {"none_wins_ret": 0, "none_wins_dd": 0, "tot": 0} for sc in scenarios}
|
||||||
|
for (code, asset) in keys:
|
||||||
|
sl_name = code.split("_")[0]
|
||||||
|
s = data[(code, asset)]
|
||||||
|
print(f"\n --- {sl_name} {asset} (OOS) ---")
|
||||||
|
for scname, kw in scenarios:
|
||||||
|
b = simulate_exec(s, "base", start_ms=OOS_START_MS, **kw)
|
||||||
|
nn = simulate_exec(s, "none", start_ms=OOS_START_MS, **kw)
|
||||||
|
if not b or not nn:
|
||||||
|
continue
|
||||||
|
dret = nn["ret_pct"] - b["ret_pct"]
|
||||||
|
ddd = nn["dd_pct"] - b["dd_pct"]
|
||||||
|
summary[scname]["tot"] += 1
|
||||||
|
summary[scname]["none_wins_ret"] += dret > -1
|
||||||
|
summary[scname]["none_wins_dd"] += ddd < 1
|
||||||
|
print(f" {scname} base: {_fmt(b)}")
|
||||||
|
print(f" {scname} none: {_fmt(nn)} [dret{dret:+.0f} ddd{ddd:+.1f}]")
|
||||||
|
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("VERDETTO ESEGUIBILITA' (none >= base su ret e dd, per scenario):")
|
||||||
|
for scname, kw in scenarios:
|
||||||
|
s = summary[scname]
|
||||||
|
print(f" {scname}: none vince-o-pareggia ret {s['none_wins_ret']}/{s['tot']}, "
|
||||||
|
f"dd {s['none_wins_dd']}/{s['tot']}")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""VERIFICATORE AVVERSARIALE OVERFIT — EXIT-22 no_sl.
|
||||||
|
|
||||||
|
Tesi del sopravvissuto: "rimuovere lo SL intrabar (resta TP+max_bars) MIGLIORA
|
||||||
|
ret E DD E Sharpe su tutte le 6 sleeve, plateau monotono tight<base<wide<none".
|
||||||
|
|
||||||
|
Ipotesi avversaria (da confutare o confermare):
|
||||||
|
(A) JITTER: il plateau none>wide>base e' monotono? Aggiungo ponti 3x/4x.
|
||||||
|
Se la curva e' monotona e satura (3x~4x~none), il plateau e' robusto;
|
||||||
|
se none e' un picco isolato oltre wide, sospetto.
|
||||||
|
(B) STABILITA' TEMPORALE: spezzo train (2018-20 vs 21-22) e OOS
|
||||||
|
(2023-11..2025-01 vs 2025-01..2026-05). Il guadagno c'e' in OGNI
|
||||||
|
finestra o e' concentrato in un solo regime?
|
||||||
|
(C) DIPENDENZA HURST (DECISIVO): i segnali in cache hanno hurst_max=0.55
|
||||||
|
(loss-guard toglie il regime persistente/trending — proprio dove gli SL
|
||||||
|
servono). Rigenero i segnali SENZA hurst (in memoria, cache intatta) e
|
||||||
|
ripeto base-vs-none. Se senza guard la policy crolla -> funziona SOLO
|
||||||
|
grazie al guard => condizione di validita'.
|
||||||
|
|
||||||
|
Esegui: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
|
||||||
|
scripts/analysis/exit_policies/verify_22_no_sl_overfit.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||||
|
|
||||||
|
import exit_lab # noqa: E402
|
||||||
|
from exit_lab import (ExitPolicy, simulate, load_sleeves, OOS_START_MS, # noqa: E402
|
||||||
|
CODES, ASSETS, LIVE_PARAMS, _atr14)
|
||||||
|
from src.data.downloader import load_data # noqa: E402
|
||||||
|
from src.live.strategy_loader import load_strategy # noqa: E402
|
||||||
|
|
||||||
|
# import della policy DAL SUO FILE (no copia)
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import importlib.util
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"policy_22", str(Path(__file__).resolve().parent / "22_no_sl.py"))
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
NoSl = _mod.NoSl
|
||||||
|
|
||||||
|
|
||||||
|
# ---- policy ponte: SL a scale arbitrario (per jitter 3x/4x) -----------------
|
||||||
|
class NoSlScale(ExitPolicy):
|
||||||
|
name = "no_sl_scale"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
scale = params.get("scale", None)
|
||||||
|
self.sl = None if scale is None else entry + float(scale) * (sl0 - entry)
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
return self.tp0, self.sl, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(r):
|
||||||
|
if not r:
|
||||||
|
return " (no trades)"
|
||||||
|
return (f"ret{r['ret_pct']:>8.0f}% dd{r['dd_pct']:>6.2f} sh{r['sharpe_t']:>6.2f} "
|
||||||
|
f"n{r['trades']:>4} win{r['win_pct']:>5.1f} bars{r['avg_bars']:>5.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# (A) JITTER: ponti di scala 1x(base) 1.5 2 3 4 none
|
||||||
|
# =============================================================================
|
||||||
|
def test_jitter(data):
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("(A) JITTER PLATEAU — scala SL: base(1x) 1.5x 2x 3x 4x none [OOS]")
|
||||||
|
print("=" * 78)
|
||||||
|
scales = [("base", {"scale": 1.0}), ("1.5x", {"scale": 1.5}),
|
||||||
|
("2x(wide)", {"scale": 2.0}), ("3x", {"scale": 3.0}),
|
||||||
|
("4x", {"scale": 4.0}), ("none", {"scale": None})]
|
||||||
|
monotonic_fail = 0
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
print(f"\n{key} [OOS 2023-11 ->]")
|
||||||
|
rets, dds, shs = [], [], []
|
||||||
|
for tag, g in scales:
|
||||||
|
r = simulate(NoSlScale, sleeve, g, start_ms=OOS_START_MS)
|
||||||
|
rets.append(r.get("ret_pct", 0)); dds.append(r.get("dd_pct", 0))
|
||||||
|
shs.append(r.get("sharpe_t", 0))
|
||||||
|
print(f" {tag:<10}{_fmt(r)}")
|
||||||
|
# plateau monotono atteso: ret crescente, dd decrescente, sh crescente
|
||||||
|
ret_mono = all(rets[i] <= rets[i + 1] + 1e-6 for i in range(len(rets) - 1))
|
||||||
|
sh_mono = all(shs[i] <= shs[i + 1] + 1e-6 for i in range(len(shs) - 1))
|
||||||
|
# saturazione: none vs 4x ravvicinati (plateau "piatto" in cima)?
|
||||||
|
sat = abs(rets[-1] - rets[-2]) / (abs(rets[-1]) + 1e-9) * 100
|
||||||
|
print(f" -> ret monotono crescente: {ret_mono} | sharpe monotono: {sh_mono}"
|
||||||
|
f" | gap none-vs-4x: {sat:.1f}%")
|
||||||
|
if not (ret_mono and sh_mono):
|
||||||
|
monotonic_fail += 1
|
||||||
|
print(f"\n Sleeve con plateau NON monotono: {monotonic_fail}/6")
|
||||||
|
return monotonic_fail
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# (B) STABILITA' TEMPORALE: sotto-finestre train e OOS
|
||||||
|
# =============================================================================
|
||||||
|
def test_temporal(data):
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("(B) STABILITA' TEMPORALE — base vs none in 4 sotto-finestre")
|
||||||
|
print("=" * 78)
|
||||||
|
ms = lambda s: int(pd.Timestamp(s, tz="UTC").value // 1e6)
|
||||||
|
wins = [
|
||||||
|
("TR 2018-2020", None, ms("2021-01-01")),
|
||||||
|
("TR 2021-2022", ms("2021-01-01"), OOS_START_MS),
|
||||||
|
("OOS 23-11..25-01", OOS_START_MS, ms("2025-01-01")),
|
||||||
|
("OOS 25-01..26-05", ms("2025-01-01"), None),
|
||||||
|
]
|
||||||
|
# conta in quante (sleeve x finestra) none batte base su ret E dd E sh
|
||||||
|
cells = 0
|
||||||
|
none_better_all = 0
|
||||||
|
none_worse_dd = 0
|
||||||
|
for wname, s0, s1 in wins:
|
||||||
|
print(f"\n--- finestra {wname} ---")
|
||||||
|
for (code, asset), sleeve in data.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
b = simulate(ExitPolicy, sleeve, {}, start_ms=s0, end_ms=s1)
|
||||||
|
nn = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=s0, end_ms=s1)
|
||||||
|
if not b or not nn:
|
||||||
|
print(f" {key:<10} (campione vuoto)")
|
||||||
|
continue
|
||||||
|
cells += 1
|
||||||
|
d_ret = nn["ret_pct"] - b["ret_pct"]
|
||||||
|
d_dd = nn["dd_pct"] - b["dd_pct"] # <0 = none meglio (dd minore)
|
||||||
|
d_sh = nn["sharpe_t"] - b["sharpe_t"]
|
||||||
|
allbetter = d_ret > 0 and d_dd < 0 and d_sh > 0
|
||||||
|
none_better_all += allbetter
|
||||||
|
none_worse_dd += d_dd > 1e-6
|
||||||
|
flag = "OK" if allbetter else ("dd+" if d_dd > 0 else "ret-" if d_ret <= 0 else "sh-")
|
||||||
|
print(f" {key:<10} base ret{b['ret_pct']:>7.0f} dd{b['dd_pct']:>5.1f} "
|
||||||
|
f"sh{b['sharpe_t']:>5.2f} | none ret{nn['ret_pct']:>7.0f} "
|
||||||
|
f"dd{nn['dd_pct']:>5.1f} sh{nn['sharpe_t']:>5.2f} | "
|
||||||
|
f"dRet{d_ret:>+7.0f} dDD{d_dd:>+5.1f} dSh{d_sh:>+5.2f} [{flag}]")
|
||||||
|
print(f"\n none meglio su (ret&dd&sh) in {none_better_all}/{cells} celle "
|
||||||
|
f"sleeve x finestra | none PEGGIORA il DD in {none_worse_dd}/{cells}")
|
||||||
|
return none_better_all, none_worse_dd, cells
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# (C) DECISIVO: segnali SENZA hurst (in memoria, cache intatta)
|
||||||
|
# =============================================================================
|
||||||
|
def build_sleeves_no_hurst():
|
||||||
|
"""Rigenera i segnali con hurst_max=None (loss-guard OFF). NON tocca la
|
||||||
|
cache su disco: ritorna un dict identico in forma a load_sleeves()."""
|
||||||
|
params = dict(LIVE_PARAMS)
|
||||||
|
params["hurst_max"] = None
|
||||||
|
out = {}
|
||||||
|
for code in CODES:
|
||||||
|
strat = load_strategy(code)
|
||||||
|
for asset in ASSETS:
|
||||||
|
df = load_data(asset, "1h")
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
sigs = strat.generate_signals(df, ts, **params)
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
out[(code, asset)] = {
|
||||||
|
"signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]),
|
||||||
|
float(s.metadata["sl"]), int(s.metadata["max_bars"]))
|
||||||
|
for s in sigs],
|
||||||
|
"open": df["open"].values.astype(float),
|
||||||
|
"high": h, "low": l, "close": c,
|
||||||
|
"ts_ms": df["timestamp"].values.astype(np.int64),
|
||||||
|
"atr14": _atr14(h, l, c),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_hurst_dependency(data_guard):
|
||||||
|
print("\n" + "=" * 78)
|
||||||
|
print("(C) DECISIVO — DIPENDENZA DAL FILTRO HURST")
|
||||||
|
print(" rigenero segnali con hurst_max=None (guard OFF), confronto base vs none")
|
||||||
|
print("=" * 78)
|
||||||
|
data_noh = build_sleeves_no_hurst()
|
||||||
|
# quanto cambia il numero di segnali (il guard quanti ne toglieva?)
|
||||||
|
print("\nConteggio segnali (guard ON cache vs guard OFF):")
|
||||||
|
for k in data_guard:
|
||||||
|
ng = len(data_guard[k]["signals"])
|
||||||
|
nh = len(data_noh[k]["signals"])
|
||||||
|
print(f" {k[0].split('_')[0]} {k[1]:<4} guard ON {ng:>5} OFF {nh:>5} "
|
||||||
|
f"(+{nh - ng} segnali tossici reintrodotti)")
|
||||||
|
|
||||||
|
for label, ms0 in [("FULL", None), ("OOS", OOS_START_MS)]:
|
||||||
|
print(f"\n--- {label} (guard OFF) base vs none ---")
|
||||||
|
none_better_all = none_worse_dd = none_worse_ret = cells = 0
|
||||||
|
for (code, asset), sleeve in data_noh.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
b = simulate(ExitPolicy, sleeve, {}, start_ms=ms0)
|
||||||
|
nn = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=ms0)
|
||||||
|
if not b or not nn:
|
||||||
|
continue
|
||||||
|
cells += 1
|
||||||
|
d_ret = nn["ret_pct"] - b["ret_pct"]
|
||||||
|
d_dd = nn["dd_pct"] - b["dd_pct"]
|
||||||
|
d_sh = nn["sharpe_t"] - b["sharpe_t"]
|
||||||
|
none_better_all += (d_ret > 0 and d_dd < 0 and d_sh > 0)
|
||||||
|
none_worse_dd += d_dd > 1e-6
|
||||||
|
none_worse_ret += d_ret <= 0
|
||||||
|
flag = "OK" if (d_ret > 0 and d_dd < 0 and d_sh > 0) else \
|
||||||
|
("dd+" if d_dd > 0 else "ret-" if d_ret <= 0 else "sh-")
|
||||||
|
print(f" {key:<10} base ret{b['ret_pct']:>8.0f} dd{b['dd_pct']:>6.1f} "
|
||||||
|
f"sh{b['sharpe_t']:>5.2f} | none ret{nn['ret_pct']:>8.0f} "
|
||||||
|
f"dd{nn['dd_pct']:>6.1f} sh{nn['sharpe_t']:>5.2f} | "
|
||||||
|
f"dRet{d_ret:>+8.0f} dDD{d_dd:>+6.1f} dSh{d_sh:>+5.2f} [{flag}]")
|
||||||
|
print(f" => guard OFF {label}: none meglio (ret&dd&sh) {none_better_all}/{cells}"
|
||||||
|
f" | none PEGGIORA dd {none_worse_dd}/{cells} | PEGGIORA ret {none_worse_ret}/{cells}")
|
||||||
|
return data_noh
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
data = load_sleeves()
|
||||||
|
mono_fail = test_jitter(data)
|
||||||
|
nb_all, nb_dd, cells = test_temporal(data)
|
||||||
|
test_hurst_dependency(data)
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""VERIFICA AVVERSARIALE — lente STRESS sul sopravvissuto EXIT-22 (mode='none', SL RIMOSSO).
|
||||||
|
|
||||||
|
Ipotesi avversaria: il vantaggio di togliere lo SL (ret/DD/Sharpe migliori) e' un
|
||||||
|
ARTEFATTO del regime calmo OOS 2024-25 e/o si dissolve sotto stress realistici. Senza
|
||||||
|
SL la coda non e' limitata a leva 3x: un trade puo' perdere fino al movimento avverso
|
||||||
|
intero entro max_bars (24h). Testo 4 stress:
|
||||||
|
|
||||||
|
(1) FEE 2x (FEE_RT=0.002): la policy ABBASSA il turnover (avg_bars 9->12-13, meno
|
||||||
|
trade) quindi la fee 2x dovrebbe penalizzarla MENO della base -> non e' la sua
|
||||||
|
debolezza, ma lo misuro comunque per onesta'.
|
||||||
|
(2) SOTTOPERIODO BEAR 2021-01..2022-12 (crash 2021-05-19, LUNA mag-2022, FTX nov-2022):
|
||||||
|
qui lo SL dovrebbe servire. Confronto ret/DD + WORST trade + 5 peggiori trade
|
||||||
|
none vs base. E' il test decisivo: se none esplode in DD o ha code mostruose qui,
|
||||||
|
il survivor e' confutato (l'OOS calmo nascondeva il rischio).
|
||||||
|
(3) SLIPPAGE AVVERSO 20bps sulle USCITE: ogni uscita (TP/horizon) pagata 20bps peggio
|
||||||
|
(prezzo di uscita penalizzato del 20bps contro la posizione). Penalizza di piu' le
|
||||||
|
policy che escono al close/horizon (none esce piu' spesso a horizon, non al TP-limit).
|
||||||
|
(4) OVERLAP/CHURN: la policy allunga la permanenza -> conto i segnali SCARTATI per
|
||||||
|
non-overlap (i<=last_exit) in piu' rispetto alla base, e l'effetto sul capitale.
|
||||||
|
|
||||||
|
Confuto SOLO con evidenza numerica concreta.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
import exit_lab # noqa: E402
|
||||||
|
from exit_lab import ExitPolicy, simulate, load_sleeves, HARD_CAP, LEV, POS # noqa: E402
|
||||||
|
|
||||||
|
# import della policy dal suo file
|
||||||
|
import importlib.util
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"policy_22", str(Path(__file__).resolve().parent / "22_no_sl.py"))
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
NoSl = _mod.NoSl
|
||||||
|
|
||||||
|
BEAR_START = int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6)
|
||||||
|
BEAR_END = int(pd.Timestamp("2023-01-01", tz="UTC").value // 1e6) # esclusivo
|
||||||
|
OOS_START = exit_lab.OOS_START_MS
|
||||||
|
|
||||||
|
DATA = load_sleeves()
|
||||||
|
|
||||||
|
|
||||||
|
def line(tag, key, r):
|
||||||
|
if not r:
|
||||||
|
print(f" {tag:<14}{key:<10} (no trades)")
|
||||||
|
return
|
||||||
|
print(f" {tag:<14}{key:<10} ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} "
|
||||||
|
f"sh{r['sharpe_t']:>5.2f} n{r['trades']:>4} win{r['win_pct']:>4.0f}% "
|
||||||
|
f"bars{r['avg_bars']:>5.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- (1) FEE 2x
|
||||||
|
def test_fee2x():
|
||||||
|
print("\n=== (1) FEE 2x (0.10%->0.20% RT x leva 3) ===")
|
||||||
|
orig = exit_lab.FEE_RT
|
||||||
|
for fee, label in [(0.001, "fee1x"), (0.002, "fee2x")]:
|
||||||
|
exit_lab.FEE_RT = fee
|
||||||
|
print(f" -- {label} --")
|
||||||
|
n_better_none = 0
|
||||||
|
for (code, asset), sleeve in DATA.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
b = simulate(ExitPolicy, sleeve, start_ms=OOS_START)
|
||||||
|
no = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=OOS_START)
|
||||||
|
flag = " <none>=base" if (no and b and no["ret_pct"] >= b["ret_pct"]
|
||||||
|
and no["dd_pct"] <= b["dd_pct"]) else ""
|
||||||
|
if flag:
|
||||||
|
n_better_none += 1
|
||||||
|
print(f" {key:<10} base ret{b['ret_pct']:>6.0f}% dd{b['dd_pct']:>5.1f} | "
|
||||||
|
f"none ret{no['ret_pct']:>6.0f}% dd{no['dd_pct']:>5.1f}{flag}")
|
||||||
|
print(f" -> none >= base (ret&dd) su {n_better_none}/6 sleeve")
|
||||||
|
exit_lab.FEE_RT = orig
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- (2) BEAR 2021-22
|
||||||
|
def worst_trades(policy_cls, sleeve, params, start_ms, end_ms, k=5):
|
||||||
|
"""Replica la logica di simulate ma raccoglie i ret per-trade per
|
||||||
|
estrarre i k peggiori e il worst. Stessa identica meccanica."""
|
||||||
|
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
|
||||||
|
n = len(c)
|
||||||
|
ctx = dict(sleeve)
|
||||||
|
policy_cls.prepare(ctx, **params)
|
||||||
|
fee = exit_lab.FEE_RT * LEV
|
||||||
|
last_exit = -1
|
||||||
|
rets = []
|
||||||
|
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
|
||||||
|
if ts[i] < start_ms or ts[i] >= end_ms:
|
||||||
|
continue
|
||||||
|
if i <= last_exit or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
horizon = min(int(pol.horizon), HARD_CAP)
|
||||||
|
fills = []
|
||||||
|
remaining = 1.0
|
||||||
|
j = i
|
||||||
|
for step in range(1, horizon + 1):
|
||||||
|
j = i + step
|
||||||
|
if j >= n:
|
||||||
|
j = n - 1
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
break
|
||||||
|
tp, sl, tpfrac = pol.levels(j)
|
||||||
|
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:
|
||||||
|
fills.append((remaining, sl)); remaining = 0.0
|
||||||
|
break
|
||||||
|
if hit_tp:
|
||||||
|
f = min(max(tpfrac, 0.0), 1.0) * remaining
|
||||||
|
if f > 0:
|
||||||
|
fills.append((f, tp)); remaining -= f
|
||||||
|
if remaining <= 1e-9:
|
||||||
|
break
|
||||||
|
pol.on_partial(j, tp, remaining)
|
||||||
|
if pol.after_bar(j):
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
break
|
||||||
|
if step == horizon:
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
if remaining > 1e-9:
|
||||||
|
fills.append((remaining, c[j]))
|
||||||
|
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
|
||||||
|
rets.append(ret * 1e4) # bps
|
||||||
|
last_exit = j
|
||||||
|
rets = np.array(sorted(rets))
|
||||||
|
return rets
|
||||||
|
|
||||||
|
|
||||||
|
def test_bear():
|
||||||
|
print("\n=== (2) SOTTOPERIODO BEAR 2021-01..2022-12 (LUNA/FTX/19-may-2021) ===")
|
||||||
|
print(" (ret/DD su periodo + WORST trade e media 5-peggiori, bps; leva 3x)")
|
||||||
|
agg_none_dd = []
|
||||||
|
agg_base_dd = []
|
||||||
|
for (code, asset), sleeve in DATA.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
b = simulate(ExitPolicy, sleeve, start_ms=BEAR_START, end_ms=BEAR_END)
|
||||||
|
no = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=BEAR_START, end_ms=BEAR_END)
|
||||||
|
rb = worst_trades(ExitPolicy, sleeve, {}, BEAR_START, BEAR_END)
|
||||||
|
rn = worst_trades(NoSl, sleeve, {"mode": "none"}, BEAR_START, BEAR_END)
|
||||||
|
wb = rb[0] if len(rb) else float("nan")
|
||||||
|
wn = rn[0] if len(rn) else float("nan")
|
||||||
|
w5b = rb[:5].mean() if len(rb) >= 5 else float("nan")
|
||||||
|
w5n = rn[:5].mean() if len(rn) >= 5 else float("nan")
|
||||||
|
if b:
|
||||||
|
agg_base_dd.append(b["dd_pct"])
|
||||||
|
if no:
|
||||||
|
agg_none_dd.append(no["dd_pct"])
|
||||||
|
print(f" {key:<10}")
|
||||||
|
print(f" base ret{b.get('ret_pct',0):>6.0f}% dd{b.get('dd_pct',0):>5.1f} "
|
||||||
|
f"n{b.get('trades',0):>3} | worst{wb:>8.0f}bps 5worst{w5b:>8.0f}bps")
|
||||||
|
print(f" none ret{no.get('ret_pct',0):>6.0f}% dd{no.get('dd_pct',0):>5.1f} "
|
||||||
|
f"n{no.get('trades',0):>3} | worst{wn:>8.0f}bps 5worst{w5n:>8.0f}bps "
|
||||||
|
f"{'TAIL+' if wn < wb - 1 else ''}")
|
||||||
|
print(f" -> DD medio bear: base {np.mean(agg_base_dd):.1f}% none {np.mean(agg_none_dd):.1f}%")
|
||||||
|
print(f" worst-DD bear: base {np.max(agg_base_dd):.1f}% none {np.max(agg_none_dd):.1f}%")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- (3) SLIPPAGE 20bps exit
|
||||||
|
class NoSlSlip(NoSl):
|
||||||
|
"""none + slippage avverso 20bps su OGNI uscita (sia TP che horizon/close).
|
||||||
|
Implementato penalizzando i livelli: tp eseguito 20bps peggio, e l'uscita al
|
||||||
|
close subisce un haircut equivalente applicato nel ret. Per semplicita' e
|
||||||
|
fedelta', applico lo slippage al RET finale come costo per-trade extra: ogni
|
||||||
|
trade paga 20bps di slippage sul notional (una uscita per trade)."""
|
||||||
|
name = "no_sl_slip"
|
||||||
|
|
||||||
|
|
||||||
|
class BaseSlip(ExitPolicy):
|
||||||
|
name = "base_slip"
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_slip(policy_cls, sleeve, params, start_ms, slip_bps=20.0):
|
||||||
|
"""simulate con costo di uscita avverso slip_bps (in bps di prezzo) applicato
|
||||||
|
al ret per-trade (oltre alla fee). Penalizza uscite al close E al TP."""
|
||||||
|
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
|
||||||
|
n = len(c)
|
||||||
|
ctx = dict(sleeve)
|
||||||
|
policy_cls.prepare(ctx, **params)
|
||||||
|
fee = exit_lab.FEE_RT * LEV
|
||||||
|
slip = slip_bps / 1e4 * LEV
|
||||||
|
capital = peak = 1000.0
|
||||||
|
max_dd = 0.0
|
||||||
|
last_exit = -1
|
||||||
|
trades = wins = 0
|
||||||
|
rets = []
|
||||||
|
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
|
||||||
|
if ts[i] < start_ms:
|
||||||
|
continue
|
||||||
|
if i <= last_exit or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
horizon = min(int(pol.horizon), HARD_CAP)
|
||||||
|
fills = []
|
||||||
|
remaining = 1.0
|
||||||
|
j = i
|
||||||
|
for step in range(1, horizon + 1):
|
||||||
|
j = i + step
|
||||||
|
if j >= n:
|
||||||
|
j = n - 1
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
break
|
||||||
|
tp, sl, tpfrac = pol.levels(j)
|
||||||
|
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:
|
||||||
|
fills.append((remaining, sl)); remaining = 0.0
|
||||||
|
break
|
||||||
|
if hit_tp:
|
||||||
|
f = min(max(tpfrac, 0.0), 1.0) * remaining
|
||||||
|
if f > 0:
|
||||||
|
fills.append((f, tp)); remaining -= f
|
||||||
|
if remaining <= 1e-9:
|
||||||
|
break
|
||||||
|
pol.on_partial(j, tp, remaining)
|
||||||
|
if pol.after_bar(j):
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
break
|
||||||
|
if step == horizon:
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
if remaining > 1e-9:
|
||||||
|
fills.append((remaining, c[j]))
|
||||||
|
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee - slip
|
||||||
|
capital = max(capital + capital * POS * ret, 10.0)
|
||||||
|
peak = max(peak, capital)
|
||||||
|
max_dd = max(max_dd, (peak - capital) / peak)
|
||||||
|
last_exit = j
|
||||||
|
trades += 1
|
||||||
|
wins += ret > 0
|
||||||
|
rets.append(ret)
|
||||||
|
if trades == 0:
|
||||||
|
return {}
|
||||||
|
r = np.array(rets)
|
||||||
|
return {"ret_pct": (capital / 1000.0 - 1) * 100, "dd_pct": max_dd * 100,
|
||||||
|
"trades": trades, "win_pct": wins / trades * 100,
|
||||||
|
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_slippage():
|
||||||
|
print("\n=== (3) SLIPPAGE AVVERSO 20bps su ogni uscita (OOS 2023-11+) ===")
|
||||||
|
print(" confronto: none-noslip vs none-slip20 vs base-slip20")
|
||||||
|
n_survive = 0
|
||||||
|
for (code, asset), sleeve in DATA.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
no0 = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=OOS_START)
|
||||||
|
nos = simulate_slip(NoSl, sleeve, {"mode": "none"}, OOS_START, 20.0)
|
||||||
|
bas = simulate_slip(ExitPolicy, sleeve, {}, OOS_START, 20.0)
|
||||||
|
surv = nos and bas and nos["ret_pct"] >= bas["ret_pct"] and nos["dd_pct"] <= bas["dd_pct"]
|
||||||
|
if surv:
|
||||||
|
n_survive += 1
|
||||||
|
print(f" {key:<10} none ret{no0['ret_pct']:>6.0f}% | none+slip ret{nos['ret_pct']:>6.0f}%"
|
||||||
|
f" dd{nos['dd_pct']:>5.1f} sh{nos['sharpe_t']:>5.2f} | base+slip ret{bas['ret_pct']:>6.0f}%"
|
||||||
|
f" dd{bas['dd_pct']:>5.1f} {'EDGE' if surv else 'lost'}")
|
||||||
|
print(f" -> none+slip >= base+slip (ret&dd) su {n_survive}/6 sleeve")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- (4) OVERLAP / CHURN
|
||||||
|
def count_dropped(policy_cls, sleeve, params, start_ms):
|
||||||
|
"""Conta i segnali scartati per non-overlap (i<=last_exit) e quelli eseguiti."""
|
||||||
|
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
|
||||||
|
n = len(c)
|
||||||
|
ctx = dict(sleeve)
|
||||||
|
policy_cls.prepare(ctx, **params)
|
||||||
|
last_exit = -1
|
||||||
|
dropped = executed = 0
|
||||||
|
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
|
||||||
|
if ts[i] < start_ms or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
if i <= last_exit:
|
||||||
|
dropped += 1
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
horizon = min(int(pol.horizon), HARD_CAP)
|
||||||
|
remaining = 1.0
|
||||||
|
j = i
|
||||||
|
for step in range(1, horizon + 1):
|
||||||
|
j = i + step
|
||||||
|
if j >= n:
|
||||||
|
j = n - 1
|
||||||
|
break
|
||||||
|
tp, sl, tpfrac = pol.levels(j)
|
||||||
|
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:
|
||||||
|
break
|
||||||
|
if hit_tp:
|
||||||
|
f = min(max(tpfrac, 0.0), 1.0) * remaining
|
||||||
|
remaining -= f
|
||||||
|
if remaining <= 1e-9:
|
||||||
|
break
|
||||||
|
pol.on_partial(j, tp, remaining)
|
||||||
|
if pol.after_bar(j):
|
||||||
|
break
|
||||||
|
if step == horizon:
|
||||||
|
break
|
||||||
|
last_exit = j
|
||||||
|
executed += 1
|
||||||
|
return executed, dropped
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlap():
|
||||||
|
print("\n=== (4) OVERLAP / CHURN (OOS 2023-11+) — segnali persi per non-overlap ===")
|
||||||
|
tot_extra = 0
|
||||||
|
for (code, asset), sleeve in DATA.items():
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
eb, db = count_dropped(ExitPolicy, sleeve, {}, OOS_START)
|
||||||
|
en, dn = count_dropped(NoSl, sleeve, {"mode": "none"}, OOS_START)
|
||||||
|
extra = dn - db
|
||||||
|
tot_extra += extra
|
||||||
|
print(f" {key:<10} base exec{eb:>4} drop{db:>3} | none exec{en:>4} drop{dn:>3} "
|
||||||
|
f"| extra-drop {extra:+d}")
|
||||||
|
print(f" -> segnali extra persi per overlap (none vs base): {tot_extra:+d} totali")
|
||||||
|
print(" (capitale gira meno: trade piu' lunghi, ma none rende comunque di piu' net)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_fee2x()
|
||||||
|
test_bear()
|
||||||
|
test_slippage()
|
||||||
|
test_overlap()
|
||||||
|
print("\n[fatto] verifica stress EXIT-22 no_sl completata.")
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"""TAIL-RISK AUDIT of EXIT-22 (no_sl) and EXIT-16 (close_confirm_sl).
|
||||||
|
|
||||||
|
Hypothesis under test (to REFUTE if possible): removing/softening the intrabar SL
|
||||||
|
is an artifact whose hidden cost is catastrophic tail risk in crash regimes.
|
||||||
|
|
||||||
|
Sections:
|
||||||
|
(1) Per-trade MAE (maximum adverse excursion, intrabar, leverage 3, % of notional
|
||||||
|
ret terms == same units as engine `ret`) for base vs no_sl vs close_confirm.
|
||||||
|
Distribution p50/p95/p99/max per sleeve.
|
||||||
|
(2) Crash windows: 2020-03-12, 2021-05-19, 2022-06, 2022-11 (FTX). Trades OPEN in
|
||||||
|
those windows: realized loss + MAE under base / no_sl / close_confirm.
|
||||||
|
(3) Live worker -2% fallback: with no_sl the strategy emits tp but sl=0 -> the
|
||||||
|
worker branch `if self.tp and self.sl` is False, falls to `elif self.max_bars`
|
||||||
|
(fade have max_bars) -> PURE horizon exit, the -2% `else` branch NEVER fires.
|
||||||
|
So no_sl LIVE has NO stop at all. We simulate what a -2% price stop WOULD have
|
||||||
|
capped, to quantify the protection that is in fact ABSENT.
|
||||||
|
(4) Disaster SL at 3x / 4x the base SL distance, intrabar: does it keep almost all
|
||||||
|
the no_sl gain while cutting the tail?
|
||||||
|
|
||||||
|
Run: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
|
||||||
|
scripts/analysis/exit_policies/verify_tail_risk.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
import exit_lab as EL # noqa: E402
|
||||||
|
from exit_lab import ExitPolicy, simulate, load_sleeves, LEV, OOS_START_MS # noqa: E402
|
||||||
|
|
||||||
|
DATA = load_sleeves()
|
||||||
|
SLEEVES = list(DATA.items())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
def _replay_trades(policy_cls, sleeve, params=None, start_ms=None, end_ms=None):
|
||||||
|
"""Re-run the engine logic but COLLECT per-trade detail incl. MAE.
|
||||||
|
|
||||||
|
MAE = worst adverse excursion (in `ret` units == leverage*frac move on notional)
|
||||||
|
measured on the bars the trade is actually OPEN, from entry bar+1 up to and
|
||||||
|
INCLUDING the exit bar (the exit bar's wick counts: it's where SL would trigger).
|
||||||
|
"""
|
||||||
|
params = params or {}
|
||||||
|
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
|
||||||
|
n = len(c)
|
||||||
|
ctx = dict(sleeve)
|
||||||
|
policy_cls.prepare(ctx, **params)
|
||||||
|
fee = EL.FEE_RT * LEV
|
||||||
|
last_exit = -1
|
||||||
|
out = []
|
||||||
|
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
|
||||||
|
if start_ms is not None and ts[i] < start_ms:
|
||||||
|
continue
|
||||||
|
if end_ms is not None and ts[i] >= end_ms:
|
||||||
|
continue
|
||||||
|
if i <= last_exit or i + 1 >= n:
|
||||||
|
continue
|
||||||
|
entry = c[i]
|
||||||
|
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
horizon = min(int(pol.horizon), EL.HARD_CAP)
|
||||||
|
fills = []
|
||||||
|
remaining = 1.0
|
||||||
|
j = i
|
||||||
|
worst = 0.0 # most negative excursion in ret units
|
||||||
|
for step in range(1, horizon + 1):
|
||||||
|
j = i + step
|
||||||
|
if j >= n:
|
||||||
|
j = n - 1
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
break
|
||||||
|
# adverse excursion of THIS bar (before any exit): worst intrabar price
|
||||||
|
adverse = l[j] if d == 1 else h[j]
|
||||||
|
exc = (adverse - entry) / entry * d * LEV
|
||||||
|
worst = min(worst, exc)
|
||||||
|
tp, sl, tpfrac = pol.levels(j)
|
||||||
|
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:
|
||||||
|
fills.append((remaining, sl)); remaining = 0.0
|
||||||
|
break
|
||||||
|
if hit_tp:
|
||||||
|
f = min(max(tpfrac, 0.0), 1.0) * remaining
|
||||||
|
if f > 0:
|
||||||
|
fills.append((f, tp)); remaining -= f
|
||||||
|
if remaining <= 1e-9:
|
||||||
|
break
|
||||||
|
pol.on_partial(j, tp, remaining)
|
||||||
|
if pol.after_bar(j):
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
break
|
||||||
|
if step == horizon:
|
||||||
|
fills.append((remaining, c[j])); remaining = 0.0
|
||||||
|
if remaining > 1e-9:
|
||||||
|
fills.append((remaining, c[j]))
|
||||||
|
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
|
||||||
|
last_exit = j
|
||||||
|
out.append({
|
||||||
|
"i": i, "j": j, "d": d, "entry": entry,
|
||||||
|
"ts_entry": ts[i], "ts_exit": ts[j],
|
||||||
|
"ret": ret, "mae": worst, "bars": j - i,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _pct(arr, q):
|
||||||
|
return np.percentile(arr, q) if len(arr) else float("nan")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- (1) MAE dist
|
||||||
|
|
||||||
|
def section1():
|
||||||
|
print("=" * 100)
|
||||||
|
print("(1) MAE DISTRIBUTION per sleeve (ret units = leverage*move on notional; "
|
||||||
|
"fee NOT included). Negative = adverse.")
|
||||||
|
print(" MAE is the worst intrabar excursion BEFORE exit. For base, SL caps it; "
|
||||||
|
"for no_sl/close_confirm it can run.")
|
||||||
|
print("=" * 100)
|
||||||
|
policies = _load_policies()
|
||||||
|
hdr = f"{'sleeve':<11}{'policy':<16}{'n':>5}{'p50':>9}{'p95':>9}{'p99':>9}{'max':>9}{'realP99':>10}{'realMax':>10}"
|
||||||
|
for (code, asset), sleeve in SLEEVES:
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
print(f"\n--- {key}")
|
||||||
|
print(hdr)
|
||||||
|
for pname, (cls, prm) in policies.items():
|
||||||
|
tr = _replay_trades(cls, sleeve, prm)
|
||||||
|
mae = np.array([t["mae"] for t in tr]) * 100 # to %
|
||||||
|
rets = np.array([t["ret"] for t in tr]) * 100
|
||||||
|
print(f"{'':<11}{pname:<16}{len(tr):>5}"
|
||||||
|
f"{_pct(mae,50):>9.2f}{_pct(mae,5):>9.2f}{_pct(mae,1):>9.2f}{mae.min():>9.2f}"
|
||||||
|
f"{_pct(rets,1):>10.2f}{rets.min():>10.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_policies():
|
||||||
|
"""Return {name: (cls, params)} for base, no_sl, close_confirm(buf0)."""
|
||||||
|
p22 = Path(__file__).resolve().parents[0] / "22_no_sl.py"
|
||||||
|
p16 = Path(__file__).resolve().parents[0] / "16_close_confirm_sl.py"
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
def _load(path, attr):
|
||||||
|
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return getattr(mod, attr)
|
||||||
|
|
||||||
|
NoSl = _load(p22, "NoSl")
|
||||||
|
CloseConfirm = _load(p16, "CloseConfirmSl")
|
||||||
|
return {
|
||||||
|
"base": (ExitPolicy, {}),
|
||||||
|
"no_sl": (NoSl, {"mode": "none"}),
|
||||||
|
"close_confirm0": (CloseConfirm, {"buffer": 0.0}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- (2) crashes
|
||||||
|
|
||||||
|
CRASHES = {
|
||||||
|
"2020-03-12 covid": ("2020-03-10", "2020-03-16"),
|
||||||
|
"2021-05-19 leverage": ("2021-05-17", "2021-05-23"),
|
||||||
|
"2022-06 3AC/Luna": ("2022-06-10", "2022-06-20"),
|
||||||
|
"2022-11 FTX": ("2022-11-07", "2022-11-12"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ms(s):
|
||||||
|
return int(pd.Timestamp(s, tz="UTC").value // 1e6)
|
||||||
|
|
||||||
|
|
||||||
|
def section2():
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("(2) CRASH WINDOWS — trades OPEN (entry inside window) per policy: realized "
|
||||||
|
"loss (ret%) and MAE%.")
|
||||||
|
print("=" * 100)
|
||||||
|
policies = _load_policies()
|
||||||
|
for label, (a, b) in CRASHES.items():
|
||||||
|
lo, hi = _ms(a), _ms(b)
|
||||||
|
print(f"\n### {label} [{a} .. {b}]")
|
||||||
|
any_trade = False
|
||||||
|
for (code, asset), sleeve in SLEEVES:
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
for pname, (cls, prm) in policies.items():
|
||||||
|
tr = _replay_trades(cls, sleeve, prm)
|
||||||
|
win = [t for t in tr if lo <= t["ts_entry"] <= hi]
|
||||||
|
if not win:
|
||||||
|
continue
|
||||||
|
any_trade = True
|
||||||
|
rets = np.array([t["ret"] for t in win]) * 100
|
||||||
|
mae = np.array([t["mae"] for t in win]) * 100
|
||||||
|
worst = min(win, key=lambda t: t["ret"])
|
||||||
|
print(f" {key:<11}{pname:<16}n{len(win):>3} "
|
||||||
|
f"sumRet{rets.sum():>8.2f}% worstRet{rets.min():>8.2f}% "
|
||||||
|
f"worstMAE{mae.min():>8.2f}% avgBars{np.mean([t['bars'] for t in win]):>5.1f}")
|
||||||
|
if not any_trade:
|
||||||
|
print(" (no trades opened in window across sleeves)")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- (3) -2% fallback
|
||||||
|
|
||||||
|
def section3():
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("(3) LIVE -2% FALLBACK on no_sl. NOTE: with no_sl the worker has NO stop at "
|
||||||
|
"all (branch analysis in module docstring).")
|
||||||
|
print(" Below = what a -2% PRICE stop (==-6% ret at lev3) WOULD cap if it WERE "
|
||||||
|
"wired. Compares no_sl ret vs a synthetic no_sl+2%stop.")
|
||||||
|
print("=" * 100)
|
||||||
|
NoSl = _load_policies()["no_sl"][0]
|
||||||
|
stop_ret = -0.02 * LEV # -2% price move * leverage = -6% on notional in ret units
|
||||||
|
hdr = f"{'sleeve':<11}{'no_sl ret%':>12}{'+2%stop ret%':>14}{'Δret pp':>10}{'n capped':>10}{'worst no_sl':>13}{'worst +2%':>11}"
|
||||||
|
print(hdr)
|
||||||
|
for (code, asset), sleeve in SLEEVES:
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
tr = _replay_trades(NoSl, sleeve, {"mode": "none"})
|
||||||
|
# synthetic: if MAE breaches stop_ret, realize at stop_ret (approx; ignores
|
||||||
|
# that price may recover — that's the point: a -2% stop locks the loss).
|
||||||
|
base_rets = np.array([t["ret"] for t in tr])
|
||||||
|
capped = []
|
||||||
|
n_cap = 0
|
||||||
|
fee = EL.FEE_RT * LEV
|
||||||
|
for t in tr:
|
||||||
|
if t["mae"] <= stop_ret:
|
||||||
|
capped.append(stop_ret - fee) # stopped at -2% price, pay fee
|
||||||
|
n_cap += 1
|
||||||
|
else:
|
||||||
|
capped.append(t["ret"])
|
||||||
|
capped = np.array(capped)
|
||||||
|
|
||||||
|
def _compound(rets):
|
||||||
|
cap = 1000.0
|
||||||
|
for r in rets:
|
||||||
|
cap = max(cap + cap * EL.POS * r, 10.0)
|
||||||
|
return (cap / 1000.0 - 1) * 100
|
||||||
|
|
||||||
|
r_nosl = _compound(base_rets)
|
||||||
|
r_stop = _compound(capped)
|
||||||
|
print(f"{key:<11}{r_nosl:>12.0f}{r_stop:>14.0f}{r_stop - r_nosl:>10.1f}"
|
||||||
|
f"{n_cap:>10}{base_rets.min()*100:>13.2f}{capped.min()*100:>11.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- (4) disaster SL
|
||||||
|
|
||||||
|
class DisasterSl(ExitPolicy):
|
||||||
|
"""no_sl behaviour + a FAR intrabar disaster stop at `mult` x base SL distance."""
|
||||||
|
name = "disaster_sl"
|
||||||
|
|
||||||
|
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
|
||||||
|
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
|
||||||
|
mult = float(params.get("mult", 3.0))
|
||||||
|
self.sl = entry + mult * (sl0 - entry)
|
||||||
|
|
||||||
|
def levels(self, j: int):
|
||||||
|
return self.tp0, self.sl, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _full_metrics(cls, sleeve, prm):
|
||||||
|
full = simulate(cls, sleeve, prm)
|
||||||
|
oos = simulate(cls, sleeve, prm, start_ms=OOS_START_MS)
|
||||||
|
return full, oos
|
||||||
|
|
||||||
|
|
||||||
|
def section4():
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("(4) DISASTER SL — no_sl + far intrabar stop at 3x / 4x base SL distance. "
|
||||||
|
"Keep the gain, cut the tail?")
|
||||||
|
print("=" * 100)
|
||||||
|
NoSl = _load_policies()["no_sl"][0]
|
||||||
|
hdr = (f"{'sleeve':<11}{'policy':<13}"
|
||||||
|
f"{'FULLret%':>10}{'FULLdd%':>9}{'FULLsh':>8}"
|
||||||
|
f"{'OOSret%':>9}{'OOSdd%':>8}{'OOSsh':>7}{'worstMAE%':>11}{'nStop':>7}")
|
||||||
|
print(hdr)
|
||||||
|
for (code, asset), sleeve in SLEEVES:
|
||||||
|
key = f"{code.split('_')[0]} {asset}"
|
||||||
|
rows = [
|
||||||
|
("base", ExitPolicy, {}),
|
||||||
|
("no_sl", NoSl, {"mode": "none"}),
|
||||||
|
("disaster3x", DisasterSl, {"mult": 3.0}),
|
||||||
|
("disaster4x", DisasterSl, {"mult": 4.0}),
|
||||||
|
]
|
||||||
|
print(f"--- {key}")
|
||||||
|
for pname, cls, prm in rows:
|
||||||
|
full, oos = _full_metrics(cls, sleeve, prm)
|
||||||
|
tr = _replay_trades(cls, sleeve, prm)
|
||||||
|
mae = np.array([t["mae"] for t in tr]) * 100
|
||||||
|
# count trades that hit the disaster stop (ret near the stop level)
|
||||||
|
n_stop = 0
|
||||||
|
if pname.startswith("disaster"):
|
||||||
|
mult = prm["mult"]
|
||||||
|
for t, raw in zip(tr, sleeve["signals"]):
|
||||||
|
pass
|
||||||
|
# simpler: a stop hit shows up as a large negative ret roughly = stop level
|
||||||
|
n_stop = int(np.sum(mae <= -2.0 * mult * LEV / LEV * 0)) # placeholder
|
||||||
|
print(f"{'':<11}{pname:<13}"
|
||||||
|
f"{full.get('ret_pct',0):>10.0f}{full.get('dd_pct',0):>9.2f}{full.get('sharpe_t',0):>8.2f}"
|
||||||
|
f"{oos.get('ret_pct',0):>9.0f}{oos.get('dd_pct',0):>8.2f}{oos.get('sharpe_t',0):>7.2f}"
|
||||||
|
f"{mae.min():>11.2f}{'':>7}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- aggregate
|
||||||
|
|
||||||
|
def section5_aggregate():
|
||||||
|
"""Equal-weight aggregate across the 6 sleeves: does no_sl's tail blow up the
|
||||||
|
PORTFOLIO worst-trade vs disaster3x? Sum of per-sleeve compounded won't aggregate
|
||||||
|
DD honestly, so we report the WORST single-trade ret across all sleeves and the
|
||||||
|
99th pct of the pooled trade distribution."""
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print("(5) POOLED TRADE DISTRIBUTION across all 6 sleeves (the real tail metric).")
|
||||||
|
print("=" * 100)
|
||||||
|
NoSl = _load_policies()["no_sl"][0]
|
||||||
|
cfgs = [
|
||||||
|
("base", ExitPolicy, {}),
|
||||||
|
("no_sl", NoSl, {"mode": "none"}),
|
||||||
|
("close_confirm0", _load_policies()["close_confirm0"][0], {"buffer": 0.0}),
|
||||||
|
("disaster3x", DisasterSl, {"mult": 3.0}),
|
||||||
|
("disaster4x", DisasterSl, {"mult": 4.0}),
|
||||||
|
]
|
||||||
|
print(f"{'policy':<16}{'n':>6}{'retP1%':>9}{'retMin%':>9}{'maeP1%':>9}{'maeMin%':>9}{'<-15%cnt':>10}")
|
||||||
|
for pname, cls, prm in cfgs:
|
||||||
|
allret, allmae = [], []
|
||||||
|
for (code, asset), sleeve in SLEEVES:
|
||||||
|
tr = _replay_trades(cls, sleeve, prm)
|
||||||
|
allret += [t["ret"] * 100 for t in tr]
|
||||||
|
allmae += [t["mae"] * 100 for t in tr]
|
||||||
|
ar = np.array(allret); am = np.array(allmae)
|
||||||
|
n_bad = int(np.sum(ar < -15.0))
|
||||||
|
print(f"{pname:<16}{len(ar):>6}{_pct(ar,1):>9.2f}{ar.min():>9.2f}"
|
||||||
|
f"{_pct(am,1):>9.2f}{am.min():>9.2f}{n_bad:>10}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
section1()
|
||||||
|
section2()
|
||||||
|
section3()
|
||||||
|
section4()
|
||||||
|
section5_aggregate()
|
||||||
@@ -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,66 @@
|
|||||||
|
import sys; sys.path.insert(0,".")
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from scripts.analysis.regime_lab import load_features
|
||||||
|
from scripts.analysis.explore_lab import atr
|
||||||
|
import importlib
|
||||||
|
FEE=0.001; LEV=3
|
||||||
|
|
||||||
|
def load_strat(mod):
|
||||||
|
m=importlib.import_module(mod)
|
||||||
|
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
|
||||||
|
|
||||||
|
STR={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
|
||||||
|
|
||||||
|
def replay(df, sigs):
|
||||||
|
h=df['high'].values; l=df['low'].values; c=df['close'].values
|
||||||
|
out=[]; last=-1
|
||||||
|
for s in sigs:
|
||||||
|
i=s.idx
|
||||||
|
if i<=last: continue
|
||||||
|
d=s.direction; tp=s.metadata['tp']; sl=s.metadata['sl']; mb=s.metadata['max_bars']
|
||||||
|
j=min(i+mb,len(c)-1); exit_p=c[j]
|
||||||
|
for t in range(i+1,j+1):
|
||||||
|
if d==1:
|
||||||
|
if l[t]<=sl: exit_p=sl;j=t;break
|
||||||
|
if h[t]>=tp: exit_p=tp;j=t;break
|
||||||
|
else:
|
||||||
|
if h[t]>=sl: exit_p=sl;j=t;break
|
||||||
|
if l[t]<=tp: exit_p=tp;j=t;break
|
||||||
|
ret=(exit_p-c[i])/c[i]*d*LEV-FEE*LEV
|
||||||
|
out.append((i,ret)); last=j
|
||||||
|
return out
|
||||||
|
|
||||||
|
# raccogli tutti i trade con il loro dvol_pct e hurst all'ingresso
|
||||||
|
rows=[]
|
||||||
|
for asset in ("BTC","ETH"):
|
||||||
|
df=load_features(asset,"1h"); ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
for code,(mod,par) in STR.items():
|
||||||
|
s=load_strat(mod); sigs=s.generate_signals(df,ts,**par)
|
||||||
|
for i,ret in replay(df,sigs):
|
||||||
|
rows.append(dict(asset=asset,code=code,year=ts.iloc[i].year,ret=ret,
|
||||||
|
dvol_pct=df['dvol_pct'].iloc[i], hurst=df['hurst'].iloc[i],
|
||||||
|
dvol=df['dvol'].iloc[i]))
|
||||||
|
R=pd.DataFrame(rows).dropna(subset=['dvol_pct'])
|
||||||
|
print(f"trade totali (con DVOL, 2021+): {len(R)}")
|
||||||
|
|
||||||
|
print("\n=== PnL medio per trade per TERZILE DVOL (bassa/media/alta vol) ===")
|
||||||
|
R['dvbin']=pd.cut(R['dvol_pct'],[0,.33,.66,1.0],labels=['LOW-vol','MID','HIGH-vol'])
|
||||||
|
g=R.groupby('dvbin',observed=True)['ret']
|
||||||
|
print(f" {'regime':<10}{'n':>6}{'ret_medio%':>12}{'win%':>8}{'somma%':>10}")
|
||||||
|
for b in ['LOW-vol','MID','HIGH-vol']:
|
||||||
|
x=R[R.dvbin==b]['ret']
|
||||||
|
print(f" {b:<10}{len(x):>6}{x.mean()*100:>12.3f}{(x>0).mean()*100:>8.1f}{x.sum()*100:>10.0f}")
|
||||||
|
|
||||||
|
print("\n=== dentro LOW-vol: split per HURST (anti-persistente vs trending) ===")
|
||||||
|
LV=R[R.dvbin=='LOW-vol'].copy()
|
||||||
|
LV['hbin']=pd.cut(LV['hurst'],[0,.45,.55,1.0],labels=['hurst<.45 (anti-pers)','.45-.55','>.55 (trend)'])
|
||||||
|
for b in ['hurst<.45 (anti-pers)','.45-.55','>.55 (trend)']:
|
||||||
|
x=LV[LV.hbin==b]['ret']
|
||||||
|
if len(x): print(f" {b:<24}{len(x):>6} ret_medio {x.mean()*100:>+7.3f}% win {(x>0).mean()*100:>5.1f}% somma {x.sum()*100:>+6.0f}%")
|
||||||
|
|
||||||
|
print("\n=== per anno: PnL fade in LOW-vol vs resto ===")
|
||||||
|
for y in range(2021,2027):
|
||||||
|
lo=R[(R.year==y)&(R.dvbin=='LOW-vol')]['ret']; hi=R[(R.year==y)&(R.dvbin!='LOW-vol')]['ret']
|
||||||
|
print(f" {y}: LOW-vol somma {lo.sum()*100:>+6.0f}% (n{len(lo)}) | MID/HIGH somma {hi.sum()*100:>+6.0f}% (n{len(hi)})")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import sys; sys.path.insert(0,".")
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from scripts.analysis.regime_lab import load_features
|
||||||
|
import importlib
|
||||||
|
FEE=0.001; LEV=3
|
||||||
|
def load_strat(mod):
|
||||||
|
m=importlib.import_module(mod)
|
||||||
|
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
|
||||||
|
STR={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
|
||||||
|
def replay(df,sigs):
|
||||||
|
h=df['high'].values;l=df['low'].values;c=df['close'].values;out=[];last=-1
|
||||||
|
for s in sigs:
|
||||||
|
i=s.idx
|
||||||
|
if i<=last: continue
|
||||||
|
d=s.direction;tp=s.metadata['tp'];sl=s.metadata['sl'];mb=s.metadata['max_bars'];j=min(i+mb,len(c)-1);exit_p=c[j];reason='time'
|
||||||
|
for t in range(i+1,j+1):
|
||||||
|
if d==1:
|
||||||
|
if l[t]<=sl: exit_p=sl;j=t;reason='sl';break
|
||||||
|
if h[t]>=tp: exit_p=tp;j=t;reason='tp';break
|
||||||
|
else:
|
||||||
|
if h[t]>=sl: exit_p=sl;j=t;reason='sl';break
|
||||||
|
if l[t]<=tp: exit_p=tp;j=t;reason='tp';break
|
||||||
|
out.append((i,(exit_p-c[i])/c[i]*d*LEV-FEE*LEV,reason));last=j
|
||||||
|
return out
|
||||||
|
rows=[]
|
||||||
|
for asset in ("BTC","ETH"):
|
||||||
|
df=load_features(asset,"1h");ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
for code,(mod,par) in STR.items():
|
||||||
|
s=load_strat(mod)
|
||||||
|
for i,ret,reason in replay(df,s.generate_signals(df,ts,**par)):
|
||||||
|
rows.append(dict(ret=ret,reason=reason,dvol_pct=df['dvol_pct'].iloc[i],hurst=df['hurst'].iloc[i],
|
||||||
|
vratio=df['vratio'].iloc[i],higuchi=df['higuchi'].iloc[i]))
|
||||||
|
R=pd.DataFrame(rows).dropna(subset=['dvol_pct','hurst'])
|
||||||
|
L=R[R.ret<0] # solo i trade in perdita
|
||||||
|
print(f"trade {len(R)} | in perdita {len(L)} ({len(L)/len(R)*100:.0f}%) | somma perdite {L.ret.sum()*100:.0f}% | media perdita {L.ret.mean()*100:.2f}%")
|
||||||
|
print("\n=== somma PERDITE per regime (dove si concentra il danno) ===")
|
||||||
|
R['dvbin']=pd.cut(R.dvol_pct,[0,.33,.66,1],labels=['LOWvol','MID','HIGHvol'])
|
||||||
|
R['hbin']=pd.cut(R.hurst,[0,.45,.55,1],labels=['anti<.45','.45-.55','trend>.55'])
|
||||||
|
piv=R[R.ret<0].pivot_table(index='dvbin',columns='hbin',values='ret',aggfunc='sum',observed=True)*100
|
||||||
|
print((piv.round(0)).to_string())
|
||||||
|
print("\n (numeri = somma % delle perdite per cella; piu negativo = piu danno)")
|
||||||
|
print("\n=== quota di SL (stop) per regime ===")
|
||||||
|
slr=R.groupby(['dvbin','hbin'],observed=True).apply(lambda x:(x.reason=='sl').mean()*100, include_groups=False)
|
||||||
|
print(slr.round(0).to_string())
|
||||||
|
# worst tail
|
||||||
|
print(f"\n=== peggiori 1% trade: dove? ===")
|
||||||
|
W=R.nsmallest(max(10,len(R)//100),'ret')
|
||||||
|
print(f" worst {len(W)} trade: dvol_pct medio {W.dvol_pct.mean():.2f}, hurst medio {W.hurst.mean():.2f}, quota hurst>.55 {(W.hurst>.55).mean()*100:.0f}%, quota dvol<.33 {(W.dvol_pct<.33).mean()*100:.0f}%")
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import sys; sys.path.insert(0,".")
|
||||||
|
import numpy as np, pandas as pd, importlib
|
||||||
|
from scripts.analysis.combine_portfolio import IDX, SPLIT, INIT, _norm, metrics, port_returns, build_trades
|
||||||
|
from src.portfolio.sleeves import all_sleeve_equities
|
||||||
|
from scripts.analysis.regime_lab import load_features
|
||||||
|
|
||||||
|
def load_strat(mod):
|
||||||
|
m=importlib.import_module(mod)
|
||||||
|
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
|
||||||
|
FADES={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
|
||||||
|
FEE=0.001; LEV=3; POS=0.15
|
||||||
|
|
||||||
|
def fade_equity_filtered(code, asset, hurst_thr=None):
|
||||||
|
"""equity giornaliera dello sleeve fade, opz. filtrata Hurst<thr (skip hurst>=thr). Convenzione fade_daily_equity."""
|
||||||
|
mod,par=FADES[code]; s=load_strat(mod)
|
||||||
|
df=load_features(asset,"1h"); ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
h=df['high'].values; l=df['low'].values; c=df['close'].values; hur=df['hurst'].values
|
||||||
|
eq=np.full(len(c),INIT,float); cap=INIT; last=-1
|
||||||
|
for sg in s.generate_signals(df,ts,**par):
|
||||||
|
i=sg.idx
|
||||||
|
if i<=last: continue
|
||||||
|
if hurst_thr is not None and not np.isnan(hur[i]) and hur[i]>=hurst_thr: continue # FILTRO
|
||||||
|
d=sg.direction; tp=sg.metadata['tp']; sl=sg.metadata['sl']; mb=sg.metadata['max_bars']
|
||||||
|
j=min(i+mb,len(c)-1); exit_p=c[j]
|
||||||
|
for t in range(i+1,j+1):
|
||||||
|
if d==1:
|
||||||
|
if l[t]<=sl: exit_p=sl;j=t;break
|
||||||
|
if h[t]>=tp: exit_p=tp;j=t;break
|
||||||
|
else:
|
||||||
|
if h[t]>=sl: exit_p=sl;j=t;break
|
||||||
|
if l[t]<=tp: exit_p=tp;j=t;break
|
||||||
|
ret=(exit_p-c[i])/c[i]*d*LEV-FEE*LEV
|
||||||
|
cap=max(cap+cap*POS*ret,10.0); eq[j:]=cap; last=j
|
||||||
|
sser=pd.Series(eq,index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
|
||||||
|
return _norm(sser)
|
||||||
|
|
||||||
|
base=all_sleeve_equities()
|
||||||
|
fade_ids=["MR01_BTC","MR02_BTC","MR07_BTC","MR01_ETH","MR02_ETH","MR07_ETH"]
|
||||||
|
|
||||||
|
def port(members):
|
||||||
|
dr=port_returns(members); return metrics(dr), metrics(dr,lo=SPLIT)
|
||||||
|
|
||||||
|
# baseline PORT06
|
||||||
|
fB,oB=port(base)
|
||||||
|
print(f"PORT06 baseline (17 sleeve): FULL Sharpe {fB['sharpe']:.2f} DD {fB['dd']:.2f}% | OOS Sharpe {oB['sharpe']:.2f} DD {oB['dd']:.2f}% ret {oB['ret']:+.0f}%")
|
||||||
|
|
||||||
|
# sostituisci le 6 fade con versione Hurst-skip
|
||||||
|
for thr in (0.55, 0.50):
|
||||||
|
filt=dict(base)
|
||||||
|
for fid in fade_ids:
|
||||||
|
code,asset=fid.split("_")
|
||||||
|
filt[fid]=fade_equity_filtered(code,asset,hurst_thr=thr)
|
||||||
|
fF,oF=port(filt)
|
||||||
|
print(f"PORT06 + Hurst-skip h<{thr} sulle fade: FULL Sharpe {fF['sharpe']:.2f} DD {fF['dd']:.2f}% | OOS Sharpe {oF['sharpe']:.2f} DD {oF['dd']:.2f}% ret {oF['ret']:+.0f}%")
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
export const meta = {
|
||||||
|
name: 'fade-lossguard',
|
||||||
|
description: 'Sistema anti-perdite per le fade in regime trending/low-vol: test meccanismi su MR01/02/07',
|
||||||
|
phases: [
|
||||||
|
{ title: 'Test', detail: 'agenti: ogni meccanismo di filtro applicato alle fade reali (BTC+ETH)' },
|
||||||
|
{ title: 'Synth', detail: 'classifica + miglior loss-guard, gate: riduce DD senza uccidere edge' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = `
|
||||||
|
=== Harness (gia pronto) ===
|
||||||
|
import sys; sys.path.insert(0,'.')
|
||||||
|
import numpy as np, pandas as pd, importlib
|
||||||
|
from scripts.analysis.regime_lab import load_features, report
|
||||||
|
from scripts.analysis.explore_lab import robust, atr
|
||||||
|
|
||||||
|
def load_strat(mod):
|
||||||
|
m=importlib.import_module(mod)
|
||||||
|
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
|
||||||
|
FADES={'MR01':('scripts.strategies.MR01_bollinger_fade',dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
'MR02':('scripts.strategies.MR02_donchian_fade',dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
'MR07':('scripts.strategies.MR07_return_reversal',dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
|
||||||
|
|
||||||
|
# colonne regime_lab (causali): dvol, dvol_pct, vrp, funding_z, dvol_chg, hurst, higuchi, vratio, frac_up/dn
|
||||||
|
# ADX (se ti serve) calcolalo causale da OHLC; efficiency-ratio Kaufman = |c[i]-c[i-n]| / sum|diff| su [i-n,i].
|
||||||
|
|
||||||
|
# PATTERN: genera i segnali fade, poi APPLICA IL TUO FILTRO scartando le entries in regime sfavorevole,
|
||||||
|
# confronta BASELINE vs FILTRATA su ogni fade x asset:
|
||||||
|
def entries_from(strat, df, par, keep=lambda df,i: True):
|
||||||
|
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
out=[]
|
||||||
|
for s in strat.generate_signals(df,ts,**par):
|
||||||
|
if keep(df, s.idx): # keep=False -> filtro scarta (loss-guard)
|
||||||
|
out.append({'i':s.idx,'d':s.direction,'tp':s.metadata['tp'],'sl':s.metadata['sl'],'max_bars':s.metadata['max_bars']})
|
||||||
|
return out
|
||||||
|
# per ogni (fade,asset): res_base=report(.., entries_from(..., keep=tutto)); res_filt=report(.., col tuo keep)
|
||||||
|
# confronta: Sharpe OOS, DD full/oos, ret, #trade (quanti scartati), e robust(). Aggrega sulle 6 combo.
|
||||||
|
`
|
||||||
|
|
||||||
|
const CONTEXT = `
|
||||||
|
PROBLEMA: le fade (MR01 Bollinger, MR02 Donchian, MR07 return-reversal) sono mean-reversion 1h con
|
||||||
|
filtro trend EMA200 (trend_max=3.0). DIAGNOSI EMPIRICA (3022 trade, 2021+): le PERDITE e gli STOP
|
||||||
|
si concentrano nel regime PERSISTENTE/TRENDING, NON nella bassa vol:
|
||||||
|
- somma perdite per cella (Hurst x DVOL): la cella peggiore e' hurst>0.55 (-2695% in low-vol,
|
||||||
|
dominante in ogni terzile vol). I peggiori 1% trade hanno hurst medio 0.61 (77% con hurst>0.55).
|
||||||
|
- tasso STOP-LOSS: 43% quando hurst>0.55 vs 21% quando hurst<0.45 (anti-persistente). 2x.
|
||||||
|
- net: le celle restano positive (i winner battono), quindi filtrare toglie anche winner -> il
|
||||||
|
loss-guard e' utile SOLO se riduce DD/coda SENZA uccidere l'edge netto.
|
||||||
|
RICERCA ESTERNA (confermata): (a) Hurst regime filter: MR solo H<0.45, in 0.45-0.55 ridurre size,
|
||||||
|
evitare H>0.55. (b) ADX: MR profit factor 1.62 con ADX<20 vs -0.74 con ADX>30 (switch di regime piu'
|
||||||
|
importante). (c) ATR/vol-EXPANSION ratio>1.5 disabilita MR -> ha prevenuto il 72% delle perdite
|
||||||
|
maggiori. (d) time-stop: se non rientra in ~15 barre e' un trend, esci.
|
||||||
|
|
||||||
|
OBIETTIVO: trovare il MIGLIOR meccanismo (o combo) che, applicato alle fade reali, RIDUCE DD/coda/
|
||||||
|
stop-rate MANTENENDO l'edge netto OOS. Metodologia: causale no-look-ahead (le colonne regime_lab
|
||||||
|
sono causali; i filtri usano solo dati <= i), netto fee (report() fa OOS+sweep). LEZIONE FR01: un
|
||||||
|
filtro che riduce le perdite ma anche i winner spesso NON migliora -> il gate vero e' DD giu' a
|
||||||
|
parita' (o quasi) di Sharpe/ret, idealmente Sharpe SU e DD GIU'.
|
||||||
|
|
||||||
|
` + API
|
||||||
|
|
||||||
|
const SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
meccanismo: { type: 'string' },
|
||||||
|
descrizione: { type: 'string' },
|
||||||
|
base_oos_sharpe: { type: 'number' }, filt_oos_sharpe: { type: 'number' },
|
||||||
|
base_dd_full: { type: 'number' }, filt_dd_full: { type: 'number' },
|
||||||
|
base_oos_ret: { type: 'number' }, filt_oos_ret: { type: 'number' },
|
||||||
|
trade_scartati_pct: { type: 'number' },
|
||||||
|
riduce_perdite: { type: 'boolean', description: 'riduce DD/coda/stop-rate' },
|
||||||
|
preserva_edge: { type: 'boolean', description: 'edge netto OOS preservato (Sharpe non crolla, robust resta)' },
|
||||||
|
buon_lossguard: { type: 'boolean', description: 'riduce perdite SENZA uccidere edge -> candidato' },
|
||||||
|
verdetto: { type: 'string', description: 'numeri base vs filtrato aggregati sulle 6 combo fade x asset' },
|
||||||
|
},
|
||||||
|
required: ['meccanismo', 'buon_lossguard', 'riduce_perdite', 'preserva_edge', 'verdetto'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const MECHS = [
|
||||||
|
['Hurst-skip H>0.55', 'scarta le fade quando rolling-hurst(window=100) >= 0.55 (regime persistente). Test anche soglia 0.50 e 0.60, riporta la migliore.'],
|
||||||
|
['Hurst-size transition', 'NON scartare ma RIDURRE: tieni tutte le entries ma pesa size 1.0 se hurst<0.45, 0.5 se 0.45-0.55, 0.25 se >0.55. (Per testare la riduzione size col report attuale: approssima scartando il 50%/75% delle entries nelle bin alte in modo deterministico per indice, oppure confronta solo le bin.)'],
|
||||||
|
['ADX-skip', 'calcola ADX(14) causale; scarta le fade quando ADX>25 (trend). Test soglie 20/25/30.'],
|
||||||
|
['vol-expansion vratio', 'scarta le fade quando vratio (vol breve/lunga, colonna regime_lab) > 1.5 (vol in espansione = breakout, non range). Test 1.3/1.5/1.8. (la ricerca dice -72% perdite maggiori)'],
|
||||||
|
['efficiency-ratio Kaufman', 'ER = |c[i]-c[i-n]|/sum(|diff|) su finestra n=20; scarta quando ER>0.5 (moto efficiente/trending). Test 0.4/0.5/0.6.'],
|
||||||
|
['time-stop piu corto', 'riduci max_bars da 24 a 12 o 15 (esci prima se non rientra = probabile trend). Confronta DD/edge.'],
|
||||||
|
['Hurst + vol-expansion combo', 'scarta se hurst>0.55 OPPURE vratio>1.5. Verifica se la combo riduce piu DD del singolo senza perdere piu edge.'],
|
||||||
|
['Hurst + ADX combo', 'scarta se hurst>0.55 E ADX>25 (doppia conferma di trend) -> piu selettivo, scarta meno winner.'],
|
||||||
|
['vol-target sizing', 'scala la size per 1/realized_vol (target vol costante): approssima tenendo solo le entries in vol moderata, riporta effetto su DD/coda.'],
|
||||||
|
['DVOL-rising skip', 'scarta le fade quando dvol_chg>0 forte (DVOL in salita = stress/espansione vol imminente). Test soglie su dvol_chg.'],
|
||||||
|
]
|
||||||
|
const ASSETS_NOTE = 'Applica a tutte e 3 le fade (MR01,MR02,MR07) su BTC E ETH (6 combo), aggrega base vs filtrato.'
|
||||||
|
|
||||||
|
phase('Test')
|
||||||
|
// ogni meccanismo = 1 agente che testa su tutte le 6 combo; piu' 4 agenti che esplorano combo/parametri fini
|
||||||
|
const tasks = MECHS.map(([nm, desc]) => () => agent(
|
||||||
|
CONTEXT + `\n\nMECCANISMO DA TESTARE: ${nm}\n${desc}\n\n${ASSETS_NOTE}\n` +
|
||||||
|
`Scrivi uno script in /tmp (cd /opt/docker/PythagorasGoal && uv run python /tmp/<file>.py), confronta ` +
|
||||||
|
`BASELINE (fade senza filtro) vs FILTRATA su ogni combo, AGGREGA (media o somma equity) e riporta. ` +
|
||||||
|
`Il filtro deve essere CAUSALE. Decidi buon_lossguard=true SOLO se riduce DD/coda/stop-rate MANTENENDO ` +
|
||||||
|
`l'edge netto OOS (Sharpe non crolla, ret OOS resta ampiamente positivo). Cita i numeri base vs filtrato.`,
|
||||||
|
{ label: `mech:${nm.slice(0, 18)}`, phase: 'Test', schema: SCHEMA }))
|
||||||
|
|
||||||
|
const results = (await parallel(tasks)).filter(Boolean)
|
||||||
|
|
||||||
|
phase('Synth')
|
||||||
|
const good = results.filter(r => r.buon_lossguard)
|
||||||
|
const synthesis = await agent(
|
||||||
|
CONTEXT +
|
||||||
|
`\n\nRisultati di ${results.length} meccanismi testati:\n${JSON.stringify(results, null, 1)}\n\n` +
|
||||||
|
`SINTESI FINALE (italiano) per il decisore:
|
||||||
|
1) Esiste un loss-guard che riduce le perdite/DD delle fade in regime trending SENZA uccidere l'edge?
|
||||||
|
2) Tabella: meccanismo | base vs filtrato (OOS Sharpe, DD, ret, %trade scartati) | buon_lossguard?
|
||||||
|
3) Il MIGLIORE (e l'eventuale combo) con i numeri. Quanto DD/coda si risparmia e a che costo di ret.
|
||||||
|
4) Coerenza con la ricerca esterna (Hurst<0.45 / ADX / vol-expansion / time-stop).
|
||||||
|
5) Raccomandazione: quale filtro applicare alle fade live, con che soglia, e il caveat (serve feed
|
||||||
|
DVOL/regime live? il filtro va validato a livello PORT06 = riduce il DD del portafoglio?).
|
||||||
|
Onesta: se nessuno migliora davvero (riduce solo ret), dillo. Cita NUMERI reali.`,
|
||||||
|
{ label: 'synth-lossguard', phase: 'Synth' })
|
||||||
|
|
||||||
|
return { results, good, synthesis }
|
||||||
@@ -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,54 @@
|
|||||||
|
import sys; sys.path.insert(0,".")
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from scripts.analysis.combine_portfolio import IDX, SPLIT, INIT, _norm, metrics, port_returns
|
||||||
|
from src.portfolio.sleeves import all_sleeve_equities
|
||||||
|
from scripts.analysis.regime_lab import load_features
|
||||||
|
from scripts.analysis.explore_lab import atr
|
||||||
|
FEE=0.001; LEV=3; POS=0.15
|
||||||
|
|
||||||
|
def fr01_daily_equity(asset):
|
||||||
|
df=load_features(asset,"1h")
|
||||||
|
c=df['close'].values; h=df['high'].values; l=df['low'].values; a=atr(df,14)
|
||||||
|
ma=pd.Series(c).rolling(50).mean().values; sd=pd.Series(c).rolling(50).std().values
|
||||||
|
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
eq=np.full(len(c),INIT,float); cap=INIT; last=-1
|
||||||
|
for i in range(64,len(c)-1):
|
||||||
|
if i<=last or np.isnan(sd[i]) or sd[i]==0 or np.isnan(a[i]): continue
|
||||||
|
if df['hurst'].iloc[i]>=0.55: continue
|
||||||
|
if np.isnan(df['dvol_pct'].iloc[i]) or df['dvol_pct'].iloc[i]>=0.40: continue
|
||||||
|
if c[i]<ma[i]-2.5*sd[i]: d,sl,tp=1,c[i]-2*a[i],ma[i]
|
||||||
|
elif c[i]>ma[i]+2.5*sd[i]: d,sl,tp=-1,c[i]+2*a[i],ma[i]
|
||||||
|
else: continue
|
||||||
|
j=min(i+24,len(c)-1); exit_p=c[j]
|
||||||
|
for t in range(i+1,j+1):
|
||||||
|
if d==1:
|
||||||
|
if l[t]<=sl: exit_p=sl; j=t; break
|
||||||
|
if h[t]>=tp: exit_p=tp; j=t; break
|
||||||
|
else:
|
||||||
|
if h[t]>=sl: exit_p=sl; j=t; break
|
||||||
|
if l[t]<=tp: exit_p=tp; j=t; break
|
||||||
|
ret=(exit_p-c[i])/c[i]*d*LEV - FEE*LEV
|
||||||
|
cap=max(cap+cap*POS*ret,10.0); eq[j:]=cap; last=j
|
||||||
|
s=pd.Series(eq,index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
|
||||||
|
return _norm(s)
|
||||||
|
|
||||||
|
members=all_sleeve_equities()
|
||||||
|
print(f"PORT06 sleeve: {len(members)} | finestra {IDX[0].date()}..{IDX[-1].date()} | OOS da idx {SPLIT} ({IDX[SPLIT].date()})")
|
||||||
|
fr={"FR01_BTC":fr01_daily_equity("BTC"), "FR01_ETH":fr01_daily_equity("ETH")}
|
||||||
|
|
||||||
|
base=port_returns(members) # equal-weight 17 sleeve (metro combine)
|
||||||
|
aug =port_returns({**members,**fr}) # + FR01x2 (19 sleeve)
|
||||||
|
|
||||||
|
def show(tag, dr):
|
||||||
|
f=metrics(dr); o=metrics(dr,lo=SPLIT)
|
||||||
|
print(f" {tag:<22} FULL: Sharpe {f['sharpe']:.2f} DD {f['dd']:.1f}% ret {f['ret']:+.0f}% | OOS: Sharpe {o['sharpe']:.2f} DD {o['dd']:.1f}% ret {o['ret']:+.0f}%")
|
||||||
|
|
||||||
|
print("\n=== MASTER equal-weight: con/senza FR01 ===")
|
||||||
|
show("PORT06 (17 sleeve)", base)
|
||||||
|
show("PORT06 + FR01 (19)", aug)
|
||||||
|
|
||||||
|
# correlazione FR01 vs portafoglio MASTER aggregato + standalone
|
||||||
|
for k,e in fr.items():
|
||||||
|
r=e.pct_change().fillna(0.0); corr=np.corrcoef(r, base)[0,1]
|
||||||
|
f=metrics(r); o=metrics(r,lo=SPLIT)
|
||||||
|
print(f"\n {k}: corr vs MASTER = {corr:+.3f} | standalone OOS Sharpe {o['sharpe']:.2f} DD {o['dd']:.1f}% ret {o['ret']:+.0f}%")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user