Compare commits
28 Commits
5d45f4ef6e
...
cfdd8f6a72
| Author | SHA1 | Date | |
|---|---|---|---|
| cfdd8f6a72 | |||
| 844a9d0e22 | |||
| 22b77668f1 | |||
| 521447fce6 | |||
| 429fa01c97 | |||
| 8a2b065dd7 | |||
| 51baed22e4 | |||
| 12625b3315 | |||
| 15520dda70 | |||
| 2ae6d7afe6 | |||
| b208417f2d | |||
| 9d15506b05 | |||
| ba5ec7bd6b | |||
| 8d69a0cef5 | |||
| 8adf388e86 | |||
| e25d5db6ad | |||
| d1180ef25b | |||
| 61fcc29c5b | |||
| 5e557fe998 | |||
| cc39c36c08 | |||
| d2948d582b | |||
| a85289d7c7 | |||
| d3dab57532 | |||
| abc1c1cb80 | |||
| 1a9032feaa | |||
| 90c4726a31 | |||
| be72b157cf | |||
| d25d897fd1 |
@@ -18,6 +18,7 @@ data/processed/
|
||||
notebooks/.ipynb_checkpoints/
|
||||
data/paper_trades/
|
||||
data/portfolio_paper/
|
||||
data/portfolio_paper_stats/
|
||||
data/portfolios/
|
||||
|
||||
# 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)
|
||||
data/options/
|
||||
data/_reset_backup/
|
||||
|
||||
# game artifacts (log/json di scripts/games e gate)
|
||||
data/games/
|
||||
|
||||
@@ -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);
|
||||
exit_lab.py + exit_policies/ (harness ricerca exit: cache segnali, train/OOS);
|
||||
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)
|
||||
VERSION → versione semver (cotta nell'immagine, +1 ad ogni deploy)
|
||||
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
|
||||
|
||||
- Un `Portfolio` è un oggetto di prima classe (`src/portfolio/`) con definizione (sleeve + schema pesi) e due facce sulla **STESSA** definizione: `.backtest()` (riusa il builder unico di `sleeves.py` → parità esatta con `report_families`) e live (`PortfolioRunner`: capitale pool condiviso, sizing per peso, ribilancio giornaliero, ledger aggregato in `data/portfolios/{code}/`).
|
||||
- **Schemi peso:** `equal` (default), `cap` (tetto per famiglia, es. pairs 33% — config raccomandata), `inverse_vol`, `cluster_rp` (equal fra cluster naturali poi inverse-vol intra-cluster), `manual`. Definiti in `weighting.py`; la chiave cap è la famiglia (PAIRS/FADE/HONEST/SHAPE/TSM).
|
||||
- **Default `portfolios.yml`:** PORT06 (master+shape), `weighting=cap pairs 0.33 + shape 0.0588`, leva 2x, ribilancio 1D. Backtest PORT06 canonico (dati al 2026-05-28, pre-cap-shape): FULL Sharpe 6.47 DD 4.10% / OOS Sharpe 8.82 DD 1.30%; **con EXIT-16 close-confirm (config live attuale): FULL 7.84 / 2.60%, OOS 10.06 / 1.15%** (i vecchi 6.07/8.19 erano pre-loss-guard/pre-refresh dati). Col cap SHAPE (2026-06-05): FULL 6.43 / 3.96%, OOS 8.58 / 1.36% — assicurazione sulla coda SH01, vedi sotto.
|
||||
- **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. 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`.
|
||||
- **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
|
||||
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,
|
||||
@@ -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
|
||||
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
|
||||
3.96→5.35%). Copertura reale ora ~81% (fade+DIP+pairs+SH01); restano simulati TR01/ROT02/TSM01
|
||||
(book multi-asset, bloccati dal capitale: rumore arrotondamento 20-30% a €2k, serve ~€20k).
|
||||
Diari `docs/diary/2026-06-08-pairs-executor.md`.
|
||||
3.96→5.35%). Copertura reale ora 15 sleeve su 19 (fade+DIP+**6 pairs incl. ETH/BTC 15m**+SH01);
|
||||
restano simulati TR01/ROT02/TSM01/XS01 (book multi-asset, bloccati dal capitale: rumore
|
||||
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.
|
||||
- **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,
|
||||
@@ -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
|
||||
`PANEL_SHORT` su TSM01/ROT02; `hourly_report` ora mostra i multi-asset (sezione MULTI-ASSET).
|
||||
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`.
|
||||
- **position_size per-famiglia (2026-06-07).** `portfolios.yml` accetta `position_size_family`
|
||||
(chiave = `weighting.family_of`); plumbing `runner.pos_for_spec`. **PAIRS a 0.20** (esposizione
|
||||
0.40 ≈ il validato 0.45): la famiglia è senza stop e col globale 0.5×lev2 girava a ~2.2x il
|
||||
validato (ETH/BTC DD grezzo 78% a quella taglia). PORT06 OOS DD 3.40→1.26% al costo di OOS
|
||||
Sharpe 9.05→8.43 — assicurazione come il cap SHAPE. Gate `pairspos_port06_impact.py`,
|
||||
diario `docs/diary/2026-06-07-pairspos-gate.md`.
|
||||
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.
|
||||
|
||||
## Multi-Strategy Paper Trader
|
||||
|
||||
@@ -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.*
|
||||
+51
-25
@@ -1,17 +1,18 @@
|
||||
# TODO — prossimi passi
|
||||
|
||||
> Raccolta dei follow-up aperti. Aggiornato 2026-06-08 (pairs reali ATTIVI v1.1.12;
|
||||
> roadmap improvement-sweep 10/10 completata).
|
||||
> Raccolta dei follow-up aperti. Aggiornato 2026-06-11 (XS01 dispersion-gate live v1.1.20;
|
||||
> real-truth ledger attivo; reset portafoglio 2026-06-10).
|
||||
|
||||
## 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+
|
||||
- 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)
|
||||
|
||||
**SIMULATO (paper, NON esegue reale) — categoria distinta:**
|
||||
- TR01 / ROT02 / TSM01 → book multi-asset, bloccati dal CAPITALE (rumore arrotondamento)
|
||||
**SIMULATO (paper, NON esegue reale) — 4 book multi-asset, fuori dal capitale-pool:**
|
||||
- TR01 / ROT02 / TSM01 / XS01 → bloccati dal CAPITALE (rumore arrotondamento)
|
||||
|
||||
## Esecuzione reale — pezzi mancanti
|
||||
|
||||
@@ -20,7 +21,7 @@
|
||||
(_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
|
||||
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
|
||||
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
|
||||
@@ -33,27 +34,34 @@
|
||||
- [ ] **Valutare aumento capitale a €5.000-10.000** per togliere il rumore di arrotondamento.
|
||||
A €2.000 il rumore BTC per trade è ~2.4% (morde); soglie: ~€4.800 (<1%), ~€9.500 (<0.5%,
|
||||
"punto dolce"), ~€19.000 (trascurabile). Le commissioni invece sono % e neutre al capitale.
|
||||
Riguarda solo i 7 single-leg che eseguono reale; pairs/SH01 sono simulati.
|
||||
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)
|
||||
|
||||
- [ ] **forming-bar su ROT02/TSM01**: i worker daily valutano la candela 1d IN FORMAZIONE al primo
|
||||
poll dopo mezzanotte UTC (stessa classe del fix TR01/Pairs, pre-esistente). Ora è banale:
|
||||
usare `src.live.bars.last_settled_idx` in `_panel` / nei due tick. Impatto basso (1d) ma
|
||||
stessa famiglia di bug.
|
||||
- [ ] **ancora bfill di `_daily_equity`**: la serie a punti-trade reindexata su IDX àncora il primo
|
||||
valore della finestra al PRIMO trade in-finestra, non al capitale portato avanti. Tocca le
|
||||
metriche canoniche di TUTTI gli sleeve a punti-trade (honest/pairs/tsm). Valutare se
|
||||
correggere OVUNQUE in un colpo (cambierebbe i numeri canonici di riferimento). Scoperto nel
|
||||
gate DIP01 (parità inizialmente fallita per questo).
|
||||
- [ ] **convenzione TR01 worker vs reference**: il replay `validate_honest_workers` dà TR01 worker
|
||||
-44% vs reference +42% (identico pre/post fix di oggi). È la divergenza capitale-unico
|
||||
(`mean(rets)` solo sugli asset in posizione) vs media-equity 1/N del reference. Indagare:
|
||||
sospetto che `mean(rets)` sovrappesi gli asset in posizione quando il paniere è parzialmente
|
||||
investito.
|
||||
- [ ] **engine duplicato nei 3 gate** `*_port06_impact.py` (exit16/trendmax/dip01): `build_trades_variant`
|
||||
/ `port_metrics` copiati quasi verbatim. Sono regression-lock per decisioni live → la
|
||||
copy-drift corrompe i verdetti. Fattorizzare in un modulo condiviso.
|
||||
- [x] ~~forming-bar su ROT02/TSM01~~ — GIÀ FIXATO (v1.1.10, 2026-06-08): `_panel` condiviso
|
||||
scarta la barra in formazione via `last_bar_is_forming`. Item rimasto stantio nel TODO.
|
||||
- [ ] **ancora bfill di `_daily_equity`** — QUANTIFICATO 2026-06-11 (`daily_equity_bfill_impact.py`):
|
||||
**NON materiale, lasciare documentato**. PORT06 OOS invariato per costruzione (il bias vive
|
||||
in testa alla serie, pre-SPLIT; ΔSharpe +0.001, ΔDD 0.000); FULL DD leggermente OTTIMISTICO
|
||||
(3.46→3.67 col fix: il primo trade DIP01 2021, −3.75%, è nascosto dal bfill). Nessun verdetto
|
||||
di gate a rischio (bias identico nei due bracci A-vs-B, si cancella). Unica eccezione OOS:
|
||||
TSM01 (primo punto equity post-SPLIT, −0.014 Sh). Correggere SOLO in un eventuale refactor
|
||||
del builder daily, OVUNQUE in un colpo (~12 file di gate replicano la convenzione) e
|
||||
ri-baselinando i canonici nello stesso commit. CAVEAT per gate futuri: finestre IDX che
|
||||
partono a metà storia amplificano il bug → usare lì la convenzione carry-forward corretta.
|
||||
- [x] ~~convenzione TR01 worker vs reference~~ — ERA UN BUG, FIXATO (2026-06-11): `mean(rets)`
|
||||
sui soli asset in posizione sovrappesava N/k a paniere parziale (con 1 long: 0.45 del
|
||||
capitale invece di 0.09). Fix: `sum(rets)/len(universe)` (equal-weight 1/N canonico).
|
||||
Replay post-fix: +32% vs reference +42% (residuo = convenzione capitale-unico vs
|
||||
media-equity, dichiarata). Diario `2026-06-11-stability-sweep.md`.
|
||||
- [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):
|
||||
ogni nuova epoca-filtro richiede di editare la funzione. Derivarle da deploy history/config.
|
||||
|
||||
@@ -66,6 +74,24 @@
|
||||
|
||||
## 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.
|
||||
- [ ] **SH01 post-bootstrap**: il trade-rate live deve scendere da ~25% a ~10% delle barre
|
||||
(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 **2–3.9** su lb 12–72 × hold 6–24.
|
||||
- **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`.
|
||||
@@ -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 ETH−BTC: 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).
|
||||
@@ -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[i−lb])`. 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.
|
||||
@@ -0,0 +1,86 @@
|
||||
# 2026-06-11 — Sweep di stabilità su tutte le strategie (anti-overfit)
|
||||
|
||||
Obiettivo: analisi di tutte le 19 sleeve cercando miglioramenti/correzioni/protezioni che
|
||||
aumentino la STABILITÀ, con disciplina anti-overfit (ipotesi pre-registrate, griglie fissate
|
||||
prima di guardare i numeri, verdetti su TRAIN E OOS con plateau, gate PORT06; nessun re-test
|
||||
di idee già bocciate: ADX/vol-target/time-stop/hurst/stop SH01/multi-TF/entry-guard ecc.).
|
||||
|
||||
## Audit di drift (nessun fitting)
|
||||
|
||||
- `report_families` sui dati correnti: correlazioni cross-famiglia ancora ≈0 (pairs 0.03-0.11,
|
||||
XS01 0.01, SH 0.03-0.04 vs MASTER-9) → la diversificazione NON è driftata.
|
||||
- **Regression-lock trend_max** (`trendmax_port06_impact`) rieseguito su dati freschi:
|
||||
parità 1.00000 su tutte e 6 le fade (engine live-path integro) e **plateau trend_max
|
||||
2.5/3.0/3.5 confermato** (OOS Sh 11.14/11.24/10.98, DD 1.33 identico).
|
||||
- **Percentile del rolling-return PORT06** (cap weights, storia 2021+): finestra corrente
|
||||
60g al 19°, 120g al 21°, 160g al 28° percentile → periodo fiacco ma in variazione normale.
|
||||
- ⚠️ **FADE in coda storica**: il rolling 120g equal-weight delle 6 fade è a **−1.0% =
|
||||
2° percentile** della propria storia (p5 = +0.4%). È il tratto peggiore mai attraversato
|
||||
dalla famiglia. Decisione esplicita: NESSUN ritocco ai parametri (sarebbe fit sul regime
|
||||
corrente, l'errore che il progetto evita da sempre); la protezione è la diversificazione
|
||||
(già in atto: il portafoglio regge al 19-28° pct) + monitoraggio. Follow-up in TODO:
|
||||
alert di drift per-famiglia nel hourly_report (distribuzione storica precomputata).
|
||||
|
||||
## Correzione: bug contabile TR01 worker (FIXATO)
|
||||
|
||||
`BasketTrendWorker.tick` usava `mean(rets)` sui SOLI asset in posizione → con paniere
|
||||
parziale sovrappesa N/k (con 1 solo long: 0.45 del capitale invece di 0.09). Era l'origine
|
||||
della divergenza replay −44% vs reference +42% annotata nel TODO. La convenzione canonica
|
||||
(backtest PORT06 via `_tr_basket_daily`) è equal-weight 1/N sull'universo: fix 1 riga
|
||||
(`sum(rets)/len(universe)`). Replay post-fix: **TR01 +32% vs reference +42%** (stesso segno
|
||||
e ordine di grandezza = gate del validatore; il residuo è la differenza dichiarata
|
||||
capitale-unico vs media-equity). ROT02 +171%==ref, TSM01 +5%==ref invariati. Solo
|
||||
statistica PAPER: nessun effetto su pool/ordini. NB: il forming-bar su ROT02/TSM01 segnato
|
||||
nel TODO era GIÀ fixato (v1.1.10, `_panel` scarta la barra in formazione) — TODO aggiornato.
|
||||
|
||||
## Protezione 1 (pre-registrata): disaster-cap z sui pairs → **NO-GO**
|
||||
|
||||
Ipotesi: exit immediata se |z| ≥ z_stop dopo l'ingresso taglia la coda da structural-break
|
||||
senza toccare i trade normali. Griglia fissata: z_stop {3.0,3.5,4.0,5.0} × 5 coppie 1h +
|
||||
{2.5,3.0,3.5,4.0} sul 15m, train <2023-11-01 / OOS, engine con regression-lock ESATTO su
|
||||
`pairs_sim`/`pairs_sim_flat`. Esito (potere statistico AMPIO, centinaia di trigger):
|
||||
**bocciata su tutti e 3 i criteri** — il DD peggiora quasi ovunque, il worst-trade OOS
|
||||
peggiora su 4 coppie su 6 (ETH/BTC −62→−168%!), Sharpe OOS cala oltre il −10% relativo in
|
||||
TUTTE le celle, e il "plateau" esiste solo per il danno (monotono: più stretto, peggio).
|
||||
Meccanismi: (i) lo stop realizza la perdita al massimo overshoot — il movimento che la
|
||||
strategia fada (stessa lezione EXIT-16/SH01, **5ª conferma**); (ii) l'engine non-overlap
|
||||
rientra subito nello spread ancora divergente → churn di fee a 2 gambe e stop ripetuti.
|
||||
I pairs restano senza stop by design; la mitigazione resta la taglia
|
||||
(`position_size_family` PAIRS 0.20). Record: `scripts/analysis/pairs_zstop_research.py`.
|
||||
|
||||
## Protezione 2 (pre-registrata): phase-tranching XS01 → **PROMOSSA e LIVE**
|
||||
|
||||
Diagnosi: il roll non-sovrapposto di XS01 ha una FASE arbitraria (dipende da quando il
|
||||
worker parte) e l'esito ne dipende parecchio — sulle 12 fasi possibili: Sharpe daily FULL
|
||||
1.52-2.33, DD per-trade 13.8-33.1% (`xs01_tranche_research.py`). È timing-luck puro: il
|
||||
backtest canonico (fase 0) è una delle estrazioni FORTUNATE sul FULL (DD 15.4 vs mediana
|
||||
~21). Rimedio senza parametri fittati: **ensemble di fase** — K sub-book sfasati di hold/K
|
||||
barre su capitale comune (PnL/K). Gate onesto su equity daily (`xs01_tranche_gate.py`):
|
||||
|
||||
- standalone OOS: Sharpe 3.79→4.27 (K=2) →4.85 (K=3), DD 7.99→7.25→5.56;
|
||||
- FULL standalone: il DD sale verso la mediana di fase (~21%) — il "peggioramento" è la
|
||||
RIMOZIONE della fortuna della fase 0, non un costo;
|
||||
- PORT06 swap-sleeve: FULL invariato (7.33/3.46), OOS Sh 10.07→10.11 (K=2) →10.15 (K=3),
|
||||
OOS DD 1.48→1.43→1.38. **Plateau: K=2 E K=3 entrambi promossi** (non best-pick).
|
||||
|
||||
Implementazione: `CrossSectionalWorker` param `tranches` (default 1 = storico; live K=3 in
|
||||
`_defs.py`), books indipendenti con sfasamento iniziale `wait`, capitale comune, migrazione
|
||||
automatica dello status legacy (il vecchio book → tranche 0), `last_bar_ts` solo-avanti
|
||||
(robustezza ai panel accorciati da feed in ritardo). Solo path LIVE come `disp_min` (il
|
||||
backtest canonico resta single-phase → il confronto live/backtest va letto con la mediana
|
||||
di fase in mente). **Validatore esteso e PASSATO**: K=1 replay == `xsec_sim` ESATTO
|
||||
(1427 trade, cap 4993==4993); K=3 replay == unione fasi 0/4/8 ESATTO (4279 trade,
|
||||
4512==4512). Osservabilità: `hourly_report` aggrega i sub-book nel book medio.
|
||||
|
||||
## Non toccati (per evidenza, non per pigrizia)
|
||||
|
||||
- **Fade/DIP01**: exit-lab già esaustivo (23+11 famiglie); plateau e parità riconfermati oggi.
|
||||
- **SH01**: 11 famiglie di stop già bocciate; cap famiglia + monitor trade-rate in essere.
|
||||
- **ROT02/TSM01**: replay == reference; forming-bar già fixato.
|
||||
- **TR01 come sleeve** (3 anni negativi su 6): rimosso? NO — sarebbe una decisione guidata
|
||||
dal regime recente (recency bias); il suo ruolo è catturare i trend che le fade non
|
||||
prendono. Resta, con la statistica ora contabilizzata correttamente.
|
||||
|
||||
## Esito test
|
||||
|
||||
`pytest`: 99 passed. Validatori: honest workers OK, xsec K=1/K=3 OK, trendmax lock OK.
|
||||
@@ -0,0 +1,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
@@ -17,7 +17,7 @@ overrides:
|
||||
# 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:
|
||||
# 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).
|
||||
# 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).
|
||||
@@ -56,3 +56,10 @@ overrides:
|
||||
# Assicurazione per gli outage (runner fermo = exit non valutati); in
|
||||
# operativita' normale non scatta mai -> 0 costo Sharpe. 0 = disattivo.
|
||||
disaster_sl_pct: 0.30
|
||||
# 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
|
||||
|
||||
@@ -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()
|
||||
@@ -33,11 +33,9 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from scripts.analysis.strategy_research import atr
|
||||
from scripts.analysis.combine_portfolio import (
|
||||
_norm, IDX, port_returns, metrics, SPLIT, OOS_DATE,
|
||||
)
|
||||
from scripts.analysis.combine_portfolio import _norm, IDX, metrics, SPLIT, OOS_DATE
|
||||
from scripts.analysis._port06_gate_common import port_metrics
|
||||
from scripts.portfolios._defs import PORTFOLIOS
|
||||
from src.portfolio import weighting as W
|
||||
|
||||
FEE_RT, LEV, POS, INIT = 0.001, 3.0, 0.15, 1000.0
|
||||
BUFFER = 0.5
|
||||
@@ -139,15 +137,6 @@ def cell_metrics(eq):
|
||||
return metrics(dr), metrics(dr, lo=SPLIT)
|
||||
|
||||
|
||||
def port_metrics(members, p):
|
||||
ids = p.sleeve_ids
|
||||
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
|
||||
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
|
||||
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
||||
drp = port_returns({i: members[i] for i in ids}, w)
|
||||
return metrics(drp), metrics(drp, lo=SPLIT)
|
||||
|
||||
|
||||
def main():
|
||||
p = PORTFOLIOS["PORT06"]
|
||||
print("=" * 104)
|
||||
|
||||
@@ -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()
|
||||
@@ -24,106 +24,23 @@ from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from scripts.analysis.strategy_research import atr
|
||||
from scripts.analysis.risk_management import strats_for, FEE_RT, LEV, POS, INIT
|
||||
from scripts.analysis.combine_portfolio import (
|
||||
fade_daily_equity, _norm, IDX, port_returns, metrics, SPLIT, OOS_DATE,
|
||||
from scripts.analysis.risk_management import strats_for
|
||||
from scripts.analysis.combine_portfolio import OOS_DATE
|
||||
from scripts.analysis._port06_gate_common import (
|
||||
build_trades_variant, equity_from_trades, port_metrics, dd as _dd,
|
||||
)
|
||||
from scripts.portfolios._defs import PORTFOLIOS
|
||||
from src.portfolio import weighting as W
|
||||
|
||||
BUFFER = 0.5 # EXIT-16 promossa: close-confirm con buffer 0.5 ATR
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- engine replay
|
||||
def build_trades_variant(ents, df, mode, buffer=BUFFER,
|
||||
lev=LEV, fee_rt=FEE_RT, trend_max=3.0, ema_long=200):
|
||||
"""Replica ESATTA di risk_management.build_trades, cambiando SOLO il ramo SL.
|
||||
|
||||
mode="orig" : SL intrabar al livello (SL prima del TP) == canonico.
|
||||
mode="exit16" : SL intrabar DISATTIVATO; close-confirm sul close[j]:
|
||||
long esci a close[j] se close[j] < sl0 - buffer*atr14[j]
|
||||
short esci a close[j] se close[j] > sl0 + buffer*atr14[j]
|
||||
TP intrabar al livello e max_bars al close INVARIATI.
|
||||
"""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c)
|
||||
a = atr(df, 14)
|
||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
||||
fee = fee_rt * lev
|
||||
out = []
|
||||
last = -1
|
||||
for e in ents:
|
||||
i, d = e["i"], e["d"]
|
||||
if i <= last or i + 1 >= n:
|
||||
continue
|
||||
if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max:
|
||||
continue
|
||||
entry = c[i]
|
||||
tp, sl0, mb = e["tp"], e["sl"], e["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]
|
||||
j = min(i + mb, n - 1)
|
||||
for k in range(1, mb + 1):
|
||||
j = i + k
|
||||
if j >= n:
|
||||
exit_p = c[n - 1]
|
||||
break
|
||||
if mode == "orig":
|
||||
hs = (d == 1 and l[j] <= sl0) or (d == -1 and h[j] >= sl0)
|
||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hs:
|
||||
exit_p = sl0
|
||||
break
|
||||
if ht:
|
||||
exit_p = tp
|
||||
break
|
||||
if k == mb:
|
||||
exit_p = c[j]
|
||||
else: # exit16: no SL intrabar; TP intrabar; poi close-confirm SL al close[j]
|
||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if ht:
|
||||
exit_p = tp
|
||||
break
|
||||
aj = a[j] if np.isfinite(a[j]) else 0.0
|
||||
confirm = (d == 1 and c[j] < sl0 - buffer * aj) or \
|
||||
(d == -1 and c[j] > sl0 + buffer * aj)
|
||||
if confirm:
|
||||
exit_p = c[j]
|
||||
break
|
||||
if k == mb:
|
||||
exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * d * lev - fee
|
||||
out.append((i, j, ret))
|
||||
last = j
|
||||
return out
|
||||
|
||||
|
||||
def fade_equity_variant(asset, fn, params, mode):
|
||||
"""Stesso flusso di combine_portfolio.fade_daily_equity ma con build_trades_variant."""
|
||||
df = load_data(asset, "1h")
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
trades = build_trades_variant(fn(df, **params), df, mode=mode, trend_max=3.0)
|
||||
n = len(df)
|
||||
eq = np.full(n, INIT, dtype=float)
|
||||
cap = INIT
|
||||
for i, j, ret in sorted(trades, key=lambda t: t[1]):
|
||||
cap = max(cap + cap * POS * ret, 10.0)
|
||||
eq[j:] = cap
|
||||
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
|
||||
return _norm(s)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pesi PORT06
|
||||
def port_metrics(members: dict[str, pd.Series], weights: dict[str, float]):
|
||||
dr = port_returns(members, weights)
|
||||
return metrics(dr), metrics(dr, lo=SPLIT)
|
||||
return equity_from_trades(df, trades)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -174,17 +91,9 @@ def main():
|
||||
for sid in fade_ids:
|
||||
members_e16[sid] = eq_e16[sid] # sostituisco SOLO le 6 colonne fade
|
||||
|
||||
ids = p.sleeve_ids
|
||||
# pesi cap canonici (gli stessi che usa Portfolio.backtest)
|
||||
dr_base = pd.DataFrame({i: members_base[i].pct_change().fillna(0.0) for i in ids})
|
||||
w_base = W.weight_vector(p.weighting, ids, dr_base, weights=p.weights,
|
||||
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
||||
dr_e16 = pd.DataFrame({i: members_e16[i].pct_change().fillna(0.0) for i in ids})
|
||||
w_e16 = W.weight_vector(p.weighting, ids, dr_e16, weights=p.weights,
|
||||
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
||||
|
||||
f_b, o_b = port_metrics({i: members_base[i] for i in ids}, w_base)
|
||||
f_e, o_e = port_metrics({i: members_e16[i] for i in ids}, w_e16)
|
||||
# pesi cap canonici (gli stessi che usa Portfolio.backtest) dentro port_metrics
|
||||
f_b, o_b = port_metrics(members_base, p)
|
||||
f_e, o_e = port_metrics(members_e16, p)
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
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}")
|
||||
for sid in fade_ids:
|
||||
ro = eq_orig[sid]; re = eq_e16[sid]
|
||||
def _dd(s):
|
||||
pk = s.cummax(); return float(((pk - s) / pk).max() * 100)
|
||||
rro = (ro.iloc[-1] / ro.iloc[0] - 1) * 100
|
||||
rre = (re.iloc[-1] / re.iloc[0] - 1) * 100
|
||||
print(f" {sid:<10s}{rro:>12.1f}{rre:>14.1f}{rre-rro:>+10.1f}"
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -379,13 +379,43 @@ def chart_sh01():
|
||||
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():
|
||||
p = PORTFOLIOS["PORT06"]
|
||||
ids = p.sleeve_ids
|
||||
w = W.weight_vector("cap", ids, None, caps=p.caps)
|
||||
fam = {i: W.family_of(i) for i in ids}
|
||||
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))
|
||||
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])
|
||||
@@ -495,9 +525,14 @@ def stats_pairs():
|
||||
from scripts.analysis.pairs_research import pairs_sim
|
||||
from scripts.strategies.PR01_pairs_reversion import PAIRS as PAIRS_CFG
|
||||
cols, data = [], {}
|
||||
for a, b, p in PAIRS_CFG:
|
||||
r = pairs_sim(a, b, **p)
|
||||
tag = f"{a}/{b}"
|
||||
from scripts.analysis.pairs_research import pairs_sim_flat
|
||||
# le 5 coppie 1h universali + il BLEND ETH/BTC 15m flat-skip (mezza size = sleeve live)
|
||||
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)
|
||||
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)
|
||||
@@ -542,6 +577,25 @@ def stats_multi():
|
||||
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():
|
||||
"""SH01 per-anno dal walk-forward EXPANDING (il regime validato e ora live)."""
|
||||
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;
|
||||
margin-right:6px;color:#fff}
|
||||
.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-sim{background:#8a8f98}
|
||||
.b-tsm{background:#5ab4ac}.b-shape{background:#d64545}.b-xsec{background:#8c564b}
|
||||
.b-real{background:#2e9e6b}.b-sim{background:#8a8f98}
|
||||
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}
|
||||
td,th{border:1px solid #e3e5e8;padding:5px 9px;text-align:left}
|
||||
@@ -604,6 +658,7 @@ def main():
|
||||
st_dip = stats_dip()
|
||||
t_pairs = stats_pairs()
|
||||
t_tr, t_rot, t_tsm = stats_multi()
|
||||
t_xs = stats_xs01()
|
||||
print(" SH01 walk-forward expanding (il piu' lento)...")
|
||||
st_sh = stats_sh01()
|
||||
|
||||
@@ -626,6 +681,13 @@ def main():
|
||||
g_pr = chart_pr01()
|
||||
g_tsm = chart_tsm01(panel)
|
||||
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>'
|
||||
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
|
||||
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
|
||||
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, """
|
||||
<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<SMA100. Diversificare su 3
|
||||
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, """
|
||||
<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
|
||||
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.
|
||||
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>):
|
||||
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
|
||||
H=12 (market reduce-only), disaster-bracket on-book come unica protezione di coda — è il
|
||||
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))
|
||||
|
||||
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">
|
||||
<title>PythagorasGoal — Strategie attive PORT06</title><style>{CSS}</style></head>
|
||||
<body><div class="wrap">
|
||||
<h1>PythagorasGoal — Strategie attive</h1>
|
||||
<p class="sub">Portafoglio live <b>PORT06</b> — definizione 17 sleeve; <b>pool live real-only 14 sleeve</b>
|
||||
(i 3 book multi-asset girano in statistica, fuori dal capitale-pool). Capitale pool €2.000, leva 2x ·
|
||||
v{ver} · generato {now} · backtest canonico: FULL Sharpe 6.61 / DD 3.58% — OOS Sharpe 8.77 / DD 1.34%</p>
|
||||
<p class="sub">Portafoglio live <b>PORT06</b> — definizione {n_def} sleeve; <b>pool live real-only 15 sleeve</b>
|
||||
(i 4 book multi-asset TR01/ROT02/TSM01/XS01 girano in statistica, fuori dal capitale-pool).
|
||||
Capitale pool €2.000, leva 2x, <b>real-truth ledger</b> ·
|
||||
v{ver} · generato {now} · backtest canonico: {hdr}</p>
|
||||
<div class="card">
|
||||
<p>Tre famiglie principali 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,
|
||||
ribilancio giornaliero su capitale pool condiviso. Fee Deribit 0.10% round-trip incluse ovunque;
|
||||
ogni meccanismo live è passato da un gate out-of-sample a livello di portafoglio.</p>
|
||||
<p>Famiglie quasi <b>scorrelate</b> fra loro (fade↔honest ~0.05, pairs ~0.02-0.09,
|
||||
shape ~0.08, xsec ~0): la diversificazione è la leva anti-drawdown. Pesi equal con tetti
|
||||
per famiglia, ribilancio giornaliero su capitale pool condiviso. Fee Deribit 0.10% round-trip
|
||||
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>
|
||||
|
||||
<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>:
|
||||
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
|
||||
@@ -724,14 +809,16 @@ non si fada un crollo/parabolica), <b>EXIT-16</b> (stop solo sul close confermat
|
||||
{c_mr02}
|
||||
{c_mr07}
|
||||
{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_tr}
|
||||
{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}
|
||||
<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}
|
||||
<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>
|
||||
{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>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>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>
|
||||
<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>"""
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
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():
|
||||
"""Perturba il FUTURO del ratio e verifica che z[i] non cambi (causalita')."""
|
||||
m = aligned("ETH", "BTC")
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -28,7 +28,7 @@ from scripts.analysis.combine_portfolio import (
|
||||
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
|
||||
)
|
||||
from scripts.analysis.honest_improve2 import _daily_equity, _norm
|
||||
from scripts.analysis.pairs_research import pairs_sim
|
||||
from scripts.analysis.pairs_research import pairs_sim, pairs_sim_flat
|
||||
from scripts.analysis.tsmom_research import tsmom_sim
|
||||
from scripts.strategies.PR01_pairs_reversion import PAIRS
|
||||
from scripts.analysis.shape_ml_validate import shape_daily_equity
|
||||
@@ -46,9 +46,24 @@ def build_everything():
|
||||
for a, b, p in PAIRS:
|
||||
r = pairs_sim(a, b, **p)
|
||||
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()
|
||||
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")}
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -29,116 +29,20 @@ 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 src.strategies.fade_base import hurst_skip_mask
|
||||
from scripts.analysis.strategy_research import atr
|
||||
from scripts.analysis.risk_management import strats_for, FEE_RT, LEV, POS, INIT
|
||||
from scripts.analysis.combine_portfolio import (
|
||||
_norm, IDX, port_returns, metrics, SPLIT, OOS_DATE,
|
||||
from scripts.analysis.risk_management import strats_for
|
||||
from scripts.analysis.combine_portfolio import OOS_DATE
|
||||
from scripts.analysis._port06_gate_common import (
|
||||
build_trades_variant, equity_from_trades, port_metrics, dd as _dd,
|
||||
)
|
||||
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
|
||||
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():
|
||||
|
||||
@@ -18,56 +18,70 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
m = aligned(a, b)
|
||||
df_a = m[["timestamp"]].copy(); df_a["close"] = m["close_a"].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)
|
||||
# replay veloce: niente I/O su file / log / notifiche ad ogni tick (servono solo le metriche finali)
|
||||
def replay(a, b, params, data_dir, tf="1h", ohlc=False) -> PairsWorker:
|
||||
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_b = m[["timestamp"]].copy(); df_b["close"] = m["close_b"].values
|
||||
w = PairsWorker(a, b, tf, params=params, fee_rt=0.001, data_dir=data_dir)
|
||||
w._save_state = lambda: None
|
||||
w._log = lambda *a, **k: None
|
||||
w._notify = lambda *a, **k: None
|
||||
n = w.n
|
||||
for k in range(n + 2, len(m) + 1):
|
||||
lo = max(0, k - WINDOW)
|
||||
window = max(60, w.n + 6) # finestra trailing >= n+? : z[i] corretto
|
||||
for k in range(w.n + 2, len(m) + 1):
|
||||
lo = max(0, k - window)
|
||||
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
|
||||
|
||||
|
||||
def _row(label, w, bt):
|
||||
bt_cap = 1000.0 * (1 + bt["ret"] / 100)
|
||||
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)
|
||||
ok = "OK" if (cap_match and trd_match) else "DIFF"
|
||||
ww = w.total_wins / w.total_trades * 100 if w.total_trades else 0
|
||||
print(f" {label:<16s}{w.capital:>13.0f}{w.total_trades:>6d}{ww:>6.1f} | "
|
||||
f"{bt_cap:>14.0f}{bt['trades']:>6d}{bt['win']:>6.1f} {ok}")
|
||||
return ok == "OK"
|
||||
|
||||
|
||||
def main():
|
||||
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")}]
|
||||
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:
|
||||
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)
|
||||
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)
|
||||
ok = "OK" if (cap_match and trd_match) else "DIFF"
|
||||
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} | "
|
||||
f"{bt_cap:>13.0f}{bt['trades']:>5d}{bt['win']:>6.1f} {ok}")
|
||||
# [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:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
print(" " + "-" * 88)
|
||||
print(" match = capitale entro 2% e trade entro 2% del backtest. Differenze minime sono")
|
||||
print(" attese (gestione bar finale/troncamento), ma la semantica deve coincidere.")
|
||||
print(" " + "-" * 92)
|
||||
print(" match = capitale e trade entro 2% del backtest (diff minime = bar finale aperta).")
|
||||
print(f" ESITO COMPLESSIVO: {'TUTTO OK' if allok else 'DIFFERENZE -> INDAGARE'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""GATE — XS01 phase-tranching (K sub-book sfasati di hold/K, capitale comune).
|
||||
|
||||
`xs01_tranche_research.py` ha misurato timing-luck di fase reale (FULL Sharpe
|
||||
1.87-2.87, DD 13.8-33.1% a seconda della fase di partenza, che live e' arbitraria).
|
||||
Qui il confronto ONESTO su equity GIORNALIERA (stessa convenzione dei gate del
|
||||
progetto: combine_portfolio.metrics) + gate PORT06 swap-sleeve:
|
||||
[1] standalone: base (fase canonica) vs K=2 vs K=3, FULL e OOS, daily Sharpe/DD;
|
||||
in piu' il range delle 12 fasi singole (il rischio che il tranching elimina).
|
||||
[2] PORT06: members canonici con XS01 sostituito dalla variante tranched.
|
||||
Criterio (tutti): OOS Sharpe portafoglio non peggiora (>-0.02) E DD non sale;
|
||||
K=2 e K=3 devono essere ENTRAMBI >= base (plateau, non best-pick di K).
|
||||
|
||||
uv run python scripts/analysis/xs01_tranche_gate.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT, OOS_DATE
|
||||
from scripts.analysis.report_families import daily_from
|
||||
from scripts.analysis.xs01_tranche_research import xsec_trades
|
||||
from scripts.strategies.XS01_cross_sectional import aligned_panel, HOLD, POS, LEV
|
||||
from scripts.portfolios._defs import PORTFOLIOS
|
||||
from src.portfolio.sleeves import all_sleeve_equities
|
||||
from src.portfolio import weighting as W
|
||||
|
||||
|
||||
def daily_equity_for(phases, M, ts):
|
||||
"""Trade di tutte le fasi su capitale comune (peso 1/K) -> equity daily."""
|
||||
K = len(phases)
|
||||
allt = sorted([t for ph in phases for t in xsec_trades(phase=ph, M=M)],
|
||||
key=lambda t: t[1])
|
||||
cap = 1000.0
|
||||
eq_ts, eq_v = [], []
|
||||
for i, j, net in allt:
|
||||
cap = max(cap + cap * POS * LEV * net / K, 10.0)
|
||||
eq_ts.append(ts[j])
|
||||
eq_v.append(cap)
|
||||
return daily_from(eq_ts, eq_v)
|
||||
|
||||
|
||||
def port_metrics(members, ids, clusters, caps):
|
||||
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
|
||||
w = W.weight_vector("cap", ids, dr, caps=caps, clusters=clusters)
|
||||
drp = port_returns({i: members[i] for i in ids}, w)
|
||||
return metrics(drp), metrics(drp, lo=SPLIT)
|
||||
|
||||
|
||||
def main():
|
||||
M = aligned_panel()
|
||||
ts = pd.to_datetime(M.index, unit="ms", utc=True)
|
||||
p = PORTFOLIOS["PORT06"]
|
||||
print("=" * 92)
|
||||
print(" GATE — XS01 phase-tranching | equity daily, OOS da", OOS_DATE)
|
||||
print("=" * 92)
|
||||
|
||||
variants = {"base (fase 0)": [0],
|
||||
"K=2 (fasi 0,6)": [0, 6],
|
||||
"K=3 (fasi 0,4,8)": [0, 4, 8]}
|
||||
eqs = {k: daily_equity_for(v, M, ts) for k, v in variants.items()}
|
||||
|
||||
print(f"\n [1] STANDALONE daily — {'config':<18}{'FULL Sh':>8}{'FULL DD%':>9}{'OOS Sh':>8}{'OOS DD%':>8}")
|
||||
for k, e in eqs.items():
|
||||
r = e.pct_change().fillna(0.0)
|
||||
f, o = metrics(r), metrics(r, lo=SPLIT)
|
||||
print(f" {k:<22}{f['sharpe']:>8.2f}{f['dd']:>9.2f}{o['sharpe']:>8.2f}{o['dd']:>8.2f}")
|
||||
# range delle 12 fasi singole (daily): il rischio di fase che il tranching elimina
|
||||
fs, os_ = [], []
|
||||
for ph in range(HOLD):
|
||||
r = daily_equity_for([ph], M, ts).pct_change().fillna(0.0)
|
||||
fs.append(metrics(r)["sharpe"])
|
||||
os_.append(metrics(r, lo=SPLIT)["sharpe"])
|
||||
print(f" 12 fasi singole: FULL Sh {min(fs):.2f}-{max(fs):.2f} | OOS Sh {min(os_):.2f}-{max(os_):.2f}")
|
||||
|
||||
eq_base = dict(all_sleeve_equities())
|
||||
ids, cl, caps = list(p.sleeve_ids), p.clusters, p.caps
|
||||
print(f"\n [2] PORT06 swap-sleeve — {'config':<18}{'FULL Sh':>8}{'FULL DD%':>9}{'OOS Sh':>8}{'OOS DD%':>8}")
|
||||
f0, o0 = port_metrics(eq_base, ids, cl, caps)
|
||||
print(f" {'ATTUALE (base)':<22}{f0['sharpe']:>8.2f}{f0['dd']:>9.2f}{o0['sharpe']:>8.2f}{o0['dd']:>8.2f}")
|
||||
verdicts = []
|
||||
for k in ("K=2 (fasi 0,6)", "K=3 (fasi 0,4,8)"):
|
||||
mem = dict(eq_base)
|
||||
mem["XS01"] = eqs[k]
|
||||
f1, o1 = port_metrics(mem, ids, cl, caps)
|
||||
ok = (o1["sharpe"] >= o0["sharpe"] - 0.02 and o1["dd"] <= o0["dd"] + 1e-9
|
||||
and f1["sharpe"] >= f0["sharpe"] - 0.02 and f1["dd"] <= f0["dd"] + 1e-9)
|
||||
verdicts.append(ok)
|
||||
print(f" {k:<22}{f1['sharpe']:>8.2f}{f1['dd']:>9.2f}{o1['sharpe']:>8.2f}{o1['dd']:>8.2f}"
|
||||
f" {'OK' if ok else 'NO'}")
|
||||
print(f"\n => {'PROMOSSO (plateau K=2 e K=3)' if all(verdicts) else 'NON promosso (serve plateau su entrambi i K)'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""XS01 phase-tranching — PRE-REGISTRATO: il roll non-sovrapposto di XS01 (entry a i,
|
||||
exit a i+hold, re-entry) ha una FASE arbitraria (dipende dal primo indice valido).
|
||||
Se l'esito dipende dalla fase, c'e' timing-luck: dividere il book in K sub-book
|
||||
sfasati di hold/K barre (capitale 1/K ciascuno) e' un ensemble di fase che riduce
|
||||
la varianza SENZA parametri fittati (K=2 e K=3 riportati entrambi, nessun best-pick).
|
||||
|
||||
Test (griglia fissata qui, completa):
|
||||
[1] SENSIBILITA' DI FASE: xsec_sim base con offset di partenza 0..hold-1
|
||||
-> dispersione di Sharpe/ret/DD (FULL e OOS). Se la dispersione e' piccola,
|
||||
il tranching non serve (verdetto onesto, fine).
|
||||
[2] TRANCHING: K in {2, 3} sub-book sfasati equal-capital -> Sharpe/DD/ret
|
||||
FULL e OOS vs la MEDIA e il WORST delle fasi singole.
|
||||
Criterio (tutti necessari): il tranched riduce la dispersione (per costruzione) e
|
||||
il suo Sharpe OOS >= worst-fase OOS con DD <= mediana fasi; fee identiche (il
|
||||
tranching NON cambia il turnover per unita' di capitale).
|
||||
|
||||
uv run python scripts/analysis/xs01_tranche_research.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.strategies.XS01_cross_sectional import (
|
||||
aligned_panel, UNIVERSE, LB, HOLD, FEE_RT, LEV, POS, OOS_FRAC)
|
||||
|
||||
|
||||
def xsec_trades(phase: int = 0, lb: int = LB, hold: int = HOLD, fee_rt: float = FEE_RT,
|
||||
split_frac: float = 0.0, M=None):
|
||||
"""Replica ESATTA della logica di xsec_sim ma parte da max(lb, split)+phase e
|
||||
ritorna la lista trade (i, j, net) — stessa matematica, fee = 2*fee_rt."""
|
||||
C = M[UNIVERSE].values
|
||||
n = len(C)
|
||||
logC = np.log(C)
|
||||
split = int(n * split_frac)
|
||||
fee = 2 * fee_rt
|
||||
out = []
|
||||
last = -1
|
||||
i = max(lb, split) + phase
|
||||
while i < n - hold:
|
||||
if i <= last:
|
||||
i += 1
|
||||
continue
|
||||
dm = logC[i] - logC[i - lb]
|
||||
dm = dm - dm.mean()
|
||||
gw = np.sum(np.abs(dm))
|
||||
if gw < 1e-9:
|
||||
i += 1
|
||||
continue
|
||||
w = -dm / gw
|
||||
book = float(np.sum(w * (logC[i + hold] - logC[i])))
|
||||
out.append((i, i + hold, book - fee))
|
||||
last = i + hold
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def equity_from(trades, ts, pos=POS, lev=LEV, weight=1.0):
|
||||
"""Equity compounding a punti-exit (convenzione xsec_sim), peso per tranche."""
|
||||
cap = 1000.0
|
||||
eq_ts, eq_v = [], []
|
||||
for i, j, net in sorted(trades, key=lambda t: t[1]):
|
||||
cap = max(cap + cap * pos * lev * net * weight, 10.0)
|
||||
eq_ts.append(ts[j])
|
||||
eq_v.append(cap)
|
||||
return pd.Series(eq_v, index=pd.DatetimeIndex(eq_ts))
|
||||
|
||||
|
||||
def metrics(trades, ts, yrs_span):
|
||||
rets = [t[2] * POS for t in trades]
|
||||
n = len(rets)
|
||||
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(n / yrs_span)) if n > 1 and np.std(rets) > 0 else 0.0
|
||||
eq = equity_from(trades, ts)
|
||||
pk = eq.cummax()
|
||||
dd = float(((pk - eq) / pk).max() * 100) if len(eq) else 0.0
|
||||
ret = float((eq.iloc[-1] / 1000 - 1) * 100) if len(eq) else 0.0
|
||||
return dict(n=n, ret=ret, sharpe=sharpe, dd=dd)
|
||||
|
||||
|
||||
def combined_metrics(branches, ts, yrs_span):
|
||||
"""K tranche -> un'unica equity: pesa ogni trade 1/K sul capitale comune."""
|
||||
K = len(branches)
|
||||
allt = sorted([t for b in branches for t in b], key=lambda t: t[1])
|
||||
cap = 1000.0
|
||||
eq_ts, eq_v, rets = [], [], []
|
||||
for i, j, net in allt:
|
||||
rets.append(net * POS / K)
|
||||
cap = max(cap + cap * POS * LEV * net / K, 10.0)
|
||||
eq_ts.append(ts[j])
|
||||
eq_v.append(cap)
|
||||
eq = pd.Series(eq_v, index=pd.DatetimeIndex(eq_ts))
|
||||
pk = eq.cummax()
|
||||
n = len(rets)
|
||||
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(n / yrs_span)) if n > 1 and np.std(rets) > 0 else 0.0
|
||||
return dict(n=n, ret=float((eq.iloc[-1] / 1000 - 1) * 100),
|
||||
sharpe=sharpe, dd=float(((pk - eq) / pk).max() * 100))
|
||||
|
||||
|
||||
def run():
|
||||
M = aligned_panel()
|
||||
ts = pd.to_datetime(M.index, unit="ms", utc=True)
|
||||
n = len(M)
|
||||
print("=" * 96)
|
||||
print(" XS01 phase-tranching — sensibilita' di fase + ensemble K=2/3 | griglia pre-registrata")
|
||||
print("=" * 96)
|
||||
for tag, split in (("FULL", 0.0), ("OOS", 1 - OOS_FRAC)):
|
||||
yrs_span = (ts[-1] - ts[max(int(n * split), LB)]).days / 365.25 or 1
|
||||
rows = []
|
||||
for ph in range(HOLD):
|
||||
tr = xsec_trades(phase=ph, split_frac=split, M=M)
|
||||
rows.append(metrics(tr, ts, yrs_span))
|
||||
sh = np.array([r["sharpe"] for r in rows])
|
||||
dd = np.array([r["dd"] for r in rows])
|
||||
rt = np.array([r["ret"] for r in rows])
|
||||
print(f"\n [{tag}] sensibilita' di fase (12 fasi):")
|
||||
print(f" Sharpe: min {sh.min():.2f} | med {np.median(sh):.2f} | max {sh.max():.2f} | std {sh.std():.2f}")
|
||||
print(f" ret%: min {rt.min():+.0f} | med {np.median(rt):+.0f} | max {rt.max():+.0f}")
|
||||
print(f" DD%: min {dd.min():.1f} | med {np.median(dd):.1f} | max {dd.max():.1f}")
|
||||
for K in (2, 3):
|
||||
branches = [xsec_trades(phase=int(p), split_frac=split, M=M)
|
||||
for p in np.linspace(0, HOLD, K, endpoint=False)]
|
||||
m = combined_metrics(branches, ts, yrs_span)
|
||||
print(f" K={K} tranched: Sharpe {m['sharpe']:.2f} | ret {m['ret']:+.0f}% | "
|
||||
f"DD {m['dd']:.1f}% | trade {m['n']}")
|
||||
print("\n criterio: tranched-Sharpe OOS >= worst-fase OOS e DD <= mediana fasi; "
|
||||
"se la dispersione di fase e' gia' piccola -> NON SERVE (verdetto onesto).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -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()
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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])
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -79,6 +79,13 @@ HONEST = [
|
||||
]
|
||||
PAIRS = [
|
||||
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_ADAETH", a="ADA", b="ETH", cluster="ETH-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",
|
||||
params={"last_block_only": True})
|
||||
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 = {
|
||||
"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
|
||||
# (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.
|
||||
"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),
|
||||
}
|
||||
|
||||
@@ -115,7 +115,11 @@ def collect():
|
||||
nr = ev.get("net_return", 0.0)
|
||||
pnl = ev.get("pnl", 0.0)
|
||||
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:
|
||||
continue # multi-asset (TR01/ROT02/TSM01): sezione dedicata
|
||||
if st.get("in_position"):
|
||||
@@ -141,6 +145,13 @@ def multi_asset_section() -> str:
|
||||
d = Path(sp).parent
|
||||
st = json.loads(Path(sp).read_text())
|
||||
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:
|
||||
continue # single-leg/pairs: gia' coperti da collect()
|
||||
held = {a: v for a, v in book.items() if v > 0}
|
||||
|
||||
@@ -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()
|
||||
@@ -86,7 +86,11 @@ class BasketTrendWorker:
|
||||
self.positions[a] = target
|
||||
self.last_bar_ts[a] = bar_ts
|
||||
if rets:
|
||||
self.capital = max(self.capital * (1 + float(np.mean(rets))), 10.0)
|
||||
# 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._save()
|
||||
|
||||
|
||||
+62
-5
@@ -119,6 +119,33 @@ class Fill:
|
||||
verified: bool # posizione/trade riscontrati su Deribit
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
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:
|
||||
@@ -233,6 +260,15 @@ class ExecutionClient:
|
||||
fee_usd = fee_coin if spec.get("linear") else (
|
||||
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);
|
||||
# limit = accettato in book ('open') o gia' eseguito ('filled');
|
||||
# stop_market = trigger accettato ('untriggered' finche' il mark non tocca)
|
||||
@@ -242,9 +278,13 @@ class ExecutionClient:
|
||||
verified = state in ("untriggered", "open", "filled")
|
||||
else:
|
||||
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,
|
||||
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,
|
||||
label: str | None = None) -> Fill:
|
||||
@@ -255,16 +295,33 @@ class ExecutionClient:
|
||||
|
||||
def close_amount(self, instrument: str, entry_side: str, amount: float,
|
||||
label: str | None = None) -> Fill:
|
||||
"""Chiude SOLO la quota del worker: market reduce_only di lato opposto,
|
||||
stesso `amount` dell'apertura. Non usa close_position (flatterebbe anche
|
||||
le quote degli altri worker sullo stesso strumento)."""
|
||||
"""Chiude SOLO la quota del worker. Non usa close_position (flatterebbe
|
||||
anche 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)
|
||||
# quantizza difensivamente: il chiamante puo' passare un residuo
|
||||
# (amount aperto − fill parziale del TP) con artefatti float
|
||||
amount = _quantize_step(amount, spec["step"], spec["min"]) if amount > 0 else 0.0
|
||||
opp = "sell" if entry_side == "buy" else "buy"
|
||||
return self._submit(instrument, opp, amount, 0.0,
|
||||
fill = self._submit(instrument, opp, amount, 0.0,
|
||||
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,
|
||||
tp_price: float, label: str | None = None) -> Fill:
|
||||
|
||||
+95
-23
@@ -40,6 +40,7 @@ class PairsWorker:
|
||||
data_dir: Path = Path("data/paper_trades"),
|
||||
executor=None, # PairsExecutionClient: esecuzione REALE shadow a 2 gambe
|
||||
exec_instruments: dict | None = None, # {asset: instrument USDC}
|
||||
real_truth: bool = False,
|
||||
):
|
||||
self.asset_a = asset_a
|
||||
self.asset_b = asset_b
|
||||
@@ -51,6 +52,11 @@ class PairsWorker:
|
||||
self.z_exit = float(p.get("z_exit", 0.75))
|
||||
self.max_bars = int(p.get("max_bars", 72))
|
||||
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.position_size = position_size
|
||||
@@ -71,6 +77,7 @@ class PairsWorker:
|
||||
self.entry_z = 0.0
|
||||
self.entry_time = ""
|
||||
self.bars_held = 0
|
||||
self.exit_ready = False # flat-skip: condizione di uscita armata, attende barra pulita
|
||||
self.total_trades = 0
|
||||
self.total_wins = 0
|
||||
self.last_bar_ts = 0
|
||||
@@ -82,6 +89,9 @@ class PairsWorker:
|
||||
self.inst_a = self.exec_instruments.get(asset_a)
|
||||
self.inst_b = self.exec_instruments.get(asset_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_in_position = False
|
||||
self.real_dir = 0
|
||||
@@ -96,6 +106,7 @@ class PairsWorker:
|
||||
self.real_entry_fee = 0.0
|
||||
self.real_trades = 0
|
||||
self.real_first_notified = False
|
||||
self.orphan_legs: list[dict] = [] # gambe respinte dal netting (persistite)
|
||||
|
||||
self._load_state()
|
||||
self._save_state()
|
||||
@@ -117,6 +128,7 @@ class PairsWorker:
|
||||
self.entry_z = s.get("entry_z", 0.0)
|
||||
self.entry_time = s.get("entry_time", "")
|
||||
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_wins = s.get("total_wins", 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_trades = s.get("real_trades", 0)
|
||||
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),
|
||||
"total_trades": self.total_trades, "in_position": self.in_position,
|
||||
"real_capital": round(self.real_capital, 2),
|
||||
@@ -145,7 +158,8 @@ class PairsWorker:
|
||||
"capital": round(self.capital, 2), "in_position": self.in_position,
|
||||
"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,
|
||||
"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,
|
||||
"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,
|
||||
@@ -155,6 +169,7 @@ class PairsWorker:
|
||||
"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_first_notified": self.real_first_notified,
|
||||
"orphan_legs": self.orphan_legs,
|
||||
}
|
||||
with open(self.status_path, "w") as f:
|
||||
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_time = datetime.now(timezone.utc).isoformat()
|
||||
self.bars_held = 0
|
||||
self.exit_ready = False
|
||||
data = {"direction": "long_ratio" if d == 1 else "short_ratio",
|
||||
"long_leg": self.asset_a if d == 1 else self.asset_b,
|
||||
"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._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
|
||||
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:
|
||||
return
|
||||
return None, False
|
||||
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,
|
||||
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_b = pf.leg_b.fill_price or sim_b
|
||||
# PnL per gamba: dir A = +d (long ratio compra A), dir B = -d
|
||||
da, db = self.real_dir, -self.real_dir
|
||||
gross = (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_a = da * (exit_a - self.real_entry_a) / self.real_entry_a * self.real_notional_a
|
||||
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
|
||||
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_trades += 1
|
||||
self._log("REAL_CLOSE_PAIR", {
|
||||
"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),
|
||||
"entry_fee": round(self.real_entry_fee, 5), "exit_fee": round(exit_fee, 5),
|
||||
"real_capital": round(self.real_capital, 4), "verified": pf.verified})
|
||||
if not pf.verified:
|
||||
self._notify("REAL_CLOSE_FAILED", {"worker": self.worker_id, "note": pf.notes})
|
||||
for ok, inst, side, amt in ((ok_a, self.inst_a, self.real_side_a, self.real_amount_a),
|
||||
(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_dir = 0
|
||||
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_entry_fee = 0.0
|
||||
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):
|
||||
if not self.in_position:
|
||||
@@ -264,9 +311,17 @@ class PairsWorker:
|
||||
gross = (ret_a - ret_b) * self.direction * self.leverage
|
||||
fee = 2 * self.fee_rt * self.leverage # 2 gambe
|
||||
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)
|
||||
is_win = net > 0
|
||||
is_win = pnl > 0
|
||||
self.total_trades += 1
|
||||
self.total_wins += is_win
|
||||
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),
|
||||
"capital": round(self.capital, 2), "bars_held": self.bars_held,
|
||||
"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)
|
||||
if self.execution_enabled:
|
||||
self._real_close_pair(ca, cb, reason, pnl)
|
||||
self.in_position = False
|
||||
self.direction = 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."""
|
||||
if df_a is None or df_b is None or df_a.empty or df_b.empty:
|
||||
return
|
||||
m = df_a[["timestamp", "close"]].rename(columns={"close": "ca"}).merge(
|
||||
df_b[["timestamp", "close"]].rename(columns={"close": "cb"}), on="timestamp", how="inner"
|
||||
).sort_values("timestamp").reset_index(drop=True)
|
||||
# merge OHLC quando disponibile (serve a rilevare le candele flat per il flat-skip);
|
||||
# se le colonne OHLC mancano, flat resta False -> comportamento close-only invariato.
|
||||
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
|
||||
# barra COMPLETA, come il backtest (pairs_research: close settled) —
|
||||
# lezione EXIT-16. Detection condivisa: src.live.bars.
|
||||
@@ -298,7 +360,7 @@ class PairsWorker:
|
||||
m = m.iloc[:-1]
|
||||
if len(m) < self.n + 2:
|
||||
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)
|
||||
i = len(m) - 1
|
||||
cur_ts = int(m["timestamp"].iloc[i])
|
||||
@@ -306,19 +368,29 @@ class PairsWorker:
|
||||
if np.isnan(zi):
|
||||
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 cur_ts > self.last_bar_ts:
|
||||
self.bars_held += 1
|
||||
self.last_bar_ts = cur_ts
|
||||
if abs(zi) <= self.z_exit:
|
||||
self._close(float(ca[i]), float(cb[i]), float(zi), "mean_revert")
|
||||
elif self.bars_held >= self.max_bars:
|
||||
self._close(float(ca[i]), float(cb[i]), float(zi), "time_limit")
|
||||
# arma l'uscita: |z|<=z_exit (rientro) o time-limit; poi esegui alla 1a barra pulita
|
||||
if not self.exit_ready and (abs(zi) <= self.z_exit or self.bars_held >= self.max_bars):
|
||||
self.exit_ready = True
|
||||
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()
|
||||
return
|
||||
|
||||
# flat: cerca ingresso (no look-ahead: z[i] usa solo dati <= i)
|
||||
if dr[i] <= self.jump_max:
|
||||
# cerca ingresso (no look-ahead: z[i] usa solo dati <= i); mai su barra stale
|
||||
if dr[i] <= self.jump_max and not flat_i:
|
||||
if zi <= -self.z_in:
|
||||
self._open(1, float(ca[i]), float(cb[i]), float(zi)); self.last_bar_ts = cur_ts
|
||||
elif zi >= self.z_in:
|
||||
|
||||
+103
-20
@@ -38,6 +38,7 @@ class StrategyWorker:
|
||||
data_dir: Path = Path("data/paper_trades"),
|
||||
executor: ExecutionClient | None = None,
|
||||
exec_instrument: str | None = None,
|
||||
real_truth: bool = False,
|
||||
):
|
||||
self.strategy = strategy
|
||||
self.asset = asset
|
||||
@@ -53,6 +54,11 @@ class StrategyWorker:
|
||||
self.executor = executor
|
||||
self.exec_instrument = 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_in_position = False
|
||||
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_trades = 0
|
||||
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.work_dir = data_dir / self.worker_id
|
||||
@@ -241,13 +249,16 @@ class StrategyWorker:
|
||||
}
|
||||
if fill.verified:
|
||||
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_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_fee_usd = fill.fee_usd
|
||||
self.real_entry_notional = (fill.amount * self.real_entry_price
|
||||
if linear else fill.amount)
|
||||
self.real_entry_notional = (real_amt * self.real_entry_price
|
||||
if linear else real_amt)
|
||||
self.real_order_id = fill.order_id or ""
|
||||
self._log("REAL_OPEN", data)
|
||||
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:
|
||||
# sim e reale stanno tradando prezzi diversi (spike print/feed stantio):
|
||||
# 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._place_real_tp()
|
||||
self._place_disaster_sl()
|
||||
@@ -316,7 +328,8 @@ class StrategyWorker:
|
||||
else:
|
||||
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.
|
||||
|
||||
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
|
||||
viene chiusa a mercato (fallback, o exit non-TP: stop-loss/time_limit).
|
||||
L'uscita take-profit reale avviene cosi' AL livello come nel backtest,
|
||||
non al poll post-rimbalzo."""
|
||||
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:
|
||||
return
|
||||
return None, False
|
||||
from src.live.execution import contract_spec
|
||||
step = contract_spec(self.exec_instrument)["step"]
|
||||
|
||||
@@ -381,7 +398,22 @@ class StrategyWorker:
|
||||
if remainder >= step / 2:
|
||||
fill = self.executor.close_amount(self.exec_instrument, self.real_side,
|
||||
remainder, label=self.worker_id)
|
||||
market_amt = fill.amount if (fill and fill.verified) else 0.0
|
||||
# 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
|
||||
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
|
||||
if exit_price and sim_exit else None)
|
||||
if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS:
|
||||
self._notify("REAL_DIVERGENCE", {
|
||||
"fase": "close", "reason": reason, "sim_exit": round(sim_exit, 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)})
|
||||
div = {"fase": "close", "reason": reason, "sim_exit": round(sim_exit, 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)}
|
||||
# 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", {
|
||||
"reason": reason,
|
||||
"order_id": fill.order_id if fill else tp_order_id,
|
||||
@@ -434,6 +469,43 @@ class StrategyWorker:
|
||||
self.real_order_id = ""
|
||||
self.real_tp_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):
|
||||
if not self.in_position:
|
||||
@@ -442,9 +514,17 @@ class StrategyWorker:
|
||||
price_change = (current_price - self.entry_price) / self.entry_price
|
||||
trade_return = price_change * self.direction
|
||||
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 = max(self.capital, 0)
|
||||
self.total_trades += 1
|
||||
@@ -466,12 +546,15 @@ class StrategyWorker:
|
||||
"total_trades": self.total_trades,
|
||||
"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._notify("CLOSED", trade_data)
|
||||
|
||||
if self.execution_enabled:
|
||||
self._real_close(current_price, reason, pnl)
|
||||
|
||||
self.in_position = False
|
||||
self.direction = 0
|
||||
self.entry_price = 0
|
||||
@@ -525,14 +608,14 @@ class StrategyWorker:
|
||||
if not np.isfinite(buf):
|
||||
buf = 0.0
|
||||
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")
|
||||
elif confirm_close < self.sl - buf:
|
||||
self._close_position(current_price, "stop_loss")
|
||||
elif self.max_bars and self.bars_held >= self.max_bars:
|
||||
self._close_position(current_price, "time_limit")
|
||||
else:
|
||||
if bar_low <= self.tp:
|
||||
if bar_low <= self.tp and not self._tp_phantom(current_price, current_ts):
|
||||
self._close_position(self.tp, "take_profit")
|
||||
elif confirm_close > self.sl + buf:
|
||||
self._close_position(current_price, "stop_loss")
|
||||
@@ -544,14 +627,14 @@ class StrategyWorker:
|
||||
if self.direction == 1:
|
||||
if bar_low <= self.sl:
|
||||
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")
|
||||
elif self.max_bars and self.bars_held >= self.max_bars:
|
||||
self._close_position(current_price, "time_limit")
|
||||
else:
|
||||
if bar_high >= self.sl:
|
||||
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")
|
||||
elif self.max_bars and self.bars_held >= self.max_bars:
|
||||
self._close_position(current_price, "time_limit")
|
||||
|
||||
@@ -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
@@ -22,6 +22,7 @@ from src.live.pairs_worker import PairsWorker
|
||||
from src.live.basket_trend_worker import BasketTrendWorker
|
||||
from src.live.rotation_worker import RotationWorker
|
||||
from src.live.tsmom_worker import TsmomWorker
|
||||
from src.live.xsec_worker import CrossSectionalWorker
|
||||
from src.live.strategy_loader import load_strategy
|
||||
|
||||
# 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",
|
||||
"DIP01": "DIP01_dip_buy",
|
||||
}
|
||||
_MULTI_KINDS = ("basket", "rotation", "tsmom")
|
||||
_MULTI_KINDS = ("basket", "rotation", "tsmom", "xsec")
|
||||
DATA_DIR = Path("data/portfolio_paper")
|
||||
|
||||
# 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
|
||||
# margine ampio per il walk-forward. Difensivo: non dipende dal fetch 440g di TSM01/ROT02.
|
||||
_ML_LOOKBACK_DAYS = 365
|
||||
|
||||
|
||||
def pos_for_spec(sid: str, global_ps: float, family_overrides: dict[str, float]) -> float:
|
||||
"""position_size effettivo di uno sleeve: override per-famiglia (chiave =
|
||||
weighting.family_of: PAIRS/FADE/HONEST/SHAPE/TSM) o globale."""
|
||||
def pos_for_spec(sid: str, global_ps: float, family_overrides: dict[str, float],
|
||||
sleeve_ps: float | None = None) -> float:
|
||||
"""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
|
||||
if sleeve_ps is not None:
|
||||
return float(sleeve_ps)
|
||||
return family_overrides.get(family_of(sid), global_ps)
|
||||
|
||||
|
||||
def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
|
||||
data_dir: Path = DATA_DIR, position_size: float = 0.15,
|
||||
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.
|
||||
|
||||
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
|
||||
(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":
|
||||
return PairsWorker(
|
||||
asset_a=spec.a, asset_b=spec.b, tf=spec.tf, params=spec.params,
|
||||
capital=alloc_capital, position_size=position_size, leverage=leverage,
|
||||
fee_rt=0.001, name="PR01_pairs_reversion", data_dir=data_dir,
|
||||
executor=pairs_executor, exec_instruments=exec_instruments,
|
||||
real_truth=real_truth,
|
||||
)
|
||||
if spec.kind == "basket":
|
||||
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),
|
||||
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)
|
||||
if module is None:
|
||||
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(
|
||||
strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital,
|
||||
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_instr = _exec_cfg.get("instruments", {}) or {}
|
||||
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
|
||||
pairs_executor = None
|
||||
if exec_enabled:
|
||||
@@ -270,7 +293,8 @@ def run(config_path: str = "portfolios.yml"):
|
||||
# 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
|
||||
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:
|
||||
from src.live.execution import PairsExecutionClient
|
||||
pairs_executor = PairsExecutionClient(leg=executor)
|
||||
@@ -301,9 +325,10 @@ def run(config_path: str = "portfolios.yml"):
|
||||
ex, inst = _exec_for(s)
|
||||
pex, pinst = _pairs_exec_for(s)
|
||||
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,
|
||||
pairs_executor=pex, exec_instruments=pinst)
|
||||
pairs_executor=pex, exec_instruments=pinst,
|
||||
real_truth=real_truth)
|
||||
if 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_workers = {s.sid: build_worker_for(s, paper_notional, p.leverage,
|
||||
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}
|
||||
|
||||
# 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
|
||||
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)
|
||||
days = _LOOKBACK_DAYS.get(tf, 90)
|
||||
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:
|
||||
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)
|
||||
last_day = ""
|
||||
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.
|
||||
_OUTAGE_POLLS = 5
|
||||
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:
|
||||
"""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)
|
||||
_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):
|
||||
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
|
||||
res = {a: _resample(raw1h[a], tf) for a in assets}
|
||||
if s.kind == "pairs":
|
||||
w.tick(res[s.a], res[s.b])
|
||||
elif s.kind in _MULTI_KINDS:
|
||||
@@ -413,11 +469,27 @@ def run(config_path: str = "portfolios.yml"):
|
||||
# single (fade/dip): StrategyWorker su feed live.
|
||||
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:
|
||||
_tick(s, workers[s.sid])
|
||||
_tick_safe(s, workers[s.sid])
|
||||
# PAPER: ticcati per statistica, MAI nel ledger del portafoglio
|
||||
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()})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
# 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.
|
||||
assert r.full["sharpe"] == pytest.approx(6.47, abs=0.15)
|
||||
assert r.oos["sharpe"] == pytest.approx(8.82, abs=0.25)
|
||||
assert r.full["dd"] == pytest.approx(4.1, abs=0.5)
|
||||
# Aggiornato 2026-06-09: aggiunto lo sleeve BLEND PR_ETHBTC_15M (ETH/BTC pairs 15m
|
||||
# flat-skip, mezza size) -> FULL 6.47->7.20, OOS 8.82->9.66, DD 4.1%->3.7%.
|
||||
# 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)
|
||||
|
||||
@@ -8,8 +8,9 @@ def test_six_portfolios_defined():
|
||||
def test_port06_is_master_shape_cap():
|
||||
p = PORTFOLIOS["PORT06"]
|
||||
sids = set(p.sleeve_ids)
|
||||
assert {"SH_BTC", "SH_ETH", "TSM01", "PR_ETHBTC"} <= sids
|
||||
assert len(sids) == 17
|
||||
assert {"SH_BTC", "SH_ETH", "TSM01", "PR_ETHBTC", "PR_ETHBTC_15M", "XS01"} <= sids
|
||||
# 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
|
||||
# (ricerca sh01_exit_lab: 11 famiglie di stop, 0 sopravvissute)
|
||||
assert p.weighting == "cap" and p.caps == {"PAIRS": 0.33, "SHAPE": 0.0588}
|
||||
|
||||
@@ -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
|
||||
@@ -28,7 +28,7 @@ class FakeExec:
|
||||
|
||||
def close_amount(self, instrument, side, amount, label=None):
|
||||
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):
|
||||
@@ -53,7 +53,8 @@ class FakeFullExec(FakeExec):
|
||||
|
||||
def close_amount(self, instrument, side, amount, label=None):
|
||||
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):
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user