feat(stability): sweep stabilità — fix TR01 mean(rets), XS01 phase-tranching K=3, z-stop pairs bocciato
Audit anti-overfit su tutte le 19 sleeve (diario 2026-06-11-stability-sweep.md): - FIX BasketTrendWorker: mean(rets) sui soli asset in posizione sovrappesava N/k a paniere parziale (1 long = 0.45 del capitale invece di 0.09) -> replay -44% vs ref +42%. Ora sum(rets)/N (convenzione canonica 1/N): replay +32% vs +42% (residuo = convenzione dichiarata). Solo statistica PAPER. - XS01 PHASE-TRANCHING (gate xs01_tranche_gate: plateau K=2 E K=3 promossi, PORT06 OOS Sh 10.07->10.15 DD 1.48->1.38, FULL pari): la fase del roll e' timing-luck (Sharpe daily 1.52-2.33, DD 13.8-33% sulle 12 fasi). Worker con param tranches (default 1), 3 sub-book sfasati hold/3 su capitale comune, migrazione status legacy, last_bar_ts solo-avanti; runner forward del param; _defs tranches=3; hourly_report aggrega i sub-book; validatore esteso e PASSATO (K=1 == xsec_sim esatto, K=3 == unione fasi esatto). - Disaster-cap z sui pairs: pre-registrato e BOCCIATO su tutti i criteri (coda OOS peggiora 4/6 coppie, Sharpe -10..-49%, plateau solo del danno; 5a conferma stop-su-MR). Record pairs_zstop_research.py; pairs restano senza stop. - Audit drift: regression-lock trendmax OK (parita' 1.00000, plateau 2.5/3.0/3.5 confermato), correlazioni cross-famiglia ~0 invariate; PORT06 rolling al 19-28mo pct (normale) ma FADE 120g al 2o percentile storico -> monitor in TODO (nessun ritocco parametri). - TODO: forming-bar ROT02/TSM01 era gia' fixato (v1.1.10), item chiuso. Test: pytest 99 passed; validate_honest_workers OK; validate_xsec_worker OK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -403,14 +403,20 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale
|
|||||||
standalone Sharpe 2.50→3.46 (regge fee 2x), PORT06 OOS 10.07→10.37 a DD pari. Come
|
standalone Sharpe 2.50→3.46 (regge fee 2x), PORT06 OOS 10.07→10.37 a DD pari. Come
|
||||||
trend/hurst sulle fade, il gate agisce SOLO sul path live (backtest canonico non filtrato
|
trend/hurst sulle fade, il gate agisce SOLO sul path live (backtest canonico non filtrato
|
||||||
→ il live farà meglio del backtest). 8 gambe → PAPER (niente esecuzione reale a €2k).
|
→ il live farà meglio del backtest). 8 gambe → PAPER (niente esecuzione reale a €2k).
|
||||||
Worker `XsecWorker` validato (`validate_xsec_worker`: replay == backtest esatto). Strategia
|
Worker `CrossSectionalWorker` validato (`validate_xsec_worker`: replay == backtest esatto).
|
||||||
|
**PHASE-TRANCHING (2026-06-11, `tranches=3`)**: la fase del roll non-sovrapposto è arbitraria
|
||||||
|
e da sola muove Sharpe daily FULL 1.52-2.33 / DD 13.8-33% (timing-luck) → live gira con 3
|
||||||
|
sub-book sfasati di hold/3 su capitale comune (PnL/K), ensemble di fase SENZA parametri
|
||||||
|
fittati. Gate `xs01_tranche_gate.py`: plateau K=2 E K=3 promossi (PORT06 OOS Sh 10.07→10.15,
|
||||||
|
DD 1.48→1.38, FULL pari); validatore esteso (K=1 == xsec_sim esatto; K=3 == unione fasi
|
||||||
|
0/4/8 esatto). Solo path live (backtest canonico single-phase). Strategia
|
||||||
`scripts/strategies/XS01_cross_sectional.py`, gate `scripts/analysis/xsec_port06_gate.py` /
|
`scripts/strategies/XS01_cross_sectional.py`, gate `scripts/analysis/xsec_port06_gate.py` /
|
||||||
`xs01_dispersion_gate.py`. NB scartata nella stessa tornata: FC01 funding-carry (nessun
|
`xs01_dispersion_gate.py`. NB scartata nella stessa tornata: FC01 funding-carry (nessun
|
||||||
edge su 6.5 anni). Diari `docs/diary/2026-06-09/10-*`.
|
edge su 6.5 anni). Diari `docs/diary/2026-06-09/10-*`.
|
||||||
- **BLEND ETH/BTC 15m flat-skip (ATTIVO LIVE v1.1.16, 2026-06-09).** Sleeve `PR_ETHBTC_15M` accanto al 1h: pairs ETH/BTC a **15m** (n=66, z_in=1.674, z_exit=1.0, max_bars=35) a **mezza size** (`params.position_size=0.10`). **Origine: gioco "Blind Traders"** (100 agenti ciechi su BTC/ETH anonimizzati → riscoprono la mean-reversion, vincitore = spread ETH/BTC 15m; `scripts/games/`). Gate PORT06 sul serio: **non duplicato (corr 1h↔15m = 0.37)**, robusto (16/16 celle Sharpe>1), e l'edge **NON è artefatto delle candele flat ETH 15m** (16% storico; filtrandole resta l'83% dello Sharpe). Engine `pairs_research.pairs_sim_flat` con uscita **LIVE-REALIZABLE** (`flat_skip`: niente entry/exit su barre O=H=L=C, esce alla 1a barra pulita), **regression-lock** a `pairs_sim` con flat_skip=False. **Worker validato** (`validate_worker_pairs` a 15m: replay == backtest, 8452 vs 8453 trade; 1h byte-exact). Runner: fetch sub-orario diretto da Cerbero + `pos_for_spec` override **per-sleeve** (params.position_size > famiglia > globale). Mezza size perché a peso pieno il 15m pesava il 25.8% del rischio PORT06 (→ 11.5%, bilanciato col 1h) — blend-tilt prudente sul **caveat slippage** (a slippage realistico il vantaggio di Sharpe regge, quello di DD si assottiglia: il vero banco di prova è il ledger reale shadow). Diari `docs/diary/2026-06-09-pairs15m-*.md`, gate `scripts/analysis/pairs15m_port06_gate.py` / `pairs15m_gate_final.py` / `pairs15m_flatcheck.py`, smoke `pairs15m_live_smoke.py`.
|
- **BLEND ETH/BTC 15m flat-skip (ATTIVO LIVE v1.1.16, 2026-06-09).** Sleeve `PR_ETHBTC_15M` accanto al 1h: pairs ETH/BTC a **15m** (n=66, z_in=1.674, z_exit=1.0, max_bars=35) a **mezza size** (`params.position_size=0.10`). **Origine: gioco "Blind Traders"** (100 agenti ciechi su BTC/ETH anonimizzati → riscoprono la mean-reversion, vincitore = spread ETH/BTC 15m; `scripts/games/`). Gate PORT06 sul serio: **non duplicato (corr 1h↔15m = 0.37)**, robusto (16/16 celle Sharpe>1), e l'edge **NON è artefatto delle candele flat ETH 15m** (16% storico; filtrandole resta l'83% dello Sharpe). Engine `pairs_research.pairs_sim_flat` con uscita **LIVE-REALIZABLE** (`flat_skip`: niente entry/exit su barre O=H=L=C, esce alla 1a barra pulita), **regression-lock** a `pairs_sim` con flat_skip=False. **Worker validato** (`validate_worker_pairs` a 15m: replay == backtest, 8452 vs 8453 trade; 1h byte-exact). Runner: fetch sub-orario diretto da Cerbero + `pos_for_spec` override **per-sleeve** (params.position_size > famiglia > globale). Mezza size perché a peso pieno il 15m pesava il 25.8% del rischio PORT06 (→ 11.5%, bilanciato col 1h) — blend-tilt prudente sul **caveat slippage** (a slippage realistico il vantaggio di Sharpe regge, quello di DD si assottiglia: il vero banco di prova è il ledger reale shadow). Diari `docs/diary/2026-06-09-pairs15m-*.md`, gate `scripts/analysis/pairs15m_port06_gate.py` / `pairs15m_gate_final.py` / `pairs15m_flatcheck.py`, smoke `pairs15m_live_smoke.py`.
|
||||||
- **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`.
|
- **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.
|
- **Data layer Cerbero v2:** `get_historical_v2` unificato + `get_instruments` (naming robusto) + `get_ticker_batch`. Trading su Deribit.
|
||||||
- **SCOPE LIVE (fase 2 completata):** il runner esegue TUTTI gli sleeve di PORT06. Worker: single `StrategyWorker` (fade MR01/02/07, DIP01, **e SH01**), `PairsWorker` (PR01 2 gambe), e i multi-asset dedicati `BasketTrendWorker` (TR01 4h), `RotationWorker` (ROT02 1d), `TsmomWorker` (TSM01 1d). Il runner fetcha 1h da Cerbero v2 e **resampla a 4h/1d** (lookback dimensionato sui daily: TSM01 usa 252g). Validazione: runner pool/ribilancio/ledger == backtest (`validate_portfolio_runner.py`, identico); worker multi-asset == reference (`validate_honest_workers.py`: TSM01 esatto, ROT02 +1303% canonico, TR01 stesso ordine — differenza di convenzione capitale-unico vs media-equity).
|
- **SCOPE LIVE (fase 2 completata):** il runner esegue TUTTI gli sleeve di PORT06. Worker: single `StrategyWorker` (fade MR01/02/07, DIP01, **e SH01**), `PairsWorker` (PR01 2 gambe), e i multi-asset dedicati `BasketTrendWorker` (TR01 4h), `RotationWorker` (ROT02 1d), `TsmomWorker` (TSM01 1d). Il runner fetcha 1h da Cerbero v2 e **resampla a 4h/1d** (lookback dimensionato sui daily: TSM01 usa 252g). Validazione: runner pool/ribilancio/ledger == backtest (`validate_portfolio_runner.py`, identico); worker multi-asset == reference (`validate_honest_workers.py`: TSM01 esatto, ROT02 +1303% canonico, TR01 +32% vs +42% — residuo di convenzione capitale-unico vs media-equity; il bug `mean(rets)` sui soli asset in posizione, che dava −44%, è FIXATO il 2026-06-11: sovrappesava N/k a paniere parziale).
|
||||||
- **SH01 bootstrap full-history (punto-10, 2026-06-07).** La ri-validazione col train-window del
|
- **SH01 bootstrap full-history (punto-10, 2026-06-07).** La ri-validazione col train-window del
|
||||||
regime live (`sh01_trainwindow_validate.py`) ha mostrato che SH01 a train 365g **NON è robusto**
|
regime live (`sh01_trainwindow_validate.py`) ha mostrato che SH01 a train 365g **NON è robusto**
|
||||||
(BTC fee-2x FULL −42%, ETH Sharpe −0.02, trade-rate 22-26% vs 10% validato: LogReg over-confident,
|
(BTC fee-2x FULL −42%, ETH Sharpe −0.02, trade-rate 22-26% vs 10% validato: LogReg over-confident,
|
||||||
@@ -451,7 +457,12 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale
|
|||||||
0.40 ≈ il validato 0.45): la famiglia è senza stop e col globale 0.5×lev2 girava a ~2.2x il
|
0.40 ≈ il validato 0.45): la famiglia è senza stop e col globale 0.5×lev2 girava a ~2.2x il
|
||||||
validato (ETH/BTC DD grezzo 78% a quella taglia). PORT06 OOS DD 3.40→1.26% al costo di OOS
|
validato (ETH/BTC DD grezzo 78% a quella taglia). PORT06 OOS DD 3.40→1.26% al costo di OOS
|
||||||
Sharpe 9.05→8.43 — assicurazione come il cap SHAPE. Gate `pairspos_port06_impact.py`,
|
Sharpe 9.05→8.43 — assicurazione come il cap SHAPE. Gate `pairspos_port06_impact.py`,
|
||||||
diario `docs/diary/2026-06-07-pairspos-gate.md`.
|
diario `docs/diary/2026-06-07-pairspos-gate.md`. NB (2026-06-11): il **disaster-cap sullo
|
||||||
|
z** (exit se |z|≥z_stop, griglia pre-registrata 3.0-5.0 su tutte le coppie) è **BOCCIATO**
|
||||||
|
— coda e Sharpe OOS peggiorano ovunque (lo stop realizza la perdita al massimo overshoot e
|
||||||
|
l'engine rientra nello spread divergente: churn di fee), **5ª conferma** che gli stop su
|
||||||
|
mean-reversion sono falsi negativi. Record `scripts/analysis/pairs_zstop_research.py`,
|
||||||
|
diario `docs/diary/2026-06-11-stability-sweep.md`.
|
||||||
- **REAL-TRUTH ledger (2026-06-10, scelta utente).** Con `overrides.execution.real_truth: true`
|
- **REAL-TRUTH ledger (2026-06-10, scelta utente).** Con `overrides.execution.real_truth: true`
|
||||||
(ATTIVO) il `capital` dei worker eseguiti si aggiorna col **PnL dei FILL REALI** (fee reali
|
(ATTIVO) il `capital` dei worker eseguiti si aggiorna col **PnL dei FILL REALI** (fee reali
|
||||||
incluse) invece del PnL sim: `_real_close`/`_real_close_pair` ritornano `(real_pnl, applied)` e
|
incluse) invece del PnL sim: `_real_close`/`_real_close_pair` ritornano `(real_pnl, applied)` e
|
||||||
|
|||||||
+13
-9
@@ -39,20 +39,18 @@
|
|||||||
|
|
||||||
## Follow-up dal code-review (bassa priorità, non urgenti)
|
## Follow-up dal code-review (bassa priorità, non urgenti)
|
||||||
|
|
||||||
- [ ] **forming-bar su ROT02/TSM01**: i worker daily valutano la candela 1d IN FORMAZIONE al primo
|
- [x] ~~forming-bar su ROT02/TSM01~~ — GIÀ FIXATO (v1.1.10, 2026-06-08): `_panel` condiviso
|
||||||
poll dopo mezzanotte UTC (stessa classe del fix TR01/Pairs, pre-esistente). Ora è banale:
|
scarta la barra in formazione via `last_bar_is_forming`. Item rimasto stantio nel TODO.
|
||||||
usare `src.live.bars.last_settled_idx` in `_panel` / nei due tick. Impatto basso (1d) ma
|
|
||||||
stessa famiglia di bug.
|
|
||||||
- [ ] **ancora bfill di `_daily_equity`**: la serie a punti-trade reindexata su IDX àncora il primo
|
- [ ] **ancora bfill di `_daily_equity`**: la serie a punti-trade reindexata su IDX àncora il primo
|
||||||
valore della finestra al PRIMO trade in-finestra, non al capitale portato avanti. Tocca le
|
valore della finestra al PRIMO trade in-finestra, non al capitale portato avanti. Tocca le
|
||||||
metriche canoniche di TUTTI gli sleeve a punti-trade (honest/pairs/tsm). Valutare se
|
metriche canoniche di TUTTI gli sleeve a punti-trade (honest/pairs/tsm). Valutare se
|
||||||
correggere OVUNQUE in un colpo (cambierebbe i numeri canonici di riferimento). Scoperto nel
|
correggere OVUNQUE in un colpo (cambierebbe i numeri canonici di riferimento). Scoperto nel
|
||||||
gate DIP01 (parità inizialmente fallita per questo).
|
gate DIP01 (parità inizialmente fallita per questo).
|
||||||
- [ ] **convenzione TR01 worker vs reference**: il replay `validate_honest_workers` dà TR01 worker
|
- [x] ~~convenzione TR01 worker vs reference~~ — ERA UN BUG, FIXATO (2026-06-11): `mean(rets)`
|
||||||
-44% vs reference +42% (identico pre/post fix di oggi). È la divergenza capitale-unico
|
sui soli asset in posizione sovrappesava N/k a paniere parziale (con 1 long: 0.45 del
|
||||||
(`mean(rets)` solo sugli asset in posizione) vs media-equity 1/N del reference. Indagare:
|
capitale invece di 0.09). Fix: `sum(rets)/len(universe)` (equal-weight 1/N canonico).
|
||||||
sospetto che `mean(rets)` sovrappesi gli asset in posizione quando il paniere è parzialmente
|
Replay post-fix: +32% vs reference +42% (residuo = convenzione capitale-unico vs
|
||||||
investito.
|
media-equity, dichiarata). Diario `2026-06-11-stability-sweep.md`.
|
||||||
- [ ] **engine duplicato nei 3 gate** `*_port06_impact.py` (exit16/trendmax/dip01): `build_trades_variant`
|
- [ ] **engine duplicato nei 3 gate** `*_port06_impact.py` (exit16/trendmax/dip01): `build_trades_variant`
|
||||||
/ `port_metrics` copiati quasi verbatim. Sono regression-lock per decisioni live → la
|
/ `port_metrics` copiati quasi verbatim. Sono regression-lock per decisioni live → la
|
||||||
copy-drift corrompe i verdetti. Fattorizzare in un modulo condiviso.
|
copy-drift corrompe i verdetti. Fattorizzare in un modulo condiviso.
|
||||||
@@ -68,6 +66,12 @@
|
|||||||
|
|
||||||
## Monitoraggio (osservare, non agire subito)
|
## Monitoraggio (osservare, non agire subito)
|
||||||
|
|
||||||
|
- [ ] **FADE in coda storica (2026-06-11)**: il rolling 120g equal-weight delle 6 fade è al
|
||||||
|
**2° percentile** della propria storia (−1.0% vs p5 +0.4%); il PORT06 complessivo resta
|
||||||
|
in variazione normale (19-28° pct). NESSUN ritocco parametri (= fit sul regime corrente);
|
||||||
|
osservare se rientra. Follow-up tecnico: alert di drift per-FAMIGLIA nel `hourly_report`
|
||||||
|
(precomputare la distribuzione storica dei rolling-return di famiglia dal backtest
|
||||||
|
canonico → confrontare il rolling live; notifica sotto p5).
|
||||||
- [ ] **PnL live post-swap trend** (epoca TREND in `hourly_report`): verdetto stop-rate fade a n>=30.
|
- [ ] **PnL live post-swap trend** (epoca TREND in `hourly_report`): verdetto stop-rate fade a n>=30.
|
||||||
- [ ] **SH01 post-bootstrap**: il trade-rate live deve scendere da ~25% a ~10% delle barre
|
- [ ] **SH01 post-bootstrap**: il trade-rate live deve scendere da ~25% a ~10% delle barre
|
||||||
(selettività della soglia ripristinata). Controllare nel report orario.
|
(selettività della soglia ripristinata). Controllare nel report orario.
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 2026-06-11 — Sweep di stabilità su tutte le strategie (anti-overfit)
|
||||||
|
|
||||||
|
Obiettivo: analisi di tutte le 19 sleeve cercando miglioramenti/correzioni/protezioni che
|
||||||
|
aumentino la STABILITÀ, con disciplina anti-overfit (ipotesi pre-registrate, griglie fissate
|
||||||
|
prima di guardare i numeri, verdetti su TRAIN E OOS con plateau, gate PORT06; nessun re-test
|
||||||
|
di idee già bocciate: ADX/vol-target/time-stop/hurst/stop SH01/multi-TF/entry-guard ecc.).
|
||||||
|
|
||||||
|
## Audit di drift (nessun fitting)
|
||||||
|
|
||||||
|
- `report_families` sui dati correnti: correlazioni cross-famiglia ancora ≈0 (pairs 0.03-0.11,
|
||||||
|
XS01 0.01, SH 0.03-0.04 vs MASTER-9) → la diversificazione NON è driftata.
|
||||||
|
- **Regression-lock trend_max** (`trendmax_port06_impact`) rieseguito su dati freschi:
|
||||||
|
parità 1.00000 su tutte e 6 le fade (engine live-path integro) e **plateau trend_max
|
||||||
|
2.5/3.0/3.5 confermato** (OOS Sh 11.14/11.24/10.98, DD 1.33 identico).
|
||||||
|
- **Percentile del rolling-return PORT06** (cap weights, storia 2021+): finestra corrente
|
||||||
|
60g al 19°, 120g al 21°, 160g al 28° percentile → periodo fiacco ma in variazione normale.
|
||||||
|
- ⚠️ **FADE in coda storica**: il rolling 120g equal-weight delle 6 fade è a **−1.0% =
|
||||||
|
2° percentile** della propria storia (p5 = +0.4%). È il tratto peggiore mai attraversato
|
||||||
|
dalla famiglia. Decisione esplicita: NESSUN ritocco ai parametri (sarebbe fit sul regime
|
||||||
|
corrente, l'errore che il progetto evita da sempre); la protezione è la diversificazione
|
||||||
|
(già in atto: il portafoglio regge al 19-28° pct) + monitoraggio. Follow-up in TODO:
|
||||||
|
alert di drift per-famiglia nel hourly_report (distribuzione storica precomputata).
|
||||||
|
|
||||||
|
## Correzione: bug contabile TR01 worker (FIXATO)
|
||||||
|
|
||||||
|
`BasketTrendWorker.tick` usava `mean(rets)` sui SOLI asset in posizione → con paniere
|
||||||
|
parziale sovrappesa N/k (con 1 solo long: 0.45 del capitale invece di 0.09). Era l'origine
|
||||||
|
della divergenza replay −44% vs reference +42% annotata nel TODO. La convenzione canonica
|
||||||
|
(backtest PORT06 via `_tr_basket_daily`) è equal-weight 1/N sull'universo: fix 1 riga
|
||||||
|
(`sum(rets)/len(universe)`). Replay post-fix: **TR01 +32% vs reference +42%** (stesso segno
|
||||||
|
e ordine di grandezza = gate del validatore; il residuo è la differenza dichiarata
|
||||||
|
capitale-unico vs media-equity). ROT02 +171%==ref, TSM01 +5%==ref invariati. Solo
|
||||||
|
statistica PAPER: nessun effetto su pool/ordini. NB: il forming-bar su ROT02/TSM01 segnato
|
||||||
|
nel TODO era GIÀ fixato (v1.1.10, `_panel` scarta la barra in formazione) — TODO aggiornato.
|
||||||
|
|
||||||
|
## Protezione 1 (pre-registrata): disaster-cap z sui pairs → **NO-GO**
|
||||||
|
|
||||||
|
Ipotesi: exit immediata se |z| ≥ z_stop dopo l'ingresso taglia la coda da structural-break
|
||||||
|
senza toccare i trade normali. Griglia fissata: z_stop {3.0,3.5,4.0,5.0} × 5 coppie 1h +
|
||||||
|
{2.5,3.0,3.5,4.0} sul 15m, train <2023-11-01 / OOS, engine con regression-lock ESATTO su
|
||||||
|
`pairs_sim`/`pairs_sim_flat`. Esito (potere statistico AMPIO, centinaia di trigger):
|
||||||
|
**bocciata su tutti e 3 i criteri** — il DD peggiora quasi ovunque, il worst-trade OOS
|
||||||
|
peggiora su 4 coppie su 6 (ETH/BTC −62→−168%!), Sharpe OOS cala oltre il −10% relativo in
|
||||||
|
TUTTE le celle, e il "plateau" esiste solo per il danno (monotono: più stretto, peggio).
|
||||||
|
Meccanismi: (i) lo stop realizza la perdita al massimo overshoot — il movimento che la
|
||||||
|
strategia fada (stessa lezione EXIT-16/SH01, **5ª conferma**); (ii) l'engine non-overlap
|
||||||
|
rientra subito nello spread ancora divergente → churn di fee a 2 gambe e stop ripetuti.
|
||||||
|
I pairs restano senza stop by design; la mitigazione resta la taglia
|
||||||
|
(`position_size_family` PAIRS 0.20). Record: `scripts/analysis/pairs_zstop_research.py`.
|
||||||
|
|
||||||
|
## Protezione 2 (pre-registrata): phase-tranching XS01 → **PROMOSSA e LIVE**
|
||||||
|
|
||||||
|
Diagnosi: il roll non-sovrapposto di XS01 ha una FASE arbitraria (dipende da quando il
|
||||||
|
worker parte) e l'esito ne dipende parecchio — sulle 12 fasi possibili: Sharpe daily FULL
|
||||||
|
1.52-2.33, DD per-trade 13.8-33.1% (`xs01_tranche_research.py`). È timing-luck puro: il
|
||||||
|
backtest canonico (fase 0) è una delle estrazioni FORTUNATE sul FULL (DD 15.4 vs mediana
|
||||||
|
~21). Rimedio senza parametri fittati: **ensemble di fase** — K sub-book sfasati di hold/K
|
||||||
|
barre su capitale comune (PnL/K). Gate onesto su equity daily (`xs01_tranche_gate.py`):
|
||||||
|
|
||||||
|
- standalone OOS: Sharpe 3.79→4.27 (K=2) →4.85 (K=3), DD 7.99→7.25→5.56;
|
||||||
|
- FULL standalone: il DD sale verso la mediana di fase (~21%) — il "peggioramento" è la
|
||||||
|
RIMOZIONE della fortuna della fase 0, non un costo;
|
||||||
|
- PORT06 swap-sleeve: FULL invariato (7.33/3.46), OOS Sh 10.07→10.11 (K=2) →10.15 (K=3),
|
||||||
|
OOS DD 1.48→1.43→1.38. **Plateau: K=2 E K=3 entrambi promossi** (non best-pick).
|
||||||
|
|
||||||
|
Implementazione: `CrossSectionalWorker` param `tranches` (default 1 = storico; live K=3 in
|
||||||
|
`_defs.py`), books indipendenti con sfasamento iniziale `wait`, capitale comune, migrazione
|
||||||
|
automatica dello status legacy (il vecchio book → tranche 0), `last_bar_ts` solo-avanti
|
||||||
|
(robustezza ai panel accorciati da feed in ritardo). Solo path LIVE come `disp_min` (il
|
||||||
|
backtest canonico resta single-phase → il confronto live/backtest va letto con la mediana
|
||||||
|
di fase in mente). **Validatore esteso e PASSATO**: K=1 replay == `xsec_sim` ESATTO
|
||||||
|
(1427 trade, cap 4993==4993); K=3 replay == unione fasi 0/4/8 ESATTO (4279 trade,
|
||||||
|
4512==4512). Osservabilità: `hourly_report` aggrega i sub-book nel book medio.
|
||||||
|
|
||||||
|
## Non toccati (per evidenza, non per pigrizia)
|
||||||
|
|
||||||
|
- **Fade/DIP01**: exit-lab già esaustivo (23+11 famiglie); plateau e parità riconfermati oggi.
|
||||||
|
- **SH01**: 11 famiglie di stop già bocciate; cap famiglia + monitor trade-rate in essere.
|
||||||
|
- **ROT02/TSM01**: replay == reference; forming-bar già fixato.
|
||||||
|
- **TR01 come sleeve** (3 anni negativi su 6): rimosso? NO — sarebbe una decisione guidata
|
||||||
|
dal regime recente (recency bias); il suo ruolo è catturare i trend che le fade non
|
||||||
|
prendono. Resta, con la statistica ora contabilizzata correttamente.
|
||||||
|
|
||||||
|
## Esito test
|
||||||
|
|
||||||
|
`pytest`: 99 passed. Validatori: honest workers OK, xsec K=1/K=3 OK, trendmax lock OK.
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Ricerca PRE-REGISTRATA: disaster-cap z-score (z_stop) per la famiglia PAIRS.
|
||||||
|
|
||||||
|
Ipotesi pre-registrata: uscita immediata al close della barra se |z| >= z_stop
|
||||||
|
dopo l'ingresso taglia la coda da structural-break senza toccare i trade normali
|
||||||
|
(che vivono fra z_exit e z_in).
|
||||||
|
|
||||||
|
Griglia PRE-REGISTRATA (unica, completa — NIENTE varianti a posteriori):
|
||||||
|
- 5 coppie 1h (config universale n=50 z_in=2.0 z_exit=0.75 max_bars=72):
|
||||||
|
z_stop in {3.0, 3.5, 4.0, 5.0}
|
||||||
|
- ETH/BTC 15m flat_skip (n=66 z_in=1.674 z_exit=1.0 max_bars=35):
|
||||||
|
z_stop in {2.5, 3.0, 3.5, 4.0}
|
||||||
|
Split: TRAIN = entry prima del 2023-11-01, OOS = dopo (convenzione progetto).
|
||||||
|
|
||||||
|
Engine: copia FEDELE di pairs_research.pairs_sim / pairs_sim_flat (stessa
|
||||||
|
matematica, fee 2 gambe = 2*fee_rt*lev) + parametro z_stop. Causalita': lo z
|
||||||
|
usato per l'exit alla barra j e' lo stesso z[j] causale (rolling su r[<=j])
|
||||||
|
gia' usato dall'exit |z|<=z_exit.
|
||||||
|
|
||||||
|
REGRESSION-LOCK obbligatorio (eseguito in main, si ferma se fallisce):
|
||||||
|
z_stop=None deve riprodurre ESATTAMENTE pairs_sim (ETH/BTC 1h) e
|
||||||
|
pairs_sim_flat (ETH/BTC 15m flat_skip): stesso n trade, stesso ret.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.pairs_research import ( # noqa: E402
|
||||||
|
FEE_RT, LEV, POS, aligned_ohlc, is_flat_ohlc, pairs_sim, pairs_sim_flat,
|
||||||
|
)
|
||||||
|
|
||||||
|
SPLIT_DT = pd.Timestamp("2023-11-01", tz="UTC")
|
||||||
|
|
||||||
|
|
||||||
|
def pairs_sim_zstop(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.75, max_bars=72,
|
||||||
|
jump_max=0.08, fee_rt=FEE_RT, lev=LEV, pos=POS,
|
||||||
|
z_stop=None, t0=None, t1=None,
|
||||||
|
flat_skip=False, scan_buffer=192):
|
||||||
|
"""Copia fedele dell'engine pairs (pairs_sim_flat, che con flat_skip=False
|
||||||
|
e' identico a pairs_sim — regression-lock in main) + disaster-cap z_stop.
|
||||||
|
|
||||||
|
z_stop: se non None, l'exit si arma anche quando |z[jj]| >= z_stop
|
||||||
|
(structural break: lo spread diverge oltre l'ingresso). Stessa convenzione
|
||||||
|
causale e stesso fill (close della barra) dell'exit |z|<=z_exit.
|
||||||
|
t0/t1: finestra sul timestamp della barra di ENTRY (train/OOS split).
|
||||||
|
"""
|
||||||
|
m = aligned_ohlc(a, b, tf)
|
||||||
|
ca, cb = m["close_a"].values, m["close_b"].values
|
||||||
|
N = len(ca)
|
||||||
|
if flat_skip:
|
||||||
|
flat = (is_flat_ohlc(m["open_a"].values, m["high_a"].values, m["low_a"].values, ca)
|
||||||
|
| is_flat_ohlc(m["open_b"].values, m["high_b"].values, m["low_b"].values, cb))
|
||||||
|
else:
|
||||||
|
flat = np.zeros(N, dtype=bool)
|
||||||
|
r = np.log(ca / cb)
|
||||||
|
dr = np.abs(np.diff(r, prepend=r[0]))
|
||||||
|
ma = pd.Series(r).rolling(n).mean().values
|
||||||
|
sd = pd.Series(r).rolling(n).std().values
|
||||||
|
z = (r - ma) / np.where(sd == 0, np.nan, sd) # causale: usa r[<=i]
|
||||||
|
ts = m["dt"]
|
||||||
|
tsv = ts.values # datetime64 per filtro finestra
|
||||||
|
t0v = np.datetime64(t0.tz_convert(None)) if t0 is not None else None
|
||||||
|
t1v = np.datetime64(t1.tz_convert(None)) if t1 is not None else None
|
||||||
|
fee = 2 * fee_rt * lev # 2 gambe
|
||||||
|
cap = peak = 1000.0; dd = 0.0; last = -1
|
||||||
|
trades = wins = n_stop = 0
|
||||||
|
rets = []; rets_raw = []
|
||||||
|
eq_ts, eq_v = [], []
|
||||||
|
kmax = max_bars + (scan_buffer if flat_skip else 0)
|
||||||
|
for i in range(n + 1, N - 1):
|
||||||
|
if np.isnan(z[i]) or dr[i] > jump_max or i <= last:
|
||||||
|
continue
|
||||||
|
if t0v is not None and tsv[i] < t0v:
|
||||||
|
continue
|
||||||
|
if t1v is not None and tsv[i] >= t1v:
|
||||||
|
continue
|
||||||
|
if z[i] <= -z_in:
|
||||||
|
d = 1
|
||||||
|
elif z[i] >= z_in:
|
||||||
|
d = -1
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if flat[i]:
|
||||||
|
continue # niente ingresso su barra stale
|
||||||
|
# exit: |z|<=z_exit, max_bars, o DISASTER-CAP |z|>=z_stop; con flat_skip
|
||||||
|
# l'exit si arma e si esce alla prima barra pulita (live-realizable)
|
||||||
|
exit_ready = False; stopped = False; j = i
|
||||||
|
for k in range(1, kmax + 1):
|
||||||
|
jj = i + k
|
||||||
|
if jj >= N:
|
||||||
|
j = N - 1; break
|
||||||
|
if not exit_ready:
|
||||||
|
if z_stop is not None and abs(z[jj]) >= z_stop:
|
||||||
|
exit_ready = True; stopped = True
|
||||||
|
elif abs(z[jj]) <= z_exit or k >= max_bars:
|
||||||
|
exit_ready = True
|
||||||
|
if exit_ready and not flat[jj]:
|
||||||
|
j = jj; break
|
||||||
|
j = jj
|
||||||
|
retA = (ca[j] - ca[i]) / ca[i]
|
||||||
|
retB = (cb[j] - cb[i]) / cb[i]
|
||||||
|
ret = (retA - retB) * d * lev - fee # long A / short B (o viceversa)
|
||||||
|
cap = max(cap + cap * pos * ret, 10.0)
|
||||||
|
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
|
||||||
|
trades += 1; wins += ret > 0; n_stop += stopped
|
||||||
|
rets.append(ret * pos); rets_raw.append(ret); last = j
|
||||||
|
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
|
||||||
|
# span temporale della finestra effettiva (per annualizzare lo Sharpe)
|
||||||
|
lo = ts.iloc[0] if t0 is None else max(ts.iloc[0], t0)
|
||||||
|
hi = ts.iloc[-1] if t1 is None else min(ts.iloc[-1], t1)
|
||||||
|
yrs_span = (hi - lo).days / 365.25 or 1
|
||||||
|
sharpe = 0.0
|
||||||
|
if len(rets) > 1 and np.std(rets) > 0:
|
||||||
|
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(trades / yrs_span))
|
||||||
|
ret_tot = (cap / 1000 - 1) * 100
|
||||||
|
worst = min(rets_raw) * 100 if rets_raw else 0.0
|
||||||
|
return dict(trades=trades, n_stop=n_stop, win=wins / trades * 100 if trades else 0,
|
||||||
|
ret=ret_tot, dd=dd * 100, sharpe=sharpe, worst=worst)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- lock
|
||||||
|
|
||||||
|
def regression_lock():
|
||||||
|
"""z_stop=None deve riprodurre ESATTAVENTE l'engine canonico."""
|
||||||
|
ok = True
|
||||||
|
# 1h plain vs pairs_sim (config universale live z_exit=0.75)
|
||||||
|
ref = pairs_sim("ETH", "BTC", n=50, z_in=2.0, z_exit=0.75, max_bars=72)
|
||||||
|
new = pairs_sim_zstop("ETH", "BTC", n=50, z_in=2.0, z_exit=0.75, max_bars=72,
|
||||||
|
z_stop=None, flat_skip=False)
|
||||||
|
m1 = (ref["trades"] == new["trades"]) and abs(ref["ret"] - new["ret"]) < 1e-9
|
||||||
|
print(f" LOCK 1h ETH/BTC vs pairs_sim: trades {ref['trades']} vs {new['trades']}, "
|
||||||
|
f"ret {ref['ret']:+.6f} vs {new['ret']:+.6f} -> {'OK' if m1 else 'FAIL'}")
|
||||||
|
ok &= m1
|
||||||
|
# 15m flat_skip vs pairs_sim_flat
|
||||||
|
ref = pairs_sim_flat("ETH", "BTC", tf="15m", n=66, z_in=1.674, z_exit=1.0,
|
||||||
|
max_bars=35, flat_skip=True)
|
||||||
|
new = pairs_sim_zstop("ETH", "BTC", tf="15m", n=66, z_in=1.674, z_exit=1.0,
|
||||||
|
max_bars=35, z_stop=None, flat_skip=True)
|
||||||
|
m2 = (ref["trades"] == new["trades"]) and abs(ref["ret"] - new["ret"]) < 1e-9
|
||||||
|
print(f" LOCK 15m ETH/BTC vs pairs_sim_flat: trades {ref['trades']} vs {new['trades']}, "
|
||||||
|
f"ret {ref['ret']:+.6f} vs {new['ret']:+.6f} -> {'OK' if m2 else 'FAIL'}")
|
||||||
|
ok &= m2
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- main
|
||||||
|
|
||||||
|
PAIRS_1H = [("ETH", "BTC"), ("LTC", "ETH"), ("ADA", "ETH"), ("BTC", "LTC"), ("ETH", "SOL")]
|
||||||
|
GRID_1H = [None, 3.0, 3.5, 4.0, 5.0]
|
||||||
|
GRID_15M = [None, 2.5, 3.0, 3.5, 4.0]
|
||||||
|
|
||||||
|
|
||||||
|
def run_cell(a, b, win, z_stop, **kw):
|
||||||
|
t0, t1 = (None, SPLIT_DT) if win == "TRAIN" else (SPLIT_DT, None)
|
||||||
|
return pairs_sim_zstop(a, b, z_stop=z_stop, t0=t0, t1=t1, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 100)
|
||||||
|
print(" PAIRS disaster-cap z_stop — ricerca PRE-REGISTRATA (griglia fissa, tutti i risultati)")
|
||||||
|
print(f" split TRAIN < {SPLIT_DT.date()} <= OOS | fee 2 gambe {2*FEE_RT*LEV*100:.2f}% | lev {LEV:.0f}x pos {POS}")
|
||||||
|
print("=" * 100)
|
||||||
|
print("\nREGRESSION-LOCK (z_stop=None == engine canonico):")
|
||||||
|
if not regression_lock():
|
||||||
|
print("\n LOCK FALLITO — STOP."); sys.exit(1)
|
||||||
|
|
||||||
|
hdr = (f" {'z_stop':>7s} | {'trd':>5s} {'stop':>5s} {'ret%':>9s} {'Shrp':>6s} "
|
||||||
|
f"{'DD%':>6s} {'worst%':>8s}")
|
||||||
|
for a, b in PAIRS_1H:
|
||||||
|
kw = dict(tf="1h", n=50, z_in=2.0, z_exit=0.75, max_bars=72, flat_skip=False)
|
||||||
|
print(f"\n{'-'*100}\n {a}/{b} 1h (n=50 z_in=2.0 z_exit=0.75 max_bars=72)")
|
||||||
|
for win in ("TRAIN", "OOS"):
|
||||||
|
print(f" [{win}]\n{hdr}")
|
||||||
|
for zs in GRID_1H:
|
||||||
|
r = run_cell(a, b, win, zs, **kw)
|
||||||
|
lab = "None" if zs is None else f"{zs:.1f}"
|
||||||
|
print(f" {lab:>7s} | {r['trades']:>5d} {r['n_stop']:>5d} {r['ret']:>+9.1f} "
|
||||||
|
f"{r['sharpe']:>6.2f} {r['dd']:>6.2f} {r['worst']:>+8.2f}")
|
||||||
|
|
||||||
|
a, b = "ETH", "BTC"
|
||||||
|
kw = dict(tf="15m", n=66, z_in=1.674, z_exit=1.0, max_bars=35, flat_skip=True)
|
||||||
|
print(f"\n{'-'*100}\n {a}/{b} 15m flat_skip (n=66 z_in=1.674 z_exit=1.0 max_bars=35)")
|
||||||
|
for win in ("TRAIN", "OOS"):
|
||||||
|
print(f" [{win}]\n{hdr}")
|
||||||
|
for zs in GRID_15M:
|
||||||
|
r = run_cell(a, b, win, zs, **kw)
|
||||||
|
lab = "None" if zs is None else f"{zs:.1f}"
|
||||||
|
print(f" {lab:>7s} | {r['trades']:>5d} {r['n_stop']:>5d} {r['ret']:>+9.1f} "
|
||||||
|
f"{r['sharpe']:>6.2f} {r['dd']:>6.2f} {r['worst']:>+8.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -20,34 +20,63 @@ from src.live.xsec_worker import CrossSectionalWorker
|
|||||||
from scripts.strategies.XS01_cross_sectional import aligned_panel, xsec_sim, UNIVERSE, LB, HOLD
|
from scripts.strategies.XS01_cross_sectional import aligned_panel, xsec_sim, UNIVERSE, LB, HOLD
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def _replay(M, dfs, params):
|
||||||
print("=" * 88)
|
"""Alimenta il worker bar-per-bar su finestre trailing; ritorna il worker."""
|
||||||
print(" VALIDAZIONE CrossSectionalWorker — replay live vs backtest xsec_sim (fee 0.10% RT/book)")
|
|
||||||
print("=" * 88)
|
|
||||||
M = aligned_panel(UNIVERSE)
|
|
||||||
dfs = {a: pd.DataFrame({"timestamp": M.index.values, "close": M[a].values}) for a in UNIVERSE}
|
|
||||||
n = len(M)
|
n = len(M)
|
||||||
tmp = Path(tempfile.mkdtemp(prefix="xsec_val_"))
|
tmp = Path(tempfile.mkdtemp(prefix="xsec_val_"))
|
||||||
try:
|
try:
|
||||||
w = CrossSectionalWorker(UNIVERSE, tf="1h", params={"lb": LB, "hold": HOLD},
|
w = CrossSectionalWorker(UNIVERSE, tf="1h", params=params,
|
||||||
fee_rt=0.0005, data_dir=tmp)
|
fee_rt=0.0005, data_dir=tmp)
|
||||||
w._save = lambda: None; w._log = lambda *a, **k: None; w._notify = lambda *a, **k: None
|
w._save = lambda: None; w._log = lambda *a, **k: None; w._notify = lambda *a, **k: None
|
||||||
window = LB + 6
|
window = LB + 6
|
||||||
for k in range(LB + 1, n + 1): # prima finestra = lb+1 barre -> ingresso al bar lb
|
for k in range(LB + 1, n + 1): # prima finestra = lb+1 barre -> ingresso al bar lb
|
||||||
lo = max(0, k - window)
|
lo = max(0, k - window)
|
||||||
w.tick({a: dfs[a].iloc[lo:k] for a in UNIVERSE})
|
w.tick({a: dfs[a].iloc[lo:k] for a in UNIVERSE})
|
||||||
|
return w
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 88)
|
||||||
|
print(" VALIDAZIONE CrossSectionalWorker — replay live vs backtest xsec_sim (fee 0.10% RT/book)")
|
||||||
|
print("=" * 88)
|
||||||
|
M = aligned_panel(UNIVERSE)
|
||||||
|
dfs = {a: pd.DataFrame({"timestamp": M.index.values, "close": M[a].values}) for a in UNIVERSE}
|
||||||
|
|
||||||
|
# [1] K=1: parita' storica col backtest canonico
|
||||||
|
w = _replay(M, dfs, {"lb": LB, "hold": HOLD})
|
||||||
bt = xsec_sim(UNIVERSE)
|
bt = xsec_sim(UNIVERSE)
|
||||||
bt_cap = 1000.0 * (1 + bt["ret"] / 100)
|
bt_cap = 1000.0 * (1 + bt["ret"] / 100)
|
||||||
cap_ok = abs(w.capital - bt_cap) / bt_cap < 0.02 if bt_cap else False
|
cap_ok = abs(w.capital - bt_cap) / bt_cap < 0.02 if bt_cap else False
|
||||||
trd_ok = abs(w.total_trades - bt["trades"]) <= max(2, bt["trades"] * 0.02)
|
trd_ok = abs(w.total_trades - bt["trades"]) <= max(2, bt["trades"] * 0.02)
|
||||||
ww = w.total_wins / w.total_trades * 100 if w.total_trades else 0
|
ww = w.total_wins / w.total_trades * 100 if w.total_trades else 0
|
||||||
print(f"\n {'':<6}{'cap':>14}{'trades':>8}{'win%':>7}")
|
print(f"\n [K=1] {'':<6}{'cap':>14}{'trades':>8}{'win%':>7}")
|
||||||
print(f" WORKER{w.capital:>14.0f}{w.total_trades:>8d}{ww:>7.1f}")
|
print(f" WORKER{w.capital:>14.0f}{w.total_trades:>8d}{ww:>7.1f}")
|
||||||
print(f" BCKTST{bt_cap:>14.0f}{bt['trades']:>8d}{bt['win']:>7.1f}")
|
print(f" BCKTST{bt_cap:>14.0f}{bt['trades']:>8d}{bt['win']:>7.1f}")
|
||||||
print(f"\n ESITO: {'OK (replay == backtest)' if (cap_ok and trd_ok) else 'DIFF -> INDAGARE'}")
|
ok1 = cap_ok and trd_ok
|
||||||
|
print(f" ESITO: {'OK (replay == backtest)' if ok1 else 'DIFF -> INDAGARE'}")
|
||||||
|
|
||||||
|
# [2] K=3 (tranching live): parita' con l'unione delle fasi 0,4,8 (gate
|
||||||
|
# xs01_tranche_gate) — capitale comune, PnL/K per trade in ordine di exit
|
||||||
|
from scripts.analysis.xs01_tranche_research import xsec_trades
|
||||||
|
K = 3
|
||||||
|
w3 = _replay(M, dfs, {"lb": LB, "hold": HOLD, "tranches": K})
|
||||||
|
step = HOLD // K
|
||||||
|
allt = sorted([t for j in range(K) for t in xsec_trades(phase=j * step, M=M)],
|
||||||
|
key=lambda t: t[1])
|
||||||
|
cap = 1000.0
|
||||||
|
for _, _, net in allt:
|
||||||
|
cap = max(cap + cap * 0.15 * 3.0 * net / K, 10.0)
|
||||||
|
cap_ok3 = abs(w3.capital - cap) / cap < 0.02
|
||||||
|
trd_ok3 = abs(w3.total_trades - len(allt)) <= max(2, len(allt) * 0.02)
|
||||||
|
print(f"\n [K=3] {'':<6}{'cap':>14}{'trades':>8}")
|
||||||
|
print(f" WORKER{w3.capital:>14.0f}{w3.total_trades:>8d}")
|
||||||
|
print(f" BCKTST{cap:>14.0f}{len(allt):>8d}")
|
||||||
|
ok3 = cap_ok3 and trd_ok3
|
||||||
|
print(f" ESITO: {'OK (replay == unione fasi)' if ok3 else 'DIFF -> INDAGARE'}")
|
||||||
|
print(f"\n ESITO COMPLESSIVO: {'OK' if (ok1 and ok3) else 'DIFF -> INDAGARE'}")
|
||||||
print(" (diff minime attese da bar finale aperta / troncamento)")
|
print(" (diff minime attese da bar finale aperta / troncamento)")
|
||||||
finally:
|
|
||||||
shutil.rmtree(tmp, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""GATE — XS01 phase-tranching (K sub-book sfasati di hold/K, capitale comune).
|
||||||
|
|
||||||
|
`xs01_tranche_research.py` ha misurato timing-luck di fase reale (FULL Sharpe
|
||||||
|
1.87-2.87, DD 13.8-33.1% a seconda della fase di partenza, che live e' arbitraria).
|
||||||
|
Qui il confronto ONESTO su equity GIORNALIERA (stessa convenzione dei gate del
|
||||||
|
progetto: combine_portfolio.metrics) + gate PORT06 swap-sleeve:
|
||||||
|
[1] standalone: base (fase canonica) vs K=2 vs K=3, FULL e OOS, daily Sharpe/DD;
|
||||||
|
in piu' il range delle 12 fasi singole (il rischio che il tranching elimina).
|
||||||
|
[2] PORT06: members canonici con XS01 sostituito dalla variante tranched.
|
||||||
|
Criterio (tutti): OOS Sharpe portafoglio non peggiora (>-0.02) E DD non sale;
|
||||||
|
K=2 e K=3 devono essere ENTRAMBI >= base (plateau, non best-pick di K).
|
||||||
|
|
||||||
|
uv run python scripts/analysis/xs01_tranche_gate.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.analysis.combine_portfolio import port_returns, metrics, SPLIT, OOS_DATE
|
||||||
|
from scripts.analysis.report_families import daily_from
|
||||||
|
from scripts.analysis.xs01_tranche_research import xsec_trades
|
||||||
|
from scripts.strategies.XS01_cross_sectional import aligned_panel, HOLD, POS, LEV
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS
|
||||||
|
from src.portfolio.sleeves import all_sleeve_equities
|
||||||
|
from src.portfolio import weighting as W
|
||||||
|
|
||||||
|
|
||||||
|
def daily_equity_for(phases, M, ts):
|
||||||
|
"""Trade di tutte le fasi su capitale comune (peso 1/K) -> equity daily."""
|
||||||
|
K = len(phases)
|
||||||
|
allt = sorted([t for ph in phases for t in xsec_trades(phase=ph, M=M)],
|
||||||
|
key=lambda t: t[1])
|
||||||
|
cap = 1000.0
|
||||||
|
eq_ts, eq_v = [], []
|
||||||
|
for i, j, net in allt:
|
||||||
|
cap = max(cap + cap * POS * LEV * net / K, 10.0)
|
||||||
|
eq_ts.append(ts[j])
|
||||||
|
eq_v.append(cap)
|
||||||
|
return daily_from(eq_ts, eq_v)
|
||||||
|
|
||||||
|
|
||||||
|
def port_metrics(members, ids, clusters, caps):
|
||||||
|
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
|
||||||
|
w = W.weight_vector("cap", ids, dr, caps=caps, clusters=clusters)
|
||||||
|
drp = port_returns({i: members[i] for i in ids}, w)
|
||||||
|
return metrics(drp), metrics(drp, lo=SPLIT)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
M = aligned_panel()
|
||||||
|
ts = pd.to_datetime(M.index, unit="ms", utc=True)
|
||||||
|
p = PORTFOLIOS["PORT06"]
|
||||||
|
print("=" * 92)
|
||||||
|
print(" GATE — XS01 phase-tranching | equity daily, OOS da", OOS_DATE)
|
||||||
|
print("=" * 92)
|
||||||
|
|
||||||
|
variants = {"base (fase 0)": [0],
|
||||||
|
"K=2 (fasi 0,6)": [0, 6],
|
||||||
|
"K=3 (fasi 0,4,8)": [0, 4, 8]}
|
||||||
|
eqs = {k: daily_equity_for(v, M, ts) for k, v in variants.items()}
|
||||||
|
|
||||||
|
print(f"\n [1] STANDALONE daily — {'config':<18}{'FULL Sh':>8}{'FULL DD%':>9}{'OOS Sh':>8}{'OOS DD%':>8}")
|
||||||
|
for k, e in eqs.items():
|
||||||
|
r = e.pct_change().fillna(0.0)
|
||||||
|
f, o = metrics(r), metrics(r, lo=SPLIT)
|
||||||
|
print(f" {k:<22}{f['sharpe']:>8.2f}{f['dd']:>9.2f}{o['sharpe']:>8.2f}{o['dd']:>8.2f}")
|
||||||
|
# range delle 12 fasi singole (daily): il rischio di fase che il tranching elimina
|
||||||
|
fs, os_ = [], []
|
||||||
|
for ph in range(HOLD):
|
||||||
|
r = daily_equity_for([ph], M, ts).pct_change().fillna(0.0)
|
||||||
|
fs.append(metrics(r)["sharpe"])
|
||||||
|
os_.append(metrics(r, lo=SPLIT)["sharpe"])
|
||||||
|
print(f" 12 fasi singole: FULL Sh {min(fs):.2f}-{max(fs):.2f} | OOS Sh {min(os_):.2f}-{max(os_):.2f}")
|
||||||
|
|
||||||
|
eq_base = dict(all_sleeve_equities())
|
||||||
|
ids, cl, caps = list(p.sleeve_ids), p.clusters, p.caps
|
||||||
|
print(f"\n [2] PORT06 swap-sleeve — {'config':<18}{'FULL Sh':>8}{'FULL DD%':>9}{'OOS Sh':>8}{'OOS DD%':>8}")
|
||||||
|
f0, o0 = port_metrics(eq_base, ids, cl, caps)
|
||||||
|
print(f" {'ATTUALE (base)':<22}{f0['sharpe']:>8.2f}{f0['dd']:>9.2f}{o0['sharpe']:>8.2f}{o0['dd']:>8.2f}")
|
||||||
|
verdicts = []
|
||||||
|
for k in ("K=2 (fasi 0,6)", "K=3 (fasi 0,4,8)"):
|
||||||
|
mem = dict(eq_base)
|
||||||
|
mem["XS01"] = eqs[k]
|
||||||
|
f1, o1 = port_metrics(mem, ids, cl, caps)
|
||||||
|
ok = (o1["sharpe"] >= o0["sharpe"] - 0.02 and o1["dd"] <= o0["dd"] + 1e-9
|
||||||
|
and f1["sharpe"] >= f0["sharpe"] - 0.02 and f1["dd"] <= f0["dd"] + 1e-9)
|
||||||
|
verdicts.append(ok)
|
||||||
|
print(f" {k:<22}{f1['sharpe']:>8.2f}{f1['dd']:>9.2f}{o1['sharpe']:>8.2f}{o1['dd']:>8.2f}"
|
||||||
|
f" {'OK' if ok else 'NO'}")
|
||||||
|
print(f"\n => {'PROMOSSO (plateau K=2 e K=3)' if all(verdicts) else 'NON promosso (serve plateau su entrambi i K)'}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""XS01 phase-tranching — PRE-REGISTRATO: il roll non-sovrapposto di XS01 (entry a i,
|
||||||
|
exit a i+hold, re-entry) ha una FASE arbitraria (dipende dal primo indice valido).
|
||||||
|
Se l'esito dipende dalla fase, c'e' timing-luck: dividere il book in K sub-book
|
||||||
|
sfasati di hold/K barre (capitale 1/K ciascuno) e' un ensemble di fase che riduce
|
||||||
|
la varianza SENZA parametri fittati (K=2 e K=3 riportati entrambi, nessun best-pick).
|
||||||
|
|
||||||
|
Test (griglia fissata qui, completa):
|
||||||
|
[1] SENSIBILITA' DI FASE: xsec_sim base con offset di partenza 0..hold-1
|
||||||
|
-> dispersione di Sharpe/ret/DD (FULL e OOS). Se la dispersione e' piccola,
|
||||||
|
il tranching non serve (verdetto onesto, fine).
|
||||||
|
[2] TRANCHING: K in {2, 3} sub-book sfasati equal-capital -> Sharpe/DD/ret
|
||||||
|
FULL e OOS vs la MEDIA e il WORST delle fasi singole.
|
||||||
|
Criterio (tutti necessari): il tranched riduce la dispersione (per costruzione) e
|
||||||
|
il suo Sharpe OOS >= worst-fase OOS con DD <= mediana fasi; fee identiche (il
|
||||||
|
tranching NON cambia il turnover per unita' di capitale).
|
||||||
|
|
||||||
|
uv run python scripts/analysis/xs01_tranche_research.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.strategies.XS01_cross_sectional import (
|
||||||
|
aligned_panel, UNIVERSE, LB, HOLD, FEE_RT, LEV, POS, OOS_FRAC)
|
||||||
|
|
||||||
|
|
||||||
|
def xsec_trades(phase: int = 0, lb: int = LB, hold: int = HOLD, fee_rt: float = FEE_RT,
|
||||||
|
split_frac: float = 0.0, M=None):
|
||||||
|
"""Replica ESATTA della logica di xsec_sim ma parte da max(lb, split)+phase e
|
||||||
|
ritorna la lista trade (i, j, net) — stessa matematica, fee = 2*fee_rt."""
|
||||||
|
C = M[UNIVERSE].values
|
||||||
|
n = len(C)
|
||||||
|
logC = np.log(C)
|
||||||
|
split = int(n * split_frac)
|
||||||
|
fee = 2 * fee_rt
|
||||||
|
out = []
|
||||||
|
last = -1
|
||||||
|
i = max(lb, split) + phase
|
||||||
|
while i < n - hold:
|
||||||
|
if i <= last:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
dm = logC[i] - logC[i - lb]
|
||||||
|
dm = dm - dm.mean()
|
||||||
|
gw = np.sum(np.abs(dm))
|
||||||
|
if gw < 1e-9:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
w = -dm / gw
|
||||||
|
book = float(np.sum(w * (logC[i + hold] - logC[i])))
|
||||||
|
out.append((i, i + hold, book - fee))
|
||||||
|
last = i + hold
|
||||||
|
i += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def equity_from(trades, ts, pos=POS, lev=LEV, weight=1.0):
|
||||||
|
"""Equity compounding a punti-exit (convenzione xsec_sim), peso per tranche."""
|
||||||
|
cap = 1000.0
|
||||||
|
eq_ts, eq_v = [], []
|
||||||
|
for i, j, net in sorted(trades, key=lambda t: t[1]):
|
||||||
|
cap = max(cap + cap * pos * lev * net * weight, 10.0)
|
||||||
|
eq_ts.append(ts[j])
|
||||||
|
eq_v.append(cap)
|
||||||
|
return pd.Series(eq_v, index=pd.DatetimeIndex(eq_ts))
|
||||||
|
|
||||||
|
|
||||||
|
def metrics(trades, ts, yrs_span):
|
||||||
|
rets = [t[2] * POS for t in trades]
|
||||||
|
n = len(rets)
|
||||||
|
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(n / yrs_span)) if n > 1 and np.std(rets) > 0 else 0.0
|
||||||
|
eq = equity_from(trades, ts)
|
||||||
|
pk = eq.cummax()
|
||||||
|
dd = float(((pk - eq) / pk).max() * 100) if len(eq) else 0.0
|
||||||
|
ret = float((eq.iloc[-1] / 1000 - 1) * 100) if len(eq) else 0.0
|
||||||
|
return dict(n=n, ret=ret, sharpe=sharpe, dd=dd)
|
||||||
|
|
||||||
|
|
||||||
|
def combined_metrics(branches, ts, yrs_span):
|
||||||
|
"""K tranche -> un'unica equity: pesa ogni trade 1/K sul capitale comune."""
|
||||||
|
K = len(branches)
|
||||||
|
allt = sorted([t for b in branches for t in b], key=lambda t: t[1])
|
||||||
|
cap = 1000.0
|
||||||
|
eq_ts, eq_v, rets = [], [], []
|
||||||
|
for i, j, net in allt:
|
||||||
|
rets.append(net * POS / K)
|
||||||
|
cap = max(cap + cap * POS * LEV * net / K, 10.0)
|
||||||
|
eq_ts.append(ts[j])
|
||||||
|
eq_v.append(cap)
|
||||||
|
eq = pd.Series(eq_v, index=pd.DatetimeIndex(eq_ts))
|
||||||
|
pk = eq.cummax()
|
||||||
|
n = len(rets)
|
||||||
|
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(n / yrs_span)) if n > 1 and np.std(rets) > 0 else 0.0
|
||||||
|
return dict(n=n, ret=float((eq.iloc[-1] / 1000 - 1) * 100),
|
||||||
|
sharpe=sharpe, dd=float(((pk - eq) / pk).max() * 100))
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
M = aligned_panel()
|
||||||
|
ts = pd.to_datetime(M.index, unit="ms", utc=True)
|
||||||
|
n = len(M)
|
||||||
|
print("=" * 96)
|
||||||
|
print(" XS01 phase-tranching — sensibilita' di fase + ensemble K=2/3 | griglia pre-registrata")
|
||||||
|
print("=" * 96)
|
||||||
|
for tag, split in (("FULL", 0.0), ("OOS", 1 - OOS_FRAC)):
|
||||||
|
yrs_span = (ts[-1] - ts[max(int(n * split), LB)]).days / 365.25 or 1
|
||||||
|
rows = []
|
||||||
|
for ph in range(HOLD):
|
||||||
|
tr = xsec_trades(phase=ph, split_frac=split, M=M)
|
||||||
|
rows.append(metrics(tr, ts, yrs_span))
|
||||||
|
sh = np.array([r["sharpe"] for r in rows])
|
||||||
|
dd = np.array([r["dd"] for r in rows])
|
||||||
|
rt = np.array([r["ret"] for r in rows])
|
||||||
|
print(f"\n [{tag}] sensibilita' di fase (12 fasi):")
|
||||||
|
print(f" Sharpe: min {sh.min():.2f} | med {np.median(sh):.2f} | max {sh.max():.2f} | std {sh.std():.2f}")
|
||||||
|
print(f" ret%: min {rt.min():+.0f} | med {np.median(rt):+.0f} | max {rt.max():+.0f}")
|
||||||
|
print(f" DD%: min {dd.min():.1f} | med {np.median(dd):.1f} | max {dd.max():.1f}")
|
||||||
|
for K in (2, 3):
|
||||||
|
branches = [xsec_trades(phase=int(p), split_frac=split, M=M)
|
||||||
|
for p in np.linspace(0, HOLD, K, endpoint=False)]
|
||||||
|
m = combined_metrics(branches, ts, yrs_span)
|
||||||
|
print(f" K={K} tranched: Sharpe {m['sharpe']:.2f} | ret {m['ret']:+.0f}% | "
|
||||||
|
f"DD {m['dd']:.1f}% | trade {m['n']}")
|
||||||
|
print("\n criterio: tranched-Sharpe OOS >= worst-fase OOS e DD <= mediana fasi; "
|
||||||
|
"se la dispersione di fase e' gia' piccola -> NON SERVE (verdetto onesto).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -110,9 +110,15 @@ SHAPE = [SleeveSpec(kind="ml", name="SH01", sid=f"SH_{a}", asset=a, cluster="sha
|
|||||||
# (standalone Sharpe 2.50->3.46, regge fee 2x), PORT06 OOS Sh 10.07->10.37 a DD pari. Solo
|
# (standalone Sharpe 2.50->3.46, regge fee 2x), PORT06 OOS Sh 10.07->10.37 a DD pari. Solo
|
||||||
# path LIVE (backtest canonico NON filtrato, come trend/hurst sulle fade) -> il live fara'
|
# path LIVE (backtest canonico NON filtrato, come trend/hurst sulle fade) -> il live fara'
|
||||||
# meglio del backtest. Diario 2026-06-10, gate scripts/analysis/xs01_dispersion_gate.py.
|
# meglio del backtest. Diario 2026-06-10, gate scripts/analysis/xs01_dispersion_gate.py.
|
||||||
|
# PHASE-TRANCHING (2026-06-11): tranches=3 sub-book sfasati di hold/3, capitale comune.
|
||||||
|
# La fase del roll e' arbitraria e da sola muove Sharpe FULL daily 1.52-2.33 / DD 13.8-33%
|
||||||
|
# (timing-luck): l'ensemble di fase la elimina senza parametri fittati. Gate
|
||||||
|
# xs01_tranche_gate.py: plateau K=2 E K=3 promossi (PORT06 OOS Sh 10.07->10.15, DD
|
||||||
|
# 1.48->1.38, FULL pari). Solo path live come disp_min (backtest canonico single-phase).
|
||||||
XSEC = [SleeveSpec(kind="xsec", name="XS01", sid="XS01", cluster="xsec",
|
XSEC = [SleeveSpec(kind="xsec", name="XS01", sid="XS01", cluster="xsec",
|
||||||
params={"universe": ["BTC", "ETH", "LTC", "ADA", "SOL", "BNB", "XRP", "DOGE"],
|
params={"universe": ["BTC", "ETH", "LTC", "ADA", "SOL", "BNB", "XRP", "DOGE"],
|
||||||
"tf": "1h", "lb": 48, "hold": 12, "disp_min": 0.0313})]
|
"tf": "1h", "lb": 48, "hold": 12, "disp_min": 0.0313,
|
||||||
|
"tranches": 3})]
|
||||||
|
|
||||||
PORTFOLIOS = {
|
PORTFOLIOS = {
|
||||||
"PORT01": Portfolio("PORT01", "Honest", HONEST, weighting="equal"),
|
"PORT01": Portfolio("PORT01", "Honest", HONEST, weighting="equal"),
|
||||||
|
|||||||
@@ -141,6 +141,13 @@ def multi_asset_section() -> str:
|
|||||||
d = Path(sp).parent
|
d = Path(sp).parent
|
||||||
st = json.loads(Path(sp).read_text())
|
st = json.loads(Path(sp).read_text())
|
||||||
book = st.get("positions") if "positions" in st else st.get("weights")
|
book = st.get("positions") if "positions" in st else st.get("weights")
|
||||||
|
if book is None and "books" in st:
|
||||||
|
# XS01 tranched (2026-06-11): aggrega i sub-book in un book medio per-asset
|
||||||
|
k = max(1, len(st["books"]))
|
||||||
|
book = {}
|
||||||
|
for b in st["books"]:
|
||||||
|
for a, v in (b.get("weights") or {}).items():
|
||||||
|
book[a] = book.get(a, 0.0) + v / k
|
||||||
if book is None:
|
if book is None:
|
||||||
continue # single-leg/pairs: gia' coperti da collect()
|
continue # single-leg/pairs: gia' coperti da collect()
|
||||||
held = {a: v for a, v in book.items() if v > 0}
|
held = {a: v for a, v in book.items() if v > 0}
|
||||||
|
|||||||
@@ -86,7 +86,11 @@ class BasketTrendWorker:
|
|||||||
self.positions[a] = target
|
self.positions[a] = target
|
||||||
self.last_bar_ts[a] = bar_ts
|
self.last_bar_ts[a] = bar_ts
|
||||||
if rets:
|
if rets:
|
||||||
self.capital = max(self.capital * (1 + float(np.mean(rets))), 10.0)
|
# equal-weight 1/N sull'UNIVERSO come il reference (_tr_basket_daily):
|
||||||
|
# gli asset flat contribuiscono 0. mean(rets) mediava sui SOLI asset in
|
||||||
|
# posizione -> sovrappeso N/k a paniere parziale (con 1 long: 0.45 del
|
||||||
|
# capitale invece di 0.09) -> replay -44% vs reference +42%.
|
||||||
|
self.capital = max(self.capital * (1 + float(np.sum(rets)) / len(self.universe)), 10.0)
|
||||||
self.in_position = any(v > 0 for v in self.positions.values())
|
self.in_position = any(v > 0 for v in self.positions.values())
|
||||||
self._save()
|
self._save()
|
||||||
|
|
||||||
|
|||||||
+73
-36
@@ -5,6 +5,13 @@ classifica gli asset per rendimento su LB barre, pesi w = -(ret - media)/gross (
|
|||||||
neutral gross 1), entra al close, esce dopo HOLD barre, riallinea (1 barra di stacco fra
|
neutral gross 1), entra al close, esce dopo HOLD barre, riallinea (1 barra di stacco fra
|
||||||
uscita e nuovo ingresso, come l'engine). PnL su book log-return netto fee 0.10% RT.
|
uscita e nuovo ingresso, come l'engine). PnL su book log-return netto fee 0.10% RT.
|
||||||
Stato persistente (resume). Solo SIM (esecuzione reale a 8 gambe non implementata).
|
Stato persistente (resume). Solo SIM (esecuzione reale a 8 gambe non implementata).
|
||||||
|
|
||||||
|
PHASE-TRANCHING (2026-06-11, gate xs01_tranche_gate.py): param `tranches`=K divide il
|
||||||
|
book in K sub-book sfasati di hold/K barre, capitale comune (PnL/K per tranche). La fase
|
||||||
|
del roll non-sovrapposto e' arbitraria e da sola muove Sharpe FULL daily 1.52-2.33 e DD
|
||||||
|
13.8-33.1% (timing-luck): l'ensemble di fase la elimina SENZA parametri fittati (plateau
|
||||||
|
K=2 e K=3 entrambi promossi; PORT06 OOS Sh 10.07->10.15, DD 1.48->1.38). Solo path live,
|
||||||
|
come disp_min: il backtest canonico resta single-phase. K=1 = comportamento storico.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -41,40 +48,61 @@ class CrossSectionalWorker:
|
|||||||
self.status_path = self.work_dir / "status.json"
|
self.status_path = self.work_dir / "status.json"
|
||||||
self.trades_path = self.work_dir / "trades.jsonl"
|
self.trades_path = self.work_dir / "trades.jsonl"
|
||||||
|
|
||||||
|
self.k = max(1, int(p.get("tranches", 1)))
|
||||||
|
self._step = max(1, round(self.hold / self.k)) # sfasamento iniziale fra tranche
|
||||||
self.capital = capital
|
self.capital = capital
|
||||||
self.in_position = False
|
self.books = [self._flat_book(j * self._step) for j in range(self.k)]
|
||||||
self.weights = {a: 0.0 for a in self.universe}
|
|
||||||
self.entry_px = {a: 0.0 for a in self.universe}
|
|
||||||
self.bars_held = 0
|
|
||||||
self.cooldown = 0 # 1 barra di stacco dopo l'uscita (come l'engine)
|
|
||||||
self.total_trades = 0
|
self.total_trades = 0
|
||||||
self.total_wins = 0
|
self.total_wins = 0
|
||||||
self.last_bar_ts = 0
|
self.last_bar_ts = 0
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
|
def _flat_book(self, wait: int = 0):
|
||||||
|
return {"weights": {a: 0.0 for a in self.universe},
|
||||||
|
"entry_px": {a: 0.0 for a in self.universe},
|
||||||
|
"bars_held": 0, "in_position": False, "wait": int(wait)}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def in_position(self) -> bool:
|
||||||
|
return any(b["in_position"] for b in self.books)
|
||||||
|
|
||||||
# ---------- persistenza ----------
|
# ---------- persistenza ----------
|
||||||
def _load(self):
|
def _load(self):
|
||||||
if not self.status_path.exists():
|
if not self.status_path.exists():
|
||||||
self._log("INIT", {"capital": self.capital, "universe": self.universe,
|
self._log("INIT", {"capital": self.capital, "universe": self.universe,
|
||||||
"lb": self.lb, "hold": self.hold})
|
"lb": self.lb, "hold": self.hold, "tranches": self.k})
|
||||||
return
|
return
|
||||||
s = json.loads(self.status_path.read_text())
|
s = json.loads(self.status_path.read_text())
|
||||||
self.capital = s.get("capital", self.initial_capital)
|
self.capital = s.get("capital", self.initial_capital)
|
||||||
self.in_position = s.get("in_position", False)
|
|
||||||
self.weights = {**{a: 0.0 for a in self.universe}, **s.get("weights", {})}
|
|
||||||
self.entry_px = {**{a: 0.0 for a in self.universe}, **s.get("entry_px", {})}
|
|
||||||
self.bars_held = s.get("bars_held", 0)
|
|
||||||
self.cooldown = s.get("cooldown", 0)
|
|
||||||
self.total_trades = s.get("total_trades", 0)
|
self.total_trades = s.get("total_trades", 0)
|
||||||
self.total_wins = s.get("total_wins", 0)
|
self.total_wins = s.get("total_wins", 0)
|
||||||
self.last_bar_ts = s.get("last_bar_ts", 0)
|
self.last_bar_ts = s.get("last_bar_ts", 0)
|
||||||
|
if "books" in s:
|
||||||
|
for j, bs in enumerate(s["books"][: self.k]):
|
||||||
|
b = self.books[j]
|
||||||
|
b["weights"] = {**{a: 0.0 for a in self.universe}, **bs.get("weights", {})}
|
||||||
|
b["entry_px"] = {**{a: 0.0 for a in self.universe}, **bs.get("entry_px", {})}
|
||||||
|
b["bars_held"] = int(bs.get("bars_held", 0))
|
||||||
|
b["in_position"] = bool(bs.get("in_position", False))
|
||||||
|
b["wait"] = int(bs.get("wait", 0))
|
||||||
|
elif s.get("in_position") or s.get("weights"):
|
||||||
|
# migrazione dallo schema legacy single-book: il vecchio book diventa la
|
||||||
|
# tranche 0; le altre partono flat col loro sfasamento (gia' in __init__)
|
||||||
|
b = self.books[0]
|
||||||
|
b["weights"] = {**{a: 0.0 for a in self.universe}, **s.get("weights", {})}
|
||||||
|
b["entry_px"] = {**{a: 0.0 for a in self.universe}, **s.get("entry_px", {})}
|
||||||
|
b["bars_held"] = int(s.get("bars_held", 0))
|
||||||
|
b["in_position"] = bool(s.get("in_position", False))
|
||||||
|
b["wait"] = 0
|
||||||
|
|
||||||
def _save(self):
|
def _save(self):
|
||||||
self.status_path.write_text(json.dumps({
|
self.status_path.write_text(json.dumps({
|
||||||
"capital": round(float(self.capital), 2), "in_position": bool(self.in_position),
|
"capital": round(float(self.capital), 2), "in_position": bool(self.in_position),
|
||||||
"weights": {a: round(float(v), 5) for a, v in self.weights.items()},
|
"tranches": int(self.k),
|
||||||
"entry_px": {a: float(v) for a, v in self.entry_px.items()},
|
"books": [{"weights": {a: round(float(v), 5) for a, v in b["weights"].items()},
|
||||||
"bars_held": int(self.bars_held), "cooldown": int(self.cooldown),
|
"entry_px": {a: float(v) for a, v in b["entry_px"].items()},
|
||||||
|
"bars_held": int(b["bars_held"]), "in_position": bool(b["in_position"]),
|
||||||
|
"wait": int(b["wait"])} for b in self.books],
|
||||||
"total_trades": int(self.total_trades), "total_wins": int(self.total_wins),
|
"total_trades": int(self.total_trades), "total_wins": int(self.total_wins),
|
||||||
"last_bar_ts": int(self.last_bar_ts),
|
"last_bar_ts": int(self.last_bar_ts),
|
||||||
"last_update": datetime.now(timezone.utc).isoformat(),
|
"last_update": datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -114,25 +142,27 @@ class CrossSectionalWorker:
|
|||||||
gw = np.sum(np.abs(w))
|
gw = np.sum(np.abs(w))
|
||||||
return w / gw if gw > 1e-9 else None
|
return w / gw if gw > 1e-9 else None
|
||||||
|
|
||||||
def _close_book(self, closes_now):
|
def _close_book(self, b, closes_now, tranche: int):
|
||||||
"""Realizza il PnL del book corrente al prezzo attuale (log-return netto fee)."""
|
"""Realizza il PnL del book della tranche al prezzo attuale (log-return netto fee).
|
||||||
|
Capitale comune: il notional della tranche e' 1/K del book virtuale."""
|
||||||
book = 0.0
|
book = 0.0
|
||||||
for k, a in enumerate(self.universe):
|
for k, a in enumerate(self.universe):
|
||||||
book += self.weights[a] * np.log(closes_now[k] / self.entry_px[a])
|
book += b["weights"][a] * np.log(closes_now[k] / b["entry_px"][a])
|
||||||
# cast a tipi Python: i numpy (float64/int64/bool_) rompono json.dumps in _save
|
# cast a tipi Python: i numpy (float64/int64/bool_) rompono json.dumps in _save
|
||||||
net = float(book - 2 * self.fee_rt)
|
net = float(book - 2 * self.fee_rt)
|
||||||
pnl = float(self.capital * self.position_size * self.leverage * net)
|
pnl = float(self.capital * self.position_size * self.leverage * net / self.k)
|
||||||
self.capital = max(self.capital + pnl, 10.0)
|
self.capital = max(self.capital + pnl, 10.0)
|
||||||
self.total_trades += 1
|
self.total_trades += 1
|
||||||
self.total_wins += 1 if net > 0 else 0
|
self.total_wins += 1 if net > 0 else 0
|
||||||
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
|
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
|
||||||
self._log("CLOSE", {"book_ret": round(book * 100, 3), "net": round(net * 100, 3),
|
self._log("CLOSE", {"tranche": tranche, "book_ret": round(book * 100, 3),
|
||||||
|
"net": round(net * 100, 3),
|
||||||
"pnl": round(pnl, 2), "capital": round(self.capital, 2),
|
"pnl": round(pnl, 2), "capital": round(self.capital, 2),
|
||||||
"trades": self.total_trades, "acc": round(acc, 1)})
|
"trades": self.total_trades, "acc": round(acc, 1)})
|
||||||
self.in_position = False
|
b["in_position"] = False
|
||||||
self.weights = {a: 0.0 for a in self.universe}
|
b["weights"] = {a: 0.0 for a in self.universe}
|
||||||
|
|
||||||
def _open_book(self, M, i):
|
def _open_book(self, M, i, b, tranche: int):
|
||||||
cols = list(M.columns)
|
cols = list(M.columns)
|
||||||
logC = np.log(M.values)
|
logC = np.log(M.values)
|
||||||
if self.disp_min is not None:
|
if self.disp_min is not None:
|
||||||
@@ -143,12 +173,13 @@ class CrossSectionalWorker:
|
|||||||
if w is None:
|
if w is None:
|
||||||
return
|
return
|
||||||
closes = M.iloc[i].values
|
closes = M.iloc[i].values
|
||||||
self.weights = {a: float(w[cols.index(a)]) for a in self.universe}
|
b["weights"] = {a: float(w[cols.index(a)]) for a in self.universe}
|
||||||
self.entry_px = {a: float(closes[cols.index(a)]) for a in self.universe}
|
b["entry_px"] = {a: float(closes[cols.index(a)]) for a in self.universe}
|
||||||
self.bars_held = 0
|
b["bars_held"] = 0
|
||||||
self.in_position = True
|
b["in_position"] = True
|
||||||
self._log("OPEN", {"long": [a for a in self.universe if self.weights[a] > 0.05],
|
self._log("OPEN", {"tranche": tranche,
|
||||||
"short": [a for a in self.universe if self.weights[a] < -0.05],
|
"long": [a for a in self.universe if b["weights"][a] > 0.05],
|
||||||
|
"short": [a for a in self.universe if b["weights"][a] < -0.05],
|
||||||
"capital": round(self.capital, 2)})
|
"capital": round(self.capital, 2)})
|
||||||
|
|
||||||
# ---------- tick ----------
|
# ---------- tick ----------
|
||||||
@@ -160,20 +191,26 @@ class CrossSectionalWorker:
|
|||||||
cur_ts = int(M.index[i])
|
cur_ts = int(M.index[i])
|
||||||
new_bar = cur_ts > self.last_bar_ts
|
new_bar = cur_ts > self.last_bar_ts
|
||||||
|
|
||||||
if self.in_position:
|
for j, b in enumerate(self.books):
|
||||||
|
if b["in_position"]:
|
||||||
if new_bar:
|
if new_bar:
|
||||||
self.bars_held += 1
|
b["bars_held"] += 1
|
||||||
self.last_bar_ts = cur_ts
|
|
||||||
# esce dopo HOLD barre; NON rientra nello stesso tick -> entry-to-entry = hold+1
|
# esce dopo HOLD barre; NON rientra nello stesso tick -> entry-to-entry = hold+1
|
||||||
if self.bars_held >= self.hold:
|
if b["bars_held"] >= self.hold:
|
||||||
self._close_book(M.iloc[i].values)
|
self._close_book(b, M.iloc[i].values, j)
|
||||||
|
elif b["wait"] > 0:
|
||||||
|
if new_bar:
|
||||||
|
b["wait"] -= 1 # sfasamento iniziale della tranche
|
||||||
else:
|
else:
|
||||||
self._open_book(M, i) # entra al bar corrente (i = lb alla prima volta)
|
self._open_book(M, i, b, j) # entra al bar corrente (i = lb alla prima volta)
|
||||||
self.last_bar_ts = cur_ts
|
# solo avanti: se il panel si accorcia per un feed in ritardo (inner join),
|
||||||
|
# non si regredisce — una barra gia' contata non va ricontata
|
||||||
|
self.last_bar_ts = max(self.last_bar_ts, cur_ts)
|
||||||
self._save()
|
self._save()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status_summary(self) -> str:
|
def status_summary(self) -> str:
|
||||||
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
|
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
|
||||||
st = "BOOK" if self.in_position else ("COOL" if self.cooldown else "FLAT")
|
nb = sum(1 for b in self.books if b["in_position"])
|
||||||
|
st = f"BOOK {nb}/{self.k}" if nb else "FLAT"
|
||||||
return f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t {acc:.0f}% | {st}"
|
return f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t {acc:.0f}% | {st}"
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
|
|||||||
return CrossSectionalWorker(
|
return CrossSectionalWorker(
|
||||||
universe=pr["universe"], tf=pr.get("tf", "1h"),
|
universe=pr["universe"], tf=pr.get("tf", "1h"),
|
||||||
params={"lb": pr.get("lb", 48), "hold": pr.get("hold", 12),
|
params={"lb": pr.get("lb", 48), "hold": pr.get("hold", 12),
|
||||||
"disp_min": pr.get("disp_min")},
|
"disp_min": pr.get("disp_min"),
|
||||||
|
"tranches": pr.get("tranches", 1)},
|
||||||
capital=alloc_capital, position_size=position_size, leverage=leverage,
|
capital=alloc_capital, position_size=position_size, leverage=leverage,
|
||||||
data_dir=data_dir,
|
data_dir=data_dir,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user