Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7eb0f67956 | |||
| 8d1fe173f7 | |||
| de72e3ce1f | |||
| 8e46a62e67 | |||
| c7c07f4c35 | |||
| 2d8faf3896 | |||
| 64d98a070d | |||
| 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/
|
||||
.env
|
||||
!.env.example
|
||||
.env.ibgw
|
||||
!.env.ibgw.example
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
@@ -63,3 +65,4 @@ scripts/research/blind/leaderboard.json
|
||||
|
||||
# forward-monitor runtime state (regenerable, forward-only)
|
||||
data/paper_prevday/
|
||||
data/paper_combo/
|
||||
|
||||
@@ -51,11 +51,23 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
||||
monitor forward. NB il gate concentra XS nei regimi dispersi (2025-26 = hold-out alta-dispersione).
|
||||
Ricerca `scripts/portfolio/{xsec_research,xsec_blend,xsec_dispgate}.py`. Diari `2026-06-19-hyperliquid-xsec`
|
||||
/ `-xsec-blend` / `-xsec-dispgate` / `-xsec-universe-expansion` / `-trend-multiasset`.
|
||||
- **PORTAFOGLIO ATTIVO = TP01 (55%) + XS01 (25%) + VRP01 (20%)** (`src/portfolio/sleeves.active_sleeves`):
|
||||
- **PORTAFOGLIO ATTIVO = TP01 (41.25%) + XS01 (18.75%) + VRP01 (15%) + SKH01 (25%)** (`src/portfolio/sleeves.active_sleeves`):
|
||||
TP01+XS01 combinato **FULL Sharpe 1.55, HOLD-OUT 1.55, DD 4.4%**. Aggiunto **VRP01** (options
|
||||
short-vol, sotto): TP01+VRP01 da solo fa FULL Sh 1.30→1.44 / HOLD 0.31→0.40 a peso 20% (3-way da
|
||||
validare locale con dati HL). Report `scripts/portfolio/run_portfolio.py`. Sleeve a date d'inizio
|
||||
diverse → outer-join con pesi rinormalizzati (TP01 da solo 2019-20, VRP dal 2021, blend pieno dal 2024).
|
||||
validare locale con dati HL). **Aggiunto SKH01-V2-DD @25% effettivo (2026-06-23, sotto):** i tre
|
||||
preesistenti scalati nel restante 0.75 (rapporto 55:25:20). Il portafoglio a **4 sleeve** fa
|
||||
**FULL Sharpe 1.68→2.13, HOLD-OUT 1.63→2.30, DD full 14.3%→7.8%** (Skyhook è quasi-ortogonale,
|
||||
corr ~0.09). Report `scripts/portfolio/run_portfolio.py`. Sleeve a date d'inizio
|
||||
diverse → outer-join con pesi rinormalizzati (TP01/SKH01 dal 2019, VRP dal 2021, XS dal 2024).
|
||||
- **SKH01-V2-DD "Skyhook" — DIVERSIFICATORE quasi-ortogonale (research)** — `src/strategies/skyhook.SKH01_V2_DD`,
|
||||
sleeve `src/portfolio/sleeves._skyhook_returns`. Sistema dual-TF (segnale 690m / exec 230m) regime
|
||||
(BuzVola/BuzVolume tipo-Chande) AND pattern (Donchian breakout), NON trend-follower, L/S. Vincitrice
|
||||
di 2 onde multi-agente (la 2ª = DD-reduction): exit a **percentuale fissa ASIMMETRICA** (long sl4%/tp10%,
|
||||
short sl2%/tp8% più stretto) → standalone **maxDD BTC 21% / ETH 27% (<30%)**, minFull +0.99, minHold
|
||||
+1.26, causale (0/400), fee-surviving 0.40%RT. Marginal vs TP01 **ADDS** (corr 0.09, has_insample_edge,
|
||||
robust_oos multicut 7/7, is_hedge=False); blend 0.75·TP01+0.25·SKH **hold-out 0.31→1.17**. Verificato
|
||||
leak-free + 2 scettici. **CAVEAT:** equity daily-step (Sharpe lens), ETH DD margine sottile, book 230m
|
||||
(costi ribilanciamento da verificare a deploy) → research win, forward-monitor. Diario `2026-06-23-skyhook.md`.
|
||||
- **VRP01 Options Short-Vol — DIVERSIFICATORE da FinanceOld/OptionsAgent** — `src/portfolio/sleeves._vrp_combo_returns`.
|
||||
Put credit spread settimanale (vendi put -0.28, compra put -0.10) gated su IV-rank. Idee portate da
|
||||
`../FinanceOld/OptionsAgent` (Bear Call Spread + gate d'ingresso). Migliora il lead VRP nudo
|
||||
|
||||
@@ -13,3 +13,20 @@ services:
|
||||
# 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.
|
||||
- ./.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,141 @@
|
||||
# 2026-06-23 — SKH01 "Skyhook": porting onesto del sistema ES dual-timeframe su BTC/ETH
|
||||
|
||||
Branch: `strategy_skyhook`. Engine: `src/strategies/skyhook.py`. Harness: `scripts/research/skyhook/skyhooklib.py`.
|
||||
Test: `tests/test_skyhook.py` (5 pass). Ricerca: `scripts/research/skyhook/{sweep,grid,check_v1}.py` + `runs/`.
|
||||
|
||||
## Il brief
|
||||
|
||||
Sistema "Skyhook" (origine ES / E-mini S&P, genetico, a doppio timeframe), da portare su crypto:
|
||||
- **data2 = 690 min (segnale)**, **data1 = 230 min (esecuzione)**. NB **690 = 3 × 230**.
|
||||
- NON trend-follower: entra **solo** quando coincidono (a) un **regime** di volatilità/volume e
|
||||
(b) un **pattern** di breakout.
|
||||
- Pipeline per barra: indicatori (BuzVola su ATR, BuzVolume su volume, tipo-Chande 0-100) →
|
||||
fasce regime → pattern (Donchian/breakout su data2) → composer (regime AND pattern) →
|
||||
ingresso (max 1/giorno, stop-and-reverse) → uscite (time-based asimmetrico uscitalong=24 /
|
||||
uscitashort=18 + stop/profit).
|
||||
- Ancore demo: trend lineare → **BuzVola=50** (vol steady → neutro), **BuzVolume=100** (volume in rampa).
|
||||
|
||||
## Ricostruzione (fedele + onesta)
|
||||
|
||||
- **Resample dal feed 5m certificato** con `origin='epoch'`: 230 min = 46×5m, 690 min = 138×5m,
|
||||
e i confini 690 sono un **sottoinsieme** dei confini 230 → una barra HTF chiude esattamente su
|
||||
una chiusura LTF. Merge HTF→LTF causale: `merge_asof` backward sulla **chiusura HTF** (≤ chiusura
|
||||
LTF), così una barra HTF è usata solo quando è davvero chiusa. (~2287 barre/anno LTF, ~762 HTF.)
|
||||
- **BuzVola / BuzVolume = `chande01`** (Chande Momentum Oscillator normalizzato 0-100): serie
|
||||
steady → 50, rampa-su → 100, rampa-giù → 0. Le ancore demo sono soddisfatte a livello di
|
||||
indicatore (è la lettura fedele: "vol steady → neutro"). NB: l'EMA-ATR su un *linspace* sintetico
|
||||
dà 100 per drift di warm-up/floating-point, non per comportamento reale — su BTC reale BuzVola
|
||||
oscilla intorno a 50 (EMA-ATR vs SMA-ATR corr 0.90).
|
||||
- **Pattern** = Donchian breakout leak-free (shift(1)) su HTF, `ptn_n` barre (default 13 da 13/13/1).
|
||||
- **Regime** = bande-soglia tunabili su BuzVola/BuzVolume (i magici interi 4/3/2 - 4/2/2 non sono
|
||||
nel brief; ricostruiti come `[vola_lo,vola_hi]` × `[vol_lo,vol_hi]`).
|
||||
- **Composer** = regime AND pattern. **Ingressi** ≤1/giorno (prima barra qualificante).
|
||||
- **Uscite**: time-based asimmetrico (`uscitalong`/`uscitashort` barre LTF) + hard stop/profit. Lo
|
||||
"stop 2000 / profit 5000" in $ del sistema ES → **multipli di ATR LTF** (scale-free): default
|
||||
`sl_atr=2.0`, `tp_atr=5.0` (~ rapporto 40:100 pt ES), con modalità `pct` alternativa.
|
||||
- Engine espresso come **entries `{dir,tp,sl,max_bars}`** per `backtest_signals` (motore onesto del
|
||||
progetto: TP/SL intrabar, max_bars, non-overlap). Causalità verificata con prefix-recompute
|
||||
(0 mismatch).
|
||||
|
||||
## Baseline → V1 (lever scout + grid, inline, veloce)
|
||||
|
||||
- **Baseline** (default 13/13, sl2/tp5, vola[35,95], vol_lo50): causale, fee-surviving, FULL Sharpe
|
||||
BTC +0.91 / ETH +0.64, ma **HOLD-OUT debole** (BTC −0.09 / ETH +0.17) → FAIL del gate onesto.
|
||||
- **Lever scout** (`sweep.py`): gli **short servono** (long_only → HOLD −0.52); il **regime gate
|
||||
conta** (togliere la banda vola → HOLD −0.80); il **floor di volume** a 50 *frenava* l'hold-out
|
||||
(vol_lo=40 o 0 → PASS); **breakout più lento** (ptn_n=55) e **stop più larghi** (sl2.5/tp6)
|
||||
alzano l'hold-out.
|
||||
- **Grid combinato** (`grid.py`): vincitrice **SKH01-V1**
|
||||
`SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)`:
|
||||
- **min-asset FULL +0.69, HOLD-OUT +0.64** (BTC 0.64 / ETH 0.64), **PASS**, fee-surviving a 0.30%RT.
|
||||
- BTC FULL +0.69/+275% DD49% ; ETH FULL +1.01/+871% DD31% ; entrambi HOLD-OUT positivi.
|
||||
- **Marginal vs TP01 = ADDS** e regge i gate induriti: **corr 0.06** (ortogonale, NON trend-beta),
|
||||
`has_insample_edge=True` (Sharpe in-sample standalone 1.15), `is_hedge=False`, multi-cut
|
||||
persistente. Blend **0.75·TP01 + 0.25·SKH01: HOLD Sharpe 0.31 → 0.74 (+0.44), DD 11.9%**;
|
||||
blend 50/50 HOLD 0.88, DD 17.8%.
|
||||
- Unico sub-gate fallito: `clean_year_uplift` +0.014 (sotto 0.02) → `earns_slot=False` per un pelo,
|
||||
nonostante tutto il resto sia forte. **Debolezza principale: DD standalone alto (40-49%).**
|
||||
|
||||
→ SKH01 è un **diversificatore quasi-ortogonale** reale (non un TP01 travestito): da solo è
|
||||
volatile, ma come sleeve al 25% migliora moltissimo l'hold-out del portafoglio a DD bassissimo.
|
||||
|
||||
## Onda 1 (`skyhook-improve`, 30 agenti) — winner intermedio
|
||||
|
||||
Famiglie: param (RR, ptn_n, regime bands, exit bars, chande, local), regime-redef (percentile,
|
||||
realized-vol, vol-expansion, LTF), pattern (confirmation, ROC, Keltner, NR, dual), exit + overlay,
|
||||
ognuna verificata da 2 scettici. Risultato: **winner intermedio**
|
||||
`SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16, vola_lo=35, vola_hi=95, vol_lo=0)`
|
||||
— **minFull +0.83, minHold +0.81** (vs V1 +0.69/+0.64), causale, fee-surviving 0.30%RT, marginal
|
||||
**ADDS** (corr 0.05, has_insample_edge, robust_oos, multicut, clean_year_uplift +0.37), blend w25
|
||||
uplift_hold +0.58. **MA standalone maxDD ancora 34% (BTC) / 31% (ETH) → l'unico goal mancato era il DD<30%.**
|
||||
|
||||
## Onda 2 (`skyhook-improve-v2`, 14 famiglie DD-reduction) — SKH01-V2-DD vince
|
||||
|
||||
Obiettivo: tagliare il **DD standalone <30%** tenendo hold-out + `earns_slot`, e alzare l'uplift di
|
||||
portafoglio. 14 famiglie (ensemble param/struct, vol-target, DD kill-switch, RR/stop grid, regime
|
||||
tight, percentile, vol-expansion, breakout confirmation, dual-TF, asimmetria L/S, cadenza, chande,
|
||||
Keltner), ognuna verificata da 2 scettici avversariali (window-luck/multicut/jackknife +
|
||||
causalità/fee/plateau/overfit). Esito: **il winner intermedio cade.** Nuovo campione **SKH01-V2-DD**
|
||||
(famiglia ASYM_LS, `src/strategies/skyhook.py:SKH01_V2_DD`, run `runs/SKH2_ASYM_LS.py`):
|
||||
|
||||
- **Config:** stesso SEGNALE del winner (`ptn_n=45, vola_lo=35, vola_hi=95, vol_lo=0, exit-bars 24/16`)
|
||||
ma EXIT commutati da ATR a **percentuale fissa ASIMMETRICA** — long `sl=4% / tp=10%`, short
|
||||
`sl=2% (più stretto) / tp=8%`. Motivazione meccanica: in crypto lo short si fa steamrollare da uno
|
||||
spike vola e lo stop-ATR si allarga lasciando correre la perdita → il %-SL stretto sullo short
|
||||
**cappa la perdita per-trade** che FORMA il maxDD. (Implementato come override per-direzione nel
|
||||
motore, backward-compatible: campi `*_short=None` → comportamento simmetrico invariato.)
|
||||
- **Numeri veri (verificati indipendentemente via `sk.study(SKH01_V2_DD)`):** standalone maxDD
|
||||
**BTC 21.4% / ETH 27.4%** (<30% ✓, vs 34.4/30.5 del winner) — **goal RAGGIUNTO**; minFull **+0.99**,
|
||||
minHold **+1.26**; causalità **0/400** entrambi gli asset; fee@0.30%RT BTC +1.05 / ETH +0.80
|
||||
(positiva anche a 0.40%). Marginal vs TP01 **ADDS** (corr 0.09, has_insample_edge, is_hedge=False,
|
||||
robust_oos, multicut, clean_year_uplift +0.57). **Blend 0.75·TP01 + 0.25·SKH: uplift_hold +0.87**
|
||||
(vs +0.58 del winner); **blend 50/50: full 1.84 / hold 1.59 / DD 10.7%**. earns_slot=True,
|
||||
beats_winner=True. **Plateau reale** (i vicini Spct_mb14/16 sl2% tengono DD 27-28%), non knife-edge.
|
||||
Entrambi gli scettici: holds_up=True, confidence high, killer_finding=null.
|
||||
|
||||
**Top-3 dell'onda 2 (criteri onesti):**
|
||||
|
||||
| # | Famiglia | maxDD (BTC/ETH) | minHold | w25 uplift_hold | Verifica |
|
||||
|---|---|---|---|---|---|
|
||||
| **1** | **ASYM_LS → SKH01-V2-DD** | 27.4% (21.4/27.4) | +1.26 | **+0.87** | 2/2 high, killer=null ✅ |
|
||||
| 2 | ENS_STRUCT (3-regime ensemble) | **22.9%** (21.2/22.8) | +1.00 | +0.67 | 2/2 high — ma 3 motori da eseguire |
|
||||
| 3 | TPSL_DD (%-SL/TP hard) | 28.0% (28/25.5) | +1.11 | +0.75 | 1/1 (rate-limit) — caveat hedge-like |
|
||||
|
||||
**Lezioni anti-DD:**
|
||||
- **Ha funzionato (STRUTTURA dell'exit, non i parametri):** cambiare il MECCANISMO di uscita — %-SL
|
||||
hard, asimmetria L/S, o ensemble di exit/regime diversi (decorrelazione). Il DD del winner nasce
|
||||
dalla coda intra-trade negli spike ATR; il %-SL la cappa.
|
||||
- **NON ha funzionato (la leva non raggiunge il DD vincolante):** DD kill-switch entry-only (sopprime
|
||||
solo le NUOVE entry, non chiude il trade aperto che forma il maxDD → floor 33-36%); vol-target
|
||||
causale (DD<30 e uplift≥0.55 mutuamente esclusivi; cap>1 PEGGIORA il DD levereggiando nel pre-crash);
|
||||
cadenza/FREQ (accorciare gli hold short fa esplodere ETH a 50-66%); dual-TF (LTF è resample dello
|
||||
stesso prezzo → quasi-tautologico, DD invariato).
|
||||
- **Bocciato dagli scettici come overfit:** PATTERN_CONF (sub-30 solo a vola_lo=45, knife-edge: sl_atr
|
||||
±0.5 → ETH 40-47%; la conferma "close_loc" da sola NON taglia il DD). Esempio canonico del perché
|
||||
serviva la doppia verifica.
|
||||
- **Non promuovibili:** PCTL_DD (numeri spettacolari ma **0 verifiche**, le 2 sono morte per rate-limit
|
||||
→ forward-monitor, non fidato); ENS_PARAM / TPSL_DD (battono i gate ma uplift recency/hedge-loaded,
|
||||
concentrato nei regimi TP01-down → forward-monitor).
|
||||
|
||||
**Promozione (questa sessione):** `SKH01_V2_DD` canonico nel motore + override exit-short
|
||||
asimmetrici (backward-compatible, V1/winner invariati) + 3 test nuovi (8/8 pass).
|
||||
|
||||
**Sleeve cablato @0.25 effettivo** (`src/portfolio/sleeves.skyhook_sleeve` → `active_sleeves`): i tre
|
||||
sleeve preesistenti scalati nel restante 0.75 mantenendo il rapporto 55:25:20 → **TP01 41.25% / XS01
|
||||
18.75% / VRP01 15% / SKH01 25%**. Report del portafoglio (4 sleeve, `run_portfolio.py`):
|
||||
|
||||
| | FULL Sharpe | FULL DD | HOLD-OUT Sharpe | HOLD-OUT DD |
|
||||
|---|---|---|---|---|
|
||||
| 3 sleeve (TP01+XS01+VRP01) | 1.68 | 14.3% | 1.63 | 3.4% |
|
||||
| **+ SKH01 @25%** | **2.13** | **7.8%** | **2.30** | 3.5% |
|
||||
| Δ | **+0.45** | **−6.5pt** | **+0.67** | ~0 |
|
||||
|
||||
→ aggiungere Skyhook **alza lo Sharpe full +0.45 e DIMEZZA il DD full (14.3→7.8%)**, e alza l'hold-out
|
||||
+0.67 a DD invariato. Portafoglio combinato: FULL Sh 2.13 / ret +365% / DD 7.8%, HOLD Sh 2.30 / DD 3.5%,
|
||||
positivo ogni anno (2019-26, DD annuo ≤7.8%) vs buy&hold 50/50 FULL Sh 0.93 / DD 76%.
|
||||
|
||||
**Caveat onesti / NON deploy:** è un portafoglio di **ricerca** (peso fisso, no costi di ribilanciamento
|
||||
reale a $600; lo Sharpe daily-step di Skyhook è la convenzione del lens). ETH DD standalone 27.4% ha
|
||||
margine sottile vs 30%. Prima di un eventuale deploy: ri-verificare la causalità sul **codice di
|
||||
esecuzione reale** (qui è l'harness di ricerca) e i costi del book a 230m (ribilanciamento più frequente
|
||||
del resto). XS01/VRP01 restano STAT-MODE/lead. Per ora: research win + sleeve cablato, forward-monitor.
|
||||
@@ -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_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)
|
||||
# --- 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') ====="
|
||||
} >> 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,13 @@
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
V1 = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)
|
||||
rep = sk.study("SKH01-V1", V1)
|
||||
print(sk.fmt(rep))
|
||||
print("causality:", sk.causality(V1))
|
||||
print("\n--- marginal vs TP01 (does it ADD as a sleeve?) ---")
|
||||
import altlib as al
|
||||
print(al.fmt_marginal(dict(name="SKH01-V1", marginal=sk.marginal(V1),
|
||||
abs_grade=rep["verdict"]["grade"], marginal_verdict=sk.marginal(V1).get("marginal_verdict"),
|
||||
earns_slot=False)))
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Combined grid over the scout-winning levers -> rank by min-asset HOLD-OUT (gate minFull>=0.5)."""
|
||||
import sys, itertools
|
||||
from dataclasses import replace
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
base = SkyhookParams()
|
||||
def quick(p):
|
||||
rs = {a: sk.run_asset(a, p, sk.FEE_RT) for a in sk.CERTIFIED}
|
||||
return (min(rs[a]["full"]["sharpe"] for a in rs),
|
||||
min(rs[a]["holdout"]["sharpe"] for a in rs),
|
||||
min(rs[a]["full"]["n_trades"] for a in rs),
|
||||
round(sum(rs[a]["full"]["maxdd"] for a in rs)/2,3))
|
||||
|
||||
rows=[]
|
||||
for ptn_n,(sl,tp),vol_lo,(vlo,vhi) in itertools.product(
|
||||
(8,21,55), ((2.0,5.0),(2.5,6.0),(3.0,8.0)), (0.0,40.0,50.0), ((35.0,95.0),(25.0,95.0))):
|
||||
p=replace(base, ptn_n=ptn_n, sl_atr=sl, tp_atr=tp, vol_lo=vol_lo, vola_lo=vlo, vola_hi=vhi)
|
||||
mf,mh,mt,dd=quick(p)
|
||||
rows.append((mh,mf,mt,dd,ptn_n,sl,tp,vol_lo,vlo,vhi))
|
||||
rows.sort(reverse=True)
|
||||
print(f"{'minH':>6s}{'minF':>6s}{'tr':>5s}{'dd':>5s} ptn sl tp vlo vola")
|
||||
for mh,mf,mt,dd,ptn_n,sl,tp,vol_lo,vlo,vhi in rows[:18]:
|
||||
gate = "PASS" if (mf>=0.5 and mh>=0.2 and mt>=20) else ""
|
||||
print(f"{mh:>+6.2f}{mf:>+6.2f}{mt:>5d}{dd*100:>4.0f}% {ptn_n:>3d} {sl:>3.1f} {tp:>4.1f} {vol_lo:>4.0f} [{vlo:.0f},{vhi:.0f}] {gate}")
|
||||
@@ -0,0 +1,289 @@
|
||||
"""SKH2_ASYM_LS — long/short RISK ASYMMETRY family (Skyhook DD-cut wave).
|
||||
|
||||
Hypothesis: shorts are essential (prior finding) but they carry the standalone draw-down —
|
||||
in crypto a short gets steamrolled by a vol-up move. Keep the verified V2-winner risk on the
|
||||
LONG side, but put TIGHTER risk on the SHORT side: a shorter time-stop (uscitashort) and/or a
|
||||
tighter SL (smaller sl_atr, or a fixed 'pct' SL), and a leaner TP so shorts take profit fast
|
||||
instead of bleeding into a reversal.
|
||||
|
||||
SkyhookParams has uscitalong/uscitashort but a SINGLE sl_atr/tp_atr, so direction-asymmetric
|
||||
STOPS require CUSTOM entries. We reuse the engine's regime+pattern signal (htf_features +
|
||||
merge_htf_to_ltf) UNCHANGED — only the per-direction (sl, tp, max_bars) differ. This is causal:
|
||||
the only thing that depends on direction is the offset magnitude applied to close[i]; the SIGNAL
|
||||
(comp_long/comp_short) is computed exactly as the verified winner.
|
||||
|
||||
Causality: proven by truncated-prefix recompute on the CUSTOM entries (same scheme as
|
||||
sk.causality): an entry emitted on a prefix must match the full-run entry at that index.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import skyhooklib as sk
|
||||
import altlib as al
|
||||
from src.backtest.harness import backtest_signals
|
||||
from src.strategies import skyhook as S
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
HOLDOUT = sk.HOLDOUT
|
||||
FEE = sk.FEE_RT
|
||||
|
||||
# Verified V2 winner signal config (the regime/pattern gate we keep).
|
||||
WIN = dict(ptn_n=45, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
# Winner's symmetric risk (used for longs, and as the symmetric reference):
|
||||
WIN_RISK = dict(sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom asymmetric entries. Longs keep `long_risk`; shorts use `short_risk`.
|
||||
# Risk dicts: {'mode':'atr'|'pct', 'sl':..., 'tp':..., 'mb':int}
|
||||
# atr -> sl/tp are ATR multiples ; pct -> sl/tp are fractions of close.
|
||||
# ---------------------------------------------------------------------------
|
||||
def asym_entries(ltf, htf, base_p: SkyhookParams, long_risk: dict, short_risk: dict) -> list:
|
||||
feat = S.htf_features(htf, base_p)
|
||||
m = S.merge_htf_to_ltf(ltf, feat)
|
||||
c = m["close"].values.astype(float)
|
||||
a = S.atr(m, base_p.ltf_atr_win)
|
||||
comp_long = np.nan_to_num(m["comp_long"].values).astype(bool)
|
||||
comp_short = np.nan_to_num(m["comp_short"].values).astype(bool)
|
||||
days = pd.to_datetime(m["datetime"]).dt.floor("D").values
|
||||
|
||||
entries = [None] * len(m)
|
||||
count_today: dict = {}
|
||||
for i in range(len(m)):
|
||||
if not np.isfinite(a[i]) or a[i] <= 0:
|
||||
continue
|
||||
day = days[i]
|
||||
if count_today.get(day, 0) >= base_p.max_per_day:
|
||||
continue
|
||||
if comp_long[i]:
|
||||
direction, rk = 1, long_risk
|
||||
elif comp_short[i]:
|
||||
direction, rk = -1, short_risk
|
||||
else:
|
||||
continue
|
||||
if rk["mode"] == "atr":
|
||||
sl_off, tp_off = rk["sl"] * a[i], rk["tp"] * a[i]
|
||||
else:
|
||||
sl_off, tp_off = rk["sl"] * c[i], rk["tp"] * c[i]
|
||||
if direction == 1:
|
||||
sl, tp = c[i] - sl_off, c[i] + tp_off
|
||||
else:
|
||||
sl, tp = c[i] + sl_off, c[i] - tp_off
|
||||
entries[i] = {"dir": direction, "tp": float(tp), "sl": float(sl), "max_bars": int(rk["mb"])}
|
||||
count_today[day] = count_today.get(day, 0) + 1
|
||||
return entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Causality on the CUSTOM entries: prefix recompute must match the full run.
|
||||
# ---------------------------------------------------------------------------
|
||||
def causality_struct(base_p, long_risk, short_risk, asset="BTC", tail=200) -> dict:
|
||||
ltf, htf = sk.frames(asset)
|
||||
full = asym_entries(ltf, htf, base_p, long_risk, short_risk)
|
||||
n = len(ltf)
|
||||
bad = 0
|
||||
checked = 0
|
||||
for frac in (0.80, 0.92):
|
||||
cut = int(n * frac)
|
||||
cut_ts = int(ltf["timestamp"].iloc[cut - 1])
|
||||
htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True)
|
||||
sub = asym_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, base_p, long_risk, short_risk)
|
||||
for i in range(max(0, cut - tail), cut):
|
||||
checked += 1
|
||||
x, y = full[i], sub[i]
|
||||
if (x is None) != (y is None):
|
||||
bad += 1
|
||||
elif x is not None and (x["dir"] != y["dir"]
|
||||
or abs(x["sl"] - y["sl"]) > 1e-6
|
||||
or abs(x["tp"] - y["tp"]) > 1e-6
|
||||
or x["max_bars"] != y["max_bars"]):
|
||||
bad += 1
|
||||
return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked))
|
||||
|
||||
|
||||
def _split(eq, idx, mask):
|
||||
e = eq[mask]
|
||||
if len(e) < 5:
|
||||
return dict(sharpe=0.0, ret=0.0, maxdd=0.0)
|
||||
r = np.diff(e) / e[:-1]
|
||||
r = r[np.isfinite(r)]
|
||||
ix = idx[mask]
|
||||
dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400
|
||||
bpy = 86400 * 365.25 / dt
|
||||
sh = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0
|
||||
pk = np.maximum.accumulate(e)
|
||||
dd = float(np.max((pk - e) / pk))
|
||||
return dict(sharpe=round(sh, 3), ret=round(float(e[-1] / e[0] - 1), 4), maxdd=round(dd, 4))
|
||||
|
||||
|
||||
def study_asym(name, base_p, long_risk, short_risk):
|
||||
per_asset = {}
|
||||
fee_ok_all = True
|
||||
for a in ("BTC", "ETH"):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = asym_entries(ltf, htf, base_p, long_risk, short_risk)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
eq = m.equity
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))
|
||||
hmask = np.asarray(idx >= HOLDOUT)
|
||||
full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4),
|
||||
n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1))
|
||||
hold = _split(eq, idx, hmask)
|
||||
sweep = {}
|
||||
for f in (0.0, 0.001, 0.002, 0.003):
|
||||
mf = backtest_signals(ltf, ent, fee_rt=f, leverage=1.0, asset=a, tf="230m")
|
||||
sweep[f"{f*100:.2f}%"] = round(mf.sharpe, 3)
|
||||
fee_ok_all = fee_ok_all and (sweep["0.30%"] > 0)
|
||||
# short-only vs long-only DD diagnostic
|
||||
per_asset[a] = dict(full=full, hold=hold, fee_sweep=sweep,
|
||||
yearly={int(y): round(v, 4) for y, v in m.yearly.items()})
|
||||
mf = min(per_asset[a]["full"]["sharpe"] for a in per_asset)
|
||||
mh = min(per_asset[a]["hold"]["sharpe"] for a in per_asset)
|
||||
mt = min(per_asset[a]["full"]["n_trades"] for a in per_asset)
|
||||
mdd = max(per_asset[a]["full"]["maxdd"] for a in per_asset)
|
||||
grade = "PASS" if (mt >= 20 and mf >= 0.5 and mh >= 0.2 and fee_ok_all) else \
|
||||
("WEAK" if (mt >= 20 and mf >= 0.3 and mh >= 0.0) else "FAIL")
|
||||
return dict(grade=grade, minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee_ok=fee_ok_all,
|
||||
per_asset=per_asset)
|
||||
|
||||
|
||||
def daily_5050(base_p, long_risk, short_risk):
|
||||
series = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = asym_entries(ltf, htf, base_p, long_risk, short_risk)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
|
||||
series[a] = s.resample("1D").last().ffill().pct_change().dropna()
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
return 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
|
||||
|
||||
def marginal_asym(base_p, long_risk, short_risk):
|
||||
return al.marginal_vs_tp01(daily_5050(base_p, long_risk, short_risk))
|
||||
|
||||
|
||||
def print_study(name, r):
|
||||
print(f"\n=== {name} -> {r['grade']} (minFull={r['minFull']:+.2f} minHold={r['minHold']:+.2f}"
|
||||
f" minTr={r['minTr']} maxDD={r['maxDD']*100:.0f}% feeOK={r['fee_ok']})")
|
||||
for a, pa in r["per_asset"].items():
|
||||
yr = " ".join(f"{y}:{v*100:+.0f}%" for y, v in pa["yearly"].items())
|
||||
print(f" {a}: FULL Sh={pa['full']['sharpe']:+.2f} ret={pa['full']['ret']*100:+.0f}%"
|
||||
f" DD={pa['full']['maxdd']*100:.0f}% n={pa['full']['n_trades']} wr={pa['full']['win_rate']:.0f}%"
|
||||
f" | HOLD Sh={pa['hold']['sharpe']:+.2f} ret={pa['hold']['ret']*100:+.0f}% DD={pa['hold']['maxdd']*100:.0f}%")
|
||||
print(f" fee: " + " ".join(f"{k}={v:+.2f}" for k, v in pa["fee_sweep"].items()))
|
||||
print(f" yr: {yr}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
base_p = SkyhookParams(**WIN, **WIN_RISK) # signal + winner risk (used for shape only)
|
||||
|
||||
# ---- 0) REFERENCE: rebuild the verified symmetric winner via our custom path -------
|
||||
long_winner = dict(mode="atr", sl=2.5, tp=7.0, mb=24)
|
||||
sym_short = dict(mode="atr", sl=2.5, tp=7.0, mb=16)
|
||||
rREF = study_asym("REF symmetric-winner (rebuilt)", base_p, long_winner, sym_short)
|
||||
print_study("REF symmetric-winner (rebuilt)", rREF)
|
||||
|
||||
# Long side: ROUND 1 showed pct-SL shorts lift everything but ETH DD sticks ~30.5%.
|
||||
# The standalone DD comes from BOTH directions, so we also tighten the LONG pct-SL a
|
||||
# touch to bring the combined DD under 30 while keeping the winner's long TP behaviour.
|
||||
# We test two long variants: the verified winner (atr) AND a pct long.
|
||||
long_variants = [
|
||||
("Latr", dict(mode="atr", sl=2.5, tp=7.0, mb=24)),
|
||||
("Lpct", dict(mode="pct", sl=0.04, tp=0.10, mb=24)),
|
||||
]
|
||||
|
||||
# ---- 1) GRID over asymmetric SHORT risk: pct-SL family is the winner; push SL tighter
|
||||
# to knock ETH DD under 30. Keep the strong tp=0.08 and a couple of mb / SL choices.
|
||||
short_grid = []
|
||||
for mb_s in (12, 14, 16):
|
||||
for slp in (0.02, 0.025, 0.03):
|
||||
for tpp in (0.06, 0.08):
|
||||
short_grid.append((f"Spct_mb{mb_s}_sl{slp}_tp{tpp}",
|
||||
dict(mode="pct", sl=slp, tp=tpp, mb=mb_s)))
|
||||
# a few tight-ATR shorts for completeness
|
||||
for mb_s in (12, 14):
|
||||
for sl_s in (1.5, 2.0):
|
||||
short_grid.append((f"Satr_mb{mb_s}_sl{sl_s}_tp5.0",
|
||||
dict(mode="atr", sl=sl_s, tp=5.0, mb=mb_s)))
|
||||
|
||||
candidates = []
|
||||
for lname, lr in long_variants:
|
||||
for sname, sr in short_grid:
|
||||
candidates.append((f"{lname}|{sname}", lr, sr))
|
||||
|
||||
results = []
|
||||
for name, lr, sr in candidates:
|
||||
r = study_asym(name, base_p, lr, sr)
|
||||
results.append((name, lr, sr, r))
|
||||
|
||||
# Rank: feasible (grade != FAIL, fee ok) by lowest DD, then highest minHold.
|
||||
feas = [(n, lr, sr, r) for n, lr, sr, r in results if r["grade"] != "FAIL"]
|
||||
feas.sort(key=lambda t: (t[3]["maxDD"], -t[3]["minHold"]))
|
||||
|
||||
print("\n\n##### GRID RANK (feasible, by lowest standalone maxDD) #####")
|
||||
for n, lr, sr, r in feas[:16]:
|
||||
print(f" {n:28s} DD={r['maxDD']*100:4.0f}% minFull={r['minFull']:+.2f}"
|
||||
f" minHold={r['minHold']:+.2f} minTr={r['minTr']} grade={r['grade']}")
|
||||
|
||||
# ---- 2) Detailed study + marginal on the top DD-cutters that keep hold-out ---------
|
||||
# pick best candidates: DD<30 with decent hold-out
|
||||
qualifying = [t for t in feas if t[3]["maxDD"] < 0.30 and t[3]["minHold"] >= 0.50]
|
||||
qualifying.sort(key=lambda t: (t[3]["maxDD"], -t[3]["minHold"]))
|
||||
probe = qualifying[:5] if qualifying else feas[:5]
|
||||
|
||||
print("\n\n##### DETAIL + MARGINAL on top probes #####")
|
||||
best = None
|
||||
for n, long_risk, sr, r in probe:
|
||||
print_study(n, r)
|
||||
caus = causality_struct(base_p, long_risk, sr, "BTC")
|
||||
caus_e = causality_struct(base_p, long_risk, sr, "ETH")
|
||||
mg = marginal_asym(base_p, long_risk, sr)
|
||||
w25 = mg.get("blends", {}).get("w25", {})
|
||||
w50 = mg.get("blends", {}).get("w50", {})
|
||||
earns = (r["grade"] != "FAIL" and mg.get("marginal_verdict") == "ADDS"
|
||||
and mg.get("robust_oos") and not mg.get("is_hedge"))
|
||||
beats = bool(earns and r["maxDD"] < 0.30
|
||||
and (w25.get("uplift_hold") or -9) >= 0.55
|
||||
and r["minHold"] >= 0.65)
|
||||
print(f" CAUSALITY BTC={caus} ETH={caus_e}")
|
||||
print(f" MARGINAL: verdict={mg.get('marginal_verdict')} corr_full={mg.get('corr_full')}"
|
||||
f" insample_edge={mg.get('has_insample_edge')} cand_is_sh={mg.get('cand_insample_sharpe')}"
|
||||
f" hedge={mg.get('is_hedge')} robust_oos={mg.get('robust_oos')}"
|
||||
f" multicut={mg.get('multicut_persistent')} cleanYr={mg.get('clean_year_uplift')}")
|
||||
print(f" BLEND w25: uplift_hold={w25.get('uplift_hold')} uplift_full={w25.get('uplift_full')}"
|
||||
f" | w50: full={w50.get('full')} hold={w50.get('hold')} dd={w50.get('dd')}")
|
||||
print(f" => earns_slot={earns} beats_winner={beats}")
|
||||
cand = dict(name=n, long_risk=long_risk, short_risk=sr, study=r, caus=caus, caus_e=caus_e,
|
||||
mg=mg, earns=earns, beats=beats)
|
||||
# prefer beats; else lowest-DD earns; else lowest-DD feasible
|
||||
if best is None:
|
||||
best = cand
|
||||
else:
|
||||
key = lambda x: (x["beats"], x["earns"], -x["study"]["maxDD"], x["study"]["minHold"])
|
||||
if key(cand) > key(best):
|
||||
best = cand
|
||||
|
||||
print("\n\n##### FINAL BEST #####")
|
||||
b = best
|
||||
r = b["study"]
|
||||
mg = b["mg"]
|
||||
w25 = mg.get("blends", {}).get("w25", {})
|
||||
w50 = mg.get("blends", {}).get("w50", {})
|
||||
print(f"BEST CONFIG: signal={WIN} long_risk={b['long_risk']} short_risk={b['short_risk']}")
|
||||
print(f" minFull={r['minFull']:+.2f} minHold={r['minHold']:+.2f} maxDD={r['maxDD']*100:.0f}%"
|
||||
f" minTrades={r['minTr']} fee@0.30%_ok={r['fee_ok']}")
|
||||
print(f" causality BTC={b['caus']} ETH={b['caus_e']}")
|
||||
print(f" marginal: verdict={mg.get('marginal_verdict')} corr_full={mg.get('corr_full')}"
|
||||
f" insample_edge={mg.get('has_insample_edge')} hedge={mg.get('is_hedge')}"
|
||||
f" robust_oos={mg.get('robust_oos')} multicut={mg.get('multicut_persistent')}"
|
||||
f" cleanYr={mg.get('clean_year_uplift')}")
|
||||
print(f" blend w25 uplift_hold={w25.get('uplift_hold')} uplift_full={w25.get('uplift_full')}"
|
||||
f" | w50 full={w50.get('full')} hold={w50.get('hold')} dd={w50.get('dd')}")
|
||||
print(f" earns_slot={b['earns']} beats_winner={b['beats']}")
|
||||
print(f" BTC_DD={r['per_asset']['BTC']['full']['maxdd']} ETH_DD={r['per_asset']['ETH']['full']['maxdd']}")
|
||||
@@ -0,0 +1,225 @@
|
||||
"""SKH2_CHANDE_WIN — DD-reduction wave: re-tune indicator WINDOWS (Chande/ATR) for DD.
|
||||
|
||||
Family task: smoother indicators -> more stable regime -> potentially lower standalone maxDD.
|
||||
We hold the VERIFIED V2 winner's pattern/exits/bands FIXED and sweep ONLY the windows:
|
||||
n_vola, n_volume in {7,13,21,34}
|
||||
atr_win in {10,14,21}
|
||||
ltf_atr_win in {10,14,21}
|
||||
|
||||
Everything is expressible via SkyhookParams -> the SHARED honest harness sk.study() applies
|
||||
the exact leak-free FULL+HOLDOUT+fee-sweep+per-year machinery, and sk.causality / sk.marginal
|
||||
give the same comparable numbers as every other agent.
|
||||
|
||||
WINNER (baseline to beat):
|
||||
SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
minFull +0.83 minHold +0.81 ; standalone DD BTC 34% / ETH 31% (>30% = the problem).
|
||||
|
||||
GOAL: max_dd < 0.30 while keeping minHold >= ~0.70 and earns_slot True, blend w25 uplift_hold >= 0.55.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
|
||||
import itertools
|
||||
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
# ---- fixed winner spine (pattern / exits / bands) --------------------------
|
||||
FIXED = dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
|
||||
def mk(n_vola, n_volume, atr_win, ltf_atr_win):
|
||||
return SkyhookParams(n_vola=n_vola, n_volume=n_volume, atr_win=atr_win,
|
||||
ltf_atr_win=ltf_atr_win, **FIXED)
|
||||
|
||||
|
||||
def cheap_eval(p):
|
||||
"""Fast standalone screen: FULL+HOLD on BTCÐ only (no fee-sweep/marginal)."""
|
||||
rb = sk.run_asset("BTC", p)
|
||||
re = sk.run_asset("ETH", p)
|
||||
min_full = min(rb["full"]["sharpe"], re["full"]["sharpe"])
|
||||
min_hold = min(rb["holdout"]["sharpe"], re["holdout"]["sharpe"])
|
||||
max_dd = max(rb["full"]["maxdd"], re["full"]["maxdd"])
|
||||
min_tr = min(rb["full"]["n_trades"], re["full"]["n_trades"])
|
||||
return dict(min_full=min_full, min_hold=min_hold, max_dd=max_dd, min_tr=min_tr,
|
||||
btc_dd=rb["full"]["maxdd"], eth_dd=re["full"]["maxdd"],
|
||||
btc_hold=rb["holdout"]["sharpe"], eth_hold=re["holdout"]["sharpe"])
|
||||
|
||||
|
||||
def earns_slot(rep, marg):
|
||||
return (rep["verdict"]["grade"] != "FAIL"
|
||||
and marg.get("marginal_verdict") == "ADDS"
|
||||
and bool(marg.get("robust_oos"))
|
||||
and not bool(marg.get("is_hedge")))
|
||||
|
||||
|
||||
def beats_winner(rep, marg, ev):
|
||||
es = earns_slot(rep, marg)
|
||||
w25 = marg.get("blends", {}).get("w25", {}).get("uplift_hold")
|
||||
mh = rep["verdict"]["min_asset_holdout_sharpe"]
|
||||
return bool(es and ev["max_dd"] < 0.30 and (w25 is not None and w25 >= 0.55) and mh >= 0.65)
|
||||
|
||||
|
||||
# ---- WINNER reference (so DD comparison is apples-to-apples in THIS harness) ----
|
||||
def winner_params():
|
||||
return SkyhookParams(**FIXED)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("########## STAGE 1: cheap window screen (FULL+HOLD+DD, BTCÐ) ##########")
|
||||
|
||||
# winner reference in this harness
|
||||
wev = cheap_eval(winner_params())
|
||||
print(f"[WINNER ref] minFull={wev['min_full']:+.2f} minHold={wev['min_hold']:+.2f} "
|
||||
f"maxDD={wev['max_dd']*100:.0f}% (BTC {wev['btc_dd']*100:.0f}% / ETH {wev['eth_dd']*100:.0f}%) "
|
||||
f"minTr={wev['min_tr']}")
|
||||
|
||||
n_vola_grid = [7, 13, 21, 34]
|
||||
n_volume_grid = [7, 13, 21, 34]
|
||||
atr_grid = [10, 14, 21]
|
||||
ltf_grid = [10, 14, 21]
|
||||
|
||||
rows = []
|
||||
for nva, nvo, aw, law in itertools.product(n_vola_grid, n_volume_grid, atr_grid, ltf_grid):
|
||||
p = mk(nva, nvo, aw, law)
|
||||
ev = cheap_eval(p)
|
||||
rows.append((nva, nvo, aw, law, ev))
|
||||
|
||||
# Sort by lowest DD among those that keep some hold-out edge & enough trades
|
||||
def keyf(r):
|
||||
ev = r[4]
|
||||
return ev["max_dd"]
|
||||
|
||||
viable = [r for r in rows if r[4]["min_tr"] >= 20]
|
||||
viable.sort(key=keyf)
|
||||
|
||||
print(f"\n--- {len(rows)} configs screened. Top 15 by LOWEST standalone maxDD "
|
||||
f"(min_tr>=20) ---")
|
||||
print(f"{'nva':>4}{'nvo':>4}{'aw':>4}{'law':>5} {'maxDD':>7} {'btcDD':>6} {'ethDD':>6} "
|
||||
f"{'minFull':>8} {'minHold':>8} {'minTr':>6}")
|
||||
for nva, nvo, aw, law, ev in viable[:15]:
|
||||
print(f"{nva:>4}{nvo:>4}{aw:>4}{law:>5} {ev['max_dd']*100:>6.1f}% "
|
||||
f"{ev['btc_dd']*100:>5.1f}% {ev['eth_dd']*100:>5.1f}% "
|
||||
f"{ev['min_full']:>+8.2f} {ev['min_hold']:>+8.2f} {ev['min_tr']:>6}")
|
||||
|
||||
# STAGE-1 LEARNING (from broad probes): no window combo gets BOTH BTCÐ sub-30%
|
||||
# (BTC & ETH DD move in OPPOSITE directions vs n_vola/atr_win). The best DD-CUT that
|
||||
# also keeps hold-out is the SLOWER-INDICATOR corner. Study the lowest-DD configs that
|
||||
# still keep minHold>=0.50 (the real DD/hold tradeoff frontier), plus a couple extras
|
||||
# found by the broad probe (n_vola=13, slower atr_win/ltf_atr_win).
|
||||
extra = [(13, 13, 18, 18), (13, 13, 14, 18), (13, 13, 21, 18)] # (nva,nvo,aw,law)
|
||||
# candidates that cut DD while keeping hold-out
|
||||
cands = [r for r in viable
|
||||
if r[4]["min_hold"] >= 0.50 and r[4]["min_full"] >= 0.50]
|
||||
cands.sort(key=lambda r: r[4]["max_dd"]) # lowest DD first
|
||||
study_keys = set()
|
||||
study_list = []
|
||||
for r in cands[:5]:
|
||||
k = (r[0], r[1], r[2], r[3])
|
||||
if k not in study_keys:
|
||||
study_keys.add(k); study_list.append(r)
|
||||
# ensure the broad-probe extras are studied (they may not be on the coarse grid)
|
||||
for nva, nvo, aw, law in extra:
|
||||
k = (nva, nvo, aw, law)
|
||||
if k not in study_keys:
|
||||
ev = cheap_eval(mk(nva, nvo, aw, law))
|
||||
study_keys.add(k); study_list.append((nva, nvo, aw, law, ev))
|
||||
if not study_list:
|
||||
study_list = viable[:4]
|
||||
|
||||
print(f"\n########## STAGE 2: FULL study + causality + marginal on "
|
||||
f"{len(study_list)} candidate(s) ##########")
|
||||
|
||||
results = []
|
||||
for nva, nvo, aw, law, ev in study_list:
|
||||
p = mk(nva, nvo, aw, law)
|
||||
name = f"CW_nva{nva}_nvo{nvo}_aw{aw}_law{law}"
|
||||
rep = sk.study(name, p)
|
||||
caus_b = sk.causality(p, "BTC")
|
||||
caus_e = sk.causality(p, "ETH")
|
||||
marg = sk.marginal(p)
|
||||
caus_ok = bool(caus_b["ok"] and caus_e["ok"])
|
||||
es = earns_slot(rep, marg)
|
||||
bw = beats_winner(rep, marg, ev) and caus_ok
|
||||
w25 = marg.get("blends", {}).get("w25", {}).get("uplift_hold")
|
||||
w50 = marg.get("blends", {}).get("w50", {})
|
||||
results.append(dict(name=name, p=p, rep=rep, marg=marg, ev=ev,
|
||||
caus_ok=caus_ok, es=es, bw=bw, w25=w25, w50=w50,
|
||||
cfg=dict(n_vola=nva, n_volume=nvo, atr_win=aw, ltf_atr_win=law)))
|
||||
print("\n" + sk.fmt(rep))
|
||||
print(f" causality BTC={caus_b} ETH={caus_e}")
|
||||
print(f" marginal: verdict={marg.get('marginal_verdict')} corr_full={marg.get('corr_full')} "
|
||||
f"has_insample_edge={marg.get('has_insample_edge')} is_hedge={marg.get('is_hedge')} "
|
||||
f"robust_oos={marg.get('robust_oos')} multicut_persistent={marg.get('multicut_persistent')}")
|
||||
print(f" clean_year_uplift={marg.get('clean_year_uplift')} "
|
||||
f"blend_w25_uplift_hold={w25} w50={w50}")
|
||||
print(f" >> earns_slot={es} beats_winner={bw} standaloneDD={ev['max_dd']*100:.1f}%")
|
||||
|
||||
# ---- pick best: prefer beats_winner; else lowest DD among earns_slot; else lowest DD ----
|
||||
def rank(r):
|
||||
# higher is better. Priority: (1) beats_winner, (2) earns_slot, then PORTFOLIO VALUE
|
||||
# (blend w25 uplift_hold + min-asset hold-out) which the wave objectives (2)&(3) reward,
|
||||
# with DD as a final tiebreak. NOTE: no window combo reaches max_dd<0.30 (DD wall is
|
||||
# structural: BTC & ETH DD move in OPPOSITE directions vs the vola window) so we report
|
||||
# the strongest earns_slot config rather than chasing an unreachable DD gate.
|
||||
w25 = r["w25"] if r["w25"] is not None else -9
|
||||
return (1 if r["bw"] else 0,
|
||||
1 if r["es"] else 0,
|
||||
round(w25, 3),
|
||||
r["rep"]["verdict"]["min_asset_holdout_sharpe"],
|
||||
-r["ev"]["max_dd"])
|
||||
|
||||
if results:
|
||||
best = sorted(results, key=rank, reverse=True)[0]
|
||||
b = best
|
||||
rep = b["rep"]; marg = b["marg"]; ev = b["ev"]
|
||||
v = rep["verdict"]
|
||||
print("\n" + "=" * 78)
|
||||
print("FINAL BEST CONFIG (CHANDE_WIN family)")
|
||||
print("=" * 78)
|
||||
print(f" config = {b['cfg']} (+ fixed winner spine {FIXED})")
|
||||
print(f" name = {b['name']}")
|
||||
print(f" minFull = {v['min_asset_full_sharpe']:+.3f}")
|
||||
print(f" minHold = {v['min_asset_holdout_sharpe']:+.3f} "
|
||||
f"(BTC {rep['per_asset']['BTC']['holdout']['sharpe']:+.2f} / "
|
||||
f"ETH {rep['per_asset']['ETH']['holdout']['sharpe']:+.2f})")
|
||||
print(f" standalone max_dd = {ev['max_dd']:.4f} "
|
||||
f"(BTC {ev['btc_dd']:.4f} / ETH {ev['eth_dd']:.4f})")
|
||||
print(f" n_trades_min = {v['min_trades']}")
|
||||
print(f" fee_survives 0.30%= {v['fee_survives']}")
|
||||
print(f" causality_ok = {b['caus_ok']}")
|
||||
print(f" grade = {v['grade']}")
|
||||
print(f" --- marginal vs TP01 ---")
|
||||
print(f" corr_full = {marg.get('corr_full')}")
|
||||
print(f" marginal_verdict = {marg.get('marginal_verdict')}")
|
||||
print(f" has_insample_edge = {marg.get('has_insample_edge')}")
|
||||
print(f" is_hedge = {marg.get('is_hedge')}")
|
||||
print(f" robust_oos = {marg.get('robust_oos')}")
|
||||
print(f" multicut_persist = {marg.get('multicut_persistent')}")
|
||||
print(f" clean_year_uplift = {marg.get('clean_year_uplift')}")
|
||||
print(f" blend w25 uplift_hold = {b['w25']}")
|
||||
print(f" blend w50 = {b['w50']}")
|
||||
print(f" earns_slot = {b['es']}")
|
||||
print(f" BEATS_WINNER = {b['bw']}")
|
||||
print("=" * 78)
|
||||
|
||||
# machine-readable line for the harness operator
|
||||
import json
|
||||
out = dict(
|
||||
family="CHANDE_WIN", best_config=b["cfg"], fixed=FIXED, name=b["name"],
|
||||
grade=v["grade"], min_full=v["min_asset_full_sharpe"],
|
||||
min_hold=v["min_asset_holdout_sharpe"], max_dd=ev["max_dd"],
|
||||
btc_dd=ev["btc_dd"], eth_dd=ev["eth_dd"], n_trades_min=v["min_trades"],
|
||||
fee_survives=v["fee_survives"], causality_ok=b["caus_ok"],
|
||||
corr_full=marg.get("corr_full"), marginal_verdict=marg.get("marginal_verdict"),
|
||||
has_insample_edge=marg.get("has_insample_edge"), is_hedge=marg.get("is_hedge"),
|
||||
robust_oos=marg.get("robust_oos"),
|
||||
multicut_persistent=marg.get("multicut_persistent"),
|
||||
clean_year_uplift=marg.get("clean_year_uplift"),
|
||||
blend_w25_uplift_hold=b["w25"], earns_slot=b["es"], beats_winner=b["bw"],
|
||||
)
|
||||
print("RESULT_JSON " + json.dumps(out, default=str))
|
||||
@@ -0,0 +1,381 @@
|
||||
"""SKH2_DDKILL — CAUSAL drawdown kill-switch overlay on Skyhook entries.
|
||||
|
||||
Family: drawdown kill-switch on entries [DDKILL].
|
||||
|
||||
Idea
|
||||
----
|
||||
Walk the trade-by-trade REALIZED equity of the V2-winner Skyhook. Track the running peak.
|
||||
Once standalone DD from the running peak exceeds `dd_kill`, enter a "killed" state and SUPPRESS
|
||||
new entries until equity recovers within `recover` of the running peak (i.e. DD shrinks back
|
||||
below `recover`). This is sequential & causal: the kill decision for a new entry at bar i uses
|
||||
ONLY the equity realized by trades that closed at/before i (busy_until <= i).
|
||||
|
||||
Implementation
|
||||
--------------
|
||||
1. Build base entries with the winner SkyhookParams.
|
||||
2. Run backtest_signals -> realized equity path (mark-at-trade-exit, forward-filled).
|
||||
3. From that equity path compute a per-bar boolean `killed[i]` (causal: peak/DD use eq up to i,
|
||||
and eq[i] only changes at a trade-exit bar -> the state at the moment we'd open a new entry
|
||||
at i reflects only past closed trades).
|
||||
4. Null entries where killed -> re-run. Equity changes (suppressed losers/winners during DD),
|
||||
so ITERATE to a fixed point (state stabilizes, usually 2-5 iters).
|
||||
5. Evaluate FULL + HOLD-OUT + fee-sweep + per-year on BOTH assets, marginal-vs-TP01,
|
||||
combined-curve max-DD, causality (truncated-prefix recompute of the FINAL entries).
|
||||
|
||||
CAUSALITY of the overlay itself
|
||||
-------------------------------
|
||||
The base skyhook_entries are already causal (sk.causality). The kill mask at bar i is a function
|
||||
of equity[0..i], and equity[j] for j<=i only embeds trades whose exit_idx <= i. The mask never
|
||||
references a future bar. We additionally PROVE it by a truncated-prefix recompute: re-deriving the
|
||||
final (killed) entries on a data prefix must match the full-run final entries on the overlap.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import skyhooklib as sk
|
||||
from src.backtest.harness import backtest_signals
|
||||
from src.strategies.skyhook import SkyhookParams, skyhook_entries
|
||||
|
||||
import altlib as al
|
||||
|
||||
HOLDOUT = sk.HOLDOUT
|
||||
FEE = sk.FEE_RT
|
||||
FEE_SWEEP = (0.0, 0.001, 0.002, 0.003)
|
||||
|
||||
# The verified V2 winner from the prior wave.
|
||||
WINNER = dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
|
||||
def winner_params() -> SkyhookParams:
|
||||
return SkyhookParams(**WINNER)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Causal DD kill-switch overlay
|
||||
# ---------------------------------------------------------------------------
|
||||
def killed_mask_from_equity(equity: np.ndarray, dd_kill: float, recover: float) -> np.ndarray:
|
||||
"""Per-bar boolean: True = entries SUPPRESSED at this bar.
|
||||
|
||||
State machine over the realized equity path (hysteresis):
|
||||
- track running peak.
|
||||
- if not killed and DD_from_peak > dd_kill -> killed.
|
||||
- if killed and DD_from_peak <= recover -> un-killed (recovered).
|
||||
Causal: peak[i] and dd[i] use equity[0..i] only.
|
||||
"""
|
||||
n = len(equity)
|
||||
killed = np.zeros(n, dtype=bool)
|
||||
peak = equity[0]
|
||||
state = False
|
||||
for i in range(n):
|
||||
if equity[i] > peak:
|
||||
peak = equity[i]
|
||||
dd = (peak - equity[i]) / peak if peak > 0 else 0.0
|
||||
if state:
|
||||
if dd <= recover:
|
||||
state = False
|
||||
else:
|
||||
if dd > dd_kill:
|
||||
state = True
|
||||
killed[i] = state
|
||||
return killed
|
||||
|
||||
|
||||
def apply_kill(entries: list, killed: np.ndarray) -> list:
|
||||
out = list(entries)
|
||||
for i in range(len(out)):
|
||||
if i < len(killed) and killed[i]:
|
||||
out[i] = None
|
||||
return out
|
||||
|
||||
|
||||
def ddkill_entries_for_asset(asset: str, p: SkyhookParams, dd_kill: float, recover: float,
|
||||
fee_rt: float = FEE, max_iter: int = 8):
|
||||
"""Iterate the kill-switch to a fixed point. Returns (final_entries, ltf, n_iters)."""
|
||||
ltf, htf = sk.frames(asset)
|
||||
base = skyhook_entries(ltf, htf, p)
|
||||
cur = base
|
||||
prev_killcount = -1
|
||||
iters = 0
|
||||
for it in range(max_iter):
|
||||
m = backtest_signals(ltf, cur, fee_rt=fee_rt, leverage=1.0, asset=asset, tf="230m")
|
||||
killed = killed_mask_from_equity(m.equity, dd_kill, recover)
|
||||
nxt = apply_kill(base, killed) # always re-derive from BASE so a recovered state re-enables entries
|
||||
iters = it + 1
|
||||
kc = int(killed.sum())
|
||||
# fixed point: same set of nulled entries as before
|
||||
same = all((a is None) == (b is None) for a, b in zip(nxt, cur))
|
||||
cur = nxt
|
||||
if same and kc == prev_killcount:
|
||||
break
|
||||
prev_killcount = kc
|
||||
return cur, ltf, iters
|
||||
|
||||
|
||||
def _split(eq: np.ndarray, idx: pd.DatetimeIndex, mask: np.ndarray) -> dict:
|
||||
e = eq[mask]
|
||||
if len(e) < 5:
|
||||
return dict(sharpe=0.0, ret=0.0, maxdd=0.0, n=int(len(e)))
|
||||
r = np.diff(e) / e[:-1]
|
||||
r = r[np.isfinite(r)]
|
||||
ix = idx[mask]
|
||||
dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400
|
||||
bpy = 86400 * 365.25 / dt
|
||||
sharpe = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0
|
||||
pk = np.maximum.accumulate(e)
|
||||
dd = float(np.max((pk - e) / pk))
|
||||
return dict(sharpe=round(sharpe, 3), ret=round(float(e[-1] / e[0] - 1), 4),
|
||||
maxdd=round(dd, 4), n=int(len(e)))
|
||||
|
||||
|
||||
def study_ddkill(name: str, p: SkyhookParams, dd_kill: float, recover: float):
|
||||
per_asset = {}
|
||||
fee_ok_all = True
|
||||
entries_by_asset = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
ent, ltf, iters = ddkill_entries_for_asset(a, p, dd_kill, recover)
|
||||
entries_by_asset[a] = (ent, ltf)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
eq = m.equity
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))
|
||||
hmask = np.asarray(idx >= HOLDOUT)
|
||||
full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4),
|
||||
n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1))
|
||||
hold = _split(eq, idx, hmask)
|
||||
sweep = {}
|
||||
for f in FEE_SWEEP:
|
||||
mf = backtest_signals(ltf, ent, fee_rt=f, leverage=1.0, asset=a, tf="230m")
|
||||
sweep[f"{f*100:.2f}%"] = round(mf.sharpe, 3)
|
||||
fee_ok_all = fee_ok_all and (sweep["0.30%"] > 0)
|
||||
per_asset[a] = dict(full=full, hold=hold,
|
||||
yearly={int(y): round(v, 4) for y, v in m.yearly.items()},
|
||||
fee_sweep=sweep, iters=iters)
|
||||
mf = min(per_asset[a]["full"]["sharpe"] for a in per_asset)
|
||||
mh = min(per_asset[a]["hold"]["sharpe"] for a in per_asset)
|
||||
mt = min(per_asset[a]["full"]["n_trades"] for a in per_asset)
|
||||
mdd = max(per_asset[a]["full"]["maxdd"] for a in per_asset)
|
||||
grade = "PASS" if (mt >= 20 and mf >= 0.5 and mh >= 0.2 and fee_ok_all) else \
|
||||
("WEAK" if (mt >= 20 and mf >= 0.3 and mh >= 0.0) else "FAIL")
|
||||
print(f"\n=== {name} (dd_kill={dd_kill:.0%} recover={recover:.0%}) -> {grade} "
|
||||
f"(minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} maxDD={mdd*100:.0f}% feeOK={fee_ok_all})")
|
||||
for a in per_asset:
|
||||
pa = per_asset[a]
|
||||
yr = " ".join(f"{y}:{r*100:+.0f}%" for y, r in pa["yearly"].items())
|
||||
print(f" {a}: FULL Sh={pa['full']['sharpe']:+.2f} ret={pa['full']['ret']*100:+.0f}% "
|
||||
f"DD={pa['full']['maxdd']*100:.0f}% n={pa['full']['n_trades']} wr={pa['full']['win_rate']:.0f}% "
|
||||
f"| HOLD Sh={pa['hold']['sharpe']:+.2f} ret={pa['hold']['ret']*100:+.0f}% DD={pa['hold']['maxdd']*100:.0f}% "
|
||||
f"[iters={pa['iters']}]")
|
||||
print(f" fee: " + " ".join(f"{k}={v:+.2f}" for k, v in pa["fee_sweep"].items()))
|
||||
print(f" yr: {yr}")
|
||||
return dict(grade=grade, minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee_ok=fee_ok_all,
|
||||
per_asset=per_asset, entries_by_asset=entries_by_asset,
|
||||
dd_kill=dd_kill, recover=recover)
|
||||
|
||||
|
||||
def marginal_ddkill(p: SkyhookParams, dd_kill: float, recover: float):
|
||||
def daily(a):
|
||||
ent, ltf, _ = ddkill_entries_for_asset(a, p, dd_kill, recover)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
|
||||
return s.resample("1D").last().ffill().pct_change().dropna()
|
||||
sb, se = daily("BTC"), daily("ETH")
|
||||
J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0)
|
||||
cand = 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
return al.marginal_vs_tp01(cand)
|
||||
|
||||
|
||||
def combined_curve_maxdd(res: dict) -> float:
|
||||
"""Max-DD of the COMBINED 50/50 BTC+ETH bar-level equity (single standalone curve)."""
|
||||
curves = []
|
||||
for a in ("BTC", "ETH"):
|
||||
ent, ltf = res["entries_by_asset"][a]
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
|
||||
curves.append(s.resample("1D").last().ffill().pct_change().fillna(0.0))
|
||||
J = pd.concat({"BTC": curves[0], "ETH": curves[1]}, axis=1, join="inner").fillna(0.0)
|
||||
r = 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
eq = (1.0 + r).cumprod().values
|
||||
pk = np.maximum.accumulate(eq)
|
||||
return float(np.max((pk - eq) / pk))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Causality of the FINAL (killed) entries via truncated-prefix recompute.
|
||||
# ---------------------------------------------------------------------------
|
||||
def causality_ddkill(p: SkyhookParams, dd_kill: float, recover: float, asset: str = "BTC",
|
||||
tail: int = 200) -> dict:
|
||||
"""Re-derive final killed entries on a data PREFIX; they must match the full-run final
|
||||
entries on the overlap tail. Proves the kill mask uses no future bar."""
|
||||
full_ent, ltf_full = (lambda r: r[:2])(ddkill_entries_for_asset(asset, p, dd_kill, recover))
|
||||
n = len(ltf_full)
|
||||
ltf, htf = sk.frames(asset)
|
||||
bad = 0; checked = 0
|
||||
for frac in (0.80, 0.92):
|
||||
cut = int(n * frac)
|
||||
cut_ts = int(ltf["timestamp"].iloc[cut - 1])
|
||||
ltf_sub = ltf.iloc[:cut].reset_index(drop=True)
|
||||
htf_sub = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True)
|
||||
# re-run the whole kill iteration on the prefix
|
||||
base_sub = skyhook_entries(ltf_sub, htf_sub, p)
|
||||
cur = base_sub
|
||||
for _ in range(8):
|
||||
m = backtest_signals(ltf_sub, cur, fee_rt=FEE, leverage=1.0, asset=asset, tf="230m")
|
||||
killed = killed_mask_from_equity(m.equity, dd_kill, recover)
|
||||
nxt = apply_kill(base_sub, killed)
|
||||
same = all((x is None) == (y is None) for x, y in zip(nxt, cur))
|
||||
cur = nxt
|
||||
if same:
|
||||
break
|
||||
for i in range(max(0, cut - tail), cut):
|
||||
checked += 1
|
||||
aE, bE = full_ent[i], cur[i]
|
||||
if (aE is None) != (bE is None):
|
||||
bad += 1
|
||||
elif aE is not None and (aE["dir"] != bE["dir"]
|
||||
or abs(aE["sl"] - bE["sl"]) > 1e-6
|
||||
or abs(aE["tp"] - bE["tp"]) > 1e-6):
|
||||
bad += 1
|
||||
return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked))
|
||||
|
||||
|
||||
def earns_slot_of(res: dict, mg: dict) -> bool:
|
||||
return (res["grade"] != "FAIL"
|
||||
and mg.get("marginal_verdict") == "ADDS"
|
||||
and bool(mg.get("robust_oos"))
|
||||
and not bool(mg.get("is_hedge")))
|
||||
|
||||
|
||||
def beats_winner(res: dict, mg: dict, max_dd: float, earns: bool) -> bool:
|
||||
w25 = mg.get("blends", {}).get("w25", {}).get("uplift_hold")
|
||||
return bool(earns and max_dd < 0.30
|
||||
and (w25 is not None and w25 >= 0.55)
|
||||
and res["minHold"] >= 0.65)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
p = winner_params()
|
||||
|
||||
print("########## BASELINE (winner, no kill) for reference ##########")
|
||||
base_rep = sk.study("WINNER (no kill)", p)
|
||||
print(sk.fmt(base_rep))
|
||||
base_dd = max(base_rep["per_asset"][a]["full"]["maxdd"] for a in base_rep["per_asset"])
|
||||
print(f" winner standalone maxDD (per-asset max) = {base_dd*100:.0f}%")
|
||||
|
||||
# Grid of (dd_kill, recover). recover < dd_kill (hysteresis: re-enable once DD shrinks back).
|
||||
grid = [
|
||||
(0.15, 0.10),
|
||||
(0.15, 0.12),
|
||||
(0.18, 0.12),
|
||||
(0.18, 0.15),
|
||||
(0.20, 0.15),
|
||||
(0.22, 0.16),
|
||||
(0.25, 0.18),
|
||||
(0.30, 0.22),
|
||||
]
|
||||
|
||||
results = []
|
||||
for dd_kill, recover in grid:
|
||||
res = study_ddkill(f"DDKILL", p, dd_kill, recover)
|
||||
results.append(res)
|
||||
|
||||
print("\n\n########## MARGINAL + combined-DD + earns_slot ##########")
|
||||
summary = []
|
||||
for res in results:
|
||||
mg = marginal_ddkill(p, res["dd_kill"], res["recover"])
|
||||
cdd = combined_curve_maxdd(res)
|
||||
per_asset_dd = res["maxDD"]
|
||||
# standalone max_dd per the brief = max(full.maxdd over BTC & ETH) for the overlay too
|
||||
max_dd = per_asset_dd
|
||||
earns = earns_slot_of(res, mg)
|
||||
bw = beats_winner(res, mg, max_dd, earns)
|
||||
w25 = mg.get("blends", {}).get("w25", {}).get("uplift_hold")
|
||||
w50 = mg.get("blends", {}).get("w50", {})
|
||||
summary.append(dict(dd_kill=res["dd_kill"], recover=res["recover"], grade=res["grade"],
|
||||
minFull=res["minFull"], minHold=res["minHold"], minTr=res["minTr"],
|
||||
per_asset_dd=per_asset_dd, combined_dd=cdd, max_dd=max_dd,
|
||||
corr_full=mg.get("corr_full"), verdict=mg.get("marginal_verdict"),
|
||||
insample=mg.get("has_insample_edge"), hedge=mg.get("is_hedge"),
|
||||
robust=mg.get("robust_oos"), multicut=mg.get("multicut_persistent"),
|
||||
cleanYr=mg.get("clean_year_uplift"), w25=w25, w50=w50,
|
||||
fee_ok=res["fee_ok"], earns=earns, beats=bw, mg=mg, res=res))
|
||||
print(f"[dd_kill={res['dd_kill']:.0%} recover={res['recover']:.0%}] grade={res['grade']} "
|
||||
f"minFull={res['minFull']:+.2f} minHold={res['minHold']:+.2f} "
|
||||
f"perAssetDD={per_asset_dd*100:.0f}% combinedDD={cdd*100:.0f}% "
|
||||
f"| corr={mg.get('corr_full')} verdict={mg.get('marginal_verdict')} "
|
||||
f"insample={mg.get('has_insample_edge')} hedge={mg.get('is_hedge')} "
|
||||
f"robust={mg.get('robust_oos')} multicut={mg.get('multicut_persistent')} "
|
||||
f"cleanYr={mg.get('clean_year_uplift')} w25uplift={w25} "
|
||||
f"| earns={earns} BEATS={bw}")
|
||||
|
||||
# Pick best honestly: prefer beats_winner; then earns_slot AND a healthy hold-out floor
|
||||
# (>=0.65) so we never pick a DD win that kills the hold-out; then lowest per-asset DD;
|
||||
# then highest minHold.
|
||||
def rank(s):
|
||||
healthy = bool(s["earns"]) and (s["minHold"] or -9) >= 0.65
|
||||
return (not s["beats"], not healthy, not s["earns"], s["max_dd"], -(s["minHold"] or -9))
|
||||
summary.sort(key=rank)
|
||||
best = summary[0]
|
||||
res = best["res"]; mg = best["mg"]
|
||||
|
||||
# Causality of the FINAL killed entries on the best config, both assets.
|
||||
cz_btc = causality_ddkill(p, best["dd_kill"], best["recover"], "BTC")
|
||||
cz_eth = causality_ddkill(p, best["dd_kill"], best["recover"], "ETH")
|
||||
cz_ok = cz_btc["ok"] and cz_eth["ok"]
|
||||
|
||||
print("\n\n################## BEST CONFIG ##################")
|
||||
print(f"config: WINNER + DDKILL(dd_kill={best['dd_kill']:.0%}, recover={best['recover']:.0%})")
|
||||
print(f" minFull = {best['minFull']:+.3f}")
|
||||
print(f" minHold = {best['minHold']:+.3f}")
|
||||
print(f" per-asset maxDD= {best['per_asset_dd']*100:.1f}% (max over BTCÐ full.maxdd)")
|
||||
print(f" combined maxDD= {best['combined_dd']*100:.1f}% (50/50 daily curve)")
|
||||
print(f" n_trades_min = {best['minTr']}")
|
||||
print(f" fee@0.30% = {best['fee_ok']}")
|
||||
print(f" causality = BTC {cz_btc} | ETH {cz_eth} -> ok={cz_ok}")
|
||||
print(f" --- MARGINAL vs TP01 ---")
|
||||
print(f" corr_full = {mg.get('corr_full')}")
|
||||
print(f" corr_hold = {mg.get('corr_hold')}")
|
||||
print(f" marginal_verdict = {mg.get('marginal_verdict')}")
|
||||
print(f" has_insample_edge = {mg.get('has_insample_edge')}")
|
||||
print(f" is_hedge = {mg.get('is_hedge')}")
|
||||
print(f" robust_oos = {mg.get('robust_oos')}")
|
||||
print(f" multicut_persistent = {mg.get('multicut_persistent')}")
|
||||
print(f" clean_year_uplift = {mg.get('clean_year_uplift')}")
|
||||
print(f" jackknife_min_uplift= {mg.get('jackknife_min_uplift')}")
|
||||
print(f" cand_insample_sharpe= {mg.get('cand_insample_sharpe')}")
|
||||
print(f" blends.w25 = {mg.get('blends', {}).get('w25')}")
|
||||
print(f" blends.w50 = {mg.get('blends', {}).get('w50')}")
|
||||
earns = best["earns"]
|
||||
print(f" earns_slot = {earns}")
|
||||
print(f" BEATS_WINNER = {best['beats']}")
|
||||
|
||||
# Emit a compact machine-readable line for the orchestrator.
|
||||
import json
|
||||
w25 = mg.get("blends", {}).get("w25", {}).get("uplift_hold")
|
||||
out = dict(
|
||||
family="ddkill_entries",
|
||||
best_config=dict(base=WINNER, dd_kill=best["dd_kill"], recover=best["recover"]),
|
||||
ran_ok=True, grade=res["grade"],
|
||||
min_full_sharpe=round(float(best["minFull"]), 3),
|
||||
min_hold_sharpe=round(float(best["minHold"]), 3),
|
||||
max_dd=round(float(best["max_dd"]), 4),
|
||||
combined_dd=round(float(best["combined_dd"]), 4),
|
||||
n_trades_min=int(best["minTr"]),
|
||||
fee_survives_030=bool(best["fee_ok"]),
|
||||
causality_ok=bool(cz_ok),
|
||||
marginal_verdict=mg.get("marginal_verdict"),
|
||||
has_insample_edge=bool(mg.get("has_insample_edge")),
|
||||
is_hedge=bool(mg.get("is_hedge")),
|
||||
robust_oos=bool(mg.get("robust_oos")),
|
||||
multicut_persistent=bool(mg.get("multicut_persistent")),
|
||||
clean_year_uplift=mg.get("clean_year_uplift"),
|
||||
corr_full=mg.get("corr_full"),
|
||||
blend_w25_uplift_hold=w25,
|
||||
earns_slot=bool(earns),
|
||||
beats_winner=bool(best["beats"]),
|
||||
)
|
||||
print("\nRESULT_JSON " + json.dumps(out, default=str))
|
||||
@@ -0,0 +1,399 @@
|
||||
"""SKH2_DUALTF_PTN — LTF (230m) CONFIRMATION of the HTF (690m) breakout at entry.
|
||||
|
||||
FAMILY: DUALTF_PTN. Hypothesis (DD-cut): the V2 winner enters on a fresh HTF Donchian
|
||||
breakout regardless of where the LTF exec-frame is. If we ALSO require the LTF to confirm
|
||||
the breakout at the entry bar (LTF close[i] above its own EMA(n) for longs / below for
|
||||
shorts, or LTF short-term momentum agrees), we avoid entering against a freshly-turned LTF.
|
||||
Fewer "fight the exec-frame" fills -> fewer immediate stop-outs -> lower standalone maxDD,
|
||||
ideally without gutting the hold-out edge.
|
||||
|
||||
BASELINE (V2 winner): SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24,
|
||||
uscitashort=16, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0).
|
||||
minFull +0.83, minHold +0.81, maxDD BTC 34% / ETH 31% (THE PROBLEM), marginal ADDS.
|
||||
|
||||
WHAT THIS SCRIPT DOES (all leak-free):
|
||||
* Reuse S.htf_features (V2 composer, Chande regime) -> comp_long/comp_short on HTF close.
|
||||
* merge backward to LTF (S.merge_htf_to_ltf) -> causal HTF signal at each LTF bar.
|
||||
* Compute LTF confirmation features at close[i]: EMA(n) of LTF close, and an LTF
|
||||
momentum (close[i] vs close[i-mom]). All strictly causal (no shift into the future).
|
||||
* AND the HTF composer with the LTF confirmation: long only if comp_long & ltf_up;
|
||||
short only if comp_short & ltf_dn. (ltf_up/ltf_dn defined by chosen confirm mode.)
|
||||
* Same entry/exit machinery as V2 (sl/tp ATR multiples, asymmetric max_bars, 1/day).
|
||||
|
||||
CAUSALITY: every LTF feature uses ltf data with index <= i. EMA via ewm(adjust=False) is a
|
||||
pure causal recursion; momentum uses close[i] and close[i-mom]. We prove it with a
|
||||
truncated-prefix recompute (same protocol as sk.causality) on our custom entries.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import skyhooklib as sk
|
||||
from src.backtest.harness import backtest_signals
|
||||
from src.strategies import skyhook as S
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
HOLDOUT = sk.HOLDOUT
|
||||
FEE = sk.FEE_RT
|
||||
|
||||
# V2 winner params (the baseline to beat). LTF confirmation rides on top of these.
|
||||
WINNER = dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
|
||||
def winner_params(**kw):
|
||||
base = dict(WINNER)
|
||||
base.update(kw)
|
||||
return SkyhookParams(**base)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Causal LTF confirmation features (computed on the 230m exec frame, at close[i]).
|
||||
# ---------------------------------------------------------------------------
|
||||
def ltf_confirm(ltf_close: np.ndarray, *, ema_n: int, mom_n: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Return (ltf_up, ltf_dn) boolean masks per LTF bar, strictly causal.
|
||||
ltf_up := close[i] > EMA_n(close)[i] AND close[i] > close[i-mom_n] (momentum agrees)
|
||||
ltf_dn := close[i] < EMA_n(close)[i] AND close[i] < close[i-mom_n]
|
||||
EMA via ewm(adjust=False): a causal recursion (uses only data <= i)."""
|
||||
c = pd.Series(np.asarray(ltf_close, float))
|
||||
ema = c.ewm(span=ema_n, adjust=False).mean().values
|
||||
cc = c.values
|
||||
mom_up = np.zeros(len(cc), dtype=bool)
|
||||
mom_dn = np.zeros(len(cc), dtype=bool)
|
||||
if mom_n > 0:
|
||||
mom_up[mom_n:] = cc[mom_n:] > cc[:-mom_n]
|
||||
mom_dn[mom_n:] = cc[mom_n:] < cc[:-mom_n]
|
||||
else:
|
||||
mom_up[:] = True
|
||||
mom_dn[:] = True
|
||||
up = (cc > ema) & mom_up
|
||||
dn = (cc < ema) & mom_dn
|
||||
return up, dn
|
||||
|
||||
|
||||
def ltf_confirm_modes(ltf_close: np.ndarray, *, ema_n: int, mom_n: int, mode: str,
|
||||
slope_n: int = 0):
|
||||
"""Causal LTF confirmation masks (ltf_up, ltf_dn). All features use data <= i.
|
||||
Components:
|
||||
ema_up := close[i] > EMA_n(close)[i]
|
||||
mom_up := close[i] > close[i-mom_n] (sustained move over mom_n bars)
|
||||
slope_up:= EMA_n(close)[i] > EMA_n(close)[i-slope_n] (LTF trend is rising) if slope_n>0
|
||||
Modes:
|
||||
'ema' -> ema_up
|
||||
'mom' -> mom_up
|
||||
'both' -> ema_up & mom_up
|
||||
'or' -> ema_up | mom_up
|
||||
'slope' -> slope_up only (EMA itself rising/falling)
|
||||
'ema_slope' -> ema_up & slope_up (above a rising EMA = real LTF uptrend, strict)
|
||||
'all' -> ema_up & mom_up & slope_up (strictest)
|
||||
"""
|
||||
c = pd.Series(np.asarray(ltf_close, float))
|
||||
ema = c.ewm(span=ema_n, adjust=False).mean().values
|
||||
cc = c.values
|
||||
n = len(cc)
|
||||
ema_up = cc > ema
|
||||
ema_dn = cc < ema
|
||||
mom_up = np.zeros(n, dtype=bool)
|
||||
mom_dn = np.zeros(n, dtype=bool)
|
||||
if mom_n > 0:
|
||||
mom_up[mom_n:] = cc[mom_n:] > cc[:-mom_n]
|
||||
mom_dn[mom_n:] = cc[mom_n:] < cc[:-mom_n]
|
||||
else:
|
||||
mom_up[:] = True
|
||||
mom_dn[:] = True
|
||||
slope_up = np.zeros(n, dtype=bool)
|
||||
slope_dn = np.zeros(n, dtype=bool)
|
||||
if slope_n > 0:
|
||||
slope_up[slope_n:] = ema[slope_n:] > ema[:-slope_n]
|
||||
slope_dn[slope_n:] = ema[slope_n:] < ema[:-slope_n]
|
||||
else:
|
||||
slope_up[:] = True
|
||||
slope_dn[:] = True
|
||||
if mode == "ema":
|
||||
return ema_up, ema_dn
|
||||
if mode == "mom":
|
||||
return mom_up, mom_dn
|
||||
if mode == "or":
|
||||
return (ema_up | mom_up), (ema_dn | mom_dn)
|
||||
if mode == "slope":
|
||||
return slope_up, slope_dn
|
||||
if mode == "ema_slope":
|
||||
return (ema_up & slope_up), (ema_dn & slope_dn)
|
||||
if mode == "all":
|
||||
return (ema_up & mom_up & slope_up), (ema_dn & mom_dn & slope_dn)
|
||||
# default 'both'
|
||||
return (ema_up & mom_up), (ema_dn & mom_dn)
|
||||
|
||||
|
||||
def ltf_not_overextended(ltf_close: np.ndarray, ltf_atr: np.ndarray, *,
|
||||
ema_n: int, max_ext_atr: float):
|
||||
"""REJECT (return False) when the LTF is already overextended from its EMA at entry:
|
||||
a long-breakout fired when close[i] is already > ema + max_ext_atr*ATR_LTF[i] is a LATE
|
||||
fill (mean-reversion-prone, big-stop risk). Confirmation = NOT overextended.
|
||||
ltf_up := (close - ema) <= max_ext_atr*ATR (still room to run, not blown off)
|
||||
ltf_dn := (ema - close) <= max_ext_atr*ATR
|
||||
All causal: ema, ATR, close all at i."""
|
||||
c = pd.Series(np.asarray(ltf_close, float))
|
||||
ema = c.ewm(span=ema_n, adjust=False).mean().values
|
||||
cc = c.values
|
||||
a = np.asarray(ltf_atr, float)
|
||||
a = np.where(np.isfinite(a) & (a > 0), a, np.nan)
|
||||
ext = (cc - ema) / a
|
||||
# long: not too far ABOVE ema ; short: not too far BELOW ema
|
||||
up = np.where(np.isfinite(ext), ext <= max_ext_atr, False)
|
||||
dn = np.where(np.isfinite(ext), (-ext) <= max_ext_atr, False)
|
||||
return up, dn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom entries: V2 HTF composer AND LTF confirmation.
|
||||
# ---------------------------------------------------------------------------
|
||||
def dualtf_entries(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams,
|
||||
*, ema_n: int, mom_n: int, mode: str, slope_n: int = 0,
|
||||
max_ext_atr: float = 0.0) -> list:
|
||||
feat = S.htf_features(htf, p) # V2 composer (Chande regime + Donchian)
|
||||
m = S.merge_htf_to_ltf(ltf, feat) # causal backward merge
|
||||
|
||||
c = m["close"].values.astype(float)
|
||||
a = S.atr(m, p.ltf_atr_win)
|
||||
comp_long = np.nan_to_num(m["comp_long"].values).astype(bool)
|
||||
comp_short = np.nan_to_num(m["comp_short"].values).astype(bool)
|
||||
|
||||
if mode == "notext":
|
||||
ltf_up, ltf_dn = ltf_not_overextended(c, a, ema_n=ema_n, max_ext_atr=max_ext_atr)
|
||||
else:
|
||||
ltf_up, ltf_dn = ltf_confirm_modes(c, ema_n=ema_n, mom_n=mom_n, mode=mode, slope_n=slope_n)
|
||||
comp_long = comp_long & ltf_up
|
||||
comp_short = comp_short & ltf_dn
|
||||
|
||||
days = pd.to_datetime(m["datetime"]).dt.floor("D").values
|
||||
entries: list = [None] * len(m)
|
||||
count_today: dict = {}
|
||||
for i in range(len(m)):
|
||||
if not np.isfinite(a[i]) or a[i] <= 0:
|
||||
continue
|
||||
day = days[i]
|
||||
if count_today.get(day, 0) >= p.max_per_day:
|
||||
continue
|
||||
if comp_long[i]:
|
||||
direction, mb = 1, p.uscitalong
|
||||
elif comp_short[i]:
|
||||
direction, mb = -1, p.uscitashort
|
||||
else:
|
||||
continue
|
||||
if p.exit_mode == "atr":
|
||||
sl_off, tp_off = p.sl_atr * a[i], p.tp_atr * a[i]
|
||||
else:
|
||||
sl_off, tp_off = p.sl_pct * c[i], p.tp_pct * c[i]
|
||||
if direction == 1:
|
||||
sl, tp = c[i] - sl_off, c[i] + tp_off
|
||||
else:
|
||||
sl, tp = c[i] + sl_off, c[i] - tp_off
|
||||
entries[i] = {"dir": direction, "tp": float(tp), "sl": float(sl), "max_bars": int(mb)}
|
||||
count_today[day] = count_today.get(day, 0) + 1
|
||||
return entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Eval helpers (FULL + HOLD-OUT + fee sweep + per-year, both assets) — mirrors sk.study.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _split(eq, idx, mask):
|
||||
e = eq[mask]
|
||||
if len(e) < 5:
|
||||
return dict(sharpe=0.0, ret=0.0, maxdd=0.0, n=int(len(e)))
|
||||
r = np.diff(e) / e[:-1]; r = r[np.isfinite(r)]
|
||||
ix = idx[mask]
|
||||
dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400
|
||||
bpy = 86400 * 365.25 / dt
|
||||
sh = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0
|
||||
pk = np.maximum.accumulate(e); dd = float(np.max((pk - e) / pk))
|
||||
return dict(sharpe=round(sh, 3), ret=round(float(e[-1] / e[0] - 1), 4), maxdd=round(dd, 4), n=int(len(e)))
|
||||
|
||||
|
||||
def study_dualtf(name, p, confirm):
|
||||
per_asset = {}
|
||||
fee_ok_all = True
|
||||
for a in ("BTC", "ETH"):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = dualtf_entries(ltf, htf, p, **confirm)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
eq = m.equity
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))
|
||||
hmask = np.asarray(idx >= HOLDOUT)
|
||||
full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4),
|
||||
n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1))
|
||||
hold = _split(eq, idx, hmask)
|
||||
sweep = {}
|
||||
for f in (0.0, 0.001, 0.002, 0.003):
|
||||
mf = backtest_signals(ltf, ent, fee_rt=f, leverage=1.0, asset=a, tf="230m")
|
||||
sweep[f"{f*100:.2f}%"] = round(mf.sharpe, 3)
|
||||
fee_ok_all = fee_ok_all and (sweep["0.30%"] > 0)
|
||||
per_asset[a] = dict(full=full, hold=hold, fee_sweep=sweep,
|
||||
yearly={int(y): round(v, 4) for y, v in m.yearly.items()})
|
||||
mf = min(per_asset[a]["full"]["sharpe"] for a in per_asset)
|
||||
mh = min(per_asset[a]["hold"]["sharpe"] for a in per_asset)
|
||||
mt = min(per_asset[a]["full"]["n_trades"] for a in per_asset)
|
||||
mdd = max(per_asset[a]["full"]["maxdd"] for a in per_asset)
|
||||
grade = "PASS" if (mt >= 20 and mf >= 0.5 and mh >= 0.2 and fee_ok_all) else \
|
||||
("WEAK" if (mt >= 20 and mf >= 0.3 and mh >= 0.0) else "FAIL")
|
||||
print(f"\n=== {name} -> {grade} (minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} maxDD={mdd*100:.0f}% feeOK={fee_ok_all})")
|
||||
for a in per_asset:
|
||||
pa = per_asset[a]
|
||||
yr = " ".join(f"{y}:{r*100:+.0f}%" for y, r in pa["yearly"].items())
|
||||
print(f" {a}: FULL Sh={pa['full']['sharpe']:+.2f} ret={pa['full']['ret']*100:+.0f}% DD={pa['full']['maxdd']*100:.0f}%"
|
||||
f" n={pa['full']['n_trades']} wr={pa['full']['win_rate']:.0f}% | HOLD Sh={pa['hold']['sharpe']:+.2f} ret={pa['hold']['ret']*100:+.0f}% DD={pa['hold']['maxdd']*100:.0f}%")
|
||||
print(f" fee: " + " ".join(f"{k}={v:+.2f}" for k, v in pa["fee_sweep"].items()))
|
||||
print(f" yr: {yr}")
|
||||
return dict(grade=grade, minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee_ok=fee_ok_all, per_asset=per_asset)
|
||||
|
||||
|
||||
def marginal_dualtf(p, confirm):
|
||||
import altlib as al
|
||||
def daily(a):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = dualtf_entries(ltf, htf, p, **confirm)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
|
||||
return s.resample("1D").last().ffill().pct_change().dropna()
|
||||
sb, se = daily("BTC"), daily("ETH")
|
||||
J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0)
|
||||
cand = 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
return al.marginal_vs_tp01(cand)
|
||||
|
||||
|
||||
def check_causality(p, confirm, asset="BTC", tail=150):
|
||||
ltf, htf = sk.frames(asset)
|
||||
full = dualtf_entries(ltf, htf, p, **confirm)
|
||||
n = len(ltf); bad = 0; checked = 0
|
||||
for frac in (0.80, 0.92):
|
||||
cut = int(n * frac)
|
||||
cut_ts = int(ltf["timestamp"].iloc[cut - 1])
|
||||
htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True)
|
||||
sub = dualtf_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, p, **confirm)
|
||||
for i in range(max(0, cut - tail), cut):
|
||||
checked += 1
|
||||
a, b = full[i], sub[i]
|
||||
if (a is None) != (b is None):
|
||||
bad += 1
|
||||
elif a is not None and (a["dir"] != b["dir"]
|
||||
or abs(a["sl"] - b["sl"]) > 1e-6
|
||||
or abs(a["tp"] - b["tp"]) > 1e-6):
|
||||
bad += 1
|
||||
return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("########## SKH2_DUALTF_PTN: LTF confirmation of HTF breakout ##########")
|
||||
|
||||
# Reference: V2 winner WITHOUT LTF confirmation (mode 'none' via wide-open masks).
|
||||
p = winner_params()
|
||||
|
||||
# --- Reference (no LTF confirm) using sk.run_asset directly ---
|
||||
print("\n--- V2 WINNER reference (no LTF confirm) ---")
|
||||
refF, refH, refDD, refTr = [], [], [], []
|
||||
for a in ("BTC", "ETH"):
|
||||
r = sk.run_asset(a, p, FEE)
|
||||
refF.append(r["full"]["sharpe"]); refH.append(r["holdout"]["sharpe"])
|
||||
refDD.append(r["full"]["maxdd"]); refTr.append(r["full"]["n_trades"])
|
||||
print(f" {a}: FULL Sh={r['full']['sharpe']:+.2f} DD={r['full']['maxdd']*100:.0f}% n={r['full']['n_trades']}"
|
||||
f" | HOLD Sh={r['holdout']['sharpe']:+.2f}")
|
||||
print(f" REF minFull={min(refF):+.2f} minHold={min(refH):+.2f} maxDD={max(refDD)*100:.0f}% minTr={min(refTr)}")
|
||||
|
||||
# --- Sweep LTF confirmation configs ---
|
||||
# ema_n / mom_n on 230m bars. ~6.26 bars/day. EMA 10~1.6d, 20~3.2d. mom small (1-6 bars).
|
||||
# Directional confirms are near-redundant with a fresh breakout (barely filter).
|
||||
# The real DD lever in this family: REJECT OVEREXTENDED LTF fills (late, blow-off,
|
||||
# mean-reversion-prone, big-stop). max_ext_atr = max allowed (close-ema)/ATR_LTF at entry.
|
||||
configs = {
|
||||
# reference directional confirm (keeps ~all trades)
|
||||
"mom_only_m3": dict(ema_n=20, mom_n=3, mode="mom"),
|
||||
# NOT-OVEREXTENDED gate: tighter max_ext -> fewer late fills -> aim lower DD
|
||||
"notext_e20_x4": dict(ema_n=20, mom_n=0, mode="notext", max_ext_atr=4.0),
|
||||
"notext_e20_x3": dict(ema_n=20, mom_n=0, mode="notext", max_ext_atr=3.0),
|
||||
"notext_e20_x2": dict(ema_n=20, mom_n=0, mode="notext", max_ext_atr=2.0),
|
||||
"notext_e20_x1_5": dict(ema_n=20, mom_n=0, mode="notext", max_ext_atr=1.5),
|
||||
"notext_e30_x3": dict(ema_n=30, mom_n=0, mode="notext", max_ext_atr=3.0),
|
||||
"notext_e30_x2": dict(ema_n=30, mom_n=0, mode="notext", max_ext_atr=2.0),
|
||||
"notext_e10_x2": dict(ema_n=10, mom_n=0, mode="notext", max_ext_atr=2.0),
|
||||
"notext_e10_x1_5": dict(ema_n=10, mom_n=0, mode="notext", max_ext_atr=1.5),
|
||||
"notext_e30_x1_5": dict(ema_n=30, mom_n=0, mode="notext", max_ext_atr=1.5),
|
||||
}
|
||||
|
||||
results = {}
|
||||
for tag, cfg in configs.items():
|
||||
r = study_dualtf(f"DUALTF_{tag}", p, cfg)
|
||||
results[tag] = (cfg, r)
|
||||
|
||||
# --- Pick best: priority (1) DD<30, (2) earns_slot, (3) minHold high ---
|
||||
print("\n\n##### MARGINAL vs TP01 (configs with minTr>=20) #####")
|
||||
scored = []
|
||||
for tag, (cfg, r) in results.items():
|
||||
if r["minTr"] < 20:
|
||||
print(f"[{tag}] minTr={r['minTr']} <20 -> skip marginal")
|
||||
continue
|
||||
mg = marginal_dualtf(p, cfg)
|
||||
verdict = mg.get("marginal_verdict")
|
||||
robust = bool(mg.get("robust_oos"))
|
||||
hedge = bool(mg.get("is_hedge"))
|
||||
earns = (r["grade"] != "FAIL") and (verdict == "ADDS") and robust and (not hedge)
|
||||
w25 = mg.get("blends", {}).get("w25", {}).get("uplift_hold")
|
||||
beats = earns and (r["maxDD"] < 0.30) and (w25 is not None and w25 >= 0.55) and (r["minHold"] >= 0.65)
|
||||
scored.append((tag, cfg, r, mg, earns, beats))
|
||||
print(f"[{tag}] grade={r['grade']} minFull={r['minFull']:+.2f} minHold={r['minHold']:+.2f} maxDD={r['maxDD']*100:.0f}%"
|
||||
f" | corr_full={mg.get('corr_full')} verdict={verdict} insample={mg.get('has_insample_edge')}"
|
||||
f" hedge={hedge} robust={robust} w25uplift={w25} earns_slot={earns} BEATS={beats}")
|
||||
|
||||
if not scored:
|
||||
print("\nNo config with enough trades.")
|
||||
sys.exit(0)
|
||||
|
||||
# Rank: beats_winner first, then DD<30 & earns, then by minHold, then by lowest DD.
|
||||
def rank_key(item):
|
||||
tag, cfg, r, mg, earns, beats = item
|
||||
w25 = mg.get("blends", {}).get("w25", {}).get("uplift_hold") or -9
|
||||
return (beats, (r["maxDD"] < 0.30 and earns), earns, r["minHold"], -r["maxDD"])
|
||||
scored.sort(key=rank_key, reverse=True)
|
||||
best_tag, best_cfg, best_r, best_mg, best_earns, best_beats = scored[0]
|
||||
|
||||
# --- Causality on best ---
|
||||
cb = check_causality(p, best_cfg, "BTC")
|
||||
ce = check_causality(p, best_cfg, "ETH")
|
||||
caus_ok = cb["ok"] and ce["ok"]
|
||||
|
||||
w25 = best_mg.get("blends", {}).get("w25", {}).get("uplift_hold")
|
||||
w50 = best_mg.get("blends", {}).get("w50", {})
|
||||
fee030_min = min(best_r["per_asset"][a]["fee_sweep"]["0.30%"] for a in ("BTC", "ETH"))
|
||||
|
||||
print("\n\n##################### BEST CONFIG #####################")
|
||||
print(f"BEST = DUALTF_{best_tag} cfg={best_cfg}")
|
||||
print(f" params = {WINNER}")
|
||||
print(f" grade={best_r['grade']} minFull={best_r['minFull']:+.2f} minHold={best_r['minHold']:+.2f}"
|
||||
f" maxDD={best_r['maxDD']*100:.1f}% minTr={best_r['minTr']}")
|
||||
print(f" fee@0.30% (min asset FULL Sh) = {fee030_min:+.3f} feeOK={best_r['fee_ok']}")
|
||||
print(f" causality: BTC={cb} ETH={ce} -> OK={caus_ok}")
|
||||
print(f" marginal: corr_full={best_mg.get('corr_full')} corr_hold={best_mg.get('corr_hold')}"
|
||||
f" verdict={best_mg.get('marginal_verdict')}")
|
||||
print(f" has_insample_edge={best_mg.get('has_insample_edge')} is_hedge={best_mg.get('is_hedge')}"
|
||||
f" robust_oos={best_mg.get('robust_oos')} multicut_persistent={best_mg.get('multicut_persistent')}")
|
||||
print(f" clean_year_uplift={best_mg.get('clean_year_uplift')}"
|
||||
f" jackknife_min_uplift={best_mg.get('jackknife_min_uplift')}"
|
||||
f" cand_insample_sharpe={best_mg.get('cand_insample_sharpe')}")
|
||||
print(f" blend w25 uplift_hold={w25} w50={w50}")
|
||||
print(f" earns_slot={best_earns} BEATS_WINNER={best_beats}")
|
||||
|
||||
# Emit a compact machine-readable line for the harness.
|
||||
import json
|
||||
out = dict(family="DUALTF_PTN", best_tag=best_tag, best_cfg=best_cfg, winner_params=WINNER,
|
||||
grade=best_r["grade"], minFull=best_r["minFull"], minHold=best_r["minHold"],
|
||||
maxDD=best_r["maxDD"], minTr=best_r["minTr"], fee030_min=fee030_min,
|
||||
causality_ok=caus_ok, marginal_verdict=best_mg.get("marginal_verdict"),
|
||||
corr_full=best_mg.get("corr_full"), has_insample_edge=best_mg.get("has_insample_edge"),
|
||||
is_hedge=best_mg.get("is_hedge"), robust_oos=best_mg.get("robust_oos"),
|
||||
multicut_persistent=best_mg.get("multicut_persistent"),
|
||||
clean_year_uplift=best_mg.get("clean_year_uplift"), w25_uplift_hold=w25, w50=w50,
|
||||
earns_slot=best_earns, beats_winner=best_beats)
|
||||
print("\nJSON " + json.dumps(out, default=str))
|
||||
@@ -0,0 +1,285 @@
|
||||
"""SKH2_ENS_PARAM — within-sleeve PARAM ENSEMBLE for Skyhook DD reduction.
|
||||
|
||||
Family: equal-weight the DAILY returns of K diverse skyhook param sets (incl. the V2 winner),
|
||||
varying ptn_n {25,45,90}, exits, sl/tp. Diversification across configs smooths equity and cuts
|
||||
standalone DD without killing hold-out. We:
|
||||
* build each config's per-asset 230m equity (sk.run_asset) -> daily returns,
|
||||
* equal-weight average the configs' daily returns PER ASSET -> ensemble per-asset equity ->
|
||||
standalone DD (max over BTC/ETH) and per-asset/year/full/hold Sharpe via the SAME _split logic,
|
||||
* fee sweep: re-run each config at fee f, average daily, recompute Sharpe (fee_ok = Sharpe>0 @0.30% RT),
|
||||
* causality: every member is a pure SkyhookParams variant -> sk.causality on each (must be ok),
|
||||
* marginal: feed the 50/50 ensemble daily series to altlib.marginal_vs_tp01.
|
||||
|
||||
Standalone max_dd for the ensemble = max-DD of the COMBINED (averaged) per-asset equity curve.
|
||||
All causal/leak-free: ensemble is a linear combo of leak-free member equities; no future data used.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import skyhooklib as sk
|
||||
import altlib as al
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
HOLDOUT = sk.HOLDOUT
|
||||
FEE = sk.FEE_RT
|
||||
CERTIFIED = ("BTC", "ETH")
|
||||
ANN = np.sqrt(365.25)
|
||||
|
||||
# The verified V2 winner (must be a member of every ensemble).
|
||||
WINNER = SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
|
||||
def _sharpe(r: np.ndarray) -> float:
|
||||
r = r[np.isfinite(r)]
|
||||
return float(np.mean(r) / np.std(r) * ANN) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def _dd_from_eq(eq: np.ndarray) -> float:
|
||||
pk = np.maximum.accumulate(eq)
|
||||
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-config DAILY equity-return series per asset, cached by (config-id, asset, fee).
|
||||
# We use sk.run_asset to get the leak-free 230m equity, then resample to daily LAST and
|
||||
# take pct_change -> daily returns. Aligning all members on the same daily index lets us
|
||||
# equal-weight-average their daily returns (an equal-capital rebalanced ensemble).
|
||||
# ---------------------------------------------------------------------------
|
||||
_DAILY_CACHE: dict = {}
|
||||
_NTR_CACHE: dict = {}
|
||||
|
||||
|
||||
def _config_daily(p: SkyhookParams, asset: str, fee: float) -> pd.Series:
|
||||
key = (id(p), asset, fee)
|
||||
if key in _DAILY_CACHE:
|
||||
return _DAILY_CACHE[key]
|
||||
r = sk.run_asset(asset, p, fee)
|
||||
s = pd.Series(r["_eq"], index=r["_idx"])
|
||||
daily = s.resample("1D").last().ffill().pct_change().dropna()
|
||||
_DAILY_CACHE[key] = daily
|
||||
_NTR_CACHE[(id(p), asset)] = r["full"]["n_trades"]
|
||||
return daily
|
||||
|
||||
|
||||
def _ensemble_daily_asset(members, asset: str, fee: float) -> pd.Series:
|
||||
"""Equal-weight average of members' daily returns for one asset (common dates)."""
|
||||
cols = {f"m{i}": _config_daily(p, asset, fee) for i, p in enumerate(members)}
|
||||
J = pd.concat(cols, axis=1, join="inner").fillna(0.0)
|
||||
return J.mean(axis=1)
|
||||
|
||||
|
||||
def study_ensemble(name: str, members) -> dict:
|
||||
"""FULL+HOLD+fee-sweep+per-year on BOTH assets for the equal-weight param ensemble.
|
||||
Standalone DD = max-DD of the averaged per-asset equity curve."""
|
||||
per_asset = {}
|
||||
fee_ok_all = True
|
||||
for a in CERTIFIED:
|
||||
ens = _ensemble_daily_asset(members, a, FEE)
|
||||
eq = np.cumprod(1.0 + ens.values)
|
||||
idx = ens.index
|
||||
full_sh = _sharpe(ens.values)
|
||||
full_dd = _dd_from_eq(eq)
|
||||
full_ret = float(eq[-1] / eq[0] - 1) if len(eq) else 0.0
|
||||
hmask = idx >= HOLDOUT
|
||||
rh = ens.values[hmask]
|
||||
eqh = np.cumprod(1.0 + rh) if rh.size else np.array([1.0])
|
||||
hold_sh = _sharpe(rh)
|
||||
hold_ret = float(eqh[-1] / eqh[0] - 1) if eqh.size else 0.0
|
||||
hold_dd = _dd_from_eq(eqh)
|
||||
# per-year Sharpe-equivalent return
|
||||
yearly = {}
|
||||
for y in sorted(set(idx.year)):
|
||||
ry = ens.values[idx.year == y]
|
||||
eqy = np.cumprod(1.0 + ry) if ry.size else np.array([1.0])
|
||||
yearly[int(y)] = float(eqy[-1] - 1.0) if eqy.size else 0.0
|
||||
# fee sweep
|
||||
sweep = {}
|
||||
for f in (0.0, 0.001, 0.002, 0.003):
|
||||
ensf = _ensemble_daily_asset(members, a, f)
|
||||
sweep[f"{f*100:.2f}%"] = round(_sharpe(ensf.values), 3)
|
||||
fee_ok_all = fee_ok_all and (sweep["0.30%"] > 0)
|
||||
# n_trades = sum across members (the ensemble trades all of them)
|
||||
ntr = sum(_NTR_CACHE.get((id(p), a), 0) for p in members)
|
||||
per_asset[a] = dict(
|
||||
full=dict(sharpe=round(full_sh, 3), ret=round(full_ret, 4), maxdd=round(full_dd, 4),
|
||||
n_trades=int(ntr)),
|
||||
hold=dict(sharpe=round(hold_sh, 3), ret=round(hold_ret, 4), maxdd=round(hold_dd, 4)),
|
||||
yearly=yearly, fee_sweep=sweep)
|
||||
mf = min(per_asset[a]["full"]["sharpe"] for a in per_asset)
|
||||
mh = min(per_asset[a]["hold"]["sharpe"] for a in per_asset)
|
||||
mt = min(per_asset[a]["full"]["n_trades"] for a in per_asset)
|
||||
mdd = max(per_asset[a]["full"]["maxdd"] for a in per_asset)
|
||||
grade = "PASS" if (mt >= 20 and mf >= 0.5 and mh >= 0.2 and fee_ok_all) else \
|
||||
("WEAK" if (mt >= 20 and mf >= 0.3 and mh >= 0.0) else "FAIL")
|
||||
print(f"\n=== {name} -> {grade} (minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} maxDD={mdd*100:.0f}% feeOK={fee_ok_all})")
|
||||
for a in per_asset:
|
||||
pa = per_asset[a]
|
||||
yr = " ".join(f"{y}:{r*100:+.0f}%" for y, r in pa["yearly"].items())
|
||||
print(f" {a}: FULL Sh={pa['full']['sharpe']:+.2f} ret={pa['full']['ret']*100:+.0f}% DD={pa['full']['maxdd']*100:.0f}%"
|
||||
f" n={pa['full']['n_trades']} | HOLD Sh={pa['hold']['sharpe']:+.2f} ret={pa['hold']['ret']*100:+.0f}% DD={pa['hold']['maxdd']*100:.0f}%")
|
||||
print(f" fee: " + " ".join(f"{k}={v:+.2f}" for k, v in pa["fee_sweep"].items()))
|
||||
print(f" yr: {yr}")
|
||||
return dict(grade=grade, minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee_ok=fee_ok_all,
|
||||
per_asset=per_asset)
|
||||
|
||||
|
||||
def ensemble_5050_daily(members, fee: float = FEE) -> pd.Series:
|
||||
"""50/50 BTC+ETH ensemble daily series (same convention as altlib baseline) for marginal."""
|
||||
sb = _ensemble_daily_asset(members, "BTC", fee)
|
||||
se = _ensemble_daily_asset(members, "ETH", fee)
|
||||
J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0)
|
||||
return 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
|
||||
|
||||
def marginal_ensemble(members) -> dict:
|
||||
return al.marginal_vs_tp01(ensemble_5050_daily(members))
|
||||
|
||||
|
||||
def report(tag, members, names):
|
||||
r = study_ensemble(tag, members)
|
||||
caus = {}
|
||||
for i, p in enumerate(members):
|
||||
cb = sk.causality(p, "BTC")
|
||||
ce = sk.causality(p, "ETH")
|
||||
caus[names[i]] = (cb["ok"], ce["ok"])
|
||||
caus_ok = all(b and e for b, e in caus.values())
|
||||
mg = marginal_ensemble(members)
|
||||
w25 = mg.get("blends", {}).get("w25", {})
|
||||
w50 = mg.get("blends", {}).get("w50", {})
|
||||
earns = (r["grade"] != "FAIL" and mg.get("marginal_verdict") == "ADDS"
|
||||
and mg.get("robust_oos") is True and mg.get("is_hedge") is False)
|
||||
beats = (earns and r["maxDD"] < 0.30 and (w25.get("uplift_hold") or -9) >= 0.55
|
||||
and r["minHold"] >= 0.65)
|
||||
print(f"\n----- MARGINAL [{tag}] -----")
|
||||
print(f" members: {names}")
|
||||
print(f" causality per member (BTC,ETH): {caus} -> all_ok={caus_ok}")
|
||||
print(f" corr_full={mg.get('corr_full')} corr_hold={mg.get('corr_hold')} verdict={mg.get('marginal_verdict')}")
|
||||
print(f" has_insample_edge={mg.get('has_insample_edge')} cand_insample_sharpe={mg.get('cand_insample_sharpe')}"
|
||||
f" is_hedge={mg.get('is_hedge')} robust_oos={mg.get('robust_oos')} multicut_persistent={mg.get('multicut_persistent')}")
|
||||
print(f" clean_year_uplift={mg.get('clean_year_uplift')} jackknife_min_uplift={mg.get('jackknife_min_uplift')}"
|
||||
f" multicut_uplift={mg.get('multicut_uplift')}")
|
||||
print(f" w25={w25}")
|
||||
print(f" w50={w50}")
|
||||
print(f" => earns_slot={earns} BEATS_WINNER={beats} (DD={r['maxDD']*100:.0f}% minHold={r['minHold']:+.2f} w25_up_hold={w25.get('uplift_hold')})")
|
||||
return dict(study=r, marginal=mg, caus_ok=caus_ok, earns=earns, beats=beats, w25=w25, w50=w50)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# ---- Diverse member pool (all pure SkyhookParams variants, all causal) ----
|
||||
# WINNER (ptn_n=45, sl2.5/tp7.0, exits 24/16, vola 35-95, vol_lo 0)
|
||||
P_WIN = WINNER
|
||||
# Faster pattern, tighter stop, shorter TP (different turnover/regime sensitivity)
|
||||
P_FAST = SkyhookParams(ptn_n=25, sl_atr=2.0, tp_atr=5.0, uscitalong=18, uscitashort=12,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
# Slow pattern, wider stop, longer TP (smoother, fewer trades)
|
||||
P_SLOW = SkyhookParams(ptn_n=90, sl_atr=3.0, tp_atr=9.0, uscitalong=30, uscitashort=20,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
# Mid pattern, percent exits (structurally different exit mode) + tighter vola band
|
||||
P_PCT = SkyhookParams(ptn_n=45, exit_mode="pct", sl_pct=0.04, tp_pct=0.10,
|
||||
uscitalong=24, uscitashort=16, vola_lo=30.0, vola_hi=90.0, vol_lo=0.0)
|
||||
# Low-vol gate variant: add a vol floor + slightly different vola band (regime diversity)
|
||||
P_GATE = SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=6.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=40.0, vola_hi=95.0, vol_lo=40.0)
|
||||
# DEFENSIVE members: tight stop cuts losers fast -> shallow per-trade DD (the DD-cutters).
|
||||
P_TIGHT = SkyhookParams(ptn_n=45, sl_atr=1.5, tp_atr=4.5, uscitalong=18, uscitashort=12,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
P_TIGHT2 = SkyhookParams(ptn_n=25, sl_atr=1.3, tp_atr=4.0, uscitalong=14, uscitashort=10,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
# Calm-regime gate (sit out high-vola tails) + tight stop -> lowest DD contributor
|
||||
P_CALM = SkyhookParams(ptn_n=45, sl_atr=1.8, tp_atr=5.0, uscitalong=20, uscitashort=14,
|
||||
vola_lo=20.0, vola_hi=70.0, vol_lo=0.0)
|
||||
# Calm variants: narrower / different vola windows -> diverse DD timing among defenders.
|
||||
P_CALM2 = SkyhookParams(ptn_n=90, sl_atr=1.8, tp_atr=5.5, uscitalong=24, uscitashort=16,
|
||||
vola_lo=25.0, vola_hi=65.0, vol_lo=0.0)
|
||||
P_CALM3 = SkyhookParams(ptn_n=45, sl_atr=2.0, tp_atr=6.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=15.0, vola_hi=60.0, vol_lo=0.0)
|
||||
# CALM4: strong hold-out defensive (wider TP like winner but calm band) — uplift booster
|
||||
P_CALM4 = SkyhookParams(ptn_n=45, sl_atr=2.2, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=15.0, vola_hi=65.0, vol_lo=0.0)
|
||||
# CALM5: ptn_n=90 calm + wide TP (smooth, strong) for uplift+DD balance
|
||||
P_CALM5 = SkyhookParams(ptn_n=90, sl_atr=2.0, tp_atr=7.0, uscitalong=28, uscitashort=18,
|
||||
vola_lo=15.0, vola_hi=62.0, vol_lo=0.0)
|
||||
|
||||
POOL = {"WIN": P_WIN, "FAST": P_FAST, "SLOW": P_SLOW, "PCT": P_PCT, "GATE": P_GATE,
|
||||
"TIGHT": P_TIGHT, "TIGHT2": P_TIGHT2, "CALM": P_CALM,
|
||||
"CALM2": P_CALM2, "CALM3": P_CALM3, "CALM4": P_CALM4, "CALM5": P_CALM5}
|
||||
|
||||
# ---- First: standalone DD of each member (diagnostic) ----
|
||||
print("\n===== STANDALONE per-member DD (max over BTC/ETH) =====")
|
||||
for k, p in POOL.items():
|
||||
dds, fhs, hhs = {}, {}, {}
|
||||
for a in CERTIFIED:
|
||||
r = sk.run_asset(a, p, FEE)
|
||||
dds[a] = r["full"]["maxdd"]; fhs[a] = r["full"]["sharpe"]; hhs[a] = r["holdout"]["sharpe"]
|
||||
print(f" {k:7s}: maxDD={max(dds.values())*100:.0f}% (BTC {dds['BTC']*100:.0f}/ETH {dds['ETH']*100:.0f})"
|
||||
f" minFull={min(fhs.values()):+.2f} minHold={min(hhs.values()):+.2f}")
|
||||
|
||||
results = {}
|
||||
|
||||
# Smallest K first: K=3, then K=4, K=5 mixes (winner always included).
|
||||
# Focus on DEFENSIVE-heavy mixes to drive standalone DD below 30%.
|
||||
mixes = {
|
||||
# maximize w25 uplift_hold (>=0.55) while keeping DD<30 -> strong-holdout calm members
|
||||
"K3_WIN_CALM3_CALM4": ["WIN", "CALM3", "CALM4"],
|
||||
"K3_WIN_CALM4_CALM5": ["WIN", "CALM4", "CALM5"],
|
||||
"K3_WIN_CALM3_CALM5": ["WIN", "CALM3", "CALM5"],
|
||||
"K3_WIN_PCT_CALM3": ["WIN", "PCT", "CALM3"], # PCT has hold 1.0 (uplift booster) but DD 43
|
||||
"K4_WIN_CALM3_CALM4_CALM5":["WIN", "CALM3", "CALM4", "CALM5"],
|
||||
"K3_WIN_CALM3_CALM2": ["WIN", "CALM3", "CALM2"], # prior: DD 24, uplift 0.508
|
||||
"K3_WIN_CALM_CALM3": ["WIN", "CALM", "CALM3"], # prior: DD 23, uplift 0.493
|
||||
"K4_WIN_GATE_CALM3_CALM4": ["WIN", "GATE", "CALM3", "CALM4"],
|
||||
"K3_WIN_GATE_CALM3": ["WIN", "GATE", "CALM3"],
|
||||
}
|
||||
|
||||
for tag, keys in mixes.items():
|
||||
members = [POOL[k] for k in keys]
|
||||
print(f"\n########## {tag} members={keys} ##########")
|
||||
results[tag] = report(tag, members, keys)
|
||||
|
||||
# ---- pick best: prefer BEATS_WINNER, else best by (earns, low DD, hold) ----
|
||||
def score(res):
|
||||
r, w25 = res["study"], res["w25"]
|
||||
uh = (w25.get("uplift_hold") or -9)
|
||||
dd_ok = 1 if r["maxDD"] < 0.30 else 0 # goal #1 first
|
||||
hold_ok = 1 if r["minHold"] >= 0.65 else 0 # goal #2
|
||||
return (1 if res["beats"] else 0, 1 if res["earns"] else 0,
|
||||
dd_ok, hold_ok, uh, -r["maxDD"])
|
||||
best_tag = max(results, key=lambda t: score(results[t]))
|
||||
bres = results[best_tag]
|
||||
br, bw25, bw50, bmg = bres["study"], bres["w25"], bres["w50"], bres["marginal"]
|
||||
|
||||
print("\n\n##################### BEST ENSEMBLE #####################")
|
||||
print(f"BEST = {best_tag} members={mixes[best_tag]}")
|
||||
print(f"grade={br['grade']} minFull={br['minFull']:+.3f} minHold={br['minHold']:+.3f}"
|
||||
f" max_dd={br['maxDD']:.4f} n_trades_min={br['minTr']} fee_ok(@0.30%)={br['fee_ok']}")
|
||||
print(f"causality_ok={bres['caus_ok']}")
|
||||
print(f"marginal: corr_full={bmg.get('corr_full')} verdict={bmg.get('marginal_verdict')}"
|
||||
f" has_insample_edge={bmg.get('has_insample_edge')} is_hedge={bmg.get('is_hedge')}"
|
||||
f" robust_oos={bmg.get('robust_oos')} multicut_persistent={bmg.get('multicut_persistent')}"
|
||||
f" clean_year_uplift={bmg.get('clean_year_uplift')}")
|
||||
print(f"blend w25 uplift_hold={bw25.get('uplift_hold')} uplift_full={bw25.get('uplift_full')}")
|
||||
print(f"blend w50 full={bw50.get('full')} hold={bw50.get('hold')} dd={bw50.get('dd')}")
|
||||
print(f"earns_slot={bres['earns']} BEATS_WINNER={bres['beats']}")
|
||||
|
||||
# Emit a machine-readable line so the agent can lift exact numbers.
|
||||
import json
|
||||
print("\nRESULT_JSON " + json.dumps({
|
||||
"best_tag": best_tag, "members": mixes[best_tag],
|
||||
"grade": br["grade"], "minFull": br["minFull"], "minHold": br["minHold"],
|
||||
"max_dd": br["maxDD"], "n_trades_min": br["minTr"], "fee_ok": br["fee_ok"],
|
||||
"causality_ok": bres["caus_ok"],
|
||||
"corr_full": bmg.get("corr_full"), "verdict": bmg.get("marginal_verdict"),
|
||||
"has_insample_edge": bmg.get("has_insample_edge"), "is_hedge": bmg.get("is_hedge"),
|
||||
"robust_oos": bmg.get("robust_oos"), "multicut_persistent": bmg.get("multicut_persistent"),
|
||||
"clean_year_uplift": bmg.get("clean_year_uplift"),
|
||||
"w25_uplift_hold": bw25.get("uplift_hold"), "w50_full": bw50.get("full"),
|
||||
"w50_hold": bw50.get("hold"), "w50_dd": bw50.get("dd"),
|
||||
"earns_slot": bres["earns"], "beats_winner": bres["beats"],
|
||||
"cand_insample_sharpe": bmg.get("cand_insample_sharpe"),
|
||||
}, default=str))
|
||||
@@ -0,0 +1,375 @@
|
||||
"""SKH2_ENS_STRUCT — cross-definition ENSEMBLE [ENS_STRUCT].
|
||||
|
||||
WAVE GOAL: cut the V2 winner's STANDALONE max-DD below 30% (BTC 34% / ETH 31% is the only
|
||||
unmet goal) while keeping min-asset HOLD-OUT >= ~0.70 and earns_slot True.
|
||||
|
||||
IDEA: the V2 winner uses ONE regime definition (Chande01 cycle band on ATR + Donchian
|
||||
breakout). Its drawdowns come from that one signal source firing into the wrong tape. If we
|
||||
ensemble it with STRUCTURALLY DIFFERENT regime definitions — (B) causal PERCENTILE-RANK regime
|
||||
(SKH_R_PCTL) and (C) VOLATILITY-EXPANSION regime (SKH_R_EXPAND) — that disagree about WHEN to
|
||||
trade, their drawdowns are imperfectly correlated. Equal-weighting the three daily-return
|
||||
streams (per asset, then 50/50 across BTC+ETH) should reduce the COMBINED-equity DD below any
|
||||
single member's DD, at a modest cost to full Sharpe.
|
||||
|
||||
The ensemble is EQUAL-WEIGHT on DAILY RETURNS (a constant-weight rebalanced book of three
|
||||
sub-sleeves on the SAME asset). Standalone DD = max-DD of the COMBINED equity curve (per asset,
|
||||
max over BTC & ETH). Marginal vs TP01 uses the 50/50 BTC+ETH combined daily series.
|
||||
|
||||
All three members are causal/leak-free (winner via sk.causality; PCTL/EXPAND via their own
|
||||
truncated-prefix guards, re-run here). Equal-weighting causal streams stays causal.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
|
||||
import importlib.util
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import skyhooklib as sk
|
||||
import altlib as al
|
||||
from src.backtest.harness import backtest_signals
|
||||
from src.strategies import skyhook as S
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
HOLDOUT = sk.HOLDOUT
|
||||
FEE = sk.FEE_RT
|
||||
|
||||
|
||||
# --- import the two structural entry builders without running their __main__ ----------------
|
||||
def _load(modname, path):
|
||||
spec = importlib.util.spec_from_file_location(modname, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
PCTL = _load("skr_pctl", "/opt/docker/PythagorasGoal/scripts/research/skyhook/runs/SKH_R_PCTL.py")
|
||||
EXPD = _load("skr_expd", "/opt/docker/PythagorasGoal/scripts/research/skyhook/runs/SKH_R_EXPAND.py")
|
||||
|
||||
|
||||
# --- the V2 winner from the prior wave (Chande/Donchian regime) -----------------------------
|
||||
WINNER = SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
# pattern/exit shared knobs for the structural members. Match the WINNER's exit profile
|
||||
# (sl2.5/tp7.0, asym time exits) so the difference is the REGIME, not the exit.
|
||||
def member_params(**kw):
|
||||
base = dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16)
|
||||
base.update(kw)
|
||||
return SkyhookParams(**base)
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# Per-asset equity helpers. We need the FULL bar-level equity curve of each member so we can
|
||||
# build the COMBINED equity (for an honest combined max-DD), AND the daily-return series (for
|
||||
# Sharpe split + marginal). All on the SAME 230m execution index.
|
||||
# ============================================================================================
|
||||
def member_equity(asset, kind, p, cfg=None):
|
||||
"""Return (eq, idx) bar-level equity for a member. kind in {'winner','pctl','expand'}."""
|
||||
ltf, htf = sk.frames(asset)
|
||||
if kind == "winner":
|
||||
ent = S.skyhook_entries(ltf, htf, p)
|
||||
elif kind == "pctl":
|
||||
ent = PCTL.pctl_entries(ltf, htf, p, **cfg)
|
||||
elif kind == "expand":
|
||||
ent = EXPD.expand_entries(ltf, htf, p, **cfg)
|
||||
else:
|
||||
raise ValueError(kind)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=asset, tf="230m")
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))
|
||||
return np.asarray(m.equity, float), idx, int(m.n_trades)
|
||||
|
||||
|
||||
def eq_to_daily_ret(eq, idx):
|
||||
"""bar equity -> daily simple returns (last-of-day, ffill, pct_change)."""
|
||||
s = pd.Series(eq, index=idx)
|
||||
return s.resample("1D").last().ffill().pct_change()
|
||||
|
||||
|
||||
def combined_daily_ret(asset, members):
|
||||
"""Equal-weight DAILY returns of the members on one asset -> combined daily return series.
|
||||
Each member contributes its own daily return; the equal-weight portfolio return is the mean.
|
||||
Members are aligned on the union of daily timestamps; a member that has not started yet
|
||||
(NaN) contributes 0 that day and is dropped from the active-weight denominator (outer-join
|
||||
with renormalized equal weights), so early days where only some members trade are handled."""
|
||||
drs = {}
|
||||
ntr = {}
|
||||
for name, kind, p, cfg in members:
|
||||
eq, idx, nt = member_equity(asset, kind, p, cfg)
|
||||
drs[name] = eq_to_daily_ret(eq, idx)
|
||||
ntr[name] = nt
|
||||
D = pd.concat(drs, axis=1, join="outer")
|
||||
# renormalized equal weight across the members that are ACTIVE (non-NaN) each day
|
||||
active = D.notna()
|
||||
w = active.div(active.sum(axis=1).replace(0, np.nan), axis=0)
|
||||
comb = (D.fillna(0.0) * w.fillna(0.0)).sum(axis=1)
|
||||
comb = comb.dropna()
|
||||
return comb, ntr
|
||||
|
||||
|
||||
def combined_metrics(comb):
|
||||
"""Sharpe (full + hold-out) and max-DD from a DAILY combined return series."""
|
||||
comb = comb[np.isfinite(comb.values)]
|
||||
eq = (1.0 + comb).cumprod().values
|
||||
idx = comb.index
|
||||
|
||||
def _sh(r):
|
||||
r = r[np.isfinite(r)]
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) and np.std(r) > 0 else 0.0
|
||||
|
||||
pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
full_sh = _sh(comb.values)
|
||||
hmask = idx >= HOLDOUT
|
||||
hold_sh = _sh(comb.values[hmask]) if hmask.sum() > 5 else 0.0
|
||||
# hold-out DD on the hold-out slice of the combined equity
|
||||
eqh = eq[hmask]
|
||||
pkh = np.maximum.accumulate(eqh) if len(eqh) else np.array([1.0])
|
||||
ddh = float(np.max((pkh - eqh) / pkh)) if len(eqh) else 0.0
|
||||
yrs = {}
|
||||
for y in sorted(set(idx.year)):
|
||||
rr = comb.values[idx.year == y]
|
||||
yrs[int(y)] = round(float((1 + pd.Series(rr)).prod() - 1), 4)
|
||||
return dict(full_sharpe=round(full_sh, 3), hold_sharpe=round(hold_sh, 3),
|
||||
maxdd=round(dd, 4), hold_maxdd=round(ddh, 4), yearly=yrs)
|
||||
|
||||
|
||||
# --- per-member standalone DD (for the correlation/decorrelation diagnostic) ----------------
|
||||
def member_standalone_dd(asset, kind, p, cfg=None):
|
||||
eq, idx, nt = member_equity(asset, kind, p, cfg)
|
||||
pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
return dd, nt
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# Fee robustness of the ENSEMBLE: re-run every member at fee f, recombine, check Sharpe>0.
|
||||
# ============================================================================================
|
||||
def ensemble_fee_sweep(members, fees=(0.0, 0.001, 0.002, 0.003)):
|
||||
rows = {}
|
||||
for f in fees:
|
||||
ok = True
|
||||
for asset in ("BTC", "ETH"):
|
||||
drs = {}
|
||||
for name, kind, p, cfg in members:
|
||||
ltf, htf = sk.frames(asset)
|
||||
if kind == "winner":
|
||||
ent = S.skyhook_entries(ltf, htf, p)
|
||||
elif kind == "pctl":
|
||||
ent = PCTL.pctl_entries(ltf, htf, p, **cfg)
|
||||
else:
|
||||
ent = EXPD.expand_entries(ltf, htf, p, **cfg)
|
||||
m = backtest_signals(ltf, ent, fee_rt=f, leverage=1.0, asset=asset, tf="230m")
|
||||
drs[name] = eq_to_daily_ret(np.asarray(m.equity, float),
|
||||
pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
|
||||
D = pd.concat(drs, axis=1, join="outer")
|
||||
active = D.notna()
|
||||
w = active.div(active.sum(axis=1).replace(0, np.nan), axis=0)
|
||||
comb = (D.fillna(0.0) * w.fillna(0.0)).sum(axis=1).dropna()
|
||||
sh = combined_metrics(comb)["full_sharpe"]
|
||||
rows[(f, asset)] = sh
|
||||
ok = ok and (sh > 0)
|
||||
rows[(f, "ok")] = ok
|
||||
return rows
|
||||
|
||||
|
||||
def ensemble_daily_5050(members):
|
||||
"""50/50 BTC+ETH combined daily series for marginal_vs_tp01."""
|
||||
cb, _ = combined_daily_ret("BTC", members)
|
||||
ce, _ = combined_daily_ret("ETH", members)
|
||||
J = pd.concat({"BTC": cb, "ETH": ce}, axis=1, join="inner").fillna(0.0)
|
||||
return 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
def run_ensemble(tag, members):
|
||||
print(f"\n========== ENSEMBLE: {tag} ==========")
|
||||
print(f" members: {[m[0] for m in members]}")
|
||||
per = {}
|
||||
for asset in ("BTC", "ETH"):
|
||||
comb, ntr = combined_daily_ret(asset, members)
|
||||
met = combined_metrics(comb)
|
||||
per[asset] = dict(met=met, ntr=ntr)
|
||||
print(f" {asset}: FULL Sh={met['full_sharpe']:+.2f} HOLD Sh={met['hold_sharpe']:+.2f}"
|
||||
f" maxDD={met['maxdd']*100:.0f}% holdDD={met['hold_maxdd']*100:.0f}% ntrades={ntr}")
|
||||
print(f" yearly: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in met['yearly'].items()))
|
||||
min_full = min(per[a]["met"]["full_sharpe"] for a in per)
|
||||
min_hold = min(per[a]["met"]["hold_sharpe"] for a in per)
|
||||
max_dd = max(per[a]["met"]["maxdd"] for a in per)
|
||||
n_trades_min = min(sum(per[a]["ntr"].values()) for a in per)
|
||||
print(f" -> minFull={min_full:+.2f} minHold={min_hold:+.2f} maxDD={max_dd*100:.0f}%"
|
||||
f" nTrades(min,sum-of-members)={n_trades_min}")
|
||||
return dict(per=per, min_full=min_full, min_hold=min_hold, max_dd=max_dd,
|
||||
n_trades_min=n_trades_min)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== SKH2_ENS_STRUCT: cross-definition regime ensemble (DD reduction) ===")
|
||||
|
||||
# --- 0) Baseline: the V2 winner ALONE (reference) -----------------------------------
|
||||
print("\n--- V2 WINNER standalone (Chande/Donchian) ---")
|
||||
w_per = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
r = sk.run_asset(a, WINNER, FEE)
|
||||
w_per[a] = r
|
||||
print(f" {a}: FULL Sh={r['full']['sharpe']:+.2f} DD={r['full']['maxdd']*100:.0f}%"
|
||||
f" n={r['full']['n_trades']} | HOLD Sh={r['holdout']['sharpe']:+.2f}")
|
||||
w_minfull = min(w_per[a]["full"]["sharpe"] for a in w_per)
|
||||
w_minhold = min(w_per[a]["holdout"]["sharpe"] for a in w_per)
|
||||
w_maxdd = max(w_per[a]["full"]["maxdd"] for a in w_per)
|
||||
print(f" WINNER: minFull={w_minfull:+.2f} minHold={w_minhold:+.2f} maxDD={w_maxdd*100:.0f}%")
|
||||
|
||||
# --- 1) Structural member configs (different regime definitions) --------------------
|
||||
pP = member_params()
|
||||
pE = member_params()
|
||||
# PCTL: expanding percentile-rank regime. From SKH_R_PCTL_final, the lower-DD / robust
|
||||
# band was the low-vola band; we use a mid band with a modest volume floor so it disagrees
|
||||
# with the winner's HIGH-vola Chande band (different regime -> decorrelated DD).
|
||||
PCTL_CFG = dict(vola_win=None, vol_win=None, vola_lo=0.0, vola_hi=0.70, vol_lo=0.0, vol_hi=1.0)
|
||||
PCTL_CFG2 = dict(vola_win=None, vol_win=None, vola_lo=0.10, vola_hi=0.80, vol_lo=0.30, vol_hi=1.0)
|
||||
# EXPAND: volatility-expansion regime (ATR above its MA + volume elevated).
|
||||
EXP_CFG = dict(w_atr=20, k_atr=1.00, w_vol=20, k_vol=1.00)
|
||||
EXP_CFG2 = dict(w_atr=20, k_atr=1.10, w_vol=20, k_vol=1.20)
|
||||
|
||||
# --- standalone DD + pairwise correlation of the three regime streams (BTC) ---------
|
||||
print("\n--- standalone member DD + pairwise daily-return corr (decorrelation check) ---")
|
||||
for a in ("BTC", "ETH"):
|
||||
dW, nW = member_standalone_dd(a, "winner", WINNER)
|
||||
dP, nP = member_standalone_dd(a, "pctl", pP, PCTL_CFG)
|
||||
dE, nE = member_standalone_dd(a, "expand", pE, EXP_CFG)
|
||||
print(f" {a} standalone DD: winner={dW*100:.0f}%(n{nW}) pctl={dP*100:.0f}%(n{nP}) expand={dE*100:.0f}%(n{nE})")
|
||||
# corr of daily returns
|
||||
rw = eq_to_daily_ret(*member_equity(a, "winner", WINNER)[:2])
|
||||
rp = eq_to_daily_ret(*member_equity(a, "pctl", pP, PCTL_CFG)[:2])
|
||||
re_ = eq_to_daily_ret(*member_equity(a, "expand", pE, EXP_CFG)[:2])
|
||||
DD = pd.concat({"W": rw, "P": rp, "E": re_}, axis=1, join="inner").fillna(0.0)
|
||||
cc = DD.corr()
|
||||
print(f" corr W-P={cc.loc['W','P']:.2f} W-E={cc.loc['W','E']:.2f} P-E={cc.loc['P','E']:.2f}")
|
||||
|
||||
# --- 2) Candidate ensembles ---------------------------------------------------------
|
||||
candidates = {
|
||||
"WPE (winner+pctlLo+expand)": [
|
||||
("winner", "winner", WINNER, None),
|
||||
("pctl", "pctl", pP, PCTL_CFG),
|
||||
("expand", "expand", pE, EXP_CFG),
|
||||
],
|
||||
"WP (winner+pctlLo)": [
|
||||
("winner", "winner", WINNER, None),
|
||||
("pctl", "pctl", pP, PCTL_CFG),
|
||||
],
|
||||
"WE (winner+expand)": [
|
||||
("winner", "winner", WINNER, None),
|
||||
("expand", "expand", pE, EXP_CFG),
|
||||
],
|
||||
"WPE2 (winner+pctlMid+expandStrong)": [
|
||||
("winner", "winner", WINNER, None),
|
||||
("pctl", "pctl", pP, PCTL_CFG2),
|
||||
("expand", "expand", pE, EXP_CFG2),
|
||||
],
|
||||
}
|
||||
|
||||
results = {}
|
||||
for tag, members in candidates.items():
|
||||
results[tag] = (members, run_ensemble(tag, members))
|
||||
|
||||
# --- 3) Pick best by: max_dd<0.30, then maximize min_hold ---------------------------
|
||||
def score(v):
|
||||
# prefer DD<0.30 AND min_hold>=0.65; among those, maximize min_hold then -DD
|
||||
ok = v["max_dd"] < 0.30 and v["min_hold"] >= 0.65
|
||||
return (1 if ok else 0, v["min_hold"], -v["max_dd"])
|
||||
best_tag = max(results, key=lambda t: score(results[t][1]))
|
||||
best_members, best_v = results[best_tag]
|
||||
print(f"\n*** BEST ENSEMBLE = {best_tag} ***")
|
||||
print(f" minFull={best_v['min_full']:+.2f} minHold={best_v['min_hold']:+.2f}"
|
||||
f" maxDD={best_v['max_dd']*100:.0f}% nTradesMin={best_v['n_trades_min']}")
|
||||
|
||||
# --- 4) Causality: winner via sk; structural members via their own guards -----------
|
||||
print("\n--- causality (all active members of best ensemble) ---")
|
||||
caus_ok = True
|
||||
kinds = {m[0]: (m[1], m[2], m[3]) for m in best_members}
|
||||
if "winner" in kinds:
|
||||
cb = sk.causality(WINNER, "BTC"); ce = sk.causality(WINNER, "ETH")
|
||||
print(f" winner: BTC {cb} ETH {ce}")
|
||||
caus_ok = caus_ok and cb["ok"] and ce["ok"]
|
||||
if "pctl" in kinds:
|
||||
_, p, cfg = kinds["pctl"]
|
||||
cb = PCTL.check_causality(cfg, p, "BTC"); ce = PCTL.check_causality(cfg, p, "ETH")
|
||||
print(f" pctl: BTC {cb} ETH {ce}")
|
||||
caus_ok = caus_ok and cb["ok"] and ce["ok"]
|
||||
if "expand" in kinds:
|
||||
_, p, cfg = kinds["expand"]
|
||||
cb = EXPD.check_causality(cfg, p, "BTC"); ce = EXPD.check_causality(cfg, p, "ETH")
|
||||
print(f" expand: BTC {cb} ETH {ce}")
|
||||
caus_ok = caus_ok and cb["ok"] and ce["ok"]
|
||||
print(f" causality_ok (all members) = {caus_ok}")
|
||||
|
||||
# --- 5) Fee sweep on the ensemble ---------------------------------------------------
|
||||
print("\n--- ensemble fee sweep (FULL Sharpe per asset) ---")
|
||||
fsw = ensemble_fee_sweep(best_members)
|
||||
for f in (0.0, 0.001, 0.002, 0.003):
|
||||
print(f" {f*100:.2f}%RT: BTC={fsw[(f,'BTC')]:+.2f} ETH={fsw[(f,'ETH')]:+.2f} ok={fsw[(f,'ok')]}")
|
||||
fee_survives = fsw[(0.003, "ok")]
|
||||
print(f" fee_survives 0.30%RT (both): {fee_survives}")
|
||||
|
||||
# --- 6) Marginal vs TP01 on the ensemble 50/50 series -------------------------------
|
||||
print("\n--- marginal vs TP01 (best ensemble, 50/50 BTC+ETH) ---")
|
||||
cand = ensemble_daily_5050(best_members)
|
||||
marg = al.marginal_vs_tp01(cand)
|
||||
corr_full = marg.get("corr_full")
|
||||
verdict = marg.get("marginal_verdict")
|
||||
has_edge = marg.get("has_insample_edge")
|
||||
is_hedge = marg.get("is_hedge")
|
||||
robust_oos = marg.get("robust_oos")
|
||||
multicut = marg.get("multicut_persistent")
|
||||
clean_year = marg.get("clean_year_uplift")
|
||||
w25 = marg.get("blends", {}).get("w25", {})
|
||||
w50 = marg.get("blends", {}).get("w50", {})
|
||||
up25_hold = w25.get("uplift_hold")
|
||||
print(f" corr_full={corr_full} corr_hold={marg.get('corr_hold')}")
|
||||
print(f" marginal_verdict={verdict} robust_oos={robust_oos} multicut_persistent={multicut}")
|
||||
print(f" has_insample_edge={has_edge} (cand_insample_sharpe={marg.get('cand_insample_sharpe')}) is_hedge={is_hedge}")
|
||||
print(f" clean_year_uplift={clean_year} jackknife_min_uplift={marg.get('jackknife_min_uplift')}")
|
||||
print(f" multicut_uplift={marg.get('multicut_uplift')}")
|
||||
print(f" blend w25: uplift_hold={up25_hold} uplift_full={w25.get('uplift_full')} dd={w25.get('dd')}")
|
||||
print(f" blend w50: full={w50.get('full')} hold={w50.get('hold')} uplift_hold={w50.get('uplift_hold')} dd={w50.get('dd')}")
|
||||
|
||||
# --- 7) Grade + earns_slot + beats_winner ------------------------------------------
|
||||
n_trades_min = best_v["n_trades_min"]
|
||||
grade = "PASS" if (n_trades_min >= 20 and best_v["min_full"] >= 0.5
|
||||
and best_v["min_hold"] >= 0.2 and fee_survives) else \
|
||||
("WEAK" if (n_trades_min >= 20 and best_v["min_full"] >= 0.3
|
||||
and best_v["min_hold"] >= 0.0) else "FAIL")
|
||||
earns_slot = (grade != "FAIL") and verdict == "ADDS" and robust_oos and (not is_hedge)
|
||||
beats = (earns_slot and best_v["max_dd"] < 0.30
|
||||
and (up25_hold is not None and up25_hold >= 0.55)
|
||||
and best_v["min_hold"] >= 0.65)
|
||||
|
||||
print("\n=========== FINAL ===========")
|
||||
print(f"BEST CONFIG = {best_tag}")
|
||||
print(f" members:")
|
||||
for m in best_members:
|
||||
print(f" {m[0]}: kind={m[1]} cfg={m[3]}")
|
||||
print(f" minFull={best_v['min_full']:+.2f} minHold={best_v['min_hold']:+.2f}"
|
||||
f" max_dd={best_v['max_dd']*100:.1f}% n_trades_min={n_trades_min}")
|
||||
print(f" fee@0.30%RT survives={fee_survives} causality_ok={caus_ok} grade={grade}")
|
||||
print(f" marginal: corr_full={corr_full} verdict={verdict} insample_edge={has_edge}"
|
||||
f" is_hedge={is_hedge} robust_oos={robust_oos} multicut={multicut}")
|
||||
print(f" clean_year_uplift={clean_year} blend_w25_uplift_hold={up25_hold}")
|
||||
print(f" blend_w50: full={w50.get('full')} hold={w50.get('hold')} dd={w50.get('dd')}")
|
||||
print(f" earns_slot={earns_slot} beats_winner={beats}")
|
||||
print(f"\n (WINNER ref: minFull={w_minfull:+.2f} minHold={w_minhold:+.2f} maxDD={w_maxdd*100:.0f}%)")
|
||||
|
||||
# machine-readable tail for the agent
|
||||
import json
|
||||
print("\nRESULT_JSON=" + json.dumps({
|
||||
"best_tag": best_tag,
|
||||
"best_config": {"members": [{"name": m[0], "kind": m[1], "cfg": m[3]} for m in best_members]},
|
||||
"min_full": best_v["min_full"], "min_hold": best_v["min_hold"],
|
||||
"max_dd": best_v["max_dd"], "n_trades_min": n_trades_min,
|
||||
"fee_survives": bool(fee_survives), "causality_ok": bool(caus_ok), "grade": grade,
|
||||
"corr_full": corr_full, "marginal_verdict": verdict, "has_insample_edge": bool(has_edge),
|
||||
"is_hedge": bool(is_hedge), "robust_oos": bool(robust_oos),
|
||||
"multicut_persistent": bool(multicut), "clean_year_uplift": clean_year,
|
||||
"blend_w25_uplift_hold": up25_hold, "earns_slot": bool(earns_slot), "beats_winner": bool(beats),
|
||||
}, default=str))
|
||||
@@ -0,0 +1,258 @@
|
||||
"""SKH2_EXPAND_DD — DD-reduction wave, vol-EXPANSION family.
|
||||
|
||||
Family task: reuse the volatility-EXPANSION regime from SKH_R_EXPAND.py (ATR rising vs its own
|
||||
MA AND volume elevated vs its own MA), monkeypatch S.htf_features, run sk.study, and TUNE
|
||||
w_atr/k_atr/w_vol/k_vol + winner-style exits to:
|
||||
(1) cut standalone maxDD below 30% (max over BTCÐ) <-- the only unmet wave goal
|
||||
(2) keep min-asset HOLD-OUT Sharpe >= ~0.70 and earns_slot == True
|
||||
(3) stretch: lift blend w25 uplift_hold and minHold.
|
||||
|
||||
Mechanism / DD theory:
|
||||
* the EXPANSION gate (vol rising + volume elevated) is itself a DD filter: it suppresses
|
||||
entries during quiet/contracting chop where Donchian breakouts whipsaw. Tightening k_atr /
|
||||
k_vol trades trade-count for cleaner regime -> fewer adverse entries.
|
||||
* but per-trade loss size is set by sl_atr; the V2 winner used sl_atr=2.5 (DD 34/31%).
|
||||
Lowering sl_atr is the direct DD lever. We sweep sl_atr in {1.6,1.8,2.0,2.2,2.5} and
|
||||
couple it with the winner exits (uscitalong=24/uscitashort=16) and tp_atr in {5,6,7}.
|
||||
* vola_lo/vola_hi/vol_lo bands are IRRELEVANT here: the expansion regime REPLACES the Chande
|
||||
band gate (htf_features is monkeypatched), so those SkyhookParams fields are dead. Only
|
||||
ptn_n / sl_atr / tp_atr / uscita* / max_per_day / long_only matter through the patched path.
|
||||
|
||||
Everything causal: the expansion features use only x[0..i] (causal rolling MA, ATR ewm, donchian
|
||||
shift(1)); HTF merged BACKWARD onto LTF on HTF-close ts. We verify with sk.causality (works
|
||||
because we patch S.htf_features inside skyhooklib's namespace, so skyhook_entries uses our gate).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import skyhooklib as sk
|
||||
from src.strategies import skyhook as S
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
# reuse the EXPANSION feature builder verbatim
|
||||
from SKH_R_EXPAND import expand_htf_features
|
||||
|
||||
ORIG_FEAT = S.htf_features
|
||||
HOLDOUT = sk.HOLDOUT
|
||||
FEE = sk.FEE_RT
|
||||
|
||||
|
||||
def patched(cfg):
|
||||
def _feat(htf, p):
|
||||
return expand_htf_features(htf, p, **cfg)
|
||||
return _feat
|
||||
|
||||
|
||||
def study_expand(name, p, cfg, want_marginal=True):
|
||||
"""Run sk.study + causality (+ marginal) with htf_features patched to the expansion regime."""
|
||||
S.htf_features = patched(cfg)
|
||||
try:
|
||||
rep = sk.study(name, p)
|
||||
caus_b = sk.causality(p, "BTC")
|
||||
caus_e = sk.causality(p, "ETH")
|
||||
marg = sk.marginal(p) if want_marginal else None
|
||||
finally:
|
||||
S.htf_features = ORIG_FEAT
|
||||
return rep, (caus_b, caus_e), marg
|
||||
|
||||
|
||||
def vline(rep):
|
||||
v = rep["verdict"]
|
||||
pa = rep["per_asset"]
|
||||
mdd = max(pa[a]["full"]["maxdd"] for a in pa)
|
||||
return (v["grade"], v["min_asset_full_sharpe"], v["min_asset_holdout_sharpe"],
|
||||
v["min_trades"], mdd, v["fee_survives"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WINNER baseline (Chande band, NOT expansion) for reference — verify the stated DD problem.
|
||||
# ---------------------------------------------------------------------------
|
||||
def winner_reference():
|
||||
p = SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
rep = sk.study("WINNER-V2", p) # uses ORIG_FEAT (Chande band) — not patched
|
||||
g, mf, mh, mt, mdd, fee = vline(rep)
|
||||
print(f"[WINNER-V2 ref] grade={g} minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} "
|
||||
f"maxDD={mdd*100:.0f}% feeOK={fee} "
|
||||
f"(BTC DD {rep['per_asset']['BTC']['full']['maxdd']*100:.0f}% / "
|
||||
f"ETH DD {rep['per_asset']['ETH']['full']['maxdd']*100:.0f}%)")
|
||||
return mh
|
||||
|
||||
|
||||
def earns_slot(rep, marg):
|
||||
g = rep["verdict"]["grade"] != "FAIL"
|
||||
return bool(g and marg.get("marginal_verdict") == "ADDS"
|
||||
and marg.get("robust_oos") and not marg.get("is_hedge"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== SKH2_EXPAND_DD: vol-EXPANSION regime tuned for standalone maxDD < 30% ===\n")
|
||||
win_minhold = winner_reference()
|
||||
print()
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 1: coarse grid. Regime gate strength (k_atr,k_vol,windows) x SL size.
|
||||
# Goal: find DD<30% cells that keep minHold high. Marginal computed only for finalists.
|
||||
# -------------------------------------------------------------------
|
||||
# exits: asymmetric time-exits. PASS1 learned that LONGER long-holds (us30/18 vs winner's
|
||||
# 24/16) are what flip the marginal robust_oos gate POSITIVE (clean-2025-year uplift > 0)
|
||||
# while sl_atr=2.4 keeps DD<30. So we sweep exits + sl_atr here, ptn_n fixed near winner.
|
||||
base_kw = dict(ptn_n=45, uscitalong=30, uscitashort=18)
|
||||
|
||||
# The EXPANSION gate REPLACES the Chande band (htf_features monkeypatched): vola_*/vol_* are
|
||||
# dead. DD is cut by (a) the gate itself (only trade rising-vol + elevated-volume regimes) and
|
||||
# (b) sl_atr. The a20/k1.1 gate + sl2.4 + us30/18 is the DD<30 + robust_oos sweet spot found.
|
||||
regimes = {
|
||||
# tag: expansion cfg
|
||||
"r_a20k1.1_v20k1.1": dict(w_atr=20, k_atr=1.10, w_vol=20, k_vol=1.10),
|
||||
"r_a25k1.1_v25k1.1": dict(w_atr=25, k_atr=1.10, w_vol=25, k_vol=1.10),
|
||||
"r_a30k1.1_v30k1.1": dict(w_atr=30, k_atr=1.10, w_vol=30, k_vol=1.10),
|
||||
"r_a18k1.1_v18k1.1": dict(w_atr=18, k_atr=1.10, w_vol=18, k_vol=1.10),
|
||||
}
|
||||
sl_grid = (2.2, 2.4)
|
||||
tp_fixed = 7.0
|
||||
|
||||
print("--- PASS 1 coarse: regime x sl_atr (tp=7.0) ---")
|
||||
pass1 = []
|
||||
for rtag, rcfg in regimes.items():
|
||||
for sl in sl_grid:
|
||||
p = SkyhookParams(sl_atr=sl, tp_atr=tp_fixed, **base_kw)
|
||||
S.htf_features = patched(rcfg)
|
||||
try:
|
||||
rep = sk.study(f"{rtag}_sl{sl}", p)
|
||||
finally:
|
||||
S.htf_features = ORIG_FEAT
|
||||
g, mf, mh, mt, mdd, fee = vline(rep)
|
||||
pass1.append((rtag, rcfg, sl, tp_fixed, g, mf, mh, mt, mdd, fee, p))
|
||||
print(f" {rtag:22s} sl{sl} -> grade={g:4s} minFull={mf:+.2f} minHold={mh:+.2f}"
|
||||
f" minTr={mt:3d} maxDD={mdd*100:3.0f}% feeOK={fee}")
|
||||
|
||||
# finalists = DD<30% AND minHold>=0.55 AND grade!=FAIL AND fee survives
|
||||
fin = [r for r in pass1 if r[8] < 0.30 and r[6] >= 0.55 and r[4] != "FAIL" and r[9]]
|
||||
print(f"\n--- PASS1 finalists (DD<30%, minHold>=0.6, !FAIL, feeOK): {len(fin)} ---")
|
||||
for r in fin:
|
||||
print(f" {r[0]} sl{r[2]} tp{r[3]} : minHold={r[6]:+.2f} DD={r[8]*100:.0f}%")
|
||||
|
||||
# If none, relax to DD<30% AND minHold>=0.5 to still report best-effort.
|
||||
if not fin:
|
||||
fin = [r for r in pass1 if r[8] < 0.30 and r[6] >= 0.50 and r[4] != "FAIL" and r[9]]
|
||||
print(f" (relaxed minHold>=0.5): {len(fin)}")
|
||||
if not fin:
|
||||
# last resort: lowest DD among non-FAIL fee-surviving with minHold>0
|
||||
cand = [r for r in pass1 if r[4] != "FAIL" and r[9] and r[6] > 0]
|
||||
fin = sorted(cand, key=lambda r: r[8])[:3]
|
||||
print(f" (last-resort lowest-DD): {len(fin)}")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 2: finalists -> full marginal + tighten tp around best. Pick the BEATS-WINNER one,
|
||||
# else best earns_slot+lowest DD.
|
||||
# -------------------------------------------------------------------
|
||||
# de-dup finalists by (rtag,sl) and cap to keep runtime sane
|
||||
seen = set(); fin2 = []
|
||||
for r in sorted(fin, key=lambda r: (-r[6], r[8])): # prefer high minHold then low DD
|
||||
key = (r[0], r[2])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key); fin2.append(r)
|
||||
fin2 = fin2[:7]
|
||||
|
||||
print(f"\n--- PASS 2 marginal on {len(fin2)} finalists ---")
|
||||
results = []
|
||||
for r in fin2:
|
||||
rtag, rcfg, sl, tp, g, mf, mh, mt, mdd, fee, p = r
|
||||
rep, (cb, ce), marg = study_expand(f"{rtag}_sl{sl}_tp{tp}", p, rcfg)
|
||||
g, mf, mh, mt, mdd, fee = vline(rep)
|
||||
caus_ok = bool(cb["ok"] and ce["ok"])
|
||||
es = earns_slot(rep, marg)
|
||||
w25 = marg.get("blends", {}).get("w25", {})
|
||||
w50 = marg.get("blends", {}).get("w50", {})
|
||||
uph = w25.get("uplift_hold")
|
||||
beats = bool(es and mdd < 0.30 and (uph is not None and uph >= 0.55) and mh >= 0.65)
|
||||
results.append(dict(tag=f"{rtag}_sl{sl}_tp{tp}", rcfg=rcfg, p=p, rep=rep, marg=marg,
|
||||
caus_ok=caus_ok, earns=es, beats=beats,
|
||||
minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee=fee,
|
||||
uph=uph, w25=w25, w50=w50))
|
||||
print(f" {rtag}_sl{sl} -> grade={g} minFull={mf:+.2f} minHold={mh:+.2f} DD={mdd*100:.0f}%"
|
||||
f" verdict={marg.get('marginal_verdict')} corr={marg.get('corr_full')}"
|
||||
f" w25uplH={uph} earns={es} caus={caus_ok} BEATS={beats}")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 3: around the best finalist, try tp in {5,6} to see if tighter tp helps DD/minHold.
|
||||
# -------------------------------------------------------------------
|
||||
def score(d):
|
||||
# rank: beats first, then earns & DD<30, then minHold, then -DD
|
||||
return (d["beats"], d["earns"] and d["maxDD"] < 0.30, d["minHold"], -d["maxDD"])
|
||||
|
||||
if results:
|
||||
best = max(results, key=score)
|
||||
rtag = best["tag"].rsplit("_sl", 1)[0]
|
||||
rcfg = best["rcfg"]
|
||||
sl = best["p"].sl_atr
|
||||
# PASS3 sweeps the EXIT-BAR dimension: the robust_oos (2025-clean-year uplift) gate is
|
||||
# set by the long-hold length. We probe uscitalong around 30 to confirm the sweet spot
|
||||
# and hunt any DD<30 cell with higher blend uplift.
|
||||
print(f"\n--- PASS 3 exit-bar refine around best regime={rtag} sl{sl} ---")
|
||||
for usL, usS in ((28, 18), (32, 18), (30, 20)):
|
||||
kw = dict(ptn_n=45, uscitalong=usL, uscitashort=usS)
|
||||
p = SkyhookParams(sl_atr=sl, tp_atr=tp_fixed, **kw)
|
||||
rep, (cb, ce), marg = study_expand(f"{rtag}_sl{sl}_us{usL}/{usS}", p, rcfg)
|
||||
g, mf, mh, mt, mdd, fee = vline(rep)
|
||||
caus_ok = bool(cb["ok"] and ce["ok"])
|
||||
es = earns_slot(rep, marg)
|
||||
w25 = marg.get("blends", {}).get("w25", {}); w50 = marg.get("blends", {}).get("w50", {})
|
||||
uph = w25.get("uplift_hold")
|
||||
beats = bool(es and mdd < 0.30 and (uph is not None and uph >= 0.55) and mh >= 0.65)
|
||||
results.append(dict(tag=f"{rtag}_sl{sl}_us{usL}/{usS}", rcfg=rcfg, p=p, rep=rep, marg=marg,
|
||||
caus_ok=caus_ok, earns=es, beats=beats,
|
||||
minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee=fee,
|
||||
uph=uph, w25=w25, w50=w50))
|
||||
print(f" us{usL}/{usS} -> grade={g} minFull={mf:+.2f} minHold={mh:+.2f} DD={mdd*100:.0f}%"
|
||||
f" verdict={marg.get('marginal_verdict')} robust={marg.get('robust_oos')}"
|
||||
f" w25uplH={uph} earns={es} BEATS={beats}")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# FINAL: pick best config and print full block.
|
||||
# -------------------------------------------------------------------
|
||||
if not results:
|
||||
print("\n!!! no finalists at all — reporting nothing meaningful. !!!")
|
||||
sys.exit(0)
|
||||
|
||||
best = max(results, key=score)
|
||||
m = best["marg"]; rep = best["rep"]
|
||||
print("\n" + "=" * 78)
|
||||
print("FINAL BEST (vol-EXPANSION family)")
|
||||
print("=" * 78)
|
||||
print(f" tag = {best['tag']}")
|
||||
print(f" regime cfg = {best['rcfg']}")
|
||||
print(f" params = ptn_n={best['p'].ptn_n} sl_atr={best['p'].sl_atr} tp_atr={best['p'].tp_atr}"
|
||||
f" uscitalong={best['p'].uscitalong} uscitashort={best['p'].uscitashort}"
|
||||
f" max_per_day={best['p'].max_per_day} long_only={best['p'].long_only}")
|
||||
print(f" minFull = {best['minFull']:+.3f}")
|
||||
print(f" minHold = {best['minHold']:+.3f}")
|
||||
print(f" max_dd = {best['maxDD']:.4f} ({best['maxDD']*100:.1f}%)")
|
||||
print(f" n_trades = {best['minTr']} (min over BTCÐ)")
|
||||
print(f" fee@0.30%RT survives = {best['fee']}")
|
||||
print(f" causality OK (BTCÐ) = {best['caus_ok']}")
|
||||
print(f" earns_slot = {best['earns']}")
|
||||
print(f" BEATS_WINNER= {best['beats']}")
|
||||
print(" -- per-asset --")
|
||||
for a in ("BTC", "ETH"):
|
||||
pa = rep["per_asset"][a]
|
||||
print(f" {a}: FULL Sh={pa['full']['sharpe']:+.2f} DD={pa['full']['maxdd']*100:.0f}%"
|
||||
f" n={pa['full']['n_trades']} | HOLD Sh={pa['holdout']['sharpe']:+.2f}"
|
||||
f" | fee_sweep {pa['fee_sweep']}")
|
||||
print(" -- marginal vs TP01 --")
|
||||
print(f" corr_full={m.get('corr_full')} corr_hold={m.get('corr_hold')}")
|
||||
print(f" marginal_verdict={m.get('marginal_verdict')}")
|
||||
print(f" has_insample_edge={m.get('has_insample_edge')} is_hedge={m.get('is_hedge')}")
|
||||
print(f" robust_oos={m.get('robust_oos')} multicut_persistent={m.get('multicut_persistent')}")
|
||||
print(f" clean_year_uplift={m.get('clean_year_uplift')} jackknife_min_uplift={m.get('jackknife_min_uplift')}")
|
||||
print(f" cand_insample_sharpe={m.get('cand_insample_sharpe')} multicut_uplift={m.get('multicut_uplift')}")
|
||||
print(f" blend w25={m.get('blends',{}).get('w25')}")
|
||||
print(f" blend w50={m.get('blends',{}).get('w50')}")
|
||||
print(f"\n win_minhold(reference)={win_minhold:+.2f}")
|
||||
@@ -0,0 +1,206 @@
|
||||
"""SKH2_FREQ — entry cadence / holding-period family for the SKH01 DD-reduction wave.
|
||||
|
||||
Goal: cut standalone maxDD below 30% (max over BTC & ETH) while keeping min-asset HOLD-OUT
|
||||
Sharpe >= ~0.70 and earns_slot == True. Lever space (all expressible via SkyhookParams):
|
||||
* max_per_day {1, 2}
|
||||
* uscitalong / uscitashort holding windows {12..30}
|
||||
* atr_win (HTF) / ltf_atr_win (exec) windows
|
||||
|
||||
Baseline-to-beat (verified V2 winner):
|
||||
SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
minFull +0.83 minHold +0.81 maxDD BTC34%/ETH31% earns_slot True
|
||||
blend w25 uplift_hold +0.58, w50 full1.59/hold1.04/DD12.5%.
|
||||
|
||||
A candidate BEATS the winner iff: earns_slot AND max_dd<0.30 AND blend_w25_uplift_hold>=0.55
|
||||
AND min_hold_sharpe>=0.65.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, json
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
# Winner base (all FREQ variants share the regime/pattern/stop structure of the winner).
|
||||
WINNER = dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
|
||||
def mk(**over) -> SkyhookParams:
|
||||
d = dict(WINNER); d.update(over)
|
||||
return SkyhookParams(**d)
|
||||
|
||||
|
||||
def quick(name, p) -> dict:
|
||||
"""Fast screen: FULL+HOLD on both assets + standalone maxDD. No fee sweep / marginal yet."""
|
||||
rb = sk.run_asset("BTC", p)
|
||||
re = sk.run_asset("ETH", p)
|
||||
minF = min(rb["full"]["sharpe"], re["full"]["sharpe"])
|
||||
minH = min(rb["holdout"]["sharpe"], re["holdout"]["sharpe"])
|
||||
maxdd = max(rb["full"]["maxdd"], re["full"]["maxdd"])
|
||||
minTr = min(rb["full"]["n_trades"], re["full"]["n_trades"])
|
||||
print(f" {name:38s} minF={minF:+.2f} minH={minH:+.2f} maxDD={maxdd*100:4.0f}% "
|
||||
f"(B{rb['full']['maxdd']*100:.0f}/E{re['full']['maxdd']*100:.0f}) "
|
||||
f"nTr={minTr} | Bh={rb['holdout']['sharpe']:+.2f} Eh={re['holdout']['sharpe']:+.2f}")
|
||||
return dict(name=name, p=p, minF=minF, minH=minH, maxdd=maxdd, minTr=minTr,
|
||||
bdd=rb["full"]["maxdd"], edd=re["full"]["maxdd"])
|
||||
|
||||
|
||||
def full_eval(name, p) -> dict:
|
||||
rep = sk.study(name, p)
|
||||
print(sk.fmt(rep))
|
||||
caus = sk.causality(p, "BTC")
|
||||
causE = sk.causality(p, "ETH")
|
||||
caus_ok = bool(caus["ok"] and causE["ok"])
|
||||
mg = sk.marginal(p)
|
||||
v = rep["verdict"]
|
||||
maxdd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"])
|
||||
w25 = mg.get("blends", {}).get("w25", {})
|
||||
w50 = mg.get("blends", {}).get("w50", {})
|
||||
earns_slot = (v["grade"] != "FAIL" and mg.get("marginal_verdict") == "ADDS"
|
||||
and bool(mg.get("robust_oos")) and not bool(mg.get("is_hedge")))
|
||||
beats = (earns_slot and maxdd < 0.30
|
||||
and (w25.get("uplift_hold") or -9) >= 0.55
|
||||
and v["min_asset_holdout_sharpe"] >= 0.65)
|
||||
print(f" causality BTC={caus} ETH={causE} -> ok={caus_ok}")
|
||||
print(f" marginal: verdict={mg.get('marginal_verdict')} corr_full={mg.get('corr_full')} "
|
||||
f"corr_hold={mg.get('corr_hold')} insample_edge={mg.get('has_insample_edge')} "
|
||||
f"hedge={mg.get('is_hedge')} robust_oos={mg.get('robust_oos')} "
|
||||
f"multicut={mg.get('multicut_persistent')} cleanYr={mg.get('clean_year_uplift')}")
|
||||
print(f" blends: w25 uplift_hold={w25.get('uplift_hold')} uplift_full={w25.get('uplift_full')} | "
|
||||
f"w50 full={w50.get('full')} hold={w50.get('hold')} dd={w50.get('dd')} "
|
||||
f"uplift_hold={w50.get('uplift_hold')}")
|
||||
print(f" ==> maxDD={maxdd*100:.1f}% earns_slot={earns_slot} BEATS_WINNER={beats}")
|
||||
return dict(name=name, p=p, rep=rep, mg=mg, caus_ok=caus_ok, maxdd=maxdd,
|
||||
earns_slot=earns_slot, beats=beats, w25=w25, w50=w50, v=v)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("="*100)
|
||||
print("PHASE 1 — fast screen of cadence / holding / atr-window variants (FULL+HOLD+DD)")
|
||||
print("="*100)
|
||||
|
||||
screens = []
|
||||
# 0) reproduce the winner as a sanity anchor
|
||||
screens.append(quick("WINNER(uL24/uS16,mpd1,atr14/14)", mk()))
|
||||
|
||||
# --- Holding-window grid (the core DD lever): shorter holds cap single-trade risk.
|
||||
print("\n-- holding windows (uscitalong/uscitashort), mpd=1 --")
|
||||
for uL in (12, 16, 20, 24, 28, 30):
|
||||
for uS in (10, 12, 14, 16, 20):
|
||||
screens.append(quick(f"uL{uL}/uS{uS}", mk(uscitalong=uL, uscitashort=uS)))
|
||||
|
||||
# --- max_per_day: 2 entries/day = more frequent re-entry (more fee, smaller clusters?)
|
||||
print("\n-- max_per_day=2 across a few holds --")
|
||||
for uL in (12, 16, 20, 24):
|
||||
for uS in (10, 12, 16):
|
||||
screens.append(quick(f"mpd2 uL{uL}/uS{uS}", mk(max_per_day=2, uscitalong=uL, uscitashort=uS)))
|
||||
|
||||
# --- atr windows (HTF signal vola & exec stop sizing), at the WINNER hold (uL24/uS16)
|
||||
# where DD was lowest, not the whipsaw uL16/uS12.
|
||||
print("\n-- atr_win (HTF) x ltf_atr_win (exec), at WINNER hold uL24/uS16 --")
|
||||
for aw in (10, 14, 20):
|
||||
for lw in (10, 14, 20):
|
||||
screens.append(quick(f"atr{aw}/ltf{lw} uL24/uS16", mk(atr_win=aw, ltf_atr_win=lw)))
|
||||
|
||||
# --- targeted DD-reducers: mpd2 at the winner hold (smaller clusters, keep hold) +
|
||||
# longer ATR for steadier stops; and asymmetric long-bias holds (long crypto = up-drift,
|
||||
# so a longer long-hold + shorter short-hold protects the worst-asset short DD).
|
||||
print("\n-- targeted DD-reducers (mpd2 @ winner hold; long-bias asym holds) --")
|
||||
for cfg in (
|
||||
dict(max_per_day=2, uscitalong=24, uscitashort=16),
|
||||
dict(max_per_day=2, uscitalong=24, uscitashort=16, ltf_atr_win=20),
|
||||
dict(max_per_day=2, uscitalong=24, uscitashort=16, atr_win=20, ltf_atr_win=20),
|
||||
dict(uscitalong=24, uscitashort=18, ltf_atr_win=20),
|
||||
dict(uscitalong=28, uscitashort=18, ltf_atr_win=20),
|
||||
dict(uscitalong=24, uscitashort=20, ltf_atr_win=20),
|
||||
dict(max_per_day=2, uscitalong=20, uscitashort=16, ltf_atr_win=20),
|
||||
dict(max_per_day=2, uscitalong=20, uscitashort=18, ltf_atr_win=20),
|
||||
):
|
||||
nm = "DDr " + "/".join(f"{k}={v}" for k, v in cfg.items())
|
||||
screens.append(quick(nm, mk(**cfg)))
|
||||
|
||||
# Rank by: meets DD<30 first, then by minH (we need hold >=0.65), then minF.
|
||||
print("\n" + "="*100)
|
||||
print("PHASE 2 — full eval of best DD-reducing candidates (study+causality+marginal)")
|
||||
print("="*100)
|
||||
|
||||
# candidates: prioritize LOWEST DD with a still-usable hold-out (minH>=0.55), but ALSO
|
||||
# always include the global lowest-DD configs that keep minH>=0.5 (DD is the unmet goal).
|
||||
pool = [s for s in screens if s["minTr"] >= 20]
|
||||
a_set = [s for s in pool if s["maxdd"] < 0.36 and s["minH"] >= 0.55]
|
||||
a_set.sort(key=lambda s: (s["maxdd"], -s["minH"]))
|
||||
b_set = [s for s in pool if s["minH"] >= 0.50]
|
||||
b_set.sort(key=lambda s: s["maxdd"]) # lowest DD overall (usable hold)
|
||||
picked = []
|
||||
seen = set()
|
||||
for s in a_set[:6] + b_set[:6]:
|
||||
if s["name"] in seen:
|
||||
continue
|
||||
seen.add(s["name"])
|
||||
picked.append(s)
|
||||
if len(picked) >= 9:
|
||||
break
|
||||
if not picked:
|
||||
pool.sort(key=lambda s: s["maxdd"])
|
||||
picked = pool[:6]
|
||||
|
||||
print("Picked for full eval (DD<0.32, minH>=0.55, nTr>=20), sorted by DD:")
|
||||
for s in picked:
|
||||
print(f" {s['name']:38s} maxDD={s['maxdd']*100:.0f}% minH={s['minH']:+.2f} minF={s['minF']:+.2f}")
|
||||
|
||||
results = []
|
||||
for s in picked:
|
||||
print("\n" + "-"*90)
|
||||
results.append(full_eval(s["name"], s["p"]))
|
||||
|
||||
# also full-eval the winner as the reference
|
||||
print("\n" + "-"*90 + "\n[REFERENCE] WINNER full eval:")
|
||||
rwin = full_eval("WINNER", mk())
|
||||
|
||||
# ---- pick the best config: prefer beats_winner, else lowest DD with earns_slot & best hold
|
||||
print("\n" + "="*100)
|
||||
print("FINAL RANKING")
|
||||
print("="*100)
|
||||
def score(r):
|
||||
return (not r["beats"], not r["earns_slot"], r["maxdd"], -r["v"]["min_asset_holdout_sharpe"])
|
||||
allr = results + [rwin]
|
||||
allr.sort(key=score)
|
||||
for r in allr:
|
||||
print(f" {r['name']:38s} beats={r['beats']} earns={r['earns_slot']} maxDD={r['maxdd']*100:.0f}% "
|
||||
f"minF={r['v']['min_asset_full_sharpe']:+.2f} minH={r['v']['min_asset_holdout_sharpe']:+.2f} "
|
||||
f"w25uH={r['w25'].get('uplift_hold')} caus={r['caus_ok']}")
|
||||
|
||||
best = allr[0]
|
||||
print("\n" + "="*100)
|
||||
print("BEST CONFIG")
|
||||
print("="*100)
|
||||
bp = best["p"]
|
||||
cfg = {k: getattr(bp, k) for k in bp.__dataclass_fields__}
|
||||
print(f"name={best['name']}")
|
||||
print(f"config={json.dumps(cfg)}")
|
||||
print(f"minFull={best['v']['min_asset_full_sharpe']:+.3f}")
|
||||
print(f"minHold={best['v']['min_asset_holdout_sharpe']:+.3f}")
|
||||
print(f"max_dd={best['maxdd']:.4f}")
|
||||
print(f"n_trades_min={best['v']['min_trades']}")
|
||||
print(f"fee_survives_0.30%={best['v']['fee_survives']}")
|
||||
print(f"causality_ok={best['caus_ok']}")
|
||||
mg = best["mg"]
|
||||
print(f"MARGINAL DICT: corr_full={mg.get('corr_full')} corr_hold={mg.get('corr_hold')} "
|
||||
f"verdict={mg.get('marginal_verdict')} has_insample_edge={mg.get('has_insample_edge')} "
|
||||
f"is_hedge={mg.get('is_hedge')} robust_oos={mg.get('robust_oos')} "
|
||||
f"multicut_persistent={mg.get('multicut_persistent')} clean_year_uplift={mg.get('clean_year_uplift')}")
|
||||
print(f"blend w25 uplift_hold={best['w25'].get('uplift_hold')} | "
|
||||
f"w50 full={best['w50'].get('full')} hold={best['w50'].get('hold')} dd={best['w50'].get('dd')}")
|
||||
print(f"earns_slot={best['earns_slot']} BEATS_WINNER={best['beats']}")
|
||||
# dump machine-readable for final structured output
|
||||
print("\nJSON_BEST=" + json.dumps(dict(
|
||||
name=best["name"], config=cfg, minFull=best["v"]["min_asset_full_sharpe"],
|
||||
minHold=best["v"]["min_asset_holdout_sharpe"], max_dd=best["maxdd"],
|
||||
n_trades_min=best["v"]["min_trades"], fee_survives=best["v"]["fee_survives"],
|
||||
causality_ok=best["caus_ok"], earns_slot=best["earns_slot"], beats=best["beats"],
|
||||
corr_full=mg.get("corr_full"), marginal_verdict=mg.get("marginal_verdict"),
|
||||
has_insample_edge=mg.get("has_insample_edge"), is_hedge=mg.get("is_hedge"),
|
||||
robust_oos=mg.get("robust_oos"), multicut_persistent=mg.get("multicut_persistent"),
|
||||
clean_year_uplift=mg.get("clean_year_uplift"),
|
||||
w25_uplift_hold=best["w25"].get("uplift_hold"))))
|
||||
@@ -0,0 +1,277 @@
|
||||
"""SKH2_KELTNER_PTN — KELTNER/ATR-channel breakout pattern (replaces Donchian).
|
||||
|
||||
FAMILY: KELTNER_PTN. Goal of this wave = CUT standalone maxDD below 30% while keeping
|
||||
hold-out Sharpe high and earns_slot True.
|
||||
|
||||
Idea: the V1/V2 Skyhook pattern is a Donchian breakout (close > rolling-high of n bars).
|
||||
Donchian highs/lows are driven by single wicks -> a fast spike can set a fresh extreme that
|
||||
the next close pokes through, firing a false breakout that mean-reverts -> drawdown. An
|
||||
ATR-CHANNEL (Keltner) breakout instead requires close to clear EMA(n) +/- k*ATR(n), a
|
||||
SMOOTHED reference that ignores isolated wicks. Steadier reference -> fewer wick-driven false
|
||||
entries -> potentially lower DD for similar exposure.
|
||||
|
||||
We keep EVERYTHING ELSE identical to the verified V2 winner (regime Chande01 bands
|
||||
vola_lo=35/vola_hi=95/vol_lo=0, exits sl_atr=2.5/tp_atr=7.0/uscitalong=24/uscitashort=16) and
|
||||
ONLY swap the pattern from Donchian to Keltner. We do this by monkeypatching S.htf_features
|
||||
inside skyhooklib's namespace (same safe technique as SKH_R_EXPAND_study.py) so sk.study /
|
||||
sk.causality / sk.marginal run the EXACT honest machinery unchanged.
|
||||
|
||||
CAUSALITY: EMA and ATR are causal ewm (use x[0..i] inclusive of the current, already-closed
|
||||
HTF bar); the channel for breakout-comparison is shift(1) (strictly prior bar's channel) so
|
||||
close[i] is compared against a band known BEFORE bar i closes -> leak-free. We verify with
|
||||
sk.causality (truncated-prefix guard) on BOTH assets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import skyhooklib as sk
|
||||
from src.strategies import skyhook as S
|
||||
from src.strategies.skyhook import SkyhookParams, HTF_MIN
|
||||
|
||||
ORIG_FEAT = S.htf_features
|
||||
FEE = sk.FEE_RT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Keltner channel breakout on HTF (causal, shift-safe).
|
||||
# mid = EMA(close, n)
|
||||
# width = k * ATR(n) (ATR over the same n window, ewm)
|
||||
# upper = mid + width ; lower = mid - width
|
||||
# ptn_long = close[i] > upper[i-1] (clears the PRIOR bar's upper channel)
|
||||
# ptn_short = close[i] < lower[i-1]
|
||||
# The shift(1) on the channel makes the comparison strictly causal: the band the close must
|
||||
# clear is fully determined by bars <= i-1 (Donchian uses shift(1) on the rolling extreme for
|
||||
# the same reason). EMA/ATR ewm themselves use only past+current data.
|
||||
# ---------------------------------------------------------------------------
|
||||
def keltner_breakout(htf: pd.DataFrame, n: int, k: float, atr_win: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
c = htf["close"].values.astype(float)
|
||||
mid = pd.Series(c).ewm(span=n, adjust=False, min_periods=n).mean().values
|
||||
a = S.atr(htf, atr_win)
|
||||
upper = mid + k * a
|
||||
lower = mid - k * a
|
||||
# compare current close vs the PRIOR bar's channel (shift 1) -> strictly causal
|
||||
upper_prev = pd.Series(upper).shift(1).values
|
||||
lower_prev = pd.Series(lower).shift(1).values
|
||||
ptn_long = np.where(np.isfinite(upper_prev), c > upper_prev, False)
|
||||
ptn_short = np.where(np.isfinite(lower_prev), c < lower_prev, False)
|
||||
return ptn_long.astype(bool), ptn_short.astype(bool)
|
||||
|
||||
|
||||
def make_keltner_features(n: int, k: float, kelt_atr_win: int):
|
||||
"""Return an htf_features replacement: V1 Chande01 regime + Keltner pattern."""
|
||||
def _feat(htf: pd.DataFrame, p: SkyhookParams) -> pd.DataFrame:
|
||||
buz_vola = S.chande01(S.atr(htf, p.atr_win), p.n_vola)
|
||||
buz_volume = S.chande01(htf["volume"].values, p.n_volume)
|
||||
ptn_long, ptn_short = keltner_breakout(htf, n, k, kelt_atr_win)
|
||||
regime_ok = ((buz_vola >= p.vola_lo) & (buz_vola <= p.vola_hi)
|
||||
& (buz_volume >= p.vol_lo) & (buz_volume <= p.vol_hi))
|
||||
comp_long = regime_ok & ptn_long
|
||||
comp_short = regime_ok & ptn_short
|
||||
if p.long_only:
|
||||
comp_short = np.zeros_like(comp_short, dtype=bool)
|
||||
close_ts = htf["timestamp"].astype("int64").values + HTF_MIN * 60 * 1000
|
||||
return pd.DataFrame({
|
||||
"close_ts": close_ts,
|
||||
"buz_vola": buz_vola, "buz_volume": buz_volume,
|
||||
"comp_long": comp_long.astype(bool), "comp_short": comp_short.astype(bool),
|
||||
})
|
||||
return _feat
|
||||
|
||||
|
||||
def study_keltner(name, p, n, k, kelt_atr_win):
|
||||
"""sk.study + causality + marginal with htf_features patched to Keltner."""
|
||||
S.htf_features = make_keltner_features(n, k, kelt_atr_win)
|
||||
try:
|
||||
rep = sk.study(name, p)
|
||||
caus = sk.causality(p, "BTC")
|
||||
caus_eth = sk.causality(p, "ETH")
|
||||
marg = sk.marginal(p)
|
||||
finally:
|
||||
S.htf_features = ORIG_FEAT
|
||||
return rep, (caus, caus_eth), marg
|
||||
|
||||
|
||||
def earns_slot(rep, marg):
|
||||
grade_ok = rep["verdict"]["grade"] != "FAIL"
|
||||
return bool(grade_ok and marg.get("marginal_verdict") == "ADDS"
|
||||
and marg.get("robust_oos") is True and marg.get("is_hedge") is False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Winner exits/regime (the verified V2 winner) — only the pattern changes to Keltner.
|
||||
p = SkyhookParams(sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
# n in {13,20,34}, k in {1.0,1.5,2.0}; ATR window for the channel width = winner default 14.
|
||||
grid = []
|
||||
for n in (13, 20, 34):
|
||||
for k in (1.0, 1.5, 2.0):
|
||||
grid.append((n, k))
|
||||
|
||||
KELT_ATR = 14
|
||||
print("=== SKH2_KELTNER_PTN: ATR-channel (Keltner) breakout sweep (regime+exits = V2 winner) ===\n")
|
||||
print(f"grid n x k = {grid} kelt_atr_win={KELT_ATR}\n")
|
||||
|
||||
# ---- Sweep (cheap pass: FULL/HOLD/DD/trades + fee survival via study) ----
|
||||
rows = []
|
||||
for (n, k) in grid:
|
||||
tag = f"KELT_n{n}_k{k}"
|
||||
rep, (cb, ce), marg = study_keltner(tag, p, n, k, KELT_ATR)
|
||||
v = rep["verdict"]
|
||||
# standalone DD = max over BTCÐ FULL maxdd
|
||||
dd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"])
|
||||
es = earns_slot(rep, marg)
|
||||
w25 = marg.get("blends", {}).get("w25", {}).get("uplift_hold")
|
||||
rows.append(dict(tag=tag, n=n, k=k, rep=rep, caus=(cb, ce), marg=marg,
|
||||
minFull=v["min_asset_full_sharpe"], minHold=v["min_asset_holdout_sharpe"],
|
||||
minTr=v["min_trades"], feeOK=v["fee_survives"], grade=v["grade"],
|
||||
dd=dd, es=es, w25=w25, caus_ok=(cb["ok"] and ce["ok"])))
|
||||
beats = (es and dd < 0.30 and (w25 is not None and w25 >= 0.55) and v["min_asset_holdout_sharpe"] >= 0.65)
|
||||
print(f" [{tag}] grade={v['grade']} minFull={v['min_asset_full_sharpe']:+.2f}"
|
||||
f" minHold={v['min_asset_holdout_sharpe']:+.2f} minTr={v['min_trades']}"
|
||||
f" DD={dd*100:.0f}% feeOK={v['fee_survives']} caus={cb['ok'] and ce['ok']}"
|
||||
f" | verdict={marg.get('marginal_verdict')} corr={marg.get('corr_full')}"
|
||||
f" w25={w25} robust={marg.get('robust_oos')} hedge={marg.get('is_hedge')}"
|
||||
f" earns_slot={es} BEATS={beats}")
|
||||
|
||||
# =======================================================================
|
||||
# REFINEMENT PASS: the plain swap keeps DD>30% (too many entries / wick pokes).
|
||||
# DD is driven by (a) wide vola band letting in blow-off breakouts, (b) loose SL,
|
||||
# (c) shorts bleeding in a structural bull. Sweep regime-tightening + SL + long_only
|
||||
# around the best earns_slot region (n13/n20, k1.5-2.0) to push DD under 30%.
|
||||
# =======================================================================
|
||||
print("\n--- REFINEMENT: tighten regime / SL / long_only to cut DD<30% ---")
|
||||
refine = [
|
||||
# (n, k, sl_atr, tp_atr, vola_lo, vola_hi, vol_lo, long_only, tag)
|
||||
(13, 2.0, 2.0, 7.0, 35.0, 95.0, 0.0, False, "n13k2_sl2.0"),
|
||||
(13, 2.0, 2.5, 7.0, 45.0, 90.0, 0.0, False, "n13k2_vola45-90"),
|
||||
(13, 2.0, 2.5, 7.0, 35.0, 85.0, 0.0, False, "n13k2_volaHi85"),
|
||||
(13, 2.0, 2.5, 7.0, 35.0, 95.0, 40.0, False, "n13k2_volFloor40"),
|
||||
(13, 2.0, 2.5, 7.0, 35.0, 95.0, 0.0, True, "n13k2_longOnly"),
|
||||
(13, 2.0, 2.0, 7.0, 45.0, 90.0, 40.0, False, "n13k2_tight_all"),
|
||||
(20, 2.0, 2.0, 7.0, 35.0, 95.0, 0.0, False, "n20k2_sl2.0"),
|
||||
(20, 2.0, 2.5, 7.0, 45.0, 90.0, 40.0, False, "n20k2_tight_all"),
|
||||
(13, 2.5, 2.5, 7.0, 35.0, 95.0, 0.0, False, "n13k2.5"),
|
||||
(13, 2.5, 2.0, 7.0, 45.0, 90.0, 0.0, False, "n13k2.5_sl2_vola45-90"),
|
||||
# ---- pass 3: sl2.0 was the DD/hold winner; push SL tighter + lower TP (cut tail) ----
|
||||
(13, 2.0, 1.5, 6.0, 35.0, 95.0, 0.0, False, "n13k2_sl1.5_tp6"),
|
||||
(13, 2.0, 1.5, 7.0, 35.0, 95.0, 40.0, False, "n13k2_sl1.5_volFloor40"),
|
||||
(13, 2.0, 2.0, 5.0, 35.0, 95.0, 0.0, False, "n13k2_sl2_tp5"),
|
||||
(13, 2.0, 2.0, 6.0, 35.0, 95.0, 40.0, False, "n13k2_sl2_tp6_volFloor40"),
|
||||
(13, 2.0, 1.5, 5.0, 35.0, 95.0, 0.0, False, "n13k2_sl1.5_tp5"),
|
||||
(13, 1.5, 2.0, 7.0, 35.0, 95.0, 0.0, False, "n13k1.5_sl2.0"),
|
||||
]
|
||||
for (n, k, sl, tp, vlo, vhi, vol_lo, lo, tag) in refine:
|
||||
pr = SkyhookParams(sl_atr=sl, tp_atr=tp, uscitalong=24, uscitashort=16,
|
||||
vola_lo=vlo, vola_hi=vhi, vol_lo=vol_lo, long_only=lo)
|
||||
rep, (cb, ce), marg = study_keltner(tag, pr, n, k, KELT_ATR)
|
||||
v = rep["verdict"]
|
||||
dd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"])
|
||||
es = earns_slot(rep, marg)
|
||||
w25 = marg.get("blends", {}).get("w25", {}).get("uplift_hold")
|
||||
rows.append(dict(tag=tag, n=n, k=k, sl=sl, tp=tp, vlo=vlo, vhi=vhi, vol_lo=vol_lo, lo=lo,
|
||||
rep=rep, caus=(cb, ce), marg=marg,
|
||||
minFull=v["min_asset_full_sharpe"], minHold=v["min_asset_holdout_sharpe"],
|
||||
minTr=v["min_trades"], feeOK=v["fee_survives"], grade=v["grade"],
|
||||
dd=dd, es=es, w25=w25, caus_ok=(cb["ok"] and ce["ok"])))
|
||||
b2 = (es and dd < 0.30 and (w25 is not None and w25 >= 0.55) and v["min_asset_holdout_sharpe"] >= 0.65)
|
||||
print(f" [{tag}] grade={v['grade']} minFull={v['min_asset_full_sharpe']:+.2f}"
|
||||
f" minHold={v['min_asset_holdout_sharpe']:+.2f} minTr={v['min_trades']}"
|
||||
f" DD={dd*100:.0f}% feeOK={v['fee_survives']} caus={cb['ok'] and ce['ok']}"
|
||||
f" | verdict={marg.get('marginal_verdict')} w25={w25} robust={marg.get('robust_oos')}"
|
||||
f" hedge={marg.get('is_hedge')} earns_slot={es} BEATS={b2}")
|
||||
|
||||
# ---- Pick best: prefer DD<30% with earns_slot, then by (minHold, then w25) ----
|
||||
def beats_winner(r):
|
||||
return bool(r["es"] and r["dd"] < 0.30
|
||||
and (r["w25"] is not None and r["w25"] >= 0.55)
|
||||
and r["minHold"] >= 0.65)
|
||||
|
||||
winners = [r for r in rows if beats_winner(r)]
|
||||
if winners:
|
||||
best = max(winners, key=lambda r: (r["minHold"], r["w25"] or -9))
|
||||
pool = "BEATS-WINNER"
|
||||
else:
|
||||
# objective priority: DD<30 + earns_slot first; else best DD among earns_slot;
|
||||
# else best DD among fee-surviving non-FAIL; else lowest DD overall.
|
||||
cand1 = [r for r in rows if r["dd"] < 0.30 and r["es"]]
|
||||
# secondary-quality: earns_slot AND meets the two NON-DD beats gates (w25>=0.55, minHold>=0.65)
|
||||
candQ = [r for r in rows if r["es"] and (r["w25"] is not None and r["w25"] >= 0.55)
|
||||
and r["minHold"] >= 0.65]
|
||||
cand2 = [r for r in rows if r["es"]]
|
||||
cand3 = [r for r in rows if r["grade"] != "FAIL" and r["feeOK"]]
|
||||
if cand1:
|
||||
best = max(cand1, key=lambda r: (r["minHold"], r["w25"] or -9)); pool = "DD<30+earns_slot"
|
||||
elif candQ:
|
||||
# best DD among configs that already clear the other two beats gates
|
||||
best = min(candQ, key=lambda r: r["dd"]); pool = "earns_slot+w25>=.55+minHold>=.65 (DD>=30)"
|
||||
elif cand2:
|
||||
best = min(cand2, key=lambda r: r["dd"]); pool = "earns_slot (DD>=30)"
|
||||
elif cand3:
|
||||
best = min(cand3, key=lambda r: r["dd"]); pool = "fee-surviving non-FAIL"
|
||||
else:
|
||||
best = min(rows, key=lambda r: r["dd"]); pool = "lowest-DD overall"
|
||||
|
||||
rep, marg = best["rep"], best["marg"]
|
||||
cb, ce = best["caus"]
|
||||
v = rep["verdict"]
|
||||
bl = marg.get("blends", {})
|
||||
w25 = bl.get("w25", {})
|
||||
w50 = bl.get("w50", {})
|
||||
|
||||
print("\n" + "=" * 78)
|
||||
print(f"BEST CONFIG ({pool}): {best['tag']} (n={best['n']}, k={best['k']}, kelt_atr_win={KELT_ATR})")
|
||||
print("=" * 78)
|
||||
print(sk.fmt(rep))
|
||||
print(f"\nstandalone max_dd (max BTCÐ FULL) = {best['dd']:.4f} ({best['dd']*100:.1f}%)")
|
||||
print(f"causality BTC={cb} ETH={ce} -> ok={cb['ok'] and ce['ok']}")
|
||||
print(f"minFull={v['min_asset_full_sharpe']:+.3f} minHold={v['min_asset_holdout_sharpe']:+.3f}"
|
||||
f" minTrades={v['min_trades']} fee_survives_0.30%={v['fee_survives']}")
|
||||
print("\n--- MARGINAL vs TP01 ---")
|
||||
print(f" marginal_verdict = {marg.get('marginal_verdict')}")
|
||||
print(f" corr_full = {marg.get('corr_full')}")
|
||||
print(f" corr_hold = {marg.get('corr_hold')}")
|
||||
print(f" has_insample_edge = {marg.get('has_insample_edge')}")
|
||||
print(f" is_hedge = {marg.get('is_hedge')}")
|
||||
print(f" robust_oos = {marg.get('robust_oos')}")
|
||||
print(f" multicut_persistent= {marg.get('multicut_persistent')}")
|
||||
print(f" clean_year_uplift = {marg.get('clean_year_uplift')}")
|
||||
print(f" jackknife_min_uplift= {marg.get('jackknife_min_uplift')}")
|
||||
print(f" multicut_uplift = {marg.get('multicut_uplift')}")
|
||||
print(f" cand_insample_sharpe= {marg.get('cand_insample_sharpe')}")
|
||||
print(f" blend w25 uplift_hold = {w25.get('uplift_hold')} uplift_full={w25.get('uplift_full')}")
|
||||
print(f" blend w50 = full={w50.get('full')} hold={w50.get('hold')}"
|
||||
f" uplift_hold={w50.get('uplift_hold')} dd={w50.get('dd')}")
|
||||
|
||||
es = best["es"]
|
||||
beats = beats_winner(best)
|
||||
print(f"\n earns_slot = {es}")
|
||||
print(f" BEATS_WINNER = {beats} "
|
||||
f"(need: earns_slot AND max_dd<0.30 AND w25_uplift_hold>=0.55 AND minHold>=0.65)")
|
||||
|
||||
# ---- machine-readable final line for the orchestrator/agent to parse ----
|
||||
import json
|
||||
out = dict(
|
||||
family="KELTNER_PTN", tag=best["tag"],
|
||||
best_config=dict(ptn_kind="keltner", n=best["n"], k=best["k"], kelt_atr_win=KELT_ATR,
|
||||
sl_atr=best.get("sl", 2.5), tp_atr=best.get("tp", 7.0),
|
||||
uscitalong=24, uscitashort=16,
|
||||
vola_lo=best.get("vlo", 35.0), vola_hi=best.get("vhi", 95.0),
|
||||
vol_lo=best.get("vol_lo", 0.0), long_only=best.get("lo", False)),
|
||||
min_full_sharpe=v["min_asset_full_sharpe"], min_hold_sharpe=v["min_asset_holdout_sharpe"],
|
||||
max_dd=best["dd"], n_trades_min=v["min_trades"], fee_survives_030=bool(v["fee_survives"]),
|
||||
causality_ok=bool(cb["ok"] and ce["ok"]),
|
||||
marginal_verdict=marg.get("marginal_verdict"),
|
||||
has_insample_edge=bool(marg.get("has_insample_edge")),
|
||||
is_hedge=bool(marg.get("is_hedge")), robust_oos=bool(marg.get("robust_oos")),
|
||||
multicut_persistent=bool(marg.get("multicut_persistent")),
|
||||
clean_year_uplift=marg.get("clean_year_uplift"), corr_full=marg.get("corr_full"),
|
||||
blend_w25_uplift_hold=w25.get("uplift_hold"),
|
||||
earns_slot=bool(es), beats_winner=bool(beats),
|
||||
)
|
||||
print("\nFINAL_JSON=" + json.dumps(out, default=str))
|
||||
@@ -0,0 +1,298 @@
|
||||
"""SKH2_PATTERN_CONF — breakout CONFIRMATION filter family (DD-reduction wave).
|
||||
|
||||
GOAL of the wave: cut standalone maxDD < 30% (max over BTCÐ) while keeping
|
||||
min-asset HOLD-OUT Sharpe >= ~0.70 and earns_slot == True.
|
||||
|
||||
FAMILY = breakout confirmation. The main DD source is FALSE breakouts (whipsaws).
|
||||
We require CONFIRMATION before allowing the composer to fire, via a STRUCTURAL
|
||||
htf_features patch (causal, shift-safe). Confirmation modes (all use data <= close[i]):
|
||||
|
||||
persist2 : the breakout must PERSIST -> the *previous* HTF close also broke the
|
||||
donchian level that was active one bar earlier (2 consecutive breakouts).
|
||||
close_loc : the breakout close must sit in the upper/lower `loc_thr` of the HTF
|
||||
bar range (close near the high for a long, near the low for a short)
|
||||
-> rejects exhaustion wicks that close back inside the bar.
|
||||
roc_agree : HTF ROC (close/close[-roc_n]-1) sign must agree with the breakout dir.
|
||||
combos : AND-combinations of the above.
|
||||
|
||||
We monkeypatch S.htf_features INSIDE skyhooklib's namespace for the duration of each
|
||||
study (same safe pattern as SKH_R_EXPAND_study.py): only the feature/composer builder
|
||||
changes; pattern donchian, regime bands, entry/exit and ALL eval code are unchanged.
|
||||
|
||||
Baseline regime/exit params = the verified V2 WINNER:
|
||||
SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
CAUSALITY: every confirmation feature is built from HTF columns and SHIFTED so that
|
||||
only data up to (and including) the breakout-bar close is used; donchian itself is
|
||||
shift(1) (strictly prior bars). We verify with sk.causality (truncated-prefix) which
|
||||
re-runs skyhook_entries on a prefix of BOTH frames -> our patched htf_features is
|
||||
exercised on the prefix and must match the full run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import skyhooklib as sk
|
||||
from src.strategies import skyhook as S
|
||||
from src.strategies.skyhook import SkyhookParams, HTF_MIN
|
||||
from src.strategies.skyhook import atr as _atr, chande01 as _chande01
|
||||
|
||||
ORIG_FEAT = S.htf_features
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Confirmed htf_features builder (STRUCTURAL). Same shape/columns as the engine's
|
||||
# htf_features, but the composer's pattern leg is gated by a CONFIRMATION mask.
|
||||
# ---------------------------------------------------------------------------
|
||||
def conf_htf_features(htf: pd.DataFrame, p: SkyhookParams, *,
|
||||
modes=("persist2",), loc_thr: float = 0.34, roc_n: int = 1):
|
||||
"""Causal regime+CONFIRMED-pattern features indexed by HTF close.
|
||||
|
||||
modes: subset of {"persist2","close_loc","roc_agree"} ANDed together as the
|
||||
confirmation requirement on top of the raw donchian breakout.
|
||||
"""
|
||||
h = htf["high"].values.astype(float)
|
||||
l = htf["low"].values.astype(float)
|
||||
c = htf["close"].values.astype(float)
|
||||
n = len(c)
|
||||
|
||||
# --- regime (unchanged) ---
|
||||
buz_vola = _chande01(_atr(htf, p.atr_win), p.n_vola)
|
||||
buz_volume = _chande01(htf["volume"].values, p.n_volume)
|
||||
regime_ok = ((buz_vola >= p.vola_lo) & (buz_vola <= p.vola_hi)
|
||||
& (buz_volume >= p.vol_lo) & (buz_volume <= p.vol_hi))
|
||||
|
||||
# --- raw donchian breakout (leak-free: close[i] vs max/min of n PRIOR bars) ---
|
||||
hi_n = pd.Series(h).rolling(p.ptn_n, min_periods=p.ptn_n).max().shift(1).values
|
||||
lo_n = pd.Series(l).rolling(p.ptn_n, min_periods=p.ptn_n).min().shift(1).values
|
||||
ptn_long = c > hi_n
|
||||
ptn_short = c < lo_n
|
||||
|
||||
# --- CONFIRMATION masks (all causal: built from data <= close[i]) ---
|
||||
conf_long = np.ones(n, dtype=bool)
|
||||
conf_short = np.ones(n, dtype=bool)
|
||||
|
||||
if "persist2" in modes:
|
||||
# previous bar's close also broke the donchian level active ONE bar earlier.
|
||||
# ptn_long shifted by 1 == "did the prior bar break out?" (its own causal level).
|
||||
prev_long = np.concatenate(([False], ptn_long[:-1]))
|
||||
prev_short = np.concatenate(([False], ptn_short[:-1]))
|
||||
conf_long &= prev_long
|
||||
conf_short &= prev_short
|
||||
|
||||
if "close_loc" in modes:
|
||||
rng = h - l
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
pos = np.where(rng > 0, (c - l) / rng, 0.5) # 0=at low, 1=at high; current bar only
|
||||
conf_long &= (pos >= (1.0 - loc_thr))
|
||||
conf_short &= (pos <= loc_thr)
|
||||
|
||||
if "roc_agree" in modes:
|
||||
cprev = pd.Series(c).shift(roc_n).values # close roc_n bars ago (causal)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
roc = np.where(np.isfinite(cprev) & (cprev != 0), c / cprev - 1.0, 0.0)
|
||||
conf_long &= (roc > 0.0)
|
||||
conf_short &= (roc < 0.0)
|
||||
|
||||
comp_long = regime_ok & ptn_long & conf_long
|
||||
comp_short = regime_ok & ptn_short & conf_short
|
||||
if p.long_only:
|
||||
comp_short = np.zeros_like(comp_short, dtype=bool)
|
||||
|
||||
close_ts = htf["timestamp"].astype("int64").values + HTF_MIN * 60 * 1000
|
||||
return pd.DataFrame({
|
||||
"close_ts": close_ts,
|
||||
"buz_vola": buz_vola, "buz_volume": buz_volume,
|
||||
"comp_long": comp_long.astype(bool), "comp_short": comp_short.astype(bool),
|
||||
})
|
||||
|
||||
|
||||
def make_patched(**cfg):
|
||||
def _feat(htf, p):
|
||||
return conf_htf_features(htf, p, **cfg)
|
||||
return _feat
|
||||
|
||||
|
||||
def study_conf(name, p, cfg, do_marginal=True):
|
||||
"""Run sk.study/causality/marginal with htf_features patched to the confirmed builder."""
|
||||
S.htf_features = make_patched(**cfg)
|
||||
try:
|
||||
rep = sk.study(name, p)
|
||||
caus_btc = sk.causality(p, "BTC")
|
||||
caus_eth = sk.causality(p, "ETH")
|
||||
marg = sk.marginal(p) if do_marginal else None
|
||||
finally:
|
||||
S.htf_features = ORIG_FEAT
|
||||
return rep, (caus_btc, caus_eth), marg
|
||||
|
||||
|
||||
def _earns_slot(rep, marg):
|
||||
grade_ok = rep["verdict"]["grade"] != "FAIL"
|
||||
adds = marg.get("marginal_verdict") == "ADDS"
|
||||
robust = bool(marg.get("robust_oos"))
|
||||
hedge = bool(marg.get("is_hedge"))
|
||||
return bool(grade_ok and adds and robust and (not hedge))
|
||||
|
||||
|
||||
def _beats_winner(rep, marg, max_dd):
|
||||
es = _earns_slot(rep, marg)
|
||||
w25 = marg.get("blends", {}).get("w25", {}).get("uplift_hold")
|
||||
mh = rep["verdict"]["min_asset_holdout_sharpe"]
|
||||
return bool(es and max_dd < 0.30 and (w25 is not None and w25 >= 0.55) and mh >= 0.65)
|
||||
|
||||
|
||||
# Baseline regime/exit = verified V2 winner
|
||||
WIN = dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
|
||||
def win_params():
|
||||
return SkyhookParams(**WIN)
|
||||
|
||||
|
||||
def win_params_ex(**over):
|
||||
d = dict(WIN); d.update(over); return SkyhookParams(**d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
p = win_params()
|
||||
|
||||
# ---- PHASE 1: scan confirmation modes (quick, no marginal yet) ----
|
||||
# winner's exit = sl_atr 2.5 / tp_atr 7.0. close_loc+roc was the clear leader (DD 31.4%,
|
||||
# minHold +1.13). Now drive DD<30% via STRONGER close-location confirmation (tighter
|
||||
# loc_thr) and TIGHTER stops (sl_atr down), and an upper-vola cap to skip blow-offs.
|
||||
CL = ("close_loc",) # roc_agree is ~collinear with close_loc (no-op); drop it -> cleaner
|
||||
C = dict(modes=CL, loc_thr=0.40)
|
||||
scan = {
|
||||
"RAW (no conf, =winner)": (p, dict(modes=())),
|
||||
"cl 0.40 vH90": (win_params_ex(vola_hi=90.0), C),
|
||||
"cl 0.40 vH88": (win_params_ex(vola_hi=88.0), C),
|
||||
"cl 0.40 vH85": (win_params_ex(vola_hi=85.0), C),
|
||||
# volume floor: skip thin (low-volume-cycle) breakouts -> fewer false ETH whipsaws
|
||||
"cl 0.40 vH90 volLo20": (win_params_ex(vola_hi=90.0, vol_lo=20.0), C),
|
||||
"cl 0.40 vH90 volLo35": (win_params_ex(vola_hi=90.0, vol_lo=35.0), C),
|
||||
# raise vola_lo floor (skip dead-vol regimes) + cap top
|
||||
"cl 0.40 vL45 vH90": (win_params_ex(vola_lo=45.0, vola_hi=90.0), C),
|
||||
# tighten long hold to cut give-back on the trend reversals
|
||||
"cl 0.40 vH90 uL20": (win_params_ex(vola_hi=90.0, uscitalong=20), C),
|
||||
"cl 0.40 vH90 uL20 uS14": (win_params_ex(vola_hi=90.0, uscitalong=20, uscitashort=14), C),
|
||||
# tp tighter to bank wins sooner (less round-trip give-back) — keeps DD lower
|
||||
"cl 0.40 vH90 tp6": (win_params_ex(vola_hi=90.0, tp_atr=6.0), C),
|
||||
"cl 0.40 vH90 tp6 uL20": (win_params_ex(vola_hi=90.0, tp_atr=6.0, uscitalong=20), C),
|
||||
"cl 0.40 vH90 volLo20 uL20": (win_params_ex(vola_hi=90.0, vol_lo=20.0, uscitalong=20), C),
|
||||
}
|
||||
print("=" * 78)
|
||||
print("PHASE 1 SCAN — confirmation modes on the V2 winner (DD-focus)")
|
||||
print("=" * 78)
|
||||
rows = []
|
||||
for name, (pp, cfg) in scan.items():
|
||||
rep, caus, _ = study_conf(name, pp, cfg, do_marginal=False)
|
||||
v = rep["verdict"]
|
||||
mdd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"])
|
||||
cb = caus[0]["ok"] and caus[1]["ok"]
|
||||
nmin = min(rep["per_asset"][a]["full"]["n_trades"] for a in rep["per_asset"])
|
||||
rows.append((name, (pp, cfg), v["grade"], v["min_asset_full_sharpe"],
|
||||
v["min_asset_holdout_sharpe"], mdd, nmin, v["fee_survives"], cb))
|
||||
print(f" {name:30s} grade={v['grade']:4s} minFull={v['min_asset_full_sharpe']:+.2f} "
|
||||
f"minHold={v['min_asset_holdout_sharpe']:+.2f} maxDD={mdd*100:4.0f}% "
|
||||
f"nMin={nmin:4d} feeOK={v['fee_survives']} caus={cb}")
|
||||
|
||||
# pick candidates: grade != FAIL, maxDD lowest, holdout decent. Take all with DD<32% & minHold>0.4.
|
||||
cands = [r for r in rows if r[2] != "FAIL" and r[7] and r[8]
|
||||
and r[5] < 0.33 and r[4] >= 0.40 and r[0] != "RAW (no conf, =winner)"]
|
||||
# sort: sub-30% DD first (the wave goal), then highest hold
|
||||
cands.sort(key=lambda r: (r[5] >= 0.30, r[5], -r[4]))
|
||||
print("\nPHASE 1 candidates (DD<35%, minHold>=0.40, feeOK, causal), best DD first:")
|
||||
for r in cands:
|
||||
print(f" {r[0]:24s} DD={r[5]*100:.0f}% minHold={r[4]:+.2f} minFull={r[3]:+.2f}")
|
||||
|
||||
# ---- PHASE 2: full marginal on the top few ----
|
||||
top = cands[:5] if cands else []
|
||||
if not top:
|
||||
# fall back to the lowest-DD non-FAIL configs regardless of hold threshold
|
||||
fb = [r for r in rows if r[2] != "FAIL" and r[7] and r[8]]
|
||||
fb.sort(key=lambda r: r[5])
|
||||
top = fb[:3]
|
||||
|
||||
print("\n" + "=" * 78)
|
||||
print("PHASE 2 — full marginal vs TP01 on top confirmation candidates")
|
||||
print("=" * 78)
|
||||
|
||||
best = None # (beats, earns, max_dd, minHold, name, cfg, rep, marg)
|
||||
for r in top:
|
||||
name, (pp, cfg) = r[0], r[1]
|
||||
rep, caus, marg = study_conf(name, pp, cfg, do_marginal=True)
|
||||
v = rep["verdict"]
|
||||
mdd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"])
|
||||
cb = caus[0]["ok"] and caus[1]["ok"]
|
||||
es = _earns_slot(rep, marg)
|
||||
bw = _beats_winner(rep, marg, mdd)
|
||||
w25 = marg.get("blends", {}).get("w25", {})
|
||||
w50 = marg.get("blends", {}).get("w50", {})
|
||||
print(f"\n----- {name} cfg={cfg} -----")
|
||||
print(sk.fmt(rep))
|
||||
print(f"causality: BTC={caus[0]} ETH={caus[1]} -> ok={cb}")
|
||||
print(f"marginal: verdict={marg.get('marginal_verdict')} corr_full={marg.get('corr_full')} "
|
||||
f"corr_hold={marg.get('corr_hold')}")
|
||||
print(f" has_insample_edge={marg.get('has_insample_edge')} "
|
||||
f"cand_insample_sharpe={marg.get('cand_insample_sharpe')} "
|
||||
f"is_hedge={marg.get('is_hedge')} robust_oos={marg.get('robust_oos')} "
|
||||
f"multicut_persistent={marg.get('multicut_persistent')}")
|
||||
print(f" clean_year_uplift={marg.get('clean_year_uplift')} "
|
||||
f"jackknife_min_uplift={marg.get('jackknife_min_uplift')} "
|
||||
f"multicut_uplift={marg.get('multicut_uplift')}")
|
||||
print(f" blend w25: uplift_hold={w25.get('uplift_hold')} uplift_full={w25.get('uplift_full')} "
|
||||
f"hold={w25.get('hold')} full={w25.get('full')}")
|
||||
print(f" blend w50: full={w50.get('full')} hold={w50.get('hold')} "
|
||||
f"uplift_hold={w50.get('uplift_hold')} dd={w50.get('dd')}")
|
||||
print(f" EARNS_SLOT={es} BEATS_WINNER={bw}")
|
||||
key = (bw, es, -mdd, v["min_asset_holdout_sharpe"])
|
||||
cur = dict(beats=bw, earns=es, max_dd=mdd, minHold=v["min_asset_holdout_sharpe"],
|
||||
minFull=v["min_asset_full_sharpe"], name=name, cfg=cfg, rep=rep, marg=marg,
|
||||
base=sk._params_dict(pp),
|
||||
caus=cb, nmin=min(rep["per_asset"][a]["full"]["n_trades"] for a in rep["per_asset"]),
|
||||
feeOK=v["fee_survives"], grade=v["grade"])
|
||||
if best is None or key > best[0]:
|
||||
best = (key, cur)
|
||||
|
||||
# ---- FINAL BLOCK ----
|
||||
print("\n" + "#" * 78)
|
||||
print("FINAL — BEST PATTERN_CONF CONFIG")
|
||||
print("#" * 78)
|
||||
if best is None:
|
||||
print("NO non-FAIL candidate found.")
|
||||
else:
|
||||
b = best[1]
|
||||
m = b["marg"]
|
||||
w25 = m.get("blends", {}).get("w25", {})
|
||||
w50 = m.get("blends", {}).get("w50", {})
|
||||
print(f"name : {b['name']}")
|
||||
print(f"cfg : {b['cfg']}")
|
||||
print(f"base params : {b['base']}")
|
||||
print(f"grade : {b['grade']}")
|
||||
print(f"minFull : {b['minFull']:+.3f}")
|
||||
print(f"minHold : {b['minHold']:+.3f}")
|
||||
print(f"max_dd : {b['max_dd']:.4f} ({b['max_dd']*100:.1f}%)")
|
||||
print(f"n_trades_min: {b['nmin']}")
|
||||
print(f"fee@0.30% : survives={b['feeOK']}")
|
||||
print(f"causality_ok: {b['caus']}")
|
||||
print(f"--- marginal dict ---")
|
||||
print(f" corr_full : {m.get('corr_full')}")
|
||||
print(f" corr_hold : {m.get('corr_hold')}")
|
||||
print(f" marginal_verdict : {m.get('marginal_verdict')}")
|
||||
print(f" has_insample_edge : {m.get('has_insample_edge')}")
|
||||
print(f" cand_insample_sh : {m.get('cand_insample_sharpe')}")
|
||||
print(f" is_hedge : {m.get('is_hedge')}")
|
||||
print(f" robust_oos : {m.get('robust_oos')}")
|
||||
print(f" multicut_persistent: {m.get('multicut_persistent')}")
|
||||
print(f" clean_year_uplift : {m.get('clean_year_uplift')}")
|
||||
print(f" blend w25 uplift_hold: {w25.get('uplift_hold')}")
|
||||
print(f" blend w25 uplift_full: {w25.get('uplift_full')}")
|
||||
print(f" blend w50 : full={w50.get('full')} hold={w50.get('hold')} dd={w50.get('dd')}")
|
||||
print(f"EARNS_SLOT : {b['earns']}")
|
||||
print(f"BEATS_WINNER: {b['beats']}")
|
||||
@@ -0,0 +1,289 @@
|
||||
"""SKH2_PCTL_DD — DD-reduction wave, family [PCTL_DD].
|
||||
|
||||
GOAL: cut STANDALONE maxDD below 30% (max over BTC & ETH) while keeping minHold>=~0.70
|
||||
and earns_slot==True, using the CAUSAL expanding/rolling PERCENTILE-RANK regime from
|
||||
SKH_R_PCTL.py (reuse pctl_entries), tuned together with the winner's exits.
|
||||
|
||||
Baseline to beat (V2 winner, Chande regime):
|
||||
SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35, vola_hi=95, vol_lo=0.0)
|
||||
minFull +0.83, minHold +0.81, standalone DD BTC34%/ETH31% (THE PROBLEM),
|
||||
marginal ADDS, blend w25 uplift_hold +0.58, blend 50/50 full1.59/hold1.04/DD12.5%.
|
||||
|
||||
LEVERS FOR DD CUT (all causal, expressed through pctl_entries cfg + the SkyhookParams exits):
|
||||
* percentile-rank regime bands (where ATR/volume sit in their own causal history):
|
||||
- cap the upper vola band (avoid blow-off-vol entries that cluster losses)
|
||||
- add a volume floor (live tape only) OR keep vol open
|
||||
* tighter hard stop (sl_atr) caps per-trade loss -> shrinks DD
|
||||
* the winner's wider tp_atr=7.0 + asym time exits (24/16) carried over.
|
||||
|
||||
A candidate BEATS THE WINNER iff: earns_slot AND max_dd<0.30 AND blend_w25_uplift_hold>=0.55
|
||||
AND min_hold_sharpe>=0.65. We report TRUE numbers regardless.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
|
||||
import importlib.util
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import skyhooklib as sk
|
||||
import altlib as al
|
||||
from src.backtest.harness import backtest_signals
|
||||
from src.strategies import skyhook as S
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
# import the structural pctl builder (pctl_entries, pctl_rank, _split) from the sweep script
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"skr", "/opt/docker/PythagorasGoal/scripts/research/skyhook/runs/SKH_R_PCTL.py")
|
||||
skr = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(skr) # __main__ guard prevents the sweep from running
|
||||
|
||||
HOLDOUT = sk.HOLDOUT
|
||||
FEE = sk.FEE_RT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build a SkyhookParams holding the WINNER's exits; only regime comes from pctl cfg.
|
||||
# pctl_entries reads: ptn_n, sl_atr, tp_atr, uscitalong, uscitashort, exit_mode, ltf_atr_win,
|
||||
# max_per_day, long_only (the regime bands come from the cfg kwargs).
|
||||
# ---------------------------------------------------------------------------
|
||||
def winner_exit_params(**kw):
|
||||
base = dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16)
|
||||
base.update(kw)
|
||||
return SkyhookParams(**base)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Eval a (cfg, params) pair on both assets: FULL + HOLD via the honest engine.
|
||||
# ---------------------------------------------------------------------------
|
||||
def eval_pair(cfg, p):
|
||||
out = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = skr.pctl_entries(ltf, htf, p, **cfg)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
eq = m.equity
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))
|
||||
hmask = np.asarray(idx >= HOLDOUT)
|
||||
full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4),
|
||||
n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1))
|
||||
hold = skr._split(eq, idx, hmask)
|
||||
out[a] = dict(full=full, hold=hold,
|
||||
yearly={int(y): round(v, 4) for y, v in m.yearly.items()})
|
||||
return out
|
||||
|
||||
|
||||
def summarize(res):
|
||||
mf = min(res[a]["full"]["sharpe"] for a in res)
|
||||
mh = min(res[a]["hold"]["sharpe"] for a in res)
|
||||
mt = min(res[a]["full"]["n_trades"] for a in res)
|
||||
mdd = max(res[a]["full"]["maxdd"] for a in res)
|
||||
return dict(minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, res=res)
|
||||
|
||||
|
||||
def line(tag, v):
|
||||
r = v["res"]
|
||||
print(f" [{tag:30s}] minFull={v['minFull']:+.2f} minHold={v['minHold']:+.2f} "
|
||||
f"minTr={v['minTr']:3d} maxDD={v['maxDD']*100:4.0f}% | "
|
||||
f"BTC F{r['BTC']['full']['sharpe']:+.2f}/H{r['BTC']['hold']['sharpe']:+.2f}/DD{r['BTC']['full']['maxdd']*100:.0f}% "
|
||||
f"ETH F{r['ETH']['full']['sharpe']:+.2f}/H{r['ETH']['hold']['sharpe']:+.2f}/DD{r['ETH']['full']['maxdd']*100:.0f}%")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Causality (truncated-prefix) on the structural pctl entries.
|
||||
# ---------------------------------------------------------------------------
|
||||
def check_causality(cfg, p, asset="BTC", tail=200):
|
||||
return skr.check_causality(cfg, p, asset, tail=tail)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Marginal vs TP01 on a (cfg, params) pair (50/50 daily, same convention as skyhooklib).
|
||||
# ---------------------------------------------------------------------------
|
||||
def marginal_struct(cfg, p):
|
||||
def daily(a):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = skr.pctl_entries(ltf, htf, p, **cfg)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
|
||||
return s.resample("1D").last().ffill().pct_change().dropna()
|
||||
sb, se = daily("BTC"), daily("ETH")
|
||||
J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0)
|
||||
cand = 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
return al.marginal_vs_tp01(cand)
|
||||
|
||||
|
||||
def fee_sweep(cfg, p):
|
||||
ok = True
|
||||
rows = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = skr.pctl_entries(ltf, htf, p, **cfg)
|
||||
row = []
|
||||
for f in (0.0, 0.001, 0.002, 0.003):
|
||||
m = backtest_signals(ltf, ent, fee_rt=f, leverage=1.0, asset=a, tf="230m")
|
||||
row.append((f, round(m.sharpe, 3)))
|
||||
rows[a] = row
|
||||
ok = ok and (dict(row)[0.003] > 0)
|
||||
return ok, rows
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== SKH2_PCTL_DD : percentile-rank regime tuned for DD<30 ===\n")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# STAGE 1 — coarse sweep: regime bands (pctl space) x stop tightness.
|
||||
# Winner exits (tp7/24/16) carried; we vary sl_atr and the regime cfg.
|
||||
# Intuition for DD cut:
|
||||
# - cap vola_hi (drop blow-off-vol entries) ; modest vol floor (live tape)
|
||||
# - tighter sl_atr (2.0/1.8) caps per-trade loss.
|
||||
# -----------------------------------------------------------------------
|
||||
print("--- STAGE 1: regime band x stop sweep (exits tp7/24/16) ---")
|
||||
band_cfgs = {
|
||||
# name: pctl regime cfg (expanding unless _r)
|
||||
"volaHi95_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.95, vol_lo=0.0, vol_hi=1.0),
|
||||
"volaHi90_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.90, vol_lo=0.0, vol_hi=1.0),
|
||||
"volaMid_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.25, vola_hi=0.85, vol_lo=0.0, vol_hi=1.0),
|
||||
"volaMid_volFlr": dict(vola_win=None, vol_win=None, vola_lo=0.25, vola_hi=0.85, vol_lo=0.30, vol_hi=1.0),
|
||||
"volaHi90_volFlr": dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.90, vol_lo=0.30, vol_hi=1.0),
|
||||
"volaCap80_volFlr":dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.80, vol_lo=0.30, vol_hi=1.0),
|
||||
}
|
||||
sls = [2.5, 2.0, 1.8]
|
||||
stage1 = {}
|
||||
for bname, cfg in band_cfgs.items():
|
||||
for sl in sls:
|
||||
p = winner_exit_params(sl_atr=sl)
|
||||
tag = f"{bname}|sl{sl}"
|
||||
v = summarize(eval_pair(cfg, p))
|
||||
stage1[tag] = (cfg, p, v)
|
||||
line(tag, v)
|
||||
|
||||
# Pick DD<30 candidates with the best minHold (need minHold>=~0.7).
|
||||
sub30 = {t: tup for t, tup in stage1.items() if tup[2]["maxDD"] < 0.30}
|
||||
print(f"\n--- STAGE 1: configs with maxDD<30%: {len(sub30)} ---")
|
||||
for t, (_, _, v) in sorted(sub30.items(), key=lambda kv: -kv[1][2]["minHold"]):
|
||||
line(t, v)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# STAGE 2 — refine: take best DD<30 (and near-30 with high hold) candidates,
|
||||
# fine-tune bands/stop to push minHold up while keeping DD<30.
|
||||
# -----------------------------------------------------------------------
|
||||
print("\n--- STAGE 2: refinement around best DD<30 / high-hold cells ---")
|
||||
refine = {
|
||||
# tighter blow-off cap + small vol floor, sl 1.8-2.0
|
||||
"R1": (dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.85, vol_lo=0.20, vol_hi=1.0), winner_exit_params(sl_atr=2.0)),
|
||||
"R2": (dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.85, vol_lo=0.20, vol_hi=1.0), winner_exit_params(sl_atr=1.8)),
|
||||
"R3": (dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.88, vol_lo=0.30, vol_hi=1.0), winner_exit_params(sl_atr=2.0)),
|
||||
"R4": (dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.85, vol_lo=0.30, vol_hi=1.0), winner_exit_params(sl_atr=2.0)),
|
||||
# rolling-window regime (recent), which reacts faster to regime shift -> may cut DD
|
||||
"R5": (dict(vola_win=120, vol_win=120, vola_lo=0.30, vola_hi=0.85, vol_lo=0.20, vol_hi=1.0), winner_exit_params(sl_atr=2.0)),
|
||||
"R6": (dict(vola_win=120, vol_win=120, vola_lo=0.30, vola_hi=0.85, vol_lo=0.30, vol_hi=1.0), winner_exit_params(sl_atr=1.8)),
|
||||
# tighter tp to bank faster (lower DD) with tight sl
|
||||
"R7": (dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.85, vol_lo=0.20, vol_hi=1.0), winner_exit_params(sl_atr=2.0, tp_atr=6.0)),
|
||||
"R8": (dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.85, vol_lo=0.20, vol_hi=1.0), winner_exit_params(sl_atr=1.6)),
|
||||
}
|
||||
stage2 = {}
|
||||
for t, (cfg, p) in refine.items():
|
||||
v = summarize(eval_pair(cfg, p))
|
||||
stage2[t] = (cfg, p, v)
|
||||
line(t, v)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PICK BEST: among ALL cells, prefer maxDD<0.30 AND minHold>=0.65; rank by
|
||||
# (DD<30) then minHold then -DD. Fall back to best minHold if none sub-30.
|
||||
# -----------------------------------------------------------------------
|
||||
allcells = {**stage1, **stage2}
|
||||
def score(tup):
|
||||
v = tup[2]
|
||||
dd_ok = v["maxDD"] < 0.30
|
||||
hold_ok = v["minHold"] >= 0.65
|
||||
full_ok = v["minFull"] >= 0.5
|
||||
tr_ok = v["minTr"] >= 20
|
||||
# primary: meets all gates; secondary: minHold; tertiary: lower DD
|
||||
return (dd_ok and hold_ok and full_ok and tr_ok, dd_ok, v["minHold"], -v["maxDD"])
|
||||
best_tag = max(allcells, key=lambda t: score(allcells[t]))
|
||||
best_cfg, best_p, best_v = allcells[best_tag]
|
||||
print(f"\n*** SELECTED = {best_tag} ***")
|
||||
line(best_tag, best_v)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# FULL VERIFICATION on selected: causality + fee sweep + marginal.
|
||||
# -----------------------------------------------------------------------
|
||||
print("\n--- causality (truncated-prefix) ---")
|
||||
cB = check_causality(best_cfg, best_p, "BTC")
|
||||
cE = check_causality(best_cfg, best_p, "ETH")
|
||||
causality_ok = bool(cB["ok"] and cE["ok"])
|
||||
print(f" BTC={cB} ETH={cE} -> causality_ok={causality_ok}")
|
||||
|
||||
print("\n--- fee sweep (FULL sharpe) ---")
|
||||
fee_ok, frows = fee_sweep(best_cfg, best_p)
|
||||
for a, row in frows.items():
|
||||
print(f" {a}: " + " ".join(f"{f*100:.2f}%={s:+.2f}" for f, s in row))
|
||||
print(f" fee_survives 0.30%RT (both): {fee_ok}")
|
||||
|
||||
print("\n--- marginal vs TP01 (selected) ---")
|
||||
marg = marginal_struct(best_cfg, best_p)
|
||||
corr_full = marg.get("corr_full")
|
||||
verdict = marg.get("marginal_verdict")
|
||||
has_edge = marg.get("has_insample_edge")
|
||||
is_hedge = marg.get("is_hedge")
|
||||
robust_oos = marg.get("robust_oos")
|
||||
multicut = marg.get("multicut_persistent")
|
||||
clean_yr = marg.get("clean_year_uplift")
|
||||
w25 = marg.get("blends", {}).get("w25", {})
|
||||
w50 = marg.get("blends", {}).get("w50", {})
|
||||
up_h = w25.get("uplift_hold")
|
||||
print(f" corr_full={corr_full} corr_hold={marg.get('corr_hold')}")
|
||||
print(f" marginal_verdict={verdict} robust_oos={robust_oos} multicut_persistent={multicut}")
|
||||
print(f" has_insample_edge={has_edge} is_hedge={is_hedge} cand_insample_sharpe={marg.get('cand_insample_sharpe')}")
|
||||
print(f" clean_year_uplift={clean_yr} jackknife_min_uplift={marg.get('jackknife_min_uplift')}")
|
||||
print(f" blend w25: uplift_hold={up_h} uplift_full={w25.get('uplift_full')}")
|
||||
print(f" blend w50: full={w50.get('full')} hold={w50.get('hold')} uplift_hold={w50.get('uplift_hold')} dd={w50.get('dd')}")
|
||||
|
||||
# grade (mirror sk verdict thresholds): PASS if minTr>=20 & minFull>=0.5 & minHold>=0.2 & feeOK
|
||||
mf, mh, mt, mdd = best_v["minFull"], best_v["minHold"], best_v["minTr"], best_v["maxDD"]
|
||||
if mt >= 20 and mf >= 0.5 and mh >= 0.2 and fee_ok:
|
||||
grade = "PASS"
|
||||
elif mt >= 20 and mf >= 0.3 and mh >= 0.0:
|
||||
grade = "WEAK"
|
||||
else:
|
||||
grade = "FAIL"
|
||||
earns_slot = (grade != "FAIL") and (verdict == "ADDS") and bool(robust_oos) and (not bool(is_hedge))
|
||||
beats_winner = bool(earns_slot and mdd < 0.30 and (up_h is not None and up_h >= 0.55) and mh >= 0.65)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("FINAL BLOCK — SKH2_PCTL_DD")
|
||||
print("=" * 70)
|
||||
print(f"best_cfg(regime) = {best_cfg}")
|
||||
print(f"best_params = ptn_n={best_p.ptn_n} sl_atr={best_p.sl_atr} tp_atr={best_p.tp_atr} "
|
||||
f"uscitalong={best_p.uscitalong} uscitashort={best_p.uscitashort} exit_mode={best_p.exit_mode}")
|
||||
print(f"grade={grade}")
|
||||
print(f"minFull={mf:+.3f} minHold={mh:+.3f} max_dd={mdd:.4f} ({mdd*100:.0f}%) n_trades_min={mt}")
|
||||
print(f"fee@0.30%RT survives={fee_ok} causality_ok={causality_ok}")
|
||||
print(f"marginal: verdict={verdict} corr_full={corr_full} has_insample_edge={has_edge} "
|
||||
f"is_hedge={is_hedge} robust_oos={robust_oos} multicut_persistent={multicut} clean_year_uplift={clean_yr}")
|
||||
print(f"blend w25 uplift_hold={up_h} blend w50 full={w50.get('full')}/hold={w50.get('hold')}/dd={w50.get('dd')}")
|
||||
print(f"earns_slot={earns_slot}")
|
||||
print(f"beats_winner={beats_winner}")
|
||||
print("=" * 70)
|
||||
|
||||
# machine-readable echo for the agent
|
||||
import json
|
||||
print("RESULT_JSON=" + json.dumps({
|
||||
"best_cfg": best_cfg,
|
||||
"best_params": {"ptn_n": best_p.ptn_n, "sl_atr": best_p.sl_atr, "tp_atr": best_p.tp_atr,
|
||||
"uscitalong": best_p.uscitalong, "uscitashort": best_p.uscitashort,
|
||||
"exit_mode": best_p.exit_mode,
|
||||
"vola_lo": best_cfg["vola_lo"], "vola_hi": best_cfg["vola_hi"],
|
||||
"vol_lo": best_cfg["vol_lo"], "vol_hi": best_cfg["vol_hi"],
|
||||
"vola_win": best_cfg["vola_win"], "vol_win": best_cfg["vol_win"]},
|
||||
"grade": grade, "minFull": mf, "minHold": mh, "max_dd": mdd, "n_trades_min": mt,
|
||||
"fee_ok": fee_ok, "causality_ok": causality_ok,
|
||||
"marginal_verdict": verdict, "corr_full": corr_full, "has_insample_edge": has_edge,
|
||||
"is_hedge": is_hedge, "robust_oos": robust_oos, "multicut_persistent": multicut,
|
||||
"clean_year_uplift": clean_yr, "blend_w25_uplift_hold": up_h,
|
||||
"w50_full": w50.get("full"), "w50_hold": w50.get("hold"), "w50_dd": w50.get("dd"),
|
||||
"earns_slot": earns_slot, "beats_winner": beats_winner,
|
||||
}, default=str))
|
||||
@@ -0,0 +1,163 @@
|
||||
"""SKH2_REGIME_TIGHT — DD-reduction wave, family: tighter regime selectivity.
|
||||
|
||||
Hypothesis: make the regime band MORE selective (narrow vola band, add a volume floor)
|
||||
so only the cleanest setups trade -> fewer, higher-quality entries -> lower standalone DD,
|
||||
while keeping the winner's asymmetric exits (sl 2.5 / tp 7.0, uscitalong 24 / short 16).
|
||||
|
||||
Baseline winner to beat (V2):
|
||||
SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35, vola_hi=95, vol_lo=0)
|
||||
minFull +0.83 minHold +0.81 ; DD BTC 34% / ETH 31% (the unmet goal: DD<30%) ;
|
||||
marginal ADDS, corr_full 0.05, blend w25 uplift_hold +0.58, w50 full 1.59 / hold 1.04 / DD 12.5%.
|
||||
|
||||
BEATS-THE-WINNER iff: earns_slot AND max_dd<0.30 AND blend_w25_uplift_hold>=0.55 AND min_hold>=0.65.
|
||||
|
||||
Everything is param-based (no new feature) -> sk.study/sk.marginal/sk.causality are directly valid.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import itertools
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
# --- the winner's exits, frozen ----------------------------------------------------------
|
||||
EXITS = dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16)
|
||||
|
||||
|
||||
def mk(vola_lo, vola_hi, vol_lo, **extra):
|
||||
return SkyhookParams(vola_lo=vola_lo, vola_hi=vola_hi, vol_lo=vol_lo, **EXITS, **extra)
|
||||
|
||||
|
||||
def earns_slot(rep, mg):
|
||||
return (rep["verdict"]["grade"] != "FAIL"
|
||||
and mg.get("marginal_verdict") == "ADDS"
|
||||
and bool(mg.get("robust_oos"))
|
||||
and not bool(mg.get("is_hedge")))
|
||||
|
||||
|
||||
def beats(rep, mg, max_dd):
|
||||
es = earns_slot(rep, mg)
|
||||
upl = mg.get("blends", {}).get("w25", {}).get("uplift_hold")
|
||||
mh = rep["verdict"]["min_asset_holdout_sharpe"]
|
||||
return (es and max_dd < 0.30 and (upl is not None and upl >= 0.55) and mh >= 0.65)
|
||||
|
||||
|
||||
def summarize(name, p):
|
||||
rep = sk.study(name, p)
|
||||
v = rep["verdict"]
|
||||
dd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"])
|
||||
nt = v["min_trades"]
|
||||
print(sk.fmt(rep))
|
||||
print(f" >> maxDD(BTC,ETH) = {dd*100:.1f}% minTrades={nt}")
|
||||
return rep, dd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# ---- STAGE 1: coarse grid over the family levers -----------------------------------
|
||||
vola_los = [35.0, 40.0, 45.0, 50.0] # 35 = winner; 40/45/50 = tighter floor
|
||||
vola_his = [85.0, 90.0, 95.0] # 95 = winner; 85/90 = clip blow-off harder
|
||||
vol_los = [0.0, 30.0, 40.0, 50.0] # 0 = winner; floor = require live volume
|
||||
|
||||
rows = []
|
||||
print("########## STAGE 1: coarse DD scan (study, both assets) ##########")
|
||||
for vlo, vhi, vol in itertools.product(vola_los, vola_his, vol_los):
|
||||
p = mk(vlo, vhi, vol)
|
||||
rep = sk.study(f"vlo{vlo:.0f}_vhi{vhi:.0f}_vol{vol:.0f}", p)
|
||||
v = rep["verdict"]
|
||||
dd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"])
|
||||
nt = v["min_trades"]
|
||||
ddb = rep["per_asset"]["BTC"]["full"]["maxdd"]
|
||||
dde = rep["per_asset"]["ETH"]["full"]["maxdd"]
|
||||
rows.append(dict(vlo=vlo, vhi=vhi, vol=vol, grade=v["grade"],
|
||||
mf=v["min_asset_full_sharpe"], mh=v["min_asset_holdout_sharpe"],
|
||||
dd=dd, ddb=ddb, dde=dde, nt=nt, fee=v["fee_survives"]))
|
||||
print(f" vlo{vlo:.0f} vhi{vhi:.0f} vol{vol:.0f}: {v['grade']:4s} "
|
||||
f"mF={v['min_asset_full_sharpe']:+.2f} mH={v['min_asset_holdout_sharpe']:+.2f} "
|
||||
f"DD={dd*100:4.1f}% (B{ddb*100:.0f}/E{dde*100:.0f}) nT={nt:3d} feeOK={v['fee_survives']}")
|
||||
|
||||
# ---- rank: candidates with DD<30%, minTrades>=20, fee OK, holdout>=0.65 -------------
|
||||
print("\n########## STAGE 1 RANK (filter DD<30, nT>=20, fee OK, mH>=0.65, not FAIL) ##########")
|
||||
cand = [r for r in rows if r["dd"] < 0.30 and r["nt"] >= 20 and r["fee"]
|
||||
and r["mh"] >= 0.65 and r["grade"] != "FAIL"]
|
||||
cand.sort(key=lambda r: (-r["mh"], r["dd"])) # prefer high holdout then low DD
|
||||
if not cand:
|
||||
print(" (none met all hard filters; falling back to lowest-DD with nT>=20 & not FAIL)")
|
||||
cand = [r for r in rows if r["nt"] >= 20 and r["grade"] != "FAIL"]
|
||||
cand.sort(key=lambda r: (r["dd"], -r["mh"]))
|
||||
for r in cand[:8]:
|
||||
print(f" vlo{r['vlo']:.0f} vhi{r['vhi']:.0f} vol{r['vol']:.0f}: {r['grade']} "
|
||||
f"mF={r['mf']:+.2f} mH={r['mh']:+.2f} DD={r['dd']*100:.1f}% nT={r['nt']}")
|
||||
|
||||
# ---- STAGE 2: full diligence (causality + marginal) on top few ---------------------
|
||||
print("\n########## STAGE 2: causality + marginal on top candidates ##########")
|
||||
best = None
|
||||
top = cand[:4]
|
||||
for r in top:
|
||||
p = mk(r["vlo"], r["vhi"], r["vol"])
|
||||
name = f"TIGHT vlo{r['vlo']:.0f}_vhi{r['vhi']:.0f}_vol{r['vol']:.0f}"
|
||||
print(f"\n----- {name} -----")
|
||||
rep, dd = summarize(name, p)
|
||||
cau = sk.causality(p, "BTC")
|
||||
cau_e = sk.causality(p, "ETH")
|
||||
cau_ok = bool(cau["ok"] and cau_e["ok"])
|
||||
mg = sk.marginal(p)
|
||||
es = earns_slot(rep, mg)
|
||||
bw = beats(rep, mg, dd) and cau_ok
|
||||
w25 = mg.get("blends", {}).get("w25", {})
|
||||
w50 = mg.get("blends", {}).get("w50", {})
|
||||
print(f" causality BTC={cau} ETH={cau_e} -> ok={cau_ok}")
|
||||
print(f" marginal: verdict={mg.get('marginal_verdict')} corr_full={mg.get('corr_full')} "
|
||||
f"corr_hold={mg.get('corr_hold')} insample_edge={mg.get('has_insample_edge')} "
|
||||
f"(cand_is_sh={mg.get('cand_insample_sharpe')}) hedge={mg.get('is_hedge')} "
|
||||
f"robust_oos={mg.get('robust_oos')} multicut={mg.get('multicut_persistent')} "
|
||||
f"clean_year_uplift={mg.get('clean_year_uplift')}")
|
||||
print(f" blend w25: uplift_hold={w25.get('uplift_hold')} uplift_full={w25.get('uplift_full')} "
|
||||
f"| w50: full={w50.get('full')} hold={w50.get('hold')} dd={w50.get('dd')}")
|
||||
print(f" >> earns_slot={es} beats_winner={bw}")
|
||||
cand_obj = dict(name=name, p=p, rep=rep, dd=dd, mg=mg, cau_ok=cau_ok,
|
||||
es=es, bw=bw, w25=w25, w50=w50,
|
||||
mf=rep["verdict"]["min_asset_full_sharpe"],
|
||||
mh=rep["verdict"]["min_asset_holdout_sharpe"],
|
||||
nt=rep["verdict"]["min_trades"],
|
||||
fee=rep["verdict"]["fee_survives"])
|
||||
# pick best: prefer beats_winner, then earns_slot+lowDD, then lowDD
|
||||
def keyf(c):
|
||||
return (c["bw"], c["es"], -c["dd"], c["mh"])
|
||||
if best is None or keyf(cand_obj) > keyf(best):
|
||||
best = cand_obj
|
||||
|
||||
# ---- FINAL BLOCK -------------------------------------------------------------------
|
||||
print("\n\n==================== FINAL — REGIME_TIGHT BEST ====================")
|
||||
if best is None:
|
||||
print("NO CANDIDATE — no config passed even the soft filter.")
|
||||
sys.exit(0)
|
||||
b = best
|
||||
pf = {k: getattr(b["p"], k) for k in b["p"].__dataclass_fields__}
|
||||
mg = b["mg"]
|
||||
print(f"config: vola_lo={b['p'].vola_lo} vola_hi={b['p'].vola_hi} vol_lo={b['p'].vol_lo} "
|
||||
f"ptn_n={b['p'].ptn_n} sl_atr={b['p'].sl_atr} tp_atr={b['p'].tp_atr} "
|
||||
f"uscitalong={b['p'].uscitalong} uscitashort={b['p'].uscitashort}")
|
||||
print(f"minFull={b['mf']:+.3f} minHold={b['mh']:+.3f} max_dd={b['dd']*100:.1f}% "
|
||||
f"n_trades_min={b['nt']} fee@0.30%OK={b['fee']} causality_ok={b['cau_ok']}")
|
||||
print(f"earns_slot={b['es']} beats_winner={b['bw']}")
|
||||
print("FULL marginal dict:")
|
||||
for k in ("corr_full", "corr_hold", "marginal_verdict", "has_insample_edge",
|
||||
"cand_insample_sharpe", "is_hedge", "robust_oos", "multicut_persistent",
|
||||
"clean_year_uplift", "jackknife_min_uplift", "multicut_uplift"):
|
||||
print(f" {k} = {mg.get(k)}")
|
||||
print(f" blend w25: {mg.get('blends', {}).get('w25')}")
|
||||
print(f" blend w50: {mg.get('blends', {}).get('w50')}")
|
||||
print("PARAMS:", pf)
|
||||
# machine-readable tail for me to parse
|
||||
import json
|
||||
print("\nRESULT_JSON " + json.dumps(dict(
|
||||
family="REGIME_TIGHT", params=pf,
|
||||
minFull=b["mf"], minHold=b["mh"], max_dd=b["dd"], n_trades_min=b["nt"],
|
||||
fee_ok=b["fee"], causality_ok=b["cau_ok"], earns_slot=b["es"], beats=b["bw"],
|
||||
corr_full=mg.get("corr_full"), marginal_verdict=mg.get("marginal_verdict"),
|
||||
has_insample_edge=mg.get("has_insample_edge"), is_hedge=mg.get("is_hedge"),
|
||||
robust_oos=mg.get("robust_oos"), multicut_persistent=mg.get("multicut_persistent"),
|
||||
clean_year_uplift=mg.get("clean_year_uplift"),
|
||||
w25_uplift_hold=mg.get("blends", {}).get("w25", {}).get("uplift_hold"),
|
||||
), default=str))
|
||||
@@ -0,0 +1,191 @@
|
||||
"""SKH2_TPSL_DD: RR / stop fine grid around the V2 winner to push standalone maxDD < 30%
|
||||
while holding min-asset HOLD-OUT >= ~0.70 and earns_slot True.
|
||||
|
||||
Winner baseline:
|
||||
SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
-> minFull +0.83, minHold +0.81, DD BTC 34% / ETH 31% (THE PROBLEM), earns_slot True.
|
||||
|
||||
Family task: sl_atr in {1.75,2.0,2.25,2.5}, tp_atr in {5,6,7,8}, exit_mode 'pct' vs 'atr'.
|
||||
Tighter SL cuts DD but can lower hold-out. Find DD<30 cell + minHold>=0.7 + plateau.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import numpy as np # noqa: E402
|
||||
import skyhooklib as sk # noqa: E402
|
||||
from src.strategies.skyhook import SkyhookParams # noqa: E402
|
||||
|
||||
# Winner fixed (non-RR) fields:
|
||||
BASE = dict(ptn_n=45, uscitalong=24, uscitashort=16, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
|
||||
def mk(sl_atr=None, tp_atr=None, exit_mode="atr", sl_pct=None, tp_pct=None):
|
||||
kw = dict(BASE)
|
||||
kw["exit_mode"] = exit_mode
|
||||
if exit_mode == "atr":
|
||||
kw["sl_atr"] = sl_atr
|
||||
kw["tp_atr"] = tp_atr
|
||||
else:
|
||||
kw["sl_pct"] = sl_pct
|
||||
kw["tp_pct"] = tp_pct
|
||||
return SkyhookParams(**kw)
|
||||
|
||||
|
||||
def metrics(p):
|
||||
"""FULL/HOLD/DD min-asset + fee@0.30 + trades, both assets."""
|
||||
pa = {}
|
||||
fee_ok = True
|
||||
for a in ("BTC", "ETH"):
|
||||
r = sk.run_asset(a, p)
|
||||
# fee sweep at 0.30%
|
||||
rf = sk.run_asset(a, p, fee_rt=0.003)
|
||||
fee_ok = fee_ok and (rf["full"]["sharpe"] > 0)
|
||||
pa[a] = dict(full=r["full"], hold=r["holdout"], yearly=r["yearly"],
|
||||
fee30=rf["full"]["sharpe"])
|
||||
minFull = min(pa[a]["full"]["sharpe"] for a in pa)
|
||||
minHold = min(pa[a]["hold"]["sharpe"] for a in pa)
|
||||
minTr = min(pa[a]["full"]["n_trades"] for a in pa)
|
||||
maxDD = max(pa[a]["full"]["maxdd"] for a in pa)
|
||||
return dict(pa=pa, minFull=minFull, minHold=minHold, minTr=minTr, maxDD=maxDD, fee_ok=fee_ok)
|
||||
|
||||
|
||||
def grade_of(m):
|
||||
enough = m["minTr"] >= 20
|
||||
if enough and m["minFull"] >= 0.5 and m["minHold"] >= 0.2 and m["fee_ok"]:
|
||||
return "PASS"
|
||||
if enough and m["minFull"] >= 0.3 and m["minHold"] >= 0.0:
|
||||
return "WEAK"
|
||||
return "FAIL"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# ---- STAGE 1: coarse ATR grid (the family core) ----
|
||||
sl_grid = [1.75, 2.0, 2.25, 2.5]
|
||||
tp_grid = [5.0, 6.0, 7.0, 8.0]
|
||||
rows = []
|
||||
print("##### STAGE 1: ATR grid (sl_atr x tp_atr) #####")
|
||||
print(f"{'sl':>5} {'tp':>4} | {'minFull':>8} {'minHold':>8} {'maxDD%':>7} {'minTr':>6} "
|
||||
f"{'BTC_DD':>7} {'ETH_DD':>7} {'feeOK':>5} {'grade':>5}")
|
||||
for sl in sl_grid:
|
||||
for tp in tp_grid:
|
||||
p = mk(sl_atr=sl, tp_atr=tp, exit_mode="atr")
|
||||
m = metrics(p)
|
||||
g = grade_of(m)
|
||||
bdd = m["pa"]["BTC"]["full"]["maxdd"]
|
||||
edd = m["pa"]["ETH"]["full"]["maxdd"]
|
||||
rows.append((sl, tp, "atr", m, g))
|
||||
print(f"{sl:>5} {tp:>4} | {m['minFull']:>+8.2f} {m['minHold']:>+8.2f} "
|
||||
f"{m['maxDD']*100:>7.1f} {m['minTr']:>6} {bdd*100:>7.1f} {edd*100:>7.1f} "
|
||||
f"{str(m['fee_ok']):>5} {g:>5}")
|
||||
|
||||
# ---- STAGE 2: pct exit grid (a few sensible RR pairs ~ matching ATR ratios) ----
|
||||
# ATR LTF ~ a few % of price; pct exit gives a HARD dollar cap on DD per trade.
|
||||
print("\n##### STAGE 2: PCT exit grid (sl_pct x tp_pct) #####")
|
||||
print(f"{'slP':>6} {'tpP':>6} | {'minFull':>8} {'minHold':>8} {'maxDD%':>7} {'minTr':>6} "
|
||||
f"{'BTC_DD':>7} {'ETH_DD':>7} {'feeOK':>5} {'grade':>5}")
|
||||
pct_pairs = [(0.02, 0.06), (0.025, 0.07), (0.03, 0.075), (0.03, 0.09),
|
||||
(0.035, 0.10), (0.04, 0.10),
|
||||
# dense neighbourhood around the DD<30 winner (0.025,0.07) to prove a plateau:
|
||||
(0.0225, 0.065), (0.0225, 0.07), (0.0225, 0.075),
|
||||
(0.025, 0.0625), (0.025, 0.065), (0.025, 0.075), (0.025, 0.08),
|
||||
(0.0275, 0.065), (0.0275, 0.07), (0.0275, 0.075)]
|
||||
for slp, tpp in pct_pairs:
|
||||
p = mk(exit_mode="pct", sl_pct=slp, tp_pct=tpp)
|
||||
m = metrics(p)
|
||||
g = grade_of(m)
|
||||
bdd = m["pa"]["BTC"]["full"]["maxdd"]
|
||||
edd = m["pa"]["ETH"]["full"]["maxdd"]
|
||||
rows.append((slp, tpp, "pct", m, g))
|
||||
print(f"{slp:>6} {tpp:>6} | {m['minFull']:>+8.2f} {m['minHold']:>+8.2f} "
|
||||
f"{m['maxDD']*100:>7.1f} {m['minTr']:>6} {bdd*100:>7.1f} {edd*100:>7.1f} "
|
||||
f"{str(m['fee_ok']):>5} {g:>5}")
|
||||
|
||||
# ---- Pick best: DD<30, minHold>=0.7, grade!=FAIL; tie-break by minHold then minFull ----
|
||||
def ok_dd(r):
|
||||
return r[3]["maxDD"] < 0.30 and r[3]["minHold"] >= 0.70 and r[4] != "FAIL"
|
||||
cands = [r for r in rows if ok_dd(r)]
|
||||
if not cands:
|
||||
# relax: DD<30 and minHold>=0.65
|
||||
cands = [r for r in rows if r[3]["maxDD"] < 0.30 and r[3]["minHold"] >= 0.65 and r[4] != "FAIL"]
|
||||
relaxed = True
|
||||
else:
|
||||
relaxed = False
|
||||
if not cands:
|
||||
# fall back to lowest DD among non-FAIL with decent hold
|
||||
cands = [r for r in rows if r[4] != "FAIL"]
|
||||
# rank: among DD<30 cells, maximize a balanced score (minHold + minFull) so we don't pick a
|
||||
# low-DD-but-weak-Sharpe corner. DD is already gated < 0.30 above, so optimise value next.
|
||||
cands_sorted = sorted(cands, key=lambda r: -(r[3]["minHold"] + r[3]["minFull"]))
|
||||
best = cands_sorted[0]
|
||||
print(f"\n##### BEST PICK (relaxed={relaxed if cands else 'fallback'}): "
|
||||
f"{'sl/tp' if best[2]=='atr' else 'slP/tpP'}=({best[0]},{best[1]}) mode={best[2]} #####")
|
||||
|
||||
# Build best params
|
||||
if best[2] == "atr":
|
||||
bp = mk(sl_atr=best[0], tp_atr=best[1], exit_mode="atr")
|
||||
best_cfg = dict(ptn_n=45, sl_atr=best[0], tp_atr=best[1], uscitalong=24,
|
||||
uscitashort=16, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0,
|
||||
exit_mode="atr")
|
||||
else:
|
||||
bp = mk(exit_mode="pct", sl_pct=best[0], tp_pct=best[1])
|
||||
best_cfg = dict(ptn_n=45, sl_pct=best[0], tp_pct=best[1], uscitalong=24,
|
||||
uscitashort=16, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0,
|
||||
exit_mode="pct")
|
||||
|
||||
# ---- Full verification of best: study + causality + marginal ----
|
||||
print("\n##### FULL STUDY of BEST #####")
|
||||
rep = sk.study("SKH2_TPSL_DD-BEST", bp)
|
||||
print(sk.fmt(rep))
|
||||
caus = sk.causality(bp, "BTC")
|
||||
caus_eth = sk.causality(bp, "ETH")
|
||||
print(f"\ncausality BTC: {caus}")
|
||||
print(f"causality ETH: {caus_eth}")
|
||||
mg = sk.marginal(bp)
|
||||
|
||||
m = best[3]
|
||||
g = rep["verdict"]["grade"]
|
||||
earns = (g != "FAIL" and mg.get("marginal_verdict") == "ADDS"
|
||||
and bool(mg.get("robust_oos")) and not bool(mg.get("is_hedge")))
|
||||
w25 = mg.get("blends", {}).get("w25", {})
|
||||
w50 = mg.get("blends", {}).get("w50", {})
|
||||
beats = (earns and m["maxDD"] < 0.30
|
||||
and (w25.get("uplift_hold") or -9) >= 0.55 and m["minHold"] >= 0.65)
|
||||
|
||||
print("\n========== FINAL BLOCK ==========")
|
||||
print(f"best_cfg = {best_cfg}")
|
||||
print(f"exit_mode = {best[2]}")
|
||||
print(f"minFull = {m['minFull']:+.3f}")
|
||||
print(f"minHold = {m['minHold']:+.3f}")
|
||||
print(f"max_dd (BTC/ETH) = {m['maxDD']:.4f} (BTC {m['pa']['BTC']['full']['maxdd']:.4f} / "
|
||||
f"ETH {m['pa']['ETH']['full']['maxdd']:.4f})")
|
||||
print(f"n_trades_min = {m['minTr']}")
|
||||
print(f"fee@0.30 OK = {m['fee_ok']} (BTC {m['pa']['BTC']['fee30']:+.2f} / "
|
||||
f"ETH {m['pa']['ETH']['fee30']:+.2f})")
|
||||
print(f"causality_ok = {caus['ok'] and caus_eth['ok']} "
|
||||
f"(BTC mism={caus['mismatches']} ETH mism={caus_eth['mismatches']})")
|
||||
print(f"grade = {g}")
|
||||
print("--- marginal vs TP01 ---")
|
||||
print(f"corr_full = {mg.get('corr_full')}")
|
||||
print(f"corr_hold = {mg.get('corr_hold')}")
|
||||
print(f"marginal_verdict = {mg.get('marginal_verdict')}")
|
||||
print(f"has_insample_edge = {mg.get('has_insample_edge')}")
|
||||
print(f"is_hedge = {mg.get('is_hedge')}")
|
||||
print(f"robust_oos = {mg.get('robust_oos')}")
|
||||
print(f"multicut_persist = {mg.get('multicut_persistent')}")
|
||||
print(f"clean_year_uplift = {mg.get('clean_year_uplift')}")
|
||||
print(f"jackknife_min_upl = {mg.get('jackknife_min_uplift')}")
|
||||
print(f"cand_insample_sh = {mg.get('cand_insample_sharpe')}")
|
||||
print(f"blend w25 = {w25}")
|
||||
print(f"blend w50 = {w50}")
|
||||
print(f"earns_slot = {earns}")
|
||||
print(f"BEATS_WINNER = {beats}")
|
||||
|
||||
# ---- plateau report: neighbors of best in the same mode ----
|
||||
print("\n##### PLATEAU (neighbors of best) #####")
|
||||
nbrs = [r for r in rows if r[2] == best[2]]
|
||||
nbrs_sorted = sorted(nbrs, key=lambda r: (r[3]["maxDD"]))
|
||||
for r in nbrs_sorted[:8]:
|
||||
tag = f"({r[0]},{r[1]})"
|
||||
print(f" {r[2]} {tag:>14}: DD={r[3]['maxDD']*100:5.1f}% minFull={r[3]['minFull']:+.2f} "
|
||||
f"minHold={r[3]['minHold']:+.2f} grade={r[4]}")
|
||||
@@ -0,0 +1,322 @@
|
||||
"""SKH2_VOLTGT — CAUSAL vol-target overlay on the V2 winner's daily return series.
|
||||
|
||||
Family: vol-target overlay [VOLTGT]. Wave goal: cut standalone maxDD < 30% while keeping
|
||||
min-asset hold-out Sharpe >= ~0.70 and earns_slot True.
|
||||
|
||||
Method:
|
||||
* Build the winner's daily return series per asset (from the honest intrabar equity).
|
||||
* Scale each day t by lev_t = min(cap, target_vol / rv_{t-1}) where rv_{t-1} is the
|
||||
trailing realized vol KNOWN AT t-1 (rolling window of past daily returns, .shift(1)).
|
||||
-> strictly causal: the scaler at day t uses returns up to and including day t-1 only.
|
||||
* scaled_ret_t = lev_t * ret_t. Rebuild scaled equity, measure DD per asset + combined.
|
||||
* Run altlib.marginal_vs_tp01 on the 50/50 scaled-combined daily series.
|
||||
|
||||
We sweep target_vol in {15%,20%,25%}, cap in {1.5,2.0}, and a couple of vol windows.
|
||||
We prove causality of the scaler two ways:
|
||||
(1) construction (shift(1) -> rv known at t-1),
|
||||
(2) an explicit truncated-prefix recompute: lev_t computed on the full history must equal
|
||||
lev_t recomputed from only the returns up to t-1.
|
||||
The underlying winner entries are param-only -> their causality is sk.causality (0 mismatches).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import skyhooklib as sk
|
||||
import altlib as al
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
HOLDOUT = sk.HOLDOUT
|
||||
FEE = sk.FEE_RT
|
||||
ANN = np.sqrt(365.25)
|
||||
|
||||
# ---- the verified V2 winner (baseline to beat) ----------------------------------------
|
||||
WINNER = SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
|
||||
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
|
||||
def _sh(r: np.ndarray) -> float:
|
||||
r = np.asarray(r, float)
|
||||
r = r[np.isfinite(r)]
|
||||
if len(r) < 2 or np.std(r) == 0:
|
||||
return 0.0
|
||||
return float(np.mean(r) / np.std(r) * ANN)
|
||||
|
||||
|
||||
def _maxdd_from_returns(r: pd.Series) -> float:
|
||||
eq = (1.0 + r.fillna(0.0)).cumprod().values
|
||||
pk = np.maximum.accumulate(eq)
|
||||
return float(np.max((pk - eq) / pk))
|
||||
|
||||
|
||||
def _split_sharpe(r: pd.Series, mask) -> float:
|
||||
return _sh(r[mask].values)
|
||||
|
||||
|
||||
def _trailing_rv(daily: pd.Series, win: int, mode: str) -> pd.Series:
|
||||
"""Annualized trailing realized vol KNOWN AT t-1 (shift(1) -> strictly past data)."""
|
||||
if mode == "ewma":
|
||||
# EWMA std of past returns, reacts faster to a vol spike than a flat window
|
||||
v = daily.ewm(span=win, min_periods=max(10, win // 2)).std().shift(1)
|
||||
else:
|
||||
v = daily.rolling(win, min_periods=max(10, win // 2)).std().shift(1)
|
||||
return v * ANN
|
||||
|
||||
|
||||
def vol_target_lev(daily: pd.Series, target_vol: float, cap: float, win: int,
|
||||
floor: float = 0.0, mode: str = "roll") -> pd.Series:
|
||||
"""CAUSAL leverage series. rv_{t-1} = annualized trailing realized vol KNOWN at t-1.
|
||||
lev_t = clip(target/rv_{t-1}, floor, cap). cap<=1.0 => de-risk only (never lever up)."""
|
||||
rv = _trailing_rv(daily, win, mode)
|
||||
lev = (target_vol / rv).clip(lower=floor, upper=cap)
|
||||
# before we have enough history -> stay at min(1.0, cap) (no scaling, no look-ahead)
|
||||
lev = lev.where(rv.notna(), min(1.0, cap)).fillna(min(1.0, cap))
|
||||
return lev
|
||||
|
||||
|
||||
def prove_scaler_causal(daily: pd.Series, target_vol: float, cap: float, win: int,
|
||||
mode: str = "roll", n_checks: int = 60) -> dict:
|
||||
"""Truncated-prefix recompute: lev_t built on the FULL series must equal lev_t rebuilt
|
||||
from only returns up to t-1. Any leak (un-shifted vol) would break this."""
|
||||
full = vol_target_lev(daily, target_vol, cap, win, mode=mode)
|
||||
n = len(daily)
|
||||
bad = 0
|
||||
checked = 0
|
||||
mp = max(10, win // 2)
|
||||
idxs = np.linspace(int(n * 0.5), n - 1, n_checks).astype(int)
|
||||
for t in sorted(set(idxs)):
|
||||
if t < 1:
|
||||
continue
|
||||
prefix = daily.iloc[:t] # returns up to and including day t-1 ONLY
|
||||
if mode == "ewma":
|
||||
rv_prev = prefix.ewm(span=win, min_periods=mp).std().iloc[-1] * ANN
|
||||
else:
|
||||
rv_prev = prefix.rolling(win, min_periods=mp).std().iloc[-1] * ANN
|
||||
if (not np.isfinite(rv_prev)) or rv_prev == 0:
|
||||
lev_t = min(1.0, cap)
|
||||
else:
|
||||
lev_t = float(np.clip(target_vol / rv_prev, 0.0, cap))
|
||||
checked += 1
|
||||
if abs(float(full.iloc[t]) - lev_t) > 1e-9:
|
||||
bad += 1
|
||||
return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked))
|
||||
|
||||
|
||||
def winner_daily(asset: str) -> pd.Series:
|
||||
"""Winner's RAW daily return series for one asset (from honest intrabar equity)."""
|
||||
return sk.daily_returns(asset, WINNER, FEE)
|
||||
|
||||
|
||||
def run_overlay(target_vol: float, cap: float, win: int, floor: float = 0.0,
|
||||
mode: str = "roll") -> dict:
|
||||
"""Apply the causal vol-target overlay per asset, combine 50/50, report DD + marginal."""
|
||||
raw = {a: winner_daily(a) for a in ("BTC", "ETH")}
|
||||
scaled = {}
|
||||
lev_stats = {}
|
||||
scaler_caus = {}
|
||||
per_asset_dd_raw = {}
|
||||
per_asset_dd_scaled = {}
|
||||
per_asset_full_sh = {}
|
||||
per_asset_hold_sh = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
d = raw[a]
|
||||
lev = vol_target_lev(d, target_vol, cap, win, floor, mode)
|
||||
s = (lev * d)
|
||||
scaled[a] = s
|
||||
lev_stats[a] = (round(float(lev.mean()), 3), round(float(lev.median()), 3),
|
||||
round(float((lev >= cap - 1e-9).mean()), 3))
|
||||
scaler_caus[a] = prove_scaler_causal(d, target_vol, cap, win, mode)
|
||||
per_asset_dd_raw[a] = _maxdd_from_returns(d)
|
||||
per_asset_dd_scaled[a] = _maxdd_from_returns(s)
|
||||
hmask = s.index >= HOLDOUT
|
||||
per_asset_full_sh[a] = _sh(s.values)
|
||||
per_asset_hold_sh[a] = _split_sharpe(s, hmask)
|
||||
|
||||
# combined 50/50 scaled series (same convention as sk.skyhook_daily_5050)
|
||||
J = pd.concat(scaled, axis=1, join="inner").fillna(0.0)
|
||||
comb = 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
comb_dd = _maxdd_from_returns(comb)
|
||||
comb_full_sh = _sh(comb.values)
|
||||
comb_hold_sh = _split_sharpe(comb, comb.index >= HOLDOUT)
|
||||
|
||||
mg = al.marginal_vs_tp01(comb)
|
||||
|
||||
max_dd = max(per_asset_dd_scaled.values()) # max over BTC & ETH (per-asset scaled DD)
|
||||
min_full = min(per_asset_full_sh.values())
|
||||
min_hold = min(per_asset_hold_sh.values())
|
||||
return dict(target_vol=target_vol, cap=cap, win=win, floor=floor, mode=mode,
|
||||
lev_stats=lev_stats, scaler_caus=scaler_caus,
|
||||
dd_raw=per_asset_dd_raw, dd_scaled=per_asset_dd_scaled,
|
||||
full_sh=per_asset_full_sh, hold_sh=per_asset_hold_sh,
|
||||
comb_dd=comb_dd, comb_full=comb_full_sh, comb_hold=comb_hold_sh,
|
||||
max_dd=max_dd, min_full=min_full, min_hold=min_hold, marginal=mg)
|
||||
|
||||
|
||||
def fee_survives_winner() -> bool:
|
||||
"""The vol-target overlay does NOT change trade count/turnover materially (it scales an
|
||||
already-net daily series), so fee survival is the WINNER's fee survival. Report it."""
|
||||
rep = sk.study("WINNER-fee", WINNER)
|
||||
ok = True
|
||||
for a, pa in rep["per_asset"].items():
|
||||
ok = ok and (pa["fee_sweep"].get("0.30%RT", -9) > 0)
|
||||
return ok, rep
|
||||
|
||||
|
||||
def winner_min_trades() -> int:
|
||||
rep = sk.study("WINNER-tr", WINNER)
|
||||
return min(pa["full"]["n_trades"] for pa in rep["per_asset"].values())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 90)
|
||||
print("SKH2_VOLTGT — causal vol-target overlay on the V2 winner")
|
||||
print("Winner:", WINNER)
|
||||
print("=" * 90)
|
||||
|
||||
# baseline reference: winner raw (no overlay) for context
|
||||
raw = {a: winner_daily(a) for a in ("BTC", "ETH")}
|
||||
Jr = pd.concat(raw, axis=1, join="inner").fillna(0.0)
|
||||
raw_comb = 0.5 * Jr["BTC"] + 0.5 * Jr["ETH"]
|
||||
print("\n--- WINNER RAW (no overlay), daily-return view ---")
|
||||
for a in ("BTC", "ETH"):
|
||||
print(f" {a}: dailyFullSh={_sh(raw[a].values):+.2f} "
|
||||
f"holdSh={_split_sharpe(raw[a], raw[a].index>=HOLDOUT):+.2f} "
|
||||
f"DD(daily)={_maxdd_from_returns(raw[a])*100:.0f}%")
|
||||
print(f" COMB: fullSh={_sh(raw_comb.values):+.2f} "
|
||||
f"holdSh={_split_sharpe(raw_comb, raw_comb.index>=HOLDOUT):+.2f} "
|
||||
f"DD={_maxdd_from_returns(raw_comb)*100:.0f}%")
|
||||
print(" NB daily-view Sharpe != intrabar headline Sharpe (winner minFull +0.83/minHold +0.81 "
|
||||
"are the intrabar numbers). The overlay's job is the DD; we judge marginal+DD on the daily series.")
|
||||
|
||||
# KEY LESSON from v1 grid: cap>1.0 levers UP in low-vol regimes that precede crashes ->
|
||||
# per-asset BTC DD got WORSE (34%->43-55%). To CUT standalone per-asset DD<30% the cap must
|
||||
# be <=1.0 (DE-RISK ONLY: never amplify). We also test EWMA vol (reacts faster to spikes).
|
||||
GRID = []
|
||||
for mode in ("roll", "ewma"):
|
||||
for tv in (0.15, 0.20, 0.25):
|
||||
for cap in (0.8, 1.0): # de-risk only
|
||||
for win in (20, 30):
|
||||
GRID.append((tv, cap, win, mode))
|
||||
# FRONTIER scan: how much lever-up (cap) can we allow at tv=25 before BTC DD breaks 30%?
|
||||
# (rolling vol drove the highest uplift; we want max w25 uplift_hold subject to DD<0.30)
|
||||
for cap in (1.1, 1.2, 1.3):
|
||||
for win in (20, 30):
|
||||
GRID.append((0.25, cap, win, "roll"))
|
||||
GRID.append((0.25, cap, win, "ewma"))
|
||||
# plus a couple of cap=1.5 references to show the lever-up failure explicitly
|
||||
for tv in (0.20, 0.25):
|
||||
GRID.append((tv, 1.5, 20, "roll"))
|
||||
|
||||
results = []
|
||||
for tv, cap, win, mode in GRID:
|
||||
r = run_overlay(tv, cap, win, mode=mode)
|
||||
results.append(r)
|
||||
mg = r["marginal"]
|
||||
w25 = mg.get("blends", {}).get("w25", {})
|
||||
print(f"\n[{mode} tv={tv:.0%} cap={cap} win={win}] "
|
||||
f"minFull(daily)={r['min_full']:+.2f} minHold={r['min_hold']:+.2f} "
|
||||
f"max_dd={r['max_dd']*100:.0f}% (BTC {r['dd_scaled']['BTC']*100:.0f}%/"
|
||||
f"ETH {r['dd_scaled']['ETH']*100:.0f}% raw BTC {r['dd_raw']['BTC']*100:.0f}%/"
|
||||
f"ETH {r['dd_raw']['ETH']*100:.0f}%) combDD={r['comb_dd']*100:.0f}%")
|
||||
print(f" lev BTC mean/med/atcap={r['lev_stats']['BTC']} ETH={r['lev_stats']['ETH']} "
|
||||
f"scalerCausal BTC={r['scaler_caus']['BTC']['ok']} ETH={r['scaler_caus']['ETH']['ok']}")
|
||||
print(f" marginal: corr_full={mg.get('corr_full')} verdict={mg.get('marginal_verdict')} "
|
||||
f"insample_edge={mg.get('has_insample_edge')} hedge={mg.get('is_hedge')} "
|
||||
f"robust_oos={mg.get('robust_oos')} multicut={mg.get('multicut_persistent')} "
|
||||
f"cleanYr={mg.get('clean_year_uplift')} w25upHold={w25.get('uplift_hold')}")
|
||||
|
||||
# ---- pick the best config: prioritize (1) max_dd<0.30, then (2) min_hold, then (3) w25 uplift_hold
|
||||
def beats(r):
|
||||
mg = r["marginal"]
|
||||
w25 = mg.get("blends", {}).get("w25", {})
|
||||
es = (mg.get("marginal_verdict") == "ADDS" and mg.get("robust_oos") is True
|
||||
and mg.get("is_hedge") is False)
|
||||
return (es and r["max_dd"] < 0.30
|
||||
and (w25.get("uplift_hold") or -9) >= 0.55 and r["min_hold"] >= 0.65)
|
||||
|
||||
def _earns(r):
|
||||
mg = r["marginal"]
|
||||
return (mg.get("marginal_verdict") == "ADDS" and mg.get("robust_oos") is True
|
||||
and mg.get("is_hedge") is False)
|
||||
|
||||
def score(r):
|
||||
mg = r["marginal"]
|
||||
w25 = mg.get("blends", {}).get("w25", {})
|
||||
uh = w25.get("uplift_hold") or -9
|
||||
# priority: (1) full BEATS, (2) DD<0.30 AND earns_slot AND hold>=0.65 (deployable DD-cut),
|
||||
# (3) max w25 uplift_hold, (4) max min_hold
|
||||
deployable = 1 if (r["max_dd"] < 0.30 and _earns(r) and r["min_hold"] >= 0.65) else 0
|
||||
return (1 if beats(r) else 0,
|
||||
deployable,
|
||||
round(uh, 3),
|
||||
round(r["min_hold"], 3))
|
||||
|
||||
best = max(results, key=score)
|
||||
mg = best["marginal"]
|
||||
w25 = mg.get("blends", {}).get("w25", {})
|
||||
w50 = mg.get("blends", {}).get("w50", {})
|
||||
|
||||
fee_ok, _ = fee_survives_winner()
|
||||
caus = sk.causality(WINNER, "BTC")
|
||||
caus_e = sk.causality(WINNER, "ETH")
|
||||
min_tr = winner_min_trades()
|
||||
scaler_ok = all(best["scaler_caus"][a]["ok"] for a in ("BTC", "ETH"))
|
||||
causality_ok = bool(caus["ok"] and caus_e["ok"] and scaler_ok)
|
||||
|
||||
es = (mg.get("marginal_verdict") == "ADDS" and mg.get("robust_oos") is True
|
||||
and mg.get("is_hedge") is False)
|
||||
earns_slot = bool(es) # study grade for winner is PASS (it's the verified winner)
|
||||
beats_winner = bool(earns_slot and best["max_dd"] < 0.30
|
||||
and (w25.get("uplift_hold") or -9) >= 0.55 and best["min_hold"] >= 0.65)
|
||||
|
||||
print("\n" + "=" * 90)
|
||||
print("FINAL — BEST VOL-TARGET OVERLAY CONFIG")
|
||||
print("=" * 90)
|
||||
print(f" config: mode={best['mode']} target_vol={best['target_vol']:.0%} cap={best['cap']} win={best['win']}")
|
||||
print(f" minFull(daily) = {best['min_full']:+.3f}")
|
||||
print(f" minHold(daily) = {best['min_hold']:+.3f} (BTC {best['hold_sh']['BTC']:+.2f} / ETH {best['hold_sh']['ETH']:+.2f})")
|
||||
print(f" standalone max_dd (max BTCÐ scaled) = {best['max_dd']:.4f} "
|
||||
f"(BTC {best['dd_scaled']['BTC']:.3f} / ETH {best['dd_scaled']['ETH']:.3f})")
|
||||
print(f" RAW winner daily DD (no overlay) = BTC {best['dd_raw']['BTC']:.3f} / ETH {best['dd_raw']['ETH']:.3f}")
|
||||
print(f" combined scaled equity max_dd = {best['comb_dd']:.4f}")
|
||||
print(f" n_trades_min (winner) = {min_tr}")
|
||||
print(f" fee@0.30%RT survives (winner) = {fee_ok}")
|
||||
print(f" causality_ok (winner entries + scaler) = {causality_ok} "
|
||||
f"[winner BTC {caus} ETH {caus_e}; scaler BTC {best['scaler_caus']['BTC']} ETH {best['scaler_caus']['ETH']}]")
|
||||
print(f"\n MARGINAL vs TP01 (on scaled 50/50 daily):")
|
||||
print(f" corr_full = {mg.get('corr_full')}")
|
||||
print(f" corr_hold = {mg.get('corr_hold')}")
|
||||
print(f" verdict = {mg.get('marginal_verdict')}")
|
||||
print(f" has_insample_edge = {mg.get('has_insample_edge')} cand_insample_sharpe={mg.get('cand_insample_sharpe')}")
|
||||
print(f" is_hedge = {mg.get('is_hedge')}")
|
||||
print(f" robust_oos = {mg.get('robust_oos')}")
|
||||
print(f" multicut_persistent = {mg.get('multicut_persistent')} multicut={mg.get('multicut_uplift')}")
|
||||
print(f" clean_year_uplift = {mg.get('clean_year_uplift')} jackknife_min={mg.get('jackknife_min_uplift')}")
|
||||
print(f" blend w25: uplift_hold={w25.get('uplift_hold')} uplift_full={w25.get('uplift_full')} "
|
||||
f"hold={w25.get('hold')} full={w25.get('full')} dd={w25.get('dd')}")
|
||||
print(f" blend w50: full={w50.get('full')} hold={w50.get('hold')} "
|
||||
f"uplift_hold={w50.get('uplift_hold')} dd={w50.get('dd')}")
|
||||
print(f"\n earns_slot = {earns_slot}")
|
||||
print(f" BEATS_WINNER = {beats_winner}")
|
||||
print("=" * 90)
|
||||
|
||||
# machine-readable tail for the harness
|
||||
import json
|
||||
out = dict(
|
||||
family="voltgt",
|
||||
best_config=dict(strategy="winner+voltgt", mode=best["mode"],
|
||||
target_vol=best["target_vol"], cap=best["cap"], win=best["win"],
|
||||
winner=dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24,
|
||||
uscitashort=16, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)),
|
||||
min_full=round(best["min_full"], 3), min_hold=round(best["min_hold"], 3),
|
||||
max_dd=round(best["max_dd"], 4), comb_dd=round(best["comb_dd"], 4),
|
||||
n_trades_min=min_tr, fee_ok=fee_ok, causality_ok=causality_ok,
|
||||
corr_full=mg.get("corr_full"), verdict=mg.get("marginal_verdict"),
|
||||
has_insample_edge=mg.get("has_insample_edge"), is_hedge=mg.get("is_hedge"),
|
||||
robust_oos=mg.get("robust_oos"), multicut=mg.get("multicut_persistent"),
|
||||
clean_year_uplift=mg.get("clean_year_uplift"),
|
||||
w25_uplift_hold=w25.get("uplift_hold"),
|
||||
earns_slot=earns_slot, beats_winner=beats_winner,
|
||||
)
|
||||
print("JSON " + json.dumps(out, default=str))
|
||||
@@ -0,0 +1,102 @@
|
||||
"""SKH_P_CHANDE — Chande-window sweep on the V1 base.
|
||||
|
||||
TASK: on the V1 base, sweep n_vola and n_volume (the Chande momentum windows that drive
|
||||
BuzVola=Chande01(ATR) and BuzVolume=Chande01(volume)) in {8,13,21,34,55}. Does a different
|
||||
vol/volume CYCLE window help the REGIME gate out-of-sample? Maximize min HOLD-OUT Sharpe.
|
||||
|
||||
V1 reference: SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)
|
||||
with n_vola=n_volume=13 (defaults) -> minFull +0.69, minHold +0.64 (BTC 0.64 / ETH 0.64).
|
||||
|
||||
We keep EVERYTHING from V1 fixed (bands, pattern, exits) and vary only the two Chande windows.
|
||||
Rank by min HOLD-OUT subject to minFull>=0.5 and >=20 trades both assets, then plateau-check
|
||||
the winner (neighbors in the n_vola x n_volume grid must also be good), then full study +
|
||||
causality + marginal.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, itertools
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
|
||||
# V1 base: everything except the two Chande windows stays fixed.
|
||||
BASE = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)
|
||||
|
||||
WINDOWS = [8, 13, 21, 34, 55] # Fibonacci-ish cycle lengths for the Chande momentum
|
||||
|
||||
|
||||
def mk(n_vola, n_volume):
|
||||
return SkyhookParams(n_vola=n_vola, n_volume=n_volume, **BASE)
|
||||
|
||||
|
||||
def eval_combo(n_vola, n_volume):
|
||||
p = mk(n_vola, n_volume)
|
||||
res = {a: sk.run_asset(a, p, sk.FEE_RT) for a in ASSETS}
|
||||
min_full = min(res[a]["full"]["sharpe"] for a in ASSETS)
|
||||
min_hold = min(res[a]["holdout"]["sharpe"] for a in ASSETS)
|
||||
min_tr = min(res[a]["full"]["n_trades"] for a in ASSETS)
|
||||
max_dd = max(res[a]["full"]["maxdd"] for a in ASSETS)
|
||||
return dict(n_vola=n_vola, n_volume=n_volume,
|
||||
min_full=min_full, min_hold=min_hold, min_tr=min_tr, max_dd=max_dd,
|
||||
btc_hold=res["BTC"]["holdout"]["sharpe"], eth_hold=res["ETH"]["holdout"]["sharpe"],
|
||||
btc_full=res["BTC"]["full"]["sharpe"], eth_full=res["ETH"]["full"]["sharpe"])
|
||||
|
||||
|
||||
def main():
|
||||
rows = []
|
||||
combos = list(itertools.product(WINDOWS, WINDOWS))
|
||||
print(f"Sweeping {len(combos)} (n_vola x n_volume) combos x 2 assets = {len(combos)*2} run_asset calls")
|
||||
for nv, nvol in combos:
|
||||
rows.append(eval_combo(nv, nvol))
|
||||
|
||||
# V1 reference cell (n_vola=n_volume=13) for explicit comparison
|
||||
v1 = next(r for r in rows if r["n_vola"] == 13 and r["n_volume"] == 13)
|
||||
print(f"\nV1 cell (n_vola=13,n_volume=13): minF={v1['min_full']:+.2f} minH={v1['min_hold']:+.2f} "
|
||||
f"tr={v1['min_tr']} DD={v1['max_dd']*100:.0f}% (btcH={v1['btc_hold']:+.2f} ethH={v1['eth_hold']:+.2f})")
|
||||
|
||||
valid = [r for r in rows if r["min_full"] >= 0.5 and r["min_tr"] >= 20]
|
||||
valid.sort(key=lambda r: r["min_hold"], reverse=True)
|
||||
|
||||
print("\n=== ALL combos by min HOLD-OUT (minF>=0.5 & tr>=20 marked OK) ===")
|
||||
print(f"{'n_vola':>7}{'n_vol':>6} {'minF':>6}{'minH':>6}{'minTr':>6}{'maxDD':>7} {'btcH':>6}{'ethH':>6} ok")
|
||||
for r in sorted(rows, key=lambda r: r["min_hold"], reverse=True):
|
||||
ok = "OK" if (r["min_full"] >= 0.5 and r["min_tr"] >= 20) else "."
|
||||
mark = " <-V1" if (r["n_vola"] == 13 and r["n_volume"] == 13) else ""
|
||||
print(f"{r['n_vola']:>7}{r['n_volume']:>6} {r['min_full']:>6.2f}{r['min_hold']:>6.2f}"
|
||||
f"{r['min_tr']:>6}{r['max_dd']*100:>6.0f}% {r['btc_hold']:>6.2f}{r['eth_hold']:>6.2f} {ok}{mark}")
|
||||
|
||||
if not valid:
|
||||
print("\nNo valid combo (minFull>=0.5 & >=20 trades). Best raw by minHold:")
|
||||
print(sorted(rows, key=lambda r: r["min_hold"], reverse=True)[0])
|
||||
return
|
||||
|
||||
top = valid[0]
|
||||
print(f"\n=== WINNER: n_vola={top['n_vola']} n_volume={top['n_volume']} ===")
|
||||
print(f" minFull={top['min_full']:+.2f} minHold={top['min_hold']:+.2f} minTr={top['min_tr']} maxDD={top['max_dd']*100:.0f}%")
|
||||
|
||||
# plateau grid (full minHold table laid out as n_vola rows x n_volume cols)
|
||||
def find(nv, nvol):
|
||||
return next((r for r in rows if r["n_vola"] == nv and r["n_volume"] == nvol), None)
|
||||
print("\n Plateau grid (minHold; rows=n_vola, cols=n_volume):")
|
||||
print(" " + "".join(f"{nvol:>7}" for nvol in WINDOWS))
|
||||
for nv in WINDOWS:
|
||||
cells = []
|
||||
for nvol in WINDOWS:
|
||||
r = find(nv, nvol)
|
||||
tag = "*" if (nv == top['n_vola'] and nvol == top['n_volume']) else " "
|
||||
cells.append(f"{r['min_hold']:>6.2f}{tag}")
|
||||
print(f" nv={nv:>3} " + "".join(cells))
|
||||
|
||||
# final study + causality + marginal on the winner
|
||||
p = mk(top['n_vola'], top['n_volume'])
|
||||
print("\n=== STUDY (winner) ===")
|
||||
rep = sk.study(f"SKH_P_CHANDE_nv{top['n_vola']}_nvol{top['n_volume']}", p)
|
||||
print(sk.fmt(rep))
|
||||
print("\ncausality:", sk.causality(p))
|
||||
print("\nmarginal:", sk.marginal(p))
|
||||
print("\nas_json:", sk.as_json(rep))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""SKH_P_EXITBARS — sweep the asymmetric time-exit horizons on the V1 base.
|
||||
|
||||
V1 base: SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)
|
||||
defaults uscitalong=24, uscitashort=18 -> minFull +0.69, HOLD +0.64 (BTC 0.64/ETH 0.64),
|
||||
DD ~40-49% (HIGH).
|
||||
|
||||
The asymmetry (long held longer than short) is core to Skyhook. Sweep:
|
||||
uscitalong in {16,20,24,30,40} (LTF 230m bars max-hold for longs)
|
||||
uscitashort in {10,14,18,24} (LTF 230m bars max-hold for shorts)
|
||||
|
||||
Objective (priority): maximize min-asset HOLD-OUT subject to minFull>=0.5, minTrades>=20 BOTH
|
||||
assets, fee survives 0.30%RT, causality ok. Secondary: cut standalone DD toward <30%.
|
||||
Compare to V1 (minHold +0.64, DD ~40-49%).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
# V1 base (everything except the two exit horizons we sweep)
|
||||
BASE = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
UL_GRID = [16, 20, 24, 30, 40] # uscitalong
|
||||
US_GRID = [10, 14, 18, 24] # uscitashort
|
||||
|
||||
|
||||
def cell(ul, us):
|
||||
p = SkyhookParams(uscitalong=ul, uscitashort=us, **BASE)
|
||||
out = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
out[a] = sk.run_asset(a, p, fee_rt=sk.FEE_RT)
|
||||
min_full = min(out[a]["full"]["sharpe"] for a in out)
|
||||
min_hold = min(out[a]["holdout"]["sharpe"] for a in out)
|
||||
min_tr = min(out[a]["full"]["n_trades"] for a in out)
|
||||
max_dd = max(out[a]["full"]["maxdd"] for a in out)
|
||||
return dict(ul=ul, us=us, min_full=min_full, min_hold=min_hold,
|
||||
min_tr=min_tr, max_dd=max_dd,
|
||||
btc_full=out["BTC"]["full"]["sharpe"], eth_full=out["ETH"]["full"]["sharpe"],
|
||||
btc_hold=out["BTC"]["holdout"]["sharpe"], eth_hold=out["ETH"]["holdout"]["sharpe"],
|
||||
btc_dd=out["BTC"]["full"]["maxdd"], eth_dd=out["ETH"]["full"]["maxdd"])
|
||||
|
||||
|
||||
print("=== SKH_P_EXITBARS sweep (V1 base ptn_n=55 sl2.5 tp6) — fee=0.10%RT ===")
|
||||
print(f"{'uL':>4} {'uS':>4} | {'minFull':>7} {'minHold':>7} {'minTr':>5} {'maxDD':>6} | "
|
||||
f"{'btcF':>5} {'ethF':>5} {'btcH':>5} {'ethH':>5} {'btcDD':>5} {'ethDD':>5}")
|
||||
results = []
|
||||
for ul in UL_GRID:
|
||||
for us in US_GRID:
|
||||
c = cell(ul, us)
|
||||
results.append(c)
|
||||
flag = " *" if (c["min_full"] >= 0.5 and c["min_tr"] >= 20) else ""
|
||||
marker = " <V1" if (ul == 24 and us == 18) else ""
|
||||
print(f"{ul:>4} {us:>4} | {c['min_full']:>+7.2f} {c['min_hold']:>+7.2f} "
|
||||
f"{c['min_tr']:>5} {c['max_dd']*100:>5.0f}% | "
|
||||
f"{c['btc_full']:>+5.2f} {c['eth_full']:>+5.2f} "
|
||||
f"{c['btc_hold']:>+5.2f} {c['eth_hold']:>+5.2f} "
|
||||
f"{c['btc_dd']*100:>4.0f}% {c['eth_dd']*100:>4.0f}%{flag}{marker}")
|
||||
|
||||
# Eligible = minFull>=0.5 AND minTrades>=20. Rank by min_hold, tie-break lower maxDD.
|
||||
elig = [c for c in results if c["min_full"] >= 0.5 and c["min_tr"] >= 20]
|
||||
print(f"\nEligible cells (minFull>=0.5, minTr>=20): {len(elig)}")
|
||||
if not elig:
|
||||
print("No eligible cell — V1 exit horizons may already be at/near the frontier.")
|
||||
sys.exit(0)
|
||||
|
||||
elig_hold = sorted(elig, key=lambda c: (-round(c["min_hold"], 3), c["max_dd"]))
|
||||
print("Top by minHold (tie-break lower maxDD):")
|
||||
for c in elig_hold[:6]:
|
||||
print(f" uL={c['ul']} uS={c['us']}: minHold={c['min_hold']:+.2f} "
|
||||
f"minFull={c['min_full']:+.2f} maxDD={c['max_dd']*100:.0f}% minTr={c['min_tr']}")
|
||||
|
||||
dd_cands = sorted(elig, key=lambda c: (c["max_dd"], -round(c["min_hold"], 3)))
|
||||
print("\nTop by lowest maxDD (DD-cut objective):")
|
||||
for c in dd_cands[:6]:
|
||||
print(f" uL={c['ul']} uS={c['us']}: maxDD={c['max_dd']*100:.0f}% "
|
||||
f"minHold={c['min_hold']:+.2f} minFull={c['min_full']:+.2f} minTr={c['min_tr']}")
|
||||
|
||||
best = elig_hold[0]
|
||||
print(f"\n=== STUDY on best-by-minHold (uL={best['ul']} uS={best['us']}) ===")
|
||||
pbest = SkyhookParams(uscitalong=best["ul"], uscitashort=best["us"], **BASE)
|
||||
rep = sk.study(f"P_EXITBARS_uL{best['ul']}_uS{best['us']}", pbest)
|
||||
print(sk.fmt(rep))
|
||||
caus = sk.causality(pbest)
|
||||
print("causality:", caus)
|
||||
mg = sk.marginal(pbest)
|
||||
print("marginal:", {k: v for k, v in mg.items()
|
||||
if k in ("corr_full", "marginal_verdict", "has_insample_edge",
|
||||
"is_hedge", "robust_oos")})
|
||||
print("blend w25 uplift_hold:", mg.get("blends", {}).get("w25", {}).get("uplift_hold"))
|
||||
print("\nAS_JSON_STUDY:", sk.as_json(rep))
|
||||
|
||||
# If the DD-cut frontier differs from the headline pick, study it too (cheap, one config).
|
||||
ddbest = dd_cands[0]
|
||||
if (ddbest["ul"], ddbest["us"]) != (best["ul"], best["us"]) and ddbest["min_hold"] >= 0.2:
|
||||
print(f"\n=== STUDY on lowest-DD eligible (uL={ddbest['ul']} uS={ddbest['us']}) ===")
|
||||
pdd = SkyhookParams(uscitalong=ddbest["ul"], uscitashort=ddbest["us"], **BASE)
|
||||
repdd = sk.study(f"P_EXITBARS_DDcut_uL{ddbest['ul']}_uS{ddbest['us']}", pdd)
|
||||
print(sk.fmt(repdd))
|
||||
print("causality:", sk.causality(pdd))
|
||||
@@ -0,0 +1,76 @@
|
||||
"""SKH_P_LOCAL — coordinate/local search around SKH01-V1.
|
||||
|
||||
V1: SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)
|
||||
-> minFull +0.69, minHold +0.64 (BTC0.64/ETH0.64), maxDD ~49% (BTC), clean_year(2025)=0.014
|
||||
robust_oos=False ONLY because clean_year_uplift 0.014 < 0.02.
|
||||
|
||||
GOAL: (a) push 2025 clean-year uplift > 0.02 (-> robust_oos True, fully earns slot),
|
||||
(b) cut DD toward <35%, keeping minHold>=0.5, minFull>=0.5, fee survives 0.30%RT, >=20 trades.
|
||||
|
||||
Strategy: V1's 2025 is weak (BTC+2/ETH-2). Cleaner regime gating + tighter SL can both lift the
|
||||
2025 contribution AND cut the BTC DD. Local coordinate sweep on the high-leverage knobs, each near V1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
V1 = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
def evalp(**over):
|
||||
p = SkyhookParams(**{**V1, **over})
|
||||
out = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
out[a] = sk.run_asset(a, p, fee_rt=sk.FEE_RT)
|
||||
min_full = min(out[a]["full"]["sharpe"] for a in out)
|
||||
min_hold = min(out[a]["holdout"]["sharpe"] for a in out)
|
||||
min_tr = min(out[a]["full"]["n_trades"] for a in out)
|
||||
max_dd = max(out[a]["full"]["maxdd"] for a in out)
|
||||
return dict(p=p, over=over, min_full=min_full, min_hold=min_hold, min_tr=min_tr, max_dd=max_dd,
|
||||
btc_dd=out["BTC"]["full"]["maxdd"], eth_dd=out["ETH"]["full"]["maxdd"],
|
||||
btc_h=out["BTC"]["holdout"]["sharpe"], eth_h=out["ETH"]["holdout"]["sharpe"])
|
||||
|
||||
def row(tag, r):
|
||||
elig = (r["min_full"] >= 0.5 and r["min_tr"] >= 20)
|
||||
print(f"{tag:<28} minFull={r['min_full']:+.2f} minHold={r['min_hold']:+.2f} "
|
||||
f"maxDD={r['max_dd']*100:>3.0f}% (btc{r['btc_dd']*100:.0f}/eth{r['eth_dd']*100:.0f}) "
|
||||
f"minTr={r['min_tr']:>3} {'OK' if elig else 'x'}")
|
||||
return r
|
||||
|
||||
print("=== SKH_P_LOCAL coordinate search around V1 (fee 0.10%RT) ===")
|
||||
base = row("V1", evalp())
|
||||
cands = [("V1", base)]
|
||||
|
||||
# Axis 1: SL tighter to cut DD (V1 sl=2.5). Lower sl -> lower DD, but may cut hold.
|
||||
for sl in (1.75, 2.0, 2.25, 2.5):
|
||||
for tp in (5.0, 6.0, 7.0):
|
||||
cands.append((f"sl{sl}_tp{tp}", row(f"sl{sl}_tp{tp}", evalp(sl_atr=sl, tp_atr=tp))))
|
||||
|
||||
# Axis 2: regime band — tighten vola top (avoid blow-off) & raise vola_lo to skip dead vol.
|
||||
for vlo, vhi in ((40,90),(45,90),(40,85),(35,90),(45,85),(50,90)):
|
||||
cands.append((f"vola{vlo}-{vhi}", row(f"vola{vlo}-{vhi}", evalp(vola_lo=float(vlo), vola_hi=float(vhi)))))
|
||||
|
||||
# Axis 3: add a volume floor (V1 vol_lo=0 = no vol gate). A floor concentrates into live regimes.
|
||||
for vol_lo in (30.0, 40.0, 50.0):
|
||||
cands.append((f"vol_lo{vol_lo}", row(f"vol_lo{vol_lo}", evalp(vol_lo=vol_lo))))
|
||||
|
||||
# Axis 4: ptn_n around 55.
|
||||
for ptn in (45, 50, 60, 65):
|
||||
cands.append((f"ptn{ptn}", row(f"ptn{ptn}", evalp(ptn_n=ptn))))
|
||||
|
||||
# Axis 5: exit bars (asymmetry).
|
||||
for ul, us in ((24,18),(30,18),(20,14),(28,14)):
|
||||
cands.append((f"ex{ul}/{us}", row(f"ex{ul}/{us}", evalp(uscitalong=ul, uscitashort=us))))
|
||||
|
||||
# Filter eligible (the constraints), rank by min_hold then lower DD.
|
||||
elig = [(t,r) for (t,r) in cands if r["min_full"] >= 0.5 and r["min_tr"] >= 20 and r["min_hold"] >= 0.5]
|
||||
print(f"\nEligible (minFull>=0.5, minHold>=0.5, minTr>=20): {len(elig)}")
|
||||
elig.sort(key=lambda tr: (-round(tr[1]["min_hold"],3), tr[1]["max_dd"]))
|
||||
for t,r in elig[:10]:
|
||||
print(f" {t:<22} minHold={r['min_hold']:+.2f} minFull={r['min_full']:+.2f} maxDD={r['max_dd']*100:.0f}% over={r['over']}")
|
||||
|
||||
# Low-DD subset
|
||||
print("\nLowest-DD eligible:")
|
||||
for t,r in sorted(elig, key=lambda tr:(tr[1]["max_dd"], -tr[1]["min_hold"]))[:8]:
|
||||
print(f" {t:<22} maxDD={r['max_dd']*100:.0f}% minHold={r['min_hold']:+.2f} minFull={r['min_full']:+.2f} over={r['over']}")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""SKH_P_LOCAL2 — refine: combine the two winning axes (ptn_n DD-cut + sl/tp hold-lift)
|
||||
and CHECK MARGINAL clean-year(2025) uplift on the top few, since that is the true gate
|
||||
(robust_oos requires clean_year_uplift>0.02 AND multicut_persistent)."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
V1 = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
def evalp(**over):
|
||||
p = SkyhookParams(**{**V1, **over})
|
||||
out = {a: sk.run_asset(a, p, fee_rt=sk.FEE_RT) for a in ("BTC","ETH")}
|
||||
return dict(p=p, over=over,
|
||||
min_full=min(out[a]["full"]["sharpe"] for a in out),
|
||||
min_hold=min(out[a]["holdout"]["sharpe"] for a in out),
|
||||
min_tr=min(out[a]["full"]["n_trades"] for a in out),
|
||||
max_dd=max(out[a]["full"]["maxdd"] for a in out),
|
||||
btc_dd=out["BTC"]["full"]["maxdd"], eth_dd=out["ETH"]["full"]["maxdd"])
|
||||
|
||||
def row(tag, r):
|
||||
elig = (r["min_full"]>=0.5 and r["min_tr"]>=20 and r["min_hold"]>=0.5)
|
||||
print(f"{tag:<26} minFull={r['min_full']:+.2f} minHold={r['min_hold']:+.2f} "
|
||||
f"maxDD={r['max_dd']*100:>3.0f}% (b{r['btc_dd']*100:.0f}/e{r['eth_dd']*100:.0f}) "
|
||||
f"minTr={r['min_tr']:>3} {'OK' if elig else 'x'}")
|
||||
return r
|
||||
|
||||
print("=== SKH_P_LOCAL2 combine ptn_n x sl/tp (fee 0.10%RT) ===")
|
||||
cands=[]
|
||||
for ptn in (40, 45, 48):
|
||||
for sl,tp in ((2.0,6.0),(2.0,7.0),(2.25,6.0),(2.25,7.0),(2.5,7.0)):
|
||||
over=dict(ptn_n=ptn, sl_atr=sl, tp_atr=tp)
|
||||
cands.append((f"ptn{ptn}_sl{sl}_tp{tp}", row(f"ptn{ptn}_sl{sl}_tp{tp}", evalp(**over))))
|
||||
# also ptn45 with exit-bar asymmetry that lifted hold
|
||||
for ul,us in ((30,18),(24,18)):
|
||||
over=dict(ptn_n=45, uscitalong=ul, uscitashort=us)
|
||||
cands.append((f"ptn45_ex{ul}/{us}", row(f"ptn45_ex{ul}/{us}", evalp(**over))))
|
||||
|
||||
elig=[(t,r) for (t,r) in cands if r["min_full"]>=0.5 and r["min_tr"]>=20 and r["min_hold"]>=0.5]
|
||||
elig.sort(key=lambda tr:(tr[1]["max_dd"], -round(tr[1]["min_hold"],3)))
|
||||
print(f"\nEligible: {len(elig)} (sorted by lowest DD)")
|
||||
for t,r in elig:
|
||||
print(f" {t:<24} maxDD={r['max_dd']*100:.0f}% minHold={r['min_hold']:+.2f} minFull={r['min_full']:+.2f} over={r['over']}")
|
||||
|
||||
# Check MARGINAL clean-year uplift on the lowest-DD eligible + the best-hold eligible.
|
||||
def marg_check(tag, over):
|
||||
p = SkyhookParams(**{**V1, **over})
|
||||
mg = sk.marginal(p)
|
||||
print(f"\n--- MARGINAL {tag} over={over} ---")
|
||||
print(f" verdict={mg.get('marginal_verdict')} corr_full={mg.get('corr_full')} "
|
||||
f"robust_oos={mg.get('robust_oos')} multicut_persistent={mg.get('multicut_persistent')}")
|
||||
print(f" clean_year_uplift={mg.get('clean_year_uplift')} jackknife_min={mg.get('jackknife_min_uplift')} "
|
||||
f"has_insample_edge={mg.get('has_insample_edge')} is_hedge={mg.get('is_hedge')}")
|
||||
print(f" blend w25 uplift_hold={mg.get('blends',{}).get('w25',{}).get('uplift_hold')} "
|
||||
f"uplift_full={mg.get('blends',{}).get('w25',{}).get('uplift_full')}")
|
||||
return mg
|
||||
|
||||
# pick up to 4 distinct configs to marginal-check
|
||||
seen=set(); checked=0
|
||||
for t,r in elig:
|
||||
key=tuple(sorted(r["over"].items()))
|
||||
if key in seen: continue
|
||||
seen.add(key); marg_check(t, r["over"]); checked+=1
|
||||
if checked>=5: break
|
||||
@@ -0,0 +1,48 @@
|
||||
"""SKH_P_LOCAL_final — full study + causality + marginal on the top local-search winners,
|
||||
plus a small extra pass trying to push DD<35% while keeping minHold high (ptn45 + tighter exits)."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
V1 = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
def evalp(**over):
|
||||
p = SkyhookParams(**{**V1, **over})
|
||||
out = {a: sk.run_asset(a, p, fee_rt=sk.FEE_RT) for a in ("BTC","ETH")}
|
||||
return dict(over=over,
|
||||
min_full=min(out[a]["full"]["sharpe"] for a in out),
|
||||
min_hold=min(out[a]["holdout"]["sharpe"] for a in out),
|
||||
min_tr=min(out[a]["full"]["n_trades"] for a in out),
|
||||
max_dd=max(out[a]["full"]["maxdd"] for a in out))
|
||||
|
||||
# Small extra: ptn45 + tp7 + tighter SL or exit bars to chase DD<35 with hold>=0.5
|
||||
print("=== extra DD-chase around ptn45 ===")
|
||||
for over in (dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16),
|
||||
dict(ptn_n=45, sl_atr=2.25, tp_atr=7.0, uscitalong=24, uscitashort=16),
|
||||
dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, vola_lo=40.0),
|
||||
dict(ptn_n=45, sl_atr=2.5, tp_atr=8.0)):
|
||||
r=evalp(**over)
|
||||
print(f" {over} -> minFull={r['min_full']:+.2f} minHold={r['min_hold']:+.2f} maxDD={r['max_dd']*100:.0f}% minTr={r['min_tr']}")
|
||||
|
||||
# WINNER candidates -> full study
|
||||
WINNERS = {
|
||||
"P_LOCAL_ptn45_sl2.5_tp7.0": dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0), # best balanced (DD36, Full0.83, Hold0.69)
|
||||
"P_LOCAL_ptn45_sl2.25_tp7.0": dict(ptn_n=45, sl_atr=2.25, tp_atr=7.0), # best hold/clean-year (DD36, Hold0.77)
|
||||
}
|
||||
for name, over in WINNERS.items():
|
||||
p = SkyhookParams(**{**V1, **over})
|
||||
print(f"\n################ STUDY {name} over={over} ################")
|
||||
rep = sk.study(name, p)
|
||||
print(sk.fmt(rep))
|
||||
print("causality:", sk.causality(p))
|
||||
mg = sk.marginal(p)
|
||||
keys=("corr_full","corr_hold","marginal_verdict","has_insample_edge","is_hedge",
|
||||
"robust_oos","multicut_persistent","clean_year_uplift","jackknife_min_uplift",
|
||||
"cand_insample_sharpe","cand_full_sharpe")
|
||||
print("marginal:", {k: mg.get(k) for k in keys})
|
||||
print("blend w25 uplift_hold:", mg.get("blends",{}).get("w25",{}).get("uplift_hold"),
|
||||
"uplift_full:", mg.get("blends",{}).get("w25",{}).get("uplift_full"))
|
||||
print("multicut:", mg.get("multicut_uplift"))
|
||||
print("AS_JSON:", sk.as_json(rep))
|
||||
@@ -0,0 +1,18 @@
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
V1 = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
print("=== V1 reference ===")
|
||||
rep = sk.study("SKH01-V1", V1)
|
||||
print(sk.fmt(rep))
|
||||
print("causality:", sk.causality(V1))
|
||||
mg = sk.marginal(V1)
|
||||
keys = ("corr_full","corr_hold","marginal_verdict","has_insample_edge","is_hedge",
|
||||
"robust_oos","multicut_persistent","clean_year_uplift","jackknife_min_uplift",
|
||||
"cand_insample_sharpe","cand_full_sharpe")
|
||||
print("marginal:", {k: mg.get(k) for k in keys})
|
||||
print("blend w25 uplift_hold:", mg.get("blends",{}).get("w25",{}).get("uplift_hold"))
|
||||
print("blend w25 uplift_full:", mg.get("blends",{}).get("w25",{}).get("uplift_full"))
|
||||
print("multicut:", mg.get("multicut_uplift"))
|
||||
@@ -0,0 +1,26 @@
|
||||
"""SKH_P_LOCAL winner: ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16
|
||||
(rest = V1). Beats V1 on DD (34% vs 49%), minHold (+0.81 vs +0.64), minFull (+0.83 vs +0.69),
|
||||
and pushes clean-year(2025) uplift well over 0.02 -> robust_oos True (fully earns a slot)."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
V1 = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
WIN = SkyhookParams(**{**V1, **dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16)})
|
||||
|
||||
rep = sk.study("SKH_P_LOCAL_winner", WIN)
|
||||
print(sk.fmt(rep))
|
||||
print("causality:", sk.causality(WIN, asset="BTC"), sk.causality(WIN, asset="ETH"))
|
||||
mg = sk.marginal(WIN)
|
||||
keys=("corr_full","corr_hold","marginal_verdict","has_insample_edge","is_hedge",
|
||||
"robust_oos","multicut_persistent","clean_year_uplift","jackknife_min_uplift",
|
||||
"cand_insample_sharpe","cand_full_sharpe","beta_to_tp01","alpha_ann")
|
||||
print("marginal:", {k: mg.get(k) for k in keys})
|
||||
print("blend w25 uplift_hold:", mg.get("blends",{}).get("w25",{}).get("uplift_hold"),
|
||||
"uplift_full:", mg.get("blends",{}).get("w25",{}).get("uplift_full"))
|
||||
print("blend w50:", mg.get("blends",{}).get("w50"))
|
||||
print("multicut:", mg.get("multicut_uplift"))
|
||||
v=rep["verdict"]
|
||||
print("\nVERDICT:", v)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""SKH_P_PTN (FAMILY=param)
|
||||
On the SKH01-V1 base, sweep ptn_n in {34,45,55,70,89,110} x atr_win in {10,14,21}.
|
||||
Slower Donchian breakouts may generalize better OOS. Maximize min-asset HOLD-OUT
|
||||
subject to minFull>=0.5, fee survives 0.30%RT, >=20 trades BOTH assets, causality ok.
|
||||
Note standalone DD. Always compare vs V1 (ptn_n=55, atr_win=14).
|
||||
"""
|
||||
import sys
|
||||
import itertools
|
||||
from dataclasses import replace
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
# SKH01-V1 reference base
|
||||
V1 = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)
|
||||
|
||||
|
||||
def quick(p: SkyhookParams) -> dict:
|
||||
rs = {a: sk.run_asset(a, p, sk.FEE_RT) for a in sk.CERTIFIED}
|
||||
mf = min(rs[a]["full"]["sharpe"] for a in rs)
|
||||
mh = min(rs[a]["holdout"]["sharpe"] for a in rs)
|
||||
mt = min(rs[a]["full"]["n_trades"] for a in rs)
|
||||
avg_dd = sum(rs[a]["full"]["maxdd"] for a in rs) / 2
|
||||
return dict(minFull=mf, minHold=mh, minTr=mt, dd=round(avg_dd, 4),
|
||||
btc=rs["BTC"]["full"]["sharpe"], eth=rs["ETH"]["full"]["sharpe"],
|
||||
btcH=rs["BTC"]["holdout"]["sharpe"], ethH=rs["ETH"]["holdout"]["sharpe"],
|
||||
btcDD=rs["BTC"]["full"]["maxdd"], ethDD=rs["ETH"]["full"]["maxdd"])
|
||||
|
||||
|
||||
PTN_GRID = (34, 45, 55, 70, 89, 110)
|
||||
ATR_GRID = (10, 14, 21)
|
||||
|
||||
print("=== SKH_P_PTN sweep: ptn_n x atr_win on SKH01-V1 base ===")
|
||||
qv1 = quick(V1)
|
||||
print(f"V1 (ptn55/atr14): minF={qv1['minFull']:+.2f} minH={qv1['minHold']:+.2f} "
|
||||
f"btc/eth F={qv1['btc']:+.2f}/{qv1['eth']:+.2f} H={qv1['btcH']:+.2f}/{qv1['ethH']:+.2f} "
|
||||
f"tr={qv1['minTr']} dd~{qv1['dd']*100:.0f}% (btc{qv1['btcDD']*100:.0f}/eth{qv1['ethDD']*100:.0f})")
|
||||
print("-" * 108)
|
||||
print(f"{'ptn':>4s}{'atr':>4s} {'minF':>6s}{'minH':>6s} {'btcF/ethF':>13s} {'btcH/ethH':>13s} "
|
||||
f"{'tr':>4s} {'avgDD':>6s} {'btcDD/ethDD':>12s} gate")
|
||||
|
||||
rows = []
|
||||
for ptn_n, atr_win in itertools.product(PTN_GRID, ATR_GRID):
|
||||
p = replace(V1, ptn_n=ptn_n, atr_win=atr_win)
|
||||
q = quick(p)
|
||||
# gate per task: minFull>=0.5 AND minHold>=0.2 AND minTr>=20
|
||||
gate = (q["minFull"] >= 0.5 and q["minHold"] >= 0.2 and q["minTr"] >= 20)
|
||||
rows.append((q["minHold"], q["minFull"], q["minTr"], q["dd"], ptn_n, atr_win, q, gate))
|
||||
tag = "PASS" if gate else ""
|
||||
print(f"{ptn_n:>4d}{atr_win:>4d} {q['minFull']:>+6.2f}{q['minHold']:>+6.2f} "
|
||||
f"{q['btc']:>+5.2f}/{q['eth']:>+5.2f} {q['btcH']:>+5.2f}/{q['ethH']:>+5.2f} "
|
||||
f"{q['minTr']:>4d} {q['dd']*100:>5.0f}% {q['btcDD']*100:>4.0f}/{q['ethDD']*100:>4.0f}% {tag}")
|
||||
|
||||
# winner = max min-asset HOLD-OUT among gate-passers (minFull>=0.5, minTr>=20); fallback best minHold
|
||||
passers = [r for r in rows if r[7]]
|
||||
pool = passers if passers else [r for r in rows if r[1] >= 0.5 and r[2] >= 20]
|
||||
if not pool:
|
||||
pool = rows
|
||||
# rank by minHold, tiebreak lower avgDD then higher minFull
|
||||
pool.sort(key=lambda r: (r[0], -r[3], r[1]), reverse=True)
|
||||
best = pool[0]
|
||||
b_ptn, b_atr = best[4], best[5]
|
||||
print("-" * 108)
|
||||
print(f"WINNER: ptn_n={b_ptn} atr_win={b_atr} minH={best[0]:+.2f} minF={best[1]:+.2f} "
|
||||
f"tr={best[2]} avgDD={best[3]*100:.0f}%")
|
||||
|
||||
# Full study + causality + marginal on winner (and re-confirm V1 alongside)
|
||||
WIN = replace(V1, ptn_n=b_ptn, atr_win=b_atr)
|
||||
print("\n=== STUDY winner ===")
|
||||
rep = sk.study(f"SKH_P_PTN ptn{b_ptn}/atr{b_atr}", WIN)
|
||||
print(sk.fmt(rep))
|
||||
caus = sk.causality(WIN, "BTC")
|
||||
caus_eth = sk.causality(WIN, "ETH")
|
||||
print(f"causality BTC: {caus} ETH: {caus_eth}")
|
||||
mg = sk.marginal(WIN)
|
||||
print(f"marginal: corr_full={mg.get('corr_full')} "
|
||||
f"blend_w25_uplift_hold={mg.get('blends', {}).get('w25', {}).get('uplift_hold')} "
|
||||
f"verdict={mg.get('marginal_verdict')} has_insample_edge={mg.get('has_insample_edge')} "
|
||||
f"is_hedge={mg.get('is_hedge')}")
|
||||
print("\nJSON_STUDY:", sk.as_json(rep))
|
||||
print("MARGINAL:", mg)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""SKH_P_REGIME — regime-band sweep on the V1 base.
|
||||
|
||||
Search bands: vola_lo in {20,30,35,45}, vola_hi in {88,95,100},
|
||||
vol_lo in {0,30,45,55}, vol_hi in {80,100}.
|
||||
Find the combo that lifts min HOLD-OUT and is a PLATEAU (neighbors also good).
|
||||
Compare to V1: SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)
|
||||
-> minFull +0.69, minHold +0.64.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, itertools
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
|
||||
# V1 base (everything except the regime bands stays fixed)
|
||||
BASE = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0)
|
||||
|
||||
VOLA_LO = [20, 30, 35, 45]
|
||||
VOLA_HI = [88, 95, 100]
|
||||
VOL_LO = [0, 30, 45, 55]
|
||||
VOL_HI = [80, 100]
|
||||
|
||||
|
||||
def mk(vlo, vhi, vol_lo, vol_hi):
|
||||
return SkyhookParams(vola_lo=vlo, vola_hi=vhi, vol_lo=vol_lo, vol_hi=vol_hi, **BASE)
|
||||
|
||||
|
||||
def eval_combo(vlo, vhi, vol_lo, vol_hi):
|
||||
p = mk(vlo, vhi, vol_lo, vol_hi)
|
||||
res = {}
|
||||
for a in ASSETS:
|
||||
r = sk.run_asset(a, p, sk.FEE_RT)
|
||||
res[a] = r
|
||||
min_full = min(res[a]["full"]["sharpe"] for a in ASSETS)
|
||||
min_hold = min(res[a]["holdout"]["sharpe"] for a in ASSETS)
|
||||
min_tr = min(res[a]["full"]["n_trades"] for a in ASSETS)
|
||||
max_dd = max(res[a]["full"]["maxdd"] for a in ASSETS)
|
||||
return dict(vlo=vlo, vhi=vhi, vol_lo=vol_lo, vol_hi=vol_hi,
|
||||
min_full=min_full, min_hold=min_hold, min_tr=min_tr, max_dd=max_dd,
|
||||
btc_hold=res["BTC"]["holdout"]["sharpe"], eth_hold=res["ETH"]["holdout"]["sharpe"],
|
||||
btc_full=res["BTC"]["full"]["sharpe"], eth_full=res["ETH"]["full"]["sharpe"])
|
||||
|
||||
|
||||
def main():
|
||||
rows = []
|
||||
combos = list(itertools.product(VOLA_LO, VOLA_HI, VOL_LO, VOL_HI))
|
||||
print(f"Sweeping {len(combos)} band combos x 2 assets = {len(combos)*2} run_asset calls")
|
||||
for vlo, vhi, vol_lo, vol_hi in combos:
|
||||
rows.append(eval_combo(vlo, vhi, vol_lo, vol_hi))
|
||||
|
||||
# rank by min HOLD-OUT (subject to minFull>=0.5 and >=20 trades both assets)
|
||||
valid = [r for r in rows if r["min_full"] >= 0.5 and r["min_tr"] >= 20]
|
||||
valid.sort(key=lambda r: r["min_hold"], reverse=True)
|
||||
|
||||
print("\n=== TOP 15 by min HOLD-OUT (minFull>=0.5, minTrades>=20) ===")
|
||||
print(f"{'vlo':>4}{'vhi':>5}{'vol_lo':>7}{'vol_hi':>7} {'minF':>6}{'minH':>6}{'minTr':>6}{'maxDD':>7} {'btcH':>6}{'ethH':>6}")
|
||||
for r in valid[:15]:
|
||||
print(f"{r['vlo']:>4}{r['vhi']:>5}{r['vol_lo']:>7}{r['vol_hi']:>7} "
|
||||
f"{r['min_full']:>6.2f}{r['min_hold']:>6.2f}{r['min_tr']:>6}{r['max_dd']*100:>6.0f}% "
|
||||
f"{r['btc_hold']:>6.2f}{r['eth_hold']:>6.2f}")
|
||||
|
||||
# full table sorted for plateau inspection (group by lo/hi neighbors)
|
||||
rows_sorted = sorted(rows, key=lambda r: r["min_hold"], reverse=True)
|
||||
print("\n=== ALL combos by min HOLD-OUT ===")
|
||||
for r in rows_sorted:
|
||||
flag = "" if (r["min_full"] >= 0.5 and r["min_tr"] >= 20) else " (low-full/trades)"
|
||||
print(f" vlo={r['vlo']:>3} vhi={r['vhi']:>3} vol_lo={r['vol_lo']:>3} vol_hi={r['vol_hi']:>3} | "
|
||||
f"minF={r['min_full']:+.2f} minH={r['min_hold']:+.2f} tr={r['min_tr']:>3} DD={r['max_dd']*100:.0f}%{flag}")
|
||||
|
||||
if not valid:
|
||||
print("\nNo valid combo (minFull>=0.5 & >=20 trades). Best raw:")
|
||||
print(rows_sorted[0])
|
||||
return
|
||||
|
||||
# plateau check: for the top combo, look at neighbors in the grid
|
||||
top = valid[0]
|
||||
print(f"\n=== WINNER: vlo={top['vlo']} vhi={top['vhi']} vol_lo={top['vol_lo']} vol_hi={top['vol_hi']} ===")
|
||||
print(f" minFull={top['min_full']:+.2f} minHold={top['min_hold']:+.2f} minTr={top['min_tr']} maxDD={top['max_dd']*100:.0f}%")
|
||||
|
||||
# neighbor plateau: same vol_lo/vol_hi, vary vola_lo/vola_hi to adjacent grid values
|
||||
def find(vlo, vhi, vol_lo, vol_hi):
|
||||
for r in rows:
|
||||
if r["vlo"]==vlo and r["vhi"]==vhi and r["vol_lo"]==vol_lo and r["vol_hi"]==vol_hi:
|
||||
return r
|
||||
return None
|
||||
print("\n Plateau neighbors (min HOLD-OUT):")
|
||||
for vlo in VOLA_LO:
|
||||
for vhi in VOLA_HI:
|
||||
r = find(vlo, vhi, top['vol_lo'], top['vol_hi'])
|
||||
if r:
|
||||
mark = " <-- WIN" if (vlo==top['vlo'] and vhi==top['vhi']) else ""
|
||||
print(f" vola_lo={vlo:>3} vola_hi={vhi:>3}: minH={r['min_hold']:+.2f} minF={r['min_full']:+.2f}{mark}")
|
||||
|
||||
# final study + causality + marginal on the winner
|
||||
p = mk(top['vlo'], top['vhi'], top['vol_lo'], top['vol_hi'])
|
||||
print("\n=== STUDY (winner) ===")
|
||||
rep = sk.study(f"SKH_P_REGIME_vlo{top['vlo']}_vhi{top['vhi']}_vollo{top['vol_lo']}_volhi{top['vol_hi']}", p)
|
||||
print(sk.fmt(rep))
|
||||
print("\ncausality:", sk.causality(p))
|
||||
print("\nmarginal:", sk.marginal(p))
|
||||
print("\nas_json:", sk.as_json(rep))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""SKH_P_REGIME_plateau — tight plateau probe around the sweep winner
|
||||
vola_lo=20, vola_hi=88, vol_lo=55, vol_hi=80 (V1 base: ptn_n=55, sl_atr=2.5, tp_atr=6.0).
|
||||
|
||||
Confirm neighbors in ALL 4 band dims are also good (no knife-edge).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
BASE = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0)
|
||||
WIN = dict(vola_lo=20, vola_hi=88, vol_lo=55, vol_hi=80)
|
||||
|
||||
|
||||
def ev(**bands):
|
||||
p = SkyhookParams(**{**BASE, **bands})
|
||||
res = {a: sk.run_asset(a, p, sk.FEE_RT) for a in ASSETS}
|
||||
mf = min(res[a]["full"]["sharpe"] for a in ASSETS)
|
||||
mh = min(res[a]["holdout"]["sharpe"] for a in ASSETS)
|
||||
tr = min(res[a]["full"]["n_trades"] for a in ASSETS)
|
||||
dd = max(res[a]["full"]["maxdd"] for a in ASSETS)
|
||||
return mf, mh, tr, dd
|
||||
|
||||
|
||||
def probe(dim, values):
|
||||
print(f"\n-- perturb {dim} (others at winner) --")
|
||||
for v in values:
|
||||
bands = dict(WIN); bands[dim] = v
|
||||
mf, mh, tr, dd = ev(**bands)
|
||||
mark = " <-- WIN" if v == WIN[dim] else ""
|
||||
print(f" {dim}={v:>4}: minF={mf:+.2f} minH={mh:+.2f} tr={tr:>3} DD={dd*100:.0f}%{mark}")
|
||||
|
||||
|
||||
def main():
|
||||
mf, mh, tr, dd = ev(**WIN)
|
||||
print(f"WINNER {WIN}: minF={mf:+.2f} minH={mh:+.2f} tr={tr} DD={dd*100:.0f}%")
|
||||
probe("vola_lo", [15, 20, 25, 30])
|
||||
probe("vola_hi", [83, 85, 88, 90, 92])
|
||||
probe("vol_lo", [45, 50, 55, 60, 65])
|
||||
probe("vol_hi", [75, 78, 80, 82, 85])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""SKH_P_RR — fine-sweep reward:risk on the ptn_n=55 V1 base.
|
||||
|
||||
V1 base: SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)
|
||||
-> minFull +0.69, HOLD +0.64 (BTC 0.64 / ETH 0.64), DD ~40-49% (HIGH).
|
||||
|
||||
Sweep: sl_atr in {2.0,2.25,2.5,2.75,3.0,3.5} x tp_atr in {5,6,7,8,9,10}.
|
||||
Objective: maximize min-asset HOLD-OUT subject to minFull>=0.5, cut DD. Report best + plateau.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
BASE = dict(ptn_n=55, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
SL_GRID = [2.0, 2.25, 2.5, 2.75, 3.0, 3.5]
|
||||
TP_GRID = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
|
||||
|
||||
def cell(sl, tp):
|
||||
p = SkyhookParams(sl_atr=sl, tp_atr=tp, **BASE)
|
||||
out = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
r = sk.run_asset(a, p, fee_rt=sk.FEE_RT)
|
||||
out[a] = r
|
||||
min_full = min(out[a]["full"]["sharpe"] for a in out)
|
||||
min_hold = min(out[a]["holdout"]["sharpe"] for a in out)
|
||||
min_tr = min(out[a]["full"]["n_trades"] for a in out)
|
||||
max_dd = max(out[a]["full"]["maxdd"] for a in out)
|
||||
return dict(sl=sl, tp=tp, min_full=min_full, min_hold=min_hold,
|
||||
min_tr=min_tr, max_dd=max_dd,
|
||||
btc_full=out["BTC"]["full"]["sharpe"], eth_full=out["ETH"]["full"]["sharpe"],
|
||||
btc_hold=out["BTC"]["holdout"]["sharpe"], eth_hold=out["ETH"]["holdout"]["sharpe"],
|
||||
btc_dd=out["BTC"]["full"]["maxdd"], eth_dd=out["ETH"]["full"]["maxdd"])
|
||||
|
||||
print("=== SKH_P_RR sweep (ptn_n=55 base) — fee=0.10%RT ===")
|
||||
print(f"{'sl':>5} {'tp':>5} | {'minFull':>7} {'minHold':>7} {'minTr':>5} {'maxDD':>6} | "
|
||||
f"{'btcF':>5} {'ethF':>5} {'btcH':>5} {'ethH':>5} {'btcDD':>5} {'ethDD':>5}")
|
||||
results = []
|
||||
for sl in SL_GRID:
|
||||
for tp in TP_GRID:
|
||||
if tp <= sl: # tp must exceed sl for a sensible R:R; skip degenerate
|
||||
continue
|
||||
c = cell(sl, tp)
|
||||
results.append(c)
|
||||
flag = ""
|
||||
if c["min_full"] >= 0.5 and c["min_tr"] >= 20:
|
||||
flag = " *" # eligible
|
||||
print(f"{sl:>5} {tp:>5} | {c['min_full']:>+7.2f} {c['min_hold']:>+7.2f} "
|
||||
f"{c['min_tr']:>5} {c['max_dd']*100:>5.0f}% | "
|
||||
f"{c['btc_full']:>+5.2f} {c['eth_full']:>+5.2f} "
|
||||
f"{c['btc_hold']:>+5.2f} {c['eth_hold']:>+5.2f} "
|
||||
f"{c['btc_dd']*100:>4.0f}% {c['eth_dd']*100:>4.0f}%{flag}")
|
||||
|
||||
# Eligible = minFull>=0.5, minTrades>=20. Rank by min_hold, tie-break lower maxDD.
|
||||
elig = [c for c in results if c["min_full"] >= 0.5 and c["min_tr"] >= 20]
|
||||
print(f"\nEligible cells (minFull>=0.5, minTr>=20): {len(elig)}")
|
||||
if elig:
|
||||
elig_sorted = sorted(elig, key=lambda c: (-round(c["min_hold"], 3), c["max_dd"]))
|
||||
print("Top by minHold (tie-break lower maxDD):")
|
||||
for c in elig_sorted[:6]:
|
||||
print(f" sl={c['sl']} tp={c['tp']}: minHold={c['min_hold']:+.2f} "
|
||||
f"minFull={c['min_full']:+.2f} maxDD={c['max_dd']*100:.0f}% minTr={c['min_tr']}")
|
||||
best = elig_sorted[0]
|
||||
# DD-cutting candidate: best minHold among cells with maxDD < V1-ish (lower DD priority)
|
||||
dd_cands = sorted(elig, key=lambda c: (c["max_dd"], -round(c["min_hold"], 3)))
|
||||
print("\nTop by lowest maxDD (DD-cut objective):")
|
||||
for c in dd_cands[:6]:
|
||||
print(f" sl={c['sl']} tp={c['tp']}: maxDD={c['max_dd']*100:.0f}% "
|
||||
f"minHold={c['min_hold']:+.2f} minFull={c['min_full']:+.2f} minTr={c['min_tr']}")
|
||||
|
||||
print("\n=== STUDY on best-by-minHold ===")
|
||||
pbest = SkyhookParams(sl_atr=best["sl"], tp_atr=best["tp"], **BASE)
|
||||
rep = sk.study(f"P_RR_sl{best['sl']}_tp{best['tp']}", pbest)
|
||||
print(sk.fmt(rep))
|
||||
print("causality:", sk.causality(pbest))
|
||||
print("marginal:", {k: v for k, v in sk.marginal(pbest).items()
|
||||
if k in ("corr_full","marginal_verdict","has_insample_edge","is_hedge","robust_oos")})
|
||||
try:
|
||||
mg = sk.marginal(pbest)
|
||||
print("marginal-full-keys:", list(mg.keys()))
|
||||
print("blend w25 uplift_hold:", mg.get("blends",{}).get("w25",{}).get("uplift_hold"))
|
||||
except Exception as e:
|
||||
print("marginal err:", e)
|
||||
print("\nAS_JSON_STUDY:", sk.as_json(rep))
|
||||
else:
|
||||
print("No eligible cell — V1 base may already be at the frontier.")
|
||||
@@ -0,0 +1,306 @@
|
||||
"""SKH_R_EXPAND — REGIME variant: VOLATILITY-EXPANSION gate.
|
||||
|
||||
Hypothesis (the brief: "enter when vol+volume regime AND breakout coincide"):
|
||||
Instead of the Chande01 *cycle* band on ATR, define the regime as a genuine VOLATILITY
|
||||
EXPANSION: trade only when ATR is RISING vs its own moving average (a vol breakout) AND
|
||||
volume is elevated vs its own moving average. The intuition is that a Donchian breakout that
|
||||
fires WHILE volatility is expanding on rising participation (volume) is more likely to be a
|
||||
real move than one that fires inside a quiet/contracting regime (chop, mean-reversion).
|
||||
|
||||
Regime definition (HTF, causal):
|
||||
vol_expansion = ATR[i] >= k_atr * MA(ATR, w_atr) (ATR above its own MA -> rising)
|
||||
volume_elev = volume[i] >= k_vol * MA(volume, w_vol) (participation elevated)
|
||||
regime_ok = vol_expansion AND volume_elev
|
||||
MA is a CAUSAL rolling mean (uses x[i-w+1..i] inclusive of the current, already-closed bar).
|
||||
k_atr / k_vol are tunable multipliers (1.0 = "above MA"; >1 = "well above MA").
|
||||
|
||||
Pattern/composer/entry/exit reuse the V1 Skyhook building blocks unchanged:
|
||||
ptn_n=55 Donchian, sl_atr=2.5, tp_atr=6.0, asymmetric time exits, max 1/day.
|
||||
|
||||
Causality: every regime feature uses only x[0..i] (rolling MA, ATR ewm, donchian shift(1)),
|
||||
INCLUSIVE of the current HTF bar — legit because at HTF close[i] the bar is fully known. The
|
||||
HTF feature is merged BACKWARD onto LTF on the HTF-close timestamp (<= LTF close). We verify
|
||||
the truncated-prefix guard ourselves on BOTH assets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import skyhooklib as sk
|
||||
from src.backtest.harness import backtest_signals
|
||||
from src.strategies import skyhook as S
|
||||
from src.strategies.skyhook import SkyhookParams, HTF_MIN
|
||||
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
FEE = sk.FEE_RT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Causal rolling MA (inclusive of current, already-closed bar). min_periods enforced.
|
||||
# ---------------------------------------------------------------------------
|
||||
def causal_ma(x: np.ndarray, win: int, min_periods: int | None = None) -> np.ndarray:
|
||||
mp = win if min_periods is None else min_periods
|
||||
return pd.Series(np.asarray(x, float)).rolling(win, min_periods=mp).mean().values
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTF feature df: volatility-EXPANSION regime gate + Donchian pattern (V1 pattern reused).
|
||||
# regime_ok = (ATR >= k_atr*MA(ATR,w_atr)) AND (volume >= k_vol*MA(volume,w_vol))
|
||||
# ---------------------------------------------------------------------------
|
||||
def expand_htf_features(htf: pd.DataFrame, p: SkyhookParams,
|
||||
w_atr: int, k_atr: float,
|
||||
w_vol: int, k_vol: float) -> pd.DataFrame:
|
||||
atr_htf = S.atr(htf, p.atr_win)
|
||||
vol_htf = htf["volume"].values.astype(float)
|
||||
|
||||
atr_ma = causal_ma(atr_htf, w_atr)
|
||||
vol_ma = causal_ma(vol_htf, w_vol)
|
||||
|
||||
# rising-vol = current ATR above k_atr * its own MA ; same for volume.
|
||||
# NaN during warmup -> False (no trade until the regime is computable).
|
||||
vol_expansion = np.where(np.isfinite(atr_ma) & (atr_ma > 0), atr_htf >= k_atr * atr_ma, False)
|
||||
volume_elev = np.where(np.isfinite(vol_ma) & (vol_ma > 0), vol_htf >= k_vol * vol_ma, False)
|
||||
regime_ok = vol_expansion & volume_elev
|
||||
|
||||
ptn_long, ptn_short = S.donchian_breakout(htf, p.ptn_n)
|
||||
comp_long = regime_ok & ptn_long
|
||||
comp_short = regime_ok & ptn_short
|
||||
if p.long_only:
|
||||
comp_short = np.zeros_like(comp_short, dtype=bool)
|
||||
|
||||
close_ts = htf["timestamp"].astype("int64").values + HTF_MIN * 60 * 1000
|
||||
return pd.DataFrame({
|
||||
"close_ts": close_ts,
|
||||
# store the ratios for diagnostics (not used downstream)
|
||||
"buz_vola": np.where(np.isfinite(atr_ma) & (atr_ma > 0), atr_htf / atr_ma, np.nan),
|
||||
"buz_volume": np.where(np.isfinite(vol_ma) & (vol_ma > 0), vol_htf / vol_ma, np.nan),
|
||||
"comp_long": comp_long.astype(bool), "comp_short": comp_short.astype(bool),
|
||||
})
|
||||
|
||||
|
||||
def expand_entries(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams,
|
||||
w_atr, k_atr, w_vol, k_vol) -> list:
|
||||
"""Same entry/exit machinery as S.skyhook_entries, regime from expansion features."""
|
||||
feat = expand_htf_features(htf, p, w_atr, k_atr, w_vol, k_vol)
|
||||
m = S.merge_htf_to_ltf(ltf, feat)
|
||||
|
||||
c = m["close"].values.astype(float)
|
||||
a = S.atr(m, p.ltf_atr_win)
|
||||
comp_long = np.nan_to_num(m["comp_long"].values).astype(bool)
|
||||
comp_short = np.nan_to_num(m["comp_short"].values).astype(bool)
|
||||
days = pd.to_datetime(m["datetime"]).dt.floor("D").values
|
||||
|
||||
entries: list = [None] * len(m)
|
||||
count_today: dict = {}
|
||||
for i in range(len(m)):
|
||||
if not np.isfinite(a[i]) or a[i] <= 0:
|
||||
continue
|
||||
day = days[i]
|
||||
if count_today.get(day, 0) >= p.max_per_day:
|
||||
continue
|
||||
if comp_long[i]:
|
||||
direction, mb = 1, p.uscitalong
|
||||
elif comp_short[i]:
|
||||
direction, mb = -1, p.uscitashort
|
||||
else:
|
||||
continue
|
||||
if p.exit_mode == "atr":
|
||||
sl_off, tp_off = p.sl_atr * a[i], p.tp_atr * a[i]
|
||||
else:
|
||||
sl_off, tp_off = p.sl_pct * c[i], p.tp_pct * c[i]
|
||||
if direction == 1:
|
||||
sl, tp = c[i] - sl_off, c[i] + tp_off
|
||||
else:
|
||||
sl, tp = c[i] + sl_off, c[i] - tp_off
|
||||
entries[i] = {"dir": direction, "tp": float(tp), "sl": float(sl), "max_bars": int(mb)}
|
||||
count_today[day] = count_today.get(day, 0) + 1
|
||||
return entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Eval helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _split(eq, idx, mask):
|
||||
e = eq[mask]
|
||||
if len(e) < 5:
|
||||
return dict(sharpe=0.0, ret=0.0, maxdd=0.0, n=int(len(e)))
|
||||
r = np.diff(e) / e[:-1]; r = r[np.isfinite(r)]
|
||||
ix = idx[mask]
|
||||
dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400
|
||||
bpy = 86400 * 365.25 / dt
|
||||
sh = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0
|
||||
pk = np.maximum.accumulate(e); dd = float(np.max((pk - e) / pk))
|
||||
return dict(sharpe=round(sh, 3), ret=round(float(e[-1] / e[0] - 1), 4), maxdd=round(dd, 4), n=int(len(e)))
|
||||
|
||||
|
||||
def eval_cfg(cfg, p):
|
||||
out = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = expand_entries(ltf, htf, p, **cfg)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
eq = m.equity
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))
|
||||
hmask = np.asarray(idx >= HOLDOUT)
|
||||
full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4),
|
||||
n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1))
|
||||
hold = _split(eq, idx, hmask)
|
||||
out[a] = dict(full=full, hold=hold, _eq=eq, _idx=idx)
|
||||
return out
|
||||
|
||||
|
||||
def summarize(tag, res):
|
||||
mf = min(res[a]["full"]["sharpe"] for a in res)
|
||||
mh = min(res[a]["hold"]["sharpe"] for a in res)
|
||||
mt = min(res[a]["full"]["n_trades"] for a in res)
|
||||
mdd = max(res[a]["full"]["maxdd"] for a in res)
|
||||
print(f" [{tag}] minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} maxDD={mdd*100:.0f}%"
|
||||
f" | BTC F{res['BTC']['full']['sharpe']:+.2f}/H{res['BTC']['hold']['sharpe']:+.2f} n{res['BTC']['full']['n_trades']}"
|
||||
f" ETH F{res['ETH']['full']['sharpe']:+.2f}/H{res['ETH']['hold']['sharpe']:+.2f} n{res['ETH']['full']['n_trades']}")
|
||||
return dict(minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, res=res)
|
||||
|
||||
|
||||
def v1_like_params(**kw):
|
||||
base = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0) # V1 pattern/exit
|
||||
base.update(kw)
|
||||
return SkyhookParams(**base)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Causality self-check on the structural variant (truncated-prefix guard)
|
||||
# ---------------------------------------------------------------------------
|
||||
def check_causality(cfg, p, asset="BTC", tail=150):
|
||||
ltf, htf = sk.frames(asset)
|
||||
full = expand_entries(ltf, htf, p, **cfg)
|
||||
n = len(ltf); bad = 0; checked = 0
|
||||
for frac in (0.80, 0.92):
|
||||
cut = int(n * frac)
|
||||
cut_ts = int(ltf["timestamp"].iloc[cut - 1])
|
||||
htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True)
|
||||
sub = expand_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, p, **cfg)
|
||||
for i in range(max(0, cut - tail), cut):
|
||||
checked += 1
|
||||
a, b = full[i], sub[i]
|
||||
if (a is None) != (b is None):
|
||||
bad += 1
|
||||
elif a is not None and (a["dir"] != b["dir"]
|
||||
or abs(a["sl"] - b["sl"]) > 1e-6
|
||||
or abs(a["tp"] - b["tp"]) > 1e-6):
|
||||
bad += 1
|
||||
return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== SKH_R_EXPAND: volatility-EXPANSION regime (ATR rising vs its MA + volume elevated) ===\n")
|
||||
|
||||
# --- V1 reference (Chande01 regime) ---
|
||||
print("--- V1 reference (Chande01 regime, ptn_n=55 sl2.5 tp6 vola35-95 vol0) ---")
|
||||
v1 = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)
|
||||
v1res = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
r = sk.run_asset(a, v1, FEE)
|
||||
v1res[a] = dict(full=dict(sharpe=r["full"]["sharpe"], n_trades=r["full"]["n_trades"],
|
||||
maxdd=r["full"]["maxdd"]), hold=dict(sharpe=r["holdout"]["sharpe"]))
|
||||
v1_minFull = min(v1res[a]['full']['sharpe'] for a in v1res)
|
||||
v1_minHold = min(v1res[a]['hold']['sharpe'] for a in v1res)
|
||||
v1_maxDD = max(v1res[a]['full']['maxdd'] for a in v1res)
|
||||
print(f" V1 minFull={v1_minFull:+.2f} minHold={v1_minHold:+.2f} maxDD={v1_maxDD*100:.0f}%"
|
||||
f" | BTC F{v1res['BTC']['full']['sharpe']:+.2f}/H{v1res['BTC']['hold']['sharpe']:+.2f}"
|
||||
f" ETH F{v1res['ETH']['full']['sharpe']:+.2f}/H{v1res['ETH']['hold']['sharpe']:+.2f}\n")
|
||||
|
||||
p = v1_like_params() # same pattern/exit as V1; only regime changes
|
||||
|
||||
# --- Sweep: expansion-MA windows + multipliers ---
|
||||
# k=1.0 -> "ATR above its own MA" (mild rising). k>1 -> stronger expansion (fewer trades).
|
||||
# w_atr/w_vol: lookback for the MA (HTF bars; 690min each). vol elevated mirrored on volume.
|
||||
print("--- volatility-EXPANSION sweep ---")
|
||||
cfgs = {
|
||||
# (w_atr,k_atr) , (w_vol,k_vol)
|
||||
"atr20k1.0_vol20k1.0": dict(w_atr=20, k_atr=1.00, w_vol=20, k_vol=1.00),
|
||||
"atr20k1.0_vol20k1.2": dict(w_atr=20, k_atr=1.00, w_vol=20, k_vol=1.20),
|
||||
"atr20k1.1_vol20k1.0": dict(w_atr=20, k_atr=1.10, w_vol=20, k_vol=1.00),
|
||||
"atr20k1.1_vol20k1.2": dict(w_atr=20, k_atr=1.10, w_vol=20, k_vol=1.20),
|
||||
"atr10k1.0_vol10k1.0": dict(w_atr=10, k_atr=1.00, w_vol=10, k_vol=1.00),
|
||||
"atr10k1.1_vol10k1.2": dict(w_atr=10, k_atr=1.10, w_vol=10, k_vol=1.20),
|
||||
"atr30k1.0_vol30k1.0": dict(w_atr=30, k_atr=1.00, w_vol=30, k_vol=1.00),
|
||||
"atr20k1.0_volOFF": dict(w_atr=20, k_atr=1.00, w_vol=20, k_vol=0.00), # vol gate off (k=0 always true)
|
||||
"atr20k1.2_vol20k1.0": dict(w_atr=20, k_atr=1.20, w_vol=20, k_vol=1.00),
|
||||
}
|
||||
results = {}
|
||||
for tag, cfg in cfgs.items():
|
||||
results[tag] = (cfg, summarize(tag, eval_cfg(cfg, p)))
|
||||
|
||||
# --- Pick winner by minHold (subject to minFull>=0.5, minTr>=20) ---
|
||||
elig = {t: v for t, (c, v) in results.items() if v["minFull"] >= 0.5 and v["minTr"] >= 20}
|
||||
print(f"\n--- eligible (minFull>=0.5, minTr>=20): {list(elig.keys())} ---")
|
||||
if elig:
|
||||
win_tag = max(elig, key=lambda t: elig[t]["minHold"])
|
||||
else:
|
||||
win_tag = max(results, key=lambda t: results[t][1]["minHold"])
|
||||
win_cfg, win_v = results[win_tag][0], results[win_tag][1]
|
||||
print(f"\n*** WINNER = {win_tag} minFull={win_v['minFull']:+.2f} minHold={win_v['minHold']:+.2f}"
|
||||
f" minTr={win_v['minTr']} maxDD={win_v['maxDD']*100:.0f}% ***")
|
||||
print(f" cfg = {win_cfg}")
|
||||
|
||||
# --- Causality on winner (BOTH assets) ---
|
||||
caus = check_causality(win_cfg, p, "BTC")
|
||||
caus_eth = check_causality(win_cfg, p, "ETH")
|
||||
print(f"\ncausality(BTC) = {caus}")
|
||||
print(f"causality(ETH) = {caus_eth}")
|
||||
|
||||
# --- Fee sweep on winner (both assets, FULL) ---
|
||||
print("\n--- fee sweep on winner (FULL sharpe) ---")
|
||||
fee_ok_all = True
|
||||
for a in ("BTC", "ETH"):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent_cache = expand_entries(ltf, htf, p, **win_cfg)
|
||||
row = []
|
||||
for f in (0.0, 0.001, 0.002, 0.003):
|
||||
m = backtest_signals(ltf, ent_cache, fee_rt=f, leverage=1.0, asset=a, tf="230m")
|
||||
row.append((f, round(m.sharpe, 3)))
|
||||
sh030 = dict(row)[0.003]
|
||||
fee_ok_all = fee_ok_all and (sh030 > 0)
|
||||
print(f" {a}: " + " ".join(f"{f*100:.2f}%={s:+.2f}" for f, s in row))
|
||||
print(f" fee_survives 0.30%RT (both): {fee_ok_all}")
|
||||
|
||||
# --- Per-year on winner ---
|
||||
print("\n--- per-year (winner) ---")
|
||||
for a in ("BTC", "ETH"):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = expand_entries(ltf, htf, p, **win_cfg)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
yr = " ".join(f"{y}:{v*100:+.0f}%" for y, v in m.yearly.items())
|
||||
print(f" {a}: {yr}")
|
||||
|
||||
# --- Marginal vs TP01 on winner ---
|
||||
def daily_series(a, p, cfg):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = expand_entries(ltf, htf, p, **cfg)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
|
||||
return s.resample("1D").last().ffill().pct_change().dropna()
|
||||
|
||||
import altlib as al
|
||||
sb = daily_series("BTC", p, win_cfg); se = daily_series("ETH", p, win_cfg)
|
||||
J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0)
|
||||
cand_daily = 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
marg = al.marginal_vs_tp01(cand_daily)
|
||||
print("\n--- marginal vs TP01 (winner) ---")
|
||||
print(f" corr_full={marg.get('corr_full')} corr_hold={marg.get('corr_hold')}")
|
||||
print(f" blend w25 uplift_hold={marg.get('blends',{}).get('w25',{}).get('uplift_hold')}")
|
||||
print(f" marginal_verdict={marg.get('marginal_verdict')} robust_oos={marg.get('robust_oos')}")
|
||||
print(f" has_insample_edge={marg.get('has_insample_edge')} is_hedge={marg.get('is_hedge')}")
|
||||
print(f" clean_year_uplift={marg.get('clean_year_uplift')} jackknife_min_uplift={marg.get('jackknife_min_uplift')}")
|
||||
print(f" multicut_persistent={marg.get('multicut_persistent')}")
|
||||
|
||||
# --- Final verdict echo ---
|
||||
print("\n=== SUMMARY ===")
|
||||
print(f"V1: minFull={v1_minFull:+.2f} minHold={v1_minHold:+.2f} maxDD={v1_maxDD*100:.0f}%")
|
||||
print(f"EXPAND {win_tag}: minFull={win_v['minFull']:+.2f} minHold={win_v['minHold']:+.2f}"
|
||||
f" maxDD={win_v['maxDD']*100:.0f}% causalityOK={caus['ok'] and caus_eth['ok']} feeOK={fee_ok_all}")
|
||||
beats = (win_v['minHold'] > v1_minHold) and win_v['minFull'] >= 0.5 and fee_ok_all
|
||||
print(f"BEATS V1 HOLD-OUT: {beats}")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""SKH_R_EXPAND_study — final study() on the two best volatility-expansion configs.
|
||||
|
||||
We use the project's HONEST study() harness. Because the expansion regime is a STRUCTURAL
|
||||
change (not expressible via SkyhookParams bands), we monkeypatch htf_features INSIDE
|
||||
skyhooklib's namespace to our expansion-features for the duration of each study, so study()
|
||||
runs the exact same leak-free FULL+HOLDOUT+fee-sweep+per-year machinery on our entries.
|
||||
|
||||
This is safe: we only swap the feature builder (regime def); pattern/composer/entry/exit and
|
||||
all the eval code are unchanged. We restore the original after each study.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import skyhooklib as sk
|
||||
from src.strategies import skyhook as S
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
# import our feature builder
|
||||
from SKH_R_EXPAND import expand_htf_features
|
||||
|
||||
ORIG_FEAT = S.htf_features
|
||||
|
||||
|
||||
def make_patched_features(cfg):
|
||||
def _feat(htf, p):
|
||||
return expand_htf_features(htf, p, **cfg)
|
||||
return _feat
|
||||
|
||||
|
||||
def study_expand(name, p, cfg):
|
||||
"""Run sk.study with htf_features patched to the expansion regime defined by cfg."""
|
||||
patched = make_patched_features(cfg)
|
||||
# skyhook_entries calls skyhook.htf_features via the module-level name S.htf_features.
|
||||
S.htf_features = patched
|
||||
try:
|
||||
rep = sk.study(name, p)
|
||||
caus = sk.causality(p, "BTC")
|
||||
caus_eth = sk.causality(p, "ETH")
|
||||
marg = sk.marginal(p)
|
||||
finally:
|
||||
S.htf_features = ORIG_FEAT
|
||||
return rep, (caus, caus_eth), marg
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
p = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0)
|
||||
|
||||
configs = {
|
||||
# best DD-cut + above-V1 full, balanced volume gate (maxDD 34%)
|
||||
"EXP_atr20vol20": dict(w_atr=20, k_atr=1.0, w_vol=20, k_vol=1.0),
|
||||
# best hold-out / no volume gate (minFull 0.81, DD 40%)
|
||||
"EXP_atr20volOFF": dict(w_atr=20, k_atr=1.0, w_vol=20, k_vol=0.0),
|
||||
}
|
||||
|
||||
for name, cfg in configs.items():
|
||||
print(f"\n########## {name} cfg={cfg} ##########")
|
||||
rep, (caus, caus_eth), marg = study_expand(name, p, cfg)
|
||||
print(sk.fmt(rep))
|
||||
print(f"causality BTC={caus} ETH={caus_eth}")
|
||||
print(f"marginal: verdict={marg.get('marginal_verdict')} corr_full={marg.get('corr_full')}"
|
||||
f" blend_w25_uplift_hold={marg.get('blends',{}).get('w25',{}).get('uplift_hold')}")
|
||||
print(f" has_insample_edge={marg.get('has_insample_edge')} is_hedge={marg.get('is_hedge')}"
|
||||
f" robust_oos={marg.get('robust_oos')} clean_year_uplift={marg.get('clean_year_uplift')}"
|
||||
f" multicut_persistent={marg.get('multicut_persistent')}")
|
||||
v = rep["verdict"]
|
||||
print(f"VERDICT[{name}]: grade={v['grade']} minFull={v['min_asset_full_sharpe']}"
|
||||
f" minHold={v['min_asset_holdout_sharpe']} minTrades={v['min_trades']} feeOK={v['fee_survives']}")
|
||||
@@ -0,0 +1,308 @@
|
||||
"""SKH_R_PCTL — REGIME variant: replace Chande01 regime with CAUSAL expanding/rolling
|
||||
PERCENTILE-RANK of ATR and volume (0-1), gate on rank bands.
|
||||
|
||||
Hypothesis: Chande01 measures the *direction/momentum* of the vol/volume cycle (rising vs
|
||||
falling), mapped to 0-100. A percentile-RANK instead measures *where the current level sits*
|
||||
within its own history (is ATR/volume HIGH or LOW relative to the past). This is a more
|
||||
natural "regime" definition: trade only when vol/volume is in a chosen part of its own
|
||||
distribution. We test expanding (full history) and rolling-window percentile ranks.
|
||||
|
||||
Causality: rank[i] uses only x[0..i] (expanding) or x[i-w+1..i] (rolling), INCLUSIVE of the
|
||||
current bar — this is legitimate because at HTF close[i] the bar's ATR/volume is known. The
|
||||
HTF feature is then merged backward to LTF on HTF-close timestamp (<= LTF close). We verify
|
||||
the truncated-prefix guard ourselves.
|
||||
|
||||
Pattern/composer/entry/exit reuse the V1 Skyhook building blocks unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import skyhooklib as sk
|
||||
from src.backtest.harness import backtest_signals
|
||||
from src.strategies import skyhook as S
|
||||
from src.strategies.skyhook import SkyhookParams, HTF_MIN, LTF_MIN
|
||||
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
FEE = sk.FEE_RT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Causal percentile-rank (0-1). Fraction of the window strictly < current value,
|
||||
# computed inclusive of the current bar (legit: bar is closed). min_periods enforced.
|
||||
# ---------------------------------------------------------------------------
|
||||
def pctl_rank(x: np.ndarray, win: int | None, min_periods: int = 30) -> np.ndarray:
|
||||
"""Causal percentile rank in [0,1]. win=None -> EXPANDING; else rolling window.
|
||||
rank[i] = (#{x[j] < x[i]} + 0.5*#{x[j]==x[i]}) / count over the (expanding/rolling) window."""
|
||||
x = np.asarray(x, float)
|
||||
s = pd.Series(x)
|
||||
if win is None:
|
||||
# expanding rank: pandas .expanding().rank(pct=True) gives rank/count INCLUSIVE of i,
|
||||
# which counts <= (so the current bar's own value is included). Use 'average' to break ties.
|
||||
r = s.expanding(min_periods=min_periods).rank(pct=True)
|
||||
else:
|
||||
r = s.rolling(win, min_periods=min(min_periods, win)).rank(pct=True)
|
||||
return r.values # NaN until min_periods reached
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTF feature df with percentile-rank regime gate + Donchian pattern (V1 pattern reused).
|
||||
# ---------------------------------------------------------------------------
|
||||
def pctl_htf_features(htf: pd.DataFrame, p: SkyhookParams,
|
||||
vola_win: int | None, vol_win: int | None,
|
||||
vola_lo: float, vola_hi: float,
|
||||
vol_lo: float, vol_hi: float) -> pd.DataFrame:
|
||||
"""Regime via CAUSAL percentile-rank (0-1) of ATR and volume; pattern via Donchian.
|
||||
Bands here are in [0,1] (percentile space), NOT 0-100 like Chande01."""
|
||||
atr_htf = S.atr(htf, p.atr_win)
|
||||
vola_rank = pctl_rank(atr_htf, vola_win)
|
||||
vol_rank = pctl_rank(htf["volume"].values, vol_win)
|
||||
ptn_long, ptn_short = S.donchian_breakout(htf, p.ptn_n)
|
||||
|
||||
# regime_ok requires a valid (non-NaN) rank in band; NaN (warmup) -> False
|
||||
vr_ok = np.where(np.isfinite(vola_rank), (vola_rank >= vola_lo) & (vola_rank <= vola_hi), False)
|
||||
vol_ok = np.where(np.isfinite(vol_rank), (vol_rank >= vol_lo) & (vol_rank <= vol_hi), False)
|
||||
regime_ok = vr_ok & vol_ok
|
||||
|
||||
comp_long = regime_ok & ptn_long
|
||||
comp_short = regime_ok & ptn_short
|
||||
if p.long_only:
|
||||
comp_short = np.zeros_like(comp_short, dtype=bool)
|
||||
|
||||
close_ts = htf["timestamp"].astype("int64").values + HTF_MIN * 60 * 1000
|
||||
return pd.DataFrame({
|
||||
"close_ts": close_ts,
|
||||
"buz_vola": vola_rank, "buz_volume": vol_rank,
|
||||
"comp_long": comp_long.astype(bool), "comp_short": comp_short.astype(bool),
|
||||
})
|
||||
|
||||
|
||||
def pctl_entries(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams,
|
||||
vola_win, vol_win, vola_lo, vola_hi, vol_lo, vol_hi) -> list:
|
||||
"""Same entry/exit machinery as S.skyhook_entries, but regime from pctl features."""
|
||||
feat = pctl_htf_features(htf, p, vola_win, vol_win, vola_lo, vola_hi, vol_lo, vol_hi)
|
||||
m = S.merge_htf_to_ltf(ltf, feat)
|
||||
|
||||
c = m["close"].values.astype(float)
|
||||
a = S.atr(m, p.ltf_atr_win)
|
||||
comp_long = np.nan_to_num(m["comp_long"].values).astype(bool)
|
||||
comp_short = np.nan_to_num(m["comp_short"].values).astype(bool)
|
||||
days = pd.to_datetime(m["datetime"]).dt.floor("D").values
|
||||
|
||||
entries: list = [None] * len(m)
|
||||
count_today: dict = {}
|
||||
for i in range(len(m)):
|
||||
if not np.isfinite(a[i]) or a[i] <= 0:
|
||||
continue
|
||||
day = days[i]
|
||||
if count_today.get(day, 0) >= p.max_per_day:
|
||||
continue
|
||||
if comp_long[i]:
|
||||
direction, mb = 1, p.uscitalong
|
||||
elif comp_short[i]:
|
||||
direction, mb = -1, p.uscitashort
|
||||
else:
|
||||
continue
|
||||
if p.exit_mode == "atr":
|
||||
sl_off, tp_off = p.sl_atr * a[i], p.tp_atr * a[i]
|
||||
else:
|
||||
sl_off, tp_off = p.sl_pct * c[i], p.tp_pct * c[i]
|
||||
if direction == 1:
|
||||
sl, tp = c[i] - sl_off, c[i] + tp_off
|
||||
else:
|
||||
sl, tp = c[i] + sl_off, c[i] - tp_off
|
||||
entries[i] = {"dir": direction, "tp": float(tp), "sl": float(sl), "max_bars": int(mb)}
|
||||
count_today[day] = count_today.get(day, 0) + 1
|
||||
return entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Eval helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _split(eq, idx, mask):
|
||||
e = eq[mask]
|
||||
if len(e) < 5:
|
||||
return dict(sharpe=0.0, ret=0.0, maxdd=0.0, n=int(len(e)))
|
||||
r = np.diff(e) / e[:-1]; r = r[np.isfinite(r)]
|
||||
ix = idx[mask]
|
||||
dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400
|
||||
bpy = 86400 * 365.25 / dt
|
||||
sh = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0
|
||||
pk = np.maximum.accumulate(e); dd = float(np.max((pk - e) / pk))
|
||||
return dict(sharpe=round(sh, 3), ret=round(float(e[-1] / e[0] - 1), 4), maxdd=round(dd, 4), n=int(len(e)))
|
||||
|
||||
|
||||
def eval_cfg(cfg, p):
|
||||
"""Run both assets; return dict per asset with full+holdout."""
|
||||
out = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = pctl_entries(ltf, htf, p, **cfg)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
eq = m.equity
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))
|
||||
hmask = np.asarray(idx >= HOLDOUT)
|
||||
full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4),
|
||||
n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1))
|
||||
hold = _split(eq, idx, hmask)
|
||||
out[a] = dict(full=full, hold=hold, _eq=eq, _idx=idx)
|
||||
return out
|
||||
|
||||
|
||||
def summarize(tag, res):
|
||||
mf = min(res[a]["full"]["sharpe"] for a in res)
|
||||
mh = min(res[a]["hold"]["sharpe"] for a in res)
|
||||
mt = min(res[a]["full"]["n_trades"] for a in res)
|
||||
mdd = max(res[a]["full"]["maxdd"] for a in res)
|
||||
print(f" [{tag}] minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} maxDD={mdd*100:.0f}%"
|
||||
f" | BTC F{res['BTC']['full']['sharpe']:+.2f}/H{res['BTC']['hold']['sharpe']:+.2f}"
|
||||
f" ETH F{res['ETH']['full']['sharpe']:+.2f}/H{res['ETH']['hold']['sharpe']:+.2f}")
|
||||
return dict(minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, res=res)
|
||||
|
||||
|
||||
# Build a SkyhookParams matching V1 non-regime knobs (pattern + exits)
|
||||
def v1_like_params(**kw):
|
||||
base = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0) # V1 pattern/exit
|
||||
base.update(kw)
|
||||
return SkyhookParams(**base)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Causality self-check on the structural variant
|
||||
# ---------------------------------------------------------------------------
|
||||
def check_causality(cfg, p, asset="BTC", tail=150):
|
||||
ltf, htf = sk.frames(asset)
|
||||
full = pctl_entries(ltf, htf, p, **cfg)
|
||||
n = len(ltf); bad = 0; checked = 0
|
||||
for frac in (0.80, 0.92):
|
||||
cut = int(n * frac)
|
||||
cut_ts = int(ltf["timestamp"].iloc[cut - 1])
|
||||
htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True)
|
||||
sub = pctl_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, p, **cfg)
|
||||
for i in range(max(0, cut - tail), cut):
|
||||
checked += 1
|
||||
a, b = full[i], sub[i]
|
||||
if (a is None) != (b is None):
|
||||
bad += 1
|
||||
elif a is not None and (a["dir"] != b["dir"]
|
||||
or abs(a["sl"] - b["sl"]) > 1e-6
|
||||
or abs(a["tp"] - b["tp"]) > 1e-6):
|
||||
bad += 1
|
||||
return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== SKH_R_PCTL: percentile-rank regime ===\n")
|
||||
|
||||
# --- V1 reference for comparison (Chande01 regime) ---
|
||||
print("--- V1 reference (Chande01 regime, ptn_n=55 sl2.5 tp6 vola35-95 vol0) ---")
|
||||
v1 = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)
|
||||
v1res = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
r = sk.run_asset(a, v1, FEE)
|
||||
v1res[a] = dict(full=dict(sharpe=r["full"]["sharpe"], n_trades=r["full"]["n_trades"],
|
||||
maxdd=r["full"]["maxdd"]),
|
||||
hold=dict(sharpe=r["holdout"]["sharpe"]))
|
||||
print(f" V1 minFull={min(v1res[a]['full']['sharpe'] for a in v1res):+.2f}"
|
||||
f" minHold={min(v1res[a]['hold']['sharpe'] for a in v1res):+.2f}"
|
||||
f" maxDD={max(v1res[a]['full']['maxdd'] for a in v1res)*100:.0f}%"
|
||||
f" | BTC F{v1res['BTC']['full']['sharpe']:+.2f}/H{v1res['BTC']['hold']['sharpe']:+.2f}"
|
||||
f" ETH F{v1res['ETH']['full']['sharpe']:+.2f}/H{v1res['ETH']['hold']['sharpe']:+.2f}\n")
|
||||
|
||||
p = v1_like_params() # same pattern/exit as V1; only regime changes
|
||||
|
||||
# --- Sweep: percentile-rank regime bands ---
|
||||
# Two regime intuitions to test:
|
||||
# (A) HIGH-vol/HIGH-volume regime (breakout-friendly): ranks in upper band.
|
||||
# (B) MID regime (avoid blow-off + dead): ranks in a middle band.
|
||||
# vol_lo=0 means "no lower bound on volume" (mirror V1's vol_lo=0).
|
||||
print("--- EXPANDING percentile-rank sweep ---")
|
||||
cfgs = {
|
||||
# vola band, vol band, both expanding (win=None)
|
||||
"exp_volaHi_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.95, vol_lo=0.0, vol_hi=1.0),
|
||||
"exp_volaMid_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.80, vol_lo=0.0, vol_hi=1.0),
|
||||
"exp_volaHi_volHi": dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.95, vol_lo=0.40, vol_hi=1.0),
|
||||
"exp_volaLo_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.0, vola_hi=0.70, vol_lo=0.0, vol_hi=1.0),
|
||||
"exp_volaWide_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.20, vola_hi=1.0, vol_lo=0.0, vol_hi=1.0),
|
||||
}
|
||||
results = {}
|
||||
for tag, cfg in cfgs.items():
|
||||
results[tag] = (cfg, summarize(tag, eval_cfg(cfg, p)))
|
||||
|
||||
print("\n--- ROLLING percentile-rank sweep (win=60 HTF bars ~ recent regime) ---")
|
||||
cfgs_roll = {
|
||||
"roll60_volaHi_vol0": dict(vola_win=60, vol_win=60, vola_lo=0.35, vola_hi=0.95, vol_lo=0.0, vol_hi=1.0),
|
||||
"roll60_volaMid_vol0": dict(vola_win=60, vol_win=60, vola_lo=0.30, vola_hi=0.80, vol_lo=0.0, vol_hi=1.0),
|
||||
"roll120_volaHi_vol0": dict(vola_win=120, vol_win=120, vola_lo=0.35, vola_hi=0.95, vol_lo=0.0, vol_hi=1.0),
|
||||
"roll60_volaHi_volHi": dict(vola_win=60, vol_win=60, vola_lo=0.35, vola_hi=0.95, vol_lo=0.40, vol_hi=1.0),
|
||||
}
|
||||
for tag, cfg in cfgs_roll.items():
|
||||
results[tag] = (cfg, summarize(tag, eval_cfg(cfg, p)))
|
||||
|
||||
# --- Pick winner by minHold (subject to minFull>=0.5, minTr>=20) ---
|
||||
elig = {t: v for t, (c, v) in results.items()
|
||||
if v["minFull"] >= 0.5 and v["minTr"] >= 20}
|
||||
print(f"\n--- eligible (minFull>=0.5, minTr>=20): {list(elig.keys())} ---")
|
||||
if elig:
|
||||
win_tag = max(elig, key=lambda t: elig[t]["minHold"])
|
||||
else:
|
||||
# fall back to best minHold overall to report honestly
|
||||
win_tag = max(results, key=lambda t: results[t][1]["minHold"])
|
||||
win_cfg, win_v = results[win_tag][0], results[win_tag][1]
|
||||
print(f"\n*** WINNER = {win_tag} minFull={win_v['minFull']:+.2f} minHold={win_v['minHold']:+.2f}"
|
||||
f" minTr={win_v['minTr']} maxDD={win_v['maxDD']*100:.0f}% ***")
|
||||
print(f" cfg = {win_cfg}")
|
||||
|
||||
# --- Causality on winner ---
|
||||
caus = check_causality(win_cfg, p, "BTC")
|
||||
print(f"\ncausality(BTC) = {caus}")
|
||||
caus_eth = check_causality(win_cfg, p, "ETH")
|
||||
print(f"causality(ETH) = {caus_eth}")
|
||||
|
||||
# --- Fee sweep on winner (both assets, FULL) ---
|
||||
print("\n--- fee sweep on winner (FULL sharpe) ---")
|
||||
fee_ok_all = True
|
||||
for a in ("BTC", "ETH"):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent_cache = pctl_entries(ltf, htf, p, **win_cfg)
|
||||
row = []
|
||||
for f in (0.0, 0.001, 0.002, 0.003):
|
||||
m = backtest_signals(ltf, ent_cache, fee_rt=f, leverage=1.0, asset=a, tf="230m")
|
||||
row.append((f, round(m.sharpe, 3)))
|
||||
sh030 = dict(row)[0.003]
|
||||
fee_ok_all = fee_ok_all and (sh030 > 0)
|
||||
print(f" {a}: " + " ".join(f"{f*100:.2f}%={s:+.2f}" for f, s in row))
|
||||
print(f" fee_survives 0.30%RT (both): {fee_ok_all}")
|
||||
|
||||
# --- Marginal vs TP01 on winner ---
|
||||
# Build a daily 50/50 series the same way skyhooklib does, but with our entries.
|
||||
def daily_series(a, p, cfg):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = pctl_entries(ltf, htf, p, **cfg)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
|
||||
return s.resample("1D").last().ffill().pct_change().dropna()
|
||||
|
||||
import altlib as al
|
||||
sb = daily_series("BTC", p, win_cfg); se = daily_series("ETH", p, win_cfg)
|
||||
J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0)
|
||||
cand_daily = 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
marg = al.marginal_vs_tp01(cand_daily)
|
||||
print("\n--- marginal vs TP01 (winner) ---")
|
||||
print(f" corr_full={marg.get('corr_full')} corr_hold={marg.get('corr_hold')}")
|
||||
print(f" blend w25 uplift_hold={marg.get('blends',{}).get('w25',{}).get('uplift_hold')}")
|
||||
print(f" marginal_verdict={marg.get('marginal_verdict')} robust_oos={marg.get('robust_oos')}")
|
||||
print(f" has_insample_edge={marg.get('has_insample_edge')} is_hedge={marg.get('is_hedge')}")
|
||||
print(f" clean_year_uplift={marg.get('clean_year_uplift')} jackknife_min_uplift={marg.get('jackknife_min_uplift')}")
|
||||
|
||||
# --- Final verdict echo ---
|
||||
print("\n=== SUMMARY ===")
|
||||
print(f"V1: minFull={min(v1res[a]['full']['sharpe'] for a in v1res):+.2f}"
|
||||
f" minHold={min(v1res[a]['hold']['sharpe'] for a in v1res):+.2f}"
|
||||
f" maxDD={max(v1res[a]['full']['maxdd'] for a in v1res)*100:.0f}%")
|
||||
print(f"PCTL {win_tag}: minFull={win_v['minFull']:+.2f} minHold={win_v['minHold']:+.2f}"
|
||||
f" maxDD={win_v['maxDD']*100:.0f}% causalityOK={caus['ok'] and caus_eth['ok']} feeOK={fee_ok_all}")
|
||||
@@ -0,0 +1,102 @@
|
||||
"""SKH_R_PCTL final: verify top configs with sk.study + marginal, refine for minFull/DD."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import numpy as np, pandas as pd
|
||||
import skyhooklib as sk
|
||||
from src.backtest.harness import backtest_signals
|
||||
from src.strategies import skyhook as S
|
||||
from src.strategies.skyhook import SkyhookParams, HTF_MIN
|
||||
|
||||
# import the structural builder from the sweep script
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"skr", "/opt/docker/PythagorasGoal/scripts/research/skyhook/runs/SKH_R_PCTL.py")
|
||||
skr = importlib.util.module_from_spec(spec)
|
||||
# avoid running its __main__
|
||||
import builtins
|
||||
_orig_name = "__main__"
|
||||
spec.loader.exec_module(skr) # defines functions; __main__ guard prevents the sweep
|
||||
|
||||
HOLDOUT = sk.HOLDOUT
|
||||
FEE = sk.FEE_RT
|
||||
|
||||
|
||||
def study_struct(name, cfg, p):
|
||||
"""sk.study-equivalent for our structural variant: FULL+HOLD+fee-sweep+per-year BOTH assets."""
|
||||
per_asset = {}
|
||||
fee_ok_all = True
|
||||
for a in ("BTC", "ETH"):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = skr.pctl_entries(ltf, htf, p, **cfg)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
eq = m.equity
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))
|
||||
hmask = np.asarray(idx >= HOLDOUT)
|
||||
full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4),
|
||||
n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1))
|
||||
hold = skr._split(eq, idx, hmask)
|
||||
sweep = {}
|
||||
for f in (0.0, 0.001, 0.002, 0.003):
|
||||
mf = backtest_signals(ltf, ent, fee_rt=f, leverage=1.0, asset=a, tf="230m")
|
||||
sweep[f"{f*100:.2f}%"] = round(mf.sharpe, 3)
|
||||
fee_ok_all = fee_ok_all and (sweep["0.30%"] > 0)
|
||||
per_asset[a] = dict(full=full, hold=hold, yearly={int(y): round(v, 4) for y, v in m.yearly.items()},
|
||||
fee_sweep=sweep)
|
||||
mf = min(per_asset[a]["full"]["sharpe"] for a in per_asset)
|
||||
mh = min(per_asset[a]["hold"]["sharpe"] for a in per_asset)
|
||||
mt = min(per_asset[a]["full"]["n_trades"] for a in per_asset)
|
||||
mdd = max(per_asset[a]["full"]["maxdd"] for a in per_asset)
|
||||
grade = "PASS" if (mt >= 20 and mf >= 0.5 and mh >= 0.2 and fee_ok_all) else \
|
||||
("WEAK" if (mt >= 20 and mf >= 0.3 and mh >= 0.0) else "FAIL")
|
||||
print(f"\n=== {name} -> {grade} (minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} maxDD={mdd*100:.0f}% feeOK={fee_ok_all})")
|
||||
for a in per_asset:
|
||||
pa = per_asset[a]
|
||||
yr = " ".join(f"{y}:{r*100:+.0f}%" for y, r in pa["yearly"].items())
|
||||
print(f" {a}: FULL Sh={pa['full']['sharpe']:+.2f} ret={pa['full']['ret']*100:+.0f}% DD={pa['full']['maxdd']*100:.0f}%"
|
||||
f" n={pa['full']['n_trades']} wr={pa['full']['win_rate']:.0f}% | HOLD Sh={pa['hold']['sharpe']:+.2f} ret={pa['hold']['ret']*100:+.0f}%")
|
||||
print(f" fee: " + " ".join(f"{k}={v:+.2f}" for k, v in pa["fee_sweep"].items()))
|
||||
print(f" yr: {yr}")
|
||||
return dict(grade=grade, minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee_ok=fee_ok_all, per_asset=per_asset)
|
||||
|
||||
|
||||
def marginal_struct(cfg, p):
|
||||
import altlib as al
|
||||
def daily(a):
|
||||
ltf, htf = sk.frames(a)
|
||||
ent = skr.pctl_entries(ltf, htf, p, **cfg)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
|
||||
return s.resample("1D").last().ffill().pct_change().dropna()
|
||||
sb, se = daily("BTC"), daily("ETH")
|
||||
J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0)
|
||||
cand = 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
return al.marginal_vs_tp01(cand)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
p = skr.v1_like_params()
|
||||
|
||||
# Candidate A: best minHold (exp_volaHi_volHi) -- minFull 0.53
|
||||
cfgA = dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.95, vol_lo=0.40, vol_hi=1.0)
|
||||
# Candidate B: best minFull + lower DD (exp_volaLo_vol0) -- minFull 0.70, DD 39%
|
||||
cfgB = dict(vola_win=None, vol_win=None, vola_lo=0.0, vola_hi=0.70, vol_lo=0.0, vol_hi=1.0)
|
||||
|
||||
# Refinements to lift minFull on A while keeping hold-out: tighten vola band / add small vol floor
|
||||
cfgC = dict(vola_win=None, vol_win=None, vola_lo=0.10, vola_hi=0.80, vol_lo=0.30, vol_hi=1.0)
|
||||
# B + modest vol floor to keep DD low but lift hold
|
||||
cfgD = dict(vola_win=None, vol_win=None, vola_lo=0.0, vola_hi=0.70, vol_lo=0.30, vol_hi=1.0)
|
||||
|
||||
rA = study_struct("PCTL-A exp_volaHi_volHi", cfgA, p)
|
||||
rB = study_struct("PCTL-B exp_volaLo_vol0", cfgB, p)
|
||||
rC = study_struct("PCTL-C exp_volaLoMid_volFloor", cfgC, p)
|
||||
rD = study_struct("PCTL-D exp_volaLo_volFloor", cfgD, p)
|
||||
|
||||
print("\n\n##### MARGINAL vs TP01 #####")
|
||||
for tag, cfg, r in [("A", cfgA, rA), ("B", cfgB, rB), ("C", cfgC, rC), ("D", cfgD, rD)]:
|
||||
mg = marginal_struct(cfg, p)
|
||||
print(f"[{tag}] grade={r['grade']} minFull={r['minFull']:+.2f} minHold={r['minHold']:+.2f} DD={r['maxDD']*100:.0f}%"
|
||||
f" | corr_full={mg.get('corr_full')} upliftHold={mg.get('blends',{}).get('w25',{}).get('uplift_hold')}"
|
||||
f" verdict={mg.get('marginal_verdict')} robust_oos={mg.get('robust_oos')}"
|
||||
f" insample_edge={mg.get('has_insample_edge')} hedge={mg.get('is_hedge')}"
|
||||
f" cleanYr={mg.get('clean_year_uplift')}")
|
||||
@@ -0,0 +1,281 @@
|
||||
"""SKH_R_RV — REGIME family: define BuzVola from REALIZED VOL (rolling std of HTF log-returns,
|
||||
annualized) instead of ATR, then Chande-normalize. Question: does a returns-based vol regime
|
||||
gate better OOS than the ATR-based one?
|
||||
|
||||
Structural variant: we rebuild htf_features ourselves, swapping ONLY the BuzVola source from
|
||||
chande01(atr) to chande01(realized_vol). Everything else (BuzVolume, Donchian pattern, composer,
|
||||
entries, exits) is IDENTICAL to the engine so the comparison is clean. Causal-only: realized vol
|
||||
uses log-returns up to the HTF close; chande01 is causal rolling; donchian uses shift(1); the
|
||||
HTF->LTF merge is backward on HTF close. We verify causality with a truncated-prefix guard.
|
||||
|
||||
V1 reference to beat: SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95,
|
||||
vol_lo=0.0) -> minFull +0.69, minHold +0.64 (BTC .64/ETH .64), fee-safe to 0.30%RT, DD ~40-49%.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal")
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import skyhooklib as sk
|
||||
from src.strategies import skyhook as S
|
||||
from src.strategies.skyhook import SkyhookParams, HTF_MIN, LTF_MIN
|
||||
from src.backtest.harness import backtest_signals
|
||||
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
FEE = sk.FEE_RT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Realized-vol BuzVola: rolling std of HTF LOG-returns, annualized, Chande-normalized.
|
||||
# rv_win = lookback bars for the realized-vol estimate.
|
||||
# ---------------------------------------------------------------------------
|
||||
def realized_vol(htf: pd.DataFrame, rv_win: int) -> np.ndarray:
|
||||
c = htf["close"].values.astype(float)
|
||||
logret = np.zeros_like(c)
|
||||
logret[1:] = np.log(c[1:] / c[:-1])
|
||||
# annualization factor: bars per year at 690 min
|
||||
bars_per_year = 365.25 * 24 * 60 / HTF_MIN
|
||||
rv = pd.Series(logret).rolling(rv_win, min_periods=rv_win).std().values * np.sqrt(bars_per_year)
|
||||
return rv
|
||||
|
||||
|
||||
def htf_features_rv(htf: pd.DataFrame, p: SkyhookParams, rv_win: int) -> pd.DataFrame:
|
||||
"""Same as S.htf_features but BuzVola = chande01(realized_vol) instead of chande01(atr)."""
|
||||
rv = realized_vol(htf, rv_win)
|
||||
buz_vola = S.chande01(rv, p.n_vola)
|
||||
buz_volume = S.chande01(htf["volume"].values, p.n_volume)
|
||||
ptn_long, ptn_short = S.donchian_breakout(htf, p.ptn_n)
|
||||
regime_ok = ((buz_vola >= p.vola_lo) & (buz_vola <= p.vola_hi)
|
||||
& (buz_volume >= p.vol_lo) & (buz_volume <= p.vol_hi))
|
||||
comp_long = regime_ok & ptn_long
|
||||
comp_short = regime_ok & ptn_short
|
||||
if p.long_only:
|
||||
comp_short = np.zeros_like(comp_short, dtype=bool)
|
||||
close_ts = htf["timestamp"].astype("int64").values + HTF_MIN * 60 * 1000
|
||||
return pd.DataFrame({
|
||||
"close_ts": close_ts,
|
||||
"buz_vola": buz_vola, "buz_volume": buz_volume,
|
||||
"comp_long": comp_long.astype(bool), "comp_short": comp_short.astype(bool),
|
||||
})
|
||||
|
||||
|
||||
def rv_entries(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams, rv_win: int) -> list:
|
||||
"""Identical to S.skyhook_entries but using the RV-based htf_features."""
|
||||
feat = htf_features_rv(htf, p, rv_win)
|
||||
m = S.merge_htf_to_ltf(ltf, feat)
|
||||
c = m["close"].values.astype(float)
|
||||
a = S.atr(m, p.ltf_atr_win)
|
||||
comp_long = np.nan_to_num(m["comp_long"].values).astype(bool)
|
||||
comp_short = np.nan_to_num(m["comp_short"].values).astype(bool)
|
||||
days = pd.to_datetime(m["datetime"]).dt.floor("D").values
|
||||
entries: list = [None] * len(m)
|
||||
count_today: dict = {}
|
||||
for i in range(len(m)):
|
||||
if not np.isfinite(a[i]) or a[i] <= 0:
|
||||
continue
|
||||
day = days[i]
|
||||
if count_today.get(day, 0) >= p.max_per_day:
|
||||
continue
|
||||
if comp_long[i]:
|
||||
direction, mb = 1, p.uscitalong
|
||||
elif comp_short[i]:
|
||||
direction, mb = -1, p.uscitashort
|
||||
else:
|
||||
continue
|
||||
if p.exit_mode == "atr":
|
||||
sl_off, tp_off = p.sl_atr * a[i], p.tp_atr * a[i]
|
||||
else:
|
||||
sl_off, tp_off = p.sl_pct * c[i], p.tp_pct * c[i]
|
||||
if direction == 1:
|
||||
sl, tp = c[i] - sl_off, c[i] + tp_off
|
||||
else:
|
||||
sl, tp = c[i] + sl_off, c[i] - tp_off
|
||||
entries[i] = {"dir": direction, "tp": float(tp), "sl": float(sl), "max_bars": int(mb)}
|
||||
count_today[day] = count_today.get(day, 0) + 1
|
||||
return entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backtest one asset with RV entries -> FULL + HOLDOUT metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
def _split(eq, idx, mask):
|
||||
e = eq[mask]
|
||||
if len(e) < 5:
|
||||
return dict(sharpe=0.0, ret=0.0, maxdd=0.0, n=int(len(e)))
|
||||
r = np.diff(e) / e[:-1]; r = r[np.isfinite(r)]
|
||||
ix = idx[mask]
|
||||
dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400
|
||||
bpy = 86400 * 365.25 / dt
|
||||
sh = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0
|
||||
pk = np.maximum.accumulate(e); dd = float(np.max((pk - e) / pk))
|
||||
return dict(sharpe=round(sh, 3), ret=round(float(e[-1] / e[0] - 1), 4), maxdd=round(dd, 4), n=int(len(e)))
|
||||
|
||||
|
||||
def run_rv(asset: str, p: SkyhookParams, rv_win: int, fee=FEE) -> dict:
|
||||
ltf, htf = sk.frames(asset)
|
||||
ent = rv_entries(ltf, htf, p, rv_win)
|
||||
m = backtest_signals(ltf, ent, fee_rt=fee, leverage=1.0, asset=asset, tf="230m")
|
||||
eq = m.equity
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))
|
||||
hmask = np.asarray(idx >= HOLDOUT)
|
||||
full = dict(sharpe=round(m.sharpe, 3), maxdd=round(m.max_dd, 4), ret=round(m.net_return, 4),
|
||||
n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1))
|
||||
hold = _split(eq, idx, hmask)
|
||||
n_ent = int(sum(e is not None for e in ent))
|
||||
return dict(asset=asset, full=full, holdout=hold, n_entries=n_ent, _eq=eq, _idx=idx)
|
||||
|
||||
|
||||
def causality_rv(p: SkyhookParams, rv_win: int, asset="BTC", tail=200) -> dict:
|
||||
ltf, htf = sk.frames(asset)
|
||||
full = rv_entries(ltf, htf, p, rv_win)
|
||||
n = len(ltf); bad = 0; checked = 0
|
||||
for frac in (0.80, 0.92):
|
||||
cut = int(n * frac)
|
||||
cut_ts = int(ltf["timestamp"].iloc[cut - 1])
|
||||
htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True)
|
||||
sub = rv_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, p, rv_win)
|
||||
for i in range(max(0, cut - tail), cut):
|
||||
checked += 1
|
||||
a, b = full[i], sub[i]
|
||||
if (a is None) != (b is None):
|
||||
bad += 1
|
||||
elif a is not None and (a["dir"] != b["dir"]
|
||||
or abs(a["sl"] - b["sl"]) > 1e-6 or abs(a["tp"] - b["tp"]) > 1e-6):
|
||||
bad += 1
|
||||
return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked))
|
||||
|
||||
|
||||
def minrep(label, p, rv_win, fee=FEE):
|
||||
rb = run_rv("BTC", p, rv_win, fee); re = run_rv("ETH", p, rv_win, fee)
|
||||
mnf = min(rb["full"]["sharpe"], re["full"]["sharpe"])
|
||||
mnh = min(rb["holdout"]["sharpe"], re["holdout"]["sharpe"])
|
||||
mnt = min(rb["full"]["n_trades"], re["full"]["n_trades"])
|
||||
print(f" [{label} rv_win={rv_win}] minFull={mnf:+.2f} minHold={mnh:+.2f} minTr={mnt} "
|
||||
f"BTC(F{rb['full']['sharpe']:+.2f}/H{rb['holdout']['sharpe']:+.2f}/DD{rb['full']['maxdd']*100:.0f}%/n{rb['full']['n_trades']}) "
|
||||
f"ETH(F{re['full']['sharpe']:+.2f}/H{re['holdout']['sharpe']:+.2f}/DD{re['full']['maxdd']*100:.0f}%/n{re['full']['n_trades']})")
|
||||
return mnf, mnh, mnt, rb, re
|
||||
|
||||
|
||||
if __name__ == "__main__" and "--marginal" not in sys.argv:
|
||||
# V1 geometry as the base (best known config). Sweep rv_win and the vola band.
|
||||
base = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
print("=== SKH_R_RV: realized-vol BuzVola gate (V1 geometry) ===")
|
||||
print("-- sweep rv_win (Chande lookback on annualized realized vol), V1 bands --")
|
||||
grid = []
|
||||
for rv_win in (8, 13, 20, 34):
|
||||
p = SkyhookParams(**base)
|
||||
mnf, mnh, mnt, rb, re = minrep("rvband", p, rv_win)
|
||||
grid.append((rv_win, mnf, mnh, mnt))
|
||||
|
||||
# pick best holdout among feasible (minFull>=0.5, minTr>=20)
|
||||
feas = [g for g in grid if g[1] >= 0.5 and g[3] >= 20]
|
||||
pool = feas if feas else grid
|
||||
best = max(pool, key=lambda g: g[2])
|
||||
best_rv = best[0]
|
||||
print(f"\n-- best rv_win by minHold (feasible): rv_win={best_rv} (minFull={best[1]:+.2f} minHold={best[2]:+.2f}) --")
|
||||
|
||||
# Around best rv_win, try widening the vola band (RV distribution may differ from ATR's)
|
||||
print("\n-- band variations at best rv_win --")
|
||||
bandvars = [
|
||||
("V1band", dict(vola_lo=35.0, vola_hi=95.0)),
|
||||
("wide", dict(vola_lo=25.0, vola_hi=98.0)),
|
||||
("midhi", dict(vola_lo=45.0, vola_hi=95.0)),
|
||||
("nogate", dict(vola_lo=0.0, vola_hi=100.0)),
|
||||
]
|
||||
cand = []
|
||||
for nm, bd in bandvars:
|
||||
pp = SkyhookParams(**{**base, **bd})
|
||||
mnf, mnh, mnt, rb, re = minrep(nm, pp, best_rv)
|
||||
cand.append((nm, bd, mnf, mnh, mnt))
|
||||
|
||||
feas2 = [c for c in cand if c[2] >= 0.5 and c[4] >= 20]
|
||||
pool2 = feas2 if feas2 else cand
|
||||
win = max(pool2, key=lambda c: c[3])
|
||||
win_p = SkyhookParams(**{**base, **win[1]})
|
||||
print(f"\n=== WINNER: band={win[0]} rv_win={best_rv} minFull={win[2]:+.2f} minHold={win[3]:+.2f} ===")
|
||||
|
||||
# Causality on winner (both assets)
|
||||
cb = causality_rv(win_p, best_rv, "BTC")
|
||||
ce = causality_rv(win_p, best_rv, "ETH")
|
||||
causal_ok = cb["ok"] and ce["ok"]
|
||||
print(f"causality: BTC={cb} ETH={ce} -> ok={causal_ok}")
|
||||
|
||||
# Fee sweep on winner (min-asset full sharpe at each fee)
|
||||
print("\n-- fee sweep (min-asset FULL sharpe) --")
|
||||
fee_row = {}
|
||||
for f in (0.0, 0.001, 0.002, 0.003):
|
||||
rb = run_rv("BTC", win_p, best_rv, f); re = run_rv("ETH", win_p, best_rv, f)
|
||||
fee_row[f"{f*100:.2f}%RT"] = round(min(rb["full"]["sharpe"], re["full"]["sharpe"]), 3)
|
||||
print(" ", fee_row)
|
||||
fee_survives = fee_row.get("0.30%RT", -9) > 0
|
||||
|
||||
# Marginal vs TP01 on winner. Build daily 50/50 series the same way skyhooklib does.
|
||||
def daily_returns_rv(asset):
|
||||
r = run_rv(asset, win_p, best_rv, FEE)
|
||||
s = pd.Series(r["_eq"], index=r["_idx"])
|
||||
return s.resample("1D").last().ffill().pct_change().dropna()
|
||||
import altlib as al
|
||||
sb = daily_returns_rv("BTC"); se = daily_returns_rv("ETH")
|
||||
J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0)
|
||||
cand_daily = 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
mg = al.marginal_vs_tp01(cand_daily)
|
||||
print("\n-- marginal vs TP01 --")
|
||||
print(f" corr_full={mg.get('corr_full')} verdict={mg.get('marginal_verdict')} "
|
||||
f"w25_uplift_hold={mg.get('blends',{}).get('w25',{}).get('uplift_hold')} "
|
||||
f"clean_year_uplift={mg.get('clean_year_uplift')} has_insample_edge={mg.get('has_insample_edge')} "
|
||||
f"is_hedge={mg.get('is_hedge')} robust_oos={mg.get('robust_oos')}")
|
||||
|
||||
# Final per-asset detail at winner
|
||||
rb = run_rv("BTC", win_p, best_rv); re = run_rv("ETH", win_p, best_rv)
|
||||
print("\n=== FINAL (winner) ===")
|
||||
for a, r in (("BTC", rb), ("ETH", re)):
|
||||
f, h = r["full"], r["holdout"]
|
||||
print(f" {a}: FULL Sh={f['sharpe']:+.2f} ret={f['ret']*100:+.0f}% DD={f['maxdd']*100:.0f}% "
|
||||
f"n={f['n_trades']} wr={f['win_rate']:.0f}% | HOLD Sh={h['sharpe']:+.2f} ret={h['ret']*100:+.0f}% "
|
||||
f"DD={h['maxdd']*100:.0f}% n_entries={r['n_entries']}")
|
||||
import json
|
||||
summary = dict(label="R_RV", rv_win=best_rv, band=win[0], band_params=win[1],
|
||||
min_full=round(win[2], 3), min_hold=round(win[3], 3), min_trades=int(win[4]),
|
||||
btc_full=rb["full"]["sharpe"], eth_full=re["full"]["sharpe"],
|
||||
btc_hold=rb["holdout"]["sharpe"], eth_hold=re["holdout"]["sharpe"],
|
||||
avg_dd=round((rb["full"]["maxdd"] + re["full"]["maxdd"]) / 2, 4),
|
||||
causality_ok=causal_ok, fee_survives=fee_survives,
|
||||
corr_to_tp01=mg.get("corr_full"),
|
||||
blend_w25_uplift_hold=mg.get("blends", {}).get("w25", {}).get("uplift_hold"),
|
||||
marginal_verdict=mg.get("marginal_verdict"))
|
||||
print("\nJSON " + json.dumps(summary, default=str))
|
||||
|
||||
|
||||
def _full_marginal():
|
||||
"""Re-run winner and dump the COMPLETE marginal dict + per-year, for the final report."""
|
||||
import json, altlib as al
|
||||
base = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=45.0, vola_hi=95.0, vol_lo=0.0)
|
||||
p = SkyhookParams(**base); rv_win = 34
|
||||
def daily(asset):
|
||||
r = run_rv(asset, p, rv_win, FEE)
|
||||
s = pd.Series(r["_eq"], index=r["_idx"])
|
||||
return s.resample("1D").last().ffill().pct_change().dropna()
|
||||
J = pd.concat({"BTC": daily("BTC"), "ETH": daily("ETH")}, axis=1, join="inner").fillna(0.0)
|
||||
cd = 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
mg = al.marginal_vs_tp01(cd)
|
||||
print("FULL MARGINAL:", json.dumps({k: mg.get(k) for k in
|
||||
("corr_full","corr_hold","marginal_verdict","has_insample_edge","is_hedge",
|
||||
"robust_oos","clean_year_uplift","jackknife_min_uplift","multicut_persistent",
|
||||
"multicut_uplift","cand_full_sharpe","cand_hold_sharpe","alpha_ann","resid_sharpe_full",
|
||||
"null_pctl_insample")}, default=str))
|
||||
print("BLENDS:", json.dumps(mg.get("blends"), default=str))
|
||||
# per-year via backtest yearly on each asset
|
||||
for a in ("BTC","ETH"):
|
||||
ltf, htf = sk.frames(a); ent = rv_entries(ltf, htf, p, rv_win)
|
||||
m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m")
|
||||
yr = " ".join(f"{int(y)}:{v*100:+.0f}%" for y, v in m.yearly.items())
|
||||
print(f" {a} per-year: {yr}")
|
||||
|
||||
if __name__ == "__main__" and "--marginal" in sys.argv:
|
||||
_full_marginal()
|
||||
@@ -0,0 +1,217 @@
|
||||
"""skyhooklib — SHARED HONEST EVAL for the Skyhook (SKH01) multi-agent improvement wave.
|
||||
|
||||
Every agent imports THIS so results are comparable and leak-free:
|
||||
* data builders: certified 5m BTC/ETH -> 230m (exec) + 690m (signal), cached.
|
||||
* study(): FULL + HOLD-OUT (2025-01-01+) + fee sweep + per-year, on BOTH assets, via the
|
||||
project's honest intrabar engine (backtest_signals: TP/SL/max_bars, non-overlap).
|
||||
* causality(): truncated-prefix guard (a Skyhook entry on a prefix must match the full run).
|
||||
* marginal(): does Skyhook ADD to the existing TP01 portfolio? (altlib.marginal_vs_tp01).
|
||||
* verdict(): conservative PASS/WEAK/FAIL on min-asset FULL & HOLD-OUT + fee survival.
|
||||
|
||||
Quick start (inside an agent script):
|
||||
import sys; sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
rep = sk.study("MY-VARIANT", SkyhookParams(ptn_n=20, sl_atr=2.5))
|
||||
print(sk.fmt(rep)); print(sk.as_json(rep))
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
from src.backtest.harness import backtest_signals # noqa: E402
|
||||
from src.data.downloader import load_data # noqa: E402
|
||||
from src.strategies.skyhook import ( # noqa: E402
|
||||
SkyhookParams, build_frames, skyhook_entries, signal_counts)
|
||||
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
FEE_RT = 0.001 # 0.10% round-trip (Deribit taker)
|
||||
FEE_SWEEP = (0.0, 0.001, 0.002, 0.003) # round-trip fee grid
|
||||
CERTIFIED = ("BTC", "ETH")
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _frames(asset: str):
|
||||
return build_frames(load_data(asset, "5m"))
|
||||
|
||||
|
||||
def frames(asset: str):
|
||||
"""(ltf 230m, htf 690m) certificati e cached."""
|
||||
return _frames(asset.upper())
|
||||
|
||||
|
||||
def _split_metrics(eq: np.ndarray, idx: pd.DatetimeIndex, mask: np.ndarray) -> dict:
|
||||
e = eq[mask]
|
||||
if len(e) < 5:
|
||||
return dict(sharpe=0.0, ret=0.0, maxdd=0.0, n=int(len(e)))
|
||||
r = np.diff(e) / e[:-1]
|
||||
r = r[np.isfinite(r)]
|
||||
ix = idx[mask]
|
||||
dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400
|
||||
bpy = 86400 * 365.25 / dt
|
||||
sharpe = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0
|
||||
pk = np.maximum.accumulate(e)
|
||||
dd = float(np.max((pk - e) / pk))
|
||||
return dict(sharpe=round(sharpe, 3), ret=round(float(e[-1] / e[0] - 1), 4),
|
||||
maxdd=round(dd, 4), n=int(len(e)))
|
||||
|
||||
|
||||
def run_asset(asset: str, p: SkyhookParams, fee_rt: float = FEE_RT) -> dict:
|
||||
"""Backtest Skyhook su un asset (230m exec). Ritorna FULL+HOLDOUT+per-anno+diagnostica."""
|
||||
ltf, htf = frames(asset)
|
||||
entries = skyhook_entries(ltf, htf, p)
|
||||
m = backtest_signals(ltf, entries, fee_rt=fee_rt, leverage=1.0, asset=asset, tf="230m")
|
||||
eq = m.equity
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))
|
||||
hmask = np.asarray(idx >= HOLDOUT)
|
||||
full = dict(sharpe=round(m.sharpe, 3), cagr=round(m.cagr, 4), maxdd=round(m.max_dd, 4),
|
||||
ret=round(m.net_return, 4), n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1))
|
||||
hold = _split_metrics(eq, idx, hmask)
|
||||
counts = signal_counts(ltf, htf, p)
|
||||
return dict(asset=asset, full=full, holdout=hold,
|
||||
yearly={int(y): round(v, 4) for y, v in m.yearly.items()},
|
||||
counts=counts, _eq=eq, _idx=idx)
|
||||
|
||||
|
||||
def daily_returns(asset: str, p: SkyhookParams, fee_rt: float = FEE_RT) -> pd.Series:
|
||||
"""Rendimenti GIORNALIERI dell'equity Skyhook (per il lens marginal-vs-TP01).
|
||||
NB approssimazione: l'equity di backtest_signals e' marcata a fine-trade (a gradini),
|
||||
quindi i daily sono grezzi -> usalo SOLO per corr/uplift, non come headline Sharpe."""
|
||||
r = run_asset(asset, p, fee_rt)
|
||||
s = pd.Series(r["_eq"], index=r["_idx"])
|
||||
return (s.resample("1D").last().ffill().pct_change().dropna())
|
||||
|
||||
|
||||
def skyhook_daily_5050(p: SkyhookParams, fee_rt: float = FEE_RT) -> pd.Series:
|
||||
"""Serie giornaliera 50/50 BTC+ETH (stessa convenzione di altlib.tp01_baseline_daily)."""
|
||||
series = {a: daily_returns(a, p, fee_rt) for a in CERTIFIED}
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
return 0.5 * J[CERTIFIED[0]] + 0.5 * J[CERTIFIED[1]]
|
||||
|
||||
|
||||
def marginal(p: SkyhookParams, fee_rt: float = FEE_RT) -> dict:
|
||||
"""Skyhook MIGLIORA il portafoglio TP01 esistente? (altlib.marginal_vs_tp01)."""
|
||||
import altlib as al
|
||||
return al.marginal_vs_tp01(skyhook_daily_5050(p, fee_rt))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Causality guard (truncated-prefix): un ingresso emesso su un prefisso deve coincidere
|
||||
# con lo stesso indice della run completa (nessuna feature guarda il futuro).
|
||||
# ---------------------------------------------------------------------------
|
||||
def causality(p: SkyhookParams, asset: str = "BTC", tail: int = 200) -> dict:
|
||||
ltf, htf = frames(asset)
|
||||
full = skyhook_entries(ltf, htf, p)
|
||||
n = len(ltf)
|
||||
bad = 0; checked = 0
|
||||
for frac in (0.80, 0.92):
|
||||
cut = int(n * frac)
|
||||
# taglia anche l'HTF alla stessa data di chiusura del prefisso LTF
|
||||
cut_ts = int(ltf["timestamp"].iloc[cut - 1])
|
||||
htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True)
|
||||
sub = skyhook_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, p)
|
||||
for i in range(max(0, cut - tail), cut):
|
||||
checked += 1
|
||||
a, b = full[i], sub[i]
|
||||
if (a is None) != (b is None):
|
||||
bad += 1
|
||||
elif a is not None and (a["dir"] != b["dir"]
|
||||
or abs(a["sl"] - b["sl"]) > 1e-6
|
||||
or abs(a["tp"] - b["tp"]) > 1e-6):
|
||||
bad += 1
|
||||
return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verdict + drivers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _verdict(per_asset: dict, fee_survives: bool) -> dict:
|
||||
min_full = min(per_asset[a]["full"]["sharpe"] for a in per_asset)
|
||||
min_hold = min(per_asset[a]["holdout"]["sharpe"] for a in per_asset)
|
||||
min_trades = min(per_asset[a]["full"]["n_trades"] for a in per_asset)
|
||||
enough = min_trades >= 20
|
||||
pass_ = enough and min_full >= 0.5 and min_hold >= 0.2 and fee_survives
|
||||
weak = enough and min_full >= 0.3 and min_hold >= 0.0
|
||||
grade = "PASS" if pass_ else ("WEAK" if weak else "FAIL")
|
||||
return dict(grade=grade, min_asset_full_sharpe=round(min_full, 3),
|
||||
min_asset_holdout_sharpe=round(min_hold, 3),
|
||||
min_trades=int(min_trades), fee_survives=bool(fee_survives))
|
||||
|
||||
|
||||
def study(name: str, p: SkyhookParams | None = None, assets=CERTIFIED,
|
||||
fee_sweep=FEE_SWEEP) -> dict:
|
||||
"""Run completo: FULL+HOLDOUT+fee-sweep+per-anno su BTCÐ + verdict conservativo."""
|
||||
p = p or SkyhookParams()
|
||||
per_asset = {}
|
||||
fee_ok_all = True
|
||||
for a in assets:
|
||||
r = run_asset(a, p, FEE_RT)
|
||||
sweep = {}
|
||||
for f in fee_sweep:
|
||||
rf = run_asset(a, p, f)
|
||||
sweep[f"{f*100:.2f}%RT"] = rf["full"]["sharpe"]
|
||||
fee_ok = sweep.get("0.30%RT", -9) > 0
|
||||
fee_ok_all = fee_ok_all and fee_ok
|
||||
per_asset[a] = dict(full=r["full"], holdout=r["holdout"], yearly=r["yearly"],
|
||||
counts=r["counts"], fee_sweep=sweep)
|
||||
return dict(name=name, params=_params_dict(p), per_asset=per_asset,
|
||||
verdict=_verdict(per_asset, fee_ok_all))
|
||||
|
||||
|
||||
def _params_dict(p: SkyhookParams) -> dict:
|
||||
return {k: getattr(p, k) for k in p.__dataclass_fields__}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
def _clean(o):
|
||||
if isinstance(o, dict):
|
||||
return {k: _clean(v) for k, v in o.items() if not k.startswith("_")}
|
||||
if isinstance(o, (list, tuple)):
|
||||
return [_clean(x) for x in o]
|
||||
if isinstance(o, (np.floating,)):
|
||||
return round(float(o), 4)
|
||||
if isinstance(o, (np.integer,)):
|
||||
return int(o)
|
||||
if isinstance(o, (np.bool_,)):
|
||||
return bool(o)
|
||||
return o
|
||||
|
||||
|
||||
def as_json(rep: dict) -> str:
|
||||
return json.dumps(_clean(rep), default=str)
|
||||
|
||||
|
||||
def fmt(rep: dict) -> str:
|
||||
v = rep["verdict"]
|
||||
lines = [f"=== {rep['name']} -> {v['grade']} "
|
||||
f"(minFull={v['min_asset_full_sharpe']:+.2f} minHold={v['min_asset_holdout_sharpe']:+.2f} "
|
||||
f"minTrades={v['min_trades']} feeOK={v['fee_survives']})"]
|
||||
for a, pa in rep["per_asset"].items():
|
||||
f, h, c = pa["full"], pa["holdout"], pa["counts"]
|
||||
yr = " ".join(f"{y}:{r*100:+.0f}%" for y, r in pa["yearly"].items())
|
||||
lines.append(f" {a}: FULL Sh={f['sharpe']:+.2f} ret={f['ret']*100:+.0f}% DD={f['maxdd']*100:.0f}% "
|
||||
f"n={f['n_trades']} wr={f['win_rate']:.0f}% HOLD Sh={h['sharpe']:+.2f} ret={h['ret']*100:+.0f}% "
|
||||
f"| entries={c['entries']} (L{c['comp_long']}/S{c['comp_short']})")
|
||||
lines.append(f" fee sweep: " + " ".join(f"{k}={val:+.2f}" for k, val in pa["fee_sweep"].items()))
|
||||
lines.append(f" per-anno: {yr}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("--- SMOKE skyhooklib: baseline SkyhookParams() ---")
|
||||
rep = study("SKH01-BASELINE", SkyhookParams())
|
||||
print(fmt(rep))
|
||||
print("\ncausality:", causality(SkyhookParams()))
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Fast inline lever-scout for Skyhook before the agent wave. One fee (0.10% RT), both assets,
|
||||
min-asset FULL & HOLD-OUT Sharpe. Maps which knobs move the honest hold-out."""
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
|
||||
import skyhooklib as sk
|
||||
from src.strategies.skyhook import SkyhookParams
|
||||
|
||||
|
||||
def quick(p: SkyhookParams) -> dict:
|
||||
rs = {a: sk.run_asset(a, p, sk.FEE_RT) for a in sk.CERTIFIED}
|
||||
mf = min(rs[a]["full"]["sharpe"] for a in rs)
|
||||
mh = min(rs[a]["holdout"]["sharpe"] for a in rs)
|
||||
mt = min(rs[a]["full"]["n_trades"] for a in rs)
|
||||
avg_dd = sum(rs[a]["full"]["maxdd"] for a in rs) / 2
|
||||
return dict(minFull=mf, minHold=mh, minTr=mt, dd=round(avg_dd, 3),
|
||||
btc=rs["BTC"]["full"]["sharpe"], eth=rs["ETH"]["full"]["sharpe"],
|
||||
btcH=rs["BTC"]["holdout"]["sharpe"], ethH=rs["ETH"]["holdout"]["sharpe"])
|
||||
|
||||
|
||||
base = SkyhookParams()
|
||||
GRID = {
|
||||
"long_only": [dict(long_only=True), dict(long_only=False)],
|
||||
"ptn_n": [dict(ptn_n=n) for n in (8, 13, 20, 34, 55)],
|
||||
"RR(sl,tp)": [dict(sl_atr=s, tp_atr=t) for s, t in
|
||||
((1.5, 4.0), (2.0, 5.0), (2.5, 6.0), (3.0, 4.0), (2.0, 8.0), (3.0, 9.0))],
|
||||
"exitbars": [dict(uscitalong=l, uscitashort=s) for l, s in
|
||||
((12, 9), (24, 18), (36, 24), (48, 36))],
|
||||
"vola_band": [dict(vola_lo=lo, vola_hi=hi) for lo, hi in
|
||||
((0, 100), (35, 95), (50, 100), (50, 90), (20, 80))],
|
||||
"vol_band": [dict(vol_lo=lo, vol_hi=hi) for lo, hi in
|
||||
((0, 100), (50, 100), (60, 100), (40, 100), (50, 80))],
|
||||
}
|
||||
|
||||
print(f"{'param':<14s} {'value':<28s} {'minF':>6s} {'minH':>6s} {'btc/eth F':>12s} {'btc/eth H':>12s} {'tr':>5s} {'dd':>5s}")
|
||||
print(f" BASELINE: {quick(base)}")
|
||||
print("-" * 100)
|
||||
for fam, variants in GRID.items():
|
||||
for v in variants:
|
||||
p = replace(base, **v)
|
||||
q = quick(p)
|
||||
tag = "PASS" if (q["minFull"] >= 0.5 and q["minHold"] >= 0.2) else ""
|
||||
print(f"{fam:<14s} {str(v):<28s} {q['minFull']:>+6.2f} {q['minHold']:>+6.2f} "
|
||||
f"{q['btc']:>+5.2f}/{q['eth']:>+5.2f} {q['btcH']:>+5.2f}/{q['ethH']:>+5.2f} "
|
||||
f"{q['minTr']:>5d} {q['dd']*100:>4.0f}% {tag}")
|
||||
@@ -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,
|
||||
}
|
||||
+50
-4
@@ -1,4 +1,4 @@
|
||||
"""DASHBOARD web del portafoglio attivo (TP01 + XS01) — monitoraggio PAPER, stdlib only.
|
||||
"""DASHBOARD web del portafoglio attivo (TP01 + XS01 + VRP01 + SKH01) — monitoraggio PAPER, stdlib only.
|
||||
|
||||
Mostra: metriche (FULL/HOLD Sharpe, DD, CAGR), per-sleeve, posizioni correnti, equity (backtest +
|
||||
paper forward da scripts/live/paper_portfolio.py), ultima data dato. Nessuna auth -> solo rete
|
||||
@@ -15,11 +15,13 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np, pandas as pd
|
||||
from src.portfolio.portfolio import StrategyPortfolio, metrics, HOLDOUT
|
||||
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.version import APP_VERSION
|
||||
|
||||
PAPER = PROJECT_ROOT / "data" / "paper_portfolio" / "state.json"
|
||||
PREVDAY = PROJECT_ROOT / "data" / "paper_prevday" / "state.json"
|
||||
COMBO = PROJECT_ROOT / "data" / "paper_combo" / "state.json"
|
||||
_CACHE = {"t": 0.0, "data": None}
|
||||
_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)]
|
||||
paper = json.loads(PAPER.read_text()) if PAPER.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:
|
||||
shadow = shadow_report() # mainnet sola lettura, best-effort
|
||||
except Exception as e:
|
||||
@@ -49,6 +56,7 @@ def build():
|
||||
full=bt["full"], holdout=bt["holdout"], weights=bt["weights"],
|
||||
per_sleeve=bt["per_sleeve"], yearly=bt["yearly"],
|
||||
positions=pf.current_positions(), spark=spark, paper=paper, prevday=prevday,
|
||||
combo=combo, gtaa_weights=gtaa_w,
|
||||
shadow=shadow, trades=trades, bh=None,
|
||||
)
|
||||
_CACHE.update(t=time.time(), data=data)
|
||||
@@ -79,7 +87,19 @@ def html():
|
||||
yrs = "".join(f"<span class=y>{y}: {v['ret']*100:+.0f}%</span>" for y, v in sorted(d["yearly"].items()))
|
||||
pos = ""
|
||||
for sl, p in d["positions"].items():
|
||||
pos += f"<tr><td>{sl}</td><td>{'flat (in cash)' if p == {'BTC': 0.0, 'ETH': 0.0} else (p if p is not None else 'stat-mode (book 19 gambe)')}</td></tr>"
|
||||
if p == {'BTC': 0.0, 'ETH': 0.0}:
|
||||
ptxt = 'flat (in cash)'
|
||||
elif p is not None:
|
||||
ptxt = str(p)
|
||||
elif 'XS01' in sl:
|
||||
ptxt = 'stat-mode (book 19 gambe)'
|
||||
elif 'SKH' in sl:
|
||||
ptxt = 'forward-monitor (segnale dual-TF, no pos-fn)'
|
||||
elif 'VRP' in sl:
|
||||
ptxt = 'stat-mode (book opzioni settimanale)'
|
||||
else:
|
||||
ptxt = 'n/d'
|
||||
pos += f"<tr><td>{sl}</td><td>{ptxt}</td></tr>"
|
||||
pp = d["paper"]
|
||||
if pp:
|
||||
days = (pd.Timestamp(pp["last"]) - pd.Timestamp(pp["start"])).days
|
||||
@@ -104,6 +124,28 @@ def html():
|
||||
f"forward da {pd.Timestamp(pv['start_ts'], unit='ms').date()}")
|
||||
else:
|
||||
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")
|
||||
if sh and "error" not in sh:
|
||||
bits = " · ".join(
|
||||
@@ -156,7 +198,7 @@ th{{color:#8a93a0;font-weight:500}}.y{{display:inline-block;background:#161b22;b
|
||||
.warn{{color:#f1c40f;font-size:12px}}
|
||||
.section{{font-size:15px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;margin:34px 0 14px;padding:10px 14px;border-radius:9px;background:#12181f;border-left:5px solid #2ecc71;color:#d7dee6}}
|
||||
.section.live{{border-left-color:#e74c3c;background:#1c1316;color:#f0c4c4}}</style></head><body>
|
||||
<h1>PythagorasGoal — Portafoglio attivo (TP01 + XS01 + VRP01)</h1>
|
||||
<h1>PythagorasGoal — Portafoglio attivo (TP01 + XS01 + VRP01 + SKH01)</h1>
|
||||
<div class=sub>monitor · v{d['version']} · ultimo dato {d['last_data']} · esecuzione REALE non attiva (solo micro-test)</div>
|
||||
<div class="section">PAPER — simulato (backtest + forward virtuale)</div>
|
||||
<div class=cards>
|
||||
@@ -175,6 +217,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>
|
||||
<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 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=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>
|
||||
@@ -182,7 +228,7 @@ th{{color:#8a93a0;font-weight:500}}.y{{display:inline-block;background:#161b22;b
|
||||
<div class=box><b>Shadow TP01</b> (cosa farebbe ORA sul conto reale, nessun ordine inviato):<br>{shadow_html}</div>
|
||||
<h3 style="font-size:14px;color:#8a93a0">Trades REALI eseguiti su Deribit</h3>
|
||||
<table><tr><th>data/ora UTC</th><th>strum.</th><th>dir</th><th>amount</th><th>prezzo</th><th>fee USDC</th></tr>{live_trows}</table>
|
||||
<p class=warn>⚠️ Paper/monitor. XS01 e' STAT-MODE (book a 19 gambe market-neutral, non eseguibile a €2k, storia ~2.5 anni). VRP01 = lead short-vol MODELLATO (non deploy pieno). TP01 e' l'unico deployable pieno: lo "Shadow live" mostra cosa farebbe sul mainnet, ma NON invia ordini.</p>
|
||||
<p class=warn>⚠️ Paper/monitor. XS01 e' STAT-MODE (book a 19 gambe market-neutral, non eseguibile a €2k, storia ~2.5 anni). VRP01 = lead short-vol MODELLATO (non deploy pieno). SKH01 (Skyhook dual-TF regime+breakout, BTC/ETH) = diversificatore quasi-ortogonale (corr ~0.09) aggiunto @25%: alza il FULL Sharpe del portafoglio 1.68→2.13 e dimezza il DD (14→8%) — RESEARCH/forward-monitor (book a 230m, causalita' verificata su harness ma costi reali e codice d'esecuzione da validare prima del deploy). TP01 e' l'unico deployable pieno: lo "Shadow live" mostra cosa farebbe sul mainnet, ma NON invia ordini.</p>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -207,12 +207,49 @@ def vrp_sleeve(weight: float = 0.20) -> Sleeve:
|
||||
return Sleeve("VRP01_shortvol", weight, _vrp_combo_returns)
|
||||
|
||||
|
||||
# ----------------------------- SKH01-V2-DD: Skyhook dual-TF regime+breakout (BTC/ETH) -----------------------------
|
||||
# Sistema dual-timeframe (segnale 690m, exec 230m): entra solo quando coincidono REGIME
|
||||
# (BuzVola/BuzVolume tipo-Chande) E PATTERN (Donchian breakout). NON e' un trend-follower.
|
||||
# Vincitrice dell'onda DD-reduction (famiglia ASYM_LS): exit a percentuale fissa ASIMMETRICA
|
||||
# (long sl4%/tp10%, short sl2%/tp8% piu' stretto) -> taglia il DD standalone (BTC 21% / ETH 27%)
|
||||
# alzando hold-out (minHold +1.26) e valore di portafoglio. Quasi-ortogonale a TP01 (corr ~0.09):
|
||||
# blend 0.75*TP01+0.25*SKH -> hold-out Sharpe 0.31->1.17 (+0.87), DD full 14%->9%. Marginal ADDS,
|
||||
# has_insample_edge, robust_oos (multicut 7/7 anni), is_hedge=False. Verificato leak-free (causalita'
|
||||
# 0/400) + 2 scettici avversariali. Diario 2026-06-23-skyhook.md.
|
||||
# CAVEAT ONESTI: equity marcata a fine-trade (daily lumpy); ETH DD 27% ha margine sottile vs 30%;
|
||||
# il book opera a 230m -> ribilanciamento piu' frequente del resto (verificare costi reali a deploy).
|
||||
from src.strategies.skyhook import SKH01_V2_DD, build_frames, skyhook_entries
|
||||
from src.backtest.harness import backtest_signals
|
||||
|
||||
|
||||
def _skyhook_returns() -> pd.Series:
|
||||
"""SKH01-V2-DD: book 50/50 BTC+ETH del sistema regime+breakout dual-TF, riportato su griglia
|
||||
GIORNALIERA. Causale (decide a close[i], exit intrabar TP/SL/max_bars, non-overlap), netto 0.10% RT."""
|
||||
series = {}
|
||||
for a in ASSETS:
|
||||
ltf, htf = build_frames(load_data(a, "5m"))
|
||||
ent = skyhook_entries(ltf, htf, SKH01_V2_DD)
|
||||
m = backtest_signals(ltf, ent, fee_rt=0.001, leverage=1.0, asset=a, tf="230m")
|
||||
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
|
||||
series[a] = s.resample("1D").last().ffill().pct_change().dropna()
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index)
|
||||
|
||||
|
||||
def skyhook_sleeve(weight: float = 0.25) -> Sleeve:
|
||||
return Sleeve("SKH01_skyhook", weight, _skyhook_returns)
|
||||
|
||||
|
||||
# ----------------------------- REGISTRY -----------------------------
|
||||
def active_sleeves() -> list[Sleeve]:
|
||||
"""Sleeve ATTIVI nel portafoglio (pesi rinormalizzati; sleeve a date diverse si attivano
|
||||
quando parte la loro storia). Aggiungere qui SOLO strategie validate col gauntlet."""
|
||||
quando parte la loro storia). Aggiungere qui SOLO strategie validate col gauntlet.
|
||||
|
||||
SKH01 cablato @0.25 effettivo: i tre sleeve preesistenti scalati nel restante 0.75 mantenendo
|
||||
il loro rapporto 55:25:20 (-> 41.25/18.75/15), cosi' Skyhook pesa esattamente 25% del book."""
|
||||
return [
|
||||
tp01_sleeve(weight=0.55), # trend difensivo, BTC/ETH, dal 2019 (l'unico deployable pieno)
|
||||
xsec_sleeve(weight=0.25), # cross-sectional momentum Hyperliquid, dal 2024 (scorrelato, stat-mode)
|
||||
vrp_sleeve(weight=0.20), # options short-vol (put credit spread + gate IV-rank), dal 2021 (lead modellato, scorrelato)
|
||||
tp01_sleeve(weight=0.4125), # trend difensivo, BTC/ETH, dal 2019 (l'unico deployable pieno)
|
||||
xsec_sleeve(weight=0.1875), # cross-sectional momentum Hyperliquid, dal 2024 (scorrelato, stat-mode)
|
||||
vrp_sleeve(weight=0.15), # options short-vol (put credit spread + gate IV-rank), dal 2021 (lead modellato, scorrelato)
|
||||
skyhook_sleeve(weight=0.25), # dual-TF regime+breakout BTC/ETH, dal 2019 (quasi-ortogonale, exit %-asimmetrici, research)
|
||||
]
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""SKYHOOK (SKH01) — dual-timeframe regime+breakout system, ported to BTC/ETH (2026-06-23).
|
||||
|
||||
NON e' un trend-follower: entra SOLO quando coincidono (a) un REGIME di volatilita'/volume e
|
||||
(b) un PATTERN di breakout/momentum. Porting onesto su BTC/ETH certificati (Deribit mainnet)
|
||||
di un sistema ES (E-mini S&P) genetico a doppio timeframe.
|
||||
|
||||
Architettura (dal brief):
|
||||
* data2 = HTF 690 min (genera il SEGNALE: regime + pattern)
|
||||
* data1 = LTF 230 min (ESEGUE: ingressi/uscite) NB 690 = 3 x 230 (HTF = 3x LTF)
|
||||
Entrambi resampled dal feed 5m certificato con origin='epoch' -> i confini 690 sono un
|
||||
SOTTOINSIEME dei confini 230, quindi una barra HTF chiude esattamente su una chiusura LTF.
|
||||
|
||||
Pipeline per barra (evaluate_bar): barre -> indicatori -> fasce regime -> pattern -> composer
|
||||
-> ingresso/uscita -> SkyhookDecision
|
||||
1. INDICATORI (sul HTF, tipo-Chande, normalizzati 0-100):
|
||||
BuzVola = chande01(ATR) -> dove sei nel CICLO di volatilita' (flat -> 50)
|
||||
BuzVolume= chande01(volume) -> dove sei nel CICLO di volume (rampa -> 100)
|
||||
Ancore della demo del brief (trend lineare): ATR costante -> BuzVola=50 (neutro);
|
||||
volume in rampa -> BuzVolume=100. Entrambe RICOSTRUITE esattamente da chande01.
|
||||
2. FASCE REGIME (Vola, Volume): trade ammesso solo se BuzVola in [vola_lo,vola_hi] E
|
||||
BuzVolume in [vol_lo,vol_hi]. (Le "fasce 4/3/2 - 4/2/2" del sistema originale sono
|
||||
ricostruite come bande-soglia tunabili: i magici interi non sono nel brief.)
|
||||
3. PATTERN (breakout su data2/HTF): Donchian leak-free a `ptn_n` barre (default 13, da 13/13/1).
|
||||
ptn_long = close_htf rompe il massimo delle ptn_n barre PRECEDENTI
|
||||
ptn_short = close_htf rompe il minimo delle ptn_n barre PRECEDENTI
|
||||
4. COMPOSER: contenitore_long = regime_ok AND ptn_long ; contenitore_short = regime_ok AND ptn_short
|
||||
5. INGRESSO (max 1 al giorno): se il composer e' attivo -> OPEN_LONG / OPEN_SHORT alla
|
||||
chiusura LTF. (stop-and-reverse: non-overlap nell'engine -> il rovescio entra alla prima
|
||||
barra utile dopo l'uscita se il segnale persiste.)
|
||||
6. USCITE: time-based ASIMMETRICO (uscitalong=24, uscitashort=18 barre LTF) + hard stop/profit.
|
||||
Lo "stop 2000 / profit 5000" in $ del sistema ES e' tradotto in CRYPTO come multipli di ATR
|
||||
LTF (scale-free): sl = k_sl*ATR, tp = k_tp*ATR (default 2.0/5.0 ~ il rapporto 40:100 pt ES),
|
||||
con modalita' 'pct' alternativa (stop/profit in percentuale).
|
||||
|
||||
CAUSALITA': ogni feature usa dati <= close della barra (HTF: donchian con shift(1), chande01
|
||||
rolling causale). Il merge HTF->LTF e' merge_asof BACKWARD sulla CHIUSURA HTF (<= chiusura LTF):
|
||||
una barra HTF e' usata solo quando e' realmente chiusa. backtest_signals apre a close[i].
|
||||
|
||||
API:
|
||||
from src.strategies.skyhook import SkyhookParams, build_frames, skyhook_entries
|
||||
ltf, htf = build_frames(load_data("BTC","5m")) # resample 5m -> 230m + 690m
|
||||
entries = skyhook_entries(ltf, htf, SkyhookParams()) # list[dict|None] len(ltf), per backtest_signals
|
||||
from src.backtest.harness import backtest_signals
|
||||
m = backtest_signals(ltf, entries, fee_rt=0.001); m.print_summary("SKH01 BTC")
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# 690 = 3 x 230 ; entrambi multipli esatti di 5m (138 e 46 barre da 5m)
|
||||
HTF_MIN = 690 # data2 — segnale
|
||||
LTF_MIN = 230 # data1 — esecuzione
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resample dal feed 5m certificato (origin='epoch' -> confini deterministici e allineati)
|
||||
# ---------------------------------------------------------------------------
|
||||
def resample_5m(df5: pd.DataFrame, minutes: int) -> pd.DataFrame:
|
||||
"""5m -> `minutes` barre (origin epoch). Schema con 'datetime' + 'timestamp' (open-labeled)."""
|
||||
g = df5[["timestamp", "open", "high", "low", "close", "volume"]].copy()
|
||||
g.index = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
|
||||
out = (g.resample(f"{minutes}min", label="left", closed="left", origin="epoch")
|
||||
.agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
|
||||
.dropna(subset=["open"]))
|
||||
out["datetime"] = out.index
|
||||
epoch = pd.Timestamp("1970-01-01", tz="UTC")
|
||||
out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
|
||||
return out.reset_index(drop=True)[["timestamp", "open", "high", "low", "close", "volume", "datetime"]]
|
||||
|
||||
|
||||
def build_frames(df5: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""Da un feed 5m certificato -> (ltf 230m exec, htf 690m signal)."""
|
||||
return resample_5m(df5, LTF_MIN), resample_5m(df5, HTF_MIN)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Indicatori causali
|
||||
# ---------------------------------------------------------------------------
|
||||
def atr(df: pd.DataFrame, win: int = 14) -> np.ndarray:
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
pc = np.roll(c, 1); pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
return pd.Series(tr).ewm(alpha=1.0 / win, adjust=False).mean().values
|
||||
|
||||
|
||||
def chande01(x: np.ndarray, n: int) -> np.ndarray:
|
||||
"""Chande Momentum Oscillator su `x`, normalizzato 0-100 (tipo-Chande).
|
||||
CMO = (Su - Sd)/(Su + Sd) in [-1,1] sulle n variazioni; mappato (1+CMO)*50 -> [0,100].
|
||||
Serie piatta (variazioni nulle) -> 50 (neutro). Causale (rolling fino a i)."""
|
||||
x = np.asarray(x, float)
|
||||
d = np.diff(x, prepend=x[0])
|
||||
up = np.where(d > 0, d, 0.0)
|
||||
dn = np.where(d < 0, -d, 0.0)
|
||||
su = pd.Series(up).rolling(n, min_periods=n).sum().values
|
||||
sd = pd.Series(dn).rolling(n, min_periods=n).sum().values
|
||||
denom = su + sd
|
||||
cmo = np.divide(su - sd, denom, out=np.zeros_like(denom), where=denom > 0)
|
||||
out = 50.0 * (1.0 + cmo)
|
||||
out[~np.isfinite(out)] = 50.0
|
||||
return out
|
||||
|
||||
|
||||
def donchian_breakout(df: pd.DataFrame, n: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Breakout leak-free: close[i] rompe il max/min delle n barre STRETTAMENTE precedenti."""
|
||||
hi = pd.Series(df["high"].values).rolling(n, min_periods=n).max().shift(1).values
|
||||
lo = pd.Series(df["low"].values).rolling(n, min_periods=n).min().shift(1).values
|
||||
c = df["close"].values.astype(float)
|
||||
return (c > hi), (c < lo)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parametri
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class SkyhookParams:
|
||||
# indicatori (HTF)
|
||||
atr_win: int = 14
|
||||
n_vola: int = 13 # finestra Chande su ATR (da PtnL 13)
|
||||
n_volume: int = 13 # finestra Chande su volume (da PtnL 13)
|
||||
# fasce regime (bande-soglia su 0-100). Default = "regime di breakout":
|
||||
# volume vivo (BuzVolume alto) + volatilita' presente ma non da blow-off.
|
||||
vola_lo: float = 35.0
|
||||
vola_hi: float = 95.0
|
||||
vol_lo: float = 50.0
|
||||
vol_hi: float = 100.0
|
||||
# pattern (HTF) — Donchian breakout
|
||||
ptn_n: int = 13 # da PtnL 13/13/1
|
||||
# composer / direzione
|
||||
long_only: bool = False # Skyhook e' L/S di natura; True = solo long (stile crypto difensivo)
|
||||
# ingresso
|
||||
max_per_day: int = 1
|
||||
# uscite — time-based asimmetrico (barre LTF)
|
||||
uscitalong: int = 24
|
||||
uscitashort: int = 18
|
||||
# uscite — hard stop/profit (LONG, e SHORT se gli override sotto sono None)
|
||||
exit_mode: str = "atr" # 'atr' = multipli di ATR LTF ; 'pct' = percentuale fissa
|
||||
sl_atr: float = 2.0
|
||||
tp_atr: float = 5.0
|
||||
sl_pct: float = 0.03
|
||||
tp_pct: float = 0.075
|
||||
ltf_atr_win: int = 14
|
||||
# uscite — OVERRIDE asimmetrico SHORT (None = usa i valori simmetrici sopra).
|
||||
# In crypto lo short si fa steamrollare da uno spike vola: stop short piu' stretti
|
||||
# tagliano il draw-down standalone senza toccare il segnale (vedi SKH01-V2-DD, diario).
|
||||
exit_mode_short: str | None = None
|
||||
sl_atr_short: float | None = None
|
||||
tp_atr_short: float | None = None
|
||||
sl_pct_short: float | None = None
|
||||
tp_pct_short: float | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature HTF -> merge causale su LTF
|
||||
# ---------------------------------------------------------------------------
|
||||
def htf_features(htf: pd.DataFrame, p: SkyhookParams) -> pd.DataFrame:
|
||||
"""Calcola regime+pattern sull'HTF e li restituisce indicizzati per CHIUSURA HTF (timestamp
|
||||
di chiusura = open + 690min). Cosi' il merge backward su LTF e' strettamente causale."""
|
||||
buz_vola = chande01(atr(htf, p.atr_win), p.n_vola)
|
||||
buz_volume = chande01(htf["volume"].values, p.n_volume)
|
||||
ptn_long, ptn_short = donchian_breakout(htf, p.ptn_n)
|
||||
regime_ok = ((buz_vola >= p.vola_lo) & (buz_vola <= p.vola_hi)
|
||||
& (buz_volume >= p.vol_lo) & (buz_volume <= p.vol_hi))
|
||||
comp_long = regime_ok & ptn_long
|
||||
comp_short = regime_ok & ptn_short
|
||||
if p.long_only:
|
||||
comp_short = np.zeros_like(comp_short, dtype=bool)
|
||||
close_ts = htf["timestamp"].astype("int64").values + HTF_MIN * 60 * 1000
|
||||
return pd.DataFrame({
|
||||
"close_ts": close_ts,
|
||||
"buz_vola": buz_vola, "buz_volume": buz_volume,
|
||||
"comp_long": comp_long.astype(bool), "comp_short": comp_short.astype(bool),
|
||||
})
|
||||
|
||||
|
||||
def merge_htf_to_ltf(ltf: pd.DataFrame, feat: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Attacca a ogni barra LTF l'ultima feature HTF la cui CHIUSURA <= chiusura LTF (causale)."""
|
||||
left = ltf.copy()
|
||||
left["close_ts"] = left["timestamp"].astype("int64").values + LTF_MIN * 60 * 1000
|
||||
m = pd.merge_asof(left.sort_values("close_ts"),
|
||||
feat.sort_values("close_ts"),
|
||||
on="close_ts", direction="backward")
|
||||
return m.sort_index().reset_index(drop=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generatore di ingressi per backtest_signals ({'dir','tp','sl','max_bars'})
|
||||
# ---------------------------------------------------------------------------
|
||||
def skyhook_entries(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams | None = None) -> list:
|
||||
"""Lista di entry-dict (uno per barra LTF, None = niente segnale), pronta per
|
||||
backtest_signals. Max `max_per_day` ingressi/giorno (prima barra qualificante del giorno).
|
||||
sl/tp e max_bars asimmetrici per direzione. Tutto causale (decide a close[i])."""
|
||||
p = p or SkyhookParams()
|
||||
feat = htf_features(htf, p)
|
||||
m = merge_htf_to_ltf(ltf, feat)
|
||||
|
||||
c = m["close"].values.astype(float)
|
||||
a = atr(m, p.ltf_atr_win)
|
||||
comp_long = np.nan_to_num(m["comp_long"].values).astype(bool)
|
||||
comp_short = np.nan_to_num(m["comp_short"].values).astype(bool)
|
||||
days = pd.to_datetime(m["datetime"]).dt.floor("D").values
|
||||
|
||||
entries: list = [None] * len(m)
|
||||
count_today: dict = {}
|
||||
for i in range(len(m)):
|
||||
if not np.isfinite(a[i]) or a[i] <= 0:
|
||||
continue
|
||||
day = days[i]
|
||||
if count_today.get(day, 0) >= p.max_per_day:
|
||||
continue
|
||||
if comp_long[i]:
|
||||
direction, mb = 1, p.uscitalong
|
||||
mode, sl_a, tp_a, sl_p, tp_p = p.exit_mode, p.sl_atr, p.tp_atr, p.sl_pct, p.tp_pct
|
||||
elif comp_short[i]:
|
||||
direction, mb = -1, p.uscitashort
|
||||
# SHORT: usa l'override asimmetrico dove presente, altrimenti i valori simmetrici.
|
||||
mode = p.exit_mode_short if p.exit_mode_short is not None else p.exit_mode
|
||||
sl_a = p.sl_atr_short if p.sl_atr_short is not None else p.sl_atr
|
||||
tp_a = p.tp_atr_short if p.tp_atr_short is not None else p.tp_atr
|
||||
sl_p = p.sl_pct_short if p.sl_pct_short is not None else p.sl_pct
|
||||
tp_p = p.tp_pct_short if p.tp_pct_short is not None else p.tp_pct
|
||||
else:
|
||||
continue
|
||||
if mode == "atr":
|
||||
sl_off, tp_off = sl_a * a[i], tp_a * a[i]
|
||||
else:
|
||||
sl_off, tp_off = sl_p * c[i], tp_p * c[i]
|
||||
if direction == 1:
|
||||
sl, tp = c[i] - sl_off, c[i] + tp_off
|
||||
else:
|
||||
sl, tp = c[i] + sl_off, c[i] - tp_off
|
||||
entries[i] = {"dir": direction, "tp": float(tp), "sl": float(sl), "max_bars": int(mb)}
|
||||
count_today[day] = count_today.get(day, 0) + 1
|
||||
return entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config canoniche (vedi docs/diary/2026-06-23-skyhook.md)
|
||||
# ---------------------------------------------------------------------------
|
||||
# SKH01-V1: vincente del primo lever-scout/grid (regime gate + breakout lento + stop larghi).
|
||||
SKH01_V1 = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
|
||||
|
||||
# SKH01-V2-DD: vincente dell'onda DD-reduction (famiglia ASYM_LS). Stesso SEGNALE del winner
|
||||
# intermedio (ptn_n=45, banda vola larga) ma EXIT a percentuale fissa ASIMMETRICA: short con SL
|
||||
# piu' stretto (2% vs 4% long) -> taglia il draw-down standalone (maxDD BTC 21% / ETH 27% <30%)
|
||||
# alzando hold-out e uplift di portafoglio. Verificato leak-free + 2 scettici avversariali.
|
||||
SKH01_V2_DD = SkyhookParams(
|
||||
ptn_n=45, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0,
|
||||
uscitalong=24, uscitashort=16,
|
||||
exit_mode="pct", sl_pct=0.04, tp_pct=0.10, # LONG
|
||||
exit_mode_short="pct", sl_pct_short=0.02, tp_pct_short=0.08, # SHORT (SL piu' stretto)
|
||||
)
|
||||
|
||||
|
||||
def signal_counts(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams | None = None) -> dict:
|
||||
"""Diagnostica: quante barre passano regime/pattern/composer (prima del cap giornaliero)."""
|
||||
p = p or SkyhookParams()
|
||||
feat = htf_features(htf, p)
|
||||
m = merge_htf_to_ltf(ltf, feat)
|
||||
cl = np.nan_to_num(m["comp_long"].values).astype(bool)
|
||||
cs = np.nan_to_num(m["comp_short"].values).astype(bool)
|
||||
ent = skyhook_entries(ltf, htf, p)
|
||||
return dict(ltf_bars=len(m), comp_long=int(cl.sum()), comp_short=int(cs.sum()),
|
||||
entries=int(sum(e is not None for e in ent)))
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Test della strategia SKH01 (Skyhook) — dual-timeframe regime+breakout su BTC/ETH.
|
||||
|
||||
Coprono: fedelta' al brief (ancore demo BuzVola/BuzVolume), allineamento dual-TF, assenza di
|
||||
look-ahead (causalita'), e robustezza onesta del config V1 su entrambi gli asset.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "skyhook"))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from src.strategies.skyhook import (
|
||||
HTF_MIN, LTF_MIN, SKH01_V2_DD, SkyhookParams, build_frames, chande01, skyhook_entries)
|
||||
|
||||
# config V1 (vincente del lever-scout/grid; vedi diario 2026-06-23-skyhook)
|
||||
V1 = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fedelta' al brief: indicatori tipo-Chande, normalizzati 0-100.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_chande01_anchors():
|
||||
"""Semantica del brief: volatilita'/volume STEADY -> 50 (neutro); in RAMPA -> 100; in CALO -> 0."""
|
||||
n = 100
|
||||
assert abs(chande01(np.full(n, 7.0), 13)[-1] - 50.0) < 1e-9 # costante -> neutro
|
||||
assert abs(chande01(np.arange(n, dtype=float), 13)[-1] - 100.0) < 1e-9 # rampa su -> 100
|
||||
assert abs(chande01(np.arange(n, 0, -1, dtype=float), 13)[-1] - 0.0) < 1e-9 # rampa giu' -> 0
|
||||
|
||||
|
||||
def test_demo_buzvola_buzvolume():
|
||||
"""Ancore della demo: ATR costante (vol steady) -> BuzVola 50; volume in rampa -> BuzVolume 100."""
|
||||
n = 100
|
||||
buz_vola = chande01(np.full(n, 2.0), 13) # ATR steady
|
||||
buz_volume = chande01(np.linspace(1000, 5000, n), 13) # volume in rampa
|
||||
assert abs(buz_vola[-1] - 50.0) < 1e-9
|
||||
assert abs(buz_volume[-1] - 100.0) < 1e-9
|
||||
# oscillatori sempre in [0,100]
|
||||
assert chande01(np.random.default_rng(0).normal(size=500).cumsum() + 100, 13)[20:].min() >= -1e-9
|
||||
assert chande01(np.random.default_rng(1).normal(size=500).cumsum() + 100, 13)[20:].max() <= 100 + 1e-9
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allineamento dual-timeframe: 690 = 3 x 230, confini HTF subset dei confini LTF.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_dual_tf_alignment():
|
||||
assert HTF_MIN == 3 * LTF_MIN
|
||||
ltf, htf = build_frames(load_data("BTC", "5m"))
|
||||
# ogni timestamp (open) HTF e' anche un open LTF (stessa griglia epoch)
|
||||
ltf_opens = set(ltf["timestamp"].astype("int64").tolist())
|
||||
htf_opens = htf["timestamp"].astype("int64").tolist()
|
||||
inside = sum(t in ltf_opens for t in htf_opens)
|
||||
assert inside / len(htf_opens) > 0.99, "i confini HTF devono essere un sottoinsieme dei confini LTF"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Causalita': gli ingressi su un prefisso devono coincidere con la run completa.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_no_lookahead_entries():
|
||||
p = SkyhookParams(**V1)
|
||||
ltf, htf = build_frames(load_data("BTC", "5m"))
|
||||
full = skyhook_entries(ltf, htf, p)
|
||||
n = len(ltf)
|
||||
cut = int(n * 0.85)
|
||||
cut_ts = int(ltf["timestamp"].iloc[cut - 1])
|
||||
htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True)
|
||||
sub = skyhook_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, p)
|
||||
for i in range(cut - 200, cut):
|
||||
a, b = full[i], sub[i]
|
||||
assert (a is None) == (b is None)
|
||||
if a is not None:
|
||||
assert a["dir"] == b["dir"]
|
||||
assert abs(a["sl"] - b["sl"]) < 1e-6 and abs(a["tp"] - b["tp"]) < 1e-6
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Robustezza onesta del config V1: PASS su BTC E ETH, netto fee, OOS.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_v1_robust_both_assets():
|
||||
import skyhooklib as sk
|
||||
p = SkyhookParams(**V1)
|
||||
for a in ("BTC", "ETH"):
|
||||
r = sk.run_asset(a, p, sk.FEE_RT)
|
||||
assert r["full"]["sharpe"] >= 0.5, f"{a} FULL Sharpe basso: {r['full']['sharpe']}"
|
||||
assert r["holdout"]["sharpe"] >= 0.2, f"{a} HOLD-OUT Sharpe basso: {r['holdout']['sharpe']}"
|
||||
assert r["full"]["n_trades"] >= 20, f"{a} troppo pochi trade: {r['full']['n_trades']}"
|
||||
assert sk.causality(p, "BTC")["ok"] and sk.causality(p, "ETH")["ok"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exit asimmetrici SHORT (SKH01-V2-DD): l'override cambia SOLO gli short; i default
|
||||
# (None) preservano esattamente il comportamento simmetrico precedente.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_short_override_backward_compatible():
|
||||
"""Con gli override SHORT a None, gli ingressi sono identici alla versione simmetrica."""
|
||||
ltf, htf = build_frames(load_data("BTC", "5m"))
|
||||
base = SkyhookParams(**V1)
|
||||
# stessi parametri ma con campi override esplicitamente None (= default)
|
||||
same = SkyhookParams(**V1, exit_mode_short=None, sl_pct_short=None, tp_pct_short=None)
|
||||
e0, e1 = skyhook_entries(ltf, htf, base), skyhook_entries(ltf, htf, same)
|
||||
assert e0 == e1, "i campi override a None NON devono cambiare nulla (backward-compat)"
|
||||
|
||||
|
||||
def test_short_override_changes_only_shorts():
|
||||
"""Un SL short piu' stretto (pct) modifica gli stop SHORT ma lascia intatti i LONG."""
|
||||
ltf, htf = build_frames(load_data("ETH", "5m"))
|
||||
sym = SkyhookParams(ptn_n=45, vola_lo=35, vola_hi=95, vol_lo=0.0,
|
||||
exit_mode="pct", sl_pct=0.04, tp_pct=0.10)
|
||||
asym = SkyhookParams(ptn_n=45, vola_lo=35, vola_hi=95, vol_lo=0.0,
|
||||
exit_mode="pct", sl_pct=0.04, tp_pct=0.10,
|
||||
sl_pct_short=0.02, tp_pct_short=0.08)
|
||||
es, ea = skyhook_entries(ltf, htf, sym), skyhook_entries(ltf, htf, asym)
|
||||
longs_same = shorts_diff = 0
|
||||
for a, b in zip(es, ea):
|
||||
if a is None or b is None:
|
||||
assert (a is None) == (b is None)
|
||||
continue
|
||||
assert a["dir"] == b["dir"]
|
||||
if a["dir"] == 1: # LONG invariati
|
||||
assert abs(a["sl"] - b["sl"]) < 1e-6 and abs(a["tp"] - b["tp"]) < 1e-6
|
||||
longs_same += 1
|
||||
else: # SHORT con SL/TP diversi
|
||||
assert abs(a["sl"] - b["sl"]) > 1e-6
|
||||
shorts_diff += 1
|
||||
assert longs_same > 0 and shorts_diff > 0
|
||||
|
||||
|
||||
def test_v2dd_robust_both_assets():
|
||||
"""SKH01-V2-DD: PASS netto fee su BTCÐ, hold-out forte, e maxDD standalone <30%."""
|
||||
import skyhooklib as sk
|
||||
p = SKH01_V2_DD
|
||||
for a in ("BTC", "ETH"):
|
||||
r = sk.run_asset(a, p, sk.FEE_RT)
|
||||
assert r["full"]["sharpe"] >= 0.5, f"{a} FULL Sharpe basso: {r['full']['sharpe']}"
|
||||
assert r["holdout"]["sharpe"] >= 0.5, f"{a} HOLD-OUT Sharpe basso: {r['holdout']['sharpe']}"
|
||||
assert r["full"]["maxdd"] < 0.30, f"{a} maxDD non sotto 30%: {r['full']['maxdd']}"
|
||||
assert r["full"]["n_trades"] >= 20, f"{a} troppo pochi trade: {r['full']['n_trades']}"
|
||||
assert sk.causality(p, "BTC")["ok"] and sk.causality(p, "ETH")["ok"]
|
||||
Reference in New Issue
Block a user