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:
@@ -67,6 +67,7 @@ scripts/research/blind/leaderboard.json
|
||||
data/paper_prevday/
|
||||
data/paper_combo/
|
||||
data/paper_statarb/
|
||||
data/paper_xsr/
|
||||
|
||||
# log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale)
|
||||
data/live/
|
||||
|
||||
@@ -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
|
||||
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`.
|
||||
- **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_i−p̄)(r_i−r_btc) = sum(p_i−p̄)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:
|
||||
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.**
|
||||
|
||||
@@ -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 2e−17 (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`.
|
||||
@@ -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_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_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/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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
stessa disciplina del lato crypto.
|
||||
"""
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
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"
|
||||
|
||||
# 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")
|
||||
d = pd.read_parquet(p).copy()
|
||||
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"]]
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@ CERTIFICAZIONE (gemello equity di certify_feed.py):
|
||||
(1) integrità: barre, range, date monotone, duplicati, flat bars (close invariato);
|
||||
(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);
|
||||
(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).
|
||||
|
||||
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
|
||||
|
||||
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.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())
|
||||
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)
|
||||
# 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"
|
||||
if dup or not mono:
|
||||
status = "INTEGRITA'"
|
||||
elif splits:
|
||||
status = "SPLIT-NON-AGG"
|
||||
elif maxret > 0.5:
|
||||
status = "SPIKE?"
|
||||
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(),
|
||||
"anni": round(span_y, 1), "dup": dup, "mono": mono, "flat": flat,
|
||||
"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():
|
||||
@@ -119,7 +134,8 @@ def main():
|
||||
ok.append(sym)
|
||||
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"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
|
||||
|
||||
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;
|
||||
* 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`
|
||||
(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
|
||||
|
||||
|
||||
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:
|
||||
rows = [json.loads(x) for x in RETURNS.read_text().splitlines() if x.strip()]
|
||||
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" fill-haircut cum : {haircut_pp:.2f} pp (guardia < {HAIRCUT_MAX_PP} pp)")
|
||||
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:
|
||||
print(f"\n -> NESSUNA DECISIONE OGGI ({today}): mancano {(DECISION - today).days} giorni.")
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -14,6 +14,8 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
from src.data.eq_splits import repair_splits
|
||||
|
||||
RAW = Path(__file__).resolve().parents[2] / "data" / "raw"
|
||||
EQ_UNIVERSE = ("SPY", "QQQ", "IWM", "TLT", "GLD", "HYG")
|
||||
HORIZONS = (21, 63, 126, 252)
|
||||
@@ -28,8 +30,12 @@ def _close(sym: str) -> pd.Series:
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"{p} assente — gira scripts/research/fetch_ib_equities.py")
|
||||
d = pd.read_parquet(p)
|
||||
return pd.Series(d["close"].astype(float).values,
|
||||
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
|
||||
d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||
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:
|
||||
@@ -55,7 +61,7 @@ def _gated_returns(sym: str) -> pd.Series:
|
||||
def gtaa_returns(universe=EQ_UNIVERSE) -> pd.Series:
|
||||
"""Rendimenti netti daily del GTAA: EW dei rendimenti trend-gated sugli asset disponibili."""
|
||||
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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user