merge: ondata 2026-07-25 — XSR01 in forward-monitor, 2 refutazioni, fix split feed equity

Filone 'trova nuove strategie'. Book e pesi INVARIATI; il conto live non e' toccato.

- fix DATO sul libro live: split non aggiustati IWM/EFA (2005-06-09) che la
  certificazione non vedeva (soglia maxret>50% vs split 2:1 = esattamente -50%).
  GTAA01 FULL Sh 0.61->0.64, IS 0.49->0.54; OOS e maxDD invariati -> il difetto
  sottostimava lo sleeve, nessuna decisione presa va rivista.
- MAT01 (trend su 18 ETF multi-asset) SCARTATO: a pari volatilita' il minor DD si
  inverte -> de-levering. GTAA6 e' gia' al soffitto d'ampiezza.
- STATARB-EQ (12 coppie ETF, 28.5 anni) SCARTATO: il meccanismo non si trasferisce
  alle azioni; il lordo e' -0.17 e il resto e' turnover.
- STATARB-MULTI: ETH/BTC e' al 58° pctl fra le 50 coppie -> per il gate 27/09
  l'ipotesi 'fortuna di una coppia' e' refutata. Addendum pre-registrato al gate.
- XSR01 CANDIDATO NUOVO in forward-monitor (gate 2026-10-23): cross-sectional
  residual dollar-neutral su 50 alt HL, Sharpe netta 1.82, DSR 0.985, corr ~0 a
  tutti e 5 gli sleeve; fuori dal book per storia corta, tilt-null fallito e
  margine di costo sottile.

