Compare commits
19 Commits
745ba7d066
...
237ca8da13
| Author | SHA1 | Date | |
|---|---|---|---|
| 237ca8da13 | |||
| 856a02fcc5 | |||
| 010d1f0733 | |||
| 1c15c3c1be | |||
| 3a3bdd5c7b | |||
| f983cc2447 | |||
| 389573e517 | |||
| 3b552a92da | |||
| 09c8bb7de8 | |||
| c4bc336a53 | |||
| 55c337144e | |||
| d916520f2c | |||
| d2d535cf6a | |||
| 67850b0dd8 | |||
| 437cf11199 | |||
| c9b89739c1 | |||
| 03267b8fc3 | |||
| 1c0b5f1869 | |||
| 61180637eb |
@@ -0,0 +1,7 @@
|
|||||||
|
# Credenziali IB Gateway PAPER per la ricerca dati (account paper, es. DUQ513966).
|
||||||
|
# COPIA questo file in .env.ibgw (gitignored) e riempi i valori REALI.
|
||||||
|
# cp .env.ibgw.example .env.ibgw && chmod 600 .env.ibgw && nano .env.ibgw
|
||||||
|
# NON committare mai .env.ibgw. Sono credenziali del CONTO PAPER (nessun denaro reale),
|
||||||
|
# l'API e' comunque READ_ONLY (solo dati storici, nessun ordine).
|
||||||
|
TWS_USERID=il_tuo_username_paper
|
||||||
|
TWS_PASSWORD=la_tua_password_paper
|
||||||
@@ -6,6 +6,8 @@ build/
|
|||||||
.venv/
|
.venv/
|
||||||
.env
|
.env
|
||||||
!.env.example
|
!.env.example
|
||||||
|
.env.ibgw
|
||||||
|
!.env.ibgw.example
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -63,3 +65,4 @@ scripts/research/blind/leaderboard.json
|
|||||||
|
|
||||||
# forward-monitor runtime state (regenerable, forward-only)
|
# forward-monitor runtime state (regenerable, forward-only)
|
||||||
data/paper_prevday/
|
data/paper_prevday/
|
||||||
|
data/paper_combo/
|
||||||
|
|||||||
@@ -13,3 +13,20 @@ services:
|
|||||||
# token mainnet (sola lettura) per lo "Shadow live": conto/posizioni reali sulla dashboard.
|
# token mainnet (sola lettura) per lo "Shadow live": conto/posizioni reali sulla dashboard.
|
||||||
# Montato a runtime (NON nell'immagine: .env.mainnet e' dockerignored). Solo letture, nessun ordine.
|
# Montato a runtime (NON nell'immagine: .env.mainnet e' dockerignored). Solo letture, nessun ordine.
|
||||||
- ./.env.mainnet:/app/.env.mainnet:ro
|
- ./.env.mainnet:/app/.env.mainnet:ro
|
||||||
|
|
||||||
|
# IB Gateway (PAPER) per la RICERCA DATI Interactive Brokers — replica il setup provato di BuzWay
|
||||||
|
# (scout). IBC fa login automatico headless; nessuna GUI desktop. API READ-ONLY (solo dati storici,
|
||||||
|
# MAI ordini). Bind SOLO su 127.0.0.1 -> non esposto in rete. Credenziali in .env.ibgw (gitignored).
|
||||||
|
# host 4002 -> container 4004 (socat paper), esattamente come nel connect("127.0.0.1", 4002).
|
||||||
|
ib-gateway:
|
||||||
|
image: ghcr.io/gnzsnz/ib-gateway:stable
|
||||||
|
container_name: pythagoras-ibgw
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env.ibgw
|
||||||
|
environment:
|
||||||
|
TRADING_MODE: paper
|
||||||
|
READ_ONLY_API: "yes" # SOLO dati: nessun ordine possibile via API
|
||||||
|
TWOFA_TIMEOUT_ACTION: restart
|
||||||
|
TIME_ZONE: Europe/Rome
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:4002:4004" # gateway paper (socat) raggiungibile solo da localhost dell'host
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 2026-06-22 — Sweep 65-agenti: crypto -> mercati IB (mercati × timing × anni)
|
||||||
|
|
||||||
|
## Obiettivo (goal utente)
|
||||||
|
Usare >=50 agenti per prendere l'anticipazione crypto->equity e trovare la MIGLIORE soluzione,
|
||||||
|
provando diversi mercati e timing, su piu' anni.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
- **Dati**: universo IB esteso a **26 ETF certificati** (azioni US/settori/intl/bond/credito/oro/
|
||||||
|
commodity/REIT), cache su disco (`fetch_ib_equities.py` + BROAD2). Crypto BTC/ETH 1h (Deribit).
|
||||||
|
- **Harness onesto** (`crypto_lead_harness.py`): per ogni sessione equity, lead = crypto nella
|
||||||
|
finestra equity-CHIUSO [P 21:00 -> D 13:00 UTC] (overnight; il weekend e' il caso lungo). Predice
|
||||||
|
gap/intraday/full. Metriche: corr, **t incrementale vs sessione equity precedente**, Sharpe
|
||||||
|
eseguibile (sign(lead)*predict, net costi) FULL/IS/OOS, **hit per-anno**.
|
||||||
|
- **Workflow** (`wf_crypto_lead.js`): grid 416 config (2 lead × 26 mercati × 2 giorni × 2 predict ×
|
||||||
|
2 finestre). **52 agenti sweep** -> **12 agenti verifica avversariale** (stress 10bps + OOS 2024+ +
|
||||||
|
multi-anno) -> **1 sintesi**. Totale **65 agenti**, 1.7M token.
|
||||||
|
|
||||||
|
## Risultato
|
||||||
|
|
||||||
|
### Fenomeno fortissimo: crypto overnight -> GAP di apertura equity
|
||||||
|
Cluster coerente in cima, TUTTI predict=gap/overnight, su ogni target risk-on:
|
||||||
|
| lead->target | t-incr | Sh OOS@4bps | @10bps | OOS-recente | anni+ |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| ETH->IWM gap | 17.1 | 2.49 | 1.96 | 2.41 | 7/8 |
|
||||||
|
| ETH->QQQ gap | 17.9 | 2.36 | 1.83 | 2.31 | 7/8 |
|
||||||
|
| ETH->XLK gap | 17.4 | 2.40 | 1.93 | 2.30 | 7/8 |
|
||||||
|
| **BTC->QQQ gap** | 15.0 | 2.31 | 1.78 | 2.16 | **9/9** |
|
||||||
|
| BTC->SPY gap | 14.4 | 2.14 | 1.69(lf) | 2.03 | 9/9 |
|
||||||
|
|
||||||
|
Statisticamente schiacciante (t 14-18, sopra Bonferroni su 416 test), regge stress costi e OOS
|
||||||
|
recente, **positivo 8-9 anni su 8-9**.
|
||||||
|
|
||||||
|
### Ma DUE killer (i verificatori avversariali concordi)
|
||||||
|
1. **NON tradabile via ETF**: il gap e' gia' prezzato all'open dell'ETF -> serve un FUTURE indice
|
||||||
|
tenuto overnight (MNQ/MES/M2K). A $0.5-2k il margin overnight di anche un micro consuma il
|
||||||
|
capitale e rischia la liquidazione su un gap avverso -> **fuori portata per costruzione**.
|
||||||
|
2. **E' RISK-BETA, non alpha**: la finestra-lead crypto e' quasi CONTEMPORANEA al gap (stesso shock
|
||||||
|
macro overnight, equity chiuso). t enorme = co-movimento risk-on/off, non ETH/BTC che *anticipa*.
|
||||||
|
Firma: la forza e' negli anni alta-vol (2022 hit 0.71-0.75), piatta negli anni calmi (2019/21/23).
|
||||||
|
corr ~0.37 -> beta implicito ~37%, alpha residuo piccolo.
|
||||||
|
|
||||||
|
### L'unico tradabile via ETF e' troppo debole
|
||||||
|
ETH->XLE intraday 6h (compri XLE al day-open, chiudi +6h): Sh OOS 0.48@4bps **-> 0.15@10bps** (annuo
|
||||||
|
4.1%->1.0%), t-incr 2.38 **sotto Bonferroni** (~3.5 su 416 test). Edge netto onesto ~ZERO.
|
||||||
|
|
||||||
|
## Verdetto (sintesi multi-agente)
|
||||||
|
**Nessun edge proprietario deployabile a basso capitale.** Il fenomeno crypto->equity-overnight e'
|
||||||
|
statisticamente reale e robustissimo su 9 anni, ma e' (a) risk-beta condiviso, non anticipazione
|
||||||
|
sfruttabile, e (b) catturabile solo con futures overnight, fuori dal nostro capitale. L'unica
|
||||||
|
versione ETF-eseguibile e' dentro il rumore da multiple-testing. Coerente col soffitto del progetto:
|
||||||
|
"niente di nuovo regge" alla verifica onesta.
|
||||||
|
|
||||||
|
**Migliore soluzione (come FENOMENO da forward-monitor, non deploy):** BTC->QQQ gap overnight — la
|
||||||
|
storia piu' lunga (9/9 anni), lead noto prima dell'open. Da monitorare; deployabile solo con capitale
|
||||||
|
~>$20-30k su micro-futures indice e con i costi notturni modellati.
|
||||||
|
|
||||||
|
## Lezione
|
||||||
|
Anche con 65 agenti e una ricerca esaustiva su mercati/timing/anni, la disciplina onesta
|
||||||
|
(tradabilita' al capitale reale + multiple-testing + beta-vs-alpha) riduce un "Sharpe 2.5 su 9 anni"
|
||||||
|
a un non-edge per noi. Il valore della ricerca: aver QUANTIFICATO e CLASSIFICATO il fenomeno
|
||||||
|
(risk-beta overnight) invece di scambiarlo per alpha.
|
||||||
|
Artefatti: `crypto_lead_harness.py`, `wf_crypto_lead.js`.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 2026-06-22 — Crypto × mercati IB: correlazioni e ANTICIPAZIONI (lead-lag)
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
Cercare correlazioni e soprattutto ANTICIPAZIONI tra crypto e mercati IB: un mercato fa capire
|
||||||
|
l'andamento dell'altro? Dati: cache su disco (BTC/ETH Deribit 1h->1d UTC; ETF eq_* con OPEN). Nessun
|
||||||
|
IB online. Disciplina: attenzione ai tranelli di timing daily (crypto chiude 00:00 UTC, US equity
|
||||||
|
21:00 -> lag-0 contaminato), test del segno + OOS + multiple-testing.
|
||||||
|
Script: `crypto_macro_leadlag.py`, `crypto_weekend_signal.py`.
|
||||||
|
|
||||||
|
## (1) Correlazione contemporanea
|
||||||
|
Crypto = asset RISK-ON: corr BTC/ETH ~ **+0.32/0.37** con SPY/QQQ/IWM, **+0.25/0.28** con HYG
|
||||||
|
(credito), **+0.13** GLD, **~-0.02** TLT (bond). Atteso.
|
||||||
|
|
||||||
|
## (2) Lead-lag giornaliero: NIENTE
|
||||||
|
corr(BTC_{t-k}, ETF_t) ha picco a **k=0** (~0.32) e crolla a rumore (±0.05) per |k|>=1. Al daily
|
||||||
|
**nessuno anticipa l'altro** (ne' crypto->equity ne' viceversa). Honest negative.
|
||||||
|
|
||||||
|
## (3) EFFETTO WEEKEND: anticipazione PULITA, significativa, OOS-robusta
|
||||||
|
La crypto si muove Sab+Dom (azionario chiuso) -> quel movimento e' info PRIOR al lunedi'.
|
||||||
|
- **Anticipa il GAP del lunedi'**: corr +0.22/+0.24 (SPY/QQQ/IWM/HYG), hit 59-62%, e **si RAFFORZA
|
||||||
|
OOS (2022+): +0.30/+0.36**. Coerente su 4 ETF (non cherry-pick).
|
||||||
|
- Intraday del lunedi' (open->close) piu' debole ma presente (corr 0.10-0.15, OOS 0.18-0.22).
|
||||||
|
|
||||||
|
### Validazione avversariale
|
||||||
|
- **(A) INCREMENTALE vs venerdi'**: regressione `Mon ~ weekend_crypto + friday_eq`. Coeff weekend
|
||||||
|
crypto significativo ovunque (QQQ gap **t=+4.7**, intr t=+2.9; SPY +4.4/+2.0; IWM +4.7/+2.7);
|
||||||
|
friday_eq NON significativo. -> e' info CRYPTO-SPECIFICA del weekend, non momentum equity.
|
||||||
|
- **(B) TRADABILE** (osservo weekend crypto Dom 24:00, entro Monday OPEN, esco CLOSE, net 4bps):
|
||||||
|
| ETF | hit | Sharpe FULL / IS / OOS22+ | long-flat OOS | ann |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| QQQ | 60% | 1.46 / 1.61 / 1.33 | **1.91** | ~+9-11% |
|
||||||
|
| SPY | 60% | 0.96 / 0.91 / 1.01 | 1.70 | ~+5% |
|
||||||
|
| IWM | 56% | 0.89 / 0.73 / 1.04 | 1.07 | ~+6% |
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
**Trovata UNA anticipazione reale**: il weekend crypto anticipa il lunedi' azionario (massimo su QQQ,
|
||||||
|
risk-on/tech). Significativa (t>4 sul gap), incrementale al venerdi', tradabile net costi, **regge e
|
||||||
|
si rafforza OOS**, coerente su piu' ETF. Meccanismo economico sensato: crypto = proxy 24/7 del
|
||||||
|
risk-sentiment; nel weekend l'equity e' chiuso e lunedi' "recupera" la direzione crypto.
|
||||||
|
|
||||||
|
### Caveat onesti
|
||||||
|
- **Capacita' bassa**: ~52 lunedi'/anno, intraday -> ~+9%/yr sul capitale impiegato il lunedi', non
|
||||||
|
una macchina da compounding. E' un segnale TATTICO, non un cornerstone.
|
||||||
|
- Il GAP (t=4.7) e' piu' forte dell'intraday (t=2.9) ma per catturarlo serve entrare PRIMA del Monday
|
||||||
|
open -> via **futures indice IB (MNQ/MES, aperti la domenica sera)**: enhancement eseguibile da
|
||||||
|
validare (cattura gap+sessione).
|
||||||
|
- Multiple-testing 3 ETF x 2 target: ma TUTTI significativi e coerenti -> effetto ampio, non fortuna.
|
||||||
|
- Niente IB online qui (cache); per il deploy servirebbe il feed crypto live la domenica sera.
|
||||||
|
|
||||||
|
## Prossimo (se si procede)
|
||||||
|
Validare la variante FUTURES (MNQ domenica sera -> cattura il gap del lunedi') e il sizing a basso
|
||||||
|
capitale; eventualmente paper-trade. E' la prima ANTICIPAZIONE cross-mercato trovata: crypto come
|
||||||
|
lead di sentiment sul lunedi' equity.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 2026-06-22 — Combo DEPLOYABLE: TP01 (Deribit) + GTAA (IB)
|
||||||
|
|
||||||
|
## Perche'
|
||||||
|
Il combo crypto-pieno (TP01+XS01+VRP01)+GTAA diversificava (Sharpe 1.81), ma XS01/VRP01 sono
|
||||||
|
STAT-MODE (non eseguibili a $600). Validazione del combo ONESTO/eseguibile: solo le gambe deployable
|
||||||
|
a basso capitale — TP01 (gia' armato live su Deribit) + GTAA vt12 (eseguibile su IB, frazioni,
|
||||||
|
switch mensile). `eq_tp01_gtaa_combo.py`. TP01 compoundato sul calendario giorni-di-borsa.
|
||||||
|
|
||||||
|
## Risultati (finestra comune 2019-03 .. 2026-06, ~7y)
|
||||||
|
| | Sharpe | CAGR | volAnn | maxDD |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| TP01 (crypto, Deribit) | 1.25 | 16.4% | 12.9% | 14% |
|
||||||
|
| GTAA vt12 (equity, IB) | 1.12 | 6.0% | 5.3% | 8% |
|
||||||
|
| **blend 50/50** | **1.48** | 11.3% | 7.5% | **8%** |
|
||||||
|
| blend 40/60 (best cap-mix) | 1.52 | 10.2% | 6.6% | 8% |
|
||||||
|
| risk-parity (29c/71e) | 1.52 | 9.1% | 5.9% | 8% |
|
||||||
|
|
||||||
|
**corr TP01<->GTAA = +0.21**. Il blend (1.48-1.52) batte entrambe le gambe (best solo 1.25),
|
||||||
|
maxDD 14%->8%. **DIVERSIFICA anche da deployable.**
|
||||||
|
|
||||||
|
## Caveat onesti
|
||||||
|
- Per-anno 50/50: 2019 2.11, 2020 2.51, 2021 1.66, **2022 -2.64**, 2023 1.40, 2024 1.73, 2025 0.98,
|
||||||
|
2026 0.94. Anni boom iniziali gonfiano lo Sharpe assoluto; il **2022 e' negativo** (trend whipsaw
|
||||||
|
su entrambe le gambe nel bear). Recenti ~0.95. -> il numero robusto e' il GUADAGNO da
|
||||||
|
diversificazione (+0.27 Sharpe del blend vs solo), non il livello assoluto.
|
||||||
|
- **Costo deployability**: crypto-pieno+GTAA = 1.81 vs deployable = 1.48. I ~0.33 di Sharpe persi sono
|
||||||
|
cio' che XS01/VRP01 darebbero se eseguibili (servirebbe ~20k).
|
||||||
|
- **Cross-venue** Deribit+IB: due conti, capitale split. Entrambe switch mensile/basso turnover,
|
||||||
|
frazionabili a $0.5-2k.
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
Combo deployable VALIDO: due trend difensivi scorrelati (corr 0.21) su mercati diversi -> Sharpe
|
||||||
|
~1.5 / maxDD ~8%, meglio di ciascuna gamba. E' il candidato concreto per un paper-trade cross-venue.
|
||||||
|
NON risolve EUR50/g (resta capitale), ma e' la migliore configurazione rischio-aggiustata
|
||||||
|
EFFETTIVAMENTE eseguibile trovata finora. Lezione cross-mercato confermata: il salto di qualita' non
|
||||||
|
e' un nuovo alpha ma un SECONDO mercato scorrelato.
|
||||||
|
|
||||||
|
## Prossimo (se si procede)
|
||||||
|
Paper-trade della gamba GTAA su IB (forward-only, come paper_trend per TP01), per validare
|
||||||
|
l'esecuzione cross-venue a rischio zero prima di qualunque capitale reale.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 2026-06-22 — EQ-GTAA01 (trend multi-asset) + COMBO cross-mercato equity×crypto
|
||||||
|
|
||||||
|
## (1) EQ-GTAA01 — trend difensivo multi-asset (GTAA)
|
||||||
|
|
||||||
|
EQ-TREND01 (trend su SPY) taglia il DD. Diversificare le SORGENTI di trend (azioni US/tech/small +
|
||||||
|
bond + oro + high-yield) migliora il rischio-aggiustato. `eq_gtaa_trend.py`: ogni asset gestito col
|
||||||
|
proprio trend long-flat (TSMOM multi-orizzonte), equal-weight tra gli asset disponibili (outer-join,
|
||||||
|
cash dove off/assente). Universo SPY/QQQ/IWM/TLT/GLD/HYG. Causale, netto fee, OOS 2015+.
|
||||||
|
|
||||||
|
| strategia | CAGR | Sharpe (pre15/OOS) | maxDD | corr SPY |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| SPY buy&hold | 9.7% | 0.58 (0.45/0.82) | 55% | — |
|
||||||
|
| EW statico (no trend) | 9.4% | 0.59 | 62% | 0.89 |
|
||||||
|
| SPY-trend mono | 5.5% | 0.56 (/0.78) | 30% | 0.72 |
|
||||||
|
| **GTAA lf vt12%** | 3.8% | **0.64** (0.53/**0.89**) | **15%** | **0.64** |
|
||||||
|
| GTAA vt12 (6-asset, 2016+) | 5.6% | **1.08** | **8%** | 0.60 |
|
||||||
|
|
||||||
|
DD nei bear (GTAA vs SPY): dot-com 32%/49% · GFC **14%/55%** · COVID **10%/34%** · 2022 11%/24%.
|
||||||
|
Marginale vs SPY: corr 0.64; 50/50 uplift +0.041 FULL / **+0.086 OOS** (meglio del mono-SPY). Plateau
|
||||||
|
stabile (Sh 0.55-0.61, DD 25-35%). **Migliore sleeve equity**: Sharpe più alto, maxDD bassissimo
|
||||||
|
(8-15%), corr SPY più bassa (0.64) = diversificatore migliore. Tradeoff: CAGR molto più basso
|
||||||
|
(fortemente difensiva). Caveat: la finestra 6-asset (Sh 1.08) è tutta OOS ma un solo regime (toro).
|
||||||
|
|
||||||
|
## (2) COMBO cross-mercato — equity-trend × crypto
|
||||||
|
|
||||||
|
La via che alza il Sharpe COMPLESSIVO senza nuovo alpha: combinare due book scorrelati.
|
||||||
|
`eq_crypto_combo.py`: crypto = portafoglio attivo TP01+XS01+VRP01 (`StrategyPortfolio.combined_daily`,
|
||||||
|
rinormalizzato); equity = GTAA lf vt12%. Crypto compoundato sul calendario giorni-di-borsa (cattura
|
||||||
|
i weekend). Finestra comune = era crypto (2019-03 .. 2026-06, 1827 giorni di borsa).
|
||||||
|
|
||||||
|
| | Sharpe | CAGR | volAnn | maxDD |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| crypto TP01+XS01+VRP01 | 1.60 | 18.7% | 11.1% | 14% |
|
||||||
|
| equity GTAA vt12 | 1.12 | 6.0% | 5.3% | 8% |
|
||||||
|
| **blend 50/50** | **1.81** | 12.4% | 6.6% | **7%** |
|
||||||
|
| risk-parity (32c/68e) | 1.78 | 10.1% | 5.5% | 8% |
|
||||||
|
|
||||||
|
**Correlazione crypto↔equity = +0.167** (bassissima). Il blend 50/50 fa **Sharpe 1.81 > di ciascuno**
|
||||||
|
(crypto 1.60, equity 1.12), **maxDD dimezzato 14%→7%**. VERDETTO: DIVERSIFICA (blend > miglior solo
|
||||||
|
di +0.21 Sharpe). È il guadagno STRUTTURALE: due fonti di rischio scorrelate alzano il Sharpe
|
||||||
|
complessivo senza cercare un nuovo edge.
|
||||||
|
|
||||||
|
### Caveat onesti
|
||||||
|
- **Finestra crypto corta (~7y) e favorevole**: il crypto Sharpe 1.60 e' alto (regime toro + XS01/VRP01
|
||||||
|
STAT-MODE a storia corta). Gli SHARPE ASSOLUTI sono ottimistici. Ma il PUNTO della diversificazione
|
||||||
|
(corr 0.17, blend > solo, DD dimezzato) è robusto al livello assoluto.
|
||||||
|
- **Cross-venue**: crypto su Deribit, equity su IB → due conti, due percorsi d'esecuzione. A $0.5-2k
|
||||||
|
totali, ogni sleeve è minuscola. La parte equity (GTAA) e la TP01 sono entrambe eseguibili a basso
|
||||||
|
capitale; XS01/VRP01 restano STAT-MODE (il blend "reale" deployable è ~TP01 + GTAA).
|
||||||
|
|
||||||
|
## Lettura strategica
|
||||||
|
Il fronte equity da' due cose: (a) una sleeve difensiva robusta (GTAA, maxDD ~10%), (b) — piu'
|
||||||
|
importante — un DIVERSIFICATORE quasi-scorrelato al crypto che alza il Sharpe del portafoglio
|
||||||
|
complessivo da ~1.6 a ~1.8. Non risolve €50/g (resta capitale), ma e' il primo miglioramento
|
||||||
|
STRUTTURALE del rischio-aggiustato complessivo trovato in tutta la ricerca post-reset, ed e' del tipo
|
||||||
|
giusto (diversificazione vera, non alpha fittizio). Prossimo: validare il combo deployable TP01+GTAA
|
||||||
|
(solo le due gambe eseguibili), e valutare l'operativita' cross-venue.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 2026-06-22 — Fronte EQUITY aperto + EQ-MOM01 (momentum settoriale): NON batte SPY
|
||||||
|
|
||||||
|
## Apertura fronte (branch research/equities-ib)
|
||||||
|
|
||||||
|
Le 4 ondate crypto hanno esaurito gli angoli su BTC/ETH (soffitto ~1.3). L'unico modo di superarlo è
|
||||||
|
un **mercato diverso**. Aperto il fronte azioni/ETF via IB (paper, `gnzsnz/ib-gateway`, read-only).
|
||||||
|
|
||||||
|
**Dati certificati + cache su disco** (`fetch_ib_equities.py` → `data/raw/eq_*.parquet`, ADJUSTED_LAST
|
||||||
|
div+split, gitignored = cache locale; loader `eqlib.py` con lru_cache → ricerca legge da disco, MAI
|
||||||
|
da IB). Universo: 9 SPDR settoriali classici dal **1998 (27.5y)** + XLRE(2015)/XLC(2018) + SPY(1996,
|
||||||
|
30y)/QQQ/IWM/GLD/HYG/TLT. Tutti integri (monotoni, no dup, no spike>50%, 0 gap lunghi).
|
||||||
|
NB bug timestamp risolto: `pd.Timestamp` a risoluzione µs → salvati in secondi, corretti a ms.
|
||||||
|
|
||||||
|
## EQ-MOM01 — momentum cross-sectional settoriale
|
||||||
|
|
||||||
|
Costruzione causale (`eq_sector_momentum.py`): ogni 21g, momentum = blend lookback [63,126,252]g con
|
||||||
|
skip-21 (12-1 classico), z-score cross-sectional. long-only top-k (full-invested, confronto
|
||||||
|
like-for-like con SPY) e long-short (dollar-neutral, test alpha puro). Netto fee, hold-out OOS 2015+.
|
||||||
|
|
||||||
|
### Risultati (9 settori, 1998-2026)
|
||||||
|
| strategia | CAGR | Sharpe (pre15/OOS15+) | maxDD | corr SPY |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **SPY buy&hold** | 8.2% | **0.51** (0.31/0.82) | 55% | — |
|
||||||
|
| EW 9 settori | 8.9% | 0.56 (0.44/0.76) | 53% | 0.96 |
|
||||||
|
| MOM long top-3 | 7.7% | 0.50 (0.32/0.76) | 47% | 0.85 |
|
||||||
|
| MOM long vol-target 15% | 7.3% | 0.52 | 39% | 0.75 |
|
||||||
|
| **MOM long-short top-3** | −0.9% | **−0.08** (−0.19/0.08) | 32% | −0.20 |
|
||||||
|
|
||||||
|
### Verdetto: NESSUN edge vs SPY
|
||||||
|
- **Long-short Sharpe −0.08** → l'alpha cross-sectional di momentum settoriale è **morto** su 27 anni
|
||||||
|
(decadimento post-2000 noto in letteratura). Niente alpha market-neutral.
|
||||||
|
- **Long-only ≈ SPY**: corr 0.85, **uplift marginale ~0.00** (blend 75/25 +0.012 FULL / +0.001 OOS;
|
||||||
|
50/50 +0.015 / −0.010). È un SPY a beta più basso, non un edge. Plateau stabile ma sempre ~0.50
|
||||||
|
(vs SPY 0.51); sugli 11 settori (2018+) fa peggio (0.69 vs 0.82). Fee-robusto (ma niente da salvare).
|
||||||
|
- L'unico beneficio (maxDD 55%→39%) è del **vol-target**, non del momentum (lo daresti a SPY stesso).
|
||||||
|
|
||||||
|
## Lezione (coerente col progetto)
|
||||||
|
Il momentum **relative-value** è morto anche in equity, come nel crypto (ortho wave). Il baseline
|
||||||
|
equity da battere è SPY buy&hold (Sharpe ~0.51 full / 0.82 OOS), ostico come il toro crypto.
|
||||||
|
|
||||||
|
## Prossimo angolo plausibile (NON ancora testato)
|
||||||
|
L'analogo equity di TP01 (l'unica cosa che ha retto nel crypto = trend DIFENSIVO): **time-series
|
||||||
|
trend su SPY long-flat/long-bonds** — non per battere il CAGR ma per **tagliare il 55% di drawdown**
|
||||||
|
restando vicino al ritorno. È il punto dove vive il valore robusto in equity (e dove il cross-section
|
||||||
|
NON guarda). Da provare con lo stesso gauntlet: marginale vs SPY, OOS lungo, plateau.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# 2026-06-22 — EQ-TREND01: trend DIFENSIVO su SPY = edge difensivo REALE (analogo di TP01)
|
||||||
|
|
||||||
|
## Contesto
|
||||||
|
Il momentum cross-sectional settoriale è morto (EQ-MOM01: long-short Sharpe −0.08, long-only ≈ SPY).
|
||||||
|
Ma nel crypto l'unica cosa che ha retto NON era relative-value: era **TP01**, un trend DIFENSIVO che
|
||||||
|
taglia il drawdown. L'equity ha lo stesso buco: SPY buy&hold Sharpe ~0.54 ma maxDD **55%**.
|
||||||
|
|
||||||
|
## Costruzione (causale, stile TP01)
|
||||||
|
`eq_spy_trend.py`. TSMOM multi-orizzonte [21,63,126,252]g, target = frazione di orizzonti in
|
||||||
|
trend-up (allocazione graduale 0..1), opz. vol-target. Posizione decisa a ≤i-1, tenuta da i. Netto
|
||||||
|
fee. Varianti: long-flat (cash in risk-off), long-bonds (TLT, solo 2016+), SMA-200 (Faber). Dati da
|
||||||
|
cache eqlib (ADJUSTED, nessun IB). Periodo 1997-2026, OOS 2015+.
|
||||||
|
|
||||||
|
## Risultati
|
||||||
|
| strategia | CAGR | Sharpe (pre15/OOS) | maxDD | in-mkt |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| SPY buy&hold | 9.0% | 0.54 (0.38/0.82) | 55% | 99% |
|
||||||
|
| **SMA-200 (Faber)** | 7.0% | **0.65** (0.52/0.88) | **29%** | 76% |
|
||||||
|
| TSMOM lf cap1.0 | 5.7% | 0.57 (0.44/0.78) | 30% | 92% |
|
||||||
|
| TSMOM lf vt15% | 5.7% | 0.62 (0.51/0.78) | **25%** | 92% |
|
||||||
|
|
||||||
|
**Drawdown nei bear (TSMOM vs SPY):** dot-com 26%/49% · GFC **19%/55%** · COVID 17%/34% · 2022 16%/24%.
|
||||||
|
|
||||||
|
**Plateau** (long-flat): ogni config Sharpe 0.56-0.65 (> SPY 0.54), maxDD 25-31% (~metà di SPY).
|
||||||
|
SMA-200 il più semplice E il migliore (Sh 0.65, OOS 0.88, DD 29%). **Fee-robusto** (Sh 0.48 a
|
||||||
|
0.10%/lato), basso turnover.
|
||||||
|
|
||||||
|
**Marginale vs SPY:** corr 0.73. blend 50/50 uplift +0.035 FULL / +0.031 OOS (modesto positivo);
|
||||||
|
100% trend uplift −0.012 / −0.041 (nel toro recente la difesa costa).
|
||||||
|
|
||||||
|
## Verdetto: edge DIFENSIVO reale (non alpha) — analogo di TP01
|
||||||
|
- ✅ Sharpe 0.54→0.62/0.65, **maxDD dimezzato** (55%→~27%, nei bear lenti più che dimezzato),
|
||||||
|
plateau robusto, fee-robusto, **eseguibile a $0.5-2k** (switch mensile SPY/cash).
|
||||||
|
- ⚠️ NON genera ritorno (CAGR −2/3pp): è risk-management, come TP01.
|
||||||
|
- ⚠️ I tagli grossi (dot-com/GFC) sono IN-SAMPLE; l'OOS 2015-26 è quasi tutto toro → lì ha seguito
|
||||||
|
SPY a beta minore (ma COVID, OOS, dimezzato). La difesa "serve" nei bear, rari nell'OOS.
|
||||||
|
- ⚠️ long-bonds (TLT) non convince (TLT distrutto 2022).
|
||||||
|
|
||||||
|
## Lettura strategica
|
||||||
|
Primo positivo del fronte equity, e dello stesso TIPO che ha retto nel crypto: trend difensivo, non
|
||||||
|
relative-value. Conferma la lezione cross-mercato: **il valore robusto è nel ridurre il rischio
|
||||||
|
(trend long-flat), non nel battere il buy&hold**. Da solo non risolve €50/g (problema di capitale).
|
||||||
|
|
||||||
|
## Prossimo angolo plausibile
|
||||||
|
**Trend multi-asset / GTAA** sull'universo ETF in cache (SPY/QQQ/IWM + TLT/GLD/HYG): un portafoglio
|
||||||
|
di trend long-flat su classi d'attivo diverse di solito batte il trend mono-SPY sul rischio-aggiustato
|
||||||
|
(diversificazione dei trend). + domanda cross-mercato: la sleeve equity-trend DIVERSIFICA il
|
||||||
|
portafoglio crypto (TP01+XS01+VRP01)? (esecuzione split Deribit+IB).
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 2026-06-22 — Funding-CARRY cross-sectional su Hyperliquid (FC01): LEAD fragile, NON regge
|
||||||
|
|
||||||
|
## Contesto
|
||||||
|
|
||||||
|
Onda "nuova ricerca mirata" (l'utente ha chiesto di cercare un angolo non coperto dalle due grandi
|
||||||
|
ondate — sweep 104-ipotesi e ortho relative-value, entrambe esaurite sul *prezzo* BTC/ETH). L'unico
|
||||||
|
meccanismo con una **fonte di ritorno diversa** non ancora testato su dati certi è il **carry da
|
||||||
|
funding**: incassare il cashflow dei perp stando delta-neutral.
|
||||||
|
|
||||||
|
### Scan di fattibilità dati (prima di tutto, lezione v2.0.0)
|
||||||
|
- **Funding price-clock** (drift attorno agli stamp 00/08/16) sul feed Deribit certificato →
|
||||||
|
già testato nell'onda intraday (`agent_03_funding_clock_15m`) = **FAIL** ("il funding è un cashflow
|
||||||
|
perp-vs-spot; il prezzo index non ha drift tradabile attorno allo stamp al netto del trend").
|
||||||
|
- **Funding carry su Deribit** (dove eseguiamo) → ccxt `fetch_funding_rate_history` = **0 righe**
|
||||||
|
(bloccato), Cerbero MCP espone solo `get_historical` (candele), endpoint funding = 404.
|
||||||
|
- **Funding carry su Hyperliquid** → API pubblica `/info {"type":"fundingHistory"}` = **disponibile**,
|
||||||
|
oraria, tokenless, serie native dal 2023-05. HL è già l'universo certificato di XS01.
|
||||||
|
|
||||||
|
### Dato scaricato e certificato
|
||||||
|
`scripts/research/fetch_hl_funding.py` (backoff anti-429) → **19 major** (gli stessi di XS01),
|
||||||
|
`data/raw/hlfund_<sym>_1h.parquet`. Certificazione: cadenza ~1h, **0 gap**, copertura 98-100%,
|
||||||
|
funding annualizzato per asset da **APT +1.0%** a **NEAR +21.6%** (mediana ~+11.7%). Pochi `cap_hit`
|
||||||
|
(ore con |funding|>0.06%/h) su INJ/TIA/SEI, plausibili in alt ad alta vol. Dato pulito.
|
||||||
|
|
||||||
|
## Ipotesi e costruzione (FC01)
|
||||||
|
|
||||||
|
Book dollar-neutral che **SHORTA i k perp ad alto funding** e **COMPRA i k a basso** → incassa il
|
||||||
|
premio (chi è long paga il funding). Ritorno perp per un long = `price_ret − funding`. Causale come
|
||||||
|
XS01: ogni H=10 giorni, segnale = media causale del funding giornaliero realizzato sugli ultimi L
|
||||||
|
giorni (shift 1), rank cross-section, vol-target 20%, fee 0.05%/lato sul turnover.
|
||||||
|
`scripts/research/funding_carry_hl.py`. Domanda chiave: **edge reale e ORTOGONALE a XS01**, o XS01
|
||||||
|
travestito? (gli alt ad alto funding sono spesso i pompati = quelli che XS01 *compra*; qui li
|
||||||
|
*shortiamo* → potenziale anti-correlazione, oppure il carry domina).
|
||||||
|
|
||||||
|
## Risultati
|
||||||
|
|
||||||
|
### Premio reale ma direzione-dipendente
|
||||||
|
`carry` (short alto-funding) batte sistematicamente `anti` (long alto-funding, sempre molto negativo)
|
||||||
|
→ **il premio di funding esiste**: shortare i perp ad alto funding paga, in aggregato.
|
||||||
|
|
||||||
|
### Ma il book NON regge il gauntlet (19 asset, 2024-2026, 904g)
|
||||||
|
- **Standalone base (L=7 k=5): FULL Sharpe −0.12, in-sample 0.44, HOLD −0.50, DD 28.6%**, −2.8%/anno.
|
||||||
|
Decadimento netto: 2024 **+0.44** → 2025 −0.06 → 2026 **−1.42**.
|
||||||
|
- Correlazioni: TP01 −0.02, **XS01 −0.19** (ortogonale, come da ipotesi — NON è XS01 travestito),
|
||||||
|
VRP01 +0.05.
|
||||||
|
- **`marginal_vs_tp01` = DILUTES**: `has_insample_edge=False` (in-sample 0.44 < 0.5),
|
||||||
|
`multicut_persistent=False`, blend w25 uplift FULL −0.21 / HOLD −0.39.
|
||||||
|
- **Non aggiunge a XS01**: uplift w25 FULL −0.04 / HOLD −0.19.
|
||||||
|
|
||||||
|
### Il colpo di grazia: FRAGILITÀ all'universo
|
||||||
|
Un preview su 17 asset (mancavano NEAR e AAVE) dava FULL **+0.62**, ADDS, +0.22 uplift — un PASS
|
||||||
|
tentatore. Sui 19 completi: **DILUTES**. Jackknife lascia-fuori-uno (base L=7 k=5):
|
||||||
|
|
||||||
|
```
|
||||||
|
19 asset: FULL -0.12 HOLD -0.50
|
||||||
|
-SUI FULL -0.39 ... -BTC FULL +0.17
|
||||||
|
-SEI FULL -0.31 -AAVE FULL +0.26
|
||||||
|
-BNB FULL -0.29 -NEAR FULL +0.30
|
||||||
|
=> FULL oscilla in [-0.39, +0.30] togliendo UN solo asset (range 0.70), attraversa lo zero.
|
||||||
|
```
|
||||||
|
|
||||||
|
Togliere **NEAR o AAVE** (i due assenti nel preview) **recupera il segno** → il preview era fortunato
|
||||||
|
*proprio* perché quei due non c'erano ancora. **Un edge robusto non cambia segno per un singolo nome.**
|
||||||
|
Le poche celle "buone" del plateau (es. L=7 k=3: HOLD 0.91) hanno **in-sample debole + hold-out forte**
|
||||||
|
= la firma del hold-out-luck che la metodologia indurita uccide.
|
||||||
|
|
||||||
|
## Perché fallisce (meccanismo)
|
||||||
|
|
||||||
|
Tensione fondamentale **carry vs momentum**: il funding-carry shorta i forti (alto funding = domanda
|
||||||
|
long aggressiva), ma in un mercato alt toro i forti **continuano a correre** (NEAR/AAVE: alto funding
|
||||||
|
*e* prezzo su → shortarli perde più del premio incassato). Il premio di funding è reale in aggregato,
|
||||||
|
ma il book cross-sectional equal-weight top-k è dominato da pochi nomi a funding estremo che *anche*
|
||||||
|
trendano, e su 2.5 anni / 19 nomi questo basta a ribaltare il segno.
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
|
||||||
|
**FC01 NON è uno sleeve.** Né deploy (è STAT-MODE: 10 gambe market-neutral, non eseguibile a $600),
|
||||||
|
né lead affidabile: fragile all'universo (sign-flip su un nome), DILUTES vs TP01, non aggiunge a XS01,
|
||||||
|
in-sample edge < 0.5, niente persistenza multi-cut, decadimento 2026. Conferma — di nuovo — il
|
||||||
|
soffitto del progetto: promettente su un sottoinsieme fortunato, collassa sotto il gauntlet onesto.
|
||||||
|
**Win metodologico:** lo scorer indurito + il jackknife d'universo hanno intercettato un falso
|
||||||
|
positivo che il preview a 17 asset avrebbe promosso.
|
||||||
|
|
||||||
|
## Lascito / lavoro futuro (NON inseguire ora)
|
||||||
|
- I 19 parquet funding (`hlfund_*`) restano certificati per ricerca futura. Il fetcher NON va in cron
|
||||||
|
(FC01 fallito → niente da monitorare in forward).
|
||||||
|
- Idee se mai si tornasse sul carry (NON ora): (a) **gate sul LIVELLO** di funding (short solo quando
|
||||||
|
estremo, regime-filter alla VRP01 IV-rank) invece dello short-top-k incondizionato; (b) cap sul peso
|
||||||
|
per-nome / neutralizzazione momentum per togliere il dominio NEAR/AAVE. Entrambe rischiano
|
||||||
|
overfitting su storia corta — soglia di prova alta.
|
||||||
|
|
||||||
|
## Nota IB (thread parallelo, stessa sessione)
|
||||||
|
Esplorato come fonte per il **basis CME crypto** (cugino eseguibile del carry). Gateway paper
|
||||||
|
`gnzsnz/ib-gateway` su `127.0.0.1:4002` (read-only, `docker-compose.yml`), sonda `ib_probe.py`.
|
||||||
|
Esito dati: **backtest del basis NON fattibile** (ContFuture back-adjusted; contratti scaduti = 1
|
||||||
|
barra). IB resta valido per esecuzione/forward, non per scoprire l'edge. Dettagli nel corpo sessione.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 2026-06-23 — Combo DEPLOYABLE in PAPER: TP01 (Deribit) + GTAA (IB), cross-venue
|
||||||
|
|
||||||
|
## Decisione
|
||||||
|
Dopo aver esaurito (onestamente) la ricerca di nuovi edge e anticipazioni cross-mercato, l'unica cosa
|
||||||
|
VERA e deployabile e' la DIVERSIFICAZIONE: TP01 (crypto, Deribit) + GTAA (equity, IB), corr ~0.21 ->
|
||||||
|
blend Sharpe ~1.5, maxDD dimezzato (diari 2026-06-22-deployable-combo). Si va in PAPER cross-venue.
|
||||||
|
|
||||||
|
## Costruito
|
||||||
|
- **`src/portfolio/gtaa.py`** — GTAA come sleeve di prima classe: trend difensivo long-flat TSMOM
|
||||||
|
[21/63/126/252g], vol-target 12%, EW su SPY/QQQ/IWM/TLT/GLD/HYG. Espone `gtaa_returns()` (Sharpe
|
||||||
|
full 0.64, 7542 barre 1996+) e `gtaa_weights()` (pesi ETF CORRENTI azionabili). Legge cache eq_*.
|
||||||
|
- **`scripts/live/paper_combo.py`** — paper-tracker FORWARD-ONLY del blend 50/50 TP01+GTAA (crypto
|
||||||
|
compoundato sul grid giorni-di-borsa). Stato in data/paper_combo/. Mostra le posizioni azionabili
|
||||||
|
su entrambi i venue. SOLO le gambe eseguibili (XS01/VRP01 STAT-MODE esclusi).
|
||||||
|
- **`fetch_ib_equities.py --only SPY,QQQ,...`** — refresh mirato dei 6 ETF GTAA (per il cron).
|
||||||
|
- **`scripts/cron_daily.sh`** — aggiunto: up gateway IB (idempotente) -> refresh ETF GTAA ->
|
||||||
|
avanza paper_combo. Dipendenza cross-venue gestita (gateway paper sempre-up, restart unless-stopped).
|
||||||
|
|
||||||
|
## Stato iniziale (2026-06-23)
|
||||||
|
Paper combo init a 2000, forward da 2026-06-22. Posizioni azionabili:
|
||||||
|
- TP01 (Deribit): BTC/ETH 0.0x (flat, TSMOM risk-off — coerente col live).
|
||||||
|
- GTAA (IB): SPY 13% / QQQ 8% / IWM 9% / TLT 17% / GLD 2% / HYG 17% / cash 34% (difensivo).
|
||||||
|
Catena end-to-end testata: gateway -> refresh ETF -> avanza paper. OK.
|
||||||
|
|
||||||
|
## Onesta'
|
||||||
|
- E' PAPER (rischio zero). Valida l'OPERATIVITA' cross-venue prima di capitale reale.
|
||||||
|
- Sharpe atteso ~1.5 e' ottimistico (finestra crypto corta/favorevole); il dato robusto e' la
|
||||||
|
diversificazione (corr 0.21, DD dimezzato), non il livello assoluto.
|
||||||
|
- A capitale reale e' un portafoglio su DUE conti (Deribit ~$600 + IB); GTAA frazionabile a basso
|
||||||
|
capitale, TP01 gia' armato. Prossimo passo eventuale: dashboard del combo + (molto dopo) capitale.
|
||||||
|
|
||||||
|
## Prossimo
|
||||||
|
Lasciar girare il paper forward (cron giornaliero) e ricontrollare l'equity tra qualche settimana.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 2026-06-23 — Cross-market crypto-lead OLTRE l'SP500: bond, commodity, indici esteri -> niente
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
Estendere il test "crypto anticipa il mercato?" oltre SP500/azionario USA: commodity, bond, indici
|
||||||
|
ESTERI (Europa/Asia, fasi orarie diverse = il caso a priori piu' favorevole a un lead vero).
|
||||||
|
|
||||||
|
## Dati (IB, orari, cache fut_*_1h)
|
||||||
|
ES/NQ/RTY (gia'); + ZN (T-note 10y), ESTX50 (Euro Stoxx50), DAX, NKD (Nikkei). Storia ~2-2.4y (2024+).
|
||||||
|
Commodity GC/CL/HG: VUOTE (market-data subscription COMEX/NYMEX mancante sul paper) -> non testate.
|
||||||
|
|
||||||
|
## Test (`fut_leadlag_generic.py`): crypto[T-8h->T] -> future[T->T+6h], non-sovrapposto, controllo=moto proprio future
|
||||||
|
- ES/NQ/RTY: nessun edge (gia' noto).
|
||||||
|
- ZN (bond): NEGATIVO (0/3 anni).
|
||||||
|
- NKD (Nikkei): debole (t_crypto 0.2, Sharpe 0.66 ~ overnight drift, non crypto).
|
||||||
|
- **ESTX50 / DAX: forte all'apparenza** — BTC->T0h: t_crypto ~7.8, Sharpe 2.5, ann ~22%, 3/3 anni.
|
||||||
|
|
||||||
|
## Ma e' un ARTEFATTO DI CONFINE UTC (deep-dive `eu_overnight_deepdive.py`)
|
||||||
|
- **Picco a coltello a T=00:00 UTC**: t/Sharpe salgono T20->T0 (2.5->7.8 / 0.24->2.45) e CROLLANO a
|
||||||
|
T=1h (t 1.3, Sharpe -0.09). Un lead vero non e' a coltello su una sola ora.
|
||||||
|
- **GAP test**: inserendo 1h tra fine-segnale (00:00) e inizio-cattura, l'effetto MUORE
|
||||||
|
(Sharpe 2.45 -> -0.52, t 7.8 -> 1.6).
|
||||||
|
- **Singola ora**: T=0h/H=1h (cattura 00:00->01:00) Sharpe +2.93 (t 8.7); T=1h/H=1h (01:00->02:00)
|
||||||
|
Sharpe -1.02. L'INTERO "edge" e' la barra di confine 00:00->01:00.
|
||||||
|
- vs SEMPRE-LONG: always-long overnight e' negativo (-0.8/-1.2), quindi non e' overnight-drift; ma
|
||||||
|
l'uplift del crypto e' tutto nella barra di confine.
|
||||||
|
-> Firma esatta di `day_boundary_robust` (CLAUDE.md): effetto che vive/muore spostando il confine del
|
||||||
|
giorno UTC di poche ore = etichettatura/contaminazione, NON anticipazione economica.
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
NIENTE di tradabile oltre l'SP500 nemmeno. Su TUTTI i mercati il legame crypto->X e' o co-movimento
|
||||||
|
contemporaneo (risk-beta) o artefatto di confine. L'anticipazione crypto->altri-mercati sfruttabile
|
||||||
|
NON esiste su dati onesti (finestre non-sovrapposte + boundary-robust + gap). Conferma definitiva del
|
||||||
|
soffitto del progetto, ora anche cross-mercato.
|
||||||
|
|
||||||
|
## Cosa resta di valore (immutato)
|
||||||
|
La diversificazione TP01(crypto)+GTAA(equity), corr 0.21 -> Sharpe portafoglio ~1.5, DD dimezzato.
|
||||||
|
Quello e' strutturale e deployabile; l'anticipazione cross-mercato no.
|
||||||
|
Script: fetch_ib_futures.py (multi-exchange), fut_leadlag_generic.py, eu_overnight_deepdive.py.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 2026-06-23 — "Monitor Deribit / trade IB": il gap crypto->equity e' LOOK-AHEAD
|
||||||
|
|
||||||
|
## Idea testata (utente)
|
||||||
|
Guardare crypto live su Deribit (24/7) e tradare l'indice su IB sul segnale del gap overnight.
|
||||||
|
|
||||||
|
## Trappola trovata
|
||||||
|
Il segnale crypto [P 21:00 -> D 13:00 UTC] e il "gap" equity [P close -> D open 13:30] coprono QUASI
|
||||||
|
LE STESSE ORE. Condizionare il gap sul crypto-overnight = correlare due ritorni dello STESSO intervallo
|
||||||
|
notturno -> look-ahead. All'entrata (D 13:00, pre-open) il gap e' GIA' avvenuto: non catturabile.
|
||||||
|
|
||||||
|
## Prova (net 2bps, sqrt(252))
|
||||||
|
| target | OVERLAP gap (look-ahead) | TRADABILE intraday (post-entrata) |
|
||||||
|
|---|---|---|
|
||||||
|
| SPY | Sharpe 3.60 (OOS 5.23) | -0.03 (OOS 0.12) |
|
||||||
|
| QQQ | Sharpe 4.01 (OOS 5.47) | 0.25 (OOS 0.43) |
|
||||||
|
| IWM | Sharpe 3.98 (OOS 5.72) | 0.15 (OOS 0.44) |
|
||||||
|
|
||||||
|
Lo "Sharpe 5" e' artefatto. L'edge REALE tradabile via ETF (intraday, entri all'open) ~0, muore a costi.
|
||||||
|
NB: anche i Sharpe "gap" del workflow 65-agenti erano (a) look-ahead di overlap e (b) sotto-annualizzati
|
||||||
|
(sqrt(52) invece di sqrt(252)); il verdetto "non deployabile" resta, rafforzato.
|
||||||
|
|
||||||
|
## Cosa resta possibile (non testato, serve dato)
|
||||||
|
L'unica versione onesta dell'idea: entrare a META' notte via FUTURES IB e vedere se crypto [P21:00->T]
|
||||||
|
predice il future indice [T->open] su finestre NON sovrapposte (crypto come sensore di rischio piu'
|
||||||
|
veloce). Richiede dati INTRADAY dei futures (ES/NQ/RTY), non in cache -> data step se si vuole indagare.
|
||||||
|
|
||||||
|
## Lezione
|
||||||
|
Un risultato "troppo bello" (Sharpe 5) e' un test di disciplina: era overlap di finestre. Catturato.
|
||||||
|
Script: crypto_overnight_equity.py (versione artefatto), crypto_overnight_honest.py (decomposizione).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 2026-06-23 — "Monitor Deribit / trade IB" su futures: test ONESTO non-sovrapposto -> edge ~0
|
||||||
|
|
||||||
|
## Idea
|
||||||
|
Monitorare crypto live (Deribit 24/7) ed entrare sul FUTURE indice IB (ES/NQ/RTY, tradato di notte)
|
||||||
|
a meta' notte, catturando il moto SUCCESSIVO -> finestre NON sovrapposte (no look-ahead, vs il "gap"
|
||||||
|
che era contemporaneo al segnale).
|
||||||
|
|
||||||
|
## Dati
|
||||||
|
`fetch_ib_futures.py` -> data/raw/fut_{es,nq,rty}_1h.parquet (ContFuture orario, UTC). ES 3y (2023-06+),
|
||||||
|
NQ 2.75y, RTY 2.3y. (NB: ContFuture NON accetta endDateTime -> chiamata singola "4 Y" = ~3y max orari.)
|
||||||
|
|
||||||
|
## Test (`fut_overnight_leadlag.py`)
|
||||||
|
entrata a T (ora UTC notturna): segnale = crypto[P21:00->T]; controllo = future[P21:00->T] (moto
|
||||||
|
PROPRIO del future); cattura = future[T->open 13:00]. Incrementale: crypto predice la cattura OLTRE il
|
||||||
|
moto proprio del future? Trade: sign(crypto[P21:00->T]) * future[T->open], net 2bps. T in {0,3,6,9}h.
|
||||||
|
|
||||||
|
## Risultati
|
||||||
|
| future | miglior Sharpe (trade crypto) | t_crypto incrementale | esito |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ES (S&P500) | ~0 / negativo (-0.03..-0.93) | 0..1.5 | NESSUN edge |
|
||||||
|
| NQ (Nasdaq) | 0.41 (T=3h) | 0.5 (debole) | momentum del future, non crypto |
|
||||||
|
| RTY (Russell) | 0.40-0.77 | 2.0-2.7 (BTC->RTY) | soffio debole, non robusto |
|
||||||
|
|
||||||
|
- **SP500: NIENTE.** Il crypto della prima notte non predice l'ES della seconda. Il "Sharpe 5" del gap
|
||||||
|
era interamente look-ahead (finestre sovrapposte): catturato e ucciso.
|
||||||
|
- **RTY (small-cap)** e' l'unico con t_crypto incrementale ~2-2.7 e crypto che AGGIUNGE oltre il moto
|
||||||
|
proprio del future (futOwn Sharpe negativo). MA: Sharpe 0.4-0.5 modesto, 24 config (multiple-testing),
|
||||||
|
storia 2.3y, per-anno INCOERENTE (BTC->RTY T=3h: 2024 +0.99 / 2025 +0.52 / 2026 -0.31).
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
L'idea "monitor Deribit / trade IB" NON da' un edge tradabile, men che meno su SP500. Il forte
|
||||||
|
fenomeno crypto<->equity e' CO-MOVIMENTO contemporaneo (risk-beta overnight), non anticipazione: quando
|
||||||
|
si impone una finestra causale non-sovrapposta, l'edge svanisce (efficienza di mercato). L'unico
|
||||||
|
residuo (crypto->small-cap overnight) e' debole, borderline su multiple-testing e instabile per anno
|
||||||
|
-> forward-monitor al piu', NON deploy. Coerente col soffitto del progetto.
|
||||||
|
|
||||||
|
Script: fetch_ib_futures.py, fut_overnight_leadlag.py. (look-ahead documentato: 2026-06-23-crypto-overnight-lookahead.md)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 2026-06-23 — Tail-hedge / protezione DD del combo (incl. OPZIONI): vince la guardia-drawdown
|
||||||
|
|
||||||
|
## Obiettivo (goal utente)
|
||||||
|
Trovare uno sleeve/overlay da AGGIUNGERE al combo (TP01+GTAA) per proteggere il drawdown e gli anni
|
||||||
|
tipo 2022. Valutare anche le opzioni.
|
||||||
|
|
||||||
|
## Diagnosi del rischio (decisiva)
|
||||||
|
Il MaxDD del combo (1x) e' **8.4%** e il 2022 fu **-4.4%**: NON un crash, un **grind lento** (peggior
|
||||||
|
giorno -2.8%, peggior mese 2022 -1.6%). Il doppio trend (TP01+GTAA long-flat) gia' taglia i crash
|
||||||
|
veloci. Il tail residuo = (a) whipsaw da mercato choppy (2022), (b) gap/crash overnight LATENTE (TP01
|
||||||
|
non reagisce intraday, non nel campione storico), (c) la LEVA.
|
||||||
|
|
||||||
|
## Candidati testati (`tail_hedge_lab.py`)
|
||||||
|
| protezione | MaxDD | 2022 | Sharpe | CAGR |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| combo baseline | 8.4% | -4.4% | 1.48 | 11.3% |
|
||||||
|
| **+ guardia-DD -4%** | **5.8%** | **-1.8%** | 1.38 | 9.2% |
|
||||||
|
| + vol-target 5% | 8.4% | -5.9% | 1.46 | 8.0% |
|
||||||
|
| + opzioni (put/put-spread, budget 3%/y) | 8.4% | -4.4% | 1.48 | 11.3% |
|
||||||
|
|
||||||
|
### OPZIONI (valutate): NON adatte al 2022
|
||||||
|
- Put-spread/long-put LONG su indice 50/50 BTC/ETH (mirror di VRP01), premio BS su DVOL reale, payoff
|
||||||
|
sul path. Strike corretti (compra -0.30delta, vende -0.10delta).
|
||||||
|
- Sempre-on costa **~50%/anno** di premio -> con budget 3%/anno size ~0.06-0.10x = effetto ~nullo.
|
||||||
|
- Nel 2022 (grind, niente crash settimanali) **sanguinano** (scadono inutili) -> Δ2022 ~0.
|
||||||
|
- Pagano SOLO nei crash secchi: stress -30% overnight -> put paga **+25% netto**, put-spread +3.8%.
|
||||||
|
- Verdetto: assicurazione BLACK-SWAN cara e fuori-bersaglio per il grind. Utile solo come piccola
|
||||||
|
copertura del gap overnight latente, NON come fix del 2022.
|
||||||
|
|
||||||
|
### GUARDIA DRAWDOWN: centra il rischio
|
||||||
|
De-risk (esposizione 1.0->0.4) quando il DD da picco supera -4%, ri-rischia a -1.6%. Targetizza il
|
||||||
|
grind: MaxDD 8.4%->5.8%, 2022 -4.4%->-1.8%, ogni anno DD intra <=5.4%. Costo: Sharpe 1.48->1.38,
|
||||||
|
CAGR -2.1pp (de-risca sui cali, perde rimbalzo — prezzo onesto della protezione).
|
||||||
|
|
||||||
|
### A LEVA (dove il tail morde)
|
||||||
|
guard applicato pre-leva: 2x 2022 -15.6%->-10.9%, MaxDD 28%->24%; 3x resta MARGIN-CALL (DD 39%>=33%).
|
||||||
|
-> la protezione rende il 2x sopportabile; il 3x va evitato comunque.
|
||||||
|
|
||||||
|
## Raccomandazione
|
||||||
|
AGGIUNGERE una **guardia-drawdown a livello di portafoglio** al combo (overlay, niente premio):
|
||||||
|
e' la protezione che colpisce il rischio reale (grind/2022) a costo Sharpe minimo. Le opzioni NO come
|
||||||
|
fix del 2022; eventualmente una micro-allocazione deep-OTM come assicurazione black-swan separata.
|
||||||
|
Vol-target NON aiuta (il 2022 non e' uno spike di vol).
|
||||||
|
|
||||||
|
## Onesta'
|
||||||
|
- Il guard e' REATTIVO (de-risca dopo l'inizio del DD, restituisce un po' di rimbalzo) -> costa CAGR.
|
||||||
|
- Trade: -2.1pp CAGR per dimezzare il MaxDD e azzerare quasi il 2022. Sensato se la priorita' e' il DD.
|
||||||
|
- Parametri (-4% trigger) semplici; il meccanismo (non la soglia esatta) e' la sostanza.
|
||||||
|
Script: tail_hedge_lab.py.
|
||||||
|
|
||||||
|
## Aggiornamento — protezioni CLASSICHE (stop-loss), goal "prova anche SL" (`stops_lab.py`)
|
||||||
|
Confronto equo (trigger/re-entry sul NAV di mercato, non sull'equity congelata):
|
||||||
|
| protezione | Sharpe | MaxDD | 2022 | CAGR | in-mkt |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| baseline | 1.48 | 8.4% | -4.4% | 11.3% | 100% |
|
||||||
|
| **soft-guard -4% (0.4x)** | **1.38** | **5.8%** | **-1.8%** | 9.2% | 100% |
|
||||||
|
| trail-stop -4% (uscita tot.) | 1.07 | 7.5% | +0.0% | 6.6% | 42% |
|
||||||
|
| trail-stop -6% (re:newhigh) | 1.34 | 6.6% | -2.1% | 9.0% | 72% |
|
||||||
|
| trail-stop -8% | 1.41 | 8.3% | -4.2% | 10.0% | 87% |
|
||||||
|
| stop mensile -5% | 1.48 | 8.4% | -4.4% | 11.3% | 100% (mai scatta) |
|
||||||
|
| vol-stop (>90pctl) | 1.48 | 8.4% | -4.4% | 10.4% | 100% |
|
||||||
|
|
||||||
|
VERDETTO: lo SL classico funziona solo a -6% (e resta inferiore al soft-guard); a -4% fa WHIPSAW
|
||||||
|
(Sh 1.48->1.07, fuori mercato 58%) perche' l'uscita TOTALE viene choppata nel grind. Il soft-guard
|
||||||
|
alla stessa soglia non whippa (de-risk parziale 0.4x). Stop mensile/vol inutili (bersaglio sbagliato).
|
||||||
|
Conferma: per un DD da grind, de-risk PARZIALE > stop-loss duro. Soft-guard -4% confermato come scelta.
|
||||||
@@ -11,5 +11,10 @@ mkdir -p logs
|
|||||||
uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01
|
uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01
|
||||||
uv run python scripts/live/paper_prevday.py # forward-monitor lead prevday-breakout (PAPER, non deploy)
|
uv run python scripts/live/paper_prevday.py # forward-monitor lead prevday-breakout (PAPER, non deploy)
|
||||||
uv run python scripts/live/live_execute.py --execute # TP01 LIVE su Deribit (gated da config/live.json)
|
uv run python scripts/live/live_execute.py --execute # TP01 LIVE su Deribit (gated da config/live.json)
|
||||||
|
# --- COMBO cross-venue (PAPER): refresh ETF IB (GTAA) + avanza paper TP01+GTAA ---
|
||||||
|
docker compose up -d ib-gateway >/dev/null 2>&1 # gateway IB paper (idempotente)
|
||||||
|
for i in $(seq 1 25); do (echo > /dev/tcp/127.0.0.1/4002) >/dev/null 2>&1 && break; sleep 6; done
|
||||||
|
uv run --with ib_async python scripts/research/fetch_ib_equities.py --only SPY,QQQ,IWM,TLT,GLD,HYG # ETF GTAA freschi
|
||||||
|
uv run python scripts/live/paper_combo.py # avanza paper combo (forward-only)
|
||||||
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||||
} >> logs/cron_daily.log 2>&1
|
} >> logs/cron_daily.log 2>&1
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""PAPER COMBO — forward-only del combo cross-venue TP01 (Deribit) + GTAA (IB), NUDO vs PROTETTO.
|
||||||
|
|
||||||
|
Le due gambe eseguibili a basso capitale (XS01/VRP01 STAT-MODE esclusi), scorrelate (corr ~0.21) ->
|
||||||
|
blend Sharpe ~1.5, DD dimezzato. Traccia FORWARD-ONLY DUE versioni in parallelo:
|
||||||
|
* NUDO = blend 50/50 TP01+GTAA
|
||||||
|
* PROTETTO = stesso blend + GUARDIA-DRAWDOWN -4% (de-risk a 0.4x quando il DD da picco supera -4%,
|
||||||
|
ri-rischia a -1.6%). Backtest: MaxDD 8.4%->5.8%, 2022 -4.4%->-1.8%, Sharpe 1.48->1.38
|
||||||
|
(diario 2026-06-23-tail-hedge-lab). Le opzioni NON aiutano il grind del 2022 -> escluse.
|
||||||
|
Crypto compoundato sul grid giorni-di-borsa. NESSUNA esecuzione reale. Mostra posizioni azionabili.
|
||||||
|
|
||||||
|
uv run python scripts/live/paper_combo.py [--status|--reset]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys, json
|
||||||
|
from pathlib import Path
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from src.portfolio.sleeves import _tp01_returns, _tp01_positions
|
||||||
|
from src.portfolio.gtaa import gtaa_returns, gtaa_weights
|
||||||
|
|
||||||
|
STATE_DIR = PROJECT_ROOT / "data" / "paper_combo"
|
||||||
|
STATE = STATE_DIR / "state.json"
|
||||||
|
EQ = STATE_DIR / "equity.csv"
|
||||||
|
INITIAL = 2000.0
|
||||||
|
W_CRYPTO = 0.5
|
||||||
|
DD_TRIGGER = 0.04 # guardia-drawdown della versione PROTETTA
|
||||||
|
|
||||||
|
|
||||||
|
def combo_daily(wc: float = W_CRYPTO) -> pd.Series:
|
||||||
|
tp = _tp01_returns()
|
||||||
|
if tp.index.tz is None:
|
||||||
|
tp.index = tp.index.tz_localize("UTC")
|
||||||
|
eq = gtaa_returns().dropna()
|
||||||
|
grid = eq.index[eq.index >= tp.index[0]]
|
||||||
|
cum = (1.0 + tp).cumprod()
|
||||||
|
tpg = (cum.reindex(cum.index.union(grid)).ffill().reindex(grid)).pct_change()
|
||||||
|
J = pd.concat({"c": tpg, "e": eq.reindex(grid)}, axis=1).dropna()
|
||||||
|
return (wc * J["c"] + (1 - wc) * J["e"]).dropna()
|
||||||
|
|
||||||
|
|
||||||
|
def apply_dd_guard(r: pd.Series, trigger: float = DD_TRIGGER) -> pd.Series:
|
||||||
|
"""De-risk a 0.4x quando il DD da picco > trigger; ri-rischia a 1.0x quando < 0.4*trigger."""
|
||||||
|
rv = r.values; n = len(rv); eq = np.cumprod(1 + rv); pk = np.maximum.accumulate(eq)
|
||||||
|
expo = np.ones(n); on = True
|
||||||
|
for i in range(1, n):
|
||||||
|
ddi = (pk[i - 1] - eq[i - 1]) / pk[i - 1] if pk[i - 1] > 0 else 0.0
|
||||||
|
if ddi > trigger: on = False
|
||||||
|
if ddi < trigger * 0.4: on = True
|
||||||
|
expo[i] = 1.0 if on else 0.4
|
||||||
|
return pd.Series(expo * rv, index=r.index)
|
||||||
|
|
||||||
|
|
||||||
|
def both_daily():
|
||||||
|
naked = combo_daily()
|
||||||
|
return naked, apply_dd_guard(naked)
|
||||||
|
|
||||||
|
|
||||||
|
def load():
|
||||||
|
return json.loads(STATE.read_text()) if STATE.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def save(st):
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
STATE.write_text(json.dumps(st, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def advance():
|
||||||
|
naked, guard = both_daily()
|
||||||
|
st = load()
|
||||||
|
if st is None or "equity_g" not in st: # init (o migrazione a doppia versione)
|
||||||
|
last = str(naked.index[-1])
|
||||||
|
st = dict(start=last, last=last, initial=INITIAL, n_days=0, w_crypto=W_CRYPTO, dd_trigger=DD_TRIGGER,
|
||||||
|
equity=INITIAL, peak=INITIAL, max_dd=0.0,
|
||||||
|
equity_g=INITIAL, peak_g=INITIAL, max_dd_g=0.0)
|
||||||
|
save(st); EQ.write_text("date,nudo,protetto\n" + f"{last},{INITIAL},{INITIAL}\n")
|
||||||
|
return st
|
||||||
|
last = pd.Timestamp(st["last"])
|
||||||
|
nn = naked[naked.index > last]; gg = guard[guard.index > last]
|
||||||
|
if len(nn):
|
||||||
|
e = st["equity"]; pk = st["peak"]; dd = st["max_dd"]
|
||||||
|
eg = st["equity_g"]; pkg = st["peak_g"]; ddg = st["max_dd_g"]; lines = []
|
||||||
|
for d in nn.index:
|
||||||
|
e *= (1 + float(nn[d])); pk = max(pk, e); dd = max(dd, (pk - e) / pk if pk > 0 else 0)
|
||||||
|
eg *= (1 + float(gg[d])); pkg = max(pkg, eg); ddg = max(ddg, (pkg - eg) / pkg if pkg > 0 else 0)
|
||||||
|
lines.append(f"{d},{e:.4f},{eg:.4f}")
|
||||||
|
st.update(equity=e, peak=pk, max_dd=dd, equity_g=eg, peak_g=pkg, max_dd_g=ddg,
|
||||||
|
last=str(nn.index[-1]), n_days=st["n_days"] + len(nn))
|
||||||
|
save(st)
|
||||||
|
with open(EQ, "a") as f:
|
||||||
|
f.write("\n".join(lines) + "\n")
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = sys.argv[1:]
|
||||||
|
if "--reset" in a:
|
||||||
|
for f in (STATE, EQ):
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
|
print("paper combo azzerato.")
|
||||||
|
st = load() if "--status" in a else advance()
|
||||||
|
if st is None or "equity_g" not in st:
|
||||||
|
st = advance()
|
||||||
|
days = (pd.Timestamp(st["last"]) - pd.Timestamp(st["start"])).days
|
||||||
|
rn = st["equity"] / st["initial"] - 1; rg = st["equity_g"] / st["initial"] - 1
|
||||||
|
gw = gtaa_weights(); asof = gw.pop("_asof", "?"); cash = gw.pop("_cash", None)
|
||||||
|
print("PAPER COMBO — TP01 (Deribit) + GTAA (IB), forward-only, blend 50/50")
|
||||||
|
print(f" start {st['start'][:10]} -> last {st['last'][:10]} ({days}g, {st['n_days']} barre)")
|
||||||
|
print(f" NUDO : eq {st['equity']:.2f} ret {rn*100:+.2f}% maxDD {st['max_dd']*100:.1f}%")
|
||||||
|
print(f" PROTETTO : eq {st['equity_g']:.2f} ret {rg*100:+.2f}% maxDD {st['max_dd_g']*100:.1f}% (guardia-DD -{st.get('dd_trigger',DD_TRIGGER)*100:.0f}%)")
|
||||||
|
print(f" --- POSIZIONI AZIONABILI ---")
|
||||||
|
print(f" TP01 (Deribit): {_tp01_positions()}")
|
||||||
|
print(f" GTAA (IB, asof {asof}): " + ", ".join(f"{k} {v:.0%}" for k, v in gw.items() if v) + f" | cash {cash:.0%}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Simulazione LEVA 1x/2x/3x su COMBO (TP01+GTAA) e TP01-solo, da $2k e $5k.
|
||||||
|
|
||||||
|
Leva modellata onestamente: ritorno_giorno = L*r - (L-1)*financing/252 (costo del nozionale preso a
|
||||||
|
prestito ~8%/anno blended: perp funding crypto + margin IB). MaxDD calcolato sul PERCORSO LEVATO REALE
|
||||||
|
(non scalato: il compounding peggiora il DD oltre ×L). Check RUINA/margin-call: se l'equity tocca la
|
||||||
|
soglia di liquidazione (perdita cumulata >= 1/L del picco -> margin call).
|
||||||
|
|
||||||
|
CLAUDE.md: la leva NON e' la scorciatoia; raddoppia (e oltre) il drawdown. Caso base = 1x.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
from combo_yearly_report import combo_daily
|
||||||
|
from src.portfolio.sleeves import _tp01_returns
|
||||||
|
|
||||||
|
FIN = 0.08 # costo finanziamento annuo sul nozionale preso a prestito (perp funding + margin IB), blended
|
||||||
|
|
||||||
|
|
||||||
|
def lever(ret: pd.Series, L: float) -> pd.Series:
|
||||||
|
return L * ret - (L - 1) * FIN / 252.0
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(ret: pd.Series, L: float, cap0: float):
|
||||||
|
r = lever(ret.dropna().sort_index(), L)
|
||||||
|
curve = cap0 * np.cumprod(1 + r.values)
|
||||||
|
peak = np.maximum.accumulate(curve)
|
||||||
|
dd = (peak - curve) / peak
|
||||||
|
maxdd = float(np.max(dd))
|
||||||
|
# margin call: perdita dal picco >= 1/L (a leva L, un drawdown del sottostante di 1/L azzera il margine)
|
||||||
|
ruin = bool(np.any(dd >= 1.0 / L - 1e-9)) if L > 1 else False
|
||||||
|
yrs = (r.index[-1] - r.index[0]).days / 365.25
|
||||||
|
cagr = (curve[-1] / cap0) ** (1 / yrs) - 1 if yrs > 0 and curve[-1] > 0 else -1
|
||||||
|
sh = float(r.mean() / r.std() * np.sqrt(252)) if r.std() > 0 else 0
|
||||||
|
worst_y = min((np.prod(1 + r[r.index.year == y].values) - 1) for y in sorted(set(r.index.year)))
|
||||||
|
return dict(L=L, final=float(curve[-1]), cagr=cagr, maxdd=maxdd, sharpe=sh, ruin=ruin,
|
||||||
|
worst_y=float(worst_y), perday=(curve[-1] - cap0) / yrs / 365)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 92)
|
||||||
|
print(" LEVA su COMBO vs TP01-solo — percorso reale (fin 8%/anno sul prestito), 2019-2026 (~7.3y)")
|
||||||
|
print("=" * 92)
|
||||||
|
strat = {"COMBO TP01+GTAA": combo_daily(), "TP01 solo (crypto)": _tp01_returns()}
|
||||||
|
for nm, r in strat.items():
|
||||||
|
if r.index.tz is None:
|
||||||
|
r.index = r.index.tz_localize("UTC")
|
||||||
|
for cap0 in (2000.0, 5000.0):
|
||||||
|
print(f"\n ##### capitale iniziale ${cap0:,.0f} #####")
|
||||||
|
print(f" {'strategia':20}{'leva':>5}{'CAGR':>8}{'MaxDD':>8}{'Sharpe':>8}{'pegg.anno':>10}{'$/giorno':>10}{'eq fine':>12}{' RUINA?':>9}")
|
||||||
|
for nm, r in strat.items():
|
||||||
|
for L in (1, 2, 3):
|
||||||
|
a = analyze(r, L, cap0)
|
||||||
|
flag = "MARGIN-CALL" if a["ruin"] else "ok"
|
||||||
|
print(f" {nm:20}{L:>4}x{a['cagr']*100:>7.1f}%{a['maxdd']*100:>7.1f}%{a['sharpe']:>8.2f}"
|
||||||
|
f"{a['worst_y']*100:>9.1f}%{('$'+format(a['perday'],',.0f')):>10}{('$'+format(a['final'],',.0f')):>12}{flag:>11}")
|
||||||
|
# per-anno del COMBO a 2x e 3x da 2k (dettaglio)
|
||||||
|
print(f"\n ##### COMBO per-anno a leva, da $2.000 #####")
|
||||||
|
cd = combo_daily()
|
||||||
|
for L in (2, 3):
|
||||||
|
print(f"\n --- COMBO {L}x ---")
|
||||||
|
print(f" {'anno':6}{'PnL %':>9}{'MaxDD %':>9}{'eq fine':>11}")
|
||||||
|
eq = 2000.0; r = lever(cd, L)
|
||||||
|
for y in sorted(set(r.index.year)):
|
||||||
|
ry = r[r.index.year == y]
|
||||||
|
if len(ry) < 5: continue
|
||||||
|
eq0 = eq; curve = eq0 * np.cumprod(1 + ry.values); peak = np.maximum.accumulate(curve)
|
||||||
|
ddp = float(np.max((peak - curve) / peak)); eq = float(curve[-1])
|
||||||
|
print(f" {y:<6}{(eq/eq0-1)*100:>+8.1f}%{ddp*100:>8.1f}%{('$'+format(eq,',.0f')):>11}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Resoconto anno-per-anno CON PROTEZIONE (soft-guard DD -4%) — combo e singoli.
|
||||||
|
|
||||||
|
Mette combo (TP01+GTAA 50/50), TP01 e GTAA sulla STESSA griglia giorni-di-borsa (come dentro al
|
||||||
|
combo), applica la guardia-DD -4% a ciascuna serie (de-risk 1.0->0.4 a -4% dal picco, ri-rischia a
|
||||||
|
-1.6%), e per ogni anno riporta: NL (net liquidation da $2000), DD intra-anno, rendimento (=CAGR
|
||||||
|
1y), Sharpe. Riga TOT con CAGR e Sharpe complessivi.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
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.portfolio.sleeves import _tp01_returns
|
||||||
|
from src.portfolio.gtaa import gtaa_returns
|
||||||
|
|
||||||
|
INITIAL = 2000.0
|
||||||
|
ANN = np.sqrt(252.0)
|
||||||
|
DD_TRIG = 0.04
|
||||||
|
|
||||||
|
|
||||||
|
def dd_guard(r, dd_trigger=DD_TRIG):
|
||||||
|
"""De-risk: esposizione 1.0->0.4 se DD da picco > dd_trigger; ri-rischia a dd_trigger*0.4."""
|
||||||
|
r = r.values; n = len(r); eq = np.cumprod(1 + r); pk = np.maximum.accumulate(eq)
|
||||||
|
expo = np.ones(n); on = True
|
||||||
|
for i in range(1, n):
|
||||||
|
ddi = (pk[i - 1] - eq[i - 1]) / pk[i - 1]
|
||||||
|
if ddi > dd_trigger: on = False
|
||||||
|
if ddi < dd_trigger * 0.4: on = True
|
||||||
|
expo[i] = 1.0 if on else 0.4
|
||||||
|
return pd.Series(expo * r, index=_idx) # set below
|
||||||
|
|
||||||
|
|
||||||
|
def legs_on_grid(wc=0.5):
|
||||||
|
"""TP01(crypto, compoundato sul grid) e GTAA(equity) sulla stessa griglia giorni-di-borsa."""
|
||||||
|
tp = _tp01_returns()
|
||||||
|
if tp.index.tz is None:
|
||||||
|
tp.index = tp.index.tz_localize("UTC")
|
||||||
|
eq = gtaa_returns().dropna()
|
||||||
|
grid = eq.index[eq.index >= tp.index[0]]
|
||||||
|
cum = (1 + tp).cumprod()
|
||||||
|
tpg = cum.reindex(cum.index.union(grid)).ffill().reindex(grid).pct_change()
|
||||||
|
J = pd.concat({"c": tpg, "e": eq.reindex(grid)}, axis=1).dropna()
|
||||||
|
combo = wc * J["c"] + (1 - wc) * J["e"]
|
||||||
|
return combo, J["c"], J["e"]
|
||||||
|
|
||||||
|
|
||||||
|
def sh(r): r = r.dropna().values; return float(np.mean(r) / np.std(r) * ANN) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||||
|
def maxdd(curve): pk = np.maximum.accumulate(curve); return float(np.max((pk - curve) / pk)) if len(curve) else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def yearly(ret, label):
|
||||||
|
ret = ret.dropna().sort_index()
|
||||||
|
print(f"\n ===== {label} (guardia-DD -4%) =====")
|
||||||
|
print(f" {'anno':6}{'NL inizio':>11}{'NL fine':>11}{'rend%':>9}{'DD%':>8}{'Sharpe':>9}")
|
||||||
|
eq = INITIAL
|
||||||
|
for y in sorted(set(ret.index.year)):
|
||||||
|
r = ret[ret.index.year == y]
|
||||||
|
if len(r) < 5: continue
|
||||||
|
eq0 = eq
|
||||||
|
curve = eq0 * np.cumprod(1 + r.values)
|
||||||
|
eq = float(curve[-1])
|
||||||
|
print(f" {y:<6}{eq0:>11,.0f}{eq:>11,.0f}{(eq/eq0-1)*100:>+8.1f}%{maxdd(curve)*100:>7.1f}%{sh(r):>9.2f}")
|
||||||
|
yrs = (ret.index[-1] - ret.index[0]).days / 365.25
|
||||||
|
cagr = (eq / INITIAL) ** (1 / yrs) - 1 if yrs > 0 else 0
|
||||||
|
full_curve = INITIAL * np.cumprod(1 + ret.values)
|
||||||
|
print(f" {'TOT':<6}{INITIAL:>11,.0f}{eq:>11,.0f}{(eq/INITIAL-1)*100:>+8.1f}%{maxdd(full_curve)*100:>7.1f}%{sh(ret):>9.2f}"
|
||||||
|
f" | CAGR {cagr*100:+.1f}% ({yrs:.1f}y)")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global _idx
|
||||||
|
print("=" * 78)
|
||||||
|
print(" RESOCONTO PROTETTO (soft-guard DD -4%) — da $2.000, anno per anno")
|
||||||
|
print(" Tutte e tre sulla griglia giorni-di-borsa del combo (dal 2019), esposizione 1x.")
|
||||||
|
print("=" * 78)
|
||||||
|
combo, tp, g = legs_on_grid()
|
||||||
|
for ret, lbl in [(combo, "COMBO TP01+GTAA 50/50"), (tp, "solo TP01 (crypto)"), (g, "solo GTAA (equity)")]:
|
||||||
|
_idx = ret.index
|
||||||
|
yearly(dd_guard(ret), lbl)
|
||||||
|
# confronto NON protetto (baseline) in coda, una riga TOT per riferimento
|
||||||
|
print("\n --- riferimento NON protetto (baseline, TOT) ---")
|
||||||
|
for ret, lbl in [(combo, "COMBO"), (tp, "TP01"), (g, "GTAA")]:
|
||||||
|
yrs = (ret.index[-1] - ret.index[0]).days / 365.25
|
||||||
|
eqf = INITIAL * np.prod(1 + ret.values)
|
||||||
|
print(f" {lbl:6} CAGR {((eqf/INITIAL)**(1/yrs)-1)*100:>+5.1f}% DD {maxdd(INITIAL*np.cumprod(1+ret.values))*100:>4.1f}% Sharpe {sh(ret):.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Resoconto anno-per-anno della strategia combo (TP01+GTAA) + componenti, da $2.000.
|
||||||
|
|
||||||
|
Per anno: PnL ($ e %), MaxDD (intra-anno), NumTrades, equity di fine anno (compounding da 2k).
|
||||||
|
Combo = blend 50/50 TP01(Deribit) + GTAA(IB) (crypto compoundato su grid giorni-di-borsa).
|
||||||
|
NumTrades: TP01 = cambi di target BTC/ETH (>0.05); GTAA = ribilanci MENSILI per-gamba (>2%).
|
||||||
|
Onesto: il combo parte dal 2019 (crypto). GTAA-solo dato anche su 10y come contesto.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_1d
|
||||||
|
from src.portfolio.sleeves import _tp01_returns
|
||||||
|
from src.portfolio.gtaa import gtaa_returns, _exposure, _close, EQ_UNIVERSE
|
||||||
|
INITIAL = 2000.0
|
||||||
|
|
||||||
|
|
||||||
|
def tp01_trades_per_year():
|
||||||
|
tp = TrendPortfolio(**CANONICAL); cnt = {}
|
||||||
|
for a in ("BTC", "ETH"):
|
||||||
|
df = resample_1d(load_data(a, "1h")); tgt = tp.target_series(df)
|
||||||
|
idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"]))
|
||||||
|
chg = pd.Series(np.abs(np.diff(tgt, prepend=tgt[0])) > 0.05, index=idx)
|
||||||
|
for y, c in chg.groupby(idx.year).sum().items():
|
||||||
|
cnt[int(y)] = cnt.get(int(y), 0) + int(c)
|
||||||
|
return cnt
|
||||||
|
|
||||||
|
|
||||||
|
def gtaa_trades_per_year():
|
||||||
|
# pesi giornalieri -> ribilancio MENSILE realistico -> conta gambe cambiate >2%
|
||||||
|
W = {}
|
||||||
|
for a in EQ_UNIVERSE:
|
||||||
|
ex = _exposure(_close(a)) / len(EQ_UNIVERSE)
|
||||||
|
W[a] = ex
|
||||||
|
Wd = pd.concat(W, axis=1).dropna()
|
||||||
|
Wm = Wd.resample("ME").last() # peso a fine mese
|
||||||
|
chg = (Wm.diff().abs() > 0.02).sum(axis=1) # gambe ribilanciate quel mese
|
||||||
|
return chg.groupby(chg.index.year).sum().astype(int).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def yearly(ret: pd.Series, trades: dict, label: str, start_capital=INITIAL):
|
||||||
|
ret = ret.dropna().sort_index()
|
||||||
|
print(f"\n ===== {label} =====")
|
||||||
|
print(f" {'anno':6}{'eq inizio':>12}{'PnL $':>12}{'PnL %':>9}{'MaxDD %':>9}{'NumTrades':>11}{'eq fine':>12}")
|
||||||
|
eq = start_capital
|
||||||
|
for y in sorted(set(ret.index.year)):
|
||||||
|
r = ret[ret.index.year == y]
|
||||||
|
if len(r) < 5:
|
||||||
|
continue
|
||||||
|
eq0 = eq
|
||||||
|
curve = eq0 * np.cumprod(1 + r.values)
|
||||||
|
peak = np.maximum.accumulate(curve)
|
||||||
|
dd = float(np.max((peak - curve) / peak)) if len(curve) else 0.0
|
||||||
|
eq = float(curve[-1])
|
||||||
|
pnl = eq - eq0
|
||||||
|
nt = trades.get(y, None)
|
||||||
|
print(f" {y:<6}{eq0:>12,.0f}{pnl:>+12,.0f}{(eq/eq0-1)*100:>+8.1f}%{dd*100:>8.1f}%"
|
||||||
|
f"{(str(nt) if nt is not None else '—'):>11}{eq:>12,.0f}")
|
||||||
|
tot = eq / start_capital - 1
|
||||||
|
yrs = (ret.index[-1] - ret.index[0]).days / 365.25
|
||||||
|
cagr = (eq / start_capital) ** (1 / yrs) - 1 if yrs > 0 else 0
|
||||||
|
sh = float(r.mean()) if False else float(ret.mean() / ret.std() * np.sqrt(252))
|
||||||
|
print(f" {'TOT':<6}{start_capital:>12,.0f}{eq-start_capital:>+12,.0f}{tot*100:>+8.1f}%"
|
||||||
|
f"{'':>9}{sum(v for v in trades.values()) if trades else 0:>11}{eq:>12,.0f}")
|
||||||
|
print(f" -> da ${start_capital:,.0f} a ${eq:,.0f} in {yrs:.1f}y | CAGR {cagr*100:+.1f}% | Sharpe {sh:.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
def combo_daily(wc=0.5):
|
||||||
|
tp = _tp01_returns()
|
||||||
|
if tp.index.tz is None:
|
||||||
|
tp.index = tp.index.tz_localize("UTC")
|
||||||
|
eq = gtaa_returns().dropna()
|
||||||
|
grid = eq.index[eq.index >= tp.index[0]]
|
||||||
|
cum = (1 + tp).cumprod()
|
||||||
|
tpg = (cum.reindex(cum.index.union(grid)).ffill().reindex(grid)).pct_change()
|
||||||
|
J = pd.concat({"c": tpg, "e": eq.reindex(grid)}, axis=1).dropna()
|
||||||
|
return (wc * J["c"] + (1 - wc) * J["e"]).dropna()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 80)
|
||||||
|
print(" RESOCONTO STRATEGIA — da $2.000, anno per anno")
|
||||||
|
print("=" * 80)
|
||||||
|
tpt = tp01_trades_per_year(); gtt = gtaa_trades_per_year()
|
||||||
|
combo_tr = {y: tpt.get(y, 0) + gtt.get(y, 0) for y in set(tpt) | set(gtt)}
|
||||||
|
# COMBO (la strategia deployata)
|
||||||
|
yearly(combo_daily(), combo_tr, "COMBO TP01+GTAA 50/50 (deployabile, dal 2019)")
|
||||||
|
# componenti
|
||||||
|
tp = _tp01_returns(); tp.index = tp.index.tz_localize("UTC") if tp.index.tz is None else tp.index
|
||||||
|
yearly(tp, tpt, "solo TP01 (crypto, Deribit)")
|
||||||
|
g = gtaa_returns(); g10 = g[g.index >= (g.index[-1] - pd.Timedelta(days=3660))]
|
||||||
|
yearly(g10, gtt, "solo GTAA (equity, IB) — ULTIMI 10 ANNI")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""HARNESS parametrizzato — anticipazione crypto -> mercato (lead-lag eseguibile, onesto).
|
||||||
|
|
||||||
|
Generalizza l'effetto weekend: la finestra-LEAD e' l'intervallo in cui l'equity e' CHIUSO e la crypto
|
||||||
|
no (prev close 21:00 UTC -> next open 13:30 UTC). Il weekend e' il caso lungo (Ven 21:00 -> Lun 13:30).
|
||||||
|
Per ogni sessione equity D (con sessione precedente P):
|
||||||
|
lead = crypto return su [lead_start, D 13:00] (lead_start = P 21:00 se hours='overnight', else D13:00-hours)
|
||||||
|
predict target: gap = open[D]/close[P]-1 ; intraday = close[D]/open[D]-1 ; full = close[D]/close[P]-1
|
||||||
|
control = rendimento sessione precedente equity (close[P]/close[P_prev]-1) -> test INCREMENTALE
|
||||||
|
Filtro giorni: all | mon (solo lunedi'/weekend) | nonmon.
|
||||||
|
|
||||||
|
Output JSON per config: n, corr, beta+t-stat del lead AL NETTO del control (incrementale), Sharpe
|
||||||
|
settimanale/annualizzato del trade eseguibile (sign(lead)*predict - costo) FULL/IS/OOS(2022+),
|
||||||
|
hit-rate, e PER-ANNO (hit e mean) per la robustezza multi-anno.
|
||||||
|
|
||||||
|
uv run python scripts/research/crypto_lead_harness.py --configs '[{"lead":"BTC","target":"QQQ","day":"mon","predict":"intraday","hours":"overnight"}]'
|
||||||
|
Dati: cache su disco (crypto 1h, ETF eq_*). Nessun IB online. Vettoriale, veloce.
|
||||||
|
"""
|
||||||
|
import sys, json, argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
import eqlib
|
||||||
|
|
||||||
|
OOS_DEFAULT = "2022-01-01"
|
||||||
|
OPEN_H = 13 # ~apertura US 13:30 UTC -> uso barra 13:00 (info nota prima dell'open per il lead)
|
||||||
|
CLOSE_H = 21 # ~chiusura US 21:00 UTC
|
||||||
|
|
||||||
|
_CRYPTO = {}
|
||||||
|
def crypto_hourly(asset):
|
||||||
|
if asset not in _CRYPTO:
|
||||||
|
s = load_data(asset, "1h").set_index("datetime")["close"].astype(float)
|
||||||
|
full = pd.date_range(s.index[0].floor("h"), s.index[-1].ceil("h"), freq="h", tz="UTC")
|
||||||
|
_CRYPTO[asset] = s.reindex(s.index.union(full)).ffill().reindex(full)
|
||||||
|
return _CRYPTO[asset]
|
||||||
|
|
||||||
|
|
||||||
|
def at(series, ts):
|
||||||
|
try:
|
||||||
|
return float(series.asof(ts))
|
||||||
|
except Exception:
|
||||||
|
return np.nan
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(cfg, cost_rt=0.0004, oos=OOS_DEFAULT):
|
||||||
|
OOS = pd.Timestamp(oos, tz="UTC")
|
||||||
|
lead = cfg["lead"]; tgt = cfg["target"]; day = cfg.get("day", "all")
|
||||||
|
predict = cfg.get("predict", "intraday"); hours = cfg.get("hours", "overnight")
|
||||||
|
bc = crypto_hourly(lead)
|
||||||
|
try:
|
||||||
|
oc = eqlib.load_eq(tgt)["open"].astype(float); cc = eqlib.load_eq(tgt)["close"].astype(float)
|
||||||
|
except Exception as e:
|
||||||
|
return {**cfg, "err": f"no data {tgt}"}
|
||||||
|
idx = cc.index
|
||||||
|
rows = []
|
||||||
|
for j in range(2, len(idx)):
|
||||||
|
D = idx[j]; P = idx[j-1]; Pp = idx[j-2]
|
||||||
|
if day == "mon" and D.weekday() != 0: continue
|
||||||
|
if day == "nonmon" and D.weekday() == 0: continue
|
||||||
|
d_open = D.normalize() + pd.Timedelta(hours=OPEN_H)
|
||||||
|
p_close = P.normalize() + pd.Timedelta(hours=CLOSE_H)
|
||||||
|
lead_start = p_close if hours == "overnight" else d_open - pd.Timedelta(hours=int(hours))
|
||||||
|
c1 = at(bc, d_open); c0 = at(bc, lead_start)
|
||||||
|
if not (np.isfinite(c1) and np.isfinite(c0) and c0 > 0): continue
|
||||||
|
ld = c1 / c0 - 1.0
|
||||||
|
gap = oc[D] / cc[P] - 1.0
|
||||||
|
intr = cc[D] / oc[D] - 1.0
|
||||||
|
full = cc[D] / cc[P] - 1.0
|
||||||
|
ctrl = cc[P] / cc[Pp] - 1.0
|
||||||
|
rows.append((D, ld, gap, intr, full, ctrl))
|
||||||
|
if len(rows) < 60:
|
||||||
|
return {**cfg, "err": f"n={len(rows)}"}
|
||||||
|
D_ = pd.DataFrame(rows, columns=["d", "lead", "gap", "intraday", "full", "ctrl"]).set_index("d")
|
||||||
|
y = D_[predict].values; x = D_["lead"].values; ctrl = D_["ctrl"].values
|
||||||
|
|
||||||
|
def z(a):
|
||||||
|
sd = a.std(); return (a - a.mean()) / sd if sd > 0 else a * 0
|
||||||
|
corr = float(np.corrcoef(x, y)[0, 1])
|
||||||
|
# incrementale vs control (OLS standardizzato)
|
||||||
|
X = np.column_stack([np.ones(len(y)), z(x), z(ctrl)])
|
||||||
|
beta, *_ = np.linalg.lstsq(X, z(y), rcond=None)
|
||||||
|
resid = z(y) - X @ beta
|
||||||
|
dof = max(len(y) - 3, 1)
|
||||||
|
se = np.sqrt(np.sum(resid**2) / dof * np.diag(np.linalg.inv(X.T @ X)))
|
||||||
|
t_inc = float(beta[1] / se[1]) if se[1] > 0 else 0.0
|
||||||
|
# trade eseguibile: long-short e long-flat su segno del lead, intraday/predict, net costi
|
||||||
|
sign = np.sign(x)
|
||||||
|
def sharpe(r):
|
||||||
|
r = r[np.isfinite(r)]
|
||||||
|
return float(np.mean(r) / np.std(r) * np.sqrt(52)) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||||
|
ls = sign * y - cost_rt
|
||||||
|
lf = np.where(x > 0, y, 0.0) - np.where(x > 0, cost_rt, 0.0)
|
||||||
|
yrs = D_.index.year.values
|
||||||
|
def per_year(r):
|
||||||
|
out = {}
|
||||||
|
for yv in sorted(set(yrs)):
|
||||||
|
m = yrs == yv
|
||||||
|
if m.sum() >= 8:
|
||||||
|
out[int(yv)] = round(float(np.mean(np.sign(x[m]) == np.sign(y[m]))), 2)
|
||||||
|
return out
|
||||||
|
is_m = D_.index < OOS; oos_m = D_.index >= OOS
|
||||||
|
py = per_year(ls)
|
||||||
|
return {**cfg, "n": len(D_), "corr": round(corr, 3), "t_incremental": round(t_inc, 2),
|
||||||
|
"hit": round(float(np.mean(sign == np.sign(y))), 3),
|
||||||
|
"sh_ls_full": round(sharpe(ls), 2), "sh_ls_is": round(sharpe(ls[is_m]), 2),
|
||||||
|
"sh_ls_oos": round(sharpe(ls[oos_m]), 2),
|
||||||
|
"sh_lf_full": round(sharpe(lf), 2), "sh_lf_oos": round(sharpe(lf[oos_m]), 2),
|
||||||
|
"ann_ls_pct": round(float(np.nanmean(ls) * 52 * 100), 1),
|
||||||
|
"years_pos": int(sum(1 for v in py.values() if v > 0.5)), "years_tot": len(py),
|
||||||
|
"per_year_hit": py}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--configs", required=True)
|
||||||
|
ap.add_argument("--cost", type=float, default=0.0004)
|
||||||
|
ap.add_argument("--oos", default=OOS_DEFAULT)
|
||||||
|
args = ap.parse_args()
|
||||||
|
cfgs = json.loads(args.configs)
|
||||||
|
print(json.dumps([evaluate(c, cost_rt=args.cost, oos=args.oos) for c in cfgs]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""CRYPTO x MERCATI IB — correlazioni e ANTICIPAZIONI (lead-lag).
|
||||||
|
|
||||||
|
Obiettivo: la crypto (24/7) anticipa i mercati IB (azioni/bond/oro/credito), o viceversa?
|
||||||
|
Disciplina onesta: i tranelli di timing daily sono enormi (crypto chiude 00:00 UTC, US equity 21:00
|
||||||
|
UTC -> il lag-0 e' contaminato), quindi (1) allineo i rendimenti sullo STESSO intervallo
|
||||||
|
(compounding crypto sul grid giorni-di-borsa), (2) guardo i lag >=1 giorno, (3) test del segno con
|
||||||
|
hit-rate e split in-sample/OOS, (4) flag multiple-testing.
|
||||||
|
|
||||||
|
Ipotesi piu' pulita = EFFETTO WEEKEND: la crypto si muove Sab+Dom (azionario chiuso) -> quel
|
||||||
|
movimento e' informazione PRIOR al lunedi'. Predice il gap/intraday del lunedi' azionario?
|
||||||
|
Uso gli OPEN dei parquet eq_ (Monday open noto alle 13:30 UTC, weekend crypto noto alle 00:00 UTC).
|
||||||
|
|
||||||
|
DATI: cache su disco (BTC/ETH Deribit 1h->1d UTC; ETF IB eq_*). Nessun IB online.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
import eqlib
|
||||||
|
|
||||||
|
ETFS = ["SPY", "QQQ", "IWM", "TLT", "GLD", "HYG"]
|
||||||
|
|
||||||
|
|
||||||
|
def crypto_daily_close(asset="BTC") -> pd.Series:
|
||||||
|
df = load_data(asset, "1h").set_index("datetime")["close"].astype(float)
|
||||||
|
return df.resample("1D").last().dropna() # close ~00:00 UTC del giorno dopo
|
||||||
|
|
||||||
|
|
||||||
|
def _ret(s):
|
||||||
|
return s.pct_change()
|
||||||
|
|
||||||
|
|
||||||
|
def _corr_lags(x: pd.Series, y: pd.Series, lags=range(-5, 6)):
|
||||||
|
"""corr(x_{t-k}, y_t): k>0 => x ANTICIPA y di k giorni. Allineati sullo stesso grid."""
|
||||||
|
J = pd.concat({"x": x, "y": y}, axis=1, join="inner").dropna()
|
||||||
|
out = {}
|
||||||
|
for k in lags:
|
||||||
|
out[k] = round(float(J["x"].shift(k).corr(J["y"])), 3)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 98)
|
||||||
|
print(" CRYPTO x MERCATI IB — correlazioni & anticipazioni (lead-lag)")
|
||||||
|
print("=" * 98)
|
||||||
|
btc = crypto_daily_close("BTC"); eth = crypto_daily_close("ETH")
|
||||||
|
btc_r = _ret(btc); eth_r = _ret(eth)
|
||||||
|
# equity close + grid giorni-di-borsa
|
||||||
|
eq_close = {s: eqlib.load_eq(s)["close"].astype(float) for s in ETFS}
|
||||||
|
eq_open = {s: eqlib.load_eq(s)["open"].astype(float) for s in ETFS}
|
||||||
|
grid = eq_close["SPY"].index
|
||||||
|
grid = grid[grid >= btc.index[0]]
|
||||||
|
# crypto compoundato sul grid giorni-di-borsa (stesso intervallo dell'equity ret)
|
||||||
|
def to_grid(s):
|
||||||
|
cum = (1 + _ret(s)).cumprod()
|
||||||
|
return (cum.reindex(cum.index.union(grid)).ffill().reindex(grid)).pct_change()
|
||||||
|
btc_g = to_grid(btc); eth_g = to_grid(eth)
|
||||||
|
|
||||||
|
print(f" overlap dal {grid[0].date()} ({len(grid)} giorni di borsa)\n")
|
||||||
|
|
||||||
|
print(" --- (1) CORRELAZIONE CONTEMPORANEA (stesso intervallo; lag0 contaminato da timing) ---")
|
||||||
|
print(f" {'ETF':5} {'corr BTC':>9} {'corr ETH':>9}")
|
||||||
|
for s in ETFS:
|
||||||
|
er = _ret(eq_close[s]).reindex(grid)
|
||||||
|
cb = round(float(pd.concat([btc_g, er], axis=1).dropna().corr().iloc[0, 1]), 3)
|
||||||
|
ce = round(float(pd.concat([eth_g, er], axis=1).dropna().corr().iloc[0, 1]), 3)
|
||||||
|
print(f" {s:5} {cb:>9} {ce:>9}")
|
||||||
|
|
||||||
|
print("\n --- (2) LEAD-LAG BTC vs ETF: corr(BTC_{t-k}, ETF_t), k>0 = BTC ANTICIPA ---")
|
||||||
|
print(f" {'ETF':5} " + " ".join(f"k{ k:+d}" for k in range(-3, 4)) + " picco")
|
||||||
|
for s in ETFS:
|
||||||
|
er = _ret(eq_close[s]).reindex(grid)
|
||||||
|
cl = _corr_lags(btc_g, er, range(-3, 4))
|
||||||
|
peak = max(cl, key=lambda k: abs(cl[k]))
|
||||||
|
row = " ".join(f"{cl[k]:+.2f}" for k in range(-3, 4))
|
||||||
|
tag = f"k={peak:+d} ({'BTC->ETF' if peak>0 else 'ETF->BTC' if peak<0 else 'contemp'})"
|
||||||
|
print(f" {s:5} {row} {tag}")
|
||||||
|
|
||||||
|
print("\n --- (3) EFFETTO WEEKEND: crypto Sab+Dom -> lunedi' azionario (anticipazione pulita) ---")
|
||||||
|
# weekend crypto = close(Dom 00:00 lun) / close(Ven) - 1 ; calcolato su crypto daily (calendario)
|
||||||
|
cal = pd.date_range(btc.index[0], btc.index[-1], freq="D", tz="UTC")
|
||||||
|
bc = btc.reindex(cal).ffill()
|
||||||
|
for s in ["SPY", "QQQ", "IWM", "HYG"]:
|
||||||
|
oc = eq_open[s]; cc = eq_close[s]
|
||||||
|
rows = []
|
||||||
|
for mon in grid:
|
||||||
|
if mon.weekday() != 0: # solo lunedi'
|
||||||
|
continue
|
||||||
|
fri = mon - pd.Timedelta(days=3)
|
||||||
|
if fri not in cc.index: # venerdi' non di borsa (festa) -> salta
|
||||||
|
continue
|
||||||
|
wk = float(bc.get(mon, np.nan) / bc.get(fri + pd.Timedelta(days=0), np.nan) - 1) if fri in bc.index else np.nan
|
||||||
|
# weekend crypto: da venerdi 00:00(close ven) a lunedi 00:00 -> usa bc[fri]..bc[mon]
|
||||||
|
wk = float(bc.loc[mon] / bc.loc[fri] - 1) if (mon in bc.index and fri in bc.index) else np.nan
|
||||||
|
gap = float(oc.loc[mon] / cc.loc[fri] - 1) if (mon in oc.index and fri in cc.index) else np.nan
|
||||||
|
intr = float(cc.loc[mon] / oc.loc[mon] - 1) if mon in oc.index else np.nan
|
||||||
|
rows.append((mon, wk, gap, intr))
|
||||||
|
D = pd.DataFrame(rows, columns=["mon", "wk", "gap", "intr"]).dropna().set_index("mon")
|
||||||
|
if len(D) < 50:
|
||||||
|
print(f" {s}: pochi dati ({len(D)})"); continue
|
||||||
|
def stat(col):
|
||||||
|
c = float(D["wk"].corr(D[col]))
|
||||||
|
hit = float((np.sign(D["wk"]) == np.sign(D[col])).mean())
|
||||||
|
return c, hit
|
||||||
|
cg, hg = stat("gap"); ci, hi = stat("intr")
|
||||||
|
# OOS: split 2022
|
||||||
|
Dh = D[D.index >= pd.Timestamp("2022-01-01", tz="UTC")]
|
||||||
|
cg_o = float(Dh["wk"].corr(Dh["gap"])); ci_o = float(Dh["wk"].corr(Dh["intr"]))
|
||||||
|
print(f" {s}: n={len(D)} | weekend-crypto -> Mon GAP corr {cg:+.2f} hit {hg*100:.0f}% (OOS22+ {cg_o:+.2f}) "
|
||||||
|
f"| Mon INTRADAY corr {ci:+.2f} hit {hi*100:.0f}% (OOS {ci_o:+.2f})")
|
||||||
|
|
||||||
|
print("\n NB: lag-0/contemporanea contaminata dal timing (crypto chiude 00:00, equity 21:00 UTC).")
|
||||||
|
print(" Il GAP del lunedi' e' il test pulito (weekend crypto = info prior all'apertura).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""DERIBIT-monitor -> IB-trade: il segnale crypto overnight AGGIUNGE al long-overnight indice?
|
||||||
|
|
||||||
|
Idea utente: guardare crypto live su Deribit (24/7) e tradare l'indice su IB. Il GAP di apertura =
|
||||||
|
movimento OVERNIGHT dei futures (MES/MNQ/M2K, tradati di notte) -> catturabile, net ~2bps.
|
||||||
|
|
||||||
|
DOMANDA DECISIVA (test onesto): l'azionario ha gia' un OVERNIGHT PREMIUM noto (il drift positivo
|
||||||
|
notturno). Quindi "long indice overnight" rende di suo. Il segnale crypto MIGLIORA quel baseline,
|
||||||
|
o e' solo overnight-premium + beta? Confronto:
|
||||||
|
A) ALWAYS-LONG overnight (incondizionato) = cattura il premio notturno puro
|
||||||
|
B) LONG se crypto-overnight>0, else FLAT = usa il crypto come filtro
|
||||||
|
C) LONG/SHORT sul segno del crypto = il segnale pieno
|
||||||
|
La metrica chiave e' B,C VS A (l'uplift del crypto sul puro premio notturno), non A in assoluto.
|
||||||
|
|
||||||
|
Dati: cache su disco (crypto 1h Deribit; ETF eq_* = proxy del future indice). net 2bps RT (futures).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
import eqlib
|
||||||
|
from crypto_lead_harness import crypto_hourly, at, OPEN_H, CLOSE_H
|
||||||
|
|
||||||
|
OOS = pd.Timestamp("2022-01-01", tz="UTC")
|
||||||
|
COST = 0.0002 # ~2bps RT micro-future (commissione + spread)
|
||||||
|
|
||||||
|
|
||||||
|
def _sh(r):
|
||||||
|
r = np.asarray(r, float); r = r[np.isfinite(r)]
|
||||||
|
return float(np.mean(r) / np.std(r) * np.sqrt(252)) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def build(target="SPY", lead="BTC"):
|
||||||
|
bc = crypto_hourly(lead)
|
||||||
|
oc = eqlib.load_eq(target)["open"].astype(float); cc = eqlib.load_eq(target)["close"].astype(float)
|
||||||
|
idx = cc.index; rows = []
|
||||||
|
for j in range(1, len(idx)):
|
||||||
|
D = idx[j]; P = idx[j-1]
|
||||||
|
d_open = D.normalize() + pd.Timedelta(hours=OPEN_H)
|
||||||
|
p_close = P.normalize() + pd.Timedelta(hours=CLOSE_H)
|
||||||
|
c1 = at(bc, d_open); c0 = at(bc, p_close)
|
||||||
|
if not (np.isfinite(c1) and np.isfinite(c0) and c0 > 0):
|
||||||
|
continue
|
||||||
|
crypto = c1 / c0 - 1.0
|
||||||
|
overnight = oc[D] / cc[P] - 1.0 # gap = ritorno overnight del future indice (catturabile)
|
||||||
|
rows.append((D, crypto, overnight))
|
||||||
|
return pd.DataFrame(rows, columns=["d", "crypto", "on"]).set_index("d")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 92)
|
||||||
|
print(" CRYPTO overnight -> LONG INDICE OVERNIGHT (Deribit-monitor / IB-trade): aggiunge al premio?")
|
||||||
|
print("=" * 92)
|
||||||
|
print(f" net {COST*1e4:.0f}bps RT (micro-future). overnight = open[D]/close[P]-1 (= move notturno future).\n")
|
||||||
|
for tgt in ("SPY", "QQQ", "IWM"):
|
||||||
|
for lead in ("BTC", "ETH"):
|
||||||
|
D = build(tgt, lead)
|
||||||
|
up = D[D["crypto"] > 0]["on"]; dn = D[D["crypto"] <= 0]["on"]
|
||||||
|
# strategie
|
||||||
|
A = D["on"].values - COST # always-long
|
||||||
|
B = np.where(D["crypto"] > 0, D["on"], 0.0) - np.where(D["crypto"] > 0, COST, 0.0) # long se crypto su
|
||||||
|
C = np.sign(D["crypto"]) * D["on"] - COST # long/short
|
||||||
|
shA, shB, shC = _sh(A), _sh(B), _sh(C)
|
||||||
|
# OOS
|
||||||
|
m = D.index >= OOS
|
||||||
|
print(f" {tgt} <- {lead}: n={len(D)} | notti crypto-SU {len(up)} ret medio {up.mean()*1e4:+.1f}bps "
|
||||||
|
f"| crypto-GIU {len(dn)} ret medio {dn.mean()*1e4:+.1f}bps (spread {(up.mean()-dn.mean())*1e4:+.1f}bps)")
|
||||||
|
print(f" Sharpe: A always-long {shA:.2f} | B long-if-cryptoUp {shB:.2f} | C long/short {shC:.2f} "
|
||||||
|
f"-> UPLIFT crypto B-A {shB-shA:+.2f}, C-A {shC-shA:+.2f}")
|
||||||
|
print(f" OOS22+: A {_sh(A[m]):.2f} | B {_sh(B[m]):.2f} | C {_sh(C[m]):.2f} | "
|
||||||
|
f"ann: A {np.nanmean(A)*252*100:+.1f}% B {np.nanmean(B)*252*100:+.1f}% C {np.nanmean(C)*252*100:+.1f}%")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# focus SPY-BTC: per-anno A vs C, e sketch deploy
|
||||||
|
D = build("SPY", "BTC")
|
||||||
|
A = D["on"].values - COST; C = np.sign(D["crypto"]) * D["on"] - COST
|
||||||
|
print(" --- SPY<-BTC per-anno: Sharpe A(always-long) vs C(crypto long/short) ---")
|
||||||
|
for y in sorted(set(D.index.year)):
|
||||||
|
mm = D.index.year == y
|
||||||
|
if mm.sum() >= 40:
|
||||||
|
print(f" {y}: A {_sh(A[mm]):+.2f} C {_sh(C[mm]):+.2f} (n={mm.sum()})")
|
||||||
|
print("\n NB: se C-A ~ 0, il crypto NON aggiunge al premio overnight (e' solo il premio + beta).")
|
||||||
|
print(" Se C-A >> 0 e A gia' alto, il crypto e' un filtro REALE sul rischio notturno.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""ONESTA' DI TIMING: il 'Sharpe 5' del crypto->overnight equity e' look-ahead?
|
||||||
|
|
||||||
|
Test: separare la versione OVERLAP (segnale e ritorno coprono le stesse ore = look-ahead) dalla
|
||||||
|
versione TRADABILE (segnale finisce all'ENTRATA, catturo solo il moto SUCCESSIVO).
|
||||||
|
segnale crypto = BTC [P 21:00 -> D 13:00 UTC] (noto solo a D 13:00, poco prima dell'open 13:30)
|
||||||
|
- OVERLAP capture = gap = open[D]/close[P]-1 [P 21:00 -> D 13:30] <-- sovrappone il segnale
|
||||||
|
- TRADABILE capture = intra = close[D]/open[D]-1 [D 13:30 -> D 21:00] <-- DOPO l'entrata, pulito
|
||||||
|
Se OVERLAP >> TRADABILE, il numero alto e' artefatto: all'open il gap e' gia' avvenuto, non lo catturi.
|
||||||
|
Annualizzazione CORRETTA sqrt(252) (giornaliero), non sqrt(52).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
import eqlib
|
||||||
|
from crypto_lead_harness import crypto_hourly, at, OPEN_H, CLOSE_H
|
||||||
|
|
||||||
|
OOS = pd.Timestamp("2022-01-01", tz="UTC"); COST = 0.0002
|
||||||
|
|
||||||
|
|
||||||
|
def _sh(r):
|
||||||
|
r = np.asarray(r, float); r = r[np.isfinite(r)]
|
||||||
|
return float(np.mean(r) / np.std(r) * np.sqrt(252)) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def build(target="SPY", lead="BTC"):
|
||||||
|
bc = crypto_hourly(lead)
|
||||||
|
oc = eqlib.load_eq(target)["open"].astype(float); cc = eqlib.load_eq(target)["close"].astype(float)
|
||||||
|
idx = cc.index; rows = []
|
||||||
|
for j in range(1, len(idx)):
|
||||||
|
D = idx[j]; P = idx[j-1]
|
||||||
|
c1 = at(bc, D.normalize() + pd.Timedelta(hours=OPEN_H)) # crypto a D 13:00 (entrata)
|
||||||
|
c0 = at(bc, P.normalize() + pd.Timedelta(hours=CLOSE_H)) # crypto a P 21:00
|
||||||
|
if not (np.isfinite(c1) and np.isfinite(c0) and c0 > 0):
|
||||||
|
continue
|
||||||
|
crypto = c1 / c0 - 1.0
|
||||||
|
gap = oc[D] / cc[P] - 1.0 # OVERLAP col segnale
|
||||||
|
intra = cc[D] / oc[D] - 1.0 # DOPO l'entrata (tradabile)
|
||||||
|
rows.append((D, crypto, gap, intra))
|
||||||
|
return pd.DataFrame(rows, columns=["d", "crypto", "gap", "intra"]).set_index("d")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 90)
|
||||||
|
print(" TIMING ONESTO: OVERLAP (look-ahead) vs TRADABILE — crypto overnight -> equity")
|
||||||
|
print("=" * 90)
|
||||||
|
print(" segnale noto a D13:00. OVERLAP=gap [copre il segnale]. TRADABILE=intraday [dopo entrata]. net 2bps, sqrt(252)\n")
|
||||||
|
for tgt in ("SPY", "QQQ", "IWM"):
|
||||||
|
D = build(tgt, "BTC")
|
||||||
|
for name, col in (("OVERLAP gap (look-ahead)", "gap"), ("TRADABILE intraday", "intra")):
|
||||||
|
C = np.sign(D["crypto"]) * D[col] - COST
|
||||||
|
m = D.index >= OOS
|
||||||
|
print(f" {tgt} {name:26}: Sharpe long/short {_sh(C):.2f} (OOS {_sh(C[m]):.2f}) ann {np.nanmean(C)*252*100:+.1f}%")
|
||||||
|
print()
|
||||||
|
print(" --- VERDETTO ---")
|
||||||
|
D = build("QQQ", "BTC")
|
||||||
|
cg = _sh(np.sign(D["crypto"]) * D["gap"] - COST)
|
||||||
|
ci = _sh(np.sign(D["crypto"]) * D["intra"] - COST)
|
||||||
|
print(f" QQQ: gap(overlap) Sharpe {cg:.2f} vs intraday(tradabile) Sharpe {ci:.2f}")
|
||||||
|
print(f" -> il moto che il crypto 'predice' avviene NELLA finestra del segnale (overnight), non dopo.")
|
||||||
|
print(f" All'entrata (D13:00, pre-open) il gap e' gia' realizzato -> NON catturabile con l'ETF.")
|
||||||
|
print(f" Cio' che resta da catturare (intraday) ha Sharpe ~{ci:.1f}: l'edge tradabile e' quello.")
|
||||||
|
print(f" Per catturare PARTE dell'overnight servirebbe entrare a META' notte via FUTURES IB e")
|
||||||
|
print(f" testare se il crypto [P21:00->T] predice il future [T->open] (finestre NON sovrapposte):")
|
||||||
|
print(f" richiede dati intraday dei futures indice (ES/NQ), che NON abbiamo in cache -> data step.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""WEEKEND CRYPTO -> LUNEDI' AZIONARIO — validazione avversariale dell'anticipazione.
|
||||||
|
|
||||||
|
L'analisi lead-lag ha trovato UNA anticipazione pulita: il movimento crypto del weekend (Sab+Dom,
|
||||||
|
azionario chiuso) anticipa il lunedi' azionario (gap corr ~0.24, OOS piu' forte). Prima di crederci,
|
||||||
|
due test scettici:
|
||||||
|
(A) INCREMENTALE: aggiunge info OLTRE il rendimento del VENERDI'? (o e' solo momentum equity?)
|
||||||
|
regressione Mon ~ weekend_crypto + friday_equity ; il coeff del crypto resta significativo?
|
||||||
|
(B) TRADABILE: segnale eseguibile = osservo weekend crypto (noto Dom 24:00 UTC), entro al Monday
|
||||||
|
OPEN, esco al Monday CLOSE. Net di costi (4 bps RT ETF). Sharpe/hit/OOS vs sempre-long lunedi'.
|
||||||
|
|
||||||
|
DATI: cache su disco (BTC Deribit 1h->1d; ETF eq_* con OPEN). Nessun IB online.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
import eqlib
|
||||||
|
|
||||||
|
OOS = pd.Timestamp("2022-01-01", tz="UTC")
|
||||||
|
COST_RT = 0.0004 # 4 bps round-trip ETF (entry open + exit close)
|
||||||
|
|
||||||
|
|
||||||
|
def _sh_weekly(r):
|
||||||
|
r = np.asarray(pd.Series(r).dropna(), float)
|
||||||
|
return float(np.mean(r) / np.std(r) * np.sqrt(52)) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def build(asset_etf="QQQ"):
|
||||||
|
btc = load_data("BTC", "1h").set_index("datetime")["close"].astype(float).resample("1D").last()
|
||||||
|
cal = pd.date_range(btc.index[0], btc.index[-1], freq="D", tz="UTC")
|
||||||
|
bc = btc.reindex(cal).ffill()
|
||||||
|
oc = eqlib.load_eq(asset_etf)["open"].astype(float)
|
||||||
|
cc = eqlib.load_eq(asset_etf)["close"].astype(float)
|
||||||
|
rows = []
|
||||||
|
for mon in cc.index:
|
||||||
|
if mon.weekday() != 0:
|
||||||
|
continue
|
||||||
|
fri = mon - pd.Timedelta(days=3)
|
||||||
|
thu = mon - pd.Timedelta(days=4)
|
||||||
|
if fri not in cc.index or fri not in bc.index or mon not in bc.index:
|
||||||
|
continue
|
||||||
|
wk = bc.loc[mon] / bc.loc[fri] - 1.0 # weekend crypto (Ven 00:00 -> Lun 00:00)
|
||||||
|
fri_eq = (cc.loc[fri] / cc.loc[thu] - 1.0) if thu in cc.index else np.nan # rendimento venerdi'
|
||||||
|
gap = oc.loc[mon] / cc.loc[fri] - 1.0
|
||||||
|
intr = cc.loc[mon] / oc.loc[mon] - 1.0 # tradabile: open->close lunedi'
|
||||||
|
rows.append((mon, wk, fri_eq, gap, intr))
|
||||||
|
return pd.DataFrame(rows, columns=["mon", "wk", "fri_eq", "gap", "intr"]).dropna().set_index("mon")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 92)
|
||||||
|
print(" WEEKEND CRYPTO -> LUNEDI' AZIONARIO — validazione avversariale")
|
||||||
|
print("=" * 92)
|
||||||
|
for etf in ("QQQ", "SPY", "IWM"):
|
||||||
|
D = build(etf)
|
||||||
|
print(f"\n ===== {etf} (n={len(D)} lunedi', {D.index[0].date()}..{D.index[-1].date()}) =====")
|
||||||
|
|
||||||
|
# (A) INCREMENTALE vs venerdi' — regressione OLS standardizzata, t-stat su weekend_crypto
|
||||||
|
for tgt in ("gap", "intr"):
|
||||||
|
y = (D[tgt] - D[tgt].mean()) / D[tgt].std()
|
||||||
|
x1 = (D["wk"] - D["wk"].mean()) / D["wk"].std()
|
||||||
|
x2 = (D["fri_eq"] - D["fri_eq"].mean()) / D["fri_eq"].std()
|
||||||
|
X = np.column_stack([np.ones(len(D)), x1.values, x2.values])
|
||||||
|
beta, *_ = np.linalg.lstsq(X, y.values, rcond=None)
|
||||||
|
resid = y.values - X @ beta
|
||||||
|
se = np.sqrt(np.sum(resid**2) / (len(D) - 3) * np.diag(np.linalg.inv(X.T @ X)))
|
||||||
|
t_wk = beta[1] / se[1]; t_fri = beta[2] / se[2]
|
||||||
|
partial = float(pd.Series(resid).corr(x1)) # ~ contributo crypto al netto del resto
|
||||||
|
print(f" [{tgt:4}] beta_weekendCrypto {beta[1]:+.3f} (t={t_wk:+.1f}) | "
|
||||||
|
f"beta_fridayEq {beta[2]:+.3f} (t={t_fri:+.1f}) -> crypto {'INCREMENTALE' if abs(t_wk)>2 else 'non signif.'}")
|
||||||
|
|
||||||
|
# (B) TRADABILE: long lunedi' intraday se weekend crypto > 0, short se < 0 (net costi)
|
||||||
|
sig = np.sign(D["wk"].values)
|
||||||
|
gross = sig * D["intr"].values
|
||||||
|
net = gross - COST_RT
|
||||||
|
D2 = D.assign(net=net)
|
||||||
|
full = D2["net"]; oos = D2[D2.index >= OOS]["net"]; ins = D2[D2.index < OOS]["net"]
|
||||||
|
bh = D["intr"] # baseline: sempre-long lunedi' intraday
|
||||||
|
hit = float((np.sign(gross) > 0).mean()) if False else float((sig == np.sign(D["intr"].values)).mean())
|
||||||
|
print(f" TRADE (long/short Mon intraday su segno weekend-crypto, net {COST_RT*1e4:.0f}bps):")
|
||||||
|
print(f" hit-rate segno {hit*100:.0f}% | Sharpe(sett.) FULL {_sh_weekly(full):.2f} IS {_sh_weekly(ins):.2f} OOS22+ {_sh_weekly(oos):.2f}")
|
||||||
|
print(f" ritorno medio/lun {full.mean()*1e4:+.1f}bps (net) | baseline sempre-long {bh.mean()*1e4:+.1f}bps | "
|
||||||
|
f"ann.~{full.mean()*52*100:+.1f}%")
|
||||||
|
# long-flat (piu' realistico: long se crypto su, altrimenti cash)
|
||||||
|
lf = np.where(D["wk"].values > 0, D["intr"].values, 0.0) - np.where(D["wk"].values > 0, COST_RT, 0.0)
|
||||||
|
lfs = pd.Series(lf, index=D.index)
|
||||||
|
print(f" variante LONG-FLAT (long se crypto su, else cash): Sharpe FULL {_sh_weekly(lfs):.2f} "
|
||||||
|
f"OOS {_sh_weekly(lfs[lfs.index>=OOS]):.2f} ann.~{lfs.mean()*52*100:+.1f}%")
|
||||||
|
|
||||||
|
print("\n NB: ~52 lunedi'/anno -> Sharpe settimanale; OOS = 2022+. Multiple-testing: 3 ETF x 2 target.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""CROSS-MARKET COMBO — la sleeve equity-trend DIVERSIFICA il portafoglio crypto?
|
||||||
|
|
||||||
|
(2) dopo EQ-GTAA01. La via che alza il Sharpe COMPLESSIVO senza cercare nuovo alpha: combinare due
|
||||||
|
book scorrelati su mercati diversi. crypto = portafoglio attivo TP01+XS01+VRP01 (src.portfolio);
|
||||||
|
equity = GTAA lf vt12% (la migliore sleeve equity, corr SPY 0.64, maxDD ~15%). Se la correlazione
|
||||||
|
crypto<->equity e' bassa, il blend ha Sharpe > di ciascuno.
|
||||||
|
|
||||||
|
ALLINEAMENTO ONESTO: crypto e' calendario-giornaliero (7gg), equity giorni di borsa. Compoundo i
|
||||||
|
rendimenti crypto sul grid dei giorni di borsa (cattura i weekend) prima di combinare. Finestra =
|
||||||
|
era crypto (TP01 dal 2019). Esecuzione split Deribit+IB (lo noto: e' un portafoglio cross-venue).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
from eq_sector_momentum import _sh, _cagr, _dd
|
||||||
|
from eq_gtaa_trend import gtaa
|
||||||
|
from eq_spy_trend import tsmom_exposure, backtest, _series
|
||||||
|
from src.portfolio.sleeves import active_sleeves
|
||||||
|
from src.portfolio.portfolio import StrategyPortfolio
|
||||||
|
|
||||||
|
ANN = np.sqrt(252.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _ann_vol(r):
|
||||||
|
return float(np.std(np.asarray(r, float)) * ANN)
|
||||||
|
|
||||||
|
|
||||||
|
def compound_to_grid(daily: pd.Series, grid: pd.DatetimeIndex) -> pd.Series:
|
||||||
|
"""Compounda una serie di rendimenti (calendario) sul grid dato (giorni di borsa): per ogni data
|
||||||
|
del grid, somma-composta i rendimenti dal punto precedente."""
|
||||||
|
cum = (1.0 + daily).cumprod()
|
||||||
|
cum = cum.reindex(cum.index.union(grid)).ffill().reindex(grid)
|
||||||
|
return (cum / cum.shift(1) - 1.0).dropna()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 96)
|
||||||
|
print(" CROSS-MARKET COMBO — equity-trend (GTAA) x crypto (TP01+XS01+VRP01)")
|
||||||
|
print("=" * 96)
|
||||||
|
# crypto blend (rinormalizzato, date diverse)
|
||||||
|
crypto = StrategyPortfolio(active_sleeves()).combined_daily()
|
||||||
|
if crypto.index.tz is None:
|
||||||
|
crypto.index = crypto.index.tz_localize("UTC")
|
||||||
|
# equity sleeve (giorni di borsa)
|
||||||
|
eq = gtaa(target_vol=0.12).dropna()
|
||||||
|
# allinea: compounda crypto sui giorni di borsa dell'equity
|
||||||
|
grid = eq.index[eq.index >= crypto.index[0]]
|
||||||
|
cr = compound_to_grid(crypto, grid)
|
||||||
|
J = pd.concat({"crypto": cr, "equity": eq.reindex(cr.index)}, axis=1).dropna()
|
||||||
|
print(f" finestra comune {J.index[0].date()}..{J.index[-1].date()} ({len(J)} giorni di borsa)\n")
|
||||||
|
|
||||||
|
c, e = J["crypto"], J["equity"]
|
||||||
|
print(" --- STANDALONE (sulla finestra comune) ---")
|
||||||
|
for nm, r in (("crypto TP01+XS01+VRP01", c), ("equity GTAA vt12", e)):
|
||||||
|
print(f" {nm:24} Sh {_sh(r):>5.2f} CAGR {_cagr(r.values,r.index)*100:>5.1f}% volAnn {_ann_vol(r)*100:>4.1f}% maxDD {_dd(r.values)*100:>4.0f}%")
|
||||||
|
print(f"\n --- CORRELAZIONE crypto <-> equity = {c.corr(e):+.3f} (bassa = diversifica) ---")
|
||||||
|
|
||||||
|
print("\n --- BLEND (capitale) ---")
|
||||||
|
print(f" {'mix':18} {'Sharpe':>7} {'CAGR%':>6} {'volAnn%':>7} {'maxDD%':>6}")
|
||||||
|
for wc in (1.0, 0.75, 0.5, 0.25, 0.0):
|
||||||
|
b = wc * c + (1 - wc) * e
|
||||||
|
print(f" crypto {int(wc*100):>3}/{int((1-wc)*100):<3} eq {_sh(b):>7.2f} {_cagr(b.values,b.index)*100:>6.1f} {_ann_vol(b)*100:>7.1f} {_dd(b.values)*100:>6.0f}")
|
||||||
|
|
||||||
|
# risk-parity (peso inverso alla vol) — il blend "giusto" quando le vol differiscono
|
||||||
|
vc, ve = _ann_vol(c), _ann_vol(e)
|
||||||
|
wc_rp = (1/vc) / (1/vc + 1/ve)
|
||||||
|
b_rp = wc_rp * c + (1 - wc_rp) * e
|
||||||
|
print(f"\n --- RISK-PARITY (inv-vol: crypto {wc_rp*100:.0f}% / eq {(1-wc_rp)*100:.0f}%) ---")
|
||||||
|
print(f" Sharpe {_sh(b_rp):.2f} CAGR {_cagr(b_rp.values,b_rp.index)*100:.1f}% volAnn {_ann_vol(b_rp)*100:.1f}% maxDD {_dd(b_rp.values)*100:.0f}%")
|
||||||
|
|
||||||
|
# verdetto: il blender batte il migliore dei due?
|
||||||
|
best_solo = max(_sh(c), _sh(e))
|
||||||
|
best_blend = max(_sh(0.5*c+0.5*e), _sh(b_rp))
|
||||||
|
print(f"\n --- VERDETTO ---")
|
||||||
|
print(f" miglior standalone Sharpe = {best_solo:.2f} | miglior blend Sharpe = {best_blend:.2f} "
|
||||||
|
f"-> {'DIVERSIFICA (blend > solo)' if best_blend > best_solo + 0.03 else 'nessun guadagno netto'}")
|
||||||
|
print(f" (nota: portafoglio cross-venue Deribit+IB; finestra crypto corta ~{len(J)//252}y)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""EQ-GTAA01 — Trend difensivo MULTI-ASSET (GTAA) sull'universo ETF.
|
||||||
|
|
||||||
|
EQ-TREND01 ha mostrato che il trend long-flat su SPY taglia il DD (analogo TP01). La diversificazione
|
||||||
|
delle SORGENTI di trend (azioni US/tech/small + bond + oro + high-yield) di solito migliora il
|
||||||
|
rischio-aggiustato del trend mono-asset. Qui: ogni asset gestito col proprio trend long-flat
|
||||||
|
(TSMOM multi-orizzonte), equal-weight tra gli asset DISPONIBILI (la quota "off" o assente -> cash).
|
||||||
|
|
||||||
|
DATI: cache eqlib (ADJUSTED, nessun IB). Start diversi -> outer-join con peso rinormalizzato sugli
|
||||||
|
asset esistenti (come gli sleeve crypto). Finestra lunga: SPY/QQQ/IWM da ~2000; TLT(2016)/GLD(2004)/
|
||||||
|
HYG(2007) entrano dopo. Riporto anche la finestra 6-asset comune (2016+).
|
||||||
|
|
||||||
|
GIUDIZIO: vs SPY buy&hold, vs EW statico (isola il valore del TIMING di trend), vs SPY-trend mono;
|
||||||
|
Sharpe full/pre15/OOS + maxDD + plateau. Causale, netto fee.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
import eqlib
|
||||||
|
from eqlib import load_eq
|
||||||
|
from eq_sector_momentum import _sh, _cagr, _dd, EQ_HOLDOUT, spy_bh
|
||||||
|
from eq_spy_trend import tsmom_exposure, backtest, _series
|
||||||
|
|
||||||
|
ASSETS = ["SPY", "QQQ", "IWM", "TLT", "GLD", "HYG"]
|
||||||
|
|
||||||
|
|
||||||
|
def gated_returns(sym, horizons=(21, 63, 126, 252), fee_side=0.0002, target_vol=None, lev_cap=1.0):
|
||||||
|
"""Rendimenti netti daily di UN asset gestito col proprio trend long-flat (cash quando off)."""
|
||||||
|
px = _series(sym)
|
||||||
|
ex = tsmom_exposure(px, horizons=horizons, target_vol=target_vol, lev_cap=lev_cap)
|
||||||
|
return backtest(px, ex, fee_side=fee_side)
|
||||||
|
|
||||||
|
|
||||||
|
def gtaa(assets=ASSETS, horizons=(21, 63, 126, 252), fee_side=0.0002, target_vol=None):
|
||||||
|
"""Portafoglio GTAA: media (equal-weight) dei rendimenti trend-gated sugli asset disponibili
|
||||||
|
ogni giorno (outer-join). La quota di asset assenti/in-cash resta in cash."""
|
||||||
|
cols = {a: gated_returns(a, horizons, fee_side, target_vol) for a in assets}
|
||||||
|
R = pd.concat(cols, axis=1).sort_index()
|
||||||
|
return R.mean(axis=1, skipna=True) # EW sugli asset esistenti quel giorno
|
||||||
|
|
||||||
|
|
||||||
|
def ew_buyhold(assets=ASSETS):
|
||||||
|
cols = {a: _series(a).pct_change() for a in assets}
|
||||||
|
return pd.concat(cols, axis=1).sort_index().mean(axis=1, skipna=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(name, r, common=None, bench=None):
|
||||||
|
r = r.dropna() if common is None else r.reindex(common).fillna(0.0)
|
||||||
|
h = r[r.index >= EQ_HOLDOUT]; ii = r[r.index < EQ_HOLDOUT]
|
||||||
|
tim = float((r != 0).mean()) * 100
|
||||||
|
extra = ""
|
||||||
|
if bench is not None:
|
||||||
|
J = pd.concat({"r": r, "b": bench}, axis=1, join="inner").dropna()
|
||||||
|
extra = f" corrSPY {J['r'].corr(J['b']):+.2f}"
|
||||||
|
print(f" {name:26} CAGR {_cagr(r.values, r.index)*100:>5.1f}% Sh {_sh(r):>5.2f} "
|
||||||
|
f"(pre15 {_sh(ii):>5.2f}|OOS {_sh(h):>5.2f}) maxDD {_dd(r.values)*100:>4.0f}% inMkt {tim:>3.0f}%{extra}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 100)
|
||||||
|
print(" EQ-GTAA01 — Trend difensivo MULTI-ASSET (GTAA)")
|
||||||
|
print("=" * 100)
|
||||||
|
spy = spy_bh()
|
||||||
|
g = gtaa() # outer-join, finestra lunga
|
||||||
|
gl = g.dropna()
|
||||||
|
print(f" finestra lunga (outer-join) {gl.index[0].date()}..{gl.index[-1].date()} ({len(gl)}g) OOS {EQ_HOLDOUT.date()}+\n")
|
||||||
|
|
||||||
|
print(" --- BASELINE & confronti (finestra lunga) ---")
|
||||||
|
cl = gl.index
|
||||||
|
_row("SPY buy&hold", spy.reindex(cl).fillna(0))
|
||||||
|
_row("EW statico (no trend)", ew_buyhold().reindex(cl).fillna(0), bench=spy.reindex(cl).fillna(0))
|
||||||
|
_row("SPY-trend mono (TREND01)", backtest(_series("SPY"), tsmom_exposure(_series("SPY"))).reindex(cl).fillna(0), bench=spy.reindex(cl).fillna(0))
|
||||||
|
print("\n --- GTAA (multi-asset trend) ---")
|
||||||
|
_row("GTAA lf", gl, bench=spy.reindex(cl).fillna(0))
|
||||||
|
_row("GTAA lf vt12%", gtaa(target_vol=0.12).reindex(cl).fillna(0), bench=spy.reindex(cl).fillna(0))
|
||||||
|
|
||||||
|
# finestra 6-asset comune (tutti gli ETF esistono): 2016+
|
||||||
|
tlt0 = _series("TLT").index[0]
|
||||||
|
c6 = gl.index[gl.index >= tlt0]
|
||||||
|
print(f"\n --- finestra 6-asset comune ({c6[0].date()}+) ---")
|
||||||
|
_row("SPY buy&hold (6a win)", spy.reindex(c6).fillna(0))
|
||||||
|
_row("GTAA lf (6a win)", g.reindex(c6).fillna(0), bench=spy.reindex(c6).fillna(0))
|
||||||
|
_row("GTAA lf vt12 (6a win)", gtaa(target_vol=0.12).reindex(c6).fillna(0), bench=spy.reindex(c6).fillna(0))
|
||||||
|
|
||||||
|
# MARGINALE vs SPY
|
||||||
|
print("\n --- MARGINALE vs SPY (GTAA lf, finestra lunga) ---")
|
||||||
|
J = pd.concat({"spy": spy, "c": gl}, axis=1, join="inner").dropna(); JH = J[J.index >= EQ_HOLDOUT]
|
||||||
|
print(f" corr full {J['spy'].corr(J['c']):+.3f} | OOS {JH['spy'].corr(JH['c']):+.3f}")
|
||||||
|
for wt in (0.5, 1.0):
|
||||||
|
bf = _sh((1-wt)*J['spy']+wt*J['c'])-_sh(J['spy']); bh = _sh((1-wt)*JH['spy']+wt*JH['c'])-_sh(JH['spy'])
|
||||||
|
lbl = "100% GTAA" if wt == 1.0 else "50/50 SPY/GTAA"
|
||||||
|
print(f" {lbl:16}: uplift Sharpe FULL {bf:+.3f} OOS {bh:+.3f}")
|
||||||
|
print(" DD nei bear (GTAA vs SPY):")
|
||||||
|
for lo, hi, lbl in [("2000-03-01","2002-12-31","dot-com"),("2007-10-01","2009-06-30","GFC"),
|
||||||
|
("2020-02-01","2020-04-30","COVID"),("2022-01-01","2022-12-31","2022")]:
|
||||||
|
seg=lambda s: _dd(s.reindex(cl).fillna(0)[(cl>=pd.Timestamp(lo,tz='UTC'))&(cl<=pd.Timestamp(hi,tz='UTC'))].values)*100
|
||||||
|
print(f" {lbl:8} GTAA {seg(gl):.0f}% | SPY {seg(spy):.0f}%")
|
||||||
|
|
||||||
|
print("\n --- PLATEAU (Sharpe FULL/pre15/OOS, DD, CAGR) GTAA lf, finestra lunga ---")
|
||||||
|
print(f" {'horizons':22} {'FULL':>6} {'pre15':>6} {'OOS':>6} {'DD%':>5} {'CAGR%':>6}")
|
||||||
|
for hz in [(63,126,252),(21,63,126,252),(126,252),(252,)]:
|
||||||
|
r = gtaa(horizons=hz).reindex(cl).fillna(0); h=r[r.index>=EQ_HOLDOUT]; ii=r[r.index<EQ_HOLDOUT]
|
||||||
|
print(f" {'x'.join(map(str,hz)):22} {_sh(r):>6.2f} {_sh(ii):>6.2f} {_sh(h):>6.2f} {_dd(r.values)*100:>5.0f} {_cagr(r.values,r.index)*100:>6.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""EQ-MOM01 — Momentum cross-sectional settoriale (SPDR), backtest onesto.
|
||||||
|
|
||||||
|
Prima ricerca del fronte equity (branch research/equities-ib). L'edge "noioso e robusto" piu'
|
||||||
|
plausibile in un mercato efficiente: ruotare nei settori a momentum forte. Domanda chiave (come nel
|
||||||
|
crypto col soffitto TP01): NON "fa soldi?" (un long-only equity cavalca il toro) ma **batte/ADDS a
|
||||||
|
SPY buy&hold?** — il baseline vero in equity. Anche vs equal-weight 9 settori (isola il timing del
|
||||||
|
momentum dal tilt equal-weight).
|
||||||
|
|
||||||
|
DATI: cache su disco eq_*.parquet (ADJUSTED div+split), via eqlib (nessun IB). 9 settori CLASSICI
|
||||||
|
dal 1998 (27.5y) per il backtest lungo; 11 settori (2018+) come robustezza.
|
||||||
|
|
||||||
|
COSTRUZIONE (causale): ogni REB giorni, momentum = blend di lookback [63,126,252]g con SKIP recente
|
||||||
|
(12-1 classico), z-score cross-sectional mediato. long-only: full-invested nei top-k (confronto
|
||||||
|
like-for-like con SPY). long-short: dollar-neutral top-k vs bottom-k. Posizione decisa a <= i-1,
|
||||||
|
tenuta da i (W[:-1]*dret[1:]). Netto fee sul turnover. Opz. vol-target.
|
||||||
|
|
||||||
|
GIUDIZIO: standalone (FULL / pre-2015 / hold-out 2015+ / per-anno, CAGR, Sharpe, maxDD) vs SPY e
|
||||||
|
EW-settori; marginale vs SPY (corr, uplift blend full+hold, edge in-sample, persistenza multi-cut);
|
||||||
|
plateau su lookback/k/reb/skip; sweep fee.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
import eqlib
|
||||||
|
from eqlib import panel, load_eq, SECTORS_CLASSIC, SECTORS
|
||||||
|
|
||||||
|
ANN = np.sqrt(252.0)
|
||||||
|
EQ_HOLDOUT = pd.Timestamp("2015-01-01", tz="UTC") # OOS lungo: ultimi ~11 anni (post-GFC, dove il momentum e' decaduto)
|
||||||
|
|
||||||
|
|
||||||
|
def _sh(r):
|
||||||
|
r = np.asarray(pd.Series(r).dropna(), float)
|
||||||
|
return float(np.mean(r) / np.std(r) * ANN) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _cagr(r, idx):
|
||||||
|
r = np.asarray(r, float); yrs = (idx[-1] - idx[0]).days / 365.25
|
||||||
|
return float(np.prod(1 + r) ** (1 / yrs) - 1) if yrs > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _dd(r):
|
||||||
|
eq = np.cumprod(1 + np.asarray(r, float)); pk = np.maximum.accumulate(eq)
|
||||||
|
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def momentum(universe=tuple(SECTORS_CLASSIC), lookbacks=(63, 126, 252), k=3, reb=21,
|
||||||
|
skip=21, mode="long", target_vol=None, fee_side=0.0002):
|
||||||
|
"""Serie netta daily del book momentum settoriale. mode='long' (top-k full-invested) o
|
||||||
|
'ls' (dollar-neutral top-k vs bottom-k)."""
|
||||||
|
P = panel(universe, how="inner")
|
||||||
|
idx = P.index; px = P.values; n, A = px.shape
|
||||||
|
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
|
||||||
|
mlb = max(lookbacks) + skip
|
||||||
|
W = np.zeros((n, A)); w = np.zeros(A)
|
||||||
|
for i in range(n):
|
||||||
|
if i >= mlb and i % reb == 0:
|
||||||
|
score = np.zeros(A); cnt = 0
|
||||||
|
for Lb in lookbacks:
|
||||||
|
a, b = i - skip - Lb, i - skip
|
||||||
|
rL = px[b] / px[a] - 1.0
|
||||||
|
sd = rL.std()
|
||||||
|
if sd > 0:
|
||||||
|
score += (rL - rL.mean()) / sd; cnt += 1
|
||||||
|
if cnt:
|
||||||
|
score /= cnt
|
||||||
|
order = np.argsort(score)
|
||||||
|
w = np.zeros(A)
|
||||||
|
if mode == "long":
|
||||||
|
w[order[-k:]] = 1.0 / k # full-invested nei top-k
|
||||||
|
else:
|
||||||
|
w[order[-k:]] = 0.5 / k; w[order[:k]] = -0.5 / k
|
||||||
|
W[i] = w
|
||||||
|
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
|
||||||
|
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum(); turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
|
||||||
|
net = gross - turn * fee_side
|
||||||
|
s = pd.Series(net, index=idx)
|
||||||
|
if target_vol:
|
||||||
|
rv = s.rolling(63, min_periods=20).std().shift(1) * ANN
|
||||||
|
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||||
|
s = pd.Series(s.values * scale, index=idx)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def spy_bh():
|
||||||
|
d = load_eq("SPY")["close"].astype(float)
|
||||||
|
return pd.Series(d.values[1:] / d.values[:-1] - 1.0, index=d.index[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def ew_sectors_bh(universe=tuple(SECTORS_CLASSIC)):
|
||||||
|
P = panel(universe, how="inner"); dret = P.pct_change().dropna()
|
||||||
|
return dret.mean(axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _line(name, r, idx=None, bench=None):
|
||||||
|
idx = idx if idx is not None else r.index
|
||||||
|
r = r.reindex(idx).fillna(0.0) if hasattr(r, "reindex") else pd.Series(r, index=idx)
|
||||||
|
h = r[r.index >= EQ_HOLDOUT]; isamp = r[r.index < EQ_HOLDOUT]
|
||||||
|
extra = ""
|
||||||
|
if bench is not None:
|
||||||
|
J = pd.concat({"r": r, "b": bench}, axis=1, join="inner").dropna()
|
||||||
|
extra = f" corr_SPY {J['r'].corr(J['b']):+.2f}"
|
||||||
|
print(f" {name:26} CAGR {_cagr(r.values, r.index)*100:>5.1f}% Sh {_sh(r):>5.2f} "
|
||||||
|
f"(pre15 {_sh(isamp):>5.2f} | OOS15+ {_sh(h):>5.2f}) maxDD {_dd(r.values)*100:>4.0f}%{extra}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 100)
|
||||||
|
print(" EQ-MOM01 — Momentum cross-sectional settoriale (9 SPDR classici, 1998+)")
|
||||||
|
print("=" * 100)
|
||||||
|
spy = spy_bh(); ew = ew_sectors_bh()
|
||||||
|
base = momentum() # long, lb[63,126,252], k=3, reb21, skip21
|
||||||
|
common = base.index
|
||||||
|
print(f" periodo {common[0].date()}..{common[-1].date()} ({len(common)}g) hold-out OOS = {EQ_HOLDOUT.date()}+\n")
|
||||||
|
|
||||||
|
print(" --- BASELINE da battere ---")
|
||||||
|
_line("SPY buy&hold", spy, common)
|
||||||
|
_line("EW 9 settori buy&hold", ew, common, bench=spy)
|
||||||
|
print("\n --- EQ-MOM01 ---")
|
||||||
|
_line("MOM long top-3", base, common, bench=spy)
|
||||||
|
_line("MOM long top-3 vt15%", momentum(target_vol=0.15), common, bench=spy)
|
||||||
|
_line("MOM long-short top-3", momentum(mode="ls"), common, bench=spy)
|
||||||
|
|
||||||
|
# MARGINALE vs SPY (il test che conta in equity)
|
||||||
|
print("\n --- MARGINALE vs SPY buy&hold (aggiunge al baseline?) ---")
|
||||||
|
J = pd.concat({"spy": spy, "c": base}, axis=1, join="inner").dropna()
|
||||||
|
JH = J[J.index >= EQ_HOLDOUT]
|
||||||
|
print(f" corr(MOM,SPY) full {J['spy'].corr(J['c']):+.3f} | OOS {JH['spy'].corr(JH['c']):+.3f}")
|
||||||
|
for wt in (0.25, 0.5):
|
||||||
|
bf = _sh((1-wt)*J["spy"]+wt*J["c"]) - _sh(J["spy"])
|
||||||
|
bh = _sh((1-wt)*JH["spy"]+wt*JH["c"]) - _sh(JH["spy"])
|
||||||
|
print(f" blend {int((1-wt)*100)}/{int(wt*100)} SPY/MOM: uplift Sharpe FULL {bf:+.3f} OOS {bh:+.3f}")
|
||||||
|
# per-decade (multi-cut onesto)
|
||||||
|
print(" Sharpe MOM per blocco: ", {f"{y}s": round(_sh(base[(base.index.year>=y)&(base.index.year<y+5)]),2)
|
||||||
|
for y in (1999,2004,2009,2014,2019,2024)})
|
||||||
|
|
||||||
|
# PLATEAU
|
||||||
|
print("\n --- PLATEAU (Sharpe FULL / pre15 / OOS15+) long-only ---")
|
||||||
|
print(f" {'cfg':24} {'FULL':>6} {'pre15':>6} {'OOS':>6} {'CAGR%':>6} {'DD%':>5}")
|
||||||
|
for lbs in [(126,), (63,126,252), (252,)]:
|
||||||
|
for k in (2, 3, 4):
|
||||||
|
for reb in (21,):
|
||||||
|
s = momentum(lookbacks=lbs, k=k, reb=reb)
|
||||||
|
tag = f"lb{'-'.join(map(str,lbs))} k{k}"
|
||||||
|
h=s[s.index>=EQ_HOLDOUT]; ii=s[s.index<EQ_HOLDOUT]
|
||||||
|
print(f" {tag:24} {_sh(s):>6.2f} {_sh(ii):>6.2f} {_sh(h):>6.2f} {_cagr(s.values,s.index)*100:>6.1f} {_dd(s.values)*100:>5.0f}")
|
||||||
|
|
||||||
|
# sweep fee + robustezza 11 settori (2018+)
|
||||||
|
print("\n --- ROBUSTEZZA ---")
|
||||||
|
for fee in (0.0, 0.0002, 0.0005, 0.001):
|
||||||
|
s = momentum(fee_side=fee); print(f" fee {fee*100:.2f}%/lato: Sh FULL {_sh(s):.2f} OOS {_sh(s[s.index>=EQ_HOLDOUT]):.2f}")
|
||||||
|
s11 = momentum(universe=tuple(SECTORS)); spy11 = spy.reindex(s11.index)
|
||||||
|
print(f" 11 settori (2018+): MOM Sh {_sh(s11):.2f} vs SPY {_sh(spy11):.2f} (periodo {s11.index[0].date()}+)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""EQ-TREND01 — Trend DIFENSIVO time-series su SPY (analogo equity di TP01).
|
||||||
|
|
||||||
|
Il momentum cross-sectional settoriale e' morto (EQ-MOM01). Ma nel crypto l'unica cosa che ha retto
|
||||||
|
NON era un alpha relative-value: era TP01, un trend DIFENSIVO che taglia il drawdown restando vicino
|
||||||
|
al ritorno. L'equity ha lo stesso buco: SPY buy&hold fa Sharpe ~0.51 ma con maxDD 55% (due bear -50%:
|
||||||
|
2000-02 e 2008-09). Domanda: un trend long-flat su SPY ALZA il Sharpe e DIMEZZA il DD restando
|
||||||
|
investito nei tori? (NON cerchiamo di battere il CAGR — cerchiamo il taglio del rischio, come TP01.)
|
||||||
|
|
||||||
|
DATI: cache su disco eq_spy/eq_tlt (ADJUSTED), via eqlib (nessun IB).
|
||||||
|
COSTRUZIONE (causale, stile TP01): TSMOM multi-orizzonte [21,63,126,252]g (1/3/6/12 mesi); target =
|
||||||
|
frazione di orizzonti in trend-up (0..1, allocazione graduale). Vol-target opz. Posizione decisa a
|
||||||
|
<=i-1, tenuta da i. Netto fee sul turnover. Varianti: long-flat (cash in risk-off), long-bonds
|
||||||
|
(TLT in risk-off, solo dal 2016), e SMA-200 binario (Faber) come riferimento classico.
|
||||||
|
|
||||||
|
GIUDIZIO: vs SPY buy&hold (CAGR/Sharpe full-pre15-OOS15+/maxDD/time-in-market), marginale vs SPY,
|
||||||
|
DD nei due bear storici, plateau (orizzonti/vol-target/cap leva), sweep fee.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
import eqlib
|
||||||
|
from eqlib import load_eq
|
||||||
|
from eq_sector_momentum import _sh, _cagr, _dd, EQ_HOLDOUT, spy_bh
|
||||||
|
|
||||||
|
ANN = np.sqrt(252.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _series(sym):
|
||||||
|
d = load_eq(sym)["close"].astype(float)
|
||||||
|
return pd.Series(d.values, index=d.index)
|
||||||
|
|
||||||
|
|
||||||
|
def tsmom_exposure(close: pd.Series, horizons=(21, 63, 126, 252), target_vol=None,
|
||||||
|
lev_cap=1.0) -> pd.Series:
|
||||||
|
"""Esposizione SPY in [0, lev_cap]: frazione di orizzonti in trend-up, opz. vol-targeted (causale)."""
|
||||||
|
px = close.values; n = len(px); tgt = np.zeros(n)
|
||||||
|
mh = max(horizons)
|
||||||
|
for i in range(mh, n):
|
||||||
|
tgt[i] = np.mean([1.0 if px[i] > px[i - H] else 0.0 for H in horizons])
|
||||||
|
s = pd.Series(tgt, index=close.index)
|
||||||
|
if target_vol:
|
||||||
|
ret = close.pct_change()
|
||||||
|
rv = ret.rolling(63, min_periods=20).std().shift(1) * ANN
|
||||||
|
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, lev_cap / 0.0 if False else 10.0)
|
||||||
|
s = (s * scale).clip(0, lev_cap)
|
||||||
|
else:
|
||||||
|
s = s.clip(0, lev_cap)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def sma_timing(close: pd.Series, win=200) -> pd.Series:
|
||||||
|
"""Faber: long se close > SMA(win), altrimenti flat. Binario {0,1}."""
|
||||||
|
sma = close.rolling(win, min_periods=win // 2).mean()
|
||||||
|
return (close > sma).astype(float)
|
||||||
|
|
||||||
|
|
||||||
|
def backtest(close: pd.Series, exposure: pd.Series, risk_off: pd.Series | None = None,
|
||||||
|
fee_side=0.0002) -> pd.Series:
|
||||||
|
"""Rendimenti netti: held = exposure ritardata di 1 (causale). La quota non in SPY (1-held, se
|
||||||
|
exposure<=1) va in risk_off (es. TLT) o cash (0). Fee sul turnover di SPY."""
|
||||||
|
ret = close.pct_change().fillna(0.0).values
|
||||||
|
exp = exposure.reindex(close.index).fillna(0.0).values
|
||||||
|
held = np.zeros(len(exp)); held[1:] = exp[:-1]
|
||||||
|
net = held * ret
|
||||||
|
if risk_off is not None:
|
||||||
|
ro = risk_off.reindex(close.index).pct_change().fillna(0.0).values
|
||||||
|
cash_w = np.clip(1.0 - held, 0.0, 1.0) # quota fuori da SPY -> bonds
|
||||||
|
net = net + cash_w * ro
|
||||||
|
net = net - fee_side * np.abs(np.diff(held, prepend=0.0))
|
||||||
|
return pd.Series(net, index=close.index)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(name, r, common, bench=None):
|
||||||
|
r = r.reindex(common).fillna(0.0)
|
||||||
|
h = r[r.index >= EQ_HOLDOUT]; ii = r[r.index < EQ_HOLDOUT]
|
||||||
|
tim = float((r != 0).mean()) * 100
|
||||||
|
extra = ""
|
||||||
|
if bench is not None:
|
||||||
|
J = pd.concat({"r": r, "b": bench.reindex(common).fillna(0.0)}, axis=1).dropna()
|
||||||
|
extra = f" corr {J['r'].corr(J['b']):+.2f}"
|
||||||
|
print(f" {name:24} CAGR {_cagr(r.values, r.index)*100:>5.1f}% Sh {_sh(r):>5.2f} "
|
||||||
|
f"(pre15 {_sh(ii):>5.2f}|OOS {_sh(h):>5.2f}) maxDD {_dd(r.values)*100:>4.0f}% inMkt {tim:>3.0f}%{extra}")
|
||||||
|
|
||||||
|
|
||||||
|
def _bear_dd(r, common, lo, hi, label):
|
||||||
|
seg = r.reindex(common).fillna(0.0)
|
||||||
|
seg = seg[(seg.index >= pd.Timestamp(lo, tz="UTC")) & (seg.index <= pd.Timestamp(hi, tz="UTC"))]
|
||||||
|
return f"{label}: {_dd(seg.values)*100:.0f}%"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 100)
|
||||||
|
print(" EQ-TREND01 — Trend DIFENSIVO time-series su SPY (analogo di TP01)")
|
||||||
|
print("=" * 100)
|
||||||
|
spy_px = _series("SPY"); spy = spy_bh()
|
||||||
|
common = spy_px.index[spy_px.index >= spy_px.index[252]] # warmup 1y
|
||||||
|
print(f" periodo {common[0].date()}..{common[-1].date()} ({len(common)}g) OOS = {EQ_HOLDOUT.date()}+\n")
|
||||||
|
|
||||||
|
print(" --- BASELINE ---")
|
||||||
|
_row("SPY buy&hold", spy, common)
|
||||||
|
print("\n --- TREND long-flat (cash in risk-off) ---")
|
||||||
|
_row("SMA-200 (Faber)", backtest(spy_px, sma_timing(spy_px)), common, bench=spy)
|
||||||
|
_row("TSMOM lf cap1.0", backtest(spy_px, tsmom_exposure(spy_px)), common, bench=spy)
|
||||||
|
_row("TSMOM lf vt15 cap1.0", backtest(spy_px, tsmom_exposure(spy_px, target_vol=0.15, lev_cap=1.0)), common, bench=spy)
|
||||||
|
_row("TSMOM lf vt15 cap1.5", backtest(spy_px, tsmom_exposure(spy_px, target_vol=0.15, lev_cap=1.5)), common, bench=spy)
|
||||||
|
|
||||||
|
print("\n --- TREND long-BONDS (TLT in risk-off, solo dove TLT esiste: 2016+) ---")
|
||||||
|
tlt = _series("TLT")
|
||||||
|
cb = spy_px.index[(spy_px.index >= tlt.index[0])]
|
||||||
|
_row("SPY b&h (2016+)", spy.reindex(cb), cb)
|
||||||
|
_row("TSMOM lf+TLT (2016+)", backtest(spy_px, tsmom_exposure(spy_px), risk_off=tlt), cb, bench=spy)
|
||||||
|
|
||||||
|
# MARGINALE vs SPY + DD nei bear
|
||||||
|
base = backtest(spy_px, tsmom_exposure(spy_px))
|
||||||
|
print("\n --- MARGINALE vs SPY (TSMOM lf cap1.0) ---")
|
||||||
|
J = pd.concat({"spy": spy, "c": base}, axis=1, join="inner").dropna(); JH = J[J.index >= EQ_HOLDOUT]
|
||||||
|
print(f" corr full {J['spy'].corr(J['c']):+.3f} | OOS {JH['spy'].corr(JH['c']):+.3f}")
|
||||||
|
for wt in (0.5, 1.0):
|
||||||
|
bf = _sh((1-wt)*J['spy']+wt*J['c']) - _sh(J['spy']); bh = _sh((1-wt)*JH['spy']+wt*JH['c']) - _sh(JH['spy'])
|
||||||
|
lbl = "100% TREND" if wt == 1.0 else f"{int((1-wt)*100)}/{int(wt*100)} SPY/TREND"
|
||||||
|
print(f" {lbl:16}: uplift Sharpe FULL {bf:+.3f} OOS {bh:+.3f}")
|
||||||
|
print(" DD nei bear storici (TSMOM vs SPY):")
|
||||||
|
for lo, hi, lbl in [("2000-03-01","2002-12-31","dot-com"), ("2007-10-01","2009-06-30","GFC"),
|
||||||
|
("2020-02-01","2020-04-30","COVID"), ("2022-01-01","2022-12-31","2022")]:
|
||||||
|
print(f" {lbl:8} TSMOM {_bear_dd(base,common,lo,hi,'')} | SPY {_bear_dd(spy,common,lo,hi,'')}")
|
||||||
|
|
||||||
|
# PLATEAU
|
||||||
|
print("\n --- PLATEAU (Sharpe FULL/pre15/OOS, maxDD, CAGR) long-flat cap1.0 ---")
|
||||||
|
print(f" {'horizons':22} {'FULL':>6} {'pre15':>6} {'OOS':>6} {'DD%':>5} {'CAGR%':>6}")
|
||||||
|
for hz in [(63,126,252),(21,63,126,252),(126,252),(50,200),(200,)]:
|
||||||
|
ex = sma_timing(spy_px, 200) if hz == (200,) else tsmom_exposure(spy_px, horizons=hz)
|
||||||
|
r = backtest(spy_px, ex); h=r[r.index>=EQ_HOLDOUT]; ii=r[r.index<EQ_HOLDOUT]
|
||||||
|
tag = "SMA-200" if hz==(200,) else "x".join(map(str,hz))
|
||||||
|
print(f" {tag:22} {_sh(r):>6.2f} {_sh(ii):>6.2f} {_sh(h):>6.2f} {_dd(r.values)*100:>5.0f} {_cagr(r.values,r.index)*100:>6.1f}")
|
||||||
|
|
||||||
|
print("\n --- SWEEP FEE (TSMOM lf cap1.0) ---")
|
||||||
|
for fee in (0.0, 0.0002, 0.0005, 0.001):
|
||||||
|
r = backtest(spy_px, tsmom_exposure(spy_px), fee_side=fee)
|
||||||
|
print(f" fee {fee*100:.2f}%/lato: Sh {_sh(r):.2f} maxDD {_dd(r.values)*100:.0f}%")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""COMBO DEPLOYABLE — TP01 (Deribit) + GTAA (IB): le DUE gambe realmente eseguibili a basso capitale.
|
||||||
|
|
||||||
|
Il combo crypto-pieno (TP01+XS01+VRP01)+GTAA diversificava (Sharpe 1.6->1.8), ma XS01/VRP01 sono
|
||||||
|
STAT-MODE (non eseguibili a $600). Qui il combo ONESTO/deployable: solo le gambe eseguibili —
|
||||||
|
* TP01: TSMOM difensivo BTC/ETH, long-flat, gia' ARMATO live su Deribit;
|
||||||
|
* GTAA: trend difensivo multi-asset su ETF, eseguibile su IB (frazioni, switch mensile).
|
||||||
|
Domanda: due trend difensivi su mercati diversi, scorrelati, danno un blend migliore di ciascuno?
|
||||||
|
|
||||||
|
ALLINEAMENTO: TP01 e' calendario-giornaliero (crypto 7gg), GTAA giorni di borsa -> compoundo TP01 sul
|
||||||
|
grid dei giorni di borsa (cattura i weekend). Finestra = era TP01 (BTC/ETH dal 2019).
|
||||||
|
Confronto anche col combo crypto-pieno per quantificare il COSTO della deployability.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
from eq_sector_momentum import _sh, _cagr, _dd, EQ_HOLDOUT
|
||||||
|
from eq_gtaa_trend import gtaa
|
||||||
|
from eq_crypto_combo import compound_to_grid, _ann_vol
|
||||||
|
from src.portfolio.sleeves import _tp01_returns, active_sleeves
|
||||||
|
from src.portfolio.portfolio import StrategyPortfolio
|
||||||
|
|
||||||
|
ANN = np.sqrt(252.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _stat(nm, r):
|
||||||
|
print(f" {nm:26} Sh {_sh(r):>5.2f} CAGR {_cagr(r.values,r.index)*100:>5.1f}% "
|
||||||
|
f"volAnn {_ann_vol(r)*100:>4.1f}% maxDD {_dd(r.values)*100:>4.0f}%")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 96)
|
||||||
|
print(" COMBO DEPLOYABLE — TP01 (Deribit) + GTAA (IB)")
|
||||||
|
print("=" * 96)
|
||||||
|
tp01 = _tp01_returns()
|
||||||
|
if tp01.index.tz is None:
|
||||||
|
tp01.index = tp01.index.tz_localize("UTC")
|
||||||
|
eq = gtaa(target_vol=0.12).dropna()
|
||||||
|
grid = eq.index[eq.index >= tp01.index[0]]
|
||||||
|
tp = compound_to_grid(tp01, grid)
|
||||||
|
J = pd.concat({"tp01": tp, "gtaa": eq.reindex(tp.index)}, axis=1).dropna()
|
||||||
|
print(f" finestra comune {J.index[0].date()}..{J.index[-1].date()} ({len(J)} giorni di borsa, ~{len(J)//252}y)\n")
|
||||||
|
t, g = J["tp01"], J["gtaa"]
|
||||||
|
|
||||||
|
print(" --- STANDALONE (finestra comune) ---")
|
||||||
|
_stat("TP01 (crypto, Deribit)", t)
|
||||||
|
_stat("GTAA vt12 (equity, IB)", g)
|
||||||
|
c = t.corr(g)
|
||||||
|
print(f"\n --- CORRELAZIONE TP01 <-> GTAA = {c:+.3f} ---")
|
||||||
|
|
||||||
|
print("\n --- BLEND (capitale) ---")
|
||||||
|
print(f" {'mix':20} {'Sharpe':>7} {'CAGR%':>6} {'volAnn%':>7} {'maxDD%':>6}")
|
||||||
|
best = (-9, None)
|
||||||
|
for wt in (1.0, 0.75, 0.6, 0.5, 0.4, 0.25, 0.0):
|
||||||
|
b = wt * t + (1 - wt) * g
|
||||||
|
if _sh(b) > best[0]:
|
||||||
|
best = (_sh(b), wt)
|
||||||
|
print(f" TP01 {int(wt*100):>3}/{int((1-wt)*100):<3} GTAA {_sh(b):>7.2f} {_cagr(b.values,b.index)*100:>6.1f} {_ann_vol(b)*100:>7.1f} {_dd(b.values)*100:>6.0f}")
|
||||||
|
|
||||||
|
vt, vg = _ann_vol(t), _ann_vol(g)
|
||||||
|
wt_rp = (1/vt) / (1/vt + 1/vg)
|
||||||
|
b_rp = wt_rp * t + (1 - wt_rp) * g
|
||||||
|
print(f"\n --- RISK-PARITY (inv-vol: TP01 {wt_rp*100:.0f}% / GTAA {(1-wt_rp)*100:.0f}%) ---")
|
||||||
|
_stat("risk-parity blend", b_rp)
|
||||||
|
|
||||||
|
# OOS (post-2023, per dare un taglio fuori dai primi anni TP01)
|
||||||
|
cut = pd.Timestamp("2023-01-01", tz="UTC")
|
||||||
|
print(f"\n --- robustezza: blend 50/50 per taglio ---")
|
||||||
|
b50 = 0.5 * t + 0.5 * g
|
||||||
|
for lbl, sub in (("full", b50), (">=2023", b50[b50.index >= cut])):
|
||||||
|
print(f" {lbl:10} Sh {_sh(sub):.2f} maxDD {_dd(sub.values)*100:.0f}%")
|
||||||
|
print(" Sharpe 50/50 per anno:", {y: round(_sh(b50[b50.index.year == y]), 2) for y in sorted(set(b50.index.year))})
|
||||||
|
|
||||||
|
# VERDETTO + costo della deployability
|
||||||
|
best_solo = max(_sh(t), _sh(g))
|
||||||
|
print(f"\n --- VERDETTO ---")
|
||||||
|
print(f" miglior standalone {best_solo:.2f} | blend 50/50 {_sh(b50):.2f} | risk-parity {_sh(b_rp):.2f} | "
|
||||||
|
f"miglior cap-mix {best[0]:.2f} (TP01 {int(best[1]*100)}%)")
|
||||||
|
print(f" -> {'DIVERSIFICA (blend > solo)' if max(_sh(b50),_sh(b_rp)) > best_solo + 0.03 else 'nessun guadagno netto'}")
|
||||||
|
# costo vs combo crypto-pieno
|
||||||
|
full = StrategyPortfolio(active_sleeves()).combined_daily()
|
||||||
|
if full.index.tz is None: full.index = full.index.tz_localize("UTC")
|
||||||
|
fc = compound_to_grid(full, J.index)
|
||||||
|
Jf = pd.concat({"f": fc, "g": g.reindex(fc.index)}, axis=1).dropna()
|
||||||
|
print(f" costo deployability: blend 50/50 con crypto-PIENO Sh {_sh(0.5*Jf['f']+0.5*Jf['g']):.2f} "
|
||||||
|
f"vs deployable {_sh(b50):.2f} (la differenza = cio' che lasciano XS01/VRP01 STAT-MODE)")
|
||||||
|
print(f" (cross-venue Deribit+IB; entrambe switch mensile/basso turnover; frazionabili a $0.5-2k)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""EQLIB — harness di ricerca EQUITY/ETF (branch research/equities-ib).
|
||||||
|
|
||||||
|
Legge la CACHE su disco (data/raw/eq_*.parquet, ADJUSTED_LAST, scritta una volta da
|
||||||
|
fetch_ib_equities.py) — MAI da IB. `lru_cache` -> ogni parquet si legge una sola volta per processo.
|
||||||
|
"Memorizza i dati per non rileggerli ogni volta": la persistenza e' il parquet su disco + questa cache.
|
||||||
|
|
||||||
|
Espone: universi (SECTORS/BROAD), load_eq(sym), panel(universe) allineato, e riusa lo scorer
|
||||||
|
indurito di altlib (_sh, _dd_ret, _to_daily, marginal_vs_tp01) per giudicare i candidati con la
|
||||||
|
stessa disciplina del lato crypto.
|
||||||
|
"""
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
RAW = ROOT / "data" / "raw"
|
||||||
|
|
||||||
|
# 11 SPDR settoriali. I 9 "classici" (sotto SECTORS_CLASSIC) partono 1998; XLRE 2015, XLC 2018.
|
||||||
|
SECTORS = ["XLK", "XLF", "XLE", "XLV", "XLI", "XLP", "XLY", "XLU", "XLB", "XLRE", "XLC"]
|
||||||
|
SECTORS_CLASSIC = ["XLK", "XLF", "XLE", "XLV", "XLI", "XLP", "XLY", "XLU", "XLB"] # storia lunga (1998+)
|
||||||
|
BROAD = ["SPY", "QQQ", "IWM", "TLT", "GLD", "HYG"]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=64)
|
||||||
|
def load_eq(sym: str) -> pd.DataFrame:
|
||||||
|
"""OHLCV aggiustato (dividendi+split) per `sym`, indicizzato datetime UTC. Cache su disco -> RAM."""
|
||||||
|
p = RAW / f"eq_{sym.lower()}_1d.parquet"
|
||||||
|
if not p.exists():
|
||||||
|
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)
|
||||||
|
return d[["open", "high", "low", "close", "volume"]]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=16)
|
||||||
|
def _close_panel(universe: tuple) -> pd.DataFrame:
|
||||||
|
cols = {s: load_eq(s)["close"].astype(float) for s in universe}
|
||||||
|
return pd.concat(cols, axis=1).sort_index()
|
||||||
|
|
||||||
|
|
||||||
|
def panel(universe=tuple(SECTORS), how: str = "inner") -> pd.DataFrame:
|
||||||
|
"""Prezzi close aggiustati [date x asset]. how='inner' = date comuni a TUTTI (start = ETF piu' giovane);
|
||||||
|
'outer' = unione (NaN dove un ETF non esiste ancora)."""
|
||||||
|
P = _close_panel(tuple(universe))
|
||||||
|
return P.dropna(how="any") if how == "inner" else P
|
||||||
|
|
||||||
|
|
||||||
|
def describe(universe=None):
|
||||||
|
universe = universe or (SECTORS + BROAD)
|
||||||
|
print(f" {'sym':6} {'barre':>6} {'da':>11} {'a':>11} {'anni':>5}")
|
||||||
|
for s in universe:
|
||||||
|
try:
|
||||||
|
d = load_eq(s)
|
||||||
|
print(f" {s:6} {len(d):>6} {str(d.index[0].date()):>11} {str(d.index[-1].date()):>11} "
|
||||||
|
f"{(d.index[-1]-d.index[0]).days/365.25:>5.1f}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f" {s:6} (assente)")
|
||||||
|
Pc = panel(SECTORS_CLASSIC); Pa = panel(SECTORS)
|
||||||
|
print(f"\n panel 9 settori CLASSICI: {Pc.shape[1]}x{len(Pc)} start comune {Pc.index[0].date()}")
|
||||||
|
print(f" panel 11 settori : {Pa.shape[1]}x{len(Pa)} start comune {Pa.index[0].date()}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("=" * 70); print(" EQLIB — cache equity su disco (nessun IB)"); print("=" * 70)
|
||||||
|
describe()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""DEEP-DIVE: crypto[sessione USA] -> indice EUROPEO[overnight successivo]. Reale o artefatto?
|
||||||
|
|
||||||
|
Lo sweep ha trovato un forte segnale: crypto[T-8h->T] (T~00:00 UTC, fine sessione USA) predice il
|
||||||
|
future europeo (ESTX50/DAX)[T->T+6h] con t_crypto incrementale ~8, Sharpe 2.5, 3/3 anni. Verifiche:
|
||||||
|
(1) ROBUSTEZZA su T (ora entrata): un effetto vero non e' a coltello su una sola ora.
|
||||||
|
(2) vs SEMPRE-LONG: l'overnight ha un drift? il crypto AGGIUNGE oltre il long incondizionato?
|
||||||
|
(3) GAP 1-2h tra fine-segnale e inizio-cattura: se sopravvive, niente contaminazione di bordo.
|
||||||
|
(4) vs FUTURE-OWN: il crypto batte il momentum proprio del future (gia' nel t incrementale).
|
||||||
|
Dati: cache (fut europei + crypto 1h). sqrt(252), net 2bps.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]; RAW = ROOT / "data" / "raw"
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
from crypto_lead_harness import crypto_hourly, at
|
||||||
|
COST = 0.0002
|
||||||
|
|
||||||
|
|
||||||
|
def fut_hourly(sym):
|
||||||
|
d = pd.read_parquet(RAW / f"fut_{sym.lower()}_1h.parquet")
|
||||||
|
return pd.Series(d["close"].astype(float).values, index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
|
||||||
|
|
||||||
|
|
||||||
|
def _sh(r):
|
||||||
|
r = np.asarray(r, float); r = r[np.isfinite(r)]
|
||||||
|
return float(np.mean(r) / np.std(r) * np.sqrt(252)) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def series(fut, bc, T, S=8, H=6, gap=0):
|
||||||
|
days = pd.date_range(fut.index[0].normalize(), fut.index[-1].normalize(), freq="D", tz="UTC")
|
||||||
|
rows = []
|
||||||
|
for D in days:
|
||||||
|
te = D + pd.Timedelta(hours=T)
|
||||||
|
f0, f1 = at(fut, te - pd.Timedelta(hours=S)), at(fut, te)
|
||||||
|
fcs, fce = at(fut, te + pd.Timedelta(hours=gap)), at(fut, te + pd.Timedelta(hours=gap + H))
|
||||||
|
c0, c1 = at(bc, te - pd.Timedelta(hours=S)), at(bc, te)
|
||||||
|
if not all(np.isfinite(v) and v > 0 for v in (f0, f1, fcs, fce, c0, c1)):
|
||||||
|
continue
|
||||||
|
rows.append((D, c1/c0 - 1, f1/f0 - 1, fce/fcs - 1))
|
||||||
|
Dd = pd.DataFrame(rows, columns=["d", "csig", "fctrl", "cap"]).set_index("d")
|
||||||
|
return Dd[Dd["cap"].abs() > 1e-9]
|
||||||
|
|
||||||
|
|
||||||
|
def stats(Dd):
|
||||||
|
x, ctrl, y = Dd["csig"].values, Dd["fctrl"].values, Dd["cap"].values
|
||||||
|
def z(a):
|
||||||
|
s = a.std(); return (a - a.mean()) / s if s > 0 else a * 0
|
||||||
|
X = np.column_stack([np.ones(len(y)), z(x), z(ctrl)])
|
||||||
|
beta, *_ = np.linalg.lstsq(X, z(y), rcond=None)
|
||||||
|
resid = z(y) - X @ beta; dof = max(len(y) - 3, 1)
|
||||||
|
se = np.sqrt(np.sum(resid**2)/dof * np.diag(np.linalg.inv(X.T@X)))
|
||||||
|
t_c = float(beta[1]/se[1]) if se[1] > 0 else 0.0
|
||||||
|
crypto = np.sign(x)*y - COST
|
||||||
|
futown = np.sign(ctrl)*y - COST
|
||||||
|
always = y - COST
|
||||||
|
yrs = Dd.index.year.values
|
||||||
|
py = {int(v): round(_sh(crypto[yrs==v]),2) for v in sorted(set(yrs)) if (yrs==v).sum()>=30}
|
||||||
|
return dict(n=len(Dd), t_crypto=round(t_c,2), sh_crypto=round(_sh(crypto),2),
|
||||||
|
sh_futown=round(_sh(futown),2), sh_always=round(_sh(always),2),
|
||||||
|
ann_crypto=round(float(np.nanmean(crypto)*252*100),1), per_year=py)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("="*96); print(" DEEP-DIVE crypto -> indice EUROPEO overnight (reale o artefatto?)"); print("="*96)
|
||||||
|
bcs = {l: crypto_hourly(l) for l in ("BTC","ETH")}
|
||||||
|
for sym in ("ESTX50","DAX"):
|
||||||
|
fut = fut_hourly(sym)
|
||||||
|
print(f"\n ===== {sym} =====")
|
||||||
|
print(f" (1) ROBUSTEZZA su T (lead=BTC, S=8h H=6h gap=0): t_crypto | Sh crypto vs futown vs always-long")
|
||||||
|
for T in (20,21,22,23,0,1,2,3,4):
|
||||||
|
s = stats(series(fut, bcs["BTC"], T))
|
||||||
|
print(f" T={T:>2}h: n={s['n']} t {s['t_crypto']:+.1f} | crypto {s['sh_crypto']:+.2f} futown {s['sh_futown']:+.2f} always {s['sh_always']:+.2f} ann {s['ann_crypto']:+.1f}% {s['per_year']}")
|
||||||
|
print(f" (3) GAP test (T=0h, cattura inizia +1h e +2h dopo il segnale):")
|
||||||
|
for g in (0,1,2):
|
||||||
|
s = stats(series(fut, bcs["BTC"], 0, gap=g))
|
||||||
|
print(f" gap={g}h: t {s['t_crypto']:+.1f} | crypto {s['sh_crypto']:+.2f} always {s['sh_always']:+.2f}")
|
||||||
|
print(f" (lead ETH, T=0h): ", end="")
|
||||||
|
s = stats(series(fut, bcs["ETH"], 0)); print(f"t {s['t_crypto']:+.1f} crypto {s['sh_crypto']:+.2f} always {s['sh_always']:+.2f} {s['per_year']}")
|
||||||
|
print("\n LETTURA: se 'crypto' >> 'always' E >> 'futown' E regge su T e col gap -> lead REALE (crypto")
|
||||||
|
print(" anticipa il catch-up europeo). Se crypto ~ always -> e' solo overnight-drift europeo.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""FETCH + CERTIFY funding rate Hyperliquid (API pubblica, tokenless) — per ricerca CARRY cross-sectional.
|
||||||
|
|
||||||
|
CONTESTO (2026-06-22, onda "nuova ricerca mirata"). Le due grandi ondate (sweep 104-ipotesi +
|
||||||
|
ortho relative-value) hanno esaurito gli angoli DIREZIONALI e RELATIVE-VALUE sul *prezzo* BTC/ETH.
|
||||||
|
L'unico meccanismo con una fonte di ritorno DIVERSA non ancora testato su dati certi e' il CARRY da
|
||||||
|
funding (incassare il cashflow perp, delta-neutral). Scan di fattibilita':
|
||||||
|
* funding price-clock sul feed Deribit certificato -> gia' testato (agent_03 intraday) = FAIL.
|
||||||
|
* funding carry su Deribit (dove eseguiamo) -> ccxt fetch_funding_rate_history = 0 righe (bloccato).
|
||||||
|
* funding carry su Hyperliquid -> API pubblica /info {"type":"fundingHistory"} = DISPONIBILE,
|
||||||
|
cadenza ORARIA, tokenless, serie native dal 2023-05-12. HL e' gia' l'universo certificato di XS01.
|
||||||
|
|
||||||
|
DISCIPLINA (lezione v2.0.0): nessuna fiducia nel dato finche' non e' certificato. Qui certifichiamo:
|
||||||
|
(1) cadenza ~1h coerente, (2) gap interni, (3) copertura (giorni nativi reali per coin),
|
||||||
|
(4) plausibilita' magnitudine (|funding orario| tipico < ~0.06%/h = cap HL; outlier flaggati),
|
||||||
|
(5) il funding e' un CASHFLOW, non un prezzo -> niente cross-venue OHLC; il sanity check e'
|
||||||
|
che il funding medio sia ~positivo e piccolo (premio long-pays-short tipico dei perp crypto).
|
||||||
|
|
||||||
|
Universo = i 19 major di XS01 (quelli che la strategia live userebbe). Output:
|
||||||
|
data/raw/hlfund_<sym>_1h.parquet (namespace dedicato 'hlfund', NON tocca hl_<sym>_1d di XS01).
|
||||||
|
"""
|
||||||
|
import sys, time, datetime as dt
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd, requests
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
RAW = ROOT / "data" / "raw"
|
||||||
|
RAW.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 19 major di XS01 (CLAUDE.md)
|
||||||
|
UNIVERSE = ["BTC","ETH","SOL","BNB","XRP","DOGE","AVAX","LINK","LTC","ADA",
|
||||||
|
"ARB","OP","SUI","APT","INJ","TIA","SEI","NEAR","AAVE"]
|
||||||
|
|
||||||
|
HL_INFO = "https://api.hyperliquid.xyz/info"
|
||||||
|
START = int(dt.datetime(2023, 1, 1, tzinfo=dt.timezone.utc).timestamp() * 1000)
|
||||||
|
HOUR_MS = 3600 * 1000
|
||||||
|
|
||||||
|
|
||||||
|
def _post(payload, max_retry=6):
|
||||||
|
"""POST con backoff esponenziale su 429/5xx (l'API pubblica HL throttla)."""
|
||||||
|
delay = 1.0
|
||||||
|
for attempt in range(max_retry):
|
||||||
|
r = requests.post(HL_INFO, json=payload, timeout=30)
|
||||||
|
if r.status_code == 429 or r.status_code >= 500:
|
||||||
|
time.sleep(delay)
|
||||||
|
delay = min(delay * 2, 20) # 1,2,4,8,16,20
|
||||||
|
continue
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_funding(coin: str) -> pd.DataFrame:
|
||||||
|
"""Pagina fundingHistory (max 500/req, orario) avanzando startTime fino a oggi."""
|
||||||
|
rows, start = [], START
|
||||||
|
seen = set()
|
||||||
|
while True:
|
||||||
|
d = _post({"type": "fundingHistory", "coin": coin, "startTime": start})
|
||||||
|
if not d:
|
||||||
|
break
|
||||||
|
new = [x for x in d if x["time"] not in seen]
|
||||||
|
for x in new:
|
||||||
|
seen.add(x["time"])
|
||||||
|
rows.append((x["time"], float(x["fundingRate"]), float(x.get("premium", "nan"))))
|
||||||
|
last = d[-1]["time"]
|
||||||
|
if len(d) < 500: # ultima pagina
|
||||||
|
break
|
||||||
|
nxt = last + 1
|
||||||
|
if nxt <= start: # niente progresso -> stop
|
||||||
|
break
|
||||||
|
start = nxt
|
||||||
|
time.sleep(0.35) # gentile con l'API pubblica
|
||||||
|
if not rows:
|
||||||
|
return pd.DataFrame(columns=["ts", "funding", "premium"]).set_index("ts")
|
||||||
|
df = pd.DataFrame(rows, columns=["ts", "funding", "premium"]).drop_duplicates("ts").sort_values("ts")
|
||||||
|
df["ts"] = pd.to_datetime(df["ts"], unit="ms", utc=True)
|
||||||
|
return df.set_index("ts")
|
||||||
|
|
||||||
|
|
||||||
|
def certify(coin: str, df: pd.DataFrame) -> dict:
|
||||||
|
if df.empty:
|
||||||
|
return {"coin": coin, "n": 0, "status": "VUOTO"}
|
||||||
|
idx = df.index
|
||||||
|
span_days = (idx[-1] - idx[0]).total_seconds() / 86400
|
||||||
|
# cadenza: differenze in ore
|
||||||
|
deltas_h = np.diff(idx.view("int64")) / 1e9 / 3600
|
||||||
|
median_dt = float(np.median(deltas_h)) if len(deltas_h) else float("nan")
|
||||||
|
gaps = int((deltas_h > 1.5).sum()) # buchi > 1.5h
|
||||||
|
expected = int(round(span_days * 24)) + 1
|
||||||
|
coverage = len(df) / expected if expected else float("nan")
|
||||||
|
f = df["funding"].values
|
||||||
|
# statistiche funding (orario)
|
||||||
|
ann = float(np.nanmean(f)) * 24 * 365 # funding annualizzato (carry teorico per chi paga)
|
||||||
|
cap_hits = int((np.abs(f) > 0.0006).sum()) # cap HL ~0.06%/h (4%/8h clamp); fuori = sospetto
|
||||||
|
status = "OK"
|
||||||
|
if coverage < 0.97 or gaps > 50:
|
||||||
|
status = "GAP"
|
||||||
|
if span_days < 365:
|
||||||
|
status = "corto<365g"
|
||||||
|
return {"coin": coin, "n": len(df), "primo": idx[0].date(), "ultimo": idx[-1].date(),
|
||||||
|
"giorni": round(span_days), "cad_h": round(median_dt, 3), "gap>1.5h": gaps,
|
||||||
|
"cover%": round(coverage * 100, 1), "fund_med_bps": round(float(np.nanmedian(f)) * 1e4, 4),
|
||||||
|
"fund_ann%": round(ann * 100, 1), "cap_hit": cap_hits, "status": status}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 100)
|
||||||
|
print(" FETCH + CERTIFY funding Hyperliquid (orario, tokenless) — 19 major XS01 -> data/raw/hlfund_*")
|
||||||
|
print("=" * 100)
|
||||||
|
rep = []
|
||||||
|
for sym in UNIVERSE:
|
||||||
|
try:
|
||||||
|
df = fetch_funding(sym)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {sym:5} ERR {repr(e)[:80]}")
|
||||||
|
rep.append({"coin": sym, "n": 0, "status": "ERR"})
|
||||||
|
continue
|
||||||
|
c = certify(sym, df)
|
||||||
|
rep.append(c)
|
||||||
|
if c.get("n", 0) > 0:
|
||||||
|
out = RAW / f"hlfund_{sym.lower()}_1h.parquet"
|
||||||
|
df.to_parquet(out)
|
||||||
|
print(f" {sym:5} n={c.get('n',0):>6} {str(c.get('primo','')):>10}->{str(c.get('ultimo','')):>10} "
|
||||||
|
f"cad={c.get('cad_h','?')}h gap={c.get('gap>1.5h','?')} cov={c.get('cover%','?')}% "
|
||||||
|
f"med={c.get('fund_med_bps','?')}bps ann={c.get('fund_ann%','?')}% cap_hit={c.get('cap_hit','?')} "
|
||||||
|
f"[{c['status']}]")
|
||||||
|
ok = [r["coin"] for r in rep if r.get("status") in ("OK",)]
|
||||||
|
short = [r["coin"] for r in rep if r.get("status") == "corto<365g"]
|
||||||
|
print("-" * 100)
|
||||||
|
print(f" CERTIFICATI OK ({len(ok)}): {ok}")
|
||||||
|
if short:
|
||||||
|
print(f" CORTI <365g ({len(short)}): {short}")
|
||||||
|
print(f" Scritti in data/raw/hlfund_<sym>_1h.parquet (funding ORARIO, serie nativa).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""FETCH + CERTIFY universo azioni/ETF da IB (ADJUSTED_LAST) -> data/raw/eq_<sym>_1d.parquet.
|
||||||
|
|
||||||
|
Apre il fronte EQUITY (branch research/equities-ib). Disciplina v2.0.0: PRIMA il dato certificato,
|
||||||
|
POI la strategia. IB dà storia daily aggiustata per dividendi+split (ADJUSTED_LAST), profonda
|
||||||
|
(SPY dal 1996), sul conto paper. Namespace dedicato 'eq_' (NON tocca i parquet crypto).
|
||||||
|
|
||||||
|
UNIVERSO (prima ricerca = momentum cross-sectional settoriale, l'edge robusto plausibile in equity):
|
||||||
|
* 11 SPDR settoriali (XLK..XLC); * broad/macro SPY QQQ IWM TLT GLD HYG.
|
||||||
|
NB: i 9 settori "classici" partono 1998; XLRE 2015, XLC 2018 -> lo start COMUNE a 11 e' 2018.
|
||||||
|
Per backtest lunghi usare i 9 classici (1998+) o accettare lo start 2018 per gli 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).
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
import sys, time
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
RAW = ROOT / "data" / "raw"
|
||||||
|
RAW.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
SECTORS = ["XLK", "XLF", "XLE", "XLV", "XLI", "XLP", "XLY", "XLU", "XLB", "XLRE", "XLC"]
|
||||||
|
BROAD = ["SPY", "QQQ", "IWM", "TLT", "GLD", "HYG"]
|
||||||
|
# espansione "diversi mercati" (intl / bond / credito / commodity / settori extra) per il lead-lag crypto
|
||||||
|
BROAD2 = ["DIA", "EFA", "EEM", "FXI", "EWJ", "AGG", "LQD", "IEF", "USO", "SLV", "DBC", "VNQ"]
|
||||||
|
UNIVERSE = SECTORS + BROAD + BROAD2
|
||||||
|
|
||||||
|
|
||||||
|
def certify(sym: str, df: pd.DataFrame) -> dict:
|
||||||
|
if df.empty:
|
||||||
|
return {"sym": sym, "n": 0, "status": "VUOTO"}
|
||||||
|
idx = df.index
|
||||||
|
dup = int(idx.duplicated().sum())
|
||||||
|
mono = bool(idx.is_monotonic_increasing)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
ret = np.diff(c) / c[:-1]
|
||||||
|
flat = int((ret == 0).sum())
|
||||||
|
maxret = float(np.max(np.abs(ret))) if len(ret) else 0.0
|
||||||
|
# gap: giorni lavorativi attesi vs presenti, run lunghi mancanti
|
||||||
|
bdays = pd.bdate_range(idx[0], idx[-1])
|
||||||
|
missing = len(bdays) - len(idx.intersection(bdays))
|
||||||
|
gaps = bdays.difference(idx)
|
||||||
|
longgap = 0
|
||||||
|
if len(gaps):
|
||||||
|
g = pd.Series(1, index=gaps).resample("1D").sum().fillna(0)
|
||||||
|
# conta run consecutivi di bday mancanti
|
||||||
|
s = (gaps.to_series().diff().dt.days.fillna(1) > 3).cumsum()
|
||||||
|
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)
|
||||||
|
status = "OK"
|
||||||
|
if dup or not mono:
|
||||||
|
status = "INTEGRITA'"
|
||||||
|
elif maxret > 0.5:
|
||||||
|
status = "SPIKE?"
|
||||||
|
elif longgap > 0:
|
||||||
|
status = "GAP-LUNGO"
|
||||||
|
elif span_y < 1:
|
||||||
|
status = "corto<1y"
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
from ib_async import IB, Stock
|
||||||
|
except Exception:
|
||||||
|
print("ib_async assente. Esegui con: uv run --with ib_async python scripts/research/fetch_ib_equities.py")
|
||||||
|
sys.exit(2)
|
||||||
|
ib = IB()
|
||||||
|
try:
|
||||||
|
ib.connect("127.0.0.1", 4002, clientId=90, timeout=15)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[CONNESSIONE FALLITA] 127.0.0.1:4002 -> {repr(e)[:120]}\n Avvia: docker compose up -d ib-gateway")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("=" * 104)
|
||||||
|
print(f" FETCH + CERTIFY azioni/ETF (ADJUSTED_LAST) -> data/raw/eq_* | acct {ib.managedAccounts()}")
|
||||||
|
print("=" * 104)
|
||||||
|
rep, ok = [], []
|
||||||
|
force = "--force" in sys.argv[1:]
|
||||||
|
universe = UNIVERSE
|
||||||
|
if "--only" in sys.argv[1:]: # refresh mirato (es. solo i 6 ETF GTAA per il cron)
|
||||||
|
universe = sys.argv[sys.argv.index("--only") + 1].upper().split(",")
|
||||||
|
force = True # --only implica refresh dei simboli indicati
|
||||||
|
for sym in universe:
|
||||||
|
out_path = RAW / f"eq_{sym.lower()}_1d.parquet"
|
||||||
|
if out_path.exists() and not force:
|
||||||
|
print(f" {sym:5} GIA' SU DISCO -> skip (usa --force per riscaricare)")
|
||||||
|
ok.append(sym)
|
||||||
|
continue
|
||||||
|
con = Stock(sym, "SMART", "USD")
|
||||||
|
try:
|
||||||
|
bars = ib.reqHistoricalData(con, endDateTime="", durationStr="30 Y", barSizeSetting="1 day",
|
||||||
|
whatToShow="ADJUSTED_LAST", useRTH=True, formatDate=1, timeout=60)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {sym:5} ERR {repr(e)[:70]}"); rep.append({"sym": sym, "status": "ERR"}); time.sleep(1.2); continue
|
||||||
|
if not bars:
|
||||||
|
print(f" {sym:5} 0 barre (subscription?)"); rep.append({"sym": sym, "n": 0, "status": "VUOTO"}); time.sleep(1.2); continue
|
||||||
|
df = pd.DataFrame([(pd.Timestamp(str(b.date)), b.open, b.high, b.low, b.close, b.volume) for b in bars],
|
||||||
|
columns=["ts", "open", "high", "low", "close", "volume"]).set_index("ts").sort_index()
|
||||||
|
c = certify(sym, df)
|
||||||
|
rep.append(c)
|
||||||
|
if c.get("n", 0) > 0:
|
||||||
|
out = df.copy()
|
||||||
|
# ms epoch (come i parquet crypto), robusto alla risoluzione datetime64 (s/us/ns)
|
||||||
|
out["timestamp"] = out.index.astype("datetime64[ms]").astype("int64")
|
||||||
|
out.reset_index(drop=True).to_parquet(RAW / f"eq_{sym.lower()}_1d.parquet")
|
||||||
|
if c["status"] == "OK":
|
||||||
|
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']}]")
|
||||||
|
time.sleep(1.2) # pacing IB
|
||||||
|
|
||||||
|
print("-" * 104)
|
||||||
|
print(f" CERTIFICATI OK ({len(ok)}/{len(UNIVERSE)}): {ok}")
|
||||||
|
sec_ok = [s for s in SECTORS if s in ok]
|
||||||
|
print(f" settori OK: {len(sec_ok)}/11 {sec_ok}")
|
||||||
|
print(f" -> scritti in data/raw/eq_<sym>_1d.parquet (ADJUSTED_LAST, namespace dedicato).")
|
||||||
|
ib.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""FETCH futures indice ORARI (ES/NQ/RTY) da IB -> data/raw/fut_<sym>_1h.parquet (UTC).
|
||||||
|
|
||||||
|
Per il test onesto dell'idea "monitor Deribit / trade IB": serve il path INTRADAY del future indice
|
||||||
|
(che si trada di notte) per misurare finestre overnight NON sovrapposte col segnale crypto.
|
||||||
|
ContFuture orario, in chunk da 1 anno (IB limita le durate intraday). Convertito in UTC.
|
||||||
|
Resumable (salta i parquet gia' scritti). Per RETURNS/lead-lag il back-adjust del ContFuture e' ok
|
||||||
|
(i ritorni infra-contratto sono preservati; i gap di roll ~4/anno sono trascurabili).
|
||||||
|
|
||||||
|
uv run --with ib_async python scripts/research/fetch_ib_futures.py
|
||||||
|
"""
|
||||||
|
import sys, time
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
RAW = ROOT / "data" / "raw"
|
||||||
|
# sym -> exchange (indici US + esteri + commodity + bond per la ricerca cross-mercato oltre SP500)
|
||||||
|
SYMS = {"ES": "CME", "NQ": "CME", "RTY": "CME",
|
||||||
|
"GC": "COMEX", "CL": "NYMEX", "HG": "COMEX", "ZN": "CBOT",
|
||||||
|
"ESTX50": "EUREX", "DAX": "EUREX", "NKD": "CME"}
|
||||||
|
N_CHUNKS = 5 # (non usato: ContFuture non accetta endDateTime -> chiamata singola)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
from ib_async import IB, ContFuture
|
||||||
|
ib = IB()
|
||||||
|
try:
|
||||||
|
ib.connect("127.0.0.1", 4002, clientId=144, timeout=15)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[CONNESSIONE FALLITA] {repr(e)[:100]}"); sys.exit(1)
|
||||||
|
print(f" acct {ib.managedAccounts()} | fetch futures orari -> data/raw/fut_*")
|
||||||
|
for sym, exc in SYMS.items():
|
||||||
|
out = RAW / f"fut_{sym.lower()}_1h.parquet"
|
||||||
|
if out.exists():
|
||||||
|
print(f" {sym}: gia' su disco -> skip"); continue
|
||||||
|
cf = ContFuture(sym, exchange=exc)
|
||||||
|
try:
|
||||||
|
ib.qualifyContracts(cf)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {sym}: qualify ERR {repr(e)[:60]}"); continue
|
||||||
|
# ContFuture NON accetta endDateTime (Error 10339) -> chiamata singola, durata massima (~3y orari)
|
||||||
|
try:
|
||||||
|
b = ib.reqHistoricalData(cf, endDateTime="", durationStr="4 Y", barSizeSetting="1 hour",
|
||||||
|
whatToShow="TRADES", useRTH=False, formatDate=1, timeout=150)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {sym}: ERR {repr(e)[:60]}"); continue
|
||||||
|
if not b:
|
||||||
|
print(f" {sym}: VUOTO"); continue
|
||||||
|
D = pd.DataFrame([(pd.Timestamp(x.date), x.open, x.high, x.low, x.close, x.volume) for x in b],
|
||||||
|
columns=["ts", "open", "high", "low", "close", "volume"]).drop_duplicates("ts").sort_values("ts").reset_index(drop=True)
|
||||||
|
# -> UTC ms (robusto alla risoluzione us/ns: naive-UTC -> datetime64[ms] -> int64)
|
||||||
|
ts = pd.to_datetime(D["ts"], utc=True).dt.tz_convert("UTC").dt.tz_localize(None)
|
||||||
|
D["timestamp"] = ts.values.astype("datetime64[ms]").astype("int64")
|
||||||
|
D = D.drop(columns=["ts"])
|
||||||
|
D.to_parquet(out)
|
||||||
|
u = pd.to_datetime(D["timestamp"], unit="ms", utc=True)
|
||||||
|
print(f" {sym}: SCRITTO {len(D)} barre {u.iloc[0]} .. {u.iloc[-1]} -> {out.name}")
|
||||||
|
time.sleep(1.5)
|
||||||
|
ib.disconnect()
|
||||||
|
print(" done.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""FC01 — Funding-CARRY cross-sectional su Hyperliquid (backtest onesto, STAT-MODE).
|
||||||
|
|
||||||
|
IPOTESI. Il funding dei perp e' un CASHFLOW (long pagano short quando f>0). Un book
|
||||||
|
dollar-neutral che VENDE i perp ad alto funding e COMPRA quelli a basso funding INCASSA
|
||||||
|
il premio di funding (carry). E' una fonte di ritorno DIVERSA dal trend (TP01) e — forse —
|
||||||
|
dal momentum cross-sectional (XS01). Domanda chiave: e' un edge reale o solo XS01 travestito?
|
||||||
|
(gli asset ad alto funding sono spesso quelli pompati = forti = quelli che XS01 COMPRA; qui li
|
||||||
|
SHORTIAMO -> potenziale ANTI-correlazione con XS01, oppure il carry domina).
|
||||||
|
|
||||||
|
DATI (certificati). funding orario reale HL (scripts/research/fetch_hl_funding.py, dal 2023-05) +
|
||||||
|
prezzi HL 1d (data/raw/hl_*_1d.parquet, gli stessi di XS01). Universo = i 19 major di XS01.
|
||||||
|
|
||||||
|
COSTRUZIONE (causale, come XS01). Ogni H giorni: segnale = media causale del funding giornaliero
|
||||||
|
realizzato sugli ultimi L giorni (solo dati <= i-1). Rank cross-section; SHORT i k ad alto funding,
|
||||||
|
LONG i k a basso (dollar-neutral). Ritorno del perp per un LONG = price_ret - funding (chi e' long
|
||||||
|
paga il funding); quindi r_book[i] = sum_a w[i-1,a] * (price_ret[i,a] - funding_realizzato[i,a]),
|
||||||
|
meno fee sul turnover (0.05%/lato, come XS01), poi vol-target 20%.
|
||||||
|
|
||||||
|
GIUDIZIO (metodologia indurita). Standalone (FULL/in-sample/hold-out, DD, anni) + correlazione a
|
||||||
|
TP01/XS01/VRP01 + marginal_vs_tp01 (gate: edge in-sample>=0.5, persistenza multi-cut, hedge-vs-alpha,
|
||||||
|
noise-null) + UPLIFT vs XS01 (la domanda di overlap) + plateau su L/k/direzione.
|
||||||
|
|
||||||
|
CAVEAT ONESTO PRE-RISULTATO: e' market-neutral a 10 gambe -> NON eseguibile a $600 (STAT-MODE come
|
||||||
|
XS01/VRP01). Il deliverable e' "esiste un carry reale e ORTOGONALE a XS01?", non un deploy.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||||
|
|
||||||
|
from src.portfolio.sleeves import XS_UNIVERSE, _xsec_returns, _vrp_combo_returns, _HL_DIR
|
||||||
|
import altlib as L
|
||||||
|
from altlib import _sh, _dd_ret, _to_daily, _uplift_series, HOLDOUT, marginal_vs_tp01
|
||||||
|
|
||||||
|
FEE_SIDE = 0.0005 # 0.10% RT / 2, come XS01
|
||||||
|
|
||||||
|
|
||||||
|
def _load_panel():
|
||||||
|
"""Ritorna (PX, FUND): due DataFrame daily allineati [date x asset] con prezzo e funding
|
||||||
|
giornaliero realizzato (somma oraria). Solo asset con ENTRAMBI i dati."""
|
||||||
|
px, fund = {}, {}
|
||||||
|
for sym in XS_UNIVERSE:
|
||||||
|
pp = _HL_DIR / f"hl_{sym.lower()}_1d.parquet"
|
||||||
|
fp = _HL_DIR / f"hlfund_{sym.lower()}_1h.parquet"
|
||||||
|
if not (pp.exists() and fp.exists()):
|
||||||
|
continue
|
||||||
|
dp = pd.read_parquet(pp)
|
||||||
|
px[sym] = pd.Series(dp["close"].values.astype(float),
|
||||||
|
index=pd.to_datetime(dp["timestamp"], unit="ms", utc=True)).resample("1D").last()
|
||||||
|
df = pd.read_parquet(fp) # index ts (orario, utc), col funding
|
||||||
|
fund[sym] = df["funding"].resample("1D").sum()
|
||||||
|
PX = pd.concat(px, axis=1).sort_index()
|
||||||
|
FUND = pd.concat(fund, axis=1).sort_index().reindex(PX.index)
|
||||||
|
# tieni solo le date con prezzi per tutti + funding noto (0 dove manca un giorno isolato)
|
||||||
|
common = PX.dropna(how="any").index.intersection(FUND.index)
|
||||||
|
return PX.loc[common], FUND.loc[common].fillna(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def carry_returns(L_lb=7, H=10, k=5, direction="carry", target_vol=0.20,
|
||||||
|
fee_side=FEE_SIDE) -> pd.Series:
|
||||||
|
"""Serie daily netta del book funding-carry cross-sectional. direction='carry' shorta l'alto
|
||||||
|
funding (incassa il premio); 'anti' lo compra."""
|
||||||
|
PX, FUND = _load_panel()
|
||||||
|
idx = PX.index
|
||||||
|
px = PX.values; fnd = FUND.values
|
||||||
|
n, A = px.shape
|
||||||
|
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
|
||||||
|
# segnale = media causale del funding giornaliero sugli ultimi L giorni (shiftato di 1)
|
||||||
|
sig = pd.DataFrame(fnd, index=idx).rolling(L_lb, min_periods=max(3, L_lb // 2)).mean().shift(1).values
|
||||||
|
W = np.zeros((n, A)); w = np.zeros(A)
|
||||||
|
for i in range(n):
|
||||||
|
if i >= L_lb + 1 and i % H == 0 and np.isfinite(sig[i]).sum() >= 2 * k:
|
||||||
|
s = sig[i].copy()
|
||||||
|
order = np.argsort(np.where(np.isfinite(s), s, np.nan))
|
||||||
|
order = order[np.isfinite(s[order])]
|
||||||
|
if len(order) >= 2 * k:
|
||||||
|
w = np.zeros(A)
|
||||||
|
lo, hi = order[:k], order[-k:] # lo=basso funding, hi=alto funding
|
||||||
|
if direction == "carry":
|
||||||
|
w[hi] = -0.5 / k; w[lo] = +0.5 / k # SHORT alto funding (incassa), LONG basso
|
||||||
|
else:
|
||||||
|
w[hi] = +0.5 / k; w[lo] = -0.5 / k
|
||||||
|
W[i] = w
|
||||||
|
# ritorno del perp per un LONG = price_ret - funding realizzato
|
||||||
|
perp_ret = dret - fnd
|
||||||
|
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * perp_ret[1:], axis=1)
|
||||||
|
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum(); turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
|
||||||
|
net = gross - turn * fee_side
|
||||||
|
s = pd.Series(net, index=idx)
|
||||||
|
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
|
||||||
|
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||||
|
return pd.Series(s.values * scale, index=idx)
|
||||||
|
|
||||||
|
|
||||||
|
def _stats(s: pd.Series) -> dict:
|
||||||
|
s = _to_daily(s)
|
||||||
|
h = s[s.index >= HOLDOUT]; isamp = s[s.index < HOLDOUT]
|
||||||
|
yrs = {y: round(_sh(s[s.index.year == y]), 2) for y in sorted(set(s.index.year))}
|
||||||
|
return dict(n=len(s), full=round(_sh(s), 2), insample=round(_sh(isamp), 2) if len(isamp) > 30 else None,
|
||||||
|
hold=round(_sh(h), 2) if len(h) > 30 else None, dd=round(_dd_ret(s), 3),
|
||||||
|
ann_ret=round(float(s.mean() * 365.25), 3), yearly=yrs)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 96)
|
||||||
|
print(" FC01 — FUNDING-CARRY cross-sectional su Hyperliquid (STAT-MODE)")
|
||||||
|
print("=" * 96)
|
||||||
|
PX, FUND = _load_panel()
|
||||||
|
print(f" panel: {PX.shape[1]} asset x {len(PX)} giorni [{PX.index[0].date()} .. {PX.index[-1].date()}]")
|
||||||
|
print(f" asset: {list(PX.columns)}")
|
||||||
|
# funding medio annualizzato per asset (dispersione = materia prima del carry)
|
||||||
|
fann = (FUND.mean() * 365.25 * 100).round(1).sort_values(ascending=False)
|
||||||
|
print(f" funding annualizzato% (carry teorico long-pays): "
|
||||||
|
f"max {fann.index[0]} {fann.iloc[0]:+.1f} min {fann.index[-1]} {fann.iloc[-1]:+.1f} "
|
||||||
|
f"mediana {fann.median():+.1f} spread {fann.iloc[0]-fann.iloc[-1]:.1f}")
|
||||||
|
|
||||||
|
base = carry_returns()
|
||||||
|
print("\n --- STANDALONE (config base L=7 H=10 k=5, direction=carry) ---")
|
||||||
|
st = _stats(base)
|
||||||
|
print(f" FULL Sh {st['full']} in-sample {st['insample']} HOLD {st['hold']} DD {st['dd']*100:.1f}% "
|
||||||
|
f"ann.ret {st['ann_ret']*100:+.1f}% ({st['n']}g)")
|
||||||
|
print(f" per anno: {st['yearly']}")
|
||||||
|
|
||||||
|
# correlazioni vs gli sleeve attivi
|
||||||
|
print("\n --- CORRELAZIONE vs sleeve attivi (daily, overlap comune) ---")
|
||||||
|
refs = {"TP01": L.tp01_baseline_daily(), "XS01": _to_daily(_xsec_returns()),
|
||||||
|
"VRP01": _to_daily(_vrp_combo_returns())}
|
||||||
|
bd = _to_daily(base)
|
||||||
|
for nm, r in refs.items():
|
||||||
|
J = pd.concat({"c": bd, "r": r}, axis=1, join="inner").dropna()
|
||||||
|
c = round(float(J["c"].corr(J["r"])), 3) if len(J) > 30 else None
|
||||||
|
print(f" corr(FC01, {nm}) = {c} (overlap {len(J)}g)")
|
||||||
|
|
||||||
|
# marginal vs TP01 (verdetto indurito completo)
|
||||||
|
print("\n --- MARGINAL vs TP01 (scorer indurito) ---")
|
||||||
|
m = marginal_vs_tp01(bd)
|
||||||
|
for key in ("marginal_verdict", "corr_full", "cand_full_sharpe", "cand_insample_sharpe",
|
||||||
|
"has_insample_edge", "multicut_persistent", "is_hedge", "null_pctl_insample"):
|
||||||
|
print(f" {key:22} = {m.get(key)}")
|
||||||
|
print(f" blend w25: {m.get('blends', {}).get('w25')}")
|
||||||
|
print(f" multicut_uplift: {m.get('multicut_uplift')}")
|
||||||
|
|
||||||
|
# UPLIFT vs XS01 (la domanda di overlap): XS01 da solo vs blend(XS01, FC01)
|
||||||
|
print("\n --- UPLIFT vs XS01 (aggiunge a XS01 o e' ridondante?) ---")
|
||||||
|
xs = refs["XS01"]
|
||||||
|
J = pd.concat({"xs": xs, "fc": bd}, axis=1, join="inner").dropna()
|
||||||
|
JH = J[J.index >= HOLDOUT]
|
||||||
|
for w in (0.25, 0.5):
|
||||||
|
bf = _sh((1 - w) * J["xs"] + w * J["fc"]) - _sh(J["xs"])
|
||||||
|
bh = (_sh((1 - w) * JH["xs"] + w * JH["fc"]) - _sh(JH["xs"])) if len(JH) > 30 else None
|
||||||
|
print(f" w={w}: uplift FULL {bf:+.3f} HOLD {bh:+.3f}" if bh is not None
|
||||||
|
else f" w={w}: uplift FULL {bf:+.3f}")
|
||||||
|
print(f" XS01 standalone: FULL {_sh(J['xs']):.2f} | FC01 standalone su overlap: {_sh(J['fc']):.2f}")
|
||||||
|
|
||||||
|
# PLATEAU su L, k, direzione
|
||||||
|
print("\n --- PLATEAU (FULL / in-sample / HOLD Sharpe) ---")
|
||||||
|
print(f" {'cfg':22} {'FULL':>6} {'in-s':>6} {'HOLD':>6} {'DD%':>6}")
|
||||||
|
for direction in ("carry", "anti"):
|
||||||
|
for Llb in (3, 7, 14, 30):
|
||||||
|
for k in (3, 5):
|
||||||
|
s = _stats(carry_returns(L_lb=Llb, k=k, direction=direction))
|
||||||
|
tag = f"{direction} L={Llb} k={k}"
|
||||||
|
print(f" {tag:22} {str(s['full']):>6} {str(s['insample']):>6} {str(s['hold']):>6} {s['dd']*100:>5.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""LEAD-LAG GENERICO non-sovrapposto: crypto[T-S -> T] predice future[T -> T+H]? (ogni mercato/fuso).
|
||||||
|
|
||||||
|
Market-agnostic. Per ogni giorno, entrata a ora T (UTC): segnale = crypto nella finestra [T-S, T]
|
||||||
|
(finisce all'entrata), cattura = future nella finestra SUCCESSIVA [T, T+H] (non sovrapposta).
|
||||||
|
Controllo = moto PROPRIO del future [T-S, T] -> isola se il crypto AGGIUNGE (anticipa) oltre il
|
||||||
|
momentum del future. Sweep su T (copre apertura Europa ~07h, USA ~13h, Asia ~00h) e H.
|
||||||
|
|
||||||
|
TRADE: sign(crypto[T-S,T]) * future[T,T+H] - costo. Sharpe sqrt(252), per-anno, OOS 2024+.
|
||||||
|
Dati: data/raw/fut_*_1h.parquet (UTC) + crypto 1h. Solo cache, nessun IB.
|
||||||
|
"""
|
||||||
|
import sys, glob
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
RAW = ROOT / "data" / "raw"
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
from crypto_lead_harness import crypto_hourly, at
|
||||||
|
|
||||||
|
OOS = pd.Timestamp("2024-01-01", tz="UTC"); COST = 0.0002
|
||||||
|
|
||||||
|
|
||||||
|
def fut_hourly(sym):
|
||||||
|
d = pd.read_parquet(RAW / f"fut_{sym.lower()}_1h.parquet")
|
||||||
|
return pd.Series(d["close"].astype(float).values, index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
|
||||||
|
|
||||||
|
|
||||||
|
def _sh(r):
|
||||||
|
r = np.asarray(r, float); r = r[np.isfinite(r)]
|
||||||
|
return float(np.mean(r) / np.std(r) * np.sqrt(252)) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def run(fut, bc, T, S, H):
|
||||||
|
days = pd.date_range(fut.index[0].normalize(), fut.index[-1].normalize(), freq="D", tz="UTC")
|
||||||
|
rows = []
|
||||||
|
for D in days:
|
||||||
|
te = D + pd.Timedelta(hours=T)
|
||||||
|
ts = te - pd.Timedelta(hours=S)
|
||||||
|
tx = te + pd.Timedelta(hours=H)
|
||||||
|
f0, f1 = at(fut, ts), at(fut, te); f2 = at(fut, tx)
|
||||||
|
c0, c1 = at(bc, ts), at(bc, te)
|
||||||
|
if not all(np.isfinite(v) and v > 0 for v in (f0, f1, f2, c0, c1)):
|
||||||
|
continue
|
||||||
|
rows.append((D, c1/c0 - 1, f1/f0 - 1, f2/f1 - 1))
|
||||||
|
if len(rows) < 120:
|
||||||
|
return None
|
||||||
|
Dd = pd.DataFrame(rows, columns=["d", "csig", "fctrl", "cap"]).set_index("d")
|
||||||
|
# filtra giorni in cui la cattura e' identicamente 0 (mercato chiuso, prezzo stale)
|
||||||
|
Dd = Dd[Dd["cap"].abs() > 1e-9]
|
||||||
|
if len(Dd) < 120:
|
||||||
|
return None
|
||||||
|
x, ctrl, y = Dd["csig"].values, Dd["fctrl"].values, Dd["cap"].values
|
||||||
|
|
||||||
|
def z(a):
|
||||||
|
s = a.std(); return (a - a.mean()) / s if s > 0 else a * 0
|
||||||
|
X = np.column_stack([np.ones(len(y)), z(x), z(ctrl)])
|
||||||
|
beta, *_ = np.linalg.lstsq(X, z(y), rcond=None)
|
||||||
|
resid = z(y) - X @ beta; dof = max(len(y) - 3, 1)
|
||||||
|
se = np.sqrt(np.sum(resid**2) / dof * np.diag(np.linalg.inv(X.T @ X)))
|
||||||
|
t_c = float(beta[1] / se[1]) if se[1] > 0 else 0.0
|
||||||
|
C = np.sign(x) * y - COST
|
||||||
|
m = Dd.index >= OOS
|
||||||
|
yrs = Dd.index.year.values
|
||||||
|
py = {int(v): round(_sh(C[yrs == v]), 2) for v in sorted(set(yrs)) if (yrs == v).sum() >= 30}
|
||||||
|
pos = sum(1 for v in py.values() if v > 0)
|
||||||
|
return dict(T=T, H=H, n=len(Dd), t_crypto=round(t_c, 2), sh_full=round(_sh(C), 2),
|
||||||
|
sh_oos=round(_sh(C[m]), 2), ann=round(float(np.nanmean(C) * 252 * 100), 1),
|
||||||
|
years_pos=pos, years_tot=len(py), per_year=py)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
syms = sorted(p.name[4:-11].upper() for p in RAW.glob("fut_*_1h.parquet"))
|
||||||
|
print("=" * 98)
|
||||||
|
print(f" LEAD-LAG GENERICO non-sovrapposto — futures: {syms}")
|
||||||
|
print("=" * 98)
|
||||||
|
print(" crypto[T-8h -> T] -> future[T -> T+6h], controllo=moto proprio future. net 2bps, OOS2024+\n")
|
||||||
|
bcs = {l: crypto_hourly(l) for l in ("BTC", "ETH")}
|
||||||
|
winners = []
|
||||||
|
for sym in syms:
|
||||||
|
fut = fut_hourly(sym)
|
||||||
|
best = None
|
||||||
|
for lead in ("BTC", "ETH"):
|
||||||
|
for T in (0, 4, 8, 12, 16, 20):
|
||||||
|
r = run(fut, bcs[lead], T, 8, 6)
|
||||||
|
if not r:
|
||||||
|
continue
|
||||||
|
r["sym"] = sym; r["lead"] = lead
|
||||||
|
if best is None or r["sh_oos"] > best["sh_oos"]:
|
||||||
|
best = r
|
||||||
|
# raccogli i forti (crypto significativo E robusto)
|
||||||
|
if r["t_crypto"] >= 2.5 and r["sh_oos"] > 0.5 and r["years_pos"] == r["years_tot"]:
|
||||||
|
winners.append(r)
|
||||||
|
if best:
|
||||||
|
print(f" {sym:7} miglior: {best['lead']}->T{best['T']}h: t_crypto {best['t_crypto']:+.1f} "
|
||||||
|
f"Sh full {best['sh_full']:+.2f} OOS {best['sh_oos']:+.2f} ann {best['ann']:+.1f}% "
|
||||||
|
f"{best['years_pos']}/{best['years_tot']}y {best['per_year']}")
|
||||||
|
print("\n --- CANDIDATI FORTI (t_crypto>=2.5, OOS>0.5, tutti gli anni positivi) ---")
|
||||||
|
if not winners:
|
||||||
|
print(" NESSUNO. -> nessuna anticipazione crypto->future robusta oltre il rumore/beta.")
|
||||||
|
else:
|
||||||
|
winners.sort(key=lambda r: r["sh_oos"], reverse=True)
|
||||||
|
for w in winners[:10]:
|
||||||
|
print(f" {w['sym']:7} {w['lead']}->T{w['T']}h H{w['H']}: t_crypto {w['t_crypto']} "
|
||||||
|
f"Sh OOS {w['sh_oos']} full {w['sh_full']} ann {w['ann']}% {w['years_pos']}/{w['years_tot']}y {w['per_year']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""TEST ONESTO finestre NON sovrapposte: crypto[notte presto] -> future indice[notte tardi].
|
||||||
|
|
||||||
|
L'idea "monitor Deribit / trade IB" e' valida SOLO se il crypto anticipa il moto SUCCESSIVO del future
|
||||||
|
(non lo stesso intervallo = look-ahead). Qui:
|
||||||
|
entrata a T (ora UTC nella notte, equity cash chiuso): osservato crypto fino a T.
|
||||||
|
segnale = crypto[P 21:00 -> T] (info nota a T)
|
||||||
|
controllo= future[P 21:00 -> T] (il moto PROPRIO del future fino a T)
|
||||||
|
cattura = future[T -> D 13:00] (cio' che catturi entrando a T, fino a ~apertura cash)
|
||||||
|
TEST: il segnale crypto predice la cattura INCREMENTALMENTE al controllo? (crypto vede prima del future?)
|
||||||
|
TRADE: long/short future su sign(crypto[P21:00->T]), cattura future[T->open], net ~2bps. Sharpe/anno/OOS.
|
||||||
|
Confronto col baseline sign(future[P21:00->T]) (momentum proprio del future) per isolare il valore crypto.
|
||||||
|
|
||||||
|
Dati: data/raw/fut_<sym>_1h.parquet (UTC) + crypto 1h. sqrt(252).
|
||||||
|
"""
|
||||||
|
import sys, argparse, json
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
RAW = ROOT / "data" / "raw"
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
from crypto_lead_harness import crypto_hourly, at
|
||||||
|
|
||||||
|
OOS = pd.Timestamp("2024-01-01", tz="UTC"); COST = 0.0002
|
||||||
|
CLOSE_H = 21; OPEN_H = 13
|
||||||
|
|
||||||
|
|
||||||
|
def fut_hourly(sym):
|
||||||
|
d = pd.read_parquet(RAW / f"fut_{sym.lower()}_1h.parquet")
|
||||||
|
return pd.Series(d["close"].astype(float).values, index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
|
||||||
|
|
||||||
|
|
||||||
|
def _sh(r):
|
||||||
|
r = np.asarray(r, float); r = r[np.isfinite(r)]
|
||||||
|
return float(np.mean(r) / np.std(r) * np.sqrt(252)) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def run(sym="ES", lead="BTC", h_entry=6):
|
||||||
|
fut = fut_hourly(sym); bc = crypto_hourly(lead)
|
||||||
|
days = pd.date_range(fut.index[0].normalize(), fut.index[-1].normalize(), freq="B", tz="UTC")
|
||||||
|
rows = []
|
||||||
|
for k in range(1, len(days)):
|
||||||
|
D = days[k]; P = days[k-1]
|
||||||
|
t_pc = P + pd.Timedelta(hours=CLOSE_H) # P close 21:00
|
||||||
|
t_e = D + pd.Timedelta(hours=h_entry) # entrata T
|
||||||
|
t_o = D + pd.Timedelta(hours=OPEN_H) # ~apertura cash 13:00
|
||||||
|
fp, fe, fo = at(fut, t_pc), at(fut, t_e), at(fut, t_o)
|
||||||
|
cp, ce = at(bc, t_pc), at(bc, t_e)
|
||||||
|
if not all(np.isfinite(v) and v > 0 for v in (fp, fe, fo, cp, ce)):
|
||||||
|
continue
|
||||||
|
crypto_sig = ce / cp - 1.0 # crypto P21:00 -> T
|
||||||
|
fut_ctrl = fe / fp - 1.0 # future P21:00 -> T (controllo)
|
||||||
|
capture = fo / fe - 1.0 # future T -> open (cio' che catturi)
|
||||||
|
rows.append((D, crypto_sig, fut_ctrl, capture))
|
||||||
|
if len(rows) < 100:
|
||||||
|
return {"sym": sym, "lead": lead, "h_entry": h_entry, "err": f"n={len(rows)}"}
|
||||||
|
Dd = pd.DataFrame(rows, columns=["d", "csig", "fctrl", "cap"]).set_index("d")
|
||||||
|
x, ctrl, y = Dd["csig"].values, Dd["fctrl"].values, Dd["cap"].values
|
||||||
|
|
||||||
|
def z(a):
|
||||||
|
s = a.std(); return (a - a.mean()) / s if s > 0 else a * 0
|
||||||
|
# incrementale: cap ~ crypto + future_own_move
|
||||||
|
X = np.column_stack([np.ones(len(y)), z(x), z(ctrl)])
|
||||||
|
beta, *_ = np.linalg.lstsq(X, z(y), rcond=None)
|
||||||
|
resid = z(y) - X @ beta; dof = max(len(y) - 3, 1)
|
||||||
|
se = np.sqrt(np.sum(resid**2) / dof * np.diag(np.linalg.inv(X.T @ X)))
|
||||||
|
t_crypto = float(beta[1] / se[1]) if se[1] > 0 else 0.0
|
||||||
|
t_futown = float(beta[2] / se[2]) if se[2] > 0 else 0.0
|
||||||
|
# trade: crypto-signal vs future-own-signal
|
||||||
|
C_crypto = np.sign(x) * y - COST
|
||||||
|
C_futown = np.sign(ctrl) * y - COST
|
||||||
|
m = Dd.index >= OOS
|
||||||
|
yrs = Dd.index.year.values
|
||||||
|
py = {int(yv): round(_sh(C_crypto[yrs == yv]), 2) for yv in sorted(set(yrs)) if (yrs == yv).sum() >= 30}
|
||||||
|
return {"sym": sym, "lead": lead, "h_entry": h_entry, "n": len(Dd),
|
||||||
|
"corr_crypto_cap": round(float(np.corrcoef(x, y)[0, 1]), 3),
|
||||||
|
"t_crypto_incr": round(t_crypto, 2), "t_futown_incr": round(t_futown, 2),
|
||||||
|
"sh_crypto_full": round(_sh(C_crypto), 2), "sh_crypto_oos": round(_sh(C_crypto[m]), 2),
|
||||||
|
"sh_futown_full": round(_sh(C_futown), 2),
|
||||||
|
"ann_crypto_pct": round(float(np.nanmean(C_crypto) * 252 * 100), 1),
|
||||||
|
"per_year": py}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--syms", default="ES,NQ,RTY")
|
||||||
|
ap.add_argument("--leads", default="BTC,ETH")
|
||||||
|
ap.add_argument("--entries", default="0,3,6,9")
|
||||||
|
args = ap.parse_args()
|
||||||
|
print("=" * 96)
|
||||||
|
print(" FUTURE OVERNIGHT — crypto[P21:00->T] predice future[T->open]? (finestre NON sovrapposte)")
|
||||||
|
print("=" * 96)
|
||||||
|
print(" cattura = future[T->open]; controllo = future[P21:00->T]; t_crypto_incr = crypto AL NETTO del future. net 2bps\n")
|
||||||
|
best = []
|
||||||
|
for sym in args.syms.split(","):
|
||||||
|
for lead in args.leads.split(","):
|
||||||
|
for h in [int(x) for x in args.entries.split(",")]:
|
||||||
|
r = run(sym, lead, h)
|
||||||
|
if "err" in r:
|
||||||
|
print(f" {sym}<-{lead} T={h}h: {r['err']}"); continue
|
||||||
|
print(f" {sym}<-{lead} T={h:>2}h UTC: n={r['n']} corr {r['corr_crypto_cap']:+.3f} | "
|
||||||
|
f"t_crypto(incr) {r['t_crypto_incr']:+.1f} t_futOwn {r['t_futown_incr']:+.1f} | "
|
||||||
|
f"trade crypto Sh {r['sh_crypto_full']:+.2f} (OOS {r['sh_crypto_oos']:+.2f}) ann {r['ann_crypto_pct']:+.1f}% | "
|
||||||
|
f"futOwn Sh {r['sh_futown_full']:+.2f}")
|
||||||
|
best.append(r)
|
||||||
|
best.sort(key=lambda r: r.get("sh_crypto_oos", -9), reverse=True)
|
||||||
|
print("\n --- migliori per Sharpe OOS (trade su segnale crypto) ---")
|
||||||
|
for r in best[:5]:
|
||||||
|
print(f" {r['sym']}<-{r['lead']} T={r['h_entry']}h: OOS {r['sh_crypto_oos']} full {r['sh_crypto_full']} "
|
||||||
|
f"t_crypto {r['t_crypto_incr']} per-anno {r['per_year']}")
|
||||||
|
print("\n NB: t_crypto_incr alto E sh_crypto > sh_futOwn => il crypto anticipa il future (idea valida).")
|
||||||
|
print(" t_crypto ~0 o sh_crypto ~ futOwn => e' solo momentum del future, il crypto non aggiunge.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""IB EQUITIES/ETF DATA PROBE — certifica cosa il paper IB dà per la ricerca su azioni/ETF.
|
||||||
|
|
||||||
|
Gemello equity di certify_feed.py: PRIMA il dato (cosa c'è, quanto indietro, aggiustato per
|
||||||
|
dividendi/split?, cosa costa), POI la strategia. Disciplina v2.0.0.
|
||||||
|
|
||||||
|
Universo candidato per la prima ricerca equity (cross-sectional momentum / trend, l'edge "noioso e
|
||||||
|
robusto" più plausibile in un mercato efficiente):
|
||||||
|
* 11 SPDR settoriali (XLK..XLC) — universo canonico del momentum cross-section settoriale;
|
||||||
|
* ETF broad / macro (SPY QQQ IWM TLT GLD HYG) — per trend e risk-on/off;
|
||||||
|
* 2 azioni (AAPL MSFT) per tarare profondità/qualità.
|
||||||
|
|
||||||
|
Per ogni simbolo: profondità storica daily con whatToShow=ADJUSTED_LAST (split+dividendi, OBBLIGATORIO
|
||||||
|
per un backtest equity onesto) e TRADES (raw), + flag se scatta errore di subscription market-data.
|
||||||
|
|
||||||
|
uv run --with ib_async python scripts/research/ib_equities_probe.py
|
||||||
|
"""
|
||||||
|
import argparse, sys
|
||||||
|
|
||||||
|
SECTORS = ["XLK", "XLF", "XLE", "XLV", "XLI", "XLP", "XLY", "XLU", "XLB", "XLRE", "XLC"]
|
||||||
|
BROAD = ["SPY", "QQQ", "IWM", "TLT", "GLD", "HYG"]
|
||||||
|
STOCKS = ["AAPL", "MSFT"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--host", default="127.0.0.1")
|
||||||
|
ap.add_argument("--port", type=int, default=4002)
|
||||||
|
ap.add_argument("--client-id", type=int, default=88)
|
||||||
|
ap.add_argument("--years", default="20 Y", help="durata storica da richiedere")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ib_async import IB, Stock
|
||||||
|
except Exception:
|
||||||
|
print("ib_async non importabile. Esegui con: uv run --with ib_async python ...")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
ib = IB()
|
||||||
|
try:
|
||||||
|
ib.connect(args.host, args.port, clientId=args.client_id, timeout=15)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[CONNESSIONE FALLITA] {args.host}:{args.port} -> {repr(e)[:140]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("=" * 96)
|
||||||
|
print(f" IB EQUITIES/ETF PROBE — {args.host}:{args.port} | acct {ib.managedAccounts()} | depth req {args.years}")
|
||||||
|
print("=" * 96)
|
||||||
|
|
||||||
|
universe = [("SECTOR", s) for s in SECTORS] + [("BROAD", s) for s in BROAD] + [("STOCK", s) for s in STOCKS]
|
||||||
|
print(f" {'sym':6} {'tipo':7} {'ADJUSTED_LAST':>26} {'TRADES':>22} note")
|
||||||
|
rows = []
|
||||||
|
for cat, sym in universe:
|
||||||
|
con = Stock(sym, "SMART", "USD")
|
||||||
|
try:
|
||||||
|
cds = ib.reqContractDetails(con)
|
||||||
|
if not cds:
|
||||||
|
print(f" {sym:6} {cat:7} {'-- no contract --':>26}")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {sym:6} {cat:7} ERR resolve {repr(e)[:40]}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
def hist(what):
|
||||||
|
try:
|
||||||
|
b = ib.reqHistoricalData(con, endDateTime="", durationStr=args.years,
|
||||||
|
barSizeSetting="1 day", whatToShow=what,
|
||||||
|
useRTH=True, formatDate=1, timeout=45)
|
||||||
|
if not b:
|
||||||
|
return "0 barre", None
|
||||||
|
return f"{len(b)}b {b[0].date}..{b[-1].date}", b
|
||||||
|
except Exception as e:
|
||||||
|
return f"ERR {repr(e)[:30]}", None
|
||||||
|
|
||||||
|
adj_s, adj_b = hist("ADJUSTED_LAST")
|
||||||
|
trd_s, _ = hist("TRADES")
|
||||||
|
note = ""
|
||||||
|
if "ERR" in adj_s or "0 barre" in adj_s:
|
||||||
|
note = "subscription? prova delayed"
|
||||||
|
print(f" {sym:6} {cat:7} {adj_s:>26} {trd_s:>22} {note}")
|
||||||
|
if adj_b:
|
||||||
|
rows.append((sym, len(adj_b), str(adj_b[0].date), str(adj_b[-1].date)))
|
||||||
|
|
||||||
|
print("-" * 96)
|
||||||
|
if rows:
|
||||||
|
depth = min(r[1] for r in rows); start = max(r[2] for r in rows)
|
||||||
|
print(f" CERTIFICABILI (ADJUSTED_LAST): {len(rows)}/{len(universe)} | profondità comune ~{depth}b | start comune {start}")
|
||||||
|
print(f" -> per un backtest cross-sectional servono date allineate: lo start comune e' il limite.")
|
||||||
|
else:
|
||||||
|
print(" NESSUN simbolo ha reso storia ADJUSTED — probabile mancanza market-data subscription.")
|
||||||
|
print(" Ripiego: whatToShow='TRADES' (raw, non adj) o dati 'delayed' / fonte esterna certificabile.")
|
||||||
|
ib.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""IB DATA PROBE — enumera cosa un conto paper Interactive Brokers espone (dati storici).
|
||||||
|
|
||||||
|
NON e' una strategia: e' lo scan di FATTIBILITA' DATI, gemello di certify_feed.py per il mondo IB.
|
||||||
|
Disciplina v2.0.0: prima il dato (cosa c'e', quanto indietro, che qualita', cosa costa), poi la
|
||||||
|
strategia. "Solo dati, decido dopo".
|
||||||
|
|
||||||
|
PREREQUISITO: una sessione IB Gateway/TWS PAPER loggata e raggiungibile (default 127.0.0.1:4002).
|
||||||
|
Tipico: avvii IB Gateway (Paper) sul tuo PC con API abilitata su 4002, poi reverse-tunnel
|
||||||
|
SSH verso questo server: ssh -R 4002:localhost:4002 utente@server
|
||||||
|
ESECUZIONE (senza sporcare le dipendenze del progetto):
|
||||||
|
uv run --with ib_async python scripts/research/ib_probe.py
|
||||||
|
uv run --with ib_async python scripts/research/ib_probe.py --port 7497 # TWS paper
|
||||||
|
|
||||||
|
Cosa fa, in ordine, e si ferma con diagnosi chiara al primo errore:
|
||||||
|
(1) connette e stampa server version + account paper;
|
||||||
|
(2) risolve un set di contratti rilevanti per QUESTO progetto:
|
||||||
|
- CME crypto: BTC (5 BTC), MBT (micro 0.1 BTC), ETH, MET (micro); -> per il BASIS/carry
|
||||||
|
- spot crypto Paxos (se abilitato): BTC, ETH;
|
||||||
|
- 2 azioni/ETF di riferimento (SPY, AAPL) per tarare durate/qualita';
|
||||||
|
(3) per ogni contratto risolto: chiede un piccolo storico (durata breve, 1 day bars) e riporta
|
||||||
|
n barre, range, e se scatta un errore di SUBSCRIPTION mancante (codice 354/10089/10090...);
|
||||||
|
(4) sintesi: cosa e' scaricabile GRATIS su paper vs cosa richiede market-data a pagamento.
|
||||||
|
"""
|
||||||
|
import argparse, sys
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--host", default="127.0.0.1")
|
||||||
|
ap.add_argument("--port", type=int, default=7497, help="7497=TWS paper (default), 4002=GW paper, 4001/7496=live")
|
||||||
|
ap.add_argument("--client-id", type=int, default=77)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ib_async import IB, Future, Stock, Crypto, util
|
||||||
|
except Exception:
|
||||||
|
print("ib_async non importabile. Esegui con: uv run --with ib_async python scripts/research/ib_probe.py")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
ib = IB()
|
||||||
|
try:
|
||||||
|
ib.connect(args.host, args.port, clientId=args.client_id, timeout=15)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[CONNESSIONE FALLITA] {args.host}:{args.port} -> {repr(e)[:160]}")
|
||||||
|
print(" Verifica: IB Gateway/TWS Paper acceso, API abilitata, porta giusta, tunnel attivo.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("=" * 90)
|
||||||
|
print(f" IB DATA PROBE — connesso {args.host}:{args.port} | serverVersion={ib.client.serverVersion()}")
|
||||||
|
try:
|
||||||
|
accts = ib.managedAccounts()
|
||||||
|
print(f" account: {accts} (paper se inizia per 'D' tipicamente)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" account: ? ({repr(e)[:60]})")
|
||||||
|
print("=" * 90)
|
||||||
|
|
||||||
|
# (2) contratti rilevanti
|
||||||
|
candidates = []
|
||||||
|
# CME crypto futures: lasciamo che IB scelga il front-month (no expiry -> reqContractDetails)
|
||||||
|
candidates += [("CME BTC fut", Future("BTC", exchange="CME")),
|
||||||
|
("CME MBT micro", Future("MBT", exchange="CME")),
|
||||||
|
("CME ETH fut", Future("ETH", exchange="CME")),
|
||||||
|
("CME MET micro", Future("MET", exchange="CME"))]
|
||||||
|
# spot crypto Paxos (puo' non essere abilitato su paper)
|
||||||
|
candidates += [("Paxos BTC", Crypto("BTC", exchange="PAXOS", currency="USD")),
|
||||||
|
("Paxos ETH", Crypto("ETH", exchange="PAXOS", currency="USD"))]
|
||||||
|
# riferimenti equity
|
||||||
|
candidates += [("SPY ETF", Stock("SPY", "SMART", "USD")),
|
||||||
|
("AAPL", Stock("AAPL", "SMART", "USD"))]
|
||||||
|
|
||||||
|
resolved = []
|
||||||
|
print("\n (A) RISOLUZIONE CONTRATTI")
|
||||||
|
for label, c in candidates:
|
||||||
|
try:
|
||||||
|
cds = ib.reqContractDetails(c)
|
||||||
|
if not cds:
|
||||||
|
print(f" {label:16} -> NESSUN match")
|
||||||
|
continue
|
||||||
|
# per i futures prendi la scadenza piu' vicina disponibile
|
||||||
|
cd = sorted(cds, key=lambda d: getattr(d.contract, "lastTradeDateOrContractMonth", "") or "")[0]
|
||||||
|
con = cd.contract
|
||||||
|
extra = f" exp={con.lastTradeDateOrContractMonth}" if getattr(con, "lastTradeDateOrContractMonth", "") else ""
|
||||||
|
print(f" {label:16} -> OK {con.localSymbol or con.symbol} {con.exchange}{extra} (n match={len(cds)})")
|
||||||
|
resolved.append((label, con))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {label:16} -> ERR {repr(e)[:70]}")
|
||||||
|
|
||||||
|
# (3) prova storico breve
|
||||||
|
print("\n (B) STORICO DI PROVA (durata 10 D, barre 1 day)")
|
||||||
|
for label, con in resolved:
|
||||||
|
try:
|
||||||
|
bars = ib.reqHistoricalData(con, endDateTime="", durationStr="10 D",
|
||||||
|
barSizeSetting="1 day", whatToShow="TRADES",
|
||||||
|
useRTH=False, formatDate=1, timeout=30)
|
||||||
|
if not bars:
|
||||||
|
print(f" {label:16} -> 0 barre (forse serve subscription o whatToShow diverso)")
|
||||||
|
else:
|
||||||
|
print(f" {label:16} -> {len(bars)} barre {bars[0].date} .. {bars[-1].date} close={bars[-1].close}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {label:16} -> ERR {repr(e)[:90]}")
|
||||||
|
|
||||||
|
print("\n (C) NOTE")
|
||||||
|
print(" - errori 354/10089/10090/10168 = market-data subscription mancante (paper la eredita dal live).")
|
||||||
|
print(" - per il BASIS/carry servono i MULTIPLI futures (front+next) -> poi reqContractDetails senza filtro expiry.")
|
||||||
|
ib.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""STOPS LAB — protezioni CLASSICHE (stop-loss) sul combo vs la guardia-DD morbida.
|
||||||
|
|
||||||
|
Confronta su Sharpe/MaxDD/2022/CAGR/NumTrades/time-in-market:
|
||||||
|
- baseline
|
||||||
|
- guardia-DD morbida (de-risk a 0.4x, gia' scelta)
|
||||||
|
- TRAILING STOP duro (uscita TOTALE) a -4/-6/-8% dal picco, re-entry su nuovo massimo
|
||||||
|
- TRAILING STOP con re-entry su RECUPERO (DD < meta' soglia)
|
||||||
|
- STOP MENSILE (flat per il resto del mese se perdita mensile > X%)
|
||||||
|
- VOL STOP (de-risk se vol realizzata 30g > 90 pctl espandente)
|
||||||
|
Tesi da verificare: lo stop DURO taglia il DD ma fa whipsaw nel grind 2022 (Sharpe/CAGR peggiori,
|
||||||
|
piu' trade). Dati: combo_daily (cache). sqrt(252).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
from combo_yearly_report import combo_daily
|
||||||
|
ANN = np.sqrt(252.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _sh(r): r = np.asarray(pd.Series(r).dropna(), float); return float(np.mean(r)/np.std(r)*ANN) if len(r)>5 and np.std(r)>0 else 0.0
|
||||||
|
def _dd(r): eq=np.cumprod(1+np.asarray(r,float)); pk=np.maximum.accumulate(eq); return float(np.max((pk-eq)/pk)) if len(eq) else 0.0
|
||||||
|
def _yr(r,y): return float(np.prod(1+r[r.index.year==y].values)-1) if (r.index.year==y).any() else 0.0
|
||||||
|
def _cagr(r): r=r.dropna(); return (np.prod(1+r.values))**(252/len(r))-1
|
||||||
|
def _ntr(expo): return int((np.abs(np.diff(expo, prepend=expo[0]))>1e-9).sum()) # cambi di esposizione = trade
|
||||||
|
|
||||||
|
|
||||||
|
def expo_softguard(r, trig=0.04, lvl=0.4):
|
||||||
|
rv=r.values; eq=np.cumprod(1+rv); pk=np.maximum.accumulate(eq); e=np.ones(len(rv)); on=True
|
||||||
|
for i in range(1,len(rv)):
|
||||||
|
dd=(pk[i-1]-eq[i-1])/pk[i-1] if pk[i-1]>0 else 0
|
||||||
|
if dd>trig: on=False
|
||||||
|
if dd<trig*0.4: on=True
|
||||||
|
e[i]=1.0 if on else lvl
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def expo_trailstop(r, trig=0.06, reentry="newhigh"):
|
||||||
|
"""Stop DURO trailing (uscita TOTALE) — test EQUO: trigger/re-entry sul MERCATO (NAV sempre-
|
||||||
|
investito di riferimento, non sull'equity stoppata che si congela). Esci se DD del NAV > trig;
|
||||||
|
re-entry 'newhigh' = NAV torna al picco; 'recover' = DD del NAV < trig/2."""
|
||||||
|
rv=r.values; n=len(rv)
|
||||||
|
nav=np.cumprod(1+rv); pk=np.maximum.accumulate(nav); dd=(pk-nav)/pk
|
||||||
|
e=np.ones(n); on=True
|
||||||
|
for i in range(1,n):
|
||||||
|
d=dd[i-1]
|
||||||
|
if on and d>trig:
|
||||||
|
on=False
|
||||||
|
elif not on:
|
||||||
|
if reentry=="newhigh" and nav[i-1]>=pk[i-1]-1e-12: on=True
|
||||||
|
elif reentry=="recover" and d<trig*0.5: on=True
|
||||||
|
e[i]=1.0 if on else 0.0
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def expo_monthly(r, trig=0.05):
|
||||||
|
e=np.ones(len(r)); idx=r.index; cur=None; mret=1.0; stopped=False
|
||||||
|
for i in range(len(r)):
|
||||||
|
ym=(idx[i].year,idx[i].month)
|
||||||
|
if ym!=cur: cur=ym; mret=1.0; stopped=False
|
||||||
|
if stopped: e[i]=0.0; continue
|
||||||
|
mret*=(1+r.values[i])
|
||||||
|
if mret-1 < -trig: stopped=True
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def expo_volstop(r, win=30, pctl=0.90):
|
||||||
|
rv=pd.Series(r.values,index=r.index); v=rv.rolling(win,min_periods=15).std()*ANN
|
||||||
|
thr=v.expanding(min_periods=60).quantile(pctl).shift(1)
|
||||||
|
e=np.where((v.shift(1)>thr).values, 0.4, 1.0); e=np.nan_to_num(e,nan=1.0)
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def show(name, r, expo):
|
||||||
|
g=pd.Series(expo*r.values,index=r.index)
|
||||||
|
tim=float((expo>1e-9).mean())*100
|
||||||
|
print(f" {name:30} Sh {_sh(g):>5.2f} MaxDD {_dd(g.values)*100:>4.1f}% 2022 {_yr(g,2022)*100:>+5.1f}% "
|
||||||
|
f"CAGR {_cagr(g)*100:>+5.1f}% trades {_ntr(expo):>4} inMkt {tim:>3.0f}%")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("="*104); print(" STOPS LAB — protezioni classiche (SL) vs guardia-DD morbida (combo TP01+GTAA, 2019-26)"); print("="*104)
|
||||||
|
r=combo_daily()
|
||||||
|
print(f"\n {'(esposizione media applicata ai rendimenti del combo)':<30}")
|
||||||
|
show("baseline (nessuna)", r, np.ones(len(r)))
|
||||||
|
print(" --- guardia-DD MORBIDA (de-risk a 0.4x) ---")
|
||||||
|
show("soft-guard -4%", r, expo_softguard(r,0.04))
|
||||||
|
print(" --- STOP-LOSS DURO (uscita totale, trailing dal picco) ---")
|
||||||
|
for t in (0.04,0.06,0.08):
|
||||||
|
show(f"trail-stop -{t*100:.0f}% (re:newhigh)", r, expo_trailstop(r,t,"newhigh"))
|
||||||
|
show("trail-stop -6% (re:recover)", r, expo_trailstop(r,0.06,"recover"))
|
||||||
|
print(" --- altri classici ---")
|
||||||
|
show("stop mensile -5%", r, expo_monthly(r,0.05))
|
||||||
|
show("vol-stop (30g, >90pctl)", r, expo_volstop(r))
|
||||||
|
print("\n NB: lo stop DURO che taglia molto il DD di solito paga in Sharpe/CAGR e in n.trade (whipsaw")
|
||||||
|
print(" nel grind). Confronta col soft-guard: stessa protezione, meno whipsaw?")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""TAIL-HEDGE LAB — proteggere il DD del combo (TP01+GTAA) e gli anni tipo 2022. Valuta OPZIONI.
|
||||||
|
|
||||||
|
Il DD del combo (8.4%) e' grind-lento (2022), non crash. Le PUT proteggono i CRASH (rischio latente
|
||||||
|
gap/overnight + leva), non il grind. Quindi confronto:
|
||||||
|
(A) OPZIONI: put-spread LONG su indice 50/50 BTC/ETH (mirror di VRP01), always-on vs GATED (hold
|
||||||
|
l'hedge solo quando esposti al trend / quando IV economica). Premio BS su DVOL reale, payoff sul
|
||||||
|
path. Misura il BLEED nei calmi + il payoff nei crash (+ stress sintetico -30% overnight).
|
||||||
|
(B) GUARDIA DRAWDOWN: de-risk il combo quando il DD da picco supera X% (o vol spike).
|
||||||
|
(C) VOL-TARGET: cappa la vol del combo a un livello piu' basso.
|
||||||
|
Metriche: Sharpe, MaxDD, anno 2022, drag medio nei calmi, e payoff a uno shock -30%.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
from combo_yearly_report import combo_daily
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
from src.strategies.trend_portfolio import resample_1d
|
||||||
|
from src.portfolio.sleeves import _bs_put, _strike_from_delta, _HL_DIR, _tp01_returns
|
||||||
|
from scipy.stats import norm
|
||||||
|
|
||||||
|
ANN = np.sqrt(252.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _sh(r): r = np.asarray(pd.Series(r).dropna(), float); return float(np.mean(r)/np.std(r)*ANN) if len(r)>5 and np.std(r)>0 else 0.0
|
||||||
|
def _dd(r): eq=np.cumprod(1+np.asarray(r,float)); pk=np.maximum.accumulate(eq); return float(np.max((pk-eq)/pk)) if len(eq) else 0.0
|
||||||
|
def _yr(r,y): return float(np.prod(1+r[r.index.year==y].values)-1) if (r.index.year==y).any() else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def crypto_index():
|
||||||
|
"""Indice 50/50 BTC/ETH (prezzo daily) + DVOL medio, per il pricing dell'hedge."""
|
||||||
|
px={}; dv={}
|
||||||
|
for a in ("BTC","ETH"):
|
||||||
|
df=resample_1d(load_data(a,"1h")); s=pd.Series(df["close"].values.astype(float),index=pd.to_datetime(df["datetime"]))
|
||||||
|
if s.index.tz is None: s.index=s.index.tz_localize("UTC")
|
||||||
|
px[a]=s
|
||||||
|
d=pd.read_parquet(_HL_DIR/f"dvol_{a.lower()}.parquet")
|
||||||
|
dv[a]=pd.Series(d["close"].values.astype(float),index=pd.to_datetime(d["timestamp"],unit="ms",utc=True))
|
||||||
|
P=pd.concat(px,axis=1,join="inner").dropna(); D=pd.concat(dv,axis=1,join="inner").dropna()
|
||||||
|
idx_ret=0.5*P["BTC"].pct_change()+0.5*P["ETH"].pct_change()
|
||||||
|
dvol=(0.5*D["BTC"]+0.5*D["ETH"]).reindex(P.index).ffill()/100.0
|
||||||
|
return idx_ret.dropna(), dvol
|
||||||
|
|
||||||
|
|
||||||
|
def hedge_overlay(idx_ret, dvol, kind="spread", buy_delta=-0.30, sell_delta=-0.10, tenor_d=7, gate_ivr=None):
|
||||||
|
"""P&L settimanale per unita' di nozionale hedge di una protezione LONG comprata ogni settimana
|
||||||
|
sull'indice. kind='spread' = put-spread debit (compra strike vicino buy_delta, vende deep-OTM
|
||||||
|
sell_delta); kind='put' = long put nuda (buy_delta). gate_ivr: compra solo se IV-rank < soglia.
|
||||||
|
Ritorna (pnl_daily, premio_annuo_per_unita)."""
|
||||||
|
idx=idx_ret.index; r=idx_ret.values; sig=dvol.reindex(idx).ffill().values
|
||||||
|
S=np.cumprod(1+r); n=len(S)
|
||||||
|
ivr=pd.Series(sig,index=idx).rolling(252,min_periods=60).apply(lambda x:(x[-1]>=x).mean(),raw=True).values
|
||||||
|
out=np.zeros(n); prem_tot=0.0; i=30; T=tenor_d/365.25
|
||||||
|
while i+tenor_d<n:
|
||||||
|
if gate_ivr is not None and (not np.isfinite(ivr[i]) or ivr[i]>gate_ivr):
|
||||||
|
i+=tenor_d; continue
|
||||||
|
S0=S[i]; vol=sig[i]; ST=S[i+tenor_d]
|
||||||
|
Kb=_strike_from_delta(S0,T,vol,buy_delta)
|
||||||
|
if kind=="put":
|
||||||
|
prem=_bs_put(S0,Kb,T,vol); payoff=max(Kb-ST,0)
|
||||||
|
else:
|
||||||
|
Ks=_strike_from_delta(S0,T,vol,sell_delta) # Kb>Ks (compra vicino, vende lontano)
|
||||||
|
prem=_bs_put(S0,Kb,T,vol)-_bs_put(S0,Ks,T,vol); payoff=max(Kb-ST,0)-max(Ks-ST,0)
|
||||||
|
fee=0.0005*S0
|
||||||
|
out[i+tenor_d]=(payoff-prem-fee)/S0; prem_tot+=(prem+fee)/S0
|
||||||
|
i+=tenor_d
|
||||||
|
yrs=(idx[-1]-idx[0]).days/365.25
|
||||||
|
return pd.Series(out,index=idx), (prem_tot/yrs if yrs>0 else 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def dd_guard(combo, dd_trigger=0.05, look=20):
|
||||||
|
"""De-risk: se il DD da picco supera dd_trigger -> esposizione 0.5 finche' non recupera meta'."""
|
||||||
|
r=combo.values; n=len(r); eq=np.cumprod(1+r); pk=np.maximum.accumulate(eq)
|
||||||
|
expo=np.ones(n); on=True
|
||||||
|
for i in range(1,n):
|
||||||
|
ddi=(pk[i-1]-eq[i-1])/pk[i-1]
|
||||||
|
if ddi>dd_trigger: on=False
|
||||||
|
if ddi<dd_trigger*0.4: on=True
|
||||||
|
expo[i]=1.0 if on else 0.4
|
||||||
|
return pd.Series(expo*r,index=combo.index)
|
||||||
|
|
||||||
|
|
||||||
|
def voltarget(combo, tv=0.07):
|
||||||
|
rv=combo.rolling(30,min_periods=15).std().shift(1)*ANN
|
||||||
|
sc=np.clip(np.nan_to_num(tv/rv.replace(0,np.nan).values,nan=0.0),0,1.5)
|
||||||
|
return pd.Series(combo.values*sc,index=combo.index)
|
||||||
|
|
||||||
|
|
||||||
|
def line(name, r, base=None):
|
||||||
|
r=r.dropna()
|
||||||
|
extra=""
|
||||||
|
if base is not None:
|
||||||
|
b=base.reindex(r.index).dropna(); r2=r.reindex(b.index)
|
||||||
|
extra=f" Δ2022 {(_yr(r2,2022)-_yr(b,2022))*100:+.1f}pp"
|
||||||
|
print(f" {name:34} Sh {_sh(r):>5.2f} MaxDD {_dd(r.values)*100:>4.1f}% 2022 {_yr(r,2022)*100:>+5.1f}% "
|
||||||
|
f"CAGR {((np.prod(1+r.values))**(252/len(r))-1)*100:>+5.1f}%{extra}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("="*100); print(" TAIL-HEDGE LAB — proteggere DD/2022 del combo (TP01+GTAA)"); print("="*100)
|
||||||
|
combo=combo_daily()
|
||||||
|
idx_ret,dvol=crypto_index()
|
||||||
|
print("\n BASELINE")
|
||||||
|
line("combo (1x)", combo)
|
||||||
|
|
||||||
|
print("\n (A) OPZIONI — protezione LONG su crypto, sovrapposta al combo (size = spendi ~3%/anno)")
|
||||||
|
for kind,gate,lbl in [("spread",None,"put-spread sempre"),("spread",0.4,"put-spread gate IVR<0.4"),
|
||||||
|
("put",None,"long put -0.30 sempre"),("put",0.4,"long put gate IVR<0.4")]:
|
||||||
|
ov,annprem=hedge_overlay(idx_ret,dvol,kind=kind,gate_ivr=gate)
|
||||||
|
ov=ov.reindex(combo.index).fillna(0.0)
|
||||||
|
size=0.03/annprem if annprem>0 else 0.0 # nozionale hedge per ~3%/anno di premio
|
||||||
|
line(f"+{lbl} (~3%/y, size {size:.2f}x)", combo+size*ov, base=combo)
|
||||||
|
print(" (stress -30% overnight dell'indice crypto, per unita' di nozionale hedge):")
|
||||||
|
S0=1.0; vol=float(dvol.iloc[-1]); T=7/365.25
|
||||||
|
for kind in ("spread","put"):
|
||||||
|
Kb=_strike_from_delta(S0,T,vol,-0.30)
|
||||||
|
if kind=="put": prem=_bs_put(S0,Kb,T,vol); pay=max(Kb-0.7,0)
|
||||||
|
else: Ks=_strike_from_delta(S0,T,vol,-0.10); prem=_bs_put(S0,Kb,T,vol)-_bs_put(S0,Ks,T,vol); pay=max(Kb-0.7,0)-max(Ks-0.7,0)
|
||||||
|
print(f" {kind:7}: premio {prem*100:.2f}% -> payoff a -30% {pay*100:.2f}% (netto {(pay-prem)*100:+.2f}%)")
|
||||||
|
|
||||||
|
print("\n (B) GUARDIA DRAWDOWN (de-risk a -X% dal picco)")
|
||||||
|
for t in (0.04,0.06):
|
||||||
|
line(f"+dd-guard {t*100:.0f}%", dd_guard(combo,t), base=combo)
|
||||||
|
print("\n (C) VOL-TARGET del combo")
|
||||||
|
for tv in (0.05,0.07):
|
||||||
|
line(f"+vol-target {tv*100:.0f}%", voltarget(combo,tv), base=combo)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
export const meta = {
|
||||||
|
name: 'crypto-lead-sweep',
|
||||||
|
description: 'Sweep multi-agente dell\'anticipazione crypto->mercati IB (mercati x timing x lead), verifica avversariale multi-anno, sintesi della soluzione migliore',
|
||||||
|
phases: [
|
||||||
|
{ title: 'Sweep', detail: '~52 agenti: grid (lead x mercato x giorno x predict x finestra) via harness onesto' },
|
||||||
|
{ title: 'Verify', detail: 'top candidati: stress costi + OOS recente + multi-anno' },
|
||||||
|
{ title: 'Synthesize', detail: 'migliore soluzione robusta + caveat di tradabilita' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- universo mercati certificati (cache su disco) ----
|
||||||
|
const MARKETS = ["SPY","QQQ","IWM","DIA","XLK","XLF","XLE","XLV","XLI","XLP","XLY","XLU","XLB","XLRE","XLC",
|
||||||
|
"HYG","TLT","IEF","GLD","SLV","USO","DBC","VNQ","EEM","FXI","EWJ"]
|
||||||
|
const LEADS = ["BTC","ETH"]
|
||||||
|
const DAYS = ["all","mon"]
|
||||||
|
const PREDICTS = ["gap","intraday"]
|
||||||
|
const HOURS = ["overnight","6"]
|
||||||
|
|
||||||
|
// ---- genera la grid completa ----
|
||||||
|
const grid = []
|
||||||
|
for (const lead of LEADS) for (const target of MARKETS) for (const day of DAYS)
|
||||||
|
for (const predict of PREDICTS) for (const hours of HOURS)
|
||||||
|
grid.push({ lead, target, day, predict, hours })
|
||||||
|
log(`grid totale: ${grid.length} configurazioni (mercati ${MARKETS.length} x lead 2 x giorno 2 x predict 2 x finestra 2)`)
|
||||||
|
|
||||||
|
// ---- chunk in batch da 8 -> ~52 agenti ----
|
||||||
|
const BATCH = 8
|
||||||
|
const batches = []
|
||||||
|
for (let i = 0; i < grid.length; i += BATCH) batches.push(grid.slice(i, i + BATCH))
|
||||||
|
log(`sweep: ${batches.length} agenti, ${BATCH} config ciascuno`)
|
||||||
|
|
||||||
|
const RAW_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: { raw: { type: "string", description: "stdout JSON ESATTO dell'harness, senza modifiche" } },
|
||||||
|
required: ["raw"],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PHASE 1: SWEEP ----
|
||||||
|
phase('Sweep')
|
||||||
|
const sweepResults = await parallel(batches.map((batch, bi) => () =>
|
||||||
|
agent(
|
||||||
|
`Sei un esecutore deterministico. Esegui ESATTAMENTE questo comando dalla root del repo e restituisci il suo stdout.\n\n` +
|
||||||
|
`uv run python scripts/research/crypto_lead_harness.py --configs '${JSON.stringify(batch)}'\n\n` +
|
||||||
|
`Il comando stampa un array JSON di risultati. Mettilo VERBATIM nel campo "raw" (nessun commento, nessuna modifica ai numeri). ` +
|
||||||
|
`Se il comando fallisce, metti il messaggio d'errore in "raw".`,
|
||||||
|
{ label: `sweep:${bi}`, phase: 'Sweep', schema: RAW_SCHEMA, effort: 'low' }
|
||||||
|
)
|
||||||
|
))
|
||||||
|
|
||||||
|
// ---- parse + flatten ----
|
||||||
|
const all = []
|
||||||
|
for (const r of sweepResults) {
|
||||||
|
if (!r || !r.raw) continue
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(r.raw.trim())
|
||||||
|
for (const x of arr) if (x && !x.err) all.push(x)
|
||||||
|
} catch (e) { /* skip batch non parsabile */ }
|
||||||
|
}
|
||||||
|
log(`risultati validi raccolti: ${all.length}/${grid.length}`)
|
||||||
|
|
||||||
|
// ---- ranking robustezza: tutti gli anni positivi + t-stat alto + OOS positivo ----
|
||||||
|
function score(x) {
|
||||||
|
const yr = x.years_tot ? x.years_pos / x.years_tot : 0
|
||||||
|
const oosOK = (x.sh_ls_oos > 0 || x.sh_lf_oos > 0) ? 1 : 0
|
||||||
|
return yr * Math.abs(x.t_incremental || 0) * (1 + Math.max(x.sh_ls_oos || 0, x.sh_lf_oos || 0)) * oosOK
|
||||||
|
}
|
||||||
|
all.sort((a, b) => score(b) - score(a))
|
||||||
|
const top = all.slice(0, 12)
|
||||||
|
// separa per tradabilita': intraday = ETF-tradabile; gap = fenomeno (serve future)
|
||||||
|
const topIntraday = all.filter(x => x.predict === 'intraday').slice(0, 6)
|
||||||
|
const topGap = all.filter(x => x.predict === 'gap').slice(0, 6)
|
||||||
|
log(`TOP overall: ${top.map(x => `${x.lead}->${x.target}/${x.day}/${x.predict} t=${x.t_incremental} oos=${Math.max(x.sh_ls_oos,x.sh_lf_oos)} ${x.years_pos}/${x.years_tot}y`).join(' | ')}`)
|
||||||
|
|
||||||
|
// ---- PHASE 2: VERIFY (stress costi + OOS recente, multi-anno) ----
|
||||||
|
phase('Verify')
|
||||||
|
const VERDICT_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
config: { type: "string" },
|
||||||
|
robust: { type: "boolean", description: "regge stress (costi alti + OOS recente) e multi-anno?" },
|
||||||
|
tradeable_via: { type: "string", description: "etf-intraday | futures-gap | none" },
|
||||||
|
sh_oos_4bps: { type: "number" }, sh_oos_10bps: { type: "number" }, sh_oos_recent: { type: "number" },
|
||||||
|
years_pos: { type: "number" }, years_tot: { type: "number" },
|
||||||
|
note: { type: "string", description: "1-2 frasi: cosa regge, cosa no, spiegazione alternativa" },
|
||||||
|
},
|
||||||
|
required: ["config", "robust", "tradeable_via", "note"],
|
||||||
|
}
|
||||||
|
const cands = [...new Map([...topIntraday, ...topGap, ...top].map(x => [`${x.lead}|${x.target}|${x.day}|${x.predict}|${x.hours}`, x])).values()].slice(0, 12)
|
||||||
|
const verified = await parallel(cands.map((c) => () => {
|
||||||
|
const cfg = JSON.stringify([{ lead: c.lead, target: c.target, day: c.day, predict: c.predict, hours: c.hours }])
|
||||||
|
return agent(
|
||||||
|
`Verifica avversariale di UN candidato lead-lag crypto->mercato. Config: ${JSON.stringify(c)}.\n` +
|
||||||
|
`Esegui questi 3 comandi dalla root e leggi i campi t_incremental, sh_ls_oos, sh_lf_oos, years_pos, years_tot:\n` +
|
||||||
|
`1) base 4bps: uv run python scripts/research/crypto_lead_harness.py --cost 0.0004 --oos 2022-01-01 --configs '${cfg}'\n` +
|
||||||
|
`2) stress 10bps: uv run python scripts/research/crypto_lead_harness.py --cost 0.0010 --oos 2022-01-01 --configs '${cfg}'\n` +
|
||||||
|
`3) OOS recente: uv run python scripts/research/crypto_lead_harness.py --cost 0.0004 --oos 2024-01-01 --configs '${cfg}'\n\n` +
|
||||||
|
`Giudica: robust=true SOLO se l'edge resta positivo a 10bps E nell'OOS recente (2024+) E years_pos/years_tot>=0.6. ` +
|
||||||
|
`tradeable_via: "etf-intraday" se predict=intraday e regge (eseguibile comprando l'ETF al Monday/giorno open); ` +
|
||||||
|
`"futures-gap" se predict=gap e regge (il gap si cattura solo con i futures indice overnight, NON con l'ETF); "none" se non regge. ` +
|
||||||
|
`note: spiega anche un'alternativa plausibile (e' solo risk-beta? autocorrelazione? multiple-testing su ${grid.length} test?).`,
|
||||||
|
{ label: `verify:${c.lead}->${c.target}/${c.predict}`, phase: 'Verify', schema: VERDICT_SCHEMA }
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
const robust = verified.filter(v => v && v.robust)
|
||||||
|
log(`candidati robusti: ${robust.length}/${cands.length}`)
|
||||||
|
|
||||||
|
// ---- PHASE 3: SYNTHESIZE ----
|
||||||
|
phase('Synthesize')
|
||||||
|
const SYNTH_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
best_solution: { type: "string", description: "la migliore soluzione: lead+mercato+timing+predict+come si tradea" },
|
||||||
|
why: { type: "string" },
|
||||||
|
expected_edge: { type: "string", description: "Sharpe OOS onesto (post-stress), hit, anni positivi" },
|
||||||
|
tradeability: { type: "string", description: "ETF intraday vs futures overnight; eseguibile a $0.5-2k?" },
|
||||||
|
multi_year: { type: "string", description: "evidenza su piu' anni (per-anno)" },
|
||||||
|
caveats: { type: "string" },
|
||||||
|
runner_ups: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["best_solution", "why", "expected_edge", "tradeability", "multi_year", "caveats"],
|
||||||
|
}
|
||||||
|
const synthesis = await agent(
|
||||||
|
`Sei l'analista quant capo, disciplina ONESTA (questo progetto uccide i falsi positivi: multiple-testing, hold-out-luck, ` +
|
||||||
|
`tradabilita' reale a basso capitale). Obiettivo: dalla ricerca multi-agente sull'anticipazione crypto->mercati IB, ` +
|
||||||
|
`determina la SOLUZIONE MIGLIORE (mercato + timing + lead) verificata su PIU' ANNI.\n\n` +
|
||||||
|
`TOP candidati (sweep, ${grid.length} config testate): ${JSON.stringify(top)}\n\n` +
|
||||||
|
`VERDETTI di verifica avversariale (stress costi 10bps + OOS recente 2024+): ${JSON.stringify(verified)}\n\n` +
|
||||||
|
`Candidati robusti: ${JSON.stringify(robust)}\n\n` +
|
||||||
|
`Fatti noti: (a) il GAP di apertura (predict=gap) ha t-stat altissimi ma NON e' catturabile con l'ETF (serve un future ` +
|
||||||
|
`indice tradato overnight, es. MNQ/MES su IB); (b) l'INTRADAY (predict=intraday) e' debole ma eseguibile comprando l'ETF; ` +
|
||||||
|
`(c) abbiamo testato ${grid.length} configurazioni -> correggi mentalmente per multiple-testing (un t-stat ~2 non basta qui).\n\n` +
|
||||||
|
`Produci la sintesi: la soluzione migliore REALMENTE utile (distingui fenomeno-forte-non-tradabile da edge-tradabile), ` +
|
||||||
|
`il suo edge atteso onesto post-stress, come si tradea a $0.5-2k, l'evidenza multi-anno, i caveat, e i runner-up.`,
|
||||||
|
{ label: 'synthesize', phase: 'Synthesize', schema: SYNTH_SCHEMA, effort: 'high' }
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
grid_size: grid.length,
|
||||||
|
sweep_agents: batches.length,
|
||||||
|
results_collected: all.length,
|
||||||
|
top12: top,
|
||||||
|
verified,
|
||||||
|
robust_count: robust.length,
|
||||||
|
synthesis,
|
||||||
|
}
|
||||||
@@ -15,11 +15,13 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
|||||||
import numpy as np, pandas as pd
|
import numpy as np, pandas as pd
|
||||||
from src.portfolio.portfolio import StrategyPortfolio, metrics, HOLDOUT
|
from src.portfolio.portfolio import StrategyPortfolio, metrics, HOLDOUT
|
||||||
from src.portfolio.sleeves import active_sleeves
|
from src.portfolio.sleeves import active_sleeves
|
||||||
|
from src.portfolio.gtaa import gtaa_weights
|
||||||
from src.live.shadow import shadow_report, tp01_trades
|
from src.live.shadow import shadow_report, tp01_trades
|
||||||
from src.version import APP_VERSION
|
from src.version import APP_VERSION
|
||||||
|
|
||||||
PAPER = PROJECT_ROOT / "data" / "paper_portfolio" / "state.json"
|
PAPER = PROJECT_ROOT / "data" / "paper_portfolio" / "state.json"
|
||||||
PREVDAY = PROJECT_ROOT / "data" / "paper_prevday" / "state.json"
|
PREVDAY = PROJECT_ROOT / "data" / "paper_prevday" / "state.json"
|
||||||
|
COMBO = PROJECT_ROOT / "data" / "paper_combo" / "state.json"
|
||||||
_CACHE = {"t": 0.0, "data": None}
|
_CACHE = {"t": 0.0, "data": None}
|
||||||
_TTL = 120.0
|
_TTL = 120.0
|
||||||
|
|
||||||
@@ -35,6 +37,11 @@ def build():
|
|||||||
spark = [(str(idx[i].date()), float(eq[i])) for i in range(0, len(eq), step)]
|
spark = [(str(idx[i].date()), float(eq[i])) for i in range(0, len(eq), step)]
|
||||||
paper = json.loads(PAPER.read_text()) if PAPER.exists() else None
|
paper = json.loads(PAPER.read_text()) if PAPER.exists() else None
|
||||||
prevday = json.loads(PREVDAY.read_text()) if PREVDAY.exists() else None
|
prevday = json.loads(PREVDAY.read_text()) if PREVDAY.exists() else None
|
||||||
|
combo = json.loads(COMBO.read_text()) if COMBO.exists() else None
|
||||||
|
try:
|
||||||
|
gtaa_w = gtaa_weights() # pesi ETF correnti azionabili (cache eq_*)
|
||||||
|
except Exception:
|
||||||
|
gtaa_w = None
|
||||||
try:
|
try:
|
||||||
shadow = shadow_report() # mainnet sola lettura, best-effort
|
shadow = shadow_report() # mainnet sola lettura, best-effort
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -49,6 +56,7 @@ def build():
|
|||||||
full=bt["full"], holdout=bt["holdout"], weights=bt["weights"],
|
full=bt["full"], holdout=bt["holdout"], weights=bt["weights"],
|
||||||
per_sleeve=bt["per_sleeve"], yearly=bt["yearly"],
|
per_sleeve=bt["per_sleeve"], yearly=bt["yearly"],
|
||||||
positions=pf.current_positions(), spark=spark, paper=paper, prevday=prevday,
|
positions=pf.current_positions(), spark=spark, paper=paper, prevday=prevday,
|
||||||
|
combo=combo, gtaa_weights=gtaa_w,
|
||||||
shadow=shadow, trades=trades, bh=None,
|
shadow=shadow, trades=trades, bh=None,
|
||||||
)
|
)
|
||||||
_CACHE.update(t=time.time(), data=data)
|
_CACHE.update(t=time.time(), data=data)
|
||||||
@@ -104,6 +112,28 @@ def html():
|
|||||||
f"forward da {pd.Timestamp(pv['start_ts'], unit='ms').date()}")
|
f"forward da {pd.Timestamp(pv['start_ts'], unit='ms').date()}")
|
||||||
else:
|
else:
|
||||||
prevday_html = "non inizializzato (gira <code>scripts/live/paper_prevday.py</code>)"
|
prevday_html = "non inizializzato (gira <code>scripts/live/paper_prevday.py</code>)"
|
||||||
|
cb = d.get("combo")
|
||||||
|
if cb:
|
||||||
|
cdays = (pd.Timestamp(cb["last"]) - pd.Timestamp(cb["start"])).days
|
||||||
|
cret = cb["equity"] / cb["initial"] - 1
|
||||||
|
wc = cb.get("w_crypto", 0.5)
|
||||||
|
head = (f"start {cb['initial']:.0f} · {cb['start'][:10]} → {cb['last'][:10]} ({cdays}g, "
|
||||||
|
f"{cb['n_days']} barre) · blend {wc*100:.0f}/{(1-wc)*100:.0f} TP01/GTAA")
|
||||||
|
combo_html = f"{head}<br><b>NUDO</b> eq <b>{cb['equity']:.2f}</b> ret <b>{cret*100:+.2f}%</b> maxDD {cb['max_dd']*100:.1f}%"
|
||||||
|
if "equity_g" in cb:
|
||||||
|
crg = cb["equity_g"] / cb["initial"] - 1
|
||||||
|
combo_html += (f"<br><b>PROTETTO</b> (guardia-DD −{cb.get('dd_trigger',0.04)*100:.0f}%) "
|
||||||
|
f"eq <b>{cb['equity_g']:.2f}</b> ret <b>{crg*100:+.2f}%</b> maxDD {cb['max_dd_g']*100:.1f}%")
|
||||||
|
else:
|
||||||
|
combo_html = "non inizializzato (gira <code>scripts/live/paper_combo.py</code>)"
|
||||||
|
gw = d.get("gtaa_weights")
|
||||||
|
if gw:
|
||||||
|
asof = gw.get("_asof", "?"); cash = gw.get("_cash")
|
||||||
|
gw_html = (", ".join(f"{k} <b>{v:.0%}</b>" for k, v in gw.items() if not k.startswith("_") and v)
|
||||||
|
+ (f" · cash <b>{cash:.0%}</b>" if cash is not None else "")
|
||||||
|
+ f" <span style='color:#8a93a0'>(asof {asof})</span>")
|
||||||
|
else:
|
||||||
|
gw_html = "n/d (cache ETF assente — gira fetch_ib_equities.py)"
|
||||||
sh = d.get("shadow")
|
sh = d.get("shadow")
|
||||||
if sh and "error" not in sh:
|
if sh and "error" not in sh:
|
||||||
bits = " · ".join(
|
bits = " · ".join(
|
||||||
@@ -175,6 +205,10 @@ th{{color:#8a93a0;font-weight:500}}.y{{display:inline-block;background:#161b22;b
|
|||||||
<h3 style="font-size:14px;color:#8a93a0">Trades TP01 — entry/exit (segnale causale, ultimi 15)</h3>
|
<h3 style="font-size:14px;color:#8a93a0">Trades TP01 — entry/exit (segnale causale, ultimi 15)</h3>
|
||||||
<table><tr><th>data</th><th>asset</th><th>azione</th><th>posizione</th><th>prezzo</th></tr>{trows}</table>
|
<table><tr><th>data</th><th>asset</th><th>azione</th><th>posizione</th><th>prezzo</th></tr>{trows}</table>
|
||||||
<div style="margin-top:10px">{yrs}</div>
|
<div style="margin-top:10px">{yrs}</div>
|
||||||
|
<div class="section">COMBO DEPLOYABLE — cross-venue (paper, forward-only)</div>
|
||||||
|
<div class=box><b>TP01 (Deribit) + GTAA (IB)</b> — le DUE gambe ESEGUIBILI a basso capitale, scorrelate (corr ~0.21) → blend Sharpe ~1.5, drawdown dimezzato. <b>Nessuna esecuzione reale</b>:<br>{combo_html}<br>
|
||||||
|
<span style="color:#8a93a0;font-size:13px">posizioni azionabili IB (GTAA, peso ETF):</span> {gw_html}</div>
|
||||||
|
<p class=warn>⚠️ PAPER cross-venue: valida l'operativita' su due conti (Deribit + IB) a rischio zero. Lo Sharpe ~1.5 e' ottimistico (finestra crypto corta/favorevole); il dato robusto e' la diversificazione (corr 0.21, DD dimezzato), non il livello. XS01/VRP01 esclusi (STAT-MODE): qui solo TP01+GTAA.</p>
|
||||||
<div class="section">FORWARD-MONITOR — lead paper (non deploy)</div>
|
<div class="section">FORWARD-MONITOR — lead paper (non deploy)</div>
|
||||||
<div class=box><b>PREVDAY range-breakout</b> — lead ORTOGONALE a TP01 (corr ~0.15 full / ~0 hold; marginal ADDS, non-hedge, robusto allo shift del confine-giorno). Forward-only, <b>nessuna esecuzione reale</b>:<br>{prevday_html}</div>
|
<div class=box><b>PREVDAY range-breakout</b> — lead ORTOGONALE a TP01 (corr ~0.15 full / ~0 hold; marginal ADDS, non-hedge, robusto allo shift del confine-giorno). Forward-only, <b>nessuna esecuzione reale</b>:<br>{prevday_html}</div>
|
||||||
<p class=warn>⚠️ LEAD in osservazione, NON deployato. Sopravvissuto alla verifica avversariale dell'onda intraday; lo teniamo in paper per validarlo fuori-campione-vero. I due libri (modeled vs real-$600) mostrano l'haircut di fill che lo scettico aveva segnalato.</p>
|
<p class=warn>⚠️ LEAD in osservazione, NON deployato. Sopravvissuto alla verifica avversariale dell'onda intraday; lo teniamo in paper per validarlo fuori-campione-vero. I due libri (modeled vs real-$600) mostrano l'haircut di fill che lo scettico aveva segnalato.</p>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""GTAA — sleeve EQUITY/ETF: trend difensivo multi-asset (analogo di TP01, su IB).
|
||||||
|
|
||||||
|
Validato sul branch research/equities-ib (diari 2026-06-22-equities-*). Trend long-flat TSMOM
|
||||||
|
multi-orizzonte su un paniere di classi (azioni US/tech/small + bond + oro + credito), equal-weight
|
||||||
|
sugli asset in trend-up, vol-target 12%. Difensivo: Sharpe ~0.64 / maxDD ~15% standalone, e — il punto
|
||||||
|
— corr ~0.21 col crypto (TP01) -> diversificatore reale (blend Sharpe ~1.5, DD dimezzato).
|
||||||
|
|
||||||
|
Eseguibile su IB a basso capitale (ETF frazionabili, switch mensile/basso turnover). Legge la CACHE
|
||||||
|
su disco data/raw/eq_*.parquet (ADJUSTED_LAST, scritta da scripts/research/fetch_ib_equities.py);
|
||||||
|
in produzione va rinfrescata giornalmente (gateway IB). Espone rendimenti + PESI CORRENTI (posizioni).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
|
||||||
|
RAW = Path(__file__).resolve().parents[2] / "data" / "raw"
|
||||||
|
EQ_UNIVERSE = ("SPY", "QQQ", "IWM", "TLT", "GLD", "HYG")
|
||||||
|
HORIZONS = (21, 63, 126, 252)
|
||||||
|
TARGET_VOL = 0.12
|
||||||
|
FEE_SIDE = 0.0002
|
||||||
|
ANN = np.sqrt(252.0)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=16)
|
||||||
|
def _close(sym: str) -> pd.Series:
|
||||||
|
p = RAW / f"eq_{sym.lower()}_1d.parquet"
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def _exposure(close: pd.Series) -> pd.Series:
|
||||||
|
"""Esposizione long-flat [0,1] su un asset: frazione di orizzonti in trend-up, vol-targeted, cap 1.
|
||||||
|
Causale (solo dati <= i)."""
|
||||||
|
px = close.values; n = len(px); tgt = np.zeros(n); mh = max(HORIZONS)
|
||||||
|
for i in range(mh, n):
|
||||||
|
tgt[i] = np.mean([1.0 if px[i] > px[i - H] else 0.0 for H in HORIZONS])
|
||||||
|
s = pd.Series(tgt, index=close.index)
|
||||||
|
rv = close.pct_change().rolling(63, min_periods=20).std().shift(1) * ANN
|
||||||
|
scale = np.clip(np.nan_to_num(TARGET_VOL / rv.replace(0, np.nan).values, nan=0.0), 0, 10.0)
|
||||||
|
return (s * scale).clip(0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _gated_returns(sym: str) -> pd.Series:
|
||||||
|
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))
|
||||||
|
return pd.Series(net, index=close.index)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def gtaa_weights(universe=EQ_UNIVERSE) -> dict:
|
||||||
|
"""Pesi target CORRENTI (ultima barra): quanto allocare a ciascun ETF (e quanto in cash).
|
||||||
|
weight_i = esposizione_i / N_disponibili. Azionabile su IB."""
|
||||||
|
out = {}; n = len(universe)
|
||||||
|
for a in universe:
|
||||||
|
try:
|
||||||
|
ex = _exposure(_close(a))
|
||||||
|
out[a] = round(float(ex.iloc[-1]) / n, 4)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
out["_cash"] = round(1.0 - sum(out.values()), 4)
|
||||||
|
out["_asof"] = str(_close("SPY").index[-1].date())
|
||||||
|
return out
|
||||||
Reference in New Issue
Block a user