98 Commits

Author SHA1 Message Date
Adriano Dal Pastro 35c0133e63 release: v1.1.2 2026-06-05 18:43:32 +00:00
Adriano Dal Pastro 2da3457ca7 fix(live): EXIT-16 confirm-SL valutato sul close di barra COMPLETATA + alert STALE_FEED
Il worker valutava il confirm sul prezzo della candela IN FORMAZIONE ad ogni poll,
reintroducendo la wick-sensitivity che EXIT-16 elimina (audit crash ETH 2026-06-05:
2 stop su 3 erano wick-stop che il backtest validato non avrebbe preso in quel
momento). Ora il confirm usa SOLO il close dell'ultima barra completata (riga -1 =
candela in corso finche' now < ts[-1]+bar_ms), buf dall'ATR della stessa barra,
fill al prezzo corrente (~ stress lag_close_exit OK in exit-lab), TP intrabar
invariato. Concausa feed-gap: non mitigabile lato exit (fill reali ~ sim);
entry-guard post-flat TESTATA e BOCCIATA (skippare i segnali dopo barre flat
peggiora tutti gli sleeve ETH: la candela-gap e' l'overshoot che la fade fada).
Aggiunto alert Telegram STALE_FEED (>=2 barre 1h flat -> notifica + gap % al
risveglio, dedup per episodio, solo osservabilita').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:32:14 +00:00
Adriano Dal Pastro ff2ff6de7d release: v1.1.1 2026-06-05 17:56:31 +00:00
Adriano Dal Pastro bd6232dc00 config(PORT06): cap SHAPE 0.0588 — SH01 resta senza SL (ricerca multi-agente: 11 famiglie di stop, 0 sopravvissute)
Crash ETH 2026-06-05: SH01 ETH −15.6% su un trade (exit solo a orizzonte, nessuna
protezione). Ricerca con harness dedicato sh01_exit_lab (cache walk-forward, engine
fill gap-aware worse(livello,open), parity esatta con explore_lab, train<=2023-11-01):
ATR intrabar/close-confirm, %, chandelier, breakeven, giveback, loser-timestop,
disaster-cap close+intrabar, swing, vol-regime — NESSUNA passa il gate (ogni stop
stretto rompe BTC, ogni stop largo non tocca la coda ETH; nei crash il fill e' al gap).
Mitigazione: peso famiglia SHAPE 11.8%->5.9% in PORT06 (FULL 6.47->6.43 DD 4.10->3.96,
OOS 8.82->8.58 DD 1.30->1.36) — la prossima coda impatta il conto per meta'.
Regression-lock test aggiornato. Diario: docs/diary/2026-06-05-sh01-sl-research.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:56:16 +00:00
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
Adriano Dal Pastro 4f5bd32c38 release: v1.0.5 2026-06-03 18:01:30 +00:00
Adriano Dal Pastro 74c79aa6c1 feat(live): position_size configurabile da overrides (0.5 = max impiego dei 2K senza debito margine)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:01:30 +00:00
Adriano Dal Pastro 092e809946 docs(live): leva per-strumento NON impostabile su Deribit (set_leverage=400, verificato nei log Cerbero) — l'esposizione la decide la size
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:52:55 +00:00
Adriano Dal Pastro 92de7c9867 release: v1.0.4 2026-06-03 10:20:12 +00:00
Adriano Dal Pastro 719509fb2f feat(live): alert Telegram esecuzione reale + doc shadow-execution in CLAUDE.md
- telegram_notifier: eventi REAL_EXEC_LIVE (primo ordine reale verificato per worker)
  e REAL_OPEN_FAIL (apertura reale non verificata)
- StrategyWorker: notifica una-tantum al primo REAL_OPEN verificato + ogni FAIL;
  flag real_first_notified persistito
- CLAUDE.md: documentato lo shadow-execution (ExecutionClient, lineari USDC,
  verifica sul trade, fee reali, config, alert, smoke)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:20:08 +00:00
Adriano Dal Pastro fec15c07bb release: v1.0.3 2026-06-03 10:12:23 +00:00
Adriano Dal Pastro cb1b6ea46a feat(live): esecuzione REALE su Deribit testnet (shadow) per i 6 fade sui lineari USDC
- ExecutionClient: notional->amount (lineare USDC + inverse), open/close_amount
  reduce-only, verifica sul trade (order_id), fee reali lette dai trades[]
- CerberoClient: place_order market + reduce_only, get_trade_history
- StrategyWorker: shadow (REAL_OPEN/REAL_CLOSE accanto al sim), ledger reale
  parallelo persistito, confronto slippage/fee sim-vs-reale
- runner+portfolios.yml: config execution (6 fade MR01/MR02/MR07 x BTC/ETH su
  BTC_USDC/ETH_USDC-PERPETUAL), capitale 2000
- smoke: live_exec_smoke (layer) + live_shadow_smoke (catena worker), provati su testnet

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:11:26 +00:00
Adriano Dal Pastro 1f0c1ab02a release: v1.0.2 (revert fallback mainnet) 2026-06-02 19:50:47 +00:00
Adriano Dal Pastro b5de241ebc revert(live): rimuovi fallback mainnet — il paper deve usare il venue di esecuzione (testnet)
Il fallback a Deribit mainnet per i dati introduceva una DIVERGENZA paper<->esecuzione: il paper
simulava su prezzi mainnet ma gli ordini (place_order via Cerbero) andrebbero su TESTNET, i cui
prezzi/liquidità divergono ~9%. Un paper trader deve usare la STESSA fonte/venue dove gli ordini
verrebbero eseguiti, altrimenti non predice il comportamento reale. Durante un outage testnet il
runner si mette in pausa (corretto: senza il venue non si eseguirebbe comunque). Rimosso
get_historical_mainnet e il wiring nel runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:50:47 +00:00
Adriano Dal Pastro 5157cd544d release: v1.0.1 (fallback mainnet) 2026-06-02 16:58:47 +00:00
Adriano Dal Pastro 722837e599 Merge feat/mainnet-fallback: OHLCV da Deribit mainnet quando testnet e' giu'
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:58:14 +00:00
Adriano Dal Pastro 8e72d7ad02 feat(live): fallback OHLCV a Deribit MAINNET quando Cerbero/testnet e' giu'
Deribit testnet (test.deribit.com) va giu' periodicamente (502) e Cerbero lo rilancia ->
il runner si bloccava senza dati. Aggiunto CerberoClient.get_historical_mainnet (Deribit MAINNET
public, NO-AUTH, paginato sotto il cap ~5000 candele/chiamata) e fallback nel runner: try Cerbero
-> on fail/empty usa mainnet. Prezzi REALI (meglio del testnet farlocco per il paper). Verificato
durante l'outage: tutti gli 8 strumenti (BTC/ETH + alt _USDC) coperti su mainnet. Log una-tantum
all'attivazione/disattivazione del fallback.

Caveat: testnet e mainnet hanno prezzi diversi (~9%) -> al primo switch le posizioni aperte su
prezzi testnet vanno resettate (transizione pulita).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:58:14 +00:00
Adriano Dal Pastro 87b420f459 docs: monitor loss-guard nel report orario + effetto misurato (stop-loss -67%)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:17:05 +00:00
Adriano Dal Pastro 6605008f49 feat(report): monitor loss-guard Hurst nel report orario Telegram
Traccia lo stop-rate delle fade (MR01/MR02/MR07) PRIMA/DOPO l'attivazione del loss-guard
(2026-06-02 14:34 UTC). Verdetto automatico quando il campione DOPO >= 30 trade. Conferma gia'
visibile: stop-rate live PRIMA 42% (n=36) == backtest 42.1%. Gira host-side (cron), no rebuild.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:54:37 +00:00
Adriano Dal Pastro b5f644e52d docs: aggiorna CLAUDE.md e README (loss-guard Hurst, versione/deploy, fix SH01, regime infra)
- Loss-guard Hurst sulle fade (hurst_max=0.55, dimezza DD PORT06) — attivo live
- Sistema versione + deploy.sh (versione nei msg Telegram, +1 ad ogni deploy)
- Fix wiring SH01 (StrategyWorker reale, non MLWorkerWrapper squeeze)
- Fix StrategyWorker: exit orizzonte, is_win netto, min_tp_frac
- Infrastruttura regime (regime_lab/regime_fetcher, data/regime/)
- Esiti ricerca: ARGO/GEX no-go, frattali x regime (FR01 robusto ma non deployato)
- Nota deploy = up -d --build (non restart)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:39:59 +00:00
Adriano Dal Pastro 7da9dc36e4 Merge feat/version-tag: versione nei msg Telegram (+1 ad ogni deploy)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:34:18 +00:00
Adriano Dal Pastro 4770a8368f feat(deploy): tag di versione nei msg Telegram, +1 ad ogni deploy
File VERSION (semver, cotto nell'immagine) letto da src/version.py. Compare nelle notifiche trade
(telegram_notifier) e nel report orario -> sai quale codice ha generato quale msg.
scripts/bump_version.py incrementa la patch; scripts/deploy.sh = bump+commit+rebuild (versione
aumenta ad OGNI deploy). v1.0.0 = primo release versionato (include hurst loss-guard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:34:18 +00:00
Adriano Dal Pastro d624fe74c9 Merge feat/fade-lossguard: loss-guard Hurst sulle fade (attivo live)
Sistema anti-perdite: skip fade in regime persistente (hurst>=0.55). Dimezza il DD del PORT06
(FULL 4.10%->2.39%, Sharpe 6.62->6.76, OOS 8.89->9.15). Solo l'Hurst supera il gate (ADX/vol-expansion
falliscono). Attivo live sulle 6 fade. Diagnostici + workflow 11 agenti + diario.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:17:07 +00:00
Adriano Dal Pastro 29c2ea488f feat(live): attiva loss-guard Hurst sulle fade PORT06 (hurst_max=0.55)
Attivazione live: hurst_max=0.55 nei params delle 6 fade (MR01/MR02/MR07 x BTC/ETH) in _defs.py.
hurst_skip_mask step=6 (5.9s/fade su 10560 barre live, ~35s per le 6 -> OK su poll 60s, e coincide
con la cache di validazione). Calcolato dalle SOLE close -> nessun feed esterno, il worker lo computa
inline.

NB live/backtest: il filtro agisce solo sul path LIVE (spec.params); il backtest canonico
(build_everything/regression-lock, via risk_management) NON e' filtrato -> il live FARA' meglio del
backtest sul DD (FULL 4.10%->2.39% atteso). Divergenza intenzionale (miglioramento). Backtest-parity
aggiornabile in seguito. Suite: 54 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:17:07 +00:00
Adriano Dal Pastro ac6f3766b0 feat(fade): loss-guard Hurst (skip regime persistente) — dimezza il DD del PORT06
GOAL: limitare le perdite delle fade in regime sfavorevole. Diagnosi (3022 trade): le perdite/stop
si concentrano nel regime PERSISTENTE (hurst>0.55: stop-rate 43% vs 21% anti-persistente), NON in
bassa vol (low-vol e' net positivo). Ricerca web + workflow 11 agenti: l'UNICO meccanismo che riduce
DD senza uccidere l'edge e' il filtro Hurst (ADX, vol-expansion, time-stop, ER, vol-target falliscono
il gate FR01). Test esterni ADX/vol-expansion NON si replicano su queste fade crypto.

TEST DECISIVO PORT06 (gate FR01) SUPERATO: Hurst-skip h<0.55 sulle 6 fade ->
FULL Sharpe 6.62->6.76, FULL DD 4.10%->2.39% (quasi dimezzato), OOS Sharpe 8.89->9.15.
Migliora il portafoglio (a differenza di FR01 che diluiva).

Implementazione: hurst_skip_mask in fade_base.py (rolling-Hurst causale dalle SOLE close -> nessun
feed dati esterno, deployabile inline dal worker) + param hurst_max (default None=off) in
MR01/MR02/MR07. Test: test_hurst_lossguard.py. Default off -> zero impatto su backtest/parita'/live
finche' non attivato.

FIX collaterale: regime_fetcher/regime_lab scrivevano DVOL/funding/feature in data/raw/ ->
inquinavano la discovery asset del backtest (rompeva il regression-lock PORT06). Spostati in
data/regime/ (gitignored). Suite: 54 passed (lock incluso).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:08:15 +00:00
Adriano Dal Pastro 4d9f2af0c0 research(fade-lossguard): diagnostici perdite fade per regime + workflow anti-perdite
Diagnosi (3022 trade fade 2021+): perdite/stop concentrati in regime PERSISTENTE (hurst>0.55,
SL-rate 43% vs 21% anti-persistente), NON in bassa vol (low-vol e' net positivo). Ricerca web
conferma: filtro Hurst<0.45 / ADX<20 / vol-expansion ratio>1.5 (prevenne 72% perdite maggiori).
Workflow 11 agenti testa i meccanismi sulle fade reali.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:35:44 +00:00
Adriano Dal Pastro 26de94e57d Merge feat/fractal-argo-search: ricerca 100 agenti frattali x ARGO (record)
Esito: 15 strategie frattale x regime robuste e causali, ma NESSUNA migliora PORT06
(FR01 lo diluisce: OOS Sharpe 8.89->8.72). Prior ARGO 'VRP>0=range' smentito, edge su
VRP<0 + DVOL bassa. RECORD DI RICERCA: non deployato, infrastruttura regime riusabile.

Artefatti: regime_fetcher/regime_lab (dati DVOL+funding Deribit mainnet, feature regime+frattali
causali), fractal_argo_workflow.js (171 agenti), FR01_hurst_calm_fade.py (NON in MODULE_MAP/yml),
analisi per-anno + test MASTER, diario 2026-06-02.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:49:28 +00:00
Adriano Dal Pastro 8fd8f64368 test(research): FR01 NON migliora PORT06 (verdetto decisivo)
Contributo marginale al MASTER (equal-weight): OOS Sharpe 8.89->8.72 (diluisce),
OOS ret +175%->+156%. Corr FR01 vs MASTER +0.18/+0.23. Sharpe daily-return standalone
~1.85 (non il 3.73 per-trade) -> troppo basso per un PORT06 a 8.89. Ridondanza robusta.
ESITO: search a 100 agenti rigoroso, ma nessuna strategia migliora PORT06. Deploy abbandonato,
resta record di ricerca sul branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:48:02 +00:00
Adriano Dal Pastro 7005763517 feat(research): FR01 Hurst-Calm Fade + analisi per-anno/mercato (ricerca 100 agenti)
Esito ricerca frattali x regime ARGO (171 agenti):
- FR01_hurst_calm_fade.py: vincitore = fade gateato da hurst<0.55 (anti-persistente) +
  dvol_pct<0.4 (DVOL bassa). OOS Sharpe 3.73 BTC, 6/6 anni positivi su BTC+ETH, corr bassa
  coi fade esistenti (MR01 +0.17/MR02 +0.08/MR07 -0.03) -> diversificatore non ridondante.
- fractal_argo_peryear.py: analisi per-anno/regime-mercato dei top candidati.
- diario 2026-06-02: verdetto completo. Finding chiave: prior ARGO 'VRP>0=range=fade' SMENTITO,
  l'edge robusto e' su VRP<0 + DVOL bassa.

Diversificatori, NON spodestano PORT06 (OOS Sharpe 8.19). Branch di ricerca.
Deploy bloccato da: verifica corr sul MASTER intero + wiring DVOL live nel runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:22:10 +00:00
Adriano Dal Pastro 5f229cd66e fix(regime_lab): vrp annualizzato per timeframe + report() safe su 0 entries
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:08:33 +00:00
Adriano Dal Pastro 7169614506 feat(research): substrato ricerca frattali x regime ARGO
- regime_fetcher.py: fetch DVOL (2021+) + funding (2019+) BTC/ETH da Deribit mainnet public
- regime_lab.py: API condivisa, allineamento regime<->prezzo CAUSALE no-look-ahead,
  feature regime (dvol_pct/vrp/funding_z/dvol_chg) + frattali (hurst/higuchi/vratio/williams),
  cache feature precalcolate, report()=netto-fee OOS via explore_lab
- fractal_argo_workflow.js: workflow ~100 agenti (84 griglia + 8 wildcard + verifica + sintesi)

Branch di ricerca: nessun impatto su main/live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:05:14 +00:00
Adriano Dal Pastro 69619df4c7 chore: gitignore stato locale tooling (.claude/, .omc/)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:35:19 +00:00
Adriano Dal Pastro 056d18a3fd Merge feat/hourly-telegram-report: report orario PORT06 su Telegram
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:02:37 +00:00
Adriano Dal Pastro 99efc7a042 feat(report): report orario PORT06 su Telegram
Script standalone che legge lo stato persistito del paper trader a portafoglio
(data/portfolio_paper/*/ + data/portfolios/PORT06/status.json) e invia su
Telegram: (1) trade CHIUSI positivi/negativi NETTO fee con breakdown per motivo
e PnL; (2) tabella trade IN CORSO (posizioni aperte, single e pairs a 2 gambe);
(3) PnL realizzato totale + equity mark-to-market (con non realizzato).

Carica .env da solo (cron non eredita l'env del container), legge file
world-readable scritti dal container, non tocca lo stato del trader. Riusa
send_telegram di telegram_notifier.

Schedulazione: crontab host orario (minuto 0):
  0 * * * * uv run --project /opt/docker/PythagorasGoal python \
            scripts/portfolios/hourly_report.py
(uv run --project + path assoluto: nessun cd necessario.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:02:37 +00:00
Adriano Dal Pastro e44a310a3b Merge fix/sh01-live-wiring: SH01 esegue shape-ML reale (non squeeze)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:24:14 +00:00
Adriano Dal Pastro 83c4e7a334 fix(live): SH01 esegue shape-ML reale, non il wrapper squeeze scartato
Bug di wiring: runner.py avvolgeva lo sleeve SH01 nel MLWorkerWrapper legacy
di multi_runner, che usa SignalEngine (famiglia squeeze ML01 SCARTATA), apre
con Signal nudo ed esce a hold_bars=3 con tick() propria. Risultato: lo sleeve
"SH01" del portafoglio live NON eseguiva SH01_shape_ml (generate_signals mai
chiamata) e il fix horizon-exit era in un ramo morto -> SH01 continuava a
chiudere a hold_limit/3.

Fix: SH01 (kind="ml") gira come StrategyWorker normale. SH01_shape_ml.
generate_signals fa il walk-forward internamente ad ogni tick ed emette
metadata.max_bars=H=12 -> exit via StrategyWorker.tick (orizzonte H, fix
applicato). Rimosso l'import/uso di MLWorkerWrapper e il blocco train esterno.

ml_wf_entries ha train_min=4000 (>=4000 barre 1h per produrre segnali):
aggiunto _ML_LOOKBACK_DAYS=365 cosi gli asset di sleeve ml fetchano >=365g
(~8760 barre), senza dipendere dal fetch 440g di TSM01/ROT02. generate_signals
su 365g: 0,17-0,24s (logit) -> trascurabile sul poll 60s.

Test: test_build_ml_sh01_is_plain_strategyworker (StrategyWorker + strategy
SH01_shape_ml + niente engine squeeze). Suite: 51 passed.

Stato live: SH01 BTC/ETH flat -> contatori resettati (capitale preservato),
trade squeeze archiviati. Rebuild+recreate: 14 worker RESUME puliti, healthy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:24:13 +00:00
Adriano Dal Pastro 56565f6f73 Merge fix/win-net-and-tp-min-edge: win netto-fee + filtro TP edge-minimo
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:07:30 +00:00
Adriano Dal Pastro ab4f706057 fix(live): win netto-fee + filtro TP edge-minimo sulle fade
Due correzioni emerse da close live con win=True ma pnl<0.

1) Metrica win lorda -> netta. _close_position contava is_win=trade_return>0
   (lordo), gonfiando l'accuracy: un take-profit colpito con mossa < fee RT
   risultava "win" pur perdendo. 51 close live: 39 win (76,5%) -> 13 falsi win
   -> accuracy netta reale 52,9%. Fix: is_win = net > 0. Capitale/PnL erano
   già corretti (netti). Contatori persistiti riconciliati a parte (MR01/DIP01
   BTC 7->1).

2) Filtro edge-minimo min_tp_frac. I 13 falsi win erano tutti MR01/DIP01 BTC in
   regime piatto: TP (la media) entro il costo round-trip -> perdenti garantiti.
   Aggiunto param min_tp_frac (default 0.0=off) a tutte e 4 le fade (MR01 banda,
   MR02 midpoint, MR07 ATR, DIP01): salta i segnali col TP entro la soglia.
   Non si "allarga" il TP (rischierebbe di perdere di piu'): si evita la trade.
   Cablato live a 0.0015 (1,5x fee) in _defs.py.

Validazione backtest BTC+ETH 1h: neutro su tutte le fade (0-1 trade rimossi,
pnl invariato o +leggero su DIP01). I micro-scalp sotto-fee non esistono nello
storico -> artefatto del regime attuale. Filtro puro-upside.

Test: test_win_net_of_fees.py, test_min_tp_frac.py (monotonia + gap > soglia +
default-off invariato). Suite: 50 passed.

NB deploy: il sorgente e' COPY nell'immagine, non montato -> serve
`docker compose up -d --build`, non un semplice restart (vale anche per il fix
SH01 horizon-exit, andato live solo con questo rebuild). Volume ./data persiste,
14 worker in RESUME puliti.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:07:30 +00:00
Adriano Dal Pastro 05ebd6754b Merge fix/sh01-horizon-exit: exit a orizzonte SH01 (H=12, non hold_bars=3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:06:49 +00:00
Adriano Dal Pastro b5d277478c docs(diary): bugfix exit a orizzonte SH01 (3 barre -> H=12)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:06:49 +00:00
Adriano Dal Pastro 53e0965c4b fix(live): exit a orizzonte per strategie senza TP/SL (SH01)
Lo StrategyWorker onorava max_bars (orizzonte del Signal) solo nel ramo
`if self.tp and self.sl`. SH01 (shape-ML, H=12) non porta TP/SL, quindi
cadeva sul fallback legacy hold_bars=3 e chiudeva a 3 barre invece delle
12 validate: l'edge (asimmetria sull'orizzonte, non frequenza) non aveva
tempo di realizzarsi -> accuratezza live falsata (33%), tutti exit
"hold_limit" a bars_held=3.

Aggiunto un ramo `elif self.max_bars` che esce a "time_limit" quando
bars_held>=max_bars, prima del fallback hold_bars. Tocca solo le
strategie horizon-only (SH01); le fade con tp+sl+max_bars sono invariate.

Test: tests/portfolio/test_horizon_exit.py (resta in posizione a 3 barre
con max_bars=12; esce a 12 con reason time_limit). Suite: 43 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:05:53 +00:00
Adriano Dal Pastro aaf0221957 docs(diary): studio exit fade (scalping / TP dinamico / TP-ATR)
Tre alternative di uscita misurate su MR02 (fee-aware) vs baseline TP=centro canale:
- 15m "scalp": piu' PnL lordo ma fragile a fee/slippage (DD esplode a 0.20%).
- trailing/TP dinamico: win-rate 48%->36%, azzera l'edge (let-it-run = continuazione).
- TP-ATR: TP stretto m=0.5 -> win 77% ma edge ~0 (trappola scalping); nessun
  multiplo ATR batte il centro su avg/trade e Sharpe (FULL+OOS, BTC/ETH).
Verdetto: TP=centro canale e' ottimale (target adattivo alla struttura, gia'
scalato alla vol). Design blindato. Nota: win-rate alto != edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:52:44 +00:00
Adriano Dal Pastro 1177da33ea docs(diary): stato trade live PORT06 (snapshot verificato 2026-05-31)
~43h di paper trading: 10 trade chiusi (9W/1L, +EUR0.40 realizzato), 3 aperti,
equity MtM EUR1000.36, max DD 0.40%. Check: 0 anomalie (net=gross-fee, win
coerente, fee incluse), uscite pairs conformi a z_exit=0.75, riconciliazione
ledger spiegata (timing rebal). Campione a livello rumore ma sistema sano e
fedele al backtest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:23:35 +00:00
Adriano Dal Pastro 03e8938a18 docs(diary): record anti-ripetizione strategie scartate (opzioni + TA classiche)
Conclusioni-only (codice testato e scartato, non conservato): perche' non
funzionano, cosi' da non ri-testarle.
- Opzioni: overlay PORT06 / OH01 direzionale / OH02 credit-spread / V5 debit
  spread (Casario) / V4 box. Nessun edge nuovo: trend e MR gia' catturati 50-100x
  meglio dai perp; VRP contro chi compra, code grasse contro chi vende.
- TA: SMA pullback / ORB / weakness rectangle -- tutte continuazione, negative
  anche a fee 0. Flip al fade: 2/3 segno positivo ma = MR01/MR02 inferiori, WR
  rumore. Riconferma: solo mean-reversion paga.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 07:17:10 +00:00
Adriano Dal Pastro c13773e762 test(portfolio): aggiorna regression-lock PORT06 dopo recupero dati BNB/DOGE/XRP
Il recupero dello storico BNB/DOGE/XRP (29 mag) ha ampliato la copertura del
backtest -> metriche migliorate, non una regressione:
  Sharpe FULL 6.07 -> 6.47, Sharpe OOS 8.19 -> 8.82, DD FULL 4.9% -> 4.1%.
Aggiornati i tre valori attesi (tolleranze invariate) + commento col motivo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 07:10:40 +00:00
Adriano Dal Pastro 5a219ca8e5 feat(analysis): proiezione 3 anni capitale PORT06 (live 2x, esclude 2024)
Script di proiezione: partendo da 1000 EUR con capitale che compone e
puntata che cresce, stima guadagno giornaliero e traiettoria a 3 anni.
Riscalata sul sizing LIVE (pos 0.15 x 2x) vs backtest 3x; ROT02/TSM01
usano gross fisso (non riscalano). Esclude il 2024 (anno eccezionale)
e include haircut ~50% per lo scenario sobrio prudente.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:29:07 +00:00
Adriano Dal Pastro 2a11728384 feat(docker): deploy PORT06 portfolio runner via docker compose
Wire the Docker image and compose service for the capital-pool portfolio
paper trader (src.portfolio.runner) instead of the single-leg multi_runner:

- Dockerfile: copy full scripts/ (runner imports scripts.analysis.* and
  scripts.portfolios._defs via sleeves.py) and portfolios.yml.
- docker-compose.yml: service "portfolio" / container pythagoras-portfolio,
  command override to src.portfolio.runner, mount portfolios.yml, healthcheck
  on data/portfolios/ status.json.
- .gitignore: ignore portfolio runtime state (data/portfolio_paper, data/portfolios).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:30:33 +00:00
Adriano 7b2e0049eb test(live): demo numerica exit intrabar fade -- worker replay ~= backtest
MR01 BTC 1h, 4000 barre, no filtro trend: backtest build_trades +3.5%/73 trade vs
worker replay intrabar +4.5%/78 trade -> gap +1.0pt (allineato). Conferma che il fix
exit intrabar chiude il gap live-vs-backtest delle fade (residuo = bar-timing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:43:38 +02:00
Adriano fb65df7861 merge: fix exit intrabar StrategyWorker (fade/DIP01 allineate al backtest)
Lo StrategyWorker esce su TP/SL toccati intrabar (high/low della barra, al livello,
SL prioritario) come il backtest, chiudendo il gap live-vs-backtest delle fade/DIP01.
+4 test. 39 test totali passano.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:02:14 +02:00
Adriano 49039ac286 fix(live): StrategyWorker esce intrabar su TP/SL (high/low, al livello) come il backtest
Chiude il gap live-vs-backtest delle fade/DIP01: prima il worker controllava solo il
close, ora controlla high/low della barra ed esce AL LIVELLO tp/sl (SL prioritario),
identico alla semantica intrabar del backtest. +4 test. Pairs/rotation/tsmom invariati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:00:12 +02:00
Adriano 9e91ad6335 merge: fase 2 portafogli - validazione runner + worker live honest/TSM01
A) PortfolioRunner certificato (pool/ribilancio/ledger == backtest).
B) worker live dedicati: DIP01 (Strategy), BasketTrendWorker (TR01), RotationWorker (ROT02),
   TsmomWorker (TSM01) + integrazione runner (resample 1h->4h/1d). PORT06 gira live completo.
Validati vs reference (TSM01 esatto, ROT02 canonico, TR01 stesso ordine). 35 test passano.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:52:47 +02:00
Adriano 924ed8eeff docs: fase 2 completata - tutti gli sleeve PORT06 girano live (worker dedicati + validazione) 2026-05-29 17:50:43 +02:00
Adriano fe8c272460 test(portfolio): valida worker honest/TSM01 vs backtest reference
TSM01 esatto (+98%==+98%); ROT02 riproduce il +1303% canonico (reference normalizzata
su finestra piu' corta = +984%); TR01 stesso ordine (+465 vs +591%, differenza di
convenzione capitale-unico-live vs media-equity-report, non un bug). Worker fedeli.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:50:08 +02:00
Adriano a7ada9f36c feat(portfolio): integra worker honest/TSM01 nel runner (PORT06 live completo)
build_worker_for gestisce basket/rotation/tsmom + DIP01 via StrategyWorker; run()
fetcha 1h e resampla a 4h/1d, lookback dimensionato sui daily (TSM01 252g); tick
multi-asset per kind. _defs marca TR01/ROT02/TSM01 col kind+universo. Niente piu'
sleeve saltati in PORT06.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:45:39 +02:00
Adriano 1e60835612 feat(live): TsmomWorker (TSM01) consenso TSMOM multi-orizzonte risk-gated 2026-05-29 17:42:44 +02:00
Adriano a40315563e feat(live): RotationWorker (ROT02) dual-momentum top-k risk-gated 2026-05-29 17:41:13 +02:00
Adriano e7e8041dae feat(live): BasketTrendWorker (TR01) EMA-cross long/flat multi-asset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:39:11 +02:00
Adriano ce601c4507 feat(live): DIP01 dip-buy come Strategy single-asset (worker via StrategyWorker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:36:32 +02:00
Adriano dc63399cc7 docs(portfolio): piano fase 2-B worker honest/TSM01 dedicati (6 task) 2026-05-29 17:35:10 +02:00
Adriano e374cca103 test(portfolio): valida runner pool+ribilancio+ledger == backtest (identico)
Certifica il livello aggiunto dal PortfolioRunner (capitale pool, ribilancio
giornaliero, ledger aggregato): replay deterministico == port_returns del backtest
(errore 4.4e-08, floating-point). Fedeltà per-worker: pairs esatta, fade approssimata
(exit close live vs intrabar backtest = gap noto dello StrategyWorker), shape a tempo ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:28:03 +02:00
Adriano 2749553577 merge origin/main (discovery strumenti downloader) in shape+portfolios 2026-05-29 17:22:15 +02:00
Adriano 0bb14d1c6e merge: shape patterns (SH01) + cartella portfolios (PORT01-06, runner pool)
Ricerca pattern-forma (4/5 famiglie rumore, SH01 Shape-ML edge/diversificatore) +
cartella portfolios/ completa (portafogli pool, backtest+live, Cerbero v2, default PORT06).
21 test passano. Live v1 = fade+pairs+shape; honest/TSM01 backtest-only (fase 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:21:51 +02:00
Adriano 04f64c8f89 feat(portfolio): compare_all confronto PORT01-06 in un processo 2026-05-29 16:32:57 +02:00
Adriano 0f582db265 fix(portfolio): runner data_dir dedicata, no resize posizioni aperte, poll da config, +test cap/cluster_rp
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:22:33 +02:00
Adriano d02bc10ab5 docs(portfolio): documenta cartella portfolios, comandi, scope live e default PORT06
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:13:49 +02:00
Adriano a5547fb3d2 feat(portfolio): PortfolioRunner live (data v2, tick, ribilancio giornaliero, ledger)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 16:05:07 +02:00
Adriano 2b3d3e3ff8 feat(portfolio): build_worker_for (worker esecutori con capitale da alloc pool)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 16:02:35 +02:00
Adriano 169819fe31 feat(portfolio): portfolios.yml + load_active_portfolio (override operativi) 2026-05-29 16:00:54 +02:00
Adriano 7a4bdb74f0 feat(portfolio): PortfolioLedger (alloc, equity/DD, persistenza+resume)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:59:57 +02:00
Adriano eaf4800b6d feat(portfolio): definizioni PORT01-06 + report run() (default PORT06)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:58:33 +02:00
Adriano 3f6b0ccf91 feat(portfolio): SleeveSpec/Portfolio/backtest con parità verso report_families
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:55:29 +02:00
Adriano 9ff469cb8e feat(portfolio): builder unificato equity-per-sleeve (parità con report_families)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:51:38 +02:00
Adriano d99c9895bb feat(portfolio): schemi di peso (equal/manual/cap/inverse_vol/cluster_rp) 2026-05-29 15:49:27 +02:00
Adriano ea04dcd9d1 feat(portfolio): metodi Cerbero v2 (get_historical_v2, get_instruments, get_ticker_batch)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:47:10 +02:00
Adriano 753d786bb5 docs(portfolios): piano di implementazione TDD (10 task)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:29:41 +02:00
Adriano 602c46e5bf docs(portfolios): design spec cartella portfolios (brainstorming)
Portafogli come oggetti di prima classe (pool condiviso, backtest+live unificati,
ribilancio periodico, 4 schemi pesi, data layer Cerbero v2). Default PORT06
(master+shape, cap pairs 33%, leva 2x). Include analisi accorpamento sleeve
(cluster per asset/regime, pairs=47% rischio) e fuori-scope (ledger unico,
Hyperliquid alt, cointegration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:24:17 +02:00
Adriano 9e1be75444 analysis(portfolio): clustering sleeve per correlazione + contributo rischio
I cluster naturali sono per ASSET/REGIME, non per famiglia (BTC-reversion,
ETH-reversion, trend TR01+TSM01, shape, rotation ROT02). Ridondanza lieve
(max corr 0.43). PAIRS = 47% del rischio a equal-weight -> conferma cap 30-35%.
Equal-weight batte inverse-vol/risk-parity in OOS calmo (pairs corrono liberi).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:09:27 +02:00
Adriano e002968914 report(shape): integra SH01 (sleeve SHAPE) nel report per-anno e nell'integrazione MASTER
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:43:19 +02:00
Adriano 2596687679 feat(shape): SH01 Shape-ML validato come diversificatore + doc
Validazione dura del solo edge sopravvissuto alla ricerca shape (ML walk-forward
LogisticRegression sulle feature di forma). SH01 config W24 H12 th0.58:
- BTC robusto ovunque (expanding +219%/OOS+42% Sharpe2.72 8-9anni; rolling2y
  +166%/+96%; stress leva2x+slippage OK), ETH/ADA solo expanding, LTC/SOL/XRP no.
- Griglia 5/27 robuste su cresta W24/H8-12 -> overfit moderato, config conservativa.
- Free-lunch: corr +0.08 col MASTER, aggiungerlo migliora OOS (Sharpe 4.33->5.10,
  DD 4.7->4.2%). Diversificatore, non motore standalone. Regge fee 0.20% RT.

SH01 come Strategy (in MODULE_MAP) + run() riproducibile. shape_ml_research esteso
con walk-forward rolling (train_window). Live richiede worker con retraining.
Diario 2026-05-29-shape.md, CLAUDE.md famiglia SHAPE-ML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:31:26 +02:00
Adriano 4ac87ab385 research(shape): 5 famiglie di pattern-forma su harness onesto
Harness shape_lab (analog kNN causale, no look-ahead verificato) + 5 ricerche
parallele. 4/5 famiglie = RUMORE (confermano dominanza mean-reversion):
- analog kNN forma grezza: solo BTC-overfit, non robusto >=2 asset
- encoding candele UP/DOWN/DOJI + body/shadow: hit-rate ~50%, muore a fee
- DTW + template geometrici: DTW peggiora euclidea; template overfit
- PIP/pivot/zig-zag: 0/48 config robuste
1/5 = EDGE REALE: ML walk-forward (LogisticRegression) sulle feature di forma.
  BTC logit W24H12 th0.58: FULL +219% / OOS +42% / Sharpe 2.72 / 8-9 anni+ /
  regge fee 0.20% RT (+60/+26). Causalita' verificata. Da validare a fondo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:09:28 +02:00
Adriano Dal Pastro 2b01443efe Merge branch 'main' of ssh://git.tielogic.xyz:222/Adriano/PythagorasGoal
# Conflicts:
#	CLAUDE.md
#	README.md
2026-05-29 08:06:19 +00:00
Adriano Dal Pastro 5ac9ebed5b docs: documenta discovery/validazione strumenti e gate del downloader
CLAUDE.md, README.md, API_REFERENCE.md aggiornati per il nuovo layer
src/data/instruments.py: validazione strumenti per exchange (Deribit +
Hyperliquid; esclusi Alpaca e Bybit testnet), congruenza prezzo cross-exchange,
registry come allowlist, gate nel downloader. Aggiunti schemi param
get_instruments/get_markets/get_historical per exchange e convenzione simboli
Deribit (inverse vs USDC lineari). Universo dati esteso con SOL/LTC/ADA 1h.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 07:51:47 +00:00
Adriano Dal Pastro 1561005d41 feat(data): discovery + validazione strumenti con gate nel downloader
src/data/instruments.py: enumera i perpetui per exchange (Deribit, Hyperliquid;
esclusi Alpaca/stocks e Bybit per feed testnet farlocco) e valida ogni strumento
sui DATI STORICI realmente raccoglibili:
  - esistenza, congruenza OHLC, not-flat (scarta contratti morti)
  - liquidita' (volume daily) e congruenza prezzo cross-exchange via mediana
    del base-coin (scarta outlier come Deribit SOL-PERPETUAL=9.6 vs SOL ~82)
Produce data/instruments_registry.json con strumenti validi, timeframe e start-date.

Gate: _download_cerbero_range rifiuta strumenti non validati (override esplicito
allow_unvalidated). La raccolta dati e' possibile solo per strumenti validati.

Registry attuale (testnet): Deribit 18/106 validi (BTC dal 2018, alt dal 2022),
Hyperliquid 66/74. I major liquidi (BTC,ETH,SOL,LTC,ADA,XRP,DOGE,AVAX,BNB,...) passano.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 07:49:57 +00:00
Adriano b9e5176a5b docs: aggiorna README e CLAUDE.md allo stato attuale (4 famiglie + pairs worker)
README riscritto: 4 famiglie (FADE/HONEST/PAIRS/TSMOM) con profili netti OOS,
portafoglio MASTER e numeri sobri anti-overfit, tabella strategie completa, comandi
analisi/validazione, struttura aggiornata (pairs_worker), dati 8 asset + nota naming
Deribit (alt = _USDC-PERPETUAL), paper trading a 2 gambe con sezione pairs in YAML.
CLAUDE.md: struttura/comandi/sezione paper trader aggiornati (niente piu' "solo MR01").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:48:39 +02:00
Adriano a60ad30ac0 fix(live): naming Deribit corretto per alt -> tutte le 5 coppie pairs tradabili live
Gli alt su Deribit sono perp LINEARI USDC: "<COIN>_USDC-PERPETUAL" (storia dal 2022),
non "<COIN>-PERPETUAL" (vuoto per LTC/ADA, dati errati per SOL). INSTRUMENT_MAP corretto.
Smoke test live (live_smoke_pairs.py): tutte e 5 le coppie ricevono feed fresco (1448
barre, ultima ~0.4h) e ticcano. Riabilitate tutte le coppie in strategies.yml.
BTC/ETH restano inverse ("<COIN>-PERPETUAL"). CLAUDE.md / docstring PR01 aggiornati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:26:27 +02:00
Adriano bd31a15548 fix(live): smoke test REALE pairs -> live solo ETH/BTC (alt assenti su Deribit)
Test reale (scripts/analysis/live_smoke_pairs.py): fetch live Cerbero + tick vero per
coppia. Scoperta: l'endpoint Deribit serve solo BTC/ETH freschi; LTC/ADA-PERPETUAL sono
VUOTI e SOL ha pochi dati. Il backtest usava i parquet locali (8 asset completi), ma la
pipeline live no -> tradabile live SOLO ETH/BTC.

- strategies.yml: abilitata solo la coppia ETH/BTC; le altre 4 disabilitate con nota
  (valide a backtest, off finche' non si aggiunge un feed live per gli alt).
- live_smoke_pairs.py (nuovo): verifica end-to-end della pipeline live (no ordini reali).
- CLAUDE.md / docstring PR01: distinzione esplicita logica-validata vs live-disponibile.

Onesta': la validazione precedente era backtest-equivalence (ESATTA), NON test live;
il test live ha rivelato il limite del feed alt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:16:50 +02:00
Adriano 4dc0e77ee5 feat(live): worker a 2 gambe per i pairs (PR01 market-neutral)
src/live/pairs_worker.py: PairsWorker market-neutral (long A / short B sullo z-score
del log-ratio, exit |z|<=z_exit o max_bars, FEE SU 2 GAMBE = 2*fee_rt*lev, stato
persistente come StrategyWorker). multi_runner: sezione `pairs:` nello YAML, fetch di
entrambe le gambe, tick/status/shutdown; INSTRUMENT_MAP esteso agli alt. strategies.yml:
5 coppie PR01 (config universale n50 z2 zx0.75 mb72).

Validazione (scripts/analysis/validate_worker_pairs.py): replay live bar-per-bar ==
backtest pairs_sim ESATTAMENTE -> ETH/BTC capitale 2.870.429 = 2.870.429, 1754 trade,
win 74.1% identici. Caveat: shortabilita'/liquidita' del perp B sugli alt da verificare
in trading reale. CLAUDE.md / docstring PR01 aggiornati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:12:25 +02:00
166 changed files with 21204 additions and 108 deletions
+9
View File
@@ -17,3 +17,12 @@ data/processed/
*.pth
notebooks/.ipynb_checkpoints/
data/paper_trades/
data/portfolio_paper/
data/portfolios/
# stato locale di tooling (non condiviso)
.claude/
.omc/
# dati regime (DVOL/funding/feature cache, rigenerabili)
data/regime/
+37
View File
@@ -292,3 +292,40 @@ curl -X POST http://localhost:9000/mcp-bybit/tools/get_ticker \
Per lo schema completo dei body di richiesta e risposta:
<http://localhost:9000/apidocs>.
---
## 15. Discovery strumenti — schemi `get_instruments` / `get_markets` / `get_historical`
Schemi dei body verificati sull'OpenAPI live (usati da `src/data/instruments.py`).
### Lista strumenti
| Exchange | Tool | Body | Risposta (campi utili) |
|---|---|---|---|
| Deribit | `get_instruments` | `{currency:"any", kind:"future", offset:int, limit:100}` (paginato, `has_more`) | `instruments[].name` (es. `BTC-PERPETUAL`, `SOL_USDC-PERPETUAL`), `expiry`, `tick_size` |
| Bybit | `get_instruments` | `{category:"linear", symbol?}` | `instruments[]`: `symbol`, `status`, `base_coin`, `quote_coin` |
| Hyperliquid | `get_markets` | `{}` | lista `{asset, mark_price, funding_rate, open_interest, volume_24h, max_leverage}` |
### Storico OHLCV (`get_historical`, chiave `candles` uniforme `{timestamp(ms),open,high,low,close,volume}`)
| Exchange | Body |
|---|---|
| Deribit | `{instrument, start_date:"YYYY-MM-DD", end_date, resolution}` — resolution `1/5/15/60/1D` |
| Bybit | `{symbol, category:"linear", interval:"1/5/15/60/D", start:int_ms, end:int_ms, limit}` |
| Hyperliquid | `{asset|instrument, start_date, end_date, resolution:"1m/5m/15m/1h/1d", limit}` |
### Simboli Deribit
- BTC/ETH → perpetui **inverse**: `BTC-PERPETUAL`, `ETH-PERPETUAL`
- Altcoin → perpetui **lineari USDC**: `<COIN>_USDC-PERPETUAL` (es. `SOL_USDC-PERPETUAL`)
- Trappola: `LTC-PERPETUAL`/`ADA-PERPETUAL` non esistono; `SOL-PERPETUAL` esiste ma è un contratto sbagliato (prezzo ~9.6 vs SOL reale ~82).
### Validazione (lato progetto)
`src/data/instruments.py` valida ogni strumento sui dati storici realmente
raccoglibili — esistenza, congruenza OHLC, not-flat, liquidità (volume daily) e
**congruenza prezzo cross-exchange** (scostamento dalla mediana del base-coin ≤5%).
Solo gli exchange con feed affidabile sono inclusi: **Deribit** e **Hyperliquid**
(esclusi Alpaca/stocks e **Bybit**, il cui feed testnet è farlocco). Output in
`data/instruments_registry.json`; il downloader scarica **solo** strumenti validati.
> **Testnet.** Il token osservatore punta a testnet (`"testnet": true` nei ticker):
> i prezzi possono divergere dal mainnet. La congruenza cross-exchange via mediana
> è il filtro che scarta i feed incongrui prima di usarli per backtest/trading.
+189 -18
View File
@@ -17,44 +17,85 @@ Progetto di ricerca: riconoscimento pattern frattali per trading algoritmico su
## Struttura
```
src/data/ → download e caricamento dati (downloader.py)
src/data/ → download e caricamento dati
downloader.py → download/caricamento parquet (gate: solo strumenti validati)
instruments.py → discovery + validazione strumenti per exchange, registry
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
src/backtest/ → engine di backtesting (engine.py)
src/strategies/ → classe base Strategy ABC + indicatori condivisi
base.py → Strategy, Signal, BacktestResult, YearlyStats
indicators.py → keltner_ratio, detect_squeezes, ema, atr, rv, correlation
src/live/ → paper trading live multi-strategia
multi_runner.py → orchestratore: carica YAML, fetch candele, tick worker
strategy_worker.py → worker indipendente: capital, trade log, stato persistente.
multi_runner.py → orchestratore: carica YAML (strategies + pairs), fetch candele, tick worker
strategy_worker.py → worker single-leg: capital, trade log, stato persistente.
Exit guidati da strategia (TP/SL/max_bars via Signal.metadata),
fallback hold_bars/stop -2%. Usa fee_rt della strategia.
pairs_worker.py → worker a 2 GAMBE per PR01 (market-neutral): long A / short B sullo
z-score del log-ratio, exit |z|<=z_exit o max_bars, fee su 2 gambe.
strategy_loader.py → import dinamico classi Strategy da scripts/strategies/
cerbero_client.py → client HTTP per Cerbero MCP (Deribit testnet)
cerbero_client.py → client HTTP per Cerbero MCP (Deribit testnet): dati + ordini
(place_order market/reduce_only, get_trade_history, get_positions)
execution.py → ExecutionClient: esecuzione REALE su Deribit (shadow). notional→
amount (lineari USDC + inverse), open/close_amount reduce-only,
verifica sul trade (order_id), fee reali dai trades[]
signal_engine.py → squeeze + ML real-time (legacy ML01, ora in waste) + validazione OOS
telegram_notifier.py → notifiche Telegram per trade
scripts/strategies/ → strategie con edge validato OOS (solo MR01_bollinger_fade)
src/portfolio/ → portafogli di prima classe (capitale-pool, backtest+live)
base.py → SleeveSpec, Portfolio (.backtest), load_active_portfolio
weighting.py → schemi pesi: equal/cap/inverse_vol/cluster_rp/manual
sleeves.py → builder unificato equity-per-sleeve (fonte unica, parità report)
ledger.py → PortfolioLedger: capitale/PnL/DD/persistenza+resume
runner.py → PortfolioRunner live (data Cerbero v2, sizing, ribilancio)
src/version.py → APP_VERSION (legge il file VERSION) — mostrato nei msg Telegram
src/strategies/fade_base.py → FadeStrategy + helper: atr, trend_distance, hurst_skip_mask (loss-guard),
exit close-confirm SL via param sl_confirm_atr (EXIT-16)
scripts/strategies/ → strategie con edge validato OOS: FADE (MR01/MR02/MR07),
HONEST (DIP01/TR01/ROT02), PAIRS (PR01), TSMOM + portafogli (PORT01/02/03);
FR01_hurst_calm_fade.py = record ricerca (robusto ma NON deployato)
scripts/portfolios/ → definizioni PORT01-06 (_defs.py) + report run() + hourly_report.py (Telegram)
scripts/waste/ → strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD)
scripts/analysis/ → ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...)
scripts/analysis/ → ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...);
regime_fetcher.py + regime_lab.py (DVOL/funding/feature regime per la ricerca);
exit_lab.py + exit_policies/ (harness ricerca exit: cache segnali, train/OOS)
scripts/bump_version.py, scripts/deploy.sh → versionamento e deploy (bump+commit+rebuild)
VERSION → versione semver (cotta nell'immagine, +1 ad ogni deploy)
strategies.yml → config multi-strategy paper trader
docs/diary/ → diario di ricerca giornaliero
docs/specs/ → specifiche di design
data/raw/ → file .parquet OHLCV (gitignored)
data/raw/ → file .parquet OHLCV (gitignored) | data/regime/ → DVOL+funding+feature (gitignored)
data/instruments_registry.json → allowlist strumenti validati (gate del downloader)
```
## Comandi
```bash
uv sync # installa dipendenze
uv run python -m src.data.downloader # scarica dati storici
uv run python scripts/strategies/MR01_bollinger_fade.py # strategia attiva (mean-reversion)
uv run python -m src.data.downloader # scarica dati storici (solo strumenti validati)
uv run python -m src.data.instruments # (ri)costruisci il registry strumenti validati
uv run python scripts/strategies/MR01_bollinger_fade.py # backtest una strategia (es. fade)
uv run python scripts/strategies/PR01_pairs_reversion.py # backtest pairs market-neutral
uv run python scripts/analysis/strategy_research.py # ricerca strategie fee-aware OOS
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
uv run python scripts/analysis/validate_worker_mr01.py # replay worker reale su MR01
uv run python -m src.live.multi_runner # paper trading live multi-strategia
docker compose up -d # deploy Docker
uv run python scripts/analysis/report_families.py # report per anno di tutte le famiglie
uv run python scripts/analysis/validate_worker_pairs.py # replay worker 2 gambe == backtest
uv run python -m src.live.multi_runner # paper trading live multi-strategia (strategie + pairs)
uv run python scripts/portfolios/PORT06_master_shape.py # report backtest portafoglio (default)
uv run python -m src.portfolio.runner # paper trading a PORTAFOGLIO (capitale pool)
uv run python scripts/analysis/smoke_portfolio.py # smoke live data layer Cerbero v2
uv run python scripts/analysis/live_exec_smoke.py # smoke ESECUZIONE reale (ordine→verifica→fee, testnet)
uv run python scripts/analysis/live_shadow_smoke.py # smoke catena shadow nel worker (open/close reali)
uv run python scripts/analysis/regime_fetcher.py # fetch DVOL+funding (Deribit mainnet) -> data/regime/
uv run python scripts/analysis/exit_lab.py # (ri)costruisci cache segnali exit-lab + parity check
./scripts/deploy.sh [patch|minor|major] # DEPLOY: bump versione + commit + rebuild Docker
uv run pytest # test
```
> **Deploy.** Il sorgente è **COPY nell'immagine Docker** (non montato) → `docker compose restart`
> NON ricarica il codice: serve **`docker compose up -d --build`** (o `./scripts/deploy.sh`, che bumpa
> la versione, committa e rebuilda). Il volume `data/` persiste → i worker fanno RESUME dello stato.
> La **versione** (file `VERSION`, semver, +1 ad ogni deploy via `deploy.sh`) compare nei messaggi
> Telegram (notifiche trade + report orario) → correli ogni msg al codice che l'ha generato.
## Dati storici
Scaricati e salvati localmente in Parquet. Per rigenerarli:
@@ -68,6 +109,27 @@ df = load_data("ETH", "15m") # carica un asset/timeframe
Fonte primaria: Cerbero MCP (endpoint `/mcp-deribit/tools/get_historical`).
Token observer: nel file `secrets/observer.token` del progetto CerberoSuite.
### Strumenti & validazione (gate raccolta dati)
`src/data/instruments.py` scopre e **valida** gli strumenti per ogni exchange
implementato — **Deribit** e **Hyperliquid** (esclusi Alpaca/stocks e **Bybit**,
il cui feed testnet è farlocco). Per ogni perpetuo enumera via `get_instruments`
/`get_markets` e verifica sui **dati storici realmente raccoglibili**:
esistenza, congruenza OHLC, not-flat (scarta contratti morti), liquidità (volume
daily) e **congruenza prezzo cross-exchange** (scostamento dalla mediana del
base-coin ≤ 5% → scarta outlier come `SOL-PERPETUAL`=9.6 vs SOL reale ~82).
Output: `data/instruments_registry.json` (strumenti validi, timeframe, start-date).
**Gate:** `_download_cerbero_range` rifiuta gli strumenti non validati (override
`allow_unvalidated=True` solo per casi eccezionali). Rigenera con
`python -m src.data.instruments`.
> **NB testnet.** Il token Cerbero punta a testnet; la congruenza cross-exchange
> è il filtro che distingue i feed realistici (Deribit, Hyperliquid) da quelli
> farlocchi (Bybit). Simboli Deribit: BTC/ETH = `<COIN>-PERPETUAL` (inverse);
> alt = `<COIN>_USDC-PERPETUAL` (lineari USDC). Registry attuale: Deribit 18/106,
> Hyperliquid 66/74 validi (major liquidi: BTC dal 2018, alt dal 2022).
## Strategie attive
> **LEZIONE CRITICA (2026-05-28).** L'intera famiglia squeeze-breakout (SQ01-04,
@@ -118,6 +180,52 @@ MR07 46%→21%), edge OOS confermato (vedi `scripts/analysis/risk_management.py`
Unica eccezione: MR03 BTC, dove il filtro peggiora entrambe → lasciato disattivo.
Leva non robusta scartate: vol-target sizing e skip-alta-volatilità (peggiorano).
**Loss-guard Hurst (ATTIVO LIVE, 2026-06-02).** Le fade accettano `hurst_max`: saltano i segnali in
regime PERSISTENTE/trending (rolling-Hurst ≥ soglia), dove si concentrano stop-loss e perdite
(diagnosi: stop-rate 43% per hurst>0.55 vs 21% anti-persistente; i peggiori 1% trade hanno hurst
medio 0.61). Helper `src.strategies.fade_base.hurst_skip_mask` (rolling-Hurst causale **dalle sole
close** → nessun feed esterno; step=6 per velocità live). **`hurst_max=0.55` attivo sulle 6 fade in
`_defs.py`**: il test decisivo a livello PORT06 lo conferma — **FULL DD 4.10%→2.39% (quasi dimezzato),
Sharpe 6.62→6.76, OOS Sharpe 8.89→9.15**. È l'UNICO meccanismo anti-perdite che supera il gate (ADX,
vol-expansion/vratio, efficiency-ratio, time-stop, vol-target FALLISCONO: tagliano i winner insieme ai
loser; i claim esterni ADX/ATR-ratio non si replicano su queste fade crypto). NB: il filtro agisce solo
sul path LIVE (`spec.params`); il backtest canonico (`build_everything`/regression-lock) NON è filtrato
→ il live farà MEGLIO del backtest sul DD. Ricerca: `scripts/analysis/fade_lossguard_workflow.js`,
diagnosi `fade_loss_by_regime.py`, diario `docs/diary/2026-06-02-fade-lossguard.md`.
**Effetto misurato (backtest):** stop-loss fade 67% in numero (1881→621), perdite totali 68%, coda
61%→−48% (lo stop-RATE per-trade scende poco, 42→38%: il guard lavora riducendo l'ESPOSIZIONE nel
regime tossico, non rendendo sicuro ogni trade). **Monitor live:** `hourly_report.py` traccia lo
stop-rate fade PRIMA/DOPO l'attivazione (14:34 UTC del 2026-06-02) e dà il verdetto su Telegram quando
il campione DOPO ≥30 (già confermato: stop-rate live PRIMA 42% == backtest 42.1%).
**FIX EXIT-16 live — confirm su barra COMPLETATA (2026-06-05).** Il worker valutava il confirm-SL
sul prezzo della candela IN FORMAZIONE ad ogni poll → reintroduceva la wick-sensitivity che EXIT-16
elimina (audit: 2 stop su 3 del crash ETH erano wick-stop che il backtest non avrebbe preso in quel
momento). Ora `tick` valuta il confirm SOLO sul close dell'ultima barra completata (detection: la
riga -1 del df è la candela in corso finché `now < ts[-1]+bar_ms`), buf dall'ATR della stessa barra;
fill al prezzo corrente (≈ stress lag_close_exit, OK in exit-lab); TP intrabar invariato. La concausa
feed-gap NON è mitigabile lato exit (fill reali ≈ sim) e l'entry-guard post-flat è BOCCIATA (skippare
i segnali dopo barre flat PEGGIORA tutti gli sleeve ETH: la candela-gap è l'overshoot che la fade
fada). Aggiunto alert Telegram `STALE_FEED` (≥2 barre 1h flat → notifica + gap % al risveglio, solo
osservabilità). Diario `docs/diary/2026-06-05-confirm-sl-forming-bar.md`.
**EXIT-16 close-confirm SL (ATTIVO LIVE, 2026-06-04).** 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
× 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.
@@ -157,7 +265,14 @@ quest'ultima riconferma la dominanza mean-reversion). Due edge reali:
(Sharpe 4.36), LTC/ETH (3.08), ADA/ETH (2.69), BTC/LTC (2.36, robusta anche 4h), ETH/SOL
(1.96, la più debole). Pattern: sempre alt-liquido vs major. Plateau confermato
(heatmap 20/20 Sharpe>1) + walk-forward (ETH/BTC 11/12 finestre+). **BNB/ETH scartata**
(overfit). Corr col mercato ~0.02-0.08. Fee su **2 gambe** (worker da estendere). Verifica: `pairs_research.py`.
(overfit). Corr col mercato ~0.02-0.08. Fee su **2 gambe**: worker live implementato
(`src/live/pairs_worker.py`, sezione `pairs:` in `strategies.yml`). LOGICA validata
(`validate_worker_pairs.py`: replay == backtest ESATTO). LIVE (`live_smoke_pairs.py`,
smoke reale Cerbero): **tutte e 5 le coppie con feed live fresco**. Naming Deribit:
BTC/ETH = `<COIN>-PERPETUAL` (inverse); alt = `<COIN>_USDC-PERPETUAL` (lineari USDC,
storia dal 2022). Trappola: `LTC-PERPETUAL`/`SOL-PERPETUAL` danno vuoto/dati errati →
usare sempre `_USDC-PERPETUAL`. Resta da verificare solo liquidità/fill in esecuzione.
Verifica edge: `pairs_research.py`.
- **TSM01** (`scripts/analysis/tsmom_research.py`): TSMOM multi-orizzonte 3/6/12m + risk-off,
**gross 0.30**, distinto da ROT02 (corr 0.62), DD 15-22%, mai un anno negativo. Robusto
(36/36 config OOS+) ma diversificatore, non motore di ritorno (rende meno di ROT02).
@@ -167,8 +282,45 @@ grande (`scripts/analysis/combine_v2.py`). **Numeri sobri onesti** (l'OOS singol
è regime calmo → ottimistico ~50%): worst-DD su 90g rolling **~6%** (non 2.3%), Sharpe
atteso **~5** (mediana semestrale), ogni anno positivo dal 2021, regge **leva 2x +
slippage doppio** (CAGR 36%, Sharpe 5.1). Config robusta raccomandata: **MASTER-esteso
equal-weight, leva 2x, cap pairs ~30-35%** (i pairs sono ~57% del rischio e non ancora
validati col worker live a 2 gambe). La confluenza multi-TF è stata SCARTATA (overfit).
equal-weight, leva 2x, cap pairs ~30-35%** (i pairs sono ~57% del rischio; worker live a
2 gambe implementato, validato e con feed live su tutte e 5 le coppie — resta da
verificare liquidità/fill in esecuzione reale). La confluenza multi-TF è stata SCARTATA (overfit).
**Pattern del segnale per FORMA (branch `shape_patterns`, 2026-05-29).** Esplorate 5 famiglie
di *shape forecasting* con agenti paralleli su harness onesto (`scripts/analysis/shape_lab.py`:
analog kNN causale, no-look-ahead verificato). **4/5 sono RUMORE** (riconfermano la dominanza
mean-reversion): analog kNN sulla forma grezza (solo BTC-overfit), encoding candele
UP/DOWN/DOJI+body/shadow (hit-rate ~50%), DTW+template geometrici (DTW *peggiora* l'euclidea;
template overfit), PIP/pivot/zig-zag (0/48 robuste). Vedi `scripts/analysis/shape_*_research.py`.
- **SH01 Shape-ML** (`scripts/strategies/SH01_shape_ml.py`): UNICO edge. Una LogisticRegression
legge 17 feature di forma (body/shadow, rendimenti, pendenza/curvatura, pos max/min, RSI,
estensione) e predice il segno del rendimento a H barre in **walk-forward** (scaler+modello
solo sul passato, no leakage). Config **W24 H12 th0.58**. A differenza dello squeeze
**regge fee 0.20% RT**. Win-rate ~50% → l'edge è nell'**asimmetria**, non nella frequenza.
Validazione (`scripts/analysis/shape_ml_validate.py`): BTC robusto OVUNQUE (expanding +219%/
OOS +42% Sharpe 2.72 8-9 anni; rolling 2y +166%/+96%; stress 2x+slippage OK), ETH/ADA
robusti solo expanding (secondari), LTC/SOL/XRP scartati. Griglia: **5/27 celle robuste su
cresta stretta W24/H8-12** → overfit moderato, scelta la config conservativa. **Valore vero:
diversificatore** (corr +0.08 col MASTER); aggiungerlo migliora l'OOS del MASTER (Sharpe
4.33→5.10, DD 4.7%→4.2%). NON motore standalone. **LIVE (2026-06-01): gira come StrategyWorker
reale** (vedi fix wiring sotto in SCOPE LIVE). Diario: `docs/diary/2026-05-29-shape.md`.
**ARGO / GEX opzioni (analisi 2026-06-01, SCARTATO).** Valutato ARGO (lettura del gamma-exposure dei
dealer Deribit) come filtro di regime. Esito **NO-GO**: il net-GEX si calcola live (Deribit mainnet
public API, OI reale ~368k contratti, DVOL/funding storici gratis) ma **lo storico per-strike dell'OI
non è gratuito → non backtestabile OOS** (stesso muro delle opzioni W18/19/21). Niente evidenza crypto,
segno fragile, mercato dominato dai perp. Diario `docs/diary/2026-06-01-argo-gex-feasibility.md`.
**Frattali del segnale × Regime ARGO (ricerca 100 agenti, 2026-06-02, RECORD).** Cercata una strategia
che combini un segnale frattale (Hurst/Higuchi/Williams/analog) con un gate di regime (DVOL/VRP/funding).
Infrastruttura riusabile: `scripts/analysis/regime_fetcher.py` (DVOL+funding da Deribit mainnet →
**`data/regime/`**, NON `data/raw/` che è solo OHLCV) e `regime_lab.py` (feature regime+frattali causali,
cache, harness netto-OOS). Esito: **15 strategie robuste e causali, ma NESSUNA batte/migliora il PORT06**
(diversificatori sovrapposti alle fade). **Finding: il prior ARGO "VRP>0=range=fade" è SMENTITO**
l'edge è su **VRP<0 + DVOL bassa**. Il vincitore `FR01_hurst_calm_fade.py` è robusto ma DILUISCE il
PORT06 (OOS Sharpe 8.89→8.72) → **non deployato** (in `scripts/strategies/` ma NON in MODULE_MAP/yml).
Il sottoprodotto utile è stato il **loss-guard Hurst** (vedi sopra), che invece MIGLIORA il PORT06.
Diario `docs/diary/2026-06-02-fractal-argo-search.md`.
**Metodologia obbligatoria per ogni nuova strategia** (per non ripetere l'errore squeeze):
1. Ingresso eseguibile: direzione e prezzo decisi con dati **fino a `close[i]`**, mai `close[i-1]` con direzione da `i`.
@@ -184,14 +336,33 @@ ma quei numeri sono backtest a leva 3x su 8 anni e includono anni eccezionali (e
ETH 2024). Stima onesta: il target è *plausibile* su un portafoglio diversificato di
queste fade, ma va confermato col paper trader live prima di rischiare capitale reale.
## Portafogli
- Un `Portfolio` è un oggetto di prima classe (`src/portfolio/`) con definizione (sleeve + schema pesi) e due facce sulla **STESSA** definizione: `.backtest()` (riusa il builder unico di `sleeves.py` → parità esatta con `report_families`) e live (`PortfolioRunner`: capitale pool condiviso, sizing per peso, ribilancio giornaliero, ledger aggregato in `data/portfolios/{code}/`).
- **Schemi peso:** `equal` (default), `cap` (tetto per famiglia, es. pairs 33% — config raccomandata), `inverse_vol`, `cluster_rp` (equal fra cluster naturali poi inverse-vol intra-cluster), `manual`. Definiti in `weighting.py`; la chiave cap è la famiglia (PAIRS/FADE/HONEST/SHAPE/TSM).
- **Default `portfolios.yml`:** PORT06 (master+shape), `weighting=cap pairs 0.33 + shape 0.0588`, leva 2x, ribilancio 1D. Backtest PORT06 canonico (dati al 2026-05-28, pre-cap-shape): FULL Sharpe 6.47 DD 4.10% / OOS Sharpe 8.82 DD 1.30%; **con EXIT-16 close-confirm (config live attuale): FULL 7.84 / 2.60%, OOS 10.06 / 1.15%** (i vecchi 6.07/8.19 erano pre-loss-guard/pre-refresh dati). Col cap SHAPE (2026-06-05): FULL 6.43 / 3.96%, OOS 8.58 / 1.36% — assicurazione sulla coda SH01, vedi sotto.
- **SH01 SENZA STOP-LOSS — by design, CONFERMATO da ricerca (2026-06-05).** Dopo il crash ETH (15.6% su un trade SH01 live), ricerca multi-agente con harness dedicato `scripts/analysis/sh01_exit_lab.py` (cache segnali walk-forward, engine con **fill gap-aware** `worse(livello, open)`, parity esatta con explore_lab, protocollo train≤2023-11-01/OOS): **11 famiglie di stop testate (ATR intrabar/close-confirm, %, chandelier, breakeven, giveback, loser-timestop, disaster-cap close+intrabar, swing, vol-regime), 0 sopravvissute** al gate (ETH migliorato senza degradare BTC, train E oos, plateau). Pattern: ogni stop stretto abbastanza da toccare la coda ETH rompe BTC; ogni stop largo non arriva alla coda; nei crash il fill è al gap, non al livello (lo stop "protettivo" PEGGIORA la coda OOS). Mitigazione adottata: **cap famiglia SHAPE a 0.0588 in PORT06** (≈ dimezzata; costo OOS Sharpe 0.24, FULL DD 0.14pp) — la prossima coda impatta il conto per metà. NON impostare mai `sl`/`sl_confirm_atr` su SH_BTC/SH_ETH. Direzione futura: liquidity-gate sull'entry (skip dopo feed flat). Diario `docs/diary/2026-06-05-sh01-sl-research.md`.
- **Data layer Cerbero v2:** `get_historical_v2` unificato + `get_instruments` (naming robusto) + `get_ticker_batch`. Trading su Deribit.
- **SCOPE LIVE (fase 2 completata):** il runner esegue TUTTI gli sleeve di PORT06. Worker: single `StrategyWorker` (fade MR01/02/07, DIP01, **e SH01**), `PairsWorker` (PR01 2 gambe), e i multi-asset dedicati `BasketTrendWorker` (TR01 4h), `RotationWorker` (ROT02 1d), `TsmomWorker` (TSM01 1d). Il runner fetcha 1h da Cerbero v2 e **resampla a 4h/1d** (lookback dimensionato sui daily: TSM01 usa 252g). Validazione: runner pool/ribilancio/ledger == backtest (`validate_portfolio_runner.py`, identico); worker multi-asset == reference (`validate_honest_workers.py`: TSM01 esatto, ROT02 +1303% canonico, TR01 stesso ordine — differenza di convenzione capitale-unico vs media-equity).
- **FIX SH01 wiring (2026-06-01).** SH01 gira come **`StrategyWorker` NORMALE** (NON il vecchio `MLWorkerWrapper` di `multi_runner`, che usava il `SignalEngine` **squeeze SCARTATO**: apriva senza metadata ed usciva a `hold_bars=3`, ignorando del tutto SH01_shape_ml). `SH01_shape_ml.generate_signals` fa il walk-forward (retraining) internamente ad ogni tick ed emette `metadata.max_bars=12` → exit a orizzonte via `StrategyWorker.tick`. Serve ≥4000 barre 1h (`_ML_LOOKBACK_DAYS=365`). Vedi `docs/diary/2026-06-01-sh01-wiring-squeeze-bug.md`.
- **Altri fix StrategyWorker (2026-06-01).** Exit a orizzonte puro per strategie senza TP/SL (`elif self.max_bars`, SH01 esce a H=12 non hold_bars=3); `is_win = net > 0` (win NETTO fee, non lordo); filtro `min_tp_frac` (salta micro-scalp col TP entro le fee); loss-guard `hurst_max=0.55` sulle fade (vedi sopra).
- **Exit intrabar (fase 3, risolto):** lo `StrategyWorker` ora esce sui TP/SL toccati INTRABAR (high/low della barra, al livello, SL prioritario) come il backtest — non più solo sul close. Allinea fade/DIP01 live al backtest intrabar (`tests/portfolio/test_intrabar_exit.py`). Caveat residuo onesto: nel paper trading l'high/low usato è quello della barra in corso al poll; su un fill reale conterebbe il momento del tocco.
- **ESECUZIONE REALE — shadow (v1.0.3, 2026-06-03).** I **6 fade** (MR01/MR02/MR07 × BTC/ETH) eseguono ordini **REALI su Deribit testnet** accanto al fill simulato (*shadow*: sim + reale in parallelo, il sim resta la verità che guida le decisioni). `src/live/execution.py` `ExecutionClient`: `open` (market) + `close_amount` (market **reduce-only della SOLA quota del worker** — i 3 fade BTC condividono lo strumento e le posizioni si nettano per conto, quindi NON si usa `close_position` che flatterebbe le quote altrui); **verifica l'esecuzione sul TRADE** (order_id in `get_trade_history`, non sulla size netta aggregata); **fee REALI lette dai `trades[]`**. Strumenti = **lineari USDC** (`BTC_USDC`/`ETH_USDC-PERPETUAL`, amount nel base-coin, step 0.0001/0.001): scelti perché il **payoff lineare == matematica del backtest** (l'inverse `*-PERPETUAL` introdurrebbe una base 1/prezzo) e fee/PnL sono in USDC. Lo `StrategyWorker` tiene un **ledger reale parallelo** (`real_capital`, persistito) e logga `REAL_OPEN`/`REAL_CLOSE` col confronto **slippage** (prezzo sim vs eseguito) e **fee** (assunta 0.10% vs reale). Config: `portfolios.yml``overrides.execution {enabled, sleeves:[MR01,MR02,MR07,DIP01], instruments:{BTC:BTC_USDC-PERPETUAL, ETH:ETH_USDC-PERPETUAL}}` (**DIP01 aggiunto il 2026-06-04**: stesso wiring single-leg, TP resting incluso); pairs/rotation/tsmom/shape restano **simulati** (pairs richiede un executor a 2 gambe con gestione leg-risk; shape non ha TP). **Fee reali misurate = 0.05%/lato = 0.10% RT** (== assunto del backtest, su ETH; BTC inverse era ~0.094%). **Alert Telegram:** `REAL_EXEC_LIVE` (primo ordine reale verificato per worker) + `REAL_OPEN_FAIL`. Smoke (testnet, €0): `scripts/analysis/live_exec_smoke.py` (layer: ordine→verifica→fee) e `live_shadow_smoke.py` (catena worker open/close). **Capitale live portato a 2000** (notional fade ~$35) per ridurre il rumore di arrotondamento su BTC (step lineare ~$6.7). NB: ledger reale ≠ ledger sim — i worker già in posizione sim a un restart non hanno quota reale corrispondente; lo shadow reale parte pulito dalla prossima apertura.
- **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.
## Multi-Strategy Paper Trader
Orchestratore che esegue N strategie in parallelo su dati live Cerbero, ognuna con €1000 USDC virtuali indipendenti.
**Config:** `strategies.yml`lista strategie con asset, tf, sizing, parametri. Attualmente solo MR01 (BTC/ETH 1h).
**Persistenza:** `data/paper_trades/{strategy}___{asset}__{tf}/` con `trades.jsonl` (append-only) + `status.json` (resume al restart, include tp/sl/max_bars).
**Config:** `strategies.yml`due sezioni: `strategies` (single-leg: fade/honest) e
`pairs` (a 2 gambe). Attive: 6 fade (MR01/MR02/MR07 × BTC/ETH) + 5 coppie PR01.
**Due worker:** `strategy_worker.py` (single-leg) e `pairs_worker.py` (2 gambe,
long A / short B sullo z-score del log-ratio, fee su 2 gambe).
**Persistenza:** `data/paper_trades/{worker_id}/` con `trades.jsonl` (append-only) + `status.json` (resume al restart).
**Hot-add:** aggiungi riga YAML → `docker compose restart` → storico intatto.
**Exit strategia:** se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come MR01), il worker esce su take-profit/stop-loss/time-limit a quei livelli; altrimenti usa il fallback `hold_bars`/stop -2%.
**Exit strategia:** se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come le fade), il worker esce su take-profit/stop-loss/time-limit; i pairs escono su |z|≤z_exit o max_bars.
**Naming Deribit (feed live):** major = `<COIN>-PERPETUAL` (inverse); alt = `<COIN>_USDC-PERPETUAL` (lineari USDC). Vedi INSTRUMENT_MAP in `multi_runner.py`.
**Notifiche:** Telegram per ogni trade (richiede `.env` con `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID`).
## Convenzioni
+4 -2
View File
@@ -8,9 +8,11 @@ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY src/ src/
COPY scripts/strategies/ scripts/strategies/
COPY strategies.yml strategies.yml
COPY scripts/ scripts/
COPY strategies.yml portfolios.yml VERSION ./
VOLUME /app/data
# Default: paper trader multi-strategia. Il servizio "portfolio" in docker-compose
# sovrascrive il command per il runner a portafoglio (src.portfolio.runner).
CMD ["uv", "run", "python", "-m", "src.live.multi_runner"]
+260 -48
View File
@@ -4,7 +4,7 @@ Sistema di riconoscimento pattern frattali e predizione per il trading di cripto
## Obiettivo
Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di €50 al giorno entro 68 mesi, tramite strategie algoritmiche che combinano analisi frattale, squeeze di volatilità e machine learning.
Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di €50 al giorno entro 68 mesi, tramite un portafoglio di strategie algoritmiche poco correlate fra loro — mean-reversion, trend/rotazione e spread market-neutral — validate out-of-sample e fee-aware.
## Risultati
@@ -15,18 +15,42 @@ Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di
> ingresso onesto (`close[i]`) e fee reali, l'edge sparisce e tutte perdono, anche
> a fee zero. Dettagli e prove: `scripts/analysis/oos_validation.py`.
Dopo una validazione **out-of-sample, fee-aware** di tutte le famiglie, l'unica con
edge netto reale è il **mean-reversion** (i breakout *rientrano*, non continuano):
Dopo una validazione **out-of-sample, fee-aware** di molte famiglie di strategie,
emergono cinque famiglie con edge netto reale, tutte radicate nella stessa lezione
(in cripto la **mean-reversion** funziona, la continuazione no) o nella diversificazione:
| Codice | Strategia | Mercato | Edge OOS netto | Max DD | Robustezza |
|--------|-----------|---------|----------------|--------|------------|
| **MR01** | Bollinger Fade (mean-reversion) | BTC 1h | **+196 / +201%** | 15% | ✅ |
| **MR01** | Bollinger Fade (mean-reversion) | ETH 1h | **+251%** | ~25% | ⚠️ DD alto |
| Famiglia | Meccanismo | Strategie | Profilo (netto OOS) |
|----------|-----------|-----------|---------------------|
| **FADE** | mean-reversion intraday 1h (long/short, BTC/ETH) | MR01 Bollinger, MR02 Donchian, MR07 Return-reversal | Acc 52-55%, DD 18-34% |
| **HONEST** | long-only multi-regime multi-crypto | DIP01 dip-buy, TR01 EMA-trend, ROT02 dual-momentum | CAGR 31-56%, DD 15-27% |
| **PAIRS** | spread reversion *market-neutral* (2 gambe) | PR01 ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL | Sharpe 2.0-4.4, corr col mercato ~0.05 |
| **TSMOM** | time-series momentum multi-orizzonte | TSM01 (3/6/12m + risk-off) | diversificatore, DD 15-22% |
| **SHAPE** | ML walk-forward su feature di *forma* del prezzo | SH01 (LogisticRegression, orizzonte 12 barre) | diversificatore, corr +0.08 col resto |
Netto dopo **fee realistiche Deribit 0.10% RT** (taker), leva 3x, pos 15%, su finestra
held-out (nov 2023→mag 2026). MR01 è positivo su **tutta** la griglia parametri
(`n∈{14,20,30,50}` × `k∈{2.0,2.5,3.0}`) e per **ogni** livello di fee 0.00-0.20% RT —
margine di sicurezza ampio, niente parametro fortunato. Ri-validato col worker live reale.
Tutti i numeri sono **netti** dopo fee realistiche (Deribit 0.10% RT single-leg, 0.20%
RT/coppia sui pairs), leva 3x, su finestra held-out. Le strategie sono robuste su griglia
parametri, sweep fee 0.00-0.20% RT e — per i pairs — validate con **walk-forward** e
config universale (niente cherry-picking).
### Portafoglio combinato (la vera leva anti-drawdown)
Le famiglie sono **quasi scorrelate fra loro** (~0.05). Combinandole in un unico
portafoglio equipesato il drawdown crolla sotto quello di ogni singola sleeve:
| Portafoglio | CAGR | Max DD | Sharpe |
|-------------|------|--------|--------|
| FADE (6 sleeve) | ~46% | 8% | 3.9 |
| HONEST (3 sleeve) | ~46% | 13% | 2.2 |
| **MASTER** (FADE + HONEST, 9) | ~47% | **5%** | 4.2 |
| **MASTER + PAIRS + TSM01** (15) | ~67% | ~5% | ~6 |
| **PORT06 live** (17 sleeve, cap pairs 33%, leva 2×, config EXIT-16) | ~79% | **2.6%** | 7.8 (FULL) / 10.1 (OOS) |
> 🔎 **Numeri sobri (anti-overfit).** L'OOS singolo cade nel regime favorevole 2024-25:
> i valori di Sharpe/DD sopra sono ottimistici di circa il 50%. Da pianificare per le
> decisioni: **Sharpe atteso ~5**, **worst-drawdown su 90 giorni ~6%**, profilo che regge
> a leva 2x con slippage raddoppiato. Configurazione raccomandata: equal-weight, leva 2x,
> con un cap sull'allocazione ai pairs (~30-35%, poiché concentrano ~57% del rischio).
> Tutto resta da confermare nel paper trading live.
## Come funziona
@@ -42,6 +66,21 @@ di prezzo **rientrano verso la media** più di quanto proseguano:
Nessun look-ahead: direzione e livelli sono calcolati con dati fino a `close[i]`.
### Le altre famiglie
- **FADE** (oltre MR01): MR02 fada la rottura del canale Donchian verso il centro;
MR07 fada il movimento di barra estremo misurato in deviazioni standard dei
rendimenti. Stessa logica di reversione, indicatori indipendenti.
- **HONEST** (long-only, multi-crypto): DIP01 compra i dip estremi e rivende al
recupero; TR01 segue il trend con incrocio di EMA su un paniere; ROT02 ruota ogni
giorno sui tre asset col momentum più forte, andando in cash quando BTC è sotto la
sua media (risk-off). Coprono i regimi di trend e rotazione, complementari alle fade.
- **PAIRS** (market-neutral): scommette sul rientro verso la media del log-ratio fra
due cripto (z-score). Long su una, short sull'altra: l'esposizione netta al mercato è
quasi nulla (correlazione ~0.02), il che la rende un diversificatore eccellente.
- **TSMOM**: tiene gli asset con momentum positivo persistente su più orizzonti
(3/6/12 mesi), con overlay risk-off. Rende meno ma è poco correlato, utile in ensemble.
### Perché lo squeeze breakout è stato abbandonato
L'ipotesi originale era opposta — *continuazione* dopo la compressione di volatilità
@@ -70,20 +109,30 @@ PythagorasGoal/
│ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi
│ │ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats
│ │ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr
── live/ # Paper trading live su Deribit testnet
├── multi_runner.py # Orchestratore multi-strategia
├── strategy_worker.py # Worker indipendente con stato persistente
├── strategy_loader.py # Import dinamico classi Strategy
├── cerbero_client.py # Client HTTP per Cerbero MCP
├── signal_engine.py # Squeeze + ML real-time (legacy) + validazione OOS
── telegram_notifier.py
── live/ # Paper trading live su Deribit testnet
├── multi_runner.py # Orchestratore multi-strategia (strategie + pairs)
├── strategy_worker.py # Worker single-leg con stato persistente
├── pairs_worker.py # Worker a 2 gambe per i pairs (market-neutral)
├── strategy_loader.py # Import dinamico classi Strategy
├── cerbero_client.py # Client HTTP per Cerbero MCP
── signal_engine.py # Squeeze + ML real-time (legacy) + validazione OOS
│ │ └── telegram_notifier.py
│ └── portfolio/ # Portafogli di prima classe (capitale condiviso, backtest + live)
│ ├── base.py # SleeveSpec, Portfolio (.backtest), load_active_portfolio
│ ├── weighting.py # Schemi di ponderazione: equal, cap, inverse_vol, cluster_rp, manual
│ ├── sleeves.py # Builder unificato equity-per-sleeve (fonte unica, parità report)
│ ├── ledger.py # PortfolioLedger: PnL/DD aggregati, persistenza e resume
│ └── runner.py # PortfolioRunner live (Cerbero v2, sizing, ribilancio giornaliero)
├── scripts/
│ ├── strategies/ # Strategie con edge validato OOS (solo MR01_bollinger_fade)
│ ├── waste/ # Strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD)
── analysis/ # Ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...)
│ ├── strategies/ # Strategie con edge validato OOS (FADE, HONEST, PAIRS, TSMOM + portafogli)
│ ├── portfolios/ # Definizioni PORT01-06 e report run() dei portafogli di prima classe
── waste/ # Strategie scartate (squeeze SQ/MT/ML/AD/CM/PD, MR03, ROT01, W01-W28)
│ └── analysis/ # Ricerca/validazione OOS fee-aware, gestione rischio, report
├── strategies.yml # Config multi-strategy paper trader
├── data/
── raw/ # Parquet OHLCV (gitignored, ~70 MB)
── raw/ # Parquet OHLCV (gitignored, ~70 MB)
│ └── regime/ # DVOL + funding (Deribit mainnet) + cache feature regime (gitignored)
├── VERSION # versione semver (cotta nell'immagine, mostrata nei msg Telegram)
├── docs/
│ ├── diary/ # Diario di ricerca giornaliero
│ └── specs/ # Specifiche di design
@@ -94,32 +143,73 @@ PythagorasGoal/
## Strategie attive
Tutte le strategie estendono `src.strategies.base.Strategy` (`generate_signals() → backtest()`).
Le strategie single-asset estendono `src.strategies.base.Strategy`
(`generate_signals() → backtest()`); i pairs hanno un worker dedicato a 2 gambe.
| Codice | Script | Tipo | Descrizione |
|--------|--------|------|-------------|
| **MR01** | `MR01_bollinger_fade.py` | Mean-reversion | Fada la banda di Bollinger, TP alla media, SL ad ATR. Unica con edge netto validato OOS. |
| Codice | Script | Famiglia | Descrizione |
|--------|--------|----------|-------------|
| **MR01** | `MR01_bollinger_fade.py` | FADE | Fada la banda di Bollinger, TP alla media, SL ad ATR |
| **MR02** | `MR02_donchian_fade.py` | FADE | Fada la rottura del canale Donchian, TP al centro |
| **MR07** | `MR07_return_reversal.py` | FADE | Fada il movimento di barra estremo (z dei rendimenti) |
| **DIP01** | `DIP01_dip_reversion.py` | HONEST | Dip-buy long-only su z-score estremo |
| **TR01** | `TR01_ema_trend.py` | HONEST | EMA 20/100 trend-following su paniere cripto (4h) |
| **ROT02** | `ROT02_dual_momentum.py` | HONEST | Rotazione cross-sectional top-3 + risk-off (1d) |
| **PR01** | `PR01_pairs_reversion.py` | PAIRS | Spread reversion market-neutral su 5 coppie |
| **TSM01** | `tsmom_research.py` | TSMOM | Time-series momentum multi-orizzonte + risk-off |
| **SH01** | `SH01_shape_ml.py` | SHAPE | LogisticRegression walk-forward su 17 feature di forma, orizzonte 12 barre (diversificatore) |
La famiglia squeeze (SQ01-04, ML01, MT01, PD01, CM01, AD01) è in `scripts/waste/`:
edge storico = artefatto di look-ahead (vedi sezione *Come funziona*).
Le fade applicano tre protezioni live: un **filtro trend** (`trend_max`/`ema_long`,
salta i segnali col prezzo troppo esteso rispetto alla EMA200), un **loss-guard Hurst**
(`hurst_max=0.55`, salta i segnali in regime persistente/trending dove si concentrano gli stop-loss
— dimezza il drawdown del portafoglio, calcolato dalle sole close) e l'**EXIT-16 close-confirm SL**
(`sl_confirm_atr=0.5`, 2026-06-04: lo stop scatta solo se la barra *chiude* oltre `sl ∓ 0.5·ATR14`
gli stop intrabar da wick erano falsi negativi, l'overshoot che buca lo stop è proprio il movimento
che la fade fada; a livello PORT06 porta l'OOS Sharpe da 8.82 a 10.06). Più un filtro `min_tp_frac`
che scarta i micro-scalp col take-profit entro il costo delle fee. Le tre protezioni sono
complementari: Hurst toglie il regime tossico, il trend-filter gli ingressi sovra-estesi, il
close-confirm i falsi stop. Portafogli pronti: `PORT01`
(honest), `PORT02` (fade), `PORT03` (master fade+honest), **`PORT06`** (master esteso, default live).
Per eseguire il backtest della strategia:
**Scartate** (in `scripts/waste/`): la famiglia squeeze (SQ01-04, ML01, MT01, PD01,
CM01, AD01 — artefatto di look-ahead), MR03 Keltner (debole/ridondante con MR01) e
ROT01 (dominata da ROT02).
### Comandi utili
```bash
# Backtest di una strategia
uv run python scripts/strategies/MR01_bollinger_fade.py
```
uv run python scripts/strategies/PR01_pairs_reversion.py
Per la ricerca/validazione fee-aware out-of-sample:
# Ricerca e validazione fee-aware out-of-sample
uv run python scripts/analysis/strategy_research.py # screening famiglie + deep-dive fade
uv run python scripts/analysis/strategy_research_v2.py # MR02 / MR03 / MR07
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
uv run python scripts/analysis/pairs_research.py # ricerca + verifica no-look-ahead dei pairs
```bash
uv run python scripts/analysis/strategy_research.py # screening famiglie + deep-dive MR01
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
uv run python scripts/analysis/validate_worker_mr01.py # replay del worker live su MR01
# Gestione rischio, combinazione, report
uv run python scripts/analysis/risk_management.py # filtro trend + portafoglio fade
uv run python scripts/analysis/combine_portfolio.py # combinare fade + honest
uv run python scripts/analysis/combine_v2.py # master esteso con pairs + TSM01
uv run python scripts/analysis/report_families.py # report per anno di tutte le famiglie
# Validazione dei worker live (replay == backtest)
uv run python scripts/analysis/validate_worker_mr01.py # worker single-leg su MR01
uv run python scripts/analysis/validate_worker_pairs.py # worker a 2 gambe sui pairs
uv run python scripts/analysis/live_smoke_pairs.py # smoke test feed live reale dei pairs
```
## Paper Trading Live
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP, ognuna con €1000 USDC virtuali indipendenti. Se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come MR01), il worker chiude su take-profit alla media / stop-loss ad ATR / time-limit; altrimenti usa il fallback `hold_bars`/stop -2%.
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP,
ognuna con €1000 USDC virtuali indipendenti. Gestisce due tipi di worker:
- **Single-leg** (`strategy_worker.py`): per le strategie direzionali. Se un `Signal`
porta `tp`/`sl`/`max_bars` in `metadata` (come le fade), chiude su take-profit /
stop-loss / time-limit; altrimenti usa il fallback `hold_bars`/stop -2%.
- **Due gambe** (`pairs_worker.py`): per i pairs market-neutral. Apre long su una gamba
e short sull'altra, esce sul rientro dello z-score o per time-limit, conta le fee su
entrambe le gambe. Validato: il replay storico coincide *esattamente* col backtest.
### Avvio
@@ -141,19 +231,24 @@ defaults:
position_size: 0.15
leverage: 3
strategies:
strategies: # strategie single-leg
- name: MR01_bollinger_fade
asset: BTC
tf: 1h
enabled: true
params:
bb_window: 50
k: 2.5
sl_atr: 2.0
max_bars: 24
params: { bb_window: 50, k: 2.5, sl_atr: 2.0, max_bars: 24, trend_max: 3.0, ema_long: 200 }
pairs: # strategie a 2 gambe (market-neutral)
- name: PR01_pairs_reversion
a: ETH
b: BTC
tf: 1h
enabled: true
params: { n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08 }
```
Per aggiungere una strategia: nuova riga in `strategies.yml`, poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
Per aggiungere una strategia: nuova riga in `strategies.yml` (sezione `strategies` o
`pairs`), poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
### Persistenza
@@ -168,6 +263,94 @@ data/paper_trades/
Notifiche Telegram per ogni trade (richiede `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` in `.env`).
## Paper Trading a Portafoglio
Accanto al multi-strategy runner originale — in cui ogni strategia gestisce autonomamente il proprio conto virtuale da €1.000 — il progetto dispone ora di un **paper trader a portafoglio** (`src/portfolio/`) che tratta l'insieme delle strategie come un unico organismo con un capitale condiviso.
### Come funziona
La definizione di un portafoglio (`SleeveSpec` + schema di peso) ha due facce sulla stessa sorgente dati:
- **Backtest** (`.backtest()`): ricostruisce le equity-curve di ogni sleeve tramite il builder unificato in `sleeves.py`, le pondera secondo lo schema scelto e calcola le metriche aggregate (CAGR, Sharpe, max DD). La parità con i report prodotti da `report_families.py` è garantita dalla fonte unica.
- **Live** (`PortfolioRunner`): ogni ora il runner scarica le candele aggiornate via Cerbero v2, calcola i pesi correnti, avvia i worker appropriati per ogni sleeve attiva e registra il PnL aggregato nel ledger (`data/portfolios/{code}/`). Il ledger persiste tra i riavvii.
### Schemi di ponderazione
Il modulo `weighting.py` mette a disposizione cinque schemi: `equal` (default), `cap` (tetto per famiglia — p.es. `pairs: 0.33` per limitare la concentrazione), `inverse_vol` (pesi inversamente proporzionali alla volatilità storica), `cluster_rp` (equal tra cluster naturali poi inverse-vol all'interno del cluster) e `manual` (pesi liberi). Lo schema si specifica in `portfolios.yml` insieme al codice portafoglio e alla leva.
### Portafoglio di default: PORT06
La configurazione raccomandata è **PORT06** (`scripts/portfolios/PORT06_master_shape.py`): portafoglio master esteso che include tutte e sei le famiglie (FADE, HONEST, PAIRS, TSMOM, SHAPE), con schema `cap` che limita i pairs al 33% del capitale per moderare la loro concentrazione di rischio. Backtest canonico (dati al 2026-05-28): Sharpe 6.47 (FULL) / 8.82 (OOS), drawdown massimo 4.10% (FULL) / 1.30% (OOS), leva 2×; **con la config live attuale (EXIT-16 close-confirm): Sharpe 7.84 / 10.06, DD 2.60% / 1.15%**.
### Scope live
Il runner esegue **tutti e 17 gli sleeve** di PORT06: **fade** (MR01, MR02, MR07 × BTC/ETH),
**honest** (DIP01, TR01-basket 4h, ROT02-rotation 1d), **pairs** (PR01, cinque coppie),
**TSMOM** (TSM01 1d) e **shape** (SH01 × BTC/ETH). Worker dedicati: `StrategyWorker` (single-leg, fade/
dip/**shape**), `PairsWorker` (2 gambe), `BasketTrendWorker`, `RotationWorker`, `TsmomWorker`. Il runner
fetcha 1h da Cerbero v2 e resampla a 4h/1d; il pool di capitale, il ribilancio giornaliero e il ledger
sono validati == backtest.
> **SH01 (2026-06-01):** gira come `StrategyWorker` normale (il walk-forward è interno a
> `generate_signals`). Il vecchio `MLWorkerWrapper` usava il `SignalEngine` **squeeze scartato** —
> rimosso. **Loss-guard Hurst (2026-06-02):** le fade saltano i segnali in regime persistente
> (rolling-Hurst ≥ 0.55), dove si concentrano gli stop-loss — dimezza il drawdown del portafoglio
> (FULL 4.1%→2.4%; stop-loss fade 67% in numero, perdite totali 68%). Calcolato dalle sole close,
> attivo live (`hurst_max` nei params). Il report orario su Telegram **monitora lo stop-rate fade
> prima/dopo l'attivazione** e dà il verdetto automatico quando il campione è sufficiente.
### Esecuzione reale (shadow, Deribit testnet)
Sette sleeve single-leg — le **6 fade** (MR01/MR02/MR07 × BTC/ETH) e **DIP01** (dal 2026-06-04) —
eseguono ordini **reali su Deribit testnet** accanto al fill simulato (*shadow*: il sim resta la
verità che guida le decisioni; il reale misura la fattibilità). Punti chiave:
- **Strumenti lineari USDC** (`BTC_USDC`/`ETH_USDC-PERPETUAL`): payoff lineare = matematica del
backtest; fee e PnL in USDC. Quantizzazione `Decimal` di amount (step) e prezzi (tick).
- **Take-profit reale = limit reduce-only AL livello** (v1.0.7): piazzato all'apertura, copre la
sola quota del worker (gli strumenti sono condivisi fra worker e nettati per conto); alla
chiusura il worker cancella il resting, riconcilia i fill dal trade history per `order_id` e
chiude a market solo il residuo. Fix della divergenza misurata: il market-on-poll usciva
+235 bps oltre il livello TP. Fill da resting = fee maker (~0%).
- **Stop-loss close-confirm** (v1.1.0): uscita al close che sfonda il livello → market
reduce-only al poll (nessun ordine stop sul book, per scelta: i trigger Deribit generano un
nuovo order_id allo scatto, non verificabile, e i wick non devono stoppare).
- **Verifica sul trade** (order_id in `get_trade_history`), fee reali dai `trades[]`, ledger
reale parallelo persistito (`real_capital`), eventi `REAL_OPEN`/`REAL_TP_RESTING`/`REAL_CLOSE`
nel log + alert Telegram (`REAL_EXEC_LIVE`, `REAL_OPEN_FAIL`).
- Config in `portfolios.yml``overrides.execution {enabled, sleeves, instruments}`.
**Pairs/rotation/TSMOM/shape restano simulati**: i pairs richiedono un executor a 2 gambe
(leg-risk), i multi-asset un rebalance-to-target; roadmap nel diario.
### Versione & deploy
Ogni deploy ha una **versione** (file `VERSION`, semver) che compare nei messaggi Telegram (notifiche
trade + report orario), così correli ogni messaggio al codice che l'ha generato. Il sorgente è **cotto
nell'immagine** → per aggiornare il live serve un **rebuild**, non un semplice restart:
```bash
./scripts/deploy.sh # bump patch (1.0.0 → 1.0.1) + commit + rebuild + ricrea container
./scripts/deploy.sh minor # 1.0.x → 1.1.0
```
Il volume `data/` persiste tra i deploy → i worker fanno RESUME dello stato (capitale, posizioni aperte).
### Avvio del paper trader a portafoglio
```bash
# Backtest del portafoglio di default (PORT06)
uv run python scripts/portfolios/PORT06_master_shape.py
# Paper trading live a portafoglio
uv run python -m src.portfolio.runner
# Report orario su Telegram (stato + stop-rate fade prima/dopo loss-guard) — via cron
uv run python scripts/portfolios/hourly_report.py
# Smoke test del data layer Cerbero v2
uv run python scripts/analysis/smoke_portfolio.py
```
## Setup
```bash
@@ -194,12 +377,41 @@ uv run python -m src.live.multi_runner
## Dati
| Asset | Timeframe | Candele | Copertura |
|-------|-----------|---------|-----------|
| BTC | 5m / 15m / 1h | 883K / 294K / 74K | 2018-01 → oggi |
| ETH | 5m / 15m / 1h | 882K / 294K / 74K | 2018-01 → oggi |
| Asset | Timeframe | Copertura |
|-------|-----------|-----------|
| BTC, ETH | 5m / 15m / 1h | 2018-01 → oggi |
| SOL, LTC, ADA, XRP, BNB, DOGE | 15m / 1h | 2019-2022 → oggi (variabile per asset) |
Fonte primaria: Deribit perpetual via Cerbero MCP. Fallback: Binance spot via ccxt. Formato: Apache Parquet.
Fonte primaria: perpetual Deribit via Cerbero MCP. Fallback: Binance spot via ccxt.
Formato: Apache Parquet (in `data/raw/`, gitignored).
> **Nota sul naming Deribit (per il feed live).** I major sono perpetui *inverse*
> (`BTC-PERPETUAL`, `ETH-PERPETUAL`); gli altcoin sono perpetui *lineari USDC*
> (`SOL_USDC-PERPETUAL`, `LTC_USDC-PERPETUAL`, …) con storia dal 2022. Attenzione:
> `LTC-PERPETUAL`/`ADA-PERPETUAL` non esistono e `SOL-PERPETUAL` restituisce dati
> errati — per gli altcoin usare sempre la forma `_USDC-PERPETUAL`.
### Discovery & validazione strumenti
`src/data/instruments.py` scopre e **valida** gli strumenti disponibili sugli
exchange implementati — **Deribit** e **Hyperliquid** (esclusi Alpaca/stocks e
**Bybit**, feed testnet inaffidabile). Ogni perpetuo viene testato sui dati
storici realmente raccoglibili: esistenza, congruenza OHLC, contratto non-morto,
liquidità e **congruenza prezzo cross-exchange** (mediana per base-coin, tolleranza
5%) — così feed farlocchi e contratti sbagliati (es. `SOL-PERPETUAL`=9.6) vengono
scartati. Il risultato è `data/instruments_registry.json` (strumenti validi +
timeframe + data d'inizio).
**Solo gli strumenti validati possono essere scaricati**: il downloader ha un gate
(`_download_cerbero_range`) che rifiuta quelli non nel registry. Rigenera con:
```bash
uv run python -m src.data.instruments
```
Simboli Deribit: BTC/ETH = `<COIN>-PERPETUAL` (inverse); altcoin =
`<COIN>_USDC-PERPETUAL` (lineari USDC). Registry attuale (testnet): Deribit 18/106
validi (major liquidi, BTC dal 2018), Hyperliquid 66/74.
## Riferimenti
+1
View File
@@ -0,0 +1 @@
1.1.2
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -1,17 +1,18 @@
services:
paper-trader:
portfolio:
build: .
container_name: pythagoras-multi
container_name: pythagoras-portfolio
restart: unless-stopped
command: ["uv", "run", "python", "-m", "src.portfolio.runner"]
volumes:
- ./data:/app/data
- ./strategies.yml:/app/strategies.yml:ro
- ./portfolios.yml:/app/portfolios.yml:ro
env_file:
- .env
environment:
- PYTHONUNBUFFERED=1
healthcheck:
test: ["CMD", "python", "-c", "import os; assert any(f.endswith('status.json') for r,d,fs in os.walk('/app/data/paper_trades') for f in fs)"]
test: ["CMD", "python", "-c", "import os; assert any(f.endswith('status.json') for r,d,fs in os.walk('/app/data/portfolios') for f in fs)"]
interval: 120s
timeout: 10s
retries: 3
+84
View File
@@ -0,0 +1,84 @@
# Diario — 2026-05-29 — Pattern del segnale per FORMA (analog/shape forecasting)
## Obiettivo
Verificare se la **forma** del segnale (la morfologia recente del prezzo) permette di
prevedere l'andamento successivo, e ricavarne edge verso il target €1000 → €50/giorno.
Esplorazione onesta (no look-ahead, netto fee, OOS) con **agenti paralleli**, ognuno su
una famiglia di forma indipendente, tutti sullo stesso harness shape (`scripts/analysis/
shape_lab.py`, che riusa l'engine netto-fee+OOS di `explore_lab.py`). Branch `shape_patterns`.
## Harness
`shape_lab.py` — analog forecasting causale: a ogni barra `i` si guarda la forma recente
`W` (closes z-normalizzati fino a `close[i]`), si cercano nel passato le `K` finestre più
simili **il cui esito a `H` barre era già noto prima di `i`** (KDTree ricostruito ogni
`rebuild` barre → niente O(N²)), si prevede la direzione = segno del rendimento medio degli
analoghi. **No-look-ahead verificato** (perturbare il futuro non cambia la forma a `i`,
max diff 0.0). Baseline forma grezza: marginale e **muore sulle fee** (W24H12K50: FULL
+112% / OOS +48% ma a 0.20% RT → 72%; troppi trade, exp 74%, win 49.5%).
## Famiglie esplorate (5) ed esito onesto
| Famiglia | Esito | Note |
|---|---|---|
| Analog kNN (forma grezza, selettività) | ❌ RUMORE | Solo BTC-overfit, non robusto ≥2 asset |
| Encoding candele (UP/DOWN/DOJI + body/shadow) | ❌ RUMORE | Hit-rate condizionale ~50%, segno incoerente fra asset |
| DTW + template geometrici (M/W, testa-spalle, V, U) | ❌ RUMORE | DTW *peggiora* l'euclidea; template overfit (FULL ok, OOS crolla) |
| PIP / pivot / zig-zag (geometria svolte) | ❌ RUMORE | 0/48 config robuste; le rotture S/R rientrano (riconferma MR) |
| **Feature-vector + ML walk-forward** | ✅ **EDGE REALE** | LogisticRegression sulla forma, fee-robusto |
4 famiglie su 5 sono rumore: riconfermano che la forma grezza non contiene edge
direzionale eseguibile e che l'unico edge "classico" resta la mean-reversion (fade/pairs).
## L'edge: SH01 — Shape-ML
Una **LogisticRegression** legge 17 feature di forma (body/shadow ratio, rendimenti,
pendenza/curvatura del path, posizione di max/min, RSI, estensione) e predice il segno del
rendimento a `H` barre. **Walk-forward rigoroso**: scaler+modello fittati solo sul passato
con esito noto, poi predicono il blocco corrente; si entra a `close[i]` se la probabilità
≥ soglia. Causalità verificata con check espliciti (feature e predizioni invarianti al
futuro). Il GradientBoosting dà edge equivalente ma è ~60× più lento → si usa il logit.
A differenza della famiglia squeeze (che moriva anche a fee zero), **questo edge
sopravvive a fee 0.20% RT**. Win-rate ~50% → l'edge è nell'**asimmetria** (quando indovina
la direzione i moti sono più grandi), non nella frequenza.
### Validazione dura (config W24 H12 th0.58, netto fee, leva 3x, pos 0.15, OOS 30%)
- **Multi-asset expanding**: robusti **BTC** (FULL +219% / OOS +42% / Sharpe 2.72 / DD 23%
/ 8-9 anni+ / accOOS 56%), **ETH** (+80% / +144% / Sharpe 1.21, più volatile), **ADA**
(+707% / +57% / Sharpe 3.22). Scartati LTC/SOL/XRP (perdono netti).
- **Walk-forward rolling (train fisso 2 anni)**: regge **solo BTC** (+166% / +96% / Sharpe
2.05). L'edge si appoggia in parte alla memoria lunga → BTC è il più solido.
- **Stress leva 2x + slippage doppio (0.20% RT)**: BTC OK (+40% / +17% / Sharpe 1.24),
ETH marginale (+7% / +73% / Sharpe 0.37).
- **Griglia (W,H,thresh) su BTC**: **5/27 celle robuste**, su una **cresta** stretta (W24,
H8-12), non altopiano largo → rischio overfit moderato. Per prudenza si sceglie la config
robusta sul maggior numero di test (W24 H12 th0.58), non il PnL massimo (W24 H8 rende di
più ma accOOS ~49% = più drift che segnale).
### Il valore vero: diversificatore di portafoglio
Correlazione daily col MASTER **+0.08** (quasi scorrelato). Aggiungere lo sleeve shape
(BTC+ETH) al MASTER migliora l'OOS: **Sharpe 4.33 → 5.10, DD 4.7% → 4.2%** (FULL: Sharpe
4.23 → 4.37, DD 5.2% → 4.3%). Non è un motore standalone (per-asset troppo stretto fuori
da BTC), ma un **free-lunch** da aggiungere al paniere.
## Artefatti
- `scripts/analysis/shape_lab.py` — harness analog/forma causale.
- `scripts/analysis/shape_{analog,candle,template,pivot,ml}_research.py` — le 5 ricerche.
- `scripts/analysis/shape_ml_validate.py` — validazione dura del candidato ML.
- `scripts/strategies/SH01_shape_ml.py` — la strategia (Strategy + run() riproducibile).
- Aggiunta a `MODULE_MAP` (caricabile per backtest).
## Conclusione e prossimi passi
La forma del segnale **non** predice in modo grezzo (4/5 famiglie rumore), ma un modello
lineare sulle feature di forma in walk-forward onesto **sì**, soprattutto su BTC, e vale
come diversificatore quasi-scorrelato del MASTER. Da fare prima del live:
1. **Worker con retraining periodico** (lo StrategyWorker attuale è a regola fissa; SH01
riallena il modello → serve un loop tipo legacy signal_engine).
2. Validazione live-path (replay worker == backtest) come fatto per i pairs.
3. Decidere il peso nel MASTER-esteso (cap, leva) col paper trader.
+68
View File
@@ -0,0 +1,68 @@
# 2026-05-31 — Studio sugli EXIT delle fade: scalping, TP dinamico, TP-ATR
> Innescato da una domanda operativa ("un TP è stato raggiunto, non si poteva
> scalpare / fare un TP dinamico?"). Studio fee-aware su MR02 (Donchian fade,
> segnali invariati `n=20 sl_atr=2.0 max_bars=24`, fee 0.10% RT, leva 3x). Tre
> alternative di uscita misurate contro il baseline attuale (**TP = centro del
> canale**). Verdetto: **il design attuale è già ottimale; nessuna alternativa lo batte.**
## 1. "Scalping" = timeframe più veloce (15m vs 1h)
A fee 0.10% il 15m rende di più in lordo (~4× più trade), MA è molto più **fragile**:
| | trade | PnL @0% | @0.10% | @0.20% | DD @0.10% |
|---|--:|--:|--:|--:|--:|
| BTC 1h | 2041 | +22.768 | +16.645 | +10.522 | 29% |
| BTC 15m | 8251 | +65.286 | +40.533 | +15.780 | 29% |
| ETH 15m | 9388 | +120.103 | +91.939 | +63.775 | **62%** |
Da 0% a 0.20% il 15m perde **~76%** del profitto (vs 54% del 1h) e il DD esplode
(ETH 15m → 93% a 0.20%). 4× più trade = 4× più fee + slippage (non modellato, ma
peggiore su book sottili). **L'1h è scelto per il margine di sicurezza, non per il PnL
lordo.** Lo scalping vero (<0.3% target) è in pieno territorio "morte da fee".
## 2. TP dinamico / trailing ("lascia correre il vincitore")
Stessi segnali, exit per trailing a k·ATR dal massimo favorevole invece del TP fisso:
| policy | BTC win% | ETH win% | equity |
|--------|---------:|---------:|--------|
| FIXED (centro, attuale) | **48%** | **49%** | 🟢 di gran lunga il migliore |
| TRAIL (lascia correre) | 36% | 36% | 🔴 azzerato |
| MID+TRAIL | 47% | 47% | 🔴 peggio |
Il win-rate crolla 48%→36%: i trade che avrebbero incassato il TP fanno andata-e-ritorno
e stoppano fuori. **Concettuale:** l'edge della fade è la reversione *fino* alla media;
una volta toccata, l'edge è esaurito. Lasciar correre *oltre* = scommettere sulla
continuazione, che sui perp crypto NON ha edge (rientra). È la stessa logica per cui
SMA/ORB/WR (continuazione) hanno fallito: **let-it-run = trend = il lato perdente.**
## 3. TP scalato all'ATR (TP = entry + dir·m·ATR, SL fisso 2 ATR → R:R = m/2)
| Config | win% | avg %/trade | Sharpe | sumRet% |
|--------|-----:|-----------:|-------:|--------:|
| **BTC MID (attuale)** | 48% | **0.816** | **3.8** | **1664** |
| BTC ATR m=0.5 (RR0.25) | **77%** | 0.081 | 1.0 | 217 |
| BTC ATR m=1.0 | 67% | 0.192 | 1.6 | 465 |
| BTC ATR m=2.0 | 53% | 0.563 | 3.0 | 1199 |
| BTC ATR m=3.0 | 46% | 0.679 | 3.0 | 1331 |
| **ETH MID (attuale)** | 49% | **1.738** | **7.5** | **4169** |
| ETH ATR m=0.5 | 77% | 0.041 | 0.5 | 134 |
| ETH ATR m=3.0 | 46% | 1.082 | 4.7 | 2515 |
OOS (ultimo 30%) identico: **MID** batte ogni `m` (BTC MID avg 1.14/Sh 3.2; ETH MID avg
4.43/Sh 10.9). Due lezioni:
- **TP stretto (m=0.5) = trappola dello scalping quantificata:** win-rate **77%** ma edge
**zero/negativo** (BTC 0.08%/trade). I rari stop a 2 ATR spazzano via le micro-vincite,
la fee mangia il resto. **Win-rate alto ≠ edge.**
- **Nessun multiplo ATR fisso batte il centro del canale**, su avg/trade E Sharpe, FULL e OOS,
entrambi gli asset.
## Verdetto unificato
Il **TP al centro del canale è ottimale** perché è un target *adattivo alla struttura*: un
multiplo fisso di ATR misura solo *quanta* vol c'è, ma ignora *dove* sta la media; il centro
adatta al punto reale di reversione **ed è già scalato alla volatilità** (il canale si allarga
in regime volatile). Per una mean-reversion il punto giusto dove chiudere è **la media — niente
prima, niente dopo.** Tre alternative escluse coi numeri (15m, trailing, TP-ATR) → la scelta
di design corrente è blindata.
> Nota metodologica ricorrente: diffidare del **win-rate alto**. Il segnale vero è
> rendimento-medio-per-trade × Sharpe; un TP stretto regala win-rate e nasconde l'assenza
> di edge. (Stesso tranello dei guru: backtest cherry-picked ad alta % di vincite.)
+70
View File
@@ -0,0 +1,70 @@
# 2026-05-31 — Stato trade LIVE PORT06 (paper trading)
> Snapshot verificato del paper trader a portafoglio (`src.portfolio.runner`, Docker
> `pythagoras-portfolio`). Dati da `data/portfolios/PORT06/` + log del container.
> Avvio container: 2026-05-29 18:37 UTC. Snapshot: 2026-05-31 13:20 UTC (~43h).
## Riepilogo capitale
| Metrica | Valore |
|---------|--------|
| Capitale iniziale | €1000.00 (17 sleeve equal-weight, ~€58.82 ciascuno) |
| `total_capital` (realizzato, ultimo rebal 00:00) | **€1000.09** (+0.09) |
| Equity mark-to-market (live) | **€1000.36** (+0.036%) |
| Peggior punto toccato | −€0.01 |
| **Max DD** | **0.40%** |
| Container | running, healthy, 0 restart |
## Trade chiusi (storia completa dallo startup: 10 trade, 9W/1L)
| # | Sleeve | Uscita | Net % | PnL € | Esito |
|---|--------|--------|------:|------:|:---:|
| 1 | PR01 ETH/SOL | mean_revert | +0.503 | +0.040 | W |
| 2 | PR01 ETH/SOL | mean_revert | +0.683 | +0.060 | W |
| 3 | SH01 BTC (ML) | hold_limit | 0.462 | 0.040 | L |
| 4 | SH01 BTC (ML) | hold_limit | +0.017 | +0.000 | W |
| 5 | PR01 ETH/SOL | mean_revert | +0.488 | +0.040 | W |
| 6 | PR01 ETH/SOL | mean_revert | +0.284 | +0.030 | W |
| 7 | PR01 LTC/ETH | mean_revert | +0.745 | +0.070 | W |
| 8 | PR01 BTC/LTC | mean_revert | +0.434 | +0.040 | W |
| 9 | MR02 ETH fade | take_profit | +0.995 | +0.090 | W |
| 10 | SH01 ETH (ML) | hold_limit | +0.742 | +0.070 | W |
| | **TOTALE** | | | **+0.400** | **90% win** |
### Aggregato per sleeve (trade chiusi)
| Sleeve | n | win | acc% | PnL € |
|--------|--:|----:|----:|------:|
| PR01 ETH/SOL | 4 | 4 | 100 | +0.170 |
| MR02 ETH fade | 1 | 1 | 100 | +0.090 |
| PR01 LTC/ETH | 1 | 1 | 100 | +0.070 |
| SH01 ETH (ML) | 1 | 1 | 100 | +0.070 |
| PR01 BTC/LTC | 1 | 1 | 100 | +0.040 |
| SH01 BTC (ML) | 2 | 1 | 50 | 0.040 |
Motore del PnL finora: **pairs PR01** (market-neutral, mean_revert rapidi 1-6 barre) +
una fade **MR02** su take_profit. Unica perdita: SH01 BTC (ML) su hold_limit (fisiologico,
edge nell'asimmetria, win-rate ~50%). Sleeve daily (ROT02/TSM01/TR01) e diverse fade non
hanno ancora chiuso trade (orizzonte più lungo / pochi segnali in ~2 giorni).
## Posizioni aperte (3)
| Sleeve | Dir | Entry | Capitale |
|--------|-----|------:|---------:|
| MR02 BTC fade | short | 73969.0 | €58.83 |
| MR02 ETH fade | long | 2016.15 | €58.92 |
| SH01 BTC (ML) | long | 73811.5 | €58.83 |
## Verifica (check 2026-05-31)
- **0 anomalie** sui 10 CLOSE: `net = gross fee` rispettato, flag `win` coerente col
PnL, fee sempre presente (pairs 0.4% su 2 gambe, fade 0.10% RT).
- **Uscite = backtest**: tutti i CLOSE pairs sono `mean_revert` con **|z| ≤ 0.75** al close
(0.363/0.605/0.684/0.619/0.656) = esattamente `z_exit=0.75` di PR01; MR02 esce a TP al
livello. Il worker live replica la regola del backtest.
- **Riconciliazione**: +0.40 realizzato vs +0.09 `total_capital` NON è un errore — è il
timing del ribilancio giornaliero (`total_capital` snapshotta a 00:00, le posizioni
aperte restano sul notional fino al rebal; CLAUDE.md). L'equity MtM live (+0.36) è il
numero corrente, confermato da `equity.jsonl`.
## Lettura onesta
Campione minuscolo (**~2 giorni, 10 trade**) → il PnL (+€0.40 realizzato, +€0.36 MtM) è a
livello di **rumore**: non se ne deduce performance. Quello che il check conferma a questo
stadio è che il sistema è **sano e fedele**: esecuzione corretta, costi reali inclusi,
uscite conformi al backtest, DD trascurabile (0.40%), 0 errori/restart. L'edge si
manifesterà solo su orizzonte settimane/mesi. Monitor Docker attivo per down/unhealthy/restart.
+51
View File
@@ -0,0 +1,51 @@
# 2026-05-31 — Copertura opzioni: idee testate e SCARTATE (record anti-ripetizione)
> Record delle conclusioni. Il **codice** di queste prove è stato testato e poi
> scartato (non conservato nel repo): qui restano i numeri e il *perché*, così da
> non ri-testare le stesse idee in futuro. Motore di pricing usato: Black-Scholes
> r=0 + IV stimata onestamente = RV × moltiplicatore VRP ≥ 1 (il compratore
> SOVRAPPAGA, come in W18-W21), fee Deribit reali (0.03%/gamba + ~0.10% slippage).
## TL;DR
**La copertura opzioni non genera edge nuovo per questo progetto.** I due edge
disponibili (trend e mean-reversion) sono già catturati **50-100× più a buon
mercato dai perp** (fee 0.10% RT) di quanto facciano le opzioni (premio + VRP +
asimmetria coda). Comprare premio perde contro il VRP crypto; venderlo paga le
code grasse. Cappare la perdita su una strategia senza expectancy positiva limita
solo *quanto* perdi: **non esiste il pasto gratis "leva alta + perdite coperte".**
## 1. Overlay opzioni su PORT06 — non fattibile
Mismatch di orizzonte: l'edge di PORT06 è intraday (hold fade ~9h). Carry ATM 9h
**0.96%** del notional vs edge fade per-trade **0.10-0.30%** → costo 3-10× l'edge.
La coda di PORT06 è già piccola (DD ~5%) e market-neutral (pairs ~57% del rischio):
poco da assicurare. La copertura giusta era già lì (diversificazione + stop), gratis.
## 2. Strategie nuove a copertura opzioni
- **OH01 — direzionale (TSMOM) + opzione protettiva / sola opzione.** Frontiera
iso-rischio: il **perp NON coperto domina a ogni livello di rischio** (Sharpe 0.90
vs 0.33-0.57; CAGR +33% vs negativo). Comprare protezione su un trend perde per il
carry/VRP (il trend-following è "long vol" nel *payoff*, non comprando opzioni).
- **OH02 — spread di credito su mean-reversion (vendi premio = VRP a favore).**
La copertura funziona (perdita cappata, DD basso, win-rate 73-80%: la reversione è
reale). Ma **expectancy ~0/leggermente negativa**: il 27% di trade dove il movimento
*continua* (code grasse) costa ~5× ogni vincita. Un trend filter porta solo *singole
celle* a +1-2% (overfit: config diversa per asset). Non robusto.
## 3. V5 — Bull Call Spread / debit spread (stile Casario)
È **la migliore struttura long-premium**: rischio definito funziona (worstRoll 13%
vs 64% della call secca, DD 54% vs 94%). **Ma net-negativo in crypto** (BTC 2.2%
full / 13.5% OOS) e il perp non coperto lo batte. Sweep larghezza: spread più larghi
rendono di più → **cappare l'upside toglie le code grasse che pagano il premio**.
**Verdetto:** valido **sulle AZIONI** (vol/VRP bassi, uptrend puliti da screener), NON
in crypto. Casario ha ragione nel suo dominio (equity), non trasferibile ai perp crypto.
## 4. V4 — Box strategy (max/min giorno prima, supply/demand) → SKIP
Core tradabile = **fadare gli estremi del canale = MR02** (già live). La candela di
conferma (doji/hammer/rejection) = pattern di rigetto = rumore (vedi diario TA). Nessun
edge nuovo: costruirlo ri-deriverebbe solo MR02.
## Cosa servirebbe per un vero edge a opzioni (fuori scope attuale)
Non direzione né reversione (già coperte dai perp), ma un edge *specifico delle opzioni*:
dislocazioni della superficie IV/skew, o gestione attiva (chiusura al 50% del credito,
roll). Richiede storico prezzi opzioni reale (qui assente, prezzi sintetici da BS) e un
feed greche/IV che il `CerberoClient` oggi non espone.
@@ -0,0 +1,43 @@
# 2026-05-31 — 3 strategie TA "classiche": testate e SCARTATE (record anti-ripetizione)
> Record delle conclusioni. Codice testato e poi scartato (non conservato nel repo).
> Strategie da contenuti trading-guru: (1) SMA20/200 trend+pullback, (2) Opening Range
> Breakout "ironclad", (3) "Weakness rectangle" reversal (ICT). Testate con la
> metodologia onesta del progetto: ingresso eseguibile a `close[i]`, SL/TP intrabar,
> fee Deribit 0.10% RT, leva 3x, OOS(ultimo 30%), griglia robustezza, sweep fee.
## TL;DR — tutte e 3 NO EDGE (negative anche a fee ZERO)
Tutte e tre **direzionali/continuazione**, tutte negative su BTC/ETH, su tutta la
griglia, **anche a fee 0%** → il problema è il *segnale* (avg_R per-trade ≤ 0), non i
costi. Riconfermano la lezione centrale: *sui perp crypto i breakout/continuazione
rientrano; l'unico edge robusto è la mean-reversion.*
| Strategia | Tipo | avg_R @ fee0 | Motivo |
|-----------|------|--------------|--------|
| **SMA01** MA-pullback | continuazione | 0.15 BTC / 0.07 ETH | win ~30% (serve ~40% a R:R 2) |
| **ORB01** opening-range breakout | breakout | 0.10…−0.19 | crypto 24/7: manca l'asta d'apertura, ragione d'essere dell'ORB |
| **WR01** weakness rectangle | reversal→continuazione | ≈ 0.05/0.00 | R:R "5:1" illusorio (win cala in proporzione); le weakness vengono travolte |
> Verificato indipendentemente (reimplementazione minima SMA01): a fee 0 avg_R
> 0.15/0.07. Il 100% di CAGR è solo l'edge negativo composto a leva 3x su migliaia
> di trade, non un bug.
## Tentativo di MIGLIORAMENTO — ribaltarle sul lato fade
Miglioramento *di principio* (non tuning): visto che perdono perché sono continuazione,
ribaltate sul **fade** (l'unico lato con edge in crypto).
| versione fade | edge? (avg_R@fee0) | verdetto |
|---------------|--------------------|----------|
| **SMA02** fade dell'estensione→SMA20 | **+** (0.04…0.36) | = **MR01 inferiore** (FULL 1h negativo, Sharpe 0.4-0.9 vs 2.7+) |
| **ORB02** fade del breakout del range | **+** (win 35%→50-66%) | = **MR02/MR07** senza controlli di rischio (DD 90-100%) |
| **WR02** weakness come reversione | **≈0** | **rumore**, non una fade-family nascosta |
- Il flip restituisce segno positivo a 2/3 (riconferma *fade > continuazione*) **ma nulla
di additivo**: SMA02/ORB02 sono ri-scoperte inferiori di strategie già live; WR02 è rumore.
- **Ipotesi "SMA200 piatta = meglio fadare" SMENTITA**: il regime *range* non batte il fade
semplice; semmai il regime *trend* dà avg_R migliore ma con time-in-market 0.5-9%.
## Lezione metodologica
La prova del nove è l'**avg_R a fee 0**: se una strategia perde anche senza costi, il
problema è il segnale e nessun tuning la salva. Le strategie che funzionano restano
MR01/MR02/MR07 (fade) + PR01 (pairs) + PORT06 — l'edge è mean-reversion + diversificazione.
@@ -0,0 +1,81 @@
# 2026-06-01 — Bugfix: SH01 usciva a 3 barre invece di H=12 (exit a orizzonte)
> Diagnosi partita da un check sulla debolezza apparente di **SH01_BTC** nel paper
> trading live PORT06 (accuratezza 33,3% su 3 trade). Non era sfortuna statistica:
> era un bug di exit nello `StrategyWorker`.
## Sintomo
Nel live PORT06 (Docker `pythagoras-portfolio`), SH01_BTC mostrava 3 trade tutti
`long`, **tutti chiusi con `reason: "hold_limit"` a `bars_held: 3`**, con `tp: null
sl: null`:
| # | entry | exit | bars | net % | esito |
|---|-------|------|------|------:|:---:|
| 1 | 73529.5 | 73433.0 | 3 | 0,46% | ❌ |
| 2 | 73759.5 | 73839.5 | 3 | +0,02% | ✅ |
| 3 | 73811.5 | 73766.0 | 3 | 0,32% | ❌ |
`oos_signal_precision` nei log di TRAIN scendeva 55,6% → 50,0% → 43,3%.
## Causa
SH01 (`scripts/strategies/SH01_shape_ml.py`, config **W24 H12 th0.58**) è una
strategia **horizon-only**: predice il segno del rendimento a **H=12 barre** ed esce a
H barre. I suoi Signal portano `metadata={"max_bars": H}` (=12) e **nessun TP/SL**.
Nello `StrategyWorker.tick()` la logica di uscita era:
```python
if self.tp and self.sl: # SH01: False (tp=sl=0)
... usa self.max_bars ... # -> max_bars=12 consultato SOLO qui
elif self.bars_held >= self.hold_bars: # fallback legacy hold_bars=3
self._close_position(..., "hold_limit") # SH01 finiva QUI
```
`self.max_bars` (=12, settato correttamente in `_open_position`) era onorato **solo
dentro il ramo `tp and sl`**. Senza TP/SL, SH01 cadeva sul fallback `hold_bars=3` e
chiudeva a 3 barre. L'edge di SH01 — per CLAUDE.md è nell'**asimmetria sull'orizzonte
H, non nella frequenza** (win-rate ~50%) — non aveva tempo di realizzarsi: tagliato a
3/12, degenera in rumore.
Solo SH01 (BTC+ETH) era colpito: tutte le fade (MR01/MR02/MR07, DIP01) portano
tp+sl+max_bars e usano il ramo intrabar corretto.
## Fix
`src/live/strategy_worker.py`: aggiunto un ramo per l'exit a orizzonte puro, prima del
fallback `hold_bars`:
```python
elif self.max_bars:
# Exit puro a orizzonte (strategie senza TP/SL, es. SH01 shape-ML H=12):
# onora max_bars dalla metadata del Signal, non il fallback hold_bars=3.
if self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
```
Le fade restano invariate (entrano nel ramo `tp and sl`).
## Verifica
- Nuovo test `tests/portfolio/test_horizon_exit.py` (2 casi): con `max_bars=12` resta
in posizione a 3 barre; esce a 12 con `reason: "time_limit"` e `bars_held: 12`.
- Suite completa: **43 passed**.
- Container riavviato: **tutti i 17 sleeve RESUME puliti**, inclusa una posizione
SH01_ETH short aperta che ora seguirà l'exit a 12 barre.
## Atteso d'ora in poi
I trade SH01 nei log mostreranno `reason: "time_limit"` con `bars_held: 12` invece di
`hold_limit / 3`. Il 33% di accuratezza era un artefatto dell'exit prematuro; ora la
strategia gira sull'orizzonte su cui è validata (BTC OOS Sharpe 2,72, expanding).
Resta comunque un **diversificatore** del MASTER, non un motore di ritorno standalone.
## Lezione
Il backtest di SH01 (`fade_base`/engine onesto) esce a H barre via `max_bars`; il
worker live deve replicarlo. Quando una strategia non porta TP/SL ma solo un
orizzonte, il fallback `hold_bars` del worker la **falsa silenziosamente**. Verificare
sempre che la convenzione di exit del worker live coincida con quella del backtest
validato — non solo l'ingresso.
@@ -0,0 +1,71 @@
# 2026-06-01 — SH01 live eseguiva la strategia SBAGLIATA (squeeze scartato), non shape-ML
> Scoperto verificando perché SH01 continuava a chiudere a `hold_limit/3` **anche dopo**
> il rebuild col fix horizon-exit. Il fix era corretto ma in un **ramo morto**: SH01 live
> non passava da `StrategyWorker.tick()`.
## Sintomo
Dopo il deploy del fix SH01 (exit a H=12), un close SH01_BTC delle 12:00 era ancora
`reason=hold_limit bars=3` (perdita 1,27%). Il fix non aveva effetto sul path reale.
## Causa (bug di wiring, più grave del previsto)
`src/portfolio/runner.py` importava `MLWorkerWrapper` da **`src/live/multi_runner.py`** e
ci avvolgeva lo sleeve SH01:
```python
if spec.kind == "ml":
return MLWorkerWrapper(worker, {"retrain_hours": 24})
```
Ma quel wrapper è **legacy, per la famiglia squeeze ML01** (scartata, vedi CLAUDE.md):
- usa `SignalEngine` = squeeze-detection + GradientBoosting (NON SH01_shape_ml);
- ha una `tick()` propria che apre con un `Signal` **nudo** (niente tp/sl/max_bars) ed
esce con `if bars_held >= hold_bars: close("hold_limit")` → ignora del tutto la
strategia caricata e il fix horizon.
Quindi lo sleeve "SH01" del portafoglio live **non eseguiva shape-ML**: eseguiva il
motore squeeze scartato. I log `TRAIN OK / oos_signal_precision` venivano da lì. Il
`worker` con strategy=SH01_shape_ml era costruito ma la sua `generate_signals` non
veniva **mai** chiamata.
## Fix
SH01 (`kind="ml"`) ora gira come **StrategyWorker normale**: `SH01_shape_ml.generate_signals`
fa il walk-forward (retraining) **internamente** ad ogni tick (`ml_wf_entries`) ed emette
`metadata.max_bars=H=12` → gli exit passano per `StrategyWorker.tick()` e il fix horizon
si applica davvero.
```python
# runner.py: niente più MLWorkerWrapper per kind="ml"
return StrategyWorker(strategy=strategy, asset=spec.asset, tf=spec.tf, ...)
```
**Lookback dati.** `ml_wf_entries` ha `train_min=4000` → servono ≥4000 barre 1h prima di
produrre segnali (con 90g/2160 barre → 0 segnali, runtime 0.01s — il falso "muto"). Le
candele 1h di BTC/ETH già arrivano a 440g (le richiede TSM01/ROT02 a 1d), ma per non
dipendere da quella coincidenza ho aggiunto `_ML_LOOKBACK_DAYS=365`: gli asset usati da
sleeve ml fetchano ≥365g (~8760 barre). Costo `generate_signals` su 365g: **0,170,24s**
(modello logit) → trascurabile sul poll 60s.
**Verifica.** Build SH01 → `StrategyWorker` con `strategy.name=="SH01_shape_ml"`, niente
attributo `engine` (regression test `test_build_ml_sh01_is_plain_strategyworker`). Smoke
su 365g: 7661786 segnali, tutti `max_bars=12`; tick live 0,17s. `ml_wf_entries` non
predice mai l'ultima barra (`n-1`) ma fino a `n-2` = esattamente la condizione di apertura
del worker (`idx >= last_idx-1`) → apre quando il segnale è fresco. Suite: 51 passed.
**Stato live.** SH01 BTC/ETH erano flat: contatori resettati a 0 (capitale preservato
58,76/58,78), vecchi trade squeeze archiviati in `trades_squeeze_archive.jsonl`. Rebuild
+ recreate: 14 worker RESUME puliti, container healthy, nessun log TRAIN/squeeze, zero
errori.
## Lezione
1. **Verificare il path REALE, non solo il codice del fix.** Il fix horizon era giusto ma
SH01 non lo attraversava. Un fix non testato end-to-end sul percorso vivo è un fix
presunto. (Mi ero fidato del rebuild senza confermare il reason dei close SH01.)
2. Riusare un wrapper legacy "perché c'è" è un rischio: `MLWorkerWrapper` di multi_runner
era per la famiglia squeeze scartata, non per shape-ML.
3. Un modello ML "muto" può essere solo **fame di dati** (train_min), non un bug logico:
controllare sempre la dimensione della finestra prima di concludere.
+89
View File
@@ -0,0 +1,89 @@
# 2026-06-01 — "Win" che perdono: metrica netto-fee + filtro TP edge-minimo
> Partito da un'osservazione dell'utente sui trade live PORT06: **ci sono close con
> `win=True` ma `pnl` negativo**. Due problemi distinti, entrambi risolti.
## Problema 1 — la metrica `win` mentiva (lordo invece di netto)
In `strategy_worker.py::_close_position`:
```python
trade_return = price_change * direction # LORDO, prima delle fee
net = trade_return * leverage - fee_rt * leverage
pnl = capital * position_size * net # corretto (netto)
is_win = trade_return > 0 # BUG: usa il LORDO
```
`is_win` scattava appena il prezzo si muoveva di un soffio a favore, **prima delle
fee**. Capitale e PnL erano giusti (netti); solo la metrica `win`/`accuracy` era
gonfiata.
**Quantificazione (51 close live):** 39 win dichiarate (76,5%) → **13 falsi win**
(`win=True` ma `pnl≤0`) → accuratezza **netta reale 52,9%**. PnL realizzato +€0,77
(resta positivo: lo trascinano i pairs).
**Fix:** `is_win = net > 0`. + `tests/portfolio/test_win_net_of_fees.py` (mossa
sotto-fee = non win; oltre-fee = win; perdita = non win).
**Riconciliazione contatori persistiti:** i `total_wins` su disco erano gonfiati dal
vecchio conteggio lordo. Ricalcolati come `net_return>0` dai `trades.jsonl`:
**MR01_BTC 7→1, DIP01_BTC 7→1** (gli unici toccati; tutti gli altri già coerenti).
Capitale invariato.
## Problema 2 — i 13 falsi win erano tutti MR01_BTC / DIP01_BTC in take_profit
Causa: in `MR01_bollinger_fade` e `DIP01_dip_buy` il **TP è la media** (`tp = ma[i]`)
e l'entry è a `close[i]` appena fuori banda. Nel regime BTC **piatto** (inchiodato
~73.700 per ore, vol bassissima) la media è a pochi dollari dall'entry → il TP cade
**dentro** il costo round-trip (0,10%): colpire il TP = perdita netta garantita.
**Meccanismo del fix (importante).** "Spostare il TP più in là" NON garantisce di non
perdere: il prezzo rientra solo fino alla media, non oltre → si finirebbe su SL/time-
limit, perdendo di più. La mossa provabilmente non-perdente è un **filtro di edge
minimo**: se `|tp entry|/entry ≤ min_tp_frac` non si apre la trade. Break-even
esatto = `fee_rt` (= 0,10%, indipendente dalla leva, perché
`ret = mossa·lev fee_rt·lev > 0 ⇔ mossa > fee_rt`).
**Implementazione:** parametro `min_tp_frac` (default 0.0 = off) in **tutte le 4 fade**
(MR01 banda, MR02 midpoint canale, MR07 ATR-scaled) e DIP01; salta i segnali sotto
soglia. Cablato negli sleeve live a **0.0015 (1,5× fee)** in `_defs.py` (`MIN_TP_FRAC`).
**Validazione backtest (BTC+ETH 1h, config sleeve, min_tp_frac ∈ {0,.001,.0015,.002}):**
neutro su tutte e 4 le fade.
- MR01: 0 trade rimossi (BTC +8028€, ETH +10395€) — metriche identiche.
- DIP01 BTC: 1 trade a 0.002, **migliora** (+7492→+7522€, DD 26,3→25,9%).
- MR02 BTC: 1 trade a 0.0015 (pnl invariato +12198€), ETH 0 rimossi.
- MR07 BTC/ETH: 0 rimossi (TP ATR-scaled sempre ben oltre le fee nello storico).
Conclusione: i micro-scalp sotto-fee **non esistono nel campione storico** — sono un
artefatto del regime attuale. Il filtro è **puro upside**: neutro sul backtest validato,
protettivo dal vivo. (Le 12 trade live incriminate, tutte MR01/DIP01 BTC, avevano gap
~0,026%, ben sotto 0,15% → tutte bloccate.)
+ `tests/portfolio/test_min_tp_frac.py` (monotonia + ogni superstite ha gap > soglia
+ default-off invariato).
## Nota deploy — il codice è COTTO nell'immagine, non montato
Scoperta durante il deploy: `docker-compose.yml` monta solo `./data` e
`./portfolios.yml`; il sorgente (`src/`, `scripts/`) è `COPY` nel Dockerfile. Quindi
**`docker compose restart` NON ricarica le modifiche al codice** — serve
`docker compose up -d --build`. Conseguenza retroattiva: anche il fix SH01
horizon-exit di stamattina è andato live solo con questo rebuild. Da ricordare per ogni
futura modifica ai worker. Il volume `./data` persiste → i 14 worker fanno RESUME
puliti dopo il rebuild (capitale e posizioni intatti).
## Stato finale
- `is_win = net > 0` live; contatori riconciliati (MR01/DIP01 BTC 1/9).
- Filtro `min_tp_frac=0.0015` live su tutti i fade + DIP01 (attivo solo MR01/DIP01).
- Fix SH01 horizon-exit ora **effettivamente** live (rebuild).
- Suite: 49 passed. Container ricostruito, healthy, 14 sleeve in RESUME.
## Lezione
1. Una metrica di "win" deve essere **netto fee**, altrimenti l'accuracy è teatro.
2. Quando il TP è dentro il costo di transazione, la trade è persa in partenza: meglio
**non prenderla** che ritoccare il TP.
3. Per i worker live in Docker: **rebuild**, non restart. Il restart ricarica solo lo
stato dal volume, non il codice.
+67
View File
@@ -0,0 +1,67 @@
# 2026-06-02 — Loss-guard per le fade: filtro Hurst (regime persistente)
> Goal: limitare le perdite delle fade in "bassa vol". Diagnosi empirica + ricerca web + workflow
> 11 agenti + test decisivo a livello PORT06. Branch `feat/fade-lossguard`.
## Riformulazione del problema (la premessa era imprecisa)
Diagnosi su 3022 trade fade (MR01/MR02/MR07 × BTC/ETH, 2021+): **le perdite NON si concentrano in
bassa vol** — anzi il terzile low-DVOL è net positivo (+2,30%/trade). Il vero driver è il **regime
PERSISTENTE/trending**, misurato dall'Hurst:
- somma perdite peggiore: **hurst>0,55** (2695% in low-vol, dominante in ogni terzile vol)
- **stop-rate 43% per hurst>0,55 vs 21% per hurst<0,45** (anti-persistente) — 2x
- peggiori 1% trade: Hurst medio 0,61 (77% con hurst>0,55, solo 13% in bassa-DVOL)
## Ricerca web (confermata e smentita dai dati reali)
- **Hurst regime filter** (MR solo H<0,45, evitare H>0,55): **CONFERMATO** sui dati reali. ✅
- **ADX** (PF 1,62 sotto 20 vs 0,74 sopra 30): **NON si replica** — ADX-skip uccide l'edge
(Sharpe 4,82→0,99) e lo stop-rate non scende. ❌
- **vol-expansion ATR-ratio>1,5 (72% perdite)**: **NON si replica** — alza DD e stop-rate. ❌
- **time-stop ~15 barre**: riduce stop-rate ma alza il DD full → non passa standalone. ❌
## Workflow 11 agenti — meccanismi testati
| Meccanismo | OOS Sharpe (base→filt) | DD full | Buon loss-guard? |
|---|---|---|---|
| **Hurst-SKIP h<0,55** | 4,82→4,96 ↑ | 24,3→13,8% ↓ | **SÌ** |
| **Hurst-SIZE 1/0,5/0,25** | 4,65→5,32 ↑ (full) | 33,6→11,3% maxDD ↓ | **SÌ** |
| ADX-skip | 4,82→0,99 ✗ | — | NO (uccide edge) |
| vol-expansion vratio | 4,82→4,04 | 24,3→27,5% ✗ | NO |
| Kaufman ER, time-stop, vol-target, DVOL-rising, combo | tutti ↓ o DD↑ | — | NO |
**Solo l'Hurst** isola chirurgicamente il regime tossico; gli altri sono "dimmer uniformi" che
tagliano winner insieme ai loser (gate FR01 fallito).
## TEST DECISIVO a livello PORT06 — SUPERATO ✅
Applicato l'Hurst-skip alle 6 fade dentro il PORT06 intero (equal-weight, le altre 11 sleeve
invariate):
| Portafoglio | FULL Sharpe | FULL DD | OOS Sharpe | OOS DD | OOS ret |
|-------------|:--:|:--:|:--:|:--:|:--:|
| PORT06 baseline | 6,62 | 4,10% | 8,89 | 1,22% | +175% |
| **+ Hurst-skip h<0,55** | **6,76** | **2,39%** | **9,15** | 1,54% | +158% |
| + Hurst-skip h<0,50 | 6,61 | 2,08% | 9,02 | 1,54% | +150% |
**A differenza di FR01 (che diluiva), il filtro Hurst MIGLIORA il PORT06**: FULL Sharpe ↑, **FULL
DD quasi dimezzato (4,10→2,39%)**, OOS Sharpe ↑ (8,89→9,15). Costo: OOS DD +0,3pp (resta minuscolo),
OOS ret 17pp. **h<0,55 è il pick** (0,50 taglia più ritorno). Non aumenta il profitto: è puro
**rischio** — dimezza il DD mantenendo/alzando lo Sharpe.
## Implementazione
Aggiunto `hurst_skip_mask` in `src/strategies/fade_base.py` (rolling-Hurst causale dalle SOLE close)
+ parametro `hurst_max` (default None=off) in MR01/MR02/MR07. Test: `test_hurst_lossguard.py`.
**Vantaggio operativo decisivo vs FR01:** l'Hurst si calcola **dalle sole close** → nessun feed
DVOL/regime live necessario. Lo `StrategyWorker` lo computa inline dai dati che già ha → **deployabile
senza nuova infrastruttura**, basta settare `hurst_max: 0.55` nei params degli sleeve fade.
## Da fare per attivarlo live (deploy)
1. Settare `hurst_max: 0.55` nei params delle fade in `_defs.py` (sleeve live) + aggiornare i params
fade del backtest (`combine_portfolio`/`report_families`) per PARITÀ + rigenerare il
regression-lock PORT06 (i numeri canonici cambiano: DD 4,9→~2,4%).
2. Verificare che il rolling-Hurst live nel worker coincida col backtest (stessa finestra 100,
stesso stepping causale).
3. Rebuild immagine Docker (`up -d --build`, non restart) + verifica RESUME.
Default attuale: `hurst_max` OFF → zero impatto su backtest/parità/live finché non lo si attiva
esplicitamente. Il SISTEMA è trovato e validato; l'attivazione è una decisione di deploy.
@@ -0,0 +1,89 @@
# 2026-06-02 — Ricerca a 100 agenti: Frattali del segnale × Regime ARGO
> Workflow multi-agente (171 agenti, 8,4M token, ~7h) per cercare una strategia che combini
> un SEGNALE FRATTALE con un GATE/INTERAZIONE DI REGIME ARGO (DVOL/funding/VRP), validata OOS.
> Branch: `feat/fractal-argo-search`. Substrato e codice sul branch, niente impatto su main/live.
## Substrato costruito
- `regime_fetcher.py`: DVOL (2021-03→oggi) + funding (2019→oggi) BTC/ETH da **Deribit mainnet
public** (no-auth, OI/IV reali — non il testnet farlocco di Cerbero).
- `regime_lab.py`: allineamento regime↔prezzo **causale no-look-ahead** (merge_asof backward),
feature regime (dvol_pct, **vrp=dvolrv**, funding_z, dvol_chg) + frattali (rolling Hurst,
Higuchi FD, vol-ratio, Williams pivot), cache feature precalcolate, validazione netto-fee OOS
via `explore_lab`. Bug corretto in corsa: `vrp` annualizzava la realized-vol sempre come 1h →
rotta su 4h/1d (sempre negativa); fix per timeframe.
- `fractal_argo_workflow.js`: 84 agenti griglia (7 famiglie frattali × 6 angoli regime × BTC/ETH)
+ 8 wildcard + verifica avversariale dei survivor + sintesi.
## Verdetto
**Esistono edge frattale×regime reali, causali, robusti** (15 confermati dalla batteria
avversariale: audit look-ahead bit-esatto, cross-asset, split alternativo, fee 0.2% RT, plateau).
**MA nessuno batte PORT06 standalone** (OOS Sharpe 8,19 / DD 2,3%): sono **diversificatori a
bassa esposizione** (1,5-8%, ~100-460 trade), profilo SH01/pairs.
### Top candidati confermati
| Strategia | Asset/TF | OOS Sharpe | OOS DD | trade |
|-----------|----------|:--:|:--:|:--:|
| FRAC-VRP multiscala (chop × VRP<0) | ETH 1h | 5,55 | 11% | 184 |
| HigVRP-Fade (Higuchi alto × VRP<0) | BTC 1h | 4,55 | 7,2% | 286 |
| **HurstCalmFade (hurst<0,55 × dvol<0,4)** | BTC 1h | **3,73** | 5,1% | 198 |
| WILD8 Pivot-Hurst | BTC 4h | 3,87 | 10% | 482 |
| AnalogFundingFade (kNN forma × funding) | ETH 4h | 2,15 | 9,3% | 229 |
## Il finding chiave (controintuitivo): il prior ARGO è SMENTITO
La tesi naïve **"VRP>0 = GEX+ = range = fade" FALLISCE** sistematicamente. L'edge robusto e
ripetuto è su **VRP<0** (vol implicita *sottoprezzata* vs realizzata → mean-reversion whippy) e su
**DVOL bassa**, l'opposto del brief. Gate invertito VRP>0 → Sharpe 2,08/1,30 su entrambi gli
asset. È il risultato più solido di tutta la ricerca, look-ahead-clean (lag-1/3/6 robusto).
## Cosa aggiunge valore vs cosa è decorativo
- **Load-bearing (confermato per ablazione):** VRP<0; hurst-low × dvol-low (HurstCalmFade taglia
DD OOS 17%→5%); funding_z estremo |fz|≥1,8 (analog ETH 4h).
- **Decorativo (DD-reducer, non interazione):** dvol_chg, dvol_high/low e funding come gate spesso
riducono solo esposizione senza migliorare il segno.
## Cosa è RUMORE (conferma i priori del progetto)
- Frattali da soli (angolo=none): hurst/Higuchi/vratio/chop/candle/analog intraday → non robusti
(DD 30-90%, muoiono di fee). Conferma `shape_lab`.
- Momentum/breakout gateato (hurst>0,6, dvol-rising): catastrofico (Sharpe 2…−7) → riconferma
dominanza mean-reversion, i breakout rientrano.
- ARGO-GEX nella direzione attesa (VRP>0): perde. Coerente con W18/19/21 scartate.
- Pivot-fade non-laggato (frac_up[i]): artefatto squeeze-like, va sempre laggato a i2.
## Vincitore selezionato + test decisivo
**FR01 HurstCalmFade BTC 1h** (`scripts/strategies/FR01_hurst_calm_fade.py`): il più verificato,
DD più basso (5,1% OOS), generalizza a ETH. **Test di correlazione decisivo** (la domanda che
conta: aggiungerlo migliora il PORT06 o è ridondante?): correlazione daily-returns coi fade
esistenti **MR01 +0,17 / MR02 +0,08 / MR07 0,03****BASSA, quasi-ortogonale**, NON ridondante.
Passa il gate → vale l'inserimento come diversificatore.
## Onestà finale
L'edge frattale×regime è **reale, causale, robusto** ma è sempre **mean-reversion già nota
condizionata dal regime (VRP<0 / hurst-low / dvol-low)**, non un motore ortogonale nuovo. Valore =
**riduzione DD aggregato come sleeve a bassa esposizione**. La correlazione bassa lo qualifica come
diversificatore reale.
## TEST DECISIVO SUL MASTER — VERDETTO FINALE: NON deployare
Misurato il contributo marginale di FR01 al PORT06 intero (equal-weight, `master_corr`):
| Portafoglio | FULL Sharpe | OOS Sharpe | OOS DD | OOS ret |
|-------------|:--:|:--:|:--:|:--:|
| PORT06 (17 sleeve) | 6,62 | **8,89** | 1,2% | +175% |
| PORT06 + FR01 (19) | 6,55 | **8,72** | 1,1% | +156% |
**FR01 NON migliora il PORT06: lo DILUISCE** (OOS Sharpe 8,89→8,72, OOS ret +175%→+156%; DD
marginalmente meglio 1,2→1,1% ma a costo di Sharpe). Corr FR01 vs MASTER +0,18 (BTC)/+0,23 (ETH).
**Causa + nota di onestà metrica:** lo Sharpe "3,73" dei report del workflow è **per-trade/annuale**
(`explore_lab`); quello rilevante per il portafoglio è lo **Sharpe daily-return** (`combine`), che per
FR01 è solo **~1,85/1,53** — troppo basso per muovere un PORT06 a 8,89. È "ridondanza robusta":
mean-reversion regime-gated che si sovrappone a ciò che il MASTER già fa.
**ESITO: il search a 100 agenti ha trovato strategie robuste e causali, ma NESSUNA migliora il
PORT06.** Non deployare FR01 né i candidati gemelli. Valore del progetto resta nell'estendere
fade/pairs validati. Tutto resta come RECORD DI RICERCA sul branch (non si merge in produzione).
Wiring DVOL live e walk-forward: non necessari, deploy abbandonato.
+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.
@@ -0,0 +1,67 @@
# Diario — 2026-06-05 — Fix EXIT-16 live: confirm sul close di barra COMPLETATA + alert STALE_FEED
## Innesco
Crash ETH 2026-06-05: gli stop MR01/MR02 ETH sono scattati molto sotto il livello
nominale (MR02: sl 1636.28 → exit 1600.45; MR01: sl 1610.75 → exit 1594.35).
Analisi delle due concause dichiarate:
1. **EXIT-16 close-confirm** (by design): uscita al close oltre `sl ∓ 0.5·ATR14`,
non al livello — costo accettato e MODELLATO nel backtest validato (7.84/10.06).
2. **Feed gappato**: ETH testnet flat ~2h (1656.80/1655.55, 13:0014:50 UTC), poi
una candela 14:55 da 1634 a 1600 — nessun prezzo esisteva tra SL ed exit. I fill
REALI confermano (1600.2 / 1589.7, slippage 1.6/29 bps): non è un bug, è il mercato.
## Scoperta: divergenza implementativa REALE nel worker
Audit dei 3 stop ETH del 2026-06-04/05 contro le candele:
| Stop live | Close barra COMPLETATA al momento | Confirm | Verdetto |
|---|---|---|---|
| MR02 06:03 @1672.9 | 1682.25 (05:00) | ~1675.4 | **wick-stop**: il backtest NON avrebbe stoppato lì |
| MR02 15:00 @1600.45 | 1600.05 (14:00) | ~1621 | legittimo, fill ≈ backtest |
| MR01 15:01 @1594.35 | 1600.05 (14:00) | ~1594.4 | **wick-stop**: il backtest NON avrebbe stoppato lì |
**2 su 3 erano valutazioni sulla barra IN FORMAZIONE**: `tick` confrontava
`current_price` (close istantaneo della candela in corso al poll) con `sl buf`.
Il backtest (`fade_base`) valuta il confirm SOLO sul close di barra completata
`c[j]`. La differenza reintroduce live esattamente la wick-sensitivity che EXIT-16
elimina: un dip infrabarra sotto il confirm stoppava anche se la barra chiudeva
sopra (e nei dip-che-rientrano la fade vince — è il suo edge). Live sarebbe stato
strutturalmente PIÙ stop-prone del backtest validato.
## Fix (`strategy_worker.py`, ramo sl_confirm_atr)
- Il confirm è valutato sul **close dell'ultima barra COMPLETATA**: l'ultima riga
del df è la candela in corso finché non è trascorsa la sua durata
(`now_ms < ts[-1] + bar_ms`, `bar_ms` = mediana dei delta timestamp → regge 1h/4h/1d).
- Il `buf` usa l'ATR14 alla stessa barra completata (prima: barra in formazione).
- Il **fill resta al prezzo corrente** al poll (lag ≈ stress `lag_close_exit`,
superato in exit-lab). TP intrabar sulla barra in corso INVARIATO (= backtest).
- Test nuovi in `test_close_confirm_sl.py` (sezione FIX 2026-06-05): il dip della
candela in corso non stoppa; il breach della completata stoppa anche se rimbalzato;
TP intrabar invariato. NB fixture: timestamp in ms ESPLICITI
(`Timestamp.now()` è risoluzione µs → `astype int64 // 10**6` darebbe secondi).
## Concausa feed: entry-guard TESTATA e BOCCIATA, solo osservabilità
Quantificato (1h, storico completo): segnali fade subito dopo barre flat = BTC
0.20.9%, **ETH 3.411.8%**. Backtest con skip di quei segnali (config live,
sl_confirm 0.5): **PEGGIORA tutti gli sleeve ETH** (MR01 +1344→+1256, MR02
+2372→+2250, MR07 +740→+621; BTC ~invariato). La candela-gap post-flat è
l'overshoot che la fade fada con profitto — e i fill reali post-crash lo
confermano (riaperture a 1.9 bps di slippage). **Niente skip degli ingressi.**
Aggiunto invece **alert Telegram `STALE_FEED`** (`runner._check_stale_feed`):
notifica quando un asset ha ≥2 barre 1h complete flat (worker ciechi) e al
risveglio col **gap %** del primo prezzo reale. Una notifica per episodio
(dedup per asset). Solo osservabilità, zero effetto sulle decisioni.
## Cosa NON è stato toccato (e perché)
- Il **costo del confirm** (uscita al close, non al livello nominale) resta: è il
trade-off validato di EXIT-16 (FULL 6.47→7.84, OOS 8.82→10.06) — i wick-stop
evitati pagano molto più dei close-stop più profondi.
- Il **gap-through** non è mitigabile lato exit: nessun prezzo esisteva tra SL ed
exit (fill reali ≈ sim). L'unica protezione strutturale resta la diversificazione
(17 sleeve) + i cap di famiglia.
+99
View File
@@ -0,0 +1,99 @@
# Diario — 2026-06-05 — SL su SH01: ricerca multi-agente (verdetto: NO-SL, cap del peso)
## Innesco
Crash ETH del 2026-06-05 (1742→1546, 11%, con feed testnet FLAT ~2h e gap 1655→1600):
SH01 ETH si è preso la coda intera, **15.6% in un trade** (long 1727.8 → time_limit 1594.35,
leva 2x) — il singolo trade peggiore del portafoglio live. SH01 non ha TP/SL per design
(exit solo a orizzonte H=12). Domanda: esiste uno SL che taglia le code senza distruggere
l'edge (win ~50%, edge tutto nell'asimmetria dei winner)?
## Numeri storici per anno (config live W24 H12 th0.58, netto fee 0.10% RT, leva 3x)
Somma per-trade in pp (leva 3x); equity-level ≈ ×0.15.
| Anno | BTC | ETH |
|---|---:|---:|
| 2018 | 65.8 | +73.7 |
| 2019 | +87.7 | 19.3 |
| 2020 | +194.0 | **293.1** |
| 2021 | +301.3 | +67.4 |
| 2022 | +64.3 | +79.1 |
| 2023 | +17.4 | +20.6 |
| 2024 | +110.0 | +108.1 |
| 2025 | +76.8 | +539.7 |
| 2026 | +59.0 | 29.5 |
BTC = motore robusto (8/9 anni+). ETH = fragile (DD 61%, win 48.8%): la coda non
protetta è un problema STRUTTURALE di ETH, non solo dell'episodio live.
## Infrastruttura (riusabile)
`scripts/analysis/sh01_exit_lab.py` — harness onesto stile exit-lab:
- cache segnali walk-forward (`data/cache/sh01_exit_lab.pkl`, 7248 BTC + 7386 ETH entries);
- engine intrabar con **fill GAP-AWARE**: stop fillato a `worse(livello, open[j])`, non
"al livello" — chiude il bias PRO-stop-stretti documentato il 2026-06-04 (54% di fill
ottimisti) e modella il caso reale del crash (gap 1655→1600 senza scambi intermedi);
- modalità stop `intrabar` e `close` (close-confirm stile EXIT-16), `after_bar` per
policy discrezionali, stress `lag_close_exit` (poll in ritardo);
- parity ESATTA con `explore_lab.simulate` (BTC +218.50%/1531 trade, ETH +80.13%/1257);
- protocollo: TRAIN fino 2023-11-01 (selezione SOLO lì, plateau ≥3 celle), OOS una volta.
Baseline (orizzonte puro): BTC TRAIN +127%/dd23/shrp2.09/worst5.5 | OOS +41%/8/2.18/3.1.
ETH TRAIN **26%/dd61/shrp0.16/worst14.9** | OOS +143%/7/**3.60**/4.6.
## Esito: 11 famiglie testate, 0 sopravvissute
Policy in `scripts/analysis/sh01_exit_policies/` (01-10 dagli agenti + 11 dal
completeness-probe della sintesi):
| # | Famiglia | Verdetto |
|---|---|---|
| 01 | ATR fisso intrabar (k 15) | NO: 0/7 celle migliorative sul train |
| 02 | ATR fisso close-confirm (k 15) | NO: k=4 migliora ETH train (shrp 0.16→+0.62, DD 61→41, worst 8.4) MA ETH-OOS peggiora (shrp 3.60→2.32, worst 6.6) |
| 03 | % fisso (15%, 2 modi) | NO: BTC↓, ETH-OOS ret 52% del baseline |
| 04 | Chandelier trailing | NO: cella isolata; ETH-OOS tutto peggio (4ª bocciatura della famiglia trailing) |
| 05 | Breakeven ratchet | NO: 0 celle migliorative |
| 06 | Giveback (profit-protection) | NO: dormiente sulle code (worst invariato), taglia winner BTC |
| 07 | Loser time-stop (m,x) | NO: ETH-OOS shrp↓ DD↑, BTC↓ |
| 08 | Disaster-cap LARGO close-confirm (k 36) | NO: k=4 plateau sul train ma ETH-OOS DD 7→11-15%, ret 59-74%; FABBRICA short da stop a 44/54% |
| 09 | Swing strutturale | NO: 0/18 celle |
| 10 | Stop condizionale vol-regime | NO: il migliore dei falliti (ETH-OOS shrp 3.28 vs 3.60) ma BTC train <95% |
| 11 | Disaster-cap LARGO **intrabar** (k 512) | NO: k=10 cella isolata; BTC-OOS shrp 2.18→**0.92**, worst 3.1→−11.4 (il fill gap-aware rende lo stop più tossico del no-stop) |
**Pattern (5ª conferma della lezione exit-lab):** ogni stop abbastanza stretto da toccare
la coda ETH intacca il motore BTC; ogni stop abbastanza largo da risparmiare BTC non
arriva mai alla coda ETH. Per un segnale win~50% con edge nell'asimmetria dei winner,
OGNI SL taglia i winner-in-drawdown insieme ai loser. In più il fill gap-aware mostra
che proprio nei crash (quando lo stop servirebbe) il fill è al gap, non al livello:
lo stop intrabar largo PEGGIORA la coda OOS di BTC invece di proteggerla.
## Decisione
- **NO SL su SH01** (né `sl``sl_confirm_atr` per SH_BTC/SH_ETH: corretto E sicuro —
il fallback 2% del worker si applica solo se `sl` è presente).
- **La leva giusta è il PESO**: cap della famiglia SHAPE nel weighting di PORT06
(`weighting.py` supporta già `caps` per famiglia, come PAIRS 0.33). Dimezzare la quota
SHAPE ≈ dimezza l'impatto della prossima coda 15% sul conto, a costo ~nullo di Sharpe
(SH01 è un diversificatore, corr +0.08 col MASTER).
- **APPLICATO E DEPLOYATO (stesso giorno):** `caps={"PAIRS": 0.33, "SHAPE": 0.0588}` in
`_defs.py` (SHAPE 11.8%→5.9%, ≈ mezzo sleeve equal; SH_BTC=SH_ETH=0.0294, somma pesi 1.0,
verificato). Quantificato sul backtest PORT06:
| Config | FULL Sharpe | FULL DD | CAGR | OOS Sharpe | OOS DD |
|---|---:|---:|---:|---:|---:|
| precedente (SHAPE 11.8%) | 6.47 | 4.10% | 61.2% | 8.82 | 1.30% |
| **cap SHAPE 5.9% (scelto)** | 6.43 | 3.96% | 62.1% | 8.58 | 1.36% |
| SHAPE rimosso | 6.30 | 3.88% | 63.0% | 8.26 | 1.41% |
Il costo (0.24 OOS Sharpe) è il premio dell'assicurazione su una coda che il backtest
daily NON modella (gap di feed, flat testnet). Rimuovere SHAPE costa troppo (8.26).
## Direzioni future (fuori scope SL, annotate dalla sintesi)
1. **Liquidity/staleness-gate sull'ENTRY** (skip ingressi dopo finestre di feed flat —
il flat 2h ha preceduto il gap del 2026-06-05): la direzione più promettente per il
gap-through specifico, merita uno studio dedicato.
2. Vol-target sizing per-trade su SH01 (sulle fade fallì, su SH01 mai testato).
3. SL solo-ETH: scartato anche concettualmente (il beneficio coda ETH è marginale/nullo
OOS in TUTTE le famiglie — non è un problema di uniformità del meccanismo).
@@ -0,0 +1,377 @@
# Fase 2-B — Worker live honest/TSM01 (dedicati) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development o executing-plans. Steps con checkbox `- [ ]`.
**Goal:** Costruire i worker live mancanti perché PORT06 giri live al completo (oltre a fade+pairs+shape già pronti): DIP01, TR01 (basket), ROT02 (rotation), TSM01 (tsmom rotation), e integrarli nel `PortfolioRunner`.
**Architecture:** Worker DEDICATI per ogni strategia (scelta utente). DIP01 è single-asset → Strategy subclass + `StrategyWorker` esistente. TR01/ROT02/TSM01 sono multi-asset/rotation → tre classi worker nuove in `src/live/` con stato per-asset persistente, ciascuna fedele alla rispettiva funzione di backtest in `scripts/analysis/{honest_improve2,tsmom_research}.py`. Integrazione in `src/portfolio/runner.py::build_worker_for` + tick.
**Tech Stack:** Python 3.11, pandas/numpy, pytest. Riusa CerberoClient v2 (multi-asset fetch), PortfolioLedger, e le funzioni di riferimento honest/tsm.
**Branch:** `portfolio_phase2`. **Spec madre:** `docs/superpowers/specs/2026-05-29-portfolios-design.md` (§ scope live, fase 2).
**Riferimenti di logica (NON modificare, sono la verità del backtest):**
- DIP01 → `honest_improve2.dip_market_gated` (z-score dip, gate BTC>SMA, TP=SMA/SL=ATR/max_bars, intrabar).
- TR01 → `honest_improve2._tr_basket_daily` (per asset 4h: EMA20>EMA100 long/flat; basket equal-weight).
- ROT02 → `honest_improve2._rot_daily_equity` (panel 1d, mom 60g, top-3 se mom>0 e BTC>SMA100, gross 0.45 split, ribilancio giornaliero).
- TSM01 → `tsmom_research.tsmom_sim` (panel 1d, Σ sign(P/P[-h]) h∈{63,126,252} ≥ thr=1.0, gate BTC>SMA100, gross 0.30 split).
---
## File structure
| File | Responsabilità |
|------|----------------|
| `scripts/strategies/DIP01_dip_buy.py` | Strategy `Dip01DipBuy` (single-asset; metadata tp/sl/max_bars + gate) |
| `src/live/basket_trend_worker.py` | `BasketTrendWorker` (TR01): N asset 4h, EMA cross, long/flat per asset |
| `src/live/rotation_worker.py` | `RotationWorker` (ROT02): panel 1d, dual-momentum top-k, gross split |
| `src/live/tsmom_worker.py` | `TsmomWorker` (TSM01): panel 1d, consenso segni multi-orizzonte |
| `src/live/strategy_loader.py` | **mod**: aggiungi `DIP01_dip_buy` a MODULE_MAP |
| `src/portfolio/runner.py` | **mod**: `build_worker_for` gestisce kind "basket"/"rotation"/"tsmom"; tick multi-asset |
| `src/portfolio/base.py` (`_defs.py`) | **mod**: SleeveSpec degli honest/tsm con `kind` e `universe` corretti |
| `tests/portfolio/test_honest_workers.py` | unit per ciascun worker + replay==backtest su finestra |
**Universi:** TR01 = [BNB,BTC,DOGE,SOL,XRP] (4h); ROT02/TSM01 = `available_assets()` (1d). I worker multi-asset ricevono il dict {asset: df} dal runner.
---
## Task 1: DIP01 come Strategy single-asset
**Files:** Create `scripts/strategies/DIP01_dip_buy.py`; Modify `src/live/strategy_loader.py`; Test `tests/portfolio/test_dip01.py`.
- [ ] **Step 1: Test (fallisce)**`tests/portfolio/test_dip01.py`:
```python
import pandas as pd
from src.data.downloader import load_data
from scripts.strategies.DIP01_dip_buy import Dip01DipBuy
def test_dip01_generates_long_signals_with_exits():
df = load_data("BTC", "1h").iloc[-5000:].reset_index(drop=True)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = Dip01DipBuy().generate_signals(df, ts, asset="BTC", tf="1h")
assert len(sigs) > 0
s = sigs[0]
assert s.direction == 1 # dip-buy è solo long
assert {"tp", "sl", "max_bars"} <= set(s.metadata)
```
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_dip01.py -v` → FAIL (ModuleNotFoundError).
- [ ] **Step 3: Implementa `scripts/strategies/DIP01_dip_buy.py`.** Replica ESATTA della logica di `dip_market_gated` (default `market_n=0` = senza gate, come lo sleeve DIP01_BTC del portafoglio: vedi combine_portfolio che usa `market_n=0`). Genera Signal long quando `z[i] <= -z_in and z[i-1] > -z_in`, con metadata `tp=SMA[i]`, `sl=c[i]-sl_atr*atr[i]`, `max_bars`. fee_rt=0.001, leverage 3, position 0.15.
```python
"""DIP01 — Dip-buy mean-reversion single-asset (z-score sotto-banda). Honest family.
Replica live della logica validata in scripts/analysis/honest_improve2.dip_market_gated
(con market_n=0, come lo sleeve DIP01_BTC del portafoglio): compra quando lo z-score del
prezzo rispetto a SMA(n) incrocia sotto -z_in; esce a TP=SMA, SL=close-sl_atr*ATR, o max_bars.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.strategies.base import Strategy, Signal # noqa: E402
def _atr(df, n=14):
h, l, c = df["high"].values, df["low"].values, df["close"].values
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
return pd.Series(tr).rolling(n).mean().values
class Dip01DipBuy(Strategy):
name = "DIP01_dip_buy"
description = "Dip-buy mean-reversion single-asset (z-score), exit TP=SMA/SL=ATR/max_bars"
default_assets = ["BTC"]
default_timeframes = ["1h"]
fee_rt = 0.001
leverage = 3.0
position_size = 0.15
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
n: int = 50, z_in: float = 2.5, sl_atr: float = 2.5,
max_bars: int = 24, **params) -> list[Signal]:
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = _atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
out: list[Signal] = []
for i in range(n + 14, len(c)):
if np.isnan(z[i]) or np.isnan(a[i]) or np.isnan(ma[i]):
continue
if z[i] <= -z_in and z[i - 1] > -z_in:
out.append(Signal(idx=i, direction=1, entry_price=float(c[i]),
metadata={"tp": float(ma[i]),
"sl": float(c[i] - sl_atr * a[i]),
"max_bars": int(max_bars)}))
return out
```
- [ ] **Step 4: Registra nel loader.** In `src/live/strategy_loader.py` MODULE_MAP aggiungi:
```python
"DIP01_dip_buy": ("DIP01_dip_buy", "Dip01DipBuy"),
```
- [ ] **Step 5:** `uv run pytest tests/portfolio/test_dip01.py -v` → 1 passed.
- [ ] **Step 6: Commit**
```bash
git add scripts/strategies/DIP01_dip_buy.py src/live/strategy_loader.py tests/portfolio/test_dip01.py
git commit -m "feat(live): DIP01 dip-buy come Strategy single-asset (worker via StrategyWorker)"
```
**Nota:** DIP01 nel runner usa lo StrategyWorker esistente (kind="single", name="DIP01"). Aggiorna `_STRAT_MODULE` in `runner.py` con `"DIP01": "DIP01_dip_buy"` e in `_defs.py` lo SleeveSpec DIP01_BTC resta kind="single". Il backtest dello sleeve DIP01_BTC continua a venire da `build_everything` (parità invariata).
---
## Task 2: `BasketTrendWorker` (TR01)
**Files:** Create `src/live/basket_trend_worker.py`; Test `tests/portfolio/test_basket_worker.py`.
- [ ] **Step 1: Test (fallisce)** — verifica che, dato un dict {asset: df 4h}, il worker calcoli posizione long/flat per asset secondo EMA20>EMA100 e aggiorni il capitale equal-weight:
```python
import numpy as np
import pandas as pd
from src.live.basket_trend_worker import BasketTrendWorker
def _ramp_df(n=300, slope=1.0):
c = np.linspace(100, 100 + slope * n, n)
ts = (pd.date_range("2024-01-01", periods=n, freq="4h", tz="UTC").astype("int64") // 10**6)
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
def test_basket_goes_long_in_uptrend(tmp_path):
w = BasketTrendWorker(universe=["AAA", "BBB"], tf="4h", capital=1000.0, data_dir=tmp_path)
data = {"AAA": _ramp_df(slope=1.0), "BBB": _ramp_df(slope=1.0)}
w.tick(data)
assert w.positions["AAA"] == 1.0 and w.positions["BBB"] == 1.0 # EMA20>EMA100 in salita
```
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_basket_worker.py -v` → FAIL.
- [ ] **Step 3: Implementa `src/live/basket_trend_worker.py`.** Stato: capitale totale + dict `positions` (asset→0/1) + persistenza. `tick(data: dict[str,df])`: per ogni asset calcola EMA20/EMA100 sull'ultima barra; target = 1.0 se ef>es else 0.0; applica fee `FEE_RT/2*LEV` sul turnover |Δpos|; aggiorna capitale equal-weight col rendimento di barra di ogni asset attivo (`POS*LEV*ret*pos/len(universe)`... mantieni la convenzione di `_tr_basket_daily`: ogni asset è uno sleeve normalizzato, equal-weight → applica `mean` dei rendimenti per-asset). Persisti `status.json` (capitale, positions, last_bar_ts per asset) e logga `trades.jsonl`. fee_rt=0.001, leverage 3, position 0.15.
```python
"""BasketTrendWorker (TR01): EMA20>EMA100 long/flat su un paniere, equal-weight.
Replica live di honest_improve2._tr_basket_daily."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
FEE_RT, LEV, POS = 0.001, 3.0, 0.15
def _ema(x, n):
return pd.Series(x).ewm(span=n, adjust=False).mean().values
class BasketTrendWorker:
def __init__(self, universe, tf="4h", capital=1000.0, position_size=POS,
leverage=LEV, fee_rt=FEE_RT, name="TR01_basket",
data_dir=Path("data/portfolio_paper")):
self.universe = list(universe)
self.tf = tf
self.initial_capital = capital
self.capital = capital
self.position_size = position_size
self.leverage = leverage
self.fee_rt = fee_rt
self.worker_id = f"{name}__{'-'.join(self.universe)}__{tf}"
self.work_dir = Path(data_dir) / self.worker_id
self.work_dir.mkdir(parents=True, exist_ok=True)
self.status_path = self.work_dir / "status.json"
self.trades_path = self.work_dir / "trades.jsonl"
self.positions = {a: 0.0 for a in self.universe}
self.last_bar_ts = {a: 0 for a in self.universe}
self.in_position = False # per il ribilancio del runner (skip se True)
self._load()
def _load(self):
if self.status_path.exists():
s = json.loads(self.status_path.read_text())
self.capital = s.get("capital", self.capital)
self.positions = {**self.positions, **s.get("positions", {})}
self.last_bar_ts = {**self.last_bar_ts, **s.get("last_bar_ts", {})}
self.in_position = any(v > 0 for v in self.positions.values())
def _save(self):
self.status_path.write_text(json.dumps({
"capital": round(self.capital, 2), "positions": self.positions,
"last_bar_ts": self.last_bar_ts,
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
def tick(self, data: dict):
rets = []
for a in self.universe:
df = data.get(a)
if df is None or len(df) < 110:
continue
c = df["close"].values
ef, es = _ema(c, 20)[-1], _ema(c, 100)[-1]
target = 1.0 if ef > es else 0.0
bar_ts = int(df["timestamp"].iloc[-1])
prev = self.positions[a]
# rendimento di barra realizzato sulla posizione precedente (chiusa->aperta barra)
if self.last_bar_ts[a] and bar_ts > self.last_bar_ts[a] and prev > 0:
r = (c[-1] - c[-2]) / c[-2]
rets.append(self.position_size * self.leverage * r * prev)
if target != prev:
self.capital -= self.capital * self.position_size * (self.fee_rt / 2) * abs(target - prev) / len(self.universe)
self._log(a, prev, target, float(c[-1]))
self.positions[a] = target
self.last_bar_ts[a] = bar_ts
if rets:
self.capital = max(self.capital * (1 + float(np.mean(rets))), 10.0)
self.in_position = any(v > 0 for v in self.positions.values())
self._save()
def _log(self, asset, frm, to, price):
with open(self.trades_path, "a") as f:
f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(),
"asset": asset, "from": frm, "to": to,
"price": round(price, 6), "capital": round(self.capital, 2)}) + "\n")
@property
def status_summary(self):
longs = [a for a, v in self.positions.items() if v > 0]
return f"{self.worker_id}: cap={self.capital:.0f} long={longs}"
```
- [ ] **Step 4:** `uv run pytest tests/portfolio/test_basket_worker.py -v` → 1 passed.
- [ ] **Step 5: Commit**
```bash
git add src/live/basket_trend_worker.py tests/portfolio/test_basket_worker.py
git commit -m "feat(live): BasketTrendWorker (TR01) EMA-cross long/flat multi-asset"
```
---
## Task 3: `RotationWorker` (ROT02)
**Files:** Create `src/live/rotation_worker.py`; Test `tests/portfolio/test_rotation_worker.py`.
- [ ] **Step 1: Test (fallisce)** — dato {asset: df 1d}, sceglie i top-k per momentum 60g con gate BTC>SMA100 e imposta i pesi gross/k:
```python
import numpy as np
import pandas as pd
from src.live.rotation_worker import RotationWorker
def _df(n=200, slope=1.0):
c = np.linspace(100, 100 + slope * n, n)
ts = (pd.date_range("2023-01-01", periods=n, freq="1D", tz="UTC").astype("int64") // 10**6)
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
def test_rotation_picks_top_momentum_when_risk_on(tmp_path):
w = RotationWorker(universe=["BTC", "AAA", "BBB"], top_k=2, gross=0.45, data_dir=tmp_path)
data = {"BTC": _df(slope=1.0), "AAA": _df(slope=3.0), "BBB": _df(slope=0.1)}
w.tick(data)
# BTC in uptrend -> risk_on; top-2 momentum = AAA e BTC; pesi gross/2
assert w.weights["AAA"] > 0 and abs(sum(w.weights.values()) - 0.45) < 1e-9
```
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_rotation_worker.py -v` → FAIL.
- [ ] **Step 3: Implementa `src/live/rotation_worker.py`.** Replica di `_rot_daily_equity`: panel di close 1d allineato; `risk_on = BTC[-1] > SMA100(BTC)[-1]`; `mom = P[-1]/P[-61]-1`; `chosen = [top_k per mom con mom>0] se risk_on else []`; pesi `gross/len(chosen)`; turnover fee `FEE_RT/2 * Σ|Δw|`; capitale aggiornato col rendimento di portafoglio del giorno successivo (live: al tick si realizza il rendimento dell'ultima barra sui pesi correnti, poi si ricalcolano i pesi). Persisti capitale+weights+last_ts. `in_position = bool(weights)`.
(Implementazione analoga a BasketTrendWorker: stato persistente, `tick(data)` allinea i panel per timestamp comune, calcola momentum/gate, applica fee sul turnover e rendimento di barra. Mantieni `top_k=3, gross=0.45` come default — i valori dello sleeve ROT02_rot del portafoglio.)
- [ ] **Step 4:** test → 1 passed.
- [ ] **Step 5: Commit**
```bash
git add src/live/rotation_worker.py tests/portfolio/test_rotation_worker.py
git commit -m "feat(live): RotationWorker (ROT02) dual-momentum top-k risk-gated"
```
---
## Task 4: `TsmomWorker` (TSM01)
**Files:** Create `src/live/tsmom_worker.py`; Test `tests/portfolio/test_tsmom_worker.py`.
- [ ] **Step 1: Test (fallisce)** — consenso segni multi-orizzonte: sceglie gli asset con `Σ sign(P/P[-h]) ≥ thr` (h∈{63,126,252}) sotto gate, pesi gross/k.
- [ ] **Step 2-3: Implementa `src/live/tsmom_worker.py`** replicando `tsmom_sim`: `score[j] = mean_h sign(P[-1,j]/P[-1-h,j]-1)`; `chosen = [j: score>=thr] se risk_on`; pesi `gross/len(chosen)` con `gross=0.30`. Stessa struttura di RotationWorker (panel 1d, fee turnover, rendimento di barra, persistenza). Default `horizons=(63,126,252), thr=1.0, regime_n=100, gross=0.30`.
- [ ] **Step 4:** test → passed.
- [ ] **Step 5: Commit**
```bash
git add src/live/tsmom_worker.py tests/portfolio/test_tsmom_worker.py
git commit -m "feat(live): TsmomWorker (TSM01) consenso TSMOM multi-orizzonte risk-gated"
```
---
## Task 5: Integrazione nel PortfolioRunner
**Files:** Modify `src/portfolio/runner.py`, `scripts/portfolios/_defs.py`, `src/portfolio/base.py`; Test `tests/portfolio/test_runner_honest.py`.
- [ ] **Step 1:** In `_defs.py`, marca gli SleeveSpec multi-asset col `kind` giusto e l'universo:
- DIP01 → `kind="single", name="DIP01"` (resta StrategyWorker via _STRAT_MODULE["DIP01"]="DIP01_dip_buy").
- TR01 → `kind="basket"`, aggiungi campo universo (riusa `params={"universe": ["BNB","BTC","DOGE","SOL","XRP"], "tf": "4h"}`).
- ROT02 → `kind="rotation"`, `params={"top_k":3, "gross":0.45, "tf":"1d"}`.
- TSM01 → `kind="tsmom"`, `params={"horizons":[63,126,252], "thr":1.0, "gross":0.30, "tf":"1d"}`.
(Aggiungi `universe`/campi a SleeveSpec se serve, default None.)
- [ ] **Step 2:** In `runner.py::build_worker_for` aggiungi i rami `kind in ("basket","rotation","tsmom")` che costruiscono i rispettivi worker con `capital=alloc_capital` e `data_dir=DATA_DIR`. Aggiorna `_STRAT_MODULE` con `"DIP01": "DIP01_dip_buy"`. Rimuovi DIP01/TR01/ROT02/TSM01 dalla lista "saltati": ora sono supportati.
- [ ] **Step 3:** In `runner.run()` il tick deve passare ai worker multi-asset un dict {asset: df} (fetch di tutti gli asset dell'universo). Estendi la raccolta `keys` e il dispatch del tick: per kind basket/rotation/tsmom costruisci `data = {a: cache[(a, tf)] for a in universe}` e chiama `w.tick(data)`. Per `_worker_equity` i nuovi worker espongono `.capital` (già ok). Per il ribilancio, espongono `.in_position` (skip se True).
- [ ] **Step 4: Test** `tests/portfolio/test_runner_honest.py`: `build_worker_for` ritorna il tipo giusto per ogni kind con capitale = alloc; e `run()` con PORT06 non lascia più sleeve "saltati" (mocka il fetch o testa solo build).
- [ ] **Step 5:** `uv run pytest tests/portfolio/ -m "not network" -v` → tutti verdi.
- [ ] **Step 6: Commit**
```bash
git add src/portfolio/runner.py scripts/portfolios/_defs.py src/portfolio/base.py tests/portfolio/test_runner_honest.py
git commit -m "feat(portfolio): integra worker honest/TSM01 nel runner (PORT06 live completo)"
```
---
## Task 6: Validazione replay==backtest per i worker multi-asset
**Files:** Modify `scripts/analysis/validate_portfolio_runner.py` (o nuovo `validate_honest_workers.py`).
- [ ] **Step 1:** Per ogni worker multi-asset, replay bar-by-bar su dati storici (load_data) e confronto dell'equity finale con la funzione di riferimento (`_tr_basket_daily`, `_rot_daily_equity`, `tsmom_sim`) entro tolleranza. ROT02/TSM01 sono daily → replay veloce (poche migliaia di barre). TR01 4h → medio. Atteso: match stretto (differenze solo da bar-timing/cadenza). DIP01 ha il gap intrabar noto come le fade (documenta, non assert esatto).
- [ ] **Step 2: Commit**
```bash
git add scripts/analysis/validate_honest_workers.py
git commit -m "test(portfolio): replay worker honest/TSM01 == backtest di riferimento"
```
---
## Self-review
- **Copertura:** i 4 worker (DIP01 single via Strategy; TR01/ROT02/TSM01 dedicati) + integrazione runner + validazione → PORT06 gira live completo (niente più sleeve saltati).
- **Parità backtest:** invariata (gli sleeve del backtest vengono ancora da `build_everything`; i worker sono il path LIVE). La validazione replay==backtest (Task 6) certifica i worker live.
- **Gap noto:** DIP01, come le fade, ha exit intrabar nel backtest ma close-based nel live → gap strutturale documentato (non un bug). TR01/ROT02/TSM01 non hanno TP/SL intrabar (entry/exit a chiusura barra/giorno) → replay atteso stretto.
- **Tipi:** i nuovi worker espongono `.capital` e `.in_position` (richiesti da `_worker_equity`/`rebalance_allocations`); `tick(data: dict)` per i multi-asset vs `tick(df)`/`tick(dfa,dfb)` esistenti → il runner dispatcha per `kind`.
- **Rischio:** la convenzione di capitale/rendimento dei worker multi-asset deve combaciare con le funzioni di riferimento; la validazione Task 6 è il gate che lo verifica — se diverge, allineare la formula (non la reference).
> **Punto aperto:** verificare la disponibilità su Cerbero v2 dei timeframe 4h/1d per tutti gli asset dell'universo (TR01 usa 4h; ROT02/TSM01 usano 1d, oggi resample da 1h in get_df). Il runner live dovrà resamplare 1h→4h/1d dal feed v2 o fetchare nativamente — da decidere in Task 5/Step 3.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,233 @@
# Design — Cartella `portfolios/`: portafogli come oggetti di prima classe
**Data:** 2026-05-29
**Stato:** approvato in brainstorming, pronto per il piano di implementazione
**Branch:** `shape_patterns` (o branch dedicato `portfolios`)
## 1. Obiettivo e contesto
Oggi le strategie del progetto vivono come *sleeve* indipendenti: ogni worker del paper
trader (`StrategyWorker`, `PairsWorker`) gestisce un conto autonomo da €1000, con capitale
e stato propri in `data/paper_trades/{worker_id}/`. I "portafogli" `PORT01-03` esistenti
sono soltanto script di **report offline**: normalizzano le equity storiche dei singoli
sleeve e ne calcolano metriche equipesate. Non esiste un livello che gestisca davvero un
capitale condiviso, i pesi, il ribilanciamento e il PnL aggregato in tempo reale.
Questo design introduce una cartella `portfolios/` in cui il **portafoglio è un oggetto di
prima classe** che gestirà il trading e lo stato PnL. Un portafoglio possiede un capitale
totale, lo alloca ai propri sleeve secondo uno schema di pesi, dimensiona le posizioni,
ribilancia periodicamente e mantiene il ledger aggregato. La stessa definizione serve sia
al backtest sia al live, garantendo coerenza fra ciò che si misura e ciò che si tradia.
L'obiettivo strategico resta invariato: partire da €1000 e arrivare verso €50/giorno con un
paniere diversificato delle famiglie validate (fade, honest, pairs, TSMOM, shape-ML).
## 2. Decisioni di brainstorming
1. **Modello di capitale: pool condiviso.** Il portafoglio possiede il capitale totale, lo
alloca ai sleeve secondo i pesi, ridimensiona le posizioni e tiene lo stato/PnL
aggregato. I worker diventano esecutori.
2. **Scope: backtest + live unificati.** Un'unica classe `Portfolio` come fonte di verità,
capace sia di backtest/report storico sia di gestione live.
3. **Ribilanciamento periodico.** Il capitale viene riallocato ai pesi target a cadenza
fissa (giornaliera di default, configurabile), coerente con tutte le metriche misurate
finora.
4. **Schemi di peso supportati (tutti):** `equal` (default), `cap` (tetto per
famiglia/cluster, es. pairs 33% — configurazione sobria raccomandata), `inverse_vol`,
`cluster_rp` (equal fra cluster naturali poi inverse-vol dentro), `manual`.
5. **Scope live v1: tutti gli sleeve** — fade, honest, pairs (2 gambe) e shape-ML (SH01 via
worker con retraining periodico, sfruttando il `MLWorkerWrapper` esistente).
6. **Data layer Cerbero v2.** Il runner live adotta gli endpoint unificati v2: `get_historical`
unificato, `get_instruments` (naming robusto, niente `INSTRUMENT_MAP` hardcoded),
`get_ticker_batch` (fetch multi-gamba efficiente). Venue di trading = Deribit come ora.
### Analisi di accorpamento (a supporto delle decisioni)
`scripts/analysis/sleeve_clustering.py` ha mostrato che:
- i **cluster naturali** delle 17 sleeve non coincidono con le famiglie ma con
asset/regime: BTC-reversion, ETH-reversion, trend (TR01+TSM01), shape (SH_BTC+SH_ETH),
rotation (ROT02);
- la **ridondanza è lieve** (correlazione massima 0.43 MR01_BTC↔DIP01_BTC, 0.37 TR01↔TSM01):
nessuno sleeve è davvero fondibile, ognuno aggiunge diversificazione;
- a equal-weight i **pairs pesano il 47% del rischio** → giustifica lo schema `cap`;
- in OOS calmo equal-weight batte inverse-vol e risk-parity (i pairs ad alto rischio/ritorno
corrono liberi), ma è un risultato di regime → il cap resta la scelta prudente.
Il campo `cluster` di `SleeveSpec` codifica questi gruppi naturali per gli schemi `cap` e
`cluster_rp`.
## 3. Architettura e layout
Si rispecchia la struttura delle strategie (`src/strategies/` base + `scripts/strategies/`
concrete):
```
src/portfolio/
__init__.py
base.py # Portfolio (definizione + .backtest()), SleeveSpec, PortfolioResult
sleeves.py # costruzione UNIFICATA delle equity-per-sleeve (backtest);
# centralizza la logica oggi in combine_portfolio + report_families
weighting.py # schemi pesi: equal, cap, inverse_vol, cluster_rp, manual
ledger.py # PortfolioLedger: capitale, allocazioni, equity, PnL, peak/DD, persistenza
runner.py # PortfolioRunner (live): pool capital, sizing, ribilancio, aggregazione
scripts/portfolios/
PORT01_honest.py PORT02_fade.py PORT03_master.py
PORT04_master_pairs.py PORT05_master_esteso.py PORT06_master_shape.py
# definizioni concrete (lista SleeveSpec + schema pesi); run() = report backtest
portfolios.yml # config LIVE: portafoglio attivo, capitale, schema pesi, cap, cadenza, leva
```
**Integrazione col codice esistente:**
- Il backtest riusa i builder di equity-per-sleeve (`build_all_sleeves`, `pairs_sim`,
`shape_daily_equity`), centralizzati in `src/portfolio/sleeves.py`; `combine_portfolio.py`
e `report_families.py` diventano consumer sottili (niente duplicazione).
- Il live riusa da `multi_runner`: il fetch candele, `build_workers`,
`build_pairs_workers`, `MLWorkerWrapper`. `multi_runner` resta entrypoint legacy
single-sleeve finché `PortfolioRunner` non lo sostituisce.
- I vecchi `PORT01-03` di `scripts/strategies/` vengono migrati in `scripts/portfolios/`
come definizioni della nuova classe.
## 4. Definizione del portafoglio (schema)
```python
@dataclass
class SleeveSpec:
kind: str # "single" | "pairs" | "ml"
name: str # "MR01_bollinger_fade" | "PR01_pairs_reversion" | "SH01_shape_ml"
asset: str | None = None # single/ml
a: str | None = None # pairs: gamba long
b: str | None = None # pairs: gamba short
tf: str = "1h"
params: dict = field(default_factory=dict)
cluster: str = "" # BTC-rev | ETH-rev | trend | shape | rotation
@dataclass
class Portfolio:
code: str # "PORT06"
label: str # "Master + shape"
sleeves: list[SleeveSpec]
weighting: str = "equal" # equal | cap | inverse_vol | cluster_rp | manual
weights: dict | None = None # solo manual (sleeve-id -> peso)
caps: dict | None = None # solo cap: chiave = FAMIGLIA (derivata da kind/name:
# PAIRS/FADE/HONEST/SHAPE/TSM), es. {"PAIRS": 0.33}.
# cluster_rp usa invece il campo `cluster` degli sleeve.
total_capital: float = 1000.0
leverage: float = 3.0 # nota: 2x raccomandata per il live reale
rebalance: str = "1D"
vol_lookback: int = 90 # giorni per inverse_vol / cluster_rp
def backtest(self, ...) -> PortfolioResult: ...
def weight_vector(self, sleeve_returns) -> dict[str, float]: ...
```
Gli schemi di peso (in `weighting.py`) restituiscono un dict `sleeve-id -> peso` che somma a
1. `equal/cap/manual` sono statici; `inverse_vol/cluster_rp` si ricalcolano a ogni ribilancio
sulla finestra trailing `vol_lookback`, identicamente in backtest e live.
## 5. Faccia backtest
`Portfolio.backtest()` riusa la macchina che ha prodotto tutte le metriche viste finora,
centralizzata in `src/portfolio/sleeves.py`:
```
build_sleeve_equity(spec) -> pd.Series # equity daily normalizzata su IDX comune
kind="single" -> fade/honest daily equity builders
kind="pairs" -> pairs_sim -> daily
kind="ml" -> shape_daily_equity
```
Poi: `weight_vector()` → pesi → `port_returns()` con ribilancio giornaliero → `metrics()`
FULL/OOS + `yearly_returns()`. Restituisce un `PortfolioResult` con ret/CAGR/DD/Sharpe
(FULL e OOS), tabella per-anno e contributo al rischio per sleeve e per cluster. Lo `run()`
di ogni `scripts/portfolios/PORTxx.py` stampa questo report.
## 6. Faccia live (`PortfolioRunner`)
Loop a poll:
1. **Data layer v2.** All'avvio `get_instruments` risolve i nomi reali di ogni asset/coppia
(fallback a una mappa statica se l'endpoint non risponde). Per tick: `get_historical`
unificato per le candele + `get_ticker_batch` per i prezzi correnti di tutte le gambe in
un'unica chiamata.
2. **Costruzione sleeve→worker.** Riusa `build_workers` / `build_pairs_workers` /
`MLWorkerWrapper` (SH01). I worker sono esecutori, non possiedono più €1000 fissi.
3. **Capitale pool + sizing.** Il `PortfolioLedger` tiene `total_capital`. A ogni worker
viene assegnato `alloc_i = peso_i × total_capital`; il worker dimensiona il notional come
`alloc_i × position_size × leverage` (si riusa il campo `capital` del worker come base di
allocazione).
4. **Ribilancio (cadenza `rebalance`, default giornaliera).** `total_capital = Σ equity_sleeve`
(capitale + PnL realizzato); ricalcolo dei pesi (vol-based sulla finestra trailing o
statici); riallineo `alloc_i`.
5. **Aggregazione.** Dopo ogni tick il ledger aggiorna equity totale, peak, max_dd, PnL
aggregato e per-sleeve/cluster.
### Approssimazione dichiarata (limite noto)
Il ribilancio cambia la base di sizing delle posizioni **future**; le posizioni già aperte
restano sul notional con cui sono nate (nessun travaso forzato a metà trade). Per il paper
trading questo è fedele al backtest daily-rebalanced entro lo scarto dovuto al turnover
infragiornaliero. È un compromesso accettato per non introdurre la contabilità a ledger
unico (approccio C scartato in brainstorming), rimandata a quando si passerà a capitale
reale su un singolo conto-margine.
## 7. Persistenza e stato PnL
Stato del portafoglio separato dai singoli worker, in `data/portfolios/{code}/`:
```
data/portfolios/PORT06/
status.json # resume: total_capital, equity, peak, max_dd, pesi correnti,
# alloc+capitale+PnL per sleeve, ultimo ribilancio, ts
equity.jsonl # append-only: una riga per tick/giorno (ts, equity, dd, pnl_day) -> curva live
events.jsonl # append-only: ribilanci (pesi prima/dopo), milestone, errori
```
- I worker continuano a scrivere il proprio `trades.jsonl`/`status.json` in
`data/paper_trades/{worker_id}/` (storico per-sleeve intatto). Il portafoglio aggrega
sopra, non duplica i trade.
- **Resume:** al restart il runner ricarica lo `status.json` del portafoglio e gli stati
dei worker → riprende capitale, pesi e posizioni senza perdere storico.
- **Indicatori target:** il ledger espone `pnl_total`, `pnl_today`, `€/day` medio e DD
corrente.
- **Notifiche Telegram:** riepilogo a livello portafoglio (equity, PnL giorno, DD, ribilanci)
oltre alle notifiche per-trade dei worker.
## 8. Portafogli forniti e default
| Codice | Label | Sleeve | Pesi |
|--------|-------|--------|------|
| PORT01 | Honest | DIP01·TR01·ROT02 | equal |
| PORT02 | Fade master | MR01/02/07 × BTC/ETH (6) | equal |
| PORT03 | Master | fade+honest (9) | equal / manual 50-50 |
| PORT04 | Master + pairs | 9 + 5 pairs | equal · cap pairs 0.33 |
| PORT05 | Master esteso | 9 + pairs + TSM01 | equal · cap pairs |
| **PORT06** | **Master + shape** *(default)* | 9 + pairs + TSM01 + SH01 (BTC/ETH) | **cap pairs 0.33** |
**Default raccomandato:** PORT06 con `weighting="cap"` (pairs ~33%), `leverage=2` (sobrio),
`rebalance="1D"`. È la combinazione col miglior profilo OOS dell'analisi (Sharpe più alto,
DD più basso) e contiene tutte le famiglie validate. `portfolios.yml` seleziona il
portafoglio attivo e i suoi override.
## 9. Test
- **Unit** — `weighting.py` (somma pesi = 1, cap rispettato e ridistribuito,
inverse-vol/cluster corretti); `ledger.py` (capitale/PnL/DD, resume da status.json).
- **Parità backtest↔report** — `Portfolio.backtest()` di PORT03/04/05/06 riproduce
*esattamente* i numeri di `report_families.py` (regressione, stessa fonte).
- **Parità live↔backtest** — replay del `PortfolioRunner` su dati storici con ribilancio
giornaliero ≈ `Portfolio.backtest()` entro tolleranza (lo scarto è il turnover
infragiornaliero dichiarato), sullo stesso schema della validazione dei pairs.
- **Smoke live** — un tick reale end-to-end via Cerbero v2 (get_instruments +
get_historical + ticker_batch), nessun ordine reale, verifica ledger/persistenza/resume.
## 10. Fuori scope (note per il futuro)
- **Ledger unico / conto-margine reale** (approccio C): rinviato al passaggio a capitale
reale.
- **Hyperliquid come venue per gli alt** dei pairs (perp lineari nativi, evita i trap di
naming Deribit) — opzione abilitata dal data layer v2, non in v1.
- **Validazione pairs live via `get_cointegration_pairs`** e feature da macro/sentiment
(funding, liquidation, OI) per strategie future.
- **`run_backtest` server-side** di Cerbero come check incrociato.
+26
View File
@@ -0,0 +1,26 @@
# Config LIVE del paper trader a portafoglio. Seleziona UN portafoglio attivo
# (definito in scripts/portfolios/_defs.py) e ne fa l'override dei parametri operativi.
active: PORT06 # default raccomandato: master + shape
overrides:
total_capital: 2000
weighting: cap # equal | cap | inverse_vol | cluster_rp | manual
caps: {PAIRS: 0.33}
leverage: 2 # sobrio per il live reale
rebalance: 1D
poll_seconds: 60
# Frazione di capitale-sleeve per posizione (canonico backtest = 0.15).
# 0.5 con leva 2x = 100% della fetta impegnata quando in posizione (max impiego
# dei 2K senza debito di margine). NB: il DD scala ~lineare (~×3.3 vs validato).
position_size: 0.5
# Esecuzione REALE su Deribit testnet, in SHADOW (sim + reale in parallelo).
# I 7 single-leg con TP/SL in metadata: 6 fade (MR01/MR02/MR07 x BTC/ETH) +
# DIP01 BTC (attivato 2026-06-04: stesso wiring StrategyWorker, TP limit resting
# incluso). Ordini sui LINEARI USDC (payoff lineare = matematica del backtest;
# fee/PnL in USDC). Gli altri sleeve (pairs/rotation/tsmom/shape) restano
# simulati: pairs richiede executor a 2 gambe, shape non ha TP (orizzonte puro).
execution:
enabled: true
sleeves: [MR01, MR02, MR07, DIP01]
instruments:
BTC: BTC_USDC-PERPETUAL
ETH: ETH_USDC-PERPETUAL
+1
View File
@@ -27,3 +27,4 @@ dev = [
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
markers = ["network: test che richiede Cerbero MCP (rete+token)"]
+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()
+66
View File
@@ -0,0 +1,66 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd
from scripts.analysis.regime_lab import load_features
from scripts.analysis.explore_lab import atr
import importlib
FEE=0.001; LEV=3
def load_strat(mod):
m=importlib.import_module(mod)
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
STR={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
def replay(df, sigs):
h=df['high'].values; l=df['low'].values; c=df['close'].values
out=[]; last=-1
for s in sigs:
i=s.idx
if i<=last: continue
d=s.direction; tp=s.metadata['tp']; sl=s.metadata['sl']; mb=s.metadata['max_bars']
j=min(i+mb,len(c)-1); exit_p=c[j]
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl;j=t;break
if h[t]>=tp: exit_p=tp;j=t;break
else:
if h[t]>=sl: exit_p=sl;j=t;break
if l[t]<=tp: exit_p=tp;j=t;break
ret=(exit_p-c[i])/c[i]*d*LEV-FEE*LEV
out.append((i,ret)); last=j
return out
# raccogli tutti i trade con il loro dvol_pct e hurst all'ingresso
rows=[]
for asset in ("BTC","ETH"):
df=load_features(asset,"1h"); ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
for code,(mod,par) in STR.items():
s=load_strat(mod); sigs=s.generate_signals(df,ts,**par)
for i,ret in replay(df,sigs):
rows.append(dict(asset=asset,code=code,year=ts.iloc[i].year,ret=ret,
dvol_pct=df['dvol_pct'].iloc[i], hurst=df['hurst'].iloc[i],
dvol=df['dvol'].iloc[i]))
R=pd.DataFrame(rows).dropna(subset=['dvol_pct'])
print(f"trade totali (con DVOL, 2021+): {len(R)}")
print("\n=== PnL medio per trade per TERZILE DVOL (bassa/media/alta vol) ===")
R['dvbin']=pd.cut(R['dvol_pct'],[0,.33,.66,1.0],labels=['LOW-vol','MID','HIGH-vol'])
g=R.groupby('dvbin',observed=True)['ret']
print(f" {'regime':<10}{'n':>6}{'ret_medio%':>12}{'win%':>8}{'somma%':>10}")
for b in ['LOW-vol','MID','HIGH-vol']:
x=R[R.dvbin==b]['ret']
print(f" {b:<10}{len(x):>6}{x.mean()*100:>12.3f}{(x>0).mean()*100:>8.1f}{x.sum()*100:>10.0f}")
print("\n=== dentro LOW-vol: split per HURST (anti-persistente vs trending) ===")
LV=R[R.dvbin=='LOW-vol'].copy()
LV['hbin']=pd.cut(LV['hurst'],[0,.45,.55,1.0],labels=['hurst<.45 (anti-pers)','.45-.55','>.55 (trend)'])
for b in ['hurst<.45 (anti-pers)','.45-.55','>.55 (trend)']:
x=LV[LV.hbin==b]['ret']
if len(x): print(f" {b:<24}{len(x):>6} ret_medio {x.mean()*100:>+7.3f}% win {(x>0).mean()*100:>5.1f}% somma {x.sum()*100:>+6.0f}%")
print("\n=== per anno: PnL fade in LOW-vol vs resto ===")
for y in range(2021,2027):
lo=R[(R.year==y)&(R.dvbin=='LOW-vol')]['ret']; hi=R[(R.year==y)&(R.dvbin!='LOW-vol')]['ret']
print(f" {y}: LOW-vol somma {lo.sum()*100:>+6.0f}% (n{len(lo)}) | MID/HIGH somma {hi.sum()*100:>+6.0f}% (n{len(hi)})")
+50
View File
@@ -0,0 +1,50 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd
from scripts.analysis.regime_lab import load_features
import importlib
FEE=0.001; LEV=3
def load_strat(mod):
m=importlib.import_module(mod)
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
STR={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
def replay(df,sigs):
h=df['high'].values;l=df['low'].values;c=df['close'].values;out=[];last=-1
for s in sigs:
i=s.idx
if i<=last: continue
d=s.direction;tp=s.metadata['tp'];sl=s.metadata['sl'];mb=s.metadata['max_bars'];j=min(i+mb,len(c)-1);exit_p=c[j];reason='time'
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl;j=t;reason='sl';break
if h[t]>=tp: exit_p=tp;j=t;reason='tp';break
else:
if h[t]>=sl: exit_p=sl;j=t;reason='sl';break
if l[t]<=tp: exit_p=tp;j=t;reason='tp';break
out.append((i,(exit_p-c[i])/c[i]*d*LEV-FEE*LEV,reason));last=j
return out
rows=[]
for asset in ("BTC","ETH"):
df=load_features(asset,"1h");ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
for code,(mod,par) in STR.items():
s=load_strat(mod)
for i,ret,reason in replay(df,s.generate_signals(df,ts,**par)):
rows.append(dict(ret=ret,reason=reason,dvol_pct=df['dvol_pct'].iloc[i],hurst=df['hurst'].iloc[i],
vratio=df['vratio'].iloc[i],higuchi=df['higuchi'].iloc[i]))
R=pd.DataFrame(rows).dropna(subset=['dvol_pct','hurst'])
L=R[R.ret<0] # solo i trade in perdita
print(f"trade {len(R)} | in perdita {len(L)} ({len(L)/len(R)*100:.0f}%) | somma perdite {L.ret.sum()*100:.0f}% | media perdita {L.ret.mean()*100:.2f}%")
print("\n=== somma PERDITE per regime (dove si concentra il danno) ===")
R['dvbin']=pd.cut(R.dvol_pct,[0,.33,.66,1],labels=['LOWvol','MID','HIGHvol'])
R['hbin']=pd.cut(R.hurst,[0,.45,.55,1],labels=['anti<.45','.45-.55','trend>.55'])
piv=R[R.ret<0].pivot_table(index='dvbin',columns='hbin',values='ret',aggfunc='sum',observed=True)*100
print((piv.round(0)).to_string())
print("\n (numeri = somma % delle perdite per cella; piu negativo = piu danno)")
print("\n=== quota di SL (stop) per regime ===")
slr=R.groupby(['dvbin','hbin'],observed=True).apply(lambda x:(x.reason=='sl').mean()*100, include_groups=False)
print(slr.round(0).to_string())
# worst tail
print(f"\n=== peggiori 1% trade: dove? ===")
W=R.nsmallest(max(10,len(R)//100),'ret')
print(f" worst {len(W)} trade: dvol_pct medio {W.dvol_pct.mean():.2f}, hurst medio {W.hurst.mean():.2f}, quota hurst>.55 {(W.hurst>.55).mean()*100:.0f}%, quota dvol<.33 {(W.dvol_pct<.33).mean()*100:.0f}%")
@@ -0,0 +1,56 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd, importlib
from scripts.analysis.combine_portfolio import IDX, SPLIT, INIT, _norm, metrics, port_returns, build_trades
from src.portfolio.sleeves import all_sleeve_equities
from scripts.analysis.regime_lab import load_features
def load_strat(mod):
m=importlib.import_module(mod)
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
FADES={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
FEE=0.001; LEV=3; POS=0.15
def fade_equity_filtered(code, asset, hurst_thr=None):
"""equity giornaliera dello sleeve fade, opz. filtrata Hurst<thr (skip hurst>=thr). Convenzione fade_daily_equity."""
mod,par=FADES[code]; s=load_strat(mod)
df=load_features(asset,"1h"); ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
h=df['high'].values; l=df['low'].values; c=df['close'].values; hur=df['hurst'].values
eq=np.full(len(c),INIT,float); cap=INIT; last=-1
for sg in s.generate_signals(df,ts,**par):
i=sg.idx
if i<=last: continue
if hurst_thr is not None and not np.isnan(hur[i]) and hur[i]>=hurst_thr: continue # FILTRO
d=sg.direction; tp=sg.metadata['tp']; sl=sg.metadata['sl']; mb=sg.metadata['max_bars']
j=min(i+mb,len(c)-1); exit_p=c[j]
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl;j=t;break
if h[t]>=tp: exit_p=tp;j=t;break
else:
if h[t]>=sl: exit_p=sl;j=t;break
if l[t]<=tp: exit_p=tp;j=t;break
ret=(exit_p-c[i])/c[i]*d*LEV-FEE*LEV
cap=max(cap+cap*POS*ret,10.0); eq[j:]=cap; last=j
sser=pd.Series(eq,index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return _norm(sser)
base=all_sleeve_equities()
fade_ids=["MR01_BTC","MR02_BTC","MR07_BTC","MR01_ETH","MR02_ETH","MR07_ETH"]
def port(members):
dr=port_returns(members); return metrics(dr), metrics(dr,lo=SPLIT)
# baseline PORT06
fB,oB=port(base)
print(f"PORT06 baseline (17 sleeve): FULL Sharpe {fB['sharpe']:.2f} DD {fB['dd']:.2f}% | OOS Sharpe {oB['sharpe']:.2f} DD {oB['dd']:.2f}% ret {oB['ret']:+.0f}%")
# sostituisci le 6 fade con versione Hurst-skip
for thr in (0.55, 0.50):
filt=dict(base)
for fid in fade_ids:
code,asset=fid.split("_")
filt[fid]=fade_equity_filtered(code,asset,hurst_thr=thr)
fF,oF=port(filt)
print(f"PORT06 + Hurst-skip h<{thr} sulle fade: FULL Sharpe {fF['sharpe']:.2f} DD {fF['dd']:.2f}% | OOS Sharpe {oF['sharpe']:.2f} DD {oF['dd']:.2f}% ret {oF['ret']:+.0f}%")
+120
View File
@@ -0,0 +1,120 @@
export const meta = {
name: 'fade-lossguard',
description: 'Sistema anti-perdite per le fade in regime trending/low-vol: test meccanismi su MR01/02/07',
phases: [
{ title: 'Test', detail: 'agenti: ogni meccanismo di filtro applicato alle fade reali (BTC+ETH)' },
{ title: 'Synth', detail: 'classifica + miglior loss-guard, gate: riduce DD senza uccidere edge' },
],
}
const API = `
=== Harness (gia pronto) ===
import sys; sys.path.insert(0,'.')
import numpy as np, pandas as pd, importlib
from scripts.analysis.regime_lab import load_features, report
from scripts.analysis.explore_lab import robust, atr
def load_strat(mod):
m=importlib.import_module(mod)
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
FADES={'MR01':('scripts.strategies.MR01_bollinger_fade',dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
'MR02':('scripts.strategies.MR02_donchian_fade',dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
'MR07':('scripts.strategies.MR07_return_reversal',dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
# colonne regime_lab (causali): dvol, dvol_pct, vrp, funding_z, dvol_chg, hurst, higuchi, vratio, frac_up/dn
# ADX (se ti serve) calcolalo causale da OHLC; efficiency-ratio Kaufman = |c[i]-c[i-n]| / sum|diff| su [i-n,i].
# PATTERN: genera i segnali fade, poi APPLICA IL TUO FILTRO scartando le entries in regime sfavorevole,
# confronta BASELINE vs FILTRATA su ogni fade x asset:
def entries_from(strat, df, par, keep=lambda df,i: True):
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
out=[]
for s in strat.generate_signals(df,ts,**par):
if keep(df, s.idx): # keep=False -> filtro scarta (loss-guard)
out.append({'i':s.idx,'d':s.direction,'tp':s.metadata['tp'],'sl':s.metadata['sl'],'max_bars':s.metadata['max_bars']})
return out
# per ogni (fade,asset): res_base=report(.., entries_from(..., keep=tutto)); res_filt=report(.., col tuo keep)
# confronta: Sharpe OOS, DD full/oos, ret, #trade (quanti scartati), e robust(). Aggrega sulle 6 combo.
`
const CONTEXT = `
PROBLEMA: le fade (MR01 Bollinger, MR02 Donchian, MR07 return-reversal) sono mean-reversion 1h con
filtro trend EMA200 (trend_max=3.0). DIAGNOSI EMPIRICA (3022 trade, 2021+): le PERDITE e gli STOP
si concentrano nel regime PERSISTENTE/TRENDING, NON nella bassa vol:
- somma perdite per cella (Hurst x DVOL): la cella peggiore e' hurst>0.55 (-2695% in low-vol,
dominante in ogni terzile vol). I peggiori 1% trade hanno hurst medio 0.61 (77% con hurst>0.55).
- tasso STOP-LOSS: 43% quando hurst>0.55 vs 21% quando hurst<0.45 (anti-persistente). 2x.
- net: le celle restano positive (i winner battono), quindi filtrare toglie anche winner -> il
loss-guard e' utile SOLO se riduce DD/coda SENZA uccidere l'edge netto.
RICERCA ESTERNA (confermata): (a) Hurst regime filter: MR solo H<0.45, in 0.45-0.55 ridurre size,
evitare H>0.55. (b) ADX: MR profit factor 1.62 con ADX<20 vs -0.74 con ADX>30 (switch di regime piu'
importante). (c) ATR/vol-EXPANSION ratio>1.5 disabilita MR -> ha prevenuto il 72% delle perdite
maggiori. (d) time-stop: se non rientra in ~15 barre e' un trend, esci.
OBIETTIVO: trovare il MIGLIOR meccanismo (o combo) che, applicato alle fade reali, RIDUCE DD/coda/
stop-rate MANTENENDO l'edge netto OOS. Metodologia: causale no-look-ahead (le colonne regime_lab
sono causali; i filtri usano solo dati <= i), netto fee (report() fa OOS+sweep). LEZIONE FR01: un
filtro che riduce le perdite ma anche i winner spesso NON migliora -> il gate vero e' DD giu' a
parita' (o quasi) di Sharpe/ret, idealmente Sharpe SU e DD GIU'.
` + API
const SCHEMA = {
type: 'object',
properties: {
meccanismo: { type: 'string' },
descrizione: { type: 'string' },
base_oos_sharpe: { type: 'number' }, filt_oos_sharpe: { type: 'number' },
base_dd_full: { type: 'number' }, filt_dd_full: { type: 'number' },
base_oos_ret: { type: 'number' }, filt_oos_ret: { type: 'number' },
trade_scartati_pct: { type: 'number' },
riduce_perdite: { type: 'boolean', description: 'riduce DD/coda/stop-rate' },
preserva_edge: { type: 'boolean', description: 'edge netto OOS preservato (Sharpe non crolla, robust resta)' },
buon_lossguard: { type: 'boolean', description: 'riduce perdite SENZA uccidere edge -> candidato' },
verdetto: { type: 'string', description: 'numeri base vs filtrato aggregati sulle 6 combo fade x asset' },
},
required: ['meccanismo', 'buon_lossguard', 'riduce_perdite', 'preserva_edge', 'verdetto'],
}
const MECHS = [
['Hurst-skip H>0.55', 'scarta le fade quando rolling-hurst(window=100) >= 0.55 (regime persistente). Test anche soglia 0.50 e 0.60, riporta la migliore.'],
['Hurst-size transition', 'NON scartare ma RIDURRE: tieni tutte le entries ma pesa size 1.0 se hurst<0.45, 0.5 se 0.45-0.55, 0.25 se >0.55. (Per testare la riduzione size col report attuale: approssima scartando il 50%/75% delle entries nelle bin alte in modo deterministico per indice, oppure confronta solo le bin.)'],
['ADX-skip', 'calcola ADX(14) causale; scarta le fade quando ADX>25 (trend). Test soglie 20/25/30.'],
['vol-expansion vratio', 'scarta le fade quando vratio (vol breve/lunga, colonna regime_lab) > 1.5 (vol in espansione = breakout, non range). Test 1.3/1.5/1.8. (la ricerca dice -72% perdite maggiori)'],
['efficiency-ratio Kaufman', 'ER = |c[i]-c[i-n]|/sum(|diff|) su finestra n=20; scarta quando ER>0.5 (moto efficiente/trending). Test 0.4/0.5/0.6.'],
['time-stop piu corto', 'riduci max_bars da 24 a 12 o 15 (esci prima se non rientra = probabile trend). Confronta DD/edge.'],
['Hurst + vol-expansion combo', 'scarta se hurst>0.55 OPPURE vratio>1.5. Verifica se la combo riduce piu DD del singolo senza perdere piu edge.'],
['Hurst + ADX combo', 'scarta se hurst>0.55 E ADX>25 (doppia conferma di trend) -> piu selettivo, scarta meno winner.'],
['vol-target sizing', 'scala la size per 1/realized_vol (target vol costante): approssima tenendo solo le entries in vol moderata, riporta effetto su DD/coda.'],
['DVOL-rising skip', 'scarta le fade quando dvol_chg>0 forte (DVOL in salita = stress/espansione vol imminente). Test soglie su dvol_chg.'],
]
const ASSETS_NOTE = 'Applica a tutte e 3 le fade (MR01,MR02,MR07) su BTC E ETH (6 combo), aggrega base vs filtrato.'
phase('Test')
// ogni meccanismo = 1 agente che testa su tutte le 6 combo; piu' 4 agenti che esplorano combo/parametri fini
const tasks = MECHS.map(([nm, desc]) => () => agent(
CONTEXT + `\n\nMECCANISMO DA TESTARE: ${nm}\n${desc}\n\n${ASSETS_NOTE}\n` +
`Scrivi uno script in /tmp (cd /opt/docker/PythagorasGoal && uv run python /tmp/<file>.py), confronta ` +
`BASELINE (fade senza filtro) vs FILTRATA su ogni combo, AGGREGA (media o somma equity) e riporta. ` +
`Il filtro deve essere CAUSALE. Decidi buon_lossguard=true SOLO se riduce DD/coda/stop-rate MANTENENDO ` +
`l'edge netto OOS (Sharpe non crolla, ret OOS resta ampiamente positivo). Cita i numeri base vs filtrato.`,
{ label: `mech:${nm.slice(0, 18)}`, phase: 'Test', schema: SCHEMA }))
const results = (await parallel(tasks)).filter(Boolean)
phase('Synth')
const good = results.filter(r => r.buon_lossguard)
const synthesis = await agent(
CONTEXT +
`\n\nRisultati di ${results.length} meccanismi testati:\n${JSON.stringify(results, null, 1)}\n\n` +
`SINTESI FINALE (italiano) per il decisore:
1) Esiste un loss-guard che riduce le perdite/DD delle fade in regime trending SENZA uccidere l'edge?
2) Tabella: meccanismo | base vs filtrato (OOS Sharpe, DD, ret, %trade scartati) | buon_lossguard?
3) Il MIGLIORE (e l'eventuale combo) con i numeri. Quanto DD/coda si risparmia e a che costo di ret.
4) Coerenza con la ricerca esterna (Hurst<0.45 / ADX / vol-expansion / time-stop).
5) Raccomandazione: quale filtro applicare alle fade live, con che soglia, e il caveat (serve feed
DVOL/regime live? il filtro va validato a livello PORT06 = riduce il DD del portafoglio?).
Onesta: se nessuno migliora davvero (riduce solo ret), dillo. Cita NUMERI reali.`,
{ label: 'synth-lossguard', phase: 'Synth' })
return { results, good, synthesis }
@@ -0,0 +1,54 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd
from scripts.analysis.combine_portfolio import IDX, SPLIT, INIT, _norm, metrics, port_returns
from src.portfolio.sleeves import all_sleeve_equities
from scripts.analysis.regime_lab import load_features
from scripts.analysis.explore_lab import atr
FEE=0.001; LEV=3; POS=0.15
def fr01_daily_equity(asset):
df=load_features(asset,"1h")
c=df['close'].values; h=df['high'].values; l=df['low'].values; a=atr(df,14)
ma=pd.Series(c).rolling(50).mean().values; sd=pd.Series(c).rolling(50).std().values
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
eq=np.full(len(c),INIT,float); cap=INIT; last=-1
for i in range(64,len(c)-1):
if i<=last or np.isnan(sd[i]) or sd[i]==0 or np.isnan(a[i]): continue
if df['hurst'].iloc[i]>=0.55: continue
if np.isnan(df['dvol_pct'].iloc[i]) or df['dvol_pct'].iloc[i]>=0.40: continue
if c[i]<ma[i]-2.5*sd[i]: d,sl,tp=1,c[i]-2*a[i],ma[i]
elif c[i]>ma[i]+2.5*sd[i]: d,sl,tp=-1,c[i]+2*a[i],ma[i]
else: continue
j=min(i+24,len(c)-1); exit_p=c[j]
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl; j=t; break
if h[t]>=tp: exit_p=tp; j=t; break
else:
if h[t]>=sl: exit_p=sl; j=t; break
if l[t]<=tp: exit_p=tp; j=t; break
ret=(exit_p-c[i])/c[i]*d*LEV - FEE*LEV
cap=max(cap+cap*POS*ret,10.0); eq[j:]=cap; last=j
s=pd.Series(eq,index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return _norm(s)
members=all_sleeve_equities()
print(f"PORT06 sleeve: {len(members)} | finestra {IDX[0].date()}..{IDX[-1].date()} | OOS da idx {SPLIT} ({IDX[SPLIT].date()})")
fr={"FR01_BTC":fr01_daily_equity("BTC"), "FR01_ETH":fr01_daily_equity("ETH")}
base=port_returns(members) # equal-weight 17 sleeve (metro combine)
aug =port_returns({**members,**fr}) # + FR01x2 (19 sleeve)
def show(tag, dr):
f=metrics(dr); o=metrics(dr,lo=SPLIT)
print(f" {tag:<22} FULL: Sharpe {f['sharpe']:.2f} DD {f['dd']:.1f}% ret {f['ret']:+.0f}% | OOS: Sharpe {o['sharpe']:.2f} DD {o['dd']:.1f}% ret {o['ret']:+.0f}%")
print("\n=== MASTER equal-weight: con/senza FR01 ===")
show("PORT06 (17 sleeve)", base)
show("PORT06 + FR01 (19)", aug)
# correlazione FR01 vs portafoglio MASTER aggregato + standalone
for k,e in fr.items():
r=e.pct_change().fillna(0.0); corr=np.corrcoef(r, base)[0,1]
f=metrics(r); o=metrics(r,lo=SPLIT)
print(f"\n {k}: corr vs MASTER = {corr:+.3f} | standalone OOS Sharpe {o['sharpe']:.2f} DD {o['dd']:.1f}% ret {o['ret']:+.0f}%")
+86
View File
@@ -0,0 +1,86 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd
from scripts.analysis.regime_lab import load_features
from scripts.analysis.explore_lab import atr
FEE=0.001; LEV=3
def build(df, gate, k=2.5, sl_atr=2.0, mb=24, bb=50):
c=df['close'].values; a=atr(df,14)
ma=pd.Series(c).rolling(bb).mean().values; sd=pd.Series(c).rolling(bb).std().values
ent=[]
for i in range(bb+14,len(c)-1):
if np.isnan(sd[i]) or sd[i]==0 or np.isnan(a[i]): continue
if not gate(df,i): continue
if c[i]<ma[i]-k*sd[i]: d,sl=1,c[i]-sl_atr*a[i]
elif c[i]>ma[i]+k*sd[i]: d,sl=-1,c[i]+sl_atr*a[i]
else: continue
ent.append({'i':i,'d':d,'tp':ma[i],'sl':sl,'mb':mb})
return ent
def per_year(df, ent):
"""replay intrabar fedele (sl-first, tp, poi max_bars@close) -> per anno {n,ret%,win%}."""
h=df['high'].values; l=df['low'].values; c=df['close'].values
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
Y={}
last=-1
for e in ent:
i=e['i']
if i<=last: continue
d=e['d']; tp=e['tp']; sl=e['sl']; j=min(i+e['mb'],len(c)-1)
exit_p=c[j]
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl; j=t; break
if h[t]>=tp: exit_p=tp; j=t; break
else:
if h[t]>=sl: exit_p=sl; j=t; break
if l[t]<=tp: exit_p=tp; j=t; break
ret=(exit_p-c[i])/c[i]*d*LEV - FEE*LEV
last=j; yr=ts.iloc[i].year
if yr not in Y: Y[yr]=[0,0.0,0]
Y[yr][0]+=1; Y[yr][1]+=ret*100; Y[yr][2]+= (ret>0)
return Y
# gate functions
def g_hurst_calm(df,i): return df['hurst'].iloc[i]<0.55 and not np.isnan(df['dvol_pct'].iloc[i]) and df['dvol_pct'].iloc[i]<0.40
def g_vrp_neg(df,i): return not np.isnan(df['vrp'].iloc[i]) and df['vrp'].iloc[i]<0
def g_hig_vrp(df,i):
hi=df['higuchi'].iloc[i]; return (not np.isnan(hi)) and hi>1.5 and (not np.isnan(df['vrp'].iloc[i])) and df['vrp'].iloc[i]<0
def g_none(df,i): return True
STRATS=[("HurstCalmFade (hurst<.55 & DVOL<p40)",g_hurst_calm),
("VRP<0 Fade (core driver)",g_vrp_neg),
("HigVRP Fade (Higuchi>1.5 & VRP<0)",g_hig_vrp),
("Fade NUDA (no gate, baseline)",g_none)]
# regime mercato BTC per anno (da BTC close annuale)
btc=load_features("BTC","1d")
bts=pd.to_datetime(btc['timestamp'],unit='ms',utc=True); bc=btc['close'].values
mkt={}
for yr in range(2021,2027):
m=(bts.dt.year==yr).values
if m.sum()>5:
r=(bc[m][-1]/bc[m][0]-1)*100
mkt[yr]=("BULL" if r>40 else "BEAR" if r<-30 else "RANGE", r)
for asset in ("BTC","ETH"):
df=load_features(asset,"1h")
print(f"\n{'='*78}\n {asset} 1h — performance per anno (somma ret% per-trade, netto leva3x+fee0.10%)\n{'='*78}")
print(f" {'Strategia':<40} " + " ".join(f"{y}" for y in range(2021,2027)))
for name,g in STRATS:
ent=build(df,g); Y=per_year(df,ent)
cells=[]
for y in range(2021,2027):
if y in Y and Y[y][0]>0:
cells.append(f"{Y[y][1]:+5.0f}")
else: cells.append(" . ")
print(f" {name:<40} " + " ".join(cells))
# riga trades/anno per la strategia principale
ent=build(df,g_hurst_calm); Y=per_year(df,ent)
tr=" ".join(f"{Y.get(y,[0])[0]:>5}" for y in range(2021,2027))
print(f" {'(HurstCalmFade trades/anno)':<40} {tr}")
print(f"\n REGIME MERCATO BTC per anno (ret% annuale prezzo):")
for y in range(2021,2027):
if y in mkt: print(f" {y}: {mkt[y][0]:6} ({mkt[y][1]:+.0f}%)")
+175
View File
@@ -0,0 +1,175 @@
export const meta = {
name: 'fractal-argo-search',
description: 'Ricerca a ~100 agenti: strategia FRATTALI del segnale x REGIME ARGO (DVOL/funding/VRP) validata OOS',
phases: [
{ title: 'Search', detail: '92 agenti: griglia frattale x regime x asset + wildcard' },
{ title: 'Verify', detail: 'verifica avversariale dei survivor (look-ahead, fee 0.2%, altro asset/split)' },
{ title: 'Synth', detail: 'classifica, sceglie vincitori, propone implementazione' },
],
}
const API = `
=== regime_lab API (gia pronta, dati FRESCHI in cache) ===
from scripts.analysis.regime_lab import load_features, report
from scripts.analysis.explore_lab import robust, atr, ema, rsi
df = load_features(ASSET, TF) # ASSET in {BTC,ETH}, TF in {1h,4h,1d}
# Colonne (tutte CAUSALI, valore a barra i usa solo dati <= i):
# OHLCV: open high low close volume timestamp
# REGIME (ARGO-proxy backtestabile): dvol, dvol_pct (percentile rolling 0..1),
# rv (realized vol ann.), vrp = dvol-rv (>0 = vol sopravvalutata ~ ARGO GEX+ range),
# funding, funding_z (z-score rolling), dvol_chg (DVOL salita/discesa, proxy term-structure)
# FRATTALI: hurst (>0.5 persistente/trend, <0.5 anti-persistente/mean-rev), higuchi (FD: alta=frastagliato),
# vratio (vol breve/lunga), frac_up/frac_dn (Williams pivot bool: swing high/low confermati, CAUSALI)
# NB: dvol e' NaN prima del 2021-03 (storico DVOL) -> salta le barre con dvol NaN se usi il regime.
# Costruisci 'entries': lista dict {i, d(+1/-1), tp, sl, max_bars}. INGRESSO ESEGUIBILE:
# i, d, tp, sl decisi con dati <= close[i]. tp/sl in PREZZO (o None). Esempio fade:
ent=[]
c=df['close'].values; a=atr(df,14); ma=df['close'].rolling(50).mean().values; sd=df['close'].rolling(50).std().values
for i in range(300, len(c)-1):
if np.isnan(sd[i]) or np.isnan(df['dvol_pct'].iloc[i]): continue
if df['vrp'].iloc[i] > 0 and c[i] < ma[i]-2.5*sd[i]: # GATE regime + SEGNALE frattale/tecnico
ent.append({'i':i,'d':1,'tp':ma[i],'sl':c[i]-2*a[i],'max_bars':24})
res = report('NOME', ent, df) # -> {full:{ret,sharpe,dd,trades,win,exposure}, oos:{...}, sweep, sweep_oos, pos_yrs, n_yrs}
ok = robust(res) # True = full+oos>0 E regge fee 0.2% RT E anni ~tutti positivi
print('ROBUST', ok, 'trd', res['full']['trades'], 'OOSsharpe', round(res['oos']['sharpe'],2),
'OOSret', round(res['oos']['ret']), 'fee02OOS', round(res['sweep_oos'][0.002]))
`
const CONTEXT = `
PROGETTO PythagorasGoal: trading crypto BTC/ETH. Edge dimostrato = SOLO mean-reversion (fade) + pairs.
ASTICELLA ALTA: il portafoglio PORT06 e' gia a Sharpe OOS 8.19 / DD 2.3%. Una strategia nuova vale solo
se ha edge NETTO validato OOS e robusto.
PRIORI ONESTI (non ignorarli): i FRATTALI sono stati gia esplorati e quasi tutti RUMORE (shape_lab:
analog kNN solo BTC-overfit; PIP/pivot 0/48 robuste; DTW peggiora). Le OPZIONI sono state SCARTATE
(W18/19/21 VRP). L'unico edge frattale validato e SH01 (shape-ML logit, diversificatore). MA: la
combinazione FRATTALE-del-segnale x REGIME-ARGO (gating su DVOL/funding/VRP) e' NUOVA e non testata ->
e' qui che potrebbe esserci valore: il regime puo dire QUANDO il segnale frattale funziona.
OBIETTIVO: trovare una strategia che combini un SEGNALE FRATTALE con un GATE/INTERAZIONE DI REGIME
(ARGO-proxy: DVOL percentile, VRP, funding) e che superi la validazione onesta (robust()=True).
METODOLOGIA OBBLIGATORIA: ingresso ESEGUIBILE senza look-ahead (le colonne regime_lab sono gia causali;
le TUE entries devono usare solo dati <= i). Backtest NETTO fee (report() fa gia sweep 0.0-0.2% RT + OOS
ultimo 30%). robust()=True e' il gate minimo. Diffida dell'overfit: poche entries o edge solo full e
non-oos = rumore. Riporta ONESTAMENTE anche i fallimenti.
` + API
const SCHEMA = {
type: 'object',
properties: {
strategy: { type: 'string', description: 'nome + 1 frase: segnale frattale + gate regime' },
family: { type: 'string' }, angle: { type: 'string' }, asset: { type: 'string' }, tf: { type: 'string' },
trades: { type: 'integer' },
full_ret: { type: 'number' }, oos_ret: { type: 'number' },
full_sharpe: { type: 'number' }, oos_sharpe: { type: 'number' }, oos_dd: { type: 'number' },
fee02_oos_ret: { type: 'number', description: 'OOS ret a fee 0.2% RT' },
robust: { type: 'boolean', description: 'robust()=True' },
promising: { type: 'boolean', description: 'vale una verifica avversariale (robust o quasi, non overfit)' },
edge_desc: { type: 'string', description: 'perche funziona / perche e rumore, con i numeri' },
},
required: ['strategy', 'asset', 'tf', 'trades', 'oos_sharpe', 'robust', 'promising', 'edge_desc'],
}
const FAMILIES = [
['hurst', 'Hurst regime: fade quando hurst<0.5 (anti-persistente), o trend quando hurst>0.5. Soglia hurst come segnale o gate.'],
['higuchi', 'Fractal dimension Higuchi: FD alta = frastagliato/range (fade), FD bassa = liscio/trend (momentum).'],
['williams', 'Williams pivot (frac_up/frac_dn, causali): fade del pivot (reversione allo swing) o breakout del pivot.'],
['vratio', 'volatility_ratio: >1 espansione vol (breakout/fade del breakout), <1 compressione (range/squeeze).'],
['analog', 'analog kNN sulla FORMA (puoi usare scripts.analysis.shape_lab.analog_signals(df,...)): forecast causale segno a H barre, gatealo col regime.'],
['multiscale', 'multi-scala: combina hurst+higuchi+vratio in un indice di "regime frattale" (trend vs chop) come segnale.'],
['candle', 'pattern candele frattali (src.fractal.patterns: extract_body_ratios/shadow, find_patterns): sequenze multi-barra come segnale.'],
]
const ANGLES = [
['none', 'NESSUN gate regime: segnale frattale puro (baseline per misurare il valore marginale del regime).'],
['dvol_high', 'agisci solo con dvol_pct alto (>0.6..0.8): vol elevata (spesso mean-reversion piu forte).'],
['dvol_low', 'agisci solo con dvol_pct basso (<0.3..0.4): calma/range.'],
['vrp', 'VRP=vrp colonna: VRP>0 (vol sopravvalutata, analogo ARGO GEX+ -> range/fade); confronta con VRP<0. Gate o peso.'],
['funding', 'funding_z estremo: troppi long (funding_z alto) -> fade ribassista; troppi short -> fade rialzista (flusso ARGO via perp).'],
['dvol_chg', 'dvol_chg: DVOL in salita (espansione vol/stress -> trend) vs discesa (ritorno calma -> range).'],
]
const ASSETS = ['BTC', 'ETH']
phase('Search')
// 7 famiglie x 6 angoli x 2 asset = 84 agenti griglia
const gridSpecs = []
for (const [fam, fdesc] of FAMILIES)
for (const [ang, adesc] of ANGLES)
for (const asset of ASSETS)
gridSpecs.push({ fam, fdesc, ang, adesc, asset })
const gridTasks = gridSpecs.map((s) => () => agent(
CONTEXT +
`\n\nIL TUO CELLA:\n- FAMIGLIA FRATTALE: ${s.fam} -> ${s.fdesc}\n- ANGOLO REGIME: ${s.ang} -> ${s.adesc}\n- ASSET: ${s.asset}\n\n` +
`Progetta la MIGLIORE strategia in questa cella: un SEGNALE basato sulla famiglia frattale ${s.fam}, ` +
`condizionato/interagito col regime ${s.ang}. Scrivi uno script in /tmp (cd /opt/docker/PythagorasGoal && ` +
`uv run python /tmp/<tuofile>.py), prova SIA TF=1h SIA TF=1d (e se vuoi 4h), itera 2-4 varianti di soglia/` +
`direzione/exit, e RIPORTA la migliore (quella con oos_sharpe piu alto e robust se possibile). Usa report()+robust(). ` +
`Privilegia mean-reversion (l'edge del progetto) ma testa anche momentum dove il regime lo motiva. ` +
`Mai look-ahead. Se tutto e rumore, dillo onestamente (promising=false). Ritorna lo schema.`,
{ label: `srch:${s.fam}/${s.ang}/${s.asset}`, phase: 'Search', schema: SCHEMA }))
// 8 wildcard: mandato aperto
const wildTasks = Array.from({ length: 8 }, (_, k) => () => agent(
CONTEXT +
`\n\nSEI UN AGENTE WILDCARD #${k + 1}. Mandato APERTO: inventa una combinazione FRATTALE-del-segnale x ` +
`REGIME-ARGO NON banale e non nella griglia ovvia. Idee: interazione hurst*vrp (mean-rev solo se ` +
`anti-persistente E vol sopravvalutata); Williams pivot come TP/SL adattivo gateato da dvol; analog kNN ` +
`pesato per funding; size/exit modulati dal regime; combinare 2 segnali frattali con conferma di regime. ` +
`Asset e TF a tua scelta (prova entrambi gli asset). Costruisci, testa onesto (report()+robust()), riporta ` +
`la migliore. Diversifica dagli altri: varia idea in base a #${k + 1}. Schema in output.`,
{ label: `wild:${k + 1}`, phase: 'Search', schema: SCHEMA }))
const searchResults = (await parallel([...gridTasks, ...wildTasks])).filter(Boolean)
// survivor = robust, oppure promising con oos_sharpe alto e abbastanza trade
const survivors = searchResults.filter(r =>
(r.robust || (r.promising && (r.oos_sharpe || 0) >= 1.0)) && (r.trades || 0) >= 30)
log(`Search: ${searchResults.length} testati, ${survivors.length} survivor da verificare`)
phase('Verify')
const VSCHEMA = {
type: 'object',
properties: {
strategy: { type: 'string' }, confirmed: { type: 'boolean' },
reason: { type: 'string', description: 'esito audit look-ahead + fee0.2% + altro asset + split alternativo' },
oos_sharpe_recheck: { type: 'number' }, killed_by: { type: 'string' },
},
required: ['strategy', 'confirmed', 'reason'],
}
let verified = []
if (survivors.length) {
verified = (await parallel(survivors.map(s => () => agent(
CONTEXT +
`\n\nVERIFICA AVVERSARIALE di un candidato survivor:\n${JSON.stringify(s, null, 1)}\n\n` +
`Tuo compito: PROVARE A FALSIFICARLO. (1) Ricostruisci la strategia (chiedi i dettagli dal suo edge_desc; ` +
`riusa regime_lab). (2) AUDIT look-ahead: ogni colonna/calcolo usa solo dati <= i? Il gate regime e' noto a i? ` +
`(3) Regge fee 0.2% RT in OOS? (4) Regge sull'ALTRO asset (se BTC prova ETH e viceversa)? (5) Regge a uno SPLIT ` +
`OOS alternativo (es. train<=2024, test 2025-26)? (6) Numero trade sufficiente e non concentrato in 1 anno? ` +
`Default a confirmed=FALSE se incerto o se sopravvive solo per overfit. Sii spietato. Schema in output.`,
{ label: `verify:${(s.strategy || '').slice(0, 24)}`, phase: 'Verify', schema: VSCHEMA })))).filter(Boolean)
}
const confirmed = verified.filter(v => v.confirmed)
phase('Synth')
const synthesis = await agent(
CONTEXT +
`\n\nHai i risultati di ${searchResults.length} agenti di ricerca e ${verified.length} verifiche avversariali.\n\n` +
`SURVIVOR CONFERMATI:\n${JSON.stringify(confirmed, null, 1)}\n\n` +
`TUTTI I SURVIVOR (anche non confermati):\n${JSON.stringify(survivors, null, 1)}\n\n` +
`TOP 15 per oos_sharpe fra tutti i testati:\n${JSON.stringify(
searchResults.slice().sort((a, b) => (b.oos_sharpe || 0) - (a.oos_sharpe || 0)).slice(0, 15), null, 1)}\n\n` +
`Produci la SINTESI FINALE (italiano) per il decisore:
1) VERDETTO: esiste una strategia frattale x ARGO con edge validato OOS? quale/i (confermate)?
2) Tabella dei top candidati: strategia, asset/tf, OOS Sharpe, OOS ret, DD, robust, confermato?
3) Il regime ARGO (DVOL/VRP/funding) AGGIUNGE valore al segnale frattale (vs angolo 'none')? In quali celle?
4) Cosa e' rumore e perche (coerente coi priori: frattali deboli, opzioni scartate).
5) Se c'e un vincitore: piano di implementazione (file in scripts/strategies/, MODULE_MAP, validazione finale).
Se NON c'e: dillo chiaro, niente forzature.
Cita NUMERI reali (OOS Sharpe, ret, trades). Onesta brutale: deve battere PORT06, non solo essere >0.`,
{ label: 'synthesis', phase: 'Synth' })
return { searchResults, survivors, confirmed, synthesis }
+80
View File
@@ -0,0 +1,80 @@
"""Smoke end-to-end dell'esecuzione REALE su Deribit testnet.
Dimostra il giro completo che serve al live:
account → OPEN (ordine reale minimo) → VERIFICA posizione su Deribit →
FEE reale dai trade → CLOSE → VERIFICA flat → riepilogo.
Usa il sizing MINIMO del contratto (BTC $10, ETH $1): costo testnet = €0.
NON tocca il runner: e' solo una prova della macchina di esecuzione.
uv run python scripts/analysis/live_exec_smoke.py # ETH + BTC
uv run python scripts/analysis/live_exec_smoke.py ETH-PERPETUAL
"""
from __future__ import annotations
import sys
from src.live.cerbero_client import CerberoClient
from src.live.execution import ExecutionClient, contract_spec, settlement_currency
def smoke_one(ex: ExecutionClient, instrument: str, notional: float = 10.0) -> bool:
spec = contract_spec(instrument)
cur = settlement_currency(instrument)
kind = "lineare USDC" if spec.get("linear") else "inverse"
print(f"\n===== {instrument} ({kind}, ~${notional:.0f} notional) =====")
pre = ex._position_size(instrument)
print(f" posizione pre: {pre} USD (atteso 0)")
print(f" → OPEN buy ${notional:.0f} notional ...")
f = ex.open(instrument, "buy", notional, label="smoke-exec")
print(f" order_id={f.order_id} state={f.order_state} verified={f.verified}")
print(f" fill_price={f.fill_price} amount={f.amount} ({'BTC' if spec.get('linear') else 'USD'})")
print(f" FEE reale: {f.fee_coin:.10f} {cur} (~${f.fee_usd:.4f})")
if f.notes:
print(f" note: {f.notes}")
if not f.verified:
print(" ✗ OPEN NON verificato — interrompo questo strumento")
return False
print(" ✓ posizione confermata su Deribit (riletta da get_positions)")
print(" → CLOSE ...")
c = ex.close(instrument, label="smoke-exec")
print(f" order_id={c.order_id} state={c.order_state} verified(flat)={c.verified}")
print(f" fill_price={c.fill_price} FEE reale: {c.fee_coin:.10f} {cur} (~${c.fee_usd:.4f})")
if c.notes:
print(f" note: {c.notes}")
post = ex._position_size(instrument)
ok = f.verified and c.verified and post == 0
# fee RT reale osservata vs assunto 0.10% RT sul notional
fee_usd_rt = f.fee_usd + c.fee_usd
assumed_rt = notional * 0.001
print(f" posizione post: {post} USD (atteso 0)")
print(f" FEE RT reale ~${fee_usd_rt:.4f} vs assunto 0.10% RT ~${assumed_rt:.4f}")
print(f" {'✓ OK' if ok else '✗ FALLITO'} — giro completo {instrument}")
return ok
def main() -> None:
instruments = sys.argv[1:] or ["ETH-PERPETUAL", "BTC-PERPETUAL"]
client = CerberoClient()
acct = client.get_account_summary(currency="USDC")
print(f"Account testnet: equity={acct.get('equity')} USDC testnet={acct.get('testnet')}")
if not acct.get("testnet"):
print("✗ ABORT: non e' testnet — niente ordini reali su mainnet.")
sys.exit(1)
ex = ExecutionClient(client=client)
results = {inst: smoke_one(ex, inst) for inst in instruments}
print("\n===== RIEPILOGO =====")
for inst, ok in results.items():
print(f" {inst:16s} {'✓ OK' if ok else '✗ FALLITO'}")
sys.exit(0 if all(results.values()) else 1)
if __name__ == "__main__":
main()
+90
View File
@@ -0,0 +1,90 @@
"""Smoke della catena SHADOW dentro lo StrategyWorker (testnet, ordini reali minimi).
Apre e chiude la quota di UN worker fade come farebbe il runner, esercitando i
DUE percorsi del LIMIT reduce-only al TP (fix divergenza sim/reale 2026-06-04):
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.
uv run python scripts/analysis/live_shadow_smoke.py
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from src.live.cerbero_client import CerberoClient
from src.live.execution import ExecutionClient
from src.live.strategy_loader import load_strategy
from src.live.strategy_worker import StrategyWorker
from src.strategies.base import Signal
def main() -> None:
client = CerberoClient()
acct = client.get_account_summary(currency="USDC")
print(f"Account testnet equity={acct.get('equity')} USDC testnet={acct.get('testnet')}")
if not acct.get("testnet"):
raise SystemExit("ABORT: non testnet")
ex = ExecutionClient(client=client)
instrument = "BTC_USDC-PERPETUAL"
price = ex._mark_price(instrument)
print(f"{instrument} mark={price}")
with tempfile.TemporaryDirectory() as tmp:
w = StrategyWorker(
strategy=load_strategy("MR01_bollinger_fade"),
asset="BTC", tf="1h", capital=100.0, position_size=0.15, leverage=2.0,
data_dir=Path(tmp), executor=ex, exec_instrument=instrument,
)
print(f"execution_enabled={w.execution_enabled} notional atteso=${100*0.15*2:.0f}")
# --- Scenario A: TP lontano → resting in book, exit non-TP → cancel + market ---
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)
print(f" real_in_position={w.real_in_position} side={w.real_side} "
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}")
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"
cap_before = w.real_capital
w._close_position((w.entry_price or price) * 1.001, "time_limit")
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"
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)
pos = ex._position_size(instrument)
print(f"\n posizione netta {instrument}: {pos}")
print("✓ catena shadow OK — open reale, LIMIT TP resting (A: cancel+market, "
"B: fill immediato riconciliato), fee reali nel ledger reale")
if __name__ == "__main__":
main()
+87
View File
@@ -0,0 +1,87 @@
"""Smoke test REALE dei pairs: fetch live da Cerbero + un tick vero per coppia.
A differenza di validate_worker_pairs.py (replay su parquet storici), questo verifica
la PIPELINE LIVE end-to-end: chiama Cerbero per entrambe le gambe, controlla che lo
strumento esista e sia fresco, fa un tick reale del PairsWorker e riporta lo stato.
Serve a scoprire i problemi che il backtest nasconde (es. un perp alt non disponibile
sull'endpoint Deribit). NON apre ordini reali: e' solo paper/lettura.
"""
from __future__ import annotations
import sys
import shutil
import tempfile
from datetime import datetime, timezone, timedelta
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.live.cerbero_client import CerberoClient
from src.live.multi_runner import INSTRUMENT_MAP
from src.live.pairs_worker import PairsWorker
from scripts.strategies.PR01_pairs_reversion import PAIRS
def fetch(cli, asset, start, end):
inst = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
try:
cs = cli.get_historical(inst, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"), "60")
if not cs:
return inst, None, "VUOTO (strumento assente sull'endpoint)"
df = pd.DataFrame(cs)
df["timestamp"] = df["timestamp"].astype("int64")
df = df.sort_values("timestamp").reset_index(drop=True)
last = pd.to_datetime(df["timestamp"].iloc[-1], unit="ms", utc=True)
age = (datetime.now(timezone.utc) - last).total_seconds() / 3600
return inst, df, f"{len(df)} barre, ultima {last:%Y-%m-%d %H:%M} ({age:.1f}h fa)"
except Exception as e:
return inst, None, f"ERRORE {type(e).__name__}: {str(e)[:60]}"
def main():
cli = CerberoClient()
end = datetime.now(timezone.utc)
start = end - timedelta(days=60)
assets = sorted({a for a, _, _ in PAIRS} | {b for _, b, _ in PAIRS})
print("=" * 80)
print(" SMOKE TEST LIVE PAIRS — fetch reale Cerbero + tick (no ordini reali)")
print("=" * 80)
data = {}
for a in assets:
inst, df, msg = fetch(cli, a, start, end)
data[a] = df
print(f" {a:<4s} [{inst:<16s}] {msg}")
print("\n tick reale per coppia:")
tmp = Path(tempfile.mkdtemp())
try:
for a, b, p in PAIRS:
if data.get(a) is None or data.get(b) is None:
print(f" {a}/{b:<4s}: SKIP (manca feed live di una gamba) -> non tradabile live ora")
continue
w = PairsWorker(a, b, "1h", params=p, fee_rt=0.001, data_dir=tmp)
w._log = lambda *x, **k: None
w._notify = lambda *x, **k: None
m = data[a][["timestamp", "close"]].merge(
data[b][["timestamp", "close"]], on="timestamp", how="inner")
if len(m) < p["n"] + 2:
print(f" {a}/{b:<4s}: merge {len(m)} barre < n+2 ({p['n']+2}) -> dati insufficienti")
continue
z, _ = w._zscore(m["close_x"].values, m["close_y"].values)
w.tick(data[a], data[b])
state = ("IN POS " + ("LONG " + a if w.direction == 1 else "SHORT " + a)
if w.in_position else "FLAT")
print(f" {a}/{b:<4s}: OK merge {len(m)} barre, z_ora={z[-1]:+.2f} -> {state}")
finally:
shutil.rmtree(tmp, ignore_errors=True)
print("\n Solo le coppie con entrambe le gambe fresche su Cerbero sono tradabili live.")
if __name__ == "__main__":
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()
+148
View File
@@ -0,0 +1,148 @@
"""Proiezione a 3 anni del portafoglio live (PORT06) ESCLUDENDO il 2024.
Risponde a: "partendo da 1000 EUR, con capitale che compone e puntata che cresce
di conseguenza, quale guadagno giornaliero atteso e quale traiettoria a 3 anni?"
Punti chiave (perche' i numeri differiscono da report_families):
- report_families/Portfolio.backtest usano le curve sleeve NATIVE a leva 3x.
- Il container LIVE (src.portfolio.runner via portfolios.yml) gira a pos 0.15 x 2x.
- Il PnL giornaliero scala ESATTAMENTE con la leva: pnl = cap * pos * lev * (ret - fee),
stesso segnale -> ratio 2/3 per gli sleeve leverati.
- ROT02 e TSM01 NON si riscalano: usano `gross` (0.45 / 0.30), indipendente dalla leva
e identico fra backtest e worker live.
- Il 2024 e' escluso perche' anno eccezionale (crypto +; gonfia ogni stima).
CAVEAT (le proiezioni sono OTTIMISTICHE):
- 2021-2025 e' quasi tutto bull/recovery; poco orso/flat prolungato nel campione.
- Dati alt = testnet (volume sottile, fill/slippage NON modellati).
- OOS singolo (2024-25) = regime calmo -> ~50% ottimistico (vedi report_families (D)).
Lo scenario SOBRIO (haircut ~50%) e' il numero prudente su cui pianificare.
Run: uv run python scripts/analysis/projection_3y.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 scripts.portfolios._defs import PORTFOLIOS
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
from scripts.analysis.combine_portfolio import port_returns
# sleeve la cui esposizione NON dipende dalla leva (gross-based) -> non si riscalano
NOSCALE = {"ROT02_rot", "TSM01"}
NATIVE_LEV = 3.0 # leva delle curve sleeve in combine_portfolio
EXCLUDE_YEAR = 2024
START_CAPITAL = 1000.0
def live_portfolio_returns(live_leverage: float = 2.0) -> pd.Series:
"""Rendimenti giornalieri di PORT06 al sizing LIVE (pos 0.15 x `live_leverage`).
Riscala gli sleeve leverati di live_leverage/NATIVE_LEV; ROT/TSM invariati."""
p = PORTFOLIOS["PORT06"]
p.leverage = live_leverage
p.weighting = "cap"
p.caps = {"PAIRS": 0.33}
eq = all_sleeve_equities()
dr = sleeve_returns_df(p.sleeve_ids)
w = p.weight_vector(dr)
scale = live_leverage / NATIVE_LEV
eq_live = {}
for sid in p.sleeve_ids:
r = eq[sid].pct_change().fillna(0.0)
r = r if sid in NOSCALE else r * scale
eq_live[sid] = (1 + r).cumprod()
pr = port_returns(eq_live, w)
pr.index = pd.to_datetime(pr.index)
return pr
def stats_ex_year(pr: pd.Series, exclude: int = EXCLUDE_YEAR) -> dict:
ex = pr[pr.index.year != exclude]
n = len(ex)
years = n / 365.0
tot = (1 + ex).prod() - 1
cagr = (1 + tot) ** (1 / years) - 1
yvals = [(1 + g).prod() - 1 for _, g in ex.groupby(ex.index.year)]
return {
"days": n, "years": years, "total": tot, "cagr": cagr,
"year_median": float(np.median(yvals)), "year_mean": float(np.mean(yvals)),
"daily_mean_eur": float(ex.mean() * START_CAPITAL),
"daily_median_eur": float(ex.median() * START_CAPITAL),
"daily_std_eur": float(ex.std() * START_CAPITAL),
"pos_days": float((ex > 0).mean()),
"per_year": {int(y): float((1 + g).prod() - 1) for y, g in pr.groupby(pr.index.year)},
}
def project(annual: float, years: int = 3, start: float = START_CAPITAL) -> list[dict]:
"""Capitale che compone; la puntata cresce col capitale -> EUR/giorno cresce."""
rows, cap = [], start
for yr in range(1, years + 1):
s = cap
cap = cap * (1 + annual)
rows.append({"year": yr, "start": s, "end": cap,
"gain": cap - s, "eur_per_day": (cap - s) / 365.0})
return rows
def years_to_target(daily_target: float, annual: float, start: float = START_CAPITAL) -> float:
"""Anni per raggiungere un certo EUR/giorno componendo (capitale = target*365/cagr)."""
cap_needed = daily_target * 365.0 / annual
if cap_needed <= start:
return 0.0
return float(np.log(cap_needed / start) / np.log(1 + annual))
def main():
pr = live_portfolio_returns(live_leverage=2.0)
s = stats_ex_year(pr)
print("=" * 72)
print(" PORT06 LIVE (pos 0.15 x 2x) — proiezione 3 anni, ESCLUSO 2024")
print("=" * 72)
print(" Rendimento live per anno:")
for y, v in s["per_year"].items():
flag = " <-- ESCLUSO" if y == EXCLUDE_YEAR else ""
print(f" {y}: {v * 100:+6.1f}%{flag}")
print()
print(f" CAGR (escl 2024): {s['cagr'] * 100:5.1f}% "
f"[{s['years']:.2f} anni di dati]")
print(f" anno mediano: {s['year_median'] * 100:5.1f}%")
print(f" anno medio: {s['year_mean'] * 100:5.1f}%")
print(f" EUR/giorno su 1000: media {s['daily_mean_eur']:.2f} | "
f"mediana {s['daily_median_eur']:.2f} | std {s['daily_std_eur']:.2f}")
print(f" giorni positivi: {s['pos_days'] * 100:.1f}%")
print()
scenarios = [
("CAGR backtest escl-2024", s["cagr"]),
("anno mediano", s["year_median"]),
("SOBRIO (haircut ~50%)", s["cagr"] * 0.5),
]
for name, g in scenarios:
print(f" -- 3 anni @ {g * 100:.0f}%/anno ({name}) --")
for r in project(g):
print(f" anno {r['year']}: {r['start']:7.0f} -> {r['end']:7.0f} EUR "
f"(+{r['gain']:5.0f}, ~{r['eur_per_day']:4.2f} EUR/g medi)")
print()
print(" -- Target 50 EUR/giorno (reality check) --")
for name, g in scenarios[:1] + scenarios[2:]:
cap_needed = 50.0 * 365.0 / g
t = years_to_target(50.0, g)
print(f" @ {g * 100:.0f}%/anno: servono ~{cap_needed:,.0f} EUR schierati "
f"-> da 1000 EUR, ~{t:.0f} anni componendo")
print(" => il collo di bottiglia e' il CAPITALE iniziale, non la strategia.")
if __name__ == "__main__":
main()
+112
View File
@@ -0,0 +1,112 @@
"""Fetch dati REGIME backtestabili da Deribit MAINNET (public, no-auth) -> parquet.
Abilita la ricerca strategie frattali x regime (ARGO-proxy). Salva in data/raw/:
{btc,eth}_dvol.parquet : DVOL index 1h (IV 30d "VIX crypto"), storico ~2021->oggi
{btc,eth}_funding.parquet : funding rate perp 1h, storico ~2019->oggi
Solo componenti ARGO con STORICO GRATUITO (DVOL, funding) -> validabili OOS. Il GEX
per-strike resta snapshot-only (vedi analisi 2026-06-01). Run:
uv run python scripts/analysis/regime_fetcher.py
"""
from __future__ import annotations
import time
import urllib.request
import urllib.parse
import json
from pathlib import Path
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
RAW = ROOT / "data" / "regime" # NON data/raw (solo OHLCV) — evita pollution discovery asset
BASE = "https://www.deribit.com/api/v2/public/"
def _get(method: str, params: dict) -> dict:
url = BASE + method + "?" + urllib.parse.urlencode(params)
for _ in range(4):
try:
with urllib.request.urlopen(url, timeout=30) as r:
return json.loads(r.read())
except Exception:
time.sleep(1.0)
return {}
def fetch_dvol(currency: str, start_ms: int, end_ms: int, res: int = 3600) -> pd.DataFrame:
"""DVOL index (OHLC). Cap 1000 righe/chiamata -> chaining all'indietro."""
rows = []
cur_end = end_ms
span = 1000 * res * 1000
while cur_end > start_ms:
cur_start = max(start_ms, cur_end - span)
d = _get("get_volatility_index_data", {
"currency": currency, "start_timestamp": cur_start,
"end_timestamp": cur_end, "resolution": res})
data = (d.get("result") or {}).get("data") or []
if not data:
break
rows.extend(data)
oldest = min(x[0] for x in data)
if oldest >= cur_end:
break
cur_end = oldest - 1
time.sleep(0.15)
if not rows:
return pd.DataFrame()
df = pd.DataFrame(rows, columns=["timestamp", "open", "high", "low", "close"])
df = df.drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True)
df["dvol"] = df["close"]
return df
def fetch_funding(instrument: str, start_ms: int, end_ms: int) -> pd.DataFrame:
"""funding rate history perp (1h). Paginazione ~30g/chiamata."""
rows = []
cur_start = start_ms
step = 30 * 24 * 3600 * 1000
while cur_start < end_ms:
cur_end = min(end_ms, cur_start + step)
d = _get("get_funding_rate_history", {
"instrument_name": instrument,
"start_timestamp": cur_start, "end_timestamp": cur_end})
data = d.get("result") or []
if data:
rows.extend(data)
cur_start = cur_end + 1
time.sleep(0.12)
if not rows:
return pd.DataFrame()
df = pd.DataFrame(rows)
ts_col = "timestamp" if "timestamp" in df.columns else df.columns[0]
df = df.rename(columns={ts_col: "timestamp"})
keep = [c for c in ("timestamp", "interest_1h", "interest_8h", "index_price", "prev_index_price") if c in df.columns]
df = df[keep].drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True)
return df
def main():
RAW.mkdir(parents=True, exist_ok=True)
now = _get("get_time", {})
end_ms = int(now.get("result", 0)) or int(time.time() * 1000)
start_ms = end_ms - int(6.5 * 365 * 24 * 3600 * 1000) # ~6.5 anni
for cur, inst in (("BTC", "BTC-PERPETUAL"), ("ETH", "ETH-PERPETUAL")):
dv = fetch_dvol(cur, start_ms, end_ms)
if not dv.empty:
p = RAW / f"{cur.lower()}_dvol.parquet"
dv.to_parquet(p)
rng = (pd.to_datetime(dv['timestamp'].min(), unit='ms').date(),
pd.to_datetime(dv['timestamp'].max(), unit='ms').date())
print(f" {cur} DVOL: {len(dv)} righe {rng[0]}->{rng[1]} (ora={dv['dvol'].iloc[-1]:.1f}) -> {p.name}")
fr = fetch_funding(inst, start_ms, end_ms)
if not fr.empty:
p = RAW / f"{cur.lower()}_funding.parquet"
fr.to_parquet(p)
rng = (pd.to_datetime(fr['timestamp'].min(), unit='ms').date(),
pd.to_datetime(fr['timestamp'].max(), unit='ms').date())
print(f" {cur} FUNDING: {len(fr)} righe {rng[0]}->{rng[1]} -> {p.name}")
if __name__ == "__main__":
main()
+202
View File
@@ -0,0 +1,202 @@
"""regime_lab — API condivisa per la ricerca strategie FRATTALI x REGIME (ARGO-proxy).
Allinea prezzo (OHLCV) + DVOL + funding in modo CAUSALE (no look-ahead: il valore di
regime alla barra i usa solo dati <= timestamp[i]) ed espone:
- feature REGIME (ARGO-proxy backtestabili): dvol, dvol_pct (percentile rolling),
rv (realized vol), vrp = dvol - rv, funding, funding_z, dvol_chg (proxy term-structure).
- feature FRATTALI (src/fractal): rolling_hurst, higuchi, self_similarity, volatility_ratio,
williams fractals (pivot), candle encoding.
- validazione: report(name, entries, df) -> full/oos netto-fee + robustezza griglia/fee,
riusando l'engine onesto di explore_lab (simulate/evaluate).
Convenzione entries (come explore_lab): lista di dict {i, d (+1/-1), tp, sl, max_bars}.
Ingresso ESEGUIBILE: i, d, tp, sl decisi con dati <= close[i].
Uso tipico in un agente:
from scripts.analysis.regime_lab import load, report, regime_features, frac_features
df = load("BTC", "1h") # OHLCV + colonne regime allineate
R = regime_features(df); F = frac_features(df)
entries = [...] # la tua logica
print(report("MIA_STRATEGIA", entries, df))
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from scripts.analysis.explore_lab import get_df, simulate, evaluate, atr, ema, rsi # noqa: E402
from src.fractal.indicators import ( # noqa: E402
rolling_hurst, fractal_dimension_higuchi, self_similarity_score, volatility_ratio,
)
# dati regime (DVOL/funding/feature) in data/regime/ — NON in data/raw/ (che e' solo OHLCV: i file
# estranei in data/raw inquinano la discovery asset del backtest). Vedi diary 2026-06-02-fade-lossguard.
RAW = ROOT / "data" / "regime"
RAW.mkdir(parents=True, exist_ok=True)
# --------------------------------------------------------------------------- dati
def _load_regime_series(asset: str) -> tuple[pd.DataFrame, pd.DataFrame]:
a = asset.lower()
dvol = pd.read_parquet(RAW / f"{a}_dvol.parquet") if (RAW / f"{a}_dvol.parquet").exists() else pd.DataFrame()
fund = pd.read_parquet(RAW / f"{a}_funding.parquet") if (RAW / f"{a}_funding.parquet").exists() else pd.DataFrame()
return dvol, fund
def load(asset: str, tf: str) -> pd.DataFrame:
"""OHLCV (explore_lab.get_df) + colonne regime allineate CAUSALMENTE (merge_asof backward).
Ogni barra prezzo riceve l'ultimo DVOL/funding con timestamp <= timestamp barra."""
df = get_df(asset, tf).copy()
df["timestamp"] = df["timestamp"].astype("int64")
dvol, fund = _load_regime_series(asset)
if not dvol.empty:
d = dvol[["timestamp", "dvol"]].astype({"timestamp": "int64"}).sort_values("timestamp")
df = pd.merge_asof(df.sort_values("timestamp"), d, on="timestamp", direction="backward")
else:
df["dvol"] = np.nan
if not fund.empty:
col = "interest_1h" if "interest_1h" in fund.columns else fund.columns[1]
f = fund[["timestamp", col]].astype({"timestamp": "int64"}).rename(columns={col: "funding"}).sort_values("timestamp")
df = pd.merge_asof(df.sort_values("timestamp"), f, on="timestamp", direction="backward")
else:
df["funding"] = np.nan
return df.reset_index(drop=True)
# ---------------------------------------------------------------- feature REGIME
def _rolling_pct(x: np.ndarray, win: int) -> np.ndarray:
"""Percentile rolling CAUSALE: rank di x[i] nella finestra [i-win, i] (solo passato)."""
s = pd.Series(x)
return s.rolling(win, min_periods=max(20, win // 4)).apply(
lambda w: (w.iloc[-1] >= w).mean(), raw=False).values
_BARS_PER_YEAR = {"1h": 24 * 365, "4h": 6 * 365, "1d": 365}
def regime_features(df: pd.DataFrame, tf: str = "1h", pct_win: int = 252, rv_win: int = 24,
fund_win: int = 168) -> dict:
"""Tutte causali. dvol_pct/funding_z usano solo finestra passata. vrp = dvol - rv annualizz.
tf serve ad annualizzare correttamente la realized vol (sqrt barre/anno per timeframe)."""
c = df["close"].values.astype(float)
dvol = df["dvol"].values.astype(float)
fund = df["funding"].values.astype(float)
ret = np.zeros_like(c); ret[1:] = np.diff(np.log(c))
# realized vol annualizzata (punti %, scala come DVOL): std rolling * sqrt(barre/anno del tf)
bpy = _BARS_PER_YEAR.get(tf, 24 * 365)
rv = pd.Series(ret).rolling(rv_win).std().values * np.sqrt(bpy) * 100
dvol_pct = _rolling_pct(dvol, pct_win)
fmean = pd.Series(fund).rolling(fund_win).mean().values
fstd = pd.Series(fund).rolling(fund_win).std().values
funding_z = (fund - fmean) / np.where(fstd == 0, np.nan, fstd)
dvol_chg = pd.Series(dvol).diff(rv_win).values # proxy term-structure (DVOL in salita/discesa)
return {
"dvol": dvol, "dvol_pct": dvol_pct, "rv": rv, "vrp": dvol - rv,
"funding": fund, "funding_z": funding_z, "dvol_chg": dvol_chg,
}
# --------------------------------------------------------------- feature FRATTALI
def williams_fractals(df: pd.DataFrame, k: int = 2) -> tuple[np.ndarray, np.ndarray]:
"""Pivot di Bill Williams: frac_up[i]=high[i] e' il max delle 2k+1 barre centrate (causale a i+k).
Ritorna due array bool (up=swing high confermato, dn=swing low). Confermati con ritardo k."""
h, l = df["high"].values, df["low"].values
n = len(h)
up = np.zeros(n, bool); dn = np.zeros(n, bool)
for i in range(k, n - k):
if h[i] == max(h[i - k:i + k + 1]):
up[i] = True
if l[i] == min(l[i - k:i + k + 1]):
dn[i] = True
return up, dn
def frac_features(df: pd.DataFrame, hurst_win: int = 100, higuchi_win: int = 64,
step: int = 1) -> dict:
"""Feature frattali rolling, CAUSALI (finestra passata che termina a i). step>1: calcola
ogni `step` barre e fa forward-fill (i frattali variano lentamente) -> molto piu' veloce."""
c = df["close"].values.astype(float)
n = len(c)
hurst = rolling_hurst(c, window=hurst_win, step=step) # gia' causale + stepped (src/fractal)
vratio = np.full(n, np.nan)
higuchi = np.full(n, np.nan)
last_hi = last_vr = np.nan
for i in range(higuchi_win, n):
if (i - higuchi_win) % step == 0:
last_hi = fractal_dimension_higuchi(c[i - higuchi_win:i])
last_vr = volatility_ratio(c[max(0, i - 60):i])
higuchi[i] = last_hi
vratio[i] = last_vr
up, dn = williams_fractals(df)
return {"hurst": hurst, "higuchi": higuchi, "vratio": vratio,
"frac_up": up, "frac_dn": dn}
# ------------------------------------------------------------------------- cache
_FEATCOLS_R = ("dvol", "dvol_pct", "rv", "vrp", "funding", "funding_z", "dvol_chg")
_FEATCOLS_F = ("hurst", "higuchi", "vratio", "frac_up", "frac_dn")
def _cache_path(asset: str, tf: str) -> Path:
return RAW / f"features_{asset.lower()}_{tf}.parquet"
def build_cache(asset: str, tf: str, frac_step: int = 6) -> pd.DataFrame:
"""Precompute OHLCV + regime + frattali -> parquet condiviso (per i 100 agenti)."""
df = load(asset, tf)
R = regime_features(df, tf=tf)
F = frac_features(df, step=frac_step)
for k in _FEATCOLS_R:
df[k] = R[k]
for k in _FEATCOLS_F:
df[k] = F[k]
p = _cache_path(asset, tf)
df.to_parquet(p)
return df
def load_features(asset: str, tf: str) -> pd.DataFrame:
"""Carica la cache feature (la costruisce se manca). OHLCV + tutte le colonne regime+frattali."""
p = _cache_path(asset, tf)
if p.exists():
return pd.read_parquet(p)
return build_cache(asset, tf)
# ------------------------------------------------------------------- validazione
def report(name: str, entries: list[dict], df: pd.DataFrame, asset: str = "", tf: str = "") -> dict:
"""Netto-fee full + OOS (ultimo 30%) + sweep fee, via engine onesto di explore_lab.
Ritorna dict compatto: trades, full/oos (ret%, sharpe, dd, acc), robust (OK su tutte le fee)."""
if not entries:
# struttura compatibile con robust() (tutti zero) -> robust()=False pulito, niente crash
z = {"ret": 0.0, "sharpe": 0.0, "dd": 0.0, "trades": 0, "win": 0.0, "exposure": 0.0, "yearly": {}}
print(f" {name:<24s} NO ENTRIES")
return {"full": dict(z), "oos": dict(z), "sweep": {0.0: 0.0, 0.0005: 0.0, 0.001: 0.0, 0.002: 0.0},
"sweep_oos": {0.0: 0.0, 0.0005: 0.0, 0.001: 0.0, 0.002: 0.0}, "pos_yrs": 0, "n_yrs": 0}
return evaluate(name, entries, df) # full + oos + fee sweep
if __name__ == "__main__":
# smoke: una fade Bollinger gateata dal regime (DVOL alto) come esempio d'uso
df = load("BTC", "1h")
R = regime_features(df); F = frac_features(df)
c = df["close"].values
ma = pd.Series(c).rolling(50).mean().values
sd = pd.Series(c).rolling(50).std().values
a = atr(df, 14)
ent = []
for i in range(300, len(c) - 1):
if np.isnan(sd[i]) or np.isnan(R["dvol_pct"][i]):
continue
if R["dvol_pct"][i] < 0.6: # gate: solo regime DVOL alto
continue
if c[i] < ma[i] - 2.5 * sd[i]: # fade banda bassa
ent.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - 2 * a[i], "max_bars": 24})
print(f"smoke BTC 1h fade|DVOL>p60: {len(ent)} entries")
print(report("SMOKE", ent, df))
+13 -6
View File
@@ -31,6 +31,7 @@ from scripts.analysis.honest_improve2 import _daily_equity, _norm
from scripts.analysis.pairs_research import pairs_sim
from scripts.analysis.tsmom_research import tsmom_sim
from scripts.strategies.PR01_pairs_reversion import PAIRS
from scripts.analysis.shape_ml_validate import shape_daily_equity
YEARS = sorted(set(IDX.year))
@@ -47,7 +48,8 @@ def build_everything():
pairs[f"PR_{a}{b}"] = daily_from(r["eq_ts"], r["eq_v"])
t = tsmom_sim()
tsm = {"TSM01": daily_from(t["eq_ts"], t["eq_v"])}
return S, pairs, tsm
shape = {f"SH_{a}": _norm(shape_daily_equity(a, IDX)) for a in ("BTC", "ETH")}
return S, pairs, tsm, shape
def yrow(label, dr):
@@ -62,8 +64,8 @@ def metric_block(label, dr):
def main():
print("Costruzione (puo' richiedere ~1-2 min)...\n")
S, pairs, tsm = build_everything()
print("Costruzione (puo' richiedere ~2-3 min)...\n")
S, pairs, tsm, shape = build_everything()
fade = {k: v for k, v in S.items() if k.startswith("MR")}
honest = {k: v for k, v in S.items() if not k.startswith("MR")}
@@ -72,10 +74,12 @@ def main():
"HONEST": port_returns(honest),
"PAIRS": port_returns(pairs),
"TSM01": tsm["TSM01"].pct_change().fillna(0.0),
"SHAPE": port_returns(shape),
}
master9 = port_returns(S)
master_p = port_returns({**S, **pairs})
master_x = port_returns({**S, **pairs, **tsm})
master_xs = port_returns({**S, **pairs, **tsm, **shape})
# ---------- (A) per anno, per FAMIGLIA + portafogli ----------
print("=" * 110)
@@ -89,12 +93,13 @@ def main():
print(yrow("MASTER-9", master9))
print(yrow("MASTER+pairs", master_p))
print(yrow("MASTER-esteso", master_x))
print(yrow("MASTER+shape", master_xs))
# ---------- (B) per anno, per STRATEGIA singola ----------
print("\n" + "=" * 130)
print(" (B) RET% NETTO PER ANNO — per STRATEGIA singola (tutti gli sleeve)")
print("=" * 130)
allsl = {**S, **pairs, **tsm}
allsl = {**S, **pairs, **tsm, **shape}
cols = list(allsl)
print(f" {'Anno':>5s}" + "".join(f"{c.replace('_',''):>11s}" for c in cols))
print(" " + "-" * 124)
@@ -112,12 +117,14 @@ def main():
print(metric_block("MASTER-9", master9))
print(metric_block("+pairs", master_p))
print(metric_block("+TSM01", port_returns({**S, **tsm})))
print(metric_block("+shape", port_returns({**S, **shape})))
print(metric_block("MASTER-esteso", master_x))
print(metric_block("MASTER+shape", master_xs))
# correlazione media nuove vs master-9
dr_all = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in {**S, **pairs, **tsm}.items()})
dr_all = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in {**S, **pairs, **tsm, **shape}.items()})
corr = dr_all.corr(); old = list(S)
print(" " + "-" * 80)
for k in list(pairs) + list(tsm):
for k in list(pairs) + list(tsm) + list(shape):
print(f" corr {k:<11s} vs MASTER-9 = {corr.loc[k, old].mean():+.2f}")
# ---------- (D) numeri sobri ----------
+252
View File
@@ -0,0 +1,252 @@
"""SH01 EXIT LAB — harness onesto e CONDIVISO per la ricerca di STOP-LOSS su SH01.
SH01 (shape-ML, logit walk-forward W24 H12 th0.58) NON ha TP/SL: esce SOLO a
orizzonte H=12 barre. Live (2026-06-05) si è preso il crash ETH intero: 15.6%
in un trade (long 1727.8 → 1594.35, leva 2x). Domanda di ricerca: esiste uno SL
che taglia le code SENZA distruggere l'edge (che vive nell'asimmetria dei
winner, win-rate ~50%)?
CONTRATTO ANTI-LOOK-AHEAD (vincolante, verificato da agenti avversari):
- i livelli attivi nel bar j (`levels(..., j)`) possono usare SOLO dati <= j-1
(il worker live fissa i livelli al close del bar precedente; il bar j li tocca);
- `after_bar(..., j)` decide sul CLOSE del bar j (eseguibile al poll del tick);
- indicatori causali: usare l'indice j-1 (es. ctx["atr14"][j-1]).
FILL GAP-AWARE (lezione exit-lab 2026-06-04 + crash live 2026-06-05): lo stop
intrabar NON filla "al livello" se il bar apre già oltre → fill = worse(level,
open[j]). Senza questo il backtest ha un bias PRO stop-stretti (54% dei fill
era ottimista). Il crash di oggi (feed flat 2h → gap 1655→1600) è il caso reale.
PROTOCOLLO ANTI-OVERFIT (vincolante, = exit_lab):
- TRAIN = storico fino al 2023-11-01, OOS = dopo. SELEZIONE parametri SOLO
sul train; OOS guardato una volta per il verdetto.
- gate: miglioramento su ENTRAMBI gli asset (BTC e ETH), train E oos, con
plateau sulla griglia (non una cella isolata). Metrica primaria: Sharpe e
DD; il return non deve crollare (>= ~80% del baseline).
- fee 0.10% RT × leva su tutto il notional.
Baseline = exit a orizzonte puro (max_bars=H, nessun TP/SL): parità ESATTA con
`explore_lab.simulate` verificata da `parity_check()`.
uv run python scripts/analysis/sh01_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))
LEV, POS, FEE_RT = 3.0, 0.15, 0.001
OOS_START_MS = int(pd.Timestamp("2023-11-01", tz="UTC").value // 1e6)
ASSETS = ("BTC", "ETH")
CACHE = PROJECT_ROOT / "data" / "cache" / "sh01_exit_lab.pkl"
# ----------------------------------------------------------------------------- cache
def build_cache() -> dict:
"""Walk-forward SH01 (lento, ~minuti) → entries cache su disco."""
from scripts.analysis.explore_lab import get_df # noqa: E402
from scripts.analysis.shape_ml_research import ml_wf_entries, atr # noqa: E402
from scripts.strategies.SH01_shape_ml import CONFIG # noqa: E402
out = {}
for a in ASSETS:
df = get_df(a, "1h")
ents = ml_wf_entries(df, **CONFIG)
out[a] = {
"entries": [(int(e["i"]), int(e["d"]), int(e["max_bars"])) for e in ents],
"open": df["open"].values.astype(float),
"high": df["high"].values.astype(float),
"low": df["low"].values.astype(float),
"close": df["close"].values.astype(float),
"ts_ms": df["timestamp"].values.astype("int64"),
"atr14": atr(df, 14),
}
print(f" {a}: {len(ents)} entries, {len(df)} bars", flush=True)
CACHE.parent.mkdir(parents=True, exist_ok=True)
with open(CACHE, "wb") as f:
pickle.dump(out, f)
return out
def load_sleeves(refresh: bool = False) -> dict:
"""{asset: ctx}. ctx = {entries, open, high, low, close, ts_ms, atr14}."""
if CACHE.exists() and not refresh:
with open(CACHE, "rb") as f:
return pickle.load(f)
return build_cache()
# ----------------------------------------------------------------------------- policy
class ExitPolicy:
"""Contratto per le policy di stop su SH01 (solo SL/uscite anticipate: il
TP non esiste e l'exit a orizzonte max_bars resta SEMPRE il bound).
open_trade(ctx, i, d) -> state : livelli iniziali, SOLO dati <= i
levels(ctx, i, d, j, st) -> (sl, mode) attivi nel bar j, SOLO dati <= j-1.
sl=None → nessuno stop nel bar. mode: "intrabar" (tocco high/low, fill
gap-aware worse(sl, open[j])) o "close" (stop solo se il CLOSE sfonda
sl, uscita al close — stile EXIT-16).
after_bar(ctx, i, d, j, st) -> bool : uscita discrezionale al CLOSE del bar
j (dati <= j). Per giveback/time-stop/regime.
Lo state è un dict mutabile per-trade (trailing ecc.)."""
name = "base"
def open_trade(self, ctx: dict, i: int, d: int) -> dict:
return {}
def levels(self, ctx: dict, i: int, d: int, j: int, st: dict):
return None, "intrabar"
def after_bar(self, ctx: dict, i: int, d: int, j: int, st: dict) -> bool:
return False
# ----------------------------------------------------------------------------- engine
def simulate(ctx: dict, policy: ExitPolicy, fee_rt: float = FEE_RT,
lev: float = LEV, pos: float = POS,
t_lo: int | None = None, t_hi: int | None = None,
gap_fill: bool = True, lag_close_exit: bool = False) -> dict:
"""Engine intrabar con policy di stop. Entries non sovrapposte (come
explore_lab.simulate). t_lo/t_hi: filtro ms-epoch sull'ENTRY (train/oos).
gap_fill: fill stop intrabar a worse(sl, open[j]) — tenere True.
lag_close_exit: stress — le uscite "al close" fillano al close del bar
successivo (poll in ritardo)."""
o, h, l, c = ctx["open"], ctx["high"], ctx["low"], ctx["close"]
ts = ctx["ts_ms"]
n = len(c)
cap = peak = 1000.0
max_dd = 0.0
fee = fee_rt * lev
trades = wins = stops = 0
bars_in = 0
last_exit = -1
yearly: dict[int, float] = {}
rets: list[float] = []
trade_rows: list[dict] = []
for (i, d, mb) in ctx["entries"]:
if i <= last_exit or i + 1 >= n:
continue
if t_lo is not None and ts[i] < t_lo:
continue
if t_hi is not None and ts[i] >= t_hi:
continue
entry = c[i]
st = policy.open_trade(ctx, i, d)
exit_p, j, reason = c[min(i + mb, n - 1)], min(i + mb, n - 1), "time"
for k in range(1, mb + 1):
j = i + k
if j >= n:
j, exit_p, reason = n - 1, c[n - 1], "eod"
break
sl, mode = policy.levels(ctx, i, d, j, st)
if sl is not None and mode == "intrabar":
hit = (l[j] <= sl) if d == 1 else (h[j] >= sl)
if hit:
if gap_fill:
exit_p = min(sl, o[j]) if d == 1 else max(sl, o[j])
else:
exit_p = sl
reason = "stop"
break
if sl is not None and mode == "close":
brk = (c[j] < sl) if d == 1 else (c[j] > sl)
if brk:
jj = min(j + 1, n - 1) if lag_close_exit else j
exit_p, j, reason = c[jj], jj, "stop"
break
if policy.after_bar(ctx, i, d, j, st):
jj = min(j + 1, n - 1) if lag_close_exit else j
exit_p, j, reason = c[jj], jj, "policy"
break
if k == mb:
exit_p, reason = c[j], "time"
ret = (exit_p - entry) / entry * d * lev - fee
cb = cap
cap = max(cb + cb * pos * ret, 10.0)
peak = max(peak, cap)
max_dd = max(max_dd, (peak - cap) / peak)
trades += 1
wins += ret > 0
stops += reason == "stop"
bars_in += (j - i)
last_exit = j
rets.append(ret * pos)
yr = pd.Timestamp(ts[i], unit="ms", tz="UTC").year
yearly[yr] = yearly.get(yr, 0.0) + ret * 100
trade_rows.append({"i": i, "j": j, "d": d, "ret": ret, "reason": reason})
sharpe = (float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets)))
if len(rets) > 1 and np.std(rets) > 0 else 0.0)
return {
"trades": trades,
"win": wins / trades * 100 if trades else 0.0,
"stop_rate": stops / trades * 100 if trades else 0.0,
"ret": (cap / 1000 - 1) * 100,
"dd": max_dd * 100,
"sharpe": sharpe,
"worst": min(rets) * 100 if rets else 0.0, # peggior trade, % equity (ret*pos)
"yearly": yearly,
"_trades": trade_rows,
}
def evaluate(policy: ExitPolicy, sleeves: dict | None = None, **kw) -> dict:
"""train (fino al 2023-11-01) e oos (dopo) per BTC e ETH. Stampa sintesi."""
sleeves = sleeves or load_sleeves()
out = {}
for a in ASSETS:
ctx = sleeves[a]
tr = simulate(ctx, policy, t_hi=OOS_START_MS, **kw)
oo = simulate(ctx, policy, t_lo=OOS_START_MS, **kw)
out[a] = {"train": tr, "oos": oo}
print(f" {policy.name:<28s} {a}: "
f"TRAIN ret={tr['ret']:>+7.0f}% dd={tr['dd']:>4.0f}% shrp={tr['sharpe']:>5.2f} "
f"worst={tr['worst']:>+5.1f}% stop={tr['stop_rate']:>4.1f}% | "
f"OOS ret={oo['ret']:>+6.0f}% dd={oo['dd']:>4.0f}% shrp={oo['sharpe']:>5.2f} "
f"worst={oo['worst']:>+5.1f}%", flush=True)
return out
# ----------------------------------------------------------------------------- parity
def parity_check() -> bool:
"""Baseline (nessuno stop) == explore_lab.simulate sugli stessi entries."""
from scripts.analysis.explore_lab import get_df, simulate as ref_sim # noqa: E402
sleeves = load_sleeves()
ok = True
for a in ASSETS:
ctx = sleeves[a]
mine = simulate(ctx, ExitPolicy())
df = get_df(a, "1h")
ents = [{"i": i, "d": d, "max_bars": mb, "tp": None, "sl": None}
for (i, d, mb) in ctx["entries"]]
ref = ref_sim(ents, df)
same = (abs(mine["ret"] - ref["ret"]) < 1e-6 and mine["trades"] == ref["trades"]
and abs(mine["dd"] - ref["dd"]) < 1e-6)
ok &= same
print(f" parity {a}: mine ret={mine['ret']:+.2f}% trades={mine['trades']} "
f"| ref ret={ref['ret']:+.2f}% trades={ref['trades']} -> {'OK' if same else 'MISMATCH'}")
return ok
if __name__ == "__main__":
print("build cache (walk-forward SH01, puo' richiedere minuti)...")
load_sleeves(refresh="--refresh" in sys.argv)
print("parity check baseline vs explore_lab.simulate:")
ok = parity_check()
print("baseline train/oos:")
evaluate(ExitPolicy())
sys.exit(0 if ok else 1)
@@ -0,0 +1,205 @@
"""SH01 EXIT policy 01 — atr_fixed_intrabar.
SL fisso ad ATR, INTRABAR. In open_trade fissiamo il livello una volta sola:
sl = entry - d * k * ATR14[i] (entry = close[i], ATR14[i] noto a close[i])
levels() restituisce (sl, "intrabar") costante per tutta la vita del trade.
Il fill è gap-aware (worse(sl, open[j])) nell'engine — realistico sui crash a
gap (es. 2026-06-05: feed flat 2h -> gap ETH 1655->1600).
ANTI-LOOK-AHEAD: il livello usa SOLO dati <= i (ATR14[i], close[i]); levels usa
quel valore congelato (nessun dato del bar j). OK.
PROTOCOLLO: grid su k SOLO sul train (t_hi=OOS_START_MS). Plateau >=3 celle
adiacenti migliorative. Poi OOS una volta sulla config scelta + 2 vicine.
cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/01_atr_fixed_intrabar.py
"""
from __future__ import annotations
import sys
sys.path.insert(0, "/opt/docker/PythagorasGoal")
from scripts.analysis.sh01_exit_lab import ( # noqa: E402
ExitPolicy, OOS_START_MS, evaluate, load_sleeves, simulate,
)
class AtrFixedIntrabar(ExitPolicy):
def __init__(self, k: float):
self.k = float(k)
self.name = f"atr_fixed_intrabar k={k:.1f}"
def open_trade(self, ctx, i, d):
atr = ctx["atr14"][i]
entry = ctx["close"][i]
# se atr nan/0 (early bars) -> nessuno stop attivo
sl = entry - d * self.k * atr if atr == atr and atr > 0 else None
return {"sl": sl}
def levels(self, ctx, i, d, j, st):
return st["sl"], "intrabar"
def after_bar(self, ctx, i, d, j, st):
return False
# baseline numbers (exit a orizzonte puro) — dal prompt/harness
BASELINE = {
"BTC": {"train": dict(ret=127, dd=23, sharpe=2.09, worst=-5.5),
"oos": dict(ret=41, dd=8, sharpe=2.18, worst=-3.1)},
"ETH": {"train": dict(ret=-26, dd=61, sharpe=-0.16, worst=-14.9),
"oos": dict(ret=143, dd=7, sharpe=3.60, worst=-4.6)},
}
def _row(tag, a, r):
print(f" {tag:<10s} {a}: ret={r['ret']:>+7.0f}% dd={r['dd']:>4.0f}% "
f"shrp={r['sharpe']:>5.2f} worst={r['worst']:>+5.1f}% "
f"stop={r['stop_rate']:>4.1f}% trades={r['trades']}")
def main():
sleeves = load_sleeves()
KS = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
print("=" * 78)
print("TRAIN GRID (selezione SOLO sul train, t_hi=OOS_START)")
print("=" * 78)
# baseline train
print(" baseline (orizzonte puro):")
base = evaluate(ExitPolicy(), sleeves=sleeves)
print()
train = {} # k -> {asset: result}
for k in KS:
pol = AtrFixedIntrabar(k)
row = {}
for a in ("BTC", "ETH"):
ctx = sleeves[a]
row[a] = simulate(ctx, pol, t_hi=OOS_START_MS)
train[k] = row
print(f" k={k:.1f}")
_row("TRAIN", "BTC", row["BTC"])
_row("TRAIN", "ETH", row["ETH"])
print()
print("=" * 78)
print("PLATEAU CHECK (train): per ogni k, ETH sharpe up & dd down & worst up,")
print(" BTC sharpe>=95% & ret>=80% baseline")
print("=" * 78)
b_btc, b_eth = BASELINE["BTC"]["train"], BASELINE["ETH"]["train"]
improving = []
for k in KS:
bt, et = train[k]["BTC"], train[k]["ETH"]
eth_ok = (et["sharpe"] > b_eth["sharpe"] and et["dd"] < b_eth["dd"]
and et["worst"] > b_eth["worst"])
btc_ok = (bt["sharpe"] >= 0.95 * b_btc["sharpe"]
and bt["ret"] >= 0.80 * b_btc["ret"])
ok = eth_ok and btc_ok
if ok:
improving.append(k)
print(f" k={k:.1f} ETH_ok={eth_ok} BTC_ok={btc_ok} -> "
f"{'IMPROVING' if ok else '-'}")
print(f" improving cells (train): {improving}")
# plateau = >=3 k adiacenti improving
plateau = []
for idx in range(len(KS)):
run = []
for j in range(idx, len(KS)):
if KS[j] in improving:
run.append(KS[j])
else:
break
if len(run) >= 3 and len(run) > len(plateau):
plateau = run
print(f" longest adjacent improving run: {plateau} "
f"(plateau={'YES' if len(plateau) >= 3 else 'NO'})")
# scelgo centro del plateau (o miglior ETH sharpe fra gli improving)
chosen = None
if len(plateau) >= 3:
chosen = plateau[len(plateau) // 2]
elif improving:
chosen = max(improving, key=lambda k: train[k]["ETH"]["sharpe"])
print()
print("=" * 78)
if chosen is None:
print("NESSUNA cella migliorativa sul train -> verdetto NO (niente OOS).")
print("=" * 78)
return {"chosen": None, "plateau": plateau, "improving": improving,
"train": train, "oos": None}
print(f"CHOSEN k={chosen:.1f} -> OOS (config + 2 vicine), guardato UNA volta")
print("=" * 78)
ci = KS.index(chosen)
neigh = [KS[x] for x in (ci - 1, ci, ci + 1) if 0 <= x < len(KS)]
oos = {}
for k in neigh:
pol = AtrFixedIntrabar(k)
row = {}
for a in ("BTC", "ETH"):
ctx = sleeves[a]
row[a] = {
"train": train[k][a],
"oos": simulate(ctx, pol, t_lo=OOS_START_MS),
}
oos[k] = row
print(f" k={k:.1f}")
_row("TRAIN", "BTC", row["BTC"]["train"])
_row("OOS", "BTC", row["BTC"]["oos"])
_row("TRAIN", "ETH", row["ETH"]["train"])
_row("OOS", "ETH", row["ETH"]["oos"])
# gate finale sulla config scelta
print()
print("=" * 78)
print(f"GATE finale (k={chosen:.1f}):")
bt_tr, et_tr = oos[chosen]["BTC"]["train"], oos[chosen]["ETH"]["train"]
bt_oo, et_oo = oos[chosen]["BTC"]["oos"], oos[chosen]["ETH"]["oos"]
Bb_o, Be_o = BASELINE["BTC"]["oos"], BASELINE["ETH"]["oos"]
# a) ETH: sharpe up & dd down & worst up, train E oos
a_train = (et_tr["sharpe"] > b_eth["sharpe"] and et_tr["dd"] < b_eth["dd"]
and et_tr["worst"] > b_eth["worst"])
a_oos = (et_oo["sharpe"] > Be_o["sharpe"] and et_oo["dd"] < Be_o["dd"]
and et_oo["worst"] > Be_o["worst"])
cond_a = a_train and a_oos
# b) BTC sharpe>=95% & ret>=80% baseline (train e oos)
b_tr = (bt_tr["sharpe"] >= 0.95 * b_btc["sharpe"]
and bt_tr["ret"] >= 0.80 * b_btc["ret"])
b_oo = (bt_oo["sharpe"] >= 0.95 * Bb_o["sharpe"]
and bt_oo["ret"] >= 0.80 * Bb_o["ret"])
cond_b = b_tr and b_oo
# c) ret ETH oos >= 80% baseline
cond_c = et_oo["ret"] >= 0.80 * Be_o["ret"]
# d) plateau
cond_d = len(plateau) >= 3
print(f" a) ETH sharpe up & dd down & worst up (train&oos): {cond_a}")
print(f" train: shrp {et_tr['sharpe']:.2f} vs {b_eth['sharpe']:.2f} | "
f"dd {et_tr['dd']:.0f} vs {b_eth['dd']:.0f} | "
f"worst {et_tr['worst']:.1f} vs {b_eth['worst']:.1f}")
print(f" oos: shrp {et_oo['sharpe']:.2f} vs {Be_o['sharpe']:.2f} | "
f"dd {et_oo['dd']:.0f} vs {Be_o['dd']:.0f} | "
f"worst {et_oo['worst']:.1f} vs {Be_o['worst']:.1f}")
print(f" b) BTC sharpe>=95% & ret>=80% (train&oos): {cond_b}")
print(f" train: shrp {bt_tr['sharpe']:.2f} (>={0.95*b_btc['sharpe']:.2f}) | "
f"ret {bt_tr['ret']:.0f} (>={0.80*b_btc['ret']:.0f})")
print(f" oos: shrp {bt_oo['sharpe']:.2f} (>={0.95*Bb_o['sharpe']:.2f}) | "
f"ret {bt_oo['ret']:.0f} (>={0.80*Bb_o['ret']:.0f})")
print(f" c) ETH oos ret>=80% baseline ({0.80*Be_o['ret']:.0f}): {cond_c} "
f"(ret={et_oo['ret']:.0f})")
print(f" d) plateau: {cond_d} ({plateau})")
passes = cond_a and cond_b and cond_c and cond_d
print(f" PASSES GATE: {passes}")
print("=" * 78)
return {"chosen": chosen, "plateau": plateau, "improving": improving,
"passes": passes, "oos": oos, "conds": (cond_a, cond_b, cond_c, cond_d)}
if __name__ == "__main__":
main()
@@ -0,0 +1,94 @@
"""SH01 EXIT POLICY 02 — ATR-fixed stop, CLOSE-CONFIRM (stile EXIT-16 delle fade).
Stesso livello di stop fisso della policy 01 (intrabar):
sl = entry - d * k * ATR14[i] (fissato all'ingresso, dati <= i)
ma `levels` ritorna mode="close" → lo stop scatta SOLO se il CLOSE del bar j
sfonda il livello, con uscita al close (immune ai wick). E' il trasferimento a
SH01 della lezione EXIT-16 sulle fade: l'overshoot che buca lo stop e rientra e'
un falso negativo; aspettare la conferma del CLOSE evita di farsi stoppare dai
wick di un crash che poi rimbalza dentro l'orizzonte.
Griglia k in {1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0}.
ANTI-LOOK-AHEAD: sl usa SOLO atr14[i] e c[i] (dati <= i); mode="close" decide
sul close del bar j (dati <= j, eseguibile al poll). Nessun indicatore al bar j.
cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/02_atr_fixed_close_confirm.py
"""
from __future__ import annotations
import sys
sys.path.insert(0, "/opt/docker/PythagorasGoal")
from scripts.analysis.sh01_exit_lab import ( # noqa: E402
ExitPolicy, evaluate, load_sleeves, simulate, OOS_START_MS,
)
class AtrFixedCloseConfirm(ExitPolicy):
def __init__(self, k: float):
self.k = float(k)
self.name = f"atr_fixed_close k={k:g}"
def open_trade(self, ctx: dict, i: int, d: int) -> dict:
atr = ctx["atr14"][i]
entry = ctx["close"][i]
sl = entry - d * self.k * atr
return {"sl": float(sl)}
def levels(self, ctx: dict, i: int, d: int, j: int, st: dict):
return st["sl"], "close"
GRID = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
def _fmt(m):
return (f"ret={m['ret']:>+7.0f}% dd={m['dd']:>4.0f}% shrp={m['sharpe']:>5.2f} "
f"worst={m['worst']:>+5.1f}% stop={m['stop_rate']:>4.1f}% n={m['trades']}")
def main():
sleeves = load_sleeves()
# baseline (orizzonte puro) per riferimento
base = {}
for a in ("BTC", "ETH"):
ctx = sleeves[a]
base[a] = {
"train": simulate(ctx, ExitPolicy(), t_hi=OOS_START_MS),
"oos": simulate(ctx, ExitPolicy(), t_lo=OOS_START_MS),
}
print("=" * 110)
print("BASELINE (exit orizzonte puro):")
for a in ("BTC", "ETH"):
print(f" {a} TRAIN {_fmt(base[a]['train'])}")
print(f" {a} OOS {_fmt(base[a]['oos'])}")
print("=" * 110)
print("GRID — TRAIN ONLY (selezione parametri):")
train_res = {}
for k in GRID:
pol = AtrFixedCloseConfirm(k)
row = {}
for a in ("BTC", "ETH"):
row[a] = simulate(sleeves[a], pol, t_hi=OOS_START_MS)
train_res[k] = row
print(f" k={k:>3g} | BTC {_fmt(row['BTC'])}")
print(f" | ETH {_fmt(row['ETH'])}")
# verdetto OOS sulla config scelta + vicine (guardato una volta sola)
print("=" * 110)
print("OOS (verdetto, config scelta + vicine):")
for k in GRID:
pol = AtrFixedCloseConfirm(k)
print(f" k={k:>3g} | BTC TRAIN {_fmt(train_res[k]['BTC'])}")
for a in ("BTC", "ETH"):
oo = simulate(sleeves[a], pol, t_lo=OOS_START_MS)
print(f" | {a} OOS {_fmt(oo)}")
if __name__ == "__main__":
main()
@@ -0,0 +1,203 @@
"""SH01 exit policy 03 — pct_fixed.
SL fisso in PERCENTUALE del prezzo d'ingresso: sl = entry * (1 - d*p).
Griglia p in {0.01, 0.015, 0.02, 0.03, 0.04, 0.05}, modalita' {intrabar, close}
-> 12 celle. Il livello e' FISSO (deciso a open_trade su close[i]) -> nessun
look-ahead nei bar successivi (i livelli usano solo dati <= i).
Protocollo: grid SOLO sul train; plateau (>=3 celle adiacenti migliorative);
poi OOS una volta per la config scelta + le 2 vicine.
cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/03_pct_fixed.py
"""
from __future__ import annotations
import sys
sys.path.insert(0, "/opt/docker/PythagorasGoal")
from scripts.analysis.sh01_exit_lab import ( # noqa: E402
ASSETS, OOS_START_MS, ExitPolicy, load_sleeves, simulate,
)
class PctFixed(ExitPolicy):
"""SL fisso a una frazione p del prezzo d'ingresso."""
def __init__(self, p: float, mode: str = "intrabar"):
self.p = p
self.mode = mode
self.name = f"pct_fixed p={p:.3f} {mode}"
def open_trade(self, ctx, i, d):
entry = ctx["close"][i]
sl = entry * (1.0 - d * self.p) # long: sotto; short: sopra
return {"sl": sl}
def levels(self, ctx, i, d, j, st):
return st["sl"], self.mode
# ----------------------------------------------------------------------------- grid
P_GRID = [0.01, 0.015, 0.02, 0.03, 0.04, 0.05]
MODES = ["intrabar", "close"]
def _row(m):
return (f"ret={m['ret']:>+7.0f}% dd={m['dd']:>4.0f}% shrp={m['sharpe']:>5.2f} "
f"worst={m['worst']:>+5.1f}% stop={m['stop_rate']:>4.1f}%")
def main():
sleeves = load_sleeves()
# baseline (no stop)
print("=" * 110)
print("BASELINE (orizzonte puro, no SL) — TRAIN:")
base = {}
for a in ASSETS:
m = simulate(sleeves[a], ExitPolicy(), t_hi=OOS_START_MS)
base[a] = m
print(f" {a}: {_row(m)}")
print()
# ---------------- grid TRAIN only
print("=" * 110)
print("GRID — TRAIN ONLY (selezione qui):")
train = {}
for mode in MODES:
print(f"\n mode={mode}")
for p in P_GRID:
pol = PctFixed(p, mode)
row = {}
for a in ASSETS:
m = simulate(sleeves[a], pol, t_hi=OOS_START_MS)
row[a] = m
train[(mode, p)] = row
print(f" p={p:.3f} | BTC {_row(row['BTC'])}")
print(f" | ETH {_row(row['ETH'])}")
# improvement flags vs baseline on TRAIN: ETH gate (sharpe up, dd down, worst less neg)
# + BTC not degraded (sharpe>=0.95x, ret>=0.80x)
print("\n" + "=" * 110)
print("TRAIN improvement check (cell = migliorativa se ETH sharpe^ dd v worst^ AND BTC sharpe>=95% ret>=80%):")
bE, bB = base["ETH"], base["BTC"]
improved = {}
for mode in MODES:
flags = []
for p in P_GRID:
r = train[(mode, p)]
eth, btc = r["ETH"], r["BTC"]
eth_ok = (eth["sharpe"] > bE["sharpe"] and eth["dd"] < bE["dd"]
and eth["worst"] > bE["worst"])
btc_ok = (btc["sharpe"] >= 0.95 * bB["sharpe"]
and btc["ret"] >= 0.80 * bB["ret"])
cell = eth_ok and btc_ok
improved[(mode, p)] = cell
flags.append("Y" if cell else (".|E" if not eth_ok else ".|B"))
print(f" mode={mode:<9s} " + " ".join(f"p={p:.3f}:{f}" for p, f in zip(P_GRID, flags)))
# plateau detection: >=3 adjacent p's (same mode) all improved
print("\nPLATEAU (>=3 p adiacenti migliorativi nella stessa modalita'):")
plateau_cells = []
for mode in MODES:
run = []
runs = []
for p in P_GRID:
if improved[(mode, p)]:
run.append(p)
else:
if len(run) >= 1:
runs.append(run)
run = []
if run:
runs.append(run)
for run in runs:
mark = " <-- PLATEAU" if len(run) >= 3 else ""
print(f" mode={mode}: run {run} (len {len(run)}){mark}")
if len(run) >= 3:
plateau_cells.extend((mode, p) for p in run)
if not plateau_cells:
print("\nNESSUN PLATEAU sul train -> famiglia NON passa. OOS solo informativo.")
else:
print(f"\nplateau cells: {plateau_cells}")
# ---------------- pick best cell on TRAIN within plateau (or best overall if no plateau)
def score(cell):
r = train[cell]
# ETH train e' il banco di prova (baseline negativo) -> max ETH sharpe,
# tie-break ETH dd minore, poi BTC sharpe.
return (r["ETH"]["sharpe"], -r["ETH"]["dd"], r["BTC"]["sharpe"])
pool = plateau_cells if plateau_cells else list(train.keys())
best = max(pool, key=score)
print(f"\nCHOSEN (train): mode={best[0]} p={best[1]:.3f}")
# neighbors (same mode, adjacent p)
mode_b, p_b = best
idx = P_GRID.index(p_b)
neigh = [(mode_b, P_GRID[k]) for k in (idx - 1, idx, idx + 1) if 0 <= k < len(P_GRID)]
# ---------------- OOS verdict (chosen + 2 neighbors) — looked at ONCE
print("\n" + "=" * 110)
print("OOS VERDICT (config scelta + 2 vicine) — guardato UNA volta:")
print("\nBaseline OOS:")
base_oos = {}
for a in ASSETS:
m = simulate(sleeves[a], ExitPolicy(), t_lo=OOS_START_MS)
base_oos[a] = m
print(f" {a}: {_row(m)}")
chosen_oos = None
for cell in neigh:
pol = PctFixed(cell[1], cell[0])
tag = " <== CHOSEN" if cell == best else ""
print(f"\n mode={cell[0]} p={cell[1]:.3f}{tag}")
res = {}
for a in ASSETS:
tr = simulate(sleeves[a], pol, t_hi=OOS_START_MS)
oo = simulate(sleeves[a], pol, t_lo=OOS_START_MS)
res[a] = {"train": tr, "oos": oo}
print(f" {a} TRAIN {_row(tr)}")
print(f" {a} OOS {_row(oo)}")
if cell == best:
chosen_oos = res
# ---------------- gate evaluation on chosen
print("\n" + "=" * 110)
print("GATE (tutte e 4, train E oos):")
r = chosen_oos
bE_o, bB_o = base_oos["ETH"], base_oos["BTC"]
def g(label, cond):
print(f" [{'PASS' if cond else 'FAIL'}] {label}")
return cond
# a) ETH: sharpe^ dd v worst^ su train E oos
a_tr = (r["ETH"]["train"]["sharpe"] > bE["sharpe"]
and r["ETH"]["train"]["dd"] < bE["dd"]
and r["ETH"]["train"]["worst"] > bE["worst"])
a_oo = (r["ETH"]["oos"]["sharpe"] > bE_o["sharpe"]
and r["ETH"]["oos"]["dd"] < bE_o["dd"]
and r["ETH"]["oos"]["worst"] > bE_o["worst"])
A = g("a) ETH sharpe^ dd v worst^ (train E oos)", a_tr and a_oo)
# b) BTC sharpe>=95% ret>=80% baseline (train E oos)
b_tr = (r["BTC"]["train"]["sharpe"] >= 0.95 * bB["sharpe"]
and r["BTC"]["train"]["ret"] >= 0.80 * bB["ret"])
b_oo = (r["BTC"]["oos"]["sharpe"] >= 0.95 * bB_o["sharpe"]
and r["BTC"]["oos"]["ret"] >= 0.80 * bB_o["ret"])
B = g("b) BTC sharpe>=95% ret>=80% (train E oos)", b_tr and b_oo)
# c) ret ETH oos >= 80% baseline
C = g("c) ret ETH oos >= 80% baseline", r["ETH"]["oos"]["ret"] >= 0.80 * bE_o["ret"])
# d) plateau
D = g("d) plateau confermato", bool(plateau_cells) and best in plateau_cells)
passes = A and B and C and D
print(f"\n ==> GATE {'PASS' if passes else 'FAIL'}")
return passes
if __name__ == "__main__":
main()
@@ -0,0 +1,238 @@
"""SH01 EXIT policy 04 — chandelier_trail.
Trailing chandelier CAUSALE. Lo state tiene il running peak dei CLOSE da i a
j-1; lo stop per il bar j e':
long : sl = peak - k * ATR14[j-1]
short: sl = trough + k * ATR14[j-1] (specchiato)
Il peak/trough viene aggiornato dentro levels() usando SOLO close[j-1] (dato
gia' chiuso quando il worker fissa il livello per il bar j). ATR14[j-1] e'
causale. Griglia k x mode {intrabar, close}.
ANTI-LOOK-AHEAD: levels(j) usa peak su close[<=j-1] e ATR14[j-1] -> nessun dato
del bar j. open_trade usa solo close[i]/ATR14[i]. OK.
Profilo SH01: hold a orizzonte (momentum), win ~50%, edge nell'asimmetria dei
winner. Sulle fade la famiglia trailing e' stata SCARTATA (taglia i winner che
vanno in drawdown e poi recuperano) -> qui si testa se su SH01 va diversamente,
pronti a un NO.
PROTOCOLLO: grid (k x mode) SOLO sul train (t_hi=OOS_START_MS). Plateau >=3
celle adiacenti migliorative (adiacenza su k, mode fisso). Poi OOS una volta
sulla config scelta + 2 vicine.
cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/04_chandelier_trail.py
"""
from __future__ import annotations
import sys
sys.path.insert(0, "/opt/docker/PythagorasGoal")
from scripts.analysis.sh01_exit_lab import ( # noqa: E402
ExitPolicy, OOS_START_MS, evaluate, load_sleeves, simulate,
)
class ChandelierTrail(ExitPolicy):
def __init__(self, k: float, mode: str = "intrabar"):
self.k = float(k)
self.mode = mode
self.name = f"chandelier_trail k={k:.1f} {mode}"
def open_trade(self, ctx, i, d):
# peak/trough inizializzato all'entry (close[i]); atr14[i] noto a close[i].
entry = ctx["close"][i]
return {"peak": entry, "trough": entry}
def levels(self, ctx, i, d, j, st):
close = ctx["close"]
atr = ctx["atr14"]
# aggiorna il running peak/trough con close[j-1] (gia' chiuso). j>=i+1
# sempre nell'engine, quindi j-1>=i e' definito.
cprev = close[j - 1]
if cprev > st["peak"]:
st["peak"] = cprev
if cprev < st["trough"]:
st["trough"] = cprev
a = atr[j - 1]
if not (a == a and a > 0): # nan/0 -> nessuno stop attivo
return None, self.mode
if d == 1:
sl = st["peak"] - self.k * a
else:
sl = st["trough"] + self.k * a
return sl, self.mode
def after_bar(self, ctx, i, d, j, st):
return False
# baseline numbers (exit a orizzonte puro) — dal prompt/harness
BASELINE = {
"BTC": {"train": dict(ret=127, dd=23, sharpe=2.09, worst=-5.5),
"oos": dict(ret=41, dd=8, sharpe=2.18, worst=-3.1)},
"ETH": {"train": dict(ret=-26, dd=61, sharpe=-0.16, worst=-14.9),
"oos": dict(ret=143, dd=7, sharpe=3.60, worst=-4.6)},
}
KS = [2.0, 2.5, 3.0, 4.0, 5.0]
MODES = ["intrabar", "close"]
def _row(tag, a, r):
print(f" {tag:<10s} {a}: ret={r['ret']:>+7.0f}% dd={r['dd']:>4.0f}% "
f"shrp={r['sharpe']:>5.2f} worst={r['worst']:>+5.1f}% "
f"stop={r['stop_rate']:>4.1f}% trades={r['trades']}")
def _eth_ok(et, b_eth):
return (et["sharpe"] > b_eth["sharpe"] and et["dd"] < b_eth["dd"]
and et["worst"] > b_eth["worst"])
def _btc_ok(bt, b_btc):
return (bt["sharpe"] >= 0.95 * b_btc["sharpe"]
and bt["ret"] >= 0.80 * b_btc["ret"])
def main():
sleeves = load_sleeves()
b_btc, b_eth = BASELINE["BTC"]["train"], BASELINE["ETH"]["train"]
print("=" * 78)
print("TRAIN GRID (selezione SOLO sul train, t_hi=OOS_START)")
print("=" * 78)
print(" baseline (orizzonte puro):")
evaluate(ExitPolicy(), sleeves=sleeves)
print()
# train[(mode,k)] -> {asset: result}
train = {}
for mode in MODES:
print(f" --- mode={mode} ---")
for k in KS:
pol = ChandelierTrail(k, mode)
row = {}
for a in ("BTC", "ETH"):
row[a] = simulate(sleeves[a], pol, t_hi=OOS_START_MS)
train[(mode, k)] = row
print(f" k={k:.1f} ({mode})")
_row("TRAIN", "BTC", row["BTC"])
_row("TRAIN", "ETH", row["ETH"])
print()
print("=" * 78)
print("PLATEAU CHECK (train): ETH sharpe up & dd down & worst up,")
print(" BTC sharpe>=95% & ret>=80% baseline")
print("=" * 78)
improving = {} # mode -> [k...]
for mode in MODES:
imp = []
for k in KS:
bt, et = train[(mode, k)]["BTC"], train[(mode, k)]["ETH"]
eth_ok = _eth_ok(et, b_eth)
btc_ok = _btc_ok(bt, b_btc)
ok = eth_ok and btc_ok
if ok:
imp.append(k)
print(f" {mode:<9s} k={k:.1f} ETH_ok={eth_ok} BTC_ok={btc_ok} -> "
f"{'IMPROVING' if ok else '-'}")
improving[mode] = imp
print(f" improving cells ({mode}): {imp}")
# plateau = >=3 k adiacenti improving in QUALCHE mode
best_plateau, best_mode = [], None
for mode in MODES:
imp = improving[mode]
for idx in range(len(KS)):
run = []
for j in range(idx, len(KS)):
if KS[j] in imp:
run.append(KS[j])
else:
break
if len(run) >= 3 and len(run) > len(best_plateau):
best_plateau, best_mode = run, mode
print(f" longest adjacent improving run: {best_plateau} (mode={best_mode}) "
f"plateau={'YES' if len(best_plateau) >= 3 else 'NO'}")
chosen = None
if len(best_plateau) >= 3:
chosen_k = best_plateau[len(best_plateau) // 2]
chosen = (best_mode, chosen_k)
else:
# fallback: miglior ETH sharpe fra tutti gli improving (per diagnosi OOS)
cands = [(m, k) for m in MODES for k in improving[m]]
if cands:
chosen = max(cands, key=lambda mk: train[mk]["ETH"]["sharpe"])
print()
print("=" * 78)
if chosen is None:
print("NESSUNA cella migliorativa sul train -> verdetto NO (niente OOS).")
print("=" * 78)
return {"chosen": None, "plateau": best_plateau, "improving": improving,
"passes": False, "train": train}
c_mode, c_k = chosen
print(f"CHOSEN k={c_k:.1f} mode={c_mode} -> OOS (config + 2 vicine k), 1 volta")
print("=" * 78)
ci = KS.index(c_k)
neigh = [KS[x] for x in (ci - 1, ci, ci + 1) if 0 <= x < len(KS)]
oos = {}
for k in neigh:
pol = ChandelierTrail(k, c_mode)
row = {}
for a in ("BTC", "ETH"):
row[a] = {"train": train[(c_mode, k)][a],
"oos": simulate(sleeves[a], pol, t_lo=OOS_START_MS)}
oos[k] = row
print(f" k={k:.1f} ({c_mode})")
_row("TRAIN", "BTC", row["BTC"]["train"])
_row("OOS", "BTC", row["BTC"]["oos"])
_row("TRAIN", "ETH", row["ETH"]["train"])
_row("OOS", "ETH", row["ETH"]["oos"])
print()
print("=" * 78)
print(f"GATE finale (k={c_k:.1f} mode={c_mode}):")
bt_tr, et_tr = oos[c_k]["BTC"]["train"], oos[c_k]["ETH"]["train"]
bt_oo, et_oo = oos[c_k]["BTC"]["oos"], oos[c_k]["ETH"]["oos"]
Bb_o, Be_o = BASELINE["BTC"]["oos"], BASELINE["ETH"]["oos"]
a_train = _eth_ok(et_tr, b_eth)
a_oos = (et_oo["sharpe"] > Be_o["sharpe"] and et_oo["dd"] < Be_o["dd"]
and et_oo["worst"] > Be_o["worst"])
cond_a = a_train and a_oos
b_tr = _btc_ok(bt_tr, b_btc)
b_oo = (bt_oo["sharpe"] >= 0.95 * Bb_o["sharpe"]
and bt_oo["ret"] >= 0.80 * Bb_o["ret"])
cond_b = b_tr and b_oo
cond_c = et_oo["ret"] >= 0.80 * Be_o["ret"]
cond_d = len(best_plateau) >= 3
print(f" a) ETH sharpe up & dd down & worst up (train&oos): {cond_a}")
print(f" train: shrp {et_tr['sharpe']:.2f} vs {b_eth['sharpe']:.2f} | "
f"dd {et_tr['dd']:.0f} vs {b_eth['dd']:.0f} | "
f"worst {et_tr['worst']:.1f} vs {b_eth['worst']:.1f}")
print(f" oos: shrp {et_oo['sharpe']:.2f} vs {Be_o['sharpe']:.2f} | "
f"dd {et_oo['dd']:.0f} vs {Be_o['dd']:.0f} | "
f"worst {et_oo['worst']:.1f} vs {Be_o['worst']:.1f}")
print(f" b) BTC sharpe>=95% & ret>=80% (train&oos): {cond_b}")
print(f" train: shrp {bt_tr['sharpe']:.2f} (>={0.95*b_btc['sharpe']:.2f}) | "
f"ret {bt_tr['ret']:.0f} (>={0.80*b_btc['ret']:.0f})")
print(f" oos: shrp {bt_oo['sharpe']:.2f} (>={0.95*Bb_o['sharpe']:.2f}) | "
f"ret {bt_oo['ret']:.0f} (>={0.80*Bb_o['ret']:.0f})")
print(f" c) ETH oos ret>=80% baseline ({0.80*Be_o['ret']:.0f}): {cond_c} "
f"(ret={et_oo['ret']:.0f})")
print(f" d) plateau: {cond_d} ({best_plateau} mode={best_mode})")
passes = cond_a and cond_b and cond_c and cond_d
print(f" PASSES GATE: {passes}")
print("=" * 78)
return {"chosen": chosen, "plateau": best_plateau, "improving": improving,
"passes": passes, "oos": oos, "conds": (cond_a, cond_b, cond_c, cond_d)}
if __name__ == "__main__":
main()
@@ -0,0 +1,267 @@
"""SH01 EXIT policy 05 — breakeven_ratchet.
Disaster-stop ampio + ratchet a breakeven. Idea: NON tagliare i loser presto
(quello distrugge l'edge, lezione exit-lab sulle fade), ma proteggere SOLO i
trade gia' andati in profitto, alzando lo stop a entry (o entry+b*ATR) una volta
che il move e' partito. Il disaster-stop iniziale (4*ATR14[i]) taglia la coda
estrema (il crash ETH -15.6%) senza interferire coi trade normali.
Logica:
long :
sl_init = entry - 4 * ATR14[i]
quando close[<=j-1] >= entry + a * ATR14[i] -> sl = entry + b * ATR14[i]
short: specchiato.
RATCHET: una volta alzato lo stop a breakeven, NON riscende (st["armed"]).
Lo stop iniziale (4*ATR14[i]) e' FISSO sul valore noto a close[i] (open_trade);
il ratchet si arma leggendo close[j-1] (gia' chiuso quando il worker fissa il
livello per il bar j) -> nessun dato del bar j. ATR14[i] e' causale.
ANTI-LOOK-AHEAD: open_trade usa solo close[i]/ATR14[i]; levels(j) legge solo
close[j-1] per decidere l'arming e ATR14[i] (gia' fissato). OK.
Griglia: a in {0.5, 1.0, 1.5, 2.0} (soglia di arming in ATR) x b in {0, 0.25}
(dove va lo stop una volta armato: entry o entry+0.25 ATR) x mode {intrabar,
close}. Il disaster-stop 4*ATR e' fisso (la coda da tagliare e' a -15%, ~3 ATR).
Profilo SH01: hold a orizzonte, win ~50%, edge nell'asimmetria. Il rischio del
breakeven e' di chiudere a 0 i winner che vanno prima in drawdown leggero e poi
recuperano -> pronti a un NO se BTC degrada.
PROTOCOLLO: grid SOLO sul train (t_hi=OOS_START_MS). Plateau >=3 celle adiacenti
migliorative (adiacenza su a, con b/mode fissi). Poi OOS una volta su config +
2 vicine.
cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/05_breakeven_ratchet.py
"""
from __future__ import annotations
import sys
sys.path.insert(0, "/opt/docker/PythagorasGoal")
from scripts.analysis.sh01_exit_lab import ( # noqa: E402
ExitPolicy, OOS_START_MS, evaluate, load_sleeves, simulate,
)
DISASTER_ATR = 4.0
class BreakevenRatchet(ExitPolicy):
def __init__(self, a: float, b: float = 0.0, mode: str = "intrabar"):
self.a = float(a)
self.b = float(b)
self.mode = mode
self.name = f"be_ratchet a={a:.1f} b={b:.2f} {mode}"
def open_trade(self, ctx, i, d):
entry = ctx["close"][i]
a14 = ctx["atr14"][i]
if not (a14 == a14 and a14 > 0):
# nessun ATR valido -> nessuno stop (degenera a baseline su quel trade)
return {"entry": entry, "atr": None, "sl_disaster": None, "armed": False}
if d == 1:
sl_dis = entry - DISASTER_ATR * a14
else:
sl_dis = entry + DISASTER_ATR * a14
return {"entry": entry, "atr": a14, "sl_disaster": sl_dis, "armed": False}
def levels(self, ctx, i, d, j, st):
a14 = st["atr"]
if a14 is None:
return None, self.mode
entry = st["entry"]
cprev = ctx["close"][j - 1] # gia' chiuso quando si fissa il livello per j
# arming del ratchet (una volta armato resta armato)
if not st["armed"]:
if d == 1:
if cprev >= entry + self.a * a14:
st["armed"] = True
else:
if cprev <= entry - self.a * a14:
st["armed"] = True
if st["armed"]:
if d == 1:
sl = entry + self.b * a14
else:
sl = entry - self.b * a14
else:
sl = st["sl_disaster"]
return sl, self.mode
def after_bar(self, ctx, i, d, j, st):
return False
# baseline numbers (exit a orizzonte puro) — dal prompt/harness
BASELINE = {
"BTC": {"train": dict(ret=127, dd=23, sharpe=2.09, worst=-5.5),
"oos": dict(ret=41, dd=8, sharpe=2.18, worst=-3.1)},
"ETH": {"train": dict(ret=-26, dd=61, sharpe=-0.16, worst=-14.9),
"oos": dict(ret=143, dd=7, sharpe=3.60, worst=-4.6)},
}
A_VALS = [0.5, 1.0, 1.5, 2.0]
B_VALS = [0.0, 0.25]
MODES = ["intrabar", "close"]
def _row(tag, a, r):
print(f" {tag:<10s} {a}: ret={r['ret']:>+7.0f}% dd={r['dd']:>4.0f}% "
f"shrp={r['sharpe']:>5.2f} worst={r['worst']:>+5.1f}% "
f"stop={r['stop_rate']:>4.1f}% trades={r['trades']}")
def _eth_ok(et, b_eth):
return (et["sharpe"] > b_eth["sharpe"] and et["dd"] < b_eth["dd"]
and et["worst"] > b_eth["worst"])
def _btc_ok(bt, b_btc):
return (bt["sharpe"] >= 0.95 * b_btc["sharpe"]
and bt["ret"] >= 0.80 * b_btc["ret"])
def main():
sleeves = load_sleeves()
b_btc, b_eth = BASELINE["BTC"]["train"], BASELINE["ETH"]["train"]
print("=" * 78)
print("TRAIN GRID (selezione SOLO sul train, t_hi=OOS_START)")
print("=" * 78)
print(" baseline (orizzonte puro):")
evaluate(ExitPolicy(), sleeves=sleeves)
print()
# train[(mode,b,a)] -> {asset: result}
train = {}
for mode in MODES:
for b in B_VALS:
print(f" --- mode={mode} b={b:.2f} ---")
for a in A_VALS:
pol = BreakevenRatchet(a, b, mode)
row = {}
for asset in ("BTC", "ETH"):
row[asset] = simulate(sleeves[asset], pol, t_hi=OOS_START_MS)
train[(mode, b, a)] = row
print(f" a={a:.1f} b={b:.2f} ({mode})")
_row("TRAIN", "BTC", row["BTC"])
_row("TRAIN", "ETH", row["ETH"])
print()
print("=" * 78)
print("PLATEAU CHECK (train): ETH sharpe up & dd down & worst up,")
print(" BTC sharpe>=95% & ret>=80% baseline")
print("=" * 78)
# improving[(mode,b)] -> [a...]
improving = {}
for mode in MODES:
for b in B_VALS:
imp = []
for a in A_VALS:
bt, et = train[(mode, b, a)]["BTC"], train[(mode, b, a)]["ETH"]
eth_ok = _eth_ok(et, b_eth)
btc_ok = _btc_ok(bt, b_btc)
ok = eth_ok and btc_ok
if ok:
imp.append(a)
print(f" {mode:<9s} b={b:.2f} a={a:.1f} ETH_ok={eth_ok} "
f"BTC_ok={btc_ok} -> {'IMPROVING' if ok else '-'}")
improving[(mode, b)] = imp
print(f" improving cells ({mode}, b={b:.2f}): {imp}")
# plateau = >=3 a adiacenti improving in QUALCHE (mode,b)
best_plateau, best_key = [], None
for key in improving:
imp = improving[key]
for idx in range(len(A_VALS)):
run = []
for jj in range(idx, len(A_VALS)):
if A_VALS[jj] in imp:
run.append(A_VALS[jj])
else:
break
if len(run) >= 3 and len(run) > len(best_plateau):
best_plateau, best_key = run, key
print(f" longest adjacent improving run: {best_plateau} (key={best_key}) "
f"plateau={'YES' if len(best_plateau) >= 3 else 'NO'}")
chosen = None
if len(best_plateau) >= 3:
chosen_a = best_plateau[len(best_plateau) // 2]
chosen = (best_key[0], best_key[1], chosen_a)
else:
cands = [(m, b, a) for (m, b) in improving for a in improving[(m, b)]]
if cands:
chosen = max(cands, key=lambda mba: train[mba]["ETH"]["sharpe"])
print()
print("=" * 78)
if chosen is None:
print("NESSUNA cella migliorativa sul train -> verdetto NO (niente OOS).")
print("=" * 78)
return {"chosen": None, "plateau": best_plateau, "improving": improving,
"passes": False, "train": train}
c_mode, c_b, c_a = chosen
print(f"CHOSEN a={c_a:.1f} b={c_b:.2f} mode={c_mode} -> OOS (config + 2 vicine a)")
print("=" * 78)
ai = A_VALS.index(c_a)
neigh = [A_VALS[x] for x in (ai - 1, ai, ai + 1) if 0 <= x < len(A_VALS)]
oos = {}
for a in neigh:
pol = BreakevenRatchet(a, c_b, c_mode)
row = {}
for asset in ("BTC", "ETH"):
row[asset] = {"train": train[(c_mode, c_b, a)][asset],
"oos": simulate(sleeves[asset], pol, t_lo=OOS_START_MS)}
oos[a] = row
print(f" a={a:.1f} b={c_b:.2f} ({c_mode})")
_row("TRAIN", "BTC", row["BTC"]["train"])
_row("OOS", "BTC", row["BTC"]["oos"])
_row("TRAIN", "ETH", row["ETH"]["train"])
_row("OOS", "ETH", row["ETH"]["oos"])
print()
print("=" * 78)
print(f"GATE finale (a={c_a:.1f} b={c_b:.2f} mode={c_mode}):")
bt_tr, et_tr = oos[c_a]["BTC"]["train"], oos[c_a]["ETH"]["train"]
bt_oo, et_oo = oos[c_a]["BTC"]["oos"], oos[c_a]["ETH"]["oos"]
Bb_o, Be_o = BASELINE["BTC"]["oos"], BASELINE["ETH"]["oos"]
a_train = _eth_ok(et_tr, b_eth)
a_oos = (et_oo["sharpe"] > Be_o["sharpe"] and et_oo["dd"] < Be_o["dd"]
and et_oo["worst"] > Be_o["worst"])
cond_a = a_train and a_oos
b_tr = _btc_ok(bt_tr, b_btc)
b_oo = (bt_oo["sharpe"] >= 0.95 * Bb_o["sharpe"]
and bt_oo["ret"] >= 0.80 * Bb_o["ret"])
cond_b = b_tr and b_oo
cond_c = et_oo["ret"] >= 0.80 * Be_o["ret"]
cond_d = len(best_plateau) >= 3
print(f" a) ETH sharpe up & dd down & worst up (train&oos): {cond_a}")
print(f" train: shrp {et_tr['sharpe']:.2f} vs {b_eth['sharpe']:.2f} | "
f"dd {et_tr['dd']:.0f} vs {b_eth['dd']:.0f} | "
f"worst {et_tr['worst']:.1f} vs {b_eth['worst']:.1f}")
print(f" oos: shrp {et_oo['sharpe']:.2f} vs {Be_o['sharpe']:.2f} | "
f"dd {et_oo['dd']:.0f} vs {Be_o['dd']:.0f} | "
f"worst {et_oo['worst']:.1f} vs {Be_o['worst']:.1f}")
print(f" b) BTC sharpe>=95% & ret>=80% (train&oos): {cond_b}")
print(f" train: shrp {bt_tr['sharpe']:.2f} (>={0.95*b_btc['sharpe']:.2f}) | "
f"ret {bt_tr['ret']:.0f} (>={0.80*b_btc['ret']:.0f})")
print(f" oos: shrp {bt_oo['sharpe']:.2f} (>={0.95*Bb_o['sharpe']:.2f}) | "
f"ret {bt_oo['ret']:.0f} (>={0.80*Bb_o['ret']:.0f})")
print(f" c) ETH oos ret>=80% baseline ({0.80*Be_o['ret']:.0f}): {cond_c} "
f"(ret={et_oo['ret']:.0f})")
print(f" d) plateau: {cond_d} ({best_plateau} key={best_key})")
passes = cond_a and cond_b and cond_c and cond_d
print(f" PASSES GATE: {passes}")
print("=" * 78)
return {"chosen": chosen, "plateau": best_plateau, "improving": improving,
"passes": passes, "oos": oos, "conds": (cond_a, cond_b, cond_c, cond_d)}
if __name__ == "__main__":
main()
@@ -0,0 +1,260 @@
"""SH01 EXIT policy 06 — giveback (profit-protection).
Protezione del profitto via after_bar (mode "close" implicito: uscita sempre al
close del bar j). Lo state traccia il PEAK FAVOREVOLE dei close da i (per long il
max close; per short il min close, specchiato). Si esce al close del bar j se:
giveback = (peak_fav - close[j]) * d >= g * ATR14[j-1] (retrace)
profit_at_peak = (peak_fav - entry) * d >= m * ATR_ref (era in gain)
cioe' il trade aveva raggiunto un profitto di almeno m*ATR e poi ha ritracciato
di g*ATR dal massimo favorevole. Idea: lascia correre il momentum SH01 finche'
sale, ma protegge il guadagno quando rifiata — senza toccare i trade che non sono
mai andati in profitto (quelli muoiono a orizzonte come nel baseline, cosi' non
si crea un trailing-stop mascherato che taglia i winner-in-drawdown).
Griglia g in {1.0, 1.5, 2.0, 3.0} x m in {0.5, 1.0}.
ANTI-LOOK-AHEAD: after_bar(j) decide sul CLOSE del bar j (dato <= j, eseguibile
al poll). Il peak favorevole include close[j] (gia' chiuso quando si decide).
ATR di riferimento: usiamo ATR14[j-1] per la soglia di giveback (causale, come
i livelli) e ATR14[i] per la soglia di profit-at-peak (noto a close[i], cioe'
all'apertura del trade). open_trade usa solo close[i]/ATR14[i]. Nessun dato di
un bar futuro. OK.
Profilo SH01: hold a orizzonte (momentum), win ~50%, edge nell'asimmetria dei
winner. La famiglia "ride/trailing" sulle fade e' stata SCARTATA; il giveback e'
una variante condizionata-al-profitto, pensata per NON toccare i loser-che-
recuperano. Pronti a un NO se taglia comunque l'edge.
PROTOCOLLO: grid (g x m) SOLO sul train (t_hi=OOS_START_MS). Plateau >=3 celle
adiacenti migliorative (adiacenza su g, m fisso). Poi OOS una volta sulla config
scelta + 2 vicine.
cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/06_giveback.py
"""
from __future__ import annotations
import sys
sys.path.insert(0, "/opt/docker/PythagorasGoal")
from scripts.analysis.sh01_exit_lab import ( # noqa: E402
ExitPolicy, OOS_START_MS, evaluate, load_sleeves, simulate,
)
class Giveback(ExitPolicy):
def __init__(self, g: float, m: float):
self.g = float(g)
self.m = float(m)
self.name = f"giveback g={g:.1f} m={m:.1f}"
def open_trade(self, ctx, i, d):
entry = ctx["close"][i]
a0 = ctx["atr14"][i]
a0 = float(a0) if (a0 == a0 and a0 > 0) else 0.0
# peak favorevole inizializzato all'entry; atr_ref per il profit-at-peak.
return {"entry": entry, "peak": entry, "atr0": a0}
def levels(self, ctx, i, d, j, st):
# nessuno stop a livello: il giveback e' tutto in after_bar.
return None, "close"
def after_bar(self, ctx, i, d, j, st):
close = ctx["close"]
atr = ctx["atr14"]
cj = close[j]
# aggiorna il peak FAVOREVOLE con close[j] (gia' chiuso quando decidiamo).
# per long: max close; per short: min close (= peak favorevole specchiato).
if d == 1:
if cj > st["peak"]:
st["peak"] = cj
else:
if cj < st["peak"]:
st["peak"] = cj
a_gb = atr[j - 1]
if not (a_gb == a_gb and a_gb > 0):
return False
a_pk = st["atr0"]
if a_pk <= 0:
return False
# profitto raggiunto al peak favorevole (in direzione del trade).
profit_at_peak = (st["peak"] - st["entry"]) * d
if profit_at_peak < self.m * a_pk:
return False
# ritracciamento dal peak favorevole fino al close corrente.
giveback = (st["peak"] - cj) * d
return giveback >= self.g * a_gb
# baseline numbers (exit a orizzonte puro) — dal prompt/harness
BASELINE = {
"BTC": {"train": dict(ret=127, dd=23, sharpe=2.09, worst=-5.5),
"oos": dict(ret=41, dd=8, sharpe=2.18, worst=-3.1)},
"ETH": {"train": dict(ret=-26, dd=61, sharpe=-0.16, worst=-14.9),
"oos": dict(ret=143, dd=7, sharpe=3.60, worst=-4.6)},
}
GS = [1.0, 1.5, 2.0, 3.0]
MS = [0.5, 1.0]
def _row(tag, a, r):
print(f" {tag:<10s} {a}: ret={r['ret']:>+7.0f}% dd={r['dd']:>4.0f}% "
f"shrp={r['sharpe']:>5.2f} worst={r['worst']:>+5.1f}% "
f"stop={r['stop_rate']:>4.1f}% trades={r['trades']}")
def _eth_ok(et, b_eth):
return (et["sharpe"] > b_eth["sharpe"] and et["dd"] < b_eth["dd"]
and et["worst"] > b_eth["worst"])
def _btc_ok(bt, b_btc):
return (bt["sharpe"] >= 0.95 * b_btc["sharpe"]
and bt["ret"] >= 0.80 * b_btc["ret"])
def main():
sleeves = load_sleeves()
b_btc, b_eth = BASELINE["BTC"]["train"], BASELINE["ETH"]["train"]
print("=" * 78)
print("TRAIN GRID (selezione SOLO sul train, t_hi=OOS_START)")
print("=" * 78)
print(" baseline (orizzonte puro):")
evaluate(ExitPolicy(), sleeves=sleeves)
print()
# train[(m,g)] -> {asset: result}
train = {}
for m in MS:
print(f" --- m={m:.1f} (profit-at-peak threshold) ---")
for g in GS:
pol = Giveback(g, m)
row = {}
for a in ("BTC", "ETH"):
row[a] = simulate(sleeves[a], pol, t_hi=OOS_START_MS)
train[(m, g)] = row
print(f" g={g:.1f} m={m:.1f}")
_row("TRAIN", "BTC", row["BTC"])
_row("TRAIN", "ETH", row["ETH"])
print()
print("=" * 78)
print("PLATEAU CHECK (train): ETH sharpe up & dd down & worst up,")
print(" BTC sharpe>=95% & ret>=80% baseline")
print("=" * 78)
improving = {} # m -> [g...]
for m in MS:
imp = []
for g in GS:
bt, et = train[(m, g)]["BTC"], train[(m, g)]["ETH"]
eth_ok = _eth_ok(et, b_eth)
btc_ok = _btc_ok(bt, b_btc)
ok = eth_ok and btc_ok
if ok:
imp.append(g)
print(f" m={m:.1f} g={g:.1f} ETH_ok={eth_ok} BTC_ok={btc_ok} -> "
f"{'IMPROVING' if ok else '-'}")
improving[m] = imp
print(f" improving cells (m={m:.1f}): {imp}")
# plateau = >=3 g adiacenti improving in QUALCHE m
best_plateau, best_m = [], None
for m in MS:
imp = improving[m]
for idx in range(len(GS)):
run = []
for j in range(idx, len(GS)):
if GS[j] in imp:
run.append(GS[j])
else:
break
if len(run) >= 3 and len(run) > len(best_plateau):
best_plateau, best_m = run, m
print(f" longest adjacent improving run: {best_plateau} (m={best_m}) "
f"plateau={'YES' if len(best_plateau) >= 3 else 'NO'}")
chosen = None
if len(best_plateau) >= 3:
chosen_g = best_plateau[len(best_plateau) // 2]
chosen = (best_m, chosen_g)
else:
cands = [(m, g) for m in MS for g in improving[m]]
if cands:
chosen = max(cands, key=lambda mg: train[mg]["ETH"]["sharpe"])
print()
print("=" * 78)
if chosen is None:
print("NESSUNA cella migliorativa sul train -> verdetto NO (niente OOS).")
print("=" * 78)
return {"chosen": None, "plateau": best_plateau, "improving": improving,
"passes": False, "train": train}
c_m, c_g = chosen
print(f"CHOSEN g={c_g:.1f} m={c_m:.1f} -> OOS (config + 2 vicine g), 1 volta")
print("=" * 78)
gi = GS.index(c_g)
neigh = [GS[x] for x in (gi - 1, gi, gi + 1) if 0 <= x < len(GS)]
oos = {}
for g in neigh:
pol = Giveback(g, c_m)
row = {}
for a in ("BTC", "ETH"):
row[a] = {"train": train[(c_m, g)][a],
"oos": simulate(sleeves[a], pol, t_lo=OOS_START_MS)}
oos[g] = row
print(f" g={g:.1f} m={c_m:.1f}")
_row("TRAIN", "BTC", row["BTC"]["train"])
_row("OOS", "BTC", row["BTC"]["oos"])
_row("TRAIN", "ETH", row["ETH"]["train"])
_row("OOS", "ETH", row["ETH"]["oos"])
print()
print("=" * 78)
print(f"GATE finale (g={c_g:.1f} m={c_m:.1f}):")
bt_tr, et_tr = oos[c_g]["BTC"]["train"], oos[c_g]["ETH"]["train"]
bt_oo, et_oo = oos[c_g]["BTC"]["oos"], oos[c_g]["ETH"]["oos"]
Bb_o, Be_o = BASELINE["BTC"]["oos"], BASELINE["ETH"]["oos"]
a_train = _eth_ok(et_tr, b_eth)
a_oos = (et_oo["sharpe"] > Be_o["sharpe"] and et_oo["dd"] < Be_o["dd"]
and et_oo["worst"] > Be_o["worst"])
cond_a = a_train and a_oos
b_tr = _btc_ok(bt_tr, b_btc)
b_oo = (bt_oo["sharpe"] >= 0.95 * Bb_o["sharpe"]
and bt_oo["ret"] >= 0.80 * Bb_o["ret"])
cond_b = b_tr and b_oo
cond_c = et_oo["ret"] >= 0.80 * Be_o["ret"]
cond_d = len(best_plateau) >= 3
print(f" a) ETH sharpe up & dd down & worst up (train&oos): {cond_a}")
print(f" train: shrp {et_tr['sharpe']:.2f} vs {b_eth['sharpe']:.2f} | "
f"dd {et_tr['dd']:.0f} vs {b_eth['dd']:.0f} | "
f"worst {et_tr['worst']:.1f} vs {b_eth['worst']:.1f}")
print(f" oos: shrp {et_oo['sharpe']:.2f} vs {Be_o['sharpe']:.2f} | "
f"dd {et_oo['dd']:.0f} vs {Be_o['dd']:.0f} | "
f"worst {et_oo['worst']:.1f} vs {Be_o['worst']:.1f}")
print(f" b) BTC sharpe>=95% & ret>=80% (train&oos): {cond_b}")
print(f" train: shrp {bt_tr['sharpe']:.2f} (>={0.95*b_btc['sharpe']:.2f}) | "
f"ret {bt_tr['ret']:.0f} (>={0.80*b_btc['ret']:.0f})")
print(f" oos: shrp {bt_oo['sharpe']:.2f} (>={0.95*Bb_o['sharpe']:.2f}) | "
f"ret {bt_oo['ret']:.0f} (>={0.80*Bb_o['ret']:.0f})")
print(f" c) ETH oos ret>=80% baseline ({0.80*Be_o['ret']:.0f}): {cond_c} "
f"(ret={et_oo['ret']:.0f})")
print(f" d) plateau: {cond_d} ({best_plateau} m={best_m})")
passes = cond_a and cond_b and cond_c and cond_d
print(f" PASSES GATE: {passes}")
print("=" * 78)
return {"chosen": chosen, "plateau": best_plateau, "improving": improving,
"passes": passes, "oos": oos, "conds": (cond_a, cond_b, cond_c, cond_d)}
if __name__ == "__main__":
main()
@@ -0,0 +1,107 @@
"""SH01 EXIT POLICY 07 — LOSER time-stop condizionale (after_bar).
Idea: SH01 esce a orizzonte fisso H=12. Se a un check-point intermedio j=i+m il
trade e' GIA' in perdita oltre una soglia, esci subito al close invece di tenere
fino a H. L'ipotesi (lezione exit-lab fade: tagliare i loser presto rischia di
tagliare anche i winner in drawdown temporaneo): su un orizzonte FISSO di 12 bar
forse un loser conclamato a meta' corsa raramente recupera, mentre i winner del
modello partono subito (asimmetria). Il time-stop e' UNA volta sola (al bar m),
non un trailing: non insegue il prezzo, condiziona solo l'uscita a un istante.
Regola (after_bar):
al bar j == i + m: se (close[j]-entry)/entry * d < -x * ATR14[i] / entry
esci al close del bar j.
Equivalente: directional_move[j] < -x*ATR14[i]. x=0.0 => esci se in QUALSIASI
perdita direzionale al bar m.
Griglia m in {2, 3, 4, 6} x x in {0.0, 0.5, 1.0}.
ANTI-LOOK-AHEAD: ATR14[i] e entry=close[i] fissati all'ingresso (dati <= i);
after_bar decide sul close del bar j (dati <= j, eseguibile al poll del tick).
Nessun indicatore al bar j stesso.
cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/07_loser_timestop.py
"""
from __future__ import annotations
import sys
sys.path.insert(0, "/opt/docker/PythagorasGoal")
from scripts.analysis.sh01_exit_lab import ( # noqa: E402
ExitPolicy, evaluate, load_sleeves, simulate, OOS_START_MS,
)
class LoserTimestop(ExitPolicy):
def __init__(self, m: int, x: float):
self.m = int(m)
self.x = float(x)
self.name = f"loser_timestop m={m} x={x:g}"
def open_trade(self, ctx: dict, i: int, d: int) -> dict:
return {
"entry": float(ctx["close"][i]),
"atr": float(ctx["atr14"][i]),
}
def after_bar(self, ctx: dict, i: int, d: int, j: int, st: dict) -> bool:
if j != i + self.m:
return False
move = (ctx["close"][j] - st["entry"]) * d # directional, in price
thresh = -self.x * st["atr"]
return move < thresh
GRID_M = [2, 3, 4, 6]
GRID_X = [0.0, 0.5, 1.0]
def _fmt(m):
return (f"ret={m['ret']:>+7.0f}% dd={m['dd']:>4.0f}% shrp={m['sharpe']:>5.2f} "
f"worst={m['worst']:>+5.1f}% stop={m['stop_rate']:>4.1f}% n={m['trades']}")
def main():
sleeves = load_sleeves()
base = {}
for a in ("BTC", "ETH"):
ctx = sleeves[a]
base[a] = {
"train": simulate(ctx, ExitPolicy(), t_hi=OOS_START_MS),
"oos": simulate(ctx, ExitPolicy(), t_lo=OOS_START_MS),
}
print("=" * 110)
print("BASELINE (exit orizzonte puro):")
for a in ("BTC", "ETH"):
print(f" {a} TRAIN {_fmt(base[a]['train'])}")
print(f" {a} OOS {_fmt(base[a]['oos'])}")
print("=" * 110)
print("GRID — TRAIN ONLY (selezione parametri): m x rows")
train_res = {}
for mm in GRID_M:
for xx in GRID_X:
pol = LoserTimestop(mm, xx)
row = {}
for a in ("BTC", "ETH"):
row[a] = simulate(sleeves[a], pol, t_hi=OOS_START_MS)
train_res[(mm, xx)] = row
print(f" m={mm} x={xx:g} | BTC {_fmt(row['BTC'])}")
print(f" | ETH {_fmt(row['ETH'])}")
print("=" * 110)
print("OOS (verdetto, intera griglia per ispezione plateau):")
for mm in GRID_M:
for xx in GRID_X:
pol = LoserTimestop(mm, xx)
line_b = simulate(sleeves["BTC"], pol, t_lo=OOS_START_MS)
line_e = simulate(sleeves["ETH"], pol, t_lo=OOS_START_MS)
print(f" m={mm} x={xx:g} | BTC OOS {_fmt(line_b)}")
print(f" | ETH OOS {_fmt(line_e)}")
if __name__ == "__main__":
main()
@@ -0,0 +1,159 @@
"""SH01 EXIT POLICY 08 — DISASTER-CAP LARGO, close-confirm (minimal intervention).
Ipotesi: SH01 (exit a orizzonte puro, niente TP/SL) si fa massacrare dalle code
rare (crash ETH 2026-06-05 15.6% in un trade, ETH 2020). Uno stop LARGO a
k·ATR14[i] (k grande) dovrebbe toccare SOLO quei pochi trade-disastro, lasciando
intatto il resto della distribuzione — e quindi l'edge asimmetrico dei winner.
sl = entry - d * k * ATR14[i] (fissato all'ingresso, dati <= i)
mode = "close" (stop solo se il CLOSE sfonda, stile EXIT-16)
Griglia LARGA: k in {3.0, 4.0, 5.0, 6.0}. E' il complemento "wide-only" della
policy 02 (che spazzava anche stop stretti): qui l'intento e' la NON-interferenza.
Strumentazione extra (richiesta dal mandato): per ogni k riporto
- stop_rate (quanti trade vengono stoppati),
- la DISTRIBUZIONE dei trade tagliati: erano tutti loser? quanti winner uccisi?
Per ogni trade stoppato confronto il suo ret (post-stop, ⇒ negativo) con il
ret che AVREBBE avuto a orizzonte puro (baseline, senza stop) → conto quanti
sarebbero finiti winner (stop "dannoso") vs loser (stop "utile").
ANTI-LOOK-AHEAD: sl usa SOLO atr14[i] e c[i] (dati <= i); mode="close" decide sul
close del bar j (dati <= j). Nessun indicatore valutato al bar j.
cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/08_disaster_wide_close.py
"""
from __future__ import annotations
import sys
sys.path.insert(0, "/opt/docker/PythagorasGoal")
import numpy as np # noqa: E402
from scripts.analysis.sh01_exit_lab import ( # noqa: E402
ExitPolicy, load_sleeves, simulate, OOS_START_MS, FEE_RT, LEV, POS,
)
class DisasterWideClose(ExitPolicy):
def __init__(self, k: float):
self.k = float(k)
self.name = f"disaster_wide_close k={k:g}"
def open_trade(self, ctx: dict, i: int, d: int) -> dict:
atr = ctx["atr14"][i]
entry = ctx["close"][i]
sl = entry - d * self.k * atr
return {"sl": float(sl)}
def levels(self, ctx: dict, i: int, d: int, j: int, st: dict):
return st["sl"], "close"
GRID = [3.0, 4.0, 5.0, 6.0]
def _fmt(m):
return (f"ret={m['ret']:>+7.0f}% dd={m['dd']:>4.0f}% shrp={m['sharpe']:>5.2f} "
f"worst={m['worst']:>+5.1f}% stop={m['stop_rate']:>4.1f}% n={m['trades']}")
def _baseline_ret_by_entry(ctx, t_lo=None, t_hi=None):
"""Mappa entry-i -> ret a orizzonte puro (baseline, nessuno stop), stesso
engine, stesso slice. Serve a classificare i trade stoppati."""
base = simulate(ctx, ExitPolicy(), t_lo=t_lo, t_hi=t_hi)
return {r["i"]: r["ret"] for r in base["_trades"]}
def _stop_breakdown(ctx, policy, t_lo=None, t_hi=None):
"""Esegue la policy e analizza SOLO i trade con reason=='stop'.
Ritorna (n_stop, n_winner_killed, n_loser_cut, dettaglio_list)."""
res = simulate(ctx, policy, t_lo=t_lo, t_hi=t_hi)
base_ret = _baseline_ret_by_entry(ctx, t_lo=t_lo, t_hi=t_hi)
killed = cut = 0
detail = []
for r in res["_trades"]:
if r["reason"] != "stop":
continue
br = base_ret.get(r["i"])
would_win = (br is not None and br > 0)
killed += would_win
cut += (not would_win)
detail.append((r["i"], r["d"], r["ret"], br, would_win))
return res, len(detail), killed, cut, detail
def main():
sleeves = load_sleeves()
# baseline orizzonte puro
base = {}
for a in ("BTC", "ETH"):
ctx = sleeves[a]
base[a] = {
"train": simulate(ctx, ExitPolicy(), t_hi=OOS_START_MS),
"oos": simulate(ctx, ExitPolicy(), t_lo=OOS_START_MS),
}
print("=" * 118)
print("BASELINE (exit orizzonte puro):")
for a in ("BTC", "ETH"):
print(f" {a} TRAIN {_fmt(base[a]['train'])}")
print(f" {a} OOS {_fmt(base[a]['oos'])}")
print("=" * 118)
print("GRID — TRAIN ONLY (selezione parametri):")
train_res = {}
for k in GRID:
pol = DisasterWideClose(k)
row = {}
for a in ("BTC", "ETH"):
row[a] = simulate(sleeves[a], pol, t_hi=OOS_START_MS)
train_res[k] = row
print(f" k={k:>3g} | BTC {_fmt(row['BTC'])}")
print(f" | ETH {_fmt(row['ETH'])}")
# ---- breakdown dei trade stoppati (TRAIN), per la domanda "minimal intervention"
print("=" * 118)
print("STOP BREAKDOWN — TRAIN (chi viene tagliato? winner uccisi vs loser tagliati):")
for k in GRID:
pol = DisasterWideClose(k)
for a in ("BTC", "ETH"):
ctx = sleeves[a]
res, ns, killed, cut, detail = _stop_breakdown(ctx, pol, t_hi=OOS_START_MS)
print(f" k={k:>3g} {a} TRAIN: stop n={ns:>2d}/{res['trades']} "
f"({res['stop_rate']:.1f}%) -> loser_tagliati={cut} winner_UCCISI={killed}")
for (i, d, ret, br, ww) in detail:
tag = "WINNER-KILLED" if ww else "loser-cut"
brs = f"{br*100:>+6.1f}%" if br is not None else " n/a "
print(f" i={i:>6d} d={d:>+d} stop_ret={ret*100:>+6.1f}% "
f"baseline_ret={brs} [{tag}]")
# ---- verdetto OOS (config scelta + vicine, guardato una volta)
print("=" * 118)
print("OOS (verdetto):")
for k in GRID:
pol = DisasterWideClose(k)
print(f" k={k:>3g} | BTC TRAIN {_fmt(train_res[k]['BTC'])}")
for a in ("BTC", "ETH"):
oo = simulate(sleeves[a], pol, t_lo=OOS_START_MS)
print(f" | {a} OOS {_fmt(oo)}")
print("=" * 118)
print("STOP BREAKDOWN — OOS:")
for k in GRID:
pol = DisasterWideClose(k)
for a in ("BTC", "ETH"):
ctx = sleeves[a]
res, ns, killed, cut, detail = _stop_breakdown(ctx, pol, t_lo=OOS_START_MS)
print(f" k={k:>3g} {a} OOS : stop n={ns:>2d}/{res['trades']} "
f"({res['stop_rate']:.1f}%) -> loser_tagliati={cut} winner_UCCISI={killed}")
for (i, d, ret, br, ww) in detail:
tag = "WINNER-KILLED" if ww else "loser-cut"
brs = f"{br*100:>+6.1f}%" if br is not None else " n/a "
print(f" i={i:>6d} d={d:>+d} stop_ret={ret*100:>+6.1f}% "
f"baseline_ret={brs} [{tag}]")
if __name__ == "__main__":
main()
@@ -0,0 +1,256 @@
"""SH01 EXIT policy 09 — swing_stop.
Stop STRUTTURALE sullo swing recente, fissato all'ingresso:
long : sl = min(low[i-N+1 .. i]) - pad * ATR14[i]
short: sl = max(high[i-N+1 .. i]) + pad * ATR14[i]
Specchiato per d=-1. Il livello e' congelato in open_trade (SOLO dati <= i:
low/high della finestra fino a i incluso, ATR14[i] noto a close[i]). levels()
restituisce quel livello costante per tutta la vita del trade -> nessun dato del
bar j -> anti-look-ahead OK.
Idea: invece di uno stop a distanza fissa (ATR/%), ancora lo stop alla STRUTTURA
del prezzo (minimo/massimo dello swing recente). Un long viene stoppato solo se
rompe il supporto strutturale che lo ha generato; il pad in ATR da' un cuscinetto
sotto il livello per evitare i wick (mode intrabar) o per richiedere conferma sul
close (mode close, stile EXIT-16).
Griglia: N in {6, 12, 24} x pad in {0.0, 0.25, 0.5} x mode {intrabar, close}.
cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/09_swing_stop.py
"""
from __future__ import annotations
import sys
sys.path.insert(0, "/opt/docker/PythagorasGoal")
from scripts.analysis.sh01_exit_lab import ( # noqa: E402
ExitPolicy, OOS_START_MS, evaluate, load_sleeves, simulate,
)
class SwingStop(ExitPolicy):
def __init__(self, n: int, pad: float, mode: str):
self.n = int(n)
self.pad = float(pad)
self.mode = str(mode)
self.name = f"swing n={n} pad={pad:.2f} {mode}"
def open_trade(self, ctx, i, d):
lo, hi = ctx["low"], ctx["high"]
atr = ctx["atr14"][i]
lo0 = max(0, i - self.n + 1)
if atr != atr or atr <= 0: # nan/0 (early bars) -> nessuno stop
return {"sl": None}
if d == 1:
swing = float(lo[lo0:i + 1].min())
sl = swing - self.pad * atr
else:
swing = float(hi[lo0:i + 1].max())
sl = swing + self.pad * atr
return {"sl": sl}
def levels(self, ctx, i, d, j, st):
return st["sl"], self.mode
def after_bar(self, ctx, i, d, j, st):
return False
# baseline numbers (exit a orizzonte puro) — dal prompt/harness
BASELINE = {
"BTC": {"train": dict(ret=127, dd=23, sharpe=2.09, worst=-5.5),
"oos": dict(ret=41, dd=8, sharpe=2.18, worst=-3.1)},
"ETH": {"train": dict(ret=-26, dd=61, sharpe=-0.16, worst=-14.9),
"oos": dict(ret=143, dd=7, sharpe=3.60, worst=-4.6)},
}
NS = [6, 12, 24]
PADS = [0.0, 0.25, 0.5]
MODES = ["intrabar", "close"]
def _row(tag, a, r):
print(f" {tag:<10s} {a}: ret={r['ret']:>+7.0f}% dd={r['dd']:>4.0f}% "
f"shrp={r['sharpe']:>5.2f} worst={r['worst']:>+5.1f}% "
f"stop={r['stop_rate']:>4.1f}% trades={r['trades']}")
def _eth_ok(et, b_eth):
return (et["sharpe"] > b_eth["sharpe"] and et["dd"] < b_eth["dd"]
and et["worst"] > b_eth["worst"])
def _btc_ok(bt, b_btc):
return (bt["sharpe"] >= 0.95 * b_btc["sharpe"]
and bt["ret"] >= 0.80 * b_btc["ret"])
def main():
sleeves = load_sleeves()
b_btc, b_eth = BASELINE["BTC"]["train"], BASELINE["ETH"]["train"]
print("=" * 78)
print("TRAIN GRID (selezione SOLO sul train, t_hi=OOS_START)")
print("=" * 78)
print(" baseline (orizzonte puro):")
evaluate(ExitPolicy(), sleeves=sleeves)
print()
# train: (mode, n, pad) -> {asset: result}
train = {}
for mode in MODES:
print(f" --- mode={mode} ---")
for n in NS:
for pad in PADS:
pol = SwingStop(n, pad, mode)
row = {}
for a in ("BTC", "ETH"):
row[a] = simulate(sleeves[a], pol, t_hi=OOS_START_MS)
train[(mode, n, pad)] = row
print(f" n={n:<2d} pad={pad:.2f}")
_row("TRAIN", "BTC", row["BTC"])
_row("TRAIN", "ETH", row["ETH"])
print()
print("=" * 78)
print("PLATEAU CHECK (train): per ogni cella, ETH(shrp up & dd down & worst up)")
print(" & BTC(shrp>=95% & ret>=80% baseline)")
print("=" * 78)
improving = []
grid_imp = {} # (mode,n,pad) -> bool
for mode in MODES:
for n in NS:
for pad in PADS:
bt, et = train[(mode, n, pad)]["BTC"], train[(mode, n, pad)]["ETH"]
ok = _eth_ok(et, b_eth) and _btc_ok(bt, b_btc)
grid_imp[(mode, n, pad)] = ok
if ok:
improving.append((mode, n, pad))
print(f" {mode:<8s} n={n:<2d} pad={pad:.2f} "
f"ETH_ok={_eth_ok(et, b_eth)!s:<5} BTC_ok={_btc_ok(bt, b_btc)!s:<5} "
f"-> {'IMPROVING' if ok else '-'}")
print(f" improving cells (train): {len(improving)}/{len(train)} -> {improving}")
# PLATEAU = adiacenza nella griglia N x pad (stesso mode). Adiacenti = vicini
# nelle liste NS/PADS. Cerco il blocco contiguo piu' grande di celle improving.
def adjacent_block_size(mode):
cells = [(NS.index(n), PADS.index(p))
for (m, n, p) in improving if m == mode]
cells_set = set(cells)
best = []
for start in cells:
# BFS sul reticolo 4-connesso
seen, stack = set(), [start]
while stack:
cur = stack.pop()
if cur in seen:
continue
seen.add(cur)
ci, cj = cur
for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nb = (ci + di, cj + dj)
if nb in cells_set and nb not in seen:
stack.append(nb)
if len(seen) > len(best):
best = list(seen)
return best
plateau_cells = []
plateau_mode = None
for mode in MODES:
blk = adjacent_block_size(mode)
if len(blk) > len(plateau_cells):
plateau_cells = blk
plateau_mode = mode
plateau_ok = len(plateau_cells) >= 3
if plateau_mode is not None:
readable = [(plateau_mode, NS[i], PADS[j]) for (i, j) in plateau_cells]
else:
readable = []
print(f" largest adjacent improving block: {len(plateau_cells)} cells "
f"mode={plateau_mode} -> {readable} (plateau={'YES' if plateau_ok else 'NO'})")
# scelta: centro del plateau (miglior ETH sharpe fra le celle del blocco),
# altrimenti miglior ETH sharpe fra gli improving.
chosen = None
if plateau_ok:
chosen = max(readable, key=lambda c: train[c]["ETH"]["sharpe"])
elif improving:
chosen = max(improving, key=lambda c: train[c]["ETH"]["sharpe"])
print()
print("=" * 78)
if chosen is None:
print("NESSUNA cella migliorativa sul train -> verdetto NO (niente OOS).")
print("=" * 78)
return {"chosen": None, "plateau": readable, "improving": improving,
"passes": False}
print(f"CHOSEN {chosen} -> OOS (config + vicine), guardato UNA volta")
print("=" * 78)
mode, n, pad = chosen
# vicine: stesso mode, pad +-1 step e n +-1 step (se esistono e improving o no)
ni, pi = NS.index(n), PADS.index(pad)
neigh = set([chosen])
for di, dj in ((0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)):
a, b = ni + di, pi + dj
if 0 <= a < len(NS) and 0 <= b < len(PADS):
neigh.add((mode, NS[a], PADS[b]))
oos = {}
for c in sorted(neigh, key=lambda c: (c[1], c[2])):
m, nn, pp = c
pol = SwingStop(nn, pp, m)
row = {}
for a in ("BTC", "ETH"):
row[a] = {"train": train[c][a],
"oos": simulate(sleeves[a], pol, t_lo=OOS_START_MS)}
oos[c] = row
print(f" {m} n={nn} pad={pp:.2f}")
_row("TRAIN", "BTC", row["BTC"]["train"])
_row("OOS", "BTC", row["BTC"]["oos"])
_row("TRAIN", "ETH", row["ETH"]["train"])
_row("OOS", "ETH", row["ETH"]["oos"])
print()
print("=" * 78)
print(f"GATE finale ({chosen}):")
bt_tr, et_tr = oos[chosen]["BTC"]["train"], oos[chosen]["ETH"]["train"]
bt_oo, et_oo = oos[chosen]["BTC"]["oos"], oos[chosen]["ETH"]["oos"]
Bb_o, Be_o = BASELINE["BTC"]["oos"], BASELINE["ETH"]["oos"]
a_train = _eth_ok(et_tr, b_eth)
a_oos = (et_oo["sharpe"] > Be_o["sharpe"] and et_oo["dd"] < Be_o["dd"]
and et_oo["worst"] > Be_o["worst"])
cond_a = a_train and a_oos
cond_b = _btc_ok(bt_tr, b_btc) and (bt_oo["sharpe"] >= 0.95 * Bb_o["sharpe"]
and bt_oo["ret"] >= 0.80 * Bb_o["ret"])
cond_c = et_oo["ret"] >= 0.80 * Be_o["ret"]
cond_d = plateau_ok
print(f" a) ETH sharpe up & dd down & worst up (train&oos): {cond_a}")
print(f" train: shrp {et_tr['sharpe']:.2f} vs {b_eth['sharpe']:.2f} | "
f"dd {et_tr['dd']:.0f} vs {b_eth['dd']:.0f} | "
f"worst {et_tr['worst']:.1f} vs {b_eth['worst']:.1f}")
print(f" oos: shrp {et_oo['sharpe']:.2f} vs {Be_o['sharpe']:.2f} | "
f"dd {et_oo['dd']:.0f} vs {Be_o['dd']:.0f} | "
f"worst {et_oo['worst']:.1f} vs {Be_o['worst']:.1f}")
print(f" b) BTC sharpe>=95% & ret>=80% (train&oos): {cond_b}")
print(f" train: shrp {bt_tr['sharpe']:.2f} (>={0.95*b_btc['sharpe']:.2f}) | "
f"ret {bt_tr['ret']:.0f} (>={0.80*b_btc['ret']:.0f})")
print(f" oos: shrp {bt_oo['sharpe']:.2f} (>={0.95*Bb_o['sharpe']:.2f}) | "
f"ret {bt_oo['ret']:.0f} (>={0.80*Bb_o['ret']:.0f})")
print(f" c) ETH oos ret>=80% baseline ({0.80*Be_o['ret']:.0f}): {cond_c} "
f"(ret={et_oo['ret']:.0f})")
print(f" d) plateau: {cond_d} ({len(plateau_cells)} cells)")
passes = cond_a and cond_b and cond_c and cond_d
print(f" PASSES GATE: {passes}")
print("=" * 78)
return {"chosen": chosen, "plateau": readable, "improving": improving,
"passes": passes, "oos": oos,
"conds": (cond_a, cond_b, cond_c, cond_d)}
if __name__ == "__main__":
main()
@@ -0,0 +1,157 @@
"""SH01 EXIT POLICY 10 — vol_regime_stop.
Stop CONDIZIONALE al regime di volatilita': lo SL esiste solo quando la vol
sta esplodendo. Razionale: il danno (2020 ETH, crash live 2026-06-05) avviene
in vol-expansion; quando la vol e' normale lo SL taglierebbe winner in
drawdown temporaneo (l'edge SH01 e' nell'asimmetria, win ~50%).
Regime causale: vr[j] = ATR14[j] / SMA100(ATR14)[j]. Nel bar j si guarda
vr[j-1] (dati <= j-1). Se vr[j-1] > r -> SL = entry - d*k1*ATR14[i]
(ATR all'entry = dati <= i). Altrimenti nessuno stop.
r in {1.2, 1.5}
k1 in {1.5, 2.0, 3.0}
mode in {intrabar, close}
ANTI-LOOK-AHEAD:
- vr e' un array precomputato module-level (SMA100 causale, no centering).
- levels(j) usa vr[j-1] e atr14[i] (entry), entrambi <= j-1.
- mode "close": stop solo se il CLOSE sfonda (stile EXIT-16).
"""
from __future__ import annotations
import sys
sys.path.insert(0, "/opt/docker/PythagorasGoal")
import numpy as np
from scripts.analysis.sh01_exit_lab import (
ExitPolicy, evaluate, load_sleeves, simulate, OOS_START_MS,
)
_VR_CACHE: dict[int, np.ndarray] = {}
def _vol_ratio(atr14: np.ndarray, win: int = 100) -> np.ndarray:
"""vr[j] = atr14[j] / SMA(atr14, win)[j], causale. NaN dove non definito."""
key = id(atr14)
if key in _VR_CACHE:
return _VR_CACHE[key]
a = np.asarray(atr14, dtype=float)
n = len(a)
sma = np.full(n, np.nan)
# rolling mean causale (include il bar corrente j: e' OK perche' in levels
# consumiamo vr[j-1], cioe' dati fino a j-1).
csum = np.nancumsum(np.where(np.isnan(a), 0.0, a))
cnt = np.cumsum(~np.isnan(a))
for j in range(n):
lo = j - win + 1
if lo < 0:
continue
s = csum[j] - (csum[lo - 1] if lo > 0 else 0.0)
k = cnt[j] - (cnt[lo - 1] if lo > 0 else 0)
if k > 0:
sma[j] = s / k
vr = np.where((sma > 0) & ~np.isnan(a), a / sma, np.nan)
_VR_CACHE[key] = vr
return vr
class VolRegimeStop(ExitPolicy):
def __init__(self, r: float, k1: float, mode: str):
self.r = float(r)
self.k1 = float(k1)
self.mode = mode
self.name = f"volreg r{r} k{k1} {mode[:3]}"
def open_trade(self, ctx: dict, i: int, d: int) -> dict:
atr_i = ctx["atr14"][i]
if not np.isfinite(atr_i) or atr_i <= 0:
atr_i = 0.0
return {"vr": _vol_ratio(ctx["atr14"]), "atr_i": float(atr_i)}
def levels(self, ctx: dict, i: int, d: int, j: int, st: dict):
if j - 1 < 0:
return None, self.mode
vr_prev = st["vr"][j - 1]
if not np.isfinite(vr_prev) or vr_prev <= self.r:
return None, self.mode # regime calmo -> nessuno stop
atr_i = st["atr_i"]
if atr_i <= 0:
return None, self.mode
entry = ctx["close"][i]
sl = entry - d * self.k1 * atr_i
return sl, self.mode
# ----------------------------------------------------------------------------- grid
def _fmt(m: dict) -> str:
return (f"ret={m['ret']:>+7.0f}% dd={m['dd']:>4.0f}% shrp={m['sharpe']:>5.2f} "
f"worst={m['worst']:>+5.1f}% stop={m['stop_rate']:>4.1f}%")
def main():
sleeves = load_sleeves()
# baseline
print("=== BASELINE (orizzonte puro) ===")
base = {}
for a in ("BTC", "ETH"):
ctx = sleeves[a]
tr = simulate(ctx, ExitPolicy(), t_hi=OOS_START_MS)
oo = simulate(ctx, ExitPolicy(), t_lo=OOS_START_MS)
base[a] = {"train": tr, "oos": oo}
print(f" {a} TRAIN {_fmt(tr)}")
print(f" {a} OOS {_fmt(oo)}")
rs = [1.2, 1.5]
ks = [1.5, 2.0, 3.0]
modes = ["intrabar", "close"]
print("\n=== GRID (TRAIN only) ===")
grid = {}
for mode in modes:
print(f"\n--- mode={mode} ---")
for r in rs:
for k1 in ks:
pol = VolRegimeStop(r, k1, mode)
btc = simulate(sleeves["BTC"], pol, t_hi=OOS_START_MS)
eth = simulate(sleeves["ETH"], pol, t_hi=OOS_START_MS)
grid[(mode, r, k1)] = (btc, eth)
print(f" r={r} k1={k1}: BTC {_fmt(btc)} | ETH {_fmt(eth)}")
# plateau check sul train: cella migliorativa se
# ETH sharpe up & dd down & worst less-neg, BTC sharpe>=95% & ret>=80%
bB_tr = base["BTC"]["train"]; bE_tr = base["ETH"]["train"]
print("\n=== TRAIN improvement map (cell = ETH sh^ dd_v worst^ AND BTC ok) ===")
improved = {}
for key, (btc, eth) in grid.items():
eth_ok = (eth["sharpe"] > bE_tr["sharpe"] and eth["dd"] < bE_tr["dd"]
and eth["worst"] > bE_tr["worst"])
btc_ok = (btc["sharpe"] >= 0.95 * bB_tr["sharpe"]
and btc["ret"] >= 0.80 * bB_tr["ret"])
improved[key] = eth_ok and btc_ok
flag = "YES" if improved[key] else " . "
print(f" {key}: {flag} (ethSh {eth['sharpe']:+.2f} vs {bE_tr['sharpe']:+.2f}, "
f"ethDD {eth['dd']:.0f} vs {bE_tr['dd']:.0f}, ethW {eth['worst']:+.1f} vs {bE_tr['worst']:+.1f}, "
f"btcSh {btc['sharpe']:.2f} btcRet {btc['ret']:+.0f})")
n_imp = sum(improved.values())
print(f"\nTRAIN improving cells: {n_imp}/{len(grid)}")
# OOS verdict on improving cells (guardato UNA volta)
print("\n=== OOS verdict (improving train cells) ===")
for key, ok in improved.items():
if not ok:
continue
mode, r, k1 = key
pol = VolRegimeStop(r, k1, mode)
btc = simulate(sleeves["BTC"], pol, t_lo=OOS_START_MS)
eth = simulate(sleeves["ETH"], pol, t_lo=OOS_START_MS)
print(f" {key}: BTC {_fmt(btc)} | ETH {_fmt(eth)}")
if __name__ == "__main__":
main()
@@ -0,0 +1,105 @@
"""SH01 EXIT policy 11 — disaster_wide_intrabar (COMPLETENESS PROBE).
Le 10 policy precedenti hanno tutte fallito. Diagnosi ricorrente:
- close-confirm (02,08) ALLARGA la coda su momentum-continuation (caso live
ETH 2026-06-05): il close corre oltre il livello.
- intrabar fisso (01) cappa AL livello (worst limitato) ma degrada BTC anche a k=5.
QUESTA probe chiude il buco: intrabar cap MOLTO LARGO (k=6..12), gap-aware,
il cui UNICO scopo e' tagliare la coda catastrofica (la -14.9% ETH / il crash
live -15.6%) SENZA mai toccare i normali pullback. E' la domanda diretta:
"esiste un k cosi' largo che NON tocca BTC ma cappa la coda ETH?".
Anti-look-ahead: sl = entry - d*k*ATR14[i], congelato in open_trade (dati<=i);
levels restituisce il livello costante, fill gap-aware nell'engine. mode=intrabar
cappa AL livello (a differenza del close-confirm che lascia correre il close).
cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/11_disaster_wide_intrabar.py
"""
from __future__ import annotations
import sys
sys.path.insert(0, "/opt/docker/PythagorasGoal")
from scripts.analysis.sh01_exit_lab import ( # noqa: E402
ExitPolicy, OOS_START_MS, load_sleeves, simulate,
)
class DisasterWideIntrabar(ExitPolicy):
def __init__(self, k: float):
self.k = float(k)
self.name = f"disaster_wide_intrabar k={k:.1f}"
def open_trade(self, ctx, i, d):
atr = ctx["atr14"][i]
entry = ctx["close"][i]
sl = entry - d * self.k * atr if atr == atr and atr > 0 else None
return {"sl": sl}
def levels(self, ctx, i, d, j, st):
return st["sl"], "intrabar"
BASELINE = {
"BTC": {"train": dict(ret=127, dd=23, sharpe=2.09, worst=-5.5),
"oos": dict(ret=41, dd=8, sharpe=2.18, worst=-3.1)},
"ETH": {"train": dict(ret=-26, dd=61, sharpe=-0.16, worst=-14.9),
"oos": dict(ret=143, dd=7, sharpe=3.60, worst=-4.6)},
}
def _row(tag, a, r):
print(f" {tag:<7s} {a}: ret={r['ret']:>+7.0f}% dd={r['dd']:>4.0f}% "
f"shrp={r['sharpe']:>5.2f} worst={r['worst']:>+6.1f}% "
f"stop={r['stop_rate']:>4.1f}% trades={r['trades']}")
def main():
sleeves = load_sleeves()
KS = [5.0, 6.0, 7.0, 8.0, 10.0, 12.0]
print("=" * 78)
print("TRAIN GRID (intrabar cap LARGO, fill gap-aware)")
print("=" * 78)
train = {}
for k in KS:
pol = DisasterWideIntrabar(k)
row = {a: simulate(sleeves[a], pol, t_hi=OOS_START_MS) for a in ("BTC", "ETH")}
train[k] = row
print(f" k={k:.1f}")
_row("TRAIN", "BTC", row["BTC"])
_row("TRAIN", "ETH", row["ETH"])
b_btc, b_eth = BASELINE["BTC"]["train"], BASELINE["ETH"]["train"]
improving = []
for k in KS:
bt, et = train[k]["BTC"], train[k]["ETH"]
eth_ok = (et["sharpe"] > b_eth["sharpe"] and et["dd"] < b_eth["dd"]
and et["worst"] > b_eth["worst"])
btc_ok = (bt["sharpe"] >= 0.95 * b_btc["sharpe"]
and bt["ret"] >= 0.80 * b_btc["ret"])
if eth_ok and btc_ok:
improving.append(k)
print(f" k={k:.1f} ETH_ok={eth_ok} BTC_ok={btc_ok} "
f"(BTC shrp={bt['sharpe']:.2f} ret={bt['ret']:.0f} | "
f"ETH shrp={et['sharpe']:.2f} dd={et['dd']:.0f} worst={et['worst']:.1f})")
print(f"\n improving cells (train): {improving}")
if not improving:
print(" -> NESSUNA cella migliorativa: NO pulito, OOS non guardato.")
return
# plateau >=3 adiacenti? poi OOS
print("\n Plateau candidate -> OOS verdetto:")
for k in improving:
oos = {a: simulate(sleeves[a], DisasterWideIntrabar(k), t_lo=OOS_START_MS)
for a in ("BTC", "ETH")}
print(f" k={k:.1f}")
_row("OOS", "BTC", oos["BTC"])
_row("OOS", "ETH", oos["ETH"])
if __name__ == "__main__":
main()
+177
View File
@@ -0,0 +1,177 @@
"""Ricerca sistematica edge nella FORMA (analog forecasting / kNN) — netto fee, OOS.
Obiettivo: trovare una config di analog forecasting ROBUSTA, cioe' positiva
FULL+OOS, che regge fee 0.20% RT e ha quasi tutti gli anni positivi, su >=2 asset.
Si combatte la "morte per fee" della baseline (BTC1h W24H12K50 agree0.60:
FULL +112%/OOS +48% Sharpe 1.38 ma a 0.2% RT -> FULL -72 / OOS -18, win 49.5%,
esposizione 73.9%, 4531 trade) con SELETTIVITA':
- agree alto (0.70..0.90) -> entra solo con analoghi molto concordi
- conf_atr > 0 -> richiede |rendimento medio analoghi| >= conf_atr*ATR
- trend_max/ema_long -> salta forme in trend estremo
- tp_atr/sl_atr -> exit intrabar invece che solo a tempo
Tutto causale: la forma usa solo close<=i, la libreria analoghi termina < i-H.
Per performance, il forecast kNN grezzo per barra si calcola UNA volta per
(W,H,K,rebuild) con analog_signals(); i filtri (agree/conf/trend/tp/sl) sono
applicati a valle con entries_from_signals() (cheap, risultato identico ad
analog_entries — verificato). Engine netto-fee + OOS da explore_lab.
Uso:
uv run python scripts/analysis/shape_analog_research.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.shape_lab import ( # noqa: E402
analog_signals, entries_from_signals, check_no_lookahead,
)
from scripts.analysis.explore_lab import get_df, evaluate, robust # noqa: E402
ROBUSTE: list[tuple] = []
MIN_TRADES = 100 # un edge "robusto" su <100 trade e' rumore campionario, non edge
def _hdr(s: str) -> None:
print("\n" + "=" * 100, flush=True)
print(" " + s, flush=True)
print("=" * 100, flush=True)
def _eval(df, sig, asset, tf, tag, **filt):
ents = entries_from_signals(df, sig, **filt)
res = evaluate(f"[{asset} {tf}] {tag}", ents, df)
# robusto E con campione sufficiente (un edge su <100 trade non e' affidabile)
if robust(res) and res["full"]["trades"] >= MIN_TRADES:
print(f" ^^^ ROBUSTA ({asset} {tf}): {tag} filt={filt}", flush=True)
ROBUSTE.append((asset, tf, tag, dict(filt), res))
elif robust(res):
print(f" (robust ma trade={res['full']['trades']}<{MIN_TRADES}: campione "
f"insufficiente, ignorato)", flush=True)
return res
def run():
# --- 0) sanity no-lookahead ---------------------------------------------
_hdr("0) SANITY no-lookahead (forma causale)")
df_btc = get_df("BTC", "1h")
check_no_lookahead(df_btc, W=24, H=12)
# sig base W24H12K50 (riusato per selettivita' agree/conf/tp/sl/trend)
sig0 = analog_signals(df_btc, W=24, H=12, K=50, rebuild=250)
# --- 1) selettivita' via agree ------------------------------------------
_hdr("1) BTC 1h — selettivita' agree (W24 H12 K50, time-exit)")
for ag in (0.60, 0.70, 0.80, 0.90):
_eval(df_btc, sig0, "BTC", "1h", f"agree{ag}", agree=ag)
# --- 2) conf_atr (forza segnale) ----------------------------------------
_hdr("2) BTC 1h — conf_atr (W24 H12 K50 agree0.70)")
for ca in (0.0, 0.25, 0.5, 1.0, 1.5):
_eval(df_btc, sig0, "BTC", "1h", f"ag0.70 conf{ca}", agree=0.70, conf_atr=ca)
# --- 3) tp/sl intrabar ---------------------------------------------------
_hdr("3) BTC 1h — exit intrabar tp/sl (W24 H12 K50 agree0.70 conf0.5)")
for tp, sl in [(1.0, 1.0), (1.5, 1.0), (2.0, 1.5), (1.5, 2.0), (3.0, 2.0)]:
_eval(df_btc, sig0, "BTC", "1h", f"tp{tp}sl{sl}",
agree=0.70, conf_atr=0.5, tp_atr=tp, sl_atr=sl)
# --- 4) filtro trend -----------------------------------------------------
_hdr("4) BTC 1h — filtro trend_max (W24 H12 K50 agree0.70 conf0.5)")
for tm in (None, 2.0, 3.0, 4.0):
_eval(df_btc, sig0, "BTC", "1h", f"trend_max{tm}",
agree=0.70, conf_atr=0.5, trend_max=tm, ema_long=200)
# --- 5) griglia W/H/K (agree0.80, time-exit) plateau ---------------------
# Griglia focalizzata: con agree0.80 e H>=24 i trade -> ~0 (vedi sez.1), e W>=24
# porta OOS negativo; il segnale vive su W piccolo, H breve. Testo il plateau
# attorno a quella regione + una banda di controllo (W24/48) per confermare il bordo.
_hdr("5) BTC 1h — griglia W/H/K (agree0.80, time-exit) — plateau check")
for W in (12, 24, 48):
for H in (6, 12, 24):
for K in (30, 50, 80):
sig = analog_signals(df_btc, W=W, H=H, K=K, rebuild=250)
_eval(df_btc, sig, "BTC", "1h", f"W{W}H{H}K{K}", agree=0.80)
# --- 6) rebuild sensitivity ---------------------------------------------
_hdr("6) BTC 1h — rebuild 250 vs 500 (W24 H12 K80 agree0.80)")
for rb in (250, 500):
sig = analog_signals(df_btc, W=24, H=12, K=80, rebuild=rb)
_eval(df_btc, sig, "BTC", "1h", f"rebuild{rb}", agree=0.80)
# --- 7) cross-asset 1h: candidati selettivi -----------------------------
_hdr("7) cross-asset 1h — candidati selettivi (>=2 robusti richiesto)")
# (build_kw: per analog_signals) (filt: per entries_from_signals)
# Su BTC 1h le uniche regioni con OOS positivo che regge fee0.2% sono W piccolo,
# H breve, K basso (W12H12K30: FULL+88/OOS+36, fee0.2% +69/+32, 243 trade, 8/9 anni;
# W12H6K30: +35/+11, fee0.2% +20/+7). conf0.25 con W24H12 e' il miglior in-sample
# ma OOS@fee~0. Verifico questi candidati cross-asset (>=2 robusti richiesto).
candidates = [
("C1 W12H12K30 ag.80", dict(W=12, H=12, K=30), dict(agree=0.80)),
("C2 W12H6K30 ag.80", dict(W=12, H=6, K=30), dict(agree=0.80)),
("C3 W12H12K30 ag.70", dict(W=12, H=12, K=30), dict(agree=0.70)),
("C4 W24H12K50 ag.70 conf.25", dict(W=24, H=12, K=50), dict(agree=0.70, conf_atr=0.25)),
("C5 W12H12K30 ag.80 trend3", dict(W=12, H=12, K=30), dict(agree=0.80, trend_max=3.0, ema_long=200)),
("C6 W12H6K50 ag.70", dict(W=12, H=6, K=50), dict(agree=0.70)),
]
per_cand: dict[str, int] = {}
for asset in ("BTC", "ETH", "ADA", "LTC", "SOL", "XRP"):
try:
df = get_df(asset, "1h")
except Exception as ex:
print(f" [{asset} 1h] SKIP load: {ex}", flush=True)
continue
# cache analog_signals per ogni build_kw distinto su questo asset
sig_cache: dict[tuple, dict] = {}
for tag, bkw, filt in candidates:
key = tuple(sorted(bkw.items()))
if key not in sig_cache:
sig_cache[key] = analog_signals(df, rebuild=250, **bkw)
res = _eval(df, sig_cache[key], asset, "1h", tag, **filt)
if robust(res):
per_cand[tag] = per_cand.get(tag, 0) + 1
# --- 8) verifica 15m dei candidati robusti su >=2 asset 1h --------------
_hdr("8) verifica 15m dei candidati robusti su >=2 asset 1h")
good = [t for t, c in per_cand.items() if c >= 2]
if not good:
print(" Nessun candidato robusto su >=2 asset 1h -> niente verifica 15m.", flush=True)
else:
for tag in good:
_, bkw, filt = next(c for c in candidates if c[0] == tag)
for asset in ("BTC", "ETH"):
try:
df = get_df(asset, "15m")
except Exception as ex:
print(f" [{asset} 15m] SKIP load: {ex}", flush=True)
continue
sig = analog_signals(df, rebuild=250, **bkw)
_eval(df, sig, asset, "15m", f"{tag} (15m)", **filt)
# --- VERDETTO ------------------------------------------------------------
_hdr("VERDETTO")
if ROBUSTE:
agg: dict[str, list] = {}
for asset, tf, tag, filt, res in ROBUSTE:
agg.setdefault(tag, []).append(f"{asset}/{tf}")
print(f" {len(ROBUSTE)} sleeve robusti (FULL+OOS+ fee0.2% + anniPos):", flush=True)
edge = False
for tag, asl in agg.items():
n_assets = len({a.split('/')[0] for a in asl})
mark = " *** EDGE (>=2 asset)" if n_assets >= 2 else " (1 asset: non sufficiente)"
if n_assets >= 2:
edge = True
print(f" - {tag}: {asl}{mark}", flush=True)
if not edge:
print("\n CONCLUSIONE: nessuna config robusta su >=2 asset -> RUMORE.", flush=True)
else:
print(" NESSUNA config robusta. Famiglia analog/forma = RUMORE sotto fee reali.", flush=True)
return ROBUSTE
if __name__ == "__main__":
run()
+328
View File
@@ -0,0 +1,328 @@
"""Edge nella FORMA discreta delle candele -> distribuzione condizionale dell'esito.
Famiglia: encoding DISCRETO della morfologia di una finestra di L candele
(sequenza UP/DOWN/DOJI, opzionalmente arricchita con bucket di body-ratio e
shadow-ratio) -> codice intero. Per ogni codice si stima la distribuzione del
rendimento a H barre usando SOLO le occorrenze PASSATE il cui esito era gia'
realizzato prima della barra di decisione i (expanding window causale). Se un
codice mostra bias direzionale forte e statisticamente solido (n campioni >=
soglia, win-rate o |media| oltre soglia) si ENTRA a close[i] nella direzione del
bias; exit a H barre o TP/SL ATR.
VINCOLI ANTI-LOOK-AHEAD (l'errore squeeze e' nato qui):
- il codice a i usa SOLO open/high/low/close fino alla barra i inclusa;
- la statistica condizionale a i conta SOLO occorrenze del codice terminate in
e con e+H <= i-1 -> il loro esito H e' interamente noto PRIMA di i;
- direzione decisa dal CODICE (forma fino a close[i]) + STATISTICHE PASSATE,
ingresso eseguibile a close[i].
- check_no_lookahead() perturba il futuro: ne' il codice a i ne' le stat usate
devono cambiare.
Riusa l'engine netto-fee + OOS di explore_lab (simulate/evaluate/robust).
Implementazione causale O(N) per codice via accumulatori incrementali (niente
ricalcolo dell'intera storia ad ogni barra).
Asset: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m). Default 1h.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import ( # noqa: E402
get_df, evaluate, robust, simulate, atr, OOS_FRAC,
)
# --------------------------- encoding discreto della forma ---------------------------
def candle_codes(df, L: int, body_buckets: int = 1, shadow_buckets: int = 1) -> np.ndarray:
"""Codice intero della forma per la finestra di L candele terminante in i.
Componenti per ogni candela:
- direzione UP/DOWN/DOJI (sempre): 3 stati.
- bucket del body-ratio |c-o|/(h-l) (se body_buckets>1): quantizzazione fissa
in body_buckets livelli (corpo piccolo/medio/grande...).
- bucket dello shadow-ratio (h-max(o,c)-(min(o,c)-l))/(h-l) in [-1,1]
(se shadow_buckets>1): ombra sup vs inf.
Quantizzazione a SOGLIE FISSE (non quantili): non dipende dal futuro ne' dal
dataset globale -> causale per costruzione. codes[i] dipende solo da
barre [i-L+1 .. i]. Per i < L-1 -> -1 (non valido).
"""
o = df["open"].values; c = df["close"].values
h = df["high"].values; l = df["low"].values
n = len(c)
rng = np.where((h - l) == 0, 1e-12, h - l)
body = np.abs(c - o) / rng # [0,1]
direction = np.where(body < 0.1, 0, # DOJI
np.where(c > o, 1, 2)) # UP=1, DOWN=2 (3 stati: 0,1,2)
# shadow asymmetry in [-1,1]: >0 ombra sup dominante, <0 ombra inf
up_sh = (h - np.maximum(o, c)) / rng
lo_sh = (np.minimum(o, c) - l) / rng
shadow = up_sh - lo_sh
# bucket body (soglie fisse su frazioni del range): 0..body_buckets-1
if body_buckets > 1:
edges_b = np.linspace(0.0, 1.0, body_buckets + 1)[1:-1]
bbk = np.digitize(body, edges_b) # 0..body_buckets-1
else:
bbk = np.zeros(n, dtype=int)
if shadow_buckets > 1:
edges_s = np.linspace(-1.0, 1.0, shadow_buckets + 1)[1:-1]
sbk = np.digitize(shadow, edges_s)
else:
sbk = np.zeros(n, dtype=int)
# simbolo per candela: dir * (body_buckets*shadow_buckets) + bbk*shadow_buckets + sbk
nbb, nsb = body_buckets, shadow_buckets
per_dir = nbb * nsb
sym = direction * per_dir + bbk * nsb + sbk # 0 .. 3*per_dir-1
base = 3 * per_dir
codes = np.full(n, -1, dtype=np.int64)
# codice della finestra L: base-L polinomiale sui simboli [i-L+1 .. i]
acc = np.zeros(n, dtype=np.int64)
for k in range(L):
# contributo della candela a posizione (i-L+1+k): peso base**(L-1-k)
shifted = np.full(n, 0, dtype=np.int64)
shifted[L - 1 - k:] = sym[: n - (L - 1 - k)] if (L - 1 - k) > 0 else sym
acc += shifted * (base ** (L - 1 - k))
codes[L - 1:] = acc[L - 1:]
return codes
def fwd_return(close: np.ndarray, H: int) -> np.ndarray:
out = np.full(len(close), np.nan)
out[: len(close) - H] = (close[H:] - close[:-H]) / close[:-H]
return out
# --------------------------- stima condizionale causale ---------------------------
def shape_entries(df, L=3, H=12, body_buckets=1, shadow_buckets=1,
min_n=30, edge=0.55, min_lib=500,
tp_atr=None, sl_atr=None, fade=False) -> list[dict]:
"""Entries dal bias condizionale del codice di forma (causale, no look-ahead).
L: lunghezza finestra-forma. H: orizzonte = max_bars.
body_buckets/shadow_buckets: granularita' dell'encoding (1 = solo direzione).
min_n: occorrenze passate minime del codice (con esito noto) per fidarsi.
edge: win-rate minimo (frazione di esiti concordi col segno della media) per
entrare; |edge-0.5| e' il margine direzionale.
min_lib: barre minime di storia prima di iniziare a operare.
tp_atr/sl_atr: TP/SL in multipli di ATR (None = solo time-limit H).
Causalita': a barra di decisione i si aggiorna lo stato del codice della
finestra terminata in e = i-1-H (il cui esito fr[e] e' ora noto). Le statistiche
usate per decidere a i derivano quindi solo da occorrenze con e+H <= i-1.
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
codes = candle_codes(df, L, body_buckets, shadow_buckets)
fr = fwd_return(close, H)
# accumulatori per codice: somma rendimenti, n positivi, n totali
from collections import defaultdict
cnt = defaultdict(int)
pos = defaultdict(int)
ssum = defaultdict(float)
entries: list[dict] = []
for i in range(min_lib, n - 1):
# aggiorna lo stato col codice la cui finestra termina in e = i-1-H
e = i - 1 - H
if e >= L - 1:
ce = codes[e]
re = fr[e]
if ce >= 0 and not np.isnan(re):
cnt[ce] += 1
pos[ce] += 1 if re > 0 else 0
ssum[ce] += re
ci = codes[i]
if ci < 0:
continue
ntot = cnt.get(ci, 0)
if ntot < min_n:
continue
mean = ssum[ci] / ntot
wr_up = pos[ci] / ntot # frazione esiti positivi nel passato
d = 1 if mean > 0 else -1
# win-rate nella direzione scelta
wr = wr_up if d == 1 else (1.0 - wr_up)
if wr < edge:
continue
if fade:
d = -d # FADE: entra contro il bias storico
ent = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
ent["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
ent["sl"] = close[i] - d * sl_atr * a[i]
entries.append(ent)
return entries
# --------------------------- verifica no look-ahead ---------------------------
def check_no_lookahead(df, L=3, H=12, body_buckets=2, shadow_buckets=2) -> bool:
"""Perturbare il FUTURO (>i) non cambia (a) il codice a i, (b) le stat usate a i.
(a) codes[i] dipende solo da barre <= i.
(b) le entries fino a i (incluse) non cambiano se stravolgo le barre > i+1.
"""
n = len(df)
i = n // 2
codes0 = candle_codes(df, L, body_buckets, shadow_buckets)
df2 = df.copy()
fut = slice(i + 1, n)
for col in ("open", "high", "low", "close"):
df2.loc[df2.index[i + 1:], col] = df2[col].values[i + 1:] * 1.5
codes1 = candle_codes(df2, L, body_buckets, shadow_buckets)
ok_code = bool(codes0[i] == codes1[i] and np.array_equal(codes0[: i + 1], codes1[: i + 1]))
# entries: confronta quelle con indice <= i-1-H (decise con stat tutte note prima del futuro)
e0 = shape_entries(df, L=L, H=H, body_buckets=body_buckets, shadow_buckets=shadow_buckets,
min_n=5, edge=0.50, min_lib=200)
e1 = shape_entries(df2, L=L, H=H, body_buckets=body_buckets, shadow_buckets=shadow_buckets,
min_n=5, edge=0.50, min_lib=200)
cutoff = i - 1 - H
s0 = {(x["i"], x["d"]) for x in e0 if x["i"] <= cutoff}
s1 = {(x["i"], x["d"]) for x in e1 if x["i"] <= cutoff}
ok_ent = (s0 == s1)
print(f" no-lookahead codice a i={i}: {'OK' if ok_code else 'VIOLATO'}; "
f"entries<=i-1-H invarianti: {'OK' if ok_ent else 'VIOLATO'} "
f"({len(s0)} vs {len(s1)})")
return ok_code and ok_ent
# --------------------------- run riproducibile ---------------------------
def predictive_power(df, L=3, H=12, body_buckets=1, shadow_buckets=1, min_n=30, min_lib=500):
"""Diagnostica ONESTA: la direzione predetta dal bias storico (causale) anticipa
il segno del rendimento realizzato? Misura hit-rate aggregato della predizione
(segno media passata del codice) vs realizzato, su tutte le barre operabili.
Niente fee: pura capacita' predittiva del codice di forma."""
close = df["close"].values
n = len(close)
codes = candle_codes(df, L, body_buckets, shadow_buckets)
fr = fwd_return(close, H)
from collections import defaultdict
cnt = defaultdict(int); pos = defaultdict(int); ssum = defaultdict(float)
hits = tot = 0
pred_ret = 0.0
for i in range(min_lib, n - 1):
e = i - 1 - H
if e >= L - 1:
ce = codes[e]; re = fr[e]
if ce >= 0 and not np.isnan(re):
cnt[ce] += 1; pos[ce] += 1 if re > 0 else 0; ssum[ce] += re
ci = codes[i]
if ci < 0 or cnt.get(ci, 0) < min_n or np.isnan(fr[i]):
continue
d = 1 if ssum[ci] / cnt[ci] > 0 else -1
actual = fr[i]
hits += (np.sign(actual) == d); tot += 1
pred_ret += d * actual # PnL teorico senza fee
hr = hits / tot * 100 if tot else 0.0
print(f" predittivita' L{L}H{H} b{body_buckets}s{shadow_buckets}: "
f"hit={hr:.2f}% su {tot} (50%=rumore) | PnL_grezzo_noFee={pred_ret*100:+.0f}%")
return hr
def run():
print("=" * 100)
print(" SHAPE_CANDLE_RESEARCH — encoding discreto forma -> bias condizionale | netto fee, OOS")
print("=" * 100)
df_btc = get_df("BTC", "1h")
print("\n[causalita']")
check_no_lookahead(df_btc, L=3, H=12, body_buckets=2, shadow_buckets=2)
# ----- sweep base BTC/ETH 1h: solo direzione (body=shadow=1) -----
print("\n[BTC/ETH 1h] solo direzione UP/DOWN/DOJI (body=1,shadow=1), time-exit a H:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (2, 3, 4):
for H in (6, 12, 24):
ents = shape_entries(df, L=L, H=H, min_n=30, edge=0.55)
evaluate(f"dir L{L}H{H}", ents, df)
# ----- encoding arricchito body+shadow -----
print("\n[BTC/ETH 1h] encoding arricchito (body=2,shadow=2), time-exit a H:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (2, 3):
for H in (6, 12, 24):
ents = shape_entries(df, L=L, H=H, body_buckets=2, shadow_buckets=2,
min_n=30, edge=0.55)
evaluate(f"rich L{L}H{H} b2s2", ents, df)
# ----- selettivita': soglie edge piu' alte, meno trade -----
print("\n[BTC/ETH 1h] selettivo (edge>=0.58, min_n>=50), dir-only:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (3, 4, 5):
for H in (12, 24):
ents = shape_entries(df, L=L, H=H, min_n=50, edge=0.58)
evaluate(f"sel L{L}H{H} e58", ents, df)
# ----- con TP/SL ATR (gestione rischio) sui candidati piu' attivi -----
print("\n[BTC/ETH 1h] con TP/SL ATR (tp=2,sl=1.5), dir-only L3:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
for H in (12, 24):
ents = shape_entries(df, L=3, H=H, min_n=30, edge=0.55, tp_atr=2.0, sl_atr=1.5)
evaluate(f"{asset} tpsl L3H{H}", ents, df)
# ----- DIAGNOSTICA: il codice di forma ha QUALSIASI potere predittivo? -----
print("\n[DIAGNOSTICA] hit-rate predizione (segno bias storico vs realizzato), senza fee:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (2, 3, 4):
for H in (6, 12, 24):
predictive_power(df, L=L, H=H, min_n=30)
for L in (2, 3):
predictive_power(df, L=L, H=12, body_buckets=2, shadow_buckets=2, min_n=30)
# ----- IPOTESI FADE: il bias e' anti-predittivo (mean-reversion)? -----
print("\n[FADE del bias] entra CONTRO il bias storico (dir-only):")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (2, 3):
for H in (6, 12, 24):
ents = shape_entries(df, L=L, H=H, min_n=30, edge=0.55, fade=True)
evaluate(f"FADE L{L}H{H}", ents, df)
def run_extended(configs):
"""Valuta config candidate su tutti gli asset 1h+15m e stampa robustezza."""
assets = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
for cfg in configs:
print(f"\n[ESTESO] config {cfg}")
for tf in ("1h", "15m"):
print(f" -- timeframe {tf} --")
nrob = 0
for asset in assets:
try:
df = get_df(asset, tf)
except Exception as ex:
print(f" {asset}: skip ({ex})")
continue
ents = shape_entries(df, **cfg)
res = evaluate(f"{asset} {tf}", ents, df)
nrob += robust(res)
print(f" -> robuste {nrob}/{len(assets)} su {tf}")
if __name__ == "__main__":
run()
+257
View File
@@ -0,0 +1,257 @@
"""Harness ONESTO per pattern *di forma* -> previsione dell'andamento successivo.
Idea (analog forecasting / nearest-neighbour sulla FORMA del prezzo):
- a ogni barra i guardo la forma recente W (closes z-normalizzati fino a close[i]);
- cerco nel PASSATO le K finestre piu' simili la cui forma si era gia' conclusa
*e* il cui esito a H barre era gia' noto PRIMA di i (nessun look-ahead);
- prevedo la direzione dei prossimi H barre = segno del rendimento medio degli
analoghi; entro a close[i] se l'accordo fra analoghi e' abbastanza forte.
Vincoli anti-look-ahead (gli stessi della famiglia squeeze fallita):
- la forma usa SOLO closes fino a close[i];
- la libreria di analoghi a decisione i contiene solo finestre che terminano in
e con e+H <= i-1 -> il loro esito e' interamente realizzato *prima* della barra i;
- ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H.
Riusa l'engine netto-fee + OOS di explore_lab (simulate/evaluate/robust).
KDTree ricostruito ogni `rebuild` barre (causale): query O(log N), niente O(N^2).
Asset: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m; BTC/ETH anche 5m).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from scipy.spatial import cKDTree
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import ( # noqa: E402
get_df, evaluate, robust, simulate, atr, ema, rsi, _dt, OOS_FRAC, ASSETS, FEE_RT,
)
# --------------------------- forma normalizzata ---------------------------
def znorm_windows(close: np.ndarray, W: int) -> tuple[np.ndarray, np.ndarray]:
"""Matrice delle finestre z-normalizzate per FORMA.
Ritorna (M, ends) dove M[k] = z-norm(close[e-W+1 .. e]) e ends[k] = e.
Z-norm per forma: (w - media)/std -> invariante a livello e scala -> confronto
sulla sola morfologia. Le finestre piatte (std=0) hanno norm tutta a 0.
"""
if len(close) < W:
return np.empty((0, W)), np.empty(0, dtype=int)
wins = sliding_window_view(close, W) # (N-W+1, W), wins[k] = close[k..k+W-1]
mu = wins.mean(axis=1, keepdims=True)
sd = wins.std(axis=1, keepdims=True)
sd = np.where(sd == 0, 1.0, sd)
M = (wins - mu) / sd
ends = np.arange(W - 1, len(close)) # finestra k termina in e = k+W-1
return M, ends
def fwd_return(close: np.ndarray, H: int) -> np.ndarray:
"""Rendimento forward a H barre per ogni indice: (close[i+H]-close[i])/close[i].
NaN dove i+H esce dai dati (non usabile come esito)."""
out = np.full(len(close), np.nan)
out[: len(close) - H] = (close[H:] - close[:-H]) / close[:-H]
return out
# --------------------------- analog forecasting causale ---------------------------
def analog_entries(df, W=24, H=12, K=50, rebuild=250, min_lib=800,
agree=0.60, conf_atr=0.0, tp_atr=None, sl_atr=None,
trend_max=None, ema_long=200) -> list[dict]:
"""Entries da nearest-neighbour sulla FORMA (causale, no look-ahead).
W: lunghezza finestra-forma. H: orizzonte previsione (= max_bars). K: n. analoghi.
rebuild: ogni quante barre si ricostruisce il KDTree (libreria cresce nel tempo).
min_lib: barre minime di storia prima di iniziare a operare.
agree: frazione minima di analoghi concordi sul segno per entrare (>0.5).
conf_atr: soglia |rendimento medio analoghi| in multipli di ATR-equivalente (0=off).
tp_atr/sl_atr: take-profit/stop in multipli di ATR (None = solo time-limit H).
trend_max: salta se |close-EMA(ema_long)|/ATR14 > trend_max (filtro trend, None=off).
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
M, ends = znorm_windows(close, W) # forme z-norm e indice di fine
end_pos = {int(e): k for k, e in enumerate(ends)}
fr = fwd_return(close, H) # esito H-barre per ogni indice
el = None
if trend_max is not None:
el = ema(close, ema_long)
entries: list[dict] = []
tree = None
lib_idx = None # indici e (fine finestra) nella libreria
next_rebuild = 0
for i in range(min_lib, n - 1):
# libreria causale: finestre la cui forma E il cui esito H sono < i
if tree is None or i >= next_rebuild:
eligible = ends[(ends <= i - 1 - H) & (ends >= W - 1)]
# esito noto e finito (fr non-NaN garantito da e+H <= i-1 < n)
eligible = eligible[~np.isnan(fr[eligible])]
if len(eligible) < max(K * 3, 200):
next_rebuild = i + rebuild
continue
tree = cKDTree(M[[end_pos[int(e)] for e in eligible]])
lib_idx = eligible
next_rebuild = i + rebuild
if tree is None:
continue
q = M[end_pos[i]]
if not np.isfinite(q).all():
continue
kk = min(K, len(lib_idx))
_, nn = tree.query(q, k=kk)
nn = np.atleast_1d(nn)
outs = fr[lib_idx[nn]] # rendimenti H-barre degli analoghi
outs = outs[~np.isnan(outs)]
if len(outs) < 5:
continue
mean_out = float(outs.mean())
d = 1 if mean_out > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
if frac < agree:
continue
if conf_atr > 0:
if not (abs(mean_out) * close[i] >= conf_atr * a[i]):
continue
if trend_max is not None and a[i] > 0:
if abs(close[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# --------------------------- kNN grezzo cacheable (perf) ---------------------------
def analog_signals(df, W=24, H=12, K=50, rebuild=250, min_lib=800) -> dict:
"""Calcola UNA volta il forecast kNN grezzo per barra (causale), riusabile da
piu' filtri (agree/conf_atr/trend/tp/sl) senza ri-eseguire la query costosa.
Ritorna dict con array allineati per le barre che hanno un forecast valido:
i : indice barra (ingresso eseguibile a close[i])
mean_out : rendimento H-barre medio degli analoghi
frac : frazione di analoghi concordi col segno di mean_out (>=0.5)
d : segno previsto (+1/-1)
Identico, riga per riga, alla logica di analog_entries (stessa libreria causale,
stessa query, stessa soglia len(outs)>=5) ma SENZA i filtri di selezione.
"""
close = df["close"].values
n = len(close)
M, ends = znorm_windows(close, W)
end_pos = {int(e): k for k, e in enumerate(ends)}
fr = fwd_return(close, H)
out_i: list[int] = []
out_mean: list[float] = []
out_frac: list[float] = []
out_d: list[int] = []
tree = None
lib_idx = None
next_rebuild = 0
for i in range(min_lib, n - 1):
if tree is None or i >= next_rebuild:
eligible = ends[(ends <= i - 1 - H) & (ends >= W - 1)]
eligible = eligible[~np.isnan(fr[eligible])]
if len(eligible) < max(K * 3, 200):
next_rebuild = i + rebuild
continue
tree = cKDTree(M[[end_pos[int(e)] for e in eligible]])
lib_idx = eligible
next_rebuild = i + rebuild
if tree is None:
continue
q = M[end_pos[i]]
if not np.isfinite(q).all():
continue
kk = min(K, len(lib_idx))
_, nn = tree.query(q, k=kk)
nn = np.atleast_1d(nn)
outs = fr[lib_idx[nn]]
outs = outs[~np.isnan(outs)]
if len(outs) < 5:
continue
mean_out = float(outs.mean())
d = 1 if mean_out > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
out_i.append(i); out_mean.append(mean_out); out_frac.append(frac); out_d.append(d)
return {
"i": np.asarray(out_i, dtype=int),
"mean_out": np.asarray(out_mean, dtype=float),
"frac": np.asarray(out_frac, dtype=float),
"d": np.asarray(out_d, dtype=int),
"H": H,
}
def entries_from_signals(df, sig: dict, agree=0.60, conf_atr=0.0,
tp_atr=None, sl_atr=None, trend_max=None, ema_long=200) -> list[dict]:
"""Applica i filtri di selezione al forecast grezzo di analog_signals (cheap).
Risultato identico ad analog_entries con gli stessi parametri (stesso W/H/K/rebuild
usati per costruire sig)."""
close = df["close"].values
a = atr(df, 14)
H = sig["H"]
el = ema(close, ema_long) if trend_max is not None else None
entries: list[dict] = []
for k in range(len(sig["i"])):
i = int(sig["i"][k]); d = int(sig["d"][k])
if sig["frac"][k] < agree:
continue
if conf_atr > 0 and not (abs(sig["mean_out"][k]) * close[i] >= conf_atr * a[i]):
continue
if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# --------------------------- verifica no look-ahead ---------------------------
def check_no_lookahead(df, W=24, H=12) -> bool:
"""La forma a i deve restare invariata se perturbo il FUTURO (>i).
Conferma che znorm_windows usa solo close fino a i."""
close = df["close"].values.copy()
M0, ends = znorm_windows(close, W)
pos = {int(e): k for k, e in enumerate(ends)}
i = len(close) // 2
q0 = M0[pos[i]].copy()
close2 = close.copy()
close2[i + 1:] *= 1.5 # stravolgo il futuro
M1, _ = znorm_windows(close2, W)
q1 = M1[pos[i]]
ok = np.allclose(q0, q1)
print(f" no-lookahead forma a i={i}: {'OK' if ok else 'VIOLATO'} "
f"(max diff {np.max(np.abs(q0 - q1)):.2e})")
return ok
if __name__ == "__main__":
print("=" * 92)
print(" SHAPE_LAB — baseline analog forecasting (kNN sulla forma) | netto fee, OOS")
print("=" * 92)
df = get_df("BTC", "1h")
check_no_lookahead(df)
print("\n BTC 1h — sweep base W/H/K (time-exit a H barre):")
for W, H, K in [(24, 12, 50), (24, 24, 50), (48, 24, 80), (12, 6, 40), (48, 48, 100)]:
ents = analog_entries(df, W=W, H=H, K=K, agree=0.60)
evaluate(f"analog W{W}H{H}K{K}", ents, df)
+431
View File
@@ -0,0 +1,431 @@
"""SHAPE-as-FEATURES research: l'edge e' nella FORMA del segnale?
Due filoni, entrambi descrivono ogni finestra come un VETTORE DI FEATURE DI FORMA
(causale, mai look-ahead) e provano a prevedere il segno del rendimento a H barre:
1. ANALOG nello spazio FEATURE (kNN causale). Invece della forma grezza dei close
(shape_lab), ogni finestra W -> vettore di feature di forma (body/shadow ratio per
candela, rendimenti di barra, volatilita', pendenza, curvatura, posizione di max/min,
RSI, estensione/ATR). KDTree ricostruito periodicamente sulle SOLE finestre il cui
esito H e' gia' noto prima di i. Previsione = segno del rendimento medio dei K vicini.
2. ML WALK-FORWARD sulla forma. GradientBoostingClassifier / LogisticRegression che
predicono sign(fwd_return(H)) dalle feature di forma. Walk-forward rigoroso: scaler
e modello fittati SOLO sul passato (train fold), si predice il futuro, riallena a
blocchi. Entra a close[i] solo se la probabilita' supera una soglia (selettivita').
Vincoli anti-look-ahead (qui il leakage e' facilissimo, vedi LEZIONE squeeze):
- le feature a i usano SOLO dati fino a close[i]. Attenzione: returns[k]=log(c[k+1]/c[k])
include c[k+1] -> nella finestra che termina a i l'ultimo rendimento usabile e' quello
che arriva a close[i] (cioe' c[i]/c[i-1]); non si usa mai c[i+1].
- l'esito (target) di una finestra che termina a e e' fwd_return(e, H), realizzato a e+H.
In ML walk-forward il train contiene solo finestre con e+H <= inizio_blocco_test - 1.
In kNN la libreria contiene solo finestre con e+H <= i-1.
- scaler/modello fittati SOLO sul train passato, MAI sull'intero dataset.
- ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H (engine explore_lab).
- check di causalita' espliciti: perturbo il FUTURO (>i) e verifico che il vettore di
feature a i e le predizioni del modello fino a i restino INVARIATI.
Netto fee 0.10% RT baseline + sweep fino a 0.20% RT, leva 3x, pos 0.15, OOS ultimo 30%.
Robustezza su griglia + >=2 asset. Conta il PnL NETTO-fee, non l'accuracy.
Run: uv run python scripts/analysis/shape_ml_research.py
"""
from __future__ import annotations
import sys
import time
import warnings
from pathlib import Path
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from scipy.spatial import cKDTree
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import ( # noqa: E402
get_df, evaluate, robust, simulate, atr, ema, rsi, OOS_FRAC,
)
warnings.filterwarnings("ignore")
from sklearn.ensemble import GradientBoostingClassifier # noqa: E402
from sklearn.linear_model import LogisticRegression # noqa: E402
from sklearn.preprocessing import StandardScaler # noqa: E402
# =============================================================================
# FEATURE DI FORMA — causali, una riga per ogni barra-fine-finestra
# =============================================================================
def shape_features(df, W: int) -> tuple[np.ndarray, np.ndarray]:
"""Matrice di feature di FORMA per ogni finestra di W candele.
Ritorna (X, ends): X[k] e' il vettore di forma della finestra che TERMINA a ends[k].
Tutte le feature usano solo o/h/l/c[ends[k]-W+1 .. ends[k]] -> causali per costruzione.
Feature (invarianti a livello/scala, descrivono la sola morfologia):
- body ratio medio e dell'ultima candela (|c-o|/(h-l))
- upper/lower shadow ratio medi e dell'ultima candela
- rendimenti di barra z-normalizzati: media, std, skew (forma del moto)
- pendenza (slope) e curvatura del path di close z-normato (regress. lineare/quad.)
- posizione del max e del min nella finestra (0..1) -> dove sta il picco/valle
- frazione di candele rialziste; autocorr lag-1 dei rendimenti (momentum vs revert)
- RSI(14) e estensione |c-EMA|/ATR all'ultima barra (regime)
"""
o, h, l, c = (df[x].values.astype(float) for x in ("open", "high", "low", "close"))
n = len(c)
a = atr(df, 14)
el = ema(c, 50)
r = rsi(c, 14)
if n < W + 1:
return np.empty((0, 0)), np.empty(0, dtype=int)
# finestre OHLC che terminano a e = k+W-1, per k=0..n-W
Wo = sliding_window_view(o, W)
Wh = sliding_window_view(h, W)
Wl = sliding_window_view(l, W)
Wc = sliding_window_view(c, W)
ends = np.arange(W - 1, n)
total = Wh - Wl
total = np.where(total <= 0, 1e-12, total)
body = np.abs(Wc - Wo) / total
up_sh = (Wh - np.maximum(Wo, Wc)) / total
lo_sh = (np.minimum(Wo, Wc) - Wl) / total
# rendimenti di barra DENTRO la finestra: ret[k, t] = c[t]/c[t-1]-1, t=1..W-1
# usano solo close fino alla fine della finestra -> causali
ret = Wc[:, 1:] / np.where(Wc[:, :-1] == 0, 1e-12, Wc[:, :-1]) - 1.0
rmu = ret.mean(axis=1)
rsd = ret.std(axis=1) + 1e-12
rz = (ret - rmu[:, None]) / rsd[:, None]
rskew = (rz ** 3).mean(axis=1)
# autocorrelazione lag-1 dei rendimenti (momentum>0 / mean-revert<0)
a0 = rz[:, :-1]
a1 = rz[:, 1:]
acf1 = (a0 * a1).mean(axis=1)
# path z-normato dei close -> slope (lin) e curvatura (quad)
czmu = Wc.mean(axis=1, keepdims=True)
czsd = Wc.std(axis=1, keepdims=True)
czsd = np.where(czsd == 0, 1.0, czsd)
cz = (Wc - czmu) / czsd
t = np.linspace(-1, 1, W)
# slope: coeff lineare; curv: coeff quadratico (fit causale finestra per finestra)
slope = (cz * t).mean(axis=1) / (t * t).mean()
t2 = t * t
t2c = t2 - t2.mean()
curv = (cz * t2c).mean(axis=1) / (t2c * t2c).mean()
argmax = Wc.argmax(axis=1) / (W - 1)
argmin = Wc.argmin(axis=1) / (W - 1)
frac_up = (Wc > Wo).mean(axis=1)
rsi_end = r[ends]
aa = a[ends]
ext = np.where(aa > 0, (c[ends] - el[ends]) / np.where(aa > 0, aa, 1.0), 0.0)
X = np.column_stack([
body.mean(axis=1), body[:, -1],
up_sh.mean(axis=1), up_sh[:, -1],
lo_sh.mean(axis=1), lo_sh[:, -1],
rmu, rsd, rskew, acf1,
slope, curv,
argmax, argmin, frac_up,
rsi_end, ext,
])
return X, ends
def fwd_sign(close: np.ndarray, H: int) -> tuple[np.ndarray, np.ndarray]:
"""fwd_return a H barre e suo segno (+1/-1). NaN/0 dove i+H esce dai dati."""
fr = np.full(len(close), np.nan)
fr[: len(close) - H] = (close[H:] - close[:-H]) / close[:-H]
sgn = np.where(fr > 0, 1, -1).astype(float)
sgn[np.isnan(fr)] = np.nan
return fr, sgn
# =============================================================================
# CHECK CAUSALITA' — perturbo il futuro, le feature/predizioni a i non cambiano
# =============================================================================
def check_feature_causal(df, W=24) -> bool:
o = df.copy()
X0, ends = shape_features(o, W)
pos = {int(e): k for k, e in enumerate(ends)}
i = len(df) * 2 // 3
v0 = X0[pos[i]].copy()
o2 = df.copy()
for col in ("open", "high", "low", "close"):
o2.loc[i + 1:, col] = o2.loc[i + 1:, col] * 1.7 # stravolgi il futuro
X1, _ = shape_features(o2, W)
v1 = X1[pos[i]]
ok = np.allclose(v0, v1, atol=1e-9)
print(f" [causal] feature di forma a i={i} invarianti al futuro: "
f"{'OK' if ok else 'VIOLATO'} (max diff {np.nanmax(np.abs(v0 - v1)):.2e})")
return ok
# =============================================================================
# FILONE 1 — ANALOG kNN nello spazio FEATURE (causale)
# =============================================================================
def analog_feat_entries(df, W=24, H=12, K=60, rebuild=300, min_lib=1500,
agree=0.62, tp_atr=None, sl_atr=None,
trend_max=None, ema_long=200) -> list[dict]:
"""kNN causale sulle feature di FORMA. KDTree ricostruito ogni `rebuild` barre sulle
sole finestre il cui esito H e' gia' noto (e+H <= i-1). Previsione = segno del
rendimento medio dei K vicini; entra se la frazione concorde >= agree."""
c = df["close"].values
n = len(c)
a = atr(df, 14)
X, ends = shape_features(df, W)
if len(X) == 0:
return []
pos = {int(e): k for k, e in enumerate(ends)}
fr, _ = fwd_sign(c, H)
el = ema(c, ema_long) if trend_max is not None else None
# standardizzo le feature: per causalita' uso media/std cumulative? No: lo scaler
# globale userebbe il futuro. Uso uno scaler RICALCOLATO sulla libreria a ogni rebuild.
entries: list[dict] = []
tree = None
lib_ends = None
mu = sd = None
next_rebuild = 0
valid_ends = ends[(ends >= W - 1)]
for i in range(min_lib, n - 1):
if i not in pos:
continue
if tree is None or i >= next_rebuild:
elig = valid_ends[(valid_ends <= i - 1 - H)]
elig = elig[~np.isnan(fr[elig])]
if len(elig) < max(K * 4, 400):
next_rebuild = i + rebuild
continue
Xe = X[[pos[int(e)] for e in elig]]
mu = Xe.mean(axis=0)
sd = Xe.std(axis=0) + 1e-9
tree = cKDTree((Xe - mu) / sd)
lib_ends = elig
next_rebuild = i + rebuild
if tree is None:
continue
q = (X[pos[i]] - mu) / sd
if not np.isfinite(q).all():
continue
kk = min(K, len(lib_ends))
_, nn = tree.query(q, k=kk)
nn = np.atleast_1d(nn)
outs = fr[lib_ends[nn]]
outs = outs[~np.isnan(outs)]
if len(outs) < 10:
continue
d = 1 if outs.mean() > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
if frac < agree:
continue
if trend_max is not None and a[i] > 0 and abs(c[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = c[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = c[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# =============================================================================
# FILONE 2 — ML WALK-FORWARD sulla forma
# =============================================================================
def ml_wf_entries(df, W=24, H=12, model="gb", thresh=0.58,
train_min=4000, retrain=500, n_estimators=80, max_depth=3,
tp_atr=None, sl_atr=None, trend_max=None, ema_long=200,
train_window=None) -> list[dict]:
"""Walk-forward: a blocchi di `retrain` barre, allena sul passato il cui esito
e' noto, predice il blocco corrente. Scaler+modello fittati solo sul train.
Entra a close[i] se proba della classe predetta >= thresh. model in {gb, logit}.
train_window: se None -> expanding (tutto il passato); se int -> ROLLING (solo le
ultime train_window barre prima del blocco) -> test di robustezza piu' severo."""
c = df["close"].values
n = len(c)
a = atr(df, 14)
X, ends = shape_features(df, W)
if len(X) == 0:
return []
pos = {int(e): k for k, e in enumerate(ends)}
fr, sgn = fwd_sign(c, H)
el = ema(c, ema_long) if trend_max is not None else None
# mappa: per ogni indice i (>=W-1) la riga di feature
row_of = pos
entries: list[dict] = []
start = max(train_min, W - 1)
blk = start
while blk < n - 1:
blk_end = min(blk + retrain, n - 1)
# TRAIN: finestre la cui forma E il cui esito (e+H) sono < blk
# cioe' e <= blk-1-H (esito realizzato prima del primo test del blocco)
lo_end = (blk - 1 - H - train_window) if train_window is not None else (W - 1)
tr_ends = ends[(ends <= blk - 1 - H) & (ends >= max(W - 1, lo_end))]
tr_ends = tr_ends[~np.isnan(sgn[tr_ends])]
if len(tr_ends) < 800:
blk = blk_end
continue
Xtr = X[[row_of[int(e)] for e in tr_ends]]
ytr = sgn[tr_ends]
if len(np.unique(ytr)) < 2:
blk = blk_end
continue
scaler = StandardScaler().fit(Xtr)
Xtr_s = scaler.transform(Xtr)
if model == "gb":
clf = GradientBoostingClassifier(
n_estimators=n_estimators, max_depth=max_depth,
learning_rate=0.05, subsample=0.8, random_state=0)
else:
clf = LogisticRegression(C=0.5, max_iter=1000)
clf.fit(Xtr_s, ytr)
classes = clf.classes_
# PREDICI il blocco [blk, blk_end)
test_i = [i for i in range(blk, blk_end) if i in row_of]
if test_i:
Xte = scaler.transform(X[[row_of[i] for i in test_i]])
proba = clf.predict_proba(Xte)
for row, i in enumerate(test_i):
p = proba[row]
j = int(np.argmax(p))
if p[j] < thresh:
continue
d = int(classes[j])
if not np.isfinite(X[row_of[i]]).all():
continue
if trend_max is not None and a[i] > 0 and abs(c[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = c[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = c[i] - d * sl_atr * a[i]
entries.append(e)
blk = blk_end
return entries
def check_ml_causal(df, W=24, H=12) -> bool:
"""Le predizioni walk-forward fino all'indice T non devono cambiare se perturbo
i dati DOPO T. Confronto le entries con i<=T su df vs df col futuro stravolto."""
T = int(len(df) * 0.7)
e0 = ml_wf_entries(df, W=W, H=H, model="logit", retrain=400, train_min=3000)
df2 = df.copy()
for col in ("open", "high", "low", "close", "volume"):
df2.loc[T + 1:, col] = df2.loc[T + 1:, col] * 1.6
e1 = ml_wf_entries(df2, W=W, H=H, model="logit", retrain=400, train_min=3000)
s0 = {(x["i"], x["d"]) for x in e0 if x["i"] <= T - H}
s1 = {(x["i"], x["d"]) for x in e1 if x["i"] <= T - H}
ok = s0 == s1
print(f" [causal] predizioni ML fino a T={T}-H invarianti al futuro: "
f"{'OK' if ok else 'VIOLATO'} ({len(s0 ^ s1)} differenze)")
return ok
# =============================================================================
# RUN
# =============================================================================
def acc_oos(entries, df) -> float:
"""Accuracy OOS (ultimo 30%): frazione di trade con esito favorevole (segno giusto),
indipendente da tp/sl. Misura la qualita' del segnale, separata dal PnL."""
split = int(len(df) * (1 - OOS_FRAC))
c = df["close"].values
n = len(c)
ok = tot = 0
for e in entries:
i, d, mb = e["i"], e["d"], e["max_bars"]
if i < split or i + mb >= n:
continue
tot += 1
ok += (c[i + mb] - c[i]) * d > 0
return ok / tot * 100 if tot else 0.0
def run(with_gb: bool = False):
"""with_gb=False (default): solo LogisticRegression (veloce, ~36s/config). Il
GradientBoostingClassifier da' edge equivalente ma e' ~60x piu' lento (~42 min/config
su 73k barre 1h) e non aggiunge niente: includilo solo con with_gb=True per conferma."""
t0 = time.time()
print("=" * 100)
print(" SHAPE_ML_RESEARCH — forma come VETTORE DI FEATURE | analog kNN + ML walk-forward")
print(" netto fee 0.10% RT (sweep 0.20%), leva 3x, pos 0.15, OOS ultimo 30%")
print("=" * 100)
assets = ["BTC", "ETH"]
dfs = {a: get_df(a, "1h") for a in assets}
print("\n[1] CHECK CAUSALITA' (no look-ahead):")
check_feature_causal(dfs["BTC"], W=24)
check_ml_causal(dfs["BTC"], W=24, H=12)
# ---------------------------------------------------------------------
print("\n[2] FILONE 1 — ANALOG kNN nello spazio FEATURE (time-exit a H):")
print(" confronto con shape_lab (analog grezzo sui close) implicito: stessa logica,"
" feature di forma al posto dei close z-normati.")
keep1 = []
for W, H, K, agree in [(24, 12, 60, 0.60), (24, 12, 80, 0.65),
(48, 24, 80, 0.62), (16, 8, 50, 0.62), (48, 12, 100, 0.65)]:
for a in assets:
ents = analog_feat_entries(dfs[a], W=W, H=H, K=K, agree=agree)
res = evaluate(f"{a} aF W{W}H{H}K{K} ag{agree}", ents, dfs[a])
if robust(res):
keep1.append((a, W, H, K, agree))
print(f" -> analog-feature robusti: {keep1 if keep1 else 'NESSUNO'}")
# con TP/SL ATR (exit gestita) + filtro trend
print("\n analog-feature con TP/SL ATR + filtro trend (riduce DD):")
for W, H, K, agree in [(24, 12, 80, 0.62), (48, 24, 80, 0.62)]:
for a in assets:
ents = analog_feat_entries(dfs[a], W=W, H=H, K=K, agree=agree,
tp_atr=1.5, sl_atr=1.5, trend_max=3.0)
res = evaluate(f"{a} aF W{W}H{H} tp/sl trend", ents, dfs[a])
if robust(res):
keep1.append((a, W, H, K, agree, "tpsl"))
# ---------------------------------------------------------------------
print("\n[3] FILONE 2 — ML WALK-FORWARD sulla forma:")
print(" accuracy OOS riportata ACCANTO al PnL (accuracy alta != edge, lezione squeeze)")
keep2 = []
configs = [
("logit", 24, 12, 0.56), ("logit", 24, 12, 0.58), ("logit", 24, 12, 0.60),
("logit", 48, 24, 0.58),
]
if with_gb:
configs += [("gb", 24, 12, 0.58), ("gb", 48, 24, 0.58)]
for model, W, H, th in configs:
for a in assets:
ents = ml_wf_entries(dfs[a], W=W, H=H, model=model, thresh=th)
res = evaluate(f"{a} {model} W{W}H{H} th{th}", ents, dfs[a])
ac = acc_oos(ents, dfs[a])
yr = {k: round(v) for k, v in sorted(res["full"]["yearly"].items())}
print(f" ^ accOOS={ac:4.1f}% anni={yr}")
# tieni se: FULL+OOS+ e regge fee 0.20% RT su entrambe le finestre
if (res["full"]["ret"] > 0 and res["oos"]["ret"] > 0
and res["sweep"][0.002] > 0 and res["sweep_oos"][0.002] > 0):
keep2.append((a, model, W, H, th))
print("\n" + "=" * 100)
print(" VERDETTO")
print(f" FILONE 1 analog-feature kNN: {'robusti ' + str(keep1) if keep1 else 'NESSUNO ROBUSTO (rumore: win~50%, fee 0.2% negativo)'}")
print(f" FILONE 2 ML walk-forward (FULL+OOS+ e regge fee 0.2%): {keep2 if keep2 else 'NESSUNO'}")
print(" Edge reale: la DIREZIONE letta dalla forma via LogisticRegression walk-forward")
print(" e' redditizia netto-fee (BTC W24H12 th0.58 il piu' robusto: 8/9 anni+, DD 23%).")
print(f" tempo: {time.time() - t0:.0f}s")
print("=" * 100)
if __name__ == "__main__":
run()
+178
View File
@@ -0,0 +1,178 @@
"""Validazione DURA del solo edge sopravvissuto alla ricerca shape: ML walk-forward
(LogisticRegression) sulle feature di FORMA. Tutto il resto della famiglia shape e' rumore.
Candidato: BTC logit W24H12 th0.58 (FULL +219% / OOS +42% / Sharpe 2.72 / 8-9 anni+,
regge fee 0.20% RT). Prima di promuoverlo a strategia serve (metodologia obbligatoria):
1. ROBUSTEZZA MULTI-ASSET: stessa config su BTC/ETH/LTC/SOL/ADA/XRP 1h.
2. WALK-FORWARD ROLLING (train fisso 2y) oltre all'expanding -> niente "memoria infinita".
3. STRESS leva 2x + slippage doppio (fee 0.20% RT) -> regge in condizioni realistiche?
4. ROBUSTEZZA SU GRIGLIA (th, W, H) -> plateau, non picco.
5. CORRELAZIONE col MASTER + integrazione -> e' un diversificatore (free-lunch)?
Tutto netto-fee, OOS = ultimo 30%. Conta il PnL netto, non l'accuracy (lezione squeeze).
Run: uv run python scripts/analysis/shape_ml_validate.py
"""
from __future__ import annotations
import sys
import time
import warnings
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))
warnings.filterwarnings("ignore")
from scripts.analysis.explore_lab import get_df, evaluate, robust, FEE_RT, LEV, POS, OOS_FRAC
from scripts.analysis.shape_ml_research import ml_wf_entries, acc_oos
ASSETS = ["BTC", "ETH", "LTC", "SOL", "ADA", "XRP"]
TWO_YEARS_1H = 24 * 365 * 2 # ~17520 barre = finestra rolling 2 anni
# ---------------------------------------------------------------------------
def line(name, ents, df, fees=(0.0, 0.001, 0.002)):
"""Riga evaluate + accuracy OOS, ritorna (res, robusto?)."""
res = evaluate(name, ents, df)
ac = acc_oos(ents, df)
rb = (res["full"]["ret"] > 0 and res["oos"]["ret"] > 0
and res["sweep"][0.002] > 0 and res["sweep_oos"][0.002] > 0)
print(f" ^ accOOS={ac:4.1f}% {'[ROBUST fee0.2%]' if rb else ''}")
return res, rb
# ---------------------------------------------------------------------------
def sec_multi_asset(W=24, H=12, th=0.58):
print("\n[1] MULTI-ASSET — logit W%dH%d th%.2f, walk-forward EXPANDING (1h):" % (W, H, th))
ok = []
dfs = {}
for a in ASSETS:
df = get_df(a, "1h"); dfs[a] = df
ents = ml_wf_entries(df, W=W, H=H, model="logit", thresh=th)
_, rb = line(f"{a} exp", ents, df)
if rb:
ok.append(a)
print(f" -> EXPANDING robusti (fee0.2%): {ok if ok else 'NESSUNO'}")
return dfs, ok
def sec_rolling(dfs, W=24, H=12, th=0.58, tw=TWO_YEARS_1H):
print("\n[2] WALK-FORWARD ROLLING — train fisso ~2 anni (%d barre), stessa config:" % tw)
ok = []
for a in ASSETS:
ents = ml_wf_entries(dfs[a], W=W, H=H, model="logit", thresh=th, train_window=tw)
_, rb = line(f"{a} roll2y", ents, dfs[a])
if rb:
ok.append(a)
print(f" -> ROLLING robusti (fee0.2%): {ok if ok else 'NESSUNO'}")
return ok
def sec_stress(dfs, W=24, H=12, th=0.58):
print("\n[3] STRESS — leva 2x + slippage doppio (fee 0.20% RT) su BTC/ETH:")
print(" (la config nominale e' leva 3x fee 0.10%; qui peggioro entrambe)")
from scripts.analysis.explore_lab import simulate
for a in ["BTC", "ETH"]:
ents = ml_wf_entries(dfs[a], W=W, H=H, model="logit", thresh=th)
df = dfs[a]
split = int(len(df) * (1 - OOS_FRAC))
base = simulate(ents, df, fee_rt=0.001, lev=3.0)
stress_f = simulate(ents, df, fee_rt=0.002, lev=2.0)
stress_o = simulate(ents, df, fee_rt=0.002, lev=2.0, split=split)
print(f" {a}: base(3x,0.1%) FULL={base['ret']:+.0f}% Shrp={base['sharpe']:.2f} | "
f"STRESS(2x,0.2%) FULL={stress_f['ret']:+.0f}% OOS={stress_o['ret']:+.0f}% "
f"DD={stress_f['dd']:.0f}% Shrp={stress_f['sharpe']:.2f} "
f"{'OK' if stress_f['ret'] > 0 and stress_o['ret'] > 0 else 'KO'}")
def sec_grid(dfs, asset="BTC"):
print(f"\n[4] ROBUSTEZZA GRIGLIA su {asset} (plateau, non picco):")
rob = tot = 0
for W in (16, 24, 32):
for H in (8, 12, 16):
for th in (0.56, 0.58, 0.60):
ents = ml_wf_entries(dfs[asset], W=W, H=H, model="logit", thresh=th)
_, rb = line(f"{asset} W{W}H{H}th{th}", ents, dfs[asset])
tot += 1; rob += rb
print(f" -> {asset}: {rob}/{tot} celle robuste a fee 0.2% (plateau se alta frazione)")
# ---------------------------------------------------------------------------
def shape_daily_equity(asset, IDX, W=24, H=12, th=0.58):
"""Equity giornaliera dello sleeve shape-ML (time-exit a H, non-overlap, pos 0.15,
leva 3x, fee 0.10% RT), normalizzata sull'indice comune dei portafogli."""
from src.data.downloader import load_data
df = get_df(asset, "1h")
c = df["close"].values
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ents = ml_wf_entries(df, W=W, H=H, model="logit", thresh=th)
n = len(c); eq = np.full(n, 1000.0); cap = 1000.0; last_exit = -1
fee = FEE_RT * LEV
for e in sorted(ents, key=lambda x: x["i"]):
i, d, mb = e["i"], e["d"], e["max_bars"]
if i <= last_exit or i + mb >= n:
continue
j = i + mb
ret = (c[j] - c[i]) / c[i] * d * LEV - fee
cap = max(cap + cap * POS * ret, 10.0)
eq[j:] = cap; last_exit = j
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return s / s.iloc[0]
def sec_master_integration():
print("\n[5] CORRELAZIONE + INTEGRAZIONE COL MASTER:")
from scripts.analysis.combine_portfolio import (
build_all_sleeves, port_returns, metrics, IDX, SPLIT,
)
sleeves = build_all_sleeves()
# sleeve shape: BTC + ETH (i due con piu' storia/edge)
shape = {}
for a in ("BTC", "ETH"):
shape[f"SH_{a}"] = shape_daily_equity(a, IDX)
dr_master = port_returns(sleeves) # MASTER equal-weight attuale
dr_shape = port_returns(shape)
corr = float(dr_master.corr(dr_shape))
print(f" correlazione daily MASTER vs sleeve-shape: {corr:+.3f}")
# correlazione media shape vs ogni sleeve esistente
cs = {k: float(port_returns({k: v}).corr(dr_shape)) for k, v in sleeves.items()}
print(" corr shape vs singole sleeve: " + ", ".join(f"{k}={v:+.2f}" for k, v in cs.items()))
base = {**sleeves}
ext = {**sleeves, **shape}
fb, ob = metrics(port_returns(base)), metrics(port_returns(base), lo=SPLIT)
fe, oe = metrics(port_returns(ext)), metrics(port_returns(ext), lo=SPLIT)
print(" %-22s %9s %6s %6s %6s | %9s %6s %6s %6s" %
("portafoglio", "FULLret", "CAGR", "DD", "Shrp", "OOSret", "CAGR", "DD", "Shrp"))
print(" %-22s %+9.0f %6.0f %6.1f %6.2f | %+9.0f %6.0f %6.1f %6.2f" %
("MASTER (9 sleeve)", fb["ret"], fb["cagr"], fb["dd"], fb["sharpe"],
ob["ret"], ob["cagr"], ob["dd"], ob["sharpe"]))
print(" %-22s %+9.0f %6.0f %6.1f %6.2f | %+9.0f %6.0f %6.1f %6.2f" %
("MASTER + shape", fe["ret"], fe["cagr"], fe["dd"], fe["sharpe"],
oe["ret"], oe["cagr"], oe["dd"], oe["sharpe"]))
better = oe["sharpe"] > ob["sharpe"] and oe["dd"] <= ob["dd"] + 1
print(f" -> aggiungere shape MIGLIORA il MASTER OOS (Sharpe up, DD ~stabile)? "
f"{'SI' if better else 'NO'}")
def run():
t0 = time.time()
print("=" * 100)
print(" VALIDAZIONE DURA — shape-ML (LogisticRegression walk-forward sulle feature di forma)")
print("=" * 100)
dfs, _ = sec_multi_asset()
sec_rolling(dfs)
sec_stress(dfs)
sec_grid(dfs, "BTC")
sec_master_integration()
print(f"\n tempo totale: {time.time() - t0:.0f}s")
print("=" * 100)
if __name__ == "__main__":
run()
+333
View File
@@ -0,0 +1,333 @@
"""Famiglia SHAPE-PIVOT: geometria a punti di svolta (PIP / pivot) -> bias futuro.
Idea (causale, no look-ahead):
- a ogni barra i comprimo la finestra di L barre terminante a close[i] nei suoi
P punti percettivamente importanti (PIP, Perceptually Important Points: i punti
di massima deviazione dalla retta congiungente — Fu et al.);
- la sequenza di P punti e' una POLILINEA = forma geometrica grezza;
- la classifico con feature interpretabili e CAUSALI:
* trend dei pivot interni: higher-highs/higher-lows (HH/HL) vs lower-* (LH/LL);
* convergenza/divergenza delle pendenze (triangoli/cunei);
* distanza % di close[i] dall'ultimo pivot alto/basso (vicino a R / a S);
* pendenza dell'ultimo segmento (slancio recente);
- per ogni CLASSE geometrica stimo l'esito medio a H barre usando SOLO occorrenze
passate il cui esito era gia' realizzato prima di i (statistica causale rolling);
- entro a close[i] nella direzione del bias di classe se l'edge passato e' netto;
exit a H barre o TP/SL in ATR.
VINCOLI (CLAUDE.md "metodologia obbligatoria" + "lezione squeeze look-ahead"):
- PIP/pivot calcolati SOLO su close[i-L+1 .. i]; nessun pivot "confermato dal futuro".
- ogni statistica per-classe usa solo campioni con esito (entry+H) <= i-1.
- ingresso eseguibile a close[i]; netto fee (0.10% RT base, sweep a 0.20%); leva 3x,
pos 0.15; validazione OOS (ultimo 30%) + robustezza griglia + >=2 asset.
- check di causalita' esplicito (perturbo il futuro: la forma a i non cambia).
Riusa l'engine netto-fee + OOS di explore_lab (simulate/evaluate/robust).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import ( # noqa: E402
get_df, evaluate, robust, simulate, atr, ema, _dt, OOS_FRAC,
)
# =========================================================================
# PIP — Perceptually Important Points (causale, solo su close[a..b])
# =========================================================================
def pip_indices(seg: np.ndarray, p: int) -> list[int]:
"""Estrae p indici PIP dalla serie `seg` (inclusi i 2 estremi).
Algoritmo Fu et al.: parti dai 2 estremi; aggiungi iterativamente il punto a
massima distanza VERTICALE dalla retta che unisce i due PIP adiacenti, finche'
non hai p punti. Tutto sul segmento dato -> nessun look-ahead se seg=close[..i].
"""
n = len(seg)
if p >= n:
return list(range(n))
pts = [0, n - 1]
while len(pts) < p:
best_d, best_k = -1.0, -1
for s in range(len(pts) - 1):
l, r = pts[s], pts[s + 1]
if r - l < 2:
continue
x1, y1 = l, seg[l]
x2, y2 = r, seg[r]
dx = x2 - x1
# distanza verticale dalla retta (interpolazione lineare in x)
for k in range(l + 1, r):
if dx == 0:
dist = abs(seg[k] - y1)
else:
yline = y1 + (y2 - y1) * (k - x1) / dx
dist = abs(seg[k] - yline)
if dist > best_d:
best_d, best_k = dist, k
if best_k < 0:
break
# inserisci mantenendo l'ordine
for s in range(len(pts) - 1):
if pts[s] < best_k < pts[s + 1]:
pts.insert(s + 1, best_k)
break
return pts
# =========================================================================
# Classe geometrica della polilinea PIP (feature causali interpretabili)
# =========================================================================
def shape_class(seg: np.ndarray, p: int) -> tuple | None:
"""Ritorna una tupla-classe discreta della forma PIP di `seg`, o None se degenere.
Feature (tutte da seg=close[..i], causali):
- dir_seq: per ogni pivot interno, segno della variazione vs precedente
(sequenza su/giu) -> cattura HH/HL vs LH/LL e zig-zag;
- conv: convergenza pendenze inizio vs fine (triangolo/cuneo): segno di
(|slope_last| - |slope_first|) discretizzato;
- loc: posizione di close[i] nel range della finestra (vicino a max=resistenza,
vicino a min=supporto), in 3 bucket.
La classe e' invariante a livello/scala (z-norm implicito su forma).
"""
idx = pip_indices(seg, p)
if len(idx) < 3:
return None
y = seg[idx]
rng = y.max() - y.min()
if rng <= 0:
return None
yn = (y - y.min()) / rng # forma normalizzata 0..1
# sequenza direzioni dei segmenti (su=1 / giu=0)
diffs = np.diff(yn)
dir_seq = tuple(int(x > 0) for x in diffs)
# convergenza: pendenza primo vs ultimo segmento
s_first = abs(diffs[0])
s_last = abs(diffs[-1])
if s_last > s_first * 1.3:
conv = 1 # divergente (slancio finale)
elif s_last < s_first * 0.77:
conv = -1 # convergente (compressione, triangolo/cuneo)
else:
conv = 0
# posizione di close[i] (=ultimo punto) nel range: 0..1 in 3 bucket
last = yn[-1]
loc = 0 if last < 0.33 else (2 if last > 0.67 else 1)
return (dir_seq, conv, loc)
# =========================================================================
# Strategia: bias per-classe stimato CAUSALMENTE (rolling, esito realizzato)
# =========================================================================
def pivot_entries(df, L=48, P=5, H=12, min_lib=1000, min_samples=20,
edge=0.0, tp_atr=None, sl_atr=None,
trend_max=None, ema_long=200, mode="bias") -> list[dict]:
"""Entries dalla geometria PIP con bias di classe causale.
L: lunghezza finestra-forma. P: n. punti PIP. H: orizzonte (=max_bars).
min_lib: barre minime prima di operare. min_samples: campioni minimi per fidarsi
della statistica di una classe. edge: |rendimento medio classe| minimo
(frazione, es. 0.002 = 0.2%) per entrare. mode:
- "bias": entra nel verso del rendimento medio passato della classe (momentum
della forma: la classe X storicamente -> su/giu);
- "fade": entra nel verso OPPOSTO (test mean-reversion della forma).
Statistica per-classe accumulata SOLO con esiti realizzati < i (causale stretta).
"""
close = df["close"].values
high = df["high"].values
low = df["low"].values
n = len(close)
a = atr(df, 14)
el = ema(close, ema_long) if trend_max is not None else None
# stato rolling per classe: somma rendimenti e conteggio (solo esiti < i)
cls_sum: dict[tuple, float] = {}
cls_cnt: dict[tuple, int] = {}
# coda di campioni la cui forma e' stata calcolata ma esito non ancora maturo
# pending[t] = (classe, indice_entry t) -> matura quando t+H <= i-1
pending: list[tuple] = [] # (mature_at, cls, t)
pend_ptr = 0
entries: list[dict] = []
for i in range(min_lib, n - 1):
# 1) integra nello storico tutti i campioni il cui esito e' realizzato (< i)
# un campione formato a t matura quando t+H <= i-1 => mature_at = t+H+1 <= i
while pend_ptr < len(pending) and pending[pend_ptr][0] <= i:
_, cls_p, t = pending[pend_ptr]
ret_real = (close[t + H] - close[t]) / close[t]
cls_sum[cls_p] = cls_sum.get(cls_p, 0.0) + ret_real
cls_cnt[cls_p] = cls_cnt.get(cls_p, 0) + 1
pend_ptr += 1
# 2) forma corrente (solo close fino a i)
seg = close[i - L + 1: i + 1]
cls = shape_class(seg, P)
if cls is None:
continue
# registra il campione corrente come pending (esito da realizzare in futuro)
pending.append((i + H + 1, cls, i))
# 3) decisione con statistica PASSATA della classe
cnt = cls_cnt.get(cls, 0)
if cnt < min_samples:
continue
mean_ret = cls_sum[cls] / cnt
if abs(mean_ret) < edge:
continue
d = 1 if mean_ret > 0 else -1
if mode == "fade":
d = -d
# filtro trend opzionale
if trend_max is not None and a[i] > 0:
if abs(close[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# =========================================================================
# Filone (c): distanza da supporto/resistenza locale (ultimo pivot alto/basso)
# =========================================================================
def sr_entries(df, L=48, P=7, H=12, near=0.5, mode="fade",
tp_atr=None, sl_atr=None, trend_max=None, ema_long=200) -> list[dict]:
"""Filone (c): close[i] vicino all'ultimo pivot alto (R) o basso (S) della forma.
Usa i PIP per individuare l'ultimo massimo/minimo locale (resistenza/supporto) e
misura la distanza % di close[i]. Se close e' entro `near`*ATR da R -> bias short
(mode='fade': rimbalzo da R) o long (mode='break': rottura). Simmetrico per S.
Tutto causale: PIP su close[..i], decisione a close[i].
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
el = ema(close, ema_long) if trend_max is not None else None
entries: list[dict] = []
for i in range(L, n - 1):
seg = close[i - L + 1: i + 1]
idx = pip_indices(seg, P)
if len(idx) < 3 or a[i] <= 0:
continue
y = seg[idx]
# pivot interni (escludi i 2 estremi e l'ultimo punto = close[i])
inner = y[1:-1]
if len(inner) == 0:
continue
res = inner.max() # resistenza locale
sup = inner.min() # supporto locale
cur = close[i]
dist_r = (res - cur) / a[i]
dist_s = (cur - sup) / a[i]
d = None
if 0 <= dist_r <= near: # appena sotto R
d = -1 if mode == "fade" else 1
elif 0 <= dist_s <= near: # appena sopra S
d = 1 if mode == "fade" else -1
if d is None:
continue
if trend_max is not None and abs(cur - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None:
e["tp"] = cur + d * tp_atr * a[i]
if sl_atr is not None:
e["sl"] = cur - d * sl_atr * a[i]
entries.append(e)
return entries
# =========================================================================
# Check causalita' esplicito
# =========================================================================
def check_no_lookahead(df, L=48, P=5) -> bool:
"""La classe-forma a i non deve cambiare se perturbo il FUTURO (>i)."""
close = df["close"].values.copy()
i = len(close) // 2
seg0 = close[i - L + 1: i + 1].copy()
c0 = shape_class(seg0, P)
close2 = close.copy()
close2[i + 1:] *= 1.7 # stravolge il futuro
seg1 = close2[i - L + 1: i + 1]
c1 = shape_class(seg1, P)
ok = (c0 == c1)
print(f" no-lookahead classe-forma a i={i}: {'OK' if ok else 'VIOLATO'} "
f"(c0={c0} c1={c1})")
# check su PIP indices
p0 = pip_indices(seg0, P)
p1 = pip_indices(seg1, P)
ok2 = (p0 == p1)
print(f" no-lookahead indici PIP: {'OK' if ok2 else 'VIOLATO'}")
return ok and ok2
# =========================================================================
# run() riproducibile
# =========================================================================
def run():
print("=" * 100)
print(" SHAPE-PIVOT RESEARCH — geometria PIP/pivot -> bias futuro | netto fee, OOS")
print("=" * 100)
df_btc = get_df("BTC", "1h")
print("\n[CAUSALITA']")
check_no_lookahead(df_btc, L=48, P=5)
assets = ["BTC", "ETH", "SOL", "ADA"]
dfs = {a: get_df(a, "1h") for a in assets}
# ---- A) bias di classe PIP (momentum della forma) ----
print("\n[A] BIAS di classe PIP (entra nel verso del rendimento medio passato della classe)")
print(" sweep L/P/H, edge=0.002, min_samples=25, time-exit a H")
A_grid = [(48, 5, 12), (48, 5, 24), (72, 6, 24), (36, 5, 12), (96, 7, 24), (48, 7, 12)]
for L, P, H in A_grid:
print(f" -- L{L} P{P} H{H} --")
for a in assets:
ents = pivot_entries(dfs[a], L=L, P=P, H=H, edge=0.002, min_samples=25, mode="bias")
evaluate(f"{a} bias L{L}P{P}H{H}", ents, dfs[a])
# ---- B) fade di classe PIP (mean-reversion della forma) ----
print("\n[B] FADE di classe PIP (entra opposto al bias storico -> test mean-reversion)")
for L, P, H in A_grid:
print(f" -- L{L} P{P} H{H} --")
for a in assets:
ents = pivot_entries(dfs[a], L=L, P=P, H=H, edge=0.002, min_samples=25, mode="fade")
evaluate(f"{a} fade L{L}P{P}H{H}", ents, dfs[a])
# ---- C) supporto/resistenza locale dai pivot ----
print("\n[C] S/R locale dai PIP — FADE (rimbalzo da R/S) vs BREAK (rottura)")
for mode in ("fade", "break"):
for near in (0.5, 1.0):
print(f" -- mode={mode} near={near} ATR, TP/SL 1.5/1.5 ATR, H=12 --")
for a in assets:
ents = sr_entries(dfs[a], L=48, P=7, H=12, near=near, mode=mode,
tp_atr=1.5, sl_atr=1.5)
evaluate(f"{a} SR-{mode} near{near}", ents, dfs[a])
# ---- D) miglior candidato con TP/SL ATR + filtro trend (se A o B mostra segnali) ----
print("\n[D] FADE di classe con TP/SL ATR (2.0/1.5) + filtro trend 3.0, L48 P5 H24")
for a in assets:
ents = pivot_entries(dfs[a], L=48, P=5, H=24, edge=0.002, min_samples=25,
mode="fade", tp_atr=2.0, sl_atr=1.5, trend_max=3.0)
res = evaluate(f"{a} fadeTPSL L48P5H24", ents, dfs[a])
if robust(res):
print(f" ^^^ {a} ROBUSTO")
print("\n" + "=" * 100)
print(" Verdetto: cerca righe con FULL>0 E OOS>0 E fee0.2% OOS>0 su >=2 asset.")
print("=" * 100)
if __name__ == "__main__":
run()
+443
View File
@@ -0,0 +1,443 @@
"""SHAPE_TEMPLATE_RESEARCH — edge nella FORMA del prezzo: distanze alternative e template canonici.
Due filoni, sull'harness ONESTO condiviso (shape_lab + explore_lab), netto-fee e OOS:
1. ANALOG con distanza di FORMA alternativa (DTW warping-invariant, correlazione/coseno)
confrontata HEAD-TO-HEAD con l'euclidea a PARITA' di selettivita' (stessa libreria,
stesso K, stessa soglia di accordo). DTW e' O(W^2): si usa una libreria SOTTOCAMPIONATA
(uno start ogni `step` barre) + W ridotto + banda di Sakoe-Chiba.
2. TEMPLATE di forma canonici (doppio top/bottom, testa-spalle, V-reversal, salita/discesa
lineare, U). A ogni i misuro la similarita' (correlazione di Pearson sulla finestra
z-normalizzata) fra forma recente e ogni template; se supera soglia, entro a close[i]
nella DIREZIONE ATTESA del template stimata SOLO sul passato (esito medio causale delle
occorrenze gia' concluse di quel template), exit H barre o tp/sl ATR.
VINCOLI anti-look-ahead (verificati esplicitamente):
- la forma/match a i usa SOLO close fino a i (z-norm causale);
- la direzione attesa di ogni template e la libreria analog usano SOLO occorrenze il cui
esito a H barre e' gia' realizzato PRIMA di i (end + H <= i-1);
- ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H.
Netto fee 0.10% RT baseline + sweep fino a 0.20%. Leva 3x, pos 0.15. OOS ultimo 30%.
Run riproducibile: uv run python scripts/analysis/shape_template_research.py
DTW e' costoso: usa run_in_background per gli sweep larghi (vedi --sweep).
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import get_df, evaluate, robust, simulate, atr, ema, OOS_FRAC # noqa: E402
from scripts.analysis.shape_lab import znorm_windows, fwd_return # noqa: E402
RNG_SEED = 7
SUBC_ASSETS = ["BTC", "ETH", "SOL"]
# =========================================================================================
# DISTANZE DI FORMA
# =========================================================================================
def _euclid(q: np.ndarray, lib: np.ndarray) -> np.ndarray:
"""Distanza euclidea fra q (W,) e ogni riga di lib (M,W). Forme gia' z-normalizzate."""
return np.sqrt(((lib - q) ** 2).sum(axis=1))
def _corr_dist(q: np.ndarray, lib: np.ndarray) -> np.ndarray:
"""Distanza = 1 - correlazione di Pearson (q,lib gia' z-norm: corr = q.lib / W)."""
# forme z-norm hanno media 0 std 1 -> dot/W e' la correlazione di Pearson
corr = (lib @ q) / q.shape[0]
return 1.0 - corr
def _cosine_dist(q: np.ndarray, lib: np.ndarray) -> np.ndarray:
"""Distanza = 1 - coseno fra q e ogni riga di lib."""
qn = q / (np.linalg.norm(q) + 1e-12)
ln = lib / (np.linalg.norm(lib, axis=1, keepdims=True) + 1e-12)
return 1.0 - (ln @ qn)
def _dtw_one(a: np.ndarray, b: np.ndarray, band: int) -> float:
"""DTW 1D con banda di Sakoe-Chiba (|i-j|<=band). a,b stessa lunghezza W."""
n = len(a)
INF = 1e18
prev = np.full(n + 1, INF)
prev[0] = 0.0
for i in range(1, n + 1):
cur = np.full(n + 1, INF)
jlo = max(1, i - band)
jhi = min(n, i + band)
ai = a[i - 1]
for j in range(jlo, jhi + 1):
cost = abs(ai - b[j - 1])
m = prev[j]
if prev[j - 1] < m:
m = prev[j - 1]
if cur[j - 1] < m:
m = cur[j - 1]
cur[j] = cost + m
prev = cur
return float(prev[n])
def _dtw_dist(q: np.ndarray, lib: np.ndarray, band: int) -> np.ndarray:
"""DTW di q contro ogni riga di lib. O(M * W * band)."""
out = np.empty(lib.shape[0])
for k in range(lib.shape[0]):
out[k] = _dtw_one(q, lib[k], band)
return out
DIST_FUNCS = {"euclid": _euclid, "corr": _corr_dist, "cosine": _cosine_dist}
# =========================================================================================
# FILONE 1 — ANALOG con distanza configurabile (libreria sottocampionata, causale)
# =========================================================================================
def analog_dist_entries(df, dist="euclid", W=24, H=12, K=40, step=5, rebuild=500,
min_lib=2000, agree=0.62, dtw_band=4, dtw_prefilter=200,
decide_step=1, tp_atr=None, sl_atr=None,
trend_max=None, ema_long=200) -> list[dict]:
"""Analog kNN sulla FORMA con metrica `dist` ('euclid'|'corr'|'cosine'|'dtw').
Libreria SOTTOCAMPIONATA: si considerano solo finestre che terminano a indici
multipli di `step` (riduce N e rende DTW trattabile). Causalita': la libreria a
decisione i contiene solo finestre con end<=i-1-H (esito gia' realizzato).
Ricostruita ogni `rebuild` barre. Stessa firma per tutte le metriche -> confronto
head-to-head a parita' di selettivita' (stesso W,H,K,agree).
DTW (costoso, O(W*band) per coppia in Python): si PREFILTRA con la correlazione ai
`dtw_prefilter` candidati piu' simili, poi si fa DTW-rerank solo su quelli (approccio
standard lower-bound/rerank). `decide_step`>1 valuta una barra ogni decide_step (non
cambia la causalita', riduce solo il numero di query DTW).
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
M, ends = znorm_windows(close, W)
end_pos = {int(e): k for k, e in enumerate(ends)}
fr = fwd_return(close, H)
el = ema(close, ema_long) if trend_max is not None else None
# candidati di libreria: solo end multipli di step (sottocampionamento causale fisso)
base_ends = ends[(ends % step == 0)]
entries: list[dict] = []
lib_M = None
lib_idx = None
next_rebuild = 0
for i in range(min_lib, n - 1):
if i % decide_step != 0:
continue
if lib_M is None or i >= next_rebuild:
elig = base_ends[(base_ends <= i - 1 - H) & (base_ends >= W - 1)]
elig = elig[~np.isnan(fr[elig])]
if len(elig) < max(K * 3, 200):
next_rebuild = i + rebuild
continue
lib_M = M[[end_pos[int(e)] for e in elig]]
lib_idx = elig
next_rebuild = i + rebuild
if lib_M is None:
continue
q = M[end_pos[i]]
if not np.isfinite(q).all():
continue
if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max:
continue
if dist == "dtw":
# prefiltro corr (cheap, vettoriale) -> DTW-rerank solo sui top dtw_prefilter
pre = _corr_dist(q, lib_M)
npre = min(dtw_prefilter, len(lib_idx))
cand = np.argpartition(pre, npre - 1)[:npre]
dd_cand = _dtw_dist(q, lib_M[cand], dtw_band)
kk = min(K, len(cand))
sub = np.argpartition(dd_cand, kk - 1)[:kk]
nn = cand[sub]
outs = fr[lib_idx[nn]]
outs = outs[~np.isnan(outs)]
if len(outs) < 5:
continue
mean_out = float(outs.mean())
d = 1 if mean_out > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
if frac < agree:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
continue
dd = DIST_FUNCS[dist](q, lib_M)
kk = min(K, len(lib_idx))
nn = np.argpartition(dd, kk - 1)[:kk]
outs = fr[lib_idx[nn]]
outs = outs[~np.isnan(outs)]
if len(outs) < 5:
continue
mean_out = float(outs.mean())
d = 1 if mean_out > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
if frac < agree:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# =========================================================================================
# FILONE 2 — TEMPLATE di forma canonici
# =========================================================================================
def make_templates(W: int) -> dict[str, np.ndarray]:
"""Template parametrici z-normalizzati di lunghezza W (forma pura, no scala/livello).
Sono solo descrittori di FORMA recente (gli ultimi W close). La direzione attesa NON
e' decisa a priori: viene stimata causalmente sul passato (vedi template_entries).
"""
t = np.linspace(0, 1, W)
s = 0.012 # ampiezza gaussiana scalata sulla finestra (W-indipendente in t in [0,1])
g = lambda c: np.exp(-((t - c) ** 2) / s)
raw = {
# estremi di reversione a DOPPIO picco (due massimi / minimi simmetrici)
"double_top": g(0.25) + g(0.75), # M: due cime
"double_bottom": -(g(0.25) + g(0.75)), # W: due fondi
# testa-spalle: spalla-testa-spalla (centro piu' alto)
"head_shoulders": g(0.2) + 1.7 * g(0.5) + g(0.8),
"inv_head_shoulders": -(g(0.2) + 1.7 * g(0.5) + g(0.8)),
# singola reversione
"v_bottom": np.abs(t - 0.5),
"inv_v_top": -np.abs(t - 0.5),
"u_bottom": (t - 0.5) ** 2,
"arch_top": -((t - 0.5) ** 2),
# trend lineari
"ramp_up": t,
"ramp_down": -t,
}
out = {}
for k, v in raw.items():
v = np.asarray(v, dtype=float)
sd = v.std()
out[k] = (v - v.mean()) / (sd if sd > 0 else 1.0)
return out
def template_entries(df, W=24, H=12, corr_min=0.85, dir_min=0.10, min_lib=2000,
rebuild=300, tp_atr=None, sl_atr=None, trend_max=None, ema_long=200,
templates=None) -> list[dict]:
"""Entries da match con template canonici, DIREZIONE stimata SOLO sul passato.
A ogni i, per ogni template calcolo la correlazione di Pearson fra la forma recente
z-norm (close[i-W+1..i]) e il template. Prendo il template a correlazione massima; se
>= corr_min lo considero "attivo". La DIREZIONE in cui entrare e' il segno del rendimento
forward MEDIO storico delle occorrenze gia' concluse (end+H<=i-1) di quel template
(stesso criterio di match), purche' |media| in barre-equivalenti superi dir_min*media_atr-ish
-> qui dir_min e' una soglia sulla |media forward| relativa (frazione). NIENTE direzione a
priori: se il passato non e' coerente (occorrenze<min o segno debole) si salta.
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
M, ends = znorm_windows(close, W)
end_pos = {int(e): k for k, e in enumerate(ends)}
fr = fwd_return(close, H)
el = ema(close, ema_long) if trend_max is not None else None
tps = templates if templates is not None else make_templates(W)
names = list(tps.keys())
T = np.stack([tps[k] for k in names]) # (NT, W), gia' z-norm
# match-history: per ogni end di libreria, quale template e con che corr
# (precalcolo causale: per ogni end, corr con ogni template)
# corr Pearson fra forme z-norm = dot/W
lib_ends = ends[ends >= W - 1]
lib_M = M[[end_pos[int(e)] for e in lib_ends]] # (L, W)
corr_mat = (lib_M @ T.T) / W # (L, NT)
best_tpl = np.argmax(corr_mat, axis=1)
best_corr = corr_mat[np.arange(len(lib_ends)), best_tpl]
lib_fr = fr[lib_ends]
lib_end_arr = lib_ends
entries: list[dict] = []
# cache direzione per template, ricostruita ogni rebuild barre
dir_cache: dict[int, int] = {}
next_rebuild = 0
for i in range(min_lib, n - 1):
q = M[end_pos[i]]
if not np.isfinite(q).all():
continue
cq = (T @ q) / W # corr con ogni template
bt = int(np.argmax(cq))
if cq[bt] < corr_min:
continue
if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max:
continue
# direzione attesa: media forward causale delle occorrenze concluse dello stesso template
if i >= next_rebuild:
dir_cache = {}
next_rebuild = i + rebuild
if bt not in dir_cache:
mask = (lib_end_arr <= i - 1 - H) & (best_tpl == bt) & (best_corr >= corr_min) & (~np.isnan(lib_fr))
outs = lib_fr[mask]
if len(outs) < 30:
dir_cache[bt] = 0
else:
m = float(outs.mean())
# soglia: |media| forward deve superare dir_min volte la std forward (edge vs rumore)
sd = float(outs.std()) + 1e-12
dir_cache[bt] = (1 if m > 0 else -1) if abs(m) / sd >= dir_min else 0
d = dir_cache[bt]
if d == 0:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# =========================================================================================
# CHECK CAUSALITA' espliciti
# =========================================================================================
def check_causality_analog(df, **kw) -> bool:
"""Le entries non devono cambiare se perturbo il FUTURO oltre l'ultima barra usata.
Tronco il df a una certa lunghezza L e verifico che le entries con i<L-H-1 siano
identiche a quelle calcolate sul df completo (la coda futura non le tocca)."""
L = int(len(df) * 0.55)
H = kw.get("H", 12)
full = analog_dist_entries(df, **kw)
trunc = analog_dist_entries(df.iloc[:L].reset_index(drop=True), **kw)
horizon = L - H - 2
f = {e["i"]: e["d"] for e in full if e["i"] < horizon}
t = {e["i"]: e["d"] for e in trunc if e["i"] < horizon}
ok = (f == t)
print(f" causalita' analog ({kw.get('dist','euclid')}): {'OK' if ok else 'VIOLATO'} "
f"({len(f)} entries confrontate <{horizon})")
return ok
def check_causality_template(df, **kw) -> bool:
L = int(len(df) * 0.55)
H = kw.get("H", 12)
full = template_entries(df, **kw)
trunc = template_entries(df.iloc[:L].reset_index(drop=True), **kw)
horizon = L - H - 2
f = {e["i"]: e["d"] for e in full if e["i"] < horizon}
t = {e["i"]: e["d"] for e in trunc if e["i"] < horizon}
ok = (f == t)
print(f" causalita' template: {'OK' if ok else 'VIOLATO'} "
f"({len(f)} entries confrontate <{horizon})")
return ok
# =========================================================================================
# RUN
# =========================================================================================
def run_head_to_head(assets=SUBC_ASSETS, W=16, H=12, K=40, step=6, agree=0.62,
decide_step=4, dtw_prefilter=120):
"""Confronto HEAD-TO-HEAD delle metriche di forma a PARITA' di selettivita'.
Tutte le metriche valutano le STESSE barre-decisione (decide_step) con lo STESSO
W/H/K/agree: l'unica variabile e' la distanza. decide_step>1 serve a rendere DTW
trattabile (pura Python ~9ms/query); applicato a tutte per equita'.
"""
print("=" * 100)
print(f" FILONE 1 — ANALOG head-to-head metriche (W{W} H{H} K{K} step{step} "
f"agree{agree} decide_step{decide_step}) | netto fee, OOS")
print("=" * 100)
results = {}
for asset in assets:
df = get_df(asset, "1h")
print(f"\n --- {asset} 1h (n={len(df)}) ---", flush=True)
for dist in ["euclid", "corr", "cosine", "dtw"]:
t0 = time.time()
ents = analog_dist_entries(df, dist=dist, W=W, H=H, K=K, step=step, agree=agree,
dtw_band=max(2, W // 5), dtw_prefilter=dtw_prefilter,
decide_step=decide_step)
dt = time.time() - t0
res = evaluate(f"{dist:<7s}", ents, df)
results[(asset, dist)] = res
print(f" ^ time={dt:>5.1f}s robust={'YES' if robust(res) else 'no '}", flush=True)
return results
def run_templates(assets=SUBC_ASSETS, W=20, H=12, corr_min=0.85, dir_min=0.10):
print("=" * 100)
print(f" FILONE 2 — TEMPLATE canonici (W{W} H{H} corr>={corr_min} dir>={dir_min}) | netto fee, OOS")
print("=" * 100)
results = {}
for asset in assets:
df = get_df(asset, "1h")
print(f"\n --- {asset} 1h (n={len(df)}) ---")
for cm in [0.80, 0.85, 0.90]:
ents = template_entries(df, W=W, H=H, corr_min=cm, dir_min=dir_min)
res = evaluate(f"corr_min={cm}", ents, df)
results[(asset, cm)] = res
print(f" ^ robust={'YES' if robust(res) else 'no '}")
return results
def run_sweep():
"""Sweep largo (lento per via di DTW). Usa run_in_background."""
print("=" * 100)
print(" SWEEP LARGO — analog griglia W/H/K/step x metriche + template griglia")
print("=" * 100)
for W in [16, 20, 28]:
for H in [8, 12, 24]:
print(f"\n##### W={W} H={H} #####")
run_head_to_head(W=W, H=H, K=40, step=6, agree=0.62)
for W in [16, 20, 28]:
for H in [8, 12, 24]:
print(f"\n##### TEMPLATE W={W} H={H} #####")
run_templates(W=W, H=H, corr_min=0.85, dir_min=0.10)
def run():
np.random.seed(RNG_SEED)
print("#" * 100)
print(" SHAPE_TEMPLATE_RESEARCH — distanze di forma alternative + template canonici")
print("#" * 100)
# 1) check causalita' espliciti
print("\n[CAUSALITA']")
dfb = get_df("BTC", "1h")
check_causality_analog(dfb, dist="euclid", W=20, H=12, K=40, step=6, min_lib=2000)
check_causality_analog(dfb, dist="dtw", W=16, H=12, K=40, step=8, min_lib=2000,
dtw_band=3, decide_step=20)
check_causality_template(dfb, W=20, H=12, corr_min=0.85)
# 2) head-to-head metriche
print()
run_head_to_head()
# 3) template
print()
run_templates()
if __name__ == "__main__":
if "--sweep" in sys.argv:
run_sweep()
elif "--templates" in sys.argv:
run_templates()
elif "--h2h" in sys.argv:
run_head_to_head()
else:
run()
+173
View File
@@ -0,0 +1,173 @@
"""Analisi di ACCORPAMENTO degli sleeve: le strategie possono essere raggruppate
meglio o diversamente rispetto all'attuale "per famiglia"?
Costruisce le 17 sleeve daily (FADE 6 + HONEST 3 + PAIRS 5 + TSM01 + SHAPE 2),
e risponde con evidenza a:
1. CORRELAZIONE: matrice completa -> quali sleeve sono ridondanti (corr alta)?
2. CLUSTER: clustering gerarchico sulla distanza 1-corr -> i gruppi NATURALI
coincidono con le famiglie o no?
3. RISCHIO: contributo di ogni sleeve alla volatilita' del portafoglio equal-weight
-> chi domina il rischio (e va cappato)?
4. PESI: confronto equal-weight vs inverse-vol vs risk-parity (per cluster) su
ritorno/DD/Sharpe FULL e OOS.
Tutto netto fee, leva 3x, finestra comune 2021-2026, OOS = ultimo 30%.
Run: uv run python scripts/analysis/sleeve_clustering.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 scipy.cluster.hierarchy import linkage, fcluster
from scipy.spatial.distance import squareform
from scripts.analysis.report_families import build_everything
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT
def daily_matrix(sleeves: dict) -> pd.DataFrame:
return pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in sleeves.items()})
def risk_contributions(dr: pd.DataFrame, w: np.ndarray) -> np.ndarray:
"""Contributo % di ogni sleeve alla varianza del portafoglio (w'Σ)."""
cov = dr.cov().values
port_var = float(w @ cov @ w)
mrc = cov @ w # marginal risk contribution
rc = w * mrc # risk contribution (somma = port_var)
return rc / port_var * 100 if port_var > 0 else rc
def inv_vol(dr: pd.DataFrame) -> np.ndarray:
v = dr.std().values
inv = np.where(v > 0, 1.0 / v, 0.0)
return inv / inv.sum()
def cluster_risk_parity(dr: pd.DataFrame, labels: np.ndarray) -> dict:
"""Peso: equal fra i CLUSTER, poi inverse-vol DENTRO ogni cluster.
Diversifica per gruppo-naturale invece che per sleeve -> non sovrappesa cluster affollati."""
cols = list(dr.columns)
w = np.zeros(len(cols))
clusters = sorted(set(labels))
per_cluster = 1.0 / len(clusters)
for cl in clusters:
idx = [i for i, lb in enumerate(labels) if lb == cl]
sub = dr.iloc[:, idx]
iv = inv_vol(sub)
for j, i in enumerate(idx):
w[i] = per_cluster * iv[j]
return {cols[i]: w[i] for i in range(len(cols))}
def main():
print("Costruzione 17 sleeve (~2-3 min)...\n")
S, pairs, tsm, shape = build_everything()
all_sl = {**S, **pairs, **tsm, **shape}
dr = daily_matrix(all_sl)
cols = list(dr.columns)
n = len(cols)
fam_of = {}
for k in cols:
if k.startswith("MR"):
fam_of[k] = "FADE"
elif k.startswith("PR_"):
fam_of[k] = "PAIRS"
elif k.startswith("SH_"):
fam_of[k] = "SHAPE"
elif k == "TSM01":
fam_of[k] = "TSM"
else:
fam_of[k] = "HONEST"
# ---------- 1. correlazione ----------
print("=" * 100)
print(" (1) MATRICE DI CORRELAZIONE daily fra sleeve")
print("=" * 100)
corr = dr.corr()
short = [c.replace("_", "")[:8] for c in cols]
print(" " + "".join(f"{s[:6]:>7s}" for s in short))
for i, c in enumerate(cols):
print(f" {short[i]:<6s}" + "".join(f"{corr.iloc[i, j]:>7.2f}" for j in range(n)))
# coppie piu' correlate (candidati all'accorpamento)
print("\n Coppie piu' correlate (>0.5 -> ridondanza potenziale):")
pairs_corr = []
for i in range(n):
for j in range(i + 1, n):
pairs_corr.append((corr.iloc[i, j], cols[i], cols[j]))
pairs_corr.sort(reverse=True)
for cc, a, b in pairs_corr[:12]:
flag = " <-- stessa famiglia" if fam_of[a] == fam_of[b] else " <-- CROSS-famiglia"
print(f" {a:<11s} {b:<11s} {cc:+.2f}{flag if cc > 0.5 else ''}")
# ---------- 2. cluster ----------
print("\n" + "=" * 100)
print(" (2) CLUSTERING GERARCHICO (distanza = 1-corr) — i gruppi naturali")
print("=" * 100)
dist = 1.0 - corr.values
np.fill_diagonal(dist, 0.0)
dist = (dist + dist.T) / 2
Z = linkage(squareform(dist, checks=False), method="average")
for thr in (0.85, 0.95):
labels = fcluster(Z, t=thr, criterion="distance")
groups: dict[int, list] = {}
for c, lb in zip(cols, labels):
groups.setdefault(lb, []).append(c)
print(f"\n taglio a distanza {thr} (corr>{1-thr:.2f}) -> {len(groups)} cluster:")
for lb, members in sorted(groups.items()):
fams = {fam_of[m] for m in members}
print(f" C{lb}: {', '.join(members)} [{'/'.join(sorted(fams))}]")
# ---------- 3. rischio ----------
print("\n" + "=" * 100)
print(" (3) CONTRIBUTO AL RISCHIO (equal-weight) — chi domina la volatilita'")
print("=" * 100)
w_eq = np.ones(n) / n
rc = risk_contributions(dr, w_eq)
order = np.argsort(rc)[::-1]
print(f" {'sleeve':<12s}{'peso%':>7s}{'risk%':>7s} famiglia")
for i in order:
print(f" {cols[i]:<12s}{w_eq[i]*100:>7.1f}{rc[i]:>7.1f} {fam_of[cols[i]]}")
# rischio per famiglia
print("\n contributo al rischio per FAMIGLIA (equal-weight sleeve):")
fam_rc: dict[str, float] = {}
for i, c in enumerate(cols):
fam_rc[fam_of[c]] = fam_rc.get(fam_of[c], 0.0) + rc[i]
for f, v in sorted(fam_rc.items(), key=lambda x: -x[1]):
print(f" {f:<8s} {v:>5.1f}%")
# ---------- 4. schemi di peso ----------
print("\n" + "=" * 100)
print(" (4) SCHEMI DI PESO a confronto | FULL ret/DD/Sharpe | OOS ret/DD/Sharpe")
print("=" * 100)
labels95 = fcluster(Z, t=0.95, criterion="distance")
schemes = {
"equal-weight": {c: 1.0 / n for c in cols},
"inverse-vol": {cols[i]: inv_vol(dr)[i] for i in range(n)},
"cluster-risk-parity": cluster_risk_parity(dr, labels95),
}
print(f" {'schema':<22s}{'Ret%':>9s}{'DD%':>7s}{'Shrp':>7s} | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
print(" " + "-" * 78)
for nm, w in schemes.items():
dserved = port_returns(all_sl, w)
f, o = metrics(dserved), metrics(dserved, lo=SPLIT)
print(f" {nm:<22s}{f['ret']:>+9.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f} | "
f"{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
print("\n Lettura: se i cluster naturali != famiglie, conviene pesare per CLUSTER (rischio)")
print(" invece che per famiglia. Se inverse-vol/risk-parity battono equal-weight in OOS,")
print(" l'accorpamento attuale (equal-weight per sleeve) e' migliorabile.")
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
"""Smoke reale: un giro di fetch v2 + build worker + un tick del portafoglio attivo.
NON apre ordini reali (paper). Verifica data layer v2 + sizing + ledger."""
import sys, shutil, tempfile
from pathlib import Path
from datetime import datetime, timezone, timedelta
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.portfolio.base import load_active_portfolio
from src.portfolio.ledger import PortfolioLedger
from src.portfolio.runner import build_worker_for, _worker_equity
from src.live.cerbero_client import CerberoClient
from src.live.multi_runner import INSTRUMENT_MAP
def main():
tmp = Path(tempfile.mkdtemp())
p = load_active_portfolio(PROJECT_ROOT / "portfolios.yml")
ledger = PortfolioLedger(p.code, total_capital=p.total_capital, data_dir=tmp)
alloc = ledger.allocate({s.sid: 1.0 / len(p.sleeves) for s in p.sleeves})
client = CerberoClient()
print(f"Portafoglio attivo: {p.code} ({p.label}) — {len(p.sleeves)} sleeve, leva {p.leverage}x")
end = datetime.now(timezone.utc); start = end - timedelta(days=60)
ok = 0
for s in p.sleeves[:3]:
asset = s.asset or s.a
inst = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), s.tf)
print(f" {s.sid:<12s} {inst:<18s} candele={len(candles)}")
ok += len(candles) > 0
print(f"OK: {ok}/3 sleeve con feed v2 fresco. Ledger equity iniziale={ledger.equity}")
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,74 @@
"""Demo numerica: il worker fade col NUOVO exit intrabar riproduce il backtest intrabar?
Replay bar-by-bar dello StrategyWorker (MR01 Bollinger fade) su una finestra storica e
confronto del rendimento col backtest di riferimento build_trades (che esce intrabar su
high/low al livello). Filtro trend disattivato in entrambi per isolare l'effetto-exit.
Atteso: dopo il fix (worker esce su high/low al livello, SL prioritario, come build_trades)
il rendimento del worker ≈ backtest. Prima del fix (exit solo sul close) divergeva.
Run: uv run python scripts/analysis/validate_fade_intrabar.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import tempfile, shutil
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
from scripts.analysis.risk_management import bollinger_fade, build_trades
CORE = dict(n=50, k=2.5, sl_atr=2.0, max_bars=24) # MR01, niente filtro trend
POS = 0.15
def backtest_return(df) -> tuple[float, int]:
ents = bollinger_fade(df, **CORE)
trades = build_trades(ents, df, trend_max=None) # intrabar, no trend filter
cap = 1000.0
for _, _, ret in trades:
cap = max(cap + cap * POS * ret, 10.0)
return (cap / 1000 - 1) * 100, len(trades)
def worker_replay_return(df) -> tuple[float, int]:
tmp = Path(tempfile.mkdtemp())
try:
w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h",
capital=1000.0, params=dict(CORE), data_dir=tmp)
# niente I/O per tick (replay veloce)
w._save_state = lambda *a, **k: None
w._log = lambda *a, **k: None
w._notify = lambda *a, **k: None
n = len(df)
for i in range(101, n):
w.tick(df.iloc[: i + 1])
return (w.capital / 1000 - 1) * 100, w.total_trades
finally:
shutil.rmtree(tmp, ignore_errors=True)
def main():
df = load_data("BTC", "1h").iloc[-4000:].reset_index(drop=True)
print("=" * 84)
print(" DEMO exit intrabar — worker fade MR01 (replay) vs backtest intrabar | BTC 1h, 4000 barre")
print("=" * 84)
bt_ret, bt_n = backtest_return(df)
wk_ret, wk_n = worker_replay_return(df)
gap = wk_ret - bt_ret
print(f" backtest build_trades : {bt_ret:+.1f}% ({bt_n} trade)")
print(f" worker replay (intrabar): {wk_ret:+.1f}% ({wk_n} trade)")
print(f" gap = {gap:+.1f} punti % -> {'OK (allineato)' if abs(gap) < max(abs(bt_ret) * 0.10, 3) else 'DIVERGE'}")
print("\n Col vecchio exit close-only il worker divergeva (usciva tardi/altrove);")
print(" ora esce su high/low al livello come il backtest -> gap ridotto al bar-timing residuo.")
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More