Da questo merge il cron giornaliero avvia l'accumulo della finestra forward XSR01.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-25 09:50:29 +00:00
19 changed files with 2609 additions and 6 deletions
+1
View File
@@ -67,6 +67,7 @@ scripts/research/blind/leaderboard.json
data/paper_prevday/ data/paper_prevday/
data/paper_combo/ data/paper_combo/
data/paper_statarb/ data/paper_statarb/
data/paper_xsr/
# log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale) # log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale)
data/live/ data/live/
+83
View File
@@ -334,6 +334,89 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
SEA + expiry + event-clock + 375 trial weekend ⇒ su BTC/ETH nessun edge di calendario/finestra SEA + expiry + event-clock + 375 trial weekend ⇒ su BTC/ETH nessun edge di calendario/finestra
oraria; riaprire solo con meccanismo non-calendario. Book/pesi INVARIATI. Diario oraria; riaprire solo con meccanismo non-calendario. Book/pesi INVARIATI. Diario
`2026-07-17-weekend-window.md`; script `scripts/research/r0717_wk_{drift,cond,intra,overlay}.py`. `2026-07-17-weekend-window.md`; script `scripts/research/r0717_wk_{drift,cond,intra,overlay}.py`.
- **Ondata 2026-07-25 (MAT01 + generalizzazione STATARB) — 0 sleeve nuovi, 1 DIFETTO DI DATO sul
libro live trovato e riparato.** Diario `2026-07-25-mat01-statarb-generalization.md`.
(1) **MAT01** (`r0725_mat01_multiasset_trend.py`, `r0725_mat01b_regime.py`): allargare il trend
difensivo da 6 a **18 ETF multi-asset-class** (i 12 su disco dal 22/06, mai usati per un trend)
a meccanismo GTAA CONGELATO = **SCARTATO**. MAT18 perde 3/4 finestre disgiunte; **a PARI VOL il
suo minor DD si inverte** (20.6% vs 15.4%) → era solo de-levering (**3ª occorrenza del null
de-levering** dopo VRP-DD e TP01×DVOL: e' il primo test da fare su ogni claim "meno drawdown");
corr→crypto invariata (0.110 vs 0.116) → zero guadagno di diversificazione; book 2.22→2.19.
**Risultato positivo da conservare:** la curva d'ampiezza e' monotona (mediana OOS 0.44 a k=1 →
**0.80 a k=18**, DD 16.0%→−6.9%, satura a k≈10-12) — l'ampiezza E' un meccanismo reale, ma
**GTAA6 sta gia' al 95° pctl dei 6-subset casuali** (e non e' cherry-picked: contiene TLT, il
peggiore dei 18) → **niente piu' ampiezza da raccogliere su questo sleeve**.
(2) **STATARB-MULTI** (`r0725_statarb_multi.py`): meccanismo congelato (W=45, sgn=+1) su tutte le
**50 coppie alt/BTC** HL, out-of-pair-sample. REGGE: 82% Sharpe>0 (media 0.38), **0/50 degeneri**
(mono 53% → non e' la statica short-alt travestita), perm p<0.05 nel 18% (atteso 5%), paniere
Sharpe 0.82 / DD 6.0% / corr XS01 0.207. **E ETH/BTC e' al rango 22/50 (58° pctl): la coppia
scopritrice NON e' un outlier** → per il gate 27/09 l'ipotesi "fortuna di una coppia" e' refutata.
NON e' uno sleeve: ampiezza **effettiva ~4.5** (corr media 0.204, gamba BTC condivisa), paniere
**IC95% [0.12,1.72], t 1.31** (non distinguibile da zero a 2.6 anni), uplift 0.11 vs il null
a priori "sempre short alt vs BTC", 51 gambe = STAT-MODE. ⚠ Errore di metodo corretto in-sessione:
il primo null statico usava `sign(mean(segnale))` sull'intero campione = **look-ahead**; rifatto
causale (media espandente) + a priori.
(3) **STATARB-EQ** (`r0725_statarb_eq.py`): stesso meccanismo su **12 coppie ETF a priori,
28.5 anni, ampiezza effettiva 9.9** = **SCARTATO**. Paniere Sharpe **1.00** (IC95% [1.36,0.64],
t 5.33), negativo in **4/4 decadi**, 0% coppie a perm p<0.05. **Ma la lettura sta nel LORDO
(0.17, non 1.00): due terzi sono drag di turnover (0.40/g)** → **non esiste strategia specchio**
(sgn=1 sarebbe lorda +0.17, netta 0.16). Il segno lordo dice che **sulle azioni il residuo
REVERTE debolmente mentre sul crypto CONTINUA** → il meccanismo e' plausibilmente crypto-specifico.
Inferenza onesta: non prova che il crypto sia falso, ma il gate 27/09 **non puo' appoggiarsi**
all'argomento "fenomeno universale".
(4) **Addendum PRE-REGISTRATO al gate STATARB** (registrato a forward-day 26/90, 64g prima della
decisione): `r0724_statarb_deploy_gate.py` riporta ora anche lo Sharpe della **statica sempre-short
a pari vol-target sulla stessa finestra**. **Soglie del 24/07 NON toccate** (diagnostica non
binding; renderla binding e' decisione dell'operatore). Lettura odierna (26 barre): STATARB +5.70
vs benchmark **4.98**, e la posizione corrente e' LONG lo spread = opposta alla statica → la
finestra forward non e' spiegata dal beta di regime.
-**XSR01 "Cross-Sectional Residual" — CANDIDATO NUOVO in forward-monitor (2026-07-25).**
`scripts/live/paper_xsr.py`; scoperta `r0725_statarb_basket_gate.py`, scettico
`r0725_statarb_demean_skeptic.py`, gate `r0725_xsr_deploy_gate.py`, test `tests/test_paper_xsr.py`.
**Cos'e':** il meccanismo congelato di STATARB-RESID (W=45, sgn=+1, residuo OLS causale su BTC,
z-score, tanh, vol-target 20%) applicato ai **50 alt HL**, con posizioni **DEMEANATE
cross-sezionalmente** ogni giorno. Il demeaning annulla ALGEBRICAMENTE la gamba BTC comune
(`sum(p_ip̄)(r_ir_btc) = sum(p_ip̄)r_i`) → non e' un paniere di coppie ma una **strategia
cross-sectional dollar-neutral**, e l'ampiezza effettiva passa da **4.5 a 37.4**.
**Numeri (2.6 anni):** Sharpe netta **1.82** (lorda 2.70), maxDD **2.6%**, vol 2.3%, ret 4.2%/a.
**Gate superati:** marginale **ADDS** (robust_oos + beats_noise_null + non-hedge +
has_insample_edge); **deflated-Sharpe 0.985 PASS**; **corr ~0 a TUTTI e 5 gli sleeve** (TP01 0.060,
XS01 0.019, VRP01 0.001, SKH01 0.001, GTAA01 0.071); book a w=15% HOLD 2.36→**2.51**, DD invariato.
**Scettico superato:** 3 null **a fee-neutrale** (permutazione cross-sezionale / casuale /
temporale a blocchi) centrati a ~0.01, candidato lordo 2.70 **sopra il massimo di 300 estrazioni**
(p<0.004); **lag 1.82/1.19/0.81/0.51** a +0/1/2/3g = decadimento dolce di segnale lento, NON
firma di look-ahead; non ridondante con XS-momentum semplice (corr 0.17-0.29).
**NON nel book, 3 motivi dichiarati:** (a) storia 2.6a monoregime e **crescente** (Sh 2024 1.03 /
2025 1.98 / 2026 3.11) → l'edge e' recente; (b) **`weights_tilt_null` FALLITO** (gate_pass=False a
10% e 15%, delta_insample 0.004/0.007: effetto della storia corta, ma un gate fallito resta
fallito); (c) **margine di costo sottile** — 1.82 a 0.05%/gamba, **0.93 a 0.10%, NEGATIVA a 0.20%**,
con turnover 38.5% del lordo/g su 50 alt anche illiquidi e **slippage NON modellato** (rischio #1).
**Eseguibilita':** ticket/gamba $1.73 a $600 (**sotto min-order $5 → STAT-MODE oggi**), $5.76 a
$2000, **$14.41 a $5000** → diventa reale a **~$5k**, non ai ~$20k di XS01.
**Gate pre-registrato 2026-10-23** (forward-day 0): deploy solo se Sharpe≥1.0 **E** haircut di
eseguibilita' a $5000 ≤40% (se l'haircut sfonda → **RITIRO a prescindere dallo Sharpe**), poi
weights_tilt_null e capitale ≥$5k. Monitor in `cron_daily.sh`, 3 libri (MODELED $2000 / REAL $600 /
REAL $5000). ⚠ Lezione: XSR01 **non e' nato da un'idea nuova ma dalla DIAGNOSI di un fallimento**
(ampiezza effettiva 4.5 → c'e' un fattore comune da togliere); e i suoi null vanno confrontati
**a fee zero**, perche' permutare un segnale ne fa esplodere il turnover e il null perderebbe per
costo invece che per assenza di informazione (p-value trionfale e falso).
-**SPLIT NON AGGIUSTATI nel feed equity — difetto sul LIBRO LIVE, riparato (2026-07-25).**
`data/raw/eq_iwm_1d.parquet` ed `eq_efa_1d.parquet` avevano uno split non aggiustato il
**2005-06-09** (IWM 2:1 = 49.5%; EFA 3:1 = 66.5%); IB `ADJUSTED_LAST` non li aveva aggiustati.
**La certificazione non li vedeva per un punto cieco strutturale:** l'unica guardia era
`maxret > 50% → SPIKE?` e uno split 2:1 fa **esattamente 50%** → IWM passava a 49.5% con status
OK. **IWM e' una delle 6 gambe di GTAA01 in produzione.** Impatto: GTAA6 FULL Sh 0.61→**0.64**,
IS 0.49→**0.54**, OOS 2015+ e maxDD **INVARIATI** (artefatto nel 2005, fuori hold-out) → il
difetto SOTTOSTIMAVA lo sleeve, **nessuna decisione presa va rivista**. Discriminante
split-vs-crollo (riusabile): **non il rapporto** (SLV 2026-01-30 ha rapporto 1.3994, a 4bps da
1.4, ma e' un crollo vero: GLD 10.3% lo stesso giorno) ma il **RANGE INTRADAY** — lo split apre
gia' al nuovo livello con range normale (IWM: open 47.00, range 1.7%), il crollo si muove DENTRO
la barra (SLV: range 33%). Modulo `src/data/eq_splits.py` (`detect_unadjusted_splits` a 3
condizioni congiunte + `repair_splits` componibile), riparazione **in lettura** in
`src/portfolio/gtaa.py::_close` e `scripts/research/eqlib.py::load_eq`, status
**`SPLIT-NON-AGG`** in `fetch_ib_equities.certify()`, test `tests/test_eq_splits.py` (8 casi).
**Regola nuova: ogni soglia di certificazione tarata su un valore tondo va controllata contro il
difetto che genera esattamente quel valore** (una soglia a 50% non puo' sorvegliare gli split 2:1).
- **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso: - **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso:
cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55. cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55.
- **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.** - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.**
@@ -0,0 +1,295 @@
# 2026-07-25 — Caccia a strategie nuove: MAT01 (trend multi-asset) + generalizzazione di STATARB, e uno SPLIT non aggiustato nel feed equity
Goal di sessione: **trovare nuove strategie**. Tre filoni scelti cercando i vuoti reali nella
mappa (89 diari, famiglie calendario/funding/VRP/CRT gia' dichiarate sature). Esito: **0 sleeve
nuovi**, 2 refutazioni pulite, 1 **difetto di dato che tocca il libro live** trovato e riparato,
1 addendum pre-registrato al gate STATARB del 27/09.
Book e pesi **INVARIATI**.
---
## (1) MAT01 — trend difensivo su 18 ETF multi-asset-class invece di 6 → **SCARTATO**
Script: `scripts/research/r0725_mat01_multiasset_trend.py`, `r0725_mat01b_regime.py`.
**Il vuoto.** GTAA01 gira su 6 ETF (SPY QQQ IWM TLT GLD HYG), 4 su 6 azionari US. I 12 ETF
multi-classe (DIA EFA EEM FXI EWJ AGG LQD IEF USO SLV DBC VNQ) sono su disco certificati dal
2026-06-22 con 9-30 anni di storia, ma erano stati usati SOLO per il test lead-lag crypto→X
(diario 2026-06-23-crossmarket-beyond-sp500), mai per un programma di trend. Il TSMOM
multi-asset-class e' l'anomalia meglio replicata della finanza quantitativa: valeva il test.
**Disciplina.** Meccanismo CONGELATO (import di `_exposure` da `src/portfolio/gtaa.py`: orizzonti
21/63/126/252, vol-target 12%, long-flat, EW, fee 2bps/lato). Cambia SOLO l'universo. L'universo
ALL18 e' definito a priori per classe d'attivo: **a k=18 la liberta' di selezione e' zero**
(esiste un solo sottoinsieme di 18 su 18), quindi il confronto ALL18-vs-GTAA6 non e' selezionabile.
**Risultati.**
| | Sh FULL | Sh IS<2015 | Sh OOS2015+ | maxDD OOS | vol |
|---|---|---|---|---|---|
| GTAA6 (incumbent) | 0.62 | 0.49 | **0.89** | 8.2% | 6.05% |
| MAT18 (candidato) | 0.56 | 0.43 | 0.80 | 6.9% | 4.62% |
| MAT18-CLS (per classe) | 0.57 | — | 0.73 | — | 4.38% |
corr(GTAA6, MAT18) = 0.91. Sostituzione nel book a peso invariato 20%: Sharpe FULL 2.22→2.19,
HOLD 2.36→2.38, DD 6.2%→6.6%. Corr→crypto praticamente identica (0.110 vs 0.116): **nessun
guadagno di diversificazione**, che era l'unica ragione plausibile per farlo.
**Perche' scartato — quattro ragioni indipendenti:**
1. **Finestre disgiunte: MAT18 perde 3/4** (1998-2004 0.28 vs 0.40; 2005-2011 0.63 vs 0.54 —
unica vittoria; 2012-2018 0.55 vs 0.72; 2019-2026 0.99 vs 1.13). Non e' regime-luck
dell'incumbent: GTAA6 vince anche nella finestra che contiene dot-com e GFC.
2. **A PARI VOLATILITA' il vantaggio di DD sparisce e si inverte**: MAT18 va scalato ×1.31 per
eguagliare la vol di GTAA6, e a quel punto maxDD 20.6% contro 15.4%. Il suo DD piu' basso
era **solo de-levering** — replicabile meglio abbassando `target_vol`.
**3ª occorrenza della stessa lezione** (overlay DD del VRP01 2026-07-03: 4/4 refutati dal
null de-levering; TP01×DVOL 2026-06-26: il taglio di DD era de-levering). Il null de-levering
e' ormai il primo test da fare su ogni claim di "meno drawdown".
3. Beta azionario e alpha quasi identici (GTAA6 beta 0.19 / alpha 1.56%; MAT18 0.14 / 0.97%):
MAT18 non e' meno "equity travestito", e' solo piu' piccolo.
4. Nei bear MAT18 e' migliore (GFC 3.0% vs 5.1%, 2022 3.2% vs 6.1%) ma paga quel conforto
con Sharpe piu' basso in ogni altro regime.
**Il risultato POSITIVO da conservare — l'ampiezza funziona, ma il soffitto e' gia' raggiunto.**
La curva di ampiezza (400 sottoinsiemi casuali per k, Sharpe OOS) e' monotona in 13/17 passi:
mediana **0.44 a k=1 → 0.80 a k=18**, DD mediano **16.0% → 6.9%**, satura verso k≈10-12.
Cioe': allargare l'universo di un trend difensivo *e' un meccanismo reale*. Ma GTAA6 sta al
**95° percentile dei 6-subset casuali** — e non perche' sia un best-of cherry-picked (contiene
TLT, il PEGGIORE dei 18 con Sh OOS 0.03): e' la sua composizione (equity US + oro + credito HY)
a stare sopra il soffitto d'ampiezza. **Non c'e' piu' ampiezza da raccogliere su questo sleeve.**
---
## (2) STATARB-MULTI — il meccanismo generalizza fuori da ETH/BTC? → **si', debolmente; nessuno sleeve nuovo**
Script: `scripts/research/r0725_statarb_multi.py`.
**Perche'.** STATARB-RESID e' il miglior lead del progetto e ha un gate di deploy al **2026-09-27**,
ma e' stato scoperto su UNA coppia dentro uno sweep. Test out-of-pair-sample: meccanismo congelato
(W=45, sgn=+1) su tutte le **50 coppie alt/BTC** di Hyperliquid, che non hanno partecipato alla
scoperta.
**Cosa regge:**
- **82% delle 50 coppie ha Sharpe netta > 0** (media 0.38, mediana 0.32).
- **0/50 coppie degeneri**: il segno del segnale e' quasi bilanciato (mono medio 53%) → NON e' la
scommessa statica "short alt vs BTC" travestita, che era il modo di fallire pre-registrato.
- p-permutazione a blocchi < 0.05 nel **18%** delle coppie (atteso 5% per caso).
- **ETH/BTC e' al rango 22/50, 58° percentile: la coppia scopritrice NON e' un outlier.** E'
l'informazione piu' utile per il gate del 27/09: l'ipotesi "fortuna di una coppia" non regge.
- Paniere EW delle 50 coppie: Sharpe 0.82, maxDD 6.0%, **corr a XS01 solo 0.207** (non ridondante).
**Cosa NON regge — e perche' non e' uno sleeve:**
- **Ampiezza effettiva ~4.5, non 50** (corr media fra coppie 0.204: condividono la gamba BTC).
- **Block-bootstrap sul paniere: IC95% [0.12, +1.72], t ≈ 1.31** → non distinguibile da zero su
2.6 anni. Il t cross-coppie apparente di 5.05 e' gonfiato dalla dipendenza.
- Contro il null **"sempre short alt vs BTC"** (a priori, nessun parametro stimato) l'uplift medio
e' **0.11** e solo il 40% delle coppie lo batte: una parte del rendimento e' beta di regime
2024-2026, non timing.
- Eseguibilita': 50 coppie = 51 gambe → STAT-MODE come XS01, fuori portata a $600.
**Correzione a un mio errore di metodo, dentro questa stessa sessione.** La prima stesura del
null statico usava `sign(mean(segnale))` sull'INTERO campione: un null che conosce gia' la
direzione giusta, cioe' look-ahead. Rifatto in due versioni oneste — (a) causale con media
espandente, (b) a priori sempre-short. L'uplift vs la versione causale e' mediana +0.10 / 58%
positivo (debolmente favorevole); vs sempre-short e' negativo. Il numero pubblicabile e' questo,
non quello della prima stesura.
---
## (3) STATARB-EQ — lo stesso meccanismo su coppie ETF con 20-30 anni di storia → **SCARTATO, e il crypto non ne esce rafforzato**
Script: `scripts/research/r0725_statarb_eq.py`.
**Perche'.** La debolezza di STATARB-RESID non e' il segno dei numeri, e' la statistica: 2.6 anni,
ampiezza effettiva 4.5. Le coppie ETF la attaccano frontalmente — 12 coppie definite **a priori
dentro la stessa classe d'attivo** (DIA/SPY, IWM/SPY, QQQ/SPY, EEM/EFA, EWJ/EFA, FXI/EEM, EFA/SPY,
SLV/GLD, IEF/TLT, HYG/LQD, LQD/AGG, USO/DBC), **28.5 anni**, ampiezza effettiva **9.9**, 4 regimi
completi. Nessun data-mining di cointegrazione: cercare le coppie piu' cointegrate sugli stessi
dati sarebbe selezione.
**Esito: il meccanismo NON si trasferisce.**
| | valore |
|---|---|
| Sharpe LORDA media / mediana | 0.17 / 0.16 (25% positive) |
| Sharpe NETTA media / mediana | 0.50 / 0.51 (8% positive) |
| paniere EW, 28.5 anni | Sharpe **1.00**, IC95% [1.36, 0.64], **t 5.33** |
| per decade | 0.56 / 1.61 / 1.83 / 0.78 — **negativo in 4/4** |
| p-permutazione < 0.05 | **0%** delle coppie |
| turnover | 0.40/giorno → drag di fee **0.33 Sharpe** |
| borrow 0 / 30 / 100 bps | 1.00 / 1.06 / 1.21 |
**La lettura corretta e' nella decomposizione lordo/netto, non nel netto.** La lorda e' 0.17, non
1.00: due terzi del disastro sono drag di turnover. Quindi **non esiste una "strategia specchio"
da girare**: sgn=1 avrebbe lorda ≈ +0.17 e netta ≈ 0.16, morta anch'essa sui costi. Il segno
lordo dice pero' una cosa vera e interessante: **sulle coppie azionarie il residuo REVERTE
debolmente, mentre sul crypto CONTINUA** (sgn=+1 vince). Il meccanismo di STATARB e' quindi
plausibilmente specifico del crypto, non universale.
**Onesta' sull'inferenza:** che non funzioni sulle azioni NON dimostra che non funzioni sul crypto
— gli indici azionari sono efficienti e le coppie alt/BTC hanno microstruttura e flussi diversi.
Il punto e' negativo in modo preciso: il gate del 27/09 **non puo' appoggiarsi** all'argomento
"e' un fenomeno universale, quindi e' reale". Non lo e'.
---
## (4) ⚠ DIFETTO DI DATO: split NON aggiustati nel feed equity — trovato, riparato, gate chiuso
Emerso mentre diagnosticavo ritorni giornalieri impossibili in (3) (min 99.9%, max +112%).
**Il fatto.** `data/raw/eq_iwm_1d.parquet` e `eq_efa_1d.parquet` avevano uno **split non aggiustato
il 2005-06-09** (tornata di split iShares): IWM 94.13→47.53 (49.5%, rapporto 1.981 ≈ **2:1**),
EFA 85.91→28.76 (66.5%, rapporto 2.987 ≈ **3:1**). IB `ADJUSTED_LAST` non li aveva aggiustati.
**Perche' la certificazione non li vedeva — ed e' un punto cieco strutturale, non sfortuna.**
L'unica guardia sui salti era `maxret > 50% → SPIKE?`. Uno split 2:1 non aggiustato produce
**esattamente 50%**: cade sul filo della soglia. IWM passava a 49.5% con status **OK**. La soglia
era tarata precisamente sul valore che il difetto piu' comune genera.
**Perche' importa: IWM e' una delle 6 gambe di GTAA01, sleeve in PRODUZIONE.** Impatto misurato:
| GTAA6 | FULL Sh | IS<2015 Sh | OOS2015+ Sh | maxDD |
|---|---|---|---|---|
| con difetto | 0.61 | 0.49 | 0.86 | 15.38% |
| riparato | **0.64** | **0.54** | 0.86 | 15.38% |
Il difetto **sottostimava** lo sleeve (l'artefatto e' nel 2005, fuori dall'hold-out) → **nessuna
decisione presa va rivista**, ma il dato e' ora corretto.
**Il discriminante split-vs-crollo (la parte riusabile).** Non basta il rapporto: SLV il
2026-01-30 ha fatto 28.5% con rapporto **1.3994**, a 4 bps da 1.4. Il discriminante e' il **range
intraday**:
- **SPLIT**: la barra APRE gia' al nuovo livello, range intraday normale. IWM 2005-06-09: open
47.00, range 1.7%. Un crollo del 50% con range 1.7% non esiste.
- **CROLLO VERO**: il movimento avviene DENTRO la barra. SLV: open 89.33, low 69.12, range 33%,
volume raddoppiato, e **GLD 10.3% lo stesso giorno**. Stessa verifica su EEM 2008-10-28 (+26%,
reale).
**Cosa e' stato fatto:**
- Nuovo modulo `src/data/eq_splits.py``detect_unadjusted_splits()` (tre condizioni congiunte:
|ret|>20% AND rapporto entro 1.5% da un fattore comune AND range intraday <5%) e
`repair_splits()` (riscala i prezzi precedenti; split multipli si compongono; volume opposto).
- Riparazione **in lettura** in entrambi i loader: `src/portfolio/gtaa.py::_close` (produzione) e
`scripts/research/eqlib.py::load_eq` (ricerca).
- Certificazione indurita: `fetch_ib_equities.certify()` ha ora lo status **`SPLIT-NON-AGG`** e
riporta gli split rilevati.
- Test `tests/test_eq_splits.py` (8 casi): i due split reali, il falso positivo SLV, un crollo
50% esatto con range grande, split multipli componibili, serie pulita invariata, ed end-to-end
sui parquet. **Suite completa: 180 passed.**
**Regola nuova:** ogni soglia di certificazione tarata su un valore tondo va controllata contro il
difetto che genera *esattamente* quel valore. Una soglia a 50% non puo' sorvegliare gli split 2:1.
---
## (5) Addendum PRE-REGISTRATO al gate STATARB del 27/09
Registrato **oggi, forward-day 26 di 90, 64 giorni prima della decisione** — pre-registrazione
vera, non selezione a posteriori. Motivo: il null "sempre short alt vs BTC" batte il segnale sul
60% delle coppie crypto, e su ETH/BTC (finestra HL 2024-2026) il pareggio e' quasi esatto
(segnale 0.46 vs statica 0.48).
`r0724_statarb_deploy_gate.py` ora calcola e riporta anche lo Sharpe della **statica sempre-short
a pari vol-target sulla stessa finestra forward**. **Le soglie pre-registrate il 2026-07-24
(Sharpe≥0.5, DD<10%, haircut<0.5pp) NON sono state toccate**: il benchmark e' una diagnostica
obbligatoria da leggere insieme, e la decisione se renderlo binding spetta all'operatore.
**Lettura di oggi (26 barre, da non sovrainterpretare):** STATARB **+5.70** contro benchmark
statico **4.98**. La posizione forward corrente e' LONG lo spread (+0.61), cioe' **opposta** alla
scommessa statica → la finestra forward sta testando il segnale in un modo che il beta di regime
non spiega. Prima lettura favorevole.
---
## (6) ✅ XSR01 — il candidato che e' uscito dal filone (2): **CROSS-SECTIONAL RESIDUAL**
Script: `r0725_statarb_basket_gate.py` (gate), `r0725_statarb_demean_skeptic.py` (scettico),
monitor `scripts/live/paper_xsr.py`, gate `r0725_xsr_deploy_gate.py`, test `tests/test_paper_xsr.py`.
Il paniere di coppie del punto (2) non era uno sleeve per un motivo **diagnosticato, non subito**:
ampiezza effettiva 4.5 invece di 50, perche' le 50 coppie **condividono la gamba BTC**. Da qui una
variante **pre-registrata per ragione strutturale** (prima di guardare i risultati): demeanare le
posizioni cross-sezionalmente ogni giorno. L'algebra dice cosa succede:
sum_i (p_i p̄)(r_i r_btc) = sum_i (p_i p̄) r_i perche' sum_i (p_i p̄) = 0
**la gamba BTC si annulla**, e con essa il fattore comune. Il risultato non e' piu' un paniere di
coppie: e' una strategia **cross-sectional dollar-neutral sui 50 alt** con pesi (p_i p̄)/N.
| | V1 ALL50 | V2 MAJ19 | **V3 DEMEAN** |
|---|---|---|---|
| Sharpe netta | 0.82 | 0.75 | **1.82** |
| maxDD | 6.0% | 8.1% | **2.6%** |
| ampiezza effettiva | 4.5 | 4.0 | **37.4** |
| marginale vs TP01 | ADDS | NOISE | **ADDS** |
| deflated-Sharpe | 0.701 | 0.660 | **0.985 PASS** |
**Gate superati.** Marginale **ADDS** con `robust_oos`, `beats_noise_null`, `is_hedge=False`,
`has_insample_edge=True`. **Deflated-Sharpe 0.985** (soglia 0.95) su 3 varianti pre-registrate — e
la config W=45/sgn=+1 viene da un altro studio, su questi dati non e' stata cercata.
**Correlazione ~0 a TUTTI e 5 gli sleeve attivi**: TP01 0.060, XS01 0.019, VRP01 0.001,
SKH01 0.001, GTAA01 0.071. Book a w=15%: HOLD 2.36 → **2.51**, DD invariato.
**Verifica avversariale** (`r0725_statarb_demean_skeptic.py`) — numeri cosi' puliti sono, in questo
progetto, il momento in cui di solito si trova l'artefatto. Quattro attacchi:
- **Tre null a fee-neutrale** (permutazione cross-sezionale delle etichette-asset; posizioni
casuali; permutazione temporale a blocchi 20g): tutti centrati a **~0.01** con p95 1.0-1.3, e il
candidato **LORDO 2.70 sopra il massimo di 300 estrazioni** → p < 0.004 su tutti e tre.
⚠ Correzione di metodo: la prima stesura confrontava a fee piene, e i null uscivano a 3.7/4.2
perche' permutare **fa esplodere il turnover** → il null perdeva per COSTO, non per assenza di
segnale, dando un p-value trionfale e falso. A fee zero il confronto isola l'informazione.
- **Lag**: 1.82 → 1.19 → 0.81 → 0.51 a +0/1/2/3 giorni. **Decadimento dolce = segnale lento reale;**
un look-ahead crollerebbe a zero al primo giorno di ritardo.
- **Ridondanza**: contro un momentum cross-sectional semplice sugli stessi 50 (che fa Sharpe
0.03/0.17/0.79 a L=30/45/90) la correlazione e' 0.29/0.17/0.10 → **non e' XS01 travestito**.
- **Struttura**: netto 2e17 (dollar-neutral esatto), 23.8 gambe long su 50 (bilanciato), lordo
14.4% del capitale.
**Le tre debolezze, dichiarate — e' per queste che va in MONITOR e non nel book:**
1. **Storia 2.6 anni, un regime solo**, e il rendimento e' **crescente** (Sharpe 2024 1.03 / 2025
1.98 / 2026 3.11): la maggior parte dell'edge e' recente.
2. **`weights_tilt_null` NON passa** (gate_pass=False a w=10% e 15%, delta_insample 0.004/0.007).
E' un effetto strutturale della storia corta — ma un gate fallito resta fallito.
3. **Margine di costo sottile**: 1.82 a 0.05%/gamba, **0.93 a 0.10%, NEGATIVA a 0.20%**. Turnover
38.5% del lordo al giorno su 50 alt fra cui illiquidi (GALA, BLUR, JTO, ORDI, WIF) e **slippage
non modellato**: e' il rischio numero uno.
**Eseguibilita' — il numero che serve davvero:** ticket medio per gamba $1.73 a $600 (**sotto il
min-order $5: STAT-MODE al capitale attuale**), $5.76 a $2000, **$14.41 a $5000**. Cioe' XSR01
diventa reale intorno ai **$5.000**, non ai $20.000 di XS01.
**Cablato come ogni lead del progetto:** `scripts/live/paper_xsr.py` (config CONGELATA, **tre**
libri paralleli MODELED $2000 / REAL $600 / REAL $5000 con min-order per gamba, stato append-only),
inserito in `cron_daily.sh`, test a 7 casi che bloccano config, dollar-neutralita', causalita' dello
step, min-order e la trappola dei timestamp. Gate di decisione pre-registrato oggi a forward-day 0:
**2026-10-23**, con soglia Sharpe ≥ 1.0 **E** una guardia di costo esplicita (haircut di
eseguibilita' a $5000 ≤ 40%, altrimenti RITIRO a prescindere dallo Sharpe).
⚠ Trappola pandas ri-pagata e ri-documentata: `astype("int64")` su `DatetimeIndex` tz-aware dava
epoche 1970 nel monitor. E' la gemella di quella dell'ondata 2026-07-01. Ora c'e' un test.
---
## Bilancio
- **1 candidato nuovo: XSR01**, il primo da molte ondate a superare marginale-ADDS, deflated-Sharpe
0.95, tre null avversariali e il test di lag, con corr ~0 a tutti e 5 gli sleeve. In
forward-monitor (NON nel book) per tre ragioni dichiarate: storia 2.6 anni, `weights_tilt_null`
fallito, margine di costo sottile. Gate pre-registrato al **2026-10-23**.
**Nota di onesta' su come e' nato:** non e' uscito da un'idea nuova, e' uscito dalla
DIAGNOSI di un fallimento — l'ampiezza effettiva 4.5 del paniere di coppie indicava un fattore
comune da togliere, e toglierlo era una mossa algebrica obbligata, non una ricerca di parametri.
- 2 refutazioni pulite con diagnostica riusabile: il soffitto d'ampiezza di GTAA01 e' gia'
raggiunto; STATARB non e' un fenomeno universale (non si trasferisce alle azioni).
- 1 difetto di dato sul **libro live** trovato, quantificato, riparato, testato e chiuso alla fonte.
- Il gate del 27/09 arriva alla decisione con **un'ipotesi alternativa in meno** (fortuna di una
coppia: refutata, 58° percentile) e **un benchmark in piu'** (beta statico, gia' favorevole).
- Due errori di metodo miei, trovati e corretti dentro la sessione: null statico con look-ahead
(STATARB-MULTI) e null a fee piene contro un segnale permutato ad alto turnover (scettico XSR01).
Entrambi avrebbero prodotto numeri piu' belli e sbagliati.
Script: `r0725_mat01_multiasset_trend.py`, `r0725_mat01b_regime.py`, `r0725_statarb_multi.py`,
`r0725_statarb_eq.py`, `r0725_statarb_basket_gate.py`, `r0725_statarb_demean_skeptic.py`,
`r0725_xsr_deploy_gate.py`; monitor `scripts/live/paper_xsr.py`; modulo `src/data/eq_splits.py`;
test `tests/test_eq_splits.py`, `tests/test_paper_xsr.py`.
+1
View File
@@ -11,6 +11,7 @@ mkdir -p logs
uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01 uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01
uv run python scripts/live/paper_prevday.py # forward-monitor lead prevday-breakout (PAPER, non deploy) uv run python scripts/live/paper_prevday.py # forward-monitor lead prevday-breakout (PAPER, non deploy)
uv run python scripts/live/paper_statarb.py # forward-monitor lead STATARB-RESID ETH/BTC ortogonale (PAPER, non deploy) uv run python scripts/live/paper_statarb.py # forward-monitor lead STATARB-RESID ETH/BTC ortogonale (PAPER, non deploy)
uv run python scripts/live/paper_xsr.py # forward-monitor XSR01 cross-sectional residual 50 alt HL (PAPER, gate 2026-10-23)
uv run python scripts/live/cc01_regime_watch.py # trigger regime CC01 (read-only: WARN>=10%/ALERT>=15% funding 30g) uv run python scripts/live/cc01_regime_watch.py # trigger regime CC01 (read-only: WARN>=10%/ALERT>=15% funding 30g)
uv run python scripts/research/r0724_stable_snapshot.py # snapshot point-in-time supply stablecoin (sblocca WATCH STABLE a 12 mesi) uv run python scripts/research/r0724_stable_snapshot.py # snapshot point-in-time supply stablecoin (sblocca WATCH STABLE a 12 mesi)
# NB: l'esecuzione Deribit e' passata al BOOK (TP01+SKH01 nettati) via scripts/cron_book.sh a # NB: l'esecuzione Deribit e' passata al BOOK (TP01+SKH01 nettati) via scripts/cron_book.sh a
+222
View File
@@ -0,0 +1,222 @@
"""FORWARD-MONITOR — XSR01 (Cross-Sectional Residual, 50 alt Hyperliquid), PAPER.
NON e' esecuzione reale. E' il monitoraggio forward-only del candidato trovato il 2026-07-25
(diario `2026-07-25-mat01-statarb-generalization.md`, sezione XSR01). Stesso trattamento che il
progetto da' a ogni lead: config CONGELATA, finestra out-of-sample vera che parte da ora, nessun
edge creduto prima.
COS'E'. Il meccanismo di STATARB-RESID (residuo OLS rolling causale di log(alt) su log(BTC),
z-score su W, tanh, vol-target sullo spread) applicato a TUTTI i 50 alt certificati di Hyperliquid,
con le posizioni DEMEANATE cross-sezionalmente ogni giorno. Il demeaning annulla algebricamente la
gamba BTC comune ( sum_i (p_i - p̄)(r_i - r_btc) = sum_i (p_i - p̄) r_i ) e con essa il fattore
che teneva l'ampiezza effettiva del paniere a 4.5: dopo il demeaning e' **37.4**. Non e' quindi un
paniere di coppie ma una strategia CROSS-SECTIONAL dollar-neutral sui 50 alt.
PERCHE' E' IN MONITOR E NON NEL BOOK (i tre motivi, tutti dichiarati):
1. **Storia 2.6 anni, un regime solo** (bear alt 2024-2026), e il rendimento e' CRESCENTE nel
tempo (Sharpe 2024 1.03 / 2025 1.98 / 2026 3.11): la maggior parte dell'edge e' recente.
2. **`weights_tilt_null` NON passa** (gate_pass=False a w=10% e 15%): delta_insample negativo,
effetto strutturale della storia corta — ma un gate fallito resta fallito.
3. **Margine di costo sottile**: netta 1.82 a 0.05%/gamba, 0.93 a 0.10%, NEGATIVA a 0.20%. Il
turnover e' 38.5% del lordo al giorno su 50 alt fra cui parecchi illiquidi (GALA, BLUR, JTO,
ORDI, WIF): lo **slippage non e' modellato** ed e' il rischio numero uno. Questo monitor
serve soprattutto a misurare quanto costa davvero.
COSA HA GIA' SUPERATO (r0725_statarb_basket_gate.py + r0725_statarb_demean_skeptic.py):
marginale vs TP01 = ADDS (robust_oos, beats_noise_null, non-hedge, has_insample_edge);
deflated-Sharpe 0.985 PASS; corr ~0 a TUTTI e 5 gli sleeve attivi (max |0.071|);
tre null a fee-neutrale (permutazione cross-sezionale, casuale, permutazione temporale a blocchi)
tutti centrati a ~0.01 con il candidato LORDO 2.70 sopra il massimo di 300 estrazioni (p<0.004);
decadimento con lag dolce (1.82 / 1.19 / 0.81 / 0.51 a +0/1/2/3g) = firma di segnale lento, NON
di look-ahead; non ridondante con un momentum cross-sectional semplice (corr 0.17-0.29).
CONFIG CONGELATA (ogni ritocco AZZERA la finestra forward):
W=45, sgn=+1, vol-target 20%, cap 2x, demean cross-sezionale giornaliero,
universo = i 50 alt certificati in data/raw/hl_*_1d.parquet (BTC escluso), fee 0.05%/gamba.
Riusa il segnale ESATTO di scripts/research/r0725_statarb_multi.signal (nessuna reimplementazione).
TRE LIBRI IN PARALLELO (onesta' sull'eseguibilita'):
MODELED $2000 : ribilanciamento continuo di ogni gamba (limite superiore teorico).
REAL $600 : min-order $5 per gamba. Ticket medio atteso $1.73 -> **quasi nulla si esegue**:
a questo capitale la strategia e' STAT-MODE, e il libro lo mostra invece di
nasconderlo.
REAL $5000 : ticket medio ~$14 -> soglia realistica di eseguibilita'. E' il numero che dice
a che capitale questa strategia diventa vera.
Stato: data/paper_xsr/{state.json, returns.jsonl} (append-only).
uv run python scripts/live/paper_xsr.py # avanza col dato disponibile
uv run python scripts/live/paper_xsr.py --status # solo stato, non avanza
uv run python scripts/live/paper_xsr.py --reset # azzera (riparte da ora)
"""
from __future__ import annotations
import argparse
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))
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
# Segnale ESATTO dello studio (nessuna reimplementazione -> niente drift col backtest).
from r0725_statarb_multi import BASE, MIN_BARS, load_hl, signal, universe # noqa: E402
STATE_DIR = PROJECT_ROOT / "data" / "paper_xsr"
STATE_FILE = STATE_DIR / "state.json"
RETURNS_FILE = STATE_DIR / "returns.jsonl"
# --- CONFIG CONGELATA (frozen) -----------------------------------------------------------------
FEE_LEG = 0.0005 # 0.05% per gamba sul nozionale mosso
MODELED_CAPITAL = 2000.0
REAL_BOOKS = ((600.0, "REAL-$600"), (5000.0, "REAL-$5000"))
MIN_ORDER = 5.0
ANN = np.sqrt(365.0)
def build_panel():
"""(ts, dt, W target [n x k], R_alt [n x k], r_btc [n], simboli). Tutto causale."""
base_px = load_hl(BASE)
pos_cols, ret_cols = {}, {}
for sym in universe():
try:
tgt = load_hl(sym)
except FileNotFoundError:
continue
ix = base_px.index.intersection(tgt.index)
if len(ix) < MIN_BARS:
continue
p, _ = signal(base_px[ix], tgt[ix])
pos_cols[sym] = pd.Series(p, index=ix)
ret_cols[sym] = tgt[ix].pct_change().fillna(0.0)
P = pd.concat(pos_cols, axis=1, sort=True).sort_index().fillna(0.0)
R = pd.concat(ret_cols, axis=1, sort=True).sort_index().reindex(P.index).fillna(0.0)[P.columns]
Q = P.sub(P.mean(axis=1), axis=0) # demean cross-sezionale
W = Q / max(P.shape[1], 1) # peso di portafoglio per gamba
rb = base_px.pct_change().fillna(0.0).reindex(P.index).fillna(0.0)
# epoca ms ESPLICITA: `.astype("int64")`/`.view("int64")` su DatetimeIndex tz-aware non-ns
# danno la scala sbagliata in pandas 2.x — trappola gia' pagata dal progetto (CLAUDE.md,
# ondata 2026-07-01: merge_asof broadcastato = look-ahead invisibile a causality_ok).
ts = np.array([int(t.timestamp() * 1000) for t in P.index], dtype="int64")
return ts, P.index, W.to_numpy(float), R.to_numpy(float), rb.to_numpy(float), list(P.columns)
def _state_io(write: dict | None = None):
STATE_DIR.mkdir(parents=True, exist_ok=True)
if write is not None:
STATE_FILE.write_text(json.dumps(write, indent=2, default=str))
return write
return json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else None
def _append(path: Path, rec: dict):
STATE_DIR.mkdir(parents=True, exist_ok=True)
with path.open("a") as f:
f.write(json.dumps(rec) + "\n")
def _book(cap: float, k: int) -> dict:
return dict(cap=cap, cap0=cap, peak=cap, dd=0.0, w=[0.0] * k, n_skip=0, n_fill=0)
def init_state() -> dict:
ts, _, W, _, _, syms = build_panel()
return dict(start_ts=int(ts[-1]), last_ts=int(ts[-1]), n_bars=0, syms=syms,
modeled=_book(MODELED_CAPITAL, len(syms)),
reals={name: _book(c, len(syms)) for c, name in REAL_BOOKS})
def _step(bk: dict, w_target: np.ndarray, r_alt: np.ndarray, r_btc: float,
min_order: float | None) -> float:
"""Un passo di un libro: rende il netto della barra e aggiorna capitale/peak/DD in place."""
w_held = np.asarray(bk["w"], float)
ret = float(np.dot(w_held, r_alt) - w_held.sum() * r_btc) # gamba BTC = -somma dei pesi alt
if min_order is None:
w_new = w_target.copy()
else:
move = np.abs(w_target - w_held) * bk["cap"] >= min_order
w_new = np.where(move, w_target, w_held)
bk["n_fill"] += int(move.sum())
bk["n_skip"] += int((~move).sum())
d_alt = np.abs(w_new - w_held)
d_btc = abs(w_new.sum() - w_held.sum())
net = ret - FEE_LEG * (float(d_alt.sum()) + d_btc)
bk["cap"] *= (1.0 + max(net, -0.99))
bk["peak"] = max(bk["peak"], bk["cap"])
bk["dd"] = max(bk["dd"], (bk["peak"] - bk["cap"]) / bk["peak"] if bk["peak"] > 0 else 0.0)
bk["w"] = w_new.tolist()
return net
def advance(st: dict) -> dict:
ts, dt, W, R, rb, syms = build_panel()
if syms != st["syms"]: # universo cambiato -> non si avanza
print(f" [XSR01] universo cambiato ({len(st['syms'])} -> {len(syms)}): "
"la finestra forward richiede universo costante. Usa --reset per ripartire.")
return st
new = [i for i in range(len(ts)) if ts[i] > st["last_ts"]]
if not new:
return st
for i in new:
nm = _step(st["modeled"], W[i], R[i], float(rb[i]), None)
rec = dict(ts=int(ts[i]), dt=str(pd.Timestamp(dt[i])), net_modeled=round(nm, 6))
for _, name in REAL_BOOKS:
rec[f"net_{name}"] = round(_step(st["reals"][name], W[i], R[i], float(rb[i]),
MIN_ORDER), 6)
rec["gross"] = round(float(np.abs(W[i]).sum()), 4)
_append(RETURNS_FILE, rec)
st["last_ts"] = int(ts[new[-1]])
st["n_bars"] = st.get("n_bars", 0) + len(new)
return st
def print_status(st: dict) -> None:
ts, _, W, _, _, _ = build_panel()
days = (int(ts[-1]) - st["start_ts"]) / 86_400_000
print("\n XSR01 forward-monitor (PAPER — cross-sectional residual 50 alt HL, NON deploy)")
print(" config CONGELATA: W=45 sgn=+1 demean cross-sezionale, vol-target 20%, fee 0.05%/gamba")
print(f" forward da {pd.Timestamp(st['start_ts'], unit='ms', tz='UTC').date()} "
f"({st['n_bars']} barre 1d ~{days:.0f}g) gambe: {len(st['syms'])}")
print(f" lordo corrente {np.abs(W[-1]).sum()*100:.1f}% del capitale "
f"(dollar-neutral: netto {W[-1].sum():+.1e})")
m = st["modeled"]
print(f" MODELED ($2000, ribil. continuo): {(m['cap']/m['cap0']-1)*100:+6.2f}% "
f"eq ${m['cap']:.2f} maxDD {m['dd']*100:.1f}%")
for _, name in REAL_BOOKS:
b = st["reals"][name]
tot = b["n_fill"] + b["n_skip"]
print(f" {name:<12} (min-order $5) : {(b['cap']/b['cap0']-1)*100:+6.2f}% "
f"eq ${b['cap']:.2f} maxDD {b['dd']*100:.1f}% "
f"gambe eseguite {100*b['n_fill']/tot if tot else 0:.0f}%")
print(" -> il divario MODELED-REAL e' l'haircut di eseguibilita'; a $600 e' atteso GRANDE")
print(f" log: {RETURNS_FILE}\n")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--status", action="store_true")
ap.add_argument("--reset", action="store_true")
args = ap.parse_args()
if args.reset:
for p in (STATE_FILE, RETURNS_FILE):
if p.exists():
p.unlink()
st = _state_io(init_state())
print("forward-monitor XSR01 inizializzato (forward-only da ora).")
print_status(st); return
st = _state_io()
if st is None:
st = _state_io(init_state())
print("forward-monitor XSR01 inizializzato (forward-only da ora).")
print_status(st); return
if not args.status:
st = advance(st); _state_io(st)
print_status(st)
if __name__ == "__main__":
main()
+6
View File
@@ -8,11 +8,14 @@ Espone: universi (SECTORS/BROAD), load_eq(sym), panel(universe) allineato, e riu
indurito di altlib (_sh, _dd_ret, _to_daily, marginal_vs_tp01) per giudicare i candidati con la indurito di altlib (_sh, _dd_ret, _to_daily, marginal_vs_tp01) per giudicare i candidati con la
stessa disciplina del lato crypto. stessa disciplina del lato crypto.
""" """
import sys
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
import numpy as np, pandas as pd import numpy as np, pandas as pd
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.data.eq_splits import repair_splits # noqa: E402
RAW = ROOT / "data" / "raw" RAW = ROOT / "data" / "raw"
# 11 SPDR settoriali. I 9 "classici" (sotto SECTORS_CLASSIC) partono 1998; XLRE 2015, XLC 2018. # 11 SPDR settoriali. I 9 "classici" (sotto SECTORS_CLASSIC) partono 1998; XLRE 2015, XLC 2018.
@@ -29,6 +32,9 @@ def load_eq(sym: str) -> pd.DataFrame:
raise FileNotFoundError(f"{p} assente — gira: uv run --with ib_async python scripts/research/fetch_ib_equities.py") raise FileNotFoundError(f"{p} assente — gira: uv run --with ib_async python scripts/research/fetch_ib_equities.py")
d = pd.read_parquet(p).copy() d = pd.read_parquet(p).copy()
d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True) d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
d = d.sort_index()
# split NON aggiustati da IB (IWM/EFA 2005-06-09) — vedi src/data/eq_splits.py
d, _ = repair_splits(d)
return d[["open", "high", "low", "close", "volume"]] return d[["open", "high", "low", "close", "volume"]]
+19 -3
View File
@@ -13,7 +13,11 @@ CERTIFICAZIONE (gemello equity di certify_feed.py):
(1) integrità: barre, range, date monotone, duplicati, flat bars (close invariato); (1) integrità: barre, range, date monotone, duplicati, flat bars (close invariato);
(2) gap: run di giorni-lavorativi mancanti > 5 (festivi normali, buchi lunghi = sospetti); (2) gap: run di giorni-lavorativi mancanti > 5 (festivi normali, buchi lunghi = sospetti);
(3) sanità ritorni: max |daily ret| (un >50% non-evento = errore di adjustment); (3) sanità ritorni: max |daily ret| (un >50% non-evento = errore di adjustment);
(4) sanità adjustment: primo close aggiustato << ultimo (i dividendi abbassano lo storico). (4) sanità adjustment: primo close aggiustato << ultimo (i dividendi abbassano lo storico);
(5) SPLIT NON AGGIUSTATI (aggiunto 2026-07-25): ADJUSTED_LAST non sempre aggiusta gli split, e
il check (3) NON li vede — uno split 2:1 fa esattamente -50%, sul filo della soglia. IWM
(gamba di GTAA01 in produzione) ed EFA avevano uno split non aggiustato il 2005-06-09 e
passavano come OK. Rilevatore in src/data/eq_splits.py -> status SPLIT-NON-AGG.
PREREQUISITO: gateway IB paper su 127.0.0.1:4002 (docker compose up -d ib-gateway). PREREQUISITO: gateway IB paper su 127.0.0.1:4002 (docker compose up -d ib-gateway).
uv run --with ib_async python scripts/research/fetch_ib_equities.py uv run --with ib_async python scripts/research/fetch_ib_equities.py
@@ -23,6 +27,8 @@ from pathlib import Path
import numpy as np, pandas as pd import numpy as np, pandas as pd
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.data.eq_splits import detect_unadjusted_splits # noqa: E402
RAW = ROOT / "data" / "raw" RAW = ROOT / "data" / "raw"
RAW.mkdir(parents=True, exist_ok=True) RAW.mkdir(parents=True, exist_ok=True)
@@ -55,9 +61,17 @@ def certify(sym: str, df: pd.DataFrame) -> dict:
longgap = int((gaps.to_series().groupby(s).size() > 5).sum()) longgap = int((gaps.to_series().groupby(s).size() > 5).sum())
span_y = (idx[-1] - idx[0]).days / 365.25 span_y = (idx[-1] - idx[0]).days / 365.25
adj_ratio = round(float(c[0] / c[-1]), 3) # primo/ultimo: <1 atteso (storico abbassato dai div) adj_ratio = round(float(c[0] / c[-1]), 3) # primo/ultimo: <1 atteso (storico abbassato dai div)
# SPLIT NON AGGIUSTATI: IB ADJUSTED_LAST non sempre li aggiusta. NB la guardia `maxret > 0.5`
# NON li vede: uno split 2:1 non aggiustato fa ESATTAMENTE -50% e cade sul filo della soglia
# (IWM 2005-06-09 passava a 49.5% con status OK, ed e' una gamba di GTAA01 in produzione).
# Vedi src/data/eq_splits.py per il discriminante split-vs-crollo (range intraday).
splits = detect_unadjusted_splits(df)
status = "OK" status = "OK"
if dup or not mono: if dup or not mono:
status = "INTEGRITA'" status = "INTEGRITA'"
elif splits:
status = "SPLIT-NON-AGG"
elif maxret > 0.5: elif maxret > 0.5:
status = "SPIKE?" status = "SPIKE?"
elif longgap > 0: elif longgap > 0:
@@ -67,7 +81,8 @@ def certify(sym: str, df: pd.DataFrame) -> dict:
return {"sym": sym, "n": len(df), "primo": idx[0].date(), "ultimo": idx[-1].date(), return {"sym": sym, "n": len(df), "primo": idx[0].date(), "ultimo": idx[-1].date(),
"anni": round(span_y, 1), "dup": dup, "mono": mono, "flat": flat, "anni": round(span_y, 1), "dup": dup, "mono": mono, "flat": flat,
"maxret%": round(maxret * 100, 1), "miss_bd": missing, "gap_lunghi": longgap, "maxret%": round(maxret * 100, 1), "miss_bd": missing, "gap_lunghi": longgap,
"adj_first/last": adj_ratio, "status": status} "adj_first/last": adj_ratio, "status": status,
"splits": [f"{s['date'].date()} 1:{s['factor']:g}" for s in splits]}
def main(): def main():
@@ -119,7 +134,8 @@ def main():
ok.append(sym) ok.append(sym)
print(f" {sym:5} n={c.get('n',0):>5} {str(c.get('primo','')):>10}->{str(c.get('ultimo',''))} " print(f" {sym:5} n={c.get('n',0):>5} {str(c.get('primo','')):>10}->{str(c.get('ultimo',''))} "
f"{c.get('anni','?')}y flat={c.get('flat','?')} maxret={c.get('maxret%','?')}% " f"{c.get('anni','?')}y flat={c.get('flat','?')} maxret={c.get('maxret%','?')}% "
f"miss_bd={c.get('miss_bd','?')} gapL={c.get('gap_lunghi','?')} adj={c.get('adj_first/last','?')} [{c['status']}]") f"miss_bd={c.get('miss_bd','?')} gapL={c.get('gap_lunghi','?')} adj={c.get('adj_first/last','?')} [{c['status']}]"
+ (f" split={c['splits']}" if c.get("splits") else ""))
time.sleep(1.2) # pacing IB time.sleep(1.2) # pacing IB
print("-" * 104) print("-" * 104)
@@ -20,6 +20,23 @@ REGOLA (immutabile — ogni modifica va motivata nel diario come violazione):
* config invariata (W=45, sgn=+1) — ogni ritocco azzera la finestra; * config invariata (W=45, sgn=+1) — ogni ritocco azzera la finestra;
* maxDD forward < 10% (coerenza col profilo di ricerca, DD backtest ~11%). * maxDD forward < 10% (coerenza col profilo di ricerca, DD backtest ~11%).
ADDENDUM PRE-REGISTRATO IL 2026-07-25 (forward-day 26 di 90, 64 giorni PRIMA della decisione —
quindi pre-registrazione vera, non selezione a posteriori). Motivo: il test out-of-pair-sample
`r0725_statarb_multi.py` ha applicato il meccanismo congelato alle 50 coppie alt/BTC di Hyperliquid
e ha trovato che, su 60% delle coppie, una posizione STATICA "sempre short alt vs BTC" a pari
vol-target batte il segnale (uplift medio -0.11). Su ETH/BTC il pareggio e' quasi esatto:
Sharpe segnale 0.46 vs statica-short 0.48 sulla finestra HL 2024-2026. Tradotto: una parte di cio'
che STATARB-RESID guadagna potrebbe essere il beta di regime "ETH sottoperforma BTC", non timing.
Si aggiunge quindi UNA misura da riportare alla decisione: lo Sharpe, sulla STESSA finestra
forward, della statica sempre-short a pari vol-target (BENCH_SHORT). Non e' una soglia binding:
le soglie pre-registrate il 2026-07-24 (Sharpe>=0.5, DD<10%, haircut<0.5pp) restano quelle e
NON sono state toccate. E' una diagnostica obbligatoria da leggere insieme, e la decisione se
renderla binding spetta esplicitamente all'operatore, non allo script.
NB confortante gia' oggi: la posizione forward corrente e' LONG lo spread (+0.61 = long ETH /
short BTC), cioe' OPPOSTA alla scommessa statica -> la finestra forward sta testando il segnale
in un modo che la statica non spiega.
Uso: `uv run python scripts/research/r0724_statarb_deploy_gate.py` Uso: `uv run python scripts/research/r0724_statarb_deploy_gate.py`
(si puo' lanciare in qualsiasi momento: ricorda la data-decisione e valuta lo stato). (si puo' lanciare in qualsiasi momento: ricorda la data-decisione e valuta lo stato).
""" """
@@ -42,6 +59,38 @@ HAIRCUT_MAX_PP = 0.5
DD_MAX = 0.10 DD_MAX = 0.10
def _bench_static_short(rows: list[dict]) -> tuple[float, int] | None:
"""Addendum 2026-07-25: Sharpe di una posizione STATICA short-spread (short ETH / long BTC)
a pari vol-target, sulla STESSA finestra forward. Stessa meccanica di costo del monitor
(fee su 2 gambe per |delta pos|), ma qui il turnover viene solo dal vol-target."""
import sys as _sys
from pathlib import Path as _P
_sys.path.insert(0, str(_P(__file__).resolve().parents[2]))
_sys.path.insert(0, str(_P(__file__).resolve().parents[1] / "research"))
try:
import pandas as pd
from orthogonal_signals import build_joint, spread_ret
except Exception:
return None
try:
j = build_joint("1d")
except Exception:
return None
ts = {int(r["ts"]) for r in rows}
s = spread_ret(j)
vol = pd.Series(s).rolling(30, min_periods=30).std().values * np.sqrt(365)
scal = np.where((vol > 0) & np.isfinite(vol), 0.20 / vol, 0.0)
pos = np.clip(-1.0 * np.nan_to_num(scal), -2.0, 2.0) # SEMPRE short lo spread
held = np.concatenate([[0.0], pos[:-1]])
net = held * s - 2.0 * 0.0005 * np.abs(np.diff(held, prepend=0.0))
mask = np.array([int(t) in ts for t in j["timestamp"].values])
r = net[mask]
r = r[np.isfinite(r)]
if len(r) < 10 or r.std() == 0:
return None
return float(r.mean() / r.std() * np.sqrt(365)), int(len(r))
def main() -> None: def main() -> None:
rows = [json.loads(x) for x in RETURNS.read_text().splitlines() if x.strip()] rows = [json.loads(x) for x in RETURNS.read_text().splitlines() if x.strip()]
rm = np.array([r["net_modeled"] for r in rows]) rm = np.array([r["net_modeled"] for r in rows])
@@ -61,6 +110,18 @@ def main() -> None:
print(f" maxDD fwd : {dd:.1%} (guardia < {DD_MAX:.0%})") print(f" maxDD fwd : {dd:.1%} (guardia < {DD_MAX:.0%})")
print(f" fill-haircut cum : {haircut_pp:.2f} pp (guardia < {HAIRCUT_MAX_PP} pp)") print(f" fill-haircut cum : {haircut_pp:.2f} pp (guardia < {HAIRCUT_MAX_PP} pp)")
print(f" data decisione : {DECISION} (proroga unica: {DECISION_EXT})") print(f" data decisione : {DECISION} (proroga unica: {DECISION_EXT})")
bench = _bench_static_short(rows)
if bench is not None:
sh_b, n_b = bench
print(f"\n [addendum 2026-07-25, diagnostico NON binding]")
print(f" benchmark statico 'sempre short ETH vs BTC' a pari vol-target, stessa finestra:")
print(f" Sharpe benchmark {sh_b:+.2f} su {n_b} barre -> STATARB {sh:+.2f}, "
f"differenza {sh - sh_b:+.2f}")
print(" se STATARB non batte questo benchmark, il suo guadagno e' beta di regime")
print(" (ETH sottoperforma BTC), non timing. Da leggere PRIMA di deliberare il deploy.")
else:
print("\n [addendum: benchmark statico non calcolabile — dati BTC/ETH non disponibili]")
if today < DECISION: if today < DECISION:
print(f"\n -> NESSUNA DECISIONE OGGI ({today}): mancano {(DECISION - today).days} giorni.") print(f"\n -> NESSUNA DECISIONE OGGI ({today}): mancano {(DECISION - today).days} giorni.")
print(" Il numero corrente NON autorizza deploy anticipato (regola pre-registrata).") print(" Il numero corrente NON autorizza deploy anticipato (regola pre-registrata).")
@@ -0,0 +1,301 @@
"""MAT01 — il trend difensivo GTAA su un universo MULTI-ASSET-CLASS (18 ETF) invece di 6.
DOMANDA (una sola, pre-registrata): il valore dello sleeve GTAA01 aumenta allargando l'universo
dalle 6 gambe attuali (SPY QQQ IWM TLT GLD HYG tutte US, 4 su 6 azionarie) a un paniere che
copre le classi d'attivo vere (azioni US / internazionali / emergenti, bond governativi / credito
IG / HY, commodity, real estate)?
PERCHE' E' UN FILONE NUOVO E NON UN RE-TEST: il time-series momentum multi-asset-class e'
l'anomalia meglio documentata e replicata della finanza quantitativa (Moskowitz-Ooi-Pedersen 2012;
repliche su 100+ anni), e il suo motore dichiarato NON e' la previsione ma la DIVERSIFICAZIONE fra
classi con drawdown decorrelati. In questo progetto il trend e' stato testato solo dentro una
classe alla volta (crypto = TP01/trend-multiasset; equity US = GTAA01; settori = XS equity). I 12
ETF non-US / bond / commodity sono su disco CERTIFICATI dal 2026-06-22 ma sono stati usati SOLO
per il test lead-lag crypto->X (diario 2026-06-23-crossmarket-beyond-sp500), mai per un programma
di trend. Questo e' il vuoto.
DISCIPLINA (la parte che conta):
* Il MECCANISMO E' CONGELATO. Si importa `_exposure` da src/portfolio/gtaa.py senza toccarlo:
orizzonti (21,63,126,252), vol-target 12%, long-flat, fee 2bps/lato, EW sugli asset. Cambia
UNA SOLA COSA: l'insieme degli asset. Zero gradi di liberta' sui parametri => niente
ri-ottimizzazione mascherata da "espansione".
* L'universo ALL18 e' definito A PRIORI per classe d'attivo (vedi CLASSES), non scelto per
performance. A k=18 la liberta' di selezione e' ESATTAMENTE ZERO (esiste un solo sottoinsieme
di 18 elementi su 18) => il confronto ALL18 vs GTAA6 non e' selezionabile.
* Il rischio vero non e' l'universo scelto, e' che l'INCUMBENT sia stato cherry-picked. Quindi
si misura anche il percentile di GTAA6 fra i sottoinsiemi casuali di dimensione 6 (test D).
* Split IS/OOS = quello su cui GTAA01 fu validato (IS <=2014-12-31, OOS 2015+), indipendente
dall'hold-out crypto.
TEST ESEGUITI
A) Standalone per asset (solo diagnostica, MAI selezione): Sharpe del trend gated asset per asset.
B) ALL18 vs GTAA6: FULL / IS / OOS su Sharpe, CAGR, volAnn, maxDD, corr fra i due.
C) CURVA DI AMPIEZZA (il test di meccanismo): per k=1..18, N sorteggi di sottoinsiemi casuali ->
distribuzione dello Sharpe OOS. Se il beneficio e' AMPIEZZA e non selezione, la mediana deve
salire con k in modo monotono, e ALL18 deve stare vicino alla mediana di k=18 (che e' se stesso).
D) L'incumbent e' cherry-picked? percentile di GTAA6 nella distribuzione dei 6-subset casuali.
E) Marginale sul BOOK: sostituire GTAA6 con MAT18 al peso invariato 20% nel book 5-sleeve, e
misurare Sharpe/DD FULL e HOLD-OUT del book. Nessun cambio di peso => `weights_tilt_null`
non si applica (non e' un tilt, e' una sostituzione a peso costante), ma il confronto e' 1:1.
F) Eseguibilita' a capitale piccolo: ticket minimo per gamba ai pesi correnti.
uv run python scripts/research/r0725_mat01_multiasset_trend.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.portfolio.gtaa import _close, _exposure, EQ_UNIVERSE, FEE_SIDE # meccanismo CONGELATO
ANN = np.sqrt(252.0)
OOS_START = pd.Timestamp("2015-01-01", tz="UTC")
SEED = 20260725
N_DRAWS = 400
# --- universo A PRIORI, per classe d'attivo (nessuna scelta guidata dai risultati) ---------------
CLASSES = {
"equity_US": ["SPY", "QQQ", "IWM", "DIA"],
"equity_intl": ["EFA", "EWJ", "FXI"],
"equity_EM": ["EEM"],
"bond_gov": ["TLT", "IEF", "AGG"],
"bond_credit": ["LQD", "HYG"],
"commodity": ["GLD", "SLV", "DBC", "USO"],
"real_estate": ["VNQ"],
}
ALL18 = [s for v in CLASSES.values() for s in v]
GTAA6 = list(EQ_UNIVERSE)
# ---------------------------------------------------------------------------- metriche
def _sh(r: pd.Series) -> float:
r = r.dropna()
return float(r.mean() / r.std() * ANN) if len(r) > 20 and r.std() > 0 else 0.0
def _dd(r: pd.Series) -> float:
eq = (1.0 + r.fillna(0.0)).cumprod()
return float((eq / eq.cummax() - 1.0).min())
def _cagr(r: pd.Series) -> float:
r = r.dropna()
if len(r) < 50:
return 0.0
eq = float((1.0 + r).prod())
yrs = len(r) / 252.0
return float(eq ** (1.0 / yrs) - 1.0) if eq > 0 else -1.0
def stats(r: pd.Series, label: str) -> dict:
return {
"label": label, "n": int(r.dropna().shape[0]),
"sharpe": _sh(r), "cagr": _cagr(r), "vol": float(r.std() * ANN), "maxdd": _dd(r),
}
def fmt(s: dict) -> str:
return (f"{s['label']:<22} n={s['n']:>5} Sh {s['sharpe']:>6.2f} CAGR {s['cagr']*100:>6.2f}% "
f"vol {s['vol']*100:>5.2f}% maxDD {s['maxdd']*100:>6.2f}%")
# ---------------------------------------------------------------------------- motore (congelato)
_CACHE: dict[str, pd.Series] = {}
def gated(sym: str) -> pd.Series:
"""Rendimenti netti daily del trend long-flat su `sym` — IDENTICO a gtaa._gated_returns."""
if sym in _CACHE:
return _CACHE[sym]
close = _close(sym)
ex = _exposure(close)
ret = close.pct_change().fillna(0.0).values
held = np.zeros(len(ex))
held[1:] = ex.values[:-1] # causale: esposizione decisa a i-1, tenuta in i
net = held * ret - FEE_SIDE * np.abs(np.diff(held, prepend=0.0))
out = pd.Series(net, index=close.index)
_CACHE[sym] = out
return out
def basket(universe, end: pd.Timestamp | None = None) -> pd.Series:
"""EW dei rendimenti trend-gated sugli asset disponibili (outer, skipna) — come gtaa_returns."""
cols = {a: gated(a) for a in universe}
r = pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
return r[r.index <= end] if end is not None else r
def split(r: pd.Series):
return r[r.index < OOS_START], r[r.index >= OOS_START]
# ---------------------------------------------------------------------------- main
def main() -> None:
print("=" * 100)
print(" MAT01 — trend difensivo su universo MULTI-ASSET-CLASS (18 ETF) vs GTAA6 attuale")
print(" meccanismo CONGELATO da src/portfolio/gtaa.py (21/63/126/252, vt 12%, long-flat, EW)")
print("=" * 100)
# end comune: i 12 ETF non-GTAA sono rinfrescati solo on-demand -> tronco al min dei last date
ends = {s: _close(s).index[-1] for s in ALL18}
end = min(ends.values())
print(f"\n finestra comune troncata a {end.date()} "
f"(i 12 ETF fuori-GTAA sono rinfrescati on-demand, non nel cron giornaliero)")
print(f" universo a priori ({len(ALL18)}):")
for cls, syms in CLASSES.items():
print(f" {cls:<14} {', '.join(syms)}")
# ---------------- A) standalone per asset (DIAGNOSTICA, non selezione)
print("\n" + "-" * 100)
print(" (A) TREND STANDALONE PER ASSET — diagnostica pura, nessuna selezione su questi numeri")
print("-" * 100)
print(f" {'sym':<6}{'classe':<14}{'da':<12}{'Sh FULL':>9}{'Sh IS':>9}{'Sh OOS':>9}{'CAGR':>9}{'maxDD':>9}")
cls_of = {s: c for c, v in CLASSES.items() for s in v}
rows = []
for s in ALL18:
r = gated(s)
r = r[r.index <= end]
ins, oos = split(r)
rows.append((s, _sh(r), _sh(ins), _sh(oos)))
print(f" {s:<6}{cls_of[s]:<14}{str(r.index[0].date()):<12}{_sh(r):>9.2f}{_sh(ins):>9.2f}"
f"{_sh(oos):>9.2f}{_cagr(r)*100:>8.1f}%{_dd(r)*100:>8.1f}%")
n_pos_oos = sum(1 for _, _, _, so in rows if so > 0)
print(f"\n asset con Sharpe OOS > 0: {n_pos_oos}/{len(rows)} "
f"(mediana OOS {np.median([so for *_ , so in rows]):.2f})")
# ---------------- B) ALL18 vs GTAA6
print("\n" + "-" * 100)
print(" (B) ALL18 vs GTAA6 — stesso meccanismo, stesso periodo, cambia solo l'universo")
print("-" * 100)
r6, r18 = basket(GTAA6, end), basket(ALL18, end)
common = r6.index.intersection(r18.index)
r6, r18 = r6[common], r18[common]
for nm, r in (("GTAA6 (incumbent)", r6), ("MAT18 (candidato)", r18)):
i, o = split(r)
print(f" {fmt(stats(r, nm + ' FULL'))}")
print(f" {fmt(stats(i, nm + ' IS<2015'))}")
print(f" {fmt(stats(o, nm + ' OOS2015+'))}")
print()
i6, o6 = split(r6)
i18, o18 = split(r18)
print(f" corr(GTAA6, MAT18) FULL {r6.corr(r18):>5.2f} OOS {o6.corr(o18):>5.2f}")
print(f" DELTA OOS Sharpe {_sh(o18)-_sh(o6):+.2f} maxDD {(_dd(o18)-_dd(o6))*100:+.1f}pp "
f"CAGR {(_cagr(o18)-_cagr(o6))*100:+.2f}pp")
print(f" DELTA IS Sharpe {_sh(i18)-_sh(i6):+.2f} maxDD {(_dd(i18)-_dd(i6))*100:+.1f}pp")
# per anno OOS
print("\n Sharpe per anno (OOS 2015+):")
yrs = sorted(set(o6.index.year))
print(" anno " + "".join(f"{y:>7}" for y in yrs))
print(" GTAA6 " + "".join(f"{_sh(o6[o6.index.year == y]):>7.2f}" for y in yrs))
print(" MAT18 " + "".join(f"{_sh(o18[o18.index.year == y]):>7.2f}" for y in yrs))
wins = sum(1 for y in yrs if _sh(o18[o18.index.year == y]) > _sh(o6[o6.index.year == y]))
print(f" -> MAT18 batte GTAA6 in {wins}/{len(yrs)} anni OOS")
# ---------------- C) curva di ampiezza
print("\n" + "-" * 100)
print(f" (C) CURVA DI AMPIEZZA — {N_DRAWS} sottoinsiemi casuali per k, Sharpe OOS 2015+")
print(" se il motore e' AMPIEZZA (diversificazione) la mediana sale con k; se e' selezione, no")
print("-" * 100)
rng = np.random.default_rng(SEED)
print(f" {'k':>3}{'mediana':>10}{'p10':>8}{'p90':>8}{'medDD':>9}{'frac Sh>0':>11}")
curve = {}
for k in range(1, len(ALL18) + 1):
shs, dds = [], []
n = 1 if k == len(ALL18) else N_DRAWS
for _ in range(n):
sub = list(rng.choice(ALL18, size=k, replace=False)) if k < len(ALL18) else ALL18
r = basket(sub, end)
_, o = split(r)
shs.append(_sh(o))
dds.append(_dd(o))
curve[k] = (float(np.median(shs)), float(np.percentile(shs, 10)),
float(np.percentile(shs, 90)), float(np.median(dds)),
float(np.mean(np.array(shs) > 0)))
m, p10, p90, mdd, fp = curve[k]
print(f" {k:>3}{m:>10.2f}{p10:>8.2f}{p90:>8.2f}{mdd*100:>8.1f}%{fp*100:>10.0f}%")
ks = sorted(curve)
med = [curve[k][0] for k in ks]
mono = int(np.sum(np.diff(med) > 0))
print(f"\n monotonia: {mono}/{len(med)-1} passi di k con mediana in salita "
f"(k=1 {med[0]:.2f} -> k=18 {med[-1]:.2f}, delta {med[-1]-med[0]:+.2f})")
print(f" DD mediano: k=1 {curve[1][3]*100:.1f}% -> k=18 {curve[18][3]*100:.1f}%")
# ---------------- D) l'incumbent e' cherry-picked?
print("\n" + "-" * 100)
print(" (D) GTAA6 e' un 6-subset SPECIALE? (se lo fosse, il confronto B sarebbe truccato a suo favore)")
print("-" * 100)
rng = np.random.default_rng(SEED + 1)
sh6 = []
for _ in range(N_DRAWS):
sub = list(rng.choice(ALL18, size=6, replace=False))
_, o = split(basket(sub, end))
sh6.append(_sh(o))
sh6 = np.array(sh6)
pct = float((sh6 < _sh(o6)).mean())
print(f" Sharpe OOS GTAA6 = {_sh(o6):.2f} percentile fra {N_DRAWS} 6-subset casuali = {pct*100:.0f}°")
print(f" distribuzione 6-subset: mediana {np.median(sh6):.2f} p10 {np.percentile(sh6,10):.2f} "
f"p90 {np.percentile(sh6,90):.2f}")
print(" lettura: >85° = incumbent fortunato (il confronto B lo favorisce); ~50° = confronto equo.")
# ---------------- E) marginale sul book
print("\n" + "-" * 100)
print(" (E) MARGINALE SUL BOOK 5-SLEEVE — sostituzione a peso INVARIATO (20%), non un tilt")
print("-" * 100)
from src.portfolio.sleeves import active_sleeves, GTAA_BOOK_ACTIVATION
from src.portfolio.portfolio import Sleeve, StrategyPortfolio
def _book_shape(r: pd.Series) -> pd.Series:
"""Stesso trattamento che sleeves._gtaa_daily_returns da' al GTAA nel book:
griglia daily piena (weekend/festivi = 0.0, capitale IB fermo) + attivazione all'era book."""
s = r.dropna().sort_index()
if s.index.tz is None:
s.index = s.index.tz_localize("UTC")
days = pd.date_range(s.index.min().normalize(), s.index.max().normalize(), freq="1D", tz="UTC")
s = s.reindex(days).fillna(0.0)
return s[s.index >= GTAA_BOOK_ACTIVATION]
sl = active_sleeves()
print(f" sleeve attivi: {[(s.name, round(s.weight, 3)) for s in sl]}")
swapped = [
Sleeve(s.name + "→MAT18", s.weight, (lambda _x=_book_shape(r18): _x)) if "GTAA" in s.name.upper()
else s
for s in sl
]
b_base = StrategyPortfolio(sl).backtest()
b_swap = StrategyPortfolio(swapped).backtest()
print(f" {'book':<26}{'Sh FULL':>9}{'Sh HOLD':>9}{'DD FULL':>10}{'CAGR FULL':>11}")
for nm, b in (("base (GTAA6)", b_base), ("con MAT18", b_swap)):
print(f" {nm:<26}{b['full']['sharpe']:>9.2f}{b['holdout']['sharpe']:>9.2f}"
f"{b['full']['maxdd']*100:>9.1f}%{b['full']['cagr']*100:>10.1f}%")
print(f" {'DELTA':<26}{b_swap['full']['sharpe']-b_base['full']['sharpe']:>+9.2f}"
f"{b_swap['holdout']['sharpe']-b_base['holdout']['sharpe']:>+9.2f}"
f"{(b_swap['full']['maxdd']-b_base['full']['maxdd'])*100:>+9.1f}pp"
f"{(b_swap['full']['cagr']-b_base['full']['cagr'])*100:>+10.1f}pp")
# ---------------- F) eseguibilita'
print("\n" + "-" * 100)
print(" (F) ESEGUIBILITA' a capitale piccolo (IB, frazionabili)")
print("-" * 100)
ex_now = {s: float(_exposure(_close(s)).iloc[-1]) / len(ALL18) for s in ALL18}
inv = {s: w for s, w in ex_now.items() if w > 1e-6}
print(f" gambe investite ora: {len(inv)}/{len(ALL18)} cash {100*(1-sum(ex_now.values())):.1f}%")
for cap in (600, 2000, 5000, 20000):
sleeve_cap = cap * 0.20 # GTAA pesa 20% del book
tickets = [sleeve_cap * w for w in inv.values()]
if not tickets:
continue
print(f" capitale ${cap:>6} sleeve ${sleeve_cap:>7.0f} ticket min ${min(tickets):>6.2f} "
f"medio ${np.mean(tickets):>6.2f} (< $1 = non eseguibile anche con frazioni)")
print("\n" + "=" * 100)
if __name__ == "__main__":
main()
+179
View File
@@ -0,0 +1,179 @@
"""MAT01-b — il vantaggio di GTAA6 su MAT18 e' EDGE o SCOMMESSA DI REGIME sull'equity US?
CONTESTO (da r0725_mat01_multiasset_trend.py):
* ALL18 (universo multi-classe, ZERO liberta' di selezione) fa OOS Sharpe 0.80 / DD -6.9%.
* GTAA6 (incumbent) fa OOS Sharpe 0.89 / DD -8.2% -> l'espansione NON migliora lo Sharpe.
* MA la curva di ampiezza e' monotona (mediana OOS 0.44 a k=1 -> 0.80 a k=18, DD -16% -> -6.9%):
l'ampiezza FUNZIONA come meccanismo.
* E GTAA6 sta al 95° percentile dei 6-subset casuali PUR CONTENENDO TLT, il peggiore dei 18
(Sh OOS 0.03) -> non e' un best-of cherry-picked: e' la sua COMPOSIZIONE a essere forte.
DOMANDA: quella composizione (3 gambe su 6 = equity US: SPY+QQQ+IWM, piu' GLD e HYG) e' un edge
stabile, o e' beta azionario US travestito da trend, premiato da un OOS 2015-2026 che e' stato un
toro azionario US quasi ininterrotto? La distinzione decide se lo 0.89 e' una stima onesta del
forward o un massimo di regime esattamente la domanda che il progetto si e' gia' posto sulle
ancore temporali (anchor-luck) e sui pesi (weights_tilt_null).
TEST
1) FINESTRE DISGIUNTE (nessun overlap, ~7 anni ciascuna): se GTAA6 vince in tutte -> edge di
composizione; se vince solo dove l'equity US corre -> regime.
2) BETA AZIONARIO: regressione di ciascun paniere su SPY buy&hold (beta, alpha annualizzata,
corr). Un "trend difensivo multi-asset" con beta alto e' equity long travestito.
3) COMPORTAMENTO NEI BEAR AZIONARI (dot-com, GFC, COVID, 2022): il valore dichiarato dello
sleeve e' il taglio del drawdown -> e' li' che si giudica, non nel toro.
4) MAT18-CLS: aggregazione per CLASSE invece che per asset (7 classi equipesate). NON e'
selezione: e' la costruzione multi-asset canonica (l'EW per asset sovrappesa meccanicamente
commodity 4/18 ed equity US 4/18 contro real_estate 1/18). 1 variante in piu' -> 3 trial totali.
5) PARI VOLATILITA': MAT18 ha vol piu' bassa; il confronto CAGR va fatto a vol uguagliata (lo
Sharpe e' scale-invariante, il CAGR no) — quanto costa/rende davvero il cambio.
6) DIVERSIFICAZIONE DEL BOOK: correlazione di ciascun paniere alla parte CRYPTO del book. Il
compito di questo sleeve non e' fare Sharpe da solo, e' essere scorrelato dal crypto.
uv run python scripts/research/r0725_mat01b_regime.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
from r0725_mat01_multiasset_trend import (ALL18, CLASSES, GTAA6, _cagr, _dd, _sh,
basket, gated, _close)
ANN = np.sqrt(252.0)
# finestre disgiunte, ~7 anni ciascuna, definite sul calendario (non sui risultati)
WINDOWS = [("1998-2004", "1998-01-01", "2004-12-31"),
("2005-2011", "2005-01-01", "2011-12-31"),
("2012-2018", "2012-01-01", "2018-12-31"),
("2019-2026", "2019-01-01", "2026-12-31")]
# bear azionari US, date a priori (nessuna ottimizzazione)
BEARS = [("dot-com 2000-09/2002-10", "2000-09-01", "2002-10-31"),
("GFC 2007-10/2009-03", "2007-10-01", "2009-03-31"),
("COVID 2020-02/2020-03", "2020-02-15", "2020-03-31"),
("2022 2022-01/2022-10", "2022-01-01", "2022-10-31")]
def _win(r: pd.Series, lo: str, hi: str) -> pd.Series:
return r[(r.index >= pd.Timestamp(lo, tz="UTC")) & (r.index <= pd.Timestamp(hi, tz="UTC"))]
def class_basket(end) -> pd.Series:
"""MAT18-CLS: media EW delle 7 CLASSI (ogni classe = media EW dei suoi asset disponibili)."""
per_class = {}
for cls, syms in CLASSES.items():
cols = {s: gated(s) for s in syms}
per_class[cls] = pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True)
r = pd.concat(per_class, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
return r[r.index <= end]
def spy_bh(end) -> pd.Series:
c = _close("SPY")
return c.pct_change().dropna()[lambda s: s.index <= end]
def main() -> None:
print("=" * 100)
print(" MAT01-b — GTAA6 vs MAT18: edge di composizione o scommessa di regime?")
print("=" * 100)
end = min(_close(s).index[-1] for s in ALL18)
r6, r18 = basket(GTAA6, end), basket(ALL18, end)
rcls = class_basket(end)
idx = r6.index.intersection(r18.index).intersection(rcls.index)
r6, r18, rcls = r6[idx], r18[idx], rcls[idx]
books = [("GTAA6", r6), ("MAT18", r18), ("MAT18-CLS", rcls)]
# ---------------- 1) finestre disgiunte
print("\n" + "-" * 100)
print(" (1) FINESTRE DISGIUNTE — Sharpe (e maxDD) per periodo; nessun overlap")
print("-" * 100)
print(f" {'periodo':<12}" + "".join(f"{n:>22}" for n, _ in books) + f"{'SPY B&H':>12}")
spy = spy_bh(end)
wins18 = wins_cls = 0
for nm, lo, hi in WINDOWS:
cells = []
for _, r in books:
w = _win(r, lo, hi)
cells.append(f"{_sh(w):>10.2f} / {_dd(w)*100:>6.1f}%" if len(w) > 60 else f"{'':>19}")
s6, s18, scl = (_sh(_win(r, lo, hi)) for _, r in books)
wins18 += int(s18 > s6)
wins_cls += int(scl > s6)
print(f" {nm:<12}" + "".join(f"{c:>22}" for c in cells) + f"{_sh(_win(spy, lo, hi)):>12.2f}")
print(f"\n MAT18 batte GTAA6 in {wins18}/{len(WINDOWS)} finestre disgiunte; "
f"MAT18-CLS in {wins_cls}/{len(WINDOWS)}")
# ---------------- 2) beta azionario
print("\n" + "-" * 100)
print(" (2) BETA AZIONARIO US — regressione del paniere su SPY buy&hold (giorni comuni)")
print("-" * 100)
print(f" {'paniere':<12}{'corr SPY':>10}{'beta':>8}{'alpha ann':>12}{'volAnn':>9}{'Sh FULL':>9}")
for nm, r in books:
j = pd.concat({"r": r, "m": spy}, axis=1, sort=True).dropna()
beta = float(np.cov(j["r"], j["m"])[0, 1] / np.var(j["m"]))
alpha = float((j["r"] - beta * j["m"]).mean() * 252)
print(f" {nm:<12}{j['r'].corr(j['m']):>10.2f}{beta:>8.2f}{alpha*100:>11.2f}%"
f"{r.std()*ANN*100:>8.2f}%{_sh(r):>9.2f}")
print(" lettura: beta alto + alpha bassa = equity long travestito da trend.")
# ---------------- 3) bear azionari
print("\n" + "-" * 100)
print(" (3) BEAR AZIONARI — dove lo sleeve deve guadagnarsi lo stipendio (ritorno cumulato)")
print("-" * 100)
print(f" {'bear':<28}" + "".join(f"{n:>12}" for n, _ in books) + f"{'SPY B&H':>12}")
for nm, lo, hi in BEARS:
cells = []
for _, r in books:
w = _win(r, lo, hi)
cells.append(f"{(np.prod(1+w.values)-1)*100:>11.1f}%" if len(w) > 5 else f"{'':>12}")
ws = _win(spy, lo, hi)
print(f" {nm:<28}" + "".join(cells) + f"{(np.prod(1+ws.values)-1)*100:>11.1f}%")
# ---------------- 4/5) pari volatilita'
print("\n" + "-" * 100)
print(" (4/5) CONFRONTO A PARI VOLATILITA' (scalato alla vol di GTAA6; Sharpe invariato)")
print("-" * 100)
v6 = float(r6.std())
print(f" {'paniere':<12}{'scala':>8}{'Sh FULL':>10}{'Sh OOS':>9}{'CAGR@vol6':>12}{'maxDD@vol6':>12}")
for nm, r in books:
k = v6 / float(r.std())
rs = r * k
o = rs[rs.index >= pd.Timestamp("2015-01-01", tz="UTC")]
print(f" {nm:<12}{k:>8.2f}{_sh(rs):>10.2f}{_sh(o):>9.2f}{_cagr(rs)*100:>11.2f}%{_dd(rs)*100:>11.1f}%")
# ---------------- 6) diversificazione del book
print("\n" + "-" * 100)
print(" (6) IL VERO COMPITO: scorrelazione dalla parte CRYPTO del book")
print("-" * 100)
try:
from src.portfolio.portfolio import combine_outer
from src.portfolio.sleeves import active_sleeves
cry = [s for s in active_sleeves() if "GTAA" not in s.name.upper()]
wsum = sum(s.weight for s in cry)
crypto = combine_outer({s.name: s.daily() for s in cry},
{s.name: s.weight / wsum for s in cry})
print(f" crypto del book = {[s.name for s in cry]} (pesi rinormalizzati)")
print(f" {'paniere':<12}{'corr->crypto':>14}{'Sh 50/50':>11}{'DD 50/50':>11}")
for nm, r in books:
j = pd.concat({"e": r, "c": crypto}, axis=1, sort=True).dropna()
if len(j) < 100:
print(f" {nm:<12}{'(overlap corto)':>14}")
continue
bl = 0.5 * j["e"] + 0.5 * j["c"]
print(f" {nm:<12}{j['e'].corr(j['c']):>14.3f}{_sh(bl):>11.2f}{_dd(bl)*100:>10.1f}%")
except Exception as e:
print(f" [skip: {e.__class__.__name__}: {e}]")
print("\n" + "=" * 100)
if __name__ == "__main__":
main()
@@ -0,0 +1,219 @@
"""STATARB-BASKET — il paniere multi-coppia e' uno SLEEVE? Giudizio coi gate veri del progetto.
DA DOVE VIENE. `r0725_statarb_multi.py` ha applicato il meccanismo CONGELATO (W=45, sgn=+1, preso
da un altro studio: qui non e' stato cercato nulla) alle 50 coppie alt/BTC di Hyperliquid e ha
trovato un paniere EW con Sharpe 0.82, maxDD -6.0%, corr a XS01 solo 0.207, 0/50 coppie degeneri.
Quello script pero' si fermava alla statistica descrittiva. Questo lo porta davanti ai gate che il
progetto usa per ammettere uno sleeve:
study/marginal_vs_tp01 -> ADDS / HEDGE / NOISE / REDUNDANT / DILUTES / NEUTRAL
(include multi-cut, noise-null a corr-zero, hedge-vs-alpha)
deflated_sharpe -> PASS >= 0.95, coi trial DAVVERO fatti
weights_tilt_null -> ogni proposta di peso vs il null dei tilt casuali
corr per-sleeve -> ridondanza contro i 5 sleeve attivi
PRECEDENTE CHE RENDE LA DOMANDA LEGITTIMA: XS01 e' nel book al 15% pur essendo STAT-MODE (19 gambe,
non eseguibili a $600). Un paniere di coppie a 51 gambe sarebbe ammissibile alle STESSE condizioni,
se supera i gate. Non e' quindi l'eseguibilita' a decidere qui, e' l'edge.
VARIANTI PRE-REGISTRATE (3, contate nel deflated-Sharpe; nessuna scelta guardando i risultati):
V1 ALL50 tutte le coppie alt/BTC valutabili. Zero liberta' di selezione.
V2 MAJ19 solo i 19 major liquidi, cioe' l'universo XS_UNIVERSE definito il 2026-06-19 per
ragioni di LIQUIDITA' in un altro studio: sottoinsieme a priori, non scelto oggi.
V3 DEMEAN ALL50 con le posizioni demeanate cross-sezionalmente ogni giorno. Motivo strutturale
dichiarato prima di guardare: le 50 coppie condividono la gamba BTC, per questo
l'ampiezza effettiva era 4.5 invece di 50; togliere la componente comune dovrebbe
alzarla. E' una costruzione standard (neutralizzare il fattore comune), non un fit.
uv run python scripts/research/r0725_statarb_basket_gate.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
from r0725_statarb_multi import (BASE, BLOCK, MIN_BARS, SEED, load_hl, pnl, signal, universe,
_dd, _sh)
ANN = np.sqrt(365.0)
def pair_frames() -> tuple[pd.DataFrame, pd.DataFrame]:
"""Matrici [data x coppia] di posizione e ritorno-spread, per tutte le coppie valutabili."""
base_px = load_hl(BASE)
pos_cols, spr_cols = {}, {}
for sym in universe():
try:
tgt = load_hl(sym)
except FileNotFoundError:
continue
ix = base_px.index.intersection(tgt.index)
if len(ix) < MIN_BARS:
continue
p, s = signal(base_px[ix], tgt[ix])
pos_cols[sym] = pd.Series(p, index=ix)
spr_cols[sym] = pd.Series(s, index=ix)
P = pd.concat(pos_cols, axis=1, sort=True).sort_index()
S = pd.concat(spr_cols, axis=1, sort=True).sort_index()
return P, S
def basket_from_positions(P: pd.DataFrame, S: pd.DataFrame, cols=None,
demean: bool = False) -> pd.Series:
"""Rendimento EW del paniere, ricalcolando fee su ogni gamba (2 per coppia)."""
if cols is not None:
cols = [c for c in cols if c in P.columns]
P, S = P[cols], S[cols]
if demean:
P = P.sub(P.mean(axis=1), axis=0)
out = {}
for c in P.columns:
p = P[c].to_numpy(float)
s = S[c].to_numpy(float)
out[c] = pd.Series(pnl(p, s), index=P.index)
return pd.concat(out, axis=1, sort=True).mean(axis=1, skipna=True).dropna()
def eff_breadth(P: pd.DataFrame, S: pd.DataFrame, cols=None, demean=False) -> float:
if cols is not None:
cols = [c for c in cols if c in P.columns]
P, S = P[cols], S[cols]
if demean:
P = P.sub(P.mean(axis=1), axis=0)
R = {}
for c in P.columns:
R[c] = pd.Series(pnl(P[c].to_numpy(float), S[c].to_numpy(float)), index=P.index)
M = pd.concat(R, axis=1, sort=True)
C = M.corr().values
off = C[~np.eye(len(C), dtype=bool)]
rbar = float(np.nanmean(off))
return len(C) / (1.0 + (len(C) - 1) * rbar) if rbar > -1 / (len(C) - 1) else float(len(C))
def main() -> None:
print("=" * 100)
print(" STATARB-BASKET — il paniere multi-coppia supera i gate di ammissione a sleeve?")
print("=" * 100)
from src.portfolio.sleeves import XS_UNIVERSE
P, S = pair_frames()
print(f"\n coppie valutabili: {P.shape[1]} barre: {len(P)} "
f"da {P.index[0].date()} a {P.index[-1].date()}")
maj = [s for s in XS_UNIVERSE if s != BASE]
variants = {
"V1 ALL50": dict(cols=None, demean=False),
"V2 MAJ19": dict(cols=maj, demean=False),
"V3 DEMEAN": dict(cols=None, demean=True),
}
series, srs = {}, []
print("\n" + "-" * 100)
print(" (1) LE TRE VARIANTI PRE-REGISTRATE")
print("-" * 100)
print(f" {'variante':<12}{'gambe':>7}{'Sharpe':>9}{'maxDD':>9}{'ret tot':>10}{'ampiezza eff':>15}")
for nm, kw in variants.items():
r = basket_from_positions(P, S, **kw)
series[nm] = r
srs.append(_sh(r.values))
nlegs = (P.shape[1] if kw["cols"] is None else len([c for c in kw["cols"] if c in P.columns]))
print(f" {nm:<12}{nlegs:>7}{_sh(r.values):>9.2f}{_dd(r.values)*100:>8.1f}%"
f"{(np.prod(1+r.values)-1)*100:>9.1f}%{eff_breadth(P, S, **kw):>15.1f}")
# ---------------- gate del progetto su ciascuna variante
from altlib import deflated_sharpe, marginal_vs_tp01
print("\n" + "-" * 100)
print(" (2) GATE MARGINALE vs TP01 (il gate che decide, non lo Sharpe assoluto)")
print("-" * 100)
reports = {}
for nm, r in series.items():
rr = r.copy()
rr.index = pd.to_datetime(rr.index, utc=True)
m = marginal_vs_tp01(rr)
reports[nm] = m
print(f"\n --- {nm} ---")
print(f" verdetto : {m.get('marginal_verdict')}")
for k in ("corr", "robust_oos", "beats_noise_null", "is_hedge", "has_insample_edge",
"tp01_beta", "alpha_ann"):
if k in m:
v = m[k]
print(f" {k:<15}: {v if not isinstance(v, float) else round(v, 4)}")
bl = m.get("blends", {})
for w, d in (bl.items() if isinstance(bl, dict) else []):
if isinstance(d, dict):
print(f" blend w={w}: " + " ".join(
f"{k}={round(v,3) if isinstance(v,(int,float)) else v}" for k, v in d.items()))
# ---------------- deflated Sharpe coi trial veri
print("\n" + "-" * 100)
print(" (3) DEFLATED SHARPE — trial reali = 3 varianti (la config W=45/sgn=+1 viene da un")
print(" altro studio: su QUESTI dati non e' stata cercata)")
print("-" * 100)
for nm, r in series.items():
dsr, null_max = deflated_sharpe(_sh(r.values), srs, r.values, dpy=365.0)
print(f" {nm:<12} Sharpe {_sh(r.values):>5.2f} DSR {dsr:>6.3f} "
f"(max atteso sotto il null {null_max:>5.2f}) {'PASS' if dsr >= 0.95 else 'sotto 0.95'}")
# ---------------- correlazione ai 5 sleeve attivi + tilt-null
print("\n" + "-" * 100)
print(" (4) RIDONDANZA vs I 5 SLEEVE ATTIVI + weights_tilt_null sul peso proposto")
print("-" * 100)
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, weights_tilt_null
from src.portfolio.sleeves import active_sleeves
sl = active_sleeves()
best = max(series.items(), key=lambda kv: _sh(kv[1].values))
nm_best, r_best = best
rb = r_best.copy()
rb.index = pd.to_datetime(rb.index, utc=True)
print(f" variante col miglior Sharpe: {nm_best}")
for s in sl:
j = pd.concat({"a": s.daily(), "b": rb}, axis=1, sort=True).dropna()
if len(j) > 60:
print(f" corr -> {s.name:<20} {j['a'].corr(j['b']):>6.3f} ({len(j)} giorni comuni)")
daily_cols = {s.name: s.daily() for s in sl}
daily_cols["STATARB_BASKET"] = rb
w_cur = {s.name: s.weight for s in sl}
w_cur["STATARB_BASKET"] = 0.0
for w_new in (0.10, 0.15):
k = 1.0 - w_new
w_prop = {s.name: s.weight * k for s in sl}
w_prop["STATARB_BASKET"] = w_new
try:
res = weights_tilt_null(daily_cols, w_cur, w_prop)
print(f"\n peso {w_new:.0%} (i 5 scalati x{k:.2f}):")
for kk, vv in res.items():
if isinstance(vv, (int, float, bool, str)):
print(f" {kk:<22} {round(vv,4) if isinstance(vv,float) else vv}")
except Exception as e:
print(f" [tilt-null non calcolabile a w={w_new}: {e.__class__.__name__}: {e}]")
# ---------------- book con/senza
print("\n" + "-" * 100)
print(" (5) BOOK CON E SENZA — Sharpe/DD FULL e HOLD-OUT")
print("-" * 100)
b0 = StrategyPortfolio(sl).backtest()
for w_new in (0.10, 0.15):
k = 1.0 - w_new
sl2 = [Sleeve(s.name, s.weight * k, s.daily_fn, s.pos_fn) for s in sl]
sl2.append(Sleeve("STATARB_BASKET", w_new, lambda _r=rb: _r))
b1 = StrategyPortfolio(sl2).backtest()
print(f" w={w_new:.0%} FULL {b0['full']['sharpe']:.2f} -> {b1['full']['sharpe']:.2f} "
f"HOLD {b0['holdout']['sharpe']:.2f} -> {b1['holdout']['sharpe']:.2f} "
f"DD {b0['full']['maxdd']*100:.1f}% -> {b1['full']['maxdd']*100:.1f}%")
print("\n" + "=" * 100)
if __name__ == "__main__":
main()
@@ -0,0 +1,227 @@
"""SCETTICO su STATARB-DEMEAN (V3) — prima di crederci.
IL CANDIDATO. `r0725_statarb_basket_gate.py` ha prodotto la prima cosa che somiglia a uno sleeve
nuovo da molte ondate: posizioni del meccanismo congelato (W=45, sgn=+1) sulle 50 coppie alt/BTC,
DEMEANATE cross-sezionalmente ogni giorno. Sharpe 1.79, maxDD -2.5%, ampiezza effettiva 37.4,
marginale ADDS, deflated-Sharpe 0.985 (PASS), corr ~0 a tutti e 5 gli sleeve attivi.
Numeri cosi' puliti sono, nella storia di questo progetto, il momento in cui si e' quasi sempre
scoperto un artefatto (feed testnet, look-ahead ffill, CC01 Sharpe 11, celle 0-perdite). Quindi
prima di scrivere "trovata", si prova a UCCIDERLA.
OSSERVAZIONE STRUTTURALE CHE MOTIVA IL TEST PRINCIPALE. Demeanare annulla algebricamente la
gamba BTC: sum_i (p_i - p̄)(r_i - r_BTC) = sum_i (p_i - p̄) r_i , perche' sum_i (p_i - p̄) = 0.
Quindi V3 NON e' piu' un paniere di coppie: e' una strategia CROSS-SECTIONAL sui 50 alt con pesi
(p_i - p̄). Va quindi testata come tale, e in particolare contro il sospetto che il merito sia
della COSTRUZIONE (demean + vol-target + 50 asset) e non del SEGNALE.
I TEST (un candidato deve sopravvivere a tutti):
T1 NULL DI PERMUTAZIONE CROSS-SEZIONALE ogni giorno si permutano le etichette-asset del vettore
di posizione. Distribuzione di posizioni, demeaning, vol-target, costi: tutto identico; sparisce
SOLO l'abbinamento segnale<->asset. Se il candidato non batte questo null, l'edge e' costruzione.
T2 NULL CASUALE posizioni casuali tanh(N(0,1)) con la stessa scalatura e lo stesso demeaning.
T3 NULL DI PERMUTAZIONE TEMPORALE a blocchi (20g) rompe l'allineamento nel tempo.
T4 LAG ritardare la posizione di 1 e 2 giorni. Un segnale lento degrada dolcemente; un
look-ahead crolla a zero immediatamente.
T5 SOTTOPERIODI anno per anno 2.6 anni sono un regime solo (bear alt): dove vive il rendimento?
T6 COSTI la fee attuale addebita 2 gambe per coppia, ma dopo il demeaning la gamba BTC si
annulla: e' quindi SOVRASTIMATA. Si verifica comunque a 0.05 / 0.10 / 0.20% per gamba.
T7 RIDONDANZA con un momentum cross-sectional semplice sugli stessi 50 (e' XS01 travestito?).
T8 STRUTTURA esposizione lorda/netta, bilanciamento long/short, turnover, gambe che si muovono.
uv run python scripts/research/r0725_statarb_demean_skeptic.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
from r0725_statarb_multi import FEE_LEG, SEED, _dd, _sh
from r0725_statarb_basket_gate import pair_frames
ANN = np.sqrt(365.0)
N_NULL = 300
BLOCK = 20
def ret_from_pos(P: pd.DataFrame, S: pd.DataFrame, fee_leg: float = FEE_LEG,
lag: int = 0, demean: bool = True) -> pd.Series:
"""Rendimento EW del paniere da una matrice di posizioni [data x asset]. `lag` giorni in piu'."""
Q = P.sub(P.mean(axis=1), axis=0) if demean else P
q = Q.to_numpy(float)
s = S.to_numpy(float)
held = np.zeros_like(q)
k = 1 + lag
if k < len(q):
held[k:] = q[:-k]
turn = np.abs(np.diff(held, axis=0, prepend=np.zeros((1, q.shape[1]))))
net = held * s - 2.0 * fee_leg * turn
return pd.Series(np.nanmean(net, axis=1), index=P.index).dropna()
def main() -> None:
print("=" * 100)
print(" SCETTICO — STATARB-DEMEAN (V3): si riesce a ucciderla?")
print("=" * 100)
P, S = pair_frames()
P = P.fillna(0.0)
S = S.reindex(P.index)[P.columns]
base = ret_from_pos(P, S)
sh0 = _sh(base.values)
print(f"\n candidato: {P.shape[1]} asset, {len(P)} barre "
f"({P.index[0].date()} -> {P.index[-1].date()})")
print(f" Sharpe {sh0:.2f} maxDD {_dd(base.values)*100:.1f}% "
f"vol ann {base.std()*ANN*100:.2f}% ret ann {base.mean()*365*100:.2f}%")
rng = np.random.default_rng(SEED)
q = P.to_numpy(float)
# ⚠ I NULL SI CONFRONTANO A FEE ZERO. Permutare le posizioni ne fa esplodere il turnover
# (un asset riceve ogni giorno una posizione scorrelata dalla precedente), quindi un null a
# fee piene sarebbe battuto per motivi di COSTO e non di segnale: darebbe un p-value
# artificialmente trionfale. A fee zero il confronto isola l'informazione.
sh0_gross = _sh(ret_from_pos(P, S, fee_leg=0.0).values)
print(f" Sharpe LORDA (fee 0) {sh0_gross:.2f} <- e' questa che i null devono battere")
# ---------------- T1 permutazione cross-sezionale
print("\n" + "-" * 100)
print(" T1 NULL DI PERMUTAZIONE CROSS-SEZIONALE — il test decisivo")
print(" (stessa distribuzione di posizioni, stesso demeaning, stesso vol-target e costi;")
print(" si rompe SOLO l'abbinamento segnale<->asset)")
print("-" * 100)
nulls = []
for _ in range(N_NULL):
qq = np.take_along_axis(q, rng.permuted(np.tile(np.arange(q.shape[1]), (q.shape[0], 1)),
axis=1), axis=1)
nulls.append(_sh(ret_from_pos(pd.DataFrame(qq, index=P.index, columns=P.columns), S, fee_leg=0.0).values))
nulls = np.array(nulls)
p1 = float((nulls >= sh0_gross).mean())
print(f" null: media {nulls.mean():>6.2f} mediana {np.median(nulls):>6.2f} "
f"p95 {np.percentile(nulls,95):>6.2f} max {nulls.max():>6.2f}")
print(f" candidato LORDO {sh0_gross:.2f} -> p = {p1:.4f} "
f"{'SOPRAVVIVE' if p1 < 0.05 else '*** UCCISO: l edge e la COSTRUZIONE, non il segnale ***'}")
# ---------------- T2 posizioni casuali
print("\n" + "-" * 100)
print(" T2 NULL CASUALE — posizioni tanh(N(0,1)) con stessa scala, demeaning e costi")
print("-" * 100)
scale = np.abs(q).mean()
r2 = []
for _ in range(N_NULL):
qq = np.tanh(rng.normal(0, 1, size=q.shape)) * scale / max(np.abs(np.tanh(1.0)), 1e-9)
qq[q == 0.0] = 0.0 # stessa maschera di "non ancora attivo"
r2.append(_sh(ret_from_pos(pd.DataFrame(qq, index=P.index, columns=P.columns), S, fee_leg=0.0).values))
r2 = np.array(r2)
p2 = float((r2 >= sh0_gross).mean())
print(f" null: media {r2.mean():>6.2f} p95 {np.percentile(r2,95):>6.2f} max {r2.max():>6.2f}"
f" -> p = {p2:.4f} {'SOPRAVVIVE' if p2 < 0.05 else 'UCCISO'}")
# ---------------- T3 permutazione temporale a blocchi
print("\n" + "-" * 100)
print(" T3 NULL DI PERMUTAZIONE TEMPORALE (blocchi 20g)")
print("-" * 100)
nb = int(np.ceil(len(q) / BLOCK))
r3 = []
for _ in range(N_NULL):
order = rng.permutation(nb)
qq = np.concatenate([q[i * BLOCK:(i + 1) * BLOCK] for i in order])[:len(q)]
r3.append(_sh(ret_from_pos(pd.DataFrame(qq, index=P.index, columns=P.columns), S, fee_leg=0.0).values))
r3 = np.array(r3)
p3 = float((r3 >= sh0_gross).mean())
print(f" null: media {r3.mean():>6.2f} p95 {np.percentile(r3,95):>6.2f} -> p = {p3:.4f}"
f" {'SOPRAVVIVE' if p3 < 0.05 else 'UCCISO'}")
# ---------------- T4 lag
print("\n" + "-" * 100)
print(" T4 LAG — un segnale lento degrada dolcemente, un look-ahead crolla")
print("-" * 100)
for lag in (0, 1, 2, 3):
r = ret_from_pos(P, S, lag=lag)
print(f" lag +{lag}g: Sharpe {_sh(r.values):>5.2f} maxDD {_dd(r.values)*100:>5.1f}%")
# ---------------- T5 sottoperiodi
print("\n" + "-" * 100)
print(" T5 SOTTOPERIODI — 2.6 anni sono UN regime; dove vive il rendimento?")
print("-" * 100)
for y in sorted(set(base.index.year)):
w = base[base.index.year == y]
if len(w) < 40:
continue
print(f" {y}: n={len(w):>4} Sharpe {_sh(w.values):>5.2f} "
f"ret {(np.prod(1+w.values)-1)*100:>6.2f}% maxDD {_dd(w.values)*100:>5.1f}%")
half = len(base) // 2
print(f" prima meta' Sharpe {_sh(base.values[:half]):>5.2f} "
f"seconda meta' {_sh(base.values[half:]):>5.2f}")
# ---------------- T6 costi
print("\n" + "-" * 100)
print(" T6 COSTI — nota: dopo il demeaning la gamba BTC si annulla, quindi addebitare")
print(" 2 gambe per coppia SOVRASTIMA i costi. Si verifica comunque al rialzo.")
print("-" * 100)
for f in (0.0, 0.0005, 0.0010, 0.0020, 0.0040):
r = ret_from_pos(P, S, fee_leg=f)
print(f" fee {f*100:>5.2f}%/gamba: Sharpe {_sh(r.values):>5.2f} "
f"ret ann {r.mean()*365*100:>6.2f}%")
# ---------------- T7 ridondanza con XS momentum semplice
print("\n" + "-" * 100)
print(" T7 RIDONDANZA — e' un momentum cross-sectional travestito?")
print("-" * 100)
from r0725_statarb_multi import load_hl, universe
px = {}
for s_ in universe():
try:
px[s_] = load_hl(s_)
except FileNotFoundError:
pass
PX = pd.concat(px, axis=1, sort=True).reindex(P.index).ffill()
for L in (30, 45, 90):
mom = np.log(PX / PX.shift(L))
z = mom.sub(mom.mean(axis=1), axis=0).div(mom.std(axis=1).replace(0, np.nan), axis=0)
w = np.tanh(z.fillna(0.0))
w = w.sub(w.mean(axis=1), axis=0)
rx = ret_from_pos(w[P.columns].fillna(0.0), S, demean=False)
j = pd.concat({"a": base, "b": rx}, axis=1, sort=True).dropna()
print(f" XS-mom L={L:>3}: Sharpe {_sh(rx.values):>5.2f} "
f"corr al candidato {j['a'].corr(j['b']):>6.3f}")
# ---------------- T8 struttura
print("\n" + "-" * 100)
print(" T8 STRUTTURA — esposizione, bilanciamento, turnover, gambe operative")
print("-" * 100)
Q = P.sub(P.mean(axis=1), axis=0)
gross = Q.abs().sum(axis=1)
net = Q.sum(axis=1)
nlong = (Q > 0).sum(axis=1)
turn = Q.diff().abs().sum(axis=1)
print(f" esposizione LORDA media {gross.mean():>6.2f} (mediana {gross.median():.2f})")
print(f" esposizione NETTA media {net.mean():>8.2e} (deve essere ~0 per costruzione)")
print(f" gambe long/giorno media {nlong.mean():>5.1f} su {P.shape[1]} "
f"(bilanciamento atteso ~50%)")
print(f" turnover lordo/giorno {turn.mean():>6.2f} = {turn.mean()/max(gross.mean(),1e-9)*100:>5.1f}% del lordo")
n_ass = max(P.shape[1], 1)
# il rendimento e' la MEDIA sugli asset -> il peso di portafoglio della gamba i e' held_i/N.
# Quindi il lordo del paniere e' gross/N, e il nozionale per gamba e' cap*|held_i|/N.
gross_frac = gross.mean() / n_ass
print(f" lordo del paniere {gross_frac*100:>5.1f}% del capitale (leva {gross_frac:.2f}x)")
for cap in (600, 2000, 5000, 20000):
tk = cap * gross_frac / n_ass
print(f" cap ${cap:>6}: ticket medio per gamba ${tk:>7.2f} "
f"{'-> sotto il min-order $5: NON eseguibile' if tk < 5 else '-> sopra il min-order'}")
print("\n" + "=" * 100)
verdict = (p1 < 0.05 and p2 < 0.05 and p3 < 0.05)
print(f" ESITO SCETTICO: {'i tre null NON la uccidono' if verdict else 'UCCISA da almeno un null'}")
print("=" * 100)
if __name__ == "__main__":
main()
+243
View File
@@ -0,0 +1,243 @@
"""STATARB-EQ — il meccanismo di STATARB-RESID su coppie ETF con 20-30 ANNI di storia.
PERCHE'. STATARB-RESID (relative-momentum del residuo, W=45, sgn=+1) e' il miglior lead del
progetto e ha un gate di deploy al 2026-09-27, ma la sua debolezza non e' il segno dei numeri:
e' la STATISTICA. Su crypto ha ~2,6 anni di storia; il test multi-coppia su Hyperliquid
(r0725_statarb_multi.py) ha mostrato che le 50 coppie alt/BTC valgono solo ~4,5 scommesse
indipendenti (corr media 0,204), che il paniere fa Sharpe 0,82 ma con IC95% [-0,12, 1,72]
cioe' NON distinguibile da zero — e che il t apparente 5,05 e' gonfiato dalla dipendenza.
Le coppie ETF risolvono esattamente questo: 20-30 anni di storia certificata (IB ADJUSTED_LAST,
gia' su disco), classi d'attivo diverse fra loro (quindi ampiezza effettiva vera), e 3-4 regimi
di mercato completi (dot-com, GFC, ZIRP, 2022) invece di un solo mini-ciclo alt.
DOMANDA: il "relative-momentum del residuo" e' un fenomeno reale e persistente, o e' un artefatto
della finestra crypto 2024-2026?
DISCIPLINA
* Meccanismo CONGELATO, importato da r0725_statarb_multi.py: W=45, sgn=+1, beta OLS rolling
causale, z-score su W, tanh, vol-target 20%, cap 2x. ZERO rifit, zero griglia.
* Coppie definite A PRIORI dentro la stessa classe d'attivo (relazione economica, non
data-mining di cointegrazione: cercare le coppie piu' cointegrate SU QUESTI STESSI DATI
sarebbe selezione). 12 coppie, elencate sotto con la loro ragione economica.
* Costi: 2 bps/lato per gamba (ETF liquidi) PIU' un costo di prestito sullo short, testato a
0 / 30 / 100 bps annui sui long/short azionari il borrow non e' un dettaglio.
* Null: (a) statica CAUSALE (segno = media espandente del segnale), (b) permutazione a blocchi.
* Stabilita' per DECADE: e' il test che i 2,6 anni crypto non possono fare.
uv run python scripts/research/r0725_statarb_eq.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
from r0725_statarb_multi import (BLOCK, CAP, N_PERM, SEED, TARGET_VOL, VOL_WIN, W, _dd,
_zscore, SGN)
from eqlib import load_eq
ANN_EQ = np.sqrt(252.0)
FEE_LEG = 0.0002 # 2 bps/lato per gamba (ETF liquidi su IB)
BORROW_GRID = (0.0, 0.0030, 0.0100) # costo prestito annuo sulla gamba corta
# --- 12 coppie A PRIORI: stessa classe d'attivo, relazione economica dichiarata ------------------
PAIRS = [
("DIA", "SPY", "US large cap: due proxy dello stesso indice"),
("IWM", "SPY", "small vs large cap US (size)"),
("QQQ", "SPY", "tech vs mercato ampio US"),
("EEM", "EFA", "emergenti vs sviluppati ex-US"),
("EWJ", "EFA", "Giappone vs sviluppati ex-US"),
("FXI", "EEM", "Cina vs emergenti"),
("EFA", "SPY", "ex-US vs US (home bias)"),
("SLV", "GLD", "argento vs oro (metalli preziosi)"),
("IEF", "TLT", "duration intermedia vs lunga (curva)"),
("HYG", "LQD", "credito HY vs IG (rischio di credito)"),
("LQD", "AGG", "credito IG vs aggregato (spread)"),
("USO", "DBC", "petrolio vs paniere commodity"),
]
def signal_eq(base_px: pd.Series, tgt_px: pd.Series):
"""Meccanismo CONGELATO, identico a r0725_statarb_multi.signal ma su calendario di borsa."""
x, y = np.log(base_px.values), np.log(tgt_px.values)
sx, sy = pd.Series(x), pd.Series(y)
mx = sx.rolling(W, min_periods=W).mean()
my = sy.rolling(W, min_periods=W).mean()
cov = (sx * sy).rolling(W, min_periods=W).mean() - mx * my
var = (sx * sx).rolling(W, min_periods=W).mean() - mx * mx
beta = cov / var.replace(0, np.nan)
resid = (sy - (my - beta * mx) - beta * sx).values
z = _zscore(np.nan_to_num(resid), W)
d = SGN * np.tanh(np.nan_to_num(z))
r_b = np.concatenate([[0.0], np.diff(base_px.values) / base_px.values[:-1]])
r_t = np.concatenate([[0.0], np.diff(tgt_px.values) / tgt_px.values[:-1]])
spread = r_t - r_b
vol = pd.Series(spread).rolling(VOL_WIN, min_periods=VOL_WIN).std().values * ANN_EQ
scal = np.where((vol > 0) & np.isfinite(vol), TARGET_VOL / vol, 0.0)
pos = np.clip(np.nan_to_num(d) * np.nan_to_num(scal), -CAP, CAP)
pos[~np.isfinite(pos)] = 0.0
return pos, spread, np.nan_to_num(scal)
def pnl_eq(pos: np.ndarray, spread: np.ndarray, borrow: float = 0.0) -> np.ndarray:
held = np.concatenate([[0.0], pos[:-1]])
turn = np.abs(np.diff(held, prepend=0.0))
# il prestito si paga sulla gamba corta: |posizione| di nozionale a prestito, ogni giorno
return held * spread - 2.0 * FEE_LEG * turn - borrow / 252.0 * np.abs(held)
def _sh(r: np.ndarray) -> float:
r = r[np.isfinite(r)]
return float(r.mean() / r.std() * ANN_EQ) if len(r) > 60 and r.std() > 0 else 0.0
def main() -> None:
print("=" * 108)
print(" STATARB-EQ — meccanismo congelato (W=45, sgn=+1) su coppie ETF, 20-30 anni di storia")
print("=" * 108)
rng = np.random.default_rng(SEED + 3)
rows = []
for tgt, base, why in PAIRS:
try:
b_all, t_all = load_eq(base)["close"].astype(float), load_eq(tgt)["close"].astype(float)
except FileNotFoundError:
print(f" [{tgt}/{base}] parquet mancante — salto")
continue
ix = b_all.index.intersection(t_all.index)
if len(ix) < 1000:
continue
b, t = b_all[ix], t_all[ix]
pos, spread, scal = signal_eq(b, t)
r = pnl_eq(pos, spread)
live = pos[pos != 0.0]
mono = float(max((live > 0).mean(), (live < 0).mean())) if len(live) else 0.0
# null statica CAUSALE
cum, cnt = np.cumsum(pos), np.arange(1, len(pos) + 1)
run_mean = np.concatenate([[0.0], (cum / cnt)[:-1]])
sc = np.where(run_mean >= 0, 1.0, -1.0)
sc[:VOL_WIN + W] = 0.0
r_stat = pnl_eq(np.clip(sc * scal, -CAP, CAP), spread)
# null permutazione a blocchi
nb = int(np.ceil(len(pos) / BLOCK))
perm = []
for _ in range(N_PERM):
order = rng.permutation(nb)
pp = np.concatenate([pos[k * BLOCK:(k + 1) * BLOCK] for k in order])[:len(pos)]
perm.append(_sh(pnl_eq(pp, spread)))
perm = np.array(perm)
gross = np.concatenate([[0.0], pos[:-1]]) * spread # senza fee ne' borrow
turn = np.abs(np.diff(np.concatenate([[0.0], pos[:-1]]), prepend=0.0))
rows.append(dict(pair=f"{tgt}/{base}", why=why, n=len(ix), idx=ix, ret=r,
sh_gross=_sh(gross), turn=float(turn.mean()),
sh=_sh(r), dd=_dd(r), mono=mono, sh_stat=_sh(r_stat),
up=_sh(r) - _sh(r_stat), pperm=float((perm >= _sh(r)).mean()),
yrs=(ix[-1] - ix[0]).days / 365.25))
if not rows:
print(" nessuna coppia valutabile")
return
print("\n" + "-" * 108)
print(" (1) COPPIE A PRIORI — meccanismo congelato, netto fee (borrow 0 in questa tabella)")
print("-" * 108)
print(f" {'coppia':<11}{'anni':>6}{'Sh LORDA':>10}{'Sh netta':>10}{'turn/g':>8}{'maxDD':>9}"
f"{'stat.caus':>11}{'uplift':>8}{'p perm':>9} ragione economica")
for d in sorted(rows, key=lambda x: -x["sh_gross"]):
print(f" {d['pair']:<11}{d['yrs']:>6.1f}{d['sh_gross']:>10.2f}{d['sh']:>10.2f}"
f"{d['turn']:>8.2f}{d['dd']*100:>8.1f}%"
f"{d['sh_stat']:>11.2f}{d['up']:>8.2f}{d['pperm']:>9.3f} {d['why']}")
sh = np.array([d["sh"] for d in rows])
shg = np.array([d["sh_gross"] for d in rows])
up = np.array([d["up"] for d in rows])
pp = np.array([d["pperm"] for d in rows])
n = len(rows)
print(f"\n coppie {n} Sharpe LORDA media {shg.mean():>5.2f} mediana {np.median(shg):>5.2f} "
f"frazione > 0: {(shg>0).mean()*100:.0f}%")
print(f" Sharpe NETTA media {sh.mean():>5.2f} mediana {np.median(sh):>5.2f} "
f"frazione > 0: {(sh>0).mean()*100:.0f}%")
print(f" turnover medio {np.mean([d['turn'] for d in rows]):.2f}/giorno -> drag di fee "
f"{np.mean(shg-sh):.2f} Sharpe. LETTURA: se la LORDA e' ~0 l'edge non esiste ed e' inutile")
print(" cercare il segno opposto (anche lo specchio sarebbe ~0 lordo e negativo netto).")
print(f" uplift vs statica causale: media {up.mean():>5.2f} frazione > 0: {(up>0).mean()*100:.0f}%")
print(f" p permutazione < 0.05: {(pp<0.05).mean()*100:.0f}% delle coppie (atteso 5%)")
# ---------------- paniere + IC
print("\n" + "-" * 108)
print(" (2) PANIERE EW DELLE COPPIE — con l'ampiezza e la storia che al crypto mancano")
print("-" * 108)
M = pd.concat({d["pair"]: pd.Series(d["ret"], index=d["idx"]) for d in rows},
axis=1, sort=True).sort_index()
bask = M.mean(axis=1, skipna=True).dropna()
yrs = len(bask) / 252.0
C = M.corr().values
off = C[~np.eye(len(C), dtype=bool)]
rbar = float(np.nanmean(off))
n_eff = len(C) / (1.0 + (len(C) - 1) * rbar) if rbar > -1 / (len(C) - 1) else float(len(C))
print(f" paniere: n={len(bask)} ({yrs:.1f} anni) Sharpe {_sh(bask.values):>5.2f} "
f"maxDD {_dd(bask.values)*100:>5.1f}%")
print(f" corr media fra coppie {rbar:>5.3f} -> ampiezza EFFETTIVA ~{n_eff:.1f} "
f"(vs ~4.5 delle 50 coppie crypto)")
rng2 = np.random.default_rng(SEED + 11)
v = bask.values
nb2 = int(np.ceil(len(v) / BLOCK))
boot = np.array([_sh(np.concatenate([v[i * BLOCK:(i + 1) * BLOCK]
for i in rng2.integers(0, nb2, size=nb2)])[:len(v)])
for _ in range(2000)])
print(f" block-bootstrap IC95% [{np.percentile(boot,2.5):>5.2f}, {np.percentile(boot,97.5):>5.2f}]"
f" P(Sh>0) {(boot>0).mean()*100:.0f}% t ~ {_sh(v)*np.sqrt(yrs):.2f}")
# ---------------- stabilita' per decade
print("\n" + "-" * 108)
print(" (3) STABILITA' PER DECADE — il test che i 2,6 anni crypto NON possono fare")
print("-" * 108)
decs = [("1998-2004", 1998, 2004), ("2005-2011", 2005, 2011),
("2012-2018", 2012, 2018), ("2019-2026", 2019, 2026)]
print(f" {'periodo':<12}{'Sh paniere':>12}{'maxDD':>9}{'coppie Sh>0':>14}")
for nm, y0, y1 in decs:
w = bask[(bask.index.year >= y0) & (bask.index.year <= y1)]
if len(w) < 200:
print(f" {nm:<12}{'(storia corta)':>12}")
continue
cnt = sum(1 for d in rows
if len(pd.Series(d['ret'], index=d['idx'])[
(d['idx'].year >= y0) & (d['idx'].year <= y1)]) > 200
and _sh(pd.Series(d['ret'], index=d['idx'])[
(d['idx'].year >= y0) & (d['idx'].year <= y1)].values) > 0)
tot = sum(1 for d in rows if ((d['idx'].year >= y0) & (d['idx'].year <= y1)).sum() > 200)
print(f" {nm:<12}{_sh(w.values):>12.2f}{_dd(w.values)*100:>8.1f}%{f'{cnt}/{tot}':>14}")
# ---------------- costo di prestito
print("\n" + "-" * 108)
print(" (4) SENSIBILITA' AL COSTO DI PRESTITO (gamba corta) — su un long/short non e' un dettaglio")
print("-" * 108)
print(f" {'borrow ann':>12}{'Sh paniere':>13}{'coppie Sh>0':>14}")
for bw in BORROW_GRID:
rr = {}
for tgt, base, _ in PAIRS:
d = next((x for x in rows if x["pair"] == f"{tgt}/{base}"), None)
if d is None:
continue
b_all, t_all = load_eq(base)["close"].astype(float), load_eq(tgt)["close"].astype(float)
ix = b_all.index.intersection(t_all.index)
pos, spread, _ = signal_eq(b_all[ix], t_all[ix])
rr[d["pair"]] = pd.Series(pnl_eq(pos, spread, borrow=bw), index=ix)
bb = pd.concat(rr, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
pos_cnt = sum(1 for s in rr.values() if _sh(s.values) > 0)
print(f" {bw*100:>11.2f}%{_sh(bb.values):>13.2f}{f'{pos_cnt}/{len(rr)}':>14}")
print("\n" + "=" * 108)
if __name__ == "__main__":
main()
+317
View File
@@ -0,0 +1,317 @@
"""STATARB-MULTI — il meccanismo di STATARB-RESID generalizza fuori dalla coppia ETH/BTC?
PERCHE' QUESTA E' LA DOMANDA PIU' IMPORTANTE APERTA. STATARB-RESID e' il miglior lead del progetto
(+4,4% in ~4 settimane di forward, haircut fill ~0, ortogonale al book, ESEGUIBILE a $600 non
STAT-MODE) e ha un gate di deploy pre-registrato al 2026-09-27. Ma e' stato scoperto su UNA coppia,
ETH/BTC, dentro uno sweep a 5 thread: Sharpe 0.84, deflated-Sharpe 0.929 (sotto 0.95). Se il
meccanismo e' reale deve funzionare anche su coppie MAI usate per scoprirlo; se ETH/BTC e' un
outlier, il gate del 27/09 va letto con un prior molto peggiore e va saputo PRIMA, non dopo.
Questo e' un test out-of-PAIR-sample: i 50 alt di Hyperliquid non hanno partecipato alla scoperta.
MECCANISMO CONGELATO (nessun rifit, nessuna griglia): W=45, sgn=+1, vol-target 20%, cap 2x,
z-score su finestra W, beta OLS rolling causale, dollar-neutral, fee 0.05%/gamba per |delta pos|.
Identico a scripts/research/orthogonal_signals.f_statarb_resid, riscritto qui in forma generica
(base, target) invece che (BTC, ETH) il test di identita' e' nel passo (0).
IL MODO IN CUI QUESTO SEGNALE PUO' MENTIRE (pre-registrato, non scoperto dopo):
con sgn=+1 la posizione e' proporzionale al LIVELLO del residuo z-scorato, cioe' "long l'asset che
ha sovraperformato il suo fit su BTC". Se un alt sottoperforma BTC in modo persistente (2024-2026
e' stato esattamente questo), il segnale resta quasi sempre SHORT e la strategia degenera in una
SCOMMESSA STATICA "short alt vs BTC" che ha pagato per motivi di beta, non di timing. E' la stessa
degenerazione ("predictor affine nel ritorno proprio") gia' codificata il 2026-07-24. Quindi:
NULL 1 STATICA, in DUE versioni (la prima stesura di questo script ne aveva una sola, col segno
preso da `sign(mean(segnale))` su TUTTO il campione: era look-ahead, cioe' un null che
conosce gia' la direzione giusta. Corretta qui):
(1a) CAUSALE: segno = sign(media espandente del segnale fino a i-1) usa solo il passato;
(1b) A PRIORI: sempre SHORT alt vs BTC, la scommessa beta nota del 2024-2026 (gli alt
hanno sottoperformato BTC). Nessun parametro stimato, nessun look-ahead.
Se il segnale non batte entrambe, non c'e' timing: c'e' solo direzione media.
NULL 2 PERMUTAZIONE: 200 permutazioni a blocchi (20 barre) del segnale contro lo stesso spread,
per un p-value che non assume normalita'.
CHECK 3 DEGENERAZIONE: frazione di barre a segno costante; una coppia >85% mono-segno e' una
scommessa statica travestita, e va marcata.
CHECK 4 RIDONDANZA vs XS01: correlazione del paniere di coppie ai rendimenti dello sleeve XS01
(long-i-forti/short-i-deboli): se corr e' alta, non e' uno sleeve nuovo, e' XS01.
CHECK 5 ETH/BTC e' un outlier? rango e percentile della coppia scopritrice fra tutte.
uv run python scripts/research/r0725_statarb_multi.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
RAW = ROOT / "data" / "raw"
ANN = np.sqrt(365.0)
# --- CONFIG CONGELATA (dallo sweep 2026-06-29; NON toccare) --------------------------------------
W = 45
SGN = +1
TARGET_VOL = 0.20
VOL_WIN = 30
CAP = 2.0
FEE_LEG = 0.0005 # 0.05% per gamba per unita' di |delta posizione| (2 gambe)
BASE = "BTC"
MIN_BARS = 400 # ~13 mesi: sotto questa soglia la coppia non e' valutabile
N_PERM = 200
BLOCK = 20
SEED = 20260725
def load_hl(sym: str) -> pd.Series:
p = RAW / f"hl_{sym.lower()}_1d.parquet"
if not p.exists():
raise FileNotFoundError(p)
d = pd.read_parquet(p)
s = pd.Series(d["close"].values.astype(float),
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
return s[~s.index.duplicated(keep="last")]
def universe() -> list[str]:
out = []
for p in sorted(RAW.glob("hl_*_1d.parquet")):
sym = p.stem.replace("hl_", "").replace("_1d", "").upper()
if sym != BASE:
out.append(sym)
return out
def _zscore(v: np.ndarray, w: int) -> np.ndarray:
s = pd.Series(v)
m = s.rolling(w, min_periods=w).mean()
sd = s.rolling(w, min_periods=w).std()
return ((s - m) / sd.replace(0, np.nan)).values
def signal(base_px: pd.Series, tgt_px: pd.Series) -> tuple[np.ndarray, np.ndarray]:
"""Ritorna (posizione dollar-neutral causale, ritorno spread) — meccanismo congelato."""
x = np.log(base_px.values)
y = np.log(tgt_px.values)
sx, sy = pd.Series(x), pd.Series(y)
mx = sx.rolling(W, min_periods=W).mean()
my = sy.rolling(W, min_periods=W).mean()
cov = (sx * sy).rolling(W, min_periods=W).mean() - mx * my
var = (sx * sx).rolling(W, min_periods=W).mean() - mx * mx
beta = cov / var.replace(0, np.nan)
resid = (sy - (my - beta * mx) - beta * sx).values
z = _zscore(np.nan_to_num(resid), W)
d = SGN * np.tanh(np.nan_to_num(z))
r_b = np.concatenate([[0.0], np.diff(base_px.values) / base_px.values[:-1]])
r_t = np.concatenate([[0.0], np.diff(tgt_px.values) / tgt_px.values[:-1]])
spread = r_t - r_b
vol = pd.Series(spread).rolling(VOL_WIN, min_periods=VOL_WIN).std().values * ANN
scal = np.where((vol > 0) & np.isfinite(vol), TARGET_VOL / vol, 0.0)
pos = np.clip(np.nan_to_num(d) * scal, -CAP, CAP)
pos[~np.isfinite(pos)] = 0.0
return pos, spread
def pnl(pos: np.ndarray, spread: np.ndarray) -> np.ndarray:
"""Ritorno netto: posizione decisa a i-1 tenuta in i, fee su 2 gambe per |delta pos|."""
held = np.concatenate([[0.0], pos[:-1]])
turn = np.abs(np.diff(held, prepend=0.0))
return held * spread - 2.0 * FEE_LEG * turn
def _sh(r: np.ndarray) -> float:
r = r[np.isfinite(r)]
return float(r.mean() / r.std() * ANN) if len(r) > 30 and r.std() > 0 else 0.0
def _dd(r: np.ndarray) -> float:
eq = np.cumprod(1.0 + np.nan_to_num(r))
return float(np.min(eq / np.maximum.accumulate(eq) - 1.0))
def main() -> None:
print("=" * 104)
print(" STATARB-MULTI — il meccanismo congelato (W=45, sgn=+1) fuori dalla coppia scopritrice")
print("=" * 104)
# ---------------- (0) identita' col segnale dello sweep, sulla coppia originale
print("\n" + "-" * 104)
print(" (0) CONTROLLO DI IDENTITA' — ETH/BTC ricalcolato qui vs implementazione dello sweep")
print("-" * 104)
try:
sys.path.insert(0, str(ROOT / "scripts" / "research"))
from orthogonal_signals import build_joint, f_statarb_resid, spread_ret
j = build_joint("1d")
pos_ref = f_statarb_resid(tf="1d", W=W, sgn=SGN, tv=TARGET_VOL, vw=VOL_WIN, cap=CAP)(j)
r_ref = np.concatenate([[0.0], pos_ref[:-1]]) * spread_ret(j)
print(f" sweep (feed Deribit certificato) n={len(j):>5} Sharpe LORDA {_sh(r_ref):>5.2f}")
except Exception as e:
print(f" [riferimento sweep non disponibile: {e.__class__.__name__}: {e}]")
eth_hl, btc_hl = load_hl("ETH"), load_hl(BASE)
idx = eth_hl.index.intersection(btc_hl.index)
p, s = signal(btc_hl[idx], eth_hl[idx])
print(f" qui (feed Hyperliquid) n={len(idx):>5} Sharpe NETTA {_sh(pnl(p, s)):>5.2f}")
print(" (venue e finestra diversi -> i due numeri non devono coincidere; serve a escludere")
print(" un errore di implementazione, non a replicare il backtest)")
# ---------------- (1) tutte le coppie alt/BTC
print("\n" + "-" * 104)
print(f" (1) MECCANISMO CONGELATO SU TUTTE LE COPPIE alt/{BASE} — out-of-pair-sample")
print("-" * 104)
base_px = load_hl(BASE)
rng = np.random.default_rng(SEED)
rows = []
for sym in universe():
try:
tgt = load_hl(sym)
except FileNotFoundError:
continue
ix = base_px.index.intersection(tgt.index)
if len(ix) < MIN_BARS:
continue
b, t = base_px[ix], tgt[ix]
pos, spread = signal(b, t)
r = pnl(pos, spread)
live = pos[np.isfinite(pos)]
live = live[live != 0.0]
if len(live) < MIN_BARS // 2:
continue
mono = float(max((live > 0).mean(), (live < 0).mean())) # degenerazione
vol = pd.Series(spread).rolling(VOL_WIN, min_periods=VOL_WIN).std().values * ANN
scal = np.where((vol > 0) & np.isfinite(vol), TARGET_VOL / vol, 0.0)
scal = np.nan_to_num(scal)
def _static(sign_vec: np.ndarray) -> np.ndarray:
ps = np.clip(sign_vec * scal, -CAP, CAP)
ps[~np.isfinite(ps)] = 0.0
return pnl(ps, spread)
# (1a) CAUSALE: segno = media espandente del segnale fino a i-1 (nessun look-ahead)
cum = np.cumsum(np.nan_to_num(pos))
cnt = np.arange(1, len(pos) + 1)
run_mean = np.concatenate([[0.0], (cum / cnt)[:-1]])
sign_causal = np.where(run_mean >= 0, 1.0, -1.0)
sign_causal[:VOL_WIN + W] = 0.0 # prima che il segnale esista
r_stat = _static(sign_causal)
# (1b) A PRIORI: sempre short alt vs BTC (beta noto del regime 2024-2026)
r_short = _static(np.full(len(pos), -1.0))
# NULL 2: permutazione a blocchi del segnale
nb = int(np.ceil(len(pos) / BLOCK))
perm = []
for _ in range(N_PERM):
order = rng.permutation(nb)
pp = np.concatenate([pos[k * BLOCK:(k + 1) * BLOCK] for k in order])[:len(pos)]
perm.append(_sh(pnl(pp, spread)))
perm = np.array(perm)
rows.append(dict(sym=sym, n=len(ix), sh=_sh(r), dd=_dd(r), mono=mono,
sh_stat=_sh(r_stat), uplift=_sh(r) - _sh(r_stat),
sh_short=_sh(r_short), uplift_s=_sh(r) - _sh(r_short),
pperm=float((perm >= _sh(r)).mean()), ret=r, pos=pos, idx=ix))
if not rows:
print(" nessuna coppia valutabile — controlla data/raw/hl_*_1d.parquet")
return
rows.sort(key=lambda d: -d["sh"])
print(f" {'coppia':<10}{'n':>6}{'Sh netta':>10}{'maxDD':>9}{'mono%':>7}"
f"{'stat.caus':>11}{'uplift':>8}{'sempre-sh':>11}{'uplift':>8}{'p perm':>9}")
for d in rows:
flag = " <-- DEGENERE" if d["mono"] > 0.85 else ""
print(f" {d['sym']+'/'+BASE:<10}{d['n']:>6}{d['sh']:>10.2f}{d['dd']*100:>8.1f}%"
f"{d['mono']*100:>6.0f}%{d['sh_stat']:>11.2f}{d['uplift']:>8.2f}"
f"{d['sh_short']:>11.2f}{d['uplift_s']:>8.2f}{d['pperm']:>9.3f}{flag}")
sh = np.array([d["sh"] for d in rows])
up = np.array([d["uplift"] for d in rows])
ups = np.array([d["uplift_s"] for d in rows])
pp = np.array([d["pperm"] for d in rows])
mono = np.array([d["mono"] for d in rows])
n = len(rows)
print("\n" + "-" * 104)
print(" (2) VERDETTO STATISTICO SULL'INSIEME")
print("-" * 104)
tstat = float(sh.mean() / (sh.std(ddof=1) / np.sqrt(n))) if n > 2 else 0.0
print(f" coppie valutate : {n}")
print(f" Sharpe netta media {sh.mean():>6.2f} mediana {np.median(sh):>6.2f} "
f"p10 {np.percentile(sh,10):>5.2f} p90 {np.percentile(sh,90):>5.2f}")
print(f" frazione con Sharpe > 0 : {(sh>0).mean()*100:>5.0f}% "
f"(t della media cross-coppie = {tstat:>5.2f})")
print(f" UPLIFT vs statica CAUSALE media {up.mean():>6.2f} mediana {np.median(up):>6.2f} "
f"frazione > 0: {(up>0).mean()*100:>3.0f}%")
print(f" UPLIFT vs SEMPRE-SHORT media {ups.mean():>6.2f} mediana {np.median(ups):>6.2f} "
f"frazione > 0: {(ups>0).mean()*100:>3.0f}%")
print(f" p-value permutazione < 0.05 : {(pp<0.05).mean()*100:>5.0f}% delle coppie "
f"(atteso per caso: 5%)")
print(f" coppie DEGENERI (mono > 85%) : {(mono>0.85).sum()}/{n} "
f"mono medio {mono.mean()*100:.0f}%")
print("\n ⚠ il t cross-coppie SOVRASTIMA: le 50 coppie condividono la gamba BTC e gli alt")
print(" co-muovono -> non sono 50 scommesse indipendenti. Il test onesto e' sul PANIERE (3).")
# ---------------- (3) paniere EW
print("\n" + "-" * 104)
print(" (3) PANIERE EW DELLE COPPIE (sleeve ipotetica) + ridondanza vs XS01")
print("-" * 104)
ser = {d["sym"]: pd.Series(d["ret"], index=d["idx"]) for d in rows}
M = pd.concat(ser, axis=1, sort=True).sort_index()
bask = M.mean(axis=1, skipna=True).dropna()
sh_b = _sh(bask.values)
yrs = len(bask) / 365.0
print(f" paniere EW {len(ser)} coppie: n={len(bask)} ({yrs:.1f} anni) Sharpe {sh_b:>5.2f} "
f"maxDD {_dd(bask.values)*100:>5.1f}% ret tot {(np.prod(1+bask.values)-1)*100:>6.1f}%")
# ampiezza EFFETTIVA: quante scommesse indipendenti ci sono davvero
C = M.corr().values
off = C[~np.eye(len(C), dtype=bool)]
rbar = float(np.nanmean(off))
n_eff = len(C) / (1.0 + (len(C) - 1) * rbar) if rbar > -1 / (len(C) - 1) else float(len(C))
print(f" correlazione media fra coppie {rbar:>5.3f} -> ampiezza EFFETTIVA ~{n_eff:.1f} "
f"scommesse indipendenti (non {len(C)})")
# intervallo di confidenza onesto sul paniere: block bootstrap (blocchi 20g)
rng2 = np.random.default_rng(SEED + 7)
v = bask.values
nb2 = int(np.ceil(len(v) / BLOCK))
boot = []
for _ in range(2000):
k = rng2.integers(0, nb2, size=nb2)
x = np.concatenate([v[i * BLOCK:(i + 1) * BLOCK] for i in k])[:len(v)]
boot.append(_sh(x))
boot = np.array(boot)
lo, hi = np.percentile(boot, [2.5, 97.5])
print(f" block-bootstrap Sharpe paniere: IC95% [{lo:>5.2f}, {hi:>5.2f}] "
f"P(Sharpe>0) = {(boot>0).mean()*100:.0f}% P(Sharpe>0.5) = {(boot>0.5).mean()*100:.0f}%")
print(f" errore standard atteso a {yrs:.1f} anni ~ {1/np.sqrt(yrs):.2f} Sharpe -> "
f"t del paniere ~ {sh_b*np.sqrt(yrs):.2f}")
try:
from src.portfolio.sleeves import _xsec_returns
xs = _xsec_returns().dropna()
xs.index = pd.to_datetime(xs.index, utc=True) if xs.index.tz is None else xs.index
jj = pd.concat({"b": bask, "x": xs}, axis=1, sort=True).dropna()
print(f" corr(paniere, XS01) = {jj['b'].corr(jj['x']):>6.3f} su {len(jj)} giorni comuni"
f" -> {'RIDONDANTE con XS01' if abs(jj['b'].corr(jj['x'])) > 0.5 else 'non ridondante'}")
except Exception as e:
print(f" [corr XS01 non calcolabile: {e.__class__.__name__}: {e}]")
# ---------------- (4) ETH/BTC e' un outlier?
print("\n" + "-" * 104)
print(" (4) LA COPPIA SCOPRITRICE E' UN OUTLIER?")
print("-" * 104)
eth = next((d for d in rows if d["sym"] == "ETH"), None)
if eth is None:
print(" ETH/BTC non fra le coppie valutate (storia insufficiente?)")
else:
rank = 1 + sum(1 for d in rows if d["sh"] > eth["sh"])
print(f" ETH/{BASE}: Sharpe {eth['sh']:.2f} -> rango {rank}/{n} "
f"(percentile {100*(1-(rank-1)/n):.0f}°) mono {eth['mono']*100:.0f}% "
f"uplift vs statica {eth['uplift']:+.2f} p perm {eth['pperm']:.3f}")
print("\n" + "=" * 104)
if __name__ == "__main__":
main()
+100
View File
@@ -0,0 +1,100 @@
"""r0725_xsr_deploy_gate — gate di decisione PRE-REGISTRATO per XSR01 (fissato 2026-07-25).
PERCHE' PRE-REGISTRATO. La regola si fissa oggi, forward-day 0, PRIMA di vedere qualunque barra
out-of-sample. Decidere "a occhio" dopo aver visto i numeri e' selection-on-forward — il bias che
questo progetto ha gia' pagato due volte (gate SELECTION-ON-HOLDOUT 2026-06-29; EW-STR refutato
come selezione di 2° ordine 2026-07-01). Stesso trattamento gia' dato a STATARB-RESID.
CONTESTO. XSR01 e' il candidato del 2026-07-25: cross-sectional residual dollar-neutral sui 50 alt
HL. In-sample (2.6 anni) fa Sharpe netta 1.82 / lorda 2.70, maxDD -2.6%, marginale ADDS,
deflated-Sharpe 0.985, corr ~0 a tutti e 5 gli sleeve. Sopravvive a tre null fee-neutrali e al
test di lag. NON e' nel book per tre motivi dichiarati: storia corta e monotona crescente,
`weights_tilt_null` fallito, e margine di costo sottile con slippage NON modellato.
REGOLA (immutabile ogni modifica va motivata nel diario come violazione):
- Data decisione: 2026-10-23 (90 giorni forward dal 2026-07-25). Non prima.
- Metrica primaria: Sharpe annualizzato di `net_modeled` su TUTTA la finestra forward.
- GUARDIA DI COSTO, la piu' importante per questa strategia (turnover 38.5% del lordo/giorno su
50 alt anche illiquidi): il rapporto Sharpe_forward / Sharpe_in_sample_netta(1.82) va letto
insieme allo scarto MODELED-vs-REAL$5000. Se l'haircut di eseguibilita' a $5000 supera il 40%
del rendimento modellato, la strategia e' un artefatto di costo e va RITIRATA a prescindere
dallo Sharpe.
- Esiti:
Sharpe >= 1.0 AND haircut$5000 <= 40% -> CANDIDATO SLEEVE: proporre peso {5,10}% e
giudicarlo con `weights_tilt_null` (che oggi NON passa) + maxDD combinato.
Deploy solo se il tilt-null passa E il capitale e' >= $5000.
0.3 <= Sh < 1.0 -> ESTENDI una sola volta di 90g (2027-01-21).
Sharpe < 0.3 OR haircut > 40% -> RITIRO dal forward-monitor.
- Guardie accessorie: config invariata (il test `tests/test_paper_xsr.py` la blocca);
universo costante a 50 gambe (un cambio impone --reset e azzera la finestra).
Uso: `uv run python scripts/research/r0725_xsr_deploy_gate.py`
"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
RETURNS = ROOT / "data" / "paper_xsr" / "returns.jsonl"
START = date(2026, 7, 25)
DECISION = date(2026, 10, 23)
DECISION_EXT = date(2027, 1, 21)
SH_DEPLOY = 1.0
SH_RETIRE = 0.3
HAIRCUT_MAX = 0.40
IS_SHARPE_NET = 1.82 # riferimento in-sample, per contesto
def main() -> None:
print("=" * 92)
print(" XSR01 — gate di decisione PRE-REGISTRATO (fissato 2026-07-25, forward-day 0)")
print("=" * 92)
if not RETURNS.exists():
print(f" nessun dato forward ancora: {RETURNS} assente.")
print(f" data decisione: {DECISION} (mancano {(DECISION - date.today()).days} giorni)")
return
rows = [json.loads(x) for x in RETURNS.read_text().splitlines() if x.strip()]
if len(rows) < 5:
print(f" finestra forward troppo corta ({len(rows)} barre).")
print(f" data decisione: {DECISION} (mancano {(DECISION - date.today()).days} giorni)")
return
rm = np.array([r["net_modeled"] for r in rows])
key5 = "net_REAL-$5000"
r5 = np.array([r.get(key5, np.nan) for r in rows])
sh = float(rm.mean() / rm.std() * np.sqrt(365)) if rm.std() > 0 else 0.0
eq = np.cumprod(1 + rm)
dd = float(np.max((np.maximum.accumulate(eq) - eq) / np.maximum.accumulate(eq)))
tot_m = float(np.prod(1 + rm) - 1)
tot_5 = float(np.prod(1 + r5[np.isfinite(r5)]) - 1) if np.isfinite(r5).any() else float("nan")
haircut = (tot_m - tot_5) / abs(tot_m) if tot_m not in (0.0,) and np.isfinite(tot_5) else float("nan")
today = date.today()
print(f" finestra forward : {rows[0]['dt'][:10]} -> {rows[-1]['dt'][:10]} ({len(rows)} barre)")
print(f" Sharpe fwd (mod) : {sh:+.2f} (in-sample netta era {IS_SHARPE_NET:.2f})")
print(f" maxDD fwd : {dd:.1%}")
print(f" ritorno MODELED : {tot_m*100:+.2f}% REAL-$5000: {tot_5*100:+.2f}%")
print(f" haircut eseguib. : {haircut*100:.1f}% (guardia <= {HAIRCUT_MAX*100:.0f}%)")
print(f" data decisione : {DECISION} (proroga unica: {DECISION_EXT})")
if today < DECISION:
print(f"\n -> NESSUNA DECISIONE OGGI ({today}): mancano {(DECISION - today).days} giorni.")
print(" Il numero corrente NON autorizza deploy anticipato (regola pre-registrata).")
return
if np.isfinite(haircut) and haircut > HAIRCUT_MAX:
print("\n -> RITIRO: l'haircut di eseguibilita' supera la guardia. E' costo, non edge.")
elif sh >= SH_DEPLOY:
print("\n -> CANDIDATO SLEEVE: proporre peso {5,10}% e passarlo a weights_tilt_null "
"(oggi NON passa) + maxDD combinato. Deploy solo con capitale >= $5000.")
elif sh >= SH_RETIRE:
print(f"\n -> SOTTO SOGLIA ma >= {SH_RETIRE}: estensione unica fino al {DECISION_EXT}.")
else:
print("\n -> RITIRO dal forward-monitor.")
if __name__ == "__main__":
main()
+119
View File
@@ -0,0 +1,119 @@
"""Rilevamento e riparazione di SPLIT NON AGGIUSTATI nelle serie ETF/azioni.
PERCHE' ESISTE (2026-07-25). La serie IB `ADJUSTED_LAST` e' aggiustata per dividendi e split
tranne quando non lo e'. Su `data/raw/eq_*.parquet` due serie hanno uno split NON aggiustato lo
stesso giorno, il 2005-06-09 (data della tornata di split iShares):
IWM 94.13 -> 47.53 (-49.5%, rapporto 1.981 ~ 2:1)
EFA 85.91 -> 28.76 (-66.5%, rapporto 2.987 ~ 3:1)
La certificazione di `fetch_ib_equities.certify()` non li vedeva: la sua unica guardia era
`maxret > 50% -> SPIKE?`, e uno split 2:1 non aggiustato produce ESATTAMENTE -50%, cioe' cade
sul filo della soglia (IWM passava a 49.5%: sotto soglia, status OK). E' un punto cieco
strutturale, non un caso fortunato: la soglia era tarata proprio sul valore che il difetto
piu' comune genera.
IWM e' una delle 6 gambe di **GTAA01, lo sleeve in produzione** -> il difetto entrava nei numeri
del libro. Impatto misurato: GTAA6 FULL Sharpe 0.61 -> 0.64, IS (<2015) 0.49 -> 0.54; OOS 2015+
e maxDD INVARIATI (l'artefatto e' nel 2005, fuori dall'hold-out). Il difetto SOTTOSTIMAVA lo
sleeve, non lo gonfiava nessuna decisione presa va rivista, ma il dato va corretto.
COME SI DISTINGUE UNO SPLIT DA UN CROLLO VERO (la parte che conta). Non basta la dimensione del
salto: il 2026-01-30 SLV ha fatto -28.5% e il rapporto 1.3994 e' a 4 bps da 1.4 — sarebbe un
falso positivo per qualsiasi regola basata sul solo rapporto. Il discriminante e' il BAR STESSO:
* SPLIT: la barra APRE gia' al nuovo livello e ha un range intraday NORMALE — il salto e'
solo fra chiusura precedente e apertura. IWM 2005-06-09: open 47.00, range high/low 1.7%.
* CROLLO VERO: il movimento avviene DENTRO la barra, che ha un range enorme.
SLV 2026-01-30: open 89.33, low 69.12, range 33%; e l'attivo gemello si muove insieme
(GLD -10.3% lo stesso giorno, volume raddoppiato).
Da cui la regola: |ret| grande AND rapporto vicino a un fattore di split comune AND range
intraday piccolo. Le tre condizioni insieme; nessuna da sola basta.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
# fattori di split comuni (diretti e inversi)
COMMON_FACTORS: tuple[float, ...] = (1.5, 2.0, 3.0, 4.0, 5.0, 10.0)
MIN_RET = 0.20 # sotto il 20% non si cerca nemmeno: nessuno split e' cosi' piccolo
RATIO_TOL = 0.015 # il rapporto deve stare entro l'1.5% da un fattore comune
MAX_RANGE = 0.05 # range intraday (high/low-1) massimo perche' sia uno split e non un crollo
def _nearest_factor(ratio: float) -> tuple[float, float] | tuple[None, None]:
"""Fattore di split comune piu' vicino a `ratio` e il suo errore relativo."""
best, err = None, np.inf
for f in COMMON_FACTORS:
for cand in (f, 1.0 / f):
e = abs(ratio / cand - 1.0)
if e < err:
best, err = cand, e
return (best, err) if best is not None else (None, None)
def detect_unadjusted_splits(df: pd.DataFrame, *, min_ret: float = MIN_RET,
ratio_tol: float = RATIO_TOL,
max_range: float = MAX_RANGE) -> list[dict]:
"""Split NON aggiustati in un OHLC daily. Ritorna [{i, date, ratio, factor, ret, range}].
Tre condizioni insieme (vedi docstring del modulo): salto grande, rapporto ~ fattore comune,
range intraday piccolo. Un crollo di mercato fallisce la terza; un movimento normale la prima.
"""
if df.empty or len(df) < 2:
return []
c = df["close"].to_numpy(float)
ret = np.concatenate([[0.0], np.diff(c) / c[:-1]])
has_hl = {"high", "low"}.issubset(df.columns)
hi = df["high"].to_numpy(float) if has_hl else c
lo = df["low"].to_numpy(float) if has_hl else c
out = []
for i in np.flatnonzero(np.abs(ret) > min_ret):
if c[i] <= 0 or c[i - 1] <= 0:
continue
ratio = float(c[i - 1] / c[i])
factor, err = _nearest_factor(ratio)
if factor is None or err > ratio_tol:
continue
rng = float(hi[i] / lo[i] - 1.0) if lo[i] > 0 else np.inf
if rng > max_range: # il movimento e' DENTRO la barra -> crollo vero
continue
out.append(dict(i=int(i), date=df.index[i], ratio=ratio, factor=float(factor),
ret=float(ret[i]), range=rng))
return out
def repair_splits(df: pd.DataFrame, splits: list[dict] | None = None) -> tuple[pd.DataFrame, list[dict]]:
"""Riscala i prezzi PRIMA di ogni split rilevato, riportando la serie su base attuale.
Applicati in ordine cronologico: uno split anteriore viene poi riscalato anche dai
successivi, che e' esattamente il fattore di aggiustamento cumulato corretto.
Il volume va nella direzione opposta (piu' azioni dopo uno split diretto).
"""
splits = detect_unadjusted_splits(df) if splits is None else splits
if not splits:
return df, []
out = df.copy()
price_cols = [c for c in ("open", "high", "low", "close") if c in out.columns]
for sp in sorted(splits, key=lambda s: s["i"]):
pre = out.index < sp["date"]
if not pre.any():
continue
out.loc[pre, price_cols] = out.loc[pre, price_cols] / sp["factor"]
if "volume" in out.columns:
out.loc[pre, "volume"] = out.loc[pre, "volume"] * sp["factor"]
return out, splits
def load_repaired(df: pd.DataFrame, sym: str = "", verbose: bool = False) -> pd.DataFrame:
"""Comodita' per i loader: ripara e, se richiesto, segnala cosa ha riparato."""
fixed, splits = repair_splits(df)
if splits and verbose:
for s in splits:
print(f" [eq_splits] {sym or '?'}: split non aggiustato {s['date'].date()} "
f"1:{s['factor']:g} (ret {s['ret']*100:.1f}%) -> riparato")
return fixed
+9 -3
View File
@@ -14,6 +14,8 @@ from functools import lru_cache
from pathlib import Path from pathlib import Path
import numpy as np, pandas as pd import numpy as np, pandas as pd
from src.data.eq_splits import repair_splits
RAW = Path(__file__).resolve().parents[2] / "data" / "raw" RAW = Path(__file__).resolve().parents[2] / "data" / "raw"
EQ_UNIVERSE = ("SPY", "QQQ", "IWM", "TLT", "GLD", "HYG") EQ_UNIVERSE = ("SPY", "QQQ", "IWM", "TLT", "GLD", "HYG")
HORIZONS = (21, 63, 126, 252) HORIZONS = (21, 63, 126, 252)
@@ -28,8 +30,12 @@ def _close(sym: str) -> pd.Series:
if not p.exists(): if not p.exists():
raise FileNotFoundError(f"{p} assente — gira scripts/research/fetch_ib_equities.py") raise FileNotFoundError(f"{p} assente — gira scripts/research/fetch_ib_equities.py")
d = pd.read_parquet(p) d = pd.read_parquet(p)
return pd.Series(d["close"].astype(float).values, d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index() d = d.sort_index()
# IB ADJUSTED_LAST non aggiusta SEMPRE gli split: IWM (gamba di questo sleeve) e EFA hanno
# uno split non aggiustato il 2005-06-09. Vedi src/data/eq_splits.py. Riparato in lettura.
d, _ = repair_splits(d)
return pd.Series(d["close"].astype(float).values, index=d.index)
def _exposure(close: pd.Series) -> pd.Series: def _exposure(close: pd.Series) -> pd.Series:
@@ -55,7 +61,7 @@ def _gated_returns(sym: str) -> pd.Series:
def gtaa_returns(universe=EQ_UNIVERSE) -> pd.Series: def gtaa_returns(universe=EQ_UNIVERSE) -> pd.Series:
"""Rendimenti netti daily del GTAA: EW dei rendimenti trend-gated sugli asset disponibili.""" """Rendimenti netti daily del GTAA: EW dei rendimenti trend-gated sugli asset disponibili."""
cols = {a: _gated_returns(a) for a in universe} cols = {a: _gated_returns(a) for a in universe}
return pd.concat(cols, axis=1).sort_index().mean(axis=1, skipna=True).dropna() return pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
def gtaa_weights(universe=EQ_UNIVERSE) -> dict: def gtaa_weights(universe=EQ_UNIVERSE) -> dict:
+106
View File
@@ -0,0 +1,106 @@
"""Test del rilevatore di split NON aggiustati (src/data/eq_splits.py).
Il punto dei test non e' "trova il salto grande" — e' distinguere uno SPLIT da un CROLLO VERO.
I due casi reali che hanno motivato il modulo sono entrambi coperti:
* IWM/EFA 2005-06-09 -> split non aggiustati (devono essere rilevati e riparati);
* SLV 2026-01-30 -> crollo vero del -28.5% con rapporto 1.3994 (a 4bps da 1.4!)
e range intraday 33% (deve NON essere toccato).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
from src.data.eq_splits import detect_unadjusted_splits, repair_splits # noqa: E402
def _series(closes, ranges=None, start="2005-01-03"):
"""OHLC daily sintetico: high/low a +/- meta' del range richiesto attorno al close."""
n = len(closes)
idx = pd.bdate_range(start, periods=n, tz="UTC")
c = np.asarray(closes, float)
rng = np.full(n, 0.01) if ranges is None else np.asarray(ranges, float)
return pd.DataFrame({"open": c, "high": c * (1 + rng / 2), "low": c * (1 - rng / 2),
"close": c, "volume": np.full(n, 1e6)}, index=idx)
def test_rileva_split_2a1_stile_iwm():
df = _series([94.5, 94.6, 94.1] + [47.5, 47.6, 47.4])
sp = detect_unadjusted_splits(df)
assert len(sp) == 1
assert sp[0]["factor"] == pytest.approx(2.0)
assert sp[0]["date"] == df.index[3]
def test_rileva_split_3a1_stile_efa():
df = _series([85.9, 86.3, 85.9] + [28.8, 28.6, 28.7])
sp = detect_unadjusted_splits(df)
assert len(sp) == 1 and sp[0]["factor"] == pytest.approx(3.0)
def test_crollo_vero_non_e_uno_split_anche_se_il_rapporto_e_vicino():
"""SLV 2026-01-30: -28.5%, rapporto 1.3994 (vicinissimo a 1.4) ma range intraday 33%.
Se il rilevatore guardasse solo il rapporto, un fattore 1.4 in lista lo distruggerebbe.
La guardia vera e' il range: il movimento avviene DENTRO la barra."""
df = _series([98.3, 101.6, 105.6, 105.6, 75.4, 72.4],
ranges=[0.10, 0.07, 0.06, 0.12, 0.33, 0.09])
assert detect_unadjusted_splits(df) == []
def test_crollo_con_rapporto_esatto_ma_range_grande_non_e_split():
"""Anche un -50% ESATTO non e' uno split se la barra ha range enorme (crollo intraday)."""
df = _series([100.0, 100.0, 100.0, 50.0, 51.0], ranges=[0.01, 0.01, 0.01, 0.40, 0.05])
assert detect_unadjusted_splits(df) == []
def test_riparazione_rende_continua_la_serie():
df = _series([94.5, 94.6, 94.1] + [47.5, 47.6, 47.4])
fixed, sp = repair_splits(df)
assert len(sp) == 1
r = fixed["close"].pct_change().abs()
assert r.max() < 0.05, "dopo la riparazione non deve restare nessun salto anomalo"
# i prezzi POST-split non si toccano; quelli PRE si dimezzano
assert fixed["close"].iloc[-1] == pytest.approx(47.4)
assert fixed["close"].iloc[0] == pytest.approx(94.5 / 2)
# il volume va nella direzione opposta
assert fixed["volume"].iloc[0] == pytest.approx(1e6 * 2)
def test_split_multipli_si_compongono():
"""Due split (3:1 poi 2:1): i prezzi piu' antichi vanno divisi per 6, non per 3 o 2."""
df = _series([90.0, 90.0] + [30.0, 30.0] + [15.0, 15.0])
fixed, sp = repair_splits(df)
assert len(sp) == 2
assert fixed["close"].iloc[0] == pytest.approx(90.0 / 6)
assert fixed["close"].iloc[2] == pytest.approx(30.0 / 2)
assert fixed["close"].iloc[-1] == pytest.approx(15.0)
def test_serie_pulita_resta_identica():
rng = np.random.default_rng(0)
c = 100 * np.cumprod(1 + rng.normal(0, 0.01, 500))
df = _series(c)
assert detect_unadjusted_splits(df) == []
fixed, sp = repair_splits(df)
assert sp == []
pd.testing.assert_frame_equal(fixed, df)
def test_dati_reali_iwm_efa_riparati_dai_loader():
"""Verifica end-to-end sui parquet certificati, se presenti."""
eqlib = pytest.importorskip("eqlib", reason="scripts/research non nel path")
for sym in ("IWM", "EFA"):
try:
df = eqlib.load_eq(sym)
except FileNotFoundError:
pytest.skip(f"eq_{sym.lower()}_1d.parquet assente")
assert df["close"].pct_change().abs().max() < 0.5, f"{sym}: salto da split ancora presente"
+101
View File
@@ -0,0 +1,101 @@
"""Lock del forward-monitor XSR01 (candidato trovato 2026-07-25).
Pin degli invarianti che rendono la finestra forward interpretabile. Se uno di questi salta, i
numeri accumulati non valgono piu' e la finestra va azzerata consapevolmente (--reset), non
lasciata scorrere con una config diversa.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "live"))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
pytest.importorskip("pandas")
import pandas as pd # noqa: E402
paper_xsr = pytest.importorskip("paper_xsr")
def _panel():
try:
return paper_xsr.build_panel()
except (FileNotFoundError, ValueError):
pytest.skip("parquet Hyperliquid assenti")
def test_config_congelata():
"""La config e' il contratto della finestra forward: cambiarla la invalida."""
from r0725_statarb_multi import CAP, SGN, TARGET_VOL, VOL_WIN, W
assert (W, SGN, TARGET_VOL, VOL_WIN, CAP) == (45, +1, 0.20, 30, 2.0)
assert paper_xsr.FEE_LEG == 0.0005
assert paper_xsr.MIN_ORDER == 5.0
assert paper_xsr.MODELED_CAPITAL == 2000.0
def test_dollar_neutral_per_costruzione():
"""Il demeaning DEVE dare somma dei pesi ~0: e' cio' che annulla la gamba BTC comune."""
_, _, W, _, _, _ = _panel()
net = np.abs(W.sum(axis=1))
assert np.nanmax(net) < 1e-12, f"non dollar-neutral: max |somma pesi| = {np.nanmax(net)}"
def test_timestamp_in_millisecondi_non_1970():
"""Trappola pandas gia' pagata dal progetto: astype/view int64 su indice tz-aware.
I ts devono essere ms epoch plausibili, non nanosecondi ne' secondi."""
ts, idx, _, _, _, _ = _panel()
assert ts.dtype == np.int64
assert pd.Timestamp(int(ts[-1]), unit="ms", tz="UTC").year >= 2024
assert pd.Timestamp(int(ts[0]), unit="ms", tz="UTC").year >= 2023
# coerenza con l'indice da cui derivano
assert pd.Timestamp(int(ts[-1]), unit="ms", tz="UTC").date() == idx[-1].date()
def test_universo_50_alt_senza_btc():
_, _, _, _, _, syms = _panel()
assert len(syms) >= 45, f"attesi ~50 alt certificati, trovati {len(syms)}"
assert "BTC" not in syms, "BTC e' il riferimento, non una gamba"
def test_step_causale_la_posizione_nuova_non_guadagna_la_barra_corrente():
"""Il rendimento della barra si calcola sui pesi TENUTI (decisi prima), mai sul target."""
k = 3
bk = paper_xsr._book(1000.0, k)
bk["w"] = [0.10, -0.10, 0.0]
w_target = np.array([-0.50, 0.50, 0.0]) # ribaltamento totale
r_alt = np.array([0.10, -0.10, 0.0]) # premia i pesi VECCHI
net = paper_xsr._step(bk, w_target, r_alt, 0.0, None)
assert net > 0, "il netto deve riflettere i pesi tenuti, non il nuovo target"
assert bk["w"] == pytest.approx(list(w_target)) # e dopo si e' ribilanciato
def test_min_order_blocca_le_gambe_piccole():
"""A capitale piccolo le gambe sub-min-order NON si muovono: e' il punto dei libri REAL."""
k = 2
bk = paper_xsr._book(600.0, k)
w_target = np.array([0.001, -0.001]) # 600*0.001 = $0.60 << $5
paper_xsr._step(bk, w_target, np.zeros(k), 0.0, paper_xsr.MIN_ORDER)
assert bk["w"] == [0.0, 0.0], "gamba sub-min-order eseguita: il libro REAL sarebbe fittizio"
assert bk["n_skip"] == 2
big = paper_xsr._book(600.0, k)
paper_xsr._step(big, np.array([0.20, -0.20]), np.zeros(k), 0.0, paper_xsr.MIN_ORDER)
assert big["w"] == pytest.approx([0.20, -0.20]) # $120 per gamba: si esegue
assert big["n_fill"] == 2
def test_fee_addebitata_su_alt_piu_gamba_btc():
"""La fee copre le gambe alt E la gamba BTC netta: a ribilanciamento nullo, netto = 0."""
k = 2
bk = paper_xsr._book(1000.0, k)
net = paper_xsr._step(bk, np.zeros(k), np.zeros(k), 0.0, None)
assert net == pytest.approx(0.0)
bk2 = paper_xsr._book(1000.0, k)
net2 = paper_xsr._step(bk2, np.array([0.5, -0.5]), np.zeros(k), 0.0, None)
assert net2 == pytest.approx(-paper_xsr.FEE_LEG * 1.0) # |0.5|+|0.5| alt, BTC netto 0