28 Commits

Author SHA1 Message Date
Adriano Dal Pastro cfdd8f6a72 release: v1.1.25 2026-06-11 20:54:44 +00:00
Adriano Dal Pastro 844a9d0e22 feat(exec): netting delle chiusure market — il residuo reduce-only cappato/respinto va in market puro
close_amount ora: (1) tenta il market reduce-only (sicurezza storica: un bug di
stato filla 0 invece di aprire posizioni); (2) il residuo cappato/respinto dal
netting di conto (worker in direzioni opposte sullo stesso strumento) viene
rieseguito in MARKET PURO con label '|net' — muove il conto esattamente del
delta del libro = netta contro le quote opposte. Niente piu' gambe pairs orfane
ne' close cappati per costruzione (close_pair passa da close_amount).

- _merge_close_fills: il chiamante riceve UN Fill (prezzo medio pesato sui fill,
  fee sommate, filled_amount totale, verified se copre il richiesto, notes
  'netting' quando il fallback scatta)
- worker single-leg + pairs: evento NET_CLOSE (log jsonl + Telegram) a ogni
  fallback — osservabilita' della frequenza dei conflitti di netting
- sicurezza persa sul residuo coperta dal reconciler orario (ACCOUNT_DRIFT);
  orphan_legs/REAL_CLOSE_PARTIAL restano come ultima difesa
- 4 test nuovi (full r-o senza fallback, cappato, respinto, doppio fail) -> 110 passed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:54:43 +00:00
Adriano Dal Pastro 22b77668f1 feat(ops): reconciler read-only conto vs libri worker (primo passo verso il position-manager)
scripts/analysis/reconcile_account.py: per strumento USDC confronta l'atteso
(somma quote reali dai status.json: single-leg + pairs 2 gambe + orphan_legs
registrati = drift spiegato) col conto reale (get_positions, size/mark -> coin).
Tolleranza 1.5x step contratto, anti-race (ricontrollo a 10s), alert Telegram
ACCOUNT_DRIFT con --telegram. SOLO lettura, gira dall'host (cron orario :40,
nessun deploy). Al primo run: vero positivo su BTC (libro MR02 short 0.0028 vs
conto flat — TP fillato dallo spike reale, si riconcilia alla chiusura sim).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:43:17 +00:00
Adriano Dal Pastro 521447fce6 release: v1.1.24 2026-06-11 20:05:43 +00:00
Adriano Dal Pastro 429fa01c97 fix(exec): verità contabile sul netting — filled_amount, gambe orfane, tick isolato
Da audit live (3 indagini parallele, diario 2026-06-11-system-audit.md): il modello
quote-per-worker reduce-only si rompe con direzioni opposte sullo stesso strumento
(pairs long ETH vs fade short ETH) — close cappati bookati pieni, 3 gambe pairs mai
eseguite col PnL sim nel ledger reale, conto short 0.027 ETH oltre i libri
(riallineato a mano + DSL orfano cancellato).

- Fill.filled_amount (order.filled_amount): TUTTI i ledger usano il fillato reale
- REAL_CLOSE_PARTIAL (log+alert) su close cappato; residuo orfano dichiarato
- pairs: PnL solo per gambe verificate; gamba respinta -> orphan_legs persistito
  + alert PAIR_LEG_ORPHAN; applied solo con entrambe le gambe (else sim_fallback)
- REAL_DIVERGENCE anche su jsonl (prima solo Telegram)
- runner: tick isolato per-worker + WORKER_ERROR_STREAK a 5 fail consecutivi

Strutturale APERTO (decisione utente, opzioni nel diario): position-manager
centrale / sotto-conti per famiglia / status-quo monitorato.

Test: +2 (partial-close, orphan-leg), fixture filled_amount -> 106 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:05:42 +00:00
Adriano Dal Pastro 8a2b065dd7 chore(analysis): dedup engine gate PORT06 + drift monitor giornaliero + impact bfill
- _port06_gate_common.py: build_trades_variant/equity_from_trades/port_metrics/dd
  fattorizzati dai 3 gate exit16/trendmax/dip01 (-214 righe duplicate). Nessun
  copy-drift trovato; versione promossa = trendmax (superset con hurst_mask).
  Output dei 3 gate verificato BYTE-IDENTICO prima/dopo. dip_trades resta nel suo
  script (sibling deliberato long-only/orig_gap, non una copia).
- drift_monitor.py: rolling-return per famiglia vs distribuzione storica propria
  (warn sotto p5; oggi: FADE 120g al p2). In crontab host giornaliero 07:15 UTC
  con report Telegram. Osservabilita', non filtro di trading.
- daily_equity_bfill_impact.py: bug bfill _daily_equity QUANTIFICATO -> non
  materiale (OOS invariato per costruzione, FULL DD 3.46->3.67 col fix, nessun
  verdetto gate a rischio). Lasciato documentato in TODO, niente fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:04:23 +00:00
Adriano Dal Pastro 51baed22e4 release: v1.1.23 2026-06-11 19:32:24 +00:00
Adriano Dal Pastro 12625b3315 feat(exec): gate TP_PHANTOM — il tocco TP va confermato dal book reale, non dal feed
Il feed testnet stampa wick che 'toccano' il TP intrabar senza che il prezzo
abbia mai scambiato al livello: il sim bookava +4% fantasma a bars_held=0 e
_real_close chiudeva A MERCATO una posizione col resting TP a zero fill
(-fee/spread a giro, 14 giri l'11-06). Ora StrategyWorker._tp_phantom sopprime
l'exit take_profit quando: tocco sim + resting LIMIT zero-fill + prezzo corrente
che non ha raggiunto il livello (il limit sul book reale e' l'oracolo del tocco).
Zero parametri (verita' d'esecuzione, non filtro di strategia); SL close-confirm
e max_bars restano attivi nello stesso tick; fill parziale/prezzo oltre il
livello/worker non eseguito/errore rete -> comportamento storico. Log TP_PHANTOM
dedup per barra + alert Telegram una tantum. 5 test nuovi (104 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:32:24 +00:00
Adriano Dal Pastro 15520dda70 release: v1.1.22 2026-06-11 19:18:55 +00:00
Adriano Dal Pastro 2ae6d7afe6 fix(report): conta i CHIUSI col flag win del worker (PnL REALE), non col net_return sim
Col real-truth ledger net_return resta il numero SIM diagnostico: sui TP fantasma
da spike-print testnet (ETH, bars_held=0: lo spike genera il segnale E tocca il TP
intrabar; il reale chiude a mercato pagando fee/spread) il report Telegram diceva
26 positivi / 0 negativi mentre il reale era 11/15. Ora collect() usa ev.win
(real-truth-aware, fallback nr>0 per eventi storici); i PnL per-motivo erano gia'
reali. TODO: nota monitor sul churn da spike (niente filtri anti-spike nei segnali:
artefatto testnet, real-truth gia' contabilizza giusto).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:18:55 +00:00
Adriano Dal Pastro b208417f2d release: v1.1.21 2026-06-11 13:29:25 +00:00
Adriano Dal Pastro 9d15506b05 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>
2026-06-11 13:29:14 +00:00
Adriano Dal Pastro ba5ec7bd6b docs: aggiorna strategie_attive.html + CLAUDE/TODO a v1.1.20 (XS01, real-truth, 19 sleeve)
- make_strategy_doc: nuova famiglia XSEC — card XS01 (meccanismo, dispersion-gate
  disp_min=0.0313, gate PORT06 OOS 10.07->10.37) con grafico book+gate e tabella
  per anno (engine canonico ungated); pesi aggiornati a 19 sleeve (5.69/5.26/2.94%);
  header con real-truth ledger e 4 book paper; bullet metodologia real-truth;
  ripuliti i riferimenti 'oggi' (EXIT-16 su DIP01, fix punto-10 SH01)
- strategie_attive.html rigenerato: v1.1.20, backtest canonico FULL Sharpe 7.34 /
  DD 3.46% — OOS Sharpe 10.07 / DD 1.48% (19 sleeve, XS01 incluso)
- CLAUDE.md: sezione XS01 (famiglia XSEC, gate, dispersion-gate live-only, FC01
  scartata), copertura reale 15/19, varianti gioco options/session/grid in struttura
- TODO.md: stato esecuzione aggiornato (6 pairs, XS01 paper, real-truth)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:00:48 +00:00
Adriano Dal Pastro 8d69a0cef5 feat(games): sessioni 2-3 Blind Traders (opzioni/session/grid) + gate PORT06 e tooling reset
- Gioco GRID TRADERS (sessione 3, regola STRATEGIA_GRIGLIA.md): grid_engine
  (backtest causale fee-aware della griglia geometrica), grid_brief (digest
  anonimo per dimensionare la griglia), grid_arena (torneo 100 agenti);
  diario docs/diary/2026-06-10-grid-traders-game3.md
- Gioco OPZIONI: options_engine (BS + skew fittato + DVOL storica),
  options_arena, opt_calibrate (superficie premi REALE da cerbero-bite)
- Gioco SESSION: session_engine/session_arena (pattern orari intraday)
- arena: vincolo GAME_NO_LIVE=1 (vieta pairs e fade zscore/breakout/momentum
  gia' live, coercizione a trend/ma_cross) + normalize del candidato PRIMA
  della valutazione nel hill-climb
- Gate: grid_game_gate (griglia ETH vincitrice vs PORT06, mark-to-market),
  pairs30m_gate (ETH/BTC 30m ridondante col 15m gia' deployato?)
- reset_flatten: flatten one-shot del conto testnet per il reset portafoglio
- .gitignore: data/portfolio_paper_stats/ (stato runtime sleeve paper-only)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:49:17 +00:00
Adriano Dal Pastro 8adf388e86 release: v1.1.20 2026-06-10 21:42:12 +00:00
Adriano Dal Pastro e25d5db6ad feat(xsec): dispersion-gate XS01 live (disp_min=0.0313) — Sharpe 3.46, PORT06 OOS 10.07->10.37; FC01 funding-carry scartata
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:42:12 +00:00
Adriano Dal Pastro d1180ef25b release: v1.1.19 2026-06-10 21:24:00 +00:00
Adriano Dal Pastro 61fcc29c5b fix(xsec): cast numpy->Python in _save/_close_book — int64 rompeva json.dumps e bloccava il runner in error-streak
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:23:52 +00:00
Adriano Dal Pastro 5e557fe998 release: v1.1.18 2026-06-10 12:23:58 +00:00
Adriano Dal Pastro cc39c36c08 feat(live): REAL-TRUTH ledger — capital aggiornato dal PnL dei fill reali, sim ridotto a diagnostica
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:23:52 +00:00
Adriano Dal Pastro d2948d582b release: v1.1.17 2026-06-09 21:38:12 +00:00
Adriano Dal Pastro a85289d7c7 feat(xsec): XS01 reversione cross-sectional (8 asset) -> PORT06 PAPER
Famiglia NUOVA trovata in sessione (dopo aver scartato trend/breakout/seasonal/
opzioni/funding come rumore): ogni 12h long i perdenti relativi / short i vincenti
su 8 asset, market-neutral. Scorrelata (~0) da pairs e fade -> diversificatore.

- engine canonico scripts/strategies/XS01_cross_sectional.py (no look-ahead, plateau
  OOS Sharpe 2-3.9, 5/5 anni+, edge concentrato 2025, cost-sensitive ~0.35% RT).
- src/live/xsec_worker.py CrossSectionalWorker: validate_xsec_worker == backtest ESATTO
  (4993/1427 trade). Mirror della cadenza engine (entry-to-entry = hold+1).
- gate PORT06: +XS01 -> OOS Sharpe 9.66->10.07, FULL DD 3.68->3.46 (OOS DD +0.17pp,
  risk-contrib 2.2%). xsec_port06_gate.py.
- wiring: _defs XSEC in PORT06 (19 sleeve, family XSEC), build_everything, runner
  kind=xsec, asset_days da supported (fix fetch alt anche per paper sleeves), paper.
- 8 gambe -> niente exec reale -> gira PAPER. Regression-lock 18->19, FULL 7.20->7.34,
  OOS 9.66->10.07. 93 test verdi. Diario 2026-06-09-xs01-cross-sectional.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:38:05 +00:00
Adriano Dal Pastro d3dab57532 feat(games): sessione 2 del gioco Blind Traders su timing diversi (30m/2h/4h)
- engine: resampling (_RESAMPLE) per 30m/2h/4h/1d + TF_BPM esteso -> nuovi timing.
- arena/run_game: TIMEFRAMES estesi, out_name e GAME_SPECS_DIR/GAME_OUT parametrizzati
  (game 1 non sovrascritto).
- Risultato: 10 finalisti tutti 30m pairs ETH/BTC (vincitore #36: OOS Sh 12.3, 43 tr/mese).
  La regola >=10 trade/mese filtra i tf lunghi (4h: 4/33 qualificati). Conferma la
  frontiera frequenza-vs-edge. Diario 2026-06-09-blind-traders-game2.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:01:34 +00:00
Adriano Dal Pastro abc1c1cb80 docs(diary): analisi robustezza ETH/BTC 15m — robusto come segnale, fragile sui costi
Plateau 16/16, 8/9 anni+, OOS Sh 17.6, non-artefatto-flat (83% Sharpe), corr 0.37.
MA fee-sweep: il 15m va negativo a ~4x i costi mentre il 1h regge ~6x (frequenza 5x,
margine di costo sottile). Conferma mezza-size e NO allo swap; giudice = ledger reale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:34:08 +00:00
Adriano Dal Pastro 1a9032feaa docs(diary): statistiche per-anno delle 15 sleeve attive in real
Snapshot post-BLEND 15m (v1.1.16): PnL%/n-trade per anno + maxDD per ogni sleeve
che esegue reale (6 fade + DIP01 + 6 pairs + 2 SH01), engine path-live, fee incluse.
+ aggregato PORT06 per anno. Esclusi i 3 book PAPER (TR01/ROT02/TSM01).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:12:53 +00:00
Adriano Dal Pastro 90c4726a31 docs: aggiorna CLAUDE.md + strategie_attive.html col BLEND ETH/BTC 15m
- CLAUDE.md: default PORT06 (FULL 7.20/OOS 9.66, 18 sleeve), paragrafo BLEND 15m
  flat-skip (origine gioco Blind Traders, gate, validazione, caveat slippage),
  copertura reale ~83% (6 pairs), scripts/games/ + pairs15m_* nella struttura.
- make_strategy_doc.py: header dinamico dal backtest, colonna ETH/BTC·15m nella
  tabella pairs, card PR01 col blend, conteggi sleeve aggiornati -> rigenerato HTML.
- pairs_sim_flat: ritorna yearly_n (parita' con pairs_sim, usato dal doc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:59:36 +00:00
Adriano Dal Pastro be72b157cf release: v1.1.16 2026-06-09 11:48:26 +00:00
Adriano Dal Pastro d25d897fd1 feat(pairs): attiva ETH/BTC 15m flat-skip in PORT06 (BLEND, mezza size)
Origine: gioco "Blind Traders" (100 agenti ciechi su BTC/ETH anonimizzati) ->
vincitore = spread ETH/BTC reversion a 15m. Testato sul serio col gate PORT06:
non duplicato (corr 1h vs 15m = 0.37), robusto (16/16 celle Sharpe>1), edge NON
artefatto delle candele flat ETH 15m (filtrandole resta l'83% dello Sharpe).

Percorso live costruito e validato:
- pairs_research.pairs_sim_flat: engine generalizzato con exit LIVE-REALIZABLE
  (arma exit_ready, esce alla 1a barra pulita); regression-lock a pairs_sim.
- PairsWorker: flat_skip + exit_ready + rilevamento flat da OHLC (1h byte-exact).
- runner: fetch diretto dei timeframe sub-orari + override position_size per-sleeve.
- validate_worker_pairs: replay worker == backtest a 15m (8452 vs 8453 trade).
- _defs/build_everything: sleeve PR_ETHBTC_15M (mezza size, pos 0.10) -> PORT06
  FULL 6.43->7.20, OOS 8.58->9.66, DD giu'. Rischio bilanciato col 1h.
- smoke live: Cerbero serve candele 15m fresche; worker ticca.

Diari docs/diary/2026-06-09-*. Caveat slippage: mezza size = blend-tilt prudente.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:48:15 +00:00
73 changed files with 7480 additions and 401 deletions
+4
View File
@@ -18,6 +18,7 @@ data/processed/
notebooks/.ipynb_checkpoints/ notebooks/.ipynb_checkpoints/
data/paper_trades/ data/paper_trades/
data/portfolio_paper/ data/portfolio_paper/
data/portfolio_paper_stats/
data/portfolios/ data/portfolios/
# stato locale di tooling (non condiviso) # stato locale di tooling (non condiviso)
@@ -32,3 +33,6 @@ data/regime/dispersion_features.parquet
# storico catena opzioni importato da cerbero-bite (rigenerabile: options_fetcher.py) # storico catena opzioni importato da cerbero-bite (rigenerabile: options_fetcher.py)
data/options/ data/options/
data/_reset_backup/ data/_reset_backup/
# game artifacts (log/json di scripts/games e gate)
data/games/
+87 -8
View File
@@ -58,7 +58,14 @@ scripts/analysis/ → ricerca/validazione OOS fee-aware (strategy_research,
regime_fetcher.py + regime_lab.py (DVOL/funding/feature regime per la ricerca); regime_fetcher.py + regime_lab.py (DVOL/funding/feature regime per la ricerca);
exit_lab.py + exit_policies/ (harness ricerca exit: cache segnali, train/OOS); exit_lab.py + exit_policies/ (harness ricerca exit: cache segnali, train/OOS);
options_fetcher.py + options_chain.py (storico opzioni REALE da cerbero-bite); options_fetcher.py + options_chain.py (storico opzioni REALE da cerbero-bite);
option_overlay_lab.py (overlay opzioni prezzato; mr02eth_port06_gate.py = gate swap-sleeve) option_overlay_lab.py (overlay opzioni prezzato; mr02eth_port06_gate.py = gate swap-sleeve);
pairs15m_*.py (gate/flatcheck/smoke ETH/BTC 15m -> sleeve PR_ETHBTC_15M)
scripts/games/ → gioco "Blind Traders": 100 agenti ciechi su BTC/ETH anonimi (engine.py,
arena.py epoche+cull, agent_brief.py digest, run_game.py). Origine del BLEND 15m.
Varianti: options_* (strutture in opzioni, BS+skew+DVOL; opt_calibrate da
cerbero-bite), session_* (pattern orari), grid_* (griglie da STRATEGIA_GRIGLIA.md;
gate scripts/analysis/grid_game_gate.py). arena: GAME_NO_LIVE=1 vieta le
strategie gia' deployate (pairs, fade zscore/breakout/momentum)
scripts/bump_version.py, scripts/deploy.sh → versionamento e deploy (bump+commit+rebuild) scripts/bump_version.py, scripts/deploy.sh → versionamento e deploy (bump+commit+rebuild)
VERSION → versione semver (cotta nell'immagine, +1 ad ogni deploy) VERSION → versione semver (cotta nell'immagine, +1 ad ogni deploy)
strategies.yml → config multi-strategy paper trader strategies.yml → config multi-strategy paper trader
@@ -383,11 +390,33 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale
## Portafogli ## 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}/`). - Un `Portfolio` è un oggetto di prima classe (`src/portfolio/`) con definizione (sleeve + schema pesi) e due facce sulla **STESSA** definizione: `.backtest()` (riusa il builder unico di `sleeves.py` → parità esatta con `report_families`) e live (`PortfolioRunner`: capitale pool condiviso, sizing per peso, ribilancio giornaliero, ledger aggregato in `data/portfolios/{code}/`).
- **Schemi peso:** `equal` (default), `cap` (tetto per famiglia, es. pairs 33% — config raccomandata), `inverse_vol`, `cluster_rp` (equal fra cluster naturali poi inverse-vol intra-cluster), `manual`. Definiti in `weighting.py`; la chiave cap è la famiglia (PAIRS/FADE/HONEST/SHAPE/TSM). - **Schemi peso:** `equal` (default), `cap` (tetto per famiglia, es. pairs 33% — config raccomandata), `inverse_vol`, `cluster_rp` (equal fra cluster naturali poi inverse-vol intra-cluster), `manual`. Definiti in `weighting.py`; la chiave cap è la famiglia (PAIRS/FADE/HONEST/SHAPE/TSM/XSEC).
- **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. - **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. Col BLEND ETH/BTC 15m (2026-06-09, v1.1.16, vedi sotto): FULL 7.20 / 3.68%, OOS 9.66 / 1.31% — 18 sleeve. **Con XS01 (2026-06-09, vedi sotto): OOS Sharpe 10.07, FULL DD 3.46 — 19 sleeve** (pool live real-only 15; i 4 book multi-asset TR01/ROT02/TSM01/XS01 girano in statistica).
- **XS01 — Cross-Sectional Reversion (ATTIVO LIVE 2026-06-09; dispersion-gate v1.1.20, 2026-06-10).**
Famiglia nuova XSEC, distinta da pairs (pairwise) e fade (single-asset): ogni `hold=12` ore
classifica 8 crypto (UNIVERSE8) per rendimento a `lb=48` ore e va long i perdenti relativi /
short i vincenti (peso ∝ (ret media cross-section)), market-neutral gross 1, fee 0.20%
RT/book (turnover 2). Scorrelato ~0 da pairs e fade. FULL Sharpe ~3.3, plateau lb 12-72 ×
hold 6-24; cost-sensitive (muore a ~0.35% RT/book). Gate PORT06: OOS Sharpe 9.66→10.07,
FULL DD 3.68→3.46. **Dispersion-gate** (`disp_min=0.0313` = p50 TRAIN): entra solo se la
std cross-section del momentum ≥ soglia — diagnostica monotona TRAIN+OOS, plateau p30-p70,
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
→ il live farà meglio del backtest). 8 gambe → PAPER (niente esecuzione reale a €2k).
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` /
`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-*`.
- **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,
@@ -407,9 +436,9 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale
single-leg con exit a orizzonte H=12 — niente TP/SL, `_place_real_tp` no-op e `_real_close` chiude single-leg con exit a orizzonte H=12 — niente TP/SL, `_place_real_tp` no-op e `_real_close` chiude
tutto market reduce-only, disaster-bracket on-book come unica protezione di coda (v1.1.13). Motivo: tutto market reduce-only, disaster-bracket on-book come unica protezione di coda (v1.1.13). Motivo:
SH01 è il diversificatore più decorrelato (senza i 5 sleeve PAPER il DD del portafoglio sale SH01 è il diversificatore più decorrelato (senza i 5 sleeve PAPER il DD del portafoglio sale
3.96→5.35%). Copertura reale ora ~81% (fade+DIP+pairs+SH01); restano simulati TR01/ROT02/TSM01 3.96→5.35%). Copertura reale ora 15 sleeve su 19 (fade+DIP+**6 pairs incl. ETH/BTC 15m**+SH01);
(book multi-asset, bloccati dal capitale: rumore arrotondamento 20-30% a €2k, serve ~€20k). restano simulati TR01/ROT02/TSM01/XS01 (book multi-asset, bloccati dal capitale: rumore
Diari `docs/diary/2026-06-08-pairs-executor.md`. arrotondamento 20-30% a €2k, serve ~€20k). Diari `docs/diary/2026-06-08-pairs-executor.md`.
- **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. - **ESECUZIONE REALE — shadow (v1.0.3, 2026-06-03).** I **6 fade** (MR01/MR02/MR07 × BTC/ETH) eseguono ordini **REALI su Deribit testnet** accanto al fill simulato (*shadow*: sim + reale in parallelo, il sim resta la verità che guida le decisioni). `src/live/execution.py` `ExecutionClient`: `open` (market) + `close_amount` (market **reduce-only della SOLA quota del worker** — i 3 fade BTC condividono lo strumento e le posizioni si nettano per conto, quindi NON si usa `close_position` che flatterebbe le quote altrui); **verifica l'esecuzione sul TRADE** (order_id in `get_trade_history`, non sulla size netta aggregata); **fee REALI lette dai `trades[]`**. Strumenti = **lineari USDC** (`BTC_USDC`/`ETH_USDC-PERPETUAL`, amount nel base-coin, step 0.0001/0.001): scelti perché il **payoff lineare == matematica del backtest** (l'inverse `*-PERPETUAL` introdurrebbe una base 1/prezzo) e fee/PnL sono in USDC. Lo `StrategyWorker` tiene un **ledger reale parallelo** (`real_capital`, persistito) e logga `REAL_OPEN`/`REAL_CLOSE` col confronto **slippage** (prezzo sim vs eseguito) e **fee** (assunta 0.10% vs reale). Config: `portfolios.yml``overrides.execution {enabled, sleeves:[MR01,MR02,MR07,DIP01], instruments:{BTC:BTC_USDC-PERPETUAL, ETH:ETH_USDC-PERPETUAL}}` (**DIP01 aggiunto il 2026-06-04**: stesso wiring single-leg, TP resting incluso); pairs/rotation/tsmom/shape restano **simulati** (pairs richiede un executor a 2 gambe con gestione leg-risk; shape non ha TP). **Fee reali misurate = 0.05%/lato = 0.10% RT** (== assunto del backtest, su ETH; BTC inverse era ~0.094%). **Alert Telegram:** `REAL_EXEC_LIVE` (primo ordine reale verificato per worker) + `REAL_OPEN_FAIL`. Smoke (testnet, €0): `scripts/analysis/live_exec_smoke.py` (layer: ordine→verifica→fee) e `live_shadow_smoke.py` (catena worker open/close). **Capitale live portato a 2000** (notional fade ~$35) per ridurre il rumore di arrotondamento su BTC (step lineare ~$6.7). NB: ledger reale ≠ ledger sim — i worker già in posizione sim a un restart non hanno quota reale corrispondente; lo shadow reale parte pulito dalla prossima apertura.
- **Disaster-bracket on-book + alert outage (v1.1.4, 2026-06-07).** A ogni `REAL_OPEN` dei fade - **Disaster-bracket on-book + alert outage (v1.1.4, 2026-06-07).** A ogni `REAL_OPEN` dei fade
eseguiti il worker piazza uno **STOP_MARKET reduce-only a ~30%** dall'ingresso (trigger sul mark, eseguiti il worker piazza uno **STOP_MARKET reduce-only a ~30%** dall'ingresso (trigger sul mark,
@@ -422,13 +451,63 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale
di parità della stessa tornata (v1.1.3): TR01 fee×leva + forming-bar TR01/Pairs + WARN di parità della stessa tornata (v1.1.3): TR01 fee×leva + forming-bar TR01/Pairs + WARN
`PANEL_SHORT` su TSM01/ROT02; `hourly_report` ora mostra i multi-asset (sezione MULTI-ASSET). `PANEL_SHORT` su TSM01/ROT02; `hourly_report` ora mostra i multi-asset (sezione MULTI-ASSET).
Diario `docs/diary/2026-06-07-sweep-fixes.md`. Diario `docs/diary/2026-06-07-sweep-fixes.md`.
- **VERITÀ CONTABILE su netting di conto (v1.1.24, 2026-06-11, da audit live).** Il modello
"quote per-worker con reduce-only" si rompe quando worker dello stesso strumento hanno
direzioni OPPOSTE (pairs long ETH vs fade short ETH): Deribit cappa/respinge i reduce-only
in silenzio. L'audit ha trovato: close fillato 0.078 ma bookato 0.105 (`Fill.amount` era il
RICHIESTO), 3 gambe pairs mai eseguite col PnL sim sommato al ledger reale (gamba orfana sul
conto, ETH/SOL di fatto short nudo), e il conto short 0.027 ETH più dei libri (riallineato a
mano). Fix: (1) `Fill.filled_amount` (fonte `order.filled_amount`) usato da TUTTI i ledger;
(2) `REAL_CLOSE_PARTIAL` (log+alert) quando il close filla meno del residuo; (3) pairs:
PnL bookato SOLO per le gambe verificate, gamba respinta → `orphan_legs` persistito +
alert `PAIR_LEG_ORPHAN`, `applied` solo con ENTRAMBE le gambe (altrimenti sim_fallback
dichiarato); (4) `REAL_DIVERGENCE` anche su jsonl (prima solo Telegram); (5) runner:
tick isolato per-worker (un'eccezione non salta gli altri; alert `WORKER_ERROR_STREAK` a 5).
**RISOLTO in v1.1.25 col NETTING delle chiusure**: `close_amount` tenta il reduce-only
(sicurezza storica: un bug di stato filla 0) e riesegue il RESIDUO cappato/respinto in
**market puro** — che muove il conto esattamente del delta del libro, cioè netta contro le
quote opposte → niente più gambe orfane/close cappati per costruzione (copre anche i pairs
via `close_pair`). Il chiamante riceve UN Fill combinato (prezzo medio pesato, fee sommate,
`notes` con 'netting'); evento `NET_CLOSE` (log+Telegram) a ogni fallback. La sicurezza
persa sul residuo è coperta dal **reconciler orario** (`reconcile_account.py`, cron host
:40, alert `ACCOUNT_DRIFT`): conto vs Σ libri+orfani, tolleranza 1.5×step, anti-race.
`orphan_legs`/`REAL_CLOSE_PARTIAL` restano come ultima difesa (se fallisce anche il market
puro). Test `tests/portfolio/test_netting_close.py`.
- **TP_PHANTOM — il tocco TP va confermato dal book reale (v1.1.23, 2026-06-11).** Il feed
testnet stampa wick anomali che (a) generano segnali fade su ETH e (b) "toccano" il TP
intrabar della stessa barra: il sim bookava +4% fantasma a bars_held=0 e `_real_close`
chiudeva A MERCATO una posizione il cui resting TP non aveva mai fillato (fee/spread a
giro, 14 giri l'11-06, report Telegram 26/0 vs reale 11/15 — fix conteggio in v1.1.22).
Gate in `StrategyWorker._tp_phantom` (zero parametri, verita' d'esecuzione, NON un filtro
di strategia): tocco sim + **resting LIMIT a zero fill** + prezzo corrente che non ha
raggiunto il livello → exit SOPPRESSA (il limit sul book reale e' l'oracolo: se il prezzo
avesse scambiato li', avrebbe fillato); SL close-confirm e max_bars restano attivi.
Fill (anche parziale) o prezzo oltre il livello o worker non eseguito → comportamento
storico. Fail-open su errori di rete. Log `TP_PHANTOM` dedup per barra + alert Telegram.
Test `tests/portfolio/test_tp_phantom.py`.
- **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`. - **TP reale = LIMIT reduce-only AL LIVELLO (2026-06-04).** Misurati +235 bps di slippage medio sulle uscite take-profit market-on-poll (sim esce al livello intrabar, il reale chiudeva al poll post-rimbalzo: sim +11.85 vs reale +0.62 USD sui primi 7 close). Fix: a ogni `REAL_OPEN` il worker piazza un **limit reduce-only al TP** (`ExecutionClient.place_tp_limit`, prezzo quantizzato al tick, SOLA quota del worker) → `REAL_TP_RESTING`; a ogni chiusura sim `_real_close` **cancella il resting → riconcilia i fill (anche parziali) via `get_trade_history` per order_id → market reduce-only solo del residuo** → ledger su prezzo combinato. `real_tp_order_id` persistito in `status.json` (resume-safe). Lo **SL resta market-on-poll** (deliberato: i trigger Deribit generano un nuovo order_id al trigger → fill non verificabile per order_id; e sul SL il rimbalzo lavora a favore). Fill da resting = fee **maker ~0%**. Smoke: `live_shadow_smoke.py` (2 scenari, testnet). Diario `docs/diary/2026-06-04-shadow-divergence.md`.
- **position_size per-famiglia (2026-06-07).** `portfolios.yml` accetta `position_size_family` - **position_size per-famiglia (2026-06-07).** `portfolios.yml` accetta `position_size_family`
(chiave = `weighting.family_of`); plumbing `runner.pos_for_spec`. **PAIRS a 0.20** (esposizione (chiave = `weighting.family_of`); plumbing `runner.pos_for_spec`. **PAIRS a 0.20** (esposizione
0.40 ≈ il validato 0.45): la famiglia è senza stop e col globale 0.5×lev2 girava a ~2.2x il 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`
(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
`_close_position`/`_close` applicano il reale al ledger; il sim resta SOLO diagnostica nel log
CLOSE (`pnl_source`/`sim_pnl`/`real_pnl`). Fallback al sim dichiarato (`pnl_source=sim_fallback`)
solo se il trade reale non è mai esistito/fillato (REAL_OPEN_FAIL/leg-fail). Equity → pesi →
allocazioni → notional derivano così dai soldi veri sul conto (il notional reale era GIÀ la
formula sim `capital·ps·lev`; il gap storico sim/reale era contabile: ledger separati + spike
print delle candele testnet che il sim bookava e il reale no). Le DECISIONI (entry/exit) restano
guidate dal feed; i multi-asset (TR01/ROT02/TSM01/XS01) restano sim per costruzione. Test:
`tests/portfolio/test_real_truth.py`. Diario `docs/diary/2026-06-10-real-truth-ledger.md`.
- **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate); fedele al backtest daily-rebalanced entro il turnover infragiornaliero. - **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate); fedele al backtest daily-rebalanced entro il turnover infragiornaliero.
## Multi-Strategy Paper Trader ## Multi-Strategy Paper Trader
+212
View File
@@ -0,0 +1,212 @@
# Strategia di Grid Trading — Versione Corretta
> Documento di specifica della strategia. Descrive *cosa* deve fare il bot e *perché*,
> non l'implementazione. È il riferimento da cui partire per riscrivere il codice in
> modo sicuro e testabile.
---
## 1. Obiettivo
Estrarre profitto dalla **volatilità di un asset all'interno di un intervallo di prezzo
(range)**, comprando automaticamente quando il prezzo scende e vendendo quando risale,
secondo livelli predefiniti (la "griglia").
La griglia **non prevede** la direzione del mercato: monetizza le oscillazioni. Funziona
quando il prezzo oscilla; perde quando il prezzo prende un trend deciso. Tutta la
progettazione che segue serve a massimizzare il primo caso e a limitare i danni nel
secondo.
---
## 2. Principi corretti (cosa cambia rispetto al bot originale)
| Aspetto | Bot originale (sbagliato) | Versione corretta |
|---|---|---|
| Asset | Shitcoin illiquida (LAND) | Coppia liquida (es. ETH/USDT, BNB/USDT) |
| Passo griglia | Assoluto in USDT (`GRID_STEP=3`) | **Percentuale** sul prezzo |
| Livelli | Mobili, inseguono il prezzo | **Fissi** dentro un range definito |
| Protezione perdite | Nessuna | **Stop-loss** sotto il range + **take-profit** sopra |
| Slippage | `amountOutMin = 0` (nessuna) | Calcolato da `getAmountsOut` tolleranza |
| Break-even fee | Ignorato | Passo griglia **> costo round-trip** |
| Capitale | Tutto, senza limiti | Allocazione fissa, suddivisa per livello |
| Chiave privata | In chiaro nel `.env` | Keystore cifrato o input a runtime |
| Validazione | Nessuna | **Backtest** + **testnet** prima del capitale reale |
---
## 3. Definizione della griglia
### 3.1 Parametri di ingresso
| Parametro | Significato | Esempio |
|---|---|---|
| `PAIR` | Coppia da tradare (base/quote) | `ETH/USDT` |
| `RANGE_LOW` | Estremo inferiore del range | `2800` |
| `RANGE_HIGH` | Estremo superiore del range | `3400` |
| `GRID_LEVELS` | Numero di livelli nella griglia | `12` |
| `CAPITAL_QUOTE` | Capitale totale in quote (USDT) | `1200` |
| `STOP_LOSS` | Prezzo sotto cui il bot chiude tutto e si ferma | `2650` |
| `TAKE_PROFIT` | Prezzo sopra cui il bot chiude tutto e si ferma | `3550` |
| `SLIPPAGE_BPS` | Tolleranza slippage in basis point (50 = 0,5%) | `50` |
| `FEE_BPS` | Fee del DEX in basis point (PancakeSwap = 25) | `25` |
### 3.2 Costruzione dei livelli (griglia geometrica)
I livelli vanno spaziati in **percentuale**, non in valore assoluto. Una griglia
geometrica mantiene lo stesso rendimento percentuale per ogni gradino, indipendentemente
dal prezzo.
```
ratio = (RANGE_HIGH / RANGE_LOW) ^ (1 / GRID_LEVELS)
livello[i] = RANGE_LOW * ratio ^ i per i = 0 .. GRID_LEVELS
```
Esempio (`RANGE_LOW=2800`, `RANGE_HIGH=3400`, `GRID_LEVELS=12`):
`ratio ≈ 1,0163` → passo di circa **1,63% per gradino**.
### 3.3 Capitale per livello
```
quote_per_livello = CAPITAL_QUOTE / GRID_LEVELS
```
Ogni livello di acquisto impegna `quote_per_livello`. Il capitale è suddiviso in anticipo:
il bot **non** può comprare più di quanto allocato, e non scende mai sotto zero.
---
## 4. Vincolo di break-even (regola anti-fee)
Una griglia con passi troppo fitti perde: le fee di ogni round-trip (compra + vendi) si
mangiano il profitto. **Il passo percentuale della griglia deve superare il costo totale
di un round-trip.**
```
costo_round_trip ≈ 2 * (FEE_BPS + SLIPPAGE_BPS) / 10000 (in frazione)
passo_griglia = ratio - 1
VINCOLO: passo_griglia > costo_round_trip * margine_sicurezza (margine ≥ 1,5)
```
Esempio con `FEE_BPS=25`, `SLIPPAGE_BPS=50`:
`costo_round_trip ≈ 2 * (25+50)/10000 = 1,5%`. Con margine 1,5 → il passo deve essere
**≥ 2,25%**. Se la griglia geometrica dà 1,63%, **è troppo fitta**: vanno ridotti i
`GRID_LEVELS` o allargato il range finché il vincolo è rispettato. Il bot deve rifiutarsi
di partire se il vincolo non è soddisfatto.
---
## 5. Logica operativa
### 5.1 Inizializzazione
1. Validare i parametri: `RANGE_LOW < prezzo_attuale < RANGE_HIGH`, vincolo di break-even
rispettato, capitale disponibile sul wallet.
2. Verificare la coppia: liquidità sufficiente, contratto non honeypot (controllo
obbligatorio, non opzionale), token con decimali noti.
3. Costruire i livelli (§3.2) e marcare ognuno come `attivo`/`riempito`.
4. Allocare il capitale (§3.3).
### 5.2 Ciclo principale
A ogni tick (es. ogni nuovo blocco, o ogni N secondi):
```
prezzo = prezzo_corrente(PAIR)
# --- guardie di uscita: hanno priorità su tutto ---
SE prezzo <= STOP_LOSS:
vendi_tutta_la_posizione() # con slippage protetto
ferma il bot, log "STOP-LOSS"
SE prezzo >= TAKE_PROFIT:
vendi_tutta_la_posizione()
ferma il bot, log "TAKE-PROFIT"
# --- logica griglia ---
PER ogni livello L:
SE prezzo attraversa L verso il basso E L non è ancora riempito:
compra quote_per_livello # con amountOutMin protetto
marca L come riempito
SE prezzo attraversa L verso l'alto E il livello sotto è riempito:
vendi la quantità di quel livello # con amountOutMin protetto
marca quel livello come libero
```
I livelli sono **fissi** (calcolati una volta), non inseguono il prezzo. Questo rende il
comportamento prevedibile e backtestabile.
### 5.3 Calcolo `amountOutMin` (protezione slippage — obbligatoria)
Mai passare `0`. Prima di ogni swap:
```
atteso = router.getAmountsOut(amountIn, path)[ultimo]
amountOutMin = atteso * (10000 - SLIPPAGE_BPS) / 10000
```
Se la transazione non rientra nella tolleranza, deve **fallire** (revert), non eseguire a
qualsiasi prezzo.
---
## 6. Gestione del rischio
1. **Stop-loss obbligatorio** sotto `RANGE_LOW`. È la differenza tra "strategia" e
"gambling": senza stop-loss un trend ribassista svuota il wallet.
2. **Take-profit** sopra `RANGE_HIGH` per chiudere quando il prezzo esce dal range al
rialzo (la griglia avrebbe già venduto tutto; il take-profit evita di restare esposti).
3. **Capitale segregato**: usare un wallet dedicato, con solo il capitale destinato alla
strategia. Mai il wallet principale.
4. **Limite di gas e prezzo gas** ragionevoli, ricalcolati dinamicamente (no valori fissi
obsoleti).
5. **Kill-switch manuale**: comando per fermare il bot e liquidare in qualsiasi momento.
6. **Idempotenza/recupero**: se il bot si riavvia, deve ricostruire lo stato dei livelli
(riempiti/liberi) dal saldo on-chain, non ripartire da zero.
---
## 7. Validazione prima del capitale reale
Nessun fondo reale prima di aver superato, in ordine:
1. **Backtest** su dati storici della coppia (almeno alcuni mesi, includendo sia fasi
laterali sia un trend marcato), misurando: PnL netto **dopo** fee e slippage,
max drawdown, numero di trade, comportamento allo stop-loss.
2. **Paper trading / simulazione** in tempo reale, senza eseguire ordini veri.
3. **Testnet** (BSC testnet) con la stessa logica e router di test, per verificare
l'esecuzione on-chain end-to-end.
4. **Mainnet con capitale minimo** (es. l'equivalente di pochi euro) per la prima
settimana, poi scalare solo se i risultati combaciano col backtest.
---
## 8. Quando NON usare questa strategia
- Asset illiquido o a rischio rug-pull/honeypot.
- Mercato in trend forte e prolungato (la griglia perde: lo stop-loss limita il danno ma
non genera profitto).
- Passo griglia che non rispetta il vincolo di break-even (§4).
- Capitale che non puoi permetterti di perdere.
---
## 9. Parametri di esempio (configurazione di partenza prudente)
```
PAIR = BNB/USDT # coppia liquida su PancakeSwap
RANGE_LOW = 580
RANGE_HIGH = 720
GRID_LEVELS = 8 # passo ≈ 2,7% > break-even
CAPITAL_QUOTE = 400 # USDT, su wallet dedicato
STOP_LOSS = 545 # ~6% sotto RANGE_LOW
TAKE_PROFIT = 760 # ~5,5% sopra RANGE_HIGH
SLIPPAGE_BPS = 50 # 0,5%
FEE_BPS = 25 # PancakeSwap v2
```
Verifica break-even: passo ≈ 2,7% > `1,5 × (2×0,75%) = 2,25%`
---
*Questo documento descrive la strategia. L'implementazione (ethers v6, gestione sicura
della chiave, calcolo slippage, stato persistente, backtester) va sviluppata a parte,
con test, e validata secondo §7 prima di qualsiasi capitale reale.*
+1 -1
View File
@@ -1 +1 @@
1.1.15 1.1.25
+51 -25
View File
@@ -1,17 +1,18 @@
# TODO — prossimi passi # TODO — prossimi passi
> Raccolta dei follow-up aperti. Aggiornato 2026-06-08 (pairs reali ATTIVI v1.1.12; > Raccolta dei follow-up aperti. Aggiornato 2026-06-11 (XS01 dispersion-gate live v1.1.20;
> roadmap improvement-sweep 10/10 completata). > real-truth ledger attivo; reset portafoglio 2026-06-10).
## Stato esecuzione per sleeve (REALE vs SIMULATO) ## Stato esecuzione per sleeve (REALE vs SIMULATO)
**REALE (shadow su Deribit testnet) — il "vero" risultato monetario:** **REALE (shadow su Deribit testnet, REAL-TRUTH: il ledger segue i fill reali) — 15 sleeve:**
- 6 fade (MR01/MR02/MR07 × BTC/ETH) + DIP01_BTC → single-leg, v1.0.3+ - 6 fade (MR01/MR02/MR07 × BTC/ETH) + DIP01_BTC → single-leg, v1.0.3+
- 5 pairs PR01 (ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL) → 2 gambe, v1.1.12 (2026-06-08) - 6 pairs PR01 (ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL + ETH/BTC 15m) → 2 gambe,
v1.1.12 (2026-06-08); il 15m dal v1.1.16 (2026-06-09)
- SH01 × BTC/ETH → single-leg, exit a orizzonte (no TP/SL), v1.1.13 (2026-06-08) - SH01 × BTC/ETH → single-leg, exit a orizzonte (no TP/SL), v1.1.13 (2026-06-08)
**SIMULATO (paper, NON esegue reale) — categoria distinta:** **SIMULATO (paper, NON esegue reale) — 4 book multi-asset, fuori dal capitale-pool:**
- TR01 / ROT02 / TSM01 → book multi-asset, bloccati dal CAPITALE (rumore arrotondamento) - TR01 / ROT02 / TSM01 / XS01 → bloccati dal CAPITALE (rumore arrotondamento)
## Esecuzione reale — pezzi mancanti ## Esecuzione reale — pezzi mancanti
@@ -20,7 +21,7 @@
(_place_real_tp no-op senza TP, _real_close chiude tutto market reduce-only a orizzonte). (_place_real_tp no-op senza TP, _real_close chiude tutto market reduce-only a orizzonte).
Bastava accettare kind 'ml' in _exec_for + SH01 in execution.sleeves. Disaster-bracket Bastava accettare kind 'ml' in _exec_for + SH01 in execution.sleeves. Disaster-bracket
on-book = unica protezione di coda. Test SH01 open/close reale OK. on-book = unica protezione di coda. Test SH01 open/close reale OK.
- [ ] **Multi-asset in reale** (SIMULATO: TR01/ROT02/TSM01) — **SBLOCCATO DA CAPITALE ≥ ~€20k**. - [ ] **Multi-asset in reale** (SIMULATO: TR01/ROT02/TSM01/XS01) — **SBLOCCATO DA CAPITALE ≥ ~€20k**.
Strumenti USDC tutti esistenti (BNB/DOGE/XRP inclusi), ma a €2k il rumore di arrotondamento Strumenti USDC tutti esistenti (BNB/DOGE/XRP inclusi), ma a €2k il rumore di arrotondamento
per-asset è 20-30% (DOGE/XRP/SOL step grandi in $, esposizione frammentata su 5-8 asset) → per-asset è 20-30% (DOGE/XRP/SOL step grandi in $, esposizione frammentata su 5-8 asset) →
l'esecuzione reale misurerebbe il rumore dei lotti, non la strategia. Serve un l'esecuzione reale misurerebbe il rumore dei lotti, non la strategia. Serve un
@@ -33,27 +34,34 @@
- [ ] **Valutare aumento capitale a €5.000-10.000** per togliere il rumore di arrotondamento. - [ ] **Valutare aumento capitale a €5.000-10.000** per togliere il rumore di arrotondamento.
A €2.000 il rumore BTC per trade è ~2.4% (morde); soglie: ~€4.800 (<1%), ~€9.500 (<0.5%, A €2.000 il rumore BTC per trade è ~2.4% (morde); soglie: ~€4.800 (<1%), ~€9.500 (<0.5%,
"punto dolce"), ~€19.000 (trascurabile). Le commissioni invece sono % e neutre al capitale. "punto dolce"), ~€19.000 (trascurabile). Le commissioni invece sono % e neutre al capitale.
Riguarda solo i 7 single-leg che eseguono reale; pairs/SH01 sono simulati. Col real-truth ledger il rumore di arrotondamento entra DIRETTAMENTE nell'equity → più
capitale = ledger più fedele alla strategia. Riguarda i 15 sleeve che eseguono reale.
## 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 - [ ] **ancora bfill di `_daily_equity`** — QUANTIFICATO 2026-06-11 (`daily_equity_bfill_impact.py`):
stessa famiglia di bug. **NON materiale, lasciare documentato**. PORT06 OOS invariato per costruzione (il bias vive
- [ ] **ancora bfill di `_daily_equity`**: la serie a punti-trade reindexata su IDX àncora il primo in testa alla serie, pre-SPLIT; ΔSharpe +0.001, ΔDD 0.000); FULL DD leggermente OTTIMISTICO
valore della finestra al PRIMO trade in-finestra, non al capitale portato avanti. Tocca le (3.46→3.67 col fix: il primo trade DIP01 2021, 3.75%, è nascosto dal bfill). Nessun verdetto
metriche canoniche di TUTTI gli sleeve a punti-trade (honest/pairs/tsm). Valutare se di gate a rischio (bias identico nei due bracci A-vs-B, si cancella). Unica eccezione OOS:
correggere OVUNQUE in un colpo (cambierebbe i numeri canonici di riferimento). Scoperto nel TSM01 (primo punto equity post-SPLIT, 0.014 Sh). Correggere SOLO in un eventuale refactor
gate DIP01 (parità inizialmente fallita per questo). del builder daily, OVUNQUE in un colpo (~12 file di gate replicano la convenzione) e
- [ ] **convenzione TR01 worker vs reference**: il replay `validate_honest_workers` dà TR01 worker ri-baselinando i canonici nello stesso commit. CAVEAT per gate futuri: finestre IDX che
-44% vs reference +42% (identico pre/post fix di oggi). È la divergenza capitale-unico partono a metà storia amplificano il bug → usare lì la convenzione carry-forward corretta.
(`mean(rets)` solo sugli asset in posizione) vs media-equity 1/N del reference. Indagare: - [x] ~~convenzione TR01 worker vs reference~~ — ERA UN BUG, FIXATO (2026-06-11): `mean(rets)`
sospetto che `mean(rets)` sovrappesi gli asset in posizione quando il paniere è parzialmente sui soli asset in posizione sovrappesava N/k a paniere parziale (con 1 long: 0.45 del
investito. capitale invece di 0.09). Fix: `sum(rets)/len(universe)` (equal-weight 1/N canonico).
- [ ] **engine duplicato nei 3 gate** `*_port06_impact.py` (exit16/trendmax/dip01): `build_trades_variant` Replay post-fix: +32% vs reference +42% (residuo = convenzione capitale-unico vs
/ `port_metrics` copiati quasi verbatim. Sono regression-lock per decisioni live → la media-equity, dichiarata). Diario `2026-06-11-stability-sweep.md`.
copy-drift corrompe i verdetti. Fattorizzare in un modulo condiviso. - [x] ~~engine duplicato nei 3 gate~~ — FATTO (2026-06-11): fattorizzato in
`scripts/analysis/_port06_gate_common.py` (`build_trades_variant` versione trendmax =
superset con hurst_mask/trend_max parametrici, `equity_from_trades`, `port_metrics`,
`dd`); i 3 gate importano da lì. Nessuna drift di matematica trovata fra le copie
(solo firme/docstring). Output dei 3 gate verificato BYTE-IDENTICO prima/dopo.
`dip_trades` (DIP01) NON è una copia ma un sibling deliberato (long-only, orig_gap,
j clampato) → resta nel suo script, documentato nel modulo comune.
- [ ] **epoche hardcoded in `hourly_report.lossguard_section`** (LOSSGUARD_SINCE, TRENDSWAP_SINCE): - [ ] **epoche hardcoded in `hourly_report.lossguard_section`** (LOSSGUARD_SINCE, TRENDSWAP_SINCE):
ogni nuova epoca-filtro richiede di editare la funzione. Derivarle da deploy history/config. ogni nuova epoca-filtro richiede di editare la funzione. Derivarle da deploy history/config.
@@ -66,6 +74,24 @@
## Monitoraggio (osservare, non agire subito) ## Monitoraggio (osservare, non agire subito)
- [ ] **Churn da spike-print testnet su ETH (2026-06-11)**: il feed testnet stampa wick
anomali sulla barra 1h ETH che (a) generano segnali short MR01/MR07 (lo spike È lo
z-score estremo) e (b) "toccano" il TP intrabar della stessa barra → il sim booka
+4% fantasma a bars_held=0, il reale apre+chiude pagando solo fee/spread (~0.17€
a giro, 14 giri oggi ≈ 2.3€). Il real-truth ledger contabilizza GIUSTO (per questo
esiste) e il report orario ora conta i win dal flag reale. MITIGATO in v1.1.23:
gate `TP_PHANTOM` (il tocco TP deve essere confermato dal fill del resting sul book
reale, o dal prezzo oltre il livello) → niente più chiusure a mercato su wick
fantasma. Resta l'ENTRY spike-driven (il segnale stesso nasce dal wick): NON
filtrarlo nei segnali (= fit su artefatto testnet); osservare la frequenza dei log
TP_PHANTOM — se cresce, valutare un gate di QUALITÀ FEED nel data layer.
- [ ] **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,56 @@
# 2026-06-09 — Gioco "Blind Traders": 100 agenti ciechi
## Setup
100 agenti LLM (haiku) ricevono due serie anonime **X** e **Y** — in realta'
**BTC** e **ETH** 1h/15m/5m, mai etichettate — e devono proporre UNA regola che
"anticipi" i movimenti per un PnL netto positivo (fee 0.10% RT) con **>=10
trade/mese**. Non sanno cosa siano i dati. L'orchestratore (engine deterministico)
valuta ogni strategia, assegna un punteggio su **PNL + %win**, da' **90 epoche di
elaborazione** (hill-climb dei parametri) e **ogni 10 epoche blocca il 10% meno
profittevole** -> restano i **10 piu' profittevoli**.
Infrastruttura in `scripts/games/`:
- `engine.py` — dati anonimizzati, 6 famiglie segnale (zscore/breakout/ma_cross/
rsi/momentum/pairs), backtester causale fee-aware, scoring (>=10 tpm o squalifica).
- `agent_brief.py` — digest ANONIMO (stat aggregate + finestra normalizzata) + menu.
- `arena.py` — torneo a **3 finestre**: TRAIN (hill-climb), VALID (cull+rank
dell'orchestratore), TEST (OOS puro, mai ottimizzato). Anti-overfit.
- `run_game.py` — carica le 100 spec degli agenti e lancia il torneo.
## Risultato emergente
I 100 agenti ciechi, leggendo SOLO le statistiche anonime (autocorrelazione
negativa, "after_big_move_continues_pct" ~30-40% => le mosse estreme rientrano),
hanno **riscoperto da soli che il mercato e' mean-reverting**: 100/100 reversion,
67 hanno scelto il detector pairs, 30 zscore. Esattamente la lezione storica del
progetto (edge = reversione; pairs ETH/BTC il piu' robusto) — senza sapere che
fosse crypto.
## Classifica finale (top 10) — tutti PAIRS su 15m
Vincitore **agente #91** (15m, pairs market-neutral sul log-ratio X/Y):
- TEST/OOS puro: **PnL +3126%**, **win 77%**, **108.9 trade/mese**, **Sharpe 20.3**
- Full-period: PnL +8052%, win 70%, 94 tpm, Sharpe 12.2 (9604 trade)
- params: lookback 66, entry 1.67σ, exit 1.0σ, max_bars 35
- ipotesi (cieca): "Y altamente reversivo, X/Y log-ratio strong mean-reversion
(-0.43 autocorr), bassa correlazione cross-asset -> pairs market-neutral".
Tutti i 10 finalisti: pairs 15m, TEST Sharpe medio 19.9, tpm 66-109 (>>10).
## Caveat onesti
- Numeri OOS ottimistici: PnL additivo a notional fisso, **niente slippage sulle 2
gambe**, finestra OOS calma, 15m molti trade. Coerente col caveat PR01 del
progetto (Sharpe reale atteso ~4-5, non 20). Il valore del gioco e' il **metodo**
(scoperta cieca + selezione anti-overfit), non il livello assoluto di Sharpe.
- La convergenza su pairs conferma robustezza ma riduce la diversita': i 10 finalisti
sono varianti della stessa idea (ETH/BTC spread). Per un portafoglio servirebbe
diversificare (gia' fatto altrove: fade + honest + shape).
## Re-run "sobrio" con slippage (0.05%/lato)
`GAME_SLIP=0.0005` -> i pairs pagano +0.20% RT extra (4 lati). Lo slippage spinge
l'ottimizzatore verso **meno churn**: tpm dei finalisti 66-109 -> **40-47**, Sharpe
top-10 ~20 -> ~13.5. Vincitore **#43** (15m pairs): TEST PnL **+2091%**, win 77%,
**46.9 tpm**, Sharpe **15.6**. La gerarchia (pairs 15m domina) e la robustezza
reggono lo stress; lo Sharpe reale atteso resta ~4-5 (OOS calmo + PnL additivo).
Log: `data/games/game_slip.log`.
Artefatti: `data/games/tournament_result.json`, `data/games/specs/agent_*.json`,
`engine.set_slippage()` (env `GAME_SLIP`).
@@ -0,0 +1,41 @@
# 2026-06-09 — Gioco "Blind Traders" sessione 2: timing diversi (30m/2h/4h)
Seconda sessione del gioco (vedi `2026-06-09-blind-traders-game.md`), stesso protocollo
(100 agenti ciechi su BTC/ETH anonimi, scoring PNL+%win, ≥10 trade/mese, 90 epoche, cull
10% ogni 10 epoche → 10 finalisti, split a 3 anti-overfit) ma su **timeframe diversi**:
game 1 = 5m/15m/1h; **game 2 = 30m/2h/4h** (medio-lunghi). Engine con resampling aggiunto
(`engine._RESAMPLE`: 30m←15m, 2h/4h←1h). Specs in `data/games/specs2/`, risultato
`data/games/tournament_result2.json`.
## Diversita' proposte (di nuovo: riscoperta cieca della mean-reversion)
100 agenti: **74 pairs, 25 zscore, 1 breakout; 100% reversion**; tf 34/33/33. Come nel
game 1, leggendo solo le statistiche anonime (autocorrelazione negativa del log-ratio,
continuazione post-mossa ~40%) gli agenti convergono sulla reversione senza sapere che
sia crypto.
## Classifica finale — tutti 30m pairs
Vincitore **agente #36** (30m, pairs ETH/BTC):
- TEST/OOS: **PnL +1451%, win 77%, 43.4 trade/mese, Sharpe 12.3**.
- I 10 finalisti sono TUTTI 30m pairs (TEST Sharpe ~12, win 76-77%, tpm 43-49).
## Finding chiave: la regola ≥10 trade/mese e' un FILTRO sul timeframe
Quanti agenti per tf superano la soglia di attivita' + qualita':
| tf | agenti | ≥10 trade/mese | positivi OOS | miglior OOS Sharpe (pnl/win/tpm) |
|---|---|---|---|---|
| 30m | 34 | **34 (100%)** | 26 | **11.6** (1405% 76% 56) |
| 2h | 33 | 29 (88%) | 17 | 6.1 (512% 79% 17) |
| 4h | 33 | **4 (12%)** | 6 | 1.4 (103% 68% 14) |
A 4h solo 4/33 agenti riescono a fare ≥10 trade/mese (le barre sono troppo rade per
la reversione pairs); e l'edge cala col timeframe (Sharpe 11.6→6.1→1.4). Per questo i
finalisti sono tutti 30m.
## Lezione cross-game (game 1 + game 2)
Esiste una **frontiera frequenza-vs-edge**: la regola ≥10 trade/mese mette un *pavimento*
sul timeframe (i lunghi non fanno abbastanza trade), il costo/edge mette un *soffitto* (i
cortissimi sono cost-fragili). Il punto ottimo e' **il timeframe piu' corto con edge ancora
robusto**: game 1 (con 15m disponibile) → vince 15m; game 2 (senza 15m) → vince 30m. Sempre
**ETH/BTC spread reversion**. Coerente con l'analisi di robustezza del 15m
(`2026-06-09-pairs15m-robustezza.md`): piu' corto = piu' trade = piu' edge di backtest, ma
piu' fragile ai costi. Il gioco trova l'edge; la prudenza di deploy (mezza size) gestisce
la fragilita'. Artefatti: `scripts/games/`, `data/games/tournament_result2.json`.
@@ -0,0 +1,82 @@
# 2026-06-09 — Percorso live 15m per ETH/BTC pairs: COSTRUITO e VALIDATO
Seguito di `2026-06-09-pairs15m-port06-gate.md` (il gate passa, edge reale e non
artefatto flat). Qui si costruisce e VALIDA l'infrastruttura per eseguire il pairs
ETH/BTC a 15m con flat-skip, alla pari del backtest (disciplina validate_worker_pairs).
## 1. Engine canonico (regression-locked)
`scripts/analysis/pairs_research.py`: aggiunti `aligned_ohlc`, `is_flat_ohlc`,
`pairs_sim_flat(..., flat_skip, scan_buffer)`. Regola di uscita **LIVE-REALIZABLE**:
la condizione (|z|<=z_exit O bars>=max_bars) ARMA `exit_ready`; si esce al CLOSE della
PRIMA barra PULITA successiva (mai a un prezzo passato come faceva il prototipo push-back).
- **Regression-lock**: `pairs_sim_flat(flat_skip=False)` == `pairs_sim` ESATTO
(ETH/BTC 1h 1756 trade, 15m 9388 trade, ret/dd/sharpe identici al bit).
## 2. PairsWorker esteso (retrocompatibile)
`src/live/pairs_worker.py`: param `flat_skip`, stato `exit_ready` (persistito), tick
ora fa merge OHLC e rileva le candele flat (O=H=L=C in UNA gamba). Entry saltato su barra
stale; uscita con la stessa regola exit_ready dell'engine. **Default off = comportamento
1h storico invariato** (se mancano le colonne OHLC, flat=False).
## 3. Runner: fetch sub-orario (inerte finche' non c'e' uno sleeve 15m)
`src/portfolio/runner.py`: `_SUBHOURLY={5m,15m,30m}`, `_LOOKBACK_DAYS` esteso; il loop
fetcha DIRETTO da Cerbero i timeframe sub-orari per (asset,tf) (non resamplabili dal 1h) e
un router `_series_for` instrada la serie giusta a ogni worker. Zero impatto sul live
attuale: nessuno sleeve e' 15m → `subhourly_needs` vuoto → ramo morto.
## 4. VALIDAZIONE (validate_worker_pairs.py) — TUTTO OK
Replay bar-per-bar del worker == backtest:
| caso | worker | backtest | match |
|---|---|---|---|
| ETH/BTC 1h | 1756 trd, cap 2.886.616 | 1756, 2.886.616 | **OK esatto** |
| BTC/LTC 1h | 599 trd, cap 16.861 | 599, 16.861 | **OK esatto** |
| **ETH/BTC 15m-flat** | **8452 trd** | **8453 trd** (cap entro 0.15%) | **OK** |
(1 trade di differenza = posizione finale aperta non chiusa nel replay, atteso.)
## 5. Gate finale (engine == worker) — PROMOSSO
`pairs15m_gate_final.py` (corr 1h vs 15m = 0.372, 3201 ingressi flat saltati):
| variante ETH/BTC | FULL Sh | FULL DD | OOS Sh | OOS DD |
|---|---|---|---|---|
| baseline 1h | 6.43 | 3.96 | 8.58 | 1.36 |
| **SWAP 15m-flat** | 7.31 | 3.55 | **9.95** | **1.26** |
| **BLEND 1h+15m** | 7.03 | 3.66 | 9.57 | 1.24 |
Entrambi PROMOSSI (a fee backtest). Caveat slippage del gate precedente invariato → il
BLEND e' la forma raccomandata (meta' allocazione sul 1h pulito, slippage-robusto).
## Stato e attivazione (NON fatta — decisione di deploy)
Tutto il PERCORSO e' pronto e validato, ma il 15m **non e' attivo nel portafoglio live**:
attivarlo cambia il trading reale e va deciso esplicitamente. Per accenderlo:
1. `_defs.py`: aggiungere SleeveSpec pairs ETH/BTC a 15m (tf="15m",
params={n:66,z_in:1.674,z_exit:1.0,max_bars:35,flat_skip:True}) — come SWAP della 1h o
come 2a sleeve (BLEND) sotto il cap PAIRS.
2. `report_families.build_everything` / `sleeves`: l'equity del nuovo sleeve dal
`pairs_sim_flat(tf=15m, flat_skip=True)` (per parita' backtest==report).
3. Shadow smoke su testnet (come `live_smoke_pairs.py`) prima del paper reale.
4. `deploy.sh` (bump+rebuild) — il runner gia' fetcha 15m e passa flat_skip via spec.params.
Test suite: nessuna regressione (1h byte-exact). Artefatti: pairs_research.py,
pairs_worker.py, runner.py, validate_worker_pairs.py, pairs15m_gate_final.py.
## ATTIVAZIONE IN REALE (2026-06-09) — BLEND, mezza size
Deciso: BLEND (sleeve 15m ACCANTO al 1h, non swap). Implementato:
- `_defs.py`: SleeveSpec `PR_ETHBTC_15M` (tf=15m, flat_skip, params.position_size=0.10
= meta' del family PAIRS 0.20) in PAIRS -> entra in PORT04/05/06.
- `report_families.build_everything`: equity da `pairs_sim_flat(tf=15m, flat_skip=True, pos=0.075)`
(mezza size, == intento live) con sid PR_ETHBTC_15M.
- `runner.pos_for_spec`: override PER-SLEEVE (params.position_size) > famiglia > globale.
- **Mezza size perche'** a peso pieno il 15m pesava il 25.8% del rischio PORT06 (vs 9.5% del
1h): dimezzato -> 11.5% vs 10.6%, bilanciato. Disciplina come la cap SHAPE; rispetta il
caveat slippage (il 15m non domina il book).
**PORT06 col BLEND (mezza size)**: FULL Sharpe **6.43->7.20** DD **3.96->3.68**,
OOS Sharpe **8.58->9.66** DD **1.36->1.31**. Migliora tutto.
**Smoke live 15m** (`pairs15m_live_smoke.py`): Cerbero serve candele 15m FRESCHE per
ETH e BTC (ultima barra 0 min fa, flat live 2-3%), worker flat-skip ticca OK. Esecuzione
reale a 2 gambe gia' coperta da `live_pairs_smoke.py` (livello strumento, tf-indipendente).
**Regression-lock aggiornati** (miglioria attesa, non regressione): test_definitions
(17->18 sleeve), test_backtest_parity_cap (FULL 6.47->7.20, OOS 8.82->9.66). Suite verde.
Live: il runner fetcha 15m diretto, costruisce il PairsWorker(flat_skip) col pos 0.10,
e lo esegue reale a 2 gambe (pairs_enabled). Attivazione via deploy (bump+rebuild).
@@ -0,0 +1,89 @@
# 2026-06-09 — ETH/BTC pairs a 15m: gate PORT06 (dal gioco Blind Traders)
## Origine
Il gioco "Blind Traders" (100 agenti ciechi) ha eletto come vincitore una variante
ETH/BTC pairs su **15m** (config #43: n=66 z_in=1.67 z_exit=1.0 max_bars=35). Domanda:
e' un vero miglioramento o un duplicato piu' veloce della sleeve PR01 ETH/BTC gia'
deployata a 1h? Testato sul serio con l'engine di PRODUZIONE `pairs_sim` + gate PORT06.
Script: `scripts/analysis/pairs15m_port06_gate.py`.
## Risultati
- **Parita' OK** (corr 1.00000): l'harness riproduce esattamente il sleeve canonico
PR_ETHBTC → gate affidabile.
- **CORRELAZIONE 1h vs 15m = 0.349** (rendimenti giornalieri). **SMENTISCE la mia
ipotesi iniziale "duplicato ridondante"**: a 15m cattura eventi di reversione DIVERSI
→ e' un diversificatore reale, non una doppia scommessa sullo stesso spread.
- **Robustezza 15m**: griglia n×z_in → **16/16 celle Sharpe>1** (9-12), plateau non picco.
Non e' un punto overfit del gioco.
- **Standalone**: 15m fa 9388 trade (vs 1756 a 1h), Sharpe 11.7 (vs 4.36), DD 54% (vs 48%),
8/9 anni+ . (Le % FULL sono esplose dal compounding pos0.15·lev3 su 9k trade → guardare
Sharpe/DD/anni, non il livello %.)
## Gate PORT06 (pos0.15 lev3 canonico, OOS da 2024-10-12)
| variante ETH/BTC | FULL Sh | FULL DD | OOS Sh | OOS DD |
|---|---|---|---|---|
| **baseline 1h** | 6.43 | 3.96 | 8.58 | 1.36 |
| **SWAP 15m** | 7.64 | 3.49 | **10.39** | **1.26** |
| **BLEND 1h+15m** | 7.30 | 3.63 | 9.95 | 1.24 |
A fee di backtest (0.20% RT/coppia) **entrambe PROMOSSE**: Sharpe su e DD giu' ovunque.
## Stress slippage a livello PORT06 (il vero rischio: 15m = 5× i trade)
| fee_rt | RT/coppia | PORT06 FULL Sh | FULL DD | OOS Sh | OOS DD | std Sh | std oDD |
|---|---|---|---|---|---|---|---|
| baseline 1h | 0.20% | 6.43 | 3.96 | 8.58 | 1.36 | 4.36 | 16% |
| 15m | 0.20% | 7.64 | 3.49 | 10.39 | 1.26 | 11.7 | 13% |
| 15m | 0.40% | 7.04 | 4.08 | 9.78 | 1.45 | 8.5 | 27% |
| 15m | 0.60% | 6.43 | 4.67 | 9.15 | 1.66 | 5.3 | 47% |
**Degradazione graziosa ma reale**: il vantaggio di **Sharpe** sopravvive fino a slippage
pessimista (OOS 9.15 > 8.58 anche a 0.60%), ma il vantaggio di **DD si perde gia' a 0.40%**
(FULL DD 4.08 > 3.96 baseline; standalone oDD esplode 13→27→47%). La regola del progetto
("ri-gateare ogni filtro quando cambiano i costi") qui taglia: la frequenza 5× rende la
sleeve slippage-sensitive.
## Verdetto
- **NON un duplicato** (corr 0.35) e **NON overfit** (16/16 robusto) → la mia liquidazione
iniziale era SBAGLIATA, lo dico chiaro.
- **Passa il gate a fee di backtest, marginale sotto slippage**: migliora Sharpe sempre, ma
sotto slippage realistico (≥0.40% RT) peggiora leggermente il DD di portafoglio.
- **Due rischi di produzione NON ancora quantificati**: (a) qualita' dati ETH 15m (14-30%/anno
candele flat O=H=L=C → fill non eseguibili che gonfierebbero il backtest), (b) fill/liquidita'
reale a 2 gambe a 15m (5× ordini). Il worker pairs e' validato a 1h, non a 15m.
**Raccomandazione**: NON swap diretto in live. Candidato promettente → percorso forward:
preferire il **BLEND 1h+15m** (tiene il DD pulito del 1h e raccoglie il rendimento
decorrelato del 15m) **dopo** un check sull'impatto delle candele flat 15m sui pairs.
Allineato a come il progetto tratta FR01 (robusto ma non deployato finche' non domina pulito).
Resta come record di ricerca; deploy solo se il check flat-candle e' pulito.
## CHECK FLAT-CANDLE (pairs15m_flatcheck.py) — PULITO
Rischio: ETH 15m ha molte candele flat (O=H=L=C) → close stale che gonfia z-score →
reversione FINTA non eseguibile. Test:
- **Prevalenza**: ETH 15m **16.4% medio** (fino 30% nel 2022); BTC 15m solo 3.5%. Reale.
- **Fill toccati**: 12.9% degli entry e 15.2% degli exit cadono su una barra flat.
- **Test decisivo** (entry/exit SOLO su barre pulite, non-flat in entrambe le gambe):
rimuove 11.2% dei trade, **Sharpe trattenuto all'83%** (11.74→9.70; OOS Sharpe 18.4).
Se l'edge fosse un artefatto flat, filtrando crollerebbe → **NON crolla. NON e' artefatto.**
- **Gate PORT06 col 15m FLAT-FILTRATO** (corr 1h vs 15m-flat = 0.366, ancora decorrelato):
- SWAP 15m-flat: FULL 7.32/3.55, OOS **9.99/1.26** → PROMOSSO
- BLEND 1h+15m-flat: FULL 7.05/3.66, OOS **9.60/1.24** → PROMOSSO
## Conclusione (3 box su 4 puliti)
✅ NON duplicato (corr 0.35-0.37) ✅ robusto (16/16) ✅ NON artefatto flat (83% Sharpe)
⚠️ slippage-sensitive: a fee backtest passa pulito; a slippage ≥0.40% RT il vantaggio di
Sharpe regge ma il DD-edge si assottiglia. Il **BLEND** mitiga (meta' allocazione resta sul
1h pulito e slippage-robusto) → e' la forma deployabile.
## Realta' del deploy (perche' NON tocco ancora il live)
Il gate passa a livello BACKTEST. Ma il live NON puo' eseguire un sleeve 15m oggi:
- la live pairs gira SOLO a 1h (`PairsWorker`, validato da `validate_worker_pairs` a 1h);
il runner risampla a 1h/4h/1d, non gestisce un leg pairs a 15m.
- un BLEND richiede DUE sotto-sleeve ETH/BTC (1h + 15m) dentro il cap PAIRS, e il
**flat-skip va replicato nel worker live** (altrimenti il live tradera' le barre stale che
il backtest esclude → divergenza backtest-vs-live, la classe di bug che il progetto teme).
Editare `_defs.py` cambierebbe solo il backtest/report, NON il live → sarebbe ingannevole.
**Percorso deploy corretto** (da confermare): (1) estendere `PairsWorker`/runner al 15m +
flat-skip; (2) `validate_worker_pairs` a 15m (replay == backtest esatto); (3) aggiungere lo
sleeve 15m sotto il cap PAIRS; (4) shadow su testnet prima del paper. Finche' (1)-(2) non
sono fatti e validati, resta **record di ricerca PROMOSSO ma non live**.
@@ -0,0 +1,45 @@
# 2026-06-09 — ETH/BTC 15m: analisi di robustezza (il limite è il COSTO)
Domanda: lo sleeve `PR_ETHBTC_15M` (attivato in real, v1.1.16) è robusto? Risposta dai
dati: **sì su parametri, tempo e qualità-dati; fragile sui COSTI** — e quel limite è
strutturale (alta frequenza). Engine `pairs_research.pairs_sim_flat` (live-realizable).
## ✅ Robusto su parametri, tempo, dati
- **Plateau parametrico**: griglia n×z_in (40-80 × 1.5-2.5) → **16/16 celle Sharpe>1**
(range 9-12). Non un picco overfit. (`pairs15m_port06_gate.py`)
- **Consistenza temporale**: 2018-2021 +1874% (3/4 anni+, solo 2018 negativo);
2022-2026 +12958% (**5/5 anni+**); **OOS ultimo 30% Sharpe 17.6, DD 13%, +**. L'edge
è distribuito, non un singolo regime.
- **Non artefatto dei dati**: filtrando le candele flat ETH 15m (16% storico) resta
l'**83% dello Sharpe** (`pairs15m_flatcheck.py`).
- **Decorrelato dal 1h** (corr rendimenti giornalieri **0.37**): segnale diverso, non un relabel.
## ❌ Fragile sui COSTI — il vero tallone (frequenza 5× il 1h)
Sharpe in funzione del costo all-in RT/coppia (fee + slippage):
| costo RT/coppia | **15m Sharpe** | 1h Sharpe |
|---|---|---|
| 0.20% (reale, 1×) | **9.34** | 4.36 |
| 0.40% (2×) | 6.15 | 3.52 |
| 0.60% (3×) | 2.95 | 2.68 |
| 0.80% (4×) | **0.24** | 1.84 |
| 1.20% (6×) | 6.63 | 0.16 |
**Il 1h regge ~6× i costi; il 15m va negativo già a ~4×.** Tanti trade piccoli (8453 vs
1756) → margine di costo sottile. Lo **Sharpe 9.34 è un numero a costo basso**: appena lo
slippage reale a 2 gambe porta l'all-in verso 0.40-0.60%, scende a 3-6 (ancora positivo,
ma l'edge enorme del backtest è in gran parte illusione da bassi costi).
## Implicazioni (coerenti con la config deployata)
1. **La mezza size era giusta**: non si dà al 15m il peso che il backtest a costo basso
suggerirebbe. È un blend-tilt, non una scommessa.
2. **NO allo swap** (togliere il 1h, tenere solo 15m): sostituirebbe l'àncora cost-robust
(1h, regge 6×) con la sleeve cost-fragile. Confronto PORT06: swap OOS 9.95 vs blend 9.66
— +0.3 di Sharpe di backtest pagati in robustezza reale. Non vale.
3. **Giudice finale = ledger reale shadow**: misurerà lo slippage vero a 15m. Soglia di
lettura: all-in <0.40% (Sharpe ≥6) → ottimo diversificatore, valutare size piena;
verso 0.60%+ → vale appena la mezza-size che ha (già protetto).
**In una riga**: robusto come *segnale*, fragile come *esecuzione* → sta nel portafoglio a
metà size accanto al 1h, non al suo posto. Riproducibile: fee-sweep + sub-periodo +
OOS via `pairs_sim_flat`; plateau/flat-check negli script `pairs15m_*.py`.
@@ -0,0 +1,73 @@
# 2026-06-09 — Statistiche per-anno di tutte le sleeve attive in REAL
Snapshot post-attivazione BLEND ETH/BTC 15m (v1.1.16). Breakdown per-anno delle **15
sleeve che eseguono ordini reali** su Deribit testnet (escluse le 3 PAPER multi-asset
TR01/ROT02/TSM01). Engine **path-live**: EXIT-16 + filtro trend 3.0 per le fade,
walk-forward expanding per SH01, flat-skip per il pairs 15m. **PnL% = somma dei
rendimenti netti per-trade levered ×3, fee 0.10-0.20% RT incluse** — NON il contributo
al portafoglio (che applica sizing pos, cap-weighting, leva 2x, ribilancio 1D); serve a
confrontare edge grezzo e DD per sleeve.
Riproducibile: famiglie pairs via `pairs_research.pairs_sim`/`pairs_sim_flat`; fade/DIP/SH01
via le funzioni `stats_fades`/`stats_dip`/`stats_sh01` di `scripts/analysis/make_strategy_doc.py`.
## FADE (6) + DIP01 — PnL% per anno (n trade)
| anno | MR01 BTC | MR01 ETH | MR02 BTC | MR02 ETH | MR07 BTC | MR07 ETH | DIP01 BTC |
|---|---|---|---|---|---|---|---|
| 2018 | 78 (48) | 85 (42) | +69 (127) | +10 (127) | 12 (38) | +15 (25) | 78 (79) |
| 2019 | 84 (57) | +21 (46) | +18 (118) | +120 (114) | +55 (52) | 40 (35) | 104 (62) |
| 2020 | 29 (68) | 35 (55) | +100 (112) | 94 (135) | 11 (53) | +39 (43) | +67 (56) |
| 2021 | +326 (82) | +238 (60) | +332 (136) | +160 (127) | +245 (45) | +72 (24) | +315 (69) |
| 2022 | +567 (108) | +749 (76) | +728 (106) | +650 (91) | +446 (82) | +472 (58) | +407 (83) |
| 2023 | +318 (122) | +248 (73) | +235 (96) | +524 (146) | +168 (67) | +337 (58) | +228 (83) |
| 2024 | +413 (113) | +888 (126) | +868 (149) | +1952 (180) | +377 (75) | +645 (87) | +332 (81) |
| 2025 | +368 (90) | +361 (86) | +386 (136) | +931 (151) | +190 (60) | +226 (55) | +303 (85) |
| 2026* | +69 (29) | 9 (24) | +88 (37) | +25 (41) | +58 (17) | +40 (17) | +7 (29) |
| **TOT** | +1870 (717) | +2376 (588) | +2823 (1017) | +4278 (1112) | +1517 (489) | +1806 (402) | +1476 (627) |
| **maxDD%** | 32 | 23 | 19 | 31 | 12 | 23 | 37 |
## SHAPE SH01 (2) — PnL% per anno (n trade)
| anno | SH01 BTC | SH01 ETH |
|---|---|---|
| 2018 | 66 (237) | +74 (239) |
| 2019 | +88 (318) | 19 (365) |
| 2020 | +194 (240) | 293 (219) |
| 2021 | +301 (224) | +67 (146) |
| 2022 | +64 (142) | +79 (91) |
| 2023 | +17 (118) | +21 (17) |
| 2024 | +110 (144) | +108 (47) |
| 2025 | +77 (85) | +540 (108) |
| 2026* | +59 (23) | 30 (25) |
| **TOT** | +845 (1531) | +547 (1257) |
| **maxDD%** | 23 | 61 |
## PAIRS (6) — PnL% per anno (n trade) | 15m a mezza size (pos 0.075)
| anno | ETH/BTC 1h | LTC/ETH | ADA/ETH | BTC/LTC | ETH/SOL | ETH/BTC 15m |
|---|---|---|---|---|---|---|
| 2018 | 343 (177) | — | — | — | — | 492 (913) |
| 2019 | +233 (198) | — | — | — | — | +792 (963) |
| 2020 | +323 (211) | — | — | — | — | +452 (867) |
| 2021 | +549 (202) | — | — | — | — | +1122 (949) |
| 2022 | +1344 (206) | +292 (45) | +899 (157) | +328 (58) | +1243 (147) | +2136 (898) |
| 2023 | +464 (213) | +56 (94) | +341 (173) | +61 (106) | +125 (168) | +689 (976) |
| 2024 | +1661 (253) | +1024 (195) | +1078 (225) | +638 (186) | +1302 (199) | +6313 (1353) |
| 2025 | +1173 (225) | +962 (196) | +1046 (187) | +686 (178) | +1264 (202) | +3664 (1123) |
| 2026* | +61 (71) | +279 (76) | +140 (71) | +265 (71) | +270 (88) | +155 (411) |
| **TOT** | +5464 (1756) | +2614 (606) | +3504 (813) | +1978 (599) | +4204 (804) | +14832 (8453) |
| **maxDD%** | 48 | 14 | 19 | 21 | 24 | 34 |
| **Sharpe** | 4.36 | 4.22 | 4.90 | 2.72 | 4.61 | 9.34 |
\* 2026 parziale (fino al 28 mag). Coppie alt (LTC/ADA/SOL) hanno storia solo dal 2022.
## Aggregato PORT06 (BLEND attivo, 18 sleeve def.)
**FULL Sharpe 7.20 / DD 3.68% / CAGR 76% — OOS Sharpe 9.66 / DD 1.31%**. Per anno:
2021 +19.0% · 2022 +73.3% · 2023 +39.8% · 2024 +220.1% · 2025 +110.5% · 2026* +7.7%.
Nessun anno negativo dal 2021.
## Letture
- Il PnL% grezzo del 15m e' grande perche' fa ~5x i trade del 1h (8453 vs 1756), non
per-trade: il confronto equo e' Sharpe (9.34 vs 4.36) e DD (34% vs 48%). Nel portafoglio
entra a mezza size -> ~11.5% del rischio (== il 1h).
- MR02 ETH = singolo motore piu' forte (+4278%, 2024 +1952%); MR07 il piu' selettivo
(DD 12-23%); SH01 ETH il piu' rischioso (DD 61%, coda 2020 293%) -> mitigato dal cap SHAPE.
- I 3 book multi-asset (TR01/ROT02/TSM01) restano PAPER, fuori da questo elenco.
@@ -0,0 +1,49 @@
# 2026-06-09 — XS01: reversione cross-sectional (famiglia nuova, trovata + deployata PAPER)
## Origine
Dopo aver scartato (alla cieca, coi giochi) trend/breakout/seasonal/opzioni/funding come
rumore o EV, ho cercato io un meccanismo *diverso* dalla mean-reversion pairwise. Trovato:
**XS01 — reversione CROSS-SECTIONAL** su 8 asset (BTC/ETH/LTC/ADA/SOL/BNB/XRP/DOGE).
## Meccanismo
Ogni HOLD=12 ore: classifica gli 8 asset per rendimento su LB=48 ore, pesi
w = (ret media_cross-section), normalizzati a gross 1 → **long i perdenti relativi /
short i vincenti**, market-neutral. Roll non sovrapposto (entry-to-entry = hold+1 barre).
Fee 0.10% RT/book. Cattura il FATTORE reversione trasversale, distinto dai pairs (pairwise).
## Verifica (engine canonico `scripts/strategies/XS01_cross_sectional.py`)
- **No look-ahead** verificato (segnale invariato perturbando il futuro).
- **Robusto**: plateau OOS Sharpe **23.9** su lb 1272 × hold 624.
- **Scorrelato**: corr **0.006 / 0.035** da PR01 ETH/BTC, 0.028 dai fade → diversificatore.
- Per-anno (entry): 2022 +34, 2023 +6, 2024 +21, **2025 +225**, 2026 +85 (5/5 anni+).
- **Caveat**: edge concentrato sul 2025; cost-sensitive (muore ~0.35% RT/book); 8 gambe;
storia dal 2022 (no 2018-2020).
## Worker validato (== backtest esatto)
`src/live/xsec_worker.py` `CrossSectionalWorker`: book market-neutral che rolla ogni HOLD
barre, stessa formula pesi e cadenza dell'engine. `validate_xsec_worker.py`: replay
bar-per-bar == backtest **ESATTO** (worker 4993/1427 trade/49.8% == backtest 4993/1427/49.8%).
Bug risolto: il primo prototipo rollava 1 barra troppo tardi (cooldown extra) → rimosso,
guard a lb+1, entry-to-entry = hold+1.
## Gate PORT06 — PROMOSSO (con asterisco)
| | corr | FULL Sh | FULL DD | OOS Sh | OOS DD |
|---|---|---|---|---|---|
| ATTUALE (19→ senza XS01) | — | 7.20 | 3.68 | 9.66 | 1.31 |
| **+XS01** | 0.006 | **7.34** | **3.46** | **10.07** | 1.48 |
Migliora 3 metriche su 4 (OOS Sharpe **+0.41**, il salto più grande dal 15m; FULL DD giù).
Unico neo: OOS DD +0.17pp. Risk-contrib XS01 solo **2.2%** (diversificatore a bassa vol).
## Deploy (v?, 2026-06-09) — PAPER
8 gambe → niente esecuzione reale (come TR01/ROT02/TSM01) → XS01 gira **PAPER**
(`paper_sleeves`), fuori dal pool, raccoglie statistica forward. Wiring: `_defs.XSEC` in
PORT06 (19 sleeve, family XSEC via prefix "XS"), `build_everything` (equity da xsec_sim),
`runner` kind="xsec" → CrossSectionalWorker, `asset_days` ora include i paper (fix: gli alt
BNB/DOGE/XRP ora vengono fetchati anche per TR01/ROT02/TSM01). Regression-lock aggiornati
(18→19 sleeve, FULL 7.20→7.34, OOS 9.66→10.07, DD 3.68→3.46). 93 test verdi.
**Direzione futura:** se la statistica forward conferma, costruire l'esecuzione reale a
N gambe (oggi inesistente) per portarlo nel pool. Per ora: candidato validato che gira
PAPER e si osserva. Artefatti: `scripts/strategies/XS01_cross_sectional.py`,
`src/live/xsec_worker.py`, `scripts/analysis/{validate_xsec_worker,xsec_port06_gate}.py`.
+46
View File
@@ -0,0 +1,46 @@
# 2026-06-10 — FC01 funding-carry market-neutral: SCARTATA (il carry non paga le fee)
## Idea
Carry NEUTRALE sul funding Deribit (meccanismo mai esplorato: W12 era lo short
direzionale su funding alto, bocciato): short della gamba col funding alto /
long della gamba bassa (ETH vs BTC, dollar-neutral), incassando il differenziale
di funding con esposizione residua = solo lo spread ETH/BTC.
Dati REALI: `data/regime/{btc,eth}_funding.parquet` — funding orario effettivo
dic 2019 → giu 2026 (56.938 ore), `interest_1h` + index_price.
Harness: `scripts/analysis/funding_carry_research.py` (causale: decisione al
close t, accrual da t+1; fee 0.10% RT per gamba; TRAIN ≤2023-11-01 / OOS dopo).
## Numeri che uccidono l'idea (prima ancora del backtest)
- Funding annualizzato: BTC mean +7.2% / med +0.3%; ETH +5.2% / +0.05% —
la MEDIANA è ~zero: il funding alto è episodico, non strutturale.
- Spread ETHBTC: p10 19% / p90 +12.6% annualizzato, MA autocorr del
24h-smooth a 72h = 0.20 → poca persistenza.
- **Episodi |spread|>10% ann: durata mediana 19h** (p90 60h) → carry
incassabile per episodio ≈ 10%/8760×19h ≈ **0.02-0.05%**, contro **0.20%**
di fee (2 gambe). Strutturalmente morto: il carry è un ordine di grandezza
sotto i costi, e il price-leg dello spread ETH/BTC (vol ben maggiore del
carry) domina il PnL.
## Backtest (conferma)
- FC-A spread-carry, griglia smooth {24,72,168}h × thr {5,10,20}% ann:
TRAIN quasi tutto negativo (Sharpe 0.6…+0.03), OOS negativo ovunque
tranne una cella (s24 thr5: OOS +38% con TRAIN 45% = rumore/sign-flip).
Sweep fee: negativa anche a 0.05% RT/gamba. Annuale: 2021 93%, nessuna
stabilità.
- FC-B direzionale single-asset (confronto onesto): negativa ovunque,
riconferma W12.
## Verdetto
SCARTATA. Su Deribit BTC/ETH il funding non è un carry harvestabile: troppo
piccolo, troppo breve, e l'hedge cross-asset introduce più rischio del carry
che raccoglie. Eventuale rivisita SOLO se: (a) si aggiungono alt ad alto
funding cronico (Hyperliquid, registry già validato) dove i livelli sono
5-10x, o (b) si usa il funding come FEATURE/gate di strategie esistenti
(es. filtro sulle entry PR01) invece che come fonte di PnL.
Script: `scripts/analysis/funding_carry_research.py` (resta come record negativo).
+113
View File
@@ -0,0 +1,113 @@
# 2026-06-10 — Gioco "Blind Traders" sessione 3: GRID TRADERS (regola: STRATEGIA_GRIGLIA.md)
## Setup
Terza sessione del gioco dei trader ciechi, con una regola nuova: ogni agente deve
implementare la strategia descritta in `STRATEGIA_GRIGLIA.md` (grid trading a griglia
geometrica). 100 agenti (haiku, via Workflow) ricevono SOLO un digest anonimo di due
serie X/Y (in realtà BTC/ETH, mai rivelato) sul loro timing assegnato
(25×15m, 20×30m, 20×1h, 15×2h, 12×4h, 8×1d) + 5 archetipi di stile a rotazione
(prudente / aggressivo / asimmetrico-rialzista / asimmetrico-ribassista / data-driven),
e propongono la CONFIGURAZIONE della griglia: `range_down/up %`, `grid_levels`,
`sl_buf`, `tp_buf`, `max_bars`, serie.
Infrastruttura nuova in `scripts/games/`:
- **`grid_engine.py`** — backtest deterministico, causale, fee-aware della spec:
griglia geometrica `ratio=((1+ru)/(1-rd))^(1/L)` costruita sul close di deploy,
capitale 1/L per livello (§3.3), buy su attraversamento ↓ di un livello non
riempito, sell del livello su attraversamento ↑ del successivo (§5.2), SL sotto
il range e TP sopra che liquidano tutto (§6), redeploy a fine episodio
(SL/TP/max_bars). **Vincolo break-even §4 implementato alla lettera**: passo
≤ 1.5×costo RT → il motore si rifiuta di partire (`refused`, fitness 2e6);
`_normalize` dell'arena riduce i livelli al massimo legale. Fill intrabar lungo
il percorso O→L→H→C / O→H→L→C; fee 0.10% RT per round-trip + slippage opzionale.
- **`grid_arena.py`** — torneo identico alle sessioni 1-2: split 60/20/20
TRAIN/VALID/TEST, 90 epoche di hill-climb sul TRAIN, cull del 10% in VALID ogni
10 epoche → 10 superstiti; TEST = OOS puro mai ottimizzato.
- **`grid_brief.py`** — digest anonimo con statistiche per dimensionare una griglia:
escursione max/min rolling (w100/500/2000, mediana e p90) e probabilità di fuga
da un range ±5/10/20% entro 500 barre.
## Esito
`data/games/grid_result.json` (+ log `grid_tournament.log`). 100/100 spec da agenti
reali (nessun sostituto random).
- **Alla proposta, 80/100 agenti scelgono X (=BTC)**: dai soli numeri anonimi capiscono
che la griglia sopravvive meglio sulla serie meno volatile (escape ±20% in 500 barre:
BTC 34.5% vs ETH 53.2% a 1h).
- **L'evoluzione ribalta la scelta: tutti i 10 superstiti finiscono su Y (=ETH)** —
nel periodo VALID/TEST la vol più alta di ETH paga di più i round-trip, e il rischio
trend è gestito non dal range stretto ma dalla FORMA della griglia (sotto).
- **Convergenza fortissima della forma** (9/10 superstiti): griglia **asimmetrica
ribassista** — range profondo sotto (13/20%), corto sopra (+4/+8%), **livelli al
minimo (4)** → passo largo ~4.5-5.5%, SL buffer profondo (5-15%), max_bars lunghi.
Tradotto: **compra i dip di ETH in 4 tranche distanziate ~5%, rivendi ogni tranche
al rimbalzo di un passo, stop catastrofale a ~25/30%**. Il gioco ha ri-scoperto
per la terza volta la mean-reversion (qui in forma di dip-buying a tranche), e ha
imparato da solo la lezione anti-fee: meglio pochi passi larghi che griglie fitte.
**Vincitore: agente #11, ETH 15m**, griglia 17.1%/+4.6% × 4 livelli, SL buf 12.4%,
TP buf 4.8%, max 2143 barre. TEST(OOS): **PnL +891% (additivo), win 97%, 38.5
trade/mese, Sharpe 10.1**. FULL 2018-2026 (include bear 2018 e 2022): +4284%, Sharpe 9.6.
Stress slippage TEST: 0.05%/lato → 871%, 0.10%/lato → 850% (il passo ~5.4% ⋙ costi).
## Caveat onesti (perché NON è un candidato deploy così com'è)
1. **Il PnL è additivo per-trade e non misura il drawdown UNREALIZED**: l'engine
somma i round-trip realizzati; mentre la griglia tiene 4 tranche dentro un
drawdown 17%, l'equity vera è sott'acqua (fino a ~15% di episodio + SL 25/30%
quando scatta). Il win-rate 97% è il profilo classico della griglia: tante
micro-vincite, perdite rare ma grandi (stessa famiglia di rischio del
short-vol/martingala). Per un gate serio servirebbe l'equity curve mark-to-market.
2. **Long-only su ETH con VALID/TEST 2023-2026** (regime rialzista/oscillante):
l'asimmetria 17/+4.6 è anche un fit al regime. Il FULL positivo (bear inclusi)
è incoraggiante ma il grosso del PnL resta nei periodi di reversione rialzista.
3. Le candele flat ETH 15m (14-30%/anno) e i fill intrabar "al livello" condividono
i caveat noti del progetto (bias pro-stop-stretti dell'engine intrabar; qui gli
stop sono larghi, quindi l'effetto è minore).
## Lezioni
- La spec STRATEGIA_GRIGLIA.md è implementabile in modo causale e onesto; il suo
vincolo break-even (§4) è esattamente la "lezione fee" del progetto, e il torneo
l'ha confermato spingendo i livelli al minimo (passo massimo).
- La griglia evoluta è un parente povero delle fade MR già in live: stesso edge
(reversione ETH), ma incassato con inventory risk crescente invece che con
TP/SL per-trade. Non sostituisce le MR; eventuale interesse solo come variante
"a tranche" da gateare con equity mark-to-market (e confronto con MR01/MR02 a
parità di rischio) PRIMA di pensarci per il PORT06.
## GATE "si puo' inserire?" (stessa sera) — NO-GO: edge = artefatto delle wick testnet
`scripts/analysis/grid_game_gate.py`: engine **mark-to-market** dedicato (equity
per barra = capitale + inventario al close, SL gap-aware, flat-skip, fee 0.10% RT),
metriche standard del progetto + gate PORT06. Primo passaggio ingannevole: standalone
WINNER 15m FULL Sharpe 5.61 DD 15.8%, corr max coi sleeve 0.34, plateau 16/16,
e il criterio formale promuoveva il half-size (OOS Sh 10.07->10.12, DD 1.48->1.36).
MA il breakdown annuale mostrava **+4946% nel 2022** (ETH 70%): impossibile.
**Verifica avversariale decisiva**: il feed Deribit (testnet) e' pieno di **spike
print** — ETH 15m ha 1064 barre con wick ≥6% sotto i close adiacenti che rientrano
subito (fino a 19% in 15 minuti; BTC ha spike a 54% nel feb 2024). La griglia
intrabar compra su quelle wick FINTE e rivende al rimbalzo: free money in sim,
infillabile dal vivo. Stress **close-only** (fill solo su attraversamento del
close): WINNER 15m CAGR 1544%->21%, Sharpe 5.61->0.92, OOS +2738%->+32%,
trades 3803->1156; top3 1h CAGR -1.7%, OOS 18%. **~99% dell'edge vive nelle
wick.** La versione onesta (Sharpe 0.92, DD 27%) e' molto sotto ogni sleeve
deployato → **NON inseribile**, in nessuna size.
Lezioni aggiuntive:
- Il torneo ha massimizzato esattamente l'artefatto: la convergenza su ETH 15m
range profondo era la firma del **wick harvesting**, non di un edge.
- I motori intrabar dei giochi (`grid_engine`, e in misura minore i TP intrabar
di `engine.py`) vanno SEMPRE stressati con una variante close-only prima di
promuovere qualunque vincitore: aggiungere il check ai prossimi giochi.
- Gli sleeve in produzione NON sono toccati da questo artefatto nello stesso modo:
pairs entra/esce sui close, le fade hanno EXIT-16 close-confirm sullo SL, e
soprattutto il ledger REAL-TRUTH usa i fill reali (che gli spike non fillano).
Artefatti: `scripts/games/grid_{engine,arena,brief}.py`, spec agenti in
`data/games/specs_grid/`, digest `data/games/grid_digests.json`, risultato
`data/games/grid_result.json`, log `data/games/grid_tournament.log`, gate
`scripts/analysis/grid_game_gate.py` (param `close_only` per lo stress anti-wick).
@@ -0,0 +1,57 @@
# 2026-06-10 — REAL-TRUTH: il ledger segue i fill reali (sim → diagnostica)
## Richiesta
L'utente vuole che sim e reale coincidano — o meglio, che il sim non esista come
verità: «voglio vedere dati che hanno avuto movimento reale su exchange». La
ricognizione dei 77 eventi reali (dal 2026-06-03) ha mostrato che il gap
sim/reale NON era slippage (piccolo, es. 0.15 bps su un TP) ma **contabile**:
1. **Ledger separati.** Il `capital` che guida portafoglio/ribilanci/sizing era
aggiornato dal PnL SIM; il PnL reale finiva in un `real_capital` parallelo che
non guidava nulla. MR01 BTC: sim +28.93 vs reale +1.98 sugli stessi 4 trade.
2. **Prezzi sim da candele testnet** (spike print, es. 2026-06-07 sim short BTC a
65266.5 con mark reale 62395): il sim bookava PnL che il reale non vede. Con
il ledger sim come verità, l'equity del portafoglio accumulava questa fantasia.
Nota: il *notional* reale era GIÀ derivato dalla formula sim
(`capital·ps·lev` passato a `_real_open`/`_real_open_pair`) — il punto 1 della
richiesta (sizing allineato) si chiude da solo una volta che `capital` è reale.
## Implementazione (REAL-TRUTH)
- `StrategyWorker` e `PairsWorker` accettano `real_truth: bool` (default False =
shadow storico). Con flag attivo e esecuzione abilitata:
- `_real_close`/`_real_close_pair` ritornano `(real_pnl, applied)`;
`applied=True` se ci sono fill reali (o chiusura verificata).
- `_close_position`/`_close` chiamano la chiusura reale PRIMA dell'update
ledger: `capital += real_pnl` (fee reali incluse); `is_win = pnl_reale > 0`.
- Il sim resta nel log CLOSE come diagnostica: `pnl_source` ("real" |
"sim_fallback"), `sim_pnl`, `real_pnl`.
- **Fallback al sim** SOLO se il trade reale non è mai esistito/fillato
(REAL_OPEN_FAIL, fill zero) — dichiarato nel log, mai silenzioso.
- Runner: `overrides.execution.real_truth` (yml) → `build_worker_for(...,
real_truth=)`. `portfolios.yml`: **`real_truth: true`**.
- Conseguenza a catena: equity ledger → pesi → allocazioni → notional dei
prossimi ordini derivano ora dai soldi veri sul conto. Il `real_capital`
parallelo resta come ledger puro-reale di confronto.
## Test
`tests/portfolio/test_real_truth.py` (6 test): capital segue i fill reali
(single-leg e pairs), una divergenza sim-win/reale-loss viene contata come LOSS,
fallback sim dichiarato su REAL_OPEN_FAIL/leg-fail, e modalità shadow invariata
senza flag. Suite completa: 99 passed.
## Limiti onesti
- I multi-asset (TR01/ROT02/TSM01/XS01) restano sim per costruzione (paper
sleeves fuori dal pool, capitale insufficiente per i book multi-leg).
- La STORIA del ledger non è riscritta: l'equity attuale (~2154) ingloba il PnL
sim accumulato fino a oggi (reale realizzato era ≈ 15.6 dal 3/6). Da ora la
divergenza smette di accumularsi; se si vuole un azzeramento (reset equity al
conto reale) è un'operazione separata e deliberata.
- Le DECISIONI di trading (entry/exit) restano guidate dai prezzi del feed
(candele testnet): real-truth corregge la contabilità, non i segnali. Gli
spike print del feed possono ancora generare entry/exit subottimali — ma ora
il loro effetto si misura in PnL reale, non in PnL immaginario.
@@ -0,0 +1,51 @@
# 2026-06-10 — XS01 dispersion-gate: PROMOSSO e LIVE (entry solo con dispersione da fare rientrare)
## Domanda
L'edge di XS01 (reversione cross-sectional 8 asset) era concentrato (2025 domina,
2023 quasi piatto) e cost-sensitive. La reversione cross-sezionale va accesa solo
quando c'e' dispersione da far rientrare?
## Metodo (anti multiple-testing): `scripts/analysis/xs01_dispersion_gate.py`
3 feature di regime CAUSALI calcolate dallo stesso panel closes (nessun feed
esterno): `g_disp` = std cross-section del momentum lb (la grandezza che si fada),
`g_corr` = correlazione media pairwise 72h (identita' della varianza dell'indice),
`g_vol` = vol BTC 168h. Diagnostica per quintili (quintili dal TRAIN, 70/30) sul
net per-trade dell'engine canonico NON gateato, TRAIN e OOS separati: si procede
solo con relazione monotona e concorde nelle due finestre.
## Esito diagnostica
- **g_disp: monotona e concorde** — Q1 NEGATIVO (10 bps TRAIN / 8 OOS) →
Q5 +26/+280. Senza dispersione i trade sono solo fee. PROMOSSA.
- g_corr e g_vol: non monotone / segno incoerente → BOCCIATE (niente fishing).
## Gate (sweep soglie = percentili TRAIN, side dal TRAIN)
Plateau pieno p30-p70, niente picco: TRAIN Sh 1.51 → 2.0-2.3, OOS Sh 5.73 →
6.2-7.5. Scelta **p50 (disp_min = 0.0313)**, ~47% delle ore aperte:
- Standalone: trade 1427→859 (40% turnover → meta' fee), win 50→53%,
**Sharpe 2.50→3.46**, DD 16.2→15.8%. **Ogni anno migliora**: 2022 +34→+40,
2023 +6→+28, 2024 +21→+44, 2025 +225→+237, 2026 +85→+108 — risolve la
concentrazione, il punto debole della validazione originale.
- Fee stress 2x (0.20% RT/book): OOS Sh 6.76 — la cost-sensitivity e' mitigata
(il gate taglia proprio i trade che pagavano fee senza edge).
- **Gate PORT06** (swap equity sleeve): FULL Sh 7.34→7.41 DD pari,
**OOS Sh 10.07→10.37 DD 1.48→1.47** → PROMOSSO (criterio standard).
## Implementazione (solo path LIVE, come trend/hurst sulle fade)
- `src/live/xsec_worker.py`: param opzionale `disp_min` (None = off), check in
`_open_book` su `nanstd(logC[i] logC[ilb])`. Default off → la validazione
`validate_xsec_worker` (replay == backtest) resta esatta.
- `src/portfolio/runner.py`: pass-through di `disp_min` (il runner costruiva il
dict params esplicitamente e l'avrebbe perso).
- `scripts/portfolios/_defs.py`: `disp_min: 0.0313` nella spec XS01.
- Il backtest canonico (`build_everything`) resta NON filtrato → il live fara'
meglio del backtest, coerente con le altre guardie.
Unit check: gate blocca panel piatto / apre panel disperso / default off invariato.
99/99 test. Nota macro della giornata: FC01 funding-carry SCARTATA
(diario separato) — il protocollo promuove ~1 idea su molte, come deve.
+86
View File
@@ -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.
+92
View File
@@ -0,0 +1,92 @@
# 2026-06-11 — Audit di sistema + verità contabile sul netting (v1.1.24)
Seconda tornata della giornata (dopo lo sweep strategie): analisi del SISTEMA live
(esecuzione, stato, feed, processo) con 3 indagini parallele + fix.
## Audit live (dal reset 2026-06-10T21:24Z)
- **Churn phantom-TP quantificato**: 20 round-trip fantasma sui fade ETH (2 finestre,
13:06Z e 17:32-17:58Z, feed che stampava wick ~1640 con mercato a 1675-90 per 25 min).
Somma real_pnl 2.35 USD vs sim_pnl +80 USD: il real-truth ledger ha contenuto, il gate
TP_PHANTOM (v1.1.23) copre il pattern da stasera.
- **Il conto NON quadrava coi libri**: short 0.027 ETH in più (l'hedge long ETH del pair
ETH/SOL mangiato dai close reduce-only dei fade) e BTC flat con MR02_BTC convinto di
essere short (TP resting fillato da uno spike REALE del book a 3.8%, +6.6$ non bookati,
che il worker riconcilierà alla chiusura sim) col **disaster-SL residuo sul book a
posizione flat**. Bonifica eseguita: DSL `USDC-SLMB-26521` cancellato, riallineo
+0.027 ETH reduce-only (fill 1682.5) → conto == libri (verificato).
## Root-cause strutturale
Le **quote per-worker con ordini reduce-only su un conto a NETTING si rompono quando due
worker hanno direzioni opposte sullo stesso strumento** (pairs long ETH vs fade short ETH):
- un close reduce-only può essere **cappato** (Deribit riduce l'amount in silenzio) → il
ledger bookava la chiusura PIENA perché `Fill.amount` era il richiesto, non il fillato;
- un close reduce-only nel verso "sbagliato" rispetto al netto viene **respinto** → la
gamba pairs resta orfana sul conto, ma il worker bookava il PnL al prezzo sim e azzerava
lo stato (3 volte oggi: PnL fantasma nel ledger real-truth, ETH/SOL di fatto short nudo).
## Fix (v1.1.24) — verità, non workaround
1. **`Fill.filled_amount`** (da `order.filled_amount`, fallback trades/history): tutti i
ledger usano il fillato; nota "FILL PARZIALE" nel Fill.
2. **`REAL_CLOSE_PARTIAL`** (log + Telegram): close che filla meno del residuo → residuo
orfano dichiarato, `REAL_CLOSE verified=false`.
3. **Pairs per-gamba**: PnL bookato SOLO per gambe con fill verificato; gamba respinta →
record in `orphan_legs` (persistito nello status) + alert `PAIR_LEG_ORPHAN`;
`applied=True` (real-truth) solo con ENTRAMBE le gambe → altrimenti fallback sim
DICHIARATO (meglio del numero mezzo-reale di prima).
4. **`REAL_DIVERGENCE` anche su jsonl** (prima solo Telegram: l'audit ha dovuto
ricostruire gli episodi dai REAL_CLOSE).
5. **Runner: tick isolato per-worker** — un'eccezione in un worker non salta più gli
altri né l'update equity; streak per-worker con alert `WORKER_ERROR_STREAK` a 5.
Test: 2 nuovi (partial-close, orphan-leg) + fixture aggiornate → 106 passed.
## Decisione di design APERTA (per l'utente)
Finché pairs e fade condividono strumenti in direzioni opposte, le chiusure possono
orfanizzarsi: ora è VISIBILE e contabilizzato giusto, ma non eliminato. Opzioni:
A) **Position-manager centrale per strumento** (il runner netta i delta di tutti i
worker e manda UN ordine; ricostruisce le quote contabilmente) — corretto ma
invasivo, da progettare con calma;
B) **Sotto-conti Deribit separati** per famiglia (pairs vs fade) — pulito, richiede
setup conto e client multi-token;
C) **Status quo monitorato**: alert PAIR_LEG_ORPHAN + riallineo manuale (oggi: 1 giro
in 2 minuti) — accettabile su testnet, NON per capitale vero.
Raccomandazione: C ora, A prima di passare a capitale reale.
**Secondo passo — FATTO (v1.1.25): NETTING delle chiusure market.** `close_amount`
tenta il reduce-only e riesegue il residuo cappato/respinto in market puro (= il
netting contro le quote opposte: il conto si muove del delta esatto del libro).
Un solo punto di fix (anche `close_pair` ci passa). Fill combinato per il chiamante
(prezzo pesato, fee sommate), evento `NET_CLOSE` su log+Telegram a ogni fallback,
4 test dedicati. Niente più orfani per costruzione; `orphan_legs` resta come ultima
difesa se fallisce anche il market puro. Effetto collaterale benefico: la chiusura
futura della gamba ETH di ETH_SOL#2 (che sarebbe stata respinta di nuovo) ora
eseguirà correttamente. La scelta A-vs-B-vs-C resta aperta solo per la parte
RESTING (TP/DSL su book condiviso) e per i multi-asset.
**Primo passo verso A — FATTO (sera stessa): reconciler read-only.**
`scripts/analysis/reconcile_account.py`: per ogni strumento USDC confronta
atteso (Σ quote reali dai status.json: single-leg + pairs 2 gambe + orphan_legs
registrati = drift SPIEGATO) vs conto reale (`get_positions`, size/mark → coin),
tolleranza 1.5×step, anti-race (ricontrollo a 10s prima di segnalare). In crontab
host ORARIO (:40) con alert Telegram `ACCOUNT_DRIFT`. Al primo run ha beccato un
vero positivo: BTC libro short 0.0028 vs conto flat (il TP di MR02_BTC fillato
dallo spike reale delle 12:22Z, che il worker riconcilierà alla chiusura sim) —
esattamente la classe di divergenza che prima restava invisibile per ore.
## Altri esiti della tornata
- **Dedup engine gate** (TODO chiuso): `_port06_gate_common.py`, output 3 gate
byte-identici, nessun copy-drift trovato (la paura era fondata ma non ancora avverata).
- **Bug bfill `_daily_equity` quantificato** (TODO aggiornato): NON materiale — OOS
invariato per costruzione, FULL DD 3.46→3.67 col fix (l'attuale è lievemente ottimista),
nessun verdetto di gate a rischio. Lasciato documentato.
- **Drift monitor in produzione**: `drift_monitor.py` in crontab host (07:15 UTC,
Telegram): rolling 60/120g per famiglia vs distribuzione storica propria, warn < p5.
Oggi: FADE 120g al p2 (coda storica, nessun intervento), resto normale, XSEC p84.
- **Dati cerbero-bite refreshati**: catena opzioni a ~153k righe/asset (fino a oggi
19:30), pannello regime denso (net-GEX 644/673 ultimi 7g). Sempre un solo regime:
niente validazione edge, valore forward.
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -17,7 +17,7 @@ overrides:
# fisso, SOLO per statistica in vista di future implementazioni reali. NB: il portafoglio # fisso, SOLO per statistica in vista di future implementazioni reali. NB: il portafoglio
# live diverge ora dal PORT06 canonico (17 sleeve) -> DD reale ~5.35% vs 3.96% validato: # live diverge ora dal PORT06 canonico (17 sleeve) -> DD reale ~5.35% vs 3.96% validato:
# il prezzo di vedere il risultato reale puro (scelta utente). # il prezzo di vedere il risultato reale puro (scelta utente).
paper_sleeves: [TR01, ROT02, TSM01] paper_sleeves: [TR01, ROT02, TSM01, XS01]
# Frazione di capitale-sleeve per posizione (canonico backtest = 0.15). # Frazione di capitale-sleeve per posizione (canonico backtest = 0.15).
# 0.5 con leva 2x = 100% della fetta impegnata quando in posizione (max impiego # 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). # dei 2K senza debito di margine). NB: il DD scala ~lineare (~×3.3 vs validato).
@@ -56,3 +56,10 @@ overrides:
# Assicurazione per gli outage (runner fermo = exit non valutati); in # Assicurazione per gli outage (runner fermo = exit non valutati); in
# operativita' normale non scatta mai -> 0 costo Sharpe. 0 = disattivo. # operativita' normale non scatta mai -> 0 costo Sharpe. 0 = disattivo.
disaster_sl_pct: 0.30 disaster_sl_pct: 0.30
# REAL-TRUTH (2026-06-10, scelta utente): il ledger `capital` degli sleeve
# eseguiti si aggiorna col PnL dei FILL REALI (fee reali incluse) — il sim
# resta solo diagnostica nel log CLOSE (pnl_source/sim_pnl/real_pnl).
# Fallback al sim SOLO se il trade reale non e' mai esistito/fillato
# (REAL_OPEN_FAIL): flag pnl_source=sim_fallback nel log. Cosi' equity,
# ribilanci e sizing derivano dai soldi veri sul conto, non dal sim.
real_truth: true
+142
View File
@@ -0,0 +1,142 @@
"""REGRESSION-LOCK COMUNE dei gate PORT06 live (exit16 / trendmax / dip01).
Queste funzioni erano copiate quasi-verbatim in exit16_port06_impact.py,
trendmax_port06_impact.py e dip01_exit16_impact.py. Sono il regression-lock
delle DECISIONI LIVE (EXIT-16, swap hurst->trend, DIP01 EXIT-16): la copy-drift
fra le copie avrebbe corrotto i verdetti, quindi vivono qui in un'unica copia.
NON cambiare la matematica: i gate devono restare riproducibili byte-a-byte.
Se un nuovo gate richiede un comportamento diverso, PARAMETRIZZARE (come fu
fatto per hurst_mask/trend_max), mai biforcare una copia.
Contenuto:
build_trades_variant : replay ESATTO di risk_management.build_trades sulle
fade (mode="orig" == canonico), con i rami varianti
EXIT-16 (mode="exit16"), filtro trend (trend_max) e
loss-guard Hurst (hurst_mask) parametrici.
equity_from_trades : trade -> equity giornaliera normalizzata su IDX
(stesso flusso di combine_portfolio.fade_daily_equity).
port_metrics : metriche FULL/OOS del portafoglio con la STESSA
matematica pesi di Portfolio.backtest (weight_vector
su tutti gli sleeve, ribilancio come port_returns).
dd : max drawdown % di una equity.
NB: l'engine DIP01 (dip_trades in dip01_exit16_impact.py) NON e' una copia di
build_trades_variant ma un sibling deliberatamente diverso (long-only, mode
"orig_gap" gap-aware, j clampato a n-1 a fine serie, niente filtri trend/hurst)
-> resta nel suo script.
"""
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.strategy_research import atr
from scripts.analysis.risk_management import FEE_RT, LEV, POS, INIT
from scripts.analysis.combine_portfolio import (
_norm, IDX, port_returns, metrics, SPLIT,
)
from src.portfolio import weighting as W
BUFFER = 0.5 # EXIT-16 close-confirm (come in produzione)
EMA_LONG = 200
def build_trades_variant(ents, df, mode, trend_max, hurst_mask=None,
buffer=BUFFER, lev=LEV, fee_rt=FEE_RT, ema_long=EMA_LONG):
"""Replica ESATTA di risk_management.build_trades, con i rami varianti.
mode="orig" : SL intrabar al livello (SL prima del TP) == canonico.
mode="exit16" : SL intrabar OFF; TP intrabar al livello (priorita' nel bar);
SL solo se il CLOSE sfonda sl0 -/+ buffer*ATR14[j], fill a close[j].
trend_max : None = filtro OFF; 3.0 = config live.
hurst_mask : bool[i]=True -> salta l'ingresso (loss-guard storico).
"""
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 hurst_mask is not None and hurst_mask[i]:
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
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 equity_from_trades(df, trades):
"""Trade -> equity giornaliera su IDX (flusso di combine_portfolio.fade_daily_equity)."""
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
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)
def port_metrics(members: dict[str, pd.Series], p):
"""Metriche (FULL, OOS) del portafoglio p con la STESSA matematica pesi cap
di Portfolio.backtest."""
ids = p.sleeve_ids
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
drp = port_returns({i: members[i] for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
def dd(s):
"""Max drawdown % di una serie equity."""
pk = s.cummax()
return float(((pk - s) / pk).max() * 100)
@@ -0,0 +1,209 @@
"""ANALISI DI IMPATTO (sola lettura, da docs/TODO.md): bug bfill di `_daily_equity`.
IL BUG (scripts/analysis/honest_improve2.py:30):
daily = s.resample("1D").last().reindex(idx).ffill().bfill()
La serie `s` e' a PUNTI-TRADE (un valore di capitale per ogni exit). Il `reindex(idx)`
taglia PRIMA di forward-fillare: i giorni di IDX precedenti al primo trade DENTRO la
finestra restano NaN (il ffill non ha un valore precedente in-finestra da propagare) e
il `.bfill()` finale li riempie col capitale DOPO il primo trade in-finestra. Effetti:
1. l'ancora a idx[0] e' il capitale post-primo-trade-in-finestra, NON il capitale
portato avanti dall'ultimo trade PRIMA della finestra;
2. il rendimento del primo trade in-finestra viene CANCELLATO dalla serie daily
(la testa e' piatta al valore post-trade -> pct_change = 0 anche il giorno del trade).
CORREZIONE (qui, solo per confronto): ffill PRIMA del reindex (carry-forward su tutta la
storia trade) + testa pre-primo-trade-assoluto = capitale iniziale 1000. MAI valori dal futuro.
Sleeve canonici interessati (serie a punti-trade -> testa di IDX scoperta):
DIP01_BTC, PR_ETHBTC, PR_ETHBTC_15M, PR_LTCETH, PR_ADAETH, PR_BTCLTC, PR_ETHSOL,
TSM01, XS01 (questi due quasi-densi: punti daily/12h -> impatto atteso ~0).
TR01_basket / ROT02_rot passano da _daily_equity ma con punti PER-BARRA (densi dal
2018) -> verificati comunque qui via monkeypatch runtime (nessun file canonico toccato).
I fade (combine_portfolio.py:52) e SH01 (shape_ml_validate.py:124) usano lo stesso
pattern reindex+bfill ma su equity PER-BARRA con dati che iniziano prima di IDX[0]
-> il bfill e' un no-op (verificato: nessun NaN in testa).
NB: le metriche OOS canoniche affettano la STESSA serie daily a SPLIT (metrics(dr,
lo=SPLIT)); la distorsione sta solo in testa (2021) -> l'OOS e' invariato per
costruzione se il primo trade in-finestra precede lo SPLIT. Questo script lo misura.
Uso: uv run python scripts/analysis/daily_equity_bfill_impact.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import scripts.analysis.honest_improve2 as hi2
from scripts.analysis.honest_improve2 import _norm, dip_market_gated
from scripts.analysis.combine_portfolio import IDX, SPLIT, OOS_DATE, metrics, port_returns
from scripts.analysis.pairs_research import pairs_sim, pairs_sim_flat
from scripts.analysis.tsmom_research import tsmom_sim
from scripts.strategies.PR01_pairs_reversion import PAIRS as PAIR_DEFS
from scripts.strategies.XS01_cross_sectional import xsec_sim
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio import weighting as W
INIT = 1000.0
# ---------------- le due convenzioni ----------------
def daily_equity_buggy(ts_list, cap_list, idx):
"""Replica ESATTA di honest_improve2._daily_equity (per parity-check)."""
s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True))
s = s[~s.index.duplicated(keep="last")].sort_index()
return s.resample("1D").last().reindex(idx).ffill().bfill()
def daily_equity_fixed(ts_list, cap_list, idx, init=INIT):
"""CORRETTA: ancora = capitale portato avanti dall'ultimo trade PRIMA della
finestra (ffill prima del reindex); pre-primo-trade assoluto = capitale iniziale."""
s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True))
s = s[~s.index.duplicated(keep="last")].sort_index()
daily = s.resample("1D").last().ffill() # carry-forward su TUTTA la storia
daily = daily.reindex(idx).ffill() # coda oltre l'ultimo trade
return daily.fillna(init) # testa pre-primo-trade: capitale iniziale
def head_info(ts_list, cap_list, idx):
"""(primo giorno con trade dentro IDX, rendimento di testa perso dal bfill %)."""
s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True))
s = s[~s.index.duplicated(keep="last")].sort_index()
raw = s.resample("1D").last().reindex(idx) # senza fill: NaN = nessun trade quel giorno
first = raw.first_valid_index()
if first is None:
return None, 0.0
fixed = daily_equity_fixed(ts_list, cap_list, idx)
lost = (fixed.loc[first] / fixed.iloc[0] - 1) * 100 # ritorno idx[0]->primo trade-day
return first.date(), float(lost)
def m2(eq: pd.Series):
dr = eq.pct_change().fillna(0.0)
return metrics(dr), metrics(dr, lo=SPLIT)
def fmt_pair(label, b, f):
d_sh = f["sharpe"] - b["sharpe"]
d_dd = f["dd"] - b["dd"]
d_rt = f["ret"] - b["ret"]
return (f" {label:<22s}"
f"Sh {b['sharpe']:6.2f}->{f['sharpe']:6.2f} ({d_sh:+.3f}) "
f"DD {b['dd']:6.2f}->{f['dd']:6.2f} ({d_dd:+.3f}pp) "
f"ret {b['ret']:+9.1f}->{f['ret']:+9.1f} ({d_rt:+8.2f}pp)")
def main():
print("=" * 110)
print(" IMPATTO bug bfill _daily_equity (honest_improve2.py:30) — attuale vs corretto")
print(f" IDX {IDX[0].date()} -> {IDX[-1].date()} | OOS da {OOS_DATE} (slice a SPLIT={SPLIT} sui rendimenti daily)")
print("=" * 110)
# ---------------- [1] baseline canonica (bfill cosi' com'e') ----------------
print("\n[1] build_everything() canonico (2-3 min)...")
from scripts.analysis.report_families import build_everything
S, pairs, tsm, shape = build_everything()
base = {**S, **pairs, **tsm, **shape}
# ---------------- [2] ri-simula gli sleeve a punti-trade ----------------
print("[2] ri-simulazione sleeve a punti-trade (parity-check + versione corretta)...")
raw: dict[str, tuple] = {}
d = dip_market_gated("BTC", market_n=0, return_equity=True)
raw["DIP01_BTC"] = (d["eq_ts"], d["eq_v"])
for a, b_, p in PAIR_DEFS:
r = pairs_sim(a, b_, **p)
raw[f"PR_{a}{b_}"] = (r["eq_ts"], r["eq_v"])
r15 = pairs_sim_flat("ETH", "BTC", tf="15m", n=66, z_in=1.674, z_exit=1.0,
max_bars=35, flat_skip=True, pos=0.075)
raw["PR_ETHBTC_15M"] = (r15["eq_ts"], r15["eq_v"])
t = tsmom_sim()
raw["TSM01"] = (t["eq_ts"], t["eq_v"])
x = xsec_sim()
raw["XS01"] = (x["eq_ts"], x["eq_v"])
fixed: dict[str, pd.Series] = {}
print(f"\n {'sleeve':<16s}{'parity(max|diff|)':>18s}{'1o trade in IDX':>17s}{'ret testa perso%':>18s}")
for k, (ts, v) in raw.items():
bug = _norm(daily_equity_buggy(ts, v, IDX))
par = float((bug - base[k]).abs().max())
fixed[k] = _norm(daily_equity_fixed(ts, v, IDX))
first, lost = head_info(ts, v, IDX)
flag = "" if par < 1e-9 else " <-- PARITY FAIL"
print(f" {k:<16s}{par:>18.2e}{str(first):>17s}{lost:>+18.3f}{flag}")
# TR01/ROT02: passano da _daily_equity ma con punti per-barra (densi) ->
# ricalcolo con monkeypatch RUNTIME della funzione (nessun file toccato).
orig_de = hi2._daily_equity
try:
hi2._daily_equity = daily_equity_fixed
tr_f = _norm(hi2._tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], IDX))
rot_f = _norm(hi2._rot_daily_equity(IDX))
finally:
hi2._daily_equity = orig_de
for k, sf in (("TR01_basket", tr_f), ("ROT02_rot", rot_f)):
diff = float((sf - base[k]).abs().max())
print(f" {k:<16s}{'(denso)':>18s}{'':>17s}{diff:>18.2e} (diff fixed-vs-base: atteso ~0)")
fixed[k] = sf
# ---------------- [3] metriche per sleeve: attuale vs corretto ----------------
print("\n" + "=" * 110)
print(" (3) SLEEVE a punti-trade — FULL e OOS, attuale(bfill) -> corretto(carry-forward)")
print("=" * 110)
rows_oos_delta = {}
for k in fixed:
bf, bo = m2(base[k])
ff, fo = m2(fixed[k])
print(fmt_pair(f"{k} FULL", bf, ff))
print(fmt_pair(f"{k} OOS ", bo, fo))
rows_oos_delta[k] = (ff["sharpe"] - bf["sharpe"], ff["dd"] - bf["dd"],
fo["sharpe"] - bo["sharpe"], fo["dd"] - bo["dd"])
# ---------------- [4] PORT06: attuale vs corretto ----------------
print("\n" + "=" * 110)
print(" (4) PORT06 (cap PAIRS 0.33 + SHAPE 0.0588) — attuale vs corretto")
print("=" * 110)
p = PORTFOLIOS["PORT06"]
def port_m(members):
ids = p.sleeve_ids
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
drp = port_returns({i: members[i] for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
members_fix = {**base, **fixed}
bf, bo = port_m(base)
ff, fo = port_m(members_fix)
print(fmt_pair("PORT06 FULL", bf, ff))
print(fmt_pair("PORT06 OOS ", bo, fo))
# ---------------- [5] verdetto ----------------
print("\n" + "=" * 110)
print(" (5) VERDETTO (soglie materialita': >0.1 Sharpe o >0.5pp DD su PORT06 OOS)")
print("=" * 110)
d_sh_oos = abs(fo["sharpe"] - bo["sharpe"])
d_dd_oos = abs(fo["dd"] - bo["dd"])
d_sh_full = abs(ff["sharpe"] - bf["sharpe"])
d_dd_full = abs(ff["dd"] - bf["dd"])
materiale = d_sh_oos > 0.1 or d_dd_oos > 0.5
print(f" PORT06 OOS : dSharpe {fo['sharpe']-bo['sharpe']:+.4f} dDD {fo['dd']-bo['dd']:+.4f}pp"
f" -> {'MATERIALE' if materiale else 'NON materiale'}")
print(f" PORT06 FULL: dSharpe {ff['sharpe']-bf['sharpe']:+.4f} dDD {ff['dd']-bf['dd']:+.4f}pp")
worst = sorted(rows_oos_delta.items(), key=lambda kv: -abs(kv[1][0]) - abs(kv[1][1]) / 10)
print(" Sleeve piu' toccati (dSharpe FULL, dDD FULL, dSharpe OOS, dDD OOS):")
for k, (ds, dd_, dso, ddo) in worst[:5]:
print(f" {k:<16s} FULL {ds:+.3f} / {dd_:+.3f}pp OOS {dso:+.3f} / {ddo:+.3f}pp")
print("\n Nota strutturale: l'OOS canonico e' uno slice a SPLIT della stessa serie daily;")
print(" la distorsione bfill vive solo in testa (prima del primo trade in IDX) -> se il")
print(" primo trade in-finestra precede lo SPLIT, l'OOS e' INVARIATO per costruzione.")
if __name__ == "__main__":
main()
+2 -13
View File
@@ -33,11 +33,9 @@ sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data from src.data.downloader import load_data
from scripts.analysis.strategy_research import atr from scripts.analysis.strategy_research import atr
from scripts.analysis.combine_portfolio import ( from scripts.analysis.combine_portfolio import _norm, IDX, metrics, SPLIT, OOS_DATE
_norm, IDX, port_returns, metrics, SPLIT, OOS_DATE, from scripts.analysis._port06_gate_common import port_metrics
)
from scripts.portfolios._defs import PORTFOLIOS from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio import weighting as W
FEE_RT, LEV, POS, INIT = 0.001, 3.0, 0.15, 1000.0 FEE_RT, LEV, POS, INIT = 0.001, 3.0, 0.15, 1000.0
BUFFER = 0.5 BUFFER = 0.5
@@ -139,15 +137,6 @@ def cell_metrics(eq):
return metrics(dr), metrics(dr, lo=SPLIT) return metrics(dr), metrics(dr, lo=SPLIT)
def port_metrics(members, p):
ids = p.sleeve_ids
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
drp = port_returns({i: members[i] for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
def main(): def main():
p = PORTFOLIOS["PORT06"] p = PORTFOLIOS["PORT06"]
print("=" * 104) print("=" * 104)
+102
View File
@@ -0,0 +1,102 @@
"""Drift monitor per-famiglia — il rolling-return corrente di ogni famiglia vs la
DISTRIBUZIONE STORICA dei propri rolling-return (stessa finestra, storia 2021+).
Non è un filtro di trading: è OSSERVABILITÀ (la protezione giusta contro il drift è
accorgersene presto, non ritoccare i parametri — lezione 2026-06-11: le FADE al 2°
percentile sul 120g sono state trovate a mano; questo script lo rende ripetibile).
Percentile basso = la famiglia sta attraversando uno dei suoi tratti peggiori:
- sotto P_WARN (5%): segnalato — coerente con la coda storica, OSSERVARE;
- il PORT06 complessivo sotto P_WARN è più serio (la diversificazione non copre).
Equity dal builder canonico (all_sleeve_equities → parità coi gate).
uv run python scripts/analysis/drift_monitor.py # stampa
uv run python scripts/analysis/drift_monitor.py --telegram # + invio Telegram
"""
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
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio.sleeves import all_sleeve_equities
from src.portfolio import weighting as W
WINDOWS = (60, 120) # giorni
P_WARN = 5.0 # percentile sotto cui segnalare
def family_returns():
"""Rendimenti daily per famiglia (equal-weight intra-famiglia) + PORT06 (pesi cap)."""
p = PORTFOLIOS["PORT06"]
eq = dict(all_sleeve_equities())
ids = list(p.sleeve_ids)
fams: dict[str, list] = {}
for i in ids:
fams.setdefault(W.family_of(i), []).append(i)
out = {}
for f, members in sorted(fams.items()):
out[f] = port_returns({i: eq[i] for i in members},
{i: 1 / len(members) for i in members})
dr = pd.DataFrame({i: eq[i].pct_change().fillna(0.0) for i in ids})
w = W.weight_vector("cap", ids, dr, caps=p.caps, clusters=p.clusters)
out["PORT06"] = port_returns({i: eq[i] for i in ids}, w)
return out
def drift_rows():
rows = []
for name, r in family_returns().items():
for win in WINDOWS:
roll = (1 + r).rolling(win).apply(np.prod, raw=True) - 1
roll = roll.dropna()
if len(roll) < 100:
continue
cur = float(roll.iloc[-1])
pct = float((roll < cur).mean() * 100)
rows.append(dict(name=name, win=win, cur=cur * 100, pct=pct,
p5=float(roll.quantile(0.05) * 100),
med=float(roll.median() * 100)))
return rows
def build_report(rows) -> tuple[str, bool]:
warn = [r for r in rows if r["pct"] < P_WARN]
L = ["📉 <b>Drift monitor</b> — rolling-return vs storia propria (2021+)"]
L.append("<pre>" + f"{'famiglia':<9}{'win':>5}{'corr%':>8}{'pct':>6}{'p5%':>8}{'med%':>7}")
for r in rows:
flag = " ⚠️" if r["pct"] < P_WARN else ""
L.append(f"{r['name']:<9}{r['win']:>4}g{r['cur']:>+8.1f}{r['pct']:>5.0f}%"
f"{r['p5']:>+8.1f}{r['med']:>+7.1f}{flag}")
L.append("</pre>")
if warn:
names = ", ".join(f"{r['name']} {r['win']}g (p{r['pct']:.0f})" for r in warn)
L.append(f"⚠️ sotto il p{P_WARN:.0f} storico: {names} — coda storica della famiglia: "
"OSSERVARE, non ritoccare i parametri (drift ≠ rottura; "
"vedi docs/diary/2026-06-11-stability-sweep.md)")
else:
L.append(f"✅ tutte le famiglie sopra il p{P_WARN:.0f} storico")
return "\n".join(L), bool(warn)
def main():
rows = drift_rows()
report, warned = build_report(rows)
import re
print(re.sub(r"</?(b|pre)>", "", report))
if "--telegram" in sys.argv:
from src.live.telegram_notifier import send_telegram
ok = send_telegram(report)
print(f"[telegram] inviato: {ok}")
return warned
if __name__ == "__main__":
main()
+8 -101
View File
@@ -24,106 +24,23 @@ from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data from src.data.downloader import load_data
from scripts.analysis.strategy_research import atr from scripts.analysis.risk_management import strats_for
from scripts.analysis.risk_management import strats_for, FEE_RT, LEV, POS, INIT from scripts.analysis.combine_portfolio import OOS_DATE
from scripts.analysis.combine_portfolio import ( from scripts.analysis._port06_gate_common import (
fade_daily_equity, _norm, IDX, port_returns, metrics, SPLIT, OOS_DATE, build_trades_variant, equity_from_trades, port_metrics, dd as _dd,
) )
from scripts.portfolios._defs import PORTFOLIOS 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): def fade_equity_variant(asset, fn, params, mode):
"""Stesso flusso di combine_portfolio.fade_daily_equity ma con build_trades_variant.""" """Stesso flusso di combine_portfolio.fade_daily_equity ma con build_trades_variant."""
df = load_data(asset, "1h") 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) trades = build_trades_variant(fn(df, **params), df, mode=mode, trend_max=3.0)
n = len(df) return equity_from_trades(df, trades)
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(): def main():
@@ -174,17 +91,9 @@ def main():
for sid in fade_ids: for sid in fade_ids:
members_e16[sid] = eq_e16[sid] # sostituisco SOLO le 6 colonne fade 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) dentro port_metrics
# pesi cap canonici (gli stessi che usa Portfolio.backtest) f_b, o_b = port_metrics(members_base, p)
dr_base = pd.DataFrame({i: members_base[i].pct_change().fillna(0.0) for i in ids}) f_e, o_e = port_metrics(members_e16, p)
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("\n" + "=" * 96)
print(f" [3] PORT06 — pesi={p.weighting} caps={p.caps} | OOS da {OOS_DATE} | leva3x interna fade, pos0.15") print(f" [3] PORT06 — pesi={p.weighting} caps={p.caps} | OOS da {OOS_DATE} | leva3x interna fade, pos0.15")
@@ -207,8 +116,6 @@ def main():
f"{'orig DD%':>10s}{'e16 DD%':>10s}") f"{'orig DD%':>10s}{'e16 DD%':>10s}")
for sid in fade_ids: for sid in fade_ids:
ro = eq_orig[sid]; re = eq_e16[sid] 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 rro = (ro.iloc[-1] / ro.iloc[0] - 1) * 100
rre = (re.iloc[-1] / re.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}" print(f" {sid:<10s}{rro:>12.1f}{rre:>14.1f}{rre-rro:>+10.1f}"
+231
View File
@@ -0,0 +1,231 @@
"""FC01 — Funding-carry market-neutral (ricerca, 2026-06-10).
Idea: su Deribit i long pagano gli short quando il funding e' positivo (e
viceversa). W12 (scartata) shortava il perp su funding alto = direzionale.
Qui il meccanismo NUOVO e' il CARRY NEUTRALE: short della gamba con funding
alto / long della gamba con funding basso (BTC vs ETH, dollar-neutral),
incassando il DIFFERENZIALE di funding con esposizione residua = solo lo
spread ETH/BTC (correlazione ~0.95).
Dati REALI: data/regime/{btc,eth}_funding.parquet (orario, 2019-12 -> 2026-06,
interest_1h effettivo + index_price). Causale: decisione al close t con
funding noto fino a t; accrual dal bar t+1; fee 0.10% RT per GAMBA.
Varianti:
FC-A spread-carry 2 gambe (il candidato): entra quando lo spread di funding
smussato supera la soglia, esce quando rientra / max_bars.
FC-B single-asset carry direzionale (confronto onesto con W12): short se
funding smussato > thr, long se < -thr.
Protocollo: TRAIN fino a OOS_DATE (2023-11-01) per scegliere la config,
OOS dopo; griglia robustezza; sweep fee; breakdown annuale.
uv run python scripts/analysis/funding_carry_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))
FEE_RT = 0.001 # 0.10% RT per gamba (taker, baseline progetto)
OOS_DATE = "2023-11-01"
HRS_YEAR = 24 * 365
def load_panel():
btc = pd.read_parquet("data/regime/btc_funding.parquet")
eth = pd.read_parquet("data/regime/eth_funding.parquet")
for d in (btc, eth):
d["dt"] = pd.to_datetime(d["timestamp"], unit="ms")
m = btc.set_index("dt")[["interest_1h", "index_price"]].rename(
columns={"interest_1h": "f_btc", "index_price": "p_btc"}).join(
eth.set_index("dt")[["interest_1h", "index_price"]].rename(
columns={"interest_1h": "f_eth", "index_price": "p_eth"}),
how="inner").sort_index()
m = m.dropna()
return m
def explore(m):
print("=" * 96)
print(" [0] ESPLORAZIONE — funding orario reale Deribit, "
f"{m.index[0].date()} -> {m.index[-1].date()} ({len(m)} ore)")
print("=" * 96)
for a in ("btc", "eth"):
f = m[f"f_{a}"] * HRS_YEAR * 100 # annualizzato %
print(f" {a.upper()}: funding annualizzato mean {f.mean():+6.2f}% "
f"med {f.median():+6.2f}% p10 {f.quantile(.1):+7.2f}% "
f"p90 {f.quantile(.9):+7.2f}% %ore>0 {100*(f>0).mean():.0f}%")
sp = (m["f_eth"] - m["f_btc"]) * HRS_YEAR * 100
print(f" SPREAD ETH-BTC annualizzato: mean {sp.mean():+6.2f}% "
f"p10 {sp.quantile(.1):+7.2f}% p90 {sp.quantile(.9):+7.2f}%")
# persistenza: autocorr dello spread smussato 24h a vari lag
s24 = (m["f_eth"] - m["f_btc"]).rolling(24).mean()
for lag in (24, 72, 168):
c = s24.autocorr(lag)
print(f" autocorr spread(24h-smooth) lag {lag:>4}h: {c:+.3f}")
# quanto duramo sopra soglia? episodi |spread ann| > 10%
thr = 0.10 / HRS_YEAR
above = (s24.abs() > thr).astype(int)
runs = (above.groupby((above != above.shift()).cumsum()).sum())
runs = runs[runs > 0]
if len(runs):
print(f" episodi |spread|>10% ann: {len(runs)} durata mediana "
f"{runs.median():.0f}h p90 {runs.quantile(.9):.0f}h")
# ---------------------------------------------------------------------------
# Backtest FC-A: spread-carry 2 gambe
# ---------------------------------------------------------------------------
def carry_pair(m, smooth=72, thr_ann=10.0, exit_frac=0.0, max_bars=24 * 30,
fee_rt=FEE_RT, sl=None):
"""Entra quando |spread smussato| > thr (annualizzato %); short la gamba
col funding alto, long l'altra, 1x notional per gamba. Esce quando lo
spread smussato scende sotto exit_frac*thr (o cambia segno) o max_bars.
Ritorna array di net-return per trade + serie equity oraria (additiva)."""
f_sp = (m["f_eth"] - m["f_btc"]).rolling(smooth).mean().to_numpy()
fe = m["f_eth"].to_numpy()
fb = m["f_btc"].to_numpy()
pe = m["p_eth"].to_numpy()
pb = m["p_btc"].to_numpy()
n = len(m)
thr = thr_ann / 100 / HRS_YEAR
ex = exit_frac * thr
sli = m.index[:n] if sl is None else None
rets, lens, accs = [], [], []
eq = np.zeros(n)
i = smooth
while i < n - 1:
s = f_sp[i]
if not np.isfinite(s) or abs(s) <= thr:
i += 1
continue
d = -1 if s > 0 else 1 # s>0: ETH paga di piu' -> short ETH/long BTC
e_eth, e_btc = pe[i], pb[i]
acc = 0.0
j = i + 1
end = min(n - 1, i + max_bars)
while j <= end:
# accrual del funding sull'ora j: short riceve +f, long paga f
acc += (-d) * fe[j] + d * fb[j]
if abs(f_sp[j]) <= ex or np.sign(f_sp[j]) != np.sign(s):
break
j += 1
j = min(j, end)
price_leg = d * (pe[j] - e_eth) / e_eth - d * (pb[j] - e_btc) / e_btc
net = price_leg + acc - 2 * fee_rt
rets.append(net)
lens.append(j - i)
accs.append(acc)
eq[j] += net
i = j + 1
rets = np.array(rets)
eqs = pd.Series(eq, index=m.index).cumsum()
return rets, np.array(lens), np.array(accs), eqs
# ---------------------------------------------------------------------------
# Backtest FC-B: carry direzionale single-asset (confronto/W12 onesto)
# ---------------------------------------------------------------------------
def carry_single(m, asset="eth", smooth=72, thr_ann=20.0, exit_frac=0.0,
max_bars=24 * 30, fee_rt=FEE_RT):
f = m[f"f_{asset}"].rolling(smooth).mean().to_numpy()
fr = m[f"f_{asset}"].to_numpy()
p = m[f"p_{asset}"].to_numpy()
n = len(m)
thr = thr_ann / 100 / HRS_YEAR
ex = exit_frac * thr
rets = []
i = smooth
while i < n - 1:
s = f[i]
if not np.isfinite(s) or abs(s) <= thr:
i += 1
continue
d = -1 if s > 0 else 1 # funding alto -> short (incassa)
e = p[i]
acc = 0.0
j = i + 1
end = min(n - 1, i + max_bars)
while j <= end:
acc += (-d) * fr[j]
if abs(f[j]) <= ex or np.sign(f[j]) != np.sign(s):
break
j += 1
j = min(j, end)
net = d * (p[j] - e) / e + acc - fee_rt
rets.append(net)
i = j + 1
return np.array(rets)
def stats(rets, idx_len_hours, label="", lens=None, accs=None):
if len(rets) == 0:
return f" {label:<28s} 0 trade"
yrs = idx_len_hours / HRS_YEAR
pnl = rets.sum() * 100
win = (rets > 0).mean() * 100
tpy = len(rets) / yrs
sh = rets.mean() / (rets.std() + 1e-12) * np.sqrt(max(tpy, 1e-9))
extra = ""
if lens is not None and len(lens):
extra = f" | hold med {np.median(lens):.0f}h"
if accs is not None and len(accs):
extra += f" | carry quota {100*np.sum(accs)/max(np.sum(rets),1e-9):.0f}%"
return (f" {label:<28s} {len(rets):>4d} tr | win {win:>4.0f}% | "
f"PnL {pnl:>+7.1f}% | {tpy:>5.1f} tr/anno | Sh {sh:>5.2f}{extra}")
def main():
m = load_panel()
explore(m)
cut = m.index.searchsorted(pd.Timestamp(OOS_DATE))
mtr, moo = m.iloc[:cut], m.iloc[cut:]
print(f"\n TRAIN {m.index[0].date()} -> {OOS_DATE} | OOS -> {m.index[-1].date()}")
print("\n" + "=" * 96)
print(" [1] FC-A spread-carry 2 gambe (fee 0.10% RT x2 gambe) — griglia su TRAIN")
print("=" * 96)
grid = []
for smooth in (24, 72, 168):
for thr in (5.0, 10.0, 20.0):
r, ln, ac, _ = carry_pair(mtr, smooth=smooth, thr_ann=thr)
grid.append((smooth, thr, r))
print(stats(r, len(mtr), f"TRAIN s{smooth} thr{thr:.0f}%", ln, ac))
print("\n Le stesse config in OOS (mai usate per scegliere):")
for smooth in (24, 72, 168):
for thr in (5.0, 10.0, 20.0):
r, ln, ac, _ = carry_pair(moo, smooth=smooth, thr_ann=thr)
print(stats(r, len(moo), f"OOS s{smooth} thr{thr:.0f}%", ln, ac))
print("\n" + "=" * 96)
print(" [2] FC-B carry direzionale single-asset (confronto, fee 0.10% RT)")
print("=" * 96)
for a in ("btc", "eth"):
for thr in (10.0, 30.0):
rtr = carry_single(mtr, a, thr_ann=thr)
roo = carry_single(moo, a, thr_ann=thr)
print(stats(rtr, len(mtr), f"TRAIN {a} thr{thr:.0f}%"))
print(stats(roo, len(moo), f"OOS {a} thr{thr:.0f}%"))
print("\n" + "=" * 96)
print(" [3] FC-A: sweep fee (config mediana s72 thr10) e breakdown annuale")
print("=" * 96)
for fee in (0.0005, 0.001, 0.002):
r, ln, ac, _ = carry_pair(m, smooth=72, thr_ann=10.0, fee_rt=fee)
print(stats(r, len(m), f"FULL fee {fee*100:.2f}% RT/gamba", ln, ac))
_, _, _, eq = carry_pair(m, smooth=72, thr_ann=10.0)
yr = eq.groupby(eq.index.year).apply(lambda s: (s.iloc[-1] - s.iloc[0]) * 100)
print(" annuale (PnL additivo %):",
{int(k): round(float(v), 1) for k, v in yr.items()})
if __name__ == "__main__":
main()
+322
View File
@@ -0,0 +1,322 @@
"""GATE PORT06 — griglia ETH (vincitore gioco "Grid Traders", sessione 3).
Il gioco (scripts/games/grid_*, regola STRATEGIA_GRIGLIA.md) ha promosso una
griglia geometrica asimmetrica su ETH: range profondo sotto, corto sopra,
4 livelli (passo ~5%), SL catastrofale. Ma il motore del gioco somma i PnL
REALIZZATI per trade e NON misura l'equity mark-to-market: l'inventario a
tranche dentro un drawdown e' rischio vero che il fitness non vede.
Questo gate risponde alla domanda "si puo' inserire?" con il metodo del progetto:
[1] STANDALONE mark-to-market (engine MTM dedicato, fill onesti):
equity per barra = capitale + inventario valutato al close; fee 0.10% RT
(taker; i fill ai livelli sarebbero LIMIT->maker, quindi conservativo);
SL gap-aware (gap sotto lo stop -> fill all'open, non al livello);
flat-skip (nessun fill sulle candele O=H=L=C di ETH 15m, live-realizable).
Metriche FULL/OOS con le stesse funzioni degli altri gate + stress fee 2x.
[2] CORRELAZIONE coi 19 sleeve PORT06 (il sospetto: e' la stessa reversione
ETH delle fade MR, incassata con inventory risk).
[3] ROBUSTEZZA: plateau range_down x range_up attorno al vincitore.
[4] GATE PORT06: baseline vs +GRID (full e half size). Promosso solo se
OOS Sharpe non peggiora E DD non sale (criterio standard).
uv run python scripts/analysis/grid_game_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, IDX
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio import weighting as W
from src.data.downloader import load_data
POS, LEV = 0.15, 3.0 # config canonica sleeve (== build_everything)
FEE_SIDE = 0.0005 # 0.05%/lato = 0.10% RT
# top-3 del torneo (data/games/grid_result.json)
WINNER_15M = dict(tf="15m", range_down=0.171, range_up=0.046, levels=4,
sl_buf=0.124, tp_buf=0.048, max_bars=2143)
TOP2_30M = dict(tf="30m", range_down=0.158, range_up=0.048, levels=4,
sl_buf=0.081, tp_buf=0.044, max_bars=613)
TOP3_1H = dict(tf="1h", range_down=0.134, range_up=0.053, levels=4,
sl_buf=0.150, tp_buf=0.063, max_bars=562)
_RESAMPLE = {"30m": ("15m", "30min")}
def _load(asset, tf):
if tf in _RESAMPLE:
base, rule = _RESAMPLE[tf]
d = load_data(asset, base).copy()
d["dt"] = pd.to_datetime(d["datetime"])
g = d.set_index("dt").resample(rule).agg(
{"open": "first", "high": "max", "low": "min", "close": "last",
"volume": "sum"}).dropna(subset=["open", "close"]).reset_index()
g["datetime"] = g["dt"]
return g
d = load_data(asset, tf).copy()
d["dt"] = pd.to_datetime(d["datetime"])
return d
def grid_mtm(asset="ETH", *, tf, range_down, range_up, levels, sl_buf, tp_buf,
max_bars, pos=POS, lev=LEV, fee_side=FEE_SIDE, flat_skip=True,
close_only=False):
"""Griglia STRATEGIA_GRIGLIA.md con contabilita' mark-to-market.
Ritorna (equity daily Series base 1.0, stats dict). Causale: deploy sul
close, fill dalle barre successive lungo il percorso O->L->H->C / O->H->L->C.
"""
df = _load(asset, tf)
op = df["open"].to_numpy(float)
hi = df["high"].to_numpy(float)
lo = df["low"].to_numpy(float)
cl = df["close"].to_numpy(float)
dt = pd.to_datetime(df["datetime"]).to_numpy()
n = len(cl)
ratio = ((1 + range_up) / (1 - range_down)) ** (1.0 / levels)
if ratio - 1 <= 1.5 * 2 * fee_side: # vincolo break-even §4
raise ValueError("break-even violato")
flat = (op == hi) & (op == lo) & (op == cl)
capital = 1.0
eq = np.empty(n)
eq[:20] = 1.0
# stato episodio
active = False
lv = []; filled = []; tn = 0.0; sl = tp = 0.0; ep_end = 0
n_open = 0
trades = wins = stops = 0
deploy_i = -1
def mtm(px):
u = 0.0
for k in range(levels):
if filled[k]:
u += tn * (px / lv[k] - 1.0)
return capital + u
i = 20
for j in range(20, n):
if not active:
# deploy sul close di j (fill da j+1)
px = cl[j]
rl_ = px * (1 - range_down)
lv = [rl_ * ratio ** k for k in range(levels + 1)]
sl = rl_ * (1 - sl_buf)
tp = lv[levels] * (1 + tp_buf)
filled = [False] * levels
n_open = 0
tn = capital * pos * lev / levels # notional per tranche
ep_end = j + max_bars
active = True
deploy_i = j
eq[j] = capital
continue
if flat_skip and flat[j]:
eq[j] = mtm(cl[j])
continue
cur = cl[j - 1]
if close_only:
# fill solo su attraversamento del CLOSE (le wick non fillano):
# stress anti-spike-print del feed testnet
pts = (cl[j],)
else:
pts = (op[j], lo[j], hi[j], cl[j]) if cl[j] >= op[j] \
else (op[j], hi[j], lo[j], cl[j])
died = False
for pi, q in enumerate(pts):
q = float(q)
if q == cur:
continue
if q < cur:
from bisect import bisect_left
k1 = bisect_left(lv, q)
k2 = bisect_left(lv, cur) - 1
for k in range(min(k2, levels - 1), max(k1, 0) - 1, -1):
if not filled[k]:
filled[k] = True
n_open += 1
capital -= fee_side * tn # fee ingresso
if q <= sl:
# STOP: gap all'open -> fill all'open, altrimenti al livello sl
fill = q if (pi == 0 and q <= sl) else sl
for k in range(levels):
if filled[k]:
r = fill / lv[k]
capital += tn * (r - 1.0) - fee_side * tn * r
filled[k] = False
trades += 1
stops += 1
n_open = 0
died = True
cur = q
break
else:
from bisect import bisect_right
m1 = bisect_right(lv, cur)
m2 = bisect_right(lv, q) - 1
for m in range(max(m1, 1), min(m2, levels) + 1):
k = m - 1
if filled[k]:
r = lv[m] / lv[k]
capital += tn * (r - 1.0) - fee_side * tn * r
filled[k] = False
n_open -= 1
trades += 1
wins += 1
if q >= tp:
for k in range(levels):
if filled[k]:
r = tp / lv[k]
capital += tn * (r - 1.0) - fee_side * tn * r
filled[k] = False
trades += 1
wins += 1
n_open = 0
died = True
cur = q
break
cur = q
if not died and j >= ep_end:
# timeout: liquida al close
for k in range(levels):
if filled[k]:
r = cl[j] / lv[k]
capital += tn * (r - 1.0) - fee_side * tn * r
filled[k] = False
trades += 1
wins += r > 1.0 + 2 * fee_side
n_open = 0
died = True
if died:
active = False
eq[j] = capital
else:
eq[j] = mtm(cl[j])
s = pd.Series(eq, index=pd.DatetimeIndex(dt)).resample("1D").last().dropna()
s = s / s.iloc[0]
return s, dict(trades=trades, win=100.0 * wins / max(1, trades), stops=stops)
def std(eqd):
"""Metriche FULL/OOS con le funzioni standard del progetto."""
e = eqd.reindex(IDX).ffill().bfill()
dr = e.pct_change().fillna(0.0)
return metrics(dr), metrics(dr, lo=SPLIT)
def main():
p = PORTFOLIOS["PORT06"]
print("=" * 100)
print(" GATE PORT06 — griglia ETH (vincitore gioco Grid Traders) | "
f"pos={POS} lev={LEV} | OOS da {OOS_DATE}")
print("=" * 100)
# [1] STANDALONE mark-to-market
print("\n[1] STANDALONE mark-to-market (fee 0.10% RT, flat-skip, SL gap-aware):")
print(f" {'cfg':<22s}{'trd':>7s}{'win%':>6s}{'stops':>6s}{'FULL%':>8s}{'CAGR%':>7s}"
f"{'DD%':>7s}{'Shrp':>6s} | {'OOS%':>7s}{'oDD%':>6s}{'oShrp':>6s}")
eqs = {}
for tag, cfg in [("WINNER 15m", WINNER_15M), ("top2 30m", TOP2_30M),
("top3 1h", TOP3_1H)]:
eqd, st = grid_mtm("ETH", **cfg)
f, o = std(eqd)
eqs[tag] = eqd
print(f" {tag:<22s}{st['trades']:>7d}{st['win']:>6.1f}{st['stops']:>6d}"
f"{f['ret']:>+8.0f}{f['cagr']:>7.1f}{f['dd']:>7.2f}{f['sharpe']:>6.2f}"
f" | {o['ret']:>+7.0f}{o['dd']:>6.2f}{o['sharpe']:>6.2f}")
# stress: fee 2x e no flat-skip sul winner
eq2, st2 = grid_mtm("ETH", **WINNER_15M, fee_side=0.001)
f2, o2 = std(eq2)
eqnf, _ = grid_mtm("ETH", **WINNER_15M, flat_skip=False)
fnf, onf = std(eqnf)
print(f" {'winner fee 2x':<22s}{st2['trades']:>7d}{st2['win']:>6.1f}{'':>6s}"
f"{f2['ret']:>+8.0f}{f2['cagr']:>7.1f}{f2['dd']:>7.2f}{f2['sharpe']:>6.2f}"
f" | {o2['ret']:>+7.0f}{o2['dd']:>6.2f}{o2['sharpe']:>6.2f}")
print(f" {'winner no-flat-skip':<22s}{'':>7s}{'':>6s}{'':>6s}"
f"{fnf['ret']:>+8.0f}{fnf['cagr']:>7.1f}{fnf['dd']:>7.2f}{fnf['sharpe']:>6.2f}"
f" | {onf['ret']:>+7.0f}{onf['dd']:>6.2f}{onf['sharpe']:>6.2f}")
grid_eq = eqs["WINNER 15m"]
# [2] CORRELAZIONI coi sleeve PORT06
from src.portfolio.sleeves import all_sleeve_equities
eq_base = dict(all_sleeve_equities())
gr = grid_eq.reindex(IDX).ffill().bfill().pct_change().fillna(0.0)
print("\n[2] CORRELAZIONE rendimenti giornalieri GRID_ETH15M vs sleeve PORT06 (top 8):")
cors = {}
for sid, e in eq_base.items():
r = e.reindex(IDX).ffill().bfill().pct_change().fillna(0.0)
cors[sid] = gr.corr(r)
for sid, cv in sorted(cors.items(), key=lambda kv: -abs(kv[1]))[:8]:
print(f" {sid:<16s} {cv:+.3f}")
# [3] ROBUSTEZZA: plateau range_down x range_up (15m, levels=4)
print("\n[3] ROBUSTEZZA 15m (Sharpe FULL mark-to-market, levels=4, "
"sl_buf=0.12, tp_buf=0.05, max_bars=2000):")
rds = [0.13, 0.15, 0.17, 0.19]
rus = [0.04, 0.05, 0.06, 0.08]
print(" rd\\ru " + "".join(f"{ru:>7.2f}" for ru in rus))
cells = tot = 0
for rd in rds:
row = f" {rd:>5.2f} "
for ru in rus:
eqd, _ = grid_mtm("ETH", tf="15m", range_down=rd, range_up=ru,
levels=4, sl_buf=0.12, tp_buf=0.05, max_bars=2000)
f, o = std(eqd)
tot += 1
cells += (f["sharpe"] > 1) and (o["sharpe"] > 1)
row += f"{f['sharpe']:>7.2f}"
print(row)
print(f" -> {cells}/{tot} celle con Sharpe>1 sia FULL che OOS")
# [4] GATE PORT06
print("\n[4] GATE PORT06 — baseline vs +GRID_ETH15M (full/half size):")
def port_m(extra=None):
members = dict(eq_base)
ids = list(p.sleeve_ids)
if extra is not None:
members["GRID_ETH15M"] = extra
ids = ids + ["GRID_ETH15M"]
dr = pd.DataFrame({i: members[i].reindex(IDX).ffill().bfill()
.pct_change().fillna(0.0) for i in ids})
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
drp = port_returns({i: members[i].reindex(IDX).ffill().bfill()
for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
half = (1 + 0.5 * gr).cumprod()
res = {"baseline": port_m(None),
"+GRID full": port_m(grid_eq),
"+GRID half": port_m(half)}
print(f" {'variante':<12s} | {'FULL Sh':>8s}{'FULL DD%':>9s}{'CAGR%':>7s}"
f" | {'OOS Sh':>7s}{'OOS DD%':>8s}")
for tag, (f, o) in res.items():
print(f" {tag:<12s} | {f['sharpe']:>8.2f}{f['dd']:>9.2f}{f['cagr']:>7.1f}"
f" | {o['sharpe']:>7.2f}{o['dd']:>8.2f}")
fb, ob = res["baseline"]
print("\n" + "=" * 100)
print(" VERDETTO (criterio standard: OOS Sharpe non peggiora E DD non sale)")
print("=" * 100)
for tag in ("+GRID full", "+GRID half"):
f, o = res[tag]
ok = (o["sharpe"] >= ob["sharpe"] - 0.02 and o["dd"] <= ob["dd"] + 1e-9
and f["sharpe"] >= fb["sharpe"] - 0.02)
print(f" {tag:<12s}: OOS Sh {ob['sharpe']:.2f}->{o['sharpe']:.2f} "
f"DD {ob['dd']:.2f}->{o['dd']:.2f} | FULL Sh {fb['sharpe']:.2f}->{f['sharpe']:.2f} "
f"DD {fb['dd']:.2f}->{f['dd']:.2f} => {'PROMOSSO' if ok else 'bocciato'}")
if __name__ == "__main__":
main()
+109 -21
View File
@@ -379,13 +379,43 @@ def chart_sh01():
return b64(fig) return b64(fig)
def chart_xs01():
"""Book corrente (long perdenti/short vincenti) + dispersione cross-section col gate."""
from scripts.strategies.XS01_cross_sectional import aligned_panel, UNIVERSE, LB
M = aligned_panel()
logC = np.log(M.values)
ts = pd.to_datetime(M.index, unit="ms", utc=True)
dm = logC[-1] - logC[-1 - LB]
dm = dm - dm.mean()
w = -dm / np.sum(np.abs(dm))
fig, (ax, ax2) = plt.subplots(1, 2, figsize=(8.6, 3.4), width_ratios=[1, 1.5])
ax.bar(UNIVERSE, w * 100, color=[C_TP if x > 0 else C_SL for x in w])
ax.set_ylabel("peso book %")
ax.tick_params(axis="x", rotation=60)
ax.set_title("book: long i perdenti relativi,\nshort i vincenti (mom 48h demeaned)",
loc="left", fontweight="bold", fontsize=9)
D = logC[LB:] - logC[:-LB]
disp = pd.Series(D.std(axis=1), index=ts[LB:]).iloc[-24 * 180:]
ax2.plot(disp.index, disp.values, color="#333", lw=0.7)
ax2.axhline(0.0313, color=C_SL, ls="--", lw=1.2)
on = disp.values >= 0.0313
ax2.fill_between(disp.index, 0, disp.values.max(), where=on, alpha=0.10, color=C_TP)
ax2.annotate("disp_min 0.0313 (p50 TRAIN)\nentry solo sopra soglia",
(disp.index[-1], 0.0313), xytext=(-150, 10),
textcoords="offset points", color=C_SL, fontsize=8)
ax2.set_title("dispersion-gate: std cross-section del momentum",
loc="left", fontweight="bold", fontsize=9)
fig.tight_layout()
return b64(fig)
def chart_weights(): def chart_weights():
p = PORTFOLIOS["PORT06"] p = PORTFOLIOS["PORT06"]
ids = p.sleeve_ids ids = p.sleeve_ids
w = W.weight_vector("cap", ids, None, caps=p.caps) w = W.weight_vector("cap", ids, None, caps=p.caps)
fam = {i: W.family_of(i) for i in ids} fam = {i: W.family_of(i) for i in ids}
colors = {"FADE": "#1f6fd6", "HONEST": "#e08c1a", "PAIRS": "#9467bd", colors = {"FADE": "#1f6fd6", "HONEST": "#e08c1a", "PAIRS": "#9467bd",
"TSM": "#5ab4ac", "SHAPE": "#d64545"} "TSM": "#5ab4ac", "SHAPE": "#d64545", "XSEC": "#8c564b"}
order = sorted(ids, key=lambda i: (fam[i], i)) order = sorted(ids, key=lambda i: (fam[i], i))
fig, ax = plt.subplots(figsize=(8.6, 2.9)) fig, ax = plt.subplots(figsize=(8.6, 2.9))
ax.bar(order, [w[i] * 100 for i in order], color=[colors[fam[i]] for i in order]) ax.bar(order, [w[i] * 100 for i in order], color=[colors[fam[i]] for i in order])
@@ -495,9 +525,14 @@ def stats_pairs():
from scripts.analysis.pairs_research import pairs_sim from scripts.analysis.pairs_research import pairs_sim
from scripts.strategies.PR01_pairs_reversion import PAIRS as PAIRS_CFG from scripts.strategies.PR01_pairs_reversion import PAIRS as PAIRS_CFG
cols, data = [], {} cols, data = [], {}
for a, b, p in PAIRS_CFG: from scripts.analysis.pairs_research import pairs_sim_flat
r = pairs_sim(a, b, **p) # le 5 coppie 1h universali + il BLEND ETH/BTC 15m flat-skip (mezza size = sleeve live)
tag = f"{a}/{b}" runs = [(f"{a}/{b}", lambda a=a, b=b, p=p: pairs_sim(a, b, **p)) for a, b, p in PAIRS_CFG]
runs.append(("ETH/BTC·15m", lambda: pairs_sim_flat(
"ETH", "BTC", tf="15m", n=66, z_in=1.674, z_exit=1.0, max_bars=35,
flat_skip=True, pos=0.075)))
for tag, fn in runs:
r = fn()
cols.append(tag) cols.append(tag)
s = pd.Series(r["eq_v"], index=pd.to_datetime(r["eq_ts"], utc=True)) s = pd.Series(r["eq_v"], index=pd.to_datetime(r["eq_ts"], utc=True))
ydd = {int(y): float(((g.cummax() - g) / g.cummax()).max() * 100) ydd = {int(y): float(((g.cummax() - g) / g.cummax()).max() * 100)
@@ -542,6 +577,25 @@ def stats_multi():
yearly_table({"TSM01 (universo 8)": equity_yearly(tsm)}, note)) yearly_table({"TSM01 (universo 8)": equity_yearly(tsm)}, note))
def stats_xs01():
"""XS01 per-anno dall'engine canonico (UNGATED, come il backtest di portafoglio)."""
from scripts.strategies.XS01_cross_sectional import xsec_sim
r = xsec_sim()
s = pd.Series(r["eq_v"], index=pd.to_datetime(r["eq_ts"], utc=True))
years = {}
for y in sorted(r["yearly"]):
g = s[s.index.year == y]
dd = float(((g.cummax() - g) / g.cummax()).max() * 100) if len(g) else 0.0
years[int(y)] = {"n": r["yearly_n"].get(y, 0), "pnl": r["yearly"][y], "dd": dd}
pk = s.cummax()
note = ("PnL = Σ rendimenti netti per trade del book (%, gross 1, fee 0.20% RT/book = "
"turnover 2×0.10%); DD dall'equity compounding (pos 0.15, leva 3x convenzione "
"test). Engine canonico SENZA dispersion-gate (il gate agisce solo sul path "
"live, come trend/hurst sulle fade → il live farà meglio del backtest).")
return yearly_table({"XS01 (universo 8)": (years, float(((pk - s) / pk).max() * 100))},
note)
def stats_sh01(): def stats_sh01():
"""SH01 per-anno dal walk-forward EXPANDING (il regime validato e ora live).""" """SH01 per-anno dal walk-forward EXPANDING (il regime validato e ora live)."""
from scripts.analysis.shape_ml_research import ml_wf_entries from scripts.analysis.shape_ml_research import ml_wf_entries
@@ -575,8 +629,8 @@ box-shadow:0 1px 3px rgba(0,0,0,.05)}
.badge{display:inline-block;font-size:11px;font-weight:700;padding:2px 9px;border-radius:10px; .badge{display:inline-block;font-size:11px;font-weight:700;padding:2px 9px;border-radius:10px;
margin-right:6px;color:#fff} margin-right:6px;color:#fff}
.b-fade{background:#1f6fd6}.b-honest{background:#e08c1a}.b-pairs{background:#9467bd} .b-fade{background:#1f6fd6}.b-honest{background:#e08c1a}.b-pairs{background:#9467bd}
.b-tsm{background:#5ab4ac}.b-shape{background:#d64545}.b-real{background:#2e9e6b} .b-tsm{background:#5ab4ac}.b-shape{background:#d64545}.b-xsec{background:#8c564b}
.b-sim{background:#8a8f98} .b-real{background:#2e9e6b}.b-sim{background:#8a8f98}
img{max-width:100%;border:1px solid #eee;border-radius:6px;margin-top:10px} img{max-width:100%;border:1px solid #eee;border-radius:6px;margin-top:10px}
table{border-collapse:collapse;width:100%;font-size:13px;margin-top:8px} table{border-collapse:collapse;width:100%;font-size:13px;margin-top:8px}
td,th{border:1px solid #e3e5e8;padding:5px 9px;text-align:left} td,th{border:1px solid #e3e5e8;padding:5px 9px;text-align:left}
@@ -604,6 +658,7 @@ def main():
st_dip = stats_dip() st_dip = stats_dip()
t_pairs = stats_pairs() t_pairs = stats_pairs()
t_tr, t_rot, t_tsm = stats_multi() t_tr, t_rot, t_tsm = stats_multi()
t_xs = stats_xs01()
print(" SH01 walk-forward expanding (il piu' lento)...") print(" SH01 walk-forward expanding (il piu' lento)...")
st_sh = stats_sh01() st_sh = stats_sh01()
@@ -626,6 +681,13 @@ def main():
g_pr = chart_pr01() g_pr = chart_pr01()
g_tsm = chart_tsm01(panel) g_tsm = chart_tsm01(panel)
g_sh = chart_sh01() g_sh = chart_sh01()
g_xs = chart_xs01()
# metriche canoniche del default per l'intestazione (sempre aggiornate)
pr = PORTFOLIOS["PORT06"].backtest()
n_def = len(PORTFOLIOS["PORT06"].sleeve_ids)
hdr = (f"FULL Sharpe {pr.full['sharpe']:.2f} / DD {pr.full['dd']:.2f}% — "
f"OOS Sharpe {pr.oos['sharpe']:.2f} / DD {pr.oos['dd']:.2f}%")
B = lambda f, t: f'<span class="badge b-{f}">{t}</span>' B = lambda f, t: f'<span class="badge b-{f}">{t}</span>'
real = B("real", "ESECUZIONE REALE (testnet)") real = B("real", "ESECUZIONE REALE (testnet)")
@@ -654,7 +716,7 @@ estremo in un'ora), fada il movimento. Exit in multipli di ATR. È la fade più
sono falsi negativi</b> — l'overshoot che buca lo stop e rientra è esattamente il movimento che sono falsi negativi</b> — l'overshoot che buca lo stop e rientra è esattamente il movimento che
la fade sta comprando. Con EXIT-16 lo SL intrabar è disattivato: si esce solo se il <b>close</b> la fade sta comprando. Con EXIT-16 lo SL intrabar è disattivato: si esce solo se il <b>close</b>
della barra completata sfonda il livello di 0.5·ATR. Il TP intrabar resta. Impatto sul della barra completata sfonda il livello di 0.5·ATR. Il TP intrabar resta. Impatto sul
portafoglio: OOS Sharpe 8.82→10.06. Esteso oggi anche a DIP01 (grid 36/36).</p>""", g_e16) portafoglio: OOS Sharpe 8.82→10.06. Esteso anche a DIP01 (2026-06-07, grid 36/36).</p>""", g_e16)
c_dip = card("DIP01 — Dip Buy (BTC)", B("honest", "HONEST") + real, """ c_dip = card("DIP01 — Dip Buy (BTC)", B("honest", "HONEST") + real, """
<p>Compra il <b>dip</b>: quando lo z-score del prezzo incrocia sotto 2.5 (sell-off rapido), <p>Compra il <b>dip</b>: quando lo z-score del prezzo incrocia sotto 2.5 (sell-off rapido),
@@ -671,13 +733,19 @@ le fade per costruzione non prendono. Valuta solo barre 4h COMPLETE.</p>""", g_t
(solo se positive), gross 0.45. Gate di regime: tutto cash se BTC&lt;SMA100. Diversificare su 3 (solo se positive), gross 0.45. Gate di regime: tutto cash se BTC&lt;SMA100. Diversificare su 3
asset invece di 2 ha quasi dimezzato il DD (40%→26%) alzando il ritorno.</p>""", g_rot, t_rot) asset invece di 2 ha quasi dimezzato il DD (40%→26%) alzando il ritorno.</p>""", g_rot, t_rot)
c_pr = card("PR01 — Pairs Reversion (ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL)", c_pr = card("PR01 — Pairs Reversion (ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL + ETH/BTC 15m)",
B("pairs", "PAIRS") + real, """ B("pairs", "PAIRS") + real, """
<p><b>Market-neutral</b>: quando il rapporto fra due asset si allontana troppo dalla sua media <p><b>Market-neutral</b>: quando il rapporto fra due asset si allontana troppo dalla sua media
(|z| del log-ratio ≥ 2), compra la gamba debole e shorta la forte; chiude quando il rapporto (|z| del log-ratio ≥ 2), compra la gamba debole e shorta la forte; chiude quando il rapporto
rientra (|z| ≤ 0.75) o dopo 72 barre. Config <b>universale</b> per tutte le coppie (niente tuning rientra (|z| ≤ 0.75) o dopo 72 barre. Config <b>universale</b> per tutte le coppie (niente tuning
per-coppia = anti-overfit). Correlazione col mercato ~0.05: rende anche quando il mercato è fermo. per-coppia = anti-overfit). Correlazione col mercato ~0.05: rende anche quando il mercato è fermo.
Fee su 2 gambe. Senza stop per design → position size ridotto a 0.20 (esposizione ≈ validato).</p> Fee su 2 gambe. Senza stop per design → position size ridotto a 0.20 (esposizione ≈ validato).</p>
<p class='sub'><b>BLEND timeframe (nuovo, 2026-06-09)</b>: ETH/BTC gira anche a <b>15m</b> accanto al 1h
(config n=66, |z|≥1.67, exit |z|≤1.0 o 35 barre). Origine: gioco "Blind Traders" (100 agenti ciechi
su dati anonimi); testato col gate PORT06 — <b>decorrelato dal 1h (corr 0.37)</b>, robusto (16/16),
e l'edge regge anche filtrando le candele flat ETH 15m (<b>flat-skip</b>: niente ingresso/uscita su
barre stale O=H=L=C). Worker validato (replay == backtest). A <b>mezza size</b> (blend-tilt prudente
sul caveat slippage): porta il PORT06 a FULL Sharpe ~7.2 / OOS ~9.7.</p>
<p class='sub'>Esecuzione reale a 2 gambe su Deribit testnet (<code>PairsExecutionClient</code>): <p class='sub'>Esecuzione reale a 2 gambe su Deribit testnet (<code>PairsExecutionClient</code>):
open/close long A / short B, leg-risk unwind, mai <code>close_position</code>.</p>""", g_pr, t_pairs) open/close long A / short B, leg-risk unwind, mai <code>close_position</code>.</p>""", g_pr, t_pairs)
@@ -696,24 +764,41 @@ l'edge): la coda si gestisce dimezzando il peso della famiglia (cap 5.88%).</p>
<p class='sub'>Esecuzione reale single-leg su Deribit testnet: niente TP/SL, chiusura a orizzonte <p class='sub'>Esecuzione reale single-leg su Deribit testnet: niente TP/SL, chiusura a orizzonte
H=12 (market reduce-only), disaster-bracket on-book come unica protezione di coda — è il H=12 (market reduce-only), disaster-bracket on-book come unica protezione di coda — è il
diversificatore più decorrelato del portafoglio.</p> diversificatore più decorrelato del portafoglio.</p>
<p class='sub'>Fix di oggi (punto-10): il training live usa la storia COMPLETA dal parquet <p class='sub'>Fix punto-10 (2026-06-07): il training live usa la storia COMPLETA dal parquet
locale (il regime corto a 365g non era robusto: trade-rate 22% vs 10% validato).</p>""", g_sh, yearly_table(st_sh)) locale (il regime corto a 365g non era robusto: trade-rate 22% vs 10% validato).</p>""", g_sh, yearly_table(st_sh))
c_xs = card("XS01 — Cross-Sectional Reversion (8 asset)", B("xsec", "XSEC") + sim, """
<p><b>Famiglia nuova (2026-06-09)</b>: ogni 12 ore classifica 8 crypto (BTC, ETH, LTC, ADA,
SOL, BNB, XRP, DOGE) per rendimento a 48 ore e va <b>long i perdenti relativi / short i
vincenti</b> (peso ∝ (ret media cross-section)), market-neutral gross 1. Cattura il
<i>fattore</i> reversione cross-sezionale — distinto dai pairs (pairwise) e dai fade
(single-asset): correlazione ~0 con entrambi. Gate PORT06: OOS Sharpe 9.66→10.07,
FULL DD 3.68→3.46. Plateau robusto (lb 12-72 × hold 6-24 tutte OOS+).</p>
<p class='sub'><b>Dispersion-gate (2026-06-10, v1.1.20)</b>: entra solo se la dispersione
cross-section del momentum ≥ 0.0313 (mediana TRAIN) — la reversione paga quando c'è
dispersione da far rientrare. Diagnostica monotona TRAIN e OOS, plateau p30-p70,
standalone Sharpe 2.50→3.46 (regge fee 2x), PORT06 OOS 10.07→<b>10.37</b> a DD pari.
Solo path live (backtest canonico non filtrato → il live farà meglio del backtest).</p>
<p class='sub'>8 gambe → niente esecuzione reale: gira PAPER come i book multi-asset
(il rumore di arrotondamento a €2k dominerebbe). Worker validato (replay == backtest esatto).</p>""",
g_xs, t_xs)
html = f"""<!doctype html><html lang="it"><head><meta charset="utf-8"> html = f"""<!doctype html><html lang="it"><head><meta charset="utf-8">
<title>PythagorasGoal — Strategie attive PORT06</title><style>{CSS}</style></head> <title>PythagorasGoal — Strategie attive PORT06</title><style>{CSS}</style></head>
<body><div class="wrap"> <body><div class="wrap">
<h1>PythagorasGoal — Strategie attive</h1> <h1>PythagorasGoal — Strategie attive</h1>
<p class="sub">Portafoglio live <b>PORT06</b> — definizione 17 sleeve; <b>pool live real-only 14 sleeve</b> <p class="sub">Portafoglio live <b>PORT06</b> — definizione {n_def} sleeve; <b>pool live real-only 15 sleeve</b>
(i 3 book multi-asset girano in statistica, fuori dal capitale-pool). Capitale pool €2.000, leva 2x · (i 4 book multi-asset TR01/ROT02/TSM01/XS01 girano in statistica, fuori dal capitale-pool).
v{ver} · generato {now} · backtest canonico: FULL Sharpe 6.61 / DD 3.58% — OOS Sharpe 8.77 / DD 1.34%</p> Capitale pool €2.000, leva 2x, <b>real-truth ledger</b> ·
v{ver} · generato {now} · backtest canonico: {hdr}</p>
<div class="card"> <div class="card">
<p>Tre famiglie principali quasi <b>scorrelate</b> fra loro (fade↔honest ~0.05, pairs ~0.02-0.09, <p>Famiglie quasi <b>scorrelate</b> fra loro (fade↔honest ~0.05, pairs ~0.02-0.09,
shape ~0.08): la diversificazione è la leva anti-drawdown. Pesi equal con tetti per famiglia, shape ~0.08, xsec ~0): la diversificazione è la leva anti-drawdown. Pesi equal con tetti
ribilancio giornaliero su capitale pool condiviso. Fee Deribit 0.10% round-trip incluse ovunque; per famiglia, ribilancio giornaliero su capitale pool condiviso. Fee Deribit 0.10% round-trip
ogni meccanismo live è passato da un gate out-of-sample a livello di portafoglio.</p> incluse ovunque; ogni meccanismo live è passato da un gate out-of-sample a livello di portafoglio.</p>
<img src="data:image/png;base64,{g_w}"></div> <img src="data:image/png;base64,{g_w}"></div>
<h2>FADE — mean-reversion intraday 1h <span class="sub">(6 sleeve × 6.47%)</span></h2> <h2>FADE — mean-reversion intraday 1h <span class="sub">(6 sleeve × 5.69%)</span></h2>
<div class="note"><b>Tesi della famiglia:</b> sui perpetui crypto l'edge è la <i>reversione</i>: <div class="note"><b>Tesi della famiglia:</b> sui perpetui crypto l'edge è la <i>reversione</i>:
i movimenti estremi rientrano (i breakout falliscono — l'intera famiglia squeeze-breakout è stata i movimenti estremi rientrano (i breakout falliscono — l'intera famiglia squeeze-breakout è stata
scartata come artefatto di look-ahead). Le fade vendono l'eccesso e comprano il panico, con tre scartata come artefatto di look-ahead). Le fade vendono l'eccesso e comprano il panico, con tre
@@ -724,14 +809,16 @@ non si fada un crollo/parabolica), <b>EXIT-16</b> (stop solo sul close confermat
{c_mr02} {c_mr02}
{c_mr07} {c_mr07}
{c_e16} {c_e16}
<h2>HONEST — long-only multi-regime <span class="sub">(3 sleeve × 6.47%)</span></h2> <h2>HONEST — long-only multi-regime <span class="sub">(3 sleeve × 5.69%)</span></h2>
{c_dip} {c_dip}
{c_tr} {c_tr}
{c_rot} {c_rot}
<h2>PAIRS — spread reversion market-neutral <span class="sub">(5 coppie × 5.88%, famiglia ≤33%)</span></h2> <h2>PAIRS — spread reversion market-neutral <span class="sub">(6 sleeve × 5.26%: 5 coppie 1h + ETH/BTC 15m a mezza size, famiglia ≤33%)</span></h2>
{c_pr} {c_pr}
<h2>TSM — trend-following multi-orizzonte <span class="sub">(1 sleeve × 6.47%)</span></h2> <h2>TSM — trend-following multi-orizzonte <span class="sub">(1 sleeve × 5.69%)</span></h2>
{c_tsm} {c_tsm}
<h2>XSEC — reversione cross-sectional <span class="sub">(1 sleeve × 5.69%)</span></h2>
{c_xs}
<h2>SHAPE — ML morfologico <span class="sub">(2 sleeve × 2.94%, famiglia ≤5.88%)</span></h2> <h2>SHAPE — ML morfologico <span class="sub">(2 sleeve × 2.94%, famiglia ≤5.88%)</span></h2>
{c_sh} {c_sh}
@@ -740,7 +827,8 @@ non si fada un crollo/parabolica), <b>EXIT-16</b> (stop solo sul close confermat
<li><b>Fee sempre incluse</b>: 0.10% round-trip taker Deribit (misurate reali = assunte). Molte operazioni = morte per fee: ogni strategia regge lo stress a fee doppie.</li> <li><b>Fee sempre incluse</b>: 0.10% round-trip taker Deribit (misurate reali = assunte). Molte operazioni = morte per fee: ogni strategia regge lo stress a fee doppie.</li>
<li><b>Niente look-ahead</b>: direzione e prezzo decisi solo con dati fino al close corrente; barre in formazione escluse (lezione EXIT-16). La famiglia squeeze (accuratezze 76-82%) è stata scartata proprio per questo artefatto.</li> <li><b>Niente look-ahead</b>: direzione e prezzo decisi solo con dati fino al close corrente; barre in formazione escluse (lezione EXIT-16). La famiglia squeeze (accuratezze 76-82%) è stata scartata proprio per questo artefatto.</li>
<li><b>Gate out-of-sample</b>: nessun meccanismo va in produzione senza migliorare (o non degradare) il portafoglio FULL e OOS — robusto individualmente ≠ migliora PORT06.</li> <li><b>Gate out-of-sample</b>: nessun meccanismo va in produzione senza migliorare (o non degradare) il portafoglio FULL e OOS — robusto individualmente ≠ migliora PORT06.</li>
<li><b>Esecuzione reale shadow (~81% del portafoglio)</b>: 14 sleeve eseguono ordini reali su Deribit testnet accanto al sim — i 6 fade + DIP01 (single-leg, TP limit al livello, disaster-stop 30% on-book), i 5 pairs (2 gambe, leg-risk unwind) e i 2 SH01 (single-leg, exit a orizzonte, niente TP/SL). Restano simulati solo i book multi-asset TR01/ROT02/TSM01 (bloccati dal capitale: serve ~€20k per il rumore di arrotondamento). Fee reali verificate dai trade; divergenze sim/reale ≥100bps alertate su Telegram.</li> <li><b>Esecuzione reale shadow (15 sleeve su 19)</b>: i 6 fade + DIP01 (single-leg, TP limit al livello, disaster-stop 30% on-book), i 6 pairs incl. <b>ETH/BTC 15m flat-skip</b> (2 gambe, leg-risk unwind) e i 2 SH01 (single-leg, exit a orizzonte, niente TP/SL) eseguono ordini reali su Deribit testnet. Restano simulati solo i book multi-asset TR01/ROT02/TSM01/XS01 (bloccati dal capitale: serve ~€20k per il rumore di arrotondamento). Fee reali verificate dai trade; divergenze sim/reale ≥100bps alertate su Telegram.</li>
<li><b>Real-truth ledger (2026-06-10)</b>: il capitale dei worker eseguiti si aggiorna col PnL dei <b>fill reali</b> (fee reali incluse), non col sim — equity, pesi e notional derivano dai soldi veri sul conto; il sim resta diagnostica nel log (fallback dichiarato solo se il trade reale non è mai esistito). Le decisioni di entry/exit restano guidate dal feed.</li>
</ul></div> </ul></div>
<p class="sub">Generato da <code>scripts/analysis/make_strategy_doc.py</code> — grafici da episodi reali sui dati parquet locali.</p> <p class="sub">Generato da <code>scripts/analysis/make_strategy_doc.py</code> — grafici da episodi reali sui dati parquet locali.</p>
</div></body></html>""" </div></body></html>"""
+203
View File
@@ -0,0 +1,203 @@
"""Check candele FLAT (O=H=L=C, liquidita' zero) sui pairs ETH/BTC a 15m.
Rischio noto (CLAUDE.md): ETH 15m ha 14-30%/anno di candele flat per bassa liquidita'
del perpetuo. Su un pairs, un close stale gonfia lo z-score (l'altra gamba si muove,
questa e' ferma) -> segnale di "reversione" FINTO che rientra solo quando la gamba
stale si sblocca: profitto NON eseguibile dal vivo. Questo gonfierebbe il backtest 15m.
Test:
[1] prevalenza candele flat per anno (ETH 15m, BTC 15m).
[2] quanti trade del pairs 15m hanno ENTRY/EXIT su una candela flat (gamba stale).
[3] re-sim flat-aware: entry/exit SOLO su barre pulite (non-flat in ENTRAMBE le gambe)
-> quanto sopravvive l'edge? (parita': senza flat-skip == pairs_sim).
[4] gate PORT06 col 15m flat-filtrato vs baseline 1h.
uv run python scripts/analysis/pairs15m_flatcheck.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from scripts.analysis.pairs_research import pairs_sim, OOS_FRAC, FEE_RT, LEV, POS, BARS_YEAR
from scripts.analysis.report_families import daily_from
from scripts.analysis.combine_portfolio import metrics, SPLIT, OOS_DATE
from scripts.analysis.pairs15m_port06_gate import port_metrics, eth_btc_daily, UNIV_1H, GAME_15M
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio.sleeves import all_sleeve_equities
def aligned2(a, b, tf="15m"):
"""Merge con OHLC di ENTRAMBE le gambe (serve per rilevare i flat su entrambe)."""
da = load_data(a, tf)[["timestamp", "open", "high", "low", "close"]].rename(
columns=lambda x: x + "_a" if x != "timestamp" else x)
db = load_data(b, tf)[["timestamp", "open", "high", "low", "close"]].rename(
columns=lambda x: x + "_b" if x != "timestamp" else x)
m = da.merge(db, on="timestamp", how="inner").reset_index(drop=True)
m["dt"] = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
return m
def is_flat(o, h, l, c):
return (o == h) & (h == l) & (l == c)
def flat_prevalence(asset, tf="15m"):
d = load_data(asset, tf)
d = d.copy()
d["dt"] = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
fl = is_flat(d["open"].values, d["high"].values, d["low"].values, d["close"].values)
d["flat"] = fl
by = d.groupby(d["dt"].dt.year)["flat"].mean() * 100
return by, fl.mean() * 100
def pairs_sim_flataware(a, b, tf="15m", n=66, z_in=1.674, z_exit=1.0, max_bars=35,
jump_max=0.08, fee_rt=FEE_RT, lev=LEV, pos=POS,
split_frac=0.0, skip_flat=True):
"""Come pairs_sim ma: entry/exit consentiti SOLO su barre pulite (se skip_flat).
Ritorna anche n_entry_flat / n_exit_flat (diagnostica, calcolata sempre)."""
m = aligned2(a, b, tf)
ca, cb = m["close_a"].values, m["close_b"].values
flat_a = is_flat(m["open_a"].values, m["high_a"].values, m["low_a"].values, ca)
flat_b = is_flat(m["open_b"].values, m["high_b"].values, m["low_b"].values, cb)
flat = flat_a | flat_b # barra "sporca" se una delle due gambe e' flat
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)
ts = m["dt"]; N = len(r)
split = int(N * split_frac)
fee = 2 * fee_rt * lev
cap = peak = 1000.0; dd = 0.0; last = -1
trades = wins = 0; rets = []; yearly = {}
eq_ts, eq_v = [], []
n_entry_flat = n_exit_flat = 0
for i in range(n + 1, N - 1):
if i < split or np.isnan(z[i]) or dr[i] > jump_max or i <= last:
continue
if z[i] <= -z_in:
d = 1
elif z[i] >= z_in:
d = -1
else:
continue
if flat[i]:
n_entry_flat += 1
if skip_flat:
continue # non si entra su una gamba stale
# exit: |z|<=z_exit o max_bars; se skip_flat, salta le barre flat come uscita
j = min(i + max_bars, N - 1)
for k in range(1, max_bars + 1):
jj = i + k
if jj >= N:
j = N - 1; break
if skip_flat and flat[jj]:
j = jj # avanza, non esce su barra stale
continue
if abs(z[jj]) <= z_exit:
j = jj; break
j = jj
if flat[j]:
n_exit_flat += 1
if skip_flat:
# spingi all'ultima barra pulita entro l'orizzonte
back = j
while back > i and flat[back]:
back -= 1
j = back if back > i else j
retA = (ca[j] - ca[i]) / ca[i]
retB = (cb[j] - cb[i]) / cb[i]
ret = (retA - retB) * d * lev - fee
cap = max(cap + cap * pos * ret, 10.0)
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; rets.append(ret * pos); last = j
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
yrs_span = (ts.iloc[-1] - ts.iloc[max(split, 0)]).days / 365.25 or 1
sharpe = 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
return dict(trades=trades, win=wins / trades * 100 if trades else 0, ret=ret_tot,
dd=dd * 100, sharpe=sharpe, yearly=yearly, eq_ts=eq_ts, eq_v=eq_v,
n_entry_flat=n_entry_flat, n_exit_flat=n_exit_flat)
def main():
print("=" * 100)
print(" CHECK FLAT-CANDLE — ETH/BTC pairs 15m (gate condizionato)")
print("=" * 100)
# [1] prevalenza
print("\n[1] Prevalenza candele flat (O=H=L=C) per anno, 15m:")
for asset in ("ETH", "BTC"):
by, tot = flat_prevalence(asset, "15m")
print(f" {asset}: media {tot:.1f}% | " +
" ".join(f"{y}:{v:.0f}%" for y, v in by.items()))
# [2] quanti trade toccano un flat (sim SENZA skip per diagnostica)
diag = pairs_sim_flataware("ETH", "BTC", **GAME_15M, skip_flat=False)
tr = diag["trades"]
print(f"\n[2] Trade 15m totali: {tr} | entry su barra flat: {diag['n_entry_flat']} "
f"({diag['n_entry_flat']/tr*100:.1f}%) | exit su barra flat: {diag['n_exit_flat']} "
f"({diag['n_exit_flat']/tr*100:.1f}%)")
# [3] parita' + edge filtrato
print("\n[3] Edge 15m: NO-skip (== pairs_sim) vs FLAT-AWARE (entry/exit solo barre pulite):")
# parita': flataware skip_flat=False deve ~== pairs_sim
base_ps = pairs_sim("ETH", "BTC", **GAME_15M, pos=POS, lev=LEV)
print(f" parita' pairs_sim : trd {base_ps['trades']:>5d} Sh {base_ps['sharpe']:.2f} "
f"DD {base_ps['dd']:.0f}% ret {base_ps['ret']:+.0f}%")
print(f" flataware (no-skip) : trd {diag['trades']:>5d} Sh {diag['sharpe']:.2f} "
f"DD {diag['dd']:.0f}% ret {diag['ret']:+.0f}%")
filt = pairs_sim_flataware("ETH", "BTC", **GAME_15M, skip_flat=True)
filt_o = pairs_sim_flataware("ETH", "BTC", **GAME_15M, skip_flat=True, split_frac=1 - OOS_FRAC)
print(f" FLAT-AWARE (skip) : trd {filt['trades']:>5d} Sh {filt['sharpe']:.2f} "
f"DD {filt['dd']:.0f}% ret {filt['ret']:+.0f}% | OOS Sh {filt_o['sharpe']:.2f} DD {filt_o['dd']:.0f}%")
drop = (1 - filt['trades'] / diag['trades']) * 100
sh_keep = filt['sharpe'] / diag['sharpe'] * 100 if diag['sharpe'] else 0
verdict = "EDGE NON artefatto flat" if sh_keep > 70 else "EDGE in larga parte ARTEFATTO flat"
print(f" -> rimossi {drop:.1f}% dei trade; Sharpe trattenuto {sh_keep:.0f}% ({verdict})")
# [4] gate PORT06 col 15m flat-filtrato
print("\n[4] GATE PORT06 — ETH/BTC: baseline 1h vs SWAP 15m-FLATAWARE vs BLEND:")
p = PORTFOLIOS["PORT06"]
pair_ids = [s.sid for s in p.sleeves if s.sid.startswith("PR_")]
eq_base = dict(all_sleeve_equities())
e1h, _ = eth_btc_daily(UNIV_1H)
e15f = daily_from(filt["eq_ts"], filt["eq_v"])
# blend 1h + 15m-flataware (50/50 daily-rebalanced)
from scripts.analysis.pairs15m_port06_gate import blend
eblend = blend(e1h, e15f, 0.5)
corr = e1h.pct_change().fillna(0).corr(e15f.pct_change().fillna(0))
print(f" corr 1h vs 15m-flataware: {corr:.3f}")
print(f" {'variante':<18s} | {'FULL Sh':>8s}{'FULL DD%':>9s}{'CAGR':>6s} | {'OOS Sh':>7s}{'OOS DD%':>8s}")
print(" " + "-" * 70)
res = {}
for tag, eth in [("baseline 1h", e1h), ("SWAP 15m-flat", e15f), ("BLEND 1h+15m-flat", eblend)]:
members = dict(eq_base); members["PR_ETHBTC"] = eth
f, o = port_metrics(members, p)
res[tag] = (f, o)
print(f" {tag:<18s} | {f['sharpe']:>8.2f}{f['dd']:>9.2f}{f['cagr']:>5.0f}%"
f" | {o['sharpe']:>7.2f}{o['dd']:>8.2f}")
fb, ob = res["baseline 1h"]
print("\n VERDETTO (vs baseline 1h, fee backtest): Sharpe non peggiora E DD <= baseline")
for tag in ("SWAP 15m-flat", "BLEND 1h+15m-flat"):
f, o = res[tag]
ok = o["sharpe"] >= ob["sharpe"] - 0.02 and o["dd"] <= ob["dd"] + 1e-9 and f["sharpe"] >= fb["sharpe"] - 0.02 and f["dd"] <= fb["dd"] + 1e-9
print(f" {tag:<18s}: OOS {ob['sharpe']:.2f}->{o['sharpe']:.2f} DD {ob['dd']:.2f}->{o['dd']:.2f}"
f" | FULL {fb['sharpe']:.2f}->{f['sharpe']:.2f} DD {fb['dd']:.2f}->{f['dd']:.2f} => {'PROMOSSO' if ok else 'bocciato'}")
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
"""GATE PORT06 FINALE — ETH/BTC 15m flat-skip, engine canonico pairs_sim_flat.
Usa pairs_sim_flat(flat_skip=True), cioe' la STESSA semantica live-realizable del
PairsWorker (uscita alla prima barra pulita), validata da validate_worker_pairs.
Conferma i numeri deployabili: baseline 1h vs SWAP 15m vs BLEND 1h+15m.
uv run python scripts/analysis/pairs15m_gate_final.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.pairs_research import pairs_sim_flat
from scripts.analysis.report_families import daily_from
from scripts.analysis.pairs15m_port06_gate import (port_metrics, eth_btc_daily, blend,
UNIV_1H, POS, LEV)
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio.sleeves import all_sleeve_equities
CFG_15M = dict(n=66, z_in=1.674, z_exit=1.0, max_bars=35)
def main():
p = PORTFOLIOS["PORT06"]
eq_base = dict(all_sleeve_equities())
e1h, _ = eth_btc_daily(UNIV_1H)
r15 = pairs_sim_flat("ETH", "BTC", tf="15m", **CFG_15M, flat_skip=True, pos=POS, lev=LEV)
e15 = daily_from(r15["eq_ts"], r15["eq_v"])
eblend = blend(e1h, e15, 0.5)
corr = e1h.pct_change().fillna(0).corr(e15.pct_change().fillna(0))
print("=" * 92)
print(" GATE PORT06 FINALE — ETH/BTC 15m flat-skip (pairs_sim_flat == worker live)")
print(f" 15m: {r15['trades']} trade, {r15['n_skip_entry']} ingressi flat saltati | "
f"corr 1h vs 15m = {corr:.3f}")
print("=" * 92)
print(f" {'variante':<18s} | {'FULL Sh':>8s}{'FULL DD%':>9s}{'CAGR':>6s} | {'OOS Sh':>7s}{'OOS DD%':>8s}")
print(" " + "-" * 70)
res = {}
for tag, eth in [("baseline 1h", e1h), ("SWAP 15m-flat", e15), ("BLEND 1h+15m", eblend)]:
members = dict(eq_base); members["PR_ETHBTC"] = eth
f, o = port_metrics(members, p)
res[tag] = (f, o)
print(f" {tag:<18s} | {f['sharpe']:>8.2f}{f['dd']:>9.2f}{f['cagr']:>5.0f}%"
f" | {o['sharpe']:>7.2f}{o['dd']:>8.2f}")
fb, ob = res["baseline 1h"]
print("\n Promosso se OOS Sharpe non peggiora E DD<=baseline (PORT06):")
for tag in ("SWAP 15m-flat", "BLEND 1h+15m"):
f, o = res[tag]
ok = o["sharpe"] >= ob["sharpe"] - 0.02 and o["dd"] <= ob["dd"] + 1e-9 \
and f["sharpe"] >= fb["sharpe"] - 0.02 and f["dd"] <= fb["dd"] + 1e-9
print(f" {tag:<18s}: OOS {ob['sharpe']:.2f}->{o['sharpe']:.2f} DD {ob['dd']:.2f}->{o['dd']:.2f}"
f" | FULL {fb['sharpe']:.2f}->{f['sharpe']:.2f} DD {fb['dd']:.2f}->{f['dd']:.2f}"
f" => {'PROMOSSO' if ok else 'bocciato'}")
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
"""Smoke LIVE del nuovo percorso 15m: fetch DIRETTO 15m da Cerbero per ETH/BTC +
freschezza + flat-fraction + un tick reale del PairsWorker(flat_skip).
Verifica cio' che il backtest non vede: che Cerbero serva candele 15m fresche per
entrambe le gambe (il runner ora le fetcha dirette, non resamplate dal 1h) e che il
worker 15m le processi senza errori. NON apre ordini reali (l'esecuzione a 2 gambe e'
gia' coperta da live_pairs_smoke.py, indipendente dal timeframe).
uv run python scripts/analysis/pairs15m_live_smoke.py
"""
from __future__ import annotations
import sys
import shutil
import tempfile
from datetime import datetime, timezone, timedelta
from pathlib import Path
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
CFG = {"n": 66, "z_in": 1.674, "z_exit": 1.0, "max_bars": 35, "flat_skip": True}
def fetch15(cli, asset, days=14):
inst = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
end = datetime.now(timezone.utc)
start = end - timedelta(days=days)
candles = cli.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), "15m")
if not candles:
return inst, None
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
return inst, df.sort_values("timestamp").reset_index(drop=True)
def main():
print("=" * 84)
print(" SMOKE LIVE — ETH/BTC pairs 15m (fetch diretto Cerbero + tick worker flat-skip)")
print("=" * 84)
cli = CerberoClient()
inst_a, da = fetch15(cli, "ETH")
inst_b, db = fetch15(cli, "BTC")
ok = True
for asset, inst, df in [("ETH", inst_a, da), ("BTC", inst_b, db)]:
if df is None or df.empty:
print(f" {asset} ({inst}): NESSUNA candela 15m -> FAIL"); ok = False; continue
last = pd.to_datetime(df["timestamp"].iloc[-1], unit="ms", utc=True)
age_min = (datetime.now(timezone.utc) - last).total_seconds() / 60
flat = ((df["open"] == df["high"]) & (df["high"] == df["low"]) &
(df["low"] == df["close"])).mean() * 100
fresh = age_min < 60
print(f" {asset} ({inst}): {len(df)} barre 15m | ultima {last:%Y-%m-%d %H:%M} "
f"({age_min:.0f} min fa, {'FRESCO' if fresh else 'STALE'}) | flat {flat:.1f}%")
ok &= fresh
if da is None or db is None:
print("\n ESITO: FAIL (feed 15m assente)."); return
# tick reale del worker 15m
tmp = Path(tempfile.mkdtemp(prefix="smoke15m_"))
try:
w = PairsWorker("ETH", "BTC", "15m", params=CFG, fee_rt=0.001, data_dir=tmp)
df_a = pd.DataFrame({"timestamp": da["timestamp"], "open": da["open"], "high": da["high"],
"low": da["low"], "close": da["close"]})
df_b = pd.DataFrame({"timestamp": db["timestamp"], "open": db["open"], "high": db["high"],
"low": db["low"], "close": db["close"]})
w.tick(df_a, df_b)
print(f"\n Worker 15m flat_skip={w.flat_skip} -> tick OK | {w.status_summary}")
print(f" ESITO: {'OK — feed 15m fresco e worker ticca' if ok else 'ATTENZIONE: feed 15m stale/parziale'}")
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()
+169
View File
@@ -0,0 +1,169 @@
"""GATE PORT06 — ETH/BTC pairs a 15m (origine: gioco "Blind Traders", vincitore #43).
Domanda onesta sollevata dal gioco: la coppia ETH/BTC (gia' deployata in PR01 a 1h,
config UNIV n=50 z_in=2.0 z_exit=0.75 max_bars=72) MIGLIORA se girata a 15m con la
config trovata dal gioco (n=66 z_in=1.67 z_exit=1.0 max_bars=35), oppure e' solo una
variante piu' veloce, correlata, dello STESSO spread?
Metodo (engine di PRODUZIONE pairs_sim, NON il motore-giocattolo del gioco):
[1] PARITA': pairs_sim ETH/BTC 1h UNIV (pos0.15 lev3) == sleeve canonico PR_ETHBTC.
[2] CORRELAZIONE 1h vs 15m (rendimenti giornalieri): se ~1 e' ridondante.
[3] STANDALONE 1h vs 15m (+ griglia robustezza n x z_in su 15m, + stress fee 2x).
[4] GATE PORT06: baseline(1h) vs SWAP(15m) vs BLEND(0.5*1h+0.5*15m) per la sleeve
ETH/BTC; promosso se vs baseline l'OOS Sharpe non peggiora E il DD scende
(PORT06 e famiglia), come gli altri gate del progetto.
uv run python scripts/analysis/pairs15m_port06_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, IDX
from scripts.analysis.pairs_research import pairs_sim, OOS_FRAC
from scripts.analysis.report_families import daily_from
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio import weighting as W
POS, LEV = 0.15, 3.0 # config CANONICA (== build_everything)
UNIV_1H = dict(tf="1h", n=50, z_in=2.0, z_exit=0.75, max_bars=72)
GAME_15M = dict(tf="15m", n=66, z_in=1.674, z_exit=1.0, max_bars=35) # vincitore gioco
def eth_btc_daily(cfg):
r = pairs_sim("ETH", "BTC", **{**cfg, "pos": POS, "lev": LEV})
return daily_from(r["eq_ts"], r["eq_v"]), r
def std_metrics(cfg, fee_rt=0.001):
f = pairs_sim("ETH", "BTC", **{**cfg, "pos": POS, "lev": LEV, "fee_rt": fee_rt})
o = pairs_sim("ETH", "BTC", **{**cfg, "pos": POS, "lev": LEV, "fee_rt": fee_rt,
"split_frac": 1 - OOS_FRAC})
yrs = f["yearly"]; pos_y = sum(1 for v in yrs.values() if v > 0)
return f, o, pos_y, len(yrs)
def port_metrics(members, p):
ids = p.sleeve_ids
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
drp = port_returns({i: members[i] for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
def fam_metrics(eqs):
dr = port_returns(eqs)
return metrics(dr), metrics(dr, lo=SPLIT)
def blend(e1, e2, w1=0.5):
"""Sleeve combinata: media pesata dei rendimenti giornalieri (ribilancio 1D)."""
r1 = e1.reindex(IDX).ffill().bfill().pct_change().fillna(0.0)
r2 = e2.reindex(IDX).ffill().bfill().pct_change().fillna(0.0)
rb = w1 * r1 + (1 - w1) * r2
eq = (1 + rb).cumprod()
return eq / eq.iloc[0]
def main():
p = PORTFOLIOS["PORT06"]
pair_ids = [s.sid for s in p.sleeves if s.sid.startswith("PR_")]
print("=" * 100)
print(" GATE PORT06 — ETH/BTC pairs 15m (vincitore gioco) vs 1h deployato")
print(f" pos={POS} lev={LEV} (canonico) | OOS da {OOS_DATE} | coppie PORT06: {pair_ids}")
print("=" * 100)
from src.portfolio.sleeves import all_sleeve_equities
eq_base = dict(all_sleeve_equities())
# [1] PARITA'
print("\n[1] PARITA' pairs_sim ETH/BTC 1h UNIV (pos0.15 lev3) == sleeve canonico PR_ETHBTC:")
e1h, r1h = eth_btc_daily(UNIV_1H)
base = eq_base["PR_ETHBTC"]
corr = base.pct_change().fillna(0).corr(e1h.pct_change().fillna(0))
rb = (base.iloc[-1] / base.iloc[0] - 1) * 100
rr = (e1h.iloc[-1] / e1h.iloc[0] - 1) * 100
par_ok = corr > 0.999 and abs(rr - rb) <= max(1.0, abs(rb) * 0.01)
print(f" corr={corr:.5f} ret canon {rb:+.0f}% vs replay {rr:+.0f}% "
f"{'OK' if par_ok else '<-- MISMATCH (STOP)'}")
if not par_ok:
return
# [2] CORRELAZIONE 1h vs 15m
e15, r15 = eth_btc_daily(GAME_15M)
c = e1h.pct_change().fillna(0).corr(e15.pct_change().fillna(0))
print(f"\n[2] CORRELAZIONE rendimenti giornalieri ETH/BTC 1h vs 15m: {c:.3f}")
print(f" {'(quasi-duplicato se >0.8; diversificatore se <0.5)':<60s}")
# [3] STANDALONE 1h vs 15m
print("\n[3] STANDALONE ETH/BTC (netto fee 0.20% RT/coppia, leva 3x):")
print(f" {'cfg':<10s}{'trd':>6s}{'win%':>6s}{'FULL%':>9s}{'OOS%':>9s}{'CAGR%':>7s}"
f"{'DD%':>6s}{'oDD%':>7s}{'Shrp':>6s}{'anni+':>7s}{'fee2x FULL%':>12s}")
for tag, cfg in [("1h UNIV", UNIV_1H), ("15m gioco", GAME_15M)]:
f, o, py, ny = std_metrics(cfg)
f2, _, _, _ = std_metrics(cfg, fee_rt=0.002)
print(f" {tag:<10s}{f['trades']:>6d}{f['win']:>6.1f}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
f"{f['cagr']:>7.0f}{f['dd']:>6.0f}{o['dd']:>7.0f}{f['sharpe']:>6.2f}"
f"{f'{py}/{ny}':>7s}{f2['ret']:>+12.0f}")
# robustezza: plateau n x z_in su 15m (Sharpe>1?)
print("\n Robustezza 15m (Sharpe full, griglia n x z_in, z_exit=1.0 max_bars=35):")
ns = [40, 50, 66, 80]; zs = [1.5, 1.7, 2.0, 2.5]
cells = 0; tot = 0
hdr = " n\\z_in " + "".join(f"{z:>7.1f}" for z in zs)
print(hdr)
for n in ns:
row = f" {n:>6d} "
for z in zs:
s = pairs_sim("ETH", "BTC", tf="15m", n=n, z_in=z, z_exit=1.0,
max_bars=35, pos=POS, lev=LEV)["sharpe"]
tot += 1; cells += s > 1
row += f"{s:>7.2f}"
print(row)
print(f" -> {cells}/{tot} celle Sharpe>1 (plateau se ~tutte; picco se poche)")
# [4] GATE PORT06
print("\n[4] GATE PORT06 — sleeve ETH/BTC: baseline(1h) vs SWAP(15m) vs BLEND(50/50):")
variants = {
"baseline 1h": e1h,
"SWAP 15m": e15,
"BLEND 1h+15m": blend(e1h, e15, 0.5),
}
print(f" {'variante':<14s} | {'FULL Sh':>8s}{'FULL DD%':>9s}{'CAGR':>6s}"
f" | {'OOS Sh':>7s}{'OOS DD%':>8s} | {'famSh':>6s}{'famDD%':>7s}")
print(" " + "-" * 78)
res = {}
for tag, eth in variants.items():
members = dict(eq_base)
members["PR_ETHBTC"] = eth
f, o = port_metrics(members, p)
fam_eqs = {sid: (eth if sid == "PR_ETHBTC" else eq_base[sid]) for sid in pair_ids}
ff, _ = fam_metrics(fam_eqs)
res[tag] = (f, o, ff)
print(f" {tag:<14s} | {f['sharpe']:>8.2f}{f['dd']:>9.2f}{f['cagr']:>5.0f}%"
f" | {o['sharpe']:>7.2f}{o['dd']:>8.2f} | {ff['sharpe']:>6.2f}{ff['dd']:>7.1f}")
# VERDETTO
fb, ob, _ = res["baseline 1h"]
print("\n" + "=" * 100)
print(" VERDETTO vs baseline 1h: promosso se OOS Sharpe non peggiora E DD scende (PORT06 e famiglia)")
print("=" * 100)
for tag in ("SWAP 15m", "BLEND 1h+15m"):
f, o, ff = res[tag]
ok = (o["sharpe"] >= ob["sharpe"] - 0.02 and o["dd"] <= ob["dd"] + 1e-9
and f["sharpe"] >= fb["sharpe"] - 0.02)
print(f" {tag:<14s}: OOS Sh {ob['sharpe']:.2f}->{o['sharpe']:.2f} "
f"DD {ob['dd']:.2f}->{o['dd']:.2f} | FULL Sh {fb['sharpe']:.2f}->{f['sharpe']:.2f} "
f"DD {fb['dd']:.2f}->{f['dd']:.2f} => {'PROMOSSO' if ok else 'bocciato'}")
if __name__ == "__main__":
main()
+92
View File
@@ -0,0 +1,92 @@
"""GATE: aggiungere ETH/BTC 30m (vincitore gioco sessione 2) AL BLEND attuale (1h+15m)?
Domanda: il 30m e' un 3o timeframe utile dello spread ETH/BTC, o e' ridondante col 15m
adiacente gia' deployato? Test (engine pairs_sim_flat, == worker):
[1] correlazioni: 30m vs 1h, 30m vs 15m (se ~1 col 15m -> ridondante).
[2] gate PORT06: baseline ATTUALE (6 pairs, incl 15m) vs +30m (7 pairs), mezza size.
[3] robustezza ai costi (fee sweep) del 30m.
uv run python scripts/analysis/pairs30m_gate.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT, OOS_DATE
from scripts.analysis.pairs_research import pairs_sim, pairs_sim_flat
from scripts.analysis.report_families import daily_from
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio.sleeves import all_sleeve_equities
from src.portfolio import weighting as W
WIN_30M = dict(n=53, z_in=1.947, z_exit=1.0, max_bars=24) # vincitore gioco sess.2
def daily(cfg, tf, flat_skip, pos=0.15):
if flat_skip:
r = pairs_sim_flat("ETH", "BTC", tf=tf, **cfg, flat_skip=True, pos=pos)
else:
r = pairs_sim("ETH", "BTC", tf=tf, **cfg, pos=pos)
return daily_from(r["eq_ts"], r["eq_v"]), r
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), w
def main():
p = PORTFOLIOS["PORT06"]
eq_base = dict(all_sleeve_equities()) # include gia' PR_ETHBTC (1h) e PR_ETHBTC_15M
e1h = eq_base["PR_ETHBTC"]
e15 = eq_base["PR_ETHBTC_15M"]
e30h, r30 = daily(WIN_30M, "30m", flat_skip=True, pos=0.075) # half size come il 15m
print("=" * 92)
print(" GATE — ETH/BTC 30m (vincitore gioco sess.2) sopra il BLEND 1h+15m attuale")
print(f" 30m: {r30['trades']} trade, {r30.get('n_skip_entry',0)} ingressi flat saltati")
print("=" * 92)
def corr(a, b): return a.pct_change().fillna(0).corr(b.pct_change().fillna(0))
print("\n[1] CORRELAZIONI (rendimenti giornalieri):")
print(f" 30m vs 1h : {corr(e30h, e1h):.3f}")
print(f" 30m vs 15m: {corr(e30h, e15):.3f} <-- se alta, ridondante col 15m gia' deployato")
print(f" (rif) 15m vs 1h: {corr(e15, e1h):.3f}")
print(f"\n[2] GATE PORT06 (cap PAIRS 0.33 + SHAPE 0.0588) | OOS da {OOS_DATE}:")
ids0 = list(p.sleeve_ids)
cl0 = p.clusters
caps = p.caps
f0, o0, _ = port_metrics(eq_base, ids0, cl0, caps)
# + 30m
mem1 = dict(eq_base); mem1["PR_ETHBTC_30M"] = e30h
ids1 = ids0 + ["PR_ETHBTC_30M"]
cl1 = dict(cl0); cl1["PR_ETHBTC_30M"] = "ETH-rev"
f1, o1, w1 = port_metrics(mem1, ids1, cl1, caps)
print(f" {'config':<22}{'FULL Sh':>8}{'FULL DD%':>9}{'OOS Sh':>8}{'OOS DD%':>8}")
print(f" {'ATTUALE (1h+15m)':<22}{f0['sharpe']:>8.2f}{f0['dd']:>9.2f}{o0['sharpe']:>8.2f}{o0['dd']:>8.2f}")
print(f" {'+30m (1h+15m+30m)':<22}{f1['sharpe']:>8.2f}{f1['dd']:>9.2f}{o1['sharpe']:>8.2f}{o1['dd']:>8.2f}")
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
print(f" => {'MIGLIORA (promosso)' if ok else 'NON migliora (bocciato)'}")
print(f" peso pairs ETH/BTC: 1h {w1.get('PR_ETHBTC',0)*100:.1f}% + 15m "
f"{w1.get('PR_ETHBTC_15M',0)*100:.1f}% + 30m {w1.get('PR_ETHBTC_30M',0)*100:.1f}% "
f"= {(w1.get('PR_ETHBTC',0)+w1.get('PR_ETHBTC_15M',0)+w1.get('PR_ETHBTC_30M',0))*100:.1f}% su 7 coppie")
print("\n[3] ROBUSTEZZA AI COSTI (30m standalone, Sharpe per fee RT/coppia):")
for fee, lbl in [(0.001, "0.20% 1x"), (0.002, "0.40% 2x"), (0.003, "0.60% 3x"),
(0.004, "0.80% 4x"), (0.006, "1.20% 6x")]:
r = pairs_sim_flat("ETH", "BTC", tf="30m", **WIN_30M, flat_skip=True, fee_rt=fee)
print(f" {lbl:<10} Sharpe {r['sharpe']:>6.2f}")
if __name__ == "__main__":
main()
+93
View File
@@ -95,6 +95,99 @@ def pairs_sim(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.5, max_bars=72,
eq_ts=eq_ts, eq_v=eq_v) eq_ts=eq_ts, eq_v=eq_v)
def aligned_ohlc(a: str, b: str, tf: str = "1h"):
"""Come aligned ma con OHLC di ENTRAMBE le gambe (serve a rilevare candele flat)."""
da = load_data(a, tf)[["timestamp", "open", "high", "low", "close"]].rename(
columns=lambda x: x + "_a" if x != "timestamp" else x)
db = load_data(b, tf)[["timestamp", "open", "high", "low", "close"]].rename(
columns=lambda x: x + "_b" if x != "timestamp" else x)
m = da.merge(db, on="timestamp", how="inner").reset_index(drop=True)
m["dt"] = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
return m
def is_flat_ohlc(o, h, l, c):
"""Candela flat (O=H=L=C): prezzo fermo / liquidita' zero -> fill non eseguibile."""
return (o == h) & (h == l) & (l == c)
def pairs_sim_flat(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.5, max_bars=72,
jump_max=0.08, fee_rt=FEE_RT, lev=LEV, pos=POS, split_frac=0.0,
flat_skip=False, scan_buffer=192):
"""Engine pairs GENERALIZZATO con opzione flat-skip LIVE-REALIZABLE.
Identico a pairs_sim quando flat_skip=False (regression-lock verificato).
Con flat_skip=True:
- ENTRY: saltata se la barra d'ingresso e' flat in UNA delle due gambe (prezzo stale).
- EXIT: la condizione di uscita (|z|<=z_exit O bars>=max_bars) arma 'exit_ready';
si esce al CLOSE della PRIMA barra PULITA successiva (mai a un prezzo passato).
scan_buffer = barre extra oltre max_bars concesse per trovare la barra pulita.
Questa e' la stessa regola implementata nel PairsWorker live (flat_skip) -> parita'.
"""
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)
ts = m["dt"]
split = int(N * split_frac)
fee = 2 * fee_rt * lev
cap = peak = 1000.0; dd = 0.0; last = -1
trades = wins = 0; rets = []; yearly = {}; yearly_n = {}
eq_ts, eq_v = [], []
n_skip_entry = 0
kmax = max_bars + (scan_buffer if flat_skip else 0)
for i in range(n + 1, N - 1):
if i < split or np.isnan(z[i]) or dr[i] > jump_max or i <= last:
continue
if z[i] <= -z_in:
d = 1
elif z[i] >= z_in:
d = -1
else:
continue
if flat[i]:
n_skip_entry += 1
continue # niente ingresso su barra stale
# uscita live-realizable: arma a |z|<=z_exit o max_bars, esci alla prima barra pulita
exit_ready = False; j = i
for k in range(1, kmax + 1):
jj = i + k
if jj >= N:
j = N - 1; break
if not exit_ready and (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
cap = max(cap + cap * pos * ret, 10.0)
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; rets.append(ret * pos); last = j
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
yearly_n[ts.iloc[i].year] = yearly_n.get(ts.iloc[i].year, 0) + 1
yrs_span = (ts.iloc[-1] - ts.iloc[max(split, 0)]).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
cagr = ((cap / 1000) ** (1 / yrs_span) - 1) * 100 if cap > 0 else -100
return dict(trades=trades, win=wins / trades * 100 if trades else 0, ret=ret_tot,
cagr=cagr, dd=dd * 100, sharpe=sharpe, yearly=yearly, yearly_n=yearly_n,
eq_ts=eq_ts, eq_v=eq_v, n_skip_entry=n_skip_entry)
def check_no_lookahead(): def check_no_lookahead():
"""Perturba il FUTURO del ratio e verifica che z[i] non cambi (causalita').""" """Perturba il FUTURO del ratio e verifica che z[i] non cambi (causalita')."""
m = aligned("ETH", "BTC") m = aligned("ETH", "BTC")
+199
View File
@@ -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()
+136
View File
@@ -0,0 +1,136 @@
"""Reconciler READ-ONLY conto Deribit vs libri dei worker (primo passo verso il
position-manager, audit 2026-06-11: il conto era short 0.027 ETH oltre i libri e
nessuno se n'era accorto per ore).
Confronta, per ogni strumento USDC:
atteso = Σ quote reali dei worker (status.json persistiti: fade/DIP/SH single-leg
+ pairs a 2 gambe) + Σ gambe ORFANE registrate (orphan_legs: posizioni
che il conto ha ancora ma i libri hanno chiuso — drift SPIEGATO)
reale = get_positions(currency=USDC) (size USD / mark = amount in coin)
Drift oltre tolleranza (1.5×step del contratto) -> tabella + alert Telegram
`ACCOUNT_DRIFT` (con --telegram). Anti-race: se al primo passaggio c'e' drift,
rilegge libri+conto dopo qualche secondo e segnala solo se persiste (un worker
poteva essere a meta' di un open/close).
Nessun ordine, nessuna modifica di stato: SOLO lettura.
uv run python scripts/analysis/reconcile_account.py # stampa
uv run python scripts/analysis/reconcile_account.py --telegram # + alert se drift
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.live.cerbero_client import CerberoClient
from src.live.execution import contract_spec
PAPER = PROJECT_ROOT / "data" / "portfolio_paper"
RECHECK_SLEEP = 10 # anti-race: secondi fra i due passaggi
TOL_STEPS = 1.5 # tolleranza = 1.5 × step contratto
def _inst(asset: str) -> str:
return f"{asset}_USDC-PERPETUAL"
def expected_books() -> tuple[dict[str, float], dict[str, float]]:
"""(atteso per strumento dai libri, quota orfana registrata per strumento).
Amount firmato in base-coin: buy=+, sell=-."""
books: dict[str, float] = {}
orphans: dict[str, float] = {}
for sp in sorted(PAPER.glob("*/status.json")):
st = json.loads(sp.read_text())
wid = sp.parent.name
parts = wid.split("__")
if st.get("real_in_position"):
if "real_amount_a" in st and st.get("real_amount_a"):
# pairs: asset da worker_id (…__ETH_BTC__1h)
a, b = parts[1].split("_")
for asset, side, amt in ((a, st["real_side_a"], st["real_amount_a"]),
(b, st["real_side_b"], st["real_amount_b"])):
sgn = 1 if side == "buy" else -1
books[_inst(asset)] = books.get(_inst(asset), 0.0) + sgn * amt
elif st.get("real_amount"):
# single-leg: fade/DIP01/SH01
sgn = 1 if st.get("real_side") == "buy" else -1
books[_inst(parts[1])] = books.get(_inst(parts[1]), 0.0) + sgn * st["real_amount"]
for o in st.get("orphan_legs", []):
# gamba che il libro ha chiuso ma il conto ha ancora (entry_side = verso
# in cui la posizione e' rimasta aperta sul conto)
sgn = 1 if o.get("entry_side") == "buy" else -1
orphans[o["instrument"]] = orphans.get(o["instrument"], 0.0) + sgn * float(o["amount"])
return books, orphans
def account_positions(client: CerberoClient) -> dict[str, float]:
"""Posizioni reali per strumento, amount firmato in base-coin."""
out: dict[str, float] = {}
for p in client.get_positions(currency="USDC") or []:
inst = p.get("instrument")
size = float(p.get("size") or 0)
mark = float(p.get("mark_price") or 0)
if not inst or not size or not mark:
continue
amt = size / mark
out[inst] = amt if p.get("direction") == "long" else -amt
return out
def compute_drift() -> list[dict]:
client = CerberoClient()
books, orphans = expected_books()
acct = account_positions(client)
rows = []
for inst in sorted(set(books) | set(orphans) | set(acct)):
exp = books.get(inst, 0.0) + orphans.get(inst, 0.0)
real = acct.get(inst, 0.0)
step = contract_spec(inst).get("step", 0.001)
tol = TOL_STEPS * step
rows.append(dict(inst=inst, books=books.get(inst, 0.0),
orphans=orphans.get(inst, 0.0), exp=exp, real=real,
drift=real - exp, tol=tol,
ok=abs(real - exp) <= tol))
return rows
def main():
rows = compute_drift()
bad = [r for r in rows if not r["ok"]]
if bad:
# anti-race: un worker poteva essere a meta' open/close -> ricontrolla
print(f"drift su {len(bad)} strumenti: ricontrollo fra {RECHECK_SLEEP}s (anti-race)...")
time.sleep(RECHECK_SLEEP)
rows = compute_drift()
bad = [r for r in rows if not r["ok"]]
print(f"{'strumento':<22}{'libri':>10}{'orfani':>9}{'atteso':>10}{'conto':>10}{'drift':>10} esito")
for r in rows:
print(f"{r['inst']:<22}{r['books']:>10.4f}{r['orphans']:>9.4f}{r['exp']:>10.4f}"
f"{r['real']:>10.4f}{r['drift']:>+10.4f} {'OK' if r['ok'] else '⚠️ DRIFT'}")
if not rows:
print("(nessuna posizione attesa ne' reale)")
print("\nESITO:", "OK — conto allineato ai libri" if not bad
else f"⚠️ DRIFT PERSISTENTE su {len(bad)} strumenti")
if bad and "--telegram" in sys.argv:
from src.live.telegram_notifier import notify_event
notify_event("ACCOUNT_DRIFT", {
"strumenti": {r["inst"]: {"atteso": round(r["exp"], 5),
"conto": round(r["real"], 5),
"drift": round(r["drift"], 5)} for r in bad},
"note": ("conto != libri worker oltre tolleranza (drift NON spiegato dagli "
"orfani registrati): verificare close cappati/gambe respinte — "
"vedi docs/diary/2026-06-11-system-audit.md")})
print("[telegram] alert ACCOUNT_DRIFT inviato")
return bool(bad)
if __name__ == "__main__":
sys.exit(1 if main() else 0)
+16 -1
View File
@@ -28,7 +28,7 @@ from scripts.analysis.combine_portfolio import (
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX, build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
) )
from scripts.analysis.honest_improve2 import _daily_equity, _norm from scripts.analysis.honest_improve2 import _daily_equity, _norm
from scripts.analysis.pairs_research import pairs_sim from scripts.analysis.pairs_research import pairs_sim, pairs_sim_flat
from scripts.analysis.tsmom_research import tsmom_sim from scripts.analysis.tsmom_research import tsmom_sim
from scripts.strategies.PR01_pairs_reversion import PAIRS from scripts.strategies.PR01_pairs_reversion import PAIRS
from scripts.analysis.shape_ml_validate import shape_daily_equity from scripts.analysis.shape_ml_validate import shape_daily_equity
@@ -46,9 +46,24 @@ def build_everything():
for a, b, p in PAIRS: for a, b, p in PAIRS:
r = pairs_sim(a, b, **p) r = pairs_sim(a, b, **p)
pairs[f"PR_{a}{b}"] = daily_from(r["eq_ts"], r["eq_v"]) pairs[f"PR_{a}{b}"] = daily_from(r["eq_ts"], r["eq_v"])
# BLEND ETH/BTC 15m flat-skip (gioco Blind Traders -> gate PORT06, decorrelato 0.37
# dal 1h, edge non-artefatto-flat, worker validato). Engine LIVE-REALIZABLE identico
# al PairsWorker (pairs_sim_flat). Diari 2026-06-09-pairs15m-*.md.
# MEZZA size (pos 0.075 = meta' della canonica 0.15): a peso uguale il 15m, piu'
# volatile, contribuirebbe ~26% del rischio PORT06 (vs ~9% del 1h). Dimezzarlo lo
# riporta in linea col 1h -> blend-tilt, non scommessa dominante (col caveat slippage).
# Coerente col live (params.position_size=0.10 = meta' del family PAIRS 0.20).
r15 = pairs_sim_flat("ETH", "BTC", tf="15m", n=66, z_in=1.674, z_exit=1.0,
max_bars=35, flat_skip=True, pos=0.075)
pairs["PR_ETHBTC_15M"] = daily_from(r15["eq_ts"], r15["eq_v"])
t = tsmom_sim() t = tsmom_sim()
tsm = {"TSM01": daily_from(t["eq_ts"], t["eq_v"])} tsm = {"TSM01": daily_from(t["eq_ts"], t["eq_v"])}
shape = {f"SH_{a}": _norm(shape_daily_equity(a, IDX)) for a in ("BTC", "ETH")} shape = {f"SH_{a}": _norm(shape_daily_equity(a, IDX)) for a in ("BTC", "ETH")}
# XS01 — reversione cross-sectional (8 asset, market-neutral). Engine canonico
# scripts.strategies.XS01_cross_sectional (worker validato == backtest).
from scripts.strategies.XS01_cross_sectional import xsec_sim
x = xsec_sim()
tsm["XS01"] = daily_from(x["eq_ts"], x["eq_v"])
return S, pairs, tsm, shape return S, pairs, tsm, shape
+64
View File
@@ -0,0 +1,64 @@
"""Flatten one-shot del conto Deribit testnet per il RESET del portafoglio (2026-06-10).
Cancella gli ordini resting noti (TP limit / disaster-SL dai status.json dei worker),
poi chiude TUTTE le posizioni USDC con market reduce-only (close_position di cerbero
non supporta i lineari USDC) e verifica che il conto sia flat.
Uso: uv run python scripts/analysis/reset_flatten.py
"""
import glob
import json
import time
from src.live.cerbero_client import CerberoClient
from src.live.execution import ExecutionClient, contract_spec, register_contract
def main():
c = CerberoClient()
ex = ExecutionClient(client=c)
# 1) ordini resting noti dai status.json (TP limit + disaster bracket)
oids = set()
for f in glob.glob("data/portfolio_paper/*/status.json"):
s = json.load(open(f))
for k in ("real_tp_order_id", "real_dsl_order_id"):
if s.get(k):
oids.add(s[k])
for oid in sorted(oids):
r = ex.cancel_order(oid)
print(f"cancel {oid}: {r.get('state', r)}")
# 2) flatten posizioni USDC (market reduce-only, max 5 passate)
for attempt in range(5):
live = [p for p in c.get_positions("USDC")
if abs(float(p.get("size", 0) or 0)) > 0]
if not live:
print("FLAT — conto pulito")
return True
for p in live:
inst = p["instrument"]
register_contract(inst, c)
size_usd = abs(float(p["size"]))
mark = float(p.get("mark_price") or 0)
step = contract_spec(inst)["step"]
entry_side = "buy" if p["direction"] == "long" else "sell"
units = size_usd / mark if mark else 0
amount = round(units / step) * step
if amount <= 0:
print(f" {inst}: residuo {size_usd:.2f} USD sotto mezzo step, ok")
continue
f = ex.close_amount(inst, entry_side, amount, label="reset_flatten")
print(f" close {inst} amt={amount} -> {f.order_state} fill={f.fill_price} "
f"verified={f.verified} {f.notes[:60]}")
time.sleep(3)
left = [(p["instrument"], round(float(p["size"]), 4))
for p in c.get_positions("USDC") if abs(float(p.get("size", 0) or 0)) > 0]
print("RESIDUO FINALE:", left or "FLAT")
return not left
if __name__ == "__main__":
ok = main()
raise SystemExit(0 if ok else 1)
+4 -100
View File
@@ -29,116 +29,20 @@ from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data from src.data.downloader import load_data
from src.strategies.fade_base import hurst_skip_mask from src.strategies.fade_base import hurst_skip_mask
from scripts.analysis.strategy_research import atr from scripts.analysis.risk_management import strats_for
from scripts.analysis.risk_management import strats_for, FEE_RT, LEV, POS, INIT from scripts.analysis.combine_portfolio import OOS_DATE
from scripts.analysis.combine_portfolio import ( from scripts.analysis._port06_gate_common import (
_norm, IDX, port_returns, metrics, SPLIT, OOS_DATE, build_trades_variant, equity_from_trades, port_metrics, dd as _dd,
) )
from scripts.portfolios._defs import PORTFOLIOS from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio import weighting as W
BUFFER = 0.5 # EXIT-16 close-confirm (come in produzione)
HURST_MAX = 0.55 # loss-guard live HURST_MAX = 0.55 # loss-guard live
TREND_MAX = 3.0 TREND_MAX = 3.0
EMA_LONG = 200
def build_trades_variant(ents, df, mode, trend_max, hurst_mask=None,
buffer=BUFFER, lev=LEV, fee_rt=FEE_RT, ema_long=EMA_LONG):
"""Engine di exit16_port06_impact.build_trades_variant + skip Hurst opzionale.
mode="orig" : SL intrabar al livello (SL prima del TP) == canonico.
mode="exit16" : SL intrabar OFF; TP intrabar al livello (priorita' nel bar);
SL solo se il CLOSE sfonda sl0 -/+ buffer*ATR14[j], fill a close[j].
trend_max : None = filtro OFF (live attuale); 3.0 = candidato.
hurst_mask : bool[i]=True -> salta l'ingresso (loss-guard live).
"""
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 hurst_mask is not None and hurst_mask[i]:
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
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 equity_from_trades(df, trades):
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
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)
def port_metrics(members: dict[str, pd.Series], p):
ids = p.sleeve_ids
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
drp = port_returns({i: members[i] for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
def _dd(s):
pk = s.cummax()
return float(((pk - s) / pk).max() * 100)
def main(): def main():
+44 -30
View File
@@ -18,56 +18,70 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from src.live.pairs_worker import PairsWorker from src.live.pairs_worker import PairsWorker
from scripts.analysis.pairs_research import aligned, pairs_sim from scripts.analysis.pairs_research import aligned, aligned_ohlc, pairs_sim, pairs_sim_flat
from scripts.strategies.PR01_pairs_reversion import PAIRS from scripts.strategies.PR01_pairs_reversion import PAIRS
WINDOW = 60 # finestra trailing minima (>= n+2): z[i] corretto, replay veloce # Config 15m promossa dal gate (gioco Blind Traders + flat-skip): vedi
# docs/diary/2026-06-09-pairs15m-port06-gate.md
CFG_15M = dict(n=66, z_in=1.674, z_exit=1.0, max_bars=35, flat_skip=True)
def replay(a: str, b: str, params: dict, data_dir: Path) -> PairsWorker: def replay(a, b, params, data_dir, tf="1h", ohlc=False) -> PairsWorker:
m = aligned(a, b) if ohlc:
m = aligned_ohlc(a, b, tf)
df_a = pd.DataFrame({"timestamp": m["timestamp"], "open": m["open_a"],
"high": m["high_a"], "low": m["low_a"], "close": m["close_a"]})
df_b = pd.DataFrame({"timestamp": m["timestamp"], "open": m["open_b"],
"high": m["high_b"], "low": m["low_b"], "close": m["close_b"]})
else:
m = aligned(a, b, tf)
df_a = m[["timestamp"]].copy(); df_a["close"] = m["close_a"].values df_a = m[["timestamp"]].copy(); df_a["close"] = m["close_a"].values
df_b = m[["timestamp"]].copy(); df_b["close"] = m["close_b"].values df_b = m[["timestamp"]].copy(); df_b["close"] = m["close_b"].values
w = PairsWorker(a, b, "1h", params=params, fee_rt=0.001, data_dir=data_dir) w = PairsWorker(a, b, tf, params=params, fee_rt=0.001, data_dir=data_dir)
# replay veloce: niente I/O su file / log / notifiche ad ogni tick (servono solo le metriche finali)
w._save_state = lambda: None w._save_state = lambda: None
w._log = lambda *a, **k: None w._log = lambda *a, **k: None
w._notify = lambda *a, **k: None w._notify = lambda *a, **k: None
n = w.n window = max(60, w.n + 6) # finestra trailing >= n+? : z[i] corretto
for k in range(n + 2, len(m) + 1): for k in range(w.n + 2, len(m) + 1):
lo = max(0, k - WINDOW) lo = max(0, k - window)
w.tick(df_a.iloc[lo:k], df_b.iloc[lo:k]) w.tick(df_a.iloc[lo:k], df_b.iloc[lo:k])
# chiudi eventuale posizione aperta a fine serie (come fa il backtest col troncamento)
return w return w
def main(): def _row(label, w, bt):
print("=" * 96)
print(" VALIDAZIONE PairsWorker — replay live vs backtest pairs_sim (fee 0.20% RT/coppia)")
print("=" * 96)
print(f" {'coppia':<10s}{'WORKER cap':>12s}{'trd':>5s}{'win%':>6s} | {'BACKTEST cap':>13s}{'trd':>5s}{'win%':>6s} match?")
print(" " + "-" * 88)
# Sottoinsieme rappresentativo: il codice del worker e' identico per ogni coppia,
# quindi 2 coppie con strutture diverse (alt/major e major/alt) bastano a provare
# l'equivalenza. ~135s/coppia su 73k barre orarie. Per validarle tutte: usa PAIRS.
subset = [pp for pp in PAIRS if (pp[0], pp[1]) in {("ETH", "BTC"), ("BTC", "LTC")}]
tmp = Path(tempfile.mkdtemp(prefix="pairs_validate_"))
try:
for a, b, p in subset:
w = replay(a, b, p, tmp)
bt = pairs_sim(a, b, **p)
bt_cap = 1000.0 * (1 + bt["ret"] / 100) bt_cap = 1000.0 * (1 + bt["ret"] / 100)
cap_match = abs(w.capital - bt_cap) / bt_cap < 0.02 if bt_cap else False cap_match = abs(w.capital - bt_cap) / bt_cap < 0.02 if bt_cap else False
trd_match = abs(w.total_trades - bt["trades"]) <= max(2, bt["trades"] * 0.02) trd_match = abs(w.total_trades - bt["trades"]) <= max(2, bt["trades"] * 0.02)
ok = "OK" if (cap_match and trd_match) else "DIFF" ok = "OK" if (cap_match and trd_match) else "DIFF"
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" {a+'/'+b:<10s}{w.capital:>12.0f}{w.total_trades:>5d}{ww:>6.1f} | " print(f" {label:<16s}{w.capital:>13.0f}{w.total_trades:>6d}{ww:>6.1f} | "
f"{bt_cap:>13.0f}{bt['trades']:>5d}{bt['win']:>6.1f} {ok}") f"{bt_cap:>14.0f}{bt['trades']:>6d}{bt['win']:>6.1f} {ok}")
return ok == "OK"
def main():
print("=" * 100)
print(" VALIDAZIONE PairsWorker — replay live == backtest (fee 0.20% RT/coppia)")
print("=" * 100)
print(f" {'caso':<16s}{'WORKER cap':>13s}{'trd':>6s}{'win%':>6s} | "
f"{'BACKTEST cap':>14s}{'trd':>6s}{'win%':>6s} match?")
print(" " + "-" * 92)
tmp = Path(tempfile.mkdtemp(prefix="pairs_validate_"))
allok = True
try:
# [A] REGRESSIONE 1h (flat_skip=False, close-only) vs pairs_sim
for a, b, p in [pp for pp in PAIRS if (pp[0], pp[1]) in {("ETH", "BTC"), ("BTC", "LTC")}]:
w = replay(a, b, p, tmp, tf="1h", ohlc=False)
allok &= _row(f"{a}/{b} 1h", w, pairs_sim(a, b, **p))
# [B] NUOVO: 15m flat-skip (OHLC) vs pairs_sim_flat
w = replay("ETH", "BTC", CFG_15M, tmp, tf="15m", ohlc=True)
bt = pairs_sim_flat("ETH", "BTC", tf="15m", **CFG_15M)
allok &= _row("ETH/BTC 15m-flat", w, bt)
finally: finally:
shutil.rmtree(tmp, ignore_errors=True) shutil.rmtree(tmp, ignore_errors=True)
print(" " + "-" * 88) print(" " + "-" * 92)
print(" match = capitale entro 2% e trade entro 2% del backtest. Differenze minime sono") print(" match = capitale e trade entro 2% del backtest (diff minime = bar finale aperta).")
print(" attese (gestione bar finale/troncamento), ma la semantica deve coincidere.") print(f" ESITO COMPLESSIVO: {'TUTTO OK' if allok else 'DIFFERENZE -> INDAGARE'}")
if __name__ == "__main__": if __name__ == "__main__":
+83
View File
@@ -0,0 +1,83 @@
"""Valida il CrossSectionalWorker: replay bar-per-bar == backtest XS01.xsec_sim?
Come validate_worker_pairs: alimenta il worker con finestre trailing crescenti del
pannello 8-asset e confronta capitale finale e n.trade col backtest di riferimento
scripts.strategies.XS01_cross_sectional.xsec_sim. Se combaciano, la semantica live e' fedele.
"""
from __future__ import annotations
import shutil
import sys
import tempfile
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.live.xsec_worker import CrossSectionalWorker
from scripts.strategies.XS01_cross_sectional import aligned_panel, xsec_sim, UNIVERSE, LB, HOLD
def _replay(M, dfs, params):
"""Alimenta il worker bar-per-bar su finestre trailing; ritorna il worker."""
n = len(M)
tmp = Path(tempfile.mkdtemp(prefix="xsec_val_"))
try:
w = CrossSectionalWorker(UNIVERSE, tf="1h", params=params,
fee_rt=0.0005, data_dir=tmp)
w._save = lambda: None; w._log = lambda *a, **k: None; w._notify = lambda *a, **k: None
window = LB + 6
for k in range(LB + 1, n + 1): # prima finestra = lb+1 barre -> ingresso al bar lb
lo = max(0, k - window)
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_cap = 1000.0 * (1 + bt["ret"] / 100)
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)
ww = w.total_wins / w.total_trades * 100 if w.total_trades else 0
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" BCKTST{bt_cap:>14.0f}{bt['trades']:>8d}{bt['win']:>7.1f}")
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)")
if __name__ == "__main__":
main()
+189
View File
@@ -0,0 +1,189 @@
"""XS01 dispersion-gate — la reversione cross-sectional va accesa solo in certi regimi?
Motivazione: l'edge XS01 e' concentrato (2025 domina, 2023 debole). Ipotesi da
testare: il fattore reversione cross-sezionale paga quando c'e' DISPERSIONE da
far rientrare (spread cross-section largo) e/o correlazione media alta (mosse
idiosincratiche = rumore che rientra), e perde nei regime-break (dispersione da
trend divergente, es. melt-up di un singolo asset).
Metodo (anti-multiple-testing):
[1] DIAGNOSTICA: engine XS01 canonico SENZA gate, registrando per ogni trade
il valore di 3 feature di regime alla barra di ENTRY (tutte causali,
calcolate dallo stesso panel closes <= i):
g_disp = std cross-section del segnale stesso (logC[i]-logC[i-lb])
g_corr = correlazione media pairwise 72h (identita' var dell'indice)
g_vol = vol realizzata BTC 168h
Bucket per quintili (quintili dal TRAIN) -> mean net per bucket,
TRAIN e OOS SEPARATI. Si prosegue solo se la relazione e' monotona
e con lo stesso segno in entrambe le finestre.
[2] GATE: per la feature promossa, sweep soglie (percentili TRAIN
30/40/50/60/70) -> TRAIN/OOS Sharpe/PnL/DD vs base. Serve PLATEAU.
[3] Solo se [2] regge: gate PORT06 (swap equity sleeve XS01).
uv run python scripts/analysis/xs01_dispersion_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.strategies.XS01_cross_sectional import (
aligned_panel, UNIVERSE, FEE_RT, LEV, POS, OOS_FRAC, LB, HOLD)
N_A = len(UNIVERSE)
def build_features(M, lb=LB):
"""Feature di regime causali dal panel closes (nessun feed esterno)."""
logC = np.log(M.values)
r = np.diff(logC, axis=0, prepend=logC[:1]) # ret orari (r[0]=0)
R = pd.DataFrame(r, index=M.index)
# g_disp: std cross-section del momentum lb (il segnale che fadiamo)
D = pd.DataFrame(logC).diff(lb).to_numpy()
g_disp = np.nanstd(D, axis=1)
# g_corr 72h: avg pairwise corr via identita' della varianza dell'indice
w = 72
idx_var = R.mean(axis=1).rolling(w).var().to_numpy()
mean_var = R.rolling(w).var().mean(axis=1).to_numpy()
with np.errstate(divide="ignore", invalid="ignore"):
g_corr = (N_A * idx_var / mean_var - 1) / (N_A - 1)
# g_vol: vol BTC 168h annualizzata
b = UNIVERSE.index("BTC")
g_vol = R[b].rolling(168).std().to_numpy() * np.sqrt(24 * 365)
return dict(g_disp=g_disp, g_corr=g_corr, g_vol=g_vol)
def sim_with_trace(M, feats, gate=None, lb=LB, hold=HOLD, fee_rt=FEE_RT,
lev=LEV, pos=POS):
"""Engine XS01 canonico (stessa logica/ordine di XS01_cross_sectional.xsec_sim)
+ trace per-trade (entry index, net, feature) + gate opzionale bool[i]."""
C = M.values
ts = pd.to_datetime(M.index, unit="ms", utc=True)
n = len(C)
logC = np.log(C)
cap = peak = 1000.0
dd = 0.0
rows = []
eq_ts, eq_v = [], []
last = -1
i = lb
fee = 2 * fee_rt
while i < n - hold:
if i <= last:
i += 1
continue
if gate is not None and not gate[i]:
i += 1
continue
dm = (logC[i] - logC[i - lb])
dm = dm - dm.mean()
w = -dm
gw = np.sum(np.abs(w))
if gw < 1e-9:
i += 1
continue
w = w / gw
book = float(np.sum(w * (logC[i + hold] - logC[i])))
net = book - fee
cap = max(cap + cap * pos * lev * net, 10.0)
peak = max(peak, cap)
dd = max(dd, (peak - cap) / peak)
rows.append((i, int(ts[i].year), net,
feats["g_disp"][i], feats["g_corr"][i], feats["g_vol"][i]))
eq_ts.append(ts[i + hold])
eq_v.append(cap)
last = i + hold
i += 1
tr = pd.DataFrame(rows, columns=["i", "year", "net", "g_disp", "g_corr", "g_vol"])
yrs_span = (ts[-1] - ts[0]).days / 365.25 or 1
out = dict(trades=len(tr), cap=cap, dd=dd * 100, eq_ts=eq_ts, eq_v=eq_v, tr=tr)
if len(tr) > 1 and tr["net"].std() > 0:
out["sharpe"] = float(tr["net"].mean() / tr["net"].std()
* np.sqrt(len(tr) / yrs_span))
else:
out["sharpe"] = 0.0
out["pnl_add"] = float(tr["net"].sum() * 100) if len(tr) else 0.0
out["win"] = float((tr["net"] > 0).mean() * 100) if len(tr) else 0.0
out["tpm"] = len(tr) / (yrs_span * 12)
return out
def metrics_window(tr, lo, hi, yrs_span):
t = tr[(tr["i"] >= lo) & (tr["i"] < hi)]
if len(t) < 2 or t["net"].std() == 0:
return dict(n=len(t), pnl=0.0, sh=0.0, win=0.0)
sh = float(t["net"].mean() / t["net"].std() * np.sqrt(len(t) / yrs_span))
return dict(n=len(t), pnl=float(t["net"].sum() * 100), sh=sh,
win=float((t["net"] > 0).mean() * 100))
def main():
M = aligned_panel()
n = len(M)
cut = int(n * (1 - OOS_FRAC))
ts = pd.to_datetime(M.index, unit="ms", utc=True)
feats = build_features(M)
print("=" * 96)
print(f" XS01 dispersion-gate | panel {ts[0].date()} -> {ts[-1].date()} "
f"({n} ore, 8 asset) | TRAIN 70% (-> {ts[cut].date()}) / OOS 30%")
print("=" * 96)
base = sim_with_trace(M, feats)
tr = base["tr"]
yrs_tr = (ts[cut] - ts[0]).days / 365.25
yrs_oo = (ts[-1] - ts[cut]).days / 365.25
# [1] DIAGNOSTICA per quintili (quintili dal TRAIN)
print("\n[1] DIAGNOSTICA — mean net per trade (bps) per quintile feature @entry")
ttr = tr[tr["i"] < cut]
too = tr[tr["i"] >= cut]
for g in ("g_disp", "g_corr", "g_vol"):
qs = ttr[g].quantile([0.2, 0.4, 0.6, 0.8]).to_numpy()
def bucket(x):
return int(np.searchsorted(qs, x))
print(f" {g:<7s} | " + " | ".join(
f"Q{q+1} TR {ttr[ttr[g].apply(bucket) == q]['net'].mean()*1e4:+6.1f} "
f"OOS {too[too[g].apply(bucket) == q]['net'].mean()*1e4:+6.1f}"
for q in range(5)) +
f" (n TR {len(ttr)}, OOS {len(too)})")
# [2] GATE sweep — per ogni feature, tieni SOPRA o SOTTO il percentile
print("\n[2] GATE — TRAIN/OOS vs base (soglie = percentili del TRAIN; "
"side scelto dal segno della diagnostica TRAIN)")
b_tr = metrics_window(tr, 0, cut, yrs_tr)
b_oo = metrics_window(tr, cut, n, yrs_oo)
print(f" {'BASE':<24s} TRAIN n {b_tr['n']:>4} pnl {b_tr['pnl']:>+7.1f}% "
f"Sh {b_tr['sh']:>5.2f} | OOS n {b_oo['n']:>4} pnl {b_oo['pnl']:>+7.1f}% "
f"Sh {b_oo['sh']:>5.2f}")
for g in ("g_disp", "g_corr", "g_vol"):
# segno dal TRAIN: correlazione quintile->ret
qs5 = ttr[g].quantile([0.2, 0.4, 0.6, 0.8]).to_numpy()
means = [ttr[ttr[g].apply(lambda x: int(np.searchsorted(qs5, x))) == q]["net"].mean()
for q in range(5)]
side = "above" if means[-1] > means[0] else "below"
for pct in (30, 40, 50, 60, 70):
thr = float(np.nanpercentile(feats[g][:cut], pct))
gv = feats[g]
gate = (gv >= thr) if side == "above" else (gv <= thr)
gate = np.nan_to_num(gate, nan=False).astype(bool)
r = sim_with_trace(M, feats, gate=gate)
g_tr = metrics_window(r["tr"], 0, cut, yrs_tr)
g_oo = metrics_window(r["tr"], cut, n, yrs_oo)
print(f" {g} {side} p{pct:<3d}{'':<6s} TRAIN n {g_tr['n']:>4} "
f"pnl {g_tr['pnl']:>+7.1f}% Sh {g_tr['sh']:>5.2f} | "
f"OOS n {g_oo['n']:>4} pnl {g_oo['pnl']:>+7.1f}% Sh {g_oo['sh']:>5.2f}")
# breakdown annuale base (riferimento concentrazione)
print("\n base, net additivo per anno (%):",
{int(y): round(float(v * 100), 1)
for y, v in tr.groupby("year")["net"].sum().items()})
if __name__ == "__main__":
main()
+101
View File
@@ -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()
+136
View File
@@ -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()
+97
View File
@@ -0,0 +1,97 @@
"""GATE PORT06 — XS01 (reversione cross-sectional 8 asset), candidato trovato in sessione.
XS01: ogni HOLD ore, long i perdenti relativi / short i vincenti su 8 asset (lb LB),
market-neutral gross 1, fee 0.10% RT/book. Decorrelato (~0) dai pairs. Domanda: aggiunto
a PORT06 migliora Sharpe/DD? (criterio del progetto: OOS Sharpe non peggiora E DD scende.)
uv run python scripts/analysis/xsec_port06_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 src.data.downloader import load_data
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT, OOS_DATE
from scripts.analysis.report_families import daily_from
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio.sleeves import all_sleeve_equities
from src.portfolio import weighting as W
ASSETS = ["BTC", "ETH", "LTC", "ADA", "SOL", "BNB", "XRP", "DOGE"]
LB, HOLD, FEE = 48, 12, 0.0005
def xsec_equity(pos=0.15, lev=3.0):
dfs = {a: load_data(a, "1h")[["timestamp", "close"]].rename(columns={"close": a}).set_index("timestamp")
for a in ASSETS}
M = pd.concat(dfs.values(), axis=1, join="inner").sort_index()
C = M[ASSETS].values
ts = pd.to_datetime(M.index, unit="ms", utc=True)
n = len(C); logC = np.log(C)
cap = 1000.0; eq_ts, eq_v, rets = [], [], []
last = -1; i = LB
while i < n - HOLD:
if i <= last:
i += 1; continue
dm = (logC[i] - logC[i - LB]); dm = dm - dm.mean()
w = -dm; gw = np.sum(np.abs(w))
if gw < 1e-9:
i += 1; continue
w = w / gw
net = np.sum(w * (logC[i + HOLD] - logC[i])) - FEE * np.sum(np.abs(w)) * 2
cap = max(cap + cap * pos * lev * net, 10.0)
rets.append(net); eq_ts.append(ts[i + HOLD]); eq_v.append(cap)
last = i + HOLD; i += 1
return daily_from(eq_ts, eq_v), np.array(rets)
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), w
def main():
p = PORTFOLIOS["PORT06"]
eq_base = dict(all_sleeve_equities())
print("=" * 92)
print(" GATE PORT06 — XS01 reversione cross-sectional (8 asset) | OOS da", OOS_DATE)
print("=" * 92)
for pos, lbl in [(0.15, "XS01 pos0.15"), (0.075, "XS01 pos0.075 (mezza)")]:
e, r = xsec_equity(pos=pos)
# correlazione con i pairs e i fade
cors = {}
for ref in ("PR_ETHBTC", "MR02_ETH"):
j = pd.concat([e.pct_change(), eq_base[ref].pct_change()], axis=1).dropna()
cors[ref] = round(j.iloc[:, 0].corr(j.iloc[:, 1]), 3)
ids0 = list(p.sleeve_ids); cl0 = p.clusters; caps = p.caps
f0, o0, _ = port_metrics(eq_base, ids0, cl0, caps)
mem = dict(eq_base); mem["XS01"] = e
ids1 = ids0 + ["XS01"]; cl1 = dict(cl0); cl1["XS01"] = "xsec"
f1, o1, w1 = port_metrics(mem, ids1, cl1, caps)
# risk contribution di XS01
drm = pd.DataFrame({i: mem[i].pct_change().fillna(0.0) for i in ids1})
cov = drm.cov(); wv = np.array([w1[i] for i in ids1])
pv = float(wv @ cov.values @ wv)
rc = {i: float(w1[i] * (cov.values[k] @ wv) / pv * 100) for k, i in enumerate(ids1)}
print(f"\n[{lbl}] corr XS01 vs {cors} | peso XS01 {w1['XS01']*100:.1f}% | "
f"risk-contrib XS01 {rc['XS01']:.1f}%")
print(f" {'config':<16}{'FULL Sh':>8}{'FULL DD%':>9}{'OOS Sh':>8}{'OOS DD%':>8}")
print(f" {'ATTUALE':<16}{f0['sharpe']:>8.2f}{f0['dd']:>9.2f}{o0['sharpe']:>8.2f}{o0['dd']:>8.2f}")
print(f" {'+XS01':<16}{f1['sharpe']:>8.2f}{f1['dd']:>9.2f}{o1['sharpe']:>8.2f}{o1['dd']:>8.2f}")
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)
print(f" => {'PROMOSSO' if ok else 'non passa il criterio stretto (vedi numeri)'}")
if __name__ == "__main__":
main()
+100
View File
@@ -0,0 +1,100 @@
"""
agent_brief — genera il "digest" ANONIMO che ogni agente cieco riceve.
L'agente non sa che sono BTC/ETH ne' che e' crypto: vede solo due serie X e Y
(rinominate dal motore A/B), una finestra normalizzata (base 100) e statistiche
aggregate. Da queste deve proporre una regola che "anticipi" i movimenti.
Genera anche il MENU dei blocchi (famiglie + range parametri) che l'agente puo'
comporre, in modo che l'output sia una spec backtestabile.
"""
from __future__ import annotations
import json
import numpy as np
from scripts.games.engine import load_anon
def _stats(close, high, low):
r = np.diff(np.log(close))
r = r[np.isfinite(r)]
out = {
"n_bars": int(len(close)),
"ret_vol_pct": round(float(np.std(r) * 100), 4),
"ret_autocorr_lag1": round(float(np.corrcoef(r[:-1], r[1:])[0, 1]), 4),
"ret_autocorr_lag5": round(float(np.corrcoef(r[:-5], r[5:])[0, 1]), 4),
"pct_up_bars": round(float(np.mean(r > 0) * 100), 2),
"skew": round(float(((r - r.mean()) ** 3).mean() / (r.std() ** 3 + 1e-12)), 3),
"kurtosis": round(float(((r - r.mean()) ** 4).mean() / (r.std() ** 4 + 1e-12)), 2),
}
# tendenza a rientrare dopo grandi mosse (|z|>2): segno del rendimento successivo
z = (r - r.mean()) / (r.std() + 1e-12)
big = np.where(np.abs(z[:-1]) > 2)[0]
if len(big) > 20:
nxt = r[big + 1]
same = np.sign(r[big]) == np.sign(nxt)
out["after_big_move_continues_pct"] = round(float(np.mean(same) * 100), 1)
return out
def make_digest(tf: str, window: int = 60, seed: int = 0):
data = load_anon(tf)
n = data["n"]
# finestra recente normalizzata (base 100) per "vedere" la forma
s = max(0, n - window)
dig = {"timeframe_id": {"5m": "T1", "15m": "T2", "30m": "T3", "1h": "T4",
"2h": "T5", "4h": "T6", "1d": "T7"}.get(tf, "T?"),
"n_bars_total": n, "series": {}}
for name in ("A", "B"):
o = data[name]
c = o["close"]
norm = (c[s:] / c[s] * 100.0)
dig["series"][{"A": "X", "B": "Y"}[name]] = {
"stats": _stats(c, o["high"], o["low"]),
"recent_window_norm": [round(float(v), 2) for v in norm],
}
# relazione fra le due serie
ra = np.diff(np.log(data["A"]["close"]))
rb = np.diff(np.log(data["B"]["close"]))
m = min(len(ra), len(rb))
dig["XY_return_correlation"] = round(float(np.corrcoef(ra[:m], rb[:m])[0, 1]), 4)
lr = np.log(data["A"]["close"][:m + 1] / data["B"]["close"][:m + 1])
dig["XY_logratio_ret_autocorr"] = round(
float(np.corrcoef(np.diff(lr)[:-1], np.diff(lr)[1:])[0, 1]), 4)
return dig
MENU = {
"obiettivo": ("Proponi UNA regola che anticipi i movimenti futuri per un PnL "
"netto positivo dopo costi (0.10% andata+ritorno per trade). "
"Servono >=10 operazioni al mese. Non sai cosa siano X e Y."),
"famiglie": {
"zscore": "fade/segui lo z-score del prezzo su 'lookback' barre (entry_thr in sigma)",
"breakout": "rottura del canale max/min su 'lookback' barre (reversion=fade la rottura)",
"ma_cross": "incrocio EMA veloce(lookback)/lenta(lookback*slow_mult)",
"rsi": "RSI(lookback); entry_thr scala le bande attorno a 50",
"momentum": "rendimento su 'lookback' barre vs soglia entry_thr (%)",
"pairs": "market-neutral sullo z del log-rapporto X/Y (long una/short l'altra)",
},
"direzione": ["reversion (vai contro la mossa)", "trend (segui la mossa)"],
"serie": ["X", "Y (solo per single-family)", "pairs usa entrambe"],
"exit": "tp_atr / sl_atr (in unita' ATR), max_bars (durata massima)",
"range": {
"lookback": "5-120", "entry_thr": "1.0-3.5", "tp_atr": "0.5-4.0",
"sl_atr": "1.0-5.0", "max_bars": "6-120", "slow_mult": "2-6",
"exit_thr (pairs)": "0.2-1.0",
},
"output_schema": {
"family": "una di [zscore,breakout,ma_cross,rsi,momentum,pairs]",
"series": "X|Y|AB(pairs)", "direction": "reversion|trend",
"params": "dict coi parametri scelti", "hypothesis": "1-2 frasi: cosa hai notato",
},
}
if __name__ == "__main__":
import sys
tf = sys.argv[1] if len(sys.argv) > 1 else "1h"
print(json.dumps(make_digest(tf), indent=2)[:2000])
+253
View File
@@ -0,0 +1,253 @@
"""
Arena — tournament orchestrator per il gioco "Blind Traders".
100 agenti partono da una spec di strategia (creata alla cieca: vedi
agent_brief.py / workflow). L'orchestratore valuta ogni spec con il backtest
deterministico (engine.evaluate) su TRAIN, da' epoche di elaborazione (ogni
agente affina la propria strategia via hill-climb sui parametri) e OGNI 10
EPOCHE blocca il 10% meno profittevole. Restano i 10 piu' profittevoli.
Punteggio = fitness su PNL + %win, con vincolo >=10 trade/mese (engine).
"""
from __future__ import annotations
import json
import random
from pathlib import Path
import numpy as np
from scripts.games.engine import load_anon, splits3, evaluate
OUT = Path("data/games")
OUT.mkdir(parents=True, exist_ok=True)
# Spazio parametri per famiglia (min, max, tipo)
SPACE = {
"zscore": dict(lookback=(10, 100, "i"), entry_thr=(1.0, 3.5, "f"),
tp_atr=(0.5, 4.0, "f"), sl_atr=(1.0, 5.0, "f"),
max_bars=(6, 72, "i")),
"breakout": dict(lookback=(12, 120, "i"), entry_thr=(0.0, 0.0, "f"),
tp_atr=(0.5, 4.0, "f"), sl_atr=(1.0, 5.0, "f"),
max_bars=(6, 72, "i")),
"ma_cross": dict(lookback=(5, 50, "i"), slow_mult=(2.0, 6.0, "f"),
entry_thr=(0.0, 0.0, "f"), tp_atr=(0.5, 4.0, "f"),
sl_atr=(1.0, 5.0, "f"), max_bars=(6, 72, "i")),
"rsi": dict(lookback=(7, 30, "i"), entry_thr=(1.0, 4.0, "f"),
tp_atr=(0.5, 4.0, "f"), sl_atr=(1.0, 5.0, "f"),
max_bars=(6, 72, "i")),
"momentum": dict(lookback=(6, 72, "i"), entry_thr=(1.0, 6.0, "f"),
tp_atr=(0.5, 4.0, "f"), sl_atr=(1.0, 5.0, "f"),
max_bars=(6, 72, "i")),
"pairs": dict(lookback=(20, 120, "i"), entry_thr=(1.5, 3.0, "f"),
exit_thr=(0.2, 1.0, "f"), max_bars=(24, 120, "i")),
}
SINGLE_FAMILIES = ["zscore", "breakout", "ma_cross", "rsi", "momentum"]
DIRECTIONS = ["reversion", "trend"]
TIMEFRAMES = ["5m", "15m", "30m", "1h", "2h", "4h", "1d"] # tutti i timing validi
# Vincolo opzionale: accetta SOLO strategie NON gia' usate in live. Firme live (da
# vietare): 'pairs' (PR01) + REVERSION di zscore(MR01)/breakout(MR02)/momentum(MR07,
# return-reversal). NB: momentum+reversion == MR07 -> e' LIVE, va vietato (loophole).
# Coercizione: pairs -> ma_cross(trend); (zscore|breakout|momentum)+reversion -> +trend.
# Resta spazio NUOVO: trend di zscore/breakout/momentum, ma_cross, rsi (ogni direzione).
NO_LIVE = False
_LIVE_REV_FAMS = {"zscore", "breakout", "momentum"} # in reversion = MR01/MR02/MR07 live
def set_no_live(v: bool):
global NO_LIVE
NO_LIVE = bool(v)
def _rand_param(rng, lo, hi, typ):
if typ == "i":
return int(rng.randint(int(lo), int(hi)))
return round(rng.uniform(lo, hi), 3)
def random_spec(rng):
if not NO_LIVE and rng.random() < 0.25:
fam = "pairs"
else:
fam = rng.choice(SINGLE_FAMILIES)
params = {}
for k, (lo, hi, typ) in SPACE[fam].items():
params[k] = _rand_param(rng, lo, hi, typ)
spec = {"family": fam, "params": params, "tf": rng.choice(TIMEFRAMES)}
if fam == "pairs":
spec["series"] = "AB"
else:
spec["series"] = rng.choice(["A", "B"])
d = rng.choice(DIRECTIONS)
if NO_LIVE and fam in _LIVE_REV_FAMS:
d = "trend" # zscore/breakout in reversion sono live -> trend
spec["params"]["direction"] = d
return spec
def mutate(spec, rng, strength=0.25):
"""Perturba la spec (hill-climb). Per lo piu' numerica; raramente
cambia direzione/serie. La famiglia resta fissa (identita' dell'agente)."""
s = json.loads(json.dumps(spec))
fam = s["family"]
# perturba 1-2 parametri numerici
keys = [k for k in SPACE[fam] if SPACE[fam][k][0] != SPACE[fam][k][1]]
for k in rng.sample(keys, k=min(len(keys), rng.randint(1, 2))):
lo, hi, typ = SPACE[fam][k]
cur = s["params"][k]
span = (hi - lo) * strength
nv = cur + rng.uniform(-span, span)
nv = max(lo, min(hi, nv))
s["params"][k] = int(round(nv)) if typ == "i" else round(nv, 3)
if fam != "pairs":
if rng.random() < 0.10:
s["params"]["direction"] = rng.choice(DIRECTIONS)
if rng.random() < 0.05:
s["series"] = rng.choice(["A", "B"])
# il timeframe resta l'identita' dell'agente (timing fisso) -> non muta
return s
def _normalize(spec):
"""Completa/ripulisce una spec proposta da un agente (robustezza)."""
fam = spec.get("family")
if fam not in SPACE:
fam = "zscore"
if NO_LIVE and fam == "pairs":
fam = "ma_cross" # pairs (PR01) e' live -> rimpiazza con ma_cross (nuovo, trend)
out = {"family": fam, "params": {}}
for k, (lo, hi, typ) in SPACE[fam].items():
v = spec.get("params", {}).get(k, (lo + hi) / 2)
try:
v = float(v)
except Exception:
v = (lo + hi) / 2
v = max(lo, min(hi, v))
out["params"][k] = int(round(v)) if typ == "i" else round(v, 3)
out["tf"] = spec.get("tf") if spec.get("tf") in TIMEFRAMES else "1h"
if fam == "pairs":
out["series"] = "AB"
else:
out["series"] = spec.get("series", "A") if spec.get("series") in ("A", "B") else "A"
d = spec.get("params", {}).get("direction") or spec.get("direction")
d = d if d in DIRECTIONS else "reversion"
if NO_LIVE and fam in _LIVE_REV_FAMS and d == "reversion":
d = "trend" # zscore/breakout in reversion = fade live -> trend
out["params"]["direction"] = d
return out
class Agent:
def __init__(self, aid, spec, brief=""):
self.id = aid
self.spec = _normalize(spec)
self.brief = brief # cosa "dice" l'agente (ipotesi NL)
self.train_fit = -1e9 # criterio di hill-climb (l'agente ottimizza qui)
self.valid_fit = -1e9 # criterio dell'orchestratore (cull + rank)
self.metrics = {} # metriche TRAIN
self.vmetrics = {} # metriche VALID
self.alive = True
self.culled_epoch = None
@property
def tf(self):
return self.spec.get("tf", "1h")
def score(self, datasets, splits_map):
data = datasets[self.tf]
tr, va, _ = splits_map[self.tf]
self.metrics = evaluate(data, self.spec, tr)
self.vmetrics = evaluate(data, self.spec, va)
self.train_fit = self.metrics["fitness"]
self.valid_fit = self.vmetrics["fitness"]
def run_tournament(specs, briefs=None, seed=7,
epochs=90, cull_every=10, cull_n=10, log=print,
out_name="tournament_result.json"):
rng = random.Random(seed)
# carica solo i timeframe effettivamente usati dagli agenti
used_tfs = sorted({_normalize(s).get("tf", "1h") for s in specs})
datasets = {tf: load_anon(tf) for tf in used_tfs}
splits_map = {tf: splits3(datasets[tf], 0.60, 0.20) for tf in used_tfs}
briefs = briefs or [""] * len(specs)
agents = [Agent(i, s, briefs[i] if i < len(briefs) else "")
for i, s in enumerate(specs)]
for a in agents:
a.score(datasets, splits_map)
alive = lambda: [a for a in agents if a.alive]
log(f"[epoch 0] {len(alive())} agenti | best VALID fit "
f"{max(a.valid_fit for a in agents):.1f}")
history = []
for ep in range(1, epochs + 1):
# elaborazione: l'agente affina sul TRAIN (cio' che vede); ricalcola VALID
for a in alive():
cand = _normalize(mutate(a.spec, rng)) # normalizza PRIMA di valutare
data = datasets[a.tf]
tr, va, _ = splits_map[a.tf]
m = evaluate(data, cand, tr)
if m["fitness"] > a.train_fit:
a.spec = cand
a.metrics, a.train_fit = m, m["fitness"]
a.vmetrics = evaluate(data, a.spec, va)
a.valid_fit = a.vmetrics["fitness"]
# cull ogni N epoche: l'ORCHESTRATORE blocca il 10% meno profittevole
# in VALIDATION (generalizzazione, non overfit sul train)
if ep % cull_every == 0:
av = sorted(alive(), key=lambda a: a.valid_fit)
k = cull_n if len(av) - cull_n >= 10 else max(0, len(av) - 10)
for a in av[:k]:
a.alive = False
a.culled_epoch = ep
log(f"[epoch {ep:2d}] cull {k:2d} -> {len(alive()):3d} vivi | "
f"best VALID {max(a.valid_fit for a in alive()):.1f} | "
f"worst-alive {min(a.valid_fit for a in alive()):.1f}")
history.append({"epoch": ep, "alive": len(alive()),
"best_valid": max(a.valid_fit for a in alive())})
survivors = sorted(alive(), key=lambda a: a.valid_fit, reverse=True)
# report finale: TEST = OOS puro mai toccato dall'ottimizzazione
results = []
for rank, a in enumerate(survivors, 1):
data = datasets[a.tf]
_, _, te = splits_map[a.tf]
test = evaluate(data, a.spec, te)
full = evaluate(data, a.spec, None)
results.append({
"rank": rank, "agent": a.id, "spec": a.spec, "brief": a.brief,
"tf": a.tf, "train": a.metrics, "valid": a.vmetrics,
"test": test, "full": full,
})
payload = {"n_agents": len(specs), "epochs": epochs,
"survivors": len(survivors), "results": results,
"history": history,
"reveal": {"A": "BTC", "B": "ETH", "tf": "1h"}}
(OUT / out_name).write_text(json.dumps(payload, indent=2))
return payload
def leaderboard(payload, top=10, log=print):
log("\n================ CLASSIFICA FINALE (top %d) ================" % top)
log("VALID = finestra su cui l'orchestratore giudica | TEST = OOS puro (mai ottimizzato)")
log(f"{'#':>2} {'ag':>4} {'tf':>3} {'famiglia':>9} {'ser':>3} {'dir':>9} "
f"{'TEpnl%':>8} {'TEwin':>5} {'TEtpm':>6} {'TEsh':>5} {'VApnl%':>8} {'VAwin':>5}")
for r in payload["results"][:top]:
sp = r["spec"]; te = r["test"]; va = r["valid"]
d = sp["params"].get("direction", "-")
log(f"{r['rank']:>2} {r['agent']:>4} {sp.get('tf','1h'):>3} {sp['family']:>9} "
f"{sp['series']:>3} {d:>9} {te['pnl_pct']:>8.0f} {te['win_rate']*100:>4.0f}% "
f"{te['tpm']:>6.1f} {te['sharpe']:>5.1f} {va['pnl_pct']:>8.0f} "
f"{va['win_rate']*100:>4.0f}%")
if __name__ == "__main__":
import sys
# modalita' test: 100 agenti random
rng = random.Random(42)
specs = [random_spec(rng) for _ in range(100)]
payload = run_tournament(specs, seed=42)
leaderboard(payload)
+344
View File
@@ -0,0 +1,344 @@
"""
Game engine — "Blind Traders" tournament.
100 agenti ricevono due serie anonime (A, B) — in realta' BTC e ETH 1h — e
propongono strategie senza sapere cosa sono. L'orchestratore (questo motore)
valuta ogni strategia con un backtest deterministico, causale e fee-aware, e
assegna un punteggio su %win + PNL con vincolo >=10 trade/mese.
Tutto causale (nessun look-ahead): i segnali alla barra i usano solo dati
fino a close[i]; l'ingresso e' a close[i], le uscite TP/SL/max_bars intrabar
dalle barre successive.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from src.data.downloader import load_data
FEE_RT = 0.001 # 0.10% round-trip (taker Deribit, baseline progetto)
TF_BPM = {"5m": 12 * 24 * 30, "15m": 4 * 24 * 30, "30m": 2 * 24 * 30,
"1h": 24 * 30, "2h": 12 * 30, "4h": 6 * 30, "1d": 30} # barre/mese per tf
MIN_TRADES_PER_MONTH = 10.0
# timeframe non presenti come parquet -> resamplati da una base (open=first,
# high=max, low=min, close=last, volume=sum). Permette "timing diversi" nel gioco.
_RESAMPLE = {"30m": ("15m", "30min"), "2h": ("1h", "2h"),
"4h": ("1h", "4h"), "1d": ("1h", "1D")}
# Slippage per LATO (oltre alle fee). 0 = come prima. Single-leg paga 2 lati
# (ingresso+uscita), i pairs ne pagano 4 (2 gambe x 2 lati).
_SLIP = 0.0
def set_slippage(slip_per_side: float):
global _SLIP
_SLIP = float(slip_per_side)
# --------------------------------------------------------------------------
# Dati anonimizzati
# --------------------------------------------------------------------------
def _load_tf(asset: str, tf: str):
"""Carica un asset al timeframe tf (parquet diretto, o resample da una base)."""
if tf in _RESAMPLE:
base_tf, rule = _RESAMPLE[tf]
d = load_data(asset, base_tf).copy()
d["dt"] = pd.to_datetime(d["datetime"])
g = d.set_index("dt").resample(rule).agg(
{"open": "first", "high": "max", "low": "min", "close": "last",
"volume": "sum"}).dropna(subset=["open", "close"])
g = g.reset_index()
g["datetime"] = g["dt"]
g["timestamp"] = (g["dt"].astype("int64") // 1_000_000)
return g.drop(columns=["dt"])
return load_data(asset, tf).copy()
def load_anon(tf: str = "1h"):
"""Carica BTC->A, ETH->B allineati sull'intersezione temporale.
Ritorna un dict con array OHLC per A e B + datetime. I nomi reali NON
compaiono: gli agenti vedono solo 'A' e 'B'.
"""
btc = _load_tf("BTC", tf)
eth = _load_tf("ETH", tf)
for d in (btc, eth):
d["dt"] = pd.to_datetime(d["datetime"])
btc = btc.set_index("dt")
eth = eth.set_index("dt")
idx = btc.index.intersection(eth.index)
btc = btc.loc[idx].sort_index()
eth = eth.loc[idx].sort_index()
out = {"dt": idx.to_numpy()}
for name, d in (("A", btc), ("B", eth)):
out[name] = {
"open": d["open"].to_numpy(float),
"high": d["high"].to_numpy(float),
"low": d["low"].to_numpy(float),
"close": d["close"].to_numpy(float),
"volume": d["volume"].to_numpy(float),
}
out["n"] = len(idx)
out["tf"] = tf
out["bpm"] = TF_BPM[tf]
return out
# --------------------------------------------------------------------------
# Indicatori causali (vettorizzati)
# --------------------------------------------------------------------------
def _roll_mean(x, w):
return pd.Series(x).rolling(w).mean().to_numpy()
def _roll_std(x, w):
return pd.Series(x).rolling(w).std(ddof=0).to_numpy()
def _ema(x, w):
return pd.Series(x).ewm(span=w, adjust=False).mean().to_numpy()
def _atr(high, low, close, w=14):
pc = np.roll(close, 1)
pc[0] = close[0]
tr = np.maximum(high - low, np.maximum(np.abs(high - pc), np.abs(low - pc)))
return pd.Series(tr).rolling(w).mean().to_numpy()
def _rsi(close, w=14):
d = np.diff(close, prepend=close[0])
up = np.where(d > 0, d, 0.0)
dn = np.where(d < 0, -d, 0.0)
ru = pd.Series(up).ewm(alpha=1 / w, adjust=False).mean().to_numpy()
rd = pd.Series(dn).ewm(alpha=1 / w, adjust=False).mean().to_numpy()
rs = ru / (rd + 1e-12)
return 100 - 100 / (1 + rs)
# --------------------------------------------------------------------------
# Famiglie di segnale -> array di posizione desiderata {-1,0,+1} alla barra i
# (causale: usa solo dati fino a close[i]). +1 = long, -1 = short.
# --------------------------------------------------------------------------
def _signal_single(o, family, p):
"""Segnale per una singola serie. Ritorna (pos_target, atr)."""
close = o["close"]
high, low = o["high"], o["low"]
n = len(close)
atr = _atr(high, low, close, 14)
pos = np.zeros(n)
lb = max(2, int(p["lookback"]))
thr = float(p["entry_thr"])
sign = 1 if p.get("direction", "reversion") == "trend" else -1
if family == "zscore":
ma = _roll_mean(close, lb)
sd = _roll_std(close, lb)
z = (close - ma) / (sd + 1e-12)
pos = np.where(z > thr, sign * -1.0, np.where(z < -thr, sign * 1.0, 0.0))
elif family == "breakout":
hh = pd.Series(high).rolling(lb).max().shift(1).to_numpy()
ll = pd.Series(low).rolling(lb).min().shift(1).to_numpy()
up = close > hh
dn = close < ll
# trend: break-up=long ; reversion: break-up=short
pos = np.where(up, sign * 1.0, np.where(dn, sign * -1.0, 0.0))
elif family == "ma_cross":
fast = _ema(close, lb)
slow = _ema(close, max(lb + 2, int(lb * p.get("slow_mult", 3))))
pos = np.where(fast > slow, sign * 1.0, sign * -1.0)
elif family == "rsi":
r = _rsi(close, lb)
hi = 50 + thr * 10
lo = 50 - thr * 10
pos = np.where(r > hi, sign * -1.0, np.where(r < lo, sign * 1.0, 0.0))
elif family == "momentum":
ret = close / np.roll(close, lb) - 1
ret[:lb] = 0
pos = np.where(ret > thr / 100, sign * 1.0,
np.where(ret < -thr / 100, sign * -1.0, 0.0))
else:
raise ValueError(f"unknown family {family}")
pos = np.nan_to_num(pos)
return pos, atr
# --------------------------------------------------------------------------
# Backtest single-series (long/short con TP/SL/max_bars intrabar)
# --------------------------------------------------------------------------
def _backtest_single(o, pos, atr, p, fee=FEE_RT):
close, high, low = o["close"], o["high"], o["low"]
n = len(close)
tp_atr = float(p.get("tp_atr", 2.0))
sl_atr = float(p.get("sl_atr", 2.0))
max_bars = int(p.get("max_bars", 24))
rets = [] # net return per trade
# warmup
start = max(int(p["lookback"]) + 15, 20)
# indici candidati: solo barre con segnale != 0 (salta le barre flat)
cand = np.flatnonzero(pos[start:n - 1]) + start
ci = 0
nc = len(cand)
while ci < nc:
i = int(cand[ci])
d = pos[i]
if d == 0 or np.isnan(atr[i]) or atr[i] <= 0:
ci += 1
continue
entry = close[i]
a = atr[i]
if d > 0:
tp = entry + tp_atr * a
sl = entry - sl_atr * a
else:
tp = entry - tp_atr * a
sl = entry + sl_atr * a
exit_px = None
j = i + 1
end = min(n - 1, i + max_bars)
while j <= end:
hi, lo = high[j], low[j]
if d > 0:
if lo <= sl: # SL prioritario
exit_px = sl
break
if hi >= tp:
exit_px = tp
break
else:
if hi >= sl:
exit_px = sl
break
if lo <= tp:
exit_px = tp
break
j += 1
if exit_px is None:
exit_px = close[end]
j = end
gross = d * (exit_px - entry) / entry
net = gross - fee - 2 * _SLIP # 2 lati di slippage
rets.append(net)
# salta al primo ingresso candidato OLTRE l'uscita (no overlap)
ci = int(np.searchsorted(cand, j + 1, side="left"))
return np.array(rets)
# --------------------------------------------------------------------------
# Backtest cross-series (pairs market-neutral sullo z del log-ratio)
# --------------------------------------------------------------------------
def _backtest_pairs(A, B, p, fee=FEE_RT):
a, b = A["close"], B["close"]
n = len(a)
lb = max(5, int(p["lookback"]))
z_in = float(p["entry_thr"])
z_exit = float(p.get("exit_thr", 0.5))
max_bars = int(p.get("max_bars", 72))
lr = np.log(a / b)
ma = _roll_mean(lr, lb)
sd = _roll_std(lr, lb)
z = (lr - ma) / (sd + 1e-12)
rets = []
start = max(lb + 5, 20)
zabs = np.abs(z)
zabs[:start] = 0.0
zabs[np.isnan(zabs)] = 0.0
cand = np.flatnonzero(zabs[:n - 1] > z_in)
ci = 0
nc = len(cand)
while ci < nc:
i = int(cand[ci])
d = -1 if z[i] > z_in else 1 # spread alto -> short A/long B ; basso -> long A/short B
ea, eb = a[i], b[i]
j = i + 1
end = min(n - 1, i + max_bars)
while j <= end:
if abs(z[j]) <= z_exit:
break
j += 1
j = min(j, end)
# PnL = gamba A (dir d) + gamba B (dir -d), fee su 2 gambe
ra = d * (a[j] - ea) / ea
rb = -d * (b[j] - eb) / eb
net = ra + rb - 2 * fee - 4 * _SLIP # 2 gambe x 2 lati di slippage
rets.append(net)
ci = int(np.searchsorted(cand, j + 1, side="left"))
return np.array(rets)
# --------------------------------------------------------------------------
# Valutazione + scoring
# --------------------------------------------------------------------------
def evaluate(data, spec, sl=None, fee=FEE_RT):
"""Valuta una spec di strategia su uno slice [start,end) (sl=slice di indici).
spec = {family, series, params{...}}. Ritorna dict metriche.
"""
family = spec["family"]
series = spec.get("series", "A")
p = spec["params"]
def _slice(o):
if sl is None:
return o
s, e = sl
return {k: v[s:e] for k, v in o.items()}
if family == "pairs":
A = _slice(data["A"])
B = _slice(data["B"])
rets = _backtest_pairs(A, B, p, fee)
nbars = len(A["close"])
else:
o = _slice(data[series])
pos, atr = _signal_single(o, family, p)
rets = _backtest_single(o, pos, atr, p, fee)
nbars = len(o["close"])
n_tr = len(rets)
months = nbars / data.get("bpm", TF_BPM["1h"])
tpm = n_tr / months if months > 0 else 0.0
if n_tr == 0:
return dict(n_trades=0, win_rate=0.0, pnl_pct=0.0, tpm=0.0,
sharpe=0.0, avg_ret=0.0, qualified=False, fitness=-1e6)
win_rate = float(np.mean(rets > 0))
pnl = float(np.sum(rets)) * 100 # PnL additivo (notional fisso), %
equity = float(np.prod(1 + rets) - 1) * 100 # equity compounding, %
avg = float(np.mean(rets)) * 100
sharpe = float(np.mean(rets) / (np.std(rets) + 1e-12) * np.sqrt(tpm * 12)) \
if np.std(rets) > 0 else 0.0
qualified = tpm >= MIN_TRADES_PER_MONTH
# fitness: PNL domina, win% come spinta secondaria; squalifica se pochi trade
fitness = pnl + 50.0 * win_rate
if not qualified:
fitness = -1e6 + pnl # ordinati ma fuori gioco
return dict(n_trades=n_tr, win_rate=win_rate, pnl_pct=pnl, equity_pct=equity,
tpm=tpm, sharpe=sharpe, avg_ret=avg, qualified=qualified,
fitness=fitness)
# Split a 3: TRAIN (hill-climb) / VALID (cull+rank dell'orchestratore) / TEST (OOS puro)
def splits3(data, train_frac=0.60, valid_frac=0.20):
n = data["n"]
c1 = int(n * train_frac)
c2 = int(n * (train_frac + valid_frac))
return (0, c1), (c1, c2), (c2, n)
# compat: split a 2 (train/oos)
def splits(data, train_frac=0.70):
n = data["n"]
cut = int(n * train_frac)
return (0, cut), (cut, n)
if __name__ == "__main__":
data = load_anon("1h")
print("loaded", data["n"], "bars,", data["dt"][0], "->", data["dt"][-1])
tr, oos = splits(data)
demo = {"family": "zscore", "series": "B",
"params": {"lookback": 20, "entry_thr": 2.0, "direction": "reversion",
"tp_atr": 1.5, "sl_atr": 2.0, "max_bars": 24}}
print("TRAIN", evaluate(data, demo, tr))
print("OOS ", evaluate(data, demo, oos))
+251
View File
@@ -0,0 +1,251 @@
"""
Arena del gioco GRID TRADERS (sessione 3): 100 agenti ciechi configurano una
griglia di trading secondo STRATEGIA_GRIGLIA.md su due serie anonime
(A=BTC, B=ETH, mai rivelate; gli agenti le vedono come X/Y). Torneo standard:
3 finestre TRAIN/VALID/TEST, 90 epoche di hill-climb sul TRAIN, ogni 10 epoche
l'orchestratore blocca il 10% meno profittevole in VALID, fino a 10 superstiti.
TEST = OOS puro mai toccato dall'ottimizzazione.
uv run python -m scripts.games.grid_arena # 100 random (smoke)
GAME_SPECS_DIR=... GAME_OUT=... uv run python -m scripts.games.grid_arena --from-specs
"""
from __future__ import annotations
import json
import os
import random
import sys
from pathlib import Path
from scripts.games.engine import load_anon, splits3
from scripts.games import grid_engine
from scripts.games.grid_engine import evaluate, max_levels
OUT = Path("data/games")
OUT.mkdir(parents=True, exist_ok=True)
# Spazio parametri (min, max, tipo). range_*/sl_buf/tp_buf in FRAZIONE.
PSPACE = dict(
range_down=(0.02, 0.30, "f"),
range_up=(0.02, 0.30, "f"),
levels=(4, 30, "i"),
sl_buf=(0.01, 0.15, "f"),
tp_buf=(0.01, 0.15, "f"),
max_bars=(48, 3000, "i"),
)
SERIES = ["A", "B"]
TIMEFRAMES = ["15m", "30m", "1h", "2h", "4h", "1d"] # no 5m (costo computazionale)
def _rand(rng, lo, hi, typ):
return int(rng.randint(int(lo), int(hi))) if typ == "i" else round(rng.uniform(lo, hi), 4)
def random_spec(rng):
p = {k: _rand(rng, *v) for k, v in PSPACE.items()}
return {"series": rng.choice(SERIES), "tf": rng.choice(TIMEFRAMES), "params": p}
def _normalize(spec):
"""Clampa la spec nello spazio valido e APPLICA il vincolo break-even (§4):
se il passo e' troppo fitto riduce GRID_LEVELS (come da spec: 'vanno ridotti
i GRID_LEVELS o allargato il range')."""
out = {"series": spec.get("series") if spec.get("series") in SERIES else "A",
"tf": spec.get("tf") if spec.get("tf") in TIMEFRAMES else "1h",
"params": {}}
src = spec.get("params", spec)
for k, (lo, hi, typ) in PSPACE.items():
v = src.get(k, (lo + hi) / 2)
try:
v = float(v)
except Exception:
v = (lo + hi) / 2
v = max(lo, min(hi, v))
out["params"][k] = int(round(v)) if typ == "i" else round(v, 4)
p = out["params"]
lmax = max_levels(p["range_down"], p["range_up"])
if lmax >= 2:
p["levels"] = min(p["levels"], lmax)
return out
def mutate(spec, rng, strength=0.25):
"""Hill-climb: perturba 1-2 parametri; raramente cambia serie.
Il timeframe e' l'identita' dell'agente -> non muta."""
s = json.loads(json.dumps(spec))
for k in rng.sample(list(PSPACE), k=rng.randint(1, 2)):
lo, hi, typ = PSPACE[k]
span = (hi - lo) * strength
nv = max(lo, min(hi, s["params"][k] + rng.uniform(-span, span)))
s["params"][k] = int(round(nv)) if typ == "i" else round(nv, 4)
if rng.random() < 0.05:
s["series"] = rng.choice(SERIES)
return s
class Agent:
def __init__(self, aid, spec, brief=""):
self.id = aid
self.spec = _normalize(spec)
self.brief = brief
self.train_fit = -1e9
self.valid_fit = -1e9
self.metrics = {}
self.vmetrics = {}
self.alive = True
self.culled_epoch = None
@property
def tf(self):
return self.spec["tf"]
def score(self, datasets, sm):
data = datasets[self.tf]
tr, va, _ = sm[self.tf]
self.metrics = evaluate(data, self.spec, tr)
self.vmetrics = evaluate(data, self.spec, va)
self.train_fit = self.metrics["fitness"]
self.valid_fit = self.vmetrics["fitness"]
def run_tournament(specs, briefs=None, seed=2026, epochs=90, cull_every=10,
cull_n=10, out_name="grid_result.json", log=print):
rng = random.Random(seed)
used_tfs = sorted({_normalize(s)["tf"] for s in specs})
datasets = {tf: load_anon(tf) for tf in used_tfs}
sm = {tf: splits3(datasets[tf], 0.60, 0.20) for tf in used_tfs}
briefs = briefs or [""] * len(specs)
agents = [Agent(i, s, briefs[i] if i < len(briefs) else "")
for i, s in enumerate(specs)]
for a in agents:
a.score(datasets, sm)
alive = lambda: [a for a in agents if a.alive]
log(f"[epoch 0] {len(alive())} agenti | best VALID "
f"{max(a.valid_fit for a in agents):.1f}")
history = []
for ep in range(1, epochs + 1):
for a in alive():
cand = _normalize(mutate(a.spec, rng))
data = datasets[a.tf]
tr, va, _ = sm[a.tf]
m = evaluate(data, cand, tr)
if m["fitness"] > a.train_fit:
a.spec, a.metrics, a.train_fit = cand, m, m["fitness"]
a.vmetrics = evaluate(data, a.spec, va)
a.valid_fit = a.vmetrics["fitness"]
if ep % cull_every == 0:
av = sorted(alive(), key=lambda a: a.valid_fit)
k = cull_n if len(av) - cull_n >= 10 else max(0, len(av) - 10)
for a in av[:k]:
a.alive = False
a.culled_epoch = ep
log(f"[epoch {ep:2d}] cull {k:2d} -> {len(alive()):3d} vivi | "
f"best VALID {max(a.valid_fit for a in alive()):.1f} | "
f"worst-alive {min(a.valid_fit for a in alive()):.1f}")
history.append({"epoch": ep, "alive": len(alive()),
"best_valid": max(a.valid_fit for a in alive())})
survivors = sorted(alive(), key=lambda a: a.valid_fit, reverse=True)
results = []
for rank, a in enumerate(survivors, 1):
data = datasets[a.tf]
_, _, te = sm[a.tf]
results.append({"rank": rank, "agent": a.id, "spec": a.spec,
"brief": a.brief, "tf": a.tf,
"train": a.metrics, "valid": a.vmetrics,
"test": evaluate(data, a.spec, te),
"full": evaluate(data, a.spec, None)})
payload = {"n_agents": len(specs), "epochs": epochs,
"survivors": len(survivors), "results": results,
"history": history, "game": "grid",
"rule": "STRATEGIA_GRIGLIA.md",
"reveal": {"A": "BTC", "B": "ETH"}}
(OUT / out_name).write_text(json.dumps(payload, indent=2))
return payload
def leaderboard(payload, top=10, log=print):
log("\n========== CLASSIFICA GRID TRADERS (top %d) ==========" % top)
log("VALID = finestra del giudice | TEST = OOS puro (mai ottimizzato)")
log(f"{'#':>2} {'ag':>4} {'tf':>4} {'ser':>3} {'rng-/+':>11} {'lvl':>3} "
f"{'sl/tp buf':>11} {'mbars':>5} {'TEpnl%':>7} {'TEwin':>5} "
f"{'TEtpm':>6} {'TEsh':>5} {'VApnl%':>7}")
for r in payload["results"][:top]:
sp = r["spec"]; p = sp["params"]; te = r["test"]; va = r["valid"]
log(f"{r['rank']:>2} {r['agent']:>4} {sp['tf']:>4} {sp['series']:>3} "
f"{p['range_down']*100:>4.1f}/{p['range_up']*100:>4.1f}% {p['levels']:>3} "
f"{p['sl_buf']*100:>4.1f}/{p['tp_buf']*100:>4.1f}% {p['max_bars']:>5} "
f"{te['pnl_pct']:>7.0f} {te['win_rate']*100:>4.0f}% {te['tpm']:>6.1f} "
f"{te['sharpe']:>5.1f} {va['pnl_pct']:>7.0f}")
def load_specs(specs_dir, n=100):
"""Carica le spec proposte dagli agenti ciechi (X->A, Y->B, pct->frazione)."""
rng = random.Random(7)
specs, briefs = [], []
for i in range(n):
f = Path(specs_dir) / f"agent_{i}.json"
spec = None
if f.exists():
try:
raw = json.loads(f.read_text())
src = raw.get("params", raw)
params = {
"range_down": float(src.get("range_down_pct", src.get("range_down", 10))) ,
"range_up": float(src.get("range_up_pct", src.get("range_up", 10))),
"levels": src.get("grid_levels", src.get("levels", 10)),
"sl_buf": float(src.get("sl_buf_pct", src.get("sl_buf", 5))),
"tp_buf": float(src.get("tp_buf_pct", src.get("tp_buf", 5))),
"max_bars": src.get("max_bars", 500),
}
# gli agenti parlano in percentuale -> frazione
for k in ("range_down", "range_up", "sl_buf", "tp_buf"):
if params[k] > 1.0:
params[k] = params[k] / 100.0
spec = _normalize({
"series": {"X": "A", "Y": "B"}.get(raw.get("series"), raw.get("series")),
"tf": raw.get("tf", "1h"), "params": params})
briefs.append(str(raw.get("hypothesis", ""))[:300])
except Exception:
spec = None
if spec is None:
spec = random_spec(rng)
briefs.append("(spec mancante -> sostituto casuale)")
specs.append(spec)
n_real = sum(1 for b in briefs if "mancante" not in b)
print(f"caricati {n_real}/{n} spec da agenti reali, {n - n_real} sostituiti casuali")
return specs, briefs
def main():
slip = float(os.environ.get("GAME_SLIP", "0.0"))
grid_engine.set_slippage(slip)
if slip > 0:
print(f"SLIPPAGE attivo: {slip*100:.3f}%/lato")
epochs = int(os.environ.get("GAME_EPOCHS", "90"))
if "--from-specs" in sys.argv:
sd = os.environ.get("GAME_SPECS_DIR", "data/games/specs_grid")
on = os.environ.get("GAME_OUT", "grid_result.json")
specs, briefs = load_specs(sd)
payload = run_tournament(specs, briefs=briefs, epochs=epochs, out_name=on)
else:
rng = random.Random(42)
payload = run_tournament([random_spec(rng) for _ in range(100)],
seed=42, epochs=epochs)
leaderboard(payload)
rev = payload["reveal"]
w = payload["results"][0]
sp = w["spec"]; p = sp["params"]
print(f"\n>>> RIVELAZIONE: Serie X = {rev['A']}, Serie Y = {rev['B']}. "
f"Gli agenti non lo sapevano. <<<")
print(f"\nVINCITORE: agente #{w['agent']} su {sp['tf']} serie {sp['series']} | "
f"griglia -{p['range_down']*100:.1f}%/+{p['range_up']*100:.1f}% "
f"x{p['levels']} livelli, SL buf {p['sl_buf']*100:.1f}%, "
f"TP buf {p['tp_buf']*100:.1f}%, max {p['max_bars']} barre")
print(f" ipotesi dell'agente: {w['brief']}")
print(f" TEST(OOS): PnL {w['test']['pnl_pct']:.0f}% | win "
f"{w['test']['win_rate']*100:.0f}% | {w['test']['tpm']:.1f} trade/mese | "
f"Sharpe {w['test']['sharpe']:.1f}")
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
"""
grid_brief — digest ANONIMO per gli agenti del gioco GRID TRADERS (sessione 3).
Come agent_brief, ma con statistiche pensate per DIMENSIONARE una griglia:
oltre a vol/autocorrelazioni, l'escursione tipica (max/min - 1) su finestre
rolling e quanto spesso il prezzo "esce" da un range simmetrico attorno a un
punto di partenza entro N barre. L'agente non sa cosa siano X e Y.
uv run python -m scripts.games.grid_brief 1h # stampa il digest
uv run python -m scripts.games.grid_brief --all # scrive data/games/grid_digests.json
"""
from __future__ import annotations
import json
import numpy as np
import pandas as pd
from scripts.games.engine import load_anon
from scripts.games.agent_brief import _stats
TF_ID = {"15m": "T2", "30m": "T3", "1h": "T4", "2h": "T5", "4h": "T6", "1d": "T7"}
def _range_stats(close, windows=(100, 500, 2000)):
"""Escursione (max/min - 1) su finestre rolling: mediana e p90, in %."""
s = pd.Series(close)
out = {}
for w in windows:
if len(close) < w * 2:
continue
exc = (s.rolling(w).max() / s.rolling(w).min() - 1).dropna()
out[f"w{w}"] = {"median_pct": round(float(exc.median() * 100), 2),
"p90_pct": round(float(exc.quantile(0.9) * 100), 2)}
return out
def _escape_stats(close, half_widths=(0.05, 0.10, 0.20), horizon=500):
"""Da un punto di partenza, % di volte in cui il prezzo esce da
+-half_width entro `horizon` barre (campionato ogni horizon/2)."""
n = len(close)
stepi = max(1, horizon // 2)
starts = np.arange(0, n - horizon, stepi)
out = {}
for hw in half_widths:
esc = 0
for st in starts:
w = close[st:st + horizon]
p0 = w[0]
if np.any(w > p0 * (1 + hw)) or np.any(w < p0 * (1 - hw)):
esc += 1
out[f"+-{hw*100:.0f}%"] = round(100.0 * esc / max(1, len(starts)), 1)
return out
def make_grid_digest(tf: str, window: int = 60):
data = load_anon(tf)
n = data["n"]
s = max(0, n - window)
dig = {"timeframe_id": TF_ID.get(tf, "T?"), "n_bars_total": n, "series": {}}
for name in ("A", "B"):
o = data[name]
c = o["close"]
norm = c[s:] / c[s] * 100.0
dig["series"][{"A": "X", "B": "Y"}[name]] = {
"stats": _stats(c, o["high"], o["low"]),
"range_excursion_rolling": _range_stats(c),
"escape_from_range_within_500_bars_pct": _escape_stats(c),
"recent_window_norm": [round(float(v), 2) for v in norm],
}
return dig
GRID_MENU = {
"gioco": ("Configura una GRIGLIA di trading secondo la spec (griglia geometrica "
"FISSA dentro un range attorno al prezzo di deploy; compra quando il "
"prezzo scende attraverso un livello, rivendi quel livello quando "
"risale al livello successivo; stop-loss sotto il range e take-profit "
"sopra chiudono tutto; poi la griglia si ri-deploya sul prezzo corrente)."),
"obiettivo": ("PnL netto positivo dopo i costi (0.10% andata+ritorno per ogni "
"round-trip di livello). Servono >=10 operazioni al mese. La "
"griglia monetizza le oscillazioni e PERDE nei trend: lo stop-loss "
"limita il danno. Non sai cosa siano X e Y."),
"vincolo_break_even": ("passo_griglia = ((1+range_up)/(1-range_down))^(1/grid_levels) - 1 "
"DEVE superare 1.5 x 0.10% = 0.15%, o il bot si rifiuta "
"di partire. Griglie troppo fitte muoiono di fee."),
"parametri": {
"series": "X oppure Y",
"range_down_pct": "estremo inferiore del range, % sotto il prezzo di deploy (2-30)",
"range_up_pct": "estremo superiore del range, % sopra il prezzo di deploy (2-30)",
"grid_levels": "numero di livelli della griglia (4-30)",
"sl_buf_pct": "stop-loss: % sotto RANGE_LOW (1-15)",
"tp_buf_pct": "take-profit: % sopra RANGE_HIGH (1-15)",
"max_bars": "durata massima di una griglia in barre, poi liquida e ri-deploya (48-3000)",
},
"trade_off": ("range stretto + tanti livelli = tanti round-trip piccoli ma SL "
"frequenti nei trend; range largo = SL rari ma capitale spesso "
"fermo. Lo stop-loss largo aumenta la perdita quando scatta; "
"stretto scatta piu' spesso. Usa le statistiche di escursione "
"del digest per dimensionare range e stop."),
"output_schema": {
"series": "X|Y", "range_down_pct": "num", "range_up_pct": "num",
"grid_levels": "int", "sl_buf_pct": "num", "tp_buf_pct": "num",
"max_bars": "int", "hypothesis": "1-2 frasi: il tuo ragionamento",
},
}
if __name__ == "__main__":
import sys
from pathlib import Path
if "--all" in sys.argv:
out = {tf: make_grid_digest(tf) for tf in TF_ID}
p = Path("data/games/grid_digests.json")
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(out))
print(f"scritti digest per {list(out)} -> {p}")
else:
tf = sys.argv[1] if len(sys.argv) > 1 else "1h"
print(json.dumps(make_grid_digest(tf), indent=2)[:3000])
+231
View File
@@ -0,0 +1,231 @@
"""
Grid engine — gioco "Grid Traders" (sessione 3), regola: STRATEGIA_GRIGLIA.md.
100 agenti ciechi ricevono due serie anonime (X=A=BTC, Y=B=ETH, mai rivelate) e
propongono la CONFIGURAZIONE di una griglia di trading secondo la spec del
documento STRATEGIA_GRIGLIA.md. Questo motore la backtesta in modo
deterministico, causale e fee-aware:
- griglia GEOMETRICA dentro un range definito al deploy su close[i] (§3.2):
ratio = (RANGE_HIGH/RANGE_LOW)^(1/GRID_LEVELS), livello[k] = RL * ratio^k
Il range e' parametrizzato in PERCENTUALE attorno al prezzo di deploy
(range_down/range_up), cosi' la griglia e' backtestabile su tutta la storia.
- capitale suddiviso in anticipo: quote_per_livello = 1/GRID_LEVELS (§3.3)
- VINCOLO BREAK-EVEN (§4): passo > MARGINE(1.5) x costo round-trip.
Se violato il motore SI RIFIUTA DI PARTIRE (come da spec): spec squalificata.
- ciclo (§5.2): compra quote_per_livello su attraversamento VERSO IL BASSO di un
livello non riempito; vendi quel livello su attraversamento VERSO L'ALTO del
livello successivo. Livelli FISSI per tutto l'episodio (non inseguono il prezzo).
- guardie (§5.2/§6): STOP-LOSS sotto RANGE_LOW e TAKE-PROFIT sopra RANGE_HIGH
hanno priorita' su tutto: liquidano l'intera posizione e fermano la griglia.
- episodi: quando una griglia muore (SL / TP / max_bars) se ne deploya una nuova
sul prezzo corrente (il "riavvio del bot" di §6.6, qui automatizzato).
Causalita': il deploy a close[i] usa solo close[i]; i fill avvengono dalle barre
successive lungo il percorso intrabar O->L->H->C (se close>=open) o O->H->L->C.
Fee 0.10% round-trip per livello (baseline Deribit del progetto) + slippage
opzionale per lato (GAME_SLIP), come negli altri giochi.
"""
from __future__ import annotations
import math
from bisect import bisect_left, bisect_right
import numpy as np
from scripts.games.engine import load_anon, splits3, TF_BPM, FEE_RT
MIN_TRADES_PER_MONTH = 10.0
MARGIN = 1.5 # margine di sicurezza del vincolo break-even (§4)
_SLIP = 0.0 # slippage per LATO (oltre alle fee), come engine.py
def set_slippage(slip_per_side: float):
global _SLIP
_SLIP = float(slip_per_side)
def cost_rt(fee: float = FEE_RT) -> float:
"""Costo di un round-trip completo (fee RT + 2 lati di slippage)."""
return fee + 2 * _SLIP
def grid_ratio(p) -> float:
"""Ratio geometrico della griglia: indipendente dal prezzo di deploy."""
rd, ru, L = float(p["range_down"]), float(p["range_up"]), int(p["levels"])
return ((1.0 + ru) / (1.0 - rd)) ** (1.0 / L)
def max_levels(range_down: float, range_up: float, fee: float = FEE_RT) -> int:
"""Massimo numero di livelli che rispetta il vincolo break-even (§4)."""
width = math.log((1.0 + range_up) / (1.0 - range_down))
min_step = math.log(1.0 + MARGIN * cost_rt(fee))
return max(0, int(math.floor(width / min_step)))
# --------------------------------------------------------------------------
# Backtest della griglia (episodi deploy -> SL/TP/timeout -> redeploy)
# --------------------------------------------------------------------------
def _backtest_grid(o, p, fee=FEE_RT):
"""Ritorna l'array dei net-return per trade (round-trip o liquidazione),
in frazione del capitale dell'episodio. None se il vincolo break-even
e' violato (il bot si rifiuta di partire, §4)."""
op, hi, lo, cl = o["open"], o["high"], o["low"], o["close"]
n = len(cl)
crt = cost_rt(fee)
L = int(p["levels"])
rd, ru = float(p["range_down"]), float(p["range_up"])
slb, tpb = float(p["sl_buf"]), float(p["tp_buf"])
max_bars = max(1, int(p["max_bars"]))
if L < 2:
return None
ratio = grid_ratio(p)
step = ratio - 1.0
if step <= MARGIN * crt:
return None # §4: vincolo break-even violato
lstep = math.log(ratio)
with np.errstate(divide="ignore"):
llo = np.log(lo)
lhi = np.log(hi)
qpl = 1.0 / L
rets = []
i = 20 # warmup minimo (parita' con engine.py)
while i < n - 1:
px = float(cl[i])
if not np.isfinite(px) or px <= 0:
i += 1
continue
rl_ = px * (1.0 - rd)
lv = [rl_ * ratio ** k for k in range(L + 1)] # lv[L] = RANGE_HIGH
sl = rl_ * (1.0 - slb)
tp = lv[L] * (1.0 + tpb)
off = math.log(rl_)
end = min(n - 1, i + max_bars)
# indice-cella (floor) di low/high per il fast-skip delle barre quiete
klo = np.floor((llo[i + 1:end + 1] - off) / lstep).astype(np.int64)
khi = np.floor((lhi[i + 1:end + 1] - off) / lstep).astype(np.int64)
slhit = lo[i + 1:end + 1] <= sl
tphit = hi[i + 1:end + 1] >= tp
filled = [False] * L
n_open = 0
cur = px
kc = bisect_right(lv, cur) - 1
done = False
exit_i = end
for j in range(i + 1, end + 1):
jj = j - (i + 1)
if klo[jj] == khi[jj] == kc and not slhit[jj] and not tphit[jj]:
cur = cl[j] # barra quieta: nessun livello toccato
continue
pts = (op[j], lo[j], hi[j], cl[j]) if cl[j] >= op[j] \
else (op[j], hi[j], lo[j], cl[j])
for q in pts:
q = float(q)
if q == cur:
continue
if q < cur:
# discesa: fill dei buy-level attraversati (alto -> basso)
k1 = bisect_left(lv, q) # primo livello >= q
k2 = bisect_left(lv, cur) - 1 # ultimo livello < cur
for k in range(min(k2, L - 1), max(k1, 0) - 1, -1):
if not filled[k]:
filled[k] = True
n_open += 1
if q <= sl:
# STOP-LOSS: vendi tutta la posizione a sl, ferma la griglia
if n_open:
rets.append(sum(
qpl * (sl / lv[k] - 1.0 - crt)
for k in range(L) if filled[k]))
done = True
cur = q
break
else:
# salita: vendi i livelli riempiti il cui target e' attraversato
m1 = bisect_right(lv, cur) # primo livello > cur
m2 = bisect_right(lv, q) - 1 # ultimo livello <= q
for m in range(max(m1, 1), min(m2, L) + 1):
k = m - 1
if filled[k]:
rets.append(qpl * (lv[m] / lv[k] - 1.0 - crt))
filled[k] = False
n_open -= 1
if q >= tp:
# TAKE-PROFIT: chiudi il residuo a tp, ferma la griglia
if n_open:
rets.append(sum(
qpl * (tp / lv[k] - 1.0 - crt)
for k in range(L) if filled[k]))
done = True
cur = q
break
cur = q
if done:
exit_i = j
break
kc = bisect_right(lv, cur) - 1
if not done:
# timeout max_bars: liquida il residuo al close dell'ultima barra
if n_open:
rets.append(sum(
qpl * (cl[end] / lv[k] - 1.0 - crt)
for k in range(L) if filled[k]))
exit_i = end
i = exit_i # redeploy sul prezzo dove e' morta la griglia
return np.array(rets) if rets else np.array([])
# --------------------------------------------------------------------------
# Valutazione + scoring (stessa fitness degli altri giochi)
# --------------------------------------------------------------------------
def evaluate(data, spec, sl=None, fee=FEE_RT):
"""spec = {series: 'A'|'B', tf, params{range_down,range_up,levels,sl_buf,
tp_buf,max_bars}}. Ritorna dict metriche (fitness = pnl + 50*win)."""
series = spec.get("series", "A")
p = spec["params"]
o = data[series]
if sl is not None:
s, e = sl
o = {k: v[s:e] for k, v in o.items()}
rets = _backtest_grid(o, p, fee)
nbars = len(o["close"])
months = nbars / data.get("bpm", TF_BPM["1h"])
if rets is None:
# il bot si rifiuta di partire (vincolo break-even §4)
return dict(n_trades=0, win_rate=0.0, pnl_pct=0.0, tpm=0.0, sharpe=0.0,
avg_ret=0.0, qualified=False, refused=True, fitness=-2e6)
n_tr = len(rets)
tpm = n_tr / months if months > 0 else 0.0
if n_tr == 0:
return dict(n_trades=0, win_rate=0.0, pnl_pct=0.0, tpm=0.0, sharpe=0.0,
avg_ret=0.0, qualified=False, refused=False, fitness=-1e6)
win_rate = float(np.mean(rets > 0))
pnl = float(np.sum(rets)) * 100
avg = float(np.mean(rets)) * 100
sharpe = float(np.mean(rets) / (np.std(rets) + 1e-12) * np.sqrt(tpm * 12)) \
if np.std(rets) > 0 else 0.0
qualified = tpm >= MIN_TRADES_PER_MONTH
fitness = pnl + 50.0 * win_rate
if not qualified:
fitness = -1e6 + pnl
return dict(n_trades=n_tr, win_rate=win_rate, pnl_pct=pnl, tpm=tpm,
sharpe=sharpe, avg_ret=avg, qualified=qualified, refused=False,
fitness=fitness)
if __name__ == "__main__":
import time
data = load_anon("1h")
print("loaded", data["n"], "bars,", data["dt"][0], "->", data["dt"][-1])
tr, va, te = splits3(data)
demo = {"series": "B", "tf": "1h",
"params": {"range_down": 0.10, "range_up": 0.10, "levels": 12,
"sl_buf": 0.05, "tp_buf": 0.05, "max_bars": 1000}}
t0 = time.time()
print("TRAIN", evaluate(data, demo, tr))
print("VALID", evaluate(data, demo, va))
print("TEST ", evaluate(data, demo, te))
print("FULL ", evaluate(data, demo, None))
print(f"4 eval in {time.time()-t0:.2f}s")
+80
View File
@@ -0,0 +1,80 @@
"""Calibra una superficie premi REALE dalla catena cerbero-bite -> data/games/opt_calib_*.json.
Per ETH e BTC, dalla chain reale (OptionChain): premio mediano (ask, %spot), spread
bid/ask mediano, e IV mediana per (moneyness OTM x tenor). Piu' DVOL medio della finestra
(per scalare i premi sulla storia). + gate liquidita': max OTM con bid>0 frequente.
Cosi' il motore del gioco prezza con NUMERI REALI invece del Black-Scholes sintetico.
uv run python -m scripts.games.opt_calibrate
"""
from __future__ import annotations
import json
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.options_chain import OptionChain
OUT = PROJECT_ROOT / "data" / "games"
# griglie: OTM firmato (put<0, call>0) e tenor in giorni
OTM_GRID = [-0.25, -0.20, -0.15, -0.10, -0.07, -0.05, -0.03, 0.0,
0.03, 0.05, 0.07, 0.10, 0.15, 0.20, 0.25]
TEN_GRID = [7, 14, 21, 30, 45]
def calibrate(asset: str):
oc = OptionChain(asset)
d = oc.df.copy()
spot = oc._spot_proxy()
d["spot"] = d["timestamp"].map(spot)
d = d.dropna(subset=["spot", "ask", "bid", "iv"])
d = d[d["ask"] > 0]
d["otm"] = d["strike"] / d["spot"] - 1.0 # firmato: <0 put OTM, >0 call OTM
d["prem_pct"] = d["ask"] * 100.0 # ask in coin -> %notional
d["spread"] = (d["ask"] - d["bid"]) / ((d["ask"] + d["bid"]) / 2)
d["sellable"] = (d["bid"] > 0).astype(float)
# superficie: per ciascun (tipo, otm_bin, tenor_bin) -> mediane
surf = {"P": {}, "C": {}}
for typ in ("P", "C"):
dt = d[d["option_type"] == typ]
for ten in TEN_GRID:
tlo, thi = ten * 0.6, ten * 1.6
dtt = dt[(dt["tenor_d"] >= tlo) & (dt["tenor_d"] <= thi)]
for otm in OTM_GRID:
# banda moneyness +-1.5% attorno al target
band = dtt[(dtt["otm"] >= otm - 0.02) & (dtt["otm"] <= otm + 0.02)]
if len(band) < 5:
continue
surf[typ][f"{otm:+.2f}|{ten}"] = dict(
prem=round(float(band["prem_pct"].median()), 4),
spread=round(float(band["spread"].median()), 4),
iv=round(float(band["iv"].median()), 4),
sellable=round(float(band["sellable"].mean()), 3),
n=int(len(band)))
dvol_avg = float(np.nanmedian(d["iv"][d["otm"].abs() < 0.03])) # ~ATM IV medio
# gate liquidita': OTM piu' profondo (put) con bid>0 nel >=50% dei casi
puts = d[d["option_type"] == "P"]
deep = puts[puts["otm"] <= -0.10]
out = {"asset": asset, "dvol_chain": round(dvol_avg, 4),
"surface": surf, "otm_grid": OTM_GRID, "ten_grid": TEN_GRID,
"window": [str(oc.df["ts"].min())[:10], str(oc.df["ts"].max())[:10]]}
(OUT / f"opt_calib_{asset.lower()}.json").write_text(json.dumps(out))
npts = len(surf["P"]) + len(surf["C"])
print(f"{asset}: {npts} punti superficie | ATM IV ~{dvol_avg:.2f} | finestra {out['window']}")
# stampa qualche premio reale put per sanity
for key in ["-0.05|14", "-0.10|14", "-0.15|30", "-0.20|45"]:
v = surf["P"].get(key)
if v:
print(f" put {key:>9}: prem {v['prem']:.2f}% spread {v['spread']*100:.0f}% "
f"iv {v['iv']:.0f}% sellable {v['sellable']*100:.0f}% (n={v['n']})")
if __name__ == "__main__":
for a in ("BTC", "ETH"):
calibrate(a)
+190
View File
@@ -0,0 +1,190 @@
"""
Arena del gioco-OPZIONI: 100 agenti ciechi propongono STRUTTURE in opzioni su due
serie anonime (A=BTC, B=ETH). Torneo identico al gioco-prezzi (3 finestre TRAIN/VALID/
TEST, 90 epoche, cull 10% ogni 10 epoche -> 10 finalisti), ma le strategie sono opzioni
prezzate con BS + skew + DVOL (scripts/games/options_engine.py).
uv run python -m scripts.games.options_arena # 100 agenti random (test)
GAME_SPECS_DIR=... GAME_OUT=... uv run python -m scripts.games.options_arena --from-specs
"""
from __future__ import annotations
import json
import os
import random
import sys
from pathlib import Path
import numpy as np
from scripts.games.options_engine import (load_opt, splits3, evaluate, STRUCTURES)
OUT = Path("data/games"); OUT.mkdir(parents=True, exist_ok=True)
# spazio parametri: (min, max, tipo)
PSPACE = dict(otm=(0.02, 0.20, "f"), width=(0.02, 0.12, "f"), dte=(7, 45, "i"))
SERIES = ["A", "B"]
def _rand(rng, lo, hi, typ):
return int(rng.randint(int(lo), int(hi))) if typ == "i" else round(rng.uniform(lo, hi), 3)
def random_spec(rng):
p = {k: _rand(rng, *v) for k, v in PSPACE.items()}
return {"structure": rng.choice(STRUCTURES), "series": rng.choice(SERIES), "params": p}
def _normalize(spec):
st = spec.get("structure")
if st not in STRUCTURES:
st = "short_put"
out = {"structure": st, "series": spec.get("series") if spec.get("series") in SERIES else "A",
"params": {}}
src = spec.get("params", {})
for k, (lo, hi, typ) in PSPACE.items():
v = src.get(k, (lo + hi) / 2)
try:
v = float(v)
except Exception:
v = (lo + hi) / 2
v = max(lo, min(hi, v))
out["params"][k] = int(round(v)) if typ == "i" else round(v, 3)
# flatten per evaluate (structure/otm/width/dte)
out["structure"] = st
return out
def _flat(spec):
return {"structure": spec["structure"], **spec["params"]}
def mutate(spec, rng, strength=0.25):
s = json.loads(json.dumps(spec))
keys = list(PSPACE)
for k in rng.sample(keys, k=rng.randint(1, 2)):
lo, hi, typ = PSPACE[k]
span = (hi - lo) * strength
nv = max(lo, min(hi, s["params"][k] + rng.uniform(-span, span)))
s["params"][k] = int(round(nv)) if typ == "i" else round(nv, 3)
if rng.random() < 0.12:
s["structure"] = rng.choice(STRUCTURES)
if rng.random() < 0.05:
s["series"] = rng.choice(SERIES)
return s
class Agent:
def __init__(self, aid, spec, brief=""):
self.id = aid
self.spec = _normalize(spec)
self.brief = brief
self.train_fit = self.valid_fit = -1e9
self.metrics = self.vmetrics = {}
self.alive = True
@property
def series(self):
return self.spec["series"]
def score(self, datasets, splits_map):
d = datasets[self.series]; tr, va, _ = splits_map[self.series]
self.metrics = evaluate(d, _flat(self.spec), tr)
self.vmetrics = evaluate(d, _flat(self.spec), va)
self.train_fit = self.metrics["fitness"]; self.valid_fit = self.vmetrics["fitness"]
def run_tournament(specs, briefs=None, seed=2026, epochs=90, cull_every=10, cull_n=10,
out_name="options_result.json", log=print):
rng = random.Random(seed)
datasets = {"A": load_opt("BTC"), "B": load_opt("ETH")}
splits_map = {k: splits3(datasets[k]) for k in datasets}
briefs = briefs or [""] * len(specs)
agents = [Agent(i, s, briefs[i] if i < len(briefs) else "") for i, s in enumerate(specs)]
for a in agents:
a.score(datasets, splits_map)
alive = lambda: [a for a in agents if a.alive]
log(f"[epoch 0] {len(alive())} agenti | best VALID {max(a.valid_fit for a in agents):.1f}")
for ep in range(1, epochs + 1):
for a in alive():
cand = _normalize(mutate(a.spec, rng))
d = datasets[cand["series"]]; tr, va, _ = splits_map[cand["series"]]
m = evaluate(d, _flat(cand), tr)
if m["fitness"] > a.train_fit:
a.spec, a.metrics, a.train_fit = cand, m, m["fitness"]
a.vmetrics = evaluate(d, _flat(cand), va); a.valid_fit = a.vmetrics["fitness"]
if ep % cull_every == 0:
av = sorted(alive(), key=lambda a: a.valid_fit)
k = cull_n if len(av) - cull_n >= 10 else max(0, len(av) - 10)
for a in av[:k]:
a.alive = False
log(f"[epoch {ep:2d}] cull {k:2d} -> {len(alive()):3d} | best VALID "
f"{max(a.valid_fit for a in alive()):.1f} | worst {min(a.valid_fit for a in alive()):.1f}")
survivors = sorted(alive(), key=lambda a: a.valid_fit, reverse=True)
results = []
for rank, a in enumerate(survivors, 1):
d = datasets[a.series]; _, _, te = splits_map[a.series]
results.append({"rank": rank, "agent": a.id, "spec": a.spec, "brief": a.brief,
"series": a.series, "train": a.metrics, "valid": a.vmetrics,
"test": evaluate(d, _flat(a.spec), te), "full": evaluate(d, _flat(a.spec), None)})
payload = {"n_agents": len(specs), "survivors": len(survivors), "results": results,
"reveal": {"A": "BTC", "B": "ETH"}, "game": "options"}
(OUT / out_name).write_text(json.dumps(payload, indent=2))
return payload
def leaderboard(payload, top=10, log=print):
log("\n========= CLASSIFICA FINALE OPZIONI (top %d) =========" % top)
log(f"{'#':>2} {'ag':>4} {'ser':>3} {'struttura':>14} {'otm':>5} {'dte':>4} "
f"{'TEpnl%':>8} {'TEwin':>5} {'TEtpm':>6} {'TEsh':>6}")
for r in payload["results"][:top]:
sp = r["spec"]; te = r["test"]; p = sp["params"]
log(f"{r['rank']:>2} {r['agent']:>4} {sp['series']:>3} {sp['structure']:>14} "
f"{p['otm']:>5.2f} {p['dte']:>4} {te['pnl_pct']:>8.0f} {te['win_rate']*100:>4.0f}% "
f"{te['tpm']:>6.0f} {te['sharpe']:>6.1f}")
def load_specs(specs_dir, n=100):
rng = random.Random(7); specs, briefs = [], []
for i in range(n):
f = Path(specs_dir) / f"agent_{i}.json"
spec = None
if f.exists():
try:
raw = json.loads(f.read_text())
params = {k: raw.get(k, raw.get("params", {}).get(k)) for k in PSPACE}
spec = _normalize({"structure": raw.get("structure"),
"series": {"X": "A", "Y": "B"}.get(raw.get("series"), raw.get("series")),
"params": params})
briefs.append(str(raw.get("hypothesis", ""))[:300])
except Exception:
spec = None
if spec is None:
spec = random_spec(rng); briefs.append("(spec mancante -> random)")
specs.append(spec)
return specs, briefs
def main():
if "--from-specs" in sys.argv:
sd = os.environ.get("GAME_SPECS_DIR", "data/games/specs_opt")
on = os.environ.get("GAME_OUT", "options_result.json")
specs, briefs = load_specs(sd)
n_real = sum(1 for b in briefs if "mancante" not in b)
print(f"caricati {n_real}/100 spec da agenti reali")
payload = run_tournament(specs, briefs=briefs, out_name=on)
else:
rng = random.Random(42)
payload = run_tournament([random_spec(rng) for _ in range(100)], seed=42)
leaderboard(payload)
rev = payload["reveal"]; w = payload["results"][0]
print(f"\n>>> RIVELAZIONE: A={rev['A']}, B={rev['B']}. Gli agenti non lo sapevano. <<<")
print(f"VINCITORE: #{w['agent']} {w['series']} {w['spec']['structure']} "
f"otm{w['spec']['params']['otm']} dte{w['spec']['params']['dte']}")
print(f" ipotesi: {w['brief']}")
print(f" TEST: PnL {w['test']['pnl_pct']:.0f}% | win {w['test']['win_rate']*100:.0f}% | "
f"{w['test']['tpm']:.0f} tr/mese | Sharpe {w['test']['sharpe']:.1f}")
if __name__ == "__main__":
main()
+215
View File
@@ -0,0 +1,215 @@
"""
Motore del gioco-OPZIONI: prezza e backtesta strutture in opzioni proposte dagli
agenti ciechi, sui prezzi REALI ETH/BTC, con Black-Scholes + skew fittato + DVOL storica.
NON usa la chain reale (solo 6 settimane, un regime): prezza sinteticamente con la
vol implicita storica (DVOL Deribit, dal 2021-03) e la curva di skew fittata sulle IV
reali della ricerca credit-spread (iv/atm = 1 - 0.664*k + 3.494*k^2, k=ln(K/S)). Costi:
haircut bid/ask sulle opzioni (il fill reale e' peggiore del mid). Roll giornaliero,
hold-to-expiry (terminale model-free dai prezzi reali). PnL per-trade ADDITIVO.
Caveat onesto (dalla ricerca del progetto): il premium-selling a skew negativo vince nei
campioni calmi e restituisce tutto nei crash -> il gioco lo mostrera'.
"""
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))
import json as _json
from src.data.downloader import load_data
from scripts.analysis.option_overlay_lab import bs_put, bs_call, dvol_for
# skew fittato (fallback se manca la calibrazione reale): iv/atm in funzione di k=ln(K/S).
SKEW_A, SKEW_B = -0.664, 3.494
MIN_TRADES_PER_MONTH = 10.0
TRADING_DAYS_MONTH = 30.0
# --- pricing REALE: superficie premi/spread da cerbero-bite (scripts/games/opt_calibrate.py) ---
_CALIB_DIR = PROJECT_ROOT / "data" / "games"
_CALIB = {}
def _load_calib(asset):
if asset not in _CALIB:
f = _CALIB_DIR / f"opt_calib_{asset.lower()}.json"
_CALIB[asset] = _json.loads(f.read_text()) if f.exists() else None
return _CALIB[asset]
def _surf_lookup(cal, typ, otm_signed, dte):
"""Premio% e spread reali per (otm firmato, dte): punto di griglia piu' vicino.
Ritorna (prem_pct, spread, sellable) o None se fuori dalla superficie liquida."""
s = cal["surface"][typ]
og = cal["otm_grid"]; tg = cal["ten_grid"]
o = min(og, key=lambda x: abs(x - otm_signed))
t = min(tg, key=lambda x: abs(x - dte))
if abs(o - otm_signed) > 0.06: # troppo lontano dagli strike reali -> illiquido
return None
v = s.get(f"{o:+.2f}|{t}")
if not v or v["sellable"] < 0.5:
return None
return v["prem"], v["spread"], v["sellable"]
def iv_skew(k: float, atm: float) -> float:
"""IV per moneyness k=ln(K/S) dato l'ATM vol. Clamp a [0.3x, 3x] atm."""
mult = 1.0 + SKEW_A * k + SKEW_B * k * k
mult = min(max(mult, 0.3), 3.0)
return atm * mult
def load_opt(asset: str = "ETH"):
"""Prezzi GIORNALIERI (resample 1h->1d) + DVOL allineata. asset reale nascosto."""
df = load_data(asset, "1h").copy()
df["dt"] = pd.to_datetime(df["datetime"])
g = df.set_index("dt").resample("1D").agg(
{"timestamp": "first", "open": "first", "high": "max", "low": "min",
"close": "last"}).dropna(subset=["close"]).reset_index(drop=True)
g["timestamp"] = g["timestamp"].astype("int64")
dv = dvol_for(g, asset)
cal = _load_calib(asset)
dvol_chain = (cal["dvol_chain"] / 100.0) if cal else float(np.nanmedian(dv))
return {"close": g["close"].to_numpy(float), "high": g["high"].to_numpy(float),
"low": g["low"].to_numpy(float), "dvol": dv, "asset": asset,
"dvol_chain": dvol_chain, "real": cal is not None,
"dt": pd.to_datetime(g["timestamp"], unit="ms", utc=True).to_numpy(),
"n": len(g)}
# --------------------------------------------------------------------------
# Pricing di una struttura: ritorna (premio_netto_incassato, funzione_payoff(ST))
# premio>0 = struttura a CREDITO (vendi); payoff e' il valore terminale (>=0 per long opt).
# Convenzione PnL trade: net = (premio_incassato - payoff_terminale)/S0 - costi (per credito)
# Tutto normalizzato sul SPOT (frazione), cosi' e' confrontabile fra asset/epoche.
# --------------------------------------------------------------------------
STRUCTURES = ["short_put", "short_call", "short_strangle", "put_spread",
"call_spread", "iron_condor", "long_put", "long_call", "long_straddle"]
def _legs_for(struct, S, otm, width):
kp = S * (1 - otm); kc = S * (1 + otm)
kp2 = S * (1 - otm - width); kc2 = S * (1 + otm + width)
return {
"short_put": [("P", kp, -1)], "short_call": [("C", kc, -1)],
"short_strangle": [("P", kp, -1), ("C", kc, -1)],
"put_spread": [("P", kp, -1), ("P", kp2, +1)],
"call_spread": [("C", kc, -1), ("C", kc2, +1)],
"iron_condor": [("P", kp, -1), ("P", kp2, +1), ("C", kc, -1), ("C", kc2, +1)],
"long_put": [("P", kp, +1)], "long_call": [("C", kc, +1)],
"long_straddle": [("P", S, +1), ("C", S, +1)],
}[struct]
def _price_real(struct, S, dte, scale, otm, width, cal):
"""Pricing REALE dalla superficie cerbero-bite. Ritorna (entry_cf_frac, legs, ok).
entry_cf_frac = cassa d'ingresso in frazione di spot (>0 = incassi); side-aware bid/ask;
ok=False se una gamba e' fuori dagli strike liquidi reali."""
legs = _legs_for(struct, S, otm, width)
entry = 0.0
for typ, K, sgn in legs:
q = _surf_lookup(cal, typ, K / S - 1.0, dte)
if q is None:
return 0.0, legs, False
prem, spread, _ = q
pf = prem / 100.0 * scale # premio frazione di spot, scalato a DVOL del giorno
if sgn < 0: # short: incassi il BID (~ ask*(1-spread))
entry += pf * (1 - spread)
else: # long: paghi l'ASK
entry -= pf
return entry, legs, True
def _price(struct, S, T, atm, otm, width):
"""Fallback SINTETICO (BS+skew). Usato solo se manca la calibrazione reale."""
legs = _legs_for(struct, S, otm, width)
prem = gross = 0.0
for typ, K, sgn in legs:
px = bs_put(S, K, T, iv_skew(np.log(K / S), atm)) if typ == "P" \
else bs_call(S, K, T, iv_skew(np.log(K / S), atm))
prem += -sgn * px / S
gross += abs(px) / S
return prem - 0.06 * gross, legs, True
def _payoff(legs, ST):
v = 0.0
for typ, K, sgn in legs:
intr = max(K - ST, 0.0) if typ == "P" else max(ST - K, 0.0)
v += sgn * intr # valore terminale delle opzioni che POSSIEDI/devi
return v # per le short questo e' cio' che PAGHI (sgn<0 -> negativo = debito)
def evaluate(data, spec, sl=None):
"""Backtest della struttura: roll giornaliero, hold dte giorni, PnL additivo.
spec = {structure, otm, width, dte}. Ritorna metriche con scoring PNL+%win, >=10 tr/mese.
"""
c, dv = data["close"], data["dvol"]
n = data["n"]
s, e = (sl if sl else (0, n))
struct = spec["structure"]
otm = float(spec["otm"]); width = float(spec.get("width", 0.05))
dte = int(spec["dte"])
T = dte / 365.0
cal = _load_calib(data["asset"]); dvol_chain = data["dvol_chain"]
rets = []
i = s
while i < e - dte:
S0 = c[i]; atm = dv[i]
if S0 <= 0 or atm <= 0:
i += 1; continue
if cal is not None: # PRICING REALE (cerbero-bite), scalato a DVOL del giorno
scale = min(max(atm / dvol_chain, 0.3), 4.0)
entry, legs, ok = _price_real(struct, S0, dte, scale, otm, width, cal)
if not ok: # strike fuori dalla superficie liquida reale -> non eseguibile
i += 1; continue
net = entry + _payoff(legs, c[i + dte]) / S0
else: # fallback sintetico
prem, legs, _ = _price(struct, S0, T, atm, otm, width)
net = prem + _payoff(legs, c[i + dte]) / S0
rets.append(net)
i += 1 # roll giornaliero (posizioni sovrapposte)
rets = np.array(rets)
nbars = e - s
months = nbars / TRADING_DAYS_MONTH
n_tr = len(rets)
tpm = n_tr / months if months > 0 else 0.0
if n_tr == 0:
return dict(n_trades=0, win_rate=0.0, pnl_pct=0.0, tpm=0.0, sharpe=0.0,
avg_ret=0.0, qualified=False, fitness=-1e6)
win = float(np.mean(rets > 0))
pnl = float(np.sum(rets)) * 100
avg = float(np.mean(rets)) * 100
sharpe = float(np.mean(rets) / (np.std(rets) + 1e-12) * np.sqrt(tpm * 12)) \
if np.std(rets) > 0 else 0.0
qualified = tpm >= MIN_TRADES_PER_MONTH
fitness = pnl + 50.0 * win
if not qualified:
fitness = -1e6 + pnl
return dict(n_trades=n_tr, win_rate=win, pnl_pct=pnl, tpm=tpm, sharpe=sharpe,
avg_ret=avg, qualified=qualified, fitness=fitness)
def splits3(data, train_frac=0.60, valid_frac=0.20):
n = data["n"]
c1 = int(n * train_frac); c2 = int(n * (train_frac + valid_frac))
return (0, c1), (c1, c2), (c2, n)
if __name__ == "__main__":
d = load_opt("ETH")
print("loaded", d["n"], "giorni", str(d["dt"][0])[:10], "->", str(d["dt"][-1])[:10],
"| dvol", round(float(np.nanmean(d["dvol"])), 2))
tr, va, te = splits3(d)
for st in ["short_put", "short_strangle", "iron_condor", "long_straddle", "put_spread"]:
sp = {"structure": st, "otm": 0.05, "width": 0.05, "dte": 14}
f = evaluate(d, sp, None); o = evaluate(d, sp, te)
print(f"{st:14} FULL pnl{f['pnl_pct']:8.0f} win{f['win_rate']*100:4.0f} tpm{f['tpm']:5.0f} "
f"Sh{f['sharpe']:6.1f} | OOS pnl{o['pnl_pct']:8.0f} win{o['win_rate']*100:4.0f} Sh{o['sharpe']:6.1f}")
+88
View File
@@ -0,0 +1,88 @@
"""
run_game — carica le 100 strategie proposte dagli agenti ciechi (file in
data/games/specs/agent_*.json), lancia il torneo (epoche + cull) e stampa la
classifica finale, poi RIVELA cosa erano X e Y.
Se mancano agenti (file assenti o malformati) riempie con spec casuali, cosi'
il gioco gira sempre a 100 concorrenti.
"""
from __future__ import annotations
import json
import os
import random
from pathlib import Path
from scripts.games import engine
from scripts.games import arena
from scripts.games.arena import random_spec, run_tournament, leaderboard, _normalize
SPECS_DIR = Path(os.environ.get("GAME_SPECS_DIR", "data/games/specs"))
OUT_NAME = os.environ.get("GAME_OUT", "tournament_result.json")
N = 100
def load_specs():
rng = random.Random(123)
specs, briefs, sources = [], [], []
for i in range(N):
f = SPECS_DIR / f"agent_{i}.json"
spec = None
if f.exists():
try:
raw = json.loads(f.read_text())
fam = raw.get("family")
params = dict(raw.get("params", {}))
if "direction" in raw and "direction" not in params:
params["direction"] = raw["direction"]
spec = {"family": fam, "series": raw.get("series", "A"),
"tf": raw.get("tf", "1h"), "params": params}
# X->A, Y->B mapping (gli agenti vedono X/Y)
s = spec["series"]
spec["series"] = {"X": "A", "Y": "B", "AB": "AB",
"A": "A", "B": "B"}.get(s, "A")
spec = _normalize(spec)
briefs.append(str(raw.get("hypothesis", ""))[:300])
sources.append("agent")
except Exception as e:
spec = None
if spec is None:
spec = random_spec(rng)
briefs.append("(spec mancante -> sostituto casuale)")
sources.append("random")
specs.append(spec)
n_agent = sources.count("agent")
print(f"caricati {n_agent}/{N} spec da agenti reali, "
f"{N - n_agent} sostituiti casuali")
return specs, briefs
def main():
slip = float(os.environ.get("GAME_SLIP", "0.0"))
engine.set_slippage(slip)
if os.environ.get("GAME_NO_LIVE") == "1":
arena.set_no_live(True)
print("VINCOLO: solo strategie NON in live (no pairs, no zscore/breakout-reversion)")
if slip > 0:
print(f"SLIPPAGE attivo: {slip*100:.3f}%/lato "
f"(single-leg {2*slip*100:.2f}% RT extra, pairs {4*slip*100:.2f}% extra)")
specs, briefs = load_specs()
payload = run_tournament(specs, briefs=briefs, seed=2026,
epochs=90, cull_every=10, cull_n=10, out_name=OUT_NAME)
leaderboard(payload, top=10)
rev = payload["reveal"]
print(f"\n>>> RIVELAZIONE: Serie X = {rev['A']}, Serie Y = {rev['B']} "
f"(timeframe base {rev['tf']}). Gli agenti non lo sapevano. <<<")
# vincitore
w = payload["results"][0]
sp = w["spec"]
print(f"\nVINCITORE: agente #{w['agent']} su {w['tf']} | {sp['family']} "
f"{sp['series']} {sp['params'].get('direction','')}")
print(f" ipotesi dell'agente: {w['brief']}")
print(f" TEST(OOS): PnL {w['test']['pnl_pct']:.0f}% | win "
f"{w['test']['win_rate']*100:.0f}% | {w['test']['tpm']:.1f} trade/mese "
f"| Sharpe {w['test']['sharpe']:.1f}")
if __name__ == "__main__":
main()
+174
View File
@@ -0,0 +1,174 @@
"""
Arena del gioco-SESSION: 100 agenti ciechi cercano pattern ORARI intraday (fascia di
controllo -> finestra successiva) su due serie anonime (A=BTC, B=ETH). Torneo standard
(3 finestre, 90 epoche, cull 10%/10) col motore session_engine.
uv run python -m scripts.games.session_arena # 100 random (test)
GAME_SPECS_DIR=... GAME_OUT=... uv run python -m scripts.games.session_arena --from-specs
"""
from __future__ import annotations
import json
import os
import random
import sys
from pathlib import Path
from scripts.games.session_engine import load_session, splits3, evaluate
OUT = Path("data/games"); OUT.mkdir(parents=True, exist_ok=True)
PSPACE = dict(ctrl_hour=(0, 23, "i"), ctrl_len=(1, 6, "i"),
entry_thr=(0.0, 1.5, "f"), hold=(1, 12, "i"))
SERIES = ["A", "B"]
DIRECTIONS = ["trend", "reversion"]
def _rand(rng, lo, hi, typ):
return int(rng.randint(int(lo), int(hi))) if typ == "i" else round(rng.uniform(lo, hi), 3)
def random_spec(rng):
p = {k: _rand(rng, *v) for k, v in PSPACE.items()}
return {"series": rng.choice(SERIES), "direction": rng.choice(DIRECTIONS), "params": p}
def _normalize(spec):
out = {"series": spec.get("series") if spec.get("series") in SERIES else "A",
"direction": spec.get("direction") if spec.get("direction") in DIRECTIONS else "trend",
"params": {}}
src = spec.get("params", spec)
for k, (lo, hi, typ) in PSPACE.items():
v = src.get(k, (lo + hi) / 2)
try:
v = float(v)
except Exception:
v = (lo + hi) / 2
v = max(lo, min(hi, v))
out["params"][k] = int(round(v)) if typ == "i" else round(v, 3)
return out
def _flat(spec):
return {"direction": spec["direction"], **spec["params"]}
def mutate(spec, rng, strength=0.25):
s = json.loads(json.dumps(spec))
for k in rng.sample(list(PSPACE), k=rng.randint(1, 2)):
lo, hi, typ = PSPACE[k]
span = (hi - lo) * strength
nv = max(lo, min(hi, s["params"][k] + rng.uniform(-span, span)))
s["params"][k] = int(round(nv)) if typ == "i" else round(nv, 3)
if rng.random() < 0.12:
s["direction"] = rng.choice(DIRECTIONS)
if rng.random() < 0.05:
s["series"] = rng.choice(SERIES)
return s
class Agent:
def __init__(self, aid, spec, brief=""):
self.id = aid; self.spec = _normalize(spec); self.brief = brief
self.train_fit = self.valid_fit = -1e9; self.metrics = self.vmetrics = {}; self.alive = True
@property
def series(self):
return self.spec["series"]
def score(self, datasets, sm):
d = datasets[self.series]; tr, va, _ = sm[self.series]
self.metrics = evaluate(d, _flat(self.spec), tr); self.vmetrics = evaluate(d, _flat(self.spec), va)
self.train_fit = self.metrics["fitness"]; self.valid_fit = self.vmetrics["fitness"]
def run_tournament(specs, briefs=None, seed=2026, epochs=90, cull_every=10, cull_n=10,
out_name="session_result.json", log=print):
rng = random.Random(seed)
datasets = {"A": load_session("BTC"), "B": load_session("ETH")}
sm = {k: splits3(datasets[k]) for k in datasets}
briefs = briefs or [""] * len(specs)
agents = [Agent(i, s, briefs[i] if i < len(briefs) else "") for i, s in enumerate(specs)]
for a in agents:
a.score(datasets, sm)
alive = lambda: [a for a in agents if a.alive]
log(f"[epoch 0] {len(alive())} | best VALID {max(a.valid_fit for a in agents):.1f}")
for ep in range(1, epochs + 1):
for a in alive():
cand = _normalize(mutate(a.spec, rng))
d = datasets[cand["series"]]; tr, va, _ = sm[cand["series"]]
m = evaluate(d, _flat(cand), tr)
if m["fitness"] > a.train_fit:
a.spec, a.metrics, a.train_fit = cand, m, m["fitness"]
a.vmetrics = evaluate(d, _flat(cand), va); a.valid_fit = a.vmetrics["fitness"]
if ep % cull_every == 0:
av = sorted(alive(), key=lambda a: a.valid_fit)
k = cull_n if len(av) - cull_n >= 10 else max(0, len(av) - 10)
for a in av[:k]:
a.alive = False
log(f"[epoch {ep:2d}] cull {k:2d} -> {len(alive()):3d} | best VALID "
f"{max(a.valid_fit for a in alive()):.1f} | worst {min(a.valid_fit for a in alive()):.1f}")
survivors = sorted(alive(), key=lambda a: a.valid_fit, reverse=True)
results = []
for rank, a in enumerate(survivors, 1):
d = datasets[a.series]; _, _, te = sm[a.series]
results.append({"rank": rank, "agent": a.id, "spec": a.spec, "brief": a.brief,
"series": a.series, "train": a.metrics, "valid": a.vmetrics,
"test": evaluate(d, _flat(a.spec), te), "full": evaluate(d, _flat(a.spec), None)})
payload = {"n_agents": len(specs), "survivors": len(survivors), "results": results,
"reveal": {"A": "BTC", "B": "ETH"}, "game": "session"}
(OUT / out_name).write_text(json.dumps(payload, indent=2))
return payload
def leaderboard(payload, top=10, log=print):
log("\n===== CLASSIFICA SESSION (top %d) — fascia controllo -> finestra dopo =====" % top)
log(f"{'#':>2} {'ag':>4} {'ser':>3} {'h':>3} {'len':>3} {'thr%':>5} {'hold':>4} {'dir':>9} "
f"{'TEpnl%':>8} {'TEwin':>5} {'TEtpm':>6} {'TEsh':>6}")
for r in payload["results"][:top]:
sp = r["spec"]; te = r["test"]; p = sp["params"]
log(f"{r['rank']:>2} {r['agent']:>4} {sp['series']:>3} {p['ctrl_hour']:>3} {p['ctrl_len']:>3} "
f"{p['entry_thr']:>5.2f} {p['hold']:>4} {sp['direction']:>9} {te['pnl_pct']:>8.0f} "
f"{te['win_rate']*100:>4.0f}% {te['tpm']:>6.0f} {te['sharpe']:>6.1f}")
def load_specs(specs_dir, n=100):
rng = random.Random(7); specs, briefs = [], []
for i in range(n):
f = Path(specs_dir) / f"agent_{i}.json"; spec = None
if f.exists():
try:
raw = json.loads(f.read_text())
params = {k: raw.get(k, raw.get("params", {}).get(k)) for k in PSPACE}
spec = _normalize({"series": {"X": "A", "Y": "B"}.get(raw.get("series"), raw.get("series")),
"direction": raw.get("direction"), "params": params})
briefs.append(str(raw.get("hypothesis", ""))[:300])
except Exception:
spec = None
if spec is None:
spec = random_spec(rng); briefs.append("(spec mancante -> random)")
specs.append(spec)
return specs, briefs
def main():
if "--from-specs" in sys.argv:
sd = os.environ.get("GAME_SPECS_DIR", "data/games/specs_sess")
on = os.environ.get("GAME_OUT", "session_result.json")
specs, briefs = load_specs(sd)
print(f"caricati {sum(1 for b in briefs if 'mancante' not in b)}/100 spec da agenti reali")
payload = run_tournament(specs, briefs=briefs, out_name=on)
else:
rng = random.Random(42)
payload = run_tournament([random_spec(rng) for _ in range(100)], seed=42)
leaderboard(payload)
rev = payload["reveal"]; w = payload["results"][0]; p = w["spec"]["params"]
print(f"\n>>> RIVELAZIONE: A={rev['A']}, B={rev['B']}. <<<")
print(f"VINCITORE: #{w['agent']} {w['series']} fascia h{p['ctrl_hour']} len{p['ctrl_len']} "
f"-> {w['spec']['direction']} hold{p['hold']}h thr{p['entry_thr']}%")
print(f" ipotesi: {w['brief']}")
print(f" TEST: PnL {w['test']['pnl_pct']:.0f}% | win {w['test']['win_rate']*100:.0f}% | "
f"{w['test']['tpm']:.0f} tr/mese | Sharpe {w['test']['sharpe']:.1f}")
if __name__ == "__main__":
main()
+98
View File
@@ -0,0 +1,98 @@
"""
Motore del gioco-SESSION: pattern ORARI intraday. Ogni giorno si osserva il movimento
in una "fascia di controllo" [ctrl_hour, ctrl_hour+ctrl_len) e si scommette sul movimento
della finestra SUBITO DOPO (hold ore), seguendo (trend) o fadando (reversion) la fascia.
Cerca se esistono orari il cui comportamento ANTICIPA la finestra successiva, ripetibile nei
giorni. Dati orari reali (BTC=A, ETH=B), full history. PnL per-trade additivo, fee 0.10% RT.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
FEE_RT = 0.001
MIN_TRADES_PER_MONTH = 10.0
BARS_PER_MONTH = 24 * 30
def load_session(asset: str = "BTC"):
df = load_data(asset, "1h").copy()
dt = pd.to_datetime(df["datetime"])
return {"close": df["close"].to_numpy(float),
"open": df["open"].to_numpy(float),
"hour": dt.dt.hour.to_numpy(),
"day": (dt.dt.year * 366 + dt.dt.dayofyear).to_numpy(), # indice giorno
"dt": dt.to_numpy(), "n": len(df)}
def evaluate(data, spec, sl=None, fee=FEE_RT):
"""spec = {ctrl_hour, ctrl_len, entry_thr(%), direction, hold}. Una valutazione per giorno:
a fine fascia di controllo, se |ret_fascia| > entry_thr entra e tiene hold ore."""
c, hour = data["close"], data["hour"]
n = data["n"]
s, e = (sl if sl else (0, n))
ch = int(spec["ctrl_hour"]) % 24
cl = max(1, int(spec["ctrl_len"]))
thr = float(spec["entry_thr"]) / 100.0
hold = max(1, int(spec["hold"]))
sign = 1 if spec.get("direction", "trend") == "trend" else -1
# indici in cui inizia la fascia di controllo (bar all'ora ch)
starts = np.where(hour[s:e] == ch)[0] + s
rets = []
for st in starts:
be = st + cl - 1 # ultima barra della fascia
ex = be + hold # uscita
if ex >= e or st == 0:
continue
ctrl_ret = c[be] / c[st - 1] - 1.0 # ritorno della fascia (causale: chiude a be)
if abs(ctrl_ret) < thr:
continue
d = sign * (1 if ctrl_ret > 0 else -1) # trend segue, reversion fada
entry = c[be]; exit_px = c[ex]
net = d * (exit_px - entry) / entry - fee
rets.append(net)
rets = np.array(rets)
nbars = e - s
months = nbars / BARS_PER_MONTH
n_tr = len(rets)
tpm = n_tr / months if months > 0 else 0.0
if n_tr == 0:
return dict(n_trades=0, win_rate=0.0, pnl_pct=0.0, tpm=0.0, sharpe=0.0,
avg_ret=0.0, qualified=False, fitness=-1e6)
win = float(np.mean(rets > 0))
pnl = float(np.sum(rets)) * 100
avg = float(np.mean(rets)) * 100
sharpe = float(np.mean(rets) / (np.std(rets) + 1e-12) * np.sqrt(tpm * 12)) \
if np.std(rets) > 0 else 0.0
qualified = tpm >= MIN_TRADES_PER_MONTH
fitness = pnl + 50.0 * win
if not qualified:
fitness = -1e6 + pnl
return dict(n_trades=n_tr, win_rate=win, pnl_pct=pnl, tpm=tpm, sharpe=sharpe,
avg_ret=avg, qualified=qualified, fitness=fitness)
def splits3(data, train_frac=0.60, valid_frac=0.20):
n = data["n"]
c1 = int(n * train_frac); c2 = int(n * (train_frac + valid_frac))
return (0, c1), (c1, c2), (c2, n)
if __name__ == "__main__":
d = load_session("BTC"); tr, va, te = splits3(d)
for ch in [0, 8, 13, 20]:
for dr in ["trend", "reversion"]:
sp = {"ctrl_hour": ch, "ctrl_len": 2, "entry_thr": 0.3, "direction": dr, "hold": 4}
f = evaluate(d, sp, None); o = evaluate(d, sp, te)
print(f"h{ch:>2} {dr:>9} len2 hold4 thr0.3 | FULL pnl{f['pnl_pct']:7.0f} win{f['win_rate']*100:3.0f} "
f"tpm{f['tpm']:4.0f} Sh{f['sharpe']:5.1f} | OOS Sh{o['sharpe']:5.1f}")
+26 -1
View File
@@ -79,6 +79,13 @@ HONEST = [
] ]
PAIRS = [ PAIRS = [
SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHBTC", a="ETH", b="BTC", cluster="ETH-rev"), SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHBTC", a="ETH", b="BTC", cluster="ETH-rev"),
# BLEND timeframe: ETH/BTC anche a 15m (flat-skip), accanto al 1h. Decorrelato 0.37 dal
# 1h -> diversificatore intra-pairs. Worker validato (validate_worker_pairs 15m, replay
# == pairs_sim_flat). Gate PORT06: docs/diary/2026-06-09-pairs15m-live-path.md.
SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHBTC_15M", a="ETH", b="BTC", tf="15m",
params={"n": 66, "z_in": 1.674, "z_exit": 1.0, "max_bars": 35, "flat_skip": True,
"position_size": 0.10}, # meta' del family PAIRS (0.20): blend-tilt
cluster="ETH-rev"),
SleeveSpec(kind="pairs", name="PR01", sid="PR_LTCETH", a="LTC", b="ETH", cluster="ETH-rev"), SleeveSpec(kind="pairs", name="PR01", sid="PR_LTCETH", a="LTC", b="ETH", cluster="ETH-rev"),
SleeveSpec(kind="pairs", name="PR01", sid="PR_ADAETH", a="ADA", b="ETH", cluster="ETH-rev"), SleeveSpec(kind="pairs", name="PR01", sid="PR_ADAETH", a="ADA", b="ETH", cluster="ETH-rev"),
SleeveSpec(kind="pairs", name="PR01", sid="PR_BTCLTC", a="BTC", b="LTC", cluster="BTC-rev"), SleeveSpec(kind="pairs", name="PR01", sid="PR_BTCLTC", a="BTC", b="LTC", cluster="BTC-rev"),
@@ -94,6 +101,24 @@ TSM = [SleeveSpec(kind="tsmom", name="TSM01", sid="TSM01", cluster="trend",
SHAPE = [SleeveSpec(kind="ml", name="SH01", sid=f"SH_{a}", asset=a, cluster="shape", SHAPE = [SleeveSpec(kind="ml", name="SH01", sid=f"SH_{a}", asset=a, cluster="shape",
params={"last_block_only": True}) params={"last_block_only": True})
for a in ("BTC", "ETH")] for a in ("BTC", "ETH")]
# XS01 — reversione CROSS-SECTIONAL (8 asset, market-neutral). Famiglia nuova, scorrelata
# (~0) da pairs e fade. Gate PORT06: +XS01 -> OOS Sharpe 9.66->10.07, FULL DD 3.68->3.46.
# 8 gambe -> niente esecuzione reale: gira PAPER (come TR01/ROT02/TSM01). Worker validato
# (validate_xsec_worker: replay == backtest esatto). Diario 2026-06-09.
# DISPERSION-GATE (2026-06-10): entry solo se std cross-section del momentum lb >= disp_min
# (p50 TRAIN = 0.0313). Diagnostica monotona TRAIN+OOS, plateau p30-p70, ogni anno migliora
# (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'
# 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",
params={"universe": ["BTC", "ETH", "LTC", "ADA", "SOL", "BNB", "XRP", "DOGE"],
"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"),
@@ -108,6 +133,6 @@ PORTFOLIOS = {
# che NESSUNO stop taglia la coda ETH senza rompere l'edge -> si dimezza l'esposizione # che NESSUNO stop taglia la coda ETH senza rompere l'edge -> si dimezza l'esposizione
# (costo backtest ~0: FULL 6.47->6.43, OOS 8.82->8.58, FULL DD 4.10->3.96). Vedi # (costo backtest ~0: FULL 6.47->6.43, OOS 8.82->8.58, FULL DD 4.10->3.96). Vedi
# docs/diary/2026-06-05-sh01-sl-research.md. # docs/diary/2026-06-05-sh01-sl-research.md.
"PORT06": Portfolio("PORT06", "Master + shape", FADE + HONEST + PAIRS + TSM + SHAPE, "PORT06": Portfolio("PORT06", "Master + shape", FADE + HONEST + PAIRS + TSM + SHAPE + XSEC,
weighting="cap", caps={"PAIRS": 0.33, "SHAPE": 0.0588}, leverage=2.0), weighting="cap", caps={"PAIRS": 0.33, "SHAPE": 0.0588}, leverage=2.0),
} }
+12 -1
View File
@@ -115,7 +115,11 @@ def collect():
nr = ev.get("net_return", 0.0) nr = ev.get("net_return", 0.0)
pnl = ev.get("pnl", 0.0) pnl = ev.get("pnl", 0.0)
realized += pnl realized += pnl
closed.append((_short(wid), ev.get("reason", "?"), nr, pnl, nr > 0)) # win = flag del worker (col real-truth segue il PnL REALE; net_return
# resta il sim diagnostico: sui TP fantasma da spike testnet diceva
# 26/0 mentre il reale era 11/15). Fallback nr>0 per eventi storici.
closed.append((_short(wid), ev.get("reason", "?"), nr, pnl,
bool(ev.get("win", nr > 0))))
if "positions" in st or "weights" in st: if "positions" in st or "weights" in st:
continue # multi-asset (TR01/ROT02/TSM01): sezione dedicata continue # multi-asset (TR01/ROT02/TSM01): sezione dedicata
if st.get("in_position"): if st.get("in_position"):
@@ -141,6 +145,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}
+105
View File
@@ -0,0 +1,105 @@
"""XS01 — Cross-Sectional Reversion (market-neutral su 8 cripto). FAMIGLIA NUOVA.
Distinta dai pairs PR01 (pairwise) e dai fade (single-asset): ogni HOLD ore classifica
gli 8 asset per rendimento su LB ore e va LONG i perdenti relativi / SHORT i vincenti
(peso ∝ -(ret - media_cross-section)), market-neutral gross 1. Cattura il FATTORE
reversione cross-sezionale. Scorrelato (~0) da pairs e fade -> diversificatore.
Engine ONESTO (no look-ahead, verificato): pesi a barra i da close[<=i]; ingresso a
close[i], uscita a close[i+HOLD]; roll NON sovrapposto (riallinea ogni HOLD barre).
Fee = 0.10% RT/book (turnover gross 1 -> 2*fee_rt). PnL su capitale composto (pos, lev).
Validazione (sessione 2026-06-09, lb48 hold12, fee 0.10% RT, OOS ultimo 30%):
FULL Sharpe ~3.3 / OOS ~3.4, plateau lb 12-72 x hold 6-24 (OOS 2-3.9), 4/5 anni+.
Decorrelato (-0.006 da PR01 ETH/BTC). Cost-sensitive: muore ~0.35% RT/book.
Gate PORT06: +XS01 -> OOS Sharpe 9.66->10.07, FULL DD 3.68->3.46 (OOS DD +0.06pp a mezza size).
"""
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
UNIVERSE = ["BTC", "ETH", "LTC", "ADA", "SOL", "BNB", "XRP", "DOGE"]
FEE_RT, LEV, POS, OOS_FRAC = 0.0005, 3.0, 0.15, 0.30
LB, HOLD = 48, 12
def aligned_panel(assets=UNIVERSE, tf="1h"):
dfs = {a: load_data(a, tf)[["timestamp", "close"]].rename(columns={"close": a}).set_index("timestamp")
for a in assets}
M = pd.concat(dfs.values(), axis=1, join="inner").sort_index()
return M[assets]
def xsec_sim(assets=UNIVERSE, tf="1h", lb=LB, hold=HOLD, fee_rt=FEE_RT, lev=LEV,
pos=POS, split_frac=0.0):
M = aligned_panel(assets, tf)
C = M[assets].values
ts = pd.to_datetime(M.index, unit="ms", utc=True)
n = len(C); logC = np.log(C)
split = int(n * split_frac)
cap = peak = 1000.0; dd = 0.0
trades = wins = 0; rets = []; yearly = {}; yearly_n = {}
eq_ts, eq_v = [], []
last = -1; i = max(lb, split)
fee = 2 * fee_rt # gross 1 -> turnover 2 (entra+esce)
while i < n - hold:
if i <= last:
i += 1; continue
dm = (logC[i] - logC[i - lb]); dm = dm - dm.mean()
w = -dm; gw = np.sum(np.abs(w))
if gw < 1e-9:
i += 1; continue
w = w / gw # market-neutral, gross 1
book = float(np.sum(w * (logC[i + hold] - logC[i])))
net = book - fee
cap = max(cap + cap * pos * lev * net, 10.0)
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
trades += 1; wins += net > 0; rets.append(net * pos); last = i + hold
eq_ts.append(ts[i + hold]); eq_v.append(cap)
yearly[ts[i].year] = yearly.get(ts[i].year, 0.0) + net * 100
yearly_n[ts[i].year] = yearly_n.get(ts[i].year, 0) + 1
i += 1
yrs_span = (ts[-1] - ts[max(split, 0)]).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
cagr = ((cap / 1000) ** (1 / yrs_span) - 1) * 100 if cap > 0 else -100
return dict(trades=trades, win=wins / trades * 100 if trades else 0, ret=ret_tot,
cagr=cagr, dd=dd * 100, sharpe=sharpe, yearly=yearly, yearly_n=yearly_n,
eq_ts=eq_ts, eq_v=eq_v)
def check_no_lookahead():
M = aligned_panel(); logC = np.log(M.values); i = 1000
a = (logC[i] - logC[i - LB])
Cp = logC.copy(); Cp[i + 1:] += 0.5
b = (Cp[i] - Cp[i - LB])
print(f" no-look-ahead: segnale invariato col futuro perturbato -> "
f"{'OK' if np.allclose(a, b) else 'VIOLAZIONE'}")
def run():
print("=" * 84)
print(" XS01 — Cross-Sectional Reversion (8 asset, market-neutral) | netto fee 0.10% RT/book")
print("=" * 84)
check_no_lookahead()
f = xsec_sim()
o = xsec_sim(split_frac=1 - OOS_FRAC)
yrs = f["yearly"]; pos_y = sum(1 for v in yrs.values() if v > 0)
print(f" trade {f['trades']} | win {f['win']:.1f}% | CAGR {f['cagr']:.0f}% | DD {f['dd']:.0f}% | "
f"Sharpe FULL {f['sharpe']:.2f} / OOS {o['sharpe']:.2f} | anni+ {pos_y}/{len(yrs)}")
print(" per anno:", " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(yrs.items())))
if __name__ == "__main__":
run()
+5 -1
View File
@@ -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()
+62 -5
View File
@@ -119,6 +119,33 @@ class Fill:
verified: bool # posizione/trade riscontrati su Deribit verified: bool # posizione/trade riscontrati su Deribit
raw: dict[str, Any] = field(default_factory=dict) raw: dict[str, Any] = field(default_factory=dict)
notes: str = "" notes: str = ""
# amount REALMENTE fillato (order.filled_amount / somma trades) — puo' essere
# MINORE del richiesto: un reduce-only cappato dal netting di conto (worker
# long e short sullo stesso strumento) viene ridotto in silenzio da Deribit.
# Audit 2026-06-11: close 0.105, fillato 0.078, bookato pieno perche' il
# ledger usava `amount`. I ledger devono usare QUESTO campo.
filled_amount: float = 0.0
def _merge_close_fills(ro: Fill, net: Fill, requested: float) -> Fill:
"""Combina il reduce-only e il residuo netting in UN esito per il chiamante:
filled = somma, prezzo = media pesata sui fill, fee sommate. verified se i
contratti riscontrati coprono il richiesto (la stessa soglia del chiamante)."""
fa = ro.filled_amount if ro.verified else 0.0
fb = net.filled_amount if net.verified else 0.0
tot = fa + fb
parts = [(amt, f.fill_price) for amt, f in ((fa, ro), (fb, net))
if amt > 0 and f.fill_price]
px = (sum(a * p for a, p in parts) / sum(a for a, _ in parts)) if parts else None
step = contract_spec(ro.instrument).get("step", 0.001)
verified = tot >= requested - step / 2
return Fill(ro.instrument, ro.side, ro.requested_notional, requested, px,
ro.fee_coin + net.fee_coin, ro.fee_usd + net.fee_usd,
net.order_id or ro.order_id, net.order_state or ro.order_state,
verified, raw={"reduce_only": ro.raw, "net": net.raw},
notes=(f"netting: reduce-only {fa}/{requested}, residuo {fb} "
f"market puro ({net.order_state})"),
filled_amount=tot)
def _avg_fill_price(order: dict, trades: list[dict]) -> float | None: def _avg_fill_price(order: dict, trades: list[dict]) -> float | None:
@@ -233,6 +260,15 @@ class ExecutionClient:
fee_usd = fee_coin if spec.get("linear") else ( fee_usd = fee_coin if spec.get("linear") else (
fee_coin * fill_price if (fee_coin and fill_price) else 0.0) fee_coin * fill_price if (fee_coin and fill_price) else 0.0)
# amount REALMENTE fillato: order.filled_amount e' la fonte autorevole
# (Deribit RIDUCE in silenzio un reduce-only che eccede il netto di conto);
# fallback: somma trades del fill, poi trade history
filled = float(order.get("filled_amount") or 0)
if not filled:
filled = sum(float(t.get("amount", 0) or 0) for t in trades)
if not filled and th:
filled = float(th.get("amount") or 0)
# VERIFICA: market = ordine filled E fill riscontrato (trades o history); # VERIFICA: market = ordine filled E fill riscontrato (trades o history);
# limit = accettato in book ('open') o gia' eseguito ('filled'); # limit = accettato in book ('open') o gia' eseguito ('filled');
# stop_market = trigger accettato ('untriggered' finche' il mark non tocca) # stop_market = trigger accettato ('untriggered' finche' il mark non tocca)
@@ -242,9 +278,13 @@ class ExecutionClient:
verified = state in ("untriggered", "open", "filled") verified = state in ("untriggered", "open", "filled")
else: else:
verified = state in ("open", "filled") verified = state in ("open", "filled")
notes = "" if verified else f"fill non verificato (state={state}, trades={len(trades)})"
if verified and order_type == "market" and filled < amount - 1e-12:
notes = (f"FILL PARZIALE: {filled} su {amount} richiesti "
"(reduce-only cappato dal netting di conto?)")
return Fill(instrument, side, requested_notional, amount, fill_price, return Fill(instrument, side, requested_notional, amount, fill_price,
fee_coin, fee_usd, order_id, state, verified, raw=resp, fee_coin, fee_usd, order_id, state, verified, raw=resp,
notes="" if verified else f"fill non verificato (state={state}, trades={len(trades)})") notes=notes, filled_amount=filled)
def open(self, instrument: str, side: str, notional_usd: float, def open(self, instrument: str, side: str, notional_usd: float,
label: str | None = None) -> Fill: label: str | None = None) -> Fill:
@@ -255,16 +295,33 @@ class ExecutionClient:
def close_amount(self, instrument: str, entry_side: str, amount: float, def close_amount(self, instrument: str, entry_side: str, amount: float,
label: str | None = None) -> Fill: label: str | None = None) -> Fill:
"""Chiude SOLO la quota del worker: market reduce_only di lato opposto, """Chiude SOLO la quota del worker. Non usa close_position (flatterebbe
stesso `amount` dell'apertura. Non usa close_position (flatterebbe anche anche le quote degli altri worker sullo stesso strumento).
le quote degli altri worker sullo stesso strumento)."""
NETTING (v1.1.25, 2026-06-11): prima tenta il market reduce-only (la
sicurezza storica: un bug di stato filla 0 invece di aprire posizioni).
Su un conto a NETTING pero' il reduce-only viene CAPPATO o RESPINTO quando
un altro worker e' in direzione OPPOSTA sullo stesso strumento (audit
2026-06-11: 3 gambe pairs orfane + 1 close cappato) → il RESIDUO viene
rieseguito in MARKET PURO: muove il conto esattamente del delta del libro,
cioe' netta contro le quote opposte. La sicurezza persa sul residuo e'
coperta dal reconciler orario (reconcile_account.py, alert ACCOUNT_DRIFT).
Il chiamante riceve UN Fill combinato (prezzo medio pesato, fee sommate);
notes contiene 'netting' quando il fallback e' scattato."""
spec = contract_spec(instrument) spec = contract_spec(instrument)
# quantizza difensivamente: il chiamante puo' passare un residuo # quantizza difensivamente: il chiamante puo' passare un residuo
# (amount aperto fill parziale del TP) con artefatti float # (amount aperto fill parziale del TP) con artefatti float
amount = _quantize_step(amount, spec["step"], spec["min"]) if amount > 0 else 0.0 amount = _quantize_step(amount, spec["step"], spec["min"]) if amount > 0 else 0.0
opp = "sell" if entry_side == "buy" else "buy" opp = "sell" if entry_side == "buy" else "buy"
return self._submit(instrument, opp, amount, 0.0, fill = self._submit(instrument, opp, amount, 0.0,
reduce_only=True, label=label) reduce_only=True, label=label)
residual = amount - fill.filled_amount
residual = _quantize_step(residual, spec["step"], spec["min"]) if residual > 0 else 0.0
if residual < spec["step"] / 2:
return fill
net = self._submit(instrument, opp, residual, 0.0, reduce_only=False,
label=f"{label}|net" if label else "net")
return _merge_close_fills(fill, net, amount)
def place_tp_limit(self, instrument: str, entry_side: str, amount: float, def place_tp_limit(self, instrument: str, entry_side: str, amount: float,
tp_price: float, label: str | None = None) -> Fill: tp_price: float, label: str | None = None) -> Fill:
+95 -23
View File
@@ -40,6 +40,7 @@ class PairsWorker:
data_dir: Path = Path("data/paper_trades"), data_dir: Path = Path("data/paper_trades"),
executor=None, # PairsExecutionClient: esecuzione REALE shadow a 2 gambe executor=None, # PairsExecutionClient: esecuzione REALE shadow a 2 gambe
exec_instruments: dict | None = None, # {asset: instrument USDC} exec_instruments: dict | None = None, # {asset: instrument USDC}
real_truth: bool = False,
): ):
self.asset_a = asset_a self.asset_a = asset_a
self.asset_b = asset_b self.asset_b = asset_b
@@ -51,6 +52,11 @@ class PairsWorker:
self.z_exit = float(p.get("z_exit", 0.75)) self.z_exit = float(p.get("z_exit", 0.75))
self.max_bars = int(p.get("max_bars", 72)) self.max_bars = int(p.get("max_bars", 72))
self.jump_max = float(p.get("jump_max", 0.08)) self.jump_max = float(p.get("jump_max", 0.08))
# flat-skip (timeframe sub-orari, es. 15m): non entrare/uscire su candele flat
# (O=H=L=C, prezzo stale/liquidita' zero -> fill non eseguibile). LIVE-REALIZABLE:
# l'uscita arma exit_ready e si esegue alla prima barra PULITA. Parita' col backtest
# pairs_research.pairs_sim_flat(flat_skip=True). Default off = comportamento 1h storico.
self.flat_skip = bool(p.get("flat_skip", False))
self.initial_capital = capital self.initial_capital = capital
self.position_size = position_size self.position_size = position_size
@@ -71,6 +77,7 @@ class PairsWorker:
self.entry_z = 0.0 self.entry_z = 0.0
self.entry_time = "" self.entry_time = ""
self.bars_held = 0 self.bars_held = 0
self.exit_ready = False # flat-skip: condizione di uscita armata, attende barra pulita
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
@@ -82,6 +89,9 @@ class PairsWorker:
self.inst_a = self.exec_instruments.get(asset_a) self.inst_a = self.exec_instruments.get(asset_a)
self.inst_b = self.exec_instruments.get(asset_b) self.inst_b = self.exec_instruments.get(asset_b)
self.execution_enabled = bool(executor and self.inst_a and self.inst_b) self.execution_enabled = bool(executor and self.inst_a and self.inst_b)
# REAL-TRUTH (2026-06-10): come StrategyWorker — `capital` aggiornato dal
# PnL dei fill reali (2 gambe, fee reali); sim solo diagnostica nel log.
self.real_truth = bool(real_truth and self.execution_enabled)
self.real_capital = capital self.real_capital = capital
self.real_in_position = False self.real_in_position = False
self.real_dir = 0 self.real_dir = 0
@@ -96,6 +106,7 @@ class PairsWorker:
self.real_entry_fee = 0.0 self.real_entry_fee = 0.0
self.real_trades = 0 self.real_trades = 0
self.real_first_notified = False self.real_first_notified = False
self.orphan_legs: list[dict] = [] # gambe respinte dal netting (persistite)
self._load_state() self._load_state()
self._save_state() self._save_state()
@@ -117,6 +128,7 @@ class PairsWorker:
self.entry_z = s.get("entry_z", 0.0) self.entry_z = s.get("entry_z", 0.0)
self.entry_time = s.get("entry_time", "") self.entry_time = s.get("entry_time", "")
self.bars_held = s.get("bars_held", 0) self.bars_held = s.get("bars_held", 0)
self.exit_ready = s.get("exit_ready", False)
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)
@@ -135,6 +147,7 @@ class PairsWorker:
self.real_entry_fee = s.get("real_entry_fee", 0.0) self.real_entry_fee = s.get("real_entry_fee", 0.0)
self.real_trades = s.get("real_trades", 0) self.real_trades = s.get("real_trades", 0)
self.real_first_notified = s.get("real_first_notified", False) self.real_first_notified = s.get("real_first_notified", False)
self.orphan_legs = s.get("orphan_legs", [])
self._log("RESUME", {"capital": round(self.capital, 2), self._log("RESUME", {"capital": round(self.capital, 2),
"total_trades": self.total_trades, "in_position": self.in_position, "total_trades": self.total_trades, "in_position": self.in_position,
"real_capital": round(self.real_capital, 2), "real_capital": round(self.real_capital, 2),
@@ -145,7 +158,8 @@ class PairsWorker:
"capital": round(self.capital, 2), "in_position": self.in_position, "capital": round(self.capital, 2), "in_position": self.in_position,
"direction": self.direction, "entry_a": self.entry_a, "entry_b": self.entry_b, "direction": self.direction, "entry_a": self.entry_a, "entry_b": self.entry_b,
"entry_z": round(self.entry_z, 4), "entry_time": self.entry_time, "entry_z": round(self.entry_z, 4), "entry_time": self.entry_time,
"bars_held": self.bars_held, "total_trades": self.total_trades, "bars_held": self.bars_held, "exit_ready": self.exit_ready,
"total_trades": self.total_trades,
"total_wins": self.total_wins, "last_bar_ts": self.last_bar_ts, "total_wins": self.total_wins, "last_bar_ts": self.last_bar_ts,
"started_at": self.started_at, "last_update": datetime.now(timezone.utc).isoformat(), "started_at": self.started_at, "last_update": datetime.now(timezone.utc).isoformat(),
"real_capital": round(self.real_capital, 4), "real_in_position": self.real_in_position, "real_capital": round(self.real_capital, 4), "real_in_position": self.real_in_position,
@@ -155,6 +169,7 @@ class PairsWorker:
"real_notional_a": self.real_notional_a, "real_notional_b": self.real_notional_b, "real_notional_a": self.real_notional_a, "real_notional_b": self.real_notional_b,
"real_entry_fee": self.real_entry_fee, "real_trades": self.real_trades, "real_entry_fee": self.real_entry_fee, "real_trades": self.real_trades,
"real_first_notified": self.real_first_notified, "real_first_notified": self.real_first_notified,
"orphan_legs": self.orphan_legs,
} }
with open(self.status_path, "w") as f: with open(self.status_path, "w") as f:
json.dump(state, f, indent=2) json.dump(state, f, indent=2)
@@ -185,6 +200,7 @@ class PairsWorker:
self.entry_a, self.entry_b, self.entry_z = ca, cb, z self.entry_a, self.entry_b, self.entry_z = ca, cb, z
self.entry_time = datetime.now(timezone.utc).isoformat() self.entry_time = datetime.now(timezone.utc).isoformat()
self.bars_held = 0 self.bars_held = 0
self.exit_ready = False
data = {"direction": "long_ratio" if d == 1 else "short_ratio", data = {"direction": "long_ratio" if d == 1 else "short_ratio",
"long_leg": self.asset_a if d == 1 else self.asset_b, "long_leg": self.asset_a if d == 1 else self.asset_b,
"short_leg": self.asset_b if d == 1 else self.asset_a, "short_leg": self.asset_b if d == 1 else self.asset_a,
@@ -222,31 +238,58 @@ class PairsWorker:
self._notify("REAL_OPEN_FAIL", {**data, "note": pf.notes}) self._notify("REAL_OPEN_FAIL", {**data, "note": pf.notes})
self._save_state() # persisti subito il ledger reale (resume-safe sui crash) self._save_state() # persisti subito il ledger reale (resume-safe sui crash)
def _real_close_pair(self, sim_a: float, sim_b: float, reason: str, sim_pnl: float): def _real_close_pair(self, sim_a: float, sim_b: float, reason: str,
sim_pnl: float) -> tuple[float | None, bool]:
"""Chiusura REALE shadow: richiude entrambe le gambe reduce-only, riconcilia """Chiusura REALE shadow: richiude entrambe le gambe reduce-only, riconcilia
PnL reale (per gamba) e fee, aggiorna il ledger reale parallelo.""" PnL reale (per gamba) e fee, aggiorna il ledger reale parallelo.
Ritorna (real_pnl, applied): applied=True se almeno una gamba ha chiuso con
fill verificato (PnL reale utilizzabile come verita' in real-truth)."""
if not self.real_in_position: if not self.real_in_position:
return return None, False
pf = self.executor.close_pair(self.inst_a, self.inst_b, self.real_side_a, pf = self.executor.close_pair(self.inst_a, self.inst_b, self.real_side_a,
self.real_side_b, self.real_amount_a, self.real_amount_b, self.real_side_b, self.real_amount_a, self.real_amount_b,
label=self.worker_id) label=self.worker_id)
# VERITA' PER-GAMBA (audit 2026-06-11): una gamba puo' essere RESPINTA dal
# netting di conto (reduce-only nel verso sbagliato quando un altro worker e'
# nella direzione opposta sullo stesso strumento). Prima il PnL veniva
# calcolato col prezzo SIM per la gamba mai eseguita e sommato al ledger
# reale (3 PnL fantasma il 2026-06-11, gamba ETH orfana sul conto).
# Ora: si booka SOLO il realizzato delle gambe con fill verificato; la gamba
# respinta diventa un ORFANO registrato (persistito) + alert Telegram.
ok_a, ok_b = bool(pf.leg_a.verified), bool(pf.leg_b.verified)
for leg in (pf.leg_a, pf.leg_b):
if "netting" in (getattr(leg, "notes", "") or ""):
# reduce-only cappato/respinto, residuo in market puro (v1.1.25)
self._log("NET_CLOSE", {"instrument": leg.instrument, "note": leg.notes})
self._notify("NET_CLOSE", {"instrument": leg.instrument, "note": leg.notes})
exit_a = pf.leg_a.fill_price or sim_a exit_a = pf.leg_a.fill_price or sim_a
exit_b = pf.leg_b.fill_price or sim_b exit_b = pf.leg_b.fill_price or sim_b
# PnL per gamba: dir A = +d (long ratio compra A), dir B = -d # PnL per gamba: dir A = +d (long ratio compra A), dir B = -d
da, db = self.real_dir, -self.real_dir da, db = self.real_dir, -self.real_dir
gross = (da * (exit_a - self.real_entry_a) / self.real_entry_a * self.real_notional_a gross_a = da * (exit_a - self.real_entry_a) / self.real_entry_a * self.real_notional_a
+ db * (exit_b - self.real_entry_b) / self.real_entry_b * self.real_notional_b) gross_b = db * (exit_b - self.real_entry_b) / self.real_entry_b * self.real_notional_b
exit_fee = pf.leg_a.fee_usd + pf.leg_b.fee_usd exit_fee = pf.leg_a.fee_usd + pf.leg_b.fee_usd
real_pnl = gross - self.real_entry_fee - exit_fee real_pnl = ((gross_a if ok_a else 0.0) + (gross_b if ok_b else 0.0)
- self.real_entry_fee - exit_fee)
self.real_capital += real_pnl self.real_capital += real_pnl
self.real_trades += 1 self.real_trades += 1
self._log("REAL_CLOSE_PAIR", { self._log("REAL_CLOSE_PAIR", {
"reason": reason, "exit_a": exit_a, "exit_b": exit_b, "reason": reason, "exit_a": exit_a, "exit_b": exit_b,
"leg_a_ok": ok_a, "leg_b_ok": ok_b,
"real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4), "real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4),
"entry_fee": round(self.real_entry_fee, 5), "exit_fee": round(exit_fee, 5), "entry_fee": round(self.real_entry_fee, 5), "exit_fee": round(exit_fee, 5),
"real_capital": round(self.real_capital, 4), "verified": pf.verified}) "real_capital": round(self.real_capital, 4), "verified": pf.verified})
if not pf.verified: for ok, inst, side, amt in ((ok_a, self.inst_a, self.real_side_a, self.real_amount_a),
self._notify("REAL_CLOSE_FAILED", {"worker": self.worker_id, "note": pf.notes}) (ok_b, self.inst_b, self.real_side_b, self.real_amount_b)):
if not ok and amt > 0:
orphan = {"instrument": inst, "entry_side": side, "amount": amt,
"ts": datetime.now(timezone.utc).isoformat(), "reason": reason}
self.orphan_legs.append(orphan)
self._notify("PAIR_LEG_ORPHAN", {
"worker": self.worker_id, **orphan,
"note": ("gamba NON chiusa (reduce-only respinto dal netting di "
"conto?): posizione orfana sul conto, intervento richiesto")})
self.real_in_position = False self.real_in_position = False
self.real_dir = 0 self.real_dir = 0
self.real_side_a = self.real_side_b = "" self.real_side_a = self.real_side_b = ""
@@ -255,6 +298,10 @@ class PairsWorker:
self.real_notional_a = self.real_notional_b = 0.0 self.real_notional_a = self.real_notional_b = 0.0
self.real_entry_fee = 0.0 self.real_entry_fee = 0.0
self._save_state() self._save_state()
# applied (real-truth) SOLO se entrambe le gambe hanno chiuso verificate:
# con una gamba orfana il "PnL reale dello spread" non esiste -> meglio il
# fallback sim DICHIARATO che un numero mezzo-reale
return real_pnl, ok_a and ok_b
def _close(self, ca: float, cb: float, z: float, reason: str): def _close(self, ca: float, cb: float, z: float, reason: str):
if not self.in_position: if not self.in_position:
@@ -264,9 +311,17 @@ class PairsWorker:
gross = (ret_a - ret_b) * self.direction * self.leverage gross = (ret_a - ret_b) * self.direction * self.leverage
fee = 2 * self.fee_rt * self.leverage # 2 gambe fee = 2 * self.fee_rt * self.leverage # 2 gambe
net = gross - fee net = gross - fee
pnl = self.capital * self.position_size * net sim_pnl = self.capital * self.position_size * net
# REAL-TRUTH: chiusura reale PRIMA dell'update ledger (come StrategyWorker)
real_pnl, real_applied = (None, False)
if self.execution_enabled:
real_pnl, real_applied = self._real_close_pair(ca, cb, reason, sim_pnl)
use_real = self.real_truth and real_applied
pnl = real_pnl if use_real else sim_pnl
self.capital = max(self.capital + pnl, 0.0) self.capital = max(self.capital + pnl, 0.0)
is_win = net > 0 is_win = pnl > 0
self.total_trades += 1 self.total_trades += 1
self.total_wins += is_win self.total_wins += is_win
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
@@ -275,9 +330,12 @@ class PairsWorker:
"net_return": round(net * 100, 3), "pnl": round(pnl, 2), "net_return": round(net * 100, 3), "pnl": round(pnl, 2),
"capital": round(self.capital, 2), "bars_held": self.bars_held, "capital": round(self.capital, 2), "bars_held": self.bars_held,
"win": bool(is_win), "total_trades": self.total_trades, "accuracy": round(acc, 1)} "win": bool(is_win), "total_trades": self.total_trades, "accuracy": round(acc, 1)}
if self.real_truth:
data["pnl_source"] = "real" if use_real else "sim_fallback"
data["sim_pnl"] = round(sim_pnl, 2)
if real_pnl is not None:
data["real_pnl"] = round(real_pnl, 4)
self._log("CLOSE", data); self._notify("CLOSED", data) self._log("CLOSE", data); self._notify("CLOSED", data)
if self.execution_enabled:
self._real_close_pair(ca, cb, reason, pnl)
self.in_position = False self.in_position = False
self.direction = 0 self.direction = 0
self.entry_a = self.entry_b = self.entry_z = 0.0 self.entry_a = self.entry_b = self.entry_z = 0.0
@@ -287,9 +345,13 @@ class PairsWorker:
"""Chiamato ad ogni poll con gli OHLCV aggiornati delle due gambe.""" """Chiamato ad ogni poll con gli OHLCV aggiornati delle due gambe."""
if df_a is None or df_b is None or df_a.empty or df_b.empty: if df_a is None or df_b is None or df_a.empty or df_b.empty:
return return
m = df_a[["timestamp", "close"]].rename(columns={"close": "ca"}).merge( # merge OHLC quando disponibile (serve a rilevare le candele flat per il flat-skip);
df_b[["timestamp", "close"]].rename(columns={"close": "cb"}), on="timestamp", how="inner" # se le colonne OHLC mancano, flat resta False -> comportamento close-only invariato.
).sort_values("timestamp").reset_index(drop=True) ohlc = ["open", "high", "low", "close"]
keep_a = ["timestamp"] + [c for c in ohlc if c in df_a.columns]
keep_b = ["timestamp"] + [c for c in ohlc if c in df_b.columns]
m = df_a[keep_a].merge(df_b[keep_b], on="timestamp", how="inner",
suffixes=("_a", "_b")).sort_values("timestamp").reset_index(drop=True)
# Scarta la barra IN FORMAZIONE: entry ED exit valutati SOLO sul close di # Scarta la barra IN FORMAZIONE: entry ED exit valutati SOLO sul close di
# barra COMPLETA, come il backtest (pairs_research: close settled) — # barra COMPLETA, come il backtest (pairs_research: close settled) —
# lezione EXIT-16. Detection condivisa: src.live.bars. # lezione EXIT-16. Detection condivisa: src.live.bars.
@@ -298,7 +360,7 @@ class PairsWorker:
m = m.iloc[:-1] m = m.iloc[:-1]
if len(m) < self.n + 2: if len(m) < self.n + 2:
return return
ca, cb = m["ca"].values, m["cb"].values ca, cb = m["close_a"].values, m["close_b"].values
z, dr = self._zscore(ca, cb) z, dr = self._zscore(ca, cb)
i = len(m) - 1 i = len(m) - 1
cur_ts = int(m["timestamp"].iloc[i]) cur_ts = int(m["timestamp"].iloc[i])
@@ -306,19 +368,29 @@ class PairsWorker:
if np.isnan(zi): if np.isnan(zi):
self._save_state(); return self._save_state(); return
# flat della barra corrente (entrambe le gambe): O=H=L=C in una delle due
flat_i = False
if self.flat_skip and {"open_a", "high_a", "low_a"}.issubset(m.columns) \
and {"open_b", "high_b", "low_b"}.issubset(m.columns):
fa = (m["open_a"].iloc[i] == m["high_a"].iloc[i] == m["low_a"].iloc[i] == ca[i])
fb = (m["open_b"].iloc[i] == m["high_b"].iloc[i] == m["low_b"].iloc[i] == cb[i])
flat_i = bool(fa or fb)
if self.in_position: if self.in_position:
if cur_ts > self.last_bar_ts: if cur_ts > self.last_bar_ts:
self.bars_held += 1 self.bars_held += 1
self.last_bar_ts = cur_ts self.last_bar_ts = cur_ts
if abs(zi) <= self.z_exit: # arma l'uscita: |z|<=z_exit (rientro) o time-limit; poi esegui alla 1a barra pulita
self._close(float(ca[i]), float(cb[i]), float(zi), "mean_revert") if not self.exit_ready and (abs(zi) <= self.z_exit or self.bars_held >= self.max_bars):
elif self.bars_held >= self.max_bars: self.exit_ready = True
self._close(float(ca[i]), float(cb[i]), float(zi), "time_limit") if self.exit_ready and not flat_i:
reason = "mean_revert" if abs(zi) <= self.z_exit else "time_limit"
self._close(float(ca[i]), float(cb[i]), float(zi), reason)
self._save_state() self._save_state()
return return
# flat: cerca ingresso (no look-ahead: z[i] usa solo dati <= i) # cerca ingresso (no look-ahead: z[i] usa solo dati <= i); mai su barra stale
if dr[i] <= self.jump_max: if dr[i] <= self.jump_max and not flat_i:
if zi <= -self.z_in: if zi <= -self.z_in:
self._open(1, float(ca[i]), float(cb[i]), float(zi)); self.last_bar_ts = cur_ts self._open(1, float(ca[i]), float(cb[i]), float(zi)); self.last_bar_ts = cur_ts
elif zi >= self.z_in: elif zi >= self.z_in:
+102 -19
View File
@@ -38,6 +38,7 @@ class StrategyWorker:
data_dir: Path = Path("data/paper_trades"), data_dir: Path = Path("data/paper_trades"),
executor: ExecutionClient | None = None, executor: ExecutionClient | None = None,
exec_instrument: str | None = None, exec_instrument: str | None = None,
real_truth: bool = False,
): ):
self.strategy = strategy self.strategy = strategy
self.asset = asset self.asset = asset
@@ -53,6 +54,11 @@ class StrategyWorker:
self.executor = executor self.executor = executor
self.exec_instrument = exec_instrument self.exec_instrument = exec_instrument
self.execution_enabled = bool(executor and exec_instrument) self.execution_enabled = bool(executor and exec_instrument)
# REAL-TRUTH (2026-06-10): il ledger che guida il portafoglio (`capital`) si
# aggiorna col PnL dei FILL REALI (fee reali incluse); il sim resta solo
# diagnostica nel log CLOSE. Fallback al sim SOLO se il trade reale non e'
# mai esistito/fillato (REAL_OPEN_FAIL, fill zero) — flag pnl_source nel log.
self.real_truth = bool(real_truth and self.execution_enabled)
self.real_capital = capital self.real_capital = capital
self.real_in_position = False self.real_in_position = False
self.real_side = "" # "buy" | "sell" dell'apertura reale self.real_side = "" # "buy" | "sell" dell'apertura reale
@@ -65,6 +71,8 @@ class StrategyWorker:
self.real_dsl_order_id = "" # STOP_MARKET disaster bracket on-book (persistito) self.real_dsl_order_id = "" # STOP_MARKET disaster bracket on-book (persistito)
self.real_trades = 0 self.real_trades = 0
self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum
self._tp_phantom_ts = 0 # dedup log TP_PHANTOM per barra (non persistito)
self._tp_phantom_notified = False # alert Telegram una tantum per processo
self.worker_id = f"{strategy.name}__{asset}__{tf}" self.worker_id = f"{strategy.name}__{asset}__{tf}"
self.work_dir = data_dir / self.worker_id self.work_dir = data_dir / self.worker_id
@@ -241,13 +249,16 @@ class StrategyWorker:
} }
if fill.verified: if fill.verified:
linear = contract_spec(self.exec_instrument).get("linear") linear = contract_spec(self.exec_instrument).get("linear")
# amount FILLATO, non richiesto (audit 2026-06-11): il ledger deve
# seguire i contratti realmente sul conto
real_amt = fill.filled_amount or fill.amount
self.real_in_position = True self.real_in_position = True
self.real_side = side self.real_side = side
self.real_amount = fill.amount self.real_amount = real_amt
self.real_entry_price = fill.fill_price or sim_price self.real_entry_price = fill.fill_price or sim_price
self.real_entry_fee_usd = fill.fee_usd self.real_entry_fee_usd = fill.fee_usd
self.real_entry_notional = (fill.amount * self.real_entry_price self.real_entry_notional = (real_amt * self.real_entry_price
if linear else fill.amount) if linear else real_amt)
self.real_order_id = fill.order_id or "" self.real_order_id = fill.order_id or ""
self._log("REAL_OPEN", data) self._log("REAL_OPEN", data)
if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva
@@ -256,6 +267,7 @@ class StrategyWorker:
if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS: if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS:
# sim e reale stanno tradando prezzi diversi (spike print/feed stantio): # sim e reale stanno tradando prezzi diversi (spike print/feed stantio):
# il sim sta per bookare PnL che il reale non vede # il sim sta per bookare PnL che il reale non vede
self._log("REAL_DIVERGENCE", {"fase": "open", **data})
self._notify("REAL_DIVERGENCE", {"fase": "open", **data}) self._notify("REAL_DIVERGENCE", {"fase": "open", **data})
self._place_real_tp() self._place_real_tp()
self._place_disaster_sl() self._place_disaster_sl()
@@ -316,7 +328,8 @@ class StrategyWorker:
else: else:
self._log("REAL_DSL_FAIL", {**data, "note": rest.notes}) self._log("REAL_DSL_FAIL", {**data, "note": rest.notes})
def _real_close(self, sim_exit: float, reason: str, sim_pnl: float): def _real_close(self, sim_exit: float, reason: str,
sim_pnl: float) -> tuple[float | None, bool]:
"""Chiusura REALE (reduce-only della quota worker) + confronto col sim. """Chiusura REALE (reduce-only della quota worker) + confronto col sim.
Prima riconcilia l'eventuale LIMIT resting al TP: lo cancella (innocuo Prima riconcilia l'eventuale LIMIT resting al TP: lo cancella (innocuo
@@ -324,9 +337,13 @@ class StrategyWorker:
legge i fill reali dal trade history per order_id; solo la quota residua legge i fill reali dal trade history per order_id; solo la quota residua
viene chiusa a mercato (fallback, o exit non-TP: stop-loss/time_limit). viene chiusa a mercato (fallback, o exit non-TP: stop-loss/time_limit).
L'uscita take-profit reale avviene cosi' AL livello come nel backtest, L'uscita take-profit reale avviene cosi' AL livello come nel backtest,
non al poll post-rimbalzo.""" non al poll post-rimbalzo.
Ritorna (real_pnl, applied): applied=True se il PnL reale e' basato su
fill effettivi (o chiusura verificata) e puo' fare da verita' del ledger
in modalita' real-truth; False = nessuna posizione/fill reale."""
if not self.real_in_position: if not self.real_in_position:
return return None, False
from src.live.execution import contract_spec from src.live.execution import contract_spec
step = contract_spec(self.exec_instrument)["step"] step = contract_spec(self.exec_instrument)["step"]
@@ -381,7 +398,22 @@ class StrategyWorker:
if remainder >= step / 2: if remainder >= step / 2:
fill = self.executor.close_amount(self.exec_instrument, self.real_side, fill = self.executor.close_amount(self.exec_instrument, self.real_side,
remainder, label=self.worker_id) remainder, label=self.worker_id)
market_amt = fill.amount if (fill and fill.verified) else 0.0 # amount FILLATO, non richiesto: un reduce-only cappato dal netting di conto
# (worker long e short sullo stesso strumento) filla MENO del chiesto —
# bookarlo pieno mentirebbe sul ledger (audit 2026-06-11: 0.078 vs 0.105)
market_amt = fill.filled_amount if (fill and fill.verified) else 0.0
if fill and "netting" in (getattr(fill, "notes", "") or ""):
# il reduce-only era cappato/respinto e il residuo e' andato in market
# puro (netting contro quote opposte) — solo osservabilita'
self._log("NET_CLOSE", {"note": fill.notes})
self._notify("NET_CLOSE", {"note": fill.notes})
if fill and fill.verified and market_amt < remainder - step / 2:
data = {"requested": remainder, "filled": market_amt,
"residuo_orfano": round(remainder - market_amt, 6),
"note": ("close reduce-only cappato dal netting di conto: quota "
"residua NON chiusa (direzioni opposte sullo strumento?)")}
self._log("REAL_CLOSE_PARTIAL", data)
self._notify("REAL_CLOSE_PARTIAL", data)
# 3) prezzo d'uscita combinato (media pesata TP-fill + market) e fee totali # 3) prezzo d'uscita combinato (media pesata TP-fill + market) e fee totali
parts = [(a, p) for a, p in ((tp_amt, tp_px), parts = [(a, p) for a, p in ((tp_amt, tp_px),
@@ -404,10 +436,13 @@ class StrategyWorker:
slip_bps = ((exit_price / sim_exit - 1) * 1e4 slip_bps = ((exit_price / sim_exit - 1) * 1e4
if exit_price and sim_exit else None) if exit_price and sim_exit else None)
if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS: if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS:
self._notify("REAL_DIVERGENCE", { div = {"fase": "close", "reason": reason, "sim_exit": round(sim_exit, 2),
"fase": "close", "reason": reason, "sim_exit": round(sim_exit, 2),
"real_fill": round(exit_price, 2), "slippage_bps": round(slip_bps, 2), "real_fill": round(exit_price, 2), "slippage_bps": round(slip_bps, 2),
"real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4)}) "real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4)}
# anche su jsonl, non solo Telegram: gli episodi di slippage estremo
# devono restare interrogabili (l'audit 2026-06-11 ha dovuto ricostruirli)
self._log("REAL_DIVERGENCE", div)
self._notify("REAL_DIVERGENCE", div)
self._log("REAL_CLOSE", { self._log("REAL_CLOSE", {
"reason": reason, "reason": reason,
"order_id": fill.order_id if fill else tp_order_id, "order_id": fill.order_id if fill else tp_order_id,
@@ -434,6 +469,43 @@ class StrategyWorker:
self.real_order_id = "" self.real_order_id = ""
self.real_tp_order_id = "" self.real_tp_order_id = ""
self.real_dsl_order_id = "" self.real_dsl_order_id = ""
# applied: fill reali presenti (parts) o chiusura comunque verificata
return real_pnl, bool(parts) or verified
def _tp_phantom(self, current_price: float, current_ts: int) -> bool:
"""TP "toccato" solo nel feed? Il LIMIT resting sul book REALE e' l'oracolo:
se il prezzo avesse davvero scambiato al livello si sarebbe fillato (almeno
in parte). Tocco sim + resting a zero fill + prezzo corrente che NON ha
raggiunto il TP = spike-print del feed (testnet, 2026-06-11: 14 giri fantasma
su ETH, sim +4% l'uno, reale fee/spread l'uno) exit SOPPRESSA, la posizione
continua il suo ciclo normale (SL close-confirm e max_bars restano attivi).
Zero parametri: e' un check di verita' d'esecuzione, non un filtro di
strategia; sui worker senza esecuzione reale ritorna False (parita' storica).
Fail-open: se la query fill fallisce (rete) si chiude come prima."""
if not (self.execution_enabled and self.real_in_position
and self.real_tp_order_id and self.tp):
return False
# prezzo gia' oltre il livello → tocco genuino anche senza fill (gap fra poll)
if (self.direction == 1 and current_price >= self.tp) or \
(self.direction == -1 and current_price <= self.tp):
return False
try:
tp_amt, _, _ = self.executor.resting_fills(self.exec_instrument,
self.real_tp_order_id)
except Exception:
return False
if tp_amt > 0:
return False
if current_ts != self._tp_phantom_ts:
self._tp_phantom_ts = current_ts
data = {"tp": self.tp, "price": current_price, "direction": self.direction,
"tp_order_id": self.real_tp_order_id,
"note": "wick solo nel feed (resting zero-fill, prezzo lontano dal livello) -> exit soppressa"}
self._log("TP_PHANTOM", data)
if not self._tp_phantom_notified:
self._tp_phantom_notified = True
self._notify("TP_PHANTOM", data)
return True
def _close_position(self, current_price: float, reason: str): def _close_position(self, current_price: float, reason: str):
if not self.in_position: if not self.in_position:
@@ -442,9 +514,17 @@ class StrategyWorker:
price_change = (current_price - self.entry_price) / self.entry_price price_change = (current_price - self.entry_price) / self.entry_price
trade_return = price_change * self.direction trade_return = price_change * self.direction
net = trade_return * self.leverage - self.fee_rt * self.leverage net = trade_return * self.leverage - self.fee_rt * self.leverage
pnl = self.capital * self.position_size * net sim_pnl = self.capital * self.position_size * net
is_win = net > 0 # win = profitto NETTO dopo fee (non il lordo trade_return) # REAL-TRUTH: chiusura reale PRIMA dell'update ledger; se i fill reali
# esistono il loro PnL (fee reali incluse) e' la verita' del capitale.
real_pnl, real_applied = (None, False)
if self.execution_enabled:
real_pnl, real_applied = self._real_close(current_price, reason, sim_pnl)
use_real = self.real_truth and real_applied
pnl = real_pnl if use_real else sim_pnl
is_win = pnl > 0 # win = profitto NETTO dopo fee (reali se real-truth)
self.capital += pnl self.capital += pnl
self.capital = max(self.capital, 0) self.capital = max(self.capital, 0)
self.total_trades += 1 self.total_trades += 1
@@ -466,12 +546,15 @@ class StrategyWorker:
"total_trades": self.total_trades, "total_trades": self.total_trades,
"accuracy": round(accuracy, 1), "accuracy": round(accuracy, 1),
} }
if self.real_truth:
# diagnostica: sorgente del PnL applicato + sim a confronto
trade_data["pnl_source"] = "real" if use_real else "sim_fallback"
trade_data["sim_pnl"] = round(sim_pnl, 2)
if real_pnl is not None:
trade_data["real_pnl"] = round(real_pnl, 4)
self._log("CLOSE", trade_data) self._log("CLOSE", trade_data)
self._notify("CLOSED", trade_data) self._notify("CLOSED", trade_data)
if self.execution_enabled:
self._real_close(current_price, reason, pnl)
self.in_position = False self.in_position = False
self.direction = 0 self.direction = 0
self.entry_price = 0 self.entry_price = 0
@@ -525,14 +608,14 @@ class StrategyWorker:
if not np.isfinite(buf): if not np.isfinite(buf):
buf = 0.0 buf = 0.0
if self.direction == 1: if self.direction == 1:
if bar_high >= self.tp: if bar_high >= self.tp and not self._tp_phantom(current_price, current_ts):
self._close_position(self.tp, "take_profit") self._close_position(self.tp, "take_profit")
elif confirm_close < self.sl - buf: elif confirm_close < self.sl - buf:
self._close_position(current_price, "stop_loss") self._close_position(current_price, "stop_loss")
elif self.max_bars and self.bars_held >= self.max_bars: elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit") self._close_position(current_price, "time_limit")
else: else:
if bar_low <= self.tp: if bar_low <= self.tp and not self._tp_phantom(current_price, current_ts):
self._close_position(self.tp, "take_profit") self._close_position(self.tp, "take_profit")
elif confirm_close > self.sl + buf: elif confirm_close > self.sl + buf:
self._close_position(current_price, "stop_loss") self._close_position(current_price, "stop_loss")
@@ -544,14 +627,14 @@ class StrategyWorker:
if self.direction == 1: if self.direction == 1:
if bar_low <= self.sl: if bar_low <= self.sl:
self._close_position(self.sl, "stop_loss") self._close_position(self.sl, "stop_loss")
elif bar_high >= self.tp: elif bar_high >= self.tp and not self._tp_phantom(current_price, current_ts):
self._close_position(self.tp, "take_profit") self._close_position(self.tp, "take_profit")
elif self.max_bars and self.bars_held >= self.max_bars: elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit") self._close_position(current_price, "time_limit")
else: else:
if bar_high >= self.sl: if bar_high >= self.sl:
self._close_position(self.sl, "stop_loss") self._close_position(self.sl, "stop_loss")
elif bar_low <= self.tp: elif bar_low <= self.tp and not self._tp_phantom(current_price, current_ts):
self._close_position(self.tp, "take_profit") self._close_position(self.tp, "take_profit")
elif self.max_bars and self.bars_held >= self.max_bars: elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit") self._close_position(current_price, "time_limit")
+216
View File
@@ -0,0 +1,216 @@
"""CrossSectionalWorker — paper/live worker per XS01 (reversione cross-sectional, 8 asset).
Mirror ESATTO di scripts.strategies.XS01_cross_sectional.xsec_sim: ogni HOLD barre
classifica gli asset per rendimento su LB barre, pesi w = -(ret - media)/gross (market-
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.
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
import json
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
from src.live.telegram_notifier import notify_event
class CrossSectionalWorker:
def __init__(self, universe, tf="1h", params=None, capital=1000.0,
position_size=0.15, leverage=3.0, fee_rt=0.0005,
name="XS01", data_dir=Path("data/portfolio_paper")):
self.universe = list(universe)
p = params or {}
self.lb = int(p.get("lb", 48))
self.hold = int(p.get("hold", 12))
# dispersion-gate (2026-06-10): entra solo se la std cross-section del
# momentum lb supera disp_min — senza dispersione da far rientrare i
# trade sono fee. None = off (parita' col backtest canonico non filtrato).
self.disp_min = p.get("disp_min")
self.tf = tf
self.initial_capital = capital
self.position_size = position_size
self.leverage = leverage
self.fee_rt = fee_rt
self.worker_id = f"{name}__{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.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.books = [self._flat_book(j * self._step) for j in range(self.k)]
self.total_trades = 0
self.total_wins = 0
self.last_bar_ts = 0
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 ----------
def _load(self):
if not self.status_path.exists():
self._log("INIT", {"capital": self.capital, "universe": self.universe,
"lb": self.lb, "hold": self.hold, "tranches": self.k})
return
s = json.loads(self.status_path.read_text())
self.capital = s.get("capital", self.initial_capital)
self.total_trades = s.get("total_trades", 0)
self.total_wins = s.get("total_wins", 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):
self.status_path.write_text(json.dumps({
"capital": round(float(self.capital), 2), "in_position": bool(self.in_position),
"tranches": int(self.k),
"books": [{"weights": {a: round(float(v), 5) for a, v in b["weights"].items()},
"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),
"last_bar_ts": int(self.last_bar_ts),
"last_update": datetime.now(timezone.utc).isoformat(),
}, indent=2))
def _log(self, event, data=None):
entry = {"ts": datetime.now(timezone.utc).isoformat(), "worker": self.worker_id,
"event": event, **(data or {})}
with open(self.trades_path, "a") as f:
f.write(json.dumps(entry, default=str) + "\n")
print(f" [{self.worker_id}] {event}: {json.dumps(data or {}, default=str)[:160]}")
def _notify(self, event, data=None):
notify_event(event, {"worker": self.worker_id, **(data or {})})
# ---------- pannello allineato ----------
def _panel(self, data: dict):
frames = []
for a in self.universe:
df = data.get(a)
if df is None or df.empty:
return None
frames.append(df[["timestamp", "close"]].rename(columns={"close": a}).set_index("timestamp"))
M = pd.concat(frames, axis=1, join="inner").sort_index()
# scarta la barra IN FORMAZIONE (close non settled) — come gli altri worker
from src.live.bars import last_bar_is_forming
ts = M.index.to_numpy()
if len(ts) and last_bar_is_forming(ts):
M = M.iloc[:-1]
return M
# ---------- weights (identici all'engine) ----------
def _weights(self, logC_row, logC_lb_row):
dm = logC_row - logC_lb_row
dm = dm - dm.mean()
w = -dm
gw = np.sum(np.abs(w))
return w / gw if gw > 1e-9 else None
def _close_book(self, b, closes_now, tranche: int):
"""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
for k, a in enumerate(self.universe):
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
net = float(book - 2 * self.fee_rt)
pnl = float(self.capital * self.position_size * self.leverage * net / self.k)
self.capital = max(self.capital + pnl, 10.0)
self.total_trades += 1
self.total_wins += 1 if net > 0 else 0
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
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),
"trades": self.total_trades, "acc": round(acc, 1)})
b["in_position"] = False
b["weights"] = {a: 0.0 for a in self.universe}
def _open_book(self, M, i, b, tranche: int):
cols = list(M.columns)
logC = np.log(M.values)
if self.disp_min is not None:
disp = float(np.nanstd(logC[i] - logC[i - self.lb]))
if disp < float(self.disp_min):
return # regime senza dispersione: skip entry
w = self._weights(logC[i], logC[i - self.lb])
if w is None:
return
closes = M.iloc[i].values
b["weights"] = {a: float(w[cols.index(a)]) for a in self.universe}
b["entry_px"] = {a: float(closes[cols.index(a)]) for a in self.universe}
b["bars_held"] = 0
b["in_position"] = True
self._log("OPEN", {"tranche": tranche,
"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)})
# ---------- tick ----------
def tick(self, data: dict):
M = self._panel(data)
if M is None or len(M) < self.lb + 1: # serve close[i] e close[i-lb] -> lb+1 barre
return
i = len(M) - 1
cur_ts = int(M.index[i])
new_bar = cur_ts > self.last_bar_ts
for j, b in enumerate(self.books):
if b["in_position"]:
if new_bar:
b["bars_held"] += 1
# esce dopo HOLD barre; NON rientra nello stesso tick -> entry-to-entry = hold+1
if b["bars_held"] >= self.hold:
self._close_book(b, M.iloc[i].values, j)
elif b["wait"] > 0:
if new_bar:
b["wait"] -= 1 # sfasamento iniziale della tranche
else:
self._open_book(M, i, b, j) # entra al bar corrente (i = lb alla prima volta)
# 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()
@property
def status_summary(self) -> str:
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
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}"
+90 -18
View File
@@ -22,6 +22,7 @@ from src.live.pairs_worker import PairsWorker
from src.live.basket_trend_worker import BasketTrendWorker from src.live.basket_trend_worker import BasketTrendWorker
from src.live.rotation_worker import RotationWorker from src.live.rotation_worker import RotationWorker
from src.live.tsmom_worker import TsmomWorker from src.live.tsmom_worker import TsmomWorker
from src.live.xsec_worker import CrossSectionalWorker
from src.live.strategy_loader import load_strategy from src.live.strategy_loader import load_strategy
# Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/ (worker single/ml) # Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/ (worker single/ml)
@@ -30,38 +31,47 @@ _STRAT_MODULE = {
"MR07": "MR07_return_reversal", "SH01": "SH01_shape_ml", "MR07": "MR07_return_reversal", "SH01": "SH01_shape_ml",
"DIP01": "DIP01_dip_buy", "DIP01": "DIP01_dip_buy",
} }
_MULTI_KINDS = ("basket", "rotation", "tsmom") _MULTI_KINDS = ("basket", "rotation", "tsmom", "xsec")
DATA_DIR = Path("data/portfolio_paper") DATA_DIR = Path("data/portfolio_paper")
# giorni di storia da fetchare per timeframe (TSM01 1d usa 252 barre -> ~440 giorni col buffer) # giorni di storia da fetchare per timeframe (TSM01 1d usa 252 barre -> ~440 giorni col buffer)
_LOOKBACK_DAYS = {"1h": 90, "4h": 220, "1d": 440} _LOOKBACK_DAYS = {"5m": 7, "15m": 14, "30m": 21, "1h": 90, "4h": 220, "1d": 440}
# timeframe SUB-orari: si fetchano DIRETTI da Cerbero (non resamplabili dal 1h).
_SUBHOURLY = {"5m", "15m", "30m"}
# SH01 (ml) richiede >=4000 barre 1h (train_min di ml_wf_entries); 365g (~8760 barre) danno # SH01 (ml) richiede >=4000 barre 1h (train_min di ml_wf_entries); 365g (~8760 barre) danno
# margine ampio per il walk-forward. Difensivo: non dipende dal fetch 440g di TSM01/ROT02. # margine ampio per il walk-forward. Difensivo: non dipende dal fetch 440g di TSM01/ROT02.
_ML_LOOKBACK_DAYS = 365 _ML_LOOKBACK_DAYS = 365
def pos_for_spec(sid: str, global_ps: float, family_overrides: dict[str, float]) -> float: def pos_for_spec(sid: str, global_ps: float, family_overrides: dict[str, float],
"""position_size effettivo di uno sleeve: override per-famiglia (chiave = sleeve_ps: float | None = None) -> float:
weighting.family_of: PAIRS/FADE/HONEST/SHAPE/TSM) o globale.""" """position_size effettivo di uno sleeve. Precedenza: override PER-SLEEVE
(spec.params['position_size'], es. il 15m a 0.10) > override per-FAMIGLIA
(weighting.family_of: PAIRS/FADE/...) > globale."""
from src.portfolio.weighting import family_of from src.portfolio.weighting import family_of
if sleeve_ps is not None:
return float(sleeve_ps)
return family_overrides.get(family_of(sid), global_ps) return family_overrides.get(family_of(sid), global_ps)
def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float, def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
data_dir: Path = DATA_DIR, position_size: float = 0.15, data_dir: Path = DATA_DIR, position_size: float = 0.15,
executor=None, exec_instrument: str | None = None, executor=None, exec_instrument: str | None = None,
pairs_executor=None, exec_instruments: dict | None = None): pairs_executor=None, exec_instruments: dict | None = None,
real_truth: bool = False):
"""Costruisce il worker esecutore per uno sleeve con capitale = quota allocata. """Costruisce il worker esecutore per uno sleeve con capitale = quota allocata.
executor/exec_instrument: per i fade single-leg, StrategyWorker affianca al fill sim executor/exec_instrument: per i fade single-leg, StrategyWorker affianca al fill sim
un ordine REALE (shadow). pairs_executor/exec_instruments: idem per i PairsWorker un ordine REALE (shadow). pairs_executor/exec_instruments: idem per i PairsWorker
(esecuzione reale a 2 gambe).""" (esecuzione reale a 2 gambe). real_truth: il ledger `capital` si aggiorna col PnL
dei FILL REALI (sim ridotto a diagnostica) inerte senza executor."""
if spec.kind == "pairs": if spec.kind == "pairs":
return PairsWorker( return PairsWorker(
asset_a=spec.a, asset_b=spec.b, tf=spec.tf, params=spec.params, asset_a=spec.a, asset_b=spec.b, tf=spec.tf, params=spec.params,
capital=alloc_capital, position_size=position_size, leverage=leverage, capital=alloc_capital, position_size=position_size, leverage=leverage,
fee_rt=0.001, name="PR01_pairs_reversion", data_dir=data_dir, fee_rt=0.001, name="PR01_pairs_reversion", data_dir=data_dir,
executor=pairs_executor, exec_instruments=exec_instruments, executor=pairs_executor, exec_instruments=exec_instruments,
real_truth=real_truth,
) )
if spec.kind == "basket": if spec.kind == "basket":
pr = spec.params pr = spec.params
@@ -82,6 +92,16 @@ def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
thr=pr.get("thr", 1.0), gross=pr.get("gross", 0.30), thr=pr.get("thr", 1.0), gross=pr.get("gross", 0.30),
tf=pr.get("tf", "1d"), capital=alloc_capital, data_dir=data_dir, tf=pr.get("tf", "1d"), capital=alloc_capital, data_dir=data_dir,
) )
if spec.kind == "xsec":
pr = spec.params
return CrossSectionalWorker(
universe=pr["universe"], tf=pr.get("tf", "1h"),
params={"lb": pr.get("lb", 48), "hold": pr.get("hold", 12),
"disp_min": pr.get("disp_min"),
"tranches": pr.get("tranches", 1)},
capital=alloc_capital, position_size=position_size, leverage=leverage,
data_dir=data_dir,
)
module = _STRAT_MODULE.get(spec.name) module = _STRAT_MODULE.get(spec.name)
if module is None: if module is None:
raise ValueError(f"sleeve live non supportato: {spec.name} (kind={spec.kind})") raise ValueError(f"sleeve live non supportato: {spec.name} (kind={spec.kind})")
@@ -95,7 +115,7 @@ def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
return StrategyWorker( return StrategyWorker(
strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital, strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital,
position_size=position_size, leverage=leverage, params=spec.params, data_dir=data_dir, position_size=position_size, leverage=leverage, params=spec.params, data_dir=data_dir,
executor=executor, exec_instrument=exec_instrument, executor=executor, exec_instrument=exec_instrument, real_truth=real_truth,
) )
@@ -262,6 +282,9 @@ def run(config_path: str = "portfolios.yml"):
exec_sleeves = set(_exec_cfg.get("sleeves", [])) exec_sleeves = set(_exec_cfg.get("sleeves", []))
exec_instr = _exec_cfg.get("instruments", {}) or {} exec_instr = _exec_cfg.get("instruments", {}) or {}
pairs_exec_enabled = bool(_exec_cfg.get("pairs_enabled")) # esecuzione reale 2 gambe pairs_exec_enabled = bool(_exec_cfg.get("pairs_enabled")) # esecuzione reale 2 gambe
# REAL-TRUTH (2026-06-10): il capitale degli sleeve eseguiti si aggiorna col PnL
# dei fill reali (sim = diagnostica). Default False: va acceso esplicitamente in yml.
real_truth = bool(_exec_cfg.get("real_truth", False))
executor = None executor = None
pairs_executor = None pairs_executor = None
if exec_enabled: if exec_enabled:
@@ -270,7 +293,8 @@ def run(config_path: str = "portfolios.yml"):
# disaster-bracket on-book (~-30%): assicurazione outage sui fade reali # disaster-bracket on-book (~-30%): assicurazione outage sui fade reali
executor.disaster_sl_pct = float(_exec_cfg.get("disaster_sl_pct", 0.30) or 0) or None executor.disaster_sl_pct = float(_exec_cfg.get("disaster_sl_pct", 0.30) or 0) or None
print(f"[runner] ESECUZIONE REALE attiva (shadow) — sleeve={sorted(exec_sleeves)} " print(f"[runner] ESECUZIONE REALE attiva (shadow) — sleeve={sorted(exec_sleeves)} "
f"strumenti={exec_instr} disaster_sl={executor.disaster_sl_pct}") f"strumenti={exec_instr} disaster_sl={executor.disaster_sl_pct} "
f"real_truth={real_truth}")
if pairs_exec_enabled: if pairs_exec_enabled:
from src.live.execution import PairsExecutionClient from src.live.execution import PairsExecutionClient
pairs_executor = PairsExecutionClient(leg=executor) pairs_executor = PairsExecutionClient(leg=executor)
@@ -301,9 +325,10 @@ def run(config_path: str = "portfolios.yml"):
ex, inst = _exec_for(s) ex, inst = _exec_for(s)
pex, pinst = _pairs_exec_for(s) pex, pinst = _pairs_exec_for(s)
workers[s.sid] = build_worker_for(s, alloc[s.sid], p.leverage, workers[s.sid] = build_worker_for(s, alloc[s.sid], p.leverage,
position_size=pos_for_spec(s.sid, position_size, ps_family), position_size=pos_for_spec(s.sid, position_size, ps_family, s.params.get("position_size")),
executor=ex, exec_instrument=inst, executor=ex, exec_instrument=inst,
pairs_executor=pex, exec_instruments=pinst) pairs_executor=pex, exec_instruments=pinst,
real_truth=real_truth)
if ps_family: if ps_family:
print(f"[runner] position_size globale={position_size} override famiglia={ps_family}") print(f"[runner] position_size globale={position_size} override famiglia={ps_family}")
@@ -312,7 +337,7 @@ def run(config_path: str = "portfolios.yml"):
paper_dir = DATA_DIR.parent / "portfolio_paper_stats" paper_dir = DATA_DIR.parent / "portfolio_paper_stats"
paper_workers = {s.sid: build_worker_for(s, paper_notional, p.leverage, paper_workers = {s.sid: build_worker_for(s, paper_notional, p.leverage,
data_dir=paper_dir, data_dir=paper_dir,
position_size=pos_for_spec(s.sid, position_size, ps_family)) position_size=pos_for_spec(s.sid, position_size, ps_family, s.params.get("position_size")))
for s in paper_specs} for s in paper_specs}
# bootstrap storia full per gli sleeve ML (SH01): parquet locale + feed live. # bootstrap storia full per gli sleeve ML (SH01): parquet locale + feed live.
@@ -332,7 +357,7 @@ def run(config_path: str = "portfolios.yml"):
# lookback (giorni) richiesto per ogni asset = max sui worker che lo usano # lookback (giorni) richiesto per ogni asset = max sui worker che lo usano
asset_days: dict[str, int] = {} asset_days: dict[str, int] = {}
for s in live_specs: for s in supported: # live + PAPER (anche XS01/TR01/ROT02/TSM01)
assets, tf = _spec_assets_tf(s) assets, tf = _spec_assets_tf(s)
days = _LOOKBACK_DAYS.get(tf, 90) days = _LOOKBACK_DAYS.get(tf, 90)
if s.kind == "ml": # SH01 ha bisogno di molta storia 1h if s.kind == "ml": # SH01 ha bisogno di molta storia 1h
@@ -340,6 +365,18 @@ def run(config_path: str = "portfolios.yml"):
for a in assets: for a in assets:
asset_days[a] = max(asset_days.get(a, 0), days) asset_days[a] = max(asset_days.get(a, 0), days)
# timeframe SUB-orari (es. pairs 15m, flat-skip): non resamplabili dal 1h ->
# fetch DIRETTO da Cerbero per (asset, tf). Inerte se nessuno sleeve e' sub-orario.
subhourly_needs: dict[tuple[str, str], int] = {}
for s in supported: # live + paper
assets, tf = _spec_assets_tf(s)
if tf in _SUBHOURLY:
for a in assets:
subhourly_needs[(a, tf)] = max(subhourly_needs.get((a, tf), 0),
_LOOKBACK_DAYS.get(tf, 14))
if subhourly_needs:
print(f"[runner] timeframe sub-orari (fetch diretto Cerbero): {sorted(subhourly_needs)}")
inst_map = dict(INSTRUMENT_MAP) inst_map = dict(INSTRUMENT_MAP)
last_day = "" last_day = ""
stale_alerted: set[str] = set() # asset con alert STALE_FEED attivo (dedup per episodio) stale_alerted: set[str] = set() # asset con alert STALE_FEED attivo (dedup per episodio)
@@ -350,6 +387,7 @@ def run(config_path: str = "portfolios.yml"):
# non solleva eccezione ma i worker dell'asset mancante saltano il tick in silenzio. # non solleva eccezione ma i worker dell'asset mancante saltano il tick in silenzio.
_OUTAGE_POLLS = 5 _OUTAGE_POLLS = 5
fail_streak = 0 fail_streak = 0
worker_err_streak: dict[str, int] = {} # errori consecutivi per worker (isolamento tick)
def _outage_tick(failed: bool, streak: int, detail: str = "") -> int: def _outage_tick(failed: bool, streak: int, detail: str = "") -> int:
"""Aggiorna lo streak e gestisce gli alert FEED_OUTAGE (start a soglia, una """Aggiorna lo streak e gestisce gli alert FEED_OUTAGE (start a soglia, una
@@ -394,12 +432,30 @@ def run(config_path: str = "portfolios.yml"):
raw1h[asset] = df.sort_values("timestamp").reset_index(drop=True) raw1h[asset] = df.sort_values("timestamp").reset_index(drop=True)
_check_stale_feed(asset, raw1h[asset], stale_alerted) _check_stale_feed(asset, raw1h[asset], stale_alerted)
# tick di ogni worker col suo timeframe (resample dal 1h) # fetch DIRETTO dei timeframe sub-orari (15m...) per (asset, tf)
raw_sub: dict[tuple[str, str], pd.DataFrame] = {}
for (asset, tf), days in subhourly_needs.items():
inst = inst_map.get(asset, f"{asset}-PERPETUAL")
start = end - timedelta(days=days)
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), tf)
if candles:
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
raw_sub[(asset, tf)] = df.sort_values("timestamp").reset_index(drop=True)
def _series_for(a, tf):
"""Serie OHLC per (asset, tf): diretta se sub-oraria, altrimenti resample dal 1h."""
if tf in _SUBHOURLY:
return raw_sub.get((a, tf))
return _resample(raw1h[a], tf) if a in raw1h else None
# tick di ogni worker col suo timeframe
def _tick(s, w): def _tick(s, w):
assets, tf = _spec_assets_tf(s) assets, tf = _spec_assets_tf(s)
if any(a not in raw1h for a in assets): res = {a: _series_for(a, tf) for a in assets}
if any(res[a] is None or len(res[a]) == 0 for a in assets):
return return
res = {a: _resample(raw1h[a], tf) for a in assets}
if s.kind == "pairs": if s.kind == "pairs":
w.tick(res[s.a], res[s.b]) w.tick(res[s.a], res[s.b])
elif s.kind in _MULTI_KINDS: elif s.kind in _MULTI_KINDS:
@@ -413,11 +469,27 @@ def run(config_path: str = "portfolios.yml"):
# single (fade/dip): StrategyWorker su feed live. # single (fade/dip): StrategyWorker su feed live.
w.tick(res[s.asset]) w.tick(res[s.asset])
# isolamento per-worker (audit 2026-06-11): un'eccezione in un tick NON
# deve saltare gli altri worker (= exit non valutati per tutti) ne'
# l'update dell'equity. Streak per-worker -> alert dopo 5 fail di fila.
def _tick_safe(s, w):
try:
_tick(s, w)
worker_err_streak.pop(s.sid, None)
except Exception as e:
n = worker_err_streak.get(s.sid, 0) + 1
worker_err_streak[s.sid] = n
print(f"[runner] errore worker {s.sid}: {e} (streak {n}; gli altri proseguono)")
if n == 5:
from src.live.telegram_notifier import notify_event
notify_event("WORKER_ERROR_STREAK",
{"worker": s.sid, "streak": n, "errore": str(e)[:200]})
for s in live_specs: for s in live_specs:
_tick(s, workers[s.sid]) _tick_safe(s, workers[s.sid])
# PAPER: ticcati per statistica, MAI nel ledger del portafoglio # PAPER: ticcati per statistica, MAI nel ledger del portafoglio
for s in paper_specs: for s in paper_specs:
_tick(s, paper_workers[s.sid]) _tick_safe(s, paper_workers[s.sid])
ledger.update_equity({sid: _worker_equity(wk) for sid, wk in workers.items()}) ledger.update_equity({sid: _worker_equity(wk) for sid, wk in workers.items()})
+1 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import numpy as np import numpy as np
import pandas as pd import pandas as pd
_PREFIX = [("PR_", "PAIRS"), ("SH_", "SHAPE"), ("TSM", "TSM"), ("MR", "FADE")] _PREFIX = [("PR_", "PAIRS"), ("SH_", "SHAPE"), ("TSM", "TSM"), ("MR", "FADE"), ("XS", "XSEC")]
def family_of(sleeve_id: str) -> str: def family_of(sleeve_id: str) -> str:
+7 -3
View File
@@ -8,6 +8,10 @@ def test_port06_cap_backtest_numbers_locked():
# Aggiornato 2026-05-31: il recupero dati BNB/DOGE/XRP (29 mag) ha ampliato la # Aggiornato 2026-05-31: il recupero dati BNB/DOGE/XRP (29 mag) ha ampliato la
# copertura storica -> metriche migliorate (Sharpe 6.07->6.47, OOS 8.19->8.82, # copertura storica -> metriche migliorate (Sharpe 6.07->6.47, OOS 8.19->8.82,
# DD 4.9%->4.1%). Nuovo baseline atteso, non una regressione. # DD 4.9%->4.1%). Nuovo baseline atteso, non una regressione.
assert r.full["sharpe"] == pytest.approx(6.47, abs=0.15) # Aggiornato 2026-06-09: aggiunto lo sleeve BLEND PR_ETHBTC_15M (ETH/BTC pairs 15m
assert r.oos["sharpe"] == pytest.approx(8.82, abs=0.25) # flat-skip, mezza size) -> FULL 6.47->7.20, OOS 8.82->9.66, DD 4.1%->3.7%.
assert r.full["dd"] == pytest.approx(4.1, abs=0.5) # Aggiornato 2026-06-09 (2): + XS01 (reversione cross-sectional 8 asset, PAPER) ->
# FULL 7.20->7.34, OOS 9.66->10.07, FULL DD 3.68->3.46 (OOS DD +0.17pp).
assert r.full["sharpe"] == pytest.approx(7.34, abs=0.15)
assert r.oos["sharpe"] == pytest.approx(10.07, abs=0.25)
assert r.full["dd"] == pytest.approx(3.46, abs=0.5)
+3 -2
View File
@@ -8,8 +8,9 @@ def test_six_portfolios_defined():
def test_port06_is_master_shape_cap(): def test_port06_is_master_shape_cap():
p = PORTFOLIOS["PORT06"] p = PORTFOLIOS["PORT06"]
sids = set(p.sleeve_ids) sids = set(p.sleeve_ids)
assert {"SH_BTC", "SH_ETH", "TSM01", "PR_ETHBTC"} <= sids assert {"SH_BTC", "SH_ETH", "TSM01", "PR_ETHBTC", "PR_ETHBTC_15M", "XS01"} <= sids
assert len(sids) == 17 # 19 dal 2026-06-09: + XS01 (reversione cross-sectional 8 asset, sleeve PAPER, family XSEC)
assert len(sids) == 19
# SHAPE cappata a 0.0588 (2026-06-05): SH01 senza SL by-design, esposizione dimezzata # SHAPE cappata a 0.0588 (2026-06-05): SH01 senza SL by-design, esposizione dimezzata
# (ricerca sh01_exit_lab: 11 famiglie di stop, 0 sopravvissute) # (ricerca sh01_exit_lab: 11 famiglie di stop, 0 sopravvissute)
assert p.weighting == "cap" and p.caps == {"PAIRS": 0.33, "SHAPE": 0.0588} assert p.weighting == "cap" and p.caps == {"PAIRS": 0.33, "SHAPE": 0.0588}
+82
View File
@@ -0,0 +1,82 @@
"""Netting delle chiusure market (v1.1.25): close_amount tenta il reduce-only e
riesegue il RESIDUO in market puro quando il netting di conto lo cappa/respinge
(audit 2026-06-11: 3 gambe pairs orfane + 1 close cappato). Il chiamante riceve
UN Fill combinato. Nessuna rete: _submit monkeypatchato."""
from src.live.execution import ExecutionClient, Fill
def _fill(inst, side, amount, filled, px, verified=True, state="filled", fee=0.05):
return Fill(inst, side, 0.0, amount, px if filled else None, 0.0, fee,
f"oid-{side}-{filled}", state, verified, filled_amount=filled)
def _client_with(submits):
"""ExecutionClient con _submit finto: consuma la lista `submits` e registra le chiamate."""
ec = ExecutionClient()
calls = []
def fake_submit(instrument, side, amount, notional, reduce_only, label=None,
order_type="market", price=None):
calls.append(dict(amount=amount, reduce_only=reduce_only, label=label))
return submits.pop(0)(instrument, side, amount)
ec._submit = fake_submit
return ec, calls
INST = "ETH_USDC-PERPETUAL"
def test_full_reduce_only_no_fallback():
# caso normale: il reduce-only filla tutto -> NESSUN secondo ordine
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=a, px=1700.0),
])
f = ec.close_amount(INST, "sell", 0.103, label="w1")
assert len(calls) == 1 and calls[0]["reduce_only"] is True
assert f.verified and abs(f.filled_amount - 0.103) < 1e-9
assert "netting" not in (f.notes or "")
def test_capped_reduce_only_nets_residual():
# reduce-only CAPPATO (filla 0.078 su 0.105) -> residuo 0.027 in market puro
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=0.078, px=1700.0),
lambda i, s, a: _fill(i, s, a, filled=a, px=1702.0),
])
f = ec.close_amount(INST, "sell", 0.105, label="w1")
assert len(calls) == 2
assert calls[0]["reduce_only"] is True and calls[1]["reduce_only"] is False
assert abs(calls[1]["amount"] - 0.027) < 1e-9
assert calls[1]["label"] == "w1|net"
assert f.verified and abs(f.filled_amount - 0.105) < 1e-9
# prezzo medio pesato: (0.078*1700 + 0.027*1702) / 0.105
exp_px = (0.078 * 1700.0 + 0.027 * 1702.0) / 0.105
assert abs(f.fill_price - exp_px) < 1e-6
assert abs(f.fee_usd - 0.10) < 1e-9 # fee sommate
assert "netting" in f.notes
def test_rejected_reduce_only_nets_full_amount():
# reduce-only RESPINTO (error, 0 fill) -> tutto l'amount in market puro
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=0.0, px=None, verified=False, state="error", fee=0.0),
lambda i, s, a: _fill(i, s, a, filled=a, px=1701.0),
])
f = ec.close_amount(INST, "buy", 0.027, label="pair")
assert len(calls) == 2 and abs(calls[1]["amount"] - 0.027) < 1e-9
assert f.verified and abs(f.filled_amount - 0.027) < 1e-9
assert f.fill_price == 1701.0
assert "netting" in f.notes
def test_net_leg_also_fails_reports_partial():
# anche il market puro fallisce -> Fill combinato NON verified, filled = solo r-o
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=0.05, px=1700.0),
lambda i, s, a: _fill(i, s, a, filled=0.0, px=None, verified=False, state="error", fee=0.0),
])
f = ec.close_amount(INST, "sell", 0.105, label="w1")
assert len(calls) == 2
assert not f.verified
assert abs(f.filled_amount - 0.05) < 1e-9 # solo il fillato vero nel ledger
+3 -2
View File
@@ -28,7 +28,7 @@ class FakeExec:
def close_amount(self, instrument, side, amount, label=None): def close_amount(self, instrument, side, amount, label=None):
return Fill(instrument, "sell", 0.0, amount, 100.0, 0.0, 0.0, return Fill(instrument, "sell", 0.0, amount, 100.0, 0.0, 0.0,
"oid-close", "filled", True) "oid-close", "filled", True, filled_amount=amount)
class FakeFullExec(FakeExec): class FakeFullExec(FakeExec):
@@ -53,7 +53,8 @@ class FakeFullExec(FakeExec):
def close_amount(self, instrument, side, amount, label=None): def close_amount(self, instrument, side, amount, label=None):
self.close_amounts.append(amount) self.close_amounts.append(amount)
return Fill(instrument, "sell", 0.0, amount, 105.0, 0.0, 0.05, "oid-close", "filled", True) return Fill(instrument, "sell", 0.0, amount, 105.0, 0.0, 0.05, "oid-close",
"filled", True, filled_amount=amount)
def test_sh01_real_open_close_no_tp(tmp_path, monkeypatch): def test_sh01_real_open_close_no_tp(tmp_path, monkeypatch):
+238
View File
@@ -0,0 +1,238 @@
"""REAL-TRUTH (2026-06-10): col flag attivo il ledger `capital` dei worker eseguiti
si aggiorna col PnL dei FILL REALI (fee reali incluse); il sim resta diagnostica nel
log CLOSE (pnl_source/sim_pnl/real_pnl). Fallback dichiarato al sim solo se il trade
reale non e' mai esistito/fillato. Executor finti, nessuna rete."""
import json
from types import SimpleNamespace
from src.live.execution import Fill, PairFill
from src.live.strategy_worker import StrategyWorker
from src.live.pairs_worker import PairsWorker
# ---------------- helpers ----------------
class FakeExec:
"""Single-leg: open filla a `open_px`, close a `close_px` (fee fisse)."""
verify_polls = 1
verify_sleep = 0.0
disaster_sl_pct = None
def __init__(self, open_px=100.0, close_px=100.0, close_verified=True):
self.open_px = open_px
self.close_px = close_px
self.close_verified = close_verified
def open(self, instrument, side, notional, label=None):
amt = round(notional / self.open_px, 6)
return Fill(instrument, side, notional, amt, self.open_px, 0.0, 0.05,
"oid-open", "filled", True, filled_amount=amt)
def place_tp_limit(self, *a, **k):
return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False)
def place_disaster_sl(self, *a, **k):
return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False)
def cancel_order(self, oid):
return {"state": "cancelled"}
def resting_fills(self, instrument, oid):
return 0.0, None, 0.0
def close_amount(self, instrument, side, amount, label=None):
return Fill(instrument, "sell" if side == "buy" else "buy", 0.0, amount,
self.close_px, 0.0, 0.05, "oid-close", "filled", self.close_verified,
filled_amount=amount)
def _sw(tmp_path, fake, real_truth=True):
w = StrategyWorker(strategy=SimpleNamespace(name="FAKE", fee_rt=0.001),
asset="BTC", tf="1h", capital=100.0, position_size=0.5,
leverage=2.0, data_dir=tmp_path, executor=fake,
exec_instrument="BTC_USDC-PERPETUAL", real_truth=real_truth)
w._notify = lambda *a, **k: None
return w
def _last_close(w):
rows = [json.loads(l) for l in w.trades_path.read_text().strip().splitlines()]
return [r for r in rows if r.get("event") == "CLOSE"][-1]
def _open_sim(w, price=100.0):
from src.strategies.base import Signal
w._open_position(Signal(idx=0, direction=1, entry_price=price,
metadata={"max_bars": 12}), price)
# ---------------- StrategyWorker ----------------
def test_capital_updates_from_real_fills(tmp_path):
"""Fill reali 100->105: capital += real_pnl (gross sul notional reale - fee reali),
NON il sim pnl."""
fake = FakeExec(open_px=100.0, close_px=105.0)
w = _sw(tmp_path, fake)
_open_sim(w, 100.0)
assert w.real_in_position
notional = w.real_entry_notional # = capital*ps*lev = 100
w._close_position(105.0, "time_limit")
real_pnl = 0.05 * notional - 0.10 # +5% su notional - fee 2x0.05
assert abs(w.capital - (100.0 + real_pnl)) < 1e-6
c = _last_close(w)
assert c["pnl_source"] == "real"
assert abs(c["real_pnl"] - real_pnl) < 1e-3
assert c["win"] is True and w.total_wins == 1
def test_real_loss_counts_as_loss_even_if_sim_wins(tmp_path):
"""Divergenza sim/reale: il sim vede +5% (exit sim 105) ma il fill reale e' 99
-> in real-truth il trade e' LOSS e il capitale scende."""
fake = FakeExec(open_px=100.0, close_px=99.0)
w = _sw(tmp_path, fake)
_open_sim(w, 100.0)
w._close_position(105.0, "take_profit") # sim esce a 105 (win sim)
c = _last_close(w)
assert c["pnl_source"] == "real"
assert c["real_pnl"] < 0 and c["win"] is False
assert w.capital < 100.0
assert c["sim_pnl"] > 0 # il sim avrebbe bookato un win
def test_fallback_sim_when_real_open_failed(tmp_path):
"""REAL_OPEN_FAIL (nessuna posizione reale): il ledger usa il sim, con flag."""
class FailOpen(FakeExec):
def open(self, instrument, side, notional, label=None):
return Fill(instrument, side, notional, 0.0, None, 0.0, 0.0,
None, "error", False, notes="boom")
w = _sw(tmp_path, FailOpen())
_open_sim(w, 100.0)
assert not w.real_in_position
w._close_position(105.0, "time_limit")
c = _last_close(w)
assert c["pnl_source"] == "sim_fallback"
assert c["pnl"] == c["sim_pnl"]
assert w.capital > 100.0 # sim pnl applicato
def test_shadow_mode_unchanged_without_flag(tmp_path):
"""real_truth=False (default storico): capital segue il SIM, real_capital il reale."""
fake = FakeExec(open_px=100.0, close_px=99.0)
w = _sw(tmp_path, fake, real_truth=False)
_open_sim(w, 100.0)
w._close_position(105.0, "take_profit")
c = _last_close(w)
assert "pnl_source" not in c
assert w.capital > 100.0 # sim win applicato al ledger
assert w.real_capital < 100.0 # reale in perdita, separato
# ---------------- PairsWorker ----------------
class FakePairsExec:
def __init__(self, open_a=100.0, open_b=50.0, close_a=102.0, close_b=50.0):
self.px = dict(open_a=open_a, open_b=open_b, close_a=close_a, close_b=close_b)
def _fill(self, inst, side, amount, px):
return Fill(inst, side, 0.0, amount, px, 0.0, 0.02, "oid", "filled", True)
def open_pair(self, inst_a, inst_b, direction, notional, label=None):
sa = "buy" if direction == 1 else "sell"
sb = "sell" if direction == 1 else "buy"
return PairFill(True,
self._fill(inst_a, sa, notional / self.px["open_a"], self.px["open_a"]),
self._fill(inst_b, sb, notional / self.px["open_b"], self.px["open_b"]))
def close_pair(self, inst_a, inst_b, side_a, side_b, amount_a, amount_b, label=None):
return PairFill(True,
self._fill(inst_a, "sell", amount_a, self.px["close_a"]),
self._fill(inst_b, "buy", amount_b, self.px["close_b"]))
def _pw(tmp_path, fake, real_truth=True):
w = PairsWorker(asset_a="ETH", asset_b="BTC", tf="1h", capital=100.0,
position_size=0.2, leverage=2.0, data_dir=tmp_path,
executor=fake, exec_instruments={"ETH": "ETH_USDC-PERPETUAL",
"BTC": "BTC_USDC-PERPETUAL"},
real_truth=real_truth)
w._notify = lambda *a, **k: None
return w
def test_pairs_capital_updates_from_real_fills(tmp_path):
"""Long ratio, gamba A +2% reale: capital += real_pnl (2 gambe, 4 fee da 0.02)."""
fake = FakePairsExec(open_a=100.0, close_a=102.0)
w = _pw(tmp_path, fake)
w._open(1, 100.0, 50.0, -2.1)
assert w.real_in_position
na = w.real_notional_a
w._close(102.0, 50.0, 0.1, "mean_revert")
real_pnl = 0.02 * na - 4 * 0.02 # +2% gamba A - fee 4 lati
assert abs(w.capital - (100.0 + real_pnl)) < 1e-6
c = json.loads([l for l in w.trades_path.read_text().splitlines()
if '"CLOSE"' in l][-1])
assert c["pnl_source"] == "real"
assert w.total_trades == 1
def test_pairs_fallback_sim_on_leg_fail(tmp_path):
"""Apertura reale fallita (leg-risk unwound): chiusura su ledger sim con flag."""
class FailOpen(FakePairsExec):
def open_pair(self, *a, **k):
bad = Fill("x", "buy", 0.0, 0.0, None, 0.0, 0.0, None, "error", False)
return PairFill(False, bad, bad, unwound=False, notes="leg-fail")
w = _pw(tmp_path, FailOpen())
w._open(1, 100.0, 50.0, -2.1)
assert not w.real_in_position
w._close(102.0, 50.0, 0.1, "mean_revert")
c = json.loads([l for l in w.trades_path.read_text().splitlines()
if '"CLOSE"' in l][-1])
assert c["pnl_source"] == "sim_fallback"
assert c["pnl"] == c["sim_pnl"]
def test_partial_close_books_only_filled_amount(tmp_path):
"""Reduce-only cappato dal netting (audit 2026-06-11): close richiesto 1.0 ma
fillato 0.5 -> il ledger booka SOLO il fillato, REAL_CLOSE verified=False e
evento REAL_CLOSE_PARTIAL col residuo orfano (niente piu' chiusure 'piene' finte)."""
class PartialFake(FakeExec):
def close_amount(self, instrument, side, amount, label=None):
return Fill(instrument, "sell" if side == "buy" else "buy", 0.0, amount,
self.close_px, 0.0, 0.05, "oid-close", "filled", True,
filled_amount=amount * 0.5)
fake = PartialFake(open_px=100.0, close_px=105.0)
w = _sw(tmp_path, fake)
_open_sim(w, 100.0)
w._close_position(105.0, "time_limit")
rows = [json.loads(l) for l in w.trades_path.read_text().strip().splitlines()]
rc = [r for r in rows if r.get("event") == "REAL_CLOSE"][-1]
assert rc["verified"] is False # chiusura NON completa
partial = [r for r in rows if r.get("event") == "REAL_CLOSE_PARTIAL"]
assert partial and partial[-1]["residuo_orfano"] > 0
def test_pairs_orphan_leg_close_not_booked(tmp_path):
"""Gamba A respinta in CHIUSURA (reduce-only vs netting, audit 2026-06-11): il suo
'profitto' al prezzo sim NON va nel ledger reale, l'orfano e' registrato/persistito
e il real-truth ricade sul sim DICHIARATO (applied=False)."""
class LegFailClose(FakePairsExec):
def close_pair(self, inst_a, inst_b, side_a, side_b, amount_a, amount_b, label=None):
bad_a = Fill(inst_a, "sell", 0.0, amount_a, None, 0.0, 0.0, None, "error", False)
ok_b = self._fill(inst_b, "buy", amount_b, self.px["close_b"])
return PairFill(False, bad_a, ok_b)
fake = LegFailClose(open_a=100.0, close_a=110.0) # gamba A +10% ma MAI chiusa
w = _pw(tmp_path, fake)
w._open(1, 100.0, 50.0, -2.1)
na = w.real_notional_a
w._close(110.0, 50.0, 0.1, "mean_revert")
assert w.orphan_legs and w.orphan_legs[0]["instrument"] == "ETH_USDC-PERPETUAL"
rc = json.loads([l for l in w.trades_path.read_text().splitlines()
if '"REAL_CLOSE_PAIR"' in l][-1])
assert rc["leg_a_ok"] is False and rc["leg_b_ok"] is True
# il +10% della gamba A (mai eseguita) NON e' nel capitale reale
assert w.real_capital < 100.0 + 0.10 * na - 0.05
c = json.loads([l for l in w.trades_path.read_text().splitlines()
if '"event": "CLOSE"' in l][-1])
assert c["pnl_source"] == "sim_fallback"
+105
View File
@@ -0,0 +1,105 @@
"""TP_PHANTOM (2026-06-11): il tocco TP che esiste solo nel feed (spike-print testnet)
NON deve chiudere il LIMIT resting sul book reale e' l'oracolo: zero fill + prezzo
lontano dal livello = wick fantasma -> exit soppressa. Con fill reale (anche parziale),
o prezzo realmente oltre il livello, o worker senza esecuzione -> comportamento storico."""
import pandas as pd
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
def _df(last_high, last_low, n=120, price=100.0):
c = [price] * n
h = [price] * n
l = [price] * n
h[-1] = last_high
l[-1] = last_low
ts = (pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC").astype("int64") // 10**6)
return pd.DataFrame({"timestamp": ts, "open": c, "high": h, "low": l, "close": c, "volume": 1.0})
class _FakeExecutor:
"""Solo cio' che serve al gate + alla _real_close di un eventuale exit."""
def __init__(self, tp_filled_amount=0.0):
self.tp_filled_amount = tp_filled_amount
self.verify_polls = 1
self.verify_sleep = 0.0
def resting_fills(self, instrument, order_id):
return self.tp_filled_amount, 102.0 if self.tp_filled_amount else None, 0.0
def cancel_order(self, order_id):
return {"state": "cancelled"}
def close_amount(self, instrument, side, amount, label=""):
class F:
verified = True
amount = 0.01
filled_amount = 0.01
fill_price = 100.0
fee_usd = 0.01
order_id = "X"
return F()
def _long_worker(tmp, executor=None):
w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h",
capital=1000.0, data_dir=tmp,
executor=executor,
exec_instrument="BTC_USDC-PERPETUAL" if executor else None)
w._notify = lambda *a, **k: None
w.in_position = True
w.direction = 1
w.entry_price = 100.0
w.tp = 102.0
w.sl = 98.0
w.max_bars = 24
w.bars_held = 1
w.last_bar_ts = 0
if executor:
w.real_in_position = True
w.real_side = "buy"
w.real_amount = 0.01
w.real_entry_price = 100.0
w.real_entry_notional = 1.0
w.real_tp_order_id = "TP-1"
return w
def test_phantom_touch_suppressed(tmp_path):
# wick a 102.5 nel feed, resting zero-fill, close 100 lontano dal TP -> NON chiude
w = _long_worker(tmp_path, executor=_FakeExecutor(tp_filled_amount=0.0))
w.tick(_df(last_high=102.5, last_low=99.5))
assert w.in_position
assert w.real_in_position
def test_real_fill_closes(tmp_path):
# stesso wick ma il resting HA fillato -> tocco reale -> chiude
w = _long_worker(tmp_path, executor=_FakeExecutor(tp_filled_amount=0.01))
w.tick(_df(last_high=102.5, last_low=99.5))
assert not w.in_position
def test_price_beyond_tp_closes_without_fill(tmp_path):
# close corrente OLTRE il TP (gap fra poll): tocco genuino anche a zero fill
w = _long_worker(tmp_path, executor=_FakeExecutor(tp_filled_amount=0.0))
df = _df(last_high=103.0, last_low=99.5)
df.loc[df.index[-1], "close"] = 102.6
w.tick(df)
assert not w.in_position
def test_no_executor_keeps_legacy_behavior(tmp_path):
# worker senza esecuzione reale: il gate non si applica (parita' storica)
w = _long_worker(tmp_path, executor=None)
w.tick(_df(last_high=102.5, last_low=99.5))
assert not w.in_position
def test_phantom_does_not_block_other_exits(tmp_path):
# tocco TP fantasma E max_bars raggiunto nello stesso tick -> esce a time_limit
w = _long_worker(tmp_path, executor=_FakeExecutor(tp_filled_amount=0.0))
w.bars_held = 24
w.tick(_df(last_high=102.5, last_low=99.5))
assert not w.in_position