12 Commits

Author SHA1 Message Date
Adriano Dal Pastro 6f86c644bf docs: CLAUDE.md + README aggiornati — PORT06 canonici correnti (6.47/8.82, con EXIT-16 7.84/10.06), shadow reale 7 sleeve (DIP01 incluso), sezione esecuzione reale nel README, famiglia SHAPE in tabella, exit_lab in struttura/comandi
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:58:04 +00:00
Adriano Dal Pastro 2e9ec81567 config(live): DIP01 BTC aggiunto allo shadow reale (7° sleeve single-leg, wiring StrategyWorker invariato)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:45:27 +00:00
Adriano Dal Pastro 3bc42517b0 docs(diary): exit-lab — sezione implementazione+deploy v1.1.0 (complementarieta' hurst/trend/close-confirm)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:29:55 +00:00
Adriano Dal Pastro 59609a535a release: v1.1.0 2026-06-04 21:26:50 +00:00
Adriano Dal Pastro a2579d21bc feat(fade): EXIT-16 close-confirm SL — stop solo se il CLOSE sfonda sl∓0.5·ATR14 (immune ai wick)
Param sl_confirm_atr (None = comportamento storico) in FadeStrategy.backtest,
MR01.backtest e StrategyWorker.tick; attivo live a 0.5 sulle 6 fade in _defs.py.
TP intrabar al livello e max_bars invariati; in modalita' confirm il TP ha
priorita' nel bar. Ricerca exit-lab (34 agenti, 3 lenti avversariali + tail
audit): gli stop intrabar da wick sono falsi negativi per le fade. PORT06
canonico: FULL Sharpe 6.47->7.84 DD 4.10->2.60, OOS 8.82->10.06 DD 1.30->1.15.
NB path grezzo non filtrato: ret esplode ma DD per-sleeve sale — la riduzione
DD vive nel config live (trend_max+hurst) + diversificazione, come misurato.
5 test nuovi (59 verdi).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:25:17 +00:00
Adriano Dal Pastro ad65a0b344 research(exit-lab): 34 agenti su exit dinamiche → EXIT-16 close-confirm SL PROMOSSO a livello PORT06
23 famiglie esplorate (harness condiviso exit_lab, train/OOS embargo nov-2023,
tutto lo storico 1h 2018-2026) + 10 verifiche avversariali + test PORT06.
'Cavalcare il prezzo' non esiste (4a conferma: oltre il TP=media non c'e' coda).
Scoperta: lo SL intrabar fisso e' il distruttore di valore n.1 delle fade
(stop da wick = falsi negativi). Forma robusta: SL solo su CHIUSURA oltre
sl0±0.5·ATR14 — PORT06 FULL Sharpe 6.47→7.84 DD 4.10→2.60, OOS 8.82→10.06.
Collaterali: bias gap-through dell'engine sugli stop stretti; ramo -2% del
worker morto con sl=0. Diario: docs/diary/2026-06-04-exit-lab.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:16:58 +00:00
Adriano Dal Pastro 3accc91f84 docs(diary): exit ladder 80/20 con runner+stop dinamico SCARTATO (il runner non corre: TP=media, ~3% oltre TP, −38% ret su MR02 ETH)
Test onesto scripts/analysis/partial_tp_ladder.py: stessi segnali, engine
intrabar fade_base, fee identiche. Il ladder compra win-rate/DD pagando i
winner migliori — stesso profilo del vol-target gia' scartato.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:48:45 +00:00
Adriano Dal Pastro 7707cc17a0 release: v1.0.7 2026-06-04 19:24:20 +00:00
Adriano Dal Pastro abd396fa54 feat(live): TP reale = LIMIT reduce-only al livello (fix +235bps slippage exit)
All'apertura reale il worker piazza un limit reduce-only al TP della strategia
(quota del SOLO worker, prezzo quantizzato al tick); alla chiusura sim cancella
il resting, riconcilia i fill (anche parziali) via get_trade_history per
order_id e chiude a market solo il residuo. real_tp_order_id persistito
(resume-safe). SL resta market-on-poll (deliberato: trigger Deribit = nuovo
order_id al trigger, fill non verificabile; e il rimbalzo sul SL lavora a
favore). Smoke testnet 2 scenari OK (resting+cancel / fill immediato).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:24:07 +00:00
Adriano Dal Pastro e8075b6292 docs(diary): divergenza shadow sim/reale misurata (+235bps su TP) + TODO limit reduce-only al TP
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:20:07 +00:00
Adriano Dal Pastro a26397b1bf release: v1.0.6 2026-06-04 16:10:33 +00:00
Adriano Dal Pastro 74670dae05 fix(live): quantizzazione amount senza artefatti float (Decimal) — 72*0.001=0.07200000000000001 causava 'Invalid params' su Deribit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:10:28 +00:00
51 changed files with 5912 additions and 63 deletions
+25 -4
View File
@@ -47,14 +47,16 @@ src/portfolio/ → portafogli di prima classe (capitale-pool, backtest+l
ledger.py → PortfolioLedger: capitale/PnL/DD/persistenza+resume ledger.py → PortfolioLedger: capitale/PnL/DD/persistenza+resume
runner.py → PortfolioRunner live (data Cerbero v2, sizing, ribilancio) runner.py → PortfolioRunner live (data Cerbero v2, sizing, ribilancio)
src/version.py → APP_VERSION (legge il file VERSION) — mostrato nei msg Telegram 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) 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), scripts/strategies/ → strategie con edge validato OOS: FADE (MR01/MR02/MR07),
HONEST (DIP01/TR01/ROT02), PAIRS (PR01), TSMOM + portafogli (PORT01/02/03); HONEST (DIP01/TR01/ROT02), PAIRS (PR01), TSMOM + portafogli (PORT01/02/03);
FR01_hurst_calm_fade.py = record ricerca (robusto ma NON deployato) 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/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/waste/ → strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD)
scripts/analysis/ → ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...); scripts/analysis/ → ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...);
regime_fetcher.py + regime_lab.py (DVOL/funding/feature regime per la ricerca) 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) scripts/bump_version.py, scripts/deploy.sh → versionamento e deploy (bump+commit+rebuild)
VERSION → versione semver (cotta nell'immagine, +1 ad ogni deploy) VERSION → versione semver (cotta nell'immagine, +1 ad ogni deploy)
strategies.yml → config multi-strategy paper trader strategies.yml → config multi-strategy paper trader
@@ -83,6 +85,7 @@ uv run python scripts/analysis/smoke_portfolio.py # smoke live data lay
uv run python scripts/analysis/live_exec_smoke.py # smoke ESECUZIONE reale (ordine→verifica→fee, testnet) 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/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/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 ./scripts/deploy.sh [patch|minor|major] # DEPLOY: bump versione + commit + rebuild Docker
uv run pytest # test uv run pytest # test
``` ```
@@ -195,6 +198,23 @@ regime tossico, non rendendo sicuro ogni trade). **Monitor live:** `hourly_repor
stop-rate fade PRIMA/DOPO l'attivazione (14:34 UTC del 2026-06-02) e dà il verdetto su Telegram quando 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%). il campione DOPO ≥30 (già confermato: stop-rate live PRIMA 42% == backtest 42.1%).
**EXIT-16 close-confirm SL (ATTIVO LIVE, 2026-06-04).** Le fade 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 **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 × 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. paniere di 8 sleeve, contro il 20-70% del singolo. È la vera leva anti-drawdown.
@@ -309,13 +329,14 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale
- 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}/`). - 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). - **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`, leva 2x, ribilancio 1D. Backtest PORT06: FULL Sharpe 6.07 / OOS Sharpe 8.19, DD 4.9% full / 2.3% OOS. - **Default `portfolios.yml`:** PORT06 (master+shape), `weighting=cap pairs 0.33`, leva 2x, ribilancio 1D. Backtest PORT06 canonico (dati al 2026-05-28): 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).
- **Data layer Cerbero v2:** `get_historical_v2` unificato + `get_instruments` (naming robusto) + `get_ticker_batch`. Trading su Deribit. - **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). - **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).
- **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`. - **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). - **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. - **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], instruments:{BTC:BTC_USDC-PERPETUAL, ETH:ETH_USDC-PERPETUAL}}`; pairs/rotation/tsmom/shape/dip restano **simulati**. **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. - **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.
- **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`.
- **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate); fedele al backtest daily-rebalanced entro il turnover infragiornaliero. - **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
+37 -6
View File
@@ -16,7 +16,7 @@ Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di
> a fee zero. Dettagli e prove: `scripts/analysis/oos_validation.py`. > a fee zero. Dettagli e prove: `scripts/analysis/oos_validation.py`.
Dopo una validazione **out-of-sample, fee-aware** di molte famiglie di strategie, Dopo una validazione **out-of-sample, fee-aware** di molte famiglie di strategie,
emergono quattro famiglie con edge netto reale, tutte radicate nella stessa lezione emergono cinque famiglie con edge netto reale, tutte radicate nella stessa lezione
(in cripto la **mean-reversion** funziona, la continuazione no) o nella diversificazione: (in cripto la **mean-reversion** funziona, la continuazione no) o nella diversificazione:
| Famiglia | Meccanismo | Strategie | Profilo (netto OOS) | | Famiglia | Meccanismo | Strategie | Profilo (netto OOS) |
@@ -25,6 +25,7 @@ emergono quattro famiglie con edge netto reale, tutte radicate nella stessa lezi
| **HONEST** | long-only multi-regime multi-crypto | DIP01 dip-buy, TR01 EMA-trend, ROT02 dual-momentum | CAGR 31-56%, DD 15-27% | | **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 | | **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% | | **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% 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 RT/coppia sui pairs), leva 3x, su finestra held-out. Le strategie sono robuste su griglia
@@ -42,6 +43,7 @@ portafoglio equipesato il drawdown crolla sotto quello di ogni singola sleeve:
| HONEST (3 sleeve) | ~46% | 13% | 2.2 | | HONEST (3 sleeve) | ~46% | 13% | 2.2 |
| **MASTER** (FADE + HONEST, 9) | ~47% | **5%** | 4.2 | | **MASTER** (FADE + HONEST, 9) | ~47% | **5%** | 4.2 |
| **MASTER + PAIRS + TSM01** (15) | ~67% | ~5% | ~6 | | **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: > 🔎 **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 > i valori di Sharpe/DD sopra sono ottimistici di circa il 50%. Da pianificare per le
@@ -154,12 +156,18 @@ Le strategie single-asset estendono `src.strategies.base.Strategy`
| **ROT02** | `ROT02_dual_momentum.py` | HONEST | Rotazione cross-sectional top-3 + risk-off (1d) | | **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 | | **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 | | **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) |
Le fade applicano due filtri di regime opzionali: un **filtro trend** (`trend_max`/`ema_long`, Le fade applicano tre protezioni live: un **filtro trend** (`trend_max`/`ema_long`,
salta i segnali col prezzo troppo esteso rispetto alla EMA200) e un **loss-guard Hurst** 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 (`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). Più un filtro `min_tp_frac` — dimezza il drawdown del portafoglio, calcolato dalle sole close) e l'**EXIT-16 close-confirm SL**
che scarta i micro-scalp col take-profit entro il costo delle fee. Portafogli pronti: `PORT01` (`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). (honest), `PORT02` (fade), `PORT03` (master fade+honest), **`PORT06`** (master esteso, default live).
**Scartate** (in `scripts/waste/`): la famiglia squeeze (SQ01-04, ML01, MT01, PD01, **Scartate** (in `scripts/waste/`): la famiglia squeeze (SQ01-04, ML01, MT01, PD01,
@@ -272,7 +280,7 @@ Il modulo `weighting.py` mette a disposizione cinque schemi: `equal` (default),
### Portafoglio di default: PORT06 ### 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. Risultati del backtest: Sharpe 6.07 (FULL) / 8.19 (OOS), drawdown massimo 4.9% (FULL) / 2.3% (OOS), leva 2×. 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 ### Scope live
@@ -291,6 +299,29 @@ sono validati == backtest.
> attivo live (`hurst_max` nei params). Il report orario su Telegram **monitora lo stop-rate fade > 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. > 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 ### Versione & deploy
Ogni deploy ha una **versione** (file `VERSION`, semver) che compare nei messaggi Telegram (notifiche Ogni deploy ha una **versione** (file `VERSION`, semver) che compare nei messaggi Telegram (notifiche
+1 -1
View File
@@ -1 +1 @@
1.0.5 1.1.0
+176
View File
@@ -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.
+6 -4
View File
@@ -13,12 +13,14 @@ overrides:
# dei 2K senza debito di margine). NB: il DD scala ~lineare (~×3.3 vs validato). # dei 2K senza debito di margine). NB: il DD scala ~lineare (~×3.3 vs validato).
position_size: 0.5 position_size: 0.5
# Esecuzione REALE su Deribit testnet, in SHADOW (sim + reale in parallelo). # Esecuzione REALE su Deribit testnet, in SHADOW (sim + reale in parallelo).
# Solo i 6 fade single-leg (MR01/MR02/MR07 x BTC/ETH); ordini sui LINEARI USDC # I 7 single-leg con TP/SL in metadata: 6 fade (MR01/MR02/MR07 x BTC/ETH) +
# (payoff lineare = matematica del backtest; fee/PnL in USDC). Gli altri sleeve # DIP01 BTC (attivato 2026-06-04: stesso wiring StrategyWorker, TP limit resting
# (pairs/rotation/tsmom/shape/dip) restano simulati. # 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: execution:
enabled: true enabled: true
sleeves: [MR01, MR02, MR07] sleeves: [MR01, MR02, MR07, DIP01]
instruments: instruments:
BTC: BTC_USDC-PERPETUAL BTC: BTC_USDC-PERPETUAL
ETH: ETH_USDC-PERPETUAL ETH: ETH_USDC-PERPETUAL
+236
View File
@@ -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()
+259
View File
@@ -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()
+34 -11
View File
@@ -1,8 +1,13 @@
"""Smoke della catena SHADOW dentro lo StrategyWorker (testnet, ordini reali minimi). """Smoke della catena SHADOW dentro lo StrategyWorker (testnet, ordini reali minimi).
Apre e chiude la quota di UN worker fade come farebbe il runner: Apre e chiude la quota di UN worker fade come farebbe il runner, esercitando i
_open_position (sim + REAL_OPEN reale su BTC_USDC) → _close_position (sim + DUE percorsi del LIMIT reduce-only al TP (fix divergenza sim/reale 2026-06-04):
REAL_CLOSE reduce-only) → controlla che real_capital sia aggiornato dal fill reale.
A) TP lontano → REAL_TP_RESTING in book; exit sim non-TP → cancel + market
reduce-only di fallback (REAL_CLOSE con tp_filled_amount=0);
B) TP gia' oltre il prezzo → il limit crossa e filla SUBITO; la chiusura
riconcilia il fill dal trade history (order_id) SENZA ordine market
(REAL_CLOSE con market_amount=0).
Non tocca lo stato di produzione (data_dir temporanea). Costo testnet = €0. Non tocca lo stato di produzione (data_dir temporanea). Costo testnet = €0.
@@ -40,27 +45,45 @@ def main() -> None:
) )
print(f"execution_enabled={w.execution_enabled} notional atteso=${100*0.15*2:.0f}") print(f"execution_enabled={w.execution_enabled} notional atteso=${100*0.15*2:.0f}")
# OPEN long (sim + reale) # --- Scenario A: TP lontano → resting in book, exit non-TP → cancel + market ---
sig = Signal(idx=0, direction=1, entry_price=price, metadata={}) print("\n[A] TP lontano (resting) → exit time_limit → cancel + market fallback")
sig = Signal(idx=0, direction=1, entry_price=price,
metadata={"tp": price * 1.05, "sl": price * 0.50, "max_bars": 6})
w._open_position(sig, price) w._open_position(sig, price)
print(f" real_in_position={w.real_in_position} side={w.real_side} " print(f" real_in_position={w.real_in_position} side={w.real_side} "
f"amount={w.real_amount} entry={w.real_entry_price} " f"amount={w.real_amount} entry={w.real_entry_price} "
f"entry_fee=${w.real_entry_fee_usd:.5f} notional=${w.real_entry_notional:.2f}") f"entry_fee=${w.real_entry_fee_usd:.5f} notional=${w.real_entry_notional:.2f}")
assert w.real_in_position, "OPEN reale non verificato" assert w.real_in_position, "OPEN reale non verificato"
print(f" TP resting order_id={w.real_tp_order_id!r}")
assert w.real_tp_order_id, "LIMIT reduce-only al TP non piazzato"
# CLOSE (sim + reale reduce-only) a un prezzo leggermente diverso
exit_price = (w.entry_price or price) * 1.001
cap_before = w.real_capital cap_before = w.real_capital
w._close_position(exit_price, "smoke_close") w._close_position((w.entry_price or price) * 1.001, "time_limit")
print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} " print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} "
f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}") f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}")
assert not w.real_in_position, "posizione reale non chiusa" assert not w.real_in_position, "posizione reale non chiusa"
assert not w.real_tp_order_id, "order_id TP non resettato dopo la chiusura"
# --- Scenario B: TP gia' oltre il prezzo → il limit crossa e filla subito ---
print("\n[B] TP gia' crossato (fill immediato del limit) → close riconcilia da history")
price = ex._mark_price(instrument) or price
sig = Signal(idx=0, direction=1, entry_price=price,
metadata={"tp": price * 0.995, "sl": price * 0.50, "max_bars": 6})
w._open_position(sig, price)
assert w.real_in_position, "OPEN reale non verificato (B)"
assert w.real_tp_order_id, "LIMIT TP non piazzato (B)"
cap_before = w.real_capital
w._close_position(w.tp, "take_profit")
print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} "
f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}")
assert not w.real_in_position, "posizione reale non chiusa (B)"
# verifica finale: il conto e' flat sullo strumento (nessuna quota residua del worker) # verifica finale: il conto e' flat sullo strumento (nessuna quota residua del worker)
pos = ex._position_size(instrument) pos = ex._position_size(instrument)
print(f" posizione netta {instrument}: {pos}") print(f"\n posizione netta {instrument}: {pos}")
print("✓ catena shadow OK — ordine reale aperto, verificato, chiuso reduce-only, " print("✓ catena shadow OK — open reale, LIMIT TP resting (A: cancel+market, "
"fee reali nel ledger reale") "B: fill immediato riconciliato), fee reali nel ledger reale")
if __name__ == "__main__": if __name__ == "__main__":
+164
View File
@@ -0,0 +1,164 @@
"""Exit 'ladder' per le fade: al tocco di una frazione f del TP esce la quota q,
il runner (1-q) corre con STOP DINAMICO bloccato alla soglia (profitto lockato).
Proposta 2026-06-04 ("e se all'80% del TP usciamo con 80% e mettiamo un SL
dinamico su quella soglia e lo lasciamo correre?"). Confronto ONESTO con l'exit
canonico (TP pieno al livello) sugli STESSI segnali, stesso engine intrabar di
fade_base (ingresso a close[i], SL prioritario nello stesso bar, fee 0.10% RT
x leva su tutto il notional — il ladder NON paga fee extra: due uscite ma
stesso notional totale).
Convenzioni intrabar del ladder (oneste/conservative):
- SL pieno prioritario sulla soglia nello stesso bar;
- se il bar che tocca la soglia CHIUDE oltre la soglia (rientro), il runner
si considera stoppato subito alla soglia (non gli si regala il bar);
- il runner non ha TP (corre), esce su ri-tocco della soglia o a max_bars.
Gate (metodologia repo): il ladder deve migliorare ret E DD (o chiaramente il
rischio/rendimento) su ENTRAMBI gli asset, full E OOS, per tutte e 3 le fade.
uv run python scripts/analysis/partial_tp_ladder.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 # 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 = pd.Timestamp("2023-11-01", tz="UTC")
LEV, POS, FEE_RT = 3.0, 0.15, 0.001
CODES = ["MR01_bollinger_fade", "MR02_donchian_fade", "MR07_return_reversal"]
def simulate(signals, df, ts, policy: str, f: float = 0.8, q: float = 0.8,
start=None) -> dict:
"""Replay intrabar degli stessi segnali con exit 'base' o 'ladder'."""
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c)
fee = FEE_RT * LEV
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
trades = wins = 0
runner_beyond_tp = runner_stopped = 0
rets = []
for sig in signals:
i, d = sig.idx, sig.direction
if start is not None and ts.iloc[i] < start:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
tp, sl, mb = sig.metadata["tp"], sig.metadata["sl"], sig.metadata["max_bars"]
j = i
if policy == "base":
exit_p = c[min(i + mb, n - 1)]
for step in range(1, mb + 1):
j = i + step
if j >= n:
j = n - 1; exit_p = c[j]; break
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if hit_sl:
exit_p = sl; break
if hit_tp:
exit_p = tp; break
if step == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * d * LEV - fee
else: # ladder
th = entry + f * (tp - entry) # soglia f del percorso verso il TP
p1 = p2 = None # fill quota q / runner (1-q)
state = "full"
for step in range(1, mb + 1):
j = i + step
if j >= n:
j = n - 1
if p1 is None:
p1 = c[j]
p2 = c[j]
break
if state == "full":
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
hit_th = (d == 1 and h[j] >= th) or (d == -1 and l[j] <= th)
if hit_sl: # SL pieno prioritario (conservativo)
p1 = p2 = sl; break
if hit_th:
p1 = th; state = "runner"
# il bar chiude oltre la soglia -> runner stoppato subito
if (d == 1 and c[j] < th) or (d == -1 and c[j] > th):
p2 = th; runner_stopped += 1; break
if step == mb:
p2 = c[j]; break
continue
if step == mb:
p1 = p2 = c[j]; break
else: # runner: stop dinamico = soglia
hit_stop = (d == 1 and l[j] <= th) or (d == -1 and h[j] >= th)
if hit_stop:
p2 = th; runner_stopped += 1; break
if step == mb:
p2 = c[j]; break
if p2 is not None and (p2 - tp) * d > 0:
runner_beyond_tp += 1
ret = (q * (p1 - entry) + (1 - q) * (p2 - 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
rets.append(ret)
if trades == 0:
return {}
rets = 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": rets.mean() * 1e4,
"sharpe_t": rets.mean() / rets.std() * np.sqrt(len(rets)) if rets.std() else 0,
"runner_beyond_tp": runner_beyond_tp,
"runner_stopped": runner_stopped,
}
def main() -> None:
variants = [("base", None, None), ("ladder", 0.8, 0.8),
("ladder", 0.8, 0.5), ("ladder", 0.5, 0.5)]
for code in CODES:
strat = load_strategy(code)
for asset in ("BTC", "ETH"):
df = load_data(asset, "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
signals = strat.generate_signals(df, ts, **LIVE_PARAMS)
print(f"\n=== {code} {asset} 1h — {len(signals)} segnali (params live) ===")
print(f"{'policy':<16}{'periodo':<6}{'ret%':>10}{'DD%':>8}{'win%':>7}"
f"{'avg bps':>9}{'Sh(t)':>7}{'n':>6}{'run>TP':>8}{'run-stop':>9}")
for policy, f, q in variants:
tag = "base" if policy == "base" else f"ladder f{f} q{q}"
for label, start in (("FULL", None), ("OOS", OOS_START)):
r = simulate(signals, df, ts, policy, f or 0.8, q or 0.8, start)
if not r:
continue
print(f"{tag:<16}{label:<6}{r['ret_pct']:>10.0f}{r['dd_pct']:>8.1f}"
f"{r['win_pct']:>7.1f}{r['avg_ret_bps']:>9.1f}{r['sharpe_t']:>7.2f}"
f"{r['trades']:>6}{r['runner_beyond_tp']:>8}{r['runner_stopped']:>9}")
if __name__ == "__main__":
main()
+10 -1
View File
@@ -24,8 +24,17 @@ MIN_TP_FRAC = 0.0015
# Validato 2026-06-02, vedi docs/diary/2026-06-02-fade-lossguard.md. # Validato 2026-06-02, vedi docs/diary/2026-06-02-fade-lossguard.md.
HURST_MAX = 0.55 HURST_MAX = 0.55
# EXIT-16 close-confirm SL (live, 2026-06-04): lo SL intrabar fisso veniva triggerato dai
# wick transitori (falsi negativi: l'overshoot che buca lo stop rientra — e' il movimento
# che la fade fada). Lo stop scatta solo se il CLOSE sfonda sl ∓ 0.5*ATR14. PORT06:
# FULL Sharpe 6.47->7.84 DD 4.10->2.60, OOS 8.82->10.06 DD 1.30->1.15 (gate del loss-guard
# superato con margine; 34 agenti, 3 verifiche avversariali + tail-risk: coda ~= base,
# esce nei crash veri tipo FTX). Vedi docs/diary/2026-06-04-exit-lab.md.
SL_CONFIRM_ATR = 0.5
FADE = [SleeveSpec(kind="single", name=c, sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev", FADE = [SleeveSpec(kind="single", name=c, sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev",
params={"min_tp_frac": MIN_TP_FRAC, "hurst_max": HURST_MAX}) params={"min_tp_frac": MIN_TP_FRAC, "hurst_max": HURST_MAX,
"sl_confirm_atr": SL_CONFIRM_ATR})
for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")] for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")]
HONEST = [ HONEST = [
# DIP01: single-asset 1h -> StrategyWorker (Strategy DIP01_dip_buy). TR01/ROT02: multi-asset. # DIP01: single-asset 1h -> StrategyWorker (Strategy DIP01_dip_buy). TR01/ROT02: multi-asset.
+17 -5
View File
@@ -106,6 +106,10 @@ class BollingerFade(Strategy):
h, l, c = df["high"].values, df["low"].values, df["close"].values h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c) n = len(c)
# EXIT-16 close-confirm SL (2026-06-04): come in fade_base.FadeStrategy.backtest.
# None = comportamento storico. Vedi docs/diary/2026-06-04-exit-lab.md.
sl_confirm = params.get("sl_confirm_atr")
a14 = _atr(df, 14) if sl_confirm else None
fee = self.fee_rt * self.leverage fee = self.fee_rt * self.leverage
capital = peak = float(self.initial_capital) capital = peak = float(self.initial_capital)
max_dd = 0.0 max_dd = 0.0
@@ -125,12 +129,20 @@ class BollingerFade(Strategy):
j = i + step j = i + step
if j >= n: if j >= n:
j = n - 1; exit_p = c[j]; break j = n - 1; exit_p = c[j]; break
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp) hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if hit_sl: if sl_confirm is None:
exit_p = sl; break hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
if hit_tp: if hit_sl:
exit_p = tp; break exit_p = sl; break
if hit_tp:
exit_p = tp; break
else:
# close-confirm: TP intrabar al livello; SL valutato sul CLOSE
if hit_tp:
exit_p = tp; break
buf = sl_confirm * a14[j]
if (d == 1 and c[j] < sl - buf) or (d == -1 and c[j] > sl + buf):
exit_p = c[j]; break
if step == mb: if step == mb:
exit_p = c[j] exit_p = c[j]
+6
View File
@@ -116,6 +116,12 @@ class CerberoClient:
payload["reduce_only"] = True payload["reduce_only"] = True
return self._post("/mcp-deribit/tools/place_order", payload) return self._post("/mcp-deribit/tools/place_order", payload)
def cancel_order(self, order_id: str) -> dict:
"""Cancella un ordine resting (es. limit reduce-only al TP). Ritorna
{order_id, state}; state='error' se l'ordine non e' piu' open (gia'
fillato/cancellato) — il chiamante riconcilia via get_trade_history."""
return self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})
def close_position(self, instrument: str) -> dict: def close_position(self, instrument: str) -> dict:
return self._post("/mcp-deribit/tools/close_position", {"instrument_name": instrument}) return self._post("/mcp-deribit/tools/close_position", {"instrument_name": instrument})
+98 -16
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any from typing import Any
from src.live.cerbero_client import CerberoClient from src.live.cerbero_client import CerberoClient
@@ -26,10 +27,10 @@ from src.live.cerbero_client import CerberoClient
# LINEAR (USDC): amount nel base-coin, step nel base-coin (BTC 0.0001, ETH 0.001); # LINEAR (USDC): amount nel base-coin, step nel base-coin (BTC 0.0001, ETH 0.001);
# il notional USD si converte col prezzo. Fee/settle in USDC. # il notional USD si converte col prezzo. Fee/settle in USDC.
_CONTRACT: dict[str, dict[str, Any]] = { _CONTRACT: dict[str, dict[str, Any]] = {
"BTC-PERPETUAL": {"linear": False, "min": 10.0, "step": 10.0}, "BTC-PERPETUAL": {"linear": False, "min": 10.0, "step": 10.0, "tick": 0.5},
"ETH-PERPETUAL": {"linear": False, "min": 1.0, "step": 1.0}, "ETH-PERPETUAL": {"linear": False, "min": 1.0, "step": 1.0, "tick": 0.05},
"BTC_USDC-PERPETUAL": {"linear": True, "min": 0.0001, "step": 0.0001, "settle": "USDC"}, "BTC_USDC-PERPETUAL": {"linear": True, "min": 0.0001, "step": 0.0001, "tick": 0.5, "settle": "USDC"},
"ETH_USDC-PERPETUAL": {"linear": True, "min": 0.001, "step": 0.001, "settle": "USDC"}, "ETH_USDC-PERPETUAL": {"linear": True, "min": 0.001, "step": 0.001, "tick": 0.05, "settle": "USDC"},
} }
@@ -46,6 +47,22 @@ def settlement_currency(instrument: str) -> str:
return instrument.split("-")[0].split("_")[0] return instrument.split("-")[0].split("_")[0]
def _quantize_step(value: float, step: float, mn: float) -> float:
"""Arrotonda `value` al multiplo di `step` SENZA artefatti float
(es. 72*0.001 = 0.07200000000000001 Deribit 'Invalid params')."""
n_steps = round(value / step)
return float(max(n_steps * Decimal(str(step)), Decimal(str(mn))))
def quantize_price(instrument: str, price: float) -> float:
"""Arrotonda il prezzo al tick dello strumento (Decimal, niente artefatti
float) richiesto per gli ordini limit (Deribit rifiuta prezzi off-tick)."""
tick = contract_spec(instrument).get("tick")
if not tick or price <= 0:
return price
return float(round(price / tick) * Decimal(str(tick)))
def notional_to_amount(instrument: str, notional_usd: float, def notional_to_amount(instrument: str, notional_usd: float,
price: float | None = None) -> float: price: float | None = None) -> float:
"""Notional USD → `amount` Deribit, arrotondato allo step e clampato al minimo. """Notional USD → `amount` Deribit, arrotondato allo step e clampato al minimo.
@@ -59,10 +76,10 @@ def notional_to_amount(instrument: str, notional_usd: float,
units = notional_usd / price # base-coin richiesti units = notional_usd / price # base-coin richiesti
if units < step / 2: if units < step / 2:
return 0.0 return 0.0
return max(round(units / step) * step, mn) return _quantize_step(units, step, mn)
if notional_usd < step / 2: if notional_usd < step / 2:
return 0.0 return 0.0
return max(round(notional_usd / step) * step, mn) return _quantize_step(notional_usd, step, mn)
@dataclass @dataclass
@@ -149,17 +166,20 @@ class ExecutionClient:
def _submit(self, instrument: str, side: str, amount: float, def _submit(self, instrument: str, side: str, amount: float,
requested_notional: float, reduce_only: bool, requested_notional: float, reduce_only: bool,
label: str | None) -> Fill: label: str | None, order_type: str = "market",
"""Market order REALE + parsing del fill. Verifica per-worker basata sul price: float | None = None) -> Fill:
TRADE (order_id/trades), non sulla size netta lo strumento e' condiviso """Ordine REALE (market o limit resting) + parsing del fill. Verifica
fra piu' worker e la posizione su Deribit e' aggregata per conto.""" per-worker basata sul TRADE (order_id/trades), non sulla size netta lo
strumento e' condiviso fra piu' worker e la posizione su Deribit e'
aggregata per conto. Per i limit la verifica e' l'ACCETTAZIONE in book
(state 'open', o 'filled' se crossa subito)."""
spec = contract_spec(instrument) spec = contract_spec(instrument)
if amount <= 0: if amount <= 0:
return Fill(instrument, side, requested_notional, 0.0, None, 0.0, 0.0, return Fill(instrument, side, requested_notional, 0.0, None, 0.0, 0.0,
None, None, False, notes="notional sotto il minimo contratto") None, None, False, notes="notional sotto il minimo contratto")
resp = self.client.place_order(instrument, side, amount, order_type="market", resp = self.client.place_order(instrument, side, amount, order_type=order_type,
label=label, reduce_only=reduce_only) price=price, label=label, reduce_only=reduce_only)
if not isinstance(resp, dict) or resp.get("state") == "error" or "error" in resp: if not isinstance(resp, dict) or resp.get("state") == "error" or "error" in resp:
return Fill(instrument, side, requested_notional, amount, None, 0.0, 0.0, return Fill(instrument, side, requested_notional, amount, None, 0.0, 0.0,
None, "error", False, raw=resp if isinstance(resp, dict) else {}, None, "error", False, raw=resp if isinstance(resp, dict) else {},
@@ -173,8 +193,11 @@ class ExecutionClient:
# fee reale dai trade del fill (coin di settlement) # fee reale dai trade del fill (coin di settlement)
fee_coin = sum(float(t.get("fee", 0) or 0) for t in trades) fee_coin = sum(float(t.get("fee", 0) or 0) for t in trades)
# riconciliazione su trade history per order_id (fonte autorevole) # riconciliazione su trade history per order_id (fonte autorevole)
th = self._trade_by_order(instrument, order_id) # inutile per un limit ancora in book senza fill
th = None
if order_type == "market" or trades:
th = self._trade_by_order(instrument, order_id)
if fee_coin == 0 and th and th.get("fee") is not None: if fee_coin == 0 and th and th.get("fee") is not None:
fee_coin = float(th["fee"]) fee_coin = float(th["fee"])
if fill_price is None and th: if fill_price is None and th:
@@ -183,8 +206,12 @@ class ExecutionClient:
fee_usd = fee_coin if spec.get("linear") else ( fee_usd = fee_coin if spec.get("linear") else (
fee_coin * fill_price if (fee_coin and fill_price) else 0.0) fee_coin * fill_price if (fee_coin and fill_price) else 0.0)
# VERIFICA esecuzione = ordine filled E fill riscontrato (trades o trade history) # VERIFICA: market = ordine filled E fill riscontrato (trades o history);
verified = (state == "filled") and (bool(trades) or th is not None) # limit = accettato in book ('open') o gia' eseguito ('filled')
if order_type == "market":
verified = (state == "filled") and (bool(trades) or th is not None)
else:
verified = state in ("open", "filled")
return Fill(instrument, side, requested_notional, amount, fill_price, return Fill(instrument, side, requested_notional, amount, fill_price,
fee_coin, fee_usd, order_id, state, verified, raw=resp, fee_coin, fee_usd, order_id, state, verified, raw=resp,
notes="" if verified else f"fill non verificato (state={state}, trades={len(trades)})") notes="" if verified else f"fill non verificato (state={state}, trades={len(trades)})")
@@ -201,10 +228,65 @@ class ExecutionClient:
"""Chiude SOLO la quota del worker: market reduce_only di lato opposto, """Chiude SOLO la quota del worker: market reduce_only di lato opposto,
stesso `amount` dell'apertura. Non usa close_position (flatterebbe anche stesso `amount` dell'apertura. Non usa close_position (flatterebbe anche
le quote degli altri worker sullo stesso strumento).""" le quote degli altri worker sullo stesso strumento)."""
spec = contract_spec(instrument)
# quantizza difensivamente: il chiamante puo' passare un residuo
# (amount aperto fill parziale del TP) con artefatti float
amount = _quantize_step(amount, spec["step"], spec["min"]) if amount > 0 else 0.0
opp = "sell" if entry_side == "buy" else "buy" opp = "sell" if entry_side == "buy" else "buy"
return self._submit(instrument, opp, amount, 0.0, return self._submit(instrument, opp, amount, 0.0,
reduce_only=True, label=label) reduce_only=True, label=label)
def place_tp_limit(self, instrument: str, entry_side: str, amount: float,
tp_price: float, label: str | None = None) -> Fill:
"""LIMIT reduce-only appoggiato al livello TP (lato opposto all'entrata):
il fill reale replica l'assunzione intrabar del backtest (exit AL livello)
invece del market-on-poll post-rimbalzo (+235 bps misurati il 2026-06-04).
Copre la SOLA quota del worker (stesso `amount` dell'apertura) — lo
strumento e' condiviso fra piu' worker. NB: se il prezzo e' gia' oltre il
TP il limit crossa e filla subito (state 'filled'); la riconciliazione a
valle (resting_fills) lo gestisce come un fill normale."""
opp = "sell" if entry_side == "buy" else "buy"
px = quantize_price(instrument, tp_price)
if amount <= 0 or px <= 0:
return Fill(instrument, opp, 0.0, amount, None, 0.0, 0.0,
None, None, False, notes="amount/tp non validi")
return self._submit(instrument, opp, amount, 0.0, reduce_only=True,
label=label, order_type="limit", price=px)
def cancel_order(self, order_id: str) -> dict:
"""Cancella un ordine resting. {'state': 'cancelled'} su successo;
'error' se l'ordine non e' piu' open (es. gia' fillato) NON fatale:
il chiamante riconcilia i fill via resting_fills (trade history)."""
if not order_id:
return {"state": "error", "error": "no order_id"}
try:
return self.client.cancel_order(order_id)
except Exception as exc:
return {"state": "error", "error": str(exc)}
def resting_fills(self, instrument: str,
order_id: str) -> tuple[float, float | None, float]:
"""Fill (anche parziali) di un ordine resting, riconciliati dal trade
history per order_id (fonte autorevole, come per i market). Ritorna
(amount_fillato, prezzo_medio, fee_usd)."""
if not order_id:
return 0.0, None, 0.0
spec = contract_spec(instrument)
try:
trades = [t for t in self.client.get_trade_history(
limit=100, instrument_name=instrument)
if str(t.get("order_id")) == str(order_id)]
except Exception:
return 0.0, None, 0.0
amt = sum(float(t.get("amount", 0) or 0) for t in trades)
if amt <= 0:
return 0.0, None, 0.0
avg = sum(float(t.get("price", 0) or 0) * float(t.get("amount", 0) or 0)
for t in trades) / amt
fee_coin = sum(float(t.get("fee", 0) or 0) for t in trades)
fee_usd = fee_coin if spec.get("linear") else (fee_coin * avg if avg else 0.0)
return amt, avg or None, fee_usd
def close(self, instrument: str, label: str | None = None) -> Fill: def close(self, instrument: str, label: str | None = None) -> Fill:
"""Chiude a mercato la posizione e riverifica che il conto sia flat, """Chiude a mercato la posizione e riverifica che il conto sia flat,
leggendo la fee di chiusura dal trade history.""" leggendo la fee di chiusura dal trade history."""
+113 -10
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -9,6 +10,7 @@ import numpy as np
import pandas as pd import pandas as pd
from src.strategies.base import Strategy, Signal from src.strategies.base import Strategy, Signal
from src.strategies.fade_base import atr as _atr
from src.live.telegram_notifier import notify_event from src.live.telegram_notifier import notify_event
from src.live.execution import ExecutionClient from src.live.execution import ExecutionClient
@@ -54,6 +56,7 @@ class StrategyWorker:
self.real_entry_fee_usd = 0.0 self.real_entry_fee_usd = 0.0
self.real_entry_notional = 0.0 # USD effettivi esposti all'entrata self.real_entry_notional = 0.0 # USD effettivi esposti all'entrata
self.real_order_id = "" self.real_order_id = ""
self.real_tp_order_id = "" # LIMIT reduce-only resting al TP (persistito per il resume)
self.real_trades = 0 self.real_trades = 0
self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum
@@ -78,6 +81,12 @@ class StrategyWorker:
self.tp: float = 0.0 self.tp: float = 0.0
self.sl: float = 0.0 self.sl: float = 0.0
self.max_bars: int = 0 self.max_bars: int = 0
# EXIT-16 close-confirm SL (2026-06-04, fade): se settato nei params dello
# sleeve, lo SL intrabar e' disattivato e lo stop scatta solo se il CLOSE
# sfonda sl di sl_confirm_atr*ATR14 (immune ai wick). TP intrabar invariato.
self.sl_confirm_atr: float | None = (
float(self.params["sl_confirm_atr"])
if self.params.get("sl_confirm_atr") else None)
# Fee dalla strategia (MR01 = 0.001 realistico Deribit), fallback al default modulo # Fee dalla strategia (MR01 = 0.001 realistico Deribit), fallback al default modulo
self.fee_rt: float = float(getattr(strategy, "fee_rt", FEE_RT)) self.fee_rt: float = float(getattr(strategy, "fee_rt", FEE_RT))
@@ -116,6 +125,7 @@ class StrategyWorker:
self.real_entry_fee_usd = state.get("real_entry_fee_usd", 0.0) self.real_entry_fee_usd = state.get("real_entry_fee_usd", 0.0)
self.real_entry_notional = state.get("real_entry_notional", 0.0) self.real_entry_notional = state.get("real_entry_notional", 0.0)
self.real_order_id = state.get("real_order_id", "") self.real_order_id = state.get("real_order_id", "")
self.real_tp_order_id = state.get("real_tp_order_id", "")
self.real_trades = state.get("real_trades", 0) self.real_trades = state.get("real_trades", 0)
self.real_first_notified = state.get("real_first_notified", False) self.real_first_notified = state.get("real_first_notified", False)
@@ -148,6 +158,7 @@ class StrategyWorker:
"real_entry_fee_usd": self.real_entry_fee_usd, "real_entry_fee_usd": self.real_entry_fee_usd,
"real_entry_notional": self.real_entry_notional, "real_entry_notional": self.real_entry_notional,
"real_order_id": self.real_order_id, "real_order_id": self.real_order_id,
"real_tp_order_id": self.real_tp_order_id,
"real_trades": self.real_trades, "real_trades": self.real_trades,
"real_first_notified": self.real_first_notified, "real_first_notified": self.real_first_notified,
"last_update": datetime.now(timezone.utc).isoformat(), "last_update": datetime.now(timezone.utc).isoformat(),
@@ -234,22 +245,87 @@ class StrategyWorker:
if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva
self._notify("REAL_EXEC_LIVE", data) self._notify("REAL_EXEC_LIVE", data)
self.real_first_notified = True self.real_first_notified = True
self._place_real_tp()
else: else:
self._log("REAL_OPEN_FAIL", {**data, "note": fill.notes}) self._log("REAL_OPEN_FAIL", {**data, "note": fill.notes})
self._notify("REAL_OPEN_FAIL", {**data, "note": fill.notes}) self._notify("REAL_OPEN_FAIL", {**data, "note": fill.notes})
def _place_real_tp(self):
"""LIMIT reduce-only appoggiato al TP della strategia (fix divergenza
sim/reale 2026-06-04: il market-on-poll usciva post-rimbalzo, +235 bps
sopra il livello TP). Copre la SOLA quota del worker. Se il piazzamento
fallisce si resta sul fallback market-on-poll di _real_close."""
self.real_tp_order_id = ""
if not (self.tp and self.real_amount > 0):
return
rest = self.executor.place_tp_limit(self.exec_instrument, self.real_side,
self.real_amount, self.tp,
label=self.worker_id)
data = {
"instrument": self.exec_instrument,
"order_id": rest.order_id,
"tp": round(self.tp, 2),
"amount": self.real_amount,
"state": rest.order_state,
}
if rest.verified and rest.order_id:
self.real_tp_order_id = rest.order_id
self._log("REAL_TP_RESTING", data)
else:
self._log("REAL_TP_FAIL", {**data, "note": rest.notes})
def _real_close(self, sim_exit: float, reason: str, sim_pnl: float): def _real_close(self, sim_exit: float, reason: str, sim_pnl: float):
"""Chiusura REALE (reduce-only della quota worker) + confronto col sim.""" """Chiusura REALE (reduce-only della quota worker) + confronto col sim.
Prima riconcilia l'eventuale LIMIT resting al TP: lo cancella (innocuo
se gia' fillato — cosi' nessun fill puo' arrivare DOPO la lettura) e
legge i fill reali dal trade history per order_id; solo la quota residua
viene chiusa a mercato (fallback, o exit non-TP: stop-loss/time_limit).
L'uscita take-profit reale avviene cosi' AL livello come nel backtest,
non al poll post-rimbalzo."""
if not self.real_in_position: if not self.real_in_position:
return return
fill = self.executor.close_amount(self.exec_instrument, self.real_side, from src.live.execution import contract_spec
self.real_amount, label=self.worker_id) step = contract_spec(self.exec_instrument)["step"]
exit_price = fill.fill_price or sim_exit
# 1) ordine TP resting: cancella, poi riconcilia i fill (order_id su history)
tp_amt, tp_px, tp_fee = 0.0, None, 0.0
tp_order_id = self.real_tp_order_id
if tp_order_id:
cres = self.executor.cancel_order(tp_order_id)
cancelled = cres.get("state") == "cancelled"
for _ in range(self.executor.verify_polls):
tp_amt, tp_px, tp_fee = self.executor.resting_fills(
self.exec_instrument, tp_order_id)
if tp_amt > 0 or cancelled:
break # cancel pulito = al piu' fill parziali gia' visti
time.sleep(self.executor.verify_sleep)
tp_amt = min(tp_amt, self.real_amount)
if tp_amt > 0 and not tp_px:
tp_px = self.tp or sim_exit # fallback: il limit filla al suo livello
# 2) quota residua → market reduce-only (mai close_position: strumento condiviso)
remainder = self.real_amount - tp_amt
fill = None
if remainder >= step / 2:
fill = self.executor.close_amount(self.exec_instrument, self.real_side,
remainder, label=self.worker_id)
market_amt = fill.amount if (fill and fill.verified) else 0.0
# 3) prezzo d'uscita combinato (media pesata TP-fill + market) e fee totali
parts = [(a, p) for a, p in ((tp_amt, tp_px),
(market_amt, fill.fill_price if fill else None))
if a > 0 and p]
exit_price = (sum(a * p for a, p in parts) / sum(a for a, _ in parts)
if parts else sim_exit)
exit_fee = tp_fee + (fill.fee_usd if fill else 0.0)
verified = (tp_amt + market_amt) >= self.real_amount - step / 2
rdir = 1 if self.real_side == "buy" else -1 rdir = 1 if self.real_side == "buy" else -1
price_change = (exit_price - self.real_entry_price) / self.real_entry_price \ price_change = (exit_price - self.real_entry_price) / self.real_entry_price \
if self.real_entry_price else 0.0 if self.real_entry_price else 0.0
real_gross = rdir * price_change * self.real_entry_notional real_gross = rdir * price_change * self.real_entry_notional
real_fees = self.real_entry_fee_usd + fill.fee_usd real_fees = self.real_entry_fee_usd + exit_fee
real_pnl = real_gross - real_fees real_pnl = real_gross - real_fees
self.real_capital += real_pnl self.real_capital += real_pnl
self.real_trades += 1 self.real_trades += 1
@@ -258,16 +334,19 @@ class StrategyWorker:
if exit_price and sim_exit else None) if exit_price and sim_exit else None)
self._log("REAL_CLOSE", { self._log("REAL_CLOSE", {
"reason": reason, "reason": reason,
"order_id": fill.order_id, "order_id": fill.order_id if fill else tp_order_id,
"tp_order_id": tp_order_id or None,
"tp_filled_amount": tp_amt,
"market_amount": market_amt,
"sim_exit": round(sim_exit, 2), "sim_exit": round(sim_exit, 2),
"real_fill": fill.fill_price, "real_fill": round(exit_price, 2) if parts else None,
"slippage_bps": round(slip_bps, 2) if slip_bps is not None else None, "slippage_bps": round(slip_bps, 2) if slip_bps is not None else None,
"entry_fee_usd": round(self.real_entry_fee_usd, 5), "entry_fee_usd": round(self.real_entry_fee_usd, 5),
"exit_fee_usd": round(fill.fee_usd, 5), "exit_fee_usd": round(exit_fee, 5),
"real_pnl_usd": round(real_pnl, 4), "real_pnl_usd": round(real_pnl, 4),
"sim_pnl_usd": round(sim_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4),
"real_capital": round(self.real_capital, 4), "real_capital": round(self.real_capital, 4),
"verified": fill.verified, "verified": verified,
}) })
self.real_in_position = False self.real_in_position = False
@@ -277,6 +356,7 @@ class StrategyWorker:
self.real_entry_fee_usd = 0.0 self.real_entry_fee_usd = 0.0
self.real_entry_notional = 0.0 self.real_entry_notional = 0.0
self.real_order_id = "" self.real_order_id = ""
self.real_tp_order_id = ""
def _close_position(self, current_price: float, reason: str): def _close_position(self, current_price: float, reason: str):
if not self.in_position: if not self.in_position:
@@ -346,7 +426,30 @@ class StrategyWorker:
self.bars_held += 1 self.bars_held += 1
self.last_bar_ts = current_ts self.last_bar_ts = current_ts
if self.tp and self.sl: if self.tp and self.sl and self.sl_confirm_atr:
# EXIT-16 close-confirm (2026-06-04): TP intrabar al livello come il
# backtest; lo SL scatta SOLO se il close sfonda sl ∓ buf*ATR14 — i
# wick che bucano lo stop e rientrano (l'overshoot che la fade fada)
# non stoppano piu'. Uscita stop al CLOSE (prezzo corrente), non al
# livello. PORT06: OOS Sharpe 8.82->10.06 (exit-lab, 34 agenti).
buf = self.sl_confirm_atr * float(_atr(df, 14)[-1])
if not np.isfinite(buf):
buf = 0.0
if self.direction == 1:
if bar_high >= self.tp:
self._close_position(self.tp, "take_profit")
elif current_price < self.sl - buf:
self._close_position(current_price, "stop_loss")
elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
else:
if bar_low <= self.tp:
self._close_position(self.tp, "take_profit")
elif current_price > self.sl + buf:
self._close_position(current_price, "stop_loss")
elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
elif self.tp and self.sl:
# Exit INTRABAR come il backtest: si controllano high/low della barra (non solo il # Exit INTRABAR come il backtest: si controllano high/low della barra (non solo il
# close) e si esce AL LIVELLO tp/sl. SL prima (conservativo), poi TP, poi time-limit. # close) e si esce AL LIVELLO tp/sl. SL prima (conservativo), poi TP, poi time-limit.
if self.direction == 1: if self.direction == 1:
+22 -5
View File
@@ -74,6 +74,15 @@ class FadeStrategy(Strategy):
if not signals: if not signals:
return None return None
# EXIT-16 close-confirm SL (2026-06-04): se settato, lo SL intrabar e'
# DISATTIVATO e lo stop scatta solo se il CLOSE sfonda sl ∓ buf*ATR14
# (immune ai wick: l'overshoot che buca lo stop e rientra e' esattamente
# il movimento che la fade vuole fadare). TP intrabar e max_bars invariati.
# PORT06: FULL Sharpe 6.47->7.84 DD 4.10->2.60, OOS 8.82->10.06 (exit-lab,
# 34 agenti). None = comportamento storico. Diario 2026-06-04-exit-lab.md.
sl_confirm = params.get("sl_confirm_atr")
a14 = atr(df, 14) if sl_confirm else None
h, l, c = df["high"].values, df["low"].values, df["close"].values h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c) n = len(c)
fee = self.fee_rt * self.leverage fee = self.fee_rt * self.leverage
@@ -95,12 +104,20 @@ class FadeStrategy(Strategy):
j = i + step j = i + step
if j >= n: if j >= n:
j = n - 1; exit_p = c[j]; break j = n - 1; exit_p = c[j]; break
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp) hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if hit_sl: # conservativo: SL prima del TP nello stesso bar if sl_confirm is None:
exit_p = sl; break hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
if hit_tp: if hit_sl: # conservativo: SL prima del TP nello stesso bar
exit_p = tp; break exit_p = sl; break
if hit_tp:
exit_p = tp; break
else:
# close-confirm: TP intrabar al livello; SL valutato sul CLOSE
if hit_tp:
exit_p = tp; break
buf = sl_confirm * a14[j]
if (d == 1 and c[j] < sl - buf) or (d == -1 and c[j] > sl + buf):
exit_p = c[j]; break
if step == mb: if step == mb:
exit_p = c[j] exit_p = c[j]
+74
View File
@@ -0,0 +1,74 @@
"""EXIT-16 close-confirm SL (2026-06-04): col param `sl_confirm_atr` il wick che
buca lo SL NON stoppa piu' (immune ai wick); lo stop scatta solo se il CLOSE
sfonda sl buf*ATR14, con uscita al close. TP intrabar invariato.
Senza param il comportamento resta quello storico (regressione)."""
import pandas as pd
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
def _df(last_high, last_low, last_close, n=120, price=100.0):
c = [price] * n
h = [price] * n
l = [price] * n
h[-1] = last_high
l[-1] = last_low
c[-1] = last_close
ts = (pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC").astype("int64") // 10**6)
return pd.DataFrame({"timestamp": ts, "open": [price] * n, "high": h, "low": l,
"close": c, "volume": 1.0})
def _long_worker(tmp, confirm=0.5):
params = {"sl_confirm_atr": confirm} if confirm else {}
w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h",
capital=1000.0, params=params, data_dir=tmp)
w._notify = lambda *a, **k: None
w.in_position = True
w.direction = 1
w.entry_price = 100.0
w.tp = 102.0
w.sl = 98.0
w.max_bars = 24
w.bars_held = 1
w.last_bar_ts = 0
return w
def test_wick_below_sl_does_not_stop(tmp_path):
# low 97 buca lo SL(98) ma il CLOSE rientra a 100 -> resta in posizione
w = _long_worker(tmp_path)
w.tick(_df(last_high=100.5, last_low=97.0, last_close=100.0))
assert w.in_position
def test_same_wick_stops_without_param(tmp_path):
# regressione: senza sl_confirm_atr lo stesso wick stoppa intrabar al livello
w = _long_worker(tmp_path, confirm=None)
w.tick(_df(last_high=100.5, last_low=97.0, last_close=100.0))
assert not w.in_position
def test_close_breach_stops_at_close(tmp_path):
# close 96.5 sfonda sl-buf (ATR del df flat ~ TR ultimo bar /14 -> buf piccolo) -> stop
w = _long_worker(tmp_path)
cap_before = w.capital
w.tick(_df(last_high=100.5, last_low=96.0, last_close=96.5))
assert not w.in_position
assert w.capital < cap_before # uscito al CLOSE (in perdita), non al livello
def test_tp_intrabar_still_first(tmp_path):
# high tocca il TP(102) intrabar -> esce al TP anche in modalita' close-confirm
w = _long_worker(tmp_path)
w.tick(_df(last_high=102.5, last_low=99.5, last_close=100.0))
assert not w.in_position
assert w.capital > 1000.0 # uscito al TP in profitto
def test_small_breach_within_buffer_holds(tmp_path):
# close appena sotto SL ma DENTRO il buffer ATR -> resta in posizione
w = _long_worker(tmp_path)
df = _df(last_high=100.0, last_low=94.0, last_close=97.95) # TR=6 -> ATR~0.43 -> buf~0.21
w.tick(df)
assert w.in_